@things-factory/figure-ui 10.1.14 → 10.1.16

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 (31) hide show
  1. package/client/modeller/figure-ai-target.ts +12 -0
  2. package/client/modeller/figure-canvas.ts +18 -2
  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 +25 -3
  7. package/client/pages/figure-modeller-page.ts +364 -49
  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 +1 -0
  12. package/dist-client/modeller/figure-canvas.js +21 -2
  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 +10 -3
  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 +48 -1
  23. package/dist-client/pages/figure-modeller-page.js +376 -52
  24. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  25. package/dist-client/route.d.ts +1 -1
  26. package/dist-client/tsconfig.tsbuildinfo +1 -1
  27. package/dist-server/tsconfig.tsbuildinfo +1 -1
  28. package/package.json +5 -5
  29. package/test/ai-proposal-contract.test.ts +3 -3
  30. package/translations/en.json +10 -1
  31. package/translations/ko.json +11 -1
@@ -8,10 +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
+ import { openOverlay } from '@operato/layout';
16
+ import { requestFigureAI, consumePendingFigureProposal } from '../modeller/figure-ai-target.js';
15
17
  import { validate } from '@hatiolab/figure-model';
16
18
  import * as edits from '../modeller/part-edits.js';
17
19
  import * as proposals from '../modeller/proposal.js';
@@ -128,10 +130,15 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
128
130
  * 미저장 표시(`dirty`)는 그대로 남으므로 데이터를 잃지는 않았다. 잃은 것은 **사유**다.
129
131
  */
130
132
  this.saveFailure = '';
133
+ this.showSaveModal = false;
134
+ this.saveModalType = '';
135
+ this.saveModalName = '';
136
+ this.saveModalError = '';
131
137
  /** 인라인 요청창과 AI 도크가 함께 쓰는 현재 Figure의 제한된 세션 피드백. */
132
138
  this.proposalFeedback = [];
133
139
  this.undoStack = [];
134
140
  this.redoStack = [];
141
+ this.sceneRevision = 0;
135
142
  this.lastHistoryTime = 0;
136
143
  this.announceAiContext = () => {
137
144
  if (!this.active)
@@ -140,16 +147,36 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
140
147
  detail: { figureId: this.figure?.id, source: this.folded }
141
148
  }));
142
149
  };
