@things-factory/figure-ui 10.1.10 → 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.
Files changed (32) hide show
  1. package/client/modeller/figure-ai-target.ts +11 -0
  2. package/client/modeller/figure-ask.ts +8 -5
  3. package/client/modeller/figure-canvas.ts +10 -4
  4. package/client/modeller/new-figure-entry.ts +4 -0
  5. package/client/pages/figure-modeller-page.ts +241 -120
  6. package/dist-client/modeller/figure-ai-target.d.ts +8 -0
  7. package/dist-client/modeller/figure-ai-target.js +12 -0
  8. package/dist-client/modeller/figure-ai-target.js.map +1 -0
  9. package/dist-client/modeller/figure-ask.d.ts +1 -0
  10. package/dist-client/modeller/figure-ask.js +12 -5
  11. package/dist-client/modeller/figure-ask.js.map +1 -1
  12. package/dist-client/modeller/figure-canvas.d.ts +1 -0
  13. package/dist-client/modeller/figure-canvas.js +10 -4
  14. package/dist-client/modeller/figure-canvas.js.map +1 -1
  15. package/dist-client/modeller/new-figure-entry.d.ts +2 -0
  16. package/dist-client/modeller/new-figure-entry.js +5 -0
  17. package/dist-client/modeller/new-figure-entry.js.map +1 -0
  18. package/dist-client/pages/figure-modeller-page.d.ts +14 -0
  19. package/dist-client/pages/figure-modeller-page.js +240 -120
  20. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  21. package/dist-client/route.d.ts +1 -1
  22. package/dist-client/tsconfig.tsbuildinfo +1 -1
  23. package/package.json +3 -3
  24. package/test/ai-proposal-contract.test.ts +24 -3
  25. package/test/ai-target.test.ts +25 -0
  26. package/test/modeller-undo-redo.test.ts +33 -0
  27. package/test/new-figure-entry.test.ts +15 -0
  28. package/translations/en.json +2 -0
  29. package/translations/ja.json +2 -0
  30. package/translations/ko.json +2 -0
  31. package/translations/ms.json +2 -0
  32. package/translations/zh.json +2 -0
@@ -5,6 +5,7 @@ import '../modeller/figure-ask.js';
5
5
  import '../modeller/figure-canvas.js';
6
6
  import { captureThumbnail } from '../modeller/figure-thumbnail.js';
7
7
  import '../modeller/figure-preview.js';
8
+ import { shouldStartNewFigure } from '../modeller/new-figure-entry.js';
8
9
  import '../modeller/figure-parts.js';
9
10
  import '../modeller/figure-inspector.js';
10
11
  import { css, html } from 'lit';
@@ -129,6 +130,46 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
129
130
  this.saveFailure = '';
130
131
  /** 인라인 요청창과 AI 도크가 함께 쓰는 현재 Figure의 제한된 세션 피드백. */
131
132
  this.proposalFeedback = [];
133
+ this.undoStack = [];
134
+ this.redoStack = [];
135
+ this.lastHistoryTime = 0;
136
+ this.announceAiContext = () => {
137
+ if (!this.active)
138
+ return;
139
+ window.dispatchEvent(new CustomEvent('figure-ai-context', {
140
+ detail: { figureId: this.figure?.id, source: this.folded }
141
+ }));
142
+ };
143
+ this.acceptDockProposal = (event) => {
144
+ const detail = event.detail;
145
+ if (!this.active || (detail?.figureId ?? '') !== (this.figure?.id ?? ''))
146
+ return;
147
+ if (detail?.draftType && detail.draftType !== this.folded?.type)
148
+ return;
149
+ if (detail?.baseSource && detail.baseSource !== JSON.stringify(this.folded))
150
+ return;
151
+ this.receive(detail.proposal);
152
+ detail.accepted = true;
153
+ };
154
+ this.handleKeyDown = (e) => {
155
+ if (!this.active || this.mode !== 'edit' || this.proposalSession)
156
+ return;
157
+ const target = e.composedPath()[0];
158
+ const tag = target?.tagName?.toLowerCase();
159
+ if (tag === 'input' || tag === 'textarea' || target?.isContentEditable)
160
+ return;
161
+ if ((e.metaKey || e.ctrlKey) && !e.altKey) {
162
+ if (e.key === 'z' && !e.shiftKey) {
163
+ e.preventDefault();
164
+ this.undo();
165
+ }
166
+ else if ((e.key === 'z' && e.shiftKey) || e.key === 'y') {
167
+ e.preventDefault();
168
+ this.redo();
169
+ }
170
+ }
171
+ };
172
+ this.loadGeneration = 0;
132
173
  }
