@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.
- package/client/modeller/figure-ai-target.ts +12 -0
- package/client/modeller/figure-canvas.ts +18 -2
- package/client/modeller/figure-inspector.ts +48 -7
- package/client/modeller/figure-side.ts +1 -0
- package/client/modeller/figure-source.test.ts +2 -0
- package/client/modeller/figure-source.ts +25 -3
- package/client/pages/figure-modeller-page.ts +364 -49
- package/dist-client/modeller/figure-ai-target.d.ts +10 -0
- package/dist-client/modeller/figure-ai-target.js +10 -0
- package/dist-client/modeller/figure-ai-target.js.map +1 -1
- package/dist-client/modeller/figure-canvas.d.ts +1 -0
- package/dist-client/modeller/figure-canvas.js +21 -2
- package/dist-client/modeller/figure-canvas.js.map +1 -1
- package/dist-client/modeller/figure-inspector.d.ts +4 -1
- package/dist-client/modeller/figure-inspector.js +47 -6
- package/dist-client/modeller/figure-inspector.js.map +1 -1
- package/dist-client/modeller/figure-side.js +1 -0
- package/dist-client/modeller/figure-side.js.map +1 -1
- package/dist-client/modeller/figure-source.d.ts +10 -3
- package/dist-client/modeller/figure-source.js +5 -0
- package/dist-client/modeller/figure-source.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.d.ts +48 -1
- package/dist-client/pages/figure-modeller-page.js +376 -52
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/ai-proposal-contract.test.ts +3 -3
- package/translations/en.json +10 -1
- 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
|
-
|
|
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 (!
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
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(
|
|
1496
|
-
throw new Error(i18next.t('figure.text.type-name-already-taken', { 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:
|
|
1501
|
-
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"]}
|