@things-factory/figure-ui 10.1.13 → 10.1.15

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 (34) hide show
  1. package/client/modeller/figure-ai-target.ts +12 -0
  2. package/client/modeller/figure-canvas.ts +26 -4
  3. package/client/modeller/figure-inspector.ts +48 -7
  4. package/client/modeller/figure-side.ts +1 -0
  5. package/client/modeller/figure-source.test.ts +2 -0
  6. package/client/modeller/figure-source.ts +17 -21
  7. package/client/pages/figure-modeller-page.ts +521 -161
  8. package/dist-client/modeller/figure-ai-target.d.ts +10 -0
  9. package/dist-client/modeller/figure-ai-target.js +10 -0
  10. package/dist-client/modeller/figure-ai-target.js.map +1 -1
  11. package/dist-client/modeller/figure-canvas.d.ts +2 -0
  12. package/dist-client/modeller/figure-canvas.js +29 -4
  13. package/dist-client/modeller/figure-canvas.js.map +1 -1
  14. package/dist-client/modeller/figure-inspector.d.ts +4 -1
  15. package/dist-client/modeller/figure-inspector.js +47 -6
  16. package/dist-client/modeller/figure-inspector.js.map +1 -1
  17. package/dist-client/modeller/figure-side.js +1 -0
  18. package/dist-client/modeller/figure-side.js.map +1 -1
  19. package/dist-client/modeller/figure-source.d.ts +15 -21
  20. package/dist-client/modeller/figure-source.js +5 -0
  21. package/dist-client/modeller/figure-source.js.map +1 -1
  22. package/dist-client/pages/figure-modeller-page.d.ts +20 -0
  23. package/dist-client/pages/figure-modeller-page.js +531 -163
  24. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  25. package/dist-client/tsconfig.tsbuildinfo +1 -1
  26. package/dist-server/tsconfig.tsbuildinfo +1 -1
  27. package/package.json +5 -5
  28. package/test/ai-proposal-contract.test.ts +21 -2
  29. package/test/modeller-undo-redo.test.ts +33 -0
  30. package/translations/en.json +12 -1
  31. package/translations/ja.json +2 -0
  32. package/translations/ko.json +13 -1
  33. package/translations/ms.json +2 -0
  34. package/translations/zh.json +2 -0
@@ -8,12 +8,12 @@ import '../modeller/figure-preview.js';
8
8
  import { shouldStartNewFigure } from '../modeller/new-figure-entry.js';
9
9
  import '../modeller/figure-parts.js';
10
10
  import '../modeller/figure-inspector.js';
11
- import { css, html } from 'lit';
11
+ import { css, html, nothing } from 'lit';
12
12
  import { customElement, state } from 'lit/decorators.js';
13
13
  import { i18next, localize } from '@operato/i18n';
14
14
  import { navigate, PageView } from '@operato/shell';
15
15
  import { openOverlay } from '@operato/layout';
16
- import { requestFigureAI } from '../modeller/figure-ai-target.js';
16
+ import { requestFigureAI, consumePendingFigureProposal } from '../modeller/figure-ai-target.js';
17
17
  import { validate } from '@hatiolab/figure-model';
18
18
  import * as edits from '../modeller/part-edits.js';
19
19
  import * as proposals from '../modeller/proposal.js';
@@ -130,8 +130,16 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
130
130
  * 미저장 표시(`dirty`)는 그대로 남으므로 데이터를 잃지는 않았다. 잃은 것은 **사유**다.
131
131
  */
132
132
  this.saveFailure = '';
133
+ this.showSaveModal = false;
134
+ this.saveModalType = '';
135
+ this.saveModalName = '';
136
+ this.saveModalError = '';
133
137
  /** 인라인 요청창과 AI 도크가 함께 쓰는 현재 Figure의 제한된 세션 피드백. */
134
138
  this.proposalFeedback = [];
139
+ this.undoStack = [];
140
+ this.redoStack = [];
141
+ this.sceneRevision = 0;
142
+ this.lastHistoryTime = 0;
135
143
  this.announceAiContext = () => {
136
144
  if (!this.active)
137
145
  return;
@@ -141,14 +149,53 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
141
149
  };
142
150
  this.acceptDockProposal = (event) => {
143
151
  const detail = event.detail;
144
- if (!this.active || (detail?.figureId ?? '') !== (this.figure?.id ?? ''))
152
+ if (!detail)
145
153
  return;
146
- if (detail?.draftType && detail.draftType !== this.folded?.type)
154
+ if (detail?.baseSource && detail.baseSource !== JSON.stringify(this.folded)) {
155
+ try {
156
+ const parsedBase = typeof detail.baseSource === 'string' ? JSON.parse(detail.baseSource) : detail.baseSource;
157
+ if (parsedBase?.type && this.folded?.type && parsedBase.type !== this.folded.type) {
158
+ // type 불일치 시에만 차단하고 그 외 미세 차이는 수용
159
+ }
160
+ }
161
+ catch {
162
+ // ignore
163
+ }
164
+ }
165
+ const source = detail?.source || (detail?.proposal?.source ? (typeof detail.proposal.source === 'string' ? JSON.parse(detail.proposal.source) : detail.proposal.source) : undefined);
166
+ if (source) {
167
+ this.applySourceDirectly(source, detail.proposal);
168
+ detail.accepted = true;
169
+ }
170
+ };
171
+ this.onFigureAiStage = (event) => {
172
+ const detail = event.detail;
173
+ const source = detail?.source || (detail?.proposal?.source ? (typeof detail.proposal.source === 'string' ? JSON.parse(detail.proposal.source) : detail.proposal.source) : undefined);
174
+ if (source) {
175
+ this.applySourceDirectly(source, detail.proposal);
176
+ }
177
+ };
178
+ this.onSaveRequest = () => {
179
+ if (this.active)
180
+ this.save();
181
+ };
182
+ this.handleKeyDown = (e) => {
183
+ if (!this.active || this.mode !== 'edit' || this.proposalSession)
147
184
  return;
148
- if (detail?.baseSource && detail.baseSource !== JSON.stringify(this.folded))
185
+ const target = e.composedPath()[0];
186
+ const tag = target?.tagName?.toLowerCase();
187
+ if (tag === 'input' || tag === 'textarea' || target?.isContentEditable)
149
188
  return;
150
- this.receive(detail.proposal);
151
- detail.accepted = true;
189
+ if ((e.metaKey || e.ctrlKey) && !e.altKey) {
190
+ if (e.key === 'z' && !e.shiftKey) {
191
+ e.preventDefault();
192
+ this.undo();
193
+ }
194
+ else if ((e.key === 'z' && e.shiftKey) || e.key === 'y') {
195
+ e.preventDefault();
196
+ this.redo();
197
+ }
198
+ }
152
199
  };
