@things-factory/figure-ui 10.1.13 → 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.
- package/client/modeller/figure-ai-target.ts +12 -0
- package/client/modeller/figure-canvas.ts +26 -4
- 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 +17 -21
- package/client/pages/figure-modeller-page.ts +521 -161
- 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 +2 -0
- package/dist-client/modeller/figure-canvas.js +29 -4
- 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 +15 -21
- 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 +20 -0
- package/dist-client/pages/figure-modeller-page.js +531 -163
- package/dist-client/pages/figure-modeller-page.js.map +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 +21 -2
- package/test/modeller-undo-redo.test.ts +33 -0
- package/translations/en.json +12 -1
- package/translations/ja.json +2 -0
- package/translations/ko.json +13 -1
- package/translations/ms.json +2 -0
- package/translations/zh.json +2 -0
|
@@ -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
|
|
|
@@ -188,90 +188,9 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
188
188
|
display: none;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
/*
|
|
192
|
-
머리줄 — 이름 · 타입 · 미저장 표시 · 말로 시키는 줄 · 저장을 **한 줄에** 담는다.
|
|
193
|
-
|
|
194
|
-
이름은 페이지 제목에도 나온다(셸이 context.title 로 그린다). 그래도 여기 입력칸을 두는
|
|
195
|
-
이유는 **고치는 자리**가 필요해서다 — 제목은 읽는 것이고 이 칸은 바꾸는 것이다. 두 줄로
|
|
196
|
-
벌리는 대신 한 줄에 모아 캔버스에 높이를 넘긴다.
|
|
197
|
-
*/
|
|
198
|
-
div[header] {
|
|
199
|
-
display: flex;
|
|
200
|
-
align-items: center;
|
|
201
|
-
gap: var(--spacing-medium, 8px);
|
|
202
|
-
padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
|
|
203
|
-
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
204
|
-
}
|
|
205
|
-
button[ai-support] {
|
|
206
|
-
height: 28px;
|
|
207
|
-
padding: 0 12px;
|
|
208
|
-
border: 1px solid var(--md-sys-color-outline-variant);
|
|
209
|
-
border-radius: 6px;
|
|
210
|
-
background: var(--md-sys-color-primary-container);
|
|
211
|
-
color: var(--md-sys-color-on-primary-container);
|
|
212
|
-
font: inherit;
|
|
213
|
-
cursor: pointer;
|
|
214
|
-
}
|
|
215
|
-
input[name] {
|
|
216
|
-
flex: none;
|
|
217
|
-
width: 180px;
|
|
218
|
-
padding: 5px 9px;
|
|
219
|
-
font: var(--input-field-font, inherit);
|
|
220
|
-
font-size: 0.82rem;
|
|
221
|
-
color: var(--md-sys-color-on-surface);
|
|
222
|
-
background-color: var(--md-sys-color-surface-container-lowest);
|
|
223
|
-
border: 1px solid var(--md-sys-color-outline-variant);
|
|
224
|
-
border-radius: 6px;
|
|
225
|
-
}
|
|
226
|
-
/* 타입 이름은 만든 뒤 고칠 수 없다 — 그 사실이 화면에서 보여야 한다 */
|
|
227
|
-
code[type] {
|
|
228
|
-
padding: 4px 8px;
|
|
229
|
-
font-family: var(--mono-font, monospace);
|
|
230
|
-
font-size: 0.76rem;
|
|
231
|
-
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
232
|
-
background-color: var(--md-sys-color-surface-container);
|
|
233
|
-
border-radius: 6px;
|
|
234
|
-
}
|
|
235
|
-
span[dirty] {
|
|
236
|
-
font: var(--label-font, inherit);
|
|
237
|
-
font-size: 0.74rem;
|
|
238
|
-
color: var(--md-sys-color-tertiary);
|
|
239
|
-
}
|
|
240
191
|
div[spacer] {
|
|
241
192
|
flex: 1;
|
|
242
193
|
}
|
|
243
|
-
button[save] {
|
|
244
|
-
padding: 6px 16px;
|
|
245
|
-
font: var(--label-font, inherit);
|
|
246
|
-
font-size: 0.76rem;
|
|
247
|
-
font-weight: 600;
|
|
248
|
-
color: var(--md-sys-color-on-primary, #fff);
|
|
249
|
-
background-color: var(--md-sys-color-primary);
|
|
250
|
-
border: none;
|
|
251
|
-
border-radius: 6px;
|
|
252
|
-
cursor: pointer;
|
|
253
|
-
}
|
|
254
|
-
button[save][disabled] {
|
|
255
|
-
opacity: 0.4;
|
|
256
|
-
cursor: default;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/*
|
|
260
|
-
저장이 거절된 사유. 단추 **바로 왼쪽**에 둔다 — 누른 자리에서 답을 봐야 한다. 길면 줄이지만
|
|
261
|
-
전문은 title 로 남긴다(서버 문장이 길 수 있다).
|
|
262
|
-
*/
|
|
263
|
-
span[save-failed] {
|
|
264
|
-
max-width: 280px;
|
|
265
|
-
overflow: hidden;
|
|
266
|
-
text-overflow: ellipsis;
|
|
267
|
-
white-space: nowrap;
|
|
268
|
-
padding: 4px 8px;
|
|
269
|
-
font: var(--label-font, inherit);
|
|
270
|
-
font-size: 0.72rem;
|
|
271
|
-
color: var(--md-sys-color-on-error-container, var(--md-sys-color-on-surface));
|
|
272
|
-
background-color: var(--md-sys-color-error-container);
|
|
273
|
-
border-radius: 6px;
|
|
274
|
-
}
|
|
275
194
|
|
|
276
195
|
/*
|
|
277
196
|
칸을 벡터 저작도구의 관례대로 놓는다 — 왼쪽에 무엇이 있나(레이어), 오른쪽에
|
|
@@ -294,16 +213,17 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
294
213
|
}
|
|
295
214
|
|
|
296
215
|
/*
|
|
297
|
-
편집 · 미리보기
|
|
216
|
+
편집 · 미리보기 전환 및 조작 띠.
|
|
298
217
|
|
|
299
218
|
가운데 칸 위에 얇게 둔다. 무엇을 보고 있는지가 늘 보여야 하고, 두 화면이 같은
|
|
300
219
|
자리를 쓰므로 어느 쪽인지 모르면 편집이 안 먹는 것처럼 읽힌다.
|
|
301
220
|
*/
|
|
302
221
|
div[modes] {
|
|
303
222
|
display: flex;
|
|
223
|
+
align-items: center;
|
|
304
224
|
flex: none;
|
|
305
225
|
gap: 2px;
|
|
306
|
-
padding:
|
|
226
|
+
padding: 4px var(--spacing-medium, 8px);
|
|
307
227
|
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
308
228
|
}
|
|
309
229
|
div[modes] button[mode] {
|
|
@@ -324,13 +244,36 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
324
244
|
color: var(--md-sys-color-on-primary-container);
|
|
325
245
|
background-color: var(--md-sys-color-primary-container);
|
|
326
246
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
247
|
+
div[modes] div[separator] {
|
|
248
|
+
width: 1px;
|
|
249
|
+
height: 16px;
|
|
250
|
+
margin: 0 4px;
|
|
251
|
+
background-color: var(--md-sys-color-outline-variant);
|
|
252
|
+
}
|
|
253
|
+
div[modes] button[history] {
|
|
254
|
+
display: flex;
|
|
255
|
+
align-items: center;
|
|
256
|
+
justify-content: center;
|
|
257
|
+
width: 26px;
|
|
258
|
+
height: 26px;
|
|
259
|
+
padding: 0;
|
|
260
|
+
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
261
|
+
background: none;
|
|
262
|
+
border: none;
|
|
263
|
+
border-radius: 6px;
|
|
264
|
+
cursor: pointer;
|
|
265
|
+
}
|
|
266
|
+
div[modes] button[history]:hover:not([disabled]) {
|
|
267
|
+
background-color: var(--md-sys-color-surface-container);
|
|
268
|
+
color: var(--md-sys-color-on-surface);
|
|
269
|
+
}
|
|
270
|
+
div[modes] button[history][disabled] {
|
|
271
|
+
opacity: 0.3;
|
|
272
|
+
cursor: default;
|
|
273
|
+
}
|
|
274
|
+
div[modes] button[history] md-icon {
|
|
275
|
+
--md-icon-size: 18px;
|
|
276
|
+
}
|
|
334
277
|
div[modes] button[collapse] {
|
|
335
278
|
display: flex;
|
|
336
279
|
align-items: center;
|
|
@@ -348,6 +291,67 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
348
291
|
div[modes] button[collapse] md-icon {
|
|
349
292
|
--md-icon-size: 18px;
|
|
350
293
|
}
|
|
294
|
+
div[modes] span[dirty] {
|
|
295
|
+
align-self: center;
|
|
296
|
+
margin-right: 6px;
|
|
297
|
+
font: var(--label-font, inherit);
|
|
298
|
+
font-size: 0.72rem;
|
|
299
|
+
color: var(--md-sys-color-tertiary);
|
|
300
|
+
}
|
|
301
|
+
div[modes] span[save-failed] {
|
|
302
|
+
align-self: center;
|
|
303
|
+
max-width: 240px;
|
|
304
|
+
overflow: hidden;
|
|
305
|
+
text-overflow: ellipsis;
|
|
306
|
+
white-space: nowrap;
|
|
307
|
+
margin-right: 6px;
|
|
308
|
+
padding: 3px 7px;
|
|
309
|
+
font: var(--label-font, inherit);
|
|
310
|
+
font-size: 0.7rem;
|
|
311
|
+
color: var(--md-sys-color-on-error-container, var(--md-sys-color-on-surface));
|
|
312
|
+
background-color: var(--md-sys-color-error-container);
|
|
313
|
+
border-radius: 4px;
|
|
314
|
+
}
|
|
315
|
+
div[modes] button[save] {
|
|
316
|
+
padding: 4px 14px;
|
|
317
|
+
margin-right: 4px;
|
|
318
|
+
font: var(--label-font, inherit);
|
|
319
|
+
font-size: 0.74rem;
|
|
320
|
+
font-weight: 600;
|
|
321
|
+
color: var(--md-sys-color-on-primary, #fff);
|
|
322
|
+
background-color: var(--md-sys-color-primary);
|
|
323
|
+
border: none;
|
|
324
|
+
border-radius: 6px;
|
|
325
|
+
cursor: pointer;
|
|
326
|
+
}
|
|
327
|
+
div[modes] button[save]:hover:not([disabled]) {
|
|
328
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
|
329
|
+
}
|
|
330
|
+
div[modes] button[save][disabled] {
|
|
331
|
+
opacity: 0.35;
|
|
332
|
+
cursor: default;
|
|
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
|
+
}
|
|
351
355
|
|
|
352
356
|
/*
|
|
353
357
|
후보가 오면 가운데를 둘로 나눈다.
|
|
@@ -534,6 +538,86 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
534
538
|
line-height: 1.5;
|
|
535
539
|
color: var(--md-sys-color-error);
|
|
536
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
|
+
}
|
|
537
621
|
`
|
|
538
622
|
|
|
539
623
|
/** 편집 중인 Figure. 새로 만드는 중이면 `id` 가 없다. */
|
|
@@ -575,11 +659,21 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
575
659
|
* 미저장 표시(`dirty`)는 그대로 남으므로 데이터를 잃지는 않았다. 잃은 것은 **사유**다.
|
|
576
660
|
*/
|
|
577
661
|
@state() private saveFailure = ''
|
|
662
|
+
@state() private showSaveModal = false
|
|
663
|
+
@state() private saveModalType = ''
|
|
664
|
+
@state() private saveModalName = ''
|
|
665
|
+
@state() private saveModalError = ''
|
|
578
666
|
/** 저작 보조 또는 발행 게이트가 연 후보 검토 세션. 받기 전에는 정본이 아니다. */
|
|
579
667
|
@state() private proposalSession?: ProposalSession
|
|
580
668
|
/** 인라인 요청창과 AI 도크가 함께 쓰는 현재 Figure의 제한된 세션 피드백. */
|
|
581
669
|
@state() private proposalFeedback: ProposalFeedback[] = []
|
|
582
670
|
|
|
671
|
+
@state() private undoStack: { board?: BoardModel; parts: PartModel[]; selected: number }[] = []
|
|
672
|
+
@state() private redoStack: { board?: BoardModel; parts: PartModel[]; selected: number }[] = []
|
|
673
|
+
@state() private sceneRevision = 0
|
|
674
|
+
private lastCoalesceKey?: string
|
|
675
|
+
private lastHistoryTime = 0
|
|
676
|
+
|
|
583
677
|
/**
|
|
584
678
|
* 저장 형식으로 변환한 것. 씬 모델이 바뀔 때만 다시 만든다.
|
|
585
679
|
*
|
|
@@ -627,11 +721,59 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
627
721
|
|
|
628
722
|
private acceptDockProposal = (event: Event) => {
|
|
629
723
|
const detail = (event as CustomEvent).detail
|
|
630
|
-
if (!
|
|
631
|
-
if (detail?.
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
724
|
+
if (!detail) return
|
|
725
|
+
if (detail?.baseSource && detail.baseSource !== JSON.stringify(this.folded)) {
|
|
726
|
+
try {
|
|
727
|
+
const parsedBase = typeof detail.baseSource === 'string' ? JSON.parse(detail.baseSource) : detail.baseSource
|
|
728
|
+
if (parsedBase?.type && this.folded?.type && parsedBase.type !== this.folded.type) {
|
|
729
|
+
// type 불일치 시에만 차단하고 그 외 미세 차이는 수용
|
|
730
|
+
}
|
|
731
|
+
} catch {
|
|
732
|
+
// ignore
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
const source = detail?.source || (detail?.proposal?.source ? (typeof detail.proposal.source === 'string' ? JSON.parse(detail.proposal.source) : detail.proposal.source) : undefined)
|
|
736
|
+
if (source) {
|
|
737
|
+
this.applySourceDirectly(source, detail.proposal)
|
|
738
|
+
detail.accepted = true
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
private onFigureAiStage = (event: Event) => {
|
|
743
|
+
const detail = (event as CustomEvent).detail
|
|
744
|
+
const source = detail?.source || (detail?.proposal?.source ? (typeof detail.proposal.source === 'string' ? JSON.parse(detail.proposal.source) : detail.proposal.source) : undefined)
|
|
745
|
+
if (source) {
|
|
746
|
+
this.applySourceDirectly(source, detail.proposal)
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
private applySourceDirectly(rawSource: any, proposal?: any) {
|
|
751
|
+
const source = typeof rawSource === 'string' ? JSON.parse(rawSource) : rawSource
|
|
752
|
+
if (!source || !Array.isArray(source.parts)) return
|
|
753
|
+
|
|
754
|
+
this.recordHistory()
|
|
755
|
+
try {
|
|
756
|
+
const { board, parts } = fromFigureSource(source)
|
|
757
|
+
this.board = board
|
|
758
|
+
this.parts = parts
|
|
759
|
+
this.dirty = true
|
|
760
|
+
this.selected = parts.length ? 0 : -1
|
|
761
|
+
|
|
762
|
+
if (source.type) {
|
|
763
|
+
this.figure = { ...(this.figure || {}), type: source.type } as Figure
|
|
764
|
+
if (this.board) {
|
|
765
|
+
this.board = { ...this.board, figureType: source.type }
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
this.proposalSession = undefined
|
|
770
|
+
this.sceneRevision++
|
|
771
|
+
|
|
772
|
+
this.requestUpdate()
|
|
773
|
+
document.dispatchEvent(new CustomEvent('notify', { detail: { message: `${source.type || 'Figure'} 모델이 3D 뷰어에 반영되었습니다.` } }))
|
|
774
|
+
} catch (e) {
|
|
775
|
+
console.error('[FigureModeller] Failed to apply AI proposal source:', e)
|
|
776
|
+
}
|
|
635
777
|
}
|
|
636
778
|
|
|
637
779
|
private proposalBaseSource?: string
|
|
@@ -640,15 +782,42 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
640
782
|
super.connectedCallback()
|
|
641
783
|
window.addEventListener('figure-ai-context-request', this.announceAiContext)
|
|
642
784
|
window.addEventListener('figure-ai-review', this.acceptDockProposal)
|
|
785
|
+
window.addEventListener('figure-ai-stage', this.onFigureAiStage)
|
|
786
|
+
window.addEventListener('figure-save-request', this.onSaveRequest)
|
|
787
|
+
window.addEventListener('keydown', this.handleKeyDown)
|
|
643
788
|
}
|
|
644
789
|
|
|
645
790
|
disconnectedCallback() {
|
|
791
|
+
window.removeEventListener('keydown', this.handleKeyDown)
|
|
792
|
+
window.removeEventListener('figure-save-request', this.onSaveRequest)
|
|
793
|
+
window.removeEventListener('figure-ai-stage', this.onFigureAiStage)
|
|
646
794
|
window.removeEventListener('figure-ai-context-request', this.announceAiContext)
|
|
647
795
|
window.removeEventListener('figure-ai-review', this.acceptDockProposal)
|
|
648
796
|
window.dispatchEvent(new CustomEvent('figure-ai-context', { detail: undefined }))
|
|
649
797
|
super.disconnectedCallback()
|
|
650
798
|
}
|
|
651
799
|
|
|
800
|
+
private onSaveRequest = () => {
|
|
801
|
+
if (this.active) this.save()
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
private handleKeyDown = (e: KeyboardEvent) => {
|
|
805
|
+
if (!this.active || this.mode !== 'edit' || this.proposalSession) return
|
|
806
|
+
const target = e.composedPath()[0] as HTMLElement | undefined
|
|
807
|
+
const tag = target?.tagName?.toLowerCase()
|
|
808
|
+
if (tag === 'input' || tag === 'textarea' || target?.isContentEditable) return
|
|
809
|
+
|
|
810
|
+
if ((e.metaKey || e.ctrlKey) && !e.altKey) {
|
|
811
|
+
if (e.key === 'z' && !e.shiftKey) {
|
|
812
|
+
e.preventDefault()
|
|
813
|
+
this.undo()
|
|
814
|
+
} else if ((e.key === 'z' && e.shiftKey) || e.key === 'y') {
|
|
815
|
+
e.preventDefault()
|
|
816
|
+
this.redo()
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
652
821
|
get context() {
|
|
653
822
|
return {
|
|
654
823
|
title: this.figure?.name ?? i18next.t('figure.title.new-figure'),
|
|
@@ -670,32 +839,12 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
670
839
|
const source = this.folded
|
|
671
840
|
|
|
672
841
|
return html`
|
|
673
|
-
<div header>
|
|
674
|
-
<!--
|
|
675
|
-
이름과 타입은 **속성 패널**에 있다(figure-inspector 의 도형 절). 여기 있던 이름 칸은
|
|
676
|
-
하네스 시절의 자리였다 — 앱에서는 셸이 페이지 제목으로 같은 이름을 그리므로 글자가 두 번
|
|
677
|
-
나오고, 캔버스가 주인공인 화면에서 그만큼 높이를 잃었다.
|
|
678
|
-
-->
|
|
679
|
-
${this.dirty ? html`<span dirty>${i18next.t('figure.text.unsaved-changes')}</span>` : ''}
|
|
680
|
-
<!--
|
|
681
|
-
말로 시키는 줄이 머리줄의 남는 폭을 쓴다. 따로 두면 화면 위가 두 줄이 되고, 그 위 줄은
|
|
682
|
-
이름을 페이지 제목과 겹쳐 적는다.
|
|
683
|
-
-->
|
|
684
|
-
<button ai-support @click=${() => {
|
|
685
|
-
requestFigureAI({ figureId: this.figure?.id, source: this.folded })
|
|
686
|
-
openOverlay('figure-ai-dock', { backdrop: false })
|
|
687
|
-
}}>AI 지원</button>
|
|
688
|
-
${this.saveFailure ? html`<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
|
|
689
|
-
<button save ?disabled=${!this.canSave} @click=${() => this.save()}>
|
|
690
|
-
${this.saving ? i18next.t('figure.text.saving-figure') : i18next.t('figure.button.save')}
|
|
691
|
-
</button>
|
|
692
|
-
</div>
|
|
693
|
-
|
|
694
842
|
<div
|
|
695
843
|
edits
|
|
696
844
|
?collapse-left=${this.collapsed.left}
|
|
697
845
|
?collapse-right=${this.collapsed.right}
|
|
698
846
|
@figure-rename=${(e: CustomEvent) => this.rename(e.detail.name)}
|
|
847
|
+
@figure-type-change=${(e: CustomEvent) => this.typeChanged(e.detail.type)}
|
|
699
848
|
@figure-catalog=${(e: CustomEvent) => this.catalog(e.detail)}
|
|
700
849
|
@figure-released=${(e: CustomEvent) => this.released(e.detail.figure)}
|
|
701
850
|
@figure-reverted=${() => this.figure?.id && this.load(this.figure.id)}
|
|
@@ -731,10 +880,12 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
731
880
|
<div lane>
|
|
732
881
|
<h4>${i18next.t('figure.label.now')}</h4>
|
|
733
882
|
<figure-canvas
|
|
883
|
+
.figureId=${this.figure?.id || this.figure?.type}
|
|
734
884
|
.board=${this.board}
|
|
735
885
|
.parts=${this.parts}
|
|
736
886
|
.picked=${this.pickedName}
|
|
737
887
|
.view=${this.view}
|
|
888
|
+
.revision=${this.sceneRevision}
|
|
738
889
|
></figure-canvas>
|
|
739
890
|
</div>
|
|
740
891
|
<div lane candidate>
|
|
@@ -755,9 +906,12 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
755
906
|
: this.mode === 'preview'
|
|
756
907
|
? html`<figure-preview .source=${source}></figure-preview>`
|
|
757
908
|
: html`<figure-canvas
|
|
909
|
+
.figureId=${this.figure?.id || this.figure?.type}
|
|
758
910
|
.board=${this.board}
|
|
759
911
|
.parts=${this.parts}
|
|
760
912
|
.picked=${this.pickedName}
|
|
913
|
+
.view=${this.view}
|
|
914
|
+
.revision=${this.sceneRevision}
|
|
761
915
|
></figure-canvas>`}
|
|
762
916
|
</div>
|
|
763
917
|
|
|
@@ -777,6 +931,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
777
931
|
@update-view=${(e: CustomEvent) => (this.view = e.detail.view)}
|
|
778
932
|
></figure-side>
|
|
779
933
|
</div>
|
|
934
|
+
${this.renderSaveModal()}
|
|
780
935
|
`
|
|
781
936
|
}
|
|
782
937
|
|
|
@@ -811,7 +966,44 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
811
966
|
|
|
812
967
|
return html`<div modes>
|
|
813
968
|
${this.renderCollapse('left')}${this.proposalSession ? '' : html`${one('edit')}${one('preview')}`}
|
|
969
|
+
${this.mode === 'edit' && !this.proposalSession
|
|
970
|
+
? html`
|
|
971
|
+
<div separator></div>
|
|
972
|
+
<button
|
|
973
|
+
history
|
|
974
|
+
?disabled=${this.undoStack.length === 0}
|
|
975
|
+
title="${i18next.t('figure.button.undo')} (Ctrl+Z)"
|
|
976
|
+
@click=${() => this.undo()}
|
|
977
|
+
>
|
|
978
|
+
<md-icon>undo</md-icon>
|
|
979
|
+
</button>
|
|
980
|
+
<button
|
|
981
|
+
history
|
|
982
|
+
?disabled=${this.redoStack.length === 0}
|
|
983
|
+
title="${i18next.t('figure.button.redo')} (Ctrl+Y)"
|
|
984
|
+
@click=${() => this.redo()}
|
|
985
|
+
>
|
|
986
|
+
<md-icon>redo</md-icon>
|
|
987
|
+
</button>
|
|
988
|
+
`
|
|
989
|
+
: ''}
|
|
814
990
|
<div spacer></div>
|
|
991
|
+
<button
|
|
992
|
+
ai-support
|
|
993
|
+
title=${i18next.t('figure.button.ask')}
|
|
994
|
+
@click=${() => {
|
|
995
|
+
requestFigureAI({ figureId: this.figure?.id, source: this.folded })
|
|
996
|
+
openOverlay('figure-ai-dock', { backdrop: false })
|
|
997
|
+
}}
|
|
998
|
+
>
|
|
999
|
+
<md-icon>auto_awesome</md-icon>
|
|
1000
|
+
<span>AI</span>
|
|
1001
|
+
</button>
|
|
1002
|
+
${this.saveFailure ? html`<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
|
|
1003
|
+
${this.dirty ? html`<span dirty>${i18next.t('figure.text.unsaved-changes')}</span>` : ''}
|
|
1004
|
+
<button save ?disabled=${!this.canSave} @click=${() => this.save()}>
|
|
1005
|
+
${this.saving ? i18next.t('figure.text.saving-figure') : i18next.t('figure.button.save')}
|
|
1006
|
+
</button>
|
|
815
1007
|
${this.renderCollapse('right')}
|
|
816
1008
|
</div>`
|
|
817
1009
|
}
|
|
@@ -1013,6 +1205,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1013
1205
|
if (!this.proposalSession) return
|
|
1014
1206
|
if (this.proposalBaseSource !== undefined && this.proposalBaseSource !== JSON.stringify(this.folded)) return
|
|
1015
1207
|
|
|
1208
|
+
this.recordHistory()
|
|
1016
1209
|
const taken = proposals.applyProposal(this.folded, this.proposalSession.source, this.proposalSession.picked)
|
|
1017
1210
|
const { board, parts } = fromFigureSource(taken)
|
|
1018
1211
|
|
|
@@ -1069,7 +1262,11 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1069
1262
|
* 저장이 거절되면 `dirty` 를 그대로 두므로 다시 누를 수 있다.
|
|
1070
1263
|
*/
|
|
1071
1264
|
private get canSave(): boolean {
|
|
1072
|
-
if (this.saving || !this.dirty || !this.folded
|
|
1265
|
+
if (this.saving || !this.dirty || !this.folded) {
|
|
1266
|
+
return false
|
|
1267
|
+
}
|
|
1268
|
+
// 기존 저장된 모델은 이름이 있어야 바로 저장 가능. 신규 모델은 저장 팝업에서 이름을 입력할 수 있음
|
|
1269
|
+
if (this.figure?.id && !this.figure?.name) {
|
|
1073
1270
|
return false
|
|
1074
1271
|
}
|
|
1075
1272
|
return validate(this.folded).errors.length === 0
|
|
@@ -1084,9 +1281,13 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1084
1281
|
const id = lifecycle?.resourceId
|
|
1085
1282
|
|
|
1086
1283
|
if (id) {
|
|
1087
|
-
if (id !== this.figure?.id) {
|
|
1284
|
+
if (changes.active || id !== this.figure?.id) {
|
|
1088
1285
|
await this.load(id)
|
|
1089
1286
|
}
|
|
1287
|
+
const pending = consumePendingFigureProposal()
|
|
1288
|
+
if (pending?.source) {
|
|
1289
|
+
this.applySourceDirectly(pending.source, pending.proposal)
|
|
1290
|
+
}
|
|
1090
1291
|
return
|
|
1091
1292
|
}
|
|
1092
1293
|
|
|
@@ -1102,10 +1303,37 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1102
1303
|
들고 있는 것이 **저장된 도형이면**(id 가 있으면) 새로 시작한다. 아직 저장 안 한 새 도형을
|
|
1103
1304
|
들고 있으면 그대로 둔다 — 페이지를 다시 들렀다고 저작 중인 것을 버리지 않는다.
|
|
1104
1305
|
*/
|
|
1306
|
+
if (!this.figure) {
|
|
1307
|
+
this.startNew()
|
|
1308
|
+
const pending = consumePendingFigureProposal()
|
|
1309
|
+
if (pending?.source) {
|
|
1310
|
+
this.applySourceDirectly(pending.source, pending.proposal)
|
|
1311
|
+
}
|
|
1312
|
+
return
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
if (this.figure.id) {
|
|
1316
|
+
this.startNew()
|
|
1317
|
+
const pending = consumePendingFigureProposal()
|
|
1318
|
+
if (pending?.source) {
|
|
1319
|
+
this.applySourceDirectly(pending.source, pending.proposal)
|
|
1320
|
+
}
|
|
1321
|
+
return
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1105
1324
|
if (shouldStartNewFigure(changes, !!this.figure, this.figure?.id)) {
|
|
1106
1325
|
this.startNew()
|
|
1326
|
+
const pending = consumePendingFigureProposal()
|
|
1327
|
+
if (pending?.source) {
|
|
1328
|
+
this.applySourceDirectly(pending.source, pending.proposal)
|
|
1329
|
+
}
|
|
1107
1330
|
return
|
|
1108
1331
|
}
|
|
1332
|
+
|
|
1333
|
+
const pending = consumePendingFigureProposal()
|
|
1334
|
+
if (pending?.source) {
|
|
1335
|
+
this.applySourceDirectly(pending.source, pending.proposal)
|
|
1336
|
+
}
|
|
1109
1337
|
}
|
|
1110
1338
|
|
|
1111
1339
|
private loadGeneration = 0
|
|
@@ -1149,6 +1377,9 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1149
1377
|
this.selected = -1
|
|
1150
1378
|
this.dirty = false
|
|
1151
1379
|
this.violations = []
|
|
1380
|
+
this.undoStack = []
|
|
1381
|
+
this.redoStack = []
|
|
1382
|
+
this.lastCoalesceKey = undefined
|
|
1152
1383
|
}
|
|
1153
1384
|
|
|
1154
1385
|
private startNew() {
|
|
@@ -1168,6 +1399,9 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1168
1399
|
this.selected = -1
|
|
1169
1400
|
this.dirty = true
|
|
1170
1401
|
this.violations = []
|
|
1402
|
+
this.undoStack = []
|
|
1403
|
+
this.redoStack = []
|
|
1404
|
+
this.lastCoalesceKey = undefined
|
|
1171
1405
|
}
|
|
1172
1406
|
|
|
1173
1407
|
private rename(name: string) {
|
|
@@ -1177,6 +1411,15 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1177
1411
|
this.dirty = true
|
|
1178
1412
|
}
|
|
1179
1413
|
|
|
1414
|
+
private typeChanged(type: string) {
|
|
1415
|
+
if (!type || this.figure?.id) return
|
|
1416
|
+
const sanitized = type.replace(/[^A-Za-z0-9_]/g, '_').toUpperCase()
|
|
1417
|
+
if (this.board) this.board.figureType = sanitized
|
|
1418
|
+
this.figure = { ...this.figure, type: sanitized } as Figure
|
|
1419
|
+
this.folded = this.board ? toFigureSource(this.board, this.parts) : undefined
|
|
1420
|
+
this.dirty = true
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1180
1423
|
/**
|
|
1181
1424
|
* 분류와 태그를 고친다.
|
|
1182
1425
|
*
|
|
@@ -1190,13 +1433,62 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1190
1433
|
this.dirty = true
|
|
1191
1434
|
}
|
|
1192
1435
|
|
|
1436
|
+
private snapshotState(): { board?: BoardModel; parts: PartModel[]; selected: number } {
|
|
1437
|
+
return {
|
|
1438
|
+
board: this.board ? structuredClone(this.board) : undefined,
|
|
1439
|
+
parts: structuredClone(this.parts),
|
|
1440
|
+
selected: this.selected
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
private recordHistory(coalesceKey?: string) {
|
|
1445
|
+
const now = Date.now()
|
|
1446
|
+
if (coalesceKey && this.lastCoalesceKey === coalesceKey && now - this.lastHistoryTime < 600) {
|
|
1447
|
+
this.lastHistoryTime = now
|
|
1448
|
+
return
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
this.lastCoalesceKey = coalesceKey
|
|
1452
|
+
this.lastHistoryTime = now
|
|
1453
|
+
|
|
1454
|
+
this.undoStack = [...this.undoStack.slice(-49), this.snapshotState()]
|
|
1455
|
+
this.redoStack = []
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
private undo() {
|
|
1459
|
+
if (this.undoStack.length === 0) return
|
|
1460
|
+
const prev = this.undoStack[this.undoStack.length - 1]
|
|
1461
|
+
this.undoStack = this.undoStack.slice(0, -1)
|
|
1462
|
+
this.redoStack = [this.snapshotState(), ...this.redoStack.slice(0, 49)]
|
|
1463
|
+
|
|
1464
|
+
this.board = prev.board ? structuredClone(prev.board) : undefined
|
|
1465
|
+
this.parts = structuredClone(prev.parts)
|
|
1466
|
+
this.selected = this.parts.length ? Math.max(-1, Math.min(prev.selected, this.parts.length - 1)) : -1
|
|
1467
|
+
this.dirty = true
|
|
1468
|
+
this.lastCoalesceKey = undefined
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
private redo() {
|
|
1472
|
+
if (this.redoStack.length === 0) return
|
|
1473
|
+
const next = this.redoStack[0]
|
|
1474
|
+
this.redoStack = this.redoStack.slice(1)
|
|
1475
|
+
this.undoStack = [...this.undoStack.slice(-49), this.snapshotState()]
|
|
1476
|
+
|
|
1477
|
+
this.board = next.board ? structuredClone(next.board) : undefined
|
|
1478
|
+
this.parts = structuredClone(next.parts)
|
|
1479
|
+
this.selected = this.parts.length ? Math.max(-1, Math.min(next.selected, this.parts.length - 1)) : -1
|
|
1480
|
+
this.dirty = true
|
|
1481
|
+
this.lastCoalesceKey = undefined
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1193
1484
|
/**
|
|
1194
1485
|
* 부품 목록을 갈아 끼운다.
|
|
1195
1486
|
*
|
|
1196
1487
|
* 편집 규칙은 `part-edits` 가 갖는다 — 화면과 테스트가 같은 것을 쓰게 하려고 떼어
|
|
1197
1488
|
* 놓았다. 여기서는 결과를 담고 선택 인덱스를 정리한다.
|
|
1198
1489
|
*/
|
|
1199
|
-
private put(parts: PartModel[], selected = this.selected) {
|
|
1490
|
+
private put(parts: PartModel[], selected = this.selected, coalesceKey?: string) {
|
|
1491
|
+
this.recordHistory(coalesceKey)
|
|
1200
1492
|
this.parts = parts
|
|
1201
1493
|
this.selected = Math.min(selected, parts.length - 1)
|
|
1202
1494
|
this.dirty = true
|
|
@@ -1221,7 +1513,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1221
1513
|
}
|
|
1222
1514
|
|
|
1223
1515
|
private updatePart(index: number, part: PartModel) {
|
|
1224
|
-
this.put(edits.updatePart(this.parts, index, part))
|
|
1516
|
+
this.put(edits.updatePart(this.parts, index, part), this.selected, `part:${part.name || index}`)
|
|
1225
1517
|
}
|
|
1226
1518
|
|
|
1227
1519
|
/**
|
|
@@ -1231,6 +1523,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1231
1523
|
* 고칠 때마다 부품 목록이 새 배열이 되고, 그리는 쪽이 전부 다시 그린다.
|
|
1232
1524
|
*/
|
|
1233
1525
|
private updateBoard(board: BoardModel) {
|
|
1526
|
+
this.recordHistory('board')
|
|
1234
1527
|
this.board = board
|
|
1235
1528
|
this.dirty = true
|
|
1236
1529
|
}
|
|
@@ -1248,6 +1541,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1248
1541
|
private changeDetailLevel(level: DetailLevel) {
|
|
1249
1542
|
if (!this.board) return
|
|
1250
1543
|
|
|
1544
|
+
this.recordHistory('detailLevel')
|
|
1251
1545
|
this.board = { ...this.board, detailLevel: level }
|
|
1252
1546
|
this.dirty = true
|
|
1253
1547
|
}
|
|
@@ -1286,7 +1580,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1286
1580
|
*/
|
|
1287
1581
|
private partChanged(name: string, part: PartModel) {
|
|
1288
1582
|
const at = this.parts.findIndex(item => item.name === name)
|
|
1289
|
-
if (at >= 0) this.put(edits.updatePart(this.parts, at, part))
|
|
1583
|
+
if (at >= 0) this.put(edits.updatePart(this.parts, at, part), this.selected, `drag:${name}`)
|
|
1290
1584
|
}
|
|
1291
1585
|
|
|
1292
1586
|
/**
|
|
@@ -1372,71 +1666,137 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1372
1666
|
}
|
|
1373
1667
|
}
|
|
1374
1668
|
|
|
1375
|
-
private
|
|
1669
|
+
private save() {
|
|
1376
1670
|
if (!this.canSave || !this.folded || !this.figure) {
|
|
1377
1671
|
return
|
|
1378
1672
|
}
|
|
1379
1673
|
|
|
1674
|
+
if (!this.figure.id) {
|
|
1675
|
+
// 신규 저장: 타입 코드와 표시 이름을 명확히 팝업으로 확인/입력받음
|
|
1676
|
+
let defaultType = this.board?.figureType || this.figure.type || ''
|
|
1677
|
+
if (!defaultType || defaultType.startsWith('FIGURE_')) {
|
|
1678
|
+
defaultType = 'FIGURE'
|
|
1679
|
+
}
|
|
1680
|
+
this.saveModalType = defaultType
|
|
1681
|
+
this.saveModalName = this.figure.name || ''
|
|
1682
|
+
this.saveModalError = ''
|
|
1683
|
+
this.showSaveModal = true
|
|
1684
|
+
return
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
void this.doSave()
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
private async doSave() {
|
|
1691
|
+
if (!this.folded || !this.figure) return
|
|
1692
|
+
|
|
1380
1693
|
this.saving = true
|
|
1381
1694
|
this.saveFailure = ''
|
|
1382
|
-
|
|
1383
|
-
const sourceJson = JSON.stringify(this.folded)
|
|
1384
|
-
/*
|
|
1385
|
-
보이는 것을 그대로 찍어 함께 보낸다.
|
|
1695
|
+
this.saveModalError = ''
|
|
1386
1696
|
|
|
1387
|
-
|
|
1388
|
-
|
|
1697
|
+
try {
|
|
1698
|
+
if (!this.figure.id) {
|
|
1699
|
+
const typeToSave = (this.saveModalType || this.board?.figureType || this.figure.type || 'FIGURE').trim().toUpperCase()
|
|
1700
|
+
const nameToSave = (this.saveModalName || this.figure.name || typeToSave).trim()
|
|
1389
1701
|
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1702
|
+
if (!typeToSave) {
|
|
1703
|
+
throw new Error('타입 이름을 입력하세요.')
|
|
1704
|
+
}
|
|
1393
1705
|
|
|
1394
|
-
if (this.figure.id) {
|
|
1395
|
-
const result = await updateFigure(this.figure.id, {
|
|
1396
|
-
name: this.figure.name,
|
|
1397
|
-
/*
|
|
1398
|
-
카탈로그의 값도 함께 보낸다. 화면에서 고쳐 놓고 저장에 안 실으면 저장 뒤에 조용히
|
|
1399
|
-
사라진다 — 저작자는 저장했다고 보고 다시 열면 없다.
|
|
1400
|
-
*/
|
|
1401
|
-
category: this.figure.category,
|
|
1402
|
-
tags: this.figure.tags,
|
|
1403
|
-
source: sourceJson,
|
|
1404
|
-
thumbnail
|
|
1405
|
-
})
|
|
1406
|
-
this.figure = result.figure
|
|
1407
|
-
this.violations = result.violations
|
|
1408
|
-
} else {
|
|
1409
|
-
// 새로 만들 때만 타입 이름이 정해진다. 이미 쓰이는 이름이면 저장 전에 막는다 —
|
|
1410
|
-
// 만든 뒤에는 고칠 수 없으므로 여기서 걸러야 한다.
|
|
1411
1706
|
const taken = await fetchFigureTypeNames()
|
|
1412
|
-
if (taken.includes(
|
|
1413
|
-
throw new Error(i18next.t('figure.text.type-name-already-taken', { type:
|
|
1707
|
+
if (taken.includes(typeToSave)) {
|
|
1708
|
+
throw new Error(i18next.t('figure.text.type-name-already-taken', { type: typeToSave }))
|
|
1414
1709
|
}
|
|
1415
1710
|
|
|
1711
|
+
if (this.board) this.board.figureType = typeToSave
|
|
1712
|
+
this.figure = { ...this.figure, type: typeToSave, name: nameToSave }
|
|
1713
|
+
this.folded = { ...this.folded, type: typeToSave }
|
|
1714
|
+
const sourceJson = JSON.stringify(this.folded)
|
|
1715
|
+
const thumbnail = await captureThumbnail(this.board, this.parts)
|
|
1716
|
+
|
|
1416
1717
|
const result = await createFigure({
|
|
1417
|
-
type:
|
|
1418
|
-
name:
|
|
1718
|
+
type: typeToSave,
|
|
1719
|
+
name: nameToSave,
|
|
1419
1720
|
category: this.figure.category,
|
|
1420
1721
|
tags: this.figure.tags,
|
|
1421
1722
|
source: sourceJson,
|
|
1422
1723
|
thumbnail
|
|
1423
1724
|
})
|
|
1725
|
+
|
|
1424
1726
|
this.figure = result.figure
|
|
1425
1727
|
this.violations = result.violations
|
|
1728
|
+
this.showSaveModal = false
|
|
1729
|
+
this.dirty = false
|
|
1426
1730
|
navigate(`figure-modeller/${result.figure.id}`)
|
|
1731
|
+
return
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// 기존 모델 업데이트: folded.type이 figure.type과 일치하도록 보장
|
|
1735
|
+
if (this.figure.type && this.folded.type !== this.figure.type) {
|
|
1736
|
+
this.folded = { ...this.folded, type: this.figure.type }
|
|
1737
|
+
if (this.board) this.board.figureType = this.figure.type
|
|
1427
1738
|
}
|
|
1739
|
+
const sourceJson = JSON.stringify(this.folded)
|
|
1740
|
+
const thumbnail = await captureThumbnail(this.board, this.parts)
|
|
1428
1741
|
|
|
1742
|
+
const result = await updateFigure(this.figure.id, {
|
|
1743
|
+
name: this.figure.name,
|
|
1744
|
+
category: this.figure.category,
|
|
1745
|
+
tags: this.figure.tags,
|
|
1746
|
+
source: sourceJson,
|
|
1747
|
+
thumbnail
|
|
1748
|
+
})
|
|
1749
|
+
this.figure = result.figure
|
|
1750
|
+
this.violations = result.violations
|
|
1429
1751
|
this.dirty = false
|
|
1430
1752
|
} catch (e) {
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
`dirty` 는 건드리지 않는다. 저장이 안 됐으므로 미저장인 것이 사실이다.
|
|
1436
|
-
*/
|
|
1437
|
-
this.saveFailure = (e as Error).message
|
|
1753
|
+
const msg = (e as Error).message
|
|
1754
|
+
this.saveFailure = msg
|
|
1755
|
+
this.saveModalError = msg
|
|
1438
1756
|
} finally {
|
|
1439
1757
|
this.saving = false
|
|
1440
1758
|
}
|
|
1441
1759
|
}
|
|
1760
|
+
|
|
1761
|
+
private renderSaveModal() {
|
|
1762
|
+
if (!this.showSaveModal) return nothing
|
|
1763
|
+
|
|
1764
|
+
return html`
|
|
1765
|
+
<div class="save-modal-backdrop" @click=${(e: Event) => { if (e.target === e.currentTarget) this.showSaveModal = false }}>
|
|
1766
|
+
<div class="save-modal-card" role="dialog" aria-modal="true">
|
|
1767
|
+
<h3 class="save-modal-title">${i18next.t('figure.title.save-new-figure', { defaultValue: '도형 초안 저장' })}</h3>
|
|
1768
|
+
<p class="save-modal-desc">${i18next.t('figure.text.save-new-figure-desc', { defaultValue: '보드와 캔버스에서 식별할 고유 타입 코드와 이름을 정합니다.' })}</p>
|
|
1769
|
+
|
|
1770
|
+
<div class="save-modal-field">
|
|
1771
|
+
<label>${i18next.t('figure.label.figure-type', { defaultValue: '타입 코드 (영문 대문자)' })}</label>
|
|
1772
|
+
<input
|
|
1773
|
+
.value=${this.saveModalType}
|
|
1774
|
+
placeholder="예: OHT, AGV_LIFTER"
|
|
1775
|
+
@input=${(e: Event) => (this.saveModalType = (e.target as HTMLInputElement).value.trim().toUpperCase())}
|
|
1776
|
+
/>
|
|
1777
|
+
</div>
|
|
1778
|
+
|
|
1779
|
+
<div class="save-modal-field">
|
|
1780
|
+
<label>${i18next.t('figure.label.name', { defaultValue: '도형 이름' })}</label>
|
|
1781
|
+
<input
|
|
1782
|
+
.value=${this.saveModalName}
|
|
1783
|
+
placeholder="예: 오버헤드 호이스트 트랜스포트"
|
|
1784
|
+
@input=${(e: Event) => (this.saveModalName = (e.target as HTMLInputElement).value)}
|
|
1785
|
+
/>
|
|
1786
|
+
</div>
|
|
1787
|
+
|
|
1788
|
+
${this.saveModalError ? html`<div class="save-modal-error">${this.saveModalError}</div>` : nothing}
|
|
1789
|
+
|
|
1790
|
+
<div class="save-modal-actions">
|
|
1791
|
+
<button class="btn-cancel" @click=${() => (this.showSaveModal = false)}>
|
|
1792
|
+
${i18next.t('figure.button.cancel', { defaultValue: '취소' })}
|
|
1793
|
+
</button>
|
|
1794
|
+
<button class="btn-confirm" ?disabled=${this.saving || !this.saveModalType} @click=${() => this.doSave()}>
|
|
1795
|
+
${this.saving ? i18next.t('figure.text.saving-figure', { defaultValue: '저장 중...' }) : i18next.t('figure.button.save', { defaultValue: '저장' })}
|
|
1796
|
+
</button>
|
|
1797
|
+
</div>
|
|
1798
|
+
</div>
|
|
1799
|
+
</div>
|
|
1800
|
+
`
|
|
1801
|
+
}
|
|
1442
1802
|
}
|