@things-factory/figure-ui 10.1.14 → 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 (30) 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 +17 -21
  7. package/client/pages/figure-modeller-page.ts +323 -46
  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 +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 +11 -0
  23. package/dist-client/pages/figure-modeller-page.js +337 -49
  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 +3 -3
  29. package/translations/en.json +10 -1
  30. 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)
@@ -142,14 +149,35 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
142
149
  };
143
150
  this.acceptDockProposal = (event) => {
144
151
  const detail = event.detail;
145
- if (!this.active || (detail?.figureId ?? '') !== (this.figure?.id ?? ''))
152
+ if (!detail)
146
153
  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;
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();
153
181
  };
154
182
  this.handleKeyDown = (e) => {
155
183
  if (!this.active || this.mode !== 'edit' || this.proposalSession)
@@ -385,6 +413,27 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
385
413
  opacity: 0.35;
386
414
  cursor: default;
387
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
+ }
388
437
 
389
438
  /*
390
439
  후보가 오면 가운데를 둘로 나눈다.
@@ -571,6 +620,86 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
571
620
  line-height: 1.5;
572
621
  color: var(--md-sys-color-error);
573
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
+ }
574
703
  `; }
575
704
  willUpdate(changed) {
576
705
  if (changed.has('board') || changed.has('parts')) {
@@ -598,14 +727,44 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
598
727
  }));
599
728
  }
600
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
+ }
601
756
  connectedCallback() {
602
757
  super.connectedCallback();
603
758
  window.addEventListener('figure-ai-context-request', this.announceAiContext);
604
759
  window.addEventListener('figure-ai-review', this.acceptDockProposal);
760
+ window.addEventListener('figure-ai-stage', this.onFigureAiStage);
761
+ window.addEventListener('figure-save-request', this.onSaveRequest);
605
762
  window.addEventListener('keydown', this.handleKeyDown);
606
763
  }
607
764
  disconnectedCallback() {
608
765
  window.removeEventListener('keydown', this.handleKeyDown);
766
+ window.removeEventListener('figure-save-request', this.onSaveRequest);
767
+ window.removeEventListener('figure-ai-stage', this.onFigureAiStage);
609
768
  window.removeEventListener('figure-ai-context-request', this.announceAiContext);
610
769
  window.removeEventListener('figure-ai-review', this.acceptDockProposal);
611
770
  window.dispatchEvent(new CustomEvent('figure-ai-context', { detail: undefined }));
@@ -635,6 +794,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
635
794
  ?collapse-left=${this.collapsed.left}
636
795
  ?collapse-right=${this.collapsed.right}
637
796
  @figure-rename=${(e) => this.rename(e.detail.name)}
797
+ @figure-type-change=${(e) => this.typeChanged(e.detail.type)}
638
798
  @figure-catalog=${(e) => this.catalog(e.detail)}
639
799
  @figure-released=${(e) => this.released(e.detail.figure)}
640
800
  @figure-reverted=${() => this.figure?.id && this.load(this.figure.id)}
@@ -675,6 +835,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
675
835
  .parts=${this.parts}
676
836
  .picked=${this.pickedName}
677
837
  .view=${this.view}
838
+ .revision=${this.sceneRevision}
678
839
  ></figure-canvas>
679
840
  </div>
680
841
  <div lane candidate>
@@ -700,6 +861,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
700
861
  .parts=${this.parts}
701
862
  .picked=${this.pickedName}
702
863
  .view=${this.view}
864
+ .revision=${this.sceneRevision}
703
865
  ></figure-canvas>`}
704
866
  </div>
705
867
 
@@ -719,6 +881,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
719
881
  @update-view=${(e) => (this.view = e.detail.view)}
720
882
  ></figure-side>
721
883
  </div>
884
+ ${this.renderSaveModal()}
722
885
  `;
723
886
  }
724
887
  /**
@@ -773,6 +936,17 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
773
936
  `
774
937
  : ''}
775
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>
776
950
  ${this.saveFailure ? html `<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
777
951
  ${this.dirty ? html `<span dirty>${i18next.t('figure.text.unsaved-changes')}</span>` : ''}
778
952
  <button save ?disabled=${!this.canSave} @click=${() => this.save()}>
@@ -1021,7 +1195,11 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1021
1195
  * 저장이 거절되면 `dirty` 를 그대로 두므로 다시 누를 수 있다.
1022
1196
  */
1023
1197
  get canSave() {
1024
- 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) {
1025
1203
  return false;
1026
1204
  }
1027
1205
  return validate(this.folded).errors.length === 0;
@@ -1036,6 +1214,10 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1036
1214
  if (changes.active || id !== this.figure?.id) {
1037
1215
  await this.load(id);
1038
1216
  }
1217
+ const pending = consumePendingFigureProposal();
1218
+ if (pending?.source) {
1219
+ this.applySourceDirectly(pending.source, pending.proposal);
1220
+ }
1039
1221
  return;
