@things-factory/figure-ui 10.1.10 → 10.1.14

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 (32) hide show
  1. package/client/modeller/figure-ai-target.ts +11 -0
  2. package/client/modeller/figure-ask.ts +8 -5
  3. package/client/modeller/figure-canvas.ts +10 -4
  4. package/client/modeller/new-figure-entry.ts +4 -0
  5. package/client/pages/figure-modeller-page.ts +241 -120
  6. package/dist-client/modeller/figure-ai-target.d.ts +8 -0
  7. package/dist-client/modeller/figure-ai-target.js +12 -0
  8. package/dist-client/modeller/figure-ai-target.js.map +1 -0
  9. package/dist-client/modeller/figure-ask.d.ts +1 -0
  10. package/dist-client/modeller/figure-ask.js +12 -5
  11. package/dist-client/modeller/figure-ask.js.map +1 -1
  12. package/dist-client/modeller/figure-canvas.d.ts +1 -0
  13. package/dist-client/modeller/figure-canvas.js +10 -4
  14. package/dist-client/modeller/figure-canvas.js.map +1 -1
  15. package/dist-client/modeller/new-figure-entry.d.ts +2 -0
  16. package/dist-client/modeller/new-figure-entry.js +5 -0
  17. package/dist-client/modeller/new-figure-entry.js.map +1 -0
  18. package/dist-client/pages/figure-modeller-page.d.ts +14 -0
  19. package/dist-client/pages/figure-modeller-page.js +240 -120
  20. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  21. package/dist-client/route.d.ts +1 -1
  22. package/dist-client/tsconfig.tsbuildinfo +1 -1
  23. package/package.json +3 -3
  24. package/test/ai-proposal-contract.test.ts +24 -3
  25. package/test/ai-target.test.ts +25 -0
  26. package/test/modeller-undo-redo.test.ts +33 -0
  27. package/test/new-figure-entry.test.ts +15 -0
  28. package/translations/en.json +2 -0
  29. package/translations/ja.json +2 -0
  30. package/translations/ko.json +2 -0
  31. package/translations/ms.json +2 -0
  32. package/translations/zh.json +2 -0
@@ -4,6 +4,7 @@ import '../modeller/figure-ask.js'
4
4
  import '../modeller/figure-canvas.js'
5
5
  import { captureThumbnail } from '../modeller/figure-thumbnail.js'
6
6
  import '../modeller/figure-preview.js'
7
+ import { shouldStartNewFigure } from '../modeller/new-figure-entry.js'
7
8
  import '../modeller/figure-parts.js'
8
9
  import '../modeller/figure-inspector.js'
9
10
 
@@ -12,6 +13,8 @@ import { customElement, state } from 'lit/decorators.js'
12
13
 
13
14
  import { i18next, localize } from '@operato/i18n'
14
15
  import { navigate, PageView } from '@operato/shell'
16
+ import { openOverlay } from '@operato/layout'
17
+ import { requestFigureAI } from '../modeller/figure-ai-target.js'
15
18
  import { validate } from '@hatiolab/figure-model'
16
19
  import type { DetailLevel, FigureSource, PrimitiveKind } from '@hatiolab/figure-model'
17
20
 
@@ -185,84 +188,9 @@ export class FigureModellerPage extends FigureModellerPageBase {
185
188
  display: none;
186
189
  }
187
190
 