143
- this.acceptDockProposal = (event) => {
150
+ /*
151
+ `detail.staged` is the dock's answer to "did anything actually happen to my candidate". The
152
+ listener is synchronous, so the dock reads it right after dispatching and only then tells the
153
+ chat card to say so. It used to be called `accepted`, which claimed more than it knew — the
154
+ page had put the candidate somewhere, not the person had agreed to it.
155
+ */
156
+ this.receiveDockProposal = (event) => {
144
157
  const detail = event.detail;
145
- if (!this.active || (detail?.figureId ?? '') !== (this.figure?.id ?? ''))
146
- return;
147
- if (detail?.draftType && detail.draftType !== this.folded?.type)
158
+ if (!detail)
148
159
  return;
149
- if (detail?.baseSource && detail.baseSource !== JSON.stringify(this.folded))
150
- return;
151
- this.receive(detail.proposal);
152
- detail.accepted = true;
160
+ const source = detail?.source ||
161
+ (detail?.proposal?.source
162
+ ? typeof detail.proposal.source === 'string'
163
+ ? JSON.parse(detail.proposal.source)
164
+ : detail.proposal.source
165
+ : undefined);
166
+ if (source) {
167
+ detail.staged = this.openProposalForReview(source, detail.proposal);
168
+ }
169
+ };
170
+ this.onFigureAiStage = (event) => {
171
+ const detail = event.detail;
172
+ const source = detail?.source || (detail?.proposal?.source ? (typeof detail.proposal.source === 'string' ? JSON.parse(detail.proposal.source) : detail.proposal.source) : undefined);
173
+ if (source) {
174
+ this.openProposalForReview(source, detail.proposal);
175
+ }
176
+ };
177
+ this.onSaveRequest = () => {
178
+ if (this.active)
179
+ this.save();
153
180
  };
154
181
  this.handleKeyDown = (e) => {
155
182
  if (!this.active || this.mode !== 'edit' || this.proposalSession)
@@ -385,6 +412,27 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
385
412
  opacity: 0.35;
386
413
  cursor: default;
387
414
  }
415
+ div[modes] button[ai-support] {
416
+ display: inline-flex;
417
+ align-items: center;
418
+ gap: 4px;
419
+ padding: 3px 10px;
420
+ margin-right: 6px;
421
+ font: var(--label-font, inherit);
422
+ font-size: 0.74rem;
423
+ font-weight: 600;
424
+ color: var(--md-sys-color-primary);
425
+ background: var(--md-sys-color-primary-container);
426
+ border: 1px solid var(--md-sys-color-outline-variant);
427
+ border-radius: 6px;
428
+ cursor: pointer;
429
+ }
430
+ div[modes] button[ai-support]:hover {
431
+ background-color: var(--md-sys-color-surface-container-high);
432
+ }
433
+ div[modes] button[ai-support] md-icon {
434
+ --md-icon-size: 15px;
435
+ }
388
436
 
389
437
  /*
390
438
  후보가 오면 가운데를 둘로 나눈다.
@@ -571,6 +619,86 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
571
619
  line-height: 1.5;
572
620
  color: var(--md-sys-color-error);
573
621
  }
622
+ .save-modal-backdrop {
623
+ position: fixed;
624
+ inset: 0;
625
+ background: rgba(0, 0, 0, 0.5);
626
+ backdrop-filter: blur(2px);
627
+ display: flex;
628
+ align-items: center;
629
+ justify-content: center;
630
+ z-index: 1000;
631
+ }
632
+ .save-modal-card {
633
+ background: var(--md-sys-color-surface-container-high, #fff);
634
+ color: var(--md-sys-color-on-surface, #1f1f1f);
635
+ border-radius: 12px;
636
+ padding: 24px;
637
+ width: 380px;
638
+ max-width: 90vw;
639
+ box-shadow: 0 12px 36px rgba(0, 0, 0, 0.28);
640
+ display: flex;
641
+ flex-direction: column;
642
+ gap: 12px;
643
+ }
644
+ .save-modal-title {
645
+ margin: 0;
646
+ font-size: 1.15rem;
647
+ font-weight: 700;
648
+ }
649
+ .save-modal-desc {
650
+ margin: 0;
651
+ font-size: 0.8rem;
652
+ color: var(--md-sys-color-on-surface-variant, #666);
653
+ }
654
+ .save-modal-field {
655
+ display: flex;
656
+ flex-direction: column;
657
+ gap: 4px;
658
+ }
659
+ .save-modal-field label {
660
+ font-size: 0.76rem;
661
+ font-weight: 600;
662
+ color: var(--md-sys-color-on-surface-variant, #555);
663
+ }
664
+ .save-modal-field input {
665
+ padding: 8px 10px;
666
+ border: 1px solid var(--md-sys-color-outline, #ccc);
667
+ border-radius: 6px;
668
+ font-size: 0.88rem;
669
+ background: var(--md-sys-color-surface, #fff);
670
+ color: inherit;
671
+ }
672
+ .save-modal-error {
673
+ color: var(--md-sys-color-error, #b00020);
674
+ font-size: 0.78rem;
675
+ }
676
+ .save-modal-actions {
677
+ display: flex;
678
+ justify-content: flex-end;
679
+ gap: 8px;
680
+ margin-top: 8px;
681
+ }
682
+ .save-modal-actions button {
683
+ padding: 6px 14px;
684
+ border-radius: 6px;
685
+ font-size: 0.82rem;
686
+ cursor: pointer;
687
+ border: none;
688
+ }
689
+ .save-modal-actions .btn-cancel {
690
+ background: none;
691
+ color: var(--md-sys-color-on-surface, inherit);
692
+ }
693
+ .save-modal-actions .btn-confirm {
694
+ background: var(--md-sys-color-primary, #0066cc);
695
+ color: var(--md-sys-color-on-primary, #fff);
696
+ font-weight: 600;
697
+ }
698
+ .save-modal-actions .btn-confirm:disabled {
699
+ opacity: 0.5;
700
+ cursor: not-allowed;
701
+ }
574
702
  `; }
575
703
  willUpdate(changed) {
576
704
  if (changed.has('board') || changed.has('parts')) {
@@ -598,16 +726,83 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
598
726
  }));
599
727
  }
600
728
  }
729
+ /**
730
+ * A candidate from the dock opens the review lane. It does not become the draft.
731
+ *
732
+ * This used to write `board` and `parts` straight onto the working draft and then set
733
+ * `proposalSession = undefined` — the dock's candidate overwrote what the author had open and
734
+ * erased the lane built to review it. There was no moment where the person said yes, and a
735
+ * draft has no version history to go back to, so the previous work was simply gone.
736
+ *
737
+ * `ai-client-base`'s analysis contract states this as a literal type: `approvalRequired: true`,
738
+ * and of `proposal`, "It is never an instruction to mutate canonical state." The in-page
739
+ * `figure-ask` entrance already honoured it through `openAiProposal`; the dock entrance is now
740
+ * the same door. Applying is what the take button does, from the lane, per change.
741
+ *
742
+ * ## Why the overwrite was there — so it does not come back
743
+ *
744
+ * The dock opened the review lane originally. Before `c42a0b63df` this handler read:
745
+ *
746
+ * if (!this.active || (detail?.figureId ?? '') !== (this.figure?.id ?? '')) return
747
+ * if (detail?.draftType && detail.draftType !== this.folded?.type) return
748
+ * if (detail?.baseSource && detail.baseSource !== JSON.stringify(this.folded)) return
749
+ * this.receive(detail.proposal)
750
+ *
751
+ * Three guards, each a silent `return`, and the last compared a serialized source byte for byte.
752
+ * Any difference that changed nothing at all dropped the candidate with no message, so the dock
753
+ * would report a candidate and the screen would show none.
754
+ *
755
+ * The `ox-assistant-chat` migration fixed that by replacing the path rather than the guards, and
756
+ * the review lane went out with them — `applySourceDirectly` wrote straight to the draft, and
757
+ * cleared `proposalSession` on the way past. **Nobody decided that reviews were unwanted here.**
758
+ * It was the cost of loosening a guard by deleting what stood around it.
759
+ *
760
+ * So the guards are gone and stay gone, and the lane is back. If a candidate ever seems not to
761
+ * arrive, the thing to look for is a silent `return` on this path — not the lane.
762
+ *
763
+ * Returns whether the lane opened, so the dock can tell the chat whether to say it staged.
764
+ */
765
+ openProposalForReview(rawSource, proposal) {
766
+ const source = typeof rawSource === 'string' ? JSON.parse(rawSource) : rawSource;
767
+ if (!source || !Array.isArray(source.parts))
768
+ return false;
769
+ try {
770
+ /*
771
+ The dock sends a FigureProposal whose metrics sit at the top level; the staged-on-navigate
772
+ path carries the raw tool result, which nests them under score/cost. Read both rather than
773
+ making one of the two entrances reshape its payload.
774
+ */
775
+ this.proposalSession = proposalSessions.openAiProposal(this.folded, {
776
+ source: JSON.stringify(source),
777
+ grade: proposal?.grade ?? proposal?.score?.grade,
778
+ triangles: proposal?.triangles ?? proposal?.cost?.triangles,
779
+ groups: proposal?.groups ?? proposal?.cost?.groups,
780
+ attempts: proposal?.attempts,
781
+ quality: proposal?.quality
782
+ });
783
+ this.mode = 'edit';
784
+ this.requestUpdate();
785
+ return true;
786
+ }
787
+ catch (e) {
788
+ console.error('[FigureModeller] Failed to open the AI candidate for review:', e);
789
+ return false;
790
+ }
791
+ }
601
792
  connectedCallback() {
602
793
  super.connectedCallback();
603
794
  window.addEventListener('figure-ai-context-request', this.announceAiContext);
604
- window.addEventListener('figure-ai-review', this.acceptDockProposal);
795
+ window.addEventListener('figure-ai-review', this.receiveDockProposal);
796
+ window.addEventListener('figure-ai-stage', this.onFigureAiStage);
797
+ window.addEventListener('figure-save-request', this.onSaveRequest);
605
798
  window.addEventListener('keydown', this.handleKeyDown);
606
799
  }
607
800
  disconnectedCallback() {
608
801
  window.removeEventListener('keydown', this.handleKeyDown);
802
+ window.removeEventListener('figure-save-request', this.onSaveRequest);
803
+ window.removeEventListener('figure-ai-stage', this.onFigureAiStage);
609
804
  window.removeEventListener('figure-ai-context-request', this.announceAiContext);
610
- window.removeEventListener('figure-ai-review', this.acceptDockProposal);
805
+ window.removeEventListener('figure-ai-review', this.receiveDockProposal);
611
806
  window.dispatchEvent(new CustomEvent('figure-ai-context', { detail: undefined }));
612
807
  super.disconnectedCallback();
613
808
  }
@@ -635,6 +830,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
635
830
  ?collapse-left=${this.collapsed.left}
636
831
  ?collapse-right=${this.collapsed.right}
637
832
  @figure-rename=${(e) => this.rename(e.detail.name)}
833
+ @figure-type-change=${(e) => this.typeChanged(e.detail.type)}
638
834
  @figure-catalog=${(e) => this.catalog(e.detail)}
639
835
  @figure-released=${(e) => this.released(e.detail.figure)}
640
836
  @figure-reverted=${() => this.figure?.id && this.load(this.figure.id)}
@@ -675,6 +871,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
675
871
  .parts=${this.parts}
676
872
  .picked=${this.pickedName}
677
873
  .view=${this.view}
874
+ .revision=${this.sceneRevision}
678
875
  ></figure-canvas>
679
876
  </div>
680
877
  <div lane candidate>
@@ -700,6 +897,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
700
897
  .parts=${this.parts}
701
898
  .picked=${this.pickedName}
702
899
  .view=${this.view}
900
+ .revision=${this.sceneRevision}
703
901
  ></figure-canvas>`}
704
902
  </div>
705
903
 
@@ -719,6 +917,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
719
917
  @update-view=${(e) => (this.view = e.detail.view)}
720
918
  ></figure-side>
721
919
  </div>
920
+ ${this.renderSaveModal()}
722
921
  `;
723
922
  }
