@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.
Files changed (34) hide show
  1. package/client/modeller/figure-ai-target.ts +12 -0
  2. package/client/modeller/figure-canvas.ts +26 -4
  3. package/client/modeller/figure-inspector.ts +48 -7
  4. package/client/modeller/figure-side.ts +1 -0
  5. package/client/modeller/figure-source.test.ts +2 -0
  6. package/client/modeller/figure-source.ts +17 -21
  7. package/client/pages/figure-modeller-page.ts +521 -161
  8. package/dist-client/modeller/figure-ai-target.d.ts +10 -0
  9. package/dist-client/modeller/figure-ai-target.js +10 -0
  10. package/dist-client/modeller/figure-ai-target.js.map +1 -1
  11. package/dist-client/modeller/figure-canvas.d.ts +2 -0
  12. package/dist-client/modeller/figure-canvas.js +29 -4
  13. package/dist-client/modeller/figure-canvas.js.map +1 -1
  14. package/dist-client/modeller/figure-inspector.d.ts +4 -1
  15. package/dist-client/modeller/figure-inspector.js +47 -6
  16. package/dist-client/modeller/figure-inspector.js.map +1 -1
  17. package/dist-client/modeller/figure-side.js +1 -0
  18. package/dist-client/modeller/figure-side.js.map +1 -1
  19. package/dist-client/modeller/figure-source.d.ts +15 -21
  20. package/dist-client/modeller/figure-source.js +5 -0
  21. package/dist-client/modeller/figure-source.js.map +1 -1
  22. package/dist-client/pages/figure-modeller-page.d.ts +20 -0
  23. package/dist-client/pages/figure-modeller-page.js +531 -163
  24. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  25. package/dist-client/tsconfig.tsbuildinfo +1 -1
  26. package/dist-server/tsconfig.tsbuildinfo +1 -1
  27. package/package.json +5 -5
  28. package/test/ai-proposal-contract.test.ts +21 -2
  29. package/test/modeller-undo-redo.test.ts +33 -0
  30. package/translations/en.json +12 -1
  31. package/translations/ja.json +2 -0
  32. package/translations/ko.json +13 -1
  33. package/translations/ms.json +2 -0
  34. 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: 5px var(--spacing-medium, 8px);
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 (!this.active || (detail?.figureId ?? '') !== (this.figure?.id ?? '')) return
631
- if (detail?.draftType && detail.draftType !== this.folded?.type) return
632
- if (detail?.baseSource && detail.baseSource !== JSON.stringify(this.folded)) return
633
- this.receive(detail.proposal)
634
- detail.accepted = true
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 || !this.figure?.name) {
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 async save() {
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
- try {
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
- 찍으면 보내지 않는다(`undefined`). 빈 그림을 넣어 「그림이 있다」고 말하지 않는다.
1391
- */
1392
- const thumbnail = await captureThumbnail(this.board, this.parts)
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(this.folded.type)) {
1413
- throw new Error(i18next.t('figure.text.type-name-already-taken', { type: this.folded.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: this.folded.type,
1418
- name: this.figure.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
  }