133
174
  static { this.styles = css `
134
175
  :host {
@@ -201,84 +242,9 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
201
242
  display: none;
202
243
  }
203
244
 
204
- /*
205
- 머리줄 — 이름 · 타입 · 미저장 표시 · 말로 시키는 줄 · 저장을 **한 줄에** 담는다.
206
-
207
- 이름은 페이지 제목에도 나온다(셸이 context.title 로 그린다). 그래도 여기 입력칸을 두는
208
- 이유는 **고치는 자리**가 필요해서다 — 제목은 읽는 것이고 이 칸은 바꾸는 것이다. 두 줄로
209
- 벌리는 대신 한 줄에 모아 캔버스에 높이를 넘긴다.
210
- */
211
- div[header] {
212
- display: flex;
213
- align-items: center;
214
- gap: var(--spacing-medium, 8px);
215
- padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
216
- border-bottom: 1px solid var(--md-sys-color-outline-variant);
217
- }
218
- div[header] figure-ask {
219
- flex: 1;
220
- min-width: 0;
221
- }
222
- input[name] {
223
- flex: none;
224
- width: 180px;
225
- padding: 5px 9px;
226
- font: var(--input-field-font, inherit);
227
- font-size: 0.82rem;
228
- color: var(--md-sys-color-on-surface);
229
- background-color: var(--md-sys-color-surface-container-lowest);
230
- border: 1px solid var(--md-sys-color-outline-variant);
231
- border-radius: 6px;
232
- }
233
- /* 타입 이름은 만든 뒤 고칠 수 없다 — 그 사실이 화면에서 보여야 한다 */
234
- code[type] {
235
- padding: 4px 8px;
236
- font-family: var(--mono-font, monospace);
237
- font-size: 0.76rem;
238
- color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
239
- background-color: var(--md-sys-color-surface-container);
240
- border-radius: 6px;
241
- }
242
- span[dirty] {
243
- font: var(--label-font, inherit);
244
- font-size: 0.74rem;
245
- color: var(--md-sys-color-tertiary);
246
- }
247
245
  div[spacer] {
248
246
  flex: 1;
249
247
  }
250
- button[save] {
251
- padding: 6px 16px;
252
- font: var(--label-font, inherit);
253
- font-size: 0.76rem;
254
- font-weight: 600;
255
- color: var(--md-sys-color-on-primary, #fff);
256
- background-color: var(--md-sys-color-primary);
257
- border: none;
258
- border-radius: 6px;
259
- cursor: pointer;
260
- }
261
- button[save][disabled] {
262
- opacity: 0.4;
263
- cursor: default;
264
- }
265
-
266
- /*
267
- 저장이 거절된 사유. 단추 **바로 왼쪽**에 둔다 — 누른 자리에서 답을 봐야 한다. 길면 줄이지만
268
- 전문은 title 로 남긴다(서버 문장이 길 수 있다).
269
- */
270
- span[save-failed] {
271
- max-width: 280px;
272
- overflow: hidden;
273
- text-overflow: ellipsis;
274
- white-space: nowrap;
275
- padding: 4px 8px;
276
- font: var(--label-font, inherit);
277
- font-size: 0.72rem;
278
- color: var(--md-sys-color-on-error-container, var(--md-sys-color-on-surface));
279
- background-color: var(--md-sys-color-error-container);
280
- border-radius: 6px;
281
- }
282
248
 
283
249
  /*
284
250
  칸을 벡터 저작도구의 관례대로 놓는다 — 왼쪽에 무엇이 있나(레이어), 오른쪽에
@@ -301,16 +267,17 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
301
267
  }
302
268
 
303
269
  /*
304
- 편집 · 미리보기 전환.
270
+ 편집 · 미리보기 전환 및 조작 띠.
305
271
 
306
272
  가운데 칸 위에 얇게 둔다. 무엇을 보고 있는지가 늘 보여야 하고, 두 화면이 같은
307
273
  자리를 쓰므로 어느 쪽인지 모르면 편집이 안 먹는 것처럼 읽힌다.
308
274
  */
309
275
  div[modes] {
310
276
  display: flex;
277
+ align-items: center;
311
278
  flex: none;
312
279
  gap: 2px;
313
- padding: 5px var(--spacing-medium, 8px);
280
+ padding: 4px var(--spacing-medium, 8px);
314
281
  border-bottom: 1px solid var(--md-sys-color-outline-variant);
315
282
  }
316
283
  div[modes] button[mode] {
@@ -331,13 +298,36 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
331
298
  color: var(--md-sys-color-on-primary-container);
332
299
  background-color: var(--md-sys-color-primary-container);
333
300
  }
334
-
335
- /*
336
- 양쪽 칸을 접는 손잡이. 띠의 양 끝, 접히는 칸 바로 옆이다.
337
-
338
- 접었을 때 남는 것이 이것 하나뿐이므로 **접는 자리와 펴는 자리가 같아야 한다.**
339
- 다른 데 두면 접고 나서 되돌릴 곳을 찾아야 한다.
340
- */
301
+ div[modes] div[separator] {
302
+ width: 1px;
303
+ height: 16px;
304
+ margin: 0 4px;
305
+ background-color: var(--md-sys-color-outline-variant);
306
+ }
307
+ div[modes] button[history] {
308
+ display: flex;
309
+ align-items: center;
310
+ justify-content: center;
311
+ width: 26px;
312
+ height: 26px;
313
+ padding: 0;
314
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
315
+ background: none;
316
+ border: none;
317
+ border-radius: 6px;
318
+ cursor: pointer;
319
+ }
320
+ div[modes] button[history]:hover:not([disabled]) {
321
+ background-color: var(--md-sys-color-surface-container);
322
+ color: var(--md-sys-color-on-surface);
323
+ }
324
+ div[modes] button[history][disabled] {
325
+ opacity: 0.3;
326
+ cursor: default;
327
+ }
328
+ div[modes] button[history] md-icon {
329
+ --md-icon-size: 18px;
330
+ }
341
331
  div[modes] button[collapse] {
342
332
  display: flex;
343
333
  align-items: center;
@@ -355,6 +345,46 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
355
345
  div[modes] button[collapse] md-icon {
356
346
  --md-icon-size: 18px;
357
347
  }
348
+ div[modes] span[dirty] {
349
+ align-self: center;
350
+ margin-right: 6px;
351
+ font: var(--label-font, inherit);
352
+ font-size: 0.72rem;
353
+ color: var(--md-sys-color-tertiary);
354
+ }
355
+ div[modes] span[save-failed] {
356
+ align-self: center;
357
+ max-width: 240px;
358
+ overflow: hidden;
359
+ text-overflow: ellipsis;
360
+ white-space: nowrap;
361
+ margin-right: 6px;
362
+ padding: 3px 7px;
363
+ font: var(--label-font, inherit);
364
+ font-size: 0.7rem;
365
+ color: var(--md-sys-color-on-error-container, var(--md-sys-color-on-surface));
366
+ background-color: var(--md-sys-color-error-container);
367
+ border-radius: 4px;
368
+ }
369
+ div[modes] button[save] {
370
+ padding: 4px 14px;
371
+ margin-right: 4px;
372
+ font: var(--label-font, inherit);
373
+ font-size: 0.74rem;
374
+ font-weight: 600;
375
+ color: var(--md-sys-color-on-primary, #fff);
376
+ background-color: var(--md-sys-color-primary);
377
+ border: none;
378
+ border-radius: 6px;
379
+ cursor: pointer;
380
+ }
381
+ div[modes] button[save]:hover:not([disabled]) {
382
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
383
+ }
384
+ div[modes] button[save][disabled] {
385
+ opacity: 0.35;
386
+ cursor: default;
387
+ }
358
388
 
359
389
  /*
360
390
  후보가 오면 가운데를 둘로 나눈다.
@@ -568,7 +598,16 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
568
598
  }));
569
599
  }
570
600
  }
601
+ connectedCallback() {
602
+ super.connectedCallback();
603
+ window.addEventListener('figure-ai-context-request', this.announceAiContext);
604
+ window.addEventListener('figure-ai-review', this.acceptDockProposal);
605
+ window.addEventListener('keydown', this.handleKeyDown);
606
+ }
571
607
  disconnectedCallback() {
608
+ window.removeEventListener('keydown', this.handleKeyDown);
609
+ window.removeEventListener('figure-ai-context-request', this.announceAiContext);
610
+ window.removeEventListener('figure-ai-review', this.acceptDockProposal);
572
611
  window.dispatchEvent(new CustomEvent('figure-ai-context', { detail: undefined }));
573
612
  super.disconnectedCallback();
574
613
  }
@@ -591,29 +630,6 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
591
630
  render() {
592
631
  const source = this.folded;
593
632
  return html `
594
- <div header>
595
- <!--
596
- 이름과 타입은 **속성 패널**에 있다(figure-inspector 의 도형 절). 여기 있던 이름 칸은
597
- 하네스 시절의 자리였다 — 앱에서는 셸이 페이지 제목으로 같은 이름을 그리므로 글자가 두 번
598
- 나오고, 캔버스가 주인공인 화면에서 그만큼 높이를 잃었다.
599
- -->
600
- ${this.dirty ? html `<span dirty>${i18next.t('figure.text.unsaved-changes')}</span>` : ''}
601
- <!--
602
- 말로 시키는 줄이 머리줄의 남는 폭을 쓴다. 따로 두면 화면 위가 두 줄이 되고, 그 위 줄은
603
- 이름을 페이지 제목과 겹쳐 적는다.
604
- -->
605
- <figure-ask
606
- .source=${source}
607
- .type=${source?.type ?? ''}
608
- .feedback=${this.proposalFeedback}
609
- @proposed=${(e) => this.receive(e.detail.proposal)}
610
- ></figure-ask>
611
- ${this.saveFailure ? html `<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
612
- <button save ?disabled=${!this.canSave} @click=${() => this.save()}>
613
- ${this.saving ? i18next.t('figure.text.saving-figure') : i18next.t('figure.button.save')}
614
- </button>
615
- </div>
616
-
617
633
  <div
618
634
  edits
619
635
  ?collapse-left=${this.collapsed.left}
@@ -654,6 +670,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
654
670
  <div lane>
655
671
  <h4>${i18next.t('figure.label.now')}</h4>
656
672
  <figure-canvas
673
+ .figureId=${this.figure?.id || this.figure?.type}
657
674
  .board=${this.board}
658
675
  .parts=${this.parts}
659
676
  .picked=${this.pickedName}
@@ -678,9 +695,11 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
678
695
  : this.mode === 'preview'
679
696
  ? html `<figure-preview .source=${source}></figure-preview>`
680
697
  : html `<figure-canvas
698
+ .figureId=${this.figure?.id || this.figure?.type}
681
699
  .board=${this.board}
682
700
  .parts=${this.parts}
683
701
  .picked=${this.pickedName}
702
+ .view=${this.view}
684
703
  ></figure-canvas>`}
685
704
  </div>
686
705
 
@@ -732,7 +751,33 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
732
751
  `;
733
752
  return html `<div modes>
734
753
  ${this.renderCollapse('left')}${this.proposalSession ? '' : html `${one('edit')}${one('preview')}`}
754
+ ${this.mode === 'edit' && !this.proposalSession
755
+ ? html `
756
+ <div separator></div>
757
+ <button
758
+ history
759
+ ?disabled=${this.undoStack.length === 0}
760
+ title="${i18next.t('figure.button.undo')} (Ctrl+Z)"
761
+ @click=${() => this.undo()}
762
+ >
763
+ <md-icon>undo</md-icon>
764
+ </button>
765
+ <button
766
+ history
767
+ ?disabled=${this.redoStack.length === 0}
768
+ title="${i18next.t('figure.button.redo')} (Ctrl+Y)"
769
+ @click=${() => this.redo()}
770
+ >
771
+ <md-icon>redo</md-icon>
772
+ </button>
773
+ `
774
+ : ''}
735
775
  <div spacer></div>
776
+ ${this.saveFailure ? html `<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
777
+ ${this.dirty ? html `<span dirty>${i18next.t('figure.text.unsaved-changes')}</span>` : ''}
778
+ <button save ?disabled=${!this.canSave} @click=${() => this.save()}>
779
+ ${this.saving ? i18next.t('figure.text.saving-figure') : i18next.t('figure.button.save')}
780
+ </button>
736
781
  ${this.renderCollapse('right')}
737
782
  </div>`;
738
783
  }
@@ -791,6 +836,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
791
836
  renderDecision() {
792
837
  if (!this.proposalSession)
793
838
  return '';
839
+ const stale = this.proposalBaseSource !== undefined && this.proposalBaseSource !== JSON.stringify(this.folded);
794
840
  const changes = proposals.diffProposal(this.folded, this.proposalSession.source);
795
841
  const picked = proposals.applyProposal(this.folded, this.proposalSession.source, this.proposalSession.picked);
796
842
  const { errors, violations } = validate(picked);
@@ -799,7 +845,8 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
799
845
  <span>${this.proposalSession.title || i18next.t('figure.text.assistant-proposed-a-figure')}</span>
800
846
  <div spacer></div>
801
847
  <button drop @click=${() => this.discard()}>${i18next.t('figure.button.discard')}</button>
802
- <button take ?disabled=${errors.length > 0 || this.proposalSession.picked.size === 0} @click=${() => this.take()}>
848
+ ${stale ? html `<span role="alert">원본이 변경되었습니다. 후보를 취소하고 AI 지원에서 다시 요청하세요.</span>` : ''}
849
+ <button take ?disabled=${stale || errors.length > 0 || this.proposalSession.picked.size === 0} @click=${() => this.take()}>
803
850
  ${i18next.t('figure.button.take-n-changes', { n: this.proposalSession.picked.size })}
804
851
  </button>
805
852
  </div>
@@ -884,6 +931,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
884
931
  * 무엇이 자기 것이고 무엇이 기계 것인지 모르게 된다.
885
932
  */
886
933
  receive(proposal) {
934
+ this.proposalBaseSource = JSON.stringify(this.folded);
887
935
  this.proposalSession = proposalSessions.openAiProposal(this.folded, proposal);
888
936
  }
889
937
  /**
@@ -894,6 +942,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
894
942
  previewSizingFix(fix) {
895
943
  if (!this.folded)
896
944
  return;
945
+ this.proposalBaseSource = JSON.stringify(this.folded);
897
946
  this.proposalSession = proposalSessions.openSizingFix(this.folded, fix);
898
947
  }
899
948
  /**
@@ -917,6 +966,9 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
917
966
  take() {
918
967
  if (!this.proposalSession)
919
968
  return;
969
+ if (this.proposalBaseSource !== undefined && this.proposalBaseSource !== JSON.stringify(this.folded))
970
+ return;
971
+ this.recordHistory();
920
972
  const taken = proposals.applyProposal(this.folded, this.proposalSession.source, this.proposalSession.picked);
921
973
  const { board, parts } = fromFigureSource(taken);
922
974
  this.board = board;
@@ -951,6 +1003,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
951
1003
  if (reportFeedback)
952
1004
  this.reportProposalFeedback('discarded');
953
1005
  this.proposalSession = undefined;
1006
+ this.proposalBaseSource = undefined;
954
1007
  }
955
1008
  /**
956
1009
  * 저장할 수 있나.
@@ -975,11 +1028,12 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
975
1028
  }
976
1029
  async pageUpdated(changes, lifecycle) {
977
1030
  if (!this.active) {
1031
+ ++this.loadGeneration;
978
1032
  return;
979
1033
  }
980
1034
  const id = lifecycle?.resourceId;
981
1035
  if (id) {
982
- if (id !== this.figure?.id) {
1036
+ if (changes.active || id !== this.figure?.id) {
983
1037
  await this.load(id);
984
1038
  }
985
1039
  return;
@@ -1001,19 +1055,21 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1001
1055
  return;
1002
1056
  }
1003
1057
  if (this.figure.id) {
1004
- /*
1005
- 고치던 것이 있으면 묻는다. 저작한 것을 말없이 버리지 않는다 — 되돌릴 수 없다.
1006
- 그만두면 보고 있던 도형의 주소로 되돌려 화면과 주소가 어긋나지 않게 한다.
1007
- */
1008
- if (this.dirty && !confirm(i18next.t('figure.text.discard-changes-and-start-new'))) {
1009
- navigate(`figure-modeller/${this.figure.id}`, true);
1010
- return;
1011
- }
1012
1058
  this.startNew();
1059
+ return;
1060
+ }
1061
+ if (shouldStartNewFigure(changes, !!this.figure, this.figure?.id)) {
1062
+ this.startNew();
1063
+ return;
1013
1064
  }
1014
1065
  }
1015
1066
  async load(id) {
1067
+ const generation = ++this.loadGeneration;
1016
1068
  const figure = await fetchFigure(id);
1069
+ if (generation !== this.loadGeneration || !this.active)
1070
+ return;
1071
+ this.proposalSession = undefined;
1072
+ this.proposalBaseSource = undefined;
1017
1073
  this.figure = figure;
1018
1074
  /*
1019
1075
  그림이 없으면 이 자리에서 채운다(아래 `fillThumbnail`). 기다리지 않는다 — 화면이 먼저다.
@@ -1044,8 +1100,16 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1044
1100
  this.selected = -1;
1045
1101
  this.dirty = false;
1046
1102
  this.violations = [];
1103
+ this.undoStack = [];
1104
+ this.redoStack = [];
1105
+ this.lastCoalesceKey = undefined;
1047
1106
  }
1048
1107
  startNew() {
1108
+ ++this.loadGeneration;
1109
+ this.proposalSession = undefined;
1110
+ this.proposalBaseSource = undefined;
1111
+ this.proposalFeedback = [];
1112
+ this.mode = 'edit';
1049
1113
  // 타입 이름을 사람이 정하기 전까지는 임시 이름을 쓴다. 저장할 때 확정한다.
1050
1114
  const draftType = `FIGURE_${Date.now().toString(36).toUpperCase()}`;
1051
1115
  const { board, parts } = startingModel(draftType);
@@ -1056,6 +1120,9 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1056
1120
  this.selected = -1;
1057
1121
  this.dirty = true;
1058
1122
  this.violations = [];
1123
+ this.undoStack = [];
1124
+ this.redoStack = [];
1125
+ this.lastCoalesceKey = undefined;
1059
1126
  }
1060
1127
  rename(name) {
1061
1128
  if (!this.figure)
@@ -1075,13 +1142,56 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1075
1142
  this.figure = { ...this.figure, ...patch };
1076
1143
  this.dirty = true;
1077
1144
  }
1145
+ snapshotState() {
1146
+ return {
1147
+ board: this.board ? structuredClone(this.board) : undefined,
1148
+ parts: structuredClone(this.parts),
1149
+ selected: this.selected
1150
+ };
1151
+ }
1152
+ recordHistory(coalesceKey) {
1153
+ const now = Date.now();
1154
+ if (coalesceKey && this.lastCoalesceKey === coalesceKey && now - this.lastHistoryTime < 600) {
1155
+ this.lastHistoryTime = now;
1156
+ return;
1157
+ }
1158
+ this.lastCoalesceKey = coalesceKey;
1159
+ this.lastHistoryTime = now;
1160
+ this.undoStack = [...this.undoStack.slice(-49), this.snapshotState()];
1161
+ this.redoStack = [];
1162
+ }
1163
+ undo() {
1164
+ if (this.undoStack.length === 0)
1165
+ return;
1166
+ const prev = this.undoStack[this.undoStack.length - 1];
1167
+ this.undoStack = this.undoStack.slice(0, -1);
1168
+ this.redoStack = [this.snapshotState(), ...this.redoStack.slice(0, 49)];
1169
+ this.board = prev.board ? structuredClone(prev.board) : undefined;
1170
+ this.parts = structuredClone(prev.parts);
1171
+ this.selected = this.parts.length ? Math.max(-1, Math.min(prev.selected, this.parts.length - 1)) : -1;
1172
+ this.dirty = true;
1173
+ this.lastCoalesceKey = undefined;
1174
+ }
1175
+ redo() {
1176
+ if (this.redoStack.length === 0)
1177
+ return;
1178
+ const next = this.redoStack[0];
1179
+ this.redoStack = this.redoStack.slice(1);
1180
+ this.undoStack = [...this.undoStack.slice(-49), this.snapshotState()];
1181
+ this.board = next.board ? structuredClone(next.board) : undefined;
1182
+ this.parts = structuredClone(next.parts);
1183
+ this.selected = this.parts.length ? Math.max(-1, Math.min(next.selected, this.parts.length - 1)) : -1;
1184
+ this.dirty = true;
1185
+ this.lastCoalesceKey = undefined;
1186
+ }
1078
1187
  /**
1079
1188
  * 부품 목록을 갈아 끼운다.
1080
1189
  *
1081
1190
  * 편집 규칙은 `part-edits` 가 갖는다 — 화면과 테스트가 같은 것을 쓰게 하려고 떼어
1082
1191
  * 놓았다. 여기서는 결과를 담고 선택 인덱스를 정리한다.
1083
1192
  */
1084
- put(parts, selected = this.selected) {
1193
+ put(parts, selected = this.selected, coalesceKey) {
1194
+ this.recordHistory(coalesceKey);
1085
1195
  this.parts = parts;
1086
1196
  this.selected = Math.min(selected, parts.length - 1);
1087
1197
  this.dirty = true;
@@ -1102,7 +1212,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1102
1212
  this.put(edits.movePart(this.parts, index, to), to);
1103
1213
  }
1104
1214
  updatePart(index, part) {
1105
- this.put(edits.updatePart(this.parts, index, part));
1215
+ this.put(edits.updatePart(this.parts, index, part), this.selected, `part:${part.name || index}`);
1106
1216
  }
1107
1217
  /**
1108
1218
  * 판 자체의 값을 갈아 끼운다 — 능력처럼 부품보다 위에 있는 것들.
@@ -1111,6 +1221,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1111
1221
  * 고칠 때마다 부품 목록이 새 배열이 되고, 그리는 쪽이 전부 다시 그린다.
1112
1222
  */
1113
1223
  updateBoard(board) {
1224
+ this.recordHistory('board');
1114
1225
  this.board = board;
1115
1226
  this.dirty = true;
1116
1227
  }
@@ -1127,6 +1238,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1127
1238
  changeDetailLevel(level) {
1128
1239
  if (!this.board)
1129
1240
  return;
1241
+ this.recordHistory('detailLevel');
1130
1242
  this.board = { ...this.board, detailLevel: level };
1131
1243
  this.dirty = true;
1132
1244
  }
@@ -1163,7 +1275,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1163
1275
  partChanged(name, part) {
1164
1276
  const at = this.parts.findIndex(item => item.name === name);
1165
1277
  if (at >= 0)
1166
- this.put(edits.updatePart(this.parts, at, part));
1278
+ this.put(edits.updatePart(this.parts, at, part), this.selected, `drag:${name}`);
1167
1279
  }
1168
1280
  /**
1169
1281
  * 정밀 배치 도구.
@@ -1365,6 +1477,14 @@ __decorate([
1365
1477
  state(),
1366
1478
  __metadata("design:type", Array)
1367
1479
  ], FigureModellerPage.prototype, "proposalFeedback", void 0);
1480
+ __decorate([
1481
+ state(),
1482
+ __metadata("design:type", Array)
1483
+ ], FigureModellerPage.prototype, "undoStack", void 0);
1484
+ __decorate([
1485
+ state(),
1486
+ __metadata("design:type", Array)
1487
+ ], FigureModellerPage.prototype, "redoStack", void 0);
1368
1488
  FigureModellerPage = __decorate([
1369
1489
  customElement('figure-modeller-page')
1370
1490
  ], FigureModellerPage);