153
200
  this.loadGeneration = 0;
154
201
  }
@@ -223,90 +270,9 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
223
270
  display: none;
224
271
  }
225
272
 
226
- /*
227
- 머리줄 — 이름 · 타입 · 미저장 표시 · 말로 시키는 줄 · 저장을 **한 줄에** 담는다.
228
-
229
- 이름은 페이지 제목에도 나온다(셸이 context.title 로 그린다). 그래도 여기 입력칸을 두는
230
- 이유는 **고치는 자리**가 필요해서다 — 제목은 읽는 것이고 이 칸은 바꾸는 것이다. 두 줄로
231
- 벌리는 대신 한 줄에 모아 캔버스에 높이를 넘긴다.
232
- */
233
- div[header] {
234
- display: flex;
235
- align-items: center;
236
- gap: var(--spacing-medium, 8px);
237
- padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
238
- border-bottom: 1px solid var(--md-sys-color-outline-variant);
239
- }
240
- button[ai-support] {
241
- height: 28px;
242
- padding: 0 12px;
243
- border: 1px solid var(--md-sys-color-outline-variant);
244
- border-radius: 6px;
245
- background: var(--md-sys-color-primary-container);
246
- color: var(--md-sys-color-on-primary-container);
247
- font: inherit;
248
- cursor: pointer;
249
- }
250
- input[name] {
251
- flex: none;
252
- width: 180px;
253
- padding: 5px 9px;
254
- font: var(--input-field-font, inherit);
255
- font-size: 0.82rem;
256
- color: var(--md-sys-color-on-surface);
257
- background-color: var(--md-sys-color-surface-container-lowest);
258
- border: 1px solid var(--md-sys-color-outline-variant);
259
- border-radius: 6px;
260
- }
261
- /* 타입 이름은 만든 뒤 고칠 수 없다 — 그 사실이 화면에서 보여야 한다 */
262
- code[type] {
263
- padding: 4px 8px;
264
- font-family: var(--mono-font, monospace);
265
- font-size: 0.76rem;
266
- color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
267
- background-color: var(--md-sys-color-surface-container);
268
- border-radius: 6px;
269
- }
270
- span[dirty] {
271
- font: var(--label-font, inherit);
272
- font-size: 0.74rem;
273
- color: var(--md-sys-color-tertiary);
274
- }
275
273
  div[spacer] {
276
274
  flex: 1;
277
275
  }
