@things-factory/figure-ui 10.1.241 → 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 (63) hide show
  1. package/client/graphql/store.ts +3 -2
  2. package/client/modeller/editor.ts +63 -5
  3. package/client/modeller/figure-parts.ts +18 -3
  4. package/client/modeller/figure-preview.ts +1 -1
  5. package/client/modeller/figure-properties.ts +14 -7
  6. package/client/modeller/figure-save-conflict.ts +73 -0
  7. package/client/modeller/figure-section-editor.ts +3 -0
  8. package/client/modeller/keys.ts +8 -2
  9. package/client/modeller/modeller-keys.ts +4 -1
  10. package/client/modeller/product-session.ts +107 -5
  11. package/client/modeller/save-conflict.ts +91 -0
  12. package/client/pages/figure-modeller-page.ts +58 -43
  13. package/dist-client/graphql/store.js +3 -2
  14. package/dist-client/graphql/store.js.map +1 -1
  15. package/dist-client/modeller/editor.d.ts +14 -0
  16. package/dist-client/modeller/editor.js +75 -5
  17. package/dist-client/modeller/editor.js.map +1 -1
  18. package/dist-client/modeller/figure-parts.js +18 -3
  19. package/dist-client/modeller/figure-parts.js.map +1 -1
  20. package/dist-client/modeller/figure-preview.d.ts +1 -1
  21. package/dist-client/modeller/figure-preview.js.map +1 -1
  22. package/dist-client/modeller/figure-properties.d.ts +4 -2
  23. package/dist-client/modeller/figure-properties.js +16 -7
  24. package/dist-client/modeller/figure-properties.js.map +1 -1
  25. package/dist-client/modeller/figure-save-conflict.d.ts +19 -0
  26. package/dist-client/modeller/figure-save-conflict.js +76 -0
  27. package/dist-client/modeller/figure-save-conflict.js.map +1 -0
  28. package/dist-client/modeller/figure-section-editor.d.ts +2 -0
  29. package/dist-client/modeller/figure-section-editor.js +8 -0
  30. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  31. package/dist-client/modeller/keys.d.ts +5 -0
  32. package/dist-client/modeller/keys.js +5 -3
  33. package/dist-client/modeller/keys.js.map +1 -1
  34. package/dist-client/modeller/modeller-keys.d.ts +2 -0
  35. package/dist-client/modeller/modeller-keys.js +2 -1
  36. package/dist-client/modeller/modeller-keys.js.map +1 -1
  37. package/dist-client/modeller/product-session.d.ts +368 -22
  38. package/dist-client/modeller/product-session.js +107 -7
  39. package/dist-client/modeller/product-session.js.map +1 -1
  40. package/dist-client/modeller/save-conflict.d.ts +12 -0
  41. package/dist-client/modeller/save-conflict.js +63 -0
  42. package/dist-client/modeller/save-conflict.js.map +1 -0
  43. package/dist-client/pages/figure-modeller-page.d.ts +11 -5
  44. package/dist-client/pages/figure-modeller-page.js +58 -49
  45. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  46. package/dist-client/tsconfig.tsbuildinfo +1 -1
  47. package/package.json +5 -5
  48. package/test/browser/category-page.js +3 -1
  49. package/test/browser/category.check.mjs +6 -0
  50. package/test/browser/product-page.check.mjs +268 -6
  51. package/test/browser/product-page.js +4 -1
  52. package/test/browser/screen.check.mjs +3 -0
  53. package/test/browser/section.check.mjs +21 -0
  54. package/test/keys.test.ts +8 -0
  55. package/test/product-session.test.ts +137 -1
  56. package/test/save-conflict.test.ts +41 -0
  57. package/test/save-landed.test.ts +75 -0
  58. package/translations/en.json +12 -1
  59. package/translations/es.json +12 -1
  60. package/translations/ja.json +12 -1
  61. package/translations/ko.json +12 -1
  62. package/translations/ms.json +12 -1
  63. package/translations/zh.json +12 -1
@@ -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
+ }
@@ -3,10 +3,10 @@ import { customElement, state } from 'lit/decorators.js'
3
3
  import { navigate, PageView } from '@operato/shell'
4
4
  import { getPathInfo } from '@operato/utils'
5
5
  import { modellerPathOf } from '../modeller/modeller-route.js'
