@things-factory/figure-ui 10.1.243 → 10.1.244
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/modeller/editor.ts +48 -2
- package/client/modeller/figure-save-conflict.ts +73 -0
- package/client/modeller/figure-section-editor.ts +3 -0
- package/client/modeller/product-session.ts +60 -6
- package/client/modeller/save-conflict.ts +91 -0
- package/client/pages/figure-modeller-page.ts +27 -9
- package/dist-client/modeller/editor.d.ts +7 -0
- package/dist-client/modeller/editor.js +55 -2
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/figure-save-conflict.d.ts +19 -0
- package/dist-client/modeller/figure-save-conflict.js +76 -0
- package/dist-client/modeller/figure-save-conflict.js.map +1 -0
- package/dist-client/modeller/figure-section-editor.d.ts +2 -0
- package/dist-client/modeller/figure-section-editor.js +8 -0
- package/dist-client/modeller/figure-section-editor.js.map +1 -1
- package/dist-client/modeller/product-session.d.ts +234 -1
- package/dist-client/modeller/product-session.js +67 -7
- package/dist-client/modeller/product-session.js.map +1 -1
- package/dist-client/modeller/save-conflict.d.ts +12 -0
- package/dist-client/modeller/save-conflict.js +63 -0
- package/dist-client/modeller/save-conflict.js.map +1 -0
- package/dist-client/pages/figure-modeller-page.d.ts +4 -3
- package/dist-client/pages/figure-modeller-page.js +29 -9
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/browser/product-page.check.mjs +176 -0
- package/test/browser/product-page.js +4 -1
- package/test/browser/section.check.mjs +21 -0
- package/test/product-session.test.ts +73 -6
- package/test/save-conflict.test.ts +41 -0
- package/test/save-landed.test.ts +75 -0
- package/translations/en.json +12 -1
- package/translations/es.json +12 -1
- package/translations/ja.json +12 -1
- package/translations/ko.json +12 -1
- package/translations/ms.json +12 -1
- package/translations/zh.json +12 -1
|
@@ -8,7 +8,7 @@ import { openPopup } from '@operato/layout'
|
|
|
8
8
|
import { OxPrompt } from '@operato/popup'
|
|
9
9
|
import { customElement, property, state } from 'lit/decorators.js'
|
|
10
10
|
import { live } from 'lit/directives/live.js'
|
|
11
|
-
import { figureFastenAxesOf, figurePartFaceOf, PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditOf, figureSizeEditorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang } from '@hatiolab/figure-model'
|
|
11
|
+
import { parseFigureAsset, figureFastenAxesOf, figurePartFaceOf, PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditOf, figureSizeEditorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang } from '@hatiolab/figure-model'
|
|
12
12
|
import type { RecipeTemplate } from './recipes.js'
|
|
13
13
|
import './figure-recipe-catalog.js'
|
|
14
14
|
import type { FigureAsset, FigureAuthoringAction, FigureEvaluatedShape, FigureInputChange, FigurePartEdit, FigurePrimitiveCreation, FigureProposalStep, FigureRecipe, FigureRecipeValue, FigurePlacement, FigureFaceMappingNeed, FigureFaceRemap } from '@hatiolab/figure-model'
|
|
@@ -29,6 +29,9 @@ import './figure-controls.js'
|
|
|
29
29
|
import './figure-clips.js'
|
|
30
30
|
import './figure-data-panel.js'
|
|
31
31
|
import type { FigurePreview } from './figure-preview.js'
|
|
32
|
+
import './figure-save-conflict.js'
|
|
33
|
+
import type { SaveConflictChoice } from './figure-save-conflict.js'
|
|
34
|
+
import { saveConflictChangesOf, type SaveConflictChanges } from './save-conflict.js'
|
|
32
35
|
import './figure-preview.js'
|
|
33
36
|
import './figure-settings.js'
|
|
34
37
|
import { DEFAULT_VIEW, type ViewSettings } from '@things-factory/figure-viewer/dist-client/figure-view.js'
|
|
@@ -479,12 +482,54 @@ export class FigureEditor extends LitElement {
|
|
|
479
482
|
await promise
|
|
480
483
|
this.error = ''
|
|
481
484
|
} catch (error) {
|
|
485
|
+
/* Another editor saved first: the ways on are asked, not a refusal said that saving again would only repeat. */
|
|
486
|
+
if (this.session.read().conflict) void this.resolveConflict()
|
|
482
487
|
/* Not reached: whether it was written is not known, and the edits are still here -- said so, not "not saved". */
|
|
483
|
-
this.error = (error as { code?: string })?.code === 'SAVE_UNREACHABLE' ? i18next.t('figure.text.save-unreachable') : this.said(error)
|
|
488
|
+
else this.error = (error as { code?: string })?.code === 'SAVE_UNREACHABLE' ? i18next.t('figure.text.save-unreachable') : this.said(error)
|
|
484
489
|
} finally {
|
|
485
490
|
this.changed()
|
|
486
491
|
}
|
|
487
492
|
}
|
|
493
|
+
/**
|
|
494
|
+
* After a conflict (2026-11-01 audit item 6): what differs from the server's latest, and the way on the person
|
|
495
|
+
* chooses. A copy is a new figure saved by the first save's question; the latest opens in a new tab at this figure's
|
|
496
|
+
* address, the edits staying here; writing over is asked once more, since the other's change is lost.
|
|
497
|
+
*/
|
|
498
|
+
private async resolveConflict() {
|
|
499
|
+
const session = this.session!
|
|
500
|
+
const latest = session.read().conflict?.latest
|
|
501
|
+
const mine = session.revisionNow()?.asset
|
|
502
|
+
let changes: SaveConflictChanges | null = null
|
|
503
|
+
try {
|
|
504
|
+
changes = latest && mine ? saveConflictChangesOf(mine, parseFigureAsset(latest)) : null
|
|
505
|
+
} catch {
|
|
506
|
+
/* A latest this figure-model cannot read is not compared; it is still offered to keep a copy or to look at it. */
|
|
507
|
+
}
|
|
508
|
+
const choice = await openPopup(html`<figure-save-conflict .changes=${changes}></figure-save-conflict>`, {
|
|
509
|
+
backdrop: true,
|
|
510
|
+
size: 'small',
|
|
511
|
+
title: i18next.t('figure.title.save-conflict')
|
|
512
|
+
}).result as SaveConflictChoice | undefined
|
|
513
|
+
if (this.session !== session || !session.read().conflict) return
|
|
514
|
+
if (choice === 'latest') window.open(location.href, '_blank', 'noopener')
|
|
515
|
+
else if (choice === 'copy') {
|
|
516
|
+
session.detachAsCopy()
|
|
517
|
+
/* The page forgets the row of the figure left: the copy's identifier is asked, not the other's offered. */
|
|
518
|
+
this.dispatchEvent(new CustomEvent('figure-detached', { bubbles: true, composed: true }))
|
|
519
|
+
this.changed()
|
|
520
|
+
await this.save()
|
|
521
|
+
} else if (choice === 'overwrite') {
|
|
522
|
+
const agreed = await OxPrompt.open({
|
|
523
|
+
type: 'warning',
|
|
524
|
+
text: i18next.t('figure.text.overwrite-latest-warning'),
|
|
525
|
+
confirmButton: { text: i18next.t('figure.button.overwrite-latest') },
|
|
526
|
+
cancelButton: { text: i18next.t('figure.button.keep-editing') }
|
|
527
|
+
})
|
|
528
|
+
if (!agreed || this.session !== session || !session.read().conflict) return
|
|
529
|
+
session.overwriteConflict()
|
|
530
|
+
await this.save()
|
|
531
|
+
}
|
|
532
|
+
}
|
|
488
533
|
private add(kind: FigurePaletteKind) {
|
|
489
534
|
if (!this.session) return
|
|
490
535
|
this.run(() => {
|
|
@@ -1659,6 +1704,7 @@ export class FigureEditor extends LitElement {
|
|
|
1659
1704
|
.kept=${kept}
|
|
1660
1705
|
.pointBlocked=${this.pointBlockedOf(drawn)}
|
|
1661
1706
|
.revision=${this.session?.read().revision ?? 0}
|
|
1707
|
+
?active=${this.active}
|
|
1662
1708
|
.faceMapping=${this.faceMapping?.from === 'section-editor' ? this.faceMapping.need : undefined}
|
|
1663
1709
|
@command=${(e: CustomEvent) => {
|
|
1664
1710
|
/* Held for a choice of where things go: the editor stays open, the choice shown in it. */
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { LitElement, html, css, nothing } from 'lit'
|
|
2
|
+
import { customElement, property } from 'lit/decorators.js'
|
|
3
|
+
import { i18next } from '@operato/i18n'
|
|
4
|
+
import { closePopup } from '@operato/popup'
|
|
5
|
+
import '@operato/popup/ox-popup-actions.js'
|
|
6
|
+
import { CommonPopupStyles } from '@operato/styles'
|
|
7
|
+
import '@material/web/button/filled-button.js'
|
|
8
|
+
import '@material/web/button/outlined-button.js'
|
|
9
|
+
import '@material/web/button/text-button.js'
|
|
10
|
+
|
|
11
|
+
import { partLabelOf } from './fields.js'
|
|
12
|
+
import type { SaveConflictChanges } from './save-conflict.js'
|
|
13
|
+
|
|
14
|
+
/** What the person chose after a conflict; nothing when closed -- the edits stay, unsaved. */
|
|
15
|
+
export type SaveConflictChoice = 'copy' | 'latest' | 'overwrite'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A save refused because another editor saved this figure first (2026-11-01 audit item 6). The edits stay on screen;
|
|
19
|
+
* what differs from the server's latest is said by part, and the ways on are offered: keep the edits as a copy (first,
|
|
20
|
+
* since nothing is lost), look at the latest in a new tab, or write the edits over it.
|
|
21
|
+
*/
|
|
22
|
+
@customElement('figure-save-conflict')
|
|
23
|
+
export class FigureSaveConflict extends LitElement {
|
|
24
|
+
static styles = [
|
|
25
|
+
CommonPopupStyles,
|
|
26
|
+
css`
|
|
27
|
+
:host {
|
|
28
|
+
overflow-y: auto;
|
|
29
|
+
}
|
|
30
|
+
ul {
|
|
31
|
+
margin: 0;
|
|
32
|
+
padding-inline-start: 18px;
|
|
33
|
+
font-size: var(--popup-font-size-small);
|
|
34
|
+
}
|
|
35
|
+
li span {
|
|
36
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
37
|
+
}
|
|
38
|
+
div[ways] {
|
|
39
|
+
display: grid;
|
|
40
|
+
gap: 6px;
|
|
41
|
+
}
|
|
42
|
+
`
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
/** What differs from the server's latest; null when the latest could not be read. */
|
|
46
|
+
@property({ attribute: false }) changes: SaveConflictChanges | null = null
|
|
47
|
+
|
|
48
|
+
render() {
|
|
49
|
+
const changes = this.changes
|
|
50
|
+
const line = (key: string, parts: string[]) =>
|
|
51
|
+
parts.length ? html`<li><span>${i18next.t(key)}</span> ${parts.map(partLabelOf).join(', ')}</li>` : nothing
|
|
52
|
+
return html`
|
|
53
|
+
<div class="what">${i18next.t('figure.text.save-conflict-desc')}</div>
|
|
54
|
+
${changes
|
|
55
|
+
? html`<ul differs>
|
|
56
|
+
${line('figure.label.conflict-changed', changes.changed)} ${line('figure.label.conflict-mine-only', changes.mineOnly)}
|
|
57
|
+
${line('figure.label.conflict-theirs-only', changes.theirsOnly)}
|
|
58
|
+
${changes.otherChanged ? html`<li>${i18next.t('figure.text.conflict-other-changed')}</li>` : nothing}
|
|
59
|
+
</ul>`
|
|
60
|
+
: html`<div class="note">${i18next.t('figure.text.conflict-latest-unread')}</div>`}
|
|
61
|
+
<div ways>
|
|
62
|
+
<md-filled-button copy @click=${() => closePopup(this, 'copy')}>${i18next.t('figure.button.save-as-copy')}</md-filled-button>
|
|
63
|
+
<md-outlined-button latest @click=${() => closePopup(this, 'latest')}>${i18next.t('figure.button.open-latest')}</md-outlined-button>
|
|
64
|
+
${changes
|
|
65
|
+
? html`<md-text-button overwrite @click=${() => closePopup(this, 'overwrite')}>${i18next.t('figure.button.overwrite-latest')}</md-text-button>`
|
|
66
|
+
: nothing}
|
|
67
|
+
</div>
|
|
68
|
+
<ox-popup-actions>
|
|
69
|
+
<md-text-button slot="cancel" @click=${() => closePopup(this)}>${i18next.t('figure.button.keep-editing')}</md-text-button>
|
|
70
|
+
</ox-popup-actions>
|
|
71
|
+
`
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -273,6 +273,8 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
|
|
|
273
273
|
@property({ attribute: false }) pointBlocked = ''
|
|
274
274
|
/** The figure's revision now; the editor remembers the one it opened at. */
|
|
275
275
|
@property({ type: Number }) revision = 0
|
|
276
|
+
/** The page in front, as the modeller has it: a page left keeps this editor on the window, and its keys are not its (2026-11-01 audit item 1). */
|
|
277
|
+
@property({ type: Boolean }) active = true
|
|
276
278
|
/** What stands on the faces the last apply takes away (ADR-0100 A8): asked here, where the edit was made. */
|
|
277
279
|
@property({ attribute: false }) faceMapping?: FigureFaceMappingNeed
|
|
278
280
|
/** Narrow: the outline or the solid behind. */
|
|
@@ -344,6 +346,7 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
|
|
|
344
346
|
* would take Esc as "select nothing" and Delete as "delete the part".
|
|
345
347
|
*/
|
|
346
348
|
private keys = (e: KeyboardEvent) => {
|
|
349
|
+
if (!this.active) return
|
|
347
350
|
const inField = (e.composedPath()[0] as HTMLElement)?.tagName === 'INPUT'
|
|
348
351
|
/* A number being typed in the inside's drawing: Esc there puts the number back, not the editor away. */
|
|
349
352
|
if (inField && e.composedPath().some(el => (el as HTMLElement).tagName === 'FIGURE-CAVITY-DRAWING')) return
|
|
@@ -88,8 +88,21 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
88
88
|
*/
|
|
89
89
|
let done: Array<string | undefined> = [],
|
|
90
90
|
undone: Array<string | undefined> = []
|
|
91
|
+
/*
|
|
92
|
+
A save refused because the server holds another source than the one opened (FIGURE_SOURCE_CONFLICT, not one of
|
|
93
|
+
this session's own sends): the edits stay, and the server's latest is kept here to compare with and, if the person
|
|
94
|
+
says so, to write over (2026-11-01 audit item 6). Undefined source: the latest could not be read.
|
|
95
|
+
*/
|
|
96
|
+
let conflict: { source?: string } | undefined
|
|
91
97
|
/* The catalogue fields changed and not saved yet; none of the history -- undo is the figure's steps. */
|
|
92
98
|
let catalog: FigureCatalogPatch = {}
|
|
99
|
+
/* The catalogue fields a save is writing now: what the row will hold, not what it held, until it is done. */
|
|
100
|
+
let catalogSending: FigureCatalogPatch | undefined
|
|
101
|
+
/*
|
|
102
|
+
The catalogue fields the last save wrote, known to be on the row from the moment it answered: a screen compares
|
|
103
|
+
with them before it has read the row again (2026-11-01 third re-check). Empty from the start of each save.
|
|
104
|
+
*/
|
|
105
|
+
let catalogWritten: FigureCatalogPatch = {}
|
|
93
106
|
const catalogHeld = () => Object.keys(catalog).length > 0
|
|
94
107
|
/* The author's size ends the last step moved without being asked (ADR-0087 decision 6), told once. */
|
|
95
108
|
let fitted: FigureSizeRangeFit[] = []
|
|
@@ -113,7 +126,7 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
113
126
|
*/
|
|
114
127
|
function read() {
|
|
115
128
|
const view = workspace.read()
|
|
116
|
-
return { ...view, dirty: view.dirty || catalogHeld(), catalog: { ...catalog }, id, creationId, loading, saving, error }
|
|
129
|
+
return { ...view, dirty: view.dirty || catalogHeld(), catalog: { ...catalog }, catalogWritten: { ...catalogWritten }, conflict: conflict ? { latest: conflict.source } : undefined, id, creationId, loading, saving, error }
|
|
117
130
|
}
|
|
118
131
|
/*
|
|
119
132
|
The version on screen, one object for as long as it is the version: the workspace hands out one asset per version
|
|
@@ -166,6 +179,8 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
166
179
|
saving = false
|
|
167
180
|
creationId = undefined
|
|
168
181
|
catalog = {}
|
|
182
|
+
catalogWritten = {}
|
|
183
|
+
conflict = undefined
|
|
169
184
|
done = []
|
|
170
185
|
undone = []
|
|
171
186
|
}
|
|
@@ -184,19 +199,50 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
184
199
|
confirmedSource: () => (id ? baseline : undefined),
|
|
185
200
|
/**
|
|
186
201
|
* A catalogue field changed, held for the next save; one changed back to what the row holds (`saved`) is no
|
|
187
|
-
* change. Only a saved figure has a row to write: a new figure's type goes with its first save.
|
|
202
|
+
* change. Only a saved figure has a row to write: a new figure's type goes with its first save. Taken while a save
|
|
203
|
+
* is on its way too: the field shows it, so it is held for the save after (2026-11-01 re-check). A field that save
|
|
204
|
+
* is writing is held whatever it is changed to -- back to the old value too, which the save is about to replace;
|
|
205
|
+
* the save done lets go only of a value it wrote (2026-11-01 second re-check).
|
|
188
206
|
*/
|
|
189
207
|
editCatalog(patch: FigureCatalogPatch, saved: FigureCatalogPatch) {
|
|
190
208
|
if (!id) throw refusal('SESSION_NOT_SAVED', '먼저 저장해 주세요.')
|
|
191
|
-
if (saving) throw refusal('SESSION_SAVING', '저장이 끝난 뒤 편집해 주세요.')
|
|
192
209
|
const next = { ...catalog }
|
|
193
210
|
for (const key of Object.keys(patch) as (keyof FigureCatalogPatch)[]) {
|
|
194
|
-
if (sameValue(patch[key], saved[key])) delete next[key]
|
|
211
|
+
if (!(catalogSending && key in catalogSending) && sameValue(patch[key], saved[key])) delete next[key]
|
|
195
212
|
else (next as Record<string, unknown>)[key] = patch[key]
|
|
196
213
|
}
|
|
197
214
|
catalog = next
|
|
198
215
|
return read()
|
|
199
216
|
},
|
|
217
|
+
/**
|
|
218
|
+
* After a conflict, the edits written over the server's latest: the next save expects that latest, so it goes
|
|
219
|
+
* through only while the server still holds it -- a third change meanwhile is a conflict again.
|
|
220
|
+
*/
|
|
221
|
+
overwriteConflict() {
|
|
222
|
+
if (saving) throw refusal('SESSION_SAVING', '저장이 끝난 뒤 편집해 주세요.')
|
|
223
|
+
if (!conflict?.source) throw refusal('SESSION_NO_CONFLICT', '덮어쓸 최신본을 읽지 못했습니다.')
|
|
224
|
+
baseline = conflict.source
|
|
225
|
+
conflict = undefined
|
|
226
|
+
unconfirmed.clear()
|
|
227
|
+
return read()
|
|
228
|
+
},
|
|
229
|
+
/**
|
|
230
|
+
* After a conflict, the edits kept as a figure of their own: from here they are a new figure, under a new creation
|
|
231
|
+
* id, saved by the first save's question; the figure they came from is left as the server holds it.
|
|
232
|
+
*/
|
|
233
|
+
detachAsCopy() {
|
|
234
|
+
if (saving) throw refusal('SESSION_SAVING', '저장이 끝난 뒤 편집해 주세요.')
|
|
235
|
+
if (!conflict) throw refusal('SESSION_NO_CONFLICT', '충돌한 저장이 없습니다.')
|
|
236
|
+
id = undefined
|
|
237
|
+
baseline = undefined
|
|
238
|
+
creationId = crypto.randomUUID()
|
|
239
|
+
conflict = undefined
|
|
240
|
+
/* The category and tags changed go with the copy, written once its first save gives it a row; its type is asked anew. */
|
|
241
|
+
const { type: _type, ...kept } = catalog
|
|
242
|
+
catalog = kept
|
|
243
|
+
unconfirmed.clear()
|
|
244
|
+
return read()
|
|
245
|
+
},
|
|
200
246
|
/** A new figure, named in the screen's language by the page: the session has no words of its own. */
|
|
201
247
|
startNew({ discard = false, name }: { discard?: boolean; name: string }) {
|
|
202
248
|
canLeave(discard)
|
|
@@ -338,6 +384,8 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
338
384
|
const proposalKeys = inEffect()
|
|
339
385
|
const sentCatalog = { ...catalog }
|
|
340
386
|
saving = true
|
|
387
|
+
catalogSending = sentCatalog
|
|
388
|
+
catalogWritten = {}
|
|
341
389
|
try {
|
|
342
390
|
/* Only the catalogue changed: the source is not written again -- a released figure would be made a draft for nothing. */
|
|
343
391
|
if (id === undefined || workspace.read().dirty) await saveSource()
|
|
@@ -345,11 +393,15 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
345
393
|
if (id !== undefined && Object.keys(sentCatalog).length) {
|
|
346
394
|
await store.updateCatalog(id, sentCatalog)
|
|
347
395
|
if (requestEpoch !== epoch) return read()
|
|
348
|
-
|
|
349
|
-
|
|
396
|
+
catalogWritten = sentCatalog
|
|
397
|
+
/* What was sent is saved; a field changed again while it was on its way stays held. */
|
|
398
|
+
const next = { ...catalog }
|
|
399
|
+
for (const key of Object.keys(sentCatalog) as (keyof FigureCatalogPatch)[]) if (sameValue(next[key], sentCatalog[key])) delete next[key]
|
|
400
|
+
catalog = next
|
|
350
401
|
}
|
|
351
402
|
} finally {
|
|
352
403
|
if (requestEpoch === epoch) saving = false
|
|
404
|
+
catalogSending = undefined
|
|
353
405
|
}
|
|
354
406
|
return read()
|
|
355
407
|
|
|
@@ -392,6 +444,7 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
392
444
|
record = await store.save({ id, source: exported.text, expectedSource: baseline, creationId, thumbnail, proposalKeys, ...(id || !type ? {} : { type }), ...(id || !namespace ? {} : { namespace }) })
|
|
393
445
|
} else {
|
|
394
446
|
if (code === 'SAVE_UNREACHABLE') unconfirmed.set(exported.text, { thumbnail })
|
|
447
|
+
if (code === 'FIGURE_SOURCE_CONFLICT' && requestEpoch === epoch) conflict = { source: landed?.source }
|
|
395
448
|
throw error
|
|
396
449
|
}
|
|
397
450
|
}
|
|
@@ -403,6 +456,7 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
403
456
|
id = record.id
|
|
404
457
|
baseline = record.source
|
|
405
458
|
unconfirmed.clear()
|
|
459
|
+
conflict = undefined
|
|
406
460
|
/*
|
|
407
461
|
Edited while it was saving: what was sent is saved, the edits since are not -- they stay, and the model stays
|
|
408
462
|
unsaved (the workspace refuses a stale acknowledgement). Never marked saved, never wiped.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { FigureAsset } from '@hatiolab/figure-model'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* A save refused because the server holds another source than the one this editor opened (FIGURE_SOURCE_CONFLICT):
|
|
5
|
+
* what differs between the edits on screen and the server's latest, by part, so the person chooses with it in view --
|
|
6
|
+
* keep the edits as a copy, look at the latest, or write over it (2026-11-01 audit item 6). Read apart from the screen
|
|
7
|
+
* so the rule is tested without a browser.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface SaveConflictChanges {
|
|
11
|
+
/** Parts on both sides, made differently. */
|
|
12
|
+
changed: string[]
|
|
13
|
+
/** Parts only the edits on screen have. */
|
|
14
|
+
mineOnly: string[]
|
|
15
|
+
/** Parts only the server's latest has. */
|
|
16
|
+
theirsOnly: string[]
|
|
17
|
+
/** Something not of one part differs too: the name, the figure's inputs, its capabilities, its clips... */
|
|
18
|
+
otherChanged: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Node {
|
|
22
|
+
id: string
|
|
23
|
+
op: string
|
|
24
|
+
}
|
|
25
|
+
interface Model {
|
|
26
|
+
nodes: Node[]
|
|
27
|
+
constants?: { id: string }[]
|
|
28
|
+
inputs?: { id: string }[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Look = { target?: unknown }
|
|
32
|
+
const looksOf = (asset: FigureAsset) => ((asset as unknown as { appearance?: Look[] }).appearance ?? [])
|
|
33
|
+
|
|
34
|
+
/** Every node, constant and input by id, each with the values the figure gives it; a part's look under the part. */
|
|
35
|
+
function itemsOf(asset: FigureAsset): Map<string, string> {
|
|
36
|
+
const model = asset.document.model as unknown as Model
|
|
37
|
+
const design = (asset.designInputs ?? {}) as Record<string, unknown>
|
|
38
|
+
const state = (asset.stateDefaults ?? {}) as Record<string, unknown>
|
|
39
|
+
const items = new Map<string, string>()
|
|
40
|
+
for (const node of model.nodes) items.set(node.id, JSON.stringify(['node', node]))
|
|
41
|
+
for (const constant of model.constants ?? []) items.set(constant.id, JSON.stringify(['constant', constant]))
|
|
42
|
+
for (const input of model.inputs ?? []) items.set(input.id, JSON.stringify(['input', input, design[input.id] ?? null, state[input.id] ?? null]))
|
|
43
|
+
const looks = new Map<string, Look[]>()
|
|
44
|
+
for (const look of looksOf(asset)) if (typeof look.target === 'string') looks.set(look.target, [...(looks.get(look.target) ?? []), look])
|
|
45
|
+
for (const [target, of] of looks) items.set(`${target}.appearance`, JSON.stringify(['look', of]))
|
|
46
|
+
return items
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A part and all it is made of: figure-model names a part's nodes, constants and inputs under it (`lid.shape`,
|
|
51
|
+
* `lid.size.y`), and its arguments point at their outputs, not at node ids.
|
|
52
|
+
*/
|
|
53
|
+
function closureOf(asset: FigureAsset, part: string): Set<string> {
|
|
54
|
+
return new Set([...itemsOf(asset).keys()].filter(id => id === part || id.startsWith(part + '.')))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const partsOf = (asset: FigureAsset) => (asset.document.model as unknown as Model).nodes.filter(node => node.op === 'place@1').map(node => node.id)
|
|
58
|
+
|
|
59
|
+
const madeOf = (items: Map<string, string>, ids: Set<string>) => JSON.stringify([...ids].sort().map(id => [id, items.get(id) ?? null]))
|
|
60
|
+
|
|
61
|
+
/** Everything of an asset but its items: its name, palette, the look not of one part, capabilities, version. */
|
|
62
|
+
function restOf(asset: FigureAsset): string {
|
|
63
|
+
const { document, designInputs: _design, stateDefaults: _state, appearance: _looks, ...rest } = asset as FigureAsset & Record<string, unknown>
|
|
64
|
+
const { model: _model, ...documentRest } = document as unknown as Record<string, unknown>
|
|
65
|
+
return JSON.stringify([rest, documentRest, looksOf(asset).filter(look => typeof look.target !== 'string')])
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function saveConflictChangesOf(mine: FigureAsset, theirs: FigureAsset): SaveConflictChanges {
|
|
69
|
+
const mineItems = itemsOf(mine),
|
|
70
|
+
theirItems = itemsOf(theirs)
|
|
71
|
+
const mineParts = partsOf(mine),
|
|
72
|
+
theirParts = partsOf(theirs)
|
|
73
|
+
const covered = new Set<string>()
|
|
74
|
+
const changed: string[] = []
|
|
75
|
+
for (const part of mineParts) {
|
|
76
|
+
const ours = closureOf(mine, part)
|
|
77
|
+
ours.forEach(id => covered.add(id))
|
|
78
|
+
if (!theirParts.includes(part)) continue
|
|
79
|
+
const their = closureOf(theirs, part)
|
|
80
|
+
their.forEach(id => covered.add(id))
|
|
81
|
+
if (madeOf(mineItems, ours) !== madeOf(theirItems, their)) changed.push(part)
|
|
82
|
+
}
|
|
83
|
+
for (const part of theirParts) if (!mineParts.includes(part)) closureOf(theirs, part).forEach(id => covered.add(id))
|
|
84
|
+
const outside = (items: Map<string, string>) => JSON.stringify([...items].filter(([id]) => !covered.has(id)).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
|
|
85
|
+
return {
|
|
86
|
+
changed,
|
|
87
|
+
mineOnly: mineParts.filter(part => !theirParts.includes(part)),
|
|
88
|
+
theirsOnly: theirParts.filter(part => !mineParts.includes(part)),
|
|
89
|
+
otherChanged: restOf(mine) !== restOf(theirs) || outside(mineItems) !== outside(theirItems)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -147,9 +147,9 @@ export class FigureModellerPage extends PageView {
|
|
|
147
147
|
@state() private releaseBlockers: FigureGateFinding[] = []
|
|
148
148
|
@state() private budget?: FigureBudgetLine
|
|
149
149
|
private wasDirty = false
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
private
|
|
150
|
+
private wasSaving = false
|
|
151
|
+
/** The row reading asked last: an answer to an earlier one, come back after, is not the row now. */
|
|
152
|
+
private rowReading = 0
|
|
153
153
|
private titled?: string | null
|
|
154
154
|
/** The figure the AI dock was last told about - another id is another conversation. */
|
|
155
155
|
private assistantFor?: string
|
|
@@ -231,14 +231,21 @@ export class FigureModellerPage extends PageView {
|
|
|
231
231
|
navigate(modellerPathOf({ id: view.id }), true)
|
|
232
232
|
}
|
|
233
233
|
/* 저장이 끝난 순간 행을 다시 읽는다 — 발행된 피규어를 고쳐 저장하면 서버가 초안으로 내린다. */
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
234
|
+
/*
|
|
235
|
+
Read again after every save that ends, not only one that leaves nothing unsaved: a field changed while it was
|
|
236
|
+
being written is still held, and is compared with what the row holds now (2026-11-01 second re-check).
|
|
237
|
+
*/
|
|
238
|
+
if (view.id && !view.saving && ((this.wasDirty && !view.dirty) || this.wasSaving)) {
|
|
239
|
+
/*
|
|
240
|
+
What the save wrote is the row's from the moment it answered: the next change is compared with it at once, not
|
|
241
|
+
with the row as it was until the reading comes back -- a value typed in between was let go, then written over
|
|
242
|
+
(2026-11-01 third re-check). The old ones do not flash back either.
|
|
243
|
+
*/
|
|
244
|
+
if (this.wasSaving) this.row = { ...this.row, ...view.catalogWritten }
|
|
237
245
|
this.readRow(view.id)
|
|
238
246
|
}
|
|
239
247
|
this.wasDirty = !!view.dirty
|
|
240
|
-
this.
|
|
241
|
-
this.heldFor = view.id
|
|
248
|
+
this.wasSaving = !!view.saving
|
|
242
249
|
}
|
|
243
250
|
/*
|
|
244
251
|
A released figure changed on the server -- its source saved, or its category or tags -- comes back a draft, and a
|
|
@@ -250,8 +257,10 @@ export class FigureModellerPage extends PageView {
|
|
|
250
257
|
if (before === 'released' && after === 'draft') notify({ level: 'info', message: i18next.t('figure.text.released-now-draft') })
|
|
251
258
|
}
|
|
252
259
|
private async readRow(id: string) {
|
|
260
|
+
const reading = ++this.rowReading
|
|
253
261
|
try {
|
|
254
262
|
const figure = await fetchFigure(id)
|
|
263
|
+
if (reading !== this.rowReading) return
|
|
255
264
|
if (this.session.read().id === id) this.toldDemoted(this.row.state, figure.state)
|
|
256
265
|
if (this.session.read().id === id)
|
|
257
266
|
this.row = {
|
|
@@ -333,6 +342,15 @@ export class FigureModellerPage extends PageView {
|
|
|
333
342
|
const patch = (event as CustomEvent).detail as { category?: string; tags?: string[] }
|
|
334
343
|
if (this.session.read().id && patch) this.editCatalog(patch)
|
|
335
344
|
}
|
|
345
|
+
/*
|
|
346
|
+
The edits kept as a copy after a conflict: a new figure from here. The row and address of the figure left are let
|
|
347
|
+
go, so the first save asks a new identifier and the copy's address replaces this one once saved.
|
|
348
|
+
*/
|
|
349
|
+
private detached = () => {
|
|
350
|
+
this.row = { version: 0 }
|
|
351
|
+
this.routeId = undefined
|
|
352
|
+
this.withdrawAssistant()
|
|
353
|
+
}
|
|
336
354
|
private released = (event: Event) => {
|
|
337
355
|
const figure = (event as CustomEvent).detail?.figure
|
|
338
356
|
/* Keep the rest of the row: rebuilding it from these two dropped the type, and the locked type field went blank. */
|
|
@@ -515,7 +533,7 @@ export class FigureModellerPage extends PageView {
|
|
|
515
533
|
const view = this.session.read()
|
|
516
534
|
const shown = this.shownRow()
|
|
517
535
|
/* A new figure's third way to start: the app's assistant dock, with a request put in its input for the author to finish and send (B, U04). */
|
|
518
|
-
return html`<figure-editor .session=${this.session} .releaseState=${this.row} .releaseBlockers=${this.releaseBlockers} .templates=${this.templates} .guideOf=${this.guideOf} .thumbnailOf=${this.thumbnailOf} .firstSave=${this.askFirstSave} .revision=${this.revision} .budget=${this.budget} ?active=${this.active} aiDraft @ask-ai-draft=${() => askAssistant({ draft: i18next.t('figure.text.ai-draft-request') })} @changed=${this.changed}>
|
|
536
|
+
return html`<figure-editor .session=${this.session} .releaseState=${this.row} .releaseBlockers=${this.releaseBlockers} .templates=${this.templates} .guideOf=${this.guideOf} .thumbnailOf=${this.thumbnailOf} .firstSave=${this.askFirstSave} .revision=${this.revision} .budget=${this.budget} ?active=${this.active} aiDraft @figure-detached=${this.detached} @ask-ai-draft=${() => askAssistant({ draft: i18next.t('figure.text.ai-draft-request') })} @changed=${this.changed}>
|
|
519
537
|
${this.commandError ? html`<p slot="notice" fit-error role="alert">${this.commandError}</p>` : ''}
|
|
520
538
|
<figure-properties
|
|
521
539
|
slot="figure"
|
|
@@ -13,6 +13,7 @@ import './figure-fasten-dialog.js';
|
|
|
13
13
|
import './figure-controls.js';
|
|
14
14
|
import './figure-clips.js';
|
|
15
15
|
import './figure-data-panel.js';
|
|
16
|
+
import './figure-save-conflict.js';
|
|
16
17
|
import './figure-preview.js';
|
|
17
18
|
import './figure-settings.js';
|
|
18
19
|
import './figure-capability-panel.js';
|
|
@@ -173,6 +174,12 @@ export declare class FigureEditor extends LitElement {
|
|
|
173
174
|
private holdAction;
|
|
174
175
|
private changed;
|
|
175
176
|
private save;
|
|
177
|
+
/**
|
|
178
|
+
* After a conflict (2026-11-01 audit item 6): what differs from the server's latest, and the way on the person
|
|
179
|
+
* chooses. A copy is a new figure saved by the first save's question; the latest opens in a new tab at this figure's
|
|
180
|
+
* address, the edits staying here; writing over is asked once more, since the other's change is lost.
|
|
181
|
+
*/
|
|
182
|
+
private resolveConflict;
|
|
176
183
|
private add;
|
|
177
184
|
/**
|
|
178
185
|
* A GLB made into a mesh part (ADR-0129): the server reads and keeps it, the author picks the whole file or one node
|
|
@@ -6,9 +6,10 @@ import { isMacOS } from '@operato/utils';
|
|
|
6
6
|
import { unitWord } from './unit-words.js';
|
|
7
7
|
import '@operato/layout';
|
|
8
8
|
import { openPopup } from '@operato/layout';
|
|
9
|
+
import { OxPrompt } from '@operato/popup';
|
|
9
10
|
import { customElement, property, state } from 'lit/decorators.js';
|
|
10
11
|
import { live } from 'lit/directives/live.js';
|
|
11
|
-
import { figureFastenAxesOf, figurePartFaceOf, PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditOf, figureSizeEditorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, figureRowOf, figureFaceOverhangOf } from '@hatiolab/figure-model';
|
|
12
|
+
import { parseFigureAsset, figureFastenAxesOf, figurePartFaceOf, PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditOf, figureSizeEditorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, figureRowOf, figureFaceOverhangOf } from '@hatiolab/figure-model';
|
|
12
13
|
import './figure-recipe-catalog.js';
|
|
13
14
|
import { captureFigureThumbnail } from './thumbnail.js';
|
|
14
15
|
import { drawableFigureParts } from './renderer.js';
|
|
@@ -25,6 +26,8 @@ import './figure-fasten-dialog.js';
|
|
|
25
26
|
import './figure-controls.js';
|
|
26
27
|
import './figure-clips.js';
|
|
27
28
|
import './figure-data-panel.js';
|
|
29
|
+
import './figure-save-conflict.js';
|
|
30
|
+
import { saveConflictChangesOf } from './save-conflict.js';
|
|
28
31
|
import './figure-preview.js';
|
|
29
32
|
import './figure-settings.js';
|
|
30
33
|
import { DEFAULT_VIEW } from '@things-factory/figure-viewer/dist-client/figure-view.js';
|
|
@@ -462,13 +465,62 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
462
465
|
this.error = '';
|
|
463
466
|
}
|
|
464
467
|
catch (error) {
|
|
468
|
+
/* Another editor saved first: the ways on are asked, not a refusal said that saving again would only repeat. */
|
|
469
|
+
if (this.session.read().conflict)
|
|
470
|
+
void this.resolveConflict();
|
|
465
471
|
/* Not reached: whether it was written is not known, and the edits are still here -- said so, not "not saved". */
|
|
466
|
-
|
|
472
|
+
else
|
|
473
|
+
this.error = error?.code === 'SAVE_UNREACHABLE' ? i18next.t('figure.text.save-unreachable') : this.said(error);
|
|
467
474
|
}
|
|
468
475
|
finally {
|
|
469
476
|
this.changed();
|
|
470
477
|
}
|
|
471
478
|
}
|
|
479
|
+
/**
|
|
480
|
+
* After a conflict (2026-11-01 audit item 6): what differs from the server's latest, and the way on the person
|
|
481
|
+
* chooses. A copy is a new figure saved by the first save's question; the latest opens in a new tab at this figure's
|
|
482
|
+
* address, the edits staying here; writing over is asked once more, since the other's change is lost.
|
|
483
|
+
*/
|
|
484
|
+
async resolveConflict() {
|
|
485
|
+
const session = this.session;
|
|
486
|
+
const latest = session.read().conflict?.latest;
|
|
487
|
+
const mine = session.revisionNow()?.asset;
|
|
488
|
+
let changes = null;
|
|
489
|
+
try {
|
|
490
|
+
changes = latest && mine ? saveConflictChangesOf(mine, parseFigureAsset(latest)) : null;
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
/* A latest this figure-model cannot read is not compared; it is still offered to keep a copy or to look at it. */
|
|
494
|
+
}
|
|
495
|
+
const choice = await openPopup(html `<figure-save-conflict .changes=${changes}></figure-save-conflict>`, {
|
|
496
|
+
backdrop: true,
|
|
497
|
+
size: 'small',
|
|
498
|
+
title: i18next.t('figure.title.save-conflict')
|
|
499
|
+
}).result;
|
|
500
|
+
if (this.session !== session || !session.read().conflict)
|
|
501
|
+
return;
|
|
502
|
+
if (choice === 'latest')
|
|
503
|
+
window.open(location.href, '_blank', 'noopener');
|
|
504
|
+
else if (choice === 'copy') {
|
|
505
|
+
session.detachAsCopy();
|
|
506
|
+
/* The page forgets the row of the figure left: the copy's identifier is asked, not the other's offered. */
|
|
507
|
+
this.dispatchEvent(new CustomEvent('figure-detached', { bubbles: true, composed: true }));
|
|
508
|
+
this.changed();
|
|
509
|
+
await this.save();
|
|
510
|
+
}
|
|
511
|
+
else if (choice === 'overwrite') {
|
|
512
|
+
const agreed = await OxPrompt.open({
|
|
513
|
+
type: 'warning',
|
|
514
|
+
text: i18next.t('figure.text.overwrite-latest-warning'),
|
|
515
|
+
confirmButton: { text: i18next.t('figure.button.overwrite-latest') },
|
|
516
|
+
cancelButton: { text: i18next.t('figure.button.keep-editing') }
|
|
517
|
+
});
|
|
518
|
+
if (!agreed || this.session !== session || !session.read().conflict)
|
|
519
|
+
return;
|
|
520
|
+
session.overwriteConflict();
|
|
521
|
+
await this.save();
|
|
522
|
+
}
|
|
523
|
+
}
|
|
472
524
|
add(kind) {
|
|
473
525
|
if (!this.session)
|
|
474
526
|
return;
|
|
@@ -1688,6 +1740,7 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
1688
1740
|
.kept=${kept}
|
|
1689
1741
|
.pointBlocked=${this.pointBlockedOf(drawn)}
|
|
1690
1742
|
.revision=${this.session?.read().revision ?? 0}
|
|
1743
|
+
?active=${this.active}
|
|
1691
1744
|
.faceMapping=${this.faceMapping?.from === 'section-editor' ? this.faceMapping.need : undefined}
|
|
1692
1745
|
@command=${(e) => {
|
|
1693
1746
|
/* Held for a choice of where things go: the editor stays open, the choice shown in it. */
|