@things-factory/figure-ui 10.1.243 → 10.1.245

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 (46) hide show
  1. package/client/graphql/index.ts +15 -0
  2. package/client/modeller/editor.ts +48 -2
  3. package/client/modeller/figure-save-conflict.ts +73 -0
  4. package/client/modeller/figure-section-editor.ts +3 -0
  5. package/client/modeller/product-session.ts +60 -6
  6. package/client/modeller/save-conflict.ts +91 -0
  7. package/client/pages/figure-list-page.ts +15 -5
  8. package/client/pages/figure-modeller-page.ts +27 -9
  9. package/dist-client/graphql/index.d.ts +2 -0
  10. package/dist-client/graphql/index.js +15 -0
  11. package/dist-client/graphql/index.js.map +1 -1
  12. package/dist-client/modeller/editor.d.ts +7 -0
  13. package/dist-client/modeller/editor.js +55 -2
  14. package/dist-client/modeller/editor.js.map +1 -1
  15. package/dist-client/modeller/figure-save-conflict.d.ts +19 -0
  16. package/dist-client/modeller/figure-save-conflict.js +76 -0
  17. package/dist-client/modeller/figure-save-conflict.js.map +1 -0
  18. package/dist-client/modeller/figure-section-editor.d.ts +2 -0
  19. package/dist-client/modeller/figure-section-editor.js +8 -0
  20. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  21. package/dist-client/modeller/product-session.d.ts +234 -1
  22. package/dist-client/modeller/product-session.js +67 -7
  23. package/dist-client/modeller/product-session.js.map +1 -1
  24. package/dist-client/modeller/save-conflict.d.ts +12 -0
  25. package/dist-client/modeller/save-conflict.js +63 -0
  26. package/dist-client/modeller/save-conflict.js.map +1 -0
  27. package/dist-client/pages/figure-list-page.d.ts +1 -0
  28. package/dist-client/pages/figure-list-page.js +12 -3
  29. package/dist-client/pages/figure-list-page.js.map +1 -1
  30. package/dist-client/pages/figure-modeller-page.d.ts +4 -3
  31. package/dist-client/pages/figure-modeller-page.js +29 -9
  32. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  33. package/dist-client/tsconfig.tsbuildinfo +1 -1
  34. package/package.json +5 -5
  35. package/test/browser/product-page.check.mjs +176 -0
  36. package/test/browser/product-page.js +4 -1
  37. package/test/browser/section.check.mjs +21 -0
  38. package/test/product-session.test.ts +73 -6
  39. package/test/save-conflict.test.ts +41 -0
  40. package/test/save-landed.test.ts +75 -0
  41. package/translations/en.json +12 -1
  42. package/translations/es.json +12 -1
  43. package/translations/ja.json +12 -1
  44. package/translations/ko.json +12 -1
  45. package/translations/ms.json +12 -1
  46. package/translations/zh.json +12 -1
