@things-factory/figure-ui 10.1.4 → 10.1.6

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 (79) hide show
  1. package/client/graphql/index.ts +122 -15
  2. package/client/modeller/figure-ask.ts +5 -5
  3. package/client/modeller/figure-canvas.ts +1 -1
  4. package/client/modeller/figure-capabilities.ts +372 -0
  5. package/client/modeller/figure-history-panel.ts +477 -0
  6. package/client/modeller/figure-inspector.ts +392 -76
  7. package/client/modeller/figure-parts.ts +15 -15
  8. package/client/modeller/figure-preview.ts +2 -2
  9. package/client/modeller/figure-report.ts +102 -37
  10. package/client/modeller/figure-settings.ts +20 -20
  11. package/client/modeller/figure-side.ts +54 -8
  12. package/client/modeller/figure-source.test.ts +15 -1
  13. package/client/modeller/figure-source.ts +31 -15
  14. package/client/modeller/figure-thumbnail.ts +49 -4
  15. package/client/modeller/maturity.ts +10 -10
  16. package/client/pages/figure-list-page.ts +450 -227
  17. package/client/pages/figure-modeller-page.ts +282 -43
  18. package/client/types.ts +42 -0
  19. package/client/viewparts/figure-thumb.ts +172 -0
  20. package/dist-client/graphql/index.d.ts +31 -4
  21. package/dist-client/graphql/index.js +102 -12
  22. package/dist-client/graphql/index.js.map +1 -1
  23. package/dist-client/modeller/figure-ask.js +5 -5
  24. package/dist-client/modeller/figure-ask.js.map +1 -1
  25. package/dist-client/modeller/figure-canvas.js +1 -1
  26. package/dist-client/modeller/figure-canvas.js.map +1 -1
  27. package/dist-client/modeller/figure-capabilities.d.ts +42 -0
  28. package/dist-client/modeller/figure-capabilities.js +344 -0
  29. package/dist-client/modeller/figure-capabilities.js.map +1 -0
  30. package/dist-client/modeller/figure-history-panel.d.ts +56 -0
  31. package/dist-client/modeller/figure-history-panel.js +490 -0
  32. package/dist-client/modeller/figure-history-panel.js.map +1 -0
  33. package/dist-client/modeller/figure-inspector.d.ts +84 -0
  34. package/dist-client/modeller/figure-inspector.js +379 -75
  35. package/dist-client/modeller/figure-inspector.js.map +1 -1
  36. package/dist-client/modeller/figure-parts.js +15 -15
  37. package/dist-client/modeller/figure-parts.js.map +1 -1
  38. package/dist-client/modeller/figure-preview.js +2 -2
  39. package/dist-client/modeller/figure-preview.js.map +1 -1
  40. package/dist-client/modeller/figure-report.d.ts +18 -1
  41. package/dist-client/modeller/figure-report.js +105 -37
  42. package/dist-client/modeller/figure-report.js.map +1 -1
  43. package/dist-client/modeller/figure-settings.js +19 -19
  44. package/dist-client/modeller/figure-settings.js.map +1 -1
  45. package/dist-client/modeller/figure-side.d.ts +7 -0
  46. package/dist-client/modeller/figure-side.js +66 -7
  47. package/dist-client/modeller/figure-side.js.map +1 -1
  48. package/dist-client/modeller/figure-source.d.ts +15 -8
  49. package/dist-client/modeller/figure-source.js +16 -7
  50. package/dist-client/modeller/figure-source.js.map +1 -1
  51. package/dist-client/modeller/figure-thumbnail.js +40 -4
  52. package/dist-client/modeller/figure-thumbnail.js.map +1 -1
  53. package/dist-client/modeller/maturity.js +10 -10
  54. package/dist-client/modeller/maturity.js.map +1 -1
  55. package/dist-client/pages/figure-list-page.d.ts +45 -25
  56. package/dist-client/pages/figure-list-page.js +439 -247
  57. package/dist-client/pages/figure-list-page.js.map +1 -1
  58. package/dist-client/pages/figure-modeller-page.d.ts +54 -0
  59. package/dist-client/pages/figure-modeller-page.js +279 -43
  60. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  61. package/dist-client/tsconfig.tsbuildinfo +1 -1
  62. package/dist-client/types.d.ts +42 -0
  63. package/dist-client/types.js.map +1 -1
  64. package/dist-client/viewparts/figure-thumb.d.ts +29 -0
  65. package/dist-client/viewparts/figure-thumb.js +168 -0
  66. package/dist-client/viewparts/figure-thumb.js.map +1 -0
  67. package/dist-server/tsconfig.tsbuildinfo +1 -1
  68. package/package.json +9 -5
  69. package/test/i18n-prefix-guard.test.ts +129 -0
  70. package/test/korean-register-guard.test.ts +83 -0
  71. package/translations/en.json +307 -249
  72. package/translations/ja.json +275 -249
  73. package/translations/ko.json +307 -249
  74. package/translations/ms.json +275 -249
  75. package/translations/zh.json +275 -249
  76. package/client/viewparts/figure-card.ts +0 -226
  77. package/dist-client/viewparts/figure-card.d.ts +0 -24
  78. package/dist-client/viewparts/figure-card.js +0 -221
  79. package/dist-client/viewparts/figure-card.js.map +0 -1
