@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
@@ -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
  import type { FigureProductStore, FigureProductRecord } from '../modeller/product-session.js'
6
6
 
@@ -57,5 +57,6 @@ export const figureProductStore: FigureProductStore = {
57
57
  const result = response.data?.saveFigure as FigureProductRecord | undefined
58
58
  if (!result?.id || !result.source) throw Error('서버가 저장 결과를 돌려주지 않았습니다.')
59
59
  return result
60
- }
60
+ },
61
+ updateCatalog: (id, patch) => updateFigure(id, patch)
61
62
  }
@@ -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'
@@ -28,6 +28,10 @@ import './figure-fasten-dialog.js'
28
28
  import './figure-controls.js'
29
29
  import './figure-clips.js'
30
30
  import './figure-data-panel.js'
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'
31
35
  import './figure-preview.js'
32
36
  import './figure-settings.js'
33
37
  import { DEFAULT_VIEW, type ViewSettings } from '@things-factory/figure-viewer/dist-client/figure-view.js'
@@ -118,6 +122,11 @@ export class FigureEditor extends LitElement {
118
122
  /** Why the last recipe was refused, in the kernel's words. */
119
123
  @state() private recipeError = ''
120
124
  @property({ type: Number }) revision = 0
125
+ /**
126
+ * The page in front (the shell's `active`). A page left keeps its editor, on the window: a Backspace pressed on
127
+ * another page removed the part picked here, out of sight (2026-11-01 audit). Being on the document is not being seen.
128
+ */
129
+ @property({ type: Boolean }) active = true
121
130
  /** The 「?」 beside a group's title and the sentence it opens. */
122
131
  private readonly help = new HelpToggles(this)
123
132
  /** The cost line the parts list shows, from the maturity report. */
@@ -262,12 +271,18 @@ export class FigureEditor extends LitElement {
262
271
  * The section editor open takes the keys for its own: Esc cancels it, Delete deletes a point, Ctrl+Z undoes a point.
263
272
  * Heard here first, they deselected the part, deleted the part and undid the figure's last step (2026-09-30).
264
273
  */
265
- return !!view && !view.loading && !view.saving && !!view.context && this.isConnected && !this.sectionEditing
274
+ return !!view && !view.loading && !view.saving && !!view.context && this.isConnected && this.active && !this.sectionEditing
266
275
  },
276
+ previewing: () => this.middle === 'preview',
267
277
  scene: () => this.renderRoot.querySelector<FigureCanvas>('figure-canvas'),
268
278
  selected: () => this.selected,
269
279
  act: action => this.keyAction(action)
270
280
  })
281
+ /** 「화면 맞춤」 fits the canvas in front: in the preview it fitted the edit canvas hidden behind it (2026-11-01 audit). */
282
+ private fitShown() {
283
+ if (this.middle === 'preview') this.renderRoot.querySelector<FigurePreview>('figure-preview')?.fitView()
284
+ else this.renderRoot.querySelector<FigureCanvas>('figure-canvas')?.fit()
285
+ }
271
286
  /** The part Ctrl+C took: pasted as the kernel's duplicate. Not kept past the editor, nor written anywhere. */
272
287
  private copied = ''
273
288
  /** A copied part still in the figure is duplicated -- a part of its own, named by figure-model; one gone, nothing. */
@@ -467,12 +482,54 @@ export class FigureEditor extends LitElement {
467
482
  await promise
468
483
  this.error = ''
469
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()
470
487
  /* Not reached: whether it was written is not known, and the edits are still here -- said so, not "not saved". */
471
- 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)
472
489
  } finally {
473
490
  this.changed()
474
491
  }