188
- /*
189
- 머리줄 — 이름 · 타입 · 미저장 표시 · 말로 시키는 줄 · 저장을 **한 줄에** 담는다.
190
-
191
- 이름은 페이지 제목에도 나온다(셸이 context.title 로 그린다). 그래도 여기 입력칸을 두는
192
- 이유는 **고치는 자리**가 필요해서다 — 제목은 읽는 것이고 이 칸은 바꾸는 것이다. 두 줄로
193
- 벌리는 대신 한 줄에 모아 캔버스에 높이를 넘긴다.
194
- */
195
- div[header] {
196
- display: flex;
197
- align-items: center;
198
- gap: var(--spacing-medium, 8px);
199
- padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
200
- border-bottom: 1px solid var(--md-sys-color-outline-variant);
201
- }
202
- div[header] figure-ask {
203
- flex: 1;
204
- min-width: 0;
205
- }
206
- input[name] {
207
- flex: none;
208
- width: 180px;
209
- padding: 5px 9px;
210
- font: var(--input-field-font, inherit);
211
- font-size: 0.82rem;
212
- color: var(--md-sys-color-on-surface);
213
- background-color: var(--md-sys-color-surface-container-lowest);
214
- border: 1px solid var(--md-sys-color-outline-variant);
215
- border-radius: 6px;
216
- }
217
- /* 타입 이름은 만든 뒤 고칠 수 없다 — 그 사실이 화면에서 보여야 한다 */
218
- code[type] {
219
- padding: 4px 8px;
220
- font-family: var(--mono-font, monospace);
221
- font-size: 0.76rem;
222
- color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
223
- background-color: var(--md-sys-color-surface-container);
224
- border-radius: 6px;
225
- }
226
- span[dirty] {
227
- font: var(--label-font, inherit);
228
- font-size: 0.74rem;
229
- color: var(--md-sys-color-tertiary);
230
- }
231
191
  div[spacer] {
232
192
  flex: 1;
233
193
  }