@@ -1,6 +1,18 @@
1
1
  import '@material/web/icon/icon.js'
2
2
  import '../modeller/figure-side.js'
3
3
  import '../modeller/figure-ask.js'
4
+ /*
5
+ 대화 도크. **`/client` 서브패스로 적으면 안 된다.**
6
+
7
+ 그 패키지에 `exports` 맵이 없어서 `/client` 는 소스 디렉터리로 떨어지고, webpack 이 `.ts` 를 직접
8
+ 컴파일한다. 그 경로로 나온 데코레이터가 `Reflect.metadata` 를 가드 없이 불러 **브라우저에서만**
9
+ 죽는다(빌드 로그는 오류 0건). board-ai 와 plant 가 차례로 밟았다.
10
+
11
+ 그리고 더 센 이유가 있다 — `board-ui/dist-client` 의 모델러가 같은 엘리먼트를 **빌드된 경로로**
12
+ 등록한다. 다른 경로로 적으면 webpack 이 두 모듈로 보고, 두 곳이 같은 커스텀 엘리먼트 이름을
13
+ 등록해 나중에 뜨는 화면이 죽는다. 즉 「어느 경로가 되나」가 아니라 **「모두 같은 경로여야 한다」**다.
14
+ */
15
+ import '@things-factory/ai-assistant/dist-client/index.js'
4
16
  import '../modeller/figure-canvas.js'
5
17
  import { captureThumbnail } from '../modeller/figure-thumbnail.js'
6
18
  import '../modeller/figure-preview.js'
@@ -13,6 +25,7 @@ import { customElement, state } from 'lit/decorators.js'
13
25
  import { i18next, localize } from '@operato/i18n'
14
26
  import { navigate, PageView } from '@operato/shell'
15
27
  import { validate } from '@hatiolab/figure-model'
28
+ import { activePalette } from '@hatiolab/things-scene'
16
29
  import type { DetailLevel, FigureSource, PrimitiveKind } from '@hatiolab/figure-model'
17
30
 
18
31
  import * as edits from '../modeller/part-edits.js'
@@ -68,29 +81,23 @@ function rememberPanes(panes: Panes): void {
68
81
  }
69
82
 
70
83
  /**
71
- * 새로 만든 Figure 의 시작 상태. **씬 모델로 만든다.**
84
+ * 새로 만든 Figure 의 시작 상태 **빈 상자 하나뿐이다.**
72
85
  *
73
- * 상태로 시작하면 검증이 곧바로 오류를 낸다 사용자가 아무것도 하지 않았는데 빨간
74
- * 화면을 보게 된다. 그래서 부품 하나를 놓아 준다. **처음 열었을 바로 무언가 보이는
75
- * 것**이 이 도구를 익히는 첫 단계다.
86
+ * 전에는 부품(300 상자)을 하나 놓아 주었다. 형식이 부품 0개를 오류로 보기 때문이다
87
+ * (`missing-parts` parts 없다). 그래서 아무것도 했는데 붉은 판정이 뜨는 것을 피하려고
88
+ * 상자를 깔았다.
89
+ *
90
+ * **그런데 「무조건 상자」는 대개 첫 동작이 그것을 지우는 일이 된다** — 컨베이어를 만들 사람도,
91
+ * 시그널타워를 만들 사람도 상자부터 치운다. 그래서 비운다. 형식은 그대로 두고(부품 없는 것은
92
+ * 발행할 수 없는 것이 맞다) **화면이 「아직 저작 안 함」과 「형식을 어김」을 가른다.**
93
+ *
94
+ * 빈 화면이 가르치는 자리다: 부품 목록이 「위에서 도형을 골라 시작하세요」를 이미 말하고 팔레트가
95
+ * 바로 그 위에 있다(`figure-parts`).
76
96
  */
77
97
  function startingModel(type: string): { board: BoardModel; parts: PartModel[] } {
78
98
  return {
79
99
  board: { width: 300, height: 300, depth: 300, figureType: type, detailLevel: 'M' },
80
- parts: [
81
- {
82
- type: 'figure-part',
83
- name: 'body',
84
- primitive: 'cube',
85
- left: 0,
86
- top: 0,
87
- width: 300,
88
- height: 300,
89
- zPos: 0,
90
- depth: 300,
91
- token: 'palette.primary'
92
- }
93
- ]
100
+ parts: []
94
101
  }
95
102
  }
96
103
 