@@ -75,6 +75,21 @@ const FIGURE_LIST_FIELDS = `
75
75
  * filter 로 옮기던 것을 그만두었다 — 그 이름들은 격자(ox-grist)가 이미 컬럼 설정에서 만들어 주고,
76
76
  * 중간에 한 벌 더 두면 격자가 아는 조건과 서버에 가는 조건이 어긋난다.
77
77
  */
78
+ /** How many of the domain's figures each state chip would show (draft, released), under the list's other conditions. */
79
+ export async function fetchFigureStateCounts(filters: unknown[]): Promise<Map<string, number>> {
80
+ const response = await client.query({
81
+ query: gql`
82
+ query ($filters: [Filter!]) {
83
+ figureStateCounts(filters: $filters) { value count }
84
+ }
85
+ `,
86
+ variables: { filters },
87
+ fetchPolicy: 'no-cache'
88
+ })
89
+ if (response.errors?.length) throw new Error(response.errors.map(one => one.message).join(' · '))
90
+ return new Map((response.data?.figureStateCounts ?? []).map((row: { value: string; count: number }) => [row.value, row.count]))
91
+ }
92
+
78
93
  export async function fetchFigureList(params: {
79
94
  page?: number
80
95
  limit?: number
@@ -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
+ }
@@ -14,7 +14,7 @@ import { notify } from '@operato/layout'
14
14
  import { CardCatalogStyles, CommonGristStyles, CommonHeaderStyles, ScrollbarStyles, WorkSurfacePageStyles } from '@operato/styles'
15
15
 
16
16
  import { capacityFrom } from '../modeller/scene-budget.js'
17
- import { deleteFigure, fetchFigureList } from '../graphql/index.js'
17
+ import { deleteFigure, fetchFigureList, fetchFigureStateCounts } from '../graphql/index.js'
18
18
  import type { Figure } from '../types.js'
19
19
  import { hasBeenReleased } from '../figure-released.js'
20
20
  import { modellerPathOf } from '../modeller/modeller-route.js'
@@ -116,6 +116,8 @@ export class FigureListPage extends FigureListPageBase {
116
116
  * only looked at, so a switch inside one list mixed two jobs. `figure-public-list-page` sets it.
117
117
  */
118
118
  protected readonly scope: 'own' | 'public' = 'own'
119
+ /* How many figures each state chip would show, under the list's other conditions - counted by the server. */
120
+ private stateCounts = new Map<string, number>()
119
121
 
120
122
  private listen?: () => void
121
123
 
@@ -335,10 +337,11 @@ export class FigureListPage extends FigureListPageBase {
335
337
  type: 'select-buttons',
336
338
  operator: 'in',
337
339
  label: '',
338
- options: [
339
- { display: i18next.t('figure.label.draft'), value: 'draft' },
340
- { display: i18next.t('figure.label.figure-released'), value: 'released' }
341
- ]
340
+ options: () =>
341
+ [
342
+ { display: i18next.t('figure.label.draft'), value: 'draft' },
343
+ { display: i18next.t('figure.label.figure-released'), value: 'released' }
344
+ ].map(option => ({ ...option, count: this.stateCounts.get(option.value) ?? 0 }))
342
345
  }
343
346
  : undefined,
344
347
  width: 120
@@ -541,6 +544,13 @@ export class FigureListPage extends FigureListPageBase {
541
544
  * 받아 화면에 낸다 — 목록이 비어 있는 것과 못 불러온 것은 다른 사실이다.
542
545
  */
543
546
  async fetchHandler({ page = 1, limit = 24, sortings = [], filters = [] }: FetchOption) {
547
+ if (this.scope === 'own')
548
+ fetchFigureStateCounts(filters)
549
+ .then(counts => {
550
+ this.stateCounts = counts
551
+ ;(this.renderRoot.querySelector('ox-filters-form') as any)?.requestUpdate()
552
+ })
553
+ .catch(e => console.error(e))
544
554
  const { items, total } = await fetchFigureList({ page, limit, filters, sortings, scope: this.scope })
545
555
 
546
556
  return { total: total || 0, records: items || [] }
@@ -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"
@@ -7,6 +7,8 @@ import type { Figure, FigureFinding, FigureInspection, FigureListResult, FigureP
7
7
  * filter 로 옮기던 것을 그만두었다 — 그 이름들은 격자(ox-grist)가 이미 컬럼 설정에서 만들어 주고,
8
8
  * 중간에 한 벌 더 두면 격자가 아는 조건과 서버에 가는 조건이 어긋난다.
9
9
  */
10
+ /** How many of the domain's figures each state chip would show (draft, released), under the list's other conditions. */
11
+ export declare function fetchFigureStateCounts(filters: unknown[]): Promise<Map<string, number>>;
10
12
  export declare function fetchFigureList(params: {
11
13
  page?: number;
12
14
  limit?: number;
@@ -59,6 +59,21 @@ const FIGURE_LIST_FIELDS = `
59
59
  * filter 로 옮기던 것을 그만두었다 — 그 이름들은 격자(ox-grist)가 이미 컬럼 설정에서 만들어 주고,
60
60
  * 중간에 한 벌 더 두면 격자가 아는 조건과 서버에 가는 조건이 어긋난다.
61
61
  */
62
+ /** How many of the domain's figures each state chip would show (draft, released), under the list's other conditions. */
63
+ export async function fetchFigureStateCounts(filters) {
64
+ const response = await client.query({
65
+ query: gql `
66
+ query ($filters: [Filter!]) {
67
+ figureStateCounts(filters: $filters) { value count }
68
+ }
69
+ `,
70
+ variables: { filters },
71
+ fetchPolicy: 'no-cache'
72
+ });
73
+ if (response.errors?.length)
74
+ throw new Error(response.errors.map(one => one.message).join(' · '));
75
+ return new Map((response.data?.figureStateCounts ?? []).map((row) => [row.value, row.count]));
76
+ }
62
77
  export async function fetchFigureList(params) {
63
78
  const field = params.scope === 'public' ? 'publicFigures' : 'figures';
64
79
  const response = await client.query({