724
923
  /**
@@ -773,6 +972,17 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
773
972
  `
774
973
  : ''}
775
974
  <div spacer></div>
975
+ <button
976
+ ai-support
977
+ title=${i18next.t('figure.button.ask')}
978
+ @click=${() => {
979
+ requestFigureAI({ figureId: this.figure?.id, source: this.folded });
980
+ openOverlay('figure-ai-dock', { backdrop: false });
981
+ }}
982
+ >
983
+ <md-icon>auto_awesome</md-icon>
984
+ <span>AI</span>
985
+ </button>
776
986
  ${this.saveFailure ? html `<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
777
987
  ${this.dirty ? html `<span dirty>${i18next.t('figure.text.unsaved-changes')}</span>` : ''}
778
988
  <button save ?disabled=${!this.canSave} @click=${() => this.save()}>
@@ -1021,7 +1231,11 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1021
1231
  * 저장이 거절되면 `dirty` 를 그대로 두므로 다시 누를 수 있다.
1022
1232
  */
1023
1233
  get canSave() {
1024
- if (this.saving || !this.dirty || !this.folded || !this.figure?.name) {
1234
+ if (this.saving || !this.dirty || !this.folded) {
1235
+ return false;
1236
+ }
1237
+ // 기존 저장된 모델은 이름이 있어야 바로 저장 가능. 신규 모델은 저장 팝업에서 이름을 입력할 수 있음
1238
+ if (this.figure?.id && !this.figure?.name) {
1025
1239
  return false;
1026
1240
  }
1027
1241
  return validate(this.folded).errors.length === 0;
@@ -1036,6 +1250,10 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1036
1250
  if (changes.active || id !== this.figure?.id) {
1037
1251
  await this.load(id);
1038
1252
  }
1253
+ const pending = consumePendingFigureProposal();
1254
+ if (pending?.source) {
1255
+ this.openProposalForReview(pending.source, pending.proposal);
1256
+ }
1039
1257
  return;
1040
1258
  }
1041
1259
  /*
@@ -1052,16 +1270,32 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1052
1270
  */
1053
1271
  if (!this.figure) {
1054
1272
  this.startNew();
1273
+ const pending = consumePendingFigureProposal();
1274
+ if (pending?.source) {
1275
+ this.openProposalForReview(pending.source, pending.proposal);
1276
+ }
1055
1277
  return;
1056
1278
  }
1057
1279
  if (this.figure.id) {
1058
1280
  this.startNew();
1281
+ const pending = consumePendingFigureProposal();
1282
+ if (pending?.source) {
1283
+ this.openProposalForReview(pending.source, pending.proposal);
1284
+ }
1059
1285
  return;
1060
1286
  }
1061
1287
  if (shouldStartNewFigure(changes, !!this.figure, this.figure?.id)) {
1062
1288
  this.startNew();
1289
+ const pending = consumePendingFigureProposal();
1290
+ if (pending?.source) {
1291
+ this.openProposalForReview(pending.source, pending.proposal);
1292
+ }
1063
1293
  return;
1064
1294
  }
1295
+ const pending = consumePendingFigureProposal();
1296
+ if (pending?.source) {
1297
+ this.openProposalForReview(pending.source, pending.proposal);
1298
+ }
1065
1299
  }
