@things-factory/figure-ui 10.1.79 → 10.1.82

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 (145) hide show
  1. package/client/figure-assistant.ts +2 -2
  2. package/client/graphql/index.ts +99 -10
  3. package/client/graphql/reached.ts +25 -0
  4. package/client/graphql/store.ts +27 -4
  5. package/client/modeller/ai-proposal.ts +74 -9
  6. package/client/modeller/align.ts +2 -3
  7. package/client/modeller/canvas.ts +18 -5
  8. package/client/modeller/command-preview.ts +113 -0
  9. package/client/modeller/editor.ts +826 -54
  10. package/client/modeller/fields.ts +12 -4
  11. package/client/modeller/figure-facts.ts +2 -2
  12. package/client/modeller/figure-first-save.ts +36 -14
  13. package/client/modeller/figure-history-panel.ts +133 -14
  14. package/client/modeller/figure-links.ts +385 -68
  15. package/client/modeller/figure-parts.ts +56 -2
  16. package/client/modeller/figure-preview.ts +19 -1
  17. package/client/modeller/figure-properties.ts +3 -3
  18. package/client/modeller/figure-revert-choice.ts +49 -0
  19. package/client/modeller/first-save.ts +16 -0
  20. package/client/modeller/gizmo.ts +0 -16
  21. package/client/modeller/links.ts +142 -15
  22. package/client/modeller/motion-ends.ts +83 -0
  23. package/client/modeller/occupancy.ts +28 -2
  24. package/client/modeller/product-session.ts +154 -18
  25. package/client/modeller/refusals.ts +15 -0
  26. package/client/modeller/revert-request.ts +26 -0
  27. package/client/modeller/side-panel-styles.ts +12 -0
  28. package/client/modeller/stage-model.ts +25 -3
  29. package/client/pages/figure-modeller-page.ts +68 -29
  30. package/dist-client/figure-assistant.js +2 -2
  31. package/dist-client/figure-assistant.js.map +1 -1
  32. package/dist-client/graphql/index.d.ts +39 -2
  33. package/dist-client/graphql/index.js +63 -9
  34. package/dist-client/graphql/index.js.map +1 -1
  35. package/dist-client/graphql/reached.d.ts +10 -0
  36. package/dist-client/graphql/reached.js +28 -0
  37. package/dist-client/graphql/reached.js.map +1 -0
  38. package/dist-client/graphql/store.js +27 -4
  39. package/dist-client/graphql/store.js.map +1 -1
  40. package/dist-client/modeller/ai-proposal.d.ts +31 -2
  41. package/dist-client/modeller/ai-proposal.js +65 -6
  42. package/dist-client/modeller/ai-proposal.js.map +1 -1
  43. package/dist-client/modeller/align.d.ts +1 -1
  44. package/dist-client/modeller/align.js +0 -2
  45. package/dist-client/modeller/align.js.map +1 -1
  46. package/dist-client/modeller/canvas.d.ts +11 -0
  47. package/dist-client/modeller/canvas.js +33 -4
  48. package/dist-client/modeller/canvas.js.map +1 -1
  49. package/dist-client/modeller/clips.d.ts +1 -1
  50. package/dist-client/modeller/command-preview.d.ts +35 -0
  51. package/dist-client/modeller/command-preview.js +95 -0
  52. package/dist-client/modeller/command-preview.js.map +1 -0
  53. package/dist-client/modeller/editor.d.ts +113 -1
  54. package/dist-client/modeller/editor.js +850 -50
  55. package/dist-client/modeller/editor.js.map +1 -1
  56. package/dist-client/modeller/fields.d.ts +3 -2
  57. package/dist-client/modeller/fields.js +10 -3
  58. package/dist-client/modeller/fields.js.map +1 -1
  59. package/dist-client/modeller/figure-facts.js +2 -2
  60. package/dist-client/modeller/figure-facts.js.map +1 -1
  61. package/dist-client/modeller/figure-first-save.d.ts +3 -4
  62. package/dist-client/modeller/figure-first-save.js +41 -14
  63. package/dist-client/modeller/figure-first-save.js.map +1 -1
  64. package/dist-client/modeller/figure-history-panel.d.ts +31 -0
  65. package/dist-client/modeller/figure-history-panel.js +130 -14
  66. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  67. package/dist-client/modeller/figure-links.d.ts +51 -1
  68. package/dist-client/modeller/figure-links.js +392 -72
  69. package/dist-client/modeller/figure-links.js.map +1 -1
  70. package/dist-client/modeller/figure-parts.d.ts +7 -0
  71. package/dist-client/modeller/figure-parts.js +55 -2
  72. package/dist-client/modeller/figure-parts.js.map +1 -1
  73. package/dist-client/modeller/figure-preview.d.ts +6 -0
  74. package/dist-client/modeller/figure-preview.js +22 -1
  75. package/dist-client/modeller/figure-preview.js.map +1 -1
  76. package/dist-client/modeller/figure-properties.js +3 -3
  77. package/dist-client/modeller/figure-properties.js.map +1 -1
  78. package/dist-client/modeller/figure-revert-choice.d.ts +17 -0
  79. package/dist-client/modeller/figure-revert-choice.js +59 -0
  80. package/dist-client/modeller/figure-revert-choice.js.map +1 -0
  81. package/dist-client/modeller/first-save.d.ts +7 -0
  82. package/dist-client/modeller/first-save.js +17 -0
  83. package/dist-client/modeller/first-save.js.map +1 -1
  84. package/dist-client/modeller/gizmo.d.ts +0 -6
  85. package/dist-client/modeller/gizmo.js +0 -18
  86. package/dist-client/modeller/gizmo.js.map +1 -1
  87. package/dist-client/modeller/links.d.ts +49 -4
  88. package/dist-client/modeller/links.js +141 -15
  89. package/dist-client/modeller/links.js.map +1 -1
  90. package/dist-client/modeller/motion-ends.d.ts +12 -0
  91. package/dist-client/modeller/motion-ends.js +65 -0
  92. package/dist-client/modeller/motion-ends.js.map +1 -0
  93. package/dist-client/modeller/occupancy.d.ts +14 -1
  94. package/dist-client/modeller/occupancy.js +14 -1
  95. package/dist-client/modeller/occupancy.js.map +1 -1
  96. package/dist-client/modeller/product-session.d.ts +43 -2
  97. package/dist-client/modeller/product-session.js +146 -12
  98. package/dist-client/modeller/product-session.js.map +1 -1
  99. package/dist-client/modeller/refusals.d.ts +13 -0
  100. package/dist-client/modeller/refusals.js +16 -0
  101. package/dist-client/modeller/refusals.js.map +1 -1
  102. package/dist-client/modeller/revert-request.d.ts +16 -0
  103. package/dist-client/modeller/revert-request.js +11 -0
  104. package/dist-client/modeller/revert-request.js.map +1 -0
  105. package/dist-client/modeller/side-panel-styles.js +12 -0
  106. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  107. package/dist-client/modeller/stage-model.d.ts +18 -2
  108. package/dist-client/modeller/stage-model.js +21 -3
  109. package/dist-client/modeller/stage-model.js.map +1 -1
  110. package/dist-client/pages/figure-modeller-page.d.ts +8 -1
  111. package/dist-client/pages/figure-modeller-page.js +72 -34
  112. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  113. package/dist-client/route.d.ts +1 -1
  114. package/dist-client/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +6 -6
  116. package/test/ai-proposal.test.ts +2 -2
  117. package/test/align.test.ts +50 -5
  118. package/test/browser/acceptance-page.js +187 -0
  119. package/test/browser/build.mjs +5 -1
  120. package/test/browser/revert-page.js +47 -0
  121. package/test/browser/serve-acceptance.mjs +85 -0
  122. package/test/browser/serve-revert.mjs +119 -0
  123. package/test/clip-ai-proposal.test.ts +104 -0
  124. package/test/clip-consent.test.ts +88 -0
  125. package/test/command-preview.test.ts +288 -0
  126. package/test/fields.test.ts +4 -3
  127. package/test/figure-assistant-surface.test.ts +4 -1
  128. package/test/first-minute.test.ts +96 -0
  129. package/test/first-save-suggest.test.ts +42 -0
  130. package/test/gizmo.test.ts +20 -16
  131. package/test/joint-fan.test.ts +2 -1
  132. package/test/layout.test.ts +9 -2
  133. package/test/links.test.ts +7 -4
  134. package/test/proposal-record.test.ts +126 -0
  135. package/test/release-entry.test.ts +28 -0
  136. package/test/release-panel.test.ts +3 -2
  137. package/test/resize-with-placement.test.ts +225 -0
  138. package/test/revert-choice.test.ts +39 -0
  139. package/test/revert-keeping.test.ts +102 -0
  140. package/test/save-landed.test.ts +198 -0
  141. package/translations/en.json +222 -10
  142. package/translations/ja.json +223 -11
  143. package/translations/ko.json +223 -11
  144. package/translations/ms.json +222 -10
  145. package/translations/zh.json +223 -11