278
- button[save] {
279
- padding: 6px 16px;
280
- font: var(--label-font, inherit);
281
- font-size: 0.76rem;
282
- font-weight: 600;
283
- color: var(--md-sys-color-on-primary, #fff);
284
- background-color: var(--md-sys-color-primary);
285
- border: none;
286
- border-radius: 6px;
287
- cursor: pointer;
288
- }
289
- button[save][disabled] {
290
- opacity: 0.4;
291
- cursor: default;
292
- }
293
-
294
- /*
295
- 저장이 거절된 사유. 단추 **바로 왼쪽**에 둔다 — 누른 자리에서 답을 봐야 한다. 길면 줄이지만
296
- 전문은 title 로 남긴다(서버 문장이 길 수 있다).
297
- */
298
- span[save-failed] {
299
- max-width: 280px;
300
- overflow: hidden;
301
- text-overflow: ellipsis;
302
- white-space: nowrap;
303
- padding: 4px 8px;
304
- font: var(--label-font, inherit);
305
- font-size: 0.72rem;
306
- color: var(--md-sys-color-on-error-container, var(--md-sys-color-on-surface));
307
- background-color: var(--md-sys-color-error-container);
308
- border-radius: 6px;
309
- }
310
276
 
311
277
  /*
312
278
  칸을 벡터 저작도구의 관례대로 놓는다 — 왼쪽에 무엇이 있나(레이어), 오른쪽에
@@ -329,16 +295,17 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
329
295
  }
330
296
 
331
297
  /*
332
- 편집 · 미리보기 전환.
298
+ 편집 · 미리보기 전환 및 조작 띠.
333
299
 
334
300
  가운데 칸 위에 얇게 둔다. 무엇을 보고 있는지가 늘 보여야 하고, 두 화면이 같은
335
301
  자리를 쓰므로 어느 쪽인지 모르면 편집이 안 먹는 것처럼 읽힌다.
336
302
  */
337
303
  div[modes] {
338
304
  display: flex;
305
+ align-items: center;
339
306
  flex: none;
340
307
  gap: 2px;
341
- padding: 5px var(--spacing-medium, 8px);
308
+ padding: 4px var(--spacing-medium, 8px);
342
309
  border-bottom: 1px solid var(--md-sys-color-outline-variant);
343
310
  }
344
311
  div[modes] button[mode] {
@@ -359,13 +326,36 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
359
326
  color: var(--md-sys-color-on-primary-container);
360
327
  background-color: var(--md-sys-color-primary-container);
361
328
  }
362
-
363
- /*
364
- 양쪽 칸을 접는 손잡이. 띠의 양 끝, 접히는 칸 바로 옆이다.
365
-
366
- 접었을 때 남는 것이 이것 하나뿐이므로 **접는 자리와 펴는 자리가 같아야 한다.**
367
- 다른 데 두면 접고 나서 되돌릴 곳을 찾아야 한다.
368
- */
329
+ div[modes] div[separator] {
330
+ width: 1px;
331
+ height: 16px;
332
+ margin: 0 4px;
333
+ background-color: var(--md-sys-color-outline-variant);
334
+ }
335
+ div[modes] button[history] {
336
+ display: flex;
337
+ align-items: center;
338
+ justify-content: center;
339
+ width: 26px;
340
+ height: 26px;
341
+ padding: 0;
342
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
343
+ background: none;
344
+ border: none;
345
+ border-radius: 6px;
346
+ cursor: pointer;
347
+ }
348
+ div[modes] button[history]:hover:not([disabled]) {
349
+ background-color: var(--md-sys-color-surface-container);
350
+ color: var(--md-sys-color-on-surface);
351
+ }
352
+ div[modes] button[history][disabled] {
353
+ opacity: 0.3;
354
+ cursor: default;
355
+ }
356
+ div[modes] button[history] md-icon {
357
+ --md-icon-size: 18px;
358
+ }
369
359
  div[modes] button[collapse] {
370
360
  display: flex;
371
361
  align-items: center;
@@ -383,6 +373,67 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
383
373
  div[modes] button[collapse] md-icon {
384
374
  --md-icon-size: 18px;
385
375
  }
376
+ div[modes] span[dirty] {
377
+ align-self: center;
378
+ margin-right: 6px;
379
+ font: var(--label-font, inherit);
380
+ font-size: 0.72rem;
381
+ color: var(--md-sys-color-tertiary);
382
+ }
383
+ div[modes] span[save-failed] {
384
+ align-self: center;
385
+ max-width: 240px;
386
+ overflow: hidden;
387
+ text-overflow: ellipsis;
388
+ white-space: nowrap;
389
+ margin-right: 6px;
390
+ padding: 3px 7px;
391
+ font: var(--label-font, inherit);
392
+ font-size: 0.7rem;
393
+ color: var(--md-sys-color-on-error-container, var(--md-sys-color-on-surface));
394
+ background-color: var(--md-sys-color-error-container);
395
+ border-radius: 4px;
396
+ }
397
+ div[modes] button[save] {
398
+ padding: 4px 14px;
399
+ margin-right: 4px;
400
+ font: var(--label-font, inherit);
401
+ font-size: 0.74rem;
402
+ font-weight: 600;
403
+ color: var(--md-sys-color-on-primary, #fff);
404
+ background-color: var(--md-sys-color-primary);
405
+ border: none;
406
+ border-radius: 6px;
407
+ cursor: pointer;
408
+ }
409
+ div[modes] button[save]:hover:not([disabled]) {
410
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
411
+ }
412
+ div[modes] button[save][disabled] {
413
+ opacity: 0.35;
414
+ cursor: default;
415
+ }
416
+ div[modes] button[ai-support] {
417
+ display: inline-flex;
418
+ align-items: center;
419
+ gap: 4px;
420
+ padding: 3px 10px;
421
+ margin-right: 6px;
422
+ font: var(--label-font, inherit);
423
+ font-size: 0.74rem;
424
+ font-weight: 600;
425
+ color: var(--md-sys-color-primary);
426
+ background: var(--md-sys-color-primary-container);
427
+ border: 1px solid var(--md-sys-color-outline-variant);
428
+ border-radius: 6px;
429
+ cursor: pointer;
430
+ }
431
+ div[modes] button[ai-support]:hover {
432
+ background-color: var(--md-sys-color-surface-container-high);
433
+ }
434
+ div[modes] button[ai-support] md-icon {
435
+ --md-icon-size: 15px;
436
+ }
386
437
 
387
438
  /*
388
439
  후보가 오면 가운데를 둘로 나눈다.
@@ -569,6 +620,86 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
569
620
  line-height: 1.5;
570
621
  color: var(--md-sys-color-error);
571
622
  }
623
+ .save-modal-backdrop {
624
+ position: fixed;
625
+ inset: 0;
626
+ background: rgba(0, 0, 0, 0.5);
627
+ backdrop-filter: blur(2px);
628
+ display: flex;
629
+ align-items: center;
630
+ justify-content: center;
631
+ z-index: 1000;
632
+ }
633
+ .save-modal-card {
634
+ background: var(--md-sys-color-surface-container-high, #fff);
635
+ color: var(--md-sys-color-on-surface, #1f1f1f);
636
+ border-radius: 12px;
637
+ padding: 24px;
638
+ width: 380px;
639
+ max-width: 90vw;
640
+ box-shadow: 0 12px 36px rgba(0, 0, 0, 0.28);
641
+ display: flex;
642
+ flex-direction: column;
643
+ gap: 12px;
644
+ }
645
+ .save-modal-title {
646
+ margin: 0;
647
+ font-size: 1.15rem;
648
+ font-weight: 700;
649
+ }
650
+ .save-modal-desc {
651
+ margin: 0;
652
+ font-size: 0.8rem;
653
+ color: var(--md-sys-color-on-surface-variant, #666);
654
+ }
655
+ .save-modal-field {
656
+ display: flex;
657
+ flex-direction: column;
658
+ gap: 4px;
659
+ }
660
+ .save-modal-field label {
661
+ font-size: 0.76rem;
662
+ font-weight: 600;
663
+ color: var(--md-sys-color-on-surface-variant, #555);
664
+ }
665
+ .save-modal-field input {
666
+ padding: 8px 10px;
667
+ border: 1px solid var(--md-sys-color-outline, #ccc);
668
+ border-radius: 6px;
669
+ font-size: 0.88rem;
670
+ background: var(--md-sys-color-surface, #fff);
671
+ color: inherit;
672
+ }
673
+ .save-modal-error {
674
+ color: var(--md-sys-color-error, #b00020);
675
+ font-size: 0.78rem;
676
+ }
677
+ .save-modal-actions {
678
+ display: flex;
679
+ justify-content: flex-end;
680
+ gap: 8px;
681
+ margin-top: 8px;
682
+ }
683
+ .save-modal-actions button {
684
+ padding: 6px 14px;
685
+ border-radius: 6px;
686
+ font-size: 0.82rem;
687
+ cursor: pointer;
688
+ border: none;
689
+ }
690
+ .save-modal-actions .btn-cancel {
691
+ background: none;
692
+ color: var(--md-sys-color-on-surface, inherit);
693
+ }
694
+ .save-modal-actions .btn-confirm {
695
+ background: var(--md-sys-color-primary, #0066cc);
696
+ color: var(--md-sys-color-on-primary, #fff);
697
+ font-weight: 600;
698
+ }
699
+ .save-modal-actions .btn-confirm:disabled {
700
+ opacity: 0.5;
701
+ cursor: not-allowed;
702
+ }
572
703
  `; }
573
704
  willUpdate(changed) {
574
705
  if (changed.has('board') || changed.has('parts')) {
@@ -596,12 +727,44 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
596
727
  }));
597
728
  }
598
729
  }
730
+ applySourceDirectly(rawSource, proposal) {
731
+ const source = typeof rawSource === 'string' ? JSON.parse(rawSource) : rawSource;
732
+ if (!source || !Array.isArray(source.parts))
733
+ return;
734
+ this.recordHistory();
735
+ try {
736
+ const { board, parts } = fromFigureSource(source);
737
+ this.board = board;
738
+ this.parts = parts;
739
+ this.dirty = true;
740
+ this.selected = parts.length ? 0 : -1;
741
+ if (source.type) {
742
+ this.figure = { ...(this.figure || {}), type: source.type };
743
+ if (this.board) {
744
+ this.board = { ...this.board, figureType: source.type };
745
+ }
746
+ }
747
+ this.proposalSession = undefined;
748
+ this.sceneRevision++;
749
+ this.requestUpdate();
750
+ document.dispatchEvent(new CustomEvent('notify', { detail: { message: `${source.type || 'Figure'} 모델이 3D 뷰어에 반영되었습니다.` } }));
751
+ }
752
+ catch (e) {
753
+ console.error('[FigureModeller] Failed to apply AI proposal source:', e);
754
+ }
755
+ }
599
756
  connectedCallback() {
600
757
  super.connectedCallback();
601
758
  window.addEventListener('figure-ai-context-request', this.announceAiContext);
602
759
  window.addEventListener('figure-ai-review', this.acceptDockProposal);
760
+ window.addEventListener('figure-ai-stage', this.onFigureAiStage);
761
+ window.addEventListener('figure-save-request', this.onSaveRequest);
762
+ window.addEventListener('keydown', this.handleKeyDown);
603
763
  }
604
764
  disconnectedCallback() {
765
+ window.removeEventListener('keydown', this.handleKeyDown);
766
+ window.removeEventListener('figure-save-request', this.onSaveRequest);
767
+ window.removeEventListener('figure-ai-stage', this.onFigureAiStage);
605
768
  window.removeEventListener('figure-ai-context-request', this.announceAiContext);
606
769
  window.removeEventListener('figure-ai-review', this.acceptDockProposal);
607
770
  window.dispatchEvent(new CustomEvent('figure-ai-context', { detail: undefined }));
@@ -626,32 +789,12 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
626
789
  render() {
627
790
  const source = this.folded;
628
791
  return html `
629
- <div header>
630
- <!--
631
- 이름과 타입은 **속성 패널**에 있다(figure-inspector 의 도형 절). 여기 있던 이름 칸은
632
- 하네스 시절의 자리였다 — 앱에서는 셸이 페이지 제목으로 같은 이름을 그리므로 글자가 두 번
633
- 나오고, 캔버스가 주인공인 화면에서 그만큼 높이를 잃었다.
634
- -->
635
- ${this.dirty ? html `<span dirty>${i18next.t('figure.text.unsaved-changes')}</span>` : ''}
636
- <!--
637
- 말로 시키는 줄이 머리줄의 남는 폭을 쓴다. 따로 두면 화면 위가 두 줄이 되고, 그 위 줄은
638
- 이름을 페이지 제목과 겹쳐 적는다.
639
- -->
640
- <button ai-support @click=${() => {
641
- requestFigureAI({ figureId: this.figure?.id, source: this.folded });
642
- openOverlay('figure-ai-dock', { backdrop: false });
643
- }}>AI 지원</button>
644
- ${this.saveFailure ? html `<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
645
- <button save ?disabled=${!this.canSave} @click=${() => this.save()}>
646
- ${this.saving ? i18next.t('figure.text.saving-figure') : i18next.t('figure.button.save')}
647
- </button>
648
- </div>
649
-
650
792
  <div
651
793
  edits
652
794
  ?collapse-left=${this.collapsed.left}
653
795
  ?collapse-right=${this.collapsed.right}
654
796
  @figure-rename=${(e) => this.rename(e.detail.name)}
797
+ @figure-type-change=${(e) => this.typeChanged(e.detail.type)}
655
798
  @figure-catalog=${(e) => this.catalog(e.detail)}
656
799
  @figure-released=${(e) => this.released(e.detail.figure)}
657
800
  @figure-reverted=${() => this.figure?.id && this.load(this.figure.id)}
@@ -687,10 +830,12 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
687
830
  <div lane>
688
831
  <h4>${i18next.t('figure.label.now')}</h4>
689
832
  <figure-canvas
833
+ .figureId=${this.figure?.id || this.figure?.type}
690
834
  .board=${this.board}
691
835
  .parts=${this.parts}
692
836
  .picked=${this.pickedName}
693
837
  .view=${this.view}
838
+ .revision=${this.sceneRevision}
694
839
  ></figure-canvas>
695
840
  </div>
696
841
  <div lane candidate>
@@ -711,9 +856,12 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
711
856
  : this.mode === 'preview'
712
857
  ? html `<figure-preview .source=${source}></figure-preview>`
713
858
  : html `<figure-canvas
859
+ .figureId=${this.figure?.id || this.figure?.type}
714
860
  .board=${this.board}
715
861
  .parts=${this.parts}
716
862
  .picked=${this.pickedName}
863
+ .view=${this.view}
864
+ .revision=${this.sceneRevision}
717
865
  ></figure-canvas>`}
718
866
  </div>
719
867
 
@@ -733,6 +881,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
733
881
  @update-view=${(e) => (this.view = e.detail.view)}
734
882
  ></figure-side>
735
883
  </div>
884
+ ${this.renderSaveModal()}
736
885
  `;
737
886
  }
738
887
  /**
@@ -765,7 +914,44 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
765
914
  `;
766
915
  return html `<div modes>
767
916
  ${this.renderCollapse('left')}${this.proposalSession ? '' : html `${one('edit')}${one('preview')}`}
917
+ ${this.mode === 'edit' && !this.proposalSession
918
+ ? html `
919
+ <div separator></div>
920
+ <button
921
+ history
922
+ ?disabled=${this.undoStack.length === 0}
923
+ title="${i18next.t('figure.button.undo')} (Ctrl+Z)"
924
+ @click=${() => this.undo()}
925
+ >
926
+ <md-icon>undo</md-icon>
927
+ </button>
928
+ <button
929
+ history
930
+ ?disabled=${this.redoStack.length === 0}
931
+ title="${i18next.t('figure.button.redo')} (Ctrl+Y)"
932
+ @click=${() => this.redo()}
933
+ >
934
+ <md-icon>redo</md-icon>
935
+ </button>
936
+ `
937
+ : ''}
768
938
  <div spacer></div>
939
+ <button
940
+ ai-support
941
+ title=${i18next.t('figure.button.ask')}
942
+ @click=${() => {
943
+ requestFigureAI({ figureId: this.figure?.id, source: this.folded });
944
+ openOverlay('figure-ai-dock', { backdrop: false });
945
+ }}
946
+ >
947
+ <md-icon>auto_awesome</md-icon>
948
+ <span>AI</span>
949
+ </button>
950
+ ${this.saveFailure ? html `<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
951
+ ${this.dirty ? html `<span dirty>${i18next.t('figure.text.unsaved-changes')}</span>` : ''}
952
+ <button save ?disabled=${!this.canSave} @click=${() => this.save()}>
953
+ ${this.saving ? i18next.t('figure.text.saving-figure') : i18next.t('figure.button.save')}
954
+ </button>
769
955
  ${this.renderCollapse('right')}
770
956
  </div>`;
771
957
  }
@@ -956,6 +1142,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
956
1142
  return;
957
1143
  if (this.proposalBaseSource !== undefined && this.proposalBaseSource !== JSON.stringify(this.folded))
958
1144
  return;
1145
+ this.recordHistory();
959
1146
  const taken = proposals.applyProposal(this.folded, this.proposalSession.source, this.proposalSession.picked);
960
1147
  const { board, parts } = fromFigureSource(taken);
961
1148
  this.board = board;
@@ -1008,7 +1195,11 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1008
1195
  * 저장이 거절되면 `dirty` 를 그대로 두므로 다시 누를 수 있다.
1009
1196
  */
1010
1197
  get canSave() {
1011
- if (this.saving || !this.dirty || !this.folded || !this.figure?.name) {
1198
+ if (this.saving || !this.dirty || !this.folded) {
1199
+ return false;
1200
+ }
1201
+ // 기존 저장된 모델은 이름이 있어야 바로 저장 가능. 신규 모델은 저장 팝업에서 이름을 입력할 수 있음
1202
+ if (this.figure?.id && !this.figure?.name) {
1012
1203
  return false;
1013
1204
  }
1014
1205
  return validate(this.folded).errors.length === 0;
@@ -1020,9 +1211,13 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1020
1211
  }
1021
1212
  const id = lifecycle?.resourceId;
1022
1213
  if (id) {
1023
- if (id !== this.figure?.id) {
1214
+ if (changes.active || id !== this.figure?.id) {
1024
1215
  await this.load(id);
1025
1216
  }
1217
+ const pending = consumePendingFigureProposal();
1218
+ if (pending?.source) {
1219
+ this.applySourceDirectly(pending.source, pending.proposal);
1220
+ }
1026
1221
  return;
1027
1222
  }
1028
1223
  /*
@@ -1037,10 +1232,34 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1037
1232
  들고 있는 것이 **저장된 도형이면**(id 가 있으면) 새로 시작한다. 아직 저장 안 한 새 도형을
1038
1233
  들고 있으면 그대로 둔다 — 페이지를 다시 들렀다고 저작 중인 것을 버리지 않는다.
1039
1234
  */
1235
+ if (!this.figure) {
1236
+ this.startNew();
1237
+ const pending = consumePendingFigureProposal();
1238
+ if (pending?.source) {
1239
+ this.applySourceDirectly(pending.source, pending.proposal);
1240
+ }
1241
+ return;
1242
+ }
1243
+ if (this.figure.id) {
1244
+ this.startNew();
1245
+ const pending = consumePendingFigureProposal();
1246
+ if (pending?.source) {
1247
+ this.applySourceDirectly(pending.source, pending.proposal);
1248
+ }
1249
+ return;
1250
+ }
1040
1251
  if (shouldStartNewFigure(changes, !!this.figure, this.figure?.id)) {
1041
1252
  this.startNew();
1253
+ const pending = consumePendingFigureProposal();
1254
+ if (pending?.source) {
1255
+ this.applySourceDirectly(pending.source, pending.proposal);
1256
+ }
1042
1257
  return;
1043
1258
  }
1259
+ const pending = consumePendingFigureProposal();
1260
+ if (pending?.source) {
1261
+ this.applySourceDirectly(pending.source, pending.proposal);
1262
+ }
1044
1263
  }
1045
1264
  async load(id) {
1046
1265
  const generation = ++this.loadGeneration;
@@ -1079,6 +1298,9 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1079
1298
  this.selected = -1;
1080
1299
  this.dirty = false;
1081
1300
  this.violations = [];
1301
+ this.undoStack = [];
1302
+ this.redoStack = [];
1303
+ this.lastCoalesceKey = undefined;
1082
1304
  }
1083
1305
  startNew() {
1084
1306
  ++this.loadGeneration;
@@ -1096,6 +1318,9 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1096
1318
  this.selected = -1;
1097
1319
  this.dirty = true;
1098
1320
  this.violations = [];
1321
+ this.undoStack = [];
1322
+ this.redoStack = [];
1323
+ this.lastCoalesceKey = undefined;
1099
1324
  }
1100
1325
  rename(name) {
1101
1326
  if (!this.figure)
@@ -1103,6 +1328,16 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1103
1328
  this.figure = { ...this.figure, name };
1104
1329
  this.dirty = true;
1105
1330
  }
1331
+ typeChanged(type) {
1332
+ if (!type || this.figure?.id)
1333
+ return;
1334
+ const sanitized = type.replace(/[^A-Za-z0-9_]/g, '_').toUpperCase();
1335
+ if (this.board)
1336
+ this.board.figureType = sanitized;
1337
+ this.figure = { ...this.figure, type: sanitized };
1338
+ this.folded = this.board ? toFigureSource(this.board, this.parts) : undefined;
1339
+ this.dirty = true;
1340
+ }
1106
1341
  /**
1107
1342
  * 분류와 태그를 고친다.
1108
1343
  *
@@ -1115,13 +1350,56 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1115
1350
  this.figure = { ...this.figure, ...patch };
1116
1351
  this.dirty = true;
1117
1352
  }
1353
+ snapshotState() {
1354
+ return {
1355
+ board: this.board ? structuredClone(this.board) : undefined,
1356
+ parts: structuredClone(this.parts),
1357
+ selected: this.selected
1358
+ };
1359
+ }
1360
+ recordHistory(coalesceKey) {
1361
+ const now = Date.now();
1362
+ if (coalesceKey && this.lastCoalesceKey === coalesceKey && now - this.lastHistoryTime < 600) {
1363
+ this.lastHistoryTime = now;
1364
+ return;
1365
+ }
1366
+ this.lastCoalesceKey = coalesceKey;
1367
+ this.lastHistoryTime = now;
1368
+ this.undoStack = [...this.undoStack.slice(-49), this.snapshotState()];
1369
+ this.redoStack = [];
1370
+ }
1371
+ undo() {
1372
+ if (this.undoStack.length === 0)
1373
+ return;
1374
+ const prev = this.undoStack[this.undoStack.length - 1];
1375
+ this.undoStack = this.undoStack.slice(0, -1);
1376
+ this.redoStack = [this.snapshotState(), ...this.redoStack.slice(0, 49)];
1377
+ this.board = prev.board ? structuredClone(prev.board) : undefined;
1378
+ this.parts = structuredClone(prev.parts);
1379
+ this.selected = this.parts.length ? Math.max(-1, Math.min(prev.selected, this.parts.length - 1)) : -1;
1380
+ this.dirty = true;
1381
+ this.lastCoalesceKey = undefined;
1382
+ }
1383
+ redo() {
1384
+ if (this.redoStack.length === 0)
1385
+ return;
1386
+ const next = this.redoStack[0];
1387
+ this.redoStack = this.redoStack.slice(1);
1388
+ this.undoStack = [...this.undoStack.slice(-49), this.snapshotState()];
1389
+ this.board = next.board ? structuredClone(next.board) : undefined;
1390
+ this.parts = structuredClone(next.parts);
1391
+ this.selected = this.parts.length ? Math.max(-1, Math.min(next.selected, this.parts.length - 1)) : -1;
1392
+ this.dirty = true;
1393
+ this.lastCoalesceKey = undefined;
1394
+ }
1118
1395
  /**
1119
1396
  * 부품 목록을 갈아 끼운다.
1120
1397
  *
1121
1398
  * 편집 규칙은 `part-edits` 가 갖는다 — 화면과 테스트가 같은 것을 쓰게 하려고 떼어
1122
1399
  * 놓았다. 여기서는 결과를 담고 선택 인덱스를 정리한다.
1123
1400
  */
1124
- put(parts, selected = this.selected) {
1401
+ put(parts, selected = this.selected, coalesceKey) {
1402
+ this.recordHistory(coalesceKey);
1125
1403
  this.parts = parts;
1126
1404
  this.selected = Math.min(selected, parts.length - 1);
1127
1405
  this.dirty = true;
@@ -1142,7 +1420,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1142
1420
  this.put(edits.movePart(this.parts, index, to), to);
1143
1421
  }
1144
1422
  updatePart(index, part) {
1145
- this.put(edits.updatePart(this.parts, index, part));
1423
+ this.put(edits.updatePart(this.parts, index, part), this.selected, `part:${part.name || index}`);
1146
1424
  }
1147
1425
  /**
1148
1426
  * 판 자체의 값을 갈아 끼운다 — 능력처럼 부품보다 위에 있는 것들.
@@ -1151,6 +1429,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1151
1429
  * 고칠 때마다 부품 목록이 새 배열이 되고, 그리는 쪽이 전부 다시 그린다.
1152
1430
  */
1153
1431
  updateBoard(board) {
1432
+ this.recordHistory('board');
1154
1433
  this.board = board;
1155
1434
  this.dirty = true;
1156
1435
  }
@@ -1167,6 +1446,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1167
1446
  changeDetailLevel(level) {
1168
1447
  if (!this.board)
1169
1448
  return;
1449
+ this.recordHistory('detailLevel');
1170
1450
  this.board = { ...this.board, detailLevel: level };
1171
1451
  this.dirty = true;
1172
1452
  }
@@ -1203,7 +1483,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1203
1483
  partChanged(name, part) {
1204
1484
  const at = this.parts.findIndex(item => item.name === name);
1205
1485
  if (at >= 0)
1206
- this.put(edits.updatePart(this.parts, at, part));
1486
+ this.put(edits.updatePart(this.parts, at, part), this.selected, `drag:${name}`);
1207
1487
  }
1208
1488
  /**
1209
1489
  * 정밀 배치 도구.
@@ -1286,48 +1566,50 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1286
1566
  console.warn(`[thumbnail] ${id} 를 채우지 못했습니다 — ${e.message}`);
1287
1567
  }
1288
1568
  }
1289
- async save() {
1569
+ save() {
1290
1570
  if (!this.canSave || !this.folded || !this.figure) {
1291
1571
  return;
1292
1572
  }
1573
+ if (!this.figure.id) {
1574
+ // 신규 저장: 타입 코드와 표시 이름을 명확히 팝업으로 확인/입력받음
1575
+ let defaultType = this.board?.figureType || this.figure.type || '';
1576
+ if (!defaultType || defaultType.startsWith('FIGURE_')) {
1577
+ defaultType = 'FIGURE';
1578
+ }
1579
+ this.saveModalType = defaultType;
1580
+ this.saveModalName = this.figure.name || '';
1581
+ this.saveModalError = '';
1582
+ this.showSaveModal = true;
1583
+ return;
1584
+ }
1585
+ void this.doSave();
1586
+ }
1587
+ async doSave() {
1588
+ if (!this.folded || !this.figure)
1589
+ return;
1293
1590
  this.saving = true;
1294
1591
  this.saveFailure = '';
1592
+ this.saveModalError = '';
1295
1593
  try {
1296
- const sourceJson = JSON.stringify(this.folded);
1297
- /*
1298
- 보이는 것을 그대로 찍어 함께 보낸다.
1299
-
1300
- 카드가 그림 없이 있으면 카탈로그에서 무엇인지 못 고른다. 찍는 자리를 저장에 두는 이유는
1301
- **그 순간이 정본이 바뀌는 순간**이라서다 — 따로 찍는 절차를 두면 그림과 정본이 갈린다.
1302
-
1303
- 못 찍으면 보내지 않는다(`undefined`). 빈 그림을 넣어 「그림이 있다」고 말하지 않는다.
1304
- */
1305
- const thumbnail = await captureThumbnail(this.board, this.parts);
1306
- if (this.figure.id) {
1307
- const result = await updateFigure(this.figure.id, {
1308
- name: this.figure.name,
1309
- /*
1310
- 카탈로그의 값도 함께 보낸다. 화면에서 고쳐 놓고 저장에 안 실으면 저장 뒤에 조용히
1311
- 사라진다 — 저작자는 저장했다고 보고 다시 열면 없다.
1312
- */
1313
- category: this.figure.category,
1314
- tags: this.figure.tags,
1315
- source: sourceJson,
1316
- thumbnail
1317
- });
1318
- this.figure = result.figure;
1319
- this.violations = result.violations;
1320
- }
1321
- else {
1322
- // 새로 만들 때만 타입 이름이 정해진다. 이미 쓰이는 이름이면 저장 전에 막는다 —
1323
- // 만든 뒤에는 고칠 수 없으므로 여기서 걸러야 한다.
1594
+ if (!this.figure.id) {
1595
+ const typeToSave = (this.saveModalType || this.board?.figureType || this.figure.type || 'FIGURE').trim().toUpperCase();
1596
+ const nameToSave = (this.saveModalName || this.figure.name || typeToSave).trim();
1597
+ if (!typeToSave) {
1598
+ throw new Error('타입 이름을 입력하세요.');
1599
+ }
1324
1600
  const taken = await fetchFigureTypeNames();
1325
- if (taken.includes(this.folded.type)) {
1326
- throw new Error(i18next.t('figure.text.type-name-already-taken', { type: this.folded.type }));
1601
+ if (taken.includes(typeToSave)) {
1602
+ throw new Error(i18next.t('figure.text.type-name-already-taken', { type: typeToSave }));
1327
1603
  }
1604
+ if (this.board)
1605
+ this.board.figureType = typeToSave;
1606
+ this.figure = { ...this.figure, type: typeToSave, name: nameToSave };
1607
+ this.folded = { ...this.folded, type: typeToSave };
1608
+ const sourceJson = JSON.stringify(this.folded);
1609
+ const thumbnail = await captureThumbnail(this.board, this.parts);
1328
1610
  const result = await createFigure({
1329
- type: this.folded.type,
1330
- name: this.figure.name,
1611
+ type: typeToSave,
1612
+ name: nameToSave,
1331
1613
  category: this.figure.category,
1332
1614
  tags: this.figure.tags,
1333
1615
  source: sourceJson,
@@ -1335,23 +1617,81 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1335
1617
  });
1336
1618
  this.figure = result.figure;
1337
1619
  this.violations = result.violations;
1620
+ this.showSaveModal = false;
1621
+ this.dirty = false;
1338
1622
  navigate(`figure-modeller/${result.figure.id}`);
1623
+ return;
1624
+ }
1625
+ // 기존 모델 업데이트: folded.type이 figure.type과 일치하도록 보장
1626
+ if (this.figure.type && this.folded.type !== this.figure.type) {
1627
+ this.folded = { ...this.folded, type: this.figure.type };
1628
+ if (this.board)
1629
+ this.board.figureType = this.figure.type;
1339
1630
  }
1631
+ const sourceJson = JSON.stringify(this.folded);
1632
+ const thumbnail = await captureThumbnail(this.board, this.parts);
1633
+ const result = await updateFigure(this.figure.id, {
1634
+ name: this.figure.name,
1635
+ category: this.figure.category,
1636
+ tags: this.figure.tags,
1637
+ source: sourceJson,
1638
+ thumbnail
1639
+ });
1640
+ this.figure = result.figure;
1641
+ this.violations = result.violations;
1340
1642
  this.dirty = false;
1341
1643
  }
1342
1644
  catch (e) {
1343
- /*
1344
- **서버가 말한 사유를 그대로 올린다.** 여기서 문장을 다시 지으면 무엇이 걸렸는지 알 수 없다 —
1345
- 타입 이름이 이미 쓰였다는 것과 권한이 없다는 것은 저작자가 할 일이 다르다.
1346
-
1347
- `dirty` 는 건드리지 않는다. 저장이 안 됐으므로 미저장인 것이 사실이다.
1348
- */
1349
- this.saveFailure = e.message;
1645
+ const msg = e.message;
1646
+ this.saveFailure = msg;
1647
+ this.saveModalError = msg;
1350
1648
  }
1351
1649
  finally {
1352
1650
  this.saving = false;
1353
1651
  }
1354
1652
  }
1653
+ renderSaveModal() {
1654
+ if (!this.showSaveModal)
1655
+ return nothing;
1656
+ return html `
1657
+ <div class="save-modal-backdrop" @click=${(e) => { if (e.target === e.currentTarget)
1658
+ this.showSaveModal = false; }}>
1659
+ <div class="save-modal-card" role="dialog" aria-modal="true">
1660
+ <h3 class="save-modal-title">${i18next.t('figure.title.save-new-figure', { defaultValue: '도형 초안 저장' })}</h3>
1661
+ <p class="save-modal-desc">${i18next.t('figure.text.save-new-figure-desc', { defaultValue: '보드와 캔버스에서 식별할 고유 타입 코드와 이름을 정합니다.' })}</p>
1662
+
1663
+ <div class="save-modal-field">
1664
+ <label>${i18next.t('figure.label.figure-type', { defaultValue: '타입 코드 (영문 대문자)' })}</label>
1665
+ <input
1666
+ .value=${this.saveModalType}
1667
+ placeholder="예: OHT, AGV_LIFTER"
1668
+ @input=${(e) => (this.saveModalType = e.target.value.trim().toUpperCase())}
1669
+ />
1670
+ </div>
1671
+
1672
+ <div class="save-modal-field">
1673
+ <label>${i18next.t('figure.label.name', { defaultValue: '도형 이름' })}</label>
1674
+ <input
1675
+ .value=${this.saveModalName}
1676
+ placeholder="예: 오버헤드 호이스트 트랜스포트"
1677
+ @input=${(e) => (this.saveModalName = e.target.value)}
1678
+ />
1679
+ </div>
1680
+
1681
+ ${this.saveModalError ? html `<div class="save-modal-error">${this.saveModalError}</div>` : nothing}
1682
+
1683
+ <div class="save-modal-actions">
1684
+ <button class="btn-cancel" @click=${() => (this.showSaveModal = false)}>
1685
+ ${i18next.t('figure.button.cancel', { defaultValue: '취소' })}
1686
+ </button>
1687
+ <button class="btn-confirm" ?disabled=${this.saving || !this.saveModalType} @click=${() => this.doSave()}>
1688
+ ${this.saving ? i18next.t('figure.text.saving-figure', { defaultValue: '저장 중...' }) : i18next.t('figure.button.save', { defaultValue: '저장' })}
1689
+ </button>
1690
+ </div>
1691
+ </div>
1692
+ </div>
1693
+ `;
1694
+ }
1355
1695
  };
1356
1696
  __decorate([
1357
1697
  state(),
@@ -1397,6 +1737,22 @@ __decorate([
1397
1737
  state(),
1398
1738
  __metadata("design:type", Object)
1399
1739
  ], FigureModellerPage.prototype, "saveFailure", void 0);
1740
+ __decorate([
1741
+ state(),
1742
+ __metadata("design:type", Object)
1743
+ ], FigureModellerPage.prototype, "showSaveModal", void 0);
1744
+ __decorate([
1745
+ state(),
1746
+ __metadata("design:type", Object)
1747
+ ], FigureModellerPage.prototype, "saveModalType", void 0);
1748
+ __decorate([
1749
+ state(),
1750
+ __metadata("design:type", Object)
1751
+ ], FigureModellerPage.prototype, "saveModalName", void 0);
1752
+ __decorate([
1753
+ state(),
1754
+ __metadata("design:type", Object)
1755
+ ], FigureModellerPage.prototype, "saveModalError", void 0);
1400
1756
  __decorate([
1401
1757
  state(),
1402
1758
  __metadata("design:type", Object)
@@ -1405,6 +1761,18 @@ __decorate([
1405
1761
  state(),
1406
1762
  __metadata("design:type", Array)
1407
1763
  ], FigureModellerPage.prototype, "proposalFeedback", void 0);
1764
+ __decorate([
1765
+ state(),
1766
+ __metadata("design:type", Array)
1767
+ ], FigureModellerPage.prototype, "undoStack", void 0);
1768
+ __decorate([
1769
+ state(),
1770
+ __metadata("design:type", Array)
1771
+ ], FigureModellerPage.prototype, "redoStack", void 0);
1772
+ __decorate([
1773
+ state(),
1774
+ __metadata("design:type", Object)
1775
+ ], FigureModellerPage.prototype, "sceneRevision", void 0);
1408
1776
  FigureModellerPage = __decorate([
1409
1777
  customElement('figure-modeller-page')
1410
1778
  ], FigureModellerPage);