6
- import { createFigureProductSession } from '../modeller/product-session.js'
6
+ import { createFigureProductSession, type FigureCatalogPatch } from '../modeller/product-session.js'
7
7
  import { figureProductStore } from '../graphql/store.js'
8
8
  import { i18next } from '@operato/i18n'
9
- import { fetchFigure, fetchFigureCategories, fetchFigureNamespaces, fetchFigureTypeNames, updateFigure } from '../graphql/index.js'
9
+ import { fetchFigure, fetchFigureCategories, fetchFigureNamespaces, fetchFigureTypeNames } from '../graphql/index.js'
10
10
  import { figureProposalOutcome } from '../figure-assistant.js'
11
11
  import { availableRecipes, recipeGuide } from '../modeller/recipe-source.js'
12
12
  import type { RecipeTemplate } from '../modeller/recipes.js'
@@ -141,13 +141,15 @@ export class FigureModellerPage extends PageView {
141
141
  @state() private row: { state?: 'draft' | 'released'; version: number; namespace?: string; type?: string; category?: string; tags?: string[] } = {
142
142
  version: 0
143
143
  }
144
- @state() private typeError = ''
145
144
  /** Why a command from the side did nothing. Shown beside the tabs — the editor stays, since nothing in it was lost. */
146
145
  @state() private commandError = ''
147
146
  /** What the verdict says blocks release now, handed to the editor's bar. */
148
147
  @state() private releaseBlockers: FigureGateFinding[] = []
149
148
  @state() private budget?: FigureBudgetLine
150
149
  private wasDirty = false
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
151
153
  private titled?: string | null
152
154
  /** The figure the AI dock was last told about - another id is another conversation. */
153
155
  private assistantFor?: string
@@ -229,8 +231,21 @@ export class FigureModellerPage extends PageView {
229
231
  navigate(modellerPathOf({ id: view.id }), true)
230
232
  }
231
233
  /* 저장이 끝난 순간 행을 다시 읽는다 — 발행된 피규어를 고쳐 저장하면 서버가 초안으로 내린다. */
232
- if (this.wasDirty && !view.dirty && !view.saving && view.id) this.readRow(view.id)
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 }
245
+ this.readRow(view.id)
246
+ }
233
247
  this.wasDirty = !!view.dirty
248
+ this.wasSaving = !!view.saving
234
249
  }
235
250
  /*
236
251
  A released figure changed on the server -- its source saved, or its category or tags -- comes back a draft, and a
@@ -242,8 +257,10 @@ export class FigureModellerPage extends PageView {
242
257
  if (before === 'released' && after === 'draft') notify({ level: 'info', message: i18next.t('figure.text.released-now-draft') })
243
258
  }
244
259
  private async readRow(id: string) {
260
+ const reading = ++this.rowReading
245
261
  try {
246
262
  const figure = await fetchFigure(id)
263
+ if (reading !== this.rowReading) return
247
264
  if (this.session.read().id === id) this.toldDemoted(this.row.state, figure.state)
248
265
  if (this.session.read().id === id)
249
266
  this.row = {
@@ -279,15 +296,24 @@ export class FigureModellerPage extends PageView {
279
296
  this.row = { ...this.row, type: wanted || undefined }
280
297
  return
281
298
  }
282
- if (!wanted || wanted === this.row.type) return
283
- try {
284
- const result = await updateFigure(id, { type: wanted })
285
- this.typeError = ''
286
- this.row = { ...this.row, type: result.figure.type }
287
- } catch (error) {
288
- this.typeError = error instanceof Error ? error.message : String(error)
289
- input.value = this.row.type ?? ''
299
+ if (!wanted) {
300
+ input.value = this.shownRow().type ?? ''
301
+ return
290
302
  }
303
+ this.editCatalog({ type: wanted })
304
+ }
305
+ /**
306
+ * Category, tags and type: the catalogue's, kept on the row, not in the asset -- held by the session and written by
307
+ * 「저장」 with the figure (2026-11-01 audit item 5). The server's refusal is said where a save's is.
308
+ */
309
+ private editCatalog(patch: FigureCatalogPatch) {
310
+ const { type, category, tags } = this.row
311
+ this.session.editCatalog(patch, { type, category, tags })
312
+ this.changed()
313
+ }
314
+ /** The row as the screen shows it: what the server holds, with the catalogue fields changed and not saved yet. */
315
+ private shownRow() {
316
+ return { ...this.row, ...this.session.read().catalog }
291
317
  }