@@ -14,7 +14,8 @@ export type Axis3 = 'x' | 'y' | 'z'
14
14
  const AXES: readonly Axis3[] = ['x', 'y', 'z']
15
15
 
16
16
  export type FieldGroup =
17
- | { kind: 'vector'; name: 'position' | 'rotation' | 'size'; cells: Record<Axis3, FieldLike> }
17
+ /* A cell is missing where a rule sets that axis instead of a number: a floor part's height position (ADR-0097). */
18
+ | { kind: 'vector'; name: 'position' | 'rotation' | 'size'; cells: Partial<Record<Axis3, FieldLike>> }
18
19
  | { kind: 'single'; name: string; field: FieldLike }
19
20
 
20
21
  /** The name a single value goes by: its id without the part, `dimension.` and corner numbers read as words. */
@@ -24,9 +25,14 @@ function nameOf(rest: string): string {
24
25
 
25
26
  /**
26
27
  * The part's own values (`<part>.…`), or with no part the figure's (`size.x` …). Position, rotation and size come
27
- * first, each only when all three of its axes are there; the rest follow in the order they are declared.
28
+ * first, each as one row when any of its axes is there -- an axis a rule sets shows as held, not as a stray single
29
+ * further down; the rest follow in the order they are declared.
28
30
  */
29
31
  export function groupFields(fields: readonly FieldLike[], part?: string): { vectors: FieldGroup[]; singles: FieldGroup[] } {
32
+ /*
33
+ A part is always placed and turned, so its position and turn rows stand even when a rule sets every axis (a
34
+ fastened part): the screen shows the numbers the rule gives. Its size row stands only when it has a field of its own.
35
+ */
30
36
  const prefix = part ? `${part}.` : ''
31
37
  const own = fields.filter(f => (part ? f.id.startsWith(prefix) : !f.id.includes('.') || /^size\.[xyz]$/.test(f.id)))
32
38
  const byId = new Map(own.map(f => [f.id, f]))
@@ -34,9 +40,11 @@ export function groupFields(fields: readonly FieldLike[], part?: string): { vect
34
40
  const vectors: FieldGroup[] = []
35
41
  for (const name of ['position', 'rotation', 'size'] as const) {
36
42
  const ids = AXES.map(axis => `${prefix}${name}.${axis}`)
37
- if (!ids.every(id => byId.has(id))) continue
43
+ if (!ids.some(id => byId.has(id)) && !(part && name !== 'size')) continue
38
44
  ids.forEach(id => used.add(id))
39
- vectors.push({ kind: 'vector', name, cells: { x: byId.get(ids[0]!)!, y: byId.get(ids[1]!)!, z: byId.get(ids[2]!)! } })
45
+ const cells: Partial<Record<Axis3, FieldLike>> = {}
46
+ AXES.forEach((axis, i) => byId.has(ids[i]!) && (cells[axis] = byId.get(ids[i]!)!))
47
+ vectors.push({ kind: 'vector', name, cells })
40
48
  }
41
49
  const singles: FieldGroup[] = own.filter(f => !used.has(f.id)).map(field => ({ kind: 'single', name: nameOf(field.id.slice(prefix.length)), field }))
42
50
  return { vectors, singles }
@@ -38,14 +38,14 @@ export function figureFigureFacts(source: string): FigureFigureFacts {
38
38
  name: asset.name,
39
39
  parts,
40
40
  declared: !!asset.occupancy,
41
- placement: asset.occupancy?.placement as Placement | undefined,
41
+ placement: asset.placement as Placement | undefined,
42
42
  mounting: found ? { part: found[1], face: found[2] as FigureFace } : undefined,
43
43
  suggested: suggestedMountOf(asset, parts)
44
44
  }
45
45
  }
46
46
 
47
47
  function suggestedMountOf(asset: FigureAsset, parts: string[]): { part: string; face: FigureFace } | undefined {
48
- const placement = (asset.occupancy?.placement ?? 'floor') as Placement
48
+ const placement = (asset.placement ?? 'floor') as Placement
49
49
  if (placement === 'center' || !parts.length) return undefined
50
50
  let boxes
51
51
  try {
@@ -7,12 +7,15 @@ import { CommonPopupStyles, FormFieldStyles } from '@operato/styles'
7
7
  import '@material/web/button/filled-button.js'
8
8
  import '@material/web/button/text-button.js'
9
9
 
10
- import { firstSaveRefusalOf, releasableType, typeOf, type FirstSave } from './first-save.js'
10
+ import { firstSaveRefusalOf, releasableType, suggestTypeOf, typeOf, type FirstSave } from './first-save.js'
11
11
 
12
12
  /**
13
13
  * The first save of a new figure: its type and its name, asked once, in the suite's popup look.
14
14
  * Closes with the answer, or with nothing when cancelled -- then nothing is saved.
15
15
  */
16
+ /** The shape of an identifier the modeller offers (`suggestTypeOf`'s random form). */
17
+ const OFFERED = /^FIG_[0-9A-Z]{6}$/
18
+
16
19
  @customElement('figure-first-save')
17
20
  export class FigureFirstSave extends LitElement {
18
21
  static styles = [
@@ -47,16 +50,41 @@ export class FigureFirstSave extends LitElement {
47
50
  /** Type names already used in this domain. */
48
51
  @property({ attribute: false }) taken: string[] = []
49
52
  @state() private refusal: { key: string; type?: string } | null = null
53
+ /** The identifier offered, while the author has not changed it. */
54
+ @state() private suggested = ''
55
+
56
+ /*
57
+ Asked for a name first, with a valid identifier already there to keep or change (Q-D3). The identifier is not the
58
+ name: renaming does not change it, and a retried first save opens on the one answered before (the page keeps it).
59
+ Only an offered one found taken -- told by the server, or in the list -- is offered again; one the author typed is
60
+ kept as typed, and its refusal said.
61
+ */
62
+ willUpdate(changed: Map<string, unknown>) {
63
+ if (!(changed.has('type') || changed.has('taken'))) return
64
+ const offeredAndTaken = OFFERED.test(this.type) && this.taken.includes(this.type)
65
+ if (!this.type || offeredAndTaken) this.type = this.suggested = suggestTypeOf(this.name, this.taken)
66
+ else if (OFFERED.test(this.type)) this.suggested = this.type
67
+ }
50
68
 
51
69
  render() {
52
70
  return html`
53
71
  <div class="what">${i18next.t('figure.text.save-new-figure-desc')}</div>
54
72
  <div field>
55
- <label for="type">${i18next.t('figure.label.figure-type')}</label>
73
+ <label for="name">${i18next.t('figure.label.name')}</label>
74
+ <input
75
+ id="name"
76
+ autofocus
77
+ .value=${this.name}
78
+ placeholder=${i18next.t('figure.text.figure-name-example')}
79
+ @input=${(e: Event) => (this.name = (e.target as HTMLInputElement).value)}
80
+ @keydown=${this.onKey}
81
+ />
82
+ </div>
83
+ <div field>
84
+ <label for="type">${i18next.t('figure.label.figure-identifier')}</label>
56
85
  <input
57
86
  id="type"
58
87
  type-field
59
- autofocus
60
88
  .value=${this.type}
61
89
  placeholder=${i18next.t('figure.text.figure-type-example')}
62
90
  @input=${(e: Event) => {
@@ -66,17 +94,11 @@ export class FigureFirstSave extends LitElement {
66
94
  }}
67
95
  @keydown=${this.onKey}
68
96
  />
69
- ${this.type && !releasableType(this.type) ? html`<div class="note">${i18next.t('figure.text.type-name-will-not-release')}</div>` : nothing}
70
- </div>
71
- <div field>
72
- <label for="name">${i18next.t('figure.label.name')}</label>
73
- <input
74
- id="name"
75
- .value=${this.name}
76
- placeholder=${i18next.t('figure.text.figure-name-example')}
77
- @input=${(e: Event) => (this.name = (e.target as HTMLInputElement).value)}
78
- @keydown=${this.onKey}
79
- />
97
+ ${this.type && !releasableType(this.type)
98
+ ? html`<div class="note">${i18next.t('figure.text.type-name-will-not-release')}</div>`
99
+ : this.suggested && this.type === this.suggested
100
+ ? html`<div class="note">${i18next.t('figure.text.identifier-suggested')}</div>`
101
+ : nothing}
80
102
  </div>
81
103
  ${this.refusal ? html`<div class="failure" role="alert">${i18next.t(this.refusal.key, { type: this.refusal.type })}</div>` : nothing}
82
104
  <ox-popup-actions>
@@ -1,3 +1,6 @@
1
+ import { openPopup } from '@operato/layout'
2
+ import './figure-revert-choice.js'
3
+ import type { RevertChoice } from './figure-revert-choice.js'
1
4
  import '@material/web/icon/icon.js'
2
5
 
3
6
  import { gateFindingStyles, renderGateFinding } from './gate-findings.js'
@@ -7,7 +10,10 @@ import { customElement, property, state } from 'lit/decorators.js'
7
10
  import { i18next, localize } from '@operato/i18n'
8
11
  import { ScrollbarStyles } from '@operato/styles'
9
12
 
10
- import { fetchFigureVersions, inspectFigure, releaseFigure, revertFigureVersion } from '../graphql/index.js'
13
+ import { fetchFigureVersions, fetchReplacedDrafts, inspectFigure, releaseFigure, revertFigureVersion, undoFigureRevert } from '../graphql/index.js'
14
+ import type { KeptDraft } from '../graphql/index.js'
15
+ import { afterRevertAnswer, revertOutcomeUnknown, revertRequestFor } from './revert-request.js'
16
+ import type { RevertRequest } from './revert-request.js'
11
17
  import type { FigureGateFinding, FigureInspection, FigureVersion } from '../types.js'
12
18
  import { OxPrompt } from '@operato/popup'
13
19
 
@@ -29,6 +35,10 @@ import { OxPrompt } from '@operato/popup'
29
35
  *
30
36
  * 옛 판의 정본·속성·그림이 돌아오고 이름·설명은 그대로다. 되살렸다고 그 판이 다시 발행되지는
31
37
  * 않는다 — 다시 내보내려면 발행을 한 번 더 하고, 그때 번호는 이미 쓴 다음 번호를 받는다.
38
+ *
39
+ * 덮이는 초안은 서버가 같은 트랜잭션에서 보관하고, 「되돌리기 전 초안」에 남는다(ADR-0097 §5.6, 디자이너 2026-09-27).
40
+ * 발행 판과 섞지 않는다. 다시 접속해도 거기서 찾고, 가장 최근 것은 「되돌리기 취소」로 되살린다 — 되돌린 뒤 모델이
41
+ * 바뀌었으면 서버가 거절하고 보관본은 남는다.
32
42
  */
33
43
  @customElement('figure-history-panel')
34
44
  export class FigureHistoryPanel extends localize(i18next)(LitElement) {
@@ -209,6 +219,10 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
209
219
  @property({ type: Number }) figureVersion = 0
210
220
  /** 저장하지 않은 것이 있나 — 있으면 발행을 막는다. */
211
221
  @property({ type: Boolean }) dirty = false
222
+ /** Saves the edits in hand before reverting; `ok` only when the save is known to have happened. */
223
+ @property({ attribute: false }) saveFirst?: () => Promise<{ ok: boolean; reason?: string }>
224
+ /** The source the server is confirmed to hold: a revert goes on only while the server still holds it. */
225
+ @property({ attribute: false }) serverSource?: () => string | undefined
212
226
  /**
213
227
  * 행의 타입. 판정 하나(`type-is-placeholder`)가 이것을 본다 — 자산은 타입을 원본에 적지 않아서, 타입을 바꾸면
214
228
  * 원본도 dirty 도 그대로다. 그래서 이 값이 바뀌면 판정을 다시 묻는다. 안 그러면 타입을 정한 뒤에도 「임시 타입」
@@ -217,6 +231,13 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
217
231
  @property({ type: String }) figureType?: string
218
232
 
219
233
  @state() private versions: FigureVersion[] = []
234
+ /** Drafts reverts kept, newest first -- apart from the released versions. */
235
+ @state() private kept: KeptDraft[] = []
236
+ /**
237
+ * A revert or undo whose answer never came back. Asked again, it goes with the same request id, so the server
238
+ * answers with what it did rather than doing it twice. Any answer -- done or refused -- lets it go.
239
+ */
240
+ private pending?: RevertRequest
220
241
  @state() private busy = false
221
242
  @state() private failure = ''
222
243
  /**
@@ -257,20 +278,29 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
257
278
  private async reload() {
258
279
  if (!this.figureId) {
259
280
  this.versions = []
281
+ this.kept = []
260
282
  return
261
283
  }
262
284
 
263
285
  try {
264
- this.versions = await fetchFigureVersions(this.figureId)
286
+ ;[this.versions, this.kept] = await Promise.all([fetchFigureVersions(this.figureId), fetchReplacedDrafts(this.figureId)])
265
287
  this.failure = ''
266
288
  } catch (e) {
267
289
  this.failure = (e as Error).message
268
290
  }
269
291
  }
270
292
 
293
+ /** The release section in view, focus on it: where the top bar's "release" leads (ADR-0097 §5.6, Q-D2). */
294
+ async showRelease() {
295
+ await this.updateComplete
296
+ const section = this.renderRoot.querySelector<HTMLElement>('section[release]')
297
+ section?.scrollIntoView({ block: 'start', behavior: 'smooth' })
298
+ ;(section?.querySelector<HTMLElement>('input[note]:not([disabled]), button[release]:not([disabled])') ?? section)?.focus({ preventScroll: true })
299
+ }
300
+
271
301
  render() {
272
302
  return html`
273
- <section>
303
+ <section release tabindex="-1">
274
304
  <h3>${i18next.t('figure.label.release')}</h3>
275
305
  ${this.figureId ? this.renderRelease() : html`<p quiet>${i18next.t('figure.text.save-the-figure-before-releasing')}</p>`}
276
306
  </section>
@@ -283,6 +313,15 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
283
313
  </ul>`
284
314
  : html`<p quiet>${i18next.t('figure.text.nothing-released-yet')}</p>`}
285
315
  </section>
316
+
317
+ ${this.kept.length
318
+ ? html`<section kept>
319
+ <h3>${i18next.t('figure.label.kept-drafts')}</h3>
320
+ <ul>
321
+ ${this.kept.map((kept, index) => this.renderKept(kept, index === 0))}
322
+ </ul>
323
+ </section>`
324
+ : nothing}
286
325
  `
287
326
  }
288
327
 
@@ -397,6 +436,31 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
397
436
  `
398
437
  }
399
438
 
439
+ /*
440
+ Only the newest can be taken back: an undo holds only while the figure is what that revert wrote, and a later
441
+ revert or undo has written something else since. The older ones stay listed -- found, not offered.
442
+ */
443
+ private renderKept(kept: KeptDraft, newest: boolean) {
444
+ const what =
445
+ kept.kind === 'undo'
446
+ ? i18next.t('figure.text.kept-before-undo')
447
+ : i18next.t('figure.text.kept-before-revert', { version: String(kept.revertedToVersion ?? '') })
448
+ return html`
449
+ <li>
450
+ <span no>${what}</span>
451
+ <span when>
452
+ ${kept.createdAt ? new Date(kept.createdAt).toLocaleString() : ''} ${kept.creator?.name ? ` · ${kept.creator.name}` : ''}
453
+ ${kept.restoredAt ? ` · ${i18next.t('figure.text.kept-restored')}` : ''}
454
+ </span>
455
+ ${newest && !kept.restoredAt
456
+ ? html`<button revert undo ?disabled=${this.busy} @click=${() => this.undo(kept)}>
457
+ ${i18next.t(kept.kind === 'undo' ? 'figure.button.redo-revert' : 'figure.button.undo-revert')}
458
+ </button>`
459
+ : nothing}
460
+ </li>
461
+ `
462
+ }
463
+
400
464
  private async release() {
401
465
  const input = this.renderRoot.querySelector('input[note]') as HTMLInputElement
402
466
  const comment = input?.value?.trim()
@@ -418,24 +482,71 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
418
482
  }
419
483
  }
420
484
 
485
+ /**
486
+ * Edits not saved yet: saved first, let go, or kept -- asked with the three (designer 2026-09-27, Q-D1). Reverting and
487
+ * taking a revert back both reopen the figure from the server, so edits not saved would go without a word. `true` to
488
+ * go on.
489
+ */
490
+ private async settleUnsaved(purpose: 'revert' | 'undo', version = 0): Promise<boolean> {
491
+ const choice = await openPopup<RevertChoice>(
492
+ html`<figure-revert-choice .version=${version} .purpose=${purpose}></figure-revert-choice>`,
493
+ { backdrop: true, size: 'small', title: i18next.t(purpose === 'undo' ? 'figure.button.undo-revert' : 'figure.button.revert') }
494
+ ).result
495
+ if (!choice) return false
496
+ if (choice === 'save') {
497
+ /* Saved, and known to be: a save that failed or whose outcome is not known goes no further. */
498
+ const saved = await this.saveFirst?.()
499
+ if (!saved?.ok) {
500
+ this.failure = i18next.t('figure.text.revert-held-unsaved', { reason: saved?.reason ?? '' })
501
+ return false
502
+ }
503
+ }
504
+ return true
505
+ }
506
+
421
507
  private async revert(version: number) {
422
- /* 되돌아갈 수 없는 조작은 아니지만(판본은 남는다), 지금 화면의 것이 덮인다 — 이름으로 확인한다. */
423
- const agreed = await OxPrompt.open({
424
- type: 'warning',
425
- title: i18next.t('figure.button.revert'),
426
- text: i18next.t('figure.text.revert-to-version-confirm', { version }),
427
- confirmButton: { text: i18next.t('figure.button.revert') },
428
- cancelButton: { text: i18next.t('figure.button.keep-editing') }
429
- })
430
- if (!agreed) {
508
+ this.failure = ''
509
+ if (this.dirty) {
510
+ if (!(await this.settleUnsaved('revert', version))) return
511
+ } else {
512
+ /* 지금 초안은 서버가 보관하지만, 화면의 것이 그 판으로 바뀐다 — 이름으로 확인한다. */
513
+ const agreed = await OxPrompt.open({
514
+ type: 'warning',
515
+ title: i18next.t('figure.button.revert'),
516
+ text: i18next.t('figure.text.revert-to-version-confirm', { version }),
517
+ confirmButton: { text: i18next.t('figure.button.revert') },
518
+ cancelButton: { text: i18next.t('figure.button.keep-editing') }
519
+ })
520
+ if (!agreed) {
521
+ return
522
+ }
523
+ }
524
+
525
+ /* Read after any save above: what the server is now confirmed to hold. */
526
+ const expected = this.serverSource?.()
527
+ if (expected === undefined) {
528
+ this.failure = i18next.t('figure.text.revert-needs-server-source')
431
529
  return
432
530
  }
531
+ await this.send(`revert:${version}:${expected}`, requestId => revertFigureVersion(this.figureId!, version, requestId, expected))
532
+ }
433
533
 
534
+ private async undo(kept: KeptDraft) {
535
+ this.failure = ''
536
+ if (this.dirty && !(await this.settleUnsaved('undo'))) return
537
+ await this.send(`undo:${kept.id}`, requestId => undoFigureRevert(kept.id, requestId))
538
+ }
539
+
540
+ /** One revert or undo: a new request id, or the one whose answer was lost when the same is asked again. */
541
+ private async send(key: string, request: (requestId: string) => Promise<unknown>) {
542
+ const sent = revertRequestFor(this.pending, key)
543
+ this.pending = sent
434
544
  this.busy = true
435
545
  this.failure = ''
436
546
 
437
547
  try {
438
- const result = await revertFigureVersion(this.figureId!, version)
548
+ const result = await request(sent.requestId)
549
+ this.pending = undefined
439
550
  this.dispatchEvent(new CustomEvent('figure-reverted', { detail: result, bubbles: true, composed: true }))
440
551
  await this.reload()
441
552
  /*
@@ -445,7 +556,15 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
445
556
  */
446
557
  await this.inspect()
447
558
  } catch (e) {
448
- this.failure = (e as Error).message
559
+ const code = (e as { code?: string })?.code
560
+ /* Not known whether it happened: kept, so the next press asks with the same id. Anything the server said: let go. */
561
+ this.pending = afterRevertAnswer(sent, code)
562
+ if (revertOutcomeUnknown(code)) this.failure = i18next.t('figure.text.revert-unconfirmed')
563
+ else {
564
+ /* Refused: the kept list may have changed under us (another session's undo), so it is read again. */
565
+ await this.reload()
566
+ this.failure = (e as Error).message
567
+ }
449
568
  } finally {
450
569
  this.busy = false
451
570
  }