234
- button[save] {
235
- padding: 6px 16px;
236
- font: var(--label-font, inherit);
237
- font-size: 0.76rem;
238
- font-weight: 600;
239
- color: var(--md-sys-color-on-primary, #fff);
240
- background-color: var(--md-sys-color-primary);
241
- border: none;
242
- border-radius: 6px;
243
- cursor: pointer;
244
- }
245
- button[save][disabled] {
246
- opacity: 0.4;
247
- cursor: default;
248
- }
249
-
250
- /*
251
- 저장이 거절된 사유. 단추 **바로 왼쪽**에 둔다 — 누른 자리에서 답을 봐야 한다. 길면 줄이지만
252
- 전문은 title 로 남긴다(서버 문장이 길 수 있다).
253
- */
254
- span[save-failed] {
255
- max-width: 280px;
256
- overflow: hidden;
257
- text-overflow: ellipsis;
258
- white-space: nowrap;
259
- padding: 4px 8px;
260
- font: var(--label-font, inherit);
261
- font-size: 0.72rem;
262
- color: var(--md-sys-color-on-error-container, var(--md-sys-color-on-surface));
263
- background-color: var(--md-sys-color-error-container);
264
- border-radius: 6px;
265
- }
266
194
 
267
195
  /*
268
196
  칸을 벡터 저작도구의 관례대로 놓는다 — 왼쪽에 무엇이 있나(레이어), 오른쪽에
@@ -285,16 +213,17 @@ export class FigureModellerPage extends FigureModellerPageBase {
285
213
  }
286
214
 
287
215
  /*
288
- 편집 · 미리보기 전환.
216
+ 편집 · 미리보기 전환 및 조작 띠.
289
217
 
290
218
  가운데 칸 위에 얇게 둔다. 무엇을 보고 있는지가 늘 보여야 하고, 두 화면이 같은
291
219
  자리를 쓰므로 어느 쪽인지 모르면 편집이 안 먹는 것처럼 읽힌다.
292
220
  */
293
221
  div[modes] {
294
222
  display: flex;
223
+ align-items: center;
295
224
  flex: none;
296
225
  gap: 2px;
297
- padding: 5px var(--spacing-medium, 8px);
226
+ padding: 4px var(--spacing-medium, 8px);
298
227
  border-bottom: 1px solid var(--md-sys-color-outline-variant);
299
228
  }
300
229
  div[modes] button[mode] {
@@ -315,13 +244,36 @@ export class FigureModellerPage extends FigureModellerPageBase {
315
244
  color: var(--md-sys-color-on-primary-container);
316
245
  background-color: var(--md-sys-color-primary-container);
317
246
  }
318
-
319
- /*
320
- 양쪽 칸을 접는 손잡이. 띠의 양 끝, 접히는 칸 바로 옆이다.
321
-
322
- 접었을 때 남는 것이 이것 하나뿐이므로 **접는 자리와 펴는 자리가 같아야 한다.**
323
- 다른 데 두면 접고 나서 되돌릴 곳을 찾아야 한다.
324
- */
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
+ }
325
277
  div[modes] button[collapse] {
326
278
  display: flex;
327
279
  align-items: center;
@@ -339,6 +291,46 @@ export class FigureModellerPage extends FigureModellerPageBase {
339
291
  div[modes] button[collapse] md-icon {
340
292
  --md-icon-size: 18px;
341
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
+ }
342
334
 
343
335
  /*
344
336
  후보가 오면 가운데를 둘로 나눈다.
@@ -571,6 +563,11 @@ export class FigureModellerPage extends FigureModellerPageBase {
571
563
  /** 인라인 요청창과 AI 도크가 함께 쓰는 현재 Figure의 제한된 세션 피드백. */
572
564
  @state() private proposalFeedback: ProposalFeedback[] = []
573
565
 
566
+ @state() private undoStack: { board?: BoardModel; parts: PartModel[]; selected: number }[] = []
567
+ @state() private redoStack: { board?: BoardModel; parts: PartModel[]; selected: number }[] = []
568
+ private lastCoalesceKey?: string
569
+ private lastHistoryTime = 0
570
+
574
571
  /**
575
572
  * 저장 형식으로 변환한 것. 씬 모델이 바뀔 때만 다시 만든다.
576
573
  *
@@ -609,11 +606,56 @@ export class FigureModellerPage extends FigureModellerPageBase {
609
606
  }
610
607
  }
611
608
 
609
+ private announceAiContext = () => {
610
+ if (!this.active) return
611
+ window.dispatchEvent(new CustomEvent('figure-ai-context', {
612
+ detail: { figureId: this.figure?.id, source: this.folded }
613
+ }))
614
+ }
615
+
616
+ private acceptDockProposal = (event: Event) => {
617
+ const detail = (event as CustomEvent).detail
618
+ if (!this.active || (detail?.figureId ?? '') !== (this.figure?.id ?? '')) return
619
+ if (detail?.draftType && detail.draftType !== this.folded?.type) return
620
+ if (detail?.baseSource && detail.baseSource !== JSON.stringify(this.folded)) return
621
+ this.receive(detail.proposal)
622
+ detail.accepted = true
623
+ }
624
+
625
+ private proposalBaseSource?: string
626
+
627
+ connectedCallback() {
628
+ super.connectedCallback()
629
+ window.addEventListener('figure-ai-context-request', this.announceAiContext)
630
+ window.addEventListener('figure-ai-review', this.acceptDockProposal)
631
+ window.addEventListener('keydown', this.handleKeyDown)
632
+ }
633
+
612
634
  disconnectedCallback() {
635
+ window.removeEventListener('keydown', this.handleKeyDown)
636
+ window.removeEventListener('figure-ai-context-request', this.announceAiContext)
637
+ window.removeEventListener('figure-ai-review', this.acceptDockProposal)
613
638
  window.dispatchEvent(new CustomEvent('figure-ai-context', { detail: undefined }))
614
639
  super.disconnectedCallback()
615
640
  }
616
641
 
642
+ private handleKeyDown = (e: KeyboardEvent) => {
643
+ if (!this.active || this.mode !== 'edit' || this.proposalSession) return
644
+ const target = e.composedPath()[0] as HTMLElement | undefined
645
+ const tag = target?.tagName?.toLowerCase()
646
+ if (tag === 'input' || tag === 'textarea' || target?.isContentEditable) return
647
+
648
+ if ((e.metaKey || e.ctrlKey) && !e.altKey) {
649
+ if (e.key === 'z' && !e.shiftKey) {
650
+ e.preventDefault()
651
+ this.undo()
652
+ } else if ((e.key === 'z' && e.shiftKey) || e.key === 'y') {
653
+ e.preventDefault()
654
+ this.redo()
655
+ }
656
+ }
657
+ }
658
+
617
659
  get context() {
618
660
  return {
619
661
  title: this.figure?.name ?? i18next.t('figure.title.new-figure'),
@@ -635,29 +677,6 @@ export class FigureModellerPage extends FigureModellerPageBase {
635
677
  const source = this.folded
636
678
 
637
679
  return html`
638
- <div header>
639
- <!--
640
- 이름과 타입은 **속성 패널**에 있다(figure-inspector 의 도형 절). 여기 있던 이름 칸은
641
- 하네스 시절의 자리였다 — 앱에서는 셸이 페이지 제목으로 같은 이름을 그리므로 글자가 두 번
642
- 나오고, 캔버스가 주인공인 화면에서 그만큼 높이를 잃었다.
643
- -->
644
- ${this.dirty ? html`<span dirty>${i18next.t('figure.text.unsaved-changes')}</span>` : ''}
645
- <!--
646
- 말로 시키는 줄이 머리줄의 남는 폭을 쓴다. 따로 두면 화면 위가 두 줄이 되고, 그 위 줄은
647
- 이름을 페이지 제목과 겹쳐 적는다.
648
- -->
649
- <figure-ask
650
- .source=${source}
651
- .type=${source?.type ?? ''}
652
- .feedback=${this.proposalFeedback}
653
- @proposed=${(e: CustomEvent) => this.receive(e.detail.proposal)}
654
- ></figure-ask>
655
- ${this.saveFailure ? html`<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
656
- <button save ?disabled=${!this.canSave} @click=${() => this.save()}>
657
- ${this.saving ? i18next.t('figure.text.saving-figure') : i18next.t('figure.button.save')}
658
- </button>
659
- </div>
660
-
661
680
  <div
662
681
  edits
663
682
  ?collapse-left=${this.collapsed.left}
@@ -698,6 +717,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
698
717
  <div lane>
699
718
  <h4>${i18next.t('figure.label.now')}</h4>
700
719
  <figure-canvas
720
+ .figureId=${this.figure?.id || this.figure?.type}
701
721
  .board=${this.board}
702
722
  .parts=${this.parts}
703
723
  .picked=${this.pickedName}
@@ -722,9 +742,11 @@ export class FigureModellerPage extends FigureModellerPageBase {
722
742
  : this.mode === 'preview'
723
743
  ? html`<figure-preview .source=${source}></figure-preview>`
724
744
  : html`<figure-canvas
745
+ .figureId=${this.figure?.id || this.figure?.type}
725
746
  .board=${this.board}
726
747
  .parts=${this.parts}
727
748
  .picked=${this.pickedName}
749
+ .view=${this.view}
728
750
  ></figure-canvas>`}
729
751
  </div>
730
752
 
@@ -778,7 +800,33 @@ export class FigureModellerPage extends FigureModellerPageBase {
778
800
 
779
801
  return html`<div modes>
780
802
  ${this.renderCollapse('left')}${this.proposalSession ? '' : html`${one('edit')}${one('preview')}`}
803
+ ${this.mode === 'edit' && !this.proposalSession
804
+ ? html`
805
+ <div separator></div>
806
+ <button
807
+ history
808
+ ?disabled=${this.undoStack.length === 0}
809
+ title="${i18next.t('figure.button.undo')} (Ctrl+Z)"
810
+ @click=${() => this.undo()}
811
+ >
812
+ <md-icon>undo</md-icon>
813
+ </button>
814
+ <button
815
+ history
816
+ ?disabled=${this.redoStack.length === 0}
817
+ title="${i18next.t('figure.button.redo')} (Ctrl+Y)"
818
+ @click=${() => this.redo()}
819
+ >
820
+ <md-icon>redo</md-icon>
821
+ </button>
822
+ `
823
+ : ''}
781
824
  <div spacer></div>
825
+ ${this.saveFailure ? html`<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
826
+ ${this.dirty ? html`<span dirty>${i18next.t('figure.text.unsaved-changes')}</span>` : ''}
827
+ <button save ?disabled=${!this.canSave} @click=${() => this.save()}>
828
+ ${this.saving ? i18next.t('figure.text.saving-figure') : i18next.t('figure.button.save')}
829
+ </button>
782
830
  ${this.renderCollapse('right')}
783
831
  </div>`
784
832
  }
@@ -842,6 +890,8 @@ export class FigureModellerPage extends FigureModellerPageBase {
842
890
  private renderDecision() {
843
891
  if (!this.proposalSession) return ''
844
892
 
893
+ const stale = this.proposalBaseSource !== undefined && this.proposalBaseSource !== JSON.stringify(this.folded)
894
+
845
895
  const changes = proposals.diffProposal(this.folded, this.proposalSession.source)
846
896
  const picked = proposals.applyProposal(this.folded, this.proposalSession.source, this.proposalSession.picked)
847
897
  const { errors, violations } = validate(picked)
@@ -851,7 +901,8 @@ export class FigureModellerPage extends FigureModellerPageBase {
851
901
  <span>${this.proposalSession.title || i18next.t('figure.text.assistant-proposed-a-figure')}</span>
852
902
  <div spacer></div>
853
903
  <button drop @click=${() => this.discard()}>${i18next.t('figure.button.discard')}</button>
854
- <button take ?disabled=${errors.length > 0 || this.proposalSession.picked.size === 0} @click=${() => this.take()}>
904
+ ${stale ? html`<span role="alert">원본이 변경되었습니다. 후보를 취소하고 AI 지원에서 다시 요청하세요.</span>` : ''}
905
+ <button take ?disabled=${stale || errors.length > 0 || this.proposalSession.picked.size === 0} @click=${() => this.take()}>
855
906
  ${i18next.t('figure.button.take-n-changes', { n: this.proposalSession.picked.size })}
856
907
  </button>
857
908
  </div>
@@ -941,6 +992,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
941
992
  * 무엇이 자기 것이고 무엇이 기계 것인지 모르게 된다.
942
993
  */
943
994
  private receive(proposal: FigureProposal) {
995
+ this.proposalBaseSource = JSON.stringify(this.folded)
944
996
  this.proposalSession = proposalSessions.openAiProposal(this.folded, proposal)
945
997
  }
946
998
 
@@ -951,6 +1003,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
951
1003
  */
952
1004
  private previewSizingFix(fix: FigureGateFix) {
953
1005
  if (!this.folded) return
1006
+ this.proposalBaseSource = JSON.stringify(this.folded)
954
1007
  this.proposalSession = proposalSessions.openSizingFix(this.folded, fix)
955
1008
  }
956
1009
 
@@ -973,7 +1026,9 @@ export class FigureModellerPage extends FigureModellerPageBase {
973
1026
  */
974
1027
  private take() {
975
1028
  if (!this.proposalSession) return
1029
+ if (this.proposalBaseSource !== undefined && this.proposalBaseSource !== JSON.stringify(this.folded)) return
976
1030
 
1031
+ this.recordHistory()
977
1032
  const taken = proposals.applyProposal(this.folded, this.proposalSession.source, this.proposalSession.picked)
978
1033
  const { board, parts } = fromFigureSource(taken)
979
1034
 
@@ -1011,6 +1066,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
1011
1066
  private discard(reportFeedback = true) {
1012
1067
  if (reportFeedback) this.reportProposalFeedback('discarded')
1013
1068
  this.proposalSession = undefined
1069
+ this.proposalBaseSource = undefined
1014
1070
  }
1015
1071
 
1016
1072
  /**
@@ -1037,13 +1093,14 @@ export class FigureModellerPage extends FigureModellerPageBase {
1037
1093
 
1038
1094
  async pageUpdated(changes: Record<string, unknown>, lifecycle: { resourceId?: string }) {
1039
1095
  if (!this.active) {
1096
+ ++this.loadGeneration
1040
1097
  return
1041
1098
  }
1042
1099
 
1043
1100
  const id = lifecycle?.resourceId
1044
1101
 
1045
1102
  if (id) {
1046
- if (id !== this.figure?.id) {
1103
+ if (changes.active || id !== this.figure?.id) {
1047
1104
  await this.load(id)
1048
1105
  }
1049
1106
  return
@@ -1067,22 +1124,24 @@ export class FigureModellerPage extends FigureModellerPageBase {
1067
1124
  }
1068
1125
 
1069
1126
  if (this.figure.id) {
1070
- /*
1071
- 고치던 것이 있으면 묻는다. 저작한 것을 말없이 버리지 않는다 — 되돌릴 수 없다.
1072
- 그만두면 보고 있던 도형의 주소로 되돌려 화면과 주소가 어긋나지 않게 한다.
1073
- */
1074
- if (this.dirty && !confirm(i18next.t('figure.text.discard-changes-and-start-new'))) {
1075
- navigate(`figure-modeller/${this.figure.id}`, true)
1076
- return
1077
- }
1127
+ this.startNew()
1128
+ return
1129
+ }
1078
1130
 
1131
+ if (shouldStartNewFigure(changes, !!this.figure, this.figure?.id)) {
1079
1132
  this.startNew()
1133
+ return
1080
1134
  }
1081
1135
  }
1082
1136
 
1137
+ private loadGeneration = 0
1083
1138
  private async load(id: string) {
1139
+ const generation = ++this.loadGeneration
1084
1140
  const figure = await fetchFigure(id)
1141
+ if (generation !== this.loadGeneration || !this.active) return
1085
1142
 
1143
+ this.proposalSession = undefined
1144
+ this.proposalBaseSource = undefined
1086
1145
  this.figure = figure
1087
1146
 
1088
1147
  /*
@@ -1116,9 +1175,17 @@ export class FigureModellerPage extends FigureModellerPageBase {
1116
1175
  this.selected = -1
1117
1176
  this.dirty = false
1118
1177
  this.violations = []
1178
+ this.undoStack = []
1179
+ this.redoStack = []
1180
+ this.lastCoalesceKey = undefined
1119
1181
  }
1120
1182
 
1121
1183
  private startNew() {
1184
+ ++this.loadGeneration
1185
+ this.proposalSession = undefined
1186
+ this.proposalBaseSource = undefined
1187
+ this.proposalFeedback = []
1188
+ this.mode = 'edit'
1122
1189
  // 타입 이름을 사람이 정하기 전까지는 임시 이름을 쓴다. 저장할 때 확정한다.
1123
1190
  const draftType = `FIGURE_${Date.now().toString(36).toUpperCase()}`
1124
1191
  const { board, parts } = startingModel(draftType)
@@ -1130,6 +1197,9 @@ export class FigureModellerPage extends FigureModellerPageBase {
1130
1197
  this.selected = -1
1131
1198
  this.dirty = true
1132
1199
  this.violations = []
1200
+ this.undoStack = []
1201
+ this.redoStack = []
1202
+ this.lastCoalesceKey = undefined
1133
1203
  }
1134
1204
 
1135
1205
  private rename(name: string) {
@@ -1152,13 +1222,62 @@ export class FigureModellerPage extends FigureModellerPageBase {
1152
1222
  this.dirty = true
1153
1223
  }
1154
1224
 
1225
+ private snapshotState(): { board?: BoardModel; parts: PartModel[]; selected: number } {
1226
+ return {
1227
+ board: this.board ? structuredClone(this.board) : undefined,
1228
+ parts: structuredClone(this.parts),
1229
+ selected: this.selected
1230
+ }
1231
+ }
1232
+
1233
+ private recordHistory(coalesceKey?: string) {
1234
+ const now = Date.now()
1235
+ if (coalesceKey && this.lastCoalesceKey === coalesceKey && now - this.lastHistoryTime < 600) {
1236
+ this.lastHistoryTime = now
1237
+ return
1238
+ }
1239
+
1240
+ this.lastCoalesceKey = coalesceKey
1241
+ this.lastHistoryTime = now
1242
+
1243
+ this.undoStack = [...this.undoStack.slice(-49), this.snapshotState()]
1244
+ this.redoStack = []
1245
+ }
1246
+
1247
+ private undo() {
1248
+ if (this.undoStack.length === 0) return
1249
+ const prev = this.undoStack[this.undoStack.length - 1]
1250
+ this.undoStack = this.undoStack.slice(0, -1)
1251
+ this.redoStack = [this.snapshotState(), ...this.redoStack.slice(0, 49)]
1252
+
1253
+ this.board = prev.board ? structuredClone(prev.board) : undefined
1254
+ this.parts = structuredClone(prev.parts)
1255
+ this.selected = this.parts.length ? Math.max(-1, Math.min(prev.selected, this.parts.length - 1)) : -1
1256
+ this.dirty = true
1257
+ this.lastCoalesceKey = undefined
1258
+ }
1259
+
1260
+ private redo() {
1261
+ if (this.redoStack.length === 0) return
1262
+ const next = this.redoStack[0]
1263
+ this.redoStack = this.redoStack.slice(1)
1264
+ this.undoStack = [...this.undoStack.slice(-49), this.snapshotState()]
1265
+
1266
+ this.board = next.board ? structuredClone(next.board) : undefined
1267
+ this.parts = structuredClone(next.parts)
1268
+ this.selected = this.parts.length ? Math.max(-1, Math.min(next.selected, this.parts.length - 1)) : -1
1269
+ this.dirty = true
1270
+ this.lastCoalesceKey = undefined
1271
+ }
1272
+
1155
1273
  /**
1156
1274
  * 부품 목록을 갈아 끼운다.
1157
1275
  *
1158
1276
  * 편집 규칙은 `part-edits` 가 갖는다 — 화면과 테스트가 같은 것을 쓰게 하려고 떼어
1159
1277
  * 놓았다. 여기서는 결과를 담고 선택 인덱스를 정리한다.
1160
1278
  */
1161
- private put(parts: PartModel[], selected = this.selected) {
1279
+ private put(parts: PartModel[], selected = this.selected, coalesceKey?: string) {
1280
+ this.recordHistory(coalesceKey)
1162
1281
  this.parts = parts
1163
1282
  this.selected = Math.min(selected, parts.length - 1)
1164
1283
  this.dirty = true
@@ -1183,7 +1302,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
1183
1302
  }
1184
1303
 
1185
1304
  private updatePart(index: number, part: PartModel) {
1186
- this.put(edits.updatePart(this.parts, index, part))
1305
+ this.put(edits.updatePart(this.parts, index, part), this.selected, `part:${part.name || index}`)
1187
1306
  }
1188
1307
 
1189
1308
  /**
@@ -1193,6 +1312,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
1193
1312
  * 고칠 때마다 부품 목록이 새 배열이 되고, 그리는 쪽이 전부 다시 그린다.
1194
1313
  */
1195
1314
  private updateBoard(board: BoardModel) {
1315
+ this.recordHistory('board')
1196
1316
  this.board = board
1197
1317
  this.dirty = true
1198
1318
  }
@@ -1210,6 +1330,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
1210
1330
  private changeDetailLevel(level: DetailLevel) {
1211
1331
  if (!this.board) return
1212
1332
 
1333
+ this.recordHistory('detailLevel')
1213
1334
  this.board = { ...this.board, detailLevel: level }
1214
1335
  this.dirty = true
1215
1336
  }
@@ -1248,7 +1369,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
1248
1369
  */
1249
1370
  private partChanged(name: string, part: PartModel) {
1250
1371
  const at = this.parts.findIndex(item => item.name === name)
1251
- if (at >= 0) this.put(edits.updatePart(this.parts, at, part))
1372
+ if (at >= 0) this.put(edits.updatePart(this.parts, at, part), this.selected, `drag:${name}`)
1252
1373
  }
1253
1374
 
1254
1375
  /**
@@ -0,0 +1,8 @@
1
+ export declare function requestFigureAI(target: {
2
+ figureId?: string;
3
+ source?: unknown;
4
+ }): void;
5
+ export declare function takeFigureAITarget(): {
6
+ figureId?: string;
7
+ source?: unknown;
8
+ } | undefined;
@@ -0,0 +1,12 @@
1
+ /** Explicit AI entry survives lazy creation of the app-level dock. */
2
+ let pending;
3
+ export function requestFigureAI(target) {
4
+ pending = structuredClone(target);
5
+ window.dispatchEvent(new CustomEvent('figure-ai-target'));
6
+ }
7
+ export function takeFigureAITarget() {
8
+ const target = pending;
9
+ pending = undefined;
10
+ return target;
11
+ }
12
+ //# sourceMappingURL=figure-ai-target.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"figure-ai-target.js","sourceRoot":"","sources":["../../client/modeller/figure-ai-target.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,IAAI,OAA4D,CAAA;AAChE,MAAM,UAAU,eAAe,CAAC,MAA+C;IAC7E,OAAO,GAAG,eAAe,CAAC,MAAM,CAAC,CAAA;IACjC,MAAM,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,kBAAkB,CAAC,CAAC,CAAA;AAC3D,CAAC;AACD,MAAM,UAAU,kBAAkB;IAChC,MAAM,MAAM,GAAG,OAAO,CAAA;IACtB,OAAO,GAAG,SAAS,CAAA;IACnB,OAAO,MAAM,CAAA;AACf,CAAC","sourcesContent":["/** Explicit AI entry survives lazy creation of the app-level dock. */\nlet pending: { figureId?: string; source?: unknown } | undefined\nexport function requestFigureAI(target: { figureId?: string; source?: unknown }) {\n pending = structuredClone(target)\n window.dispatchEvent(new CustomEvent('figure-ai-target'))\n}\nexport function takeFigureAITarget() {\n const target = pending\n pending = undefined\n return target\n}\n"]}
@@ -29,6 +29,7 @@ export declare class FigureAsk extends FigureAsk_base {
29
29
  type: string;
30
30
  /** 이 모델러 세션에서 사람이 명시적으로 남긴 이전 후보 반응. */
31
31
  feedback: ProposalFeedback[];
32
+ imageOnly: boolean;
32
33
  private asking;
33
34
  private failure;
34
35
  private picture?;
@@ -30,6 +30,7 @@ let FigureAsk = class FigureAsk extends localize(i18next)(LitElement) {
30
30
  this.type = '';
31
31
  /** 이 모델러 세션에서 사람이 명시적으로 남긴 이전 후보 반응. */
32
32
  this.feedback = [];
33
+ this.imageOnly = false;
33
34
  this.asking = false;
34
35
  this.failure = '';
35
36
  }
@@ -61,6 +62,7 @@ let FigureAsk = class FigureAsk extends localize(i18next)(LitElement) {
61
62
 
62
63
  form {
63
64
  display: flex;
65
+ flex-wrap: wrap;
64
66
  flex: 1;
65
67
  min-width: 0;
66
68
  align-items: center;
@@ -176,7 +178,7 @@ let FigureAsk = class FigureAsk extends localize(i18next)(LitElement) {
176
178
  render() {
177
179
  const revising = !!this.source;
178
180
  return html `
179
- <form @submit=${(e) => this.submit(e)}>
181
+ <form title="이미지의 형태와 비율을 참고한 후보를 만듭니다. 저장은 하지 않습니다." @submit=${(e) => this.submit(e)}>
180
182
  <md-icon lead>auto_awesome</md-icon>
181
183
  <span mode>${i18next.t(revising ? 'figure.text.ask-to-revise' : 'figure.text.ask-to-create')}</span>
182
184
  <input
@@ -191,7 +193,7 @@ let FigureAsk = class FigureAsk extends localize(i18next)(LitElement) {
191
193
  : i18next.t('figure.button.attach-a-picture')}
192
194
  <input type="file" accept="image/*" @change=${(e) => this.pick(e)} />
193
195
  </label>
194
- <button go ?disabled=${this.asking}>
196
+ <button go ?disabled=${this.asking || (this.imageOnly && !this.picture)}>
195
197
  ${this.asking ? i18next.t('figure.text.asking') : i18next.t('figure.button.ask')}
196
198
  </button>
197
199
  </form>
@@ -214,15 +216,16 @@ let FigureAsk = class FigureAsk extends localize(i18next)(LitElement) {
214
216
  e.preventDefault();
215
217
  const input = this.renderRoot.querySelector('input[ask]');
216
218
  const prompt = input?.value?.trim();
217
- if (!prompt || this.asking) {
219
+ if (!prompt || this.asking || (this.imageOnly && !this.picture)) {
218
220
  return;
219
221
  }
220
222
  this.asking = true;
221
223
  this.failure = '';
224
+ const baseSource = this.source ? JSON.stringify(this.source) : undefined;
222
225
  try {
223
226
  const proposal = await proposeFigure({
224
227
  prompt,
225
- base: this.source ? JSON.stringify(this.source) : undefined,
228
+ base: baseSource,
226
229
  type: this.source ? undefined : this.type,
227
230
  // 그리는 쪽이 풀 수 있는 토큰만 보낸다. 서버는 이 목록 밖의 색을 거절한다.
228
231
  palette: Object.keys(activePalette()),
@@ -231,7 +234,7 @@ let FigureAsk = class FigureAsk extends localize(i18next)(LitElement) {
231
234
  feedback: this.feedback,
232
235
  image: this.picture
233
236
  });
234
- this.dispatchEvent(new CustomEvent('proposed', { detail: { proposal }, bubbles: true, composed: true }));
237
+ this.dispatchEvent(new CustomEvent('proposed', { detail: { proposal, baseSource }, bubbles: true, composed: true }));
235
238
  input.value = '';
236
239
  this.picture = undefined;
237
240
  }
@@ -256,6 +259,10 @@ __decorate([
256
259
  property({ type: Array }),
257
260
  __metadata("design:type", Array)
258
261
  ], FigureAsk.prototype, "feedback", void 0);
262
+ __decorate([
263
+ property({ type: Boolean }),
264
+ __metadata("design:type", Object)
265
+ ], FigureAsk.prototype, "imageOnly", void 0);
259
266
  __decorate([
260
267
  state(),
261
268
  __metadata("design:type", Object)