@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,13 +8,13 @@ import { shouldStartNewFigure } from '../modeller/new-figure-entry.js'
8
8
  import '../modeller/figure-parts.js'
9
9
  import '../modeller/figure-inspector.js'
10
10
 
11
- import { css, html } from 'lit'
11
+ import { css, html, nothing } from 'lit'
12
12
  import { customElement, state } from 'lit/decorators.js'
13
13
 
14
14
  import { i18next, localize } from '@operato/i18n'
15
15
  import { navigate, PageView } from '@operato/shell'
16
16
  import { openOverlay } from '@operato/layout'
17
- import { requestFigureAI } from '../modeller/figure-ai-target.js'
17
+ import { requestFigureAI, consumePendingFigureProposal } from '../modeller/figure-ai-target.js'
18
18
  import { validate } from '@hatiolab/figure-model'
19
19
  import type { DetailLevel, FigureSource, PrimitiveKind } from '@hatiolab/figure-model'
20
20
 
@@ -331,6 +331,27 @@ export class FigureModellerPage extends FigureModellerPageBase {
331
331
  opacity: 0.35;
332
332
  cursor: default;
333
333
  }
334
+ div[modes] button[ai-support] {
335
+ display: inline-flex;
336
+ align-items: center;
337
+ gap: 4px;
338
+ padding: 3px 10px;
339
+ margin-right: 6px;
340
+ font: var(--label-font, inherit);
341
+ font-size: 0.74rem;
342
+ font-weight: 600;
343
+ color: var(--md-sys-color-primary);
344
+ background: var(--md-sys-color-primary-container);
345
+ border: 1px solid var(--md-sys-color-outline-variant);
346
+ border-radius: 6px;
347
+ cursor: pointer;
348
+ }
349
+ div[modes] button[ai-support]:hover {
350
+ background-color: var(--md-sys-color-surface-container-high);
351
+ }
352
+ div[modes] button[ai-support] md-icon {
353
+ --md-icon-size: 15px;
354
+ }
334
355
 
335
356
  /*
336
357
  후보가 오면 가운데를 둘로 나눈다.
@@ -517,6 +538,86 @@ export class FigureModellerPage extends FigureModellerPageBase {
517
538
  line-height: 1.5;
518
539
  color: var(--md-sys-color-error);
519
540
  }
541
+ .save-modal-backdrop {
542
+ position: fixed;
543
+ inset: 0;
544
+ background: rgba(0, 0, 0, 0.5);
545
+ backdrop-filter: blur(2px);
546
+ display: flex;
547
+ align-items: center;
548
+ justify-content: center;
549
+ z-index: 1000;
550
+ }
551
+ .save-modal-card {
552
+ background: var(--md-sys-color-surface-container-high, #fff);
553
+ color: var(--md-sys-color-on-surface, #1f1f1f);
554
+ border-radius: 12px;
555
+ padding: 24px;
556
+ width: 380px;
557
+ max-width: 90vw;
558
+ box-shadow: 0 12px 36px rgba(0, 0, 0, 0.28);
559
+ display: flex;
560
+ flex-direction: column;
561
+ gap: 12px;
562
+ }
563
+ .save-modal-title {
564
+ margin: 0;
565
+ font-size: 1.15rem;
566
+ font-weight: 700;
567
+ }
568
+ .save-modal-desc {
569
+ margin: 0;
570
+ font-size: 0.8rem;
571
+ color: var(--md-sys-color-on-surface-variant, #666);
572
+ }
573
+ .save-modal-field {
574
+ display: flex;
575
+ flex-direction: column;
576
+ gap: 4px;
577
+ }
578
+ .save-modal-field label {
579
+ font-size: 0.76rem;
580
+ font-weight: 600;
581
+ color: var(--md-sys-color-on-surface-variant, #555);
582
+ }
583
+ .save-modal-field input {
584
+ padding: 8px 10px;
585
+ border: 1px solid var(--md-sys-color-outline, #ccc);
586
+ border-radius: 6px;
587
+ font-size: 0.88rem;
588
+ background: var(--md-sys-color-surface, #fff);
589
+ color: inherit;
590
+ }
591
+ .save-modal-error {
592
+ color: var(--md-sys-color-error, #b00020);
593
+ font-size: 0.78rem;
594
+ }
595
+ .save-modal-actions {
596
+ display: flex;
597
+ justify-content: flex-end;
598
+ gap: 8px;
599
+ margin-top: 8px;
600
+ }
601
+ .save-modal-actions button {
602
+ padding: 6px 14px;
603
+ border-radius: 6px;
604
+ font-size: 0.82rem;
605
+ cursor: pointer;
606
+ border: none;
607
+ }
608
+ .save-modal-actions .btn-cancel {
609
+ background: none;
610
+ color: var(--md-sys-color-on-surface, inherit);
611
+ }
612
+ .save-modal-actions .btn-confirm {
613
+ background: var(--md-sys-color-primary, #0066cc);
614
+ color: var(--md-sys-color-on-primary, #fff);
615
+ font-weight: 600;
616
+ }
617
+ .save-modal-actions .btn-confirm:disabled {
618
+ opacity: 0.5;
619
+ cursor: not-allowed;
620
+ }
520
621
  `
521
622
 
522
623
  /** 편집 중인 Figure. 새로 만드는 중이면 `id` 가 없다. */