475
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
+ }
476
533
  private add(kind: FigurePaletteKind) {
477
534
  if (!this.session) return
478
535
  this.run(() => {
@@ -1608,7 +1665,7 @@ export class FigureEditor extends LitElement {
1608
1665
  whatever is picked: the size tab was 「반응형」 with nothing picked and 「배치」 with a part (sweep 2026-10-27 J2).
1609
1666
  */
1610
1667
  const name = i18next.t('figure.label.tab-short-' + tab)
1611
- return html`<button tab ?on=${this.tab === tab} title=${label} aria-label=${name} @click=${() => (this.tab = tab)}>
1668
+ return html`<button tab ?on=${this.tab === tab} aria-pressed=${String(this.tab === tab)} title=${label} aria-label=${name} @click=${() => (this.tab = tab)}>
1612
1669
  <md-icon>${icon}</md-icon><span tab-label aria-hidden="true">${name}</span>
1613
1670
  </button>`
1614
1671
  }
@@ -1647,6 +1704,7 @@ export class FigureEditor extends LitElement {
1647
1704
  .kept=${kept}
1648
1705
  .pointBlocked=${this.pointBlockedOf(drawn)}
1649
1706
  .revision=${this.session?.read().revision ?? 0}
1707
+ ?active=${this.active}
1650
1708
  .faceMapping=${this.faceMapping?.from === 'section-editor' ? this.faceMapping.need : undefined}
1651
1709
  @command=${(e: CustomEvent) => {
1652
1710
  /* Held for a choice of where things go: the editor stays open, the choice shown in it. */
@@ -1806,7 +1864,7 @@ export class FigureEditor extends LitElement {
1806
1864
  .pane=${this.panes}
1807
1865
  @canvas-undo=${() => this.run(() => this.session!.undo())}
1808
1866
  @canvas-redo=${() => this.run(() => this.session!.redo())}
1809
- @canvas-fit=${() => this.renderRoot.querySelector<FigureCanvas>('figure-canvas')?.fit()}
1867
+ @canvas-fit=${() => this.fitShown()}
1810
1868
  >
1811
1869
  ${this.renderGizmoTools(!unavailable && this.middle === 'edit')}
1812
1870
  ${this.renderOverlayButton('view', 'wb_sunny', 'figure.label.tab-environment')}
@@ -137,7 +137,8 @@ export class FigureParts extends localize(i18next)(LitElement) {
137
137
  border-color: var(--md-sys-color-primary);
138
138
  background: var(--md-sys-color-primary-container);
139
139
  }
140
- button[model-root]:focus-visible {
140
+ button[model-root]:focus-visible,
141
+ button[pick]:focus-visible {
141
142
  outline: 2px solid var(--md-sys-color-primary);
142
143
  outline-offset: 1px;
143
144
  }
@@ -405,6 +406,19 @@ export class FigureParts extends localize(i18next)(LitElement) {
405
406
  li[selected] md-icon {
406
407
  color: inherit;
407
408
  }
409
+ button[pick] {
410
+ display: flex;
411
+ flex: 1;
412
+ min-width: 0;
413
+ padding: 0;
414
+ font: inherit;
415
+ color: inherit;
416
+ text-align: start;
417
+ background: none;
418
+ border: none;
419
+ border-radius: 4px;
420
+ cursor: pointer;
421
+ }
408
422
  span[label] {
409
423
  flex: 1;
410
424
  min-width: 0;
@@ -652,6 +666,7 @@ export class FigureParts extends localize(i18next)(LitElement) {
652
666
  private renderRow(part: FigurePartRow, index: number) {
653
667
  const selected = part.id === this.selected
654
668
  const off = !!part.hidden
669
+ /* The row is picked by the keyboard too, by its name's button: the eye was the only stop on a row, and it does not pick (2026-11-01 audit). */
655
670
  return html`
656
671
  <li
657
672
  ?selected=${selected}
@@ -671,11 +686,11 @@ export class FigureParts extends localize(i18next)(LitElement) {
671
686
  <md-icon>${off ? 'visibility_off' : 'visibility'}</md-icon>
672
687
  </button>
673
688
  <md-icon>${KIND_ICON[part.kind] ?? 'deployed_code'}</md-icon>
674
- <span label title=${part.id}
689
+ <button pick aria-pressed=${String(selected)}><span label title=${part.id}
675
690
  >${partLabelOf(part.id)}${part.copies ? html` <small copies>× ${part.copies}</small>` : nothing}${part.invisible ? html`<small>${i18next.t('figure.label.state-hidden')}</small>` : nothing}${part.fastenedTo
676
691
  ? html`<small fastened-to title=${i18next.t('figure.label.fastened-to')}>${part.fastenedTo}</small>`
677
692
  : nothing}</span
678
- >
693
+ ></button>
679
694
  ${selected ? this.renderOps(part.id, index) : nothing}
680
695
  </li>
681
696
  `
@@ -479,7 +479,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
479
479
  }
480
480
 
481
481
  /** The figure as stretched, whole in view: done once the slider is let go, so the ground does not slide under a drag. */
482
- private fitView() {
482
+ fitView() {
483
483
  this.scene?.fit?.('ratio')
484
484
  this.scene?.root?.fitCameraToModel?.()
485
485
  }
@@ -28,8 +28,8 @@ const PLACEMENTS: Placement[] = ['floor', 'ceiling', 'center', 'wall']
28
28
  * Two kinds of change leave this tab, and it does neither itself:
29
29
  * - `command` carries an authoring command (rename, where it stands, taking the mounting face off). The page runs
30
30
  * it through the session, so it is one step of the same undo history as every other edit, and is saved with it.
31
- * - `catalog` carries a change to the row (category, tags). Those are not in the asset; the page writes them
32
- * to the saved row at once, so they need a saved figure.
31
+ * - `catalog` carries a change to the row (category, tags). Those are not in the asset; the page holds them and
32
+ * 「저장」 writes them to the saved row with the figure, so they need a saved figure. They are not undone.
33
33
  */
34
34
  @customElement('figure-properties')
35
35
  export class FigureProperties extends localize(i18next)(LitElement) {
@@ -61,6 +61,8 @@ export class FigureProperties extends localize(i18next)(LitElement) {
61
61
  @property({ type: String }) figureCategory?: string
62
62
  /** The categories the server allows (LV figure-server design G5): chosen from, and fixed once set. Null: free text. */
63
63
  @property({ attribute: false }) figureCategories?: string[] | null
64
+ /** The category the server holds: with a vocabulary it is fixed; one chosen and not saved yet may be chosen again. */
65
+ @property({ type: String }) figureCategorySaved?: string
64
66
  @property({ type: Array }) figureTags?: string[]
65
67
  private facts(): FigureFigureFacts | undefined {
66
68
  try {
@@ -122,8 +124,9 @@ export class FigureProperties extends localize(i18next)(LitElement) {
122
124
  private renderName(facts: FigureFigureFacts) {
123
125
  return html`
124
126
  <div field>
125
- <label>${i18next.t('figure.label.name')}</label>
127
+ <label for="figure-name">${i18next.t('figure.label.name')}</label>
126
128
  <input
129
+ id="figure-name"
127
130
  .value=${facts.name}
128
131
  placeholder=${i18next.t('figure.text.figure-display-name')}
129
132
  @change=${(event: Event) => {
@@ -148,8 +151,9 @@ export class FigureProperties extends localize(i18next)(LitElement) {
148
151
  const current = facts.placement ?? 'floor'
149
152
  return html`
150
153
  <div field>
151
- <label>${i18next.t('figure.label.figure-placement')}</label>
154
+ <label for="figure-placement">${i18next.t('figure.label.figure-placement')}</label>
152
155
  <select
156
+ id="figure-placement"
153
157
  .value=${live(current)}
154
158
  @change=${(event: Event) => {
155
159
  const select = event.target as HTMLSelectElement
@@ -213,17 +217,19 @@ export class FigureProperties extends localize(i18next)(LitElement) {
213
217
  const saved = !!this.figureId
214
218
  return html`
215
219
  <div field>
216
- <label>${i18next.t('figure.label.category')}</label>
220
+ <label for="figure-category">${i18next.t('figure.label.category')}</label>
217
221
  ${this.figureCategories?.length
218
222
  ? html`<select
223
+ id="figure-category"
219
224
  category
220
- ?disabled=${!saved || !!this.figureCategory}
225
+ ?disabled=${!saved || !!this.figureCategorySaved}
221
226
  @change=${(e: Event) => this.catalog({ category: (e.target as HTMLSelectElement).value })}
222
227
  >
223
228
  ${this.figureCategory ? nothing : html`<option value="" selected disabled>${i18next.t('figure.text.category-choose')}</option>`}
224
229
  ${this.figureCategories.map(code => html`<option value=${code} ?selected=${code === this.figureCategory}>${code}</option>`)}
225
230
  </select>`
226
231
  : html`<input
232
+ id="figure-category"
227
233
  .value=${this.figureCategory ?? ''}
228
234
  ?disabled=${!saved}
229
235
  placeholder=${i18next.t('figure.text.category-placeholder')}
@@ -231,8 +237,9 @@ export class FigureProperties extends localize(i18next)(LitElement) {
231
237
  />`}
232
238
  </div>
233
239
  <div field>
234
- <label>${i18next.t('figure.label.tags')}</label>
240
+ <label for="figure-tags">${i18next.t('figure.label.tags')}</label>
235
241
  <input
242
+ id="figure-tags"
236
243
  .value=${(this.figureTags ?? []).join(', ')}
237
244
  ?disabled=${!saved}
238
245
  placeholder=${i18next.t('figure.text.tags-placeholder')}
@@ -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
@@ -31,6 +31,11 @@ export interface KeyLike {
31
31
  * Passing a key the scene already took would toggle the gizmo off again.
32
32
  */
33
33
  inScene?: boolean
34
+ /**
35
+ * The preview is in front: it shows the figure, it does not edit it. A key that adds, takes away or moves a part is
36
+ * not taken there -- Backspace removed the part picked in the edit canvas, now hidden (2026-11-01 audit).
37
+ */
38
+ previewing?: boolean
34
39
  }
35
40
 
36
41
  const TYPING = new Set(['INPUT', 'SELECT', 'TEXTAREA'])
@@ -43,12 +48,13 @@ export function keyActionOf(e: KeyLike, selected: string): KeyAction | null {
43
48
  if (mod && key === 'z') return e.shiftKey ? 'redo' : 'undo'
44
49
  if (mod && key === 'y') return 'redo'
45
50
  if (mod && key === 'c') return selected ? 'copy' : 'hold'
46
- if (mod && key === 'v') return e.shiftKey ? 'hold' : 'paste'
51
+ if (mod && key === 'v') return e.shiftKey || e.previewing ? 'hold' : 'paste'
47
52
  if (mod && key === 'x') return 'hold'
48
53
  if (!selected) return null
54
+ if (!mod && key === 'escape') return 'deselect'
55
+ if (e.previewing) return null
49
56
  if (mod && key === 'd') return 'duplicate'
50
57
  if (!mod && (key === 'delete' || key === 'backspace')) return 'remove'
51
- if (!mod && key === 'escape') return 'deselect'
52
58
  if (!mod && !e.shiftKey && !e.inScene && GIZMO_KEYS[key]) return GIZMO_KEYS[key]
53
59
  return null
54
60
  }
@@ -5,6 +5,8 @@ import { keyActionOf, type KeyAction } from './keys.js'
5
5
  export interface ModellerKeysHost {
6
6
  /** Whether the modeller takes keys now: a figure open, not loading or saving, and no section editor open. */
7
7
  taking(): boolean
8
+ /** Whether the preview is in front of the edit canvas: it takes no key that edits parts (`keys.ts`). */
9
+ previewing(): boolean
8
10
  /** The scene, whose keys the modeller's are too. */
9
11
  scene(): Element | null
10
12
  selected(): string
@@ -49,7 +51,8 @@ export class ModellerKeys implements ReactiveController {
49
51
  shiftKey: e.shiftKey,
50
52
  altKey: e.altKey,
51
53
  target: path[0] as HTMLElement,
52
- inScene: !!canvas && path.includes(canvas)
54
+ inScene: !!canvas && path.includes(canvas),
55
+ previewing: this.of.previewing()
53
56
  },
54
57
  this.of.selected()
55
58
  )
@@ -35,6 +35,17 @@ export interface FigureProductRecord {
35
35
  source: string
36
36
  }
37
37
 
38
+ /**
39
+ * The row's catalogue fields -- the type (before release), the category, the tags. Kept on the row, not in the asset,
40
+ * and saved with it by 「저장」: written at once on change, they stayed after 「저장하지 않고 떠나기」 and the save
41
+ * button never lit for them (2026-11-01 audit item 5).
42
+ */
43
+ export interface FigureCatalogPatch {
44
+ type?: string
45
+ category?: string
46
+ tags?: string[]
47
+ }
48
+
38
49
  /** Persistence is injected; neither the model engine nor the renderer owns the product ID. */
39
50
  export interface FigureProductStore {
40
51
  load(id: string): Promise<FigureProductRecord>
@@ -54,8 +65,12 @@ export interface FigureProductStore {
54
65
  /** Keys of the AI proposals whose steps the source holds: the server records them with the save. */
55
66
  proposalKeys?: string[]
56
67
  }): Promise<FigureProductRecord>
68
+ /** Writes the row's catalogue fields of a saved figure. */
69
+ updateCatalog(id: string, patch: FigureCatalogPatch): Promise<unknown>
57
70
  }
58
71
 
72
+ const sameValue = (a: unknown, b: unknown) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null)
73
+
59
74
  /** Product lifecycle boundary. A late response can never change another editor's asset or ID. */
60
75
  export function createFigureProductSession(store: FigureProductStore) {
61
76
  const workspace = createFigureEditorWorkspace()
@@ -73,6 +88,22 @@ export function createFigureProductSession(store: FigureProductStore) {
73
88
  */
74
89
  let done: Array<string | undefined> = [],
75
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
97
+ /* The catalogue fields changed and not saved yet; none of the history -- undo is the figure's steps. */
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 = {}
106
+ const catalogHeld = () => Object.keys(catalog).length > 0
76
107
  /* The author's size ends the last step moved without being asked (ADR-0087 decision 6), told once. */
77
108
  let fitted: FigureSizeRangeFit[] = []
78
109
  function tracked<T>(kind: 'do' | 'undo' | 'redo', run: () => T, tag?: string, setsRange = false): T {
@@ -94,7 +125,8 @@ export function createFigureProductSession(store: FigureProductStore) {
94
125
  conversation is anchored on it, and its first save inserts the row under it, so the name holds across that save.
95
126
  */
96
127
  function read() {
97
- return { ...workspace.read(), id, creationId, loading, saving, error }
128
+ const view = workspace.read()
129
+ return { ...view, dirty: view.dirty || catalogHeld(), catalog: { ...catalog }, catalogWritten: { ...catalogWritten }, conflict: conflict ? { latest: conflict.source } : undefined, id, creationId, loading, saving, error }
98
130
  }
99
131
  /*
100
132
  The version on screen, one object for as long as it is the version: the workspace hands out one asset per version
@@ -146,12 +178,15 @@ export function createFigureProductSession(store: FigureProductStore) {
146
178
  error = undefined
147
179
  saving = false
148
180
  creationId = undefined
181
+ catalog = {}
182
+ catalogWritten = {}
183
+ conflict = undefined
149
184
  done = []
150
185
  undone = []
151
186
  }
152
187
  function canLeave(discard: boolean) {
153
188
  if (saving) throw Error('저장이 끝난 뒤 모델을 전환해 주세요.')
154
- if (workspace.read().dirty && !discard) throw Error('저장하지 않은 변경이 있습니다.')
189
+ if ((workspace.read().dirty || catalogHeld()) && !discard) throw Error('저장하지 않은 변경이 있습니다.')
155
190
  }
156
191
  return {
157
192
  read,
@@ -162,6 +197,52 @@ export function createFigureProductSession(store: FigureProductStore) {
162
197
  * -- the server writes over it only while it still holds this. Nothing for a figure not saved yet.
163
198
  */
164
199
  confirmedSource: () => (id ? baseline : undefined),
200
+ /**
201
+ * A catalogue field changed, held for the next save; one changed back to what the row holds (`saved`) is no
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).
206
+ */
207
+ editCatalog(patch: FigureCatalogPatch, saved: FigureCatalogPatch) {
208
+ if (!id) throw refusal('SESSION_NOT_SAVED', '먼저 저장해 주세요.')
209
+ const next = { ...catalog }
210
+ for (const key of Object.keys(patch) as (keyof FigureCatalogPatch)[]) {
211
+ if (!(catalogSending && key in catalogSending) && sameValue(patch[key], saved[key])) delete next[key]
212
+ else (next as Record<string, unknown>)[key] = patch[key]
213
+ }
214
+ catalog = next
215
+ return read()
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
+ },
165
246
  /** A new figure, named in the screen's language by the page: the session has no words of its own. */
166
247
  startNew({ discard = false, name }: { discard?: boolean; name: string }) {
167
248
  canLeave(discard)
@@ -301,8 +382,30 @@ export function createFigureProductSession(store: FigureProductStore) {
301
382
  const exported = workspace.export(control)
302
383
  /* Read with the text it goes with: an edit while saving is not what this save holds. */
303
384
  const proposalKeys = inEffect()
385
+ const sentCatalog = { ...catalog }
304
386
  saving = true
387
+ catalogSending = sentCatalog
388
+ catalogWritten = {}
305
389
  try {
390
+ /* Only the catalogue changed: the source is not written again -- a released figure would be made a draft for nothing. */
391
+ if (id === undefined || workspace.read().dirty) await saveSource()
392
+ if (requestEpoch !== epoch) return read()
393
+ if (id !== undefined && Object.keys(sentCatalog).length) {
394
+ await store.updateCatalog(id, sentCatalog)
395
+ if (requestEpoch !== epoch) return read()
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
401
+ }
402
+ } finally {
403
+ if (requestEpoch === epoch) saving = false
404
+ catalogSending = undefined
405
+ }
406
+ return read()
407
+
408
+ async function saveSource() {
306
409
  let record: FigureProductRecord
307
410
  try {
308
411
  record = await store.save({ id, source: exported.text, expectedSource: baseline, creationId, thumbnail, proposalKeys, ...(id || !type ? {} : { type }), ...(id || !namespace ? {} : { namespace }) })
@@ -341,6 +444,7 @@ export function createFigureProductSession(store: FigureProductStore) {
341
444
  record = await store.save({ id, source: exported.text, expectedSource: baseline, creationId, thumbnail, proposalKeys, ...(id || !type ? {} : { type }), ...(id || !namespace ? {} : { namespace }) })
342
445
  } else {
343
446
  if (code === 'SAVE_UNREACHABLE') unconfirmed.set(exported.text, { thumbnail })
447
+ if (code === 'FIGURE_SOURCE_CONFLICT' && requestEpoch === epoch) conflict = { source: landed?.source }
344
448
  throw error
345
449
  }
346
450
  }
@@ -352,15 +456,13 @@ export function createFigureProductSession(store: FigureProductStore) {
352
456
  id = record.id
353
457
  baseline = record.source
354
458
  unconfirmed.clear()
459
+ conflict = undefined
355
460
  /*
356
461
  Edited while it was saving: what was sent is saved, the edits since are not -- they stay, and the model stays
357
462
  unsaved (the workspace refuses a stale acknowledgement). Never marked saved, never wiped.
358
463
  */
359
464
  if (workspace.read().revision === exported.revision) workspace.acknowledgeSaved(exported)
360
- } finally {
361
- if (requestEpoch === epoch) saving = false
362
465
  }
363
- return read()
364
466
  }
365
467
  }
366
468
  }