@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,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
|
-
|
|
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 (!
|
|
146
|
-
return;
|
|
147
|
-
if (detail?.draftType && detail.draftType !== this.folded?.type)
|
|
158
|
+
if (!detail)
|
|
148
159
|
return;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1398
|
-
throw new Error(i18next.t('figure.text.type-name-already-taken', { 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:
|
|
1402
|
-
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);
|