@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.
Files changed (38) hide show
  1. package/client/modeller/editor.ts +48 -2
  2. package/client/modeller/figure-save-conflict.ts +73 -0
  3. package/client/modeller/figure-section-editor.ts +3 -0
  4. package/client/modeller/product-session.ts +60 -6
  5. package/client/modeller/save-conflict.ts +91 -0
  6. package/client/pages/figure-modeller-page.ts +27 -9
  7. package/dist-client/modeller/editor.d.ts +7 -0
  8. package/dist-client/modeller/editor.js +55 -2
  9. package/dist-client/modeller/editor.js.map +1 -1
  10. package/dist-client/modeller/figure-save-conflict.d.ts +19 -0
  11. package/dist-client/modeller/figure-save-conflict.js +76 -0
  12. package/dist-client/modeller/figure-save-conflict.js.map +1 -0
  13. package/dist-client/modeller/figure-section-editor.d.ts +2 -0
  14. package/dist-client/modeller/figure-section-editor.js +8 -0
  15. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  16. package/dist-client/modeller/product-session.d.ts +234 -1
  17. package/dist-client/modeller/product-session.js +67 -7
  18. package/dist-client/modeller/product-session.js.map +1 -1
  19. package/dist-client/modeller/save-conflict.d.ts +12 -0
  20. package/dist-client/modeller/save-conflict.js +63 -0
  21. package/dist-client/modeller/save-conflict.js.map +1 -0
  22. package/dist-client/pages/figure-modeller-page.d.ts +4 -3
  23. package/dist-client/pages/figure-modeller-page.js +29 -9
  24. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  25. package/dist-client/tsconfig.tsbuildinfo +1 -1
  26. package/package.json +5 -5
  27. package/test/browser/product-page.check.mjs +176 -0
  28. package/test/browser/product-page.js +4 -1
  29. package/test/browser/section.check.mjs +21 -0
  30. package/test/product-session.test.ts +73 -6
  31. package/test/save-conflict.test.ts +41 -0
  32. package/test/save-landed.test.ts +75 -0
  33. package/translations/en.json +12 -1
  34. package/translations/es.json +12 -1
  35. package/translations/ja.json +12 -1
  36. package/translations/ko.json +12 -1
  37. package/translations/ms.json +12 -1
  38. 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
- /* Nothing could change it on the way: an edit waits for the save (editCatalog). */
349
- catalog = {}
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
- /** The catalogue fields held at the last change, and the figure they were held for. */
151
- private held: FigureCatalogPatch = {}
152
- private heldFor?: string
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
- if (this.wasDirty && !view.dirty && !view.saving && view.id) {
235
- /* The catalogue fields just saved stand in the row until it is read again: the old ones did not flash back. */
236
- if (view.id === this.heldFor) this.row = { ...this.row, ...this.held }
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.held = view.catalog
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
- this.error = error?.code === 'SAVE_UNREACHABLE' ? i18next.t('figure.text.save-unreachable') : this.said(error);
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. */