@@ -558,6 +659,10 @@ export class FigureModellerPage extends FigureModellerPageBase {
558
659
  * 미저장 표시(`dirty`)는 그대로 남으므로 데이터를 잃지는 않았다. 잃은 것은 **사유**다.
559
660
  */
560
661
  @state() private saveFailure = ''
662
+ @state() private showSaveModal = false
663
+ @state() private saveModalType = ''
664
+ @state() private saveModalName = ''
665
+ @state() private saveModalError = ''
561
666
  /** 저작 보조 또는 발행 게이트가 연 후보 검토 세션. 받기 전에는 정본이 아니다. */
562
667
  @state() private proposalSession?: ProposalSession
563
668
  /** 인라인 요청창과 AI 도크가 함께 쓰는 현재 Figure의 제한된 세션 피드백. */
@@ -565,6 +670,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
565
670
 
566
671
  @state() private undoStack: { board?: BoardModel; parts: PartModel[]; selected: number }[] = []
567
672
  @state() private redoStack: { board?: BoardModel; parts: PartModel[]; selected: number }[] = []
673
+ @state() private sceneRevision = 0
568
674
  private lastCoalesceKey?: string
569
675
  private lastHistoryTime = 0
570
676
 
@@ -613,13 +719,99 @@ export class FigureModellerPage extends FigureModellerPageBase {
613
719
  }))
614
720
  }
615
721
 
