@things-factory/figure-ui 10.1.13 → 10.1.14

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.
@@ -70,6 +70,7 @@ interface SceneBoard {
70
70
  selected?: SceneComponent[]
71
71
  app?: unknown
72
72
  addComponent?: (component: unknown) => void
73
+ removeComponent?: (component: unknown) => void
73
74
  on?: (event: string, handler: (...args: unknown[]) => void) => void
74
75
  /** 판 위의 루트. 선택 알림이 여기서 난다. */
75
76
  root?: {
@@ -82,7 +83,8 @@ interface SceneComponent {
82
83
  state: Record<string, unknown>
83
84
  set: (props: Record<string, unknown> | string, value?: unknown) => void
84
85
  on?: (event: string, handler: (...args: unknown[]) => void) => void
85
- remove?: () => void
86
+ removeSelf?: (completely?: boolean) => void
87
+ dispose?: () => void
86
88
  }
87
89
 
88
90
  /**
@@ -181,6 +183,7 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
181
183
  * 이미 씬 모델이므로, 그러면 값을 하나 고칠 때마다 왕복 변환이 한 번씩 일어난다.
182
184
  * 무손실이긴 하지만 하는 일이 없다 — 보드가 곧 보드이고 부품이 곧 컴포넌트다.
183
185
  */
186
+ @property({ type: String }) figureId?: string
184
187
  @property({ type: Object }) board?: BoardModel
185
188
  /** 보기 설정. 자산이 아니라 작업대다 — 저장되지 않는다(`figure-view.ts`). */
186
189
  @property({ type: Object }) view: ViewSettings = DEFAULT_VIEW
@@ -272,7 +275,7 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
272
275
  willUpdate(changed: Map<string, unknown>) {
273
276
  if (!this.hasUpdated) return
274
277
 
275
- if (changed.has('board') || changed.has('parts')) this.standUp()
278
+ if (changed.has('board') || changed.has('parts') || changed.has('figureId')) this.standUp()
276
279
  if (changed.has('view')) this.applyView()
277
280
  if (changed.has('picked')) this.showPicked()
278
281
  }
@@ -314,7 +317,7 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
314
317
  return
315
318
  }
316
319
 
317
- const signature = `${board.width}x${board.height}x${board.depth}`
320
+ const signature = `${this.figureId ?? ''}:${board.width}x${board.height}x${board.depth}`
318
321
  if (this.scene && this.standing === signature) {
319
322
  this.syncParts()
320
323
  return
@@ -455,7 +458,10 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
455
458
 
456
459
  // 없어진 것을 제거한다
457
460
  for (const [name, component] of standing) {
458
- if (!wanted.has(name)) component.remove?.()
461
+ if (!wanted.has(name)) {
462
+ root.removeComponent?.(component)
463
+ component.dispose?.()
464
+ }
459
465
  }
460
466
 
461
467
  for (const part of this.parts) {
@@ -188,90 +188,9 @@ export class FigureModellerPage extends FigureModellerPageBase {
188
188
  display: none;
189
189
  }
190
190
 
191
- /*
192
- 머리줄 — 이름 · 타입 · 미저장 표시 · 말로 시키는 줄 · 저장을 **한 줄에** 담는다.
193
-
194
- 이름은 페이지 제목에도 나온다(셸이 context.title 로 그린다). 그래도 여기 입력칸을 두는
195
- 이유는 **고치는 자리**가 필요해서다 — 제목은 읽는 것이고 이 칸은 바꾸는 것이다. 두 줄로
196
- 벌리는 대신 한 줄에 모아 캔버스에 높이를 넘긴다.
197
- */
198
- div[header] {
199
- display: flex;
200
- align-items: center;
201
- gap: var(--spacing-medium, 8px);
202
- padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
203
- border-bottom: 1px solid var(--md-sys-color-outline-variant);
204
- }
205
- button[ai-support] {
206
- height: 28px;
207
- padding: 0 12px;
208
- border: 1px solid var(--md-sys-color-outline-variant);
209
- border-radius: 6px;
210
- background: var(--md-sys-color-primary-container);
211
- color: var(--md-sys-color-on-primary-container);
212
- font: inherit;
213
- cursor: pointer;
214
- }
215
- input[name] {
216
- flex: none;
217
- width: 180px;
218
- padding: 5px 9px;
219
- font: var(--input-field-font, inherit);
220
- font-size: 0.82rem;
221
- color: var(--md-sys-color-on-surface);
222
- background-color: var(--md-sys-color-surface-container-lowest);
223
- border: 1px solid var(--md-sys-color-outline-variant);
224
- border-radius: 6px;
225
- }
226
- /* 타입 이름은 만든 뒤 고칠 수 없다 — 그 사실이 화면에서 보여야 한다 */
227
- code[type] {
228
- padding: 4px 8px;
229
- font-family: var(--mono-font, monospace);
230
- font-size: 0.76rem;
231
- color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
232
- background-color: var(--md-sys-color-surface-container);
233
- border-radius: 6px;
234
- }
235
- span[dirty] {
236
- font: var(--label-font, inherit);
237
- font-size: 0.74rem;
238
- color: var(--md-sys-color-tertiary);
239
- }
240
191
  div[spacer] {
241
192
  flex: 1;
242
193
  }
243
- button[save] {
244
- padding: 6px 16px;
245
- font: var(--label-font, inherit);
246
- font-size: 0.76rem;
247
- font-weight: 600;
248
- color: var(--md-sys-color-on-primary, #fff);
249
- background-color: var(--md-sys-color-primary);
250
- border: none;
251
- border-radius: 6px;
252
- cursor: pointer;
253
- }
254
- button[save][disabled] {
255
- opacity: 0.4;
256
- cursor: default;
257
- }
258
-
259
- /*
260
- 저장이 거절된 사유. 단추 **바로 왼쪽**에 둔다 — 누른 자리에서 답을 봐야 한다. 길면 줄이지만
261
- 전문은 title 로 남긴다(서버 문장이 길 수 있다).
262
- */
263
- span[save-failed] {
264
- max-width: 280px;
265
- overflow: hidden;
266
- text-overflow: ellipsis;
267
- white-space: nowrap;
268
- padding: 4px 8px;
269
- font: var(--label-font, inherit);
270
- font-size: 0.72rem;
271
- color: var(--md-sys-color-on-error-container, var(--md-sys-color-on-surface));
272
- background-color: var(--md-sys-color-error-container);
273
- border-radius: 6px;
274
- }
275
194
 
276
195
  /*
277
196
  칸을 벡터 저작도구의 관례대로 놓는다 — 왼쪽에 무엇이 있나(레이어), 오른쪽에
@@ -294,16 +213,17 @@ export class FigureModellerPage extends FigureModellerPageBase {
294
213
  }
295
214
 
296
215
  /*
297
- 편집 · 미리보기 전환.
216
+ 편집 · 미리보기 전환 및 조작 띠.
298
217
 
299
218
  가운데 칸 위에 얇게 둔다. 무엇을 보고 있는지가 늘 보여야 하고, 두 화면이 같은
300
219
  자리를 쓰므로 어느 쪽인지 모르면 편집이 안 먹는 것처럼 읽힌다.
301
220
  */
302
221
  div[modes] {
303
222
  display: flex;
223
+ align-items: center;
304
224
  flex: none;
305
225
  gap: 2px;
306
- padding: 5px var(--spacing-medium, 8px);
226
+ padding: 4px var(--spacing-medium, 8px);
307
227
  border-bottom: 1px solid var(--md-sys-color-outline-variant);
308
228
  }
309
229
  div[modes] button[mode] {
@@ -324,13 +244,36 @@ export class FigureModellerPage extends FigureModellerPageBase {
324
244
  color: var(--md-sys-color-on-primary-container);
325
245
  background-color: var(--md-sys-color-primary-container);
326
246
  }
327
-
328
- /*
329
- 양쪽 칸을 접는 손잡이. 띠의 양 끝, 접히는 칸 바로 옆이다.
330
-
331
- 접었을 때 남는 것이 이것 하나뿐이므로 **접는 자리와 펴는 자리가 같아야 한다.**
332
- 다른 데 두면 접고 나서 되돌릴 곳을 찾아야 한다.
333
- */
247
+ div[modes] div[separator] {
248
+ width: 1px;
249
+ height: 16px;
250
+ margin: 0 4px;
251
+ background-color: var(--md-sys-color-outline-variant);
252
+ }
253
+ div[modes] button[history] {
254
+ display: flex;
255
+ align-items: center;
256
+ justify-content: center;
257
+ width: 26px;
258
+ height: 26px;
259
+ padding: 0;
260
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
261
+ background: none;
262
+ border: none;
263
+ border-radius: 6px;
264
+ cursor: pointer;
265
+ }
266
+ div[modes] button[history]:hover:not([disabled]) {
267
+ background-color: var(--md-sys-color-surface-container);
268
+ color: var(--md-sys-color-on-surface);
269
+ }
270
+ div[modes] button[history][disabled] {
271
+ opacity: 0.3;
272
+ cursor: default;
273
+ }
274
+ div[modes] button[history] md-icon {
275
+ --md-icon-size: 18px;
276
+ }
334
277
  div[modes] button[collapse] {
335
278
  display: flex;
336
279
  align-items: center;
@@ -348,6 +291,46 @@ export class FigureModellerPage extends FigureModellerPageBase {
348
291
  div[modes] button[collapse] md-icon {
349
292
  --md-icon-size: 18px;
350
293
  }
294
+ div[modes] span[dirty] {
295
+ align-self: center;
296
+ margin-right: 6px;
297
+ font: var(--label-font, inherit);
298
+ font-size: 0.72rem;
299
+ color: var(--md-sys-color-tertiary);
300
+ }
301
+ div[modes] span[save-failed] {
302
+ align-self: center;
303
+ max-width: 240px;
304
+ overflow: hidden;
305
+ text-overflow: ellipsis;
306
+ white-space: nowrap;
307
+ margin-right: 6px;
308
+ padding: 3px 7px;
309
+ font: var(--label-font, inherit);
310
+ font-size: 0.7rem;
311
+ color: var(--md-sys-color-on-error-container, var(--md-sys-color-on-surface));
312
+ background-color: var(--md-sys-color-error-container);
313
+ border-radius: 4px;
314
+ }
315
+ div[modes] button[save] {
316
+ padding: 4px 14px;
317
+ margin-right: 4px;
318
+ font: var(--label-font, inherit);
319
+ font-size: 0.74rem;
320
+ font-weight: 600;
321
+ color: var(--md-sys-color-on-primary, #fff);
322
+ background-color: var(--md-sys-color-primary);
323
+ border: none;
324
+ border-radius: 6px;
325
+ cursor: pointer;
326
+ }
327
+ div[modes] button[save]:hover:not([disabled]) {
328
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
329
+ }
330
+ div[modes] button[save][disabled] {
331
+ opacity: 0.35;
332
+ cursor: default;
333
+ }
351
334
 
352
335
  /*
353
336
  후보가 오면 가운데를 둘로 나눈다.
@@ -580,6 +563,11 @@ export class FigureModellerPage extends FigureModellerPageBase {
580
563
  /** 인라인 요청창과 AI 도크가 함께 쓰는 현재 Figure의 제한된 세션 피드백. */
581
564
  @state() private proposalFeedback: ProposalFeedback[] = []
582
565
 
566
+ @state() private undoStack: { board?: BoardModel; parts: PartModel[]; selected: number }[] = []
567
+ @state() private redoStack: { board?: BoardModel; parts: PartModel[]; selected: number }[] = []
568
+ private lastCoalesceKey?: string
569
+ private lastHistoryTime = 0
570
+
583
571
  /**
584
572
  * 저장 형식으로 변환한 것. 씬 모델이 바뀔 때만 다시 만든다.
585
573
  *
@@ -640,15 +628,34 @@ export class FigureModellerPage extends FigureModellerPageBase {
640
628
  super.connectedCallback()
641
629
  window.addEventListener('figure-ai-context-request', this.announceAiContext)
642
630
  window.addEventListener('figure-ai-review', this.acceptDockProposal)
631
+ window.addEventListener('keydown', this.handleKeyDown)
643
632
  }
644
633
 
645
634
  disconnectedCallback() {
635
+ window.removeEventListener('keydown', this.handleKeyDown)
646
636
  window.removeEventListener('figure-ai-context-request', this.announceAiContext)
647
637
  window.removeEventListener('figure-ai-review', this.acceptDockProposal)
648
638
  window.dispatchEvent(new CustomEvent('figure-ai-context', { detail: undefined }))
649
639
  super.disconnectedCallback()
650
640
  }
651
641
 
642
+ private handleKeyDown = (e: KeyboardEvent) => {
643
+ if (!this.active || this.mode !== 'edit' || this.proposalSession) return
644
+ const target = e.composedPath()[0] as HTMLElement | undefined
645
+ const tag = target?.tagName?.toLowerCase()
646
+ if (tag === 'input' || tag === 'textarea' || target?.isContentEditable) return
647
+
648
+ if ((e.metaKey || e.ctrlKey) && !e.altKey) {
649
+ if (e.key === 'z' && !e.shiftKey) {
650
+ e.preventDefault()
651
+ this.undo()
652
+ } else if ((e.key === 'z' && e.shiftKey) || e.key === 'y') {
653
+ e.preventDefault()
654
+ this.redo()
655
+ }
656
+ }
657
+ }
658
+
652
659
  get context() {
653
660
  return {
654
661
  title: this.figure?.name ?? i18next.t('figure.title.new-figure'),
@@ -670,27 +677,6 @@ export class FigureModellerPage extends FigureModellerPageBase {
670
677
  const source = this.folded
671
678
 
672
679
  return html`
673
- <div header>
674
- <!--
675
- 이름과 타입은 **속성 패널**에 있다(figure-inspector 의 도형 절). 여기 있던 이름 칸은
676
- 하네스 시절의 자리였다 — 앱에서는 셸이 페이지 제목으로 같은 이름을 그리므로 글자가 두 번
677
- 나오고, 캔버스가 주인공인 화면에서 그만큼 높이를 잃었다.
678
- -->
679
- ${this.dirty ? html`<span dirty>${i18next.t('figure.text.unsaved-changes')}</span>` : ''}
680
- <!--
681
- 말로 시키는 줄이 머리줄의 남는 폭을 쓴다. 따로 두면 화면 위가 두 줄이 되고, 그 위 줄은
682
- 이름을 페이지 제목과 겹쳐 적는다.
683
- -->
684
- <button ai-support @click=${() => {
685
- requestFigureAI({ figureId: this.figure?.id, source: this.folded })
686
- openOverlay('figure-ai-dock', { backdrop: false })
687
- }}>AI 지원</button>
688
- ${this.saveFailure ? html`<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
689
- <button save ?disabled=${!this.canSave} @click=${() => this.save()}>
690
- ${this.saving ? i18next.t('figure.text.saving-figure') : i18next.t('figure.button.save')}
691
- </button>
692
- </div>
693
-
694
680
  <div
695
681
  edits
696
682
  ?collapse-left=${this.collapsed.left}
@@ -731,6 +717,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
731
717
  <div lane>
732
718
  <h4>${i18next.t('figure.label.now')}</h4>
733
719
  <figure-canvas
720
+ .figureId=${this.figure?.id || this.figure?.type}
734
721
  .board=${this.board}
735
722
  .parts=${this.parts}
736
723
  .picked=${this.pickedName}
@@ -755,9 +742,11 @@ export class FigureModellerPage extends FigureModellerPageBase {
755
742
  : this.mode === 'preview'
756
743
  ? html`<figure-preview .source=${source}></figure-preview>`
757
744
  : html`<figure-canvas
745
+ .figureId=${this.figure?.id || this.figure?.type}
758
746
  .board=${this.board}
759
747
  .parts=${this.parts}
760
748
  .picked=${this.pickedName}
749
+ .view=${this.view}
761
750
  ></figure-canvas>`}
762
751
  </div>
763
752
 
@@ -811,7 +800,33 @@ export class FigureModellerPage extends FigureModellerPageBase {
811
800
 
812
801
  return html`<div modes>
813
802
  ${this.renderCollapse('left')}${this.proposalSession ? '' : html`${one('edit')}${one('preview')}`}
803
+ ${this.mode === 'edit' && !this.proposalSession
804
+ ? html`
805
+ <div separator></div>
806
+ <button
807
+ history
808
+ ?disabled=${this.undoStack.length === 0}
809
+ title="${i18next.t('figure.button.undo')} (Ctrl+Z)"
810
+ @click=${() => this.undo()}
811
+ >
812
+ <md-icon>undo</md-icon>
813
+ </button>
814
+ <button
815
+ history
816
+ ?disabled=${this.redoStack.length === 0}
817
+ title="${i18next.t('figure.button.redo')} (Ctrl+Y)"
818
+ @click=${() => this.redo()}
819
+ >
820
+ <md-icon>redo</md-icon>
821
+ </button>
822
+ `
823
+ : ''}
814
824
  <div spacer></div>
825
+ ${this.saveFailure ? html`<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
826
+ ${this.dirty ? html`<span dirty>${i18next.t('figure.text.unsaved-changes')}</span>` : ''}
827
+ <button save ?disabled=${!this.canSave} @click=${() => this.save()}>
828
+ ${this.saving ? i18next.t('figure.text.saving-figure') : i18next.t('figure.button.save')}
829
+ </button>
815
830
  ${this.renderCollapse('right')}
816
831
  </div>`
817
832
  }
@@ -1013,6 +1028,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
1013
1028
  if (!this.proposalSession) return
1014
1029
  if (this.proposalBaseSource !== undefined && this.proposalBaseSource !== JSON.stringify(this.folded)) return
1015
1030
 
1031
+ this.recordHistory()
1016
1032
  const taken = proposals.applyProposal(this.folded, this.proposalSession.source, this.proposalSession.picked)
1017
1033
  const { board, parts } = fromFigureSource(taken)
1018
1034
 
@@ -1084,7 +1100,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
1084
1100
  const id = lifecycle?.resourceId
1085
1101
 
1086
1102
  if (id) {
1087
- if (id !== this.figure?.id) {
1103
+ if (changes.active || id !== this.figure?.id) {
1088
1104
  await this.load(id)
1089
1105
  }
1090
1106
  return
@@ -1102,6 +1118,16 @@ export class FigureModellerPage extends FigureModellerPageBase {
1102
1118
  들고 있는 것이 **저장된 도형이면**(id 가 있으면) 새로 시작한다. 아직 저장 안 한 새 도형을
1103
1119
  들고 있으면 그대로 둔다 — 페이지를 다시 들렀다고 저작 중인 것을 버리지 않는다.
1104
1120
  */
1121
+ if (!this.figure) {
1122
+ this.startNew()
1123
+ return
1124
+ }
1125
+
1126
+ if (this.figure.id) {
1127
+ this.startNew()
1128
+ return
1129
+ }
1130
+
1105
1131
  if (shouldStartNewFigure(changes, !!this.figure, this.figure?.id)) {
1106
1132
  this.startNew()
1107
1133
  return
@@ -1149,6 +1175,9 @@ export class FigureModellerPage extends FigureModellerPageBase {
1149
1175
  this.selected = -1
1150
1176
  this.dirty = false
1151
1177
  this.violations = []
1178
+ this.undoStack = []
1179
+ this.redoStack = []
1180
+ this.lastCoalesceKey = undefined
1152
1181
  }
1153
1182
 
1154
1183
  private startNew() {
@@ -1168,6 +1197,9 @@ export class FigureModellerPage extends FigureModellerPageBase {
1168
1197
  this.selected = -1
1169
1198
  this.dirty = true
1170
1199
  this.violations = []
1200
+ this.undoStack = []
1201
+ this.redoStack = []
1202
+ this.lastCoalesceKey = undefined
1171
1203
  }
1172
1204
 
1173
1205
  private rename(name: string) {
@@ -1190,13 +1222,62 @@ export class FigureModellerPage extends FigureModellerPageBase {
1190
1222
  this.dirty = true
1191
1223
  }
1192
1224
 
1225
+ private snapshotState(): { board?: BoardModel; parts: PartModel[]; selected: number } {
1226
+ return {
1227
+ board: this.board ? structuredClone(this.board) : undefined,
1228
+ parts: structuredClone(this.parts),
1229
+ selected: this.selected
1230
+ }
1231
+ }
1232
+
1233
+ private recordHistory(coalesceKey?: string) {
1234
+ const now = Date.now()
1235
+ if (coalesceKey && this.lastCoalesceKey === coalesceKey && now - this.lastHistoryTime < 600) {
1236
+ this.lastHistoryTime = now
1237
+ return
1238
+ }
1239
+
1240
+ this.lastCoalesceKey = coalesceKey
1241
+ this.lastHistoryTime = now
1242
+
1243
+ this.undoStack = [...this.undoStack.slice(-49), this.snapshotState()]
1244
+ this.redoStack = []
1245
+ }
1246
+
1247
+ private undo() {
1248
+ if (this.undoStack.length === 0) return
1249
+ const prev = this.undoStack[this.undoStack.length - 1]
1250
+ this.undoStack = this.undoStack.slice(0, -1)
1251
+ this.redoStack = [this.snapshotState(), ...this.redoStack.slice(0, 49)]
1252
+
1253
+ this.board = prev.board ? structuredClone(prev.board) : undefined
1254
+ this.parts = structuredClone(prev.parts)
1255
+ this.selected = this.parts.length ? Math.max(-1, Math.min(prev.selected, this.parts.length - 1)) : -1
1256
+ this.dirty = true
1257
+ this.lastCoalesceKey = undefined
1258
+ }
1259
+
1260
+ private redo() {
1261
+ if (this.redoStack.length === 0) return
1262
+ const next = this.redoStack[0]
1263
+ this.redoStack = this.redoStack.slice(1)
1264
+ this.undoStack = [...this.undoStack.slice(-49), this.snapshotState()]
1265
+
1266
+ this.board = next.board ? structuredClone(next.board) : undefined
1267
+ this.parts = structuredClone(next.parts)
1268
+ this.selected = this.parts.length ? Math.max(-1, Math.min(next.selected, this.parts.length - 1)) : -1
1269
+ this.dirty = true
1270
+ this.lastCoalesceKey = undefined
1271
+ }
1272
+
1193
1273
  /**
1194
1274
  * 부품 목록을 갈아 끼운다.
1195
1275
  *
1196
1276
  * 편집 규칙은 `part-edits` 가 갖는다 — 화면과 테스트가 같은 것을 쓰게 하려고 떼어
1197
1277
  * 놓았다. 여기서는 결과를 담고 선택 인덱스를 정리한다.
1198
1278
  */
1199
- private put(parts: PartModel[], selected = this.selected) {
1279
+ private put(parts: PartModel[], selected = this.selected, coalesceKey?: string) {
1280
+ this.recordHistory(coalesceKey)
1200
1281
  this.parts = parts
1201
1282
  this.selected = Math.min(selected, parts.length - 1)
1202
1283
  this.dirty = true
@@ -1221,7 +1302,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
1221
1302
  }
1222
1303
 
1223
1304
  private updatePart(index: number, part: PartModel) {
1224
- this.put(edits.updatePart(this.parts, index, part))
1305
+ this.put(edits.updatePart(this.parts, index, part), this.selected, `part:${part.name || index}`)
1225
1306
  }
1226
1307
 
1227
1308
  /**
@@ -1231,6 +1312,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
1231
1312
  * 고칠 때마다 부품 목록이 새 배열이 되고, 그리는 쪽이 전부 다시 그린다.
1232
1313
  */
1233
1314
  private updateBoard(board: BoardModel) {
1315
+ this.recordHistory('board')
1234
1316
  this.board = board
1235
1317
  this.dirty = true
1236
1318
  }
@@ -1248,6 +1330,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
1248
1330
  private changeDetailLevel(level: DetailLevel) {
1249
1331
  if (!this.board) return
1250
1332
 
1333
+ this.recordHistory('detailLevel')
1251
1334
  this.board = { ...this.board, detailLevel: level }
1252
1335
  this.dirty = true
1253
1336
  }
@@ -1286,7 +1369,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
1286
1369
  */
1287
1370
  private partChanged(name: string, part: PartModel) {
1288
1371
  const at = this.parts.findIndex(item => item.name === name)
1289
- if (at >= 0) this.put(edits.updatePart(this.parts, at, part))
1372
+ if (at >= 0) this.put(edits.updatePart(this.parts, at, part), this.selected, `drag:${name}`)
1290
1373
  }
1291
1374
 
1292
1375
  /**
@@ -41,6 +41,7 @@ export declare class FigureCanvas extends FigureCanvas_base {
41
41
  * 이미 씬 모델이므로, 그러면 값을 하나 고칠 때마다 왕복 변환이 한 번씩 일어난다.
42
42
  * 무손실이긴 하지만 하는 일이 없다 — 보드가 곧 보드이고 부품이 곧 컴포넌트다.
43
43
  */
44
+ figureId?: string;
44
45
  board?: BoardModel;
45
46
  /** 보기 설정. 자산이 아니라 작업대다 — 저장되지 않는다(`figure-view.ts`). */
46
47
  view: ViewSettings;
@@ -187,7 +187,7 @@ let FigureCanvas = class FigureCanvas extends localize(i18next)(LitElement) {
187
187
  willUpdate(changed) {
188
188
  if (!this.hasUpdated)
189
189
  return;
190
- if (changed.has('board') || changed.has('parts'))
190
+ if (changed.has('board') || changed.has('parts') || changed.has('figureId'))
191
191
  this.standUp();
192
192
  if (changed.has('view'))
193
193
  this.applyView();
@@ -226,7 +226,7 @@ let FigureCanvas = class FigureCanvas extends localize(i18next)(LitElement) {
226
226
  this.cannot = i18next.t('figure.text.canvas-appears-once-a-part-exists');
227
227
  return;
228
228
  }
229
- const signature = `${board.width}x${board.height}x${board.depth}`;
229
+ const signature = `${this.figureId ?? ''}:${board.width}x${board.height}x${board.depth}`;
230
230
  if (this.scene && this.standing === signature) {
231
231
  this.syncParts();
232
232
  return;
@@ -360,8 +360,10 @@ let FigureCanvas = class FigureCanvas extends localize(i18next)(LitElement) {
360
360
  const standing = this.partsOnBoard();
361
361
  // 없어진 것을 제거한다
362
362
  for (const [name, component] of standing) {
363
- if (!wanted.has(name))
364
- component.remove?.();
363
+ if (!wanted.has(name)) {
364
+ root.removeComponent?.(component);
365
+ component.dispose?.();
366
+ }
365
367
  }
366
368
  for (const part of this.parts) {
367
369
  const found = standing.get(part.name);
@@ -443,6 +445,10 @@ let FigureCanvas = class FigureCanvas extends localize(i18next)(LitElement) {
443
445
  this.standing = undefined;
444
446
  }
445
447
  };
448
+ __decorate([
449
+ property({ type: String }),
450
+ __metadata("design:type", String)
451
+ ], FigureCanvas.prototype, "figureId", void 0);
446
452
  __decorate([
447
453
  property({ type: Object }),
448
454
  __metadata("design:type", Object)