1040
1222
  }
1041
1223
  /*
@@ -1052,16 +1234,32 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1052
1234
  */
1053
1235
  if (!this.figure) {
1054
1236
  this.startNew();
1237
+ const pending = consumePendingFigureProposal();
1238
+ if (pending?.source) {
1239
+ this.applySourceDirectly(pending.source, pending.proposal);
1240
+ }
1055
1241
  return;
1056
1242
  }
1057
1243
  if (this.figure.id) {
1058
1244
  this.startNew();
1245
+ const pending = consumePendingFigureProposal();
1246
+ if (pending?.source) {
1247
+ this.applySourceDirectly(pending.source, pending.proposal);
1248
+ }
1059
1249
  return;
1060
1250
  }
1061
1251
  if (shouldStartNewFigure(changes, !!this.figure, this.figure?.id)) {
1062
1252
  this.startNew();
1253
+ const pending = consumePendingFigureProposal();
1254
+ if (pending?.source) {
1255
+ this.applySourceDirectly(pending.source, pending.proposal);
1256
+ }
1063
1257
  return;
1064
1258
  }
1259
+ const pending = consumePendingFigureProposal();
1260
+ if (pending?.source) {
1261
+ this.applySourceDirectly(pending.source, pending.proposal);
1262
+ }
1065
1263
  }
1066
1264
  async load(id) {
1067
1265
  const generation = ++this.loadGeneration;
@@ -1130,6 +1328,16 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1130
1328
  this.figure = { ...this.figure, name };
1131
1329
  this.dirty = true;
1132
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
+ }
1133
1341
  /**
1134
1342
  * 분류와 태그를 고친다.
1135
1343
  *
@@ -1358,48 +1566,50 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1358
1566
  console.warn(`[thumbnail] ${id} 를 채우지 못했습니다 — ${e.message}`);
1359
1567
  }
1360
1568
  }
1361
- async save() {
1569
+ save() {
1362
1570
  if (!this.canSave || !this.folded || !this.figure) {
1363
1571
  return;
1364
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;
1365
1590
  this.saving = true;
1366
1591
  this.saveFailure = '';
1592
+ this.saveModalError = '';
1367
1593
  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
- // 만든 뒤에는 고칠 수 없으므로 여기서 걸러야 한다.
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
+ }
1396
1600
  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 }));
1601
+ if (taken.includes(typeToSave)) {
1602
+ throw new Error(i18next.t('figure.text.type-name-already-taken', { type: typeToSave }));
1399
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);
1400
1610
  const result = await createFigure({
1401
- type: this.folded.type,
1402
- name: this.figure.name,
1611
+ type: typeToSave,
1612
+ name: nameToSave,
1403
1613
  category: this.figure.category,
1404
1614
  tags: this.figure.tags,
1405
1615
  source: sourceJson,
@@ -1407,23 +1617,81 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1407
1617
  });
1408
1618
  this.figure = result.figure;
1409
1619
  this.violations = result.violations;
1620
+ this.showSaveModal = false;
1621
+ this.dirty = false;
1410
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;
1411
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;
1412
1642
  this.dirty = false;
1413
1643
  }
1414
1644
  catch (e) {
1415
- /*
1416
- **서버가 말한 사유를 그대로 올린다.** 여기서 문장을 다시 지으면 무엇이 걸렸는지 알 수 없다 —
1417
- 타입 이름이 이미 쓰였다는 것과 권한이 없다는 것은 저작자가 할 일이 다르다.
1418
-
1419
- `dirty` 는 건드리지 않는다. 저장이 안 됐으므로 미저장인 것이 사실이다.
1420
- */
1421
- this.saveFailure = e.message;
1645
+ const msg = e.message;
1646
+ this.saveFailure = msg;
1647
+ this.saveModalError = msg;
1422
1648
  }
1423
1649
  finally {
1424
1650
  this.saving = false;
1425
1651
  }
1426
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
+ }
1427
1695
  };
1428
1696
  __decorate([
1429
1697
  state(),
@@ -1469,6 +1737,22 @@ __decorate([
1469
1737
  state(),
1470
1738
  __metadata("design:type", Object)
1471
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);
1472
1756
  __decorate([
1473
1757
  state(),
1474
1758
  __metadata("design:type", Object)
@@ -1485,6 +1769,10 @@ __decorate([
1485
1769
  state(),
1486
1770
  __metadata("design:type", Array)
1487
1771
  ], FigureModellerPage.prototype, "redoStack", void 0);
1772
+ __decorate([
1773
+ state(),
1774
+ __metadata("design:type", Object)
1775
+ ], FigureModellerPage.prototype, "sceneRevision", void 0);
1488
1776
  FigureModellerPage = __decorate([
1489
1777
  customElement('figure-modeller-page')
1490
1778
  ], FigureModellerPage);