616
- private acceptDockProposal = (event: Event) => {
722
+ /*
723
+ `detail.staged` is the dock's answer to "did anything actually happen to my candidate". The
724
+ listener is synchronous, so the dock reads it right after dispatching and only then tells the
725
+ chat card to say so. It used to be called `accepted`, which claimed more than it knew — the
726
+ page had put the candidate somewhere, not the person had agreed to it.
727
+ */
728
+ private receiveDockProposal = (event: Event) => {
617
729
  const detail = (event as CustomEvent).detail
618
- if (!this.active || (detail?.figureId ?? '') !== (this.figure?.id ?? '')) return
619
- if (detail?.draftType && detail.draftType !== this.folded?.type) return
620
- if (detail?.baseSource && detail.baseSource !== JSON.stringify(this.folded)) return
621
- this.receive(detail.proposal)
622
- detail.accepted = true
730
+ if (!detail) return
731
+
732
+ const source =
733
+ detail?.source ||
734
+ (detail?.proposal?.source
735
+ ? typeof detail.proposal.source === 'string'
736
+ ? JSON.parse(detail.proposal.source)
737
+ : detail.proposal.source
738
+ : undefined)
739
+
740
+ if (source) {
741
+ detail.staged = this.openProposalForReview(source, detail.proposal)
742
+ }
743
+ }
744
+
745
+ private onFigureAiStage = (event: Event) => {
746
+ const detail = (event as CustomEvent).detail
747
+ const source = detail?.source || (detail?.proposal?.source ? (typeof detail.proposal.source === 'string' ? JSON.parse(detail.proposal.source) : detail.proposal.source) : undefined)
748
+ if (source) {
749
+ this.openProposalForReview(source, detail.proposal)
750
+ }
751
+ }
752
+
753
+ /**
754
+ * A candidate from the dock opens the review lane. It does not become the draft.
755
+ *
756
+ * This used to write `board` and `parts` straight onto the working draft and then set
757
+ * `proposalSession = undefined` — the dock's candidate overwrote what the author had open and
758
+ * erased the lane built to review it. There was no moment where the person said yes, and a
759
+ * draft has no version history to go back to, so the previous work was simply gone.
760
+ *
761
+ * `ai-client-base`'s analysis contract states this as a literal type: `approvalRequired: true`,
762
+ * and of `proposal`, "It is never an instruction to mutate canonical state." The in-page
763
+ * `figure-ask` entrance already honoured it through `openAiProposal`; the dock entrance is now
764
+ * the same door. Applying is what the take button does, from the lane, per change.
765
+ *
766
+ * ## Why the overwrite was there — so it does not come back
767
+ *
768
+ * The dock opened the review lane originally. Before `c42a0b63df` this handler read:
769
+ *
770
+ * if (!this.active || (detail?.figureId ?? '') !== (this.figure?.id ?? '')) return
771
+ * if (detail?.draftType && detail.draftType !== this.folded?.type) return
772
+ * if (detail?.baseSource && detail.baseSource !== JSON.stringify(this.folded)) return
773
+ * this.receive(detail.proposal)
774
+ *
775
+ * Three guards, each a silent `return`, and the last compared a serialized source byte for byte.
776
+ * Any difference that changed nothing at all dropped the candidate with no message, so the dock
777
+ * would report a candidate and the screen would show none.
778
+ *
779
+ * The `ox-assistant-chat` migration fixed that by replacing the path rather than the guards, and
780
+ * the review lane went out with them — `applySourceDirectly` wrote straight to the draft, and
781
+ * cleared `proposalSession` on the way past. **Nobody decided that reviews were unwanted here.**
782
+ * It was the cost of loosening a guard by deleting what stood around it.
783
+ *
784
+ * So the guards are gone and stay gone, and the lane is back. If a candidate ever seems not to
785
+ * arrive, the thing to look for is a silent `return` on this path — not the lane.
786
+ *
787
+ * Returns whether the lane opened, so the dock can tell the chat whether to say it staged.
788
+ */
789
+ private openProposalForReview(rawSource: any, proposal?: any): boolean {
790
+ const source = typeof rawSource === 'string' ? JSON.parse(rawSource) : rawSource
791
+ if (!source || !Array.isArray(source.parts)) return false
792
+
793
+ try {
794
+ /*
795
+ The dock sends a FigureProposal whose metrics sit at the top level; the staged-on-navigate
796
+ path carries the raw tool result, which nests them under score/cost. Read both rather than
797
+ making one of the two entrances reshape its payload.
798
+ */
799
+ this.proposalSession = proposalSessions.openAiProposal(this.folded, {
800
+ source: JSON.stringify(source),
801
+ grade: proposal?.grade ?? proposal?.score?.grade,
802
+ triangles: proposal?.triangles ?? proposal?.cost?.triangles,
803
+ groups: proposal?.groups ?? proposal?.cost?.groups,
804
+ attempts: proposal?.attempts,
805
+ quality: proposal?.quality
806
+ } as FigureProposal)
807
+
808
+ this.mode = 'edit'
809
+ this.requestUpdate()
810
+ return true
811
+ } catch (e) {
812
+ console.error('[FigureModeller] Failed to open the AI candidate for review:', e)
813
+ return false
814
+ }
623
815
  }
624
816
 
625
817
  private proposalBaseSource?: string
@@ -627,18 +819,26 @@ export class FigureModellerPage extends FigureModellerPageBase {
627
819
  connectedCallback() {
628
820
  super.connectedCallback()
629
821
  window.addEventListener('figure-ai-context-request', this.announceAiContext)
630
- window.addEventListener('figure-ai-review', this.acceptDockProposal)
822
+ window.addEventListener('figure-ai-review', this.receiveDockProposal)
823
+ window.addEventListener('figure-ai-stage', this.onFigureAiStage)
824
+ window.addEventListener('figure-save-request', this.onSaveRequest)
631
825
  window.addEventListener('keydown', this.handleKeyDown)
632
826
  }
633
827
 
634
828
  disconnectedCallback() {
635
829
  window.removeEventListener('keydown', this.handleKeyDown)
830
+ window.removeEventListener('figure-save-request', this.onSaveRequest)
831
+ window.removeEventListener('figure-ai-stage', this.onFigureAiStage)
636
832
  window.removeEventListener('figure-ai-context-request', this.announceAiContext)
637
- window.removeEventListener('figure-ai-review', this.acceptDockProposal)
833
+ window.removeEventListener('figure-ai-review', this.receiveDockProposal)
638
834
  window.dispatchEvent(new CustomEvent('figure-ai-context', { detail: undefined }))
639
835
  super.disconnectedCallback()
640
836
  }
641
837
 
838
+ private onSaveRequest = () => {
839
+ if (this.active) this.save()
840
+ }
841
+
642
842
  private handleKeyDown = (e: KeyboardEvent) => {
643
843
  if (!this.active || this.mode !== 'edit' || this.proposalSession) return
644
844
  const target = e.composedPath()[0] as HTMLElement | undefined
@@ -682,6 +882,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
682
882
  ?collapse-left=${this.collapsed.left}
683
883
  ?collapse-right=${this.collapsed.right}
684
884
  @figure-rename=${(e: CustomEvent) => this.rename(e.detail.name)}
885
+ @figure-type-change=${(e: CustomEvent) => this.typeChanged(e.detail.type)}
685
886
  @figure-catalog=${(e: CustomEvent) => this.catalog(e.detail)}
686
887
  @figure-released=${(e: CustomEvent) => this.released(e.detail.figure)}
687
888
  @figure-reverted=${() => this.figure?.id && this.load(this.figure.id)}
@@ -722,6 +923,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
722
923
  .parts=${this.parts}
723
924
  .picked=${this.pickedName}
724
925
  .view=${this.view}
926
+ .revision=${this.sceneRevision}
725
927
  ></figure-canvas>
726
928
  </div>
727
929
  <div lane candidate>
@@ -747,6 +949,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
747
949
  .parts=${this.parts}
748
950
  .picked=${this.pickedName}
749
951
  .view=${this.view}
952
+ .revision=${this.sceneRevision}
750
953
  ></figure-canvas>`}
751
954
  </div>
752
955
 
@@ -766,6 +969,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
766
969
  @update-view=${(e: CustomEvent) => (this.view = e.detail.view)}
767
970
  ></figure-side>
768
971
  </div>
972
+ ${this.renderSaveModal()}
769
973
  `
770
974
  }
771
975
 
@@ -822,6 +1026,17 @@ export class FigureModellerPage extends FigureModellerPageBase {
822
1026
  `
823
1027
  : ''}
824
1028
  <div spacer></div>
1029
+ <button
1030
+ ai-support
1031
+ title=${i18next.t('figure.button.ask')}
1032
+ @click=${() => {
1033
+ requestFigureAI({ figureId: this.figure?.id, source: this.folded })
1034
+ openOverlay('figure-ai-dock', { backdrop: false })
1035
+ }}
1036
+ >
1037
+ <md-icon>auto_awesome</md-icon>
1038
+ <span>AI</span>
1039
+ </button>
825
1040
  ${this.saveFailure ? html`<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
826
1041
  ${this.dirty ? html`<span dirty>${i18next.t('figure.text.unsaved-changes')}</span>` : ''}
827
1042
  <button save ?disabled=${!this.canSave} @click=${() => this.save()}>
@@ -1085,7 +1300,11 @@ export class FigureModellerPage extends FigureModellerPageBase {
1085
1300
  * 저장이 거절되면 `dirty` 를 그대로 두므로 다시 누를 수 있다.
1086
1301
  */
1087
1302
  private get canSave(): boolean {
1088
- if (this.saving || !this.dirty || !this.folded || !this.figure?.name) {
1303
+ if (this.saving || !this.dirty || !this.folded) {
1304
+ return false
1305
+ }
1306
+ // 기존 저장된 모델은 이름이 있어야 바로 저장 가능. 신규 모델은 저장 팝업에서 이름을 입력할 수 있음
1307
+ if (this.figure?.id && !this.figure?.name) {
1089
1308
  return false
1090
1309
  }
1091
1310
  return validate(this.folded).errors.length === 0
@@ -1103,6 +1322,10 @@ export class FigureModellerPage extends FigureModellerPageBase {
1103
1322
  if (changes.active || id !== this.figure?.id) {
1104
1323
  await this.load(id)
1105
1324
  }
1325
+ const pending = consumePendingFigureProposal()
1326
+ if (pending?.source) {
1327
+ this.openProposalForReview(pending.source, pending.proposal)
1328
+ }
1106
1329
  return
1107
1330
  }
1108
1331
 
@@ -1120,18 +1343,35 @@ export class FigureModellerPage extends FigureModellerPageBase {
1120
1343
  */
1121
1344
  if (!this.figure) {
1122
1345
  this.startNew()
1346
+ const pending = consumePendingFigureProposal()
1347
+ if (pending?.source) {
1348
+ this.openProposalForReview(pending.source, pending.proposal)
1349
+ }
1123
1350
  return
1124
1351
  }
1125
1352
 
1126
1353
  if (this.figure.id) {
1127
1354
  this.startNew()
1355
+ const pending = consumePendingFigureProposal()
1356
+ if (pending?.source) {
1357
+ this.openProposalForReview(pending.source, pending.proposal)
1358
+ }
1128
1359
  return
1129
1360
  }
1130
1361
 
1131
1362
  if (shouldStartNewFigure(changes, !!this.figure, this.figure?.id)) {
1132
1363
  this.startNew()
1364
+ const pending = consumePendingFigureProposal()
1365
+ if (pending?.source) {
1366
+ this.openProposalForReview(pending.source, pending.proposal)
1367
+ }
1133
1368
  return
1134
1369
  }
1370
+
1371
+ const pending = consumePendingFigureProposal()
1372
+ if (pending?.source) {
1373
+ this.openProposalForReview(pending.source, pending.proposal)
1374
+ }
1135
1375
  }
1136
1376
 
1137
1377
  private loadGeneration = 0
@@ -1209,6 +1449,15 @@ export class FigureModellerPage extends FigureModellerPageBase {
1209
1449
  this.dirty = true
1210
1450
  }
1211
1451
 
1452
+ private typeChanged(type: string) {
1453
+ if (!type || this.figure?.id) return
1454
+ const sanitized = type.replace(/[^A-Za-z0-9_]/g, '_').toUpperCase()
1455
+ if (this.board) this.board.figureType = sanitized
1456
+ this.figure = { ...this.figure, type: sanitized } as Figure
1457
+ this.folded = this.board ? toFigureSource(this.board, this.parts) : undefined
1458
+ this.dirty = true
1459
+ }
1460
+
1212
1461
  /**
1213
1462
  * 분류와 태그를 고친다.
1214
1463
  *
@@ -1455,71 +1704,137 @@ export class FigureModellerPage extends FigureModellerPageBase {
1455
1704
  }
1456
1705
  }
1457
1706
 
1458
- private async save() {
1707
+ private save() {
1459
1708
  if (!this.canSave || !this.folded || !this.figure) {
1460
1709
  return
1461
1710
  }
1462
1711
 
1712
+ if (!this.figure.id) {
1713
+ // 신규 저장: 타입 코드와 표시 이름을 명확히 팝업으로 확인/입력받음
1714
+ let defaultType = this.board?.figureType || this.figure.type || ''
1715
+ if (!defaultType || defaultType.startsWith('FIGURE_')) {
1716
+ defaultType = 'FIGURE'
1717
+ }
1718
+ this.saveModalType = defaultType
1719
+ this.saveModalName = this.figure.name || ''
1720
+ this.saveModalError = ''
1721
+ this.showSaveModal = true
1722
+ return
1723
+ }
1724
+
1725
+ void this.doSave()
1726
+ }
1727
+
1728
+ private async doSave() {
1729
+ if (!this.folded || !this.figure) return
1730
+
1463
1731
  this.saving = true
1464
1732
  this.saveFailure = ''
1465
- try {
1466
- const sourceJson = JSON.stringify(this.folded)
1467
- /*
1468
- 보이는 것을 그대로 찍어 함께 보낸다.
1733
+ this.saveModalError = ''
1469
1734
 
1470
- 카드가 그림 없이 서 있으면 카탈로그에서 무엇인지 못 고른다. 찍는 자리를 저장에 두는 이유는
1471
- **그 순간이 정본이 바뀌는 순간**이라서다 — 따로 찍는 절차를 두면 그림과 정본이 갈린다.
1735
+ try {
1736
+ if (!this.figure.id) {
1737
+ const typeToSave = (this.saveModalType || this.board?.figureType || this.figure.type || 'FIGURE').trim().toUpperCase()
1738
+ const nameToSave = (this.saveModalName || this.figure.name || typeToSave).trim()
1472
1739
 
1473
- 찍으면 보내지 않는다(`undefined`). 빈 그림을 넣어 「그림이 있다」고 말하지 않는다.
1474
- */
1475
- const thumbnail = await captureThumbnail(this.board, this.parts)
1740
+ if (!typeToSave) {
1741
+ throw new Error('타입 이름을 입력하세요.')
1742
+ }
1476
1743
 
1477
- if (this.figure.id) {
1478
- const result = await updateFigure(this.figure.id, {
1479
- name: this.figure.name,
1480
- /*
1481
- 카탈로그의 값도 함께 보낸다. 화면에서 고쳐 놓고 저장에 안 실으면 저장 뒤에 조용히
1482
- 사라진다 — 저작자는 저장했다고 보고 다시 열면 없다.
1483
- */
1484
- category: this.figure.category,
1485
- tags: this.figure.tags,
1486
- source: sourceJson,
1487
- thumbnail
1488
- })
1489
- this.figure = result.figure
1490
- this.violations = result.violations
1491
- } else {
1492
- // 새로 만들 때만 타입 이름이 정해진다. 이미 쓰이는 이름이면 저장 전에 막는다 —
1493
- // 만든 뒤에는 고칠 수 없으므로 여기서 걸러야 한다.
1494
1744
  const taken = await fetchFigureTypeNames()
1495
- if (taken.includes(this.folded.type)) {
1496
- throw new Error(i18next.t('figure.text.type-name-already-taken', { type: this.folded.type }))
1745
+ if (taken.includes(typeToSave)) {
1746
+ throw new Error(i18next.t('figure.text.type-name-already-taken', { type: typeToSave }))
1497
1747
  }
1498
1748
 
1749
+ if (this.board) this.board.figureType = typeToSave
1750
+ this.figure = { ...this.figure, type: typeToSave, name: nameToSave }
1751
+ this.folded = { ...this.folded, type: typeToSave }
1752
+ const sourceJson = JSON.stringify(this.folded)
1753
+ const thumbnail = await captureThumbnail(this.board, this.parts)
1754
+
1499
1755
  const result = await createFigure({
1500
- type: this.folded.type,
1501
- name: this.figure.name,
1756
+ type: typeToSave,
1757
+ name: nameToSave,
1502
1758
  category: this.figure.category,
1503
1759
  tags: this.figure.tags,
1504
1760
  source: sourceJson,
1505
1761
  thumbnail
1506
1762
  })
1763
+
1507
1764
  this.figure = result.figure
1508
1765
  this.violations = result.violations
1766
+ this.showSaveModal = false
1767
+ this.dirty = false
1509
1768
  navigate(`figure-modeller/${result.figure.id}`)
1769
+ return
1510
1770
  }
1511
1771
 
1772
+ // 기존 모델 업데이트: folded.type이 figure.type과 일치하도록 보장
1773
+ if (this.figure.type && this.folded.type !== this.figure.type) {
1774
+ this.folded = { ...this.folded, type: this.figure.type }
1775
+ if (this.board) this.board.figureType = this.figure.type
1776
+ }
1777
+ const sourceJson = JSON.stringify(this.folded)
1778
+ const thumbnail = await captureThumbnail(this.board, this.parts)
1779
+
1780
+ const result = await updateFigure(this.figure.id, {
1781
+ name: this.figure.name,
1782
+ category: this.figure.category,
1783
+ tags: this.figure.tags,
1784
+ source: sourceJson,
1785
+ thumbnail
1786
+ })
1787
+ this.figure = result.figure
1788
+ this.violations = result.violations
1512
1789
  this.dirty = false
1513
1790
  } catch (e) {
1514
- /*
1515
- **서버가 말한 사유를 그대로 올린다.** 여기서 문장을 다시 지으면 무엇이 걸렸는지 알 수 없다 —
1516
- 타입 이름이 이미 쓰였다는 것과 권한이 없다는 것은 저작자가 할 일이 다르다.
1517
-
1518
- `dirty` 는 건드리지 않는다. 저장이 안 됐으므로 미저장인 것이 사실이다.
1519
- */
1520
- this.saveFailure = (e as Error).message
1791
+ const msg = (e as Error).message
1792
+ this.saveFailure = msg
1793
+ this.saveModalError = msg
1521
1794
  } finally {
1522
1795
  this.saving = false
1523
1796
  }
1524
1797
  }
1798
+
1799
+ private renderSaveModal() {
1800
+ if (!this.showSaveModal) return nothing
1801
+
1802
+ return html`
1803
+ <div class="save-modal-backdrop" @click=${(e: Event) => { if (e.target === e.currentTarget) this.showSaveModal = false }}>
1804
+ <div class="save-modal-card" role="dialog" aria-modal="true">
1805
+ <h3 class="save-modal-title">${i18next.t('figure.title.save-new-figure', { defaultValue: '도형 초안 저장' })}</h3>
1806
+ <p class="save-modal-desc">${i18next.t('figure.text.save-new-figure-desc', { defaultValue: '보드와 캔버스에서 식별할 고유 타입 코드와 이름을 정합니다.' })}</p>
1807
+
1808
+ <div class="save-modal-field">
1809
+ <label>${i18next.t('figure.label.figure-type', { defaultValue: '타입 코드 (영문 대문자)' })}</label>
1810
+ <input
1811
+ .value=${this.saveModalType}
1812
+ placeholder="예: OHT, AGV_LIFTER"
1813
+ @input=${(e: Event) => (this.saveModalType = (e.target as HTMLInputElement).value.trim().toUpperCase())}
1814
+ />
1815
+ </div>
1816
+
1817
+ <div class="save-modal-field">
1818
+ <label>${i18next.t('figure.label.name', { defaultValue: '도형 이름' })}</label>
1819
+ <input
1820
+ .value=${this.saveModalName}
1821
+ placeholder="예: 오버헤드 호이스트 트랜스포트"
1822
+ @input=${(e: Event) => (this.saveModalName = (e.target as HTMLInputElement).value)}
1823
+ />
1824
+ </div>
1825
+
1826
+ ${this.saveModalError ? html`<div class="save-modal-error">${this.saveModalError}</div>` : nothing}
1827
+
1828
+ <div class="save-modal-actions">
1829
+ <button class="btn-cancel" @click=${() => (this.showSaveModal = false)}>
1830
+ ${i18next.t('figure.button.cancel', { defaultValue: '취소' })}
1831
+ </button>
1832
+ <button class="btn-confirm" ?disabled=${this.saving || !this.saveModalType} @click=${() => this.doSave()}>
1833
+ ${this.saving ? i18next.t('figure.text.saving-figure', { defaultValue: '저장 중...' }) : i18next.t('figure.button.save', { defaultValue: '저장' })}
1834
+ </button>
1835
+ </div>
1836
+ </div>
1837
+ </div>
1838
+ `
1839
+ }
1525
1840
  }
@@ -6,3 +6,13 @@ export declare function takeFigureAITarget(): {
6
6
  figureId?: string;
7
7
  source?: unknown;
8
8
  } | undefined;
9
+ export declare function stageFigureProposal(data: {
10
+ figureId?: string;
11
+ source?: any;
12
+ proposal?: any;
13
+ }): void;
14
+ export declare function consumePendingFigureProposal(): {
15
+ figureId?: string;
16
+ source?: any;
17
+ proposal?: any;
18
+ } | undefined;
@@ -9,4 +9,14 @@ export function takeFigureAITarget() {
9
9
  pending = undefined;
10
10
  return target;
11
11
  }
12
+ let pendingStage;
13
+ export function stageFigureProposal(data) {
14
+ pendingStage = structuredClone(data);
15
+ window.dispatchEvent(new CustomEvent('figure-ai-stage', { detail: data }));
16
+ }
17
+ export function consumePendingFigureProposal() {
18
+ const target = pendingStage;
19
+ pendingStage = undefined;
20
+ return target;
21
+ }
12
22
  //# sourceMappingURL=figure-ai-target.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"figure-ai-target.js","sourceRoot":"","sources":["../../client/modeller/figure-ai-target.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,IAAI,OAA4D,CAAA;AAChE,MAAM,UAAU,eAAe,CAAC,MAA+C;IAC7E,OAAO,GAAG,eAAe,CAAC,MAAM,CAAC,CAAA;IACjC,MAAM,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,kBAAkB,CAAC,CAAC,CAAA;AAC3D,CAAC;AACD,MAAM,UAAU,kBAAkB;IAChC,MAAM,MAAM,GAAG,OAAO,CAAA;IACtB,OAAO,GAAG,SAAS,CAAA;IACnB,OAAO,MAAM,CAAA;AACf,CAAC","sourcesContent":["/** Explicit AI entry survives lazy creation of the app-level dock. */\nlet pending: { figureId?: string; source?: unknown } | undefined\nexport function requestFigureAI(target: { figureId?: string; source?: unknown }) {\n pending = structuredClone(target)\n window.dispatchEvent(new CustomEvent('figure-ai-target'))\n}\nexport function takeFigureAITarget() {\n const target = pending\n pending = undefined\n return target\n}\n"]}
1
+ {"version":3,"file":"figure-ai-target.js","sourceRoot":"","sources":["../../client/modeller/figure-ai-target.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,IAAI,OAA4D,CAAA;AAChE,MAAM,UAAU,eAAe,CAAC,MAA+C;IAC7E,OAAO,GAAG,eAAe,CAAC,MAAM,CAAC,CAAA;IACjC,MAAM,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,kBAAkB,CAAC,CAAC,CAAA;AAC3D,CAAC;AACD,MAAM,UAAU,kBAAkB;IAChC,MAAM,MAAM,GAAG,OAAO,CAAA;IACtB,OAAO,GAAG,SAAS,CAAA;IACnB,OAAO,MAAM,CAAA;AACf,CAAC;AAED,IAAI,YAA6E,CAAA;AACjF,MAAM,UAAU,mBAAmB,CAAC,IAAyD;IAC3F,YAAY,GAAG,eAAe,CAAC,IAAI,CAAC,CAAA;IACpC,MAAM,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,iBAAiB,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,CAAA;AAC5E,CAAC;AAED,MAAM,UAAU,4BAA4B;IAC1C,MAAM,MAAM,GAAG,YAAY,CAAA;IAC3B,YAAY,GAAG,SAAS,CAAA;IACxB,OAAO,MAAM,CAAA;AACf,CAAC","sourcesContent":["/** Explicit AI entry survives lazy creation of the app-level dock. */\nlet pending: { figureId?: string; source?: unknown } | undefined\nexport function requestFigureAI(target: { figureId?: string; source?: unknown }) {\n pending = structuredClone(target)\n window.dispatchEvent(new CustomEvent('figure-ai-target'))\n}\nexport function takeFigureAITarget() {\n const target = pending\n pending = undefined\n return target\n}\n\nlet pendingStage: { figureId?: string; source?: any; proposal?: any } | undefined\nexport function stageFigureProposal(data: { figureId?: string; source?: any; proposal?: any }) {\n pendingStage = structuredClone(data)\n window.dispatchEvent(new CustomEvent('figure-ai-stage', { detail: data }))\n}\n\nexport function consumePendingFigureProposal() {\n const target = pendingStage\n pendingStage = undefined\n return target\n}\n"]}
@@ -46,6 +46,7 @@ export declare class FigureCanvas extends FigureCanvas_base {
46
46
  /** 보기 설정. 자산이 아니라 작업대다 — 저장되지 않는다(`figure-view.ts`). */
47
47
  view: ViewSettings;
48
48
  parts: PartModel[];
49
+ revision: number;
49
50
  /** 지금 고른 부품 이름. 목록에서 고른 것을 캔버스에 알린다. */
50
51
  picked?: string;
51
52
  /**