1066
1300
  async load(id) {
1067
1301
  const generation = ++this.loadGeneration;
@@ -1130,6 +1364,16 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1130
1364
  this.figure = { ...this.figure, name };
1131
1365
  this.dirty = true;
1132
1366
  }
1367
+ typeChanged(type) {
1368
+ if (!type || this.figure?.id)
1369
+ return;
1370
+ const sanitized = type.replace(/[^A-Za-z0-9_]/g, '_').toUpperCase();
1371
+ if (this.board)
1372
+ this.board.figureType = sanitized;
1373
+ this.figure = { ...this.figure, type: sanitized };
1374
+ this.folded = this.board ? toFigureSource(this.board, this.parts) : undefined;
1375
+ this.dirty = true;
1376
+ }
1133
1377
  /**
1134
1378
  * 분류와 태그를 고친다.
1135
1379
  *
@@ -1358,48 +1602,50 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1358
1602
  console.warn(`[thumbnail] ${id} 를 채우지 못했습니다 — ${e.message}`);
1359
1603
  }
1360
1604
  }
1361
- async save() {
1605
+ save() {
1362
1606
  if (!this.canSave || !this.folded || !this.figure) {
1363
1607
  return;
1364
1608
  }
1609
+ if (!this.figure.id) {
1610
+ // 신규 저장: 타입 코드와 표시 이름을 명확히 팝업으로 확인/입력받음
1611
+ let defaultType = this.board?.figureType || this.figure.type || '';
1612
+ if (!defaultType || defaultType.startsWith('FIGURE_')) {
1613
+ defaultType = 'FIGURE';
1614
+ }
1615
+ this.saveModalType = defaultType;
1616
+ this.saveModalName = this.figure.name || '';
1617
+ this.saveModalError = '';
1618
+ this.showSaveModal = true;
1619
+ return;
1620
+ }
1621
+ void this.doSave();
1622
+ }
1623
+ async doSave() {
1624
+ if (!this.folded || !this.figure)
1625
+ return;
1365
1626
  this.saving = true;
1366
1627
  this.saveFailure = '';
1628
+ this.saveModalError = '';
1367
1629
  try {
1368
- const sourceJson = JSON.stringify(this.folded);
1369
- /*
1370
- 보이는 것을 그대로 찍어 함께 보낸다.
1371
-
1372
- 카드가 그림 없이 있으면 카탈로그에서 무엇인지 못 고른다. 찍는 자리를 저장에 두는 이유는
1373
- **그 순간이 정본이 바뀌는 순간**이라서다 — 따로 찍는 절차를 두면 그림과 정본이 갈린다.
1374
-
1375
- 못 찍으면 보내지 않는다(`undefined`). 빈 그림을 넣어 「그림이 있다」고 말하지 않는다.
1376
- */
1377
- const thumbnail = await captureThumbnail(this.board, this.parts);
1378
- if (this.figure.id) {
1379
- const result = await updateFigure(this.figure.id, {
1380
- name: this.figure.name,
1381
- /*
1382
- 카탈로그의 값도 함께 보낸다. 화면에서 고쳐 놓고 저장에 안 실으면 저장 뒤에 조용히
1383
- 사라진다 — 저작자는 저장했다고 보고 다시 열면 없다.
1384
- */
1385
- category: this.figure.category,
1386
- tags: this.figure.tags,
1387
- source: sourceJson,
1388
- thumbnail
1389
- });
1390
- this.figure = result.figure;
1391
- this.violations = result.violations;
1392
- }
1393
- else {
1394
- // 새로 만들 때만 타입 이름이 정해진다. 이미 쓰이는 이름이면 저장 전에 막는다 —
1395
- // 만든 뒤에는 고칠 수 없으므로 여기서 걸러야 한다.
1630
+ if (!this.figure.id) {
1631
+ const typeToSave = (this.saveModalType || this.board?.figureType || this.figure.type || 'FIGURE').trim().toUpperCase();
1632
+ const nameToSave = (this.saveModalName || this.figure.name || typeToSave).trim();
1633
+ if (!typeToSave) {
1634
+ throw new Error('타입 이름을 입력하세요.');
1635
+ }
1396
1636
  const taken = await fetchFigureTypeNames();
1397
- if (taken.includes(this.folded.type)) {
1398
- throw new Error(i18next.t('figure.text.type-name-already-taken', { type: this.folded.type }));
1637
+ if (taken.includes(typeToSave)) {
1638
+ throw new Error(i18next.t('figure.text.type-name-already-taken', { type: typeToSave }));
1399
1639
  }
1640
+ if (this.board)
1641
+ this.board.figureType = typeToSave;
1642
+ this.figure = { ...this.figure, type: typeToSave, name: nameToSave };
1643
+ this.folded = { ...this.folded, type: typeToSave };
1644
+ const sourceJson = JSON.stringify(this.folded);
1645
+ const thumbnail = await captureThumbnail(this.board, this.parts);
1400
1646
  const result = await createFigure({
1401
- type: this.folded.type,
1402
- name: this.figure.name,
1647
+ type: typeToSave,
1648
+ name: nameToSave,
1403
1649
  category: this.figure.category,
1404
1650
  tags: this.figure.tags,
1405
1651
  source: sourceJson,
@@ -1407,23 +1653,81 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1407
1653
  });
1408
1654
  this.figure = result.figure;
1409
1655
  this.violations = result.violations;
1656
+ this.showSaveModal = false;
1657
+ this.dirty = false;
1410
1658
  navigate(`figure-modeller/${result.figure.id}`);
1659
+ return;
1411
1660
  }
1661
+ // 기존 모델 업데이트: folded.type이 figure.type과 일치하도록 보장
1662
+ if (this.figure.type && this.folded.type !== this.figure.type) {
1663
+ this.folded = { ...this.folded, type: this.figure.type };
1664
+ if (this.board)
1665
+ this.board.figureType = this.figure.type;
1666
+ }
1667
+ const sourceJson = JSON.stringify(this.folded);
1668
+ const thumbnail = await captureThumbnail(this.board, this.parts);
1669
+ const result = await updateFigure(this.figure.id, {
1670
+ name: this.figure.name,
1671
+ category: this.figure.category,
1672
+ tags: this.figure.tags,
1673
+ source: sourceJson,
1674
+ thumbnail
1675
+ });
1676
+ this.figure = result.figure;
1677
+ this.violations = result.violations;
1412
1678
  this.dirty = false;
1413
1679
  }
1414
1680
  catch (e) {
1415
- /*
1416
- **서버가 말한 사유를 그대로 올린다.** 여기서 문장을 다시 지으면 무엇이 걸렸는지 알 수 없다 —
1417
- 타입 이름이 이미 쓰였다는 것과 권한이 없다는 것은 저작자가 할 일이 다르다.
1418
-
1419
- `dirty` 는 건드리지 않는다. 저장이 안 됐으므로 미저장인 것이 사실이다.
1420
- */
1421
- this.saveFailure = e.message;
1681
+ const msg = e.message;
1682
+ this.saveFailure = msg;
1683
+ this.saveModalError = msg;
1422
1684
  }
1423
1685
  finally {
1424
1686
  this.saving = false;
1425
1687
  }
1426
1688
  }