@@ -140,10 +147,50 @@ export class FigureModellerPage extends FigureModellerPageBase {
140
147
  div[edits] {
141
148
  flex: 1;
142
149
  display: grid;
143
- grid-template-columns: 220px 1fr 320px;
150
+ grid-template-columns: 220px 1fr 320px 0;
144
151
  min-height: 0;
145
152
  overflow: hidden;
146
153
  }
154
+ /*
155
+ 대화 도크는 **불렀을 때만 자리를 받는다.** 저작의 주인공은 캔버스이고, 대화는 필요할 때
156
+ 여는 것이다. 늘 열어 두면 넷으로 나뉜 화면에서 캔버스가 가장 좁아진다.
157
+ */
158
+ div[edits][dock] {
159
+ grid-template-columns: 220px 1fr 320px 360px;
160
+ }
161
+ div[edits] > ox-board-ai-chat {
162
+ grid-column: 4;
163
+ min-width: 0;
164
+ border-left: 1px solid var(--md-sys-color-outline-variant);
165
+ background-color: var(--md-sys-color-surface-container-lowest);
166
+ }
167
+ div[edits]:not([dock]) > ox-board-ai-chat {
168
+ display: none;
169
+ }
170
+
171
+ /* 도크를 여는 손잡이. 머리줄의 저장 단추 옆에 둔다 */
172
+ button[dock] {
173
+ flex: none;
174
+ display: flex;
175
+ align-items: center;
176
+ gap: 4px;
177
+ padding: 5px 10px;
178
+ font: var(--label-font, inherit);
179
+ font-size: 0.78rem;
180
+ color: var(--md-sys-color-on-surface-variant);
181
+ background: none;
182
+ border: 1px solid var(--md-sys-color-outline-variant);
183
+ border-radius: 6px;
184
+ cursor: pointer;
185
+ }
186
+ button[dock][on] {
187
+ color: var(--md-sys-color-primary);
188
+ border-color: var(--md-sys-color-primary);
189
+ background-color: var(--md-sys-color-surface-container);
190
+ }
191
+ button[dock] md-icon {
192
+ font-size: 16px;
193
+ }
147
194
 
148
195
  /*
149
196
  **세 칸의 자리를 못 박는다.**
@@ -251,6 +298,23 @@ export class FigureModellerPage extends FigureModellerPageBase {
251
298
  cursor: default;
252
299
  }
253
300
 
301
+ /*
302
+ 저장이 거절된 사유. 단추 **바로 왼쪽**에 둔다 — 누른 자리에서 답을 봐야 한다. 길면 줄이지만
303
+ 전문은 title 로 남긴다(서버 문장이 길 수 있다).
304
+ */
305
+ span[save-failed] {
306
+ max-width: 280px;
307
+ overflow: hidden;
308
+ text-overflow: ellipsis;
309
+ white-space: nowrap;
310
+ padding: 4px 8px;
311
+ font: var(--label-font, inherit);
312
+ font-size: 0.72rem;
313
+ color: var(--md-sys-color-on-error-container, var(--md-sys-color-on-surface));
314
+ background-color: var(--md-sys-color-error-container);
315
+ border-radius: 6px;
316
+ }
317
+
254
318
  /*
255
319
  칸을 벡터 저작도구의 관례대로 놓는다 — 왼쪽에 무엇이 있나(레이어), 오른쪽에
256
320
  고른 것의 속성. 쓰는 사람이 이미 아는 자리라 배울 것이 하나 줄어든다.
@@ -510,6 +574,18 @@ export class FigureModellerPage extends FigureModellerPageBase {
510
574
  @state() private dirty = false
511
575
  @state() private saving = false
512
576
  @state() private violations: FigureFinding[] = []
577
+ /**
578
+ * 저장이 왜 안 됐나.
579
+ *
580
+ * **`save()` 에 `catch` 가 없었다.** `try … finally` 뿐이라 서버가 거절하면(형식 위반 · 타입 이름
581
+ * 중복 · 권한 없음 · 네트워크) 단추만 멈추고 화면은 **아무 말도 하지 않았다.** 저작자는 눌렀는데
582
+ * 아무 일도 안 난 것으로 보고, 저장됐는지 아닌지도 알 수 없었다.
583
+ *
584
+ * 미저장 표시(`dirty`)는 그대로 남으므로 데이터를 잃지는 않았다. 잃은 것은 **사유**다.
585
+ */
586
+ @state() private saveFailure = ''
587
+ /** 대화 도크가 열려 있나. 저작면의 보기 상태라 저장하지 않는다. */
588
+ @state() private dockOpen = false
513
589
  /** 저작 보조가 낸 후보. 받기 전에는 원본 데이터가 아니다. */
514
590
  @state() private proposal?: FigureSource
515
591
  /** 그 후보의 셈. 무엇을 받는 것인지 숫자로도 보여 준다. */
@@ -533,7 +609,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
533
609
 
534
610
  get context() {
535
611
  return {
536
- title: this.figure?.name ?? i18next.t('title.new-figure'),
612
+ title: this.figure?.name ?? i18next.t('figure.title.new-figure'),
537
613
  /*
538
614
  **제목 줄은 끈다.** 이름은 그대로 둔다.
539
615
 
@@ -558,7 +634,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
558
634
  하네스 시절의 자리였다 — 앱에서는 셸이 페이지 제목으로 같은 이름을 그리므로 글자가 두 번
559
635
  나오고, 캔버스가 주인공인 화면에서 그만큼 높이를 잃었다.
560
636
  -->
561
- ${this.dirty ? html`<span dirty>${i18next.t('text.unsaved-changes')}</span>` : ''}
637
+ ${this.dirty ? html`<span dirty>${i18next.t('figure.text.unsaved-changes')}</span>` : ''}
562
638
  <!--
563
639
  말로 시키는 줄이 머리줄의 남는 폭을 쓴다. 따로 두면 화면 위가 두 줄이 되고, 그 위 줄은
564
640
  이름을 페이지 제목과 겹쳐 적는다.
@@ -568,8 +644,17 @@ export class FigureModellerPage extends FigureModellerPageBase {
568
644
  .type=${source?.type ?? ''}
569
645
  @proposed=${(e: CustomEvent) => this.receive(e.detail.proposal)}
570
646
  ></figure-ask>
647
+ ${this.saveFailure ? html`<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
648
+ <button
649
+ dock
650
+ ?on=${this.dockOpen}
651
+ title=${i18next.t('figure.text.dock-explained')}
652
+ @click=${() => (this.dockOpen = !this.dockOpen)}
653
+ >
654
+ <md-icon>forum</md-icon>${i18next.t('figure.button.dock')}
655
+ </button>
571
656
  <button save ?disabled=${!this.canSave} @click=${() => this.save()}>
572
- ${this.saving ? i18next.t('text.saving') : i18next.t('button.save')}
657
+ ${this.saving ? i18next.t('figure.text.saving-figure') : i18next.t('figure.button.save')}
573
658
  </button>
574
659
  </div>
575
660
 
@@ -577,7 +662,10 @@ export class FigureModellerPage extends FigureModellerPageBase {
577
662
  edits
578
663
  ?collapse-left=${this.collapsed.left}
579
664
  ?collapse-right=${this.collapsed.right}
665
+ ?dock=${this.dockOpen}
580
666
  @figure-rename=${(e: CustomEvent) => this.rename(e.detail.name)}
667
+ @figure-released=${(e: CustomEvent) => this.released(e.detail.figure)}
668
+ @figure-reverted=${() => this.figure?.id && this.load(this.figure.id)}
581
669
  @add-part=${(e: CustomEvent) => this.addPart(e.detail.primitive)}
582
670
  @select-part=${(e: CustomEvent) => (this.selected = e.detail.index)}
583
671
  @remove-part=${(e: CustomEvent) => this.removePart(e.detail.index)}
@@ -589,6 +677,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
589
677
  @toggle-part-hidden=${(e: CustomEvent) => this.toggleHidden(e.detail.index)}
590
678
  @set-all-parts-hidden=${(e: CustomEvent) => this.setAllHidden(e.detail.hidden)}
591
679
  @change-detail-level=${(e: CustomEvent) => this.changeDetailLevel(e.detail.level)}
680
+ @update-board=${(e: CustomEvent) => this.updateBoard(e.detail.board)}
592
681
  @part-tool=${(e: CustomEvent) => this.runTool(e.detail.index, e.detail.tool, e.detail.axis)}
593
682
  @pick-part=${(e: CustomEvent) => this.pickPart(e.detail.name)}
594
683
  >
@@ -605,7 +694,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
605
694
  ? html`
606
695
  <div lanes>
607
696
  <div lane>
608
- <h4>${i18next.t('label.now')}</h4>
697
+ <h4>${i18next.t('figure.label.now')}</h4>
609
698
  <figure-canvas
610
699
  .board=${this.board}
611
700
  .parts=${this.parts}
@@ -615,11 +704,11 @@ export class FigureModellerPage extends FigureModellerPageBase {
615
704
  </div>
616
705
  <div lane candidate>
617
706
  <h4>
618
- ${i18next.t('label.proposed')}
707
+ ${i18next.t('figure.label.proposed')}
619
708
  ${this.proposalOf
620
709
  ? html`<small>
621
710
  ${this.proposalOf.grade} ·
622
- ${i18next.t('text.n-triangles', { count: this.proposalOf.triangles })}
711
+ ${i18next.t('figure.text.n-triangles', { count: this.proposalOf.triangles })}
623
712
  </small>`
624
713
  : ''}
625
714
  </h4>
@@ -638,6 +727,10 @@ export class FigureModellerPage extends FigureModellerPageBase {
638
727
 
639
728
  <figure-side
640
729
  .figureName=${this.figure?.name ?? ''}
730
+ .figureId=${this.figure?.id ?? ''}
731
+ .figureState=${this.figure?.state}
732
+ .figureVersion=${this.figure?.version ?? 0}
733
+ .dirty=${this.dirty}
641
734
  .board=${this.board}
642
735
  .parts=${this.parts}
643
736
  .source=${source}
@@ -645,10 +738,71 @@ export class FigureModellerPage extends FigureModellerPageBase {
645
738
  .view=${this.view}
646
739
  @update-view=${(e: CustomEvent) => (this.view = e.detail.view)}
647
740
  ></figure-side>
741
+
742
+ <!-- 대화 도크. 무엇을 켜고 끄는지는 renderDock 위에 적어 두었다 -->
743
+ <ox-board-ai-chat
744
+ .boardTools=${false}
745
+ .toolCategories=${['figure-authoring']}
746
+ .hostContext=${this.hostContext}
747
+ .placeholder=${i18next.t('figure.text.dock-placeholder')}
748
+ @board-ai-proposal-execute=${(e: CustomEvent) => this.takeProposal(e.detail.proposal)}
749
+ ></ox-board-ai-chat>
648
750
  </div>
649
751
  `
650
752
  }
651
753
 
754
+ /*
755
+ * ── 대화 도크를 무엇으로 켜나 ───────────────────────────────────────────────
756
+ *
757
+ * board 의 컴포넌트를 그대로 쓰지만 **board 인 척하지 않는다.** `boardTools` 를 끄고 figure 의
758
+ * 도구 범주(`figure-authoring`)만 연다. 끄지 않으면 보드 편집 도구가 함께 들려, 모델이 이
759
+ * 화면에서 할 수 없는 일을 시도한다.
760
+ *
761
+ * board 모양의 인자(`currentBoard` · `knownTypes` · `componentSchemas` · `selectedRefids`)는
762
+ * 주지 않는다 — 전부 선택이고, 채우려면 없는 것을 지어내야 한다.
763
+ */
764
+
765
+ /**
766
+ * 어시스턴트가 「지금 무엇을 열고 있나」를 아는 길.
767
+ *
768
+ * 도구가 대상을 안 받았을 때 이 문맥에서 해소한다 — 사용자에게 식별자를 되묻지 않는다.
769
+ * 팔레트도 함께 보낸다: 모르면 모델이 색을 지어내고, 그 후보는 검증을 지나 **그리는 시점에**
770
+ * 죽는다.
771
+ */
772
+ private get hostContext() {
773
+ return {
774
+ surface: 'figure-modeller',
775
+ figureId: this.figure?.id || undefined,
776
+ figureType: this.board?.figureType,
777
+ figureName: this.figure?.name,
778
+ palette: Object.keys(activePalette() as Record<string, string>)
779
+ }
780
+ }
781
+
782
+ /**
783
+ * 도크의 후보를 받는다.
784
+ *
785
+ * **후보가 화면에 닿는 길은 하나뿐이다** — 말로 시키는 줄이든 도크든 `receive()` 로 모인다.
786
+ * 두 길을 두면 「지금 / 후보」 두 칸을 두 곳에서 그리게 되고, 어느 쪽이 이기는지 저작자가
787
+ * 매번 생각해야 한다.
788
+ */
789
+ private takeProposal(proposal: any) {
790
+ const source = proposal?.source
791
+ if (!source) {
792
+ // 카드는 왔는데 후보가 없다 — 조용히 넘기지 않는다. 저작자가 눌렀으니 답이 있어야 한다.
793
+ this.saveFailure = i18next.t('figure.text.proposal-had-no-source')
794
+ return
795
+ }
796
+
797
+ this.receive({
798
+ source: JSON.stringify(source),
799
+ grade: proposal.score?.grade ?? '',
800
+ triangles: proposal.cost?.triangles ?? 0,
801
+ groups: proposal.cost?.groups ?? 0,
802
+ attempts: proposal.attempts ?? 1
803
+ })
804
+ }
805
+
652
806
  /**
653
807
  * 가운데 칸 위의 띠 — 무엇을 보고 있나와, 양쪽 칸을 접는 손잡이.
654
808
  *
@@ -671,10 +825,10 @@ export class FigureModellerPage extends FigureModellerPageBase {
671
825
  <button
672
826
  mode
673
827
  ?on=${this.mode === mode}
674
- title=${i18next.t('text.mode-' + mode + '-explained')}
828
+ title=${i18next.t('figure.text.mode-' + mode + '-explained')}
675
829
  @click=${() => (this.mode = mode)}
676
830
  >
677
- ${i18next.t('label.mode-' + mode)}
831
+ ${i18next.t('figure.label.mode-' + mode)}
678
832
  </button>
679
833
  `
680
834
 
@@ -700,7 +854,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
700
854
  return html`
701
855
  <button
702
856
  collapse
703
- title=${i18next.t(`text.${gone ? 'expand' : 'collapse'}-${pane}-pane`)}
857
+ title=${i18next.t(`figure.text.${gone ? 'expand' : 'collapse'}-${pane}-pane`)}
704
858
  @click=${() => this.toggleCollapse(side)}
705
859
  >
706
860
  <md-icon>${gone ? back : shut}</md-icon>
@@ -749,11 +903,11 @@ export class FigureModellerPage extends FigureModellerPageBase {
749
903
 
750
904
  return html`
751
905
  <div decide>
752
- <span>${i18next.t('text.assistant-proposed-a-figure')}</span>
906
+ <span>${i18next.t('figure.text.assistant-proposed-a-figure')}</span>
753
907
  <div spacer></div>
754
- <button drop @click=${() => this.discard()}>${i18next.t('button.discard')}</button>
908
+ <button drop @click=${() => this.discard()}>${i18next.t('figure.button.discard')}</button>
755
909
  <button take ?disabled=${errors.length > 0 || this.picked.size === 0} @click=${() => this.take()}>
756
- ${i18next.t('button.take-n-changes', { n: this.picked.size })}
910
+ ${i18next.t('figure.button.take-n-changes', { n: this.picked.size })}
757
911
  </button>
758
912
  </div>
759
913
 
@@ -765,7 +919,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
765
919
  `
766
920
  : ''}
767
921
  ${errors.length > 0
768
- ? html`<p blocked>${i18next.t('text.picked-changes-do-not-validate')} — ${errors[0].message}</p>`
922
+ ? html`<p blocked>${i18next.t('figure.text.picked-changes-do-not-validate')} — ${errors[0].message}</p>`
769
923
  : violations.length > 0
770
924
  ? html`<p blocked warn>${violations[0].message}</p>`
771
925
  : ''}
@@ -790,7 +944,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
790
944
  <button
791
945
  change
792
946
  ?on=${on}
793
- title=${i18next.t('text.change-' + change.kind)}
947
+ title=${i18next.t('figure.text.change-' + change.kind)}
794
948
  @click=${() => this.toggle(change.key)}
795
949
  >
796
950
  <md-icon>${icon}</md-icon>
@@ -860,9 +1014,18 @@ export class FigureModellerPage extends FigureModellerPageBase {
860
1014
  *
861
1015
  * 형식을 어긴 원본 데이터로는 저장을 누를 수 없게 한다. 눌렀다가 서버가 받아주지 않는 것보다,
862
1016
  * 누를 수 없는 이유가 판단 패널에 떠 있는 것이 낫다.
1017
+ *
1018
+ * ## 바뀐 것이 없으면 누를 수 없다
1019
+ *
1020
+ * **`dirty` 를 안 보고 있었다.** 저장한 직후에도 단추가 살아 있어 같은 정본을 몇 번이고 다시 쓸
1021
+ * 수 있었고, 저장마다 썸네일을 새로 찍어 20KB 를 다시 넣고 `thumbnailUpdatedAt` 을 올렸다 —
1022
+ * 그림 주소의 판이 바뀌므로 브라우저 캐시도 함께 버려진다. 바뀐 것이 없는데 일어날 일이 아니다.
1023
+ *
1024
+ * 새로 만드는 중인 것은 `startNew` 가 `dirty` 로 시작하므로 첫 저장이 막히지 않는다.
1025
+ * 저장이 거절되면 `dirty` 를 그대로 두므로 다시 누를 수 있다.
863
1026
  */
864
1027
  private get canSave(): boolean {
865
- if (this.saving || !this.folded || !this.figure?.name) {
1028
+ if (this.saving || !this.dirty || !this.folded || !this.figure?.name) {
866
1029
  return false
867
1030
  }
868
1031
  return validate(this.folded).errors.length === 0
@@ -874,9 +1037,41 @@ export class FigureModellerPage extends FigureModellerPageBase {
874
1037
  }
875
1038
 
876
1039
  const id = lifecycle?.resourceId
877
- if (id && id !== this.figure?.id) {
878
- await this.load(id)
879
- } else if (!id && !this.figure) {
1040
+
1041
+ if (id) {
1042
+ if (id !== this.figure?.id) {
1043
+ await this.load(id)
1044
+ }
1045
+ return
1046
+ }
1047
+
1048
+ /*
1049
+ ── id 없이 들어온 것은 「새로 만든다」는 뜻이다 ─────────────────────────────
1050
+ 전에는 `!this.figure` 일 때만 새로 시작했다. 셸은 페이지를 살려 두므로(MDI 탭) 카탈로그에서
1051
+ 하나 열어 본 뒤에는 이 페이지가 그 도형을 계속 들고 있고, 그래서 「새 figure」가 **아무 일도
1052
+ 하지 않았다.**
1053
+
1054
+ 그것보다 나쁜 것이 있었다 — 화면에는 옛 도형이 그대로 있으니 사람은 새 것인 줄 알고 고치고
1055
+ 저장한다. 그러면 **열려 있던 도형에 덮어쓴다.** 「안 열린다」로 보이던 것이 실은 그 문이었다.
1056
+
1057
+ 들고 있는 것이 **저장된 도형이면**(id 가 있으면) 새로 시작한다. 아직 저장 안 한 새 도형을
1058
+ 들고 있으면 그대로 둔다 — 페이지를 다시 들렀다고 저작 중인 것을 버리지 않는다.
1059
+ */
1060
+ if (!this.figure) {
1061
+ this.startNew()
1062
+ return
1063
+ }
1064
+
1065
+ if (this.figure.id) {
1066
+ /*
1067
+ 고치던 것이 있으면 묻는다. 저작한 것을 말없이 버리지 않는다 — 되돌릴 수 없다.
1068
+ 그만두면 보고 있던 도형의 주소로 되돌려 화면과 주소가 어긋나지 않게 한다.
1069
+ */
1070
+ if (this.dirty && !confirm(i18next.t('figure.text.discard-changes-and-start-new'))) {
1071
+ navigate(`figure-modeller/${this.figure.id}`, true)
1072
+ return
1073
+ }
1074
+
880
1075
  this.startNew()
881
1076
  }
882
1077
  }
@@ -886,8 +1081,14 @@ export class FigureModellerPage extends FigureModellerPageBase {
886
1081
 
887
1082
  this.figure = figure
888
1083
 
889
- /* 그림이 없으면 이 자리에서 채운다(아래 `fillThumbnail`). 기다리지 않는다 — 화면이 먼저다. */
890
- if (!figure.thumbnail && figure.id) {
1084
+ /*
1085
+ 그림이 없으면 이 자리에서 채운다(아래 `fillThumbnail`). 기다리지 않는다 — 화면이 먼저다.
1086
+
1087
+ **`thumbnail` 이 아니라 `thumbnailUpdatedAt` 으로 판정한다.** 그림 자체는 목록·낱개 질의가
1088
+ 가져오지 않는다(base64 라서 무겁고 캐시가 안 된다 — 주소로 부른다). 없는 칸으로 판정하면
1089
+ 늘 「그림이 없다」가 되어 저작면을 열 때마다 다시 찍어 저장하게 된다.
1090
+ */
1091
+ if (!figure.thumbnailUpdatedAt && figure.id) {
891
1092
  void this.fillThumbnail(figure.id)
892
1093
  }
893
1094
 
@@ -921,7 +1122,8 @@ export class FigureModellerPage extends FigureModellerPageBase {
921
1122
  this.figure = { id: '', type: draftType, name: '' } as Figure
922
1123
  this.board = board
923
1124
  this.parts = parts
924
- this.selected = 0
1125
+ /* 고른 것이 없다 — 부품이 없으니 속성 패널도 고를 것을 기다린다. */
1126
+ this.selected = -1
925
1127
  this.dirty = true
926
1128
  this.violations = []
927
1129
  }
@@ -967,6 +1169,17 @@ export class FigureModellerPage extends FigureModellerPageBase {
967
1169
  this.put(edits.updatePart(this.parts, index, part))
968
1170
  }
969
1171
 
1172
+ /**
1173
+ * 판 자체의 값을 갈아 끼운다 — 능력처럼 부품보다 위에 있는 것들.
1174
+ *
1175
+ * 부품 목록은 건드리지 않는다. `put` 은 부품을 함께 받으므로 여기서 쓰면 능력 하나를
1176
+ * 고칠 때마다 부품 목록이 새 배열이 되고, 그리는 쪽이 전부 다시 그린다.
1177
+ */
1178
+ private updateBoard(board: BoardModel) {
1179
+ this.board = board
1180
+ this.dirty = true
1181
+ }
1182
+
970
1183
  /**
971
1184
  * 디테일 등급을 바꾼다.
972
1185
  *
@@ -1070,6 +1283,22 @@ export class FigureModellerPage extends FigureModellerPageBase {
1070
1283
  * 그림만 보낸다. 정본을 건드리지 않으므로 미저장 표시도 켜지 않는다 — 열어 봤다는 것이 고쳤다는
1071
1284
  * 뜻이 되면 저작자가 저장할지 말지를 매번 고민한다.
1072
1285
  */
1286
+ /**
1287
+ * 발행되었다 — **정본을 든 자리가 그 사실을 받는다.**
1288
+ *
1289
+ * 이력 패널은 발행을 시키고 결과를 알릴 뿐, 이 페이지의 상태를 고쳐 쓰지 않는다. 판 번호와 상태가
1290
+ * 여기서 갱신되어야 머리줄·이력 탭이 같은 것을 본다.
1291
+ *
1292
+ * 정본(source)은 발행이 건드리지 않으므로 다시 불러오지 않는다 — 저작 중인 화면을 되감지 않는다.
1293
+ */
1294
+ private released(figure: Figure): void {
1295
+ if (!this.figure || figure?.id !== this.figure.id) {
1296
+ return
1297
+ }
1298
+
1299
+ this.figure = { ...this.figure, version: figure.version, state: figure.state, updatedAt: figure.updatedAt }
1300
+ }
1301
+
1073
1302
  private async fillThumbnail(id: string): Promise<void> {
1074
1303
  await this.updateComplete
1075
1304
 
@@ -1079,8 +1308,9 @@ export class FigureModellerPage extends FigureModellerPageBase {
1079
1308
  }
1080
1309
 
1081
1310
  try {
1082
- await updateFigure(id, { thumbnail })
1083
- this.figure = { ...this.figure, thumbnail }
1311
+ const { figure } = await updateFigure(id, { thumbnail })
1312
+ /* 서버가 찍은 시각을 그대로 받는다 — 이 값이 그림을 부르는 주소의 판 번호가 된다. */
1313
+ this.figure = { ...this.figure, thumbnailUpdatedAt: figure?.thumbnailUpdatedAt }
1084
1314
  } catch (e) {
1085
1315
  /* 볼 권한만 있는 사람일 수 있다. 화면은 그대로 두고 조용히 넘어간다. */
1086
1316
  console.warn(`[thumbnail] ${id} 를 채우지 못했습니다 — ${(e as Error).message}`)
@@ -1093,6 +1323,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
1093
1323
  }
1094
1324
 
1095
1325
  this.saving = true
1326
+ this.saveFailure = ''
1096
1327
  try {
1097
1328
  const sourceJson = JSON.stringify(this.folded)
1098
1329
  /*
@@ -1118,7 +1349,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
1118
1349
  // 만든 뒤에는 고칠 수 없으므로 여기서 걸러야 한다.
1119
1350
  const taken = await fetchFigureTypeNames()
1120
1351
  if (taken.includes(this.folded.type)) {
1121
- throw new Error(i18next.t('text.type-name-already-taken', { type: this.folded.type }))
1352
+ throw new Error(i18next.t('figure.text.type-name-already-taken', { type: this.folded.type }))
1122
1353
  }
1123
1354
 
1124
1355
  const result = await createFigure({
@@ -1133,6 +1364,14 @@ export class FigureModellerPage extends FigureModellerPageBase {
1133
1364
  }
1134
1365
 
1135
1366
  this.dirty = false
1367
+ } catch (e) {
1368
+ /*
1369
+ **서버가 말한 사유를 그대로 올린다.** 여기서 문장을 다시 지으면 무엇이 걸렸는지 알 수 없다 —
1370
+ 타입 이름이 이미 쓰였다는 것과 권한이 없다는 것은 저작자가 할 일이 다르다.
1371
+
1372
+ `dirty` 는 건드리지 않는다. 저장이 안 됐으므로 미저장인 것이 사실이다.
1373
+ */
1374
+ this.saveFailure = (e as Error).message
1136
1375
  } finally {
1137
1376
  this.saving = false
1138
1377
  }
package/client/types.ts CHANGED
@@ -30,6 +30,23 @@ export interface Figure {
30
30
  version?: number
31
31
  }
32
32
 
33
+ /**
34
+ * 발행된 판 하나.
35
+ *
36
+ * 저작 중 저장은 여기 오지 않는다 — 판본은 발행에서만 생긴다.
37
+ */
38
+ export interface FigureVersion {
39
+ version: number
40
+ /** 발행하며 남긴 말. */
41
+ comment?: string
42
+ state?: 'draft' | 'released'
43
+ updatedAt?: string
44
+ updater?: { id: string; name: string }
45
+ score?: number
46
+ triangles?: number
47
+ groups?: number
48
+ }
49
+
33
50
  export interface FigureListResult {
34
51
  items: Figure[]
35
52
  total: number
@@ -94,3 +111,28 @@ export interface FigureProposal {
94
111
  /** 몇 번 만에 됐나. 진단용이다. */
95
112
  attempts: number
96
113
  }
114
+
115
+ /**
116
+ * 발행 판정 한 줄.
117
+ *
118
+ * `FigureFinding` 과 달리 **`blocking` 이 있다.** 저장 쪽 발견은 정의상 막지 않는 것들이고,
119
+ * 발행 판정은 막는 것과 알리는 것을 함께 내므로 어느 쪽인지를 값이 스스로 말한다 — 코드를 보고
120
+ * 화면이 다시 분류하면 규칙이 두 벌이 된다.
121
+ */
122
+ export interface FigureGateFinding {
123
+ code: string
124
+ message: string
125
+ /** 왜 그것이 문제인가 — 사람이 무엇을 보게 되나. */
126
+ why?: string
127
+ /** 무엇을 바꾸면 되나 — 부품 이름과 값으로. */
128
+ how?: string
129
+ /** 갈라진 부품 이름들, 또는 정본 안의 경로. */
130
+ at?: string
131
+ blocking: boolean
132
+ }
133
+
134
+ /** 발행해도 되나. `blocked` 면 서버가 거절한다. */
135
+ export interface FigureInspection {
136
+ blocked: boolean
137
+ findings: FigureGateFinding[]
138
+ }