292
318
  /** A command from the properties tab — rename, the mounting face. */
293
319
  private onCommand = (event: Event) => {
@@ -312,29 +338,18 @@ export class FigureModellerPage extends PageView {
312
338
  : String(error)
313
339
  }
314
340
  }
315
- /**
316
- * Category and tags — the catalogue's, kept on the row, not in the asset. Written at once; the server's
317
- * refusal is shown as it gave it.
318
- */
319
- private onCatalog = async (event: Event) => {
341
+ private onCatalog = (event: Event) => {
320
342
  const patch = (event as CustomEvent).detail as { category?: string; tags?: string[] }
321
- const id = this.session.read().id
322
- if (!id || !patch) return
323
- try {
324
- const result = await updateFigure(id, patch)
325
- this.commandError = ''
326
- /* The server's answer on its state too: a category changed on a released figure made it a draft, and the badge stayed. */
327
- this.toldDemoted(this.row.state, result.figure.state)
328
- this.row = {
329
- ...this.row,
330
- category: result.figure.category ?? undefined,
331
- tags: result.figure.tags ?? undefined,
332
- ...(result.figure.state ? { state: result.figure.state as 'draft' | 'released' } : {}),
333
- ...(result.figure.version !== undefined ? { version: result.figure.version ?? 0 } : {})
334
- }
335
- } catch (error) {
336
- this.commandError = error instanceof Error ? error.message : String(error)
337
- }
343
+ if (this.session.read().id && patch) this.editCatalog(patch)
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()
338
353
  }
339
354
  private released = (event: Event) => {
340
355
  const figure = (event as CustomEvent).detail?.figure
@@ -376,10 +391,11 @@ export class FigureModellerPage extends PageView {
376
391
  }
377
392
  /*
378
393
  Reloading or closing the tab is not a move the shell asks `canDeactivate` about: a new figure not yet saved was lost
379
- without a word (2026-09-26). The browser's own question is the only one it allows there.
394
+ without a word (2026-09-26). The browser's own question is the only one it allows there. Asked for a page left
395
+ behind too: the shell keeps it, with its edits, and closing the tab loses them all the same (2026-11-01 audit).
380
396
  */
381
397
  private holdUnsaved = (event: BeforeUnloadEvent) => {
382
- if (!this.active || !this.session.read().dirty) return
398
+ if (!this.session.read().dirty) return
383
399
  event.preventDefault()
384
400
  event.returnValue = ''
385
401
  }
@@ -444,7 +460,6 @@ export class FigureModellerPage extends PageView {
444
460
  if (id) {
445
461
  /* The row is read after the figure opens; until then the last figure's type must not stand in the field (C2). */
446
462
  this.row = { version: 0 }
447
- this.typeError = ''
448
463
  const opening = this.session.open(id, { discard })
449
464
  this.changed()
450
465
  await opening
@@ -452,7 +467,6 @@ export class FigureModellerPage extends PageView {
452
467
  } else {
453
468
  this.session.startNew({ discard, name: i18next.t('figure.text.new-figure-name') })
454
469
  this.row = { version: 0 }
455
- this.typeError = ''
456
470
  this.takeNewFigureProposal()
457
471
  }
458
472
  this.routeId = id
@@ -517,16 +531,18 @@ export class FigureModellerPage extends PageView {
517
531
  render() {
518
532
  if (this.error) return html`<p role="alert" class="error">${this.error}</p>`
519
533
  const view = this.session.read()
534
+ const shown = this.shownRow()
520
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). */
521
- 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} 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}>
522
537
  ${this.commandError ? html`<p slot="notice" fit-error role="alert">${this.commandError}</p>` : ''}
523
538
  <figure-properties
524
539
  slot="figure"
525
540
  .revision=${this.session.revisionNow()}
526
541
  .figureId=${view.id}
527
- .figureCategory=${this.row.category}
542
+ .figureCategory=${shown.category}
543
+ .figureCategorySaved=${this.row.category}
528
544
  .figureCategories=${this.categories}
529
- .figureTags=${this.row.tags}
545
+ .figureTags=${shown.tags}
530
546
  @command=${this.onCommand}
531
547
  @catalog=${this.onCatalog}
532
548
  >
@@ -535,11 +551,10 @@ export class FigureModellerPage extends PageView {
535
551
  ${this.row.version > 0
536
552
  ? html`<code>${this.row.type}</code><small>${i18next.t('figure.text.type-name-cannot-change')}</small>`
537
553
  : html`<input
538
- .value=${this.row.type ?? ''}
554
+ .value=${shown.type ?? ''}
539
555
  placeholder=${i18next.t('figure.text.figure-type-placeholder')}
540
556
  @change=${this.renameType}
541
557
  />`}
542
- ${this.typeError ? html`<small role="alert">${this.typeError}</small>` : ''}
543
558
  </label>
544
559
  </figure-properties>
545
560
  <figure-verdict
@@ -1,6 +1,6 @@
1
1
  import gql from 'graphql-tag';
2
2
  import { client } from '@operato/graphql';
3
- import { fetchFigure } from './index.js';
3
+ import { fetchFigure, updateFigure } from './index.js';
4
4
  import { reached } from './reached.js';
5
5
  /** source is sent intact. No conversion into the old parts/base authoring representation. */
6
6
  export const figureProductStore = {
@@ -58,6 +58,7 @@ export const figureProductStore = {
58
58
  if (!result?.id || !result.source)
59
59
  throw Error('서버가 저장 결과를 돌려주지 않았습니다.');
60
60
  return result;
61
- }
61
+ },
62
+ updateCatalog: (id, patch) => updateFigure(id, patch)
62
63
  };
63
64
  //# sourceMappingURL=store.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"store.js","sourceRoot":"","sources":["../../client/graphql/store.ts"],"names":[],"mappings":"AAAA,OAAO,GAAG,MAAM,aAAa,CAAA;AAC7B,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AACxC,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAGtC,6FAA6F;AAC7F,MAAM,CAAC,MAAM,kBAAkB,GAAuB;IACpD,KAAK,CAAC,IAAI,CAAC,EAAE;QACX,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,EAAE,CAAC,CAAA;QACpC,IAAI,CAAC,MAAM,EAAE,MAAM;YAAE,MAAM,KAAK,CAAC,kBAAkB,CAAC,CAAA;QACpD,OAAO,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAA;IACjD,CAAC;IACD,4GAA4G;IAC5G,KAAK,CAAC,QAAQ,CAAC,EAAE;QACf,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC;YAClC,KAAK,EAAE,GAAG,CAAA;;;;;;;;;OAST;YACD,SAAS,EAAE,EAAE,EAAE,EAAE;YACjB,WAAW,EAAE,cAAc;SAC5B,CAAC,CAAA;QACF,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,EAAE,MAAkG,CAAA;QAChI,IAAI,CAAC,MAAM,EAAE,MAAM;YAAE,MAAM,KAAK,CAAC,kBAAkB,CAAC,CAAA;QACpD,OAAO,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,EAAE,MAAM,CAAC,aAAa,IAAI,SAAS,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,CAAA;IACtH,CAAC;IACD,KAAK,CAAC,IAAI,CAAC,OAAO;QAChB,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC;YAC3C,QAAQ,EAAE,GAAG,CAAA;;;;;;;;;;;;;;;;OAgBZ;YACD,SAAS,EAAE,OAAO;SACnB,CAAC,CAAC,CAAA;QACH,IAAI,QAAQ,CAAC,MAAM,EAAE,MAAM;YACzB,MAAM,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,EAAE,IAAI,EAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAwC,CAAC,UAAU,EAAE,IAAI,EAAE,CAAC,CAAA;QACnK,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,EAAE,UAA6C,CAAA;QAC3E,IAAI,CAAC,MAAM,EAAE,EAAE,IAAI,CAAC,MAAM,CAAC,MAAM;YAAE,MAAM,KAAK,CAAC,wBAAwB,CAAC,CAAA;QACxE,OAAO,MAAM,CAAA;IACf,CAAC;CACF,CAAA","sourcesContent":["import gql from 'graphql-tag'\nimport { client } from '@operato/graphql'\nimport { fetchFigure } from './index.js'\nimport { reached } from './reached.js'\nimport type { FigureProductStore, FigureProductRecord } from '../modeller/product-session.js'\n\n/** source is sent intact. No conversion into the old parts/base authoring representation. */\nexport const figureProductStore: FigureProductStore = {\n async load(id) {\n const record = await fetchFigure(id)\n if (!record?.source) throw Error('Figure 정본이 없습니다.')\n return { id: record.id, source: record.source }\n },\n /* What the server holds of every field a save writes, read back after a save whose outcome is not known. */\n async readBack(id) {\n const response = await client.query({\n query: gql`\n query ($id: String!) {\n figure(id: $id) {\n id\n source\n thumbnailHash\n type\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n const figure = response.data?.figure as { id: string; source: string; thumbnailHash?: string | null; type?: string } | undefined\n if (!figure?.source) throw Error('Figure 정본이 없습니다.')\n return { id: figure.id, source: figure.source, thumbnailHash: figure.thumbnailHash ?? undefined, type: figure.type }\n },\n async save(request) {\n const response = await reached(client.mutate({\n mutation: gql`\n mutation SaveFigure($source: String!, $id: String, $expectedSource: String, $creationId: String, $thumbnail: String, $type: String, $proposalKeys: [String!], $namespace: String) {\n saveFigure(\n source: $source\n id: $id\n expectedSource: $expectedSource\n creationId: $creationId\n thumbnail: $thumbnail\n type: $type\n proposalKeys: $proposalKeys\n namespace: $namespace\n ) {\n id\n source\n }\n }\n `,\n variables: request\n }))\n if (response.errors?.length)\n throw Object.assign(Error(response.errors.map(e => e.message).join('\\n')), { code: (response.errors[0] as { extensions?: { code?: string } }).extensions?.code })\n const result = response.data?.saveFigure as FigureProductRecord | undefined\n if (!result?.id || !result.source) throw Error('서버가 저장 결과를 돌려주지 않았습니다.')\n return result\n }\n}\n"]}
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../../client/graphql/store.ts"],"names":[],"mappings":"AAAA,OAAO,GAAG,MAAM,aAAa,CAAA;AAC7B,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AACzC,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AACtD,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAGtC,6FAA6F;AAC7F,MAAM,CAAC,MAAM,kBAAkB,GAAuB;IACpD,KAAK,CAAC,IAAI,CAAC,EAAE;QACX,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,EAAE,CAAC,CAAA;QACpC,IAAI,CAAC,MAAM,EAAE,MAAM;YAAE,MAAM,KAAK,CAAC,kBAAkB,CAAC,CAAA;QACpD,OAAO,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAA;IACjD,CAAC;IACD,4GAA4G;IAC5G,KAAK,CAAC,QAAQ,CAAC,EAAE;QACf,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC;YAClC,KAAK,EAAE,GAAG,CAAA;;;;;;;;;OAST;YACD,SAAS,EAAE,EAAE,EAAE,EAAE;YACjB,WAAW,EAAE,cAAc;SAC5B,CAAC,CAAA;QACF,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,EAAE,MAAkG,CAAA;QAChI,IAAI,CAAC,MAAM,EAAE,MAAM;YAAE,MAAM,KAAK,CAAC,kBAAkB,CAAC,CAAA;QACpD,OAAO,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,EAAE,MAAM,CAAC,aAAa,IAAI,SAAS,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,CAAA;IACtH,CAAC;IACD,KAAK,CAAC,IAAI,CAAC,OAAO;QAChB,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC;YAC3C,QAAQ,EAAE,GAAG,CAAA;;;;;;;;;;;;;;;;OAgBZ;YACD,SAAS,EAAE,OAAO;SACnB,CAAC,CAAC,CAAA;QACH,IAAI,QAAQ,CAAC,MAAM,EAAE,MAAM;YACzB,MAAM,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,EAAE,IAAI,EAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAwC,CAAC,UAAU,EAAE,IAAI,EAAE,CAAC,CAAA;QACnK,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,EAAE,UAA6C,CAAA;QAC3E,IAAI,CAAC,MAAM,EAAE,EAAE,IAAI,CAAC,MAAM,CAAC,MAAM;YAAE,MAAM,KAAK,CAAC,wBAAwB,CAAC,CAAA;QACxE,OAAO,MAAM,CAAA;IACf,CAAC;IACD,aAAa,EAAE,CAAC,EAAE,EAAE,KAAK,EAAE,EAAE,CAAC,YAAY,CAAC,EAAE,EAAE,KAAK,CAAC;CACtD,CAAA","sourcesContent":["import gql from 'graphql-tag'\nimport { client } from '@operato/graphql'\nimport { fetchFigure, updateFigure } from './index.js'\nimport { reached } from './reached.js'\nimport type { FigureProductStore, FigureProductRecord } from '../modeller/product-session.js'\n\n/** source is sent intact. No conversion into the old parts/base authoring representation. */\nexport const figureProductStore: FigureProductStore = {\n async load(id) {\n const record = await fetchFigure(id)\n if (!record?.source) throw Error('Figure 정본이 없습니다.')\n return { id: record.id, source: record.source }\n },\n /* What the server holds of every field a save writes, read back after a save whose outcome is not known. */\n async readBack(id) {\n const response = await client.query({\n query: gql`\n query ($id: String!) {\n figure(id: $id) {\n id\n source\n thumbnailHash\n type\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n const figure = response.data?.figure as { id: string; source: string; thumbnailHash?: string | null; type?: string } | undefined\n if (!figure?.source) throw Error('Figure 정본이 없습니다.')\n return { id: figure.id, source: figure.source, thumbnailHash: figure.thumbnailHash ?? undefined, type: figure.type }\n },\n async save(request) {\n const response = await reached(client.mutate({\n mutation: gql`\n mutation SaveFigure($source: String!, $id: String, $expectedSource: String, $creationId: String, $thumbnail: String, $type: String, $proposalKeys: [String!], $namespace: String) {\n saveFigure(\n source: $source\n id: $id\n expectedSource: $expectedSource\n creationId: $creationId\n thumbnail: $thumbnail\n type: $type\n proposalKeys: $proposalKeys\n namespace: $namespace\n ) {\n id\n source\n }\n }\n `,\n variables: request\n }))\n if (response.errors?.length)\n throw Object.assign(Error(response.errors.map(e => e.message).join('\\n')), { code: (response.errors[0] as { extensions?: { code?: string } }).extensions?.code })\n const result = response.data?.saveFigure as FigureProductRecord | undefined\n if (!result?.id || !result.source) throw Error('서버가 저장 결과를 돌려주지 않았습니다.')\n return result\n },\n updateCatalog: (id, patch) => updateFigure(id, patch)\n}\n"]}
@@ -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';
@@ -45,6 +46,11 @@ export declare class FigureEditor extends LitElement {
45
46
  /** Why the last recipe was refused, in the kernel's words. */
46
47
  private recipeError;
47
48
  revision: number;
49
+ /**
50
+ * The page in front (the shell's `active`). A page left keeps its editor, on the window: a Backspace pressed on
51
+ * another page removed the part picked here, out of sight (2026-11-01 audit). Being on the document is not being seen.
52
+ */
53
+ active: boolean;
48
54
  /** The 「?」 beside a group's title and the sentence it opens. */
49
55
  private readonly help;
50
56
  /** The cost line the parts list shows, from the maturity report. */
@@ -116,6 +122,8 @@ export declare class FigureEditor extends LitElement {
116
122
  */
117
123
  private renderGizmoTools;
118
124
  private keys;
125
+ /** 「화면 맞춤」 fits the canvas in front: in the preview it fitted the edit canvas hidden behind it (2026-11-01 audit). */
126
+ private fitShown;
119
127
  /** The part Ctrl+C took: pasted as the kernel's duplicate. Not kept past the editor, nor written anywhere. */
120
128
  private copied;
121
129
  /** A copied part still in the figure is duplicated -- a part of its own, named by figure-model; one gone, nothing. */
@@ -166,6 +174,12 @@ export declare class FigureEditor extends LitElement {
166
174
  private holdAction;
167
175
  private changed;
168
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;
169
183
  private add;
170
184
  /**
171
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';
@@ -79,6 +82,11 @@ let FigureEditor = class FigureEditor extends LitElement {
79
82
  /** Why the last recipe was refused, in the kernel's words. */
80
83
  this.recipeError = '';
81
84
  this.revision = 0;
85
+ /**
86
+ * The page in front (the shell's `active`). A page left keeps its editor, on the window: a Backspace pressed on
87
+ * another page removed the part picked here, out of sight (2026-11-01 audit). Being on the document is not being seen.
88
+ */
89
+ this.active = true;
82
90
  /** The 「?」 beside a group's title and the sentence it opens. */
83
91
  this.help = new HelpToggles(this);
84
92
  this.selected = '';
@@ -132,8 +140,9 @@ let FigureEditor = class FigureEditor extends LitElement {
132
140
  * The section editor open takes the keys for its own: Esc cancels it, Delete deletes a point, Ctrl+Z undoes a point.
133
141
  * Heard here first, they deselected the part, deleted the part and undid the figure's last step (2026-09-30).
134
142
  */
135
- return !!view && !view.loading && !view.saving && !!view.context && this.isConnected && !this.sectionEditing;
143
+ return !!view && !view.loading && !view.saving && !!view.context && this.isConnected && this.active && !this.sectionEditing;
136
144
  },
145
+ previewing: () => this.middle === 'preview',
137
146
  scene: () => this.renderRoot.querySelector('figure-canvas'),
138
147
  selected: () => this.selected,
139
148
  act: action => this.keyAction(action)
@@ -234,6 +243,13 @@ let FigureEditor = class FigureEditor extends LitElement {
234
243
  </button>`)}
235
244
  </span>`;
236
245
  }
246
+ /** 「화면 맞춤」 fits the canvas in front: in the preview it fitted the edit canvas hidden behind it (2026-11-01 audit). */
247
+ fitShown() {
248
+ if (this.middle === 'preview')
249
+ this.renderRoot.querySelector('figure-preview')?.fitView();
250
+ else
251
+ this.renderRoot.querySelector('figure-canvas')?.fit();
252
+ }
237
253
  /** A copied part still in the figure is duplicated -- a part of its own, named by figure-model; one gone, nothing. */
238
254
  pasteCopied() {
239
255
  const asset = this.session?.revisionNow()?.asset;
@@ -449,13 +465,62 @@ let FigureEditor = class FigureEditor extends LitElement {
449
465
  this.error = '';
450
466
  }
451
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();
452
471
  /* Not reached: whether it was written is not known, and the edits are still here -- said so, not "not saved". */
453
- 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);
454
474
  }
455
475
  finally {
456
476
  this.changed();
457
477
  }
458
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
+ }
459
524
  add(kind) {
460
525
  if (!this.session)
461
526
  return;
@@ -1634,7 +1699,7 @@ let FigureEditor = class FigureEditor extends LitElement {
1634
1699
  whatever is picked: the size tab was 「반응형」 with nothing picked and 「배치」 with a part (sweep 2026-10-27 J2).
1635
1700
  */
1636
1701
  const name = i18next.t('figure.label.tab-short-' + tab);
1637
- return html `<button tab ?on=${this.tab === tab} title=${label} aria-label=${name} @click=${() => (this.tab = tab)}>
1702
+ return html `<button tab ?on=${this.tab === tab} aria-pressed=${String(this.tab === tab)} title=${label} aria-label=${name} @click=${() => (this.tab = tab)}>
1638
1703
  <md-icon>${icon}</md-icon><span tab-label aria-hidden="true">${name}</span>
1639
1704
  </button>`;
1640
1705
  }
@@ -1675,6 +1740,7 @@ let FigureEditor = class FigureEditor extends LitElement {
1675
1740
  .kept=${kept}
1676
1741
  .pointBlocked=${this.pointBlockedOf(drawn)}
1677
1742
  .revision=${this.session?.read().revision ?? 0}
1743
+ ?active=${this.active}
1678
1744
  .faceMapping=${this.faceMapping?.from === 'section-editor' ? this.faceMapping.need : undefined}
1679
1745
  @command=${(e) => {
1680
1746
  /* Held for a choice of where things go: the editor stays open, the choice shown in it. */
@@ -1837,7 +1903,7 @@ let FigureEditor = class FigureEditor extends LitElement {
1837
1903
  .pane=${this.panes}
1838
1904
  @canvas-undo=${() => this.run(() => this.session.undo())}
1839
1905
  @canvas-redo=${() => this.run(() => this.session.redo())}
1840
- @canvas-fit=${() => this.renderRoot.querySelector('figure-canvas')?.fit()}
1906
+ @canvas-fit=${() => this.fitShown()}
1841
1907
  >
1842
1908
  ${this.renderGizmoTools(!unavailable && this.middle === 'edit')}
1843
1909
  ${this.renderOverlayButton('view', 'wb_sunny', 'figure.label.tab-environment')}
@@ -2178,6 +2244,10 @@ __decorate([
2178
2244
  property({ type: Number }),
2179
2245
  __metadata("design:type", Object)
2180
2246
  ], FigureEditor.prototype, "revision", void 0);
2247
+ __decorate([
2248
+ property({ type: Boolean }),
2249
+ __metadata("design:type", Object)
2250
+ ], FigureEditor.prototype, "active", void 0);
2181
2251
  __decorate([
2182
2252
  property({ attribute: false }),
2183
2253
  __metadata("design:type", Object)