1689
+ renderSaveModal() {
1690
+ if (!this.showSaveModal)
1691
+ return nothing;
1692
+ return html `
1693
+ <div class="save-modal-backdrop" @click=${(e) => { if (e.target === e.currentTarget)
1694
+ this.showSaveModal = false; }}>
1695
+ <div class="save-modal-card" role="dialog" aria-modal="true">
1696
+ <h3 class="save-modal-title">${i18next.t('figure.title.save-new-figure', { defaultValue: '도형 초안 저장' })}</h3>
1697
+ <p class="save-modal-desc">${i18next.t('figure.text.save-new-figure-desc', { defaultValue: '보드와 캔버스에서 식별할 고유 타입 코드와 이름을 정합니다.' })}</p>
1698
+
1699
+ <div class="save-modal-field">
1700
+ <label>${i18next.t('figure.label.figure-type', { defaultValue: '타입 코드 (영문 대문자)' })}</label>
1701
+ <input
1702
+ .value=${this.saveModalType}
1703
+ placeholder="예: OHT, AGV_LIFTER"
1704
+ @input=${(e) => (this.saveModalType = e.target.value.trim().toUpperCase())}
1705
+ />
1706
+ </div>
1707
+
1708
+ <div class="save-modal-field">
1709
+ <label>${i18next.t('figure.label.name', { defaultValue: '도형 이름' })}</label>
1710
+ <input
1711
+ .value=${this.saveModalName}
1712
+ placeholder="예: 오버헤드 호이스트 트랜스포트"
1713
+ @input=${(e) => (this.saveModalName = e.target.value)}
1714
+ />
1715
+ </div>
1716
+
1717
+ ${this.saveModalError ? html `<div class="save-modal-error">${this.saveModalError}</div>` : nothing}
1718
+
1719
+ <div class="save-modal-actions">
1720
+ <button class="btn-cancel" @click=${() => (this.showSaveModal = false)}>
1721
+ ${i18next.t('figure.button.cancel', { defaultValue: '취소' })}
1722
+ </button>
1723
+ <button class="btn-confirm" ?disabled=${this.saving || !this.saveModalType} @click=${() => this.doSave()}>
1724
+ ${this.saving ? i18next.t('figure.text.saving-figure', { defaultValue: '저장 중...' }) : i18next.t('figure.button.save', { defaultValue: '저장' })}
1725
+ </button>
1726
+ </div>
1727
+ </div>
1728
+ </div>
1729
+ `;
1730
+ }
1427
1731
  };
1428
1732
  __decorate([
1429
1733
  state(),
@@ -1469,6 +1773,22 @@ __decorate([
1469
1773
  state(),
1470
1774
  __metadata("design:type", Object)
1471
1775
  ], FigureModellerPage.prototype, "saveFailure", void 0);
1776
+ __decorate([
1777
+ state(),
1778
+ __metadata("design:type", Object)
1779
+ ], FigureModellerPage.prototype, "showSaveModal", void 0);
1780
+ __decorate([
1781
+ state(),
1782
+ __metadata("design:type", Object)
1783
+ ], FigureModellerPage.prototype, "saveModalType", void 0);
1784
+ __decorate([
1785
+ state(),
1786
+ __metadata("design:type", Object)
1787
+ ], FigureModellerPage.prototype, "saveModalName", void 0);
1788
+ __decorate([
1789
+ state(),
1790
+ __metadata("design:type", Object)
1791
+ ], FigureModellerPage.prototype, "saveModalError", void 0);
1472
1792
  __decorate([
1473
1793
  state(),
1474
1794
  __metadata("design:type", Object)
@@ -1485,6 +1805,10 @@ __decorate([
1485
1805
  state(),
1486
1806
  __metadata("design:type", Array)
1487
1807
  ], FigureModellerPage.prototype, "redoStack", void 0);
1808
+ __decorate([
1809
+ state(),
1810
+ __metadata("design:type", Object)
1811
+ ], FigureModellerPage.prototype, "sceneRevision", void 0);
1488
1812
  FigureModellerPage = __decorate([
1489
1813
  customElement('figure-modeller-page')
1490
1814
  ], FigureModellerPage);