@things-factory/figure-ui 10.1.6 → 10.1.7

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 (37) hide show
  1. package/client/graphql/index.ts +1 -0
  2. package/client/modeller/figure-animations.ts +712 -0
  3. package/client/modeller/figure-inspector.ts +47 -0
  4. package/client/modeller/figure-side.ts +65 -17
  5. package/client/pages/figure-list-page.ts +56 -4
  6. package/client/pages/figure-modeller-page.ts +25 -126
  7. package/client/types.ts +6 -0
  8. package/client/viewparts/figure-thumb.ts +78 -0
  9. package/dist-client/graphql/index.js +1 -0
  10. package/dist-client/graphql/index.js.map +1 -1
  11. package/dist-client/modeller/figure-animations.d.ts +93 -0
  12. package/dist-client/modeller/figure-animations.js +655 -0
  13. package/dist-client/modeller/figure-animations.js.map +1 -0
  14. package/dist-client/modeller/figure-inspector.d.ts +14 -0
  15. package/dist-client/modeller/figure-inspector.js +46 -0
  16. package/dist-client/modeller/figure-inspector.js.map +1 -1
  17. package/dist-client/modeller/figure-side.d.ts +23 -0
  18. package/dist-client/modeller/figure-side.js +68 -16
  19. package/dist-client/modeller/figure-side.js.map +1 -1
  20. package/dist-client/pages/figure-list-page.d.ts +10 -0
  21. package/dist-client/pages/figure-list-page.js +55 -4
  22. package/dist-client/pages/figure-list-page.js.map +1 -1
  23. package/dist-client/pages/figure-modeller-page.d.ts +7 -19
  24. package/dist-client/pages/figure-modeller-page.js +24 -126
  25. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  26. package/dist-client/tsconfig.tsbuildinfo +1 -1
  27. package/dist-client/types.d.ts +6 -0
  28. package/dist-client/types.js.map +1 -1
  29. package/dist-client/viewparts/figure-thumb.js +74 -0
  30. package/dist-client/viewparts/figure-thumb.js.map +1 -1
  31. package/package.json +3 -4
  32. package/test/i18n-prefix-guard.test.ts +42 -0
  33. package/translations/en.json +50 -6
  34. package/translations/ja.json +2 -1
  35. package/translations/ko.json +50 -6
  36. package/translations/ms.json +2 -1
  37. package/translations/zh.json +2 -1
@@ -530,6 +530,10 @@ export class FigureInspector extends localize(i18next)(LitElement) {
530
530
  /** 보드. 위치를 보드 중심 기준으로 바꾸려면 이것이 있어야 한다. */
531
531
  /** 도형의 표시 이름. 정본은 저작면이 들고, 여기서는 보여 주고 바꾼다(`figure-rename` 으로 알린다). */
532
532
  @property({ type: String }) figureName = ''
533
+ /** 분류 하나 — 팔레트에서 묶는 기준. */
534
+ @property({ type: String }) figureCategory?: string
535
+ /** 태그 여럿 — 분류로는 못 하는 찾기. */
536
+ @property({ type: Array }) figureTags?: string[]
533
537
 
534
538
  /** 펴 둔 도움말. 부품을 바꿔도 유지된다 — 매번 다시 펴게 하지 않는다. */
535
539
  @state() private opened = new Set<string>()
@@ -720,10 +724,53 @@ export class FigureInspector extends localize(i18next)(LitElement) {
720
724
  왜 못 고치는지 **적어 둔다.** 전에는 tooltip 에만 있었고, 그러면 눌러 보고 안 되는 것을
721
725
  알게 된다 — 화면이 안 되는 이유를 말하지 않으면 사용자가 자기 실수라고 생각한다.
722
726
  -->
727
+
728
+ <div field>
729
+ <label>${i18next.t('figure.label.category')}${this.help('figure.text.category-explained')}</label>
730
+ <input
731
+ .value=${this.figureCategory ?? ''}
732
+ placeholder=${i18next.t('figure.text.category-placeholder')}
733
+ @change=${(e: Event) => this.tellCatalog({ category: (e.target as HTMLInputElement).value.trim() || undefined })}
734
+ />
735
+ </div>
736
+
737
+ <div field>
738
+ <label>${i18next.t('figure.label.tags')}${this.help('figure.text.tags-explained')}</label>
739
+ <input
740
+ .value=${(this.figureTags ?? []).join(', ')}
741
+ placeholder=${i18next.t('figure.text.tags-placeholder')}
742
+ @change=${(e: Event) => this.tellTags((e.target as HTMLInputElement).value)}
743
+ />
744
+ </div>
723
745
  </section>
724
746
  `
725
747
  }
726
748
 
749
+ /**
750
+ * 태그 줄을 목록으로.
751
+ *
752
+ * 쉼표로 가르고 `#` 은 떼어 낸다 — 사람은 `#반출` 이라고 적고, 저장되는 것은 `반출` 이다.
753
+ * 겹친 것은 한 번만 남긴다. 빈 줄은 **빈 배열**이지 `undefined` 가 아니다 — 전부 지우려는
754
+ * 저작자가 지울 방법을 잃지 않게.
755
+ */
756
+ private tellTags(raw: string) {
757
+ const tags = [
758
+ ...new Set(
759
+ raw
760
+ .split(',')
761
+ .map(one => one.trim().replace(/^#+/, '').trim())
762
+ .filter(one => one.length > 0)
763
+ )
764
+ ]
765
+
766
+ this.tellCatalog({ tags })
767
+ }
768
+
769
+ /** 카탈로그 값이 바뀌었다고 알린다. 정본은 저작면이 든다 — 여기서는 알리기만 한다. */
770
+ private tellCatalog(patch: { category?: string; tags?: string[] }) {
771
+ this.dispatchEvent(new CustomEvent('figure-catalog', { detail: patch, bubbles: true, composed: true }))
772
+ }
773
+
727
774
  private renderIdentity(part: FigurePart) {
728
775
  return html`
729
776
  <section>
@@ -1,5 +1,7 @@
1
+ import '@material/web/icon/icon.js'
1
2
  import './figure-inspector.js'
2
3
  import './figure-capabilities.js'
4
+ import './figure-animations.js'
3
5
  import './figure-report.js'
4
6
  import './figure-settings.js'
5
7
  import './figure-history-panel.js'
@@ -14,7 +16,7 @@ import type { BoardModel, PartModel } from './figure-source.js'
14
16
  import { DEFAULT_VIEW } from './figure-view.js'
15
17
  import type { ViewSettings } from './figure-view.js'
16
18
 
17
- type Tab = 'properties' | 'capability' | 'verdict' | 'view' | 'history'
19
+ type Tab = 'properties' | 'capability' | 'animation' | 'verdict' | 'view' | 'history'
18
20
 
19
21
  /**
20
22
  * 저작면 오른쪽 칸 — 속성과 판정을 탭으로 겹쳐 둔다.
@@ -67,21 +69,21 @@ export class FigureSide extends localize(i18next)(LitElement) {
67
69
  button[tab] {
68
70
  flex: 1;
69
71
  /*
70
- **탭 이름은줄이다.** 배지를 이름 옆에 나란히 두었더니 넷이 좁은 칸을 나눠 쓰면서
71
- 이름이 접혀 「성숙 / 도」가 되었다. 이름이 접히면 그 줄이 두 줄이 되고 네 탭의 높이가
72
- 서로 달라진다.
72
+ **아이콘 하나가 한 칸을 쓴다.**
73
73
 
74
- 그래서 배지는 자리를 차지하지 않고 **이름 위에 겹친다**(아래 span[badge]). 개수는
75
- 곁들이는 것이고 이름이 먼저다.
74
+ 전에는 이름을 적었는데, 넷이 좁은 칸을 나눠 쓰면서 접혀 「성숙 / 도」가 되었다. 접히면
75
+ 탭만 줄이 되어 높이가 서로 달라진다. 다섯이 되면서 더는 줄여 짜 넣을 수 없어
76
+ 아이콘으로 바꿨고, 이름은 title 과 aria-label 로 남는다.
77
+
78
+ 배지는 여전히 자리를 차지하지 않고 **아이콘 위에 겹친다**(아래 span[badge]). 개수는
79
+ 곁들이는 것이다.
76
80
  */
77
81
  position: relative;
78
82
  display: flex;
79
83
  align-items: center;
80
84
  justify-content: center;
81
85
  white-space: nowrap;
82
- padding: 8px 6px;
83
- font: var(--label-font, inherit);
84
- font-size: 0.76rem;
86
+ padding: 7px 6px;
85
87
  color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
86
88
  background: none;
87
89
  border: none;
@@ -104,6 +106,11 @@ export class FigureSide extends localize(i18next)(LitElement) {
104
106
  자리를 먹지 않게 오른쪽 위에 겹쳐 둔다 — 탭 이름을 밀지 않으므로 이름이 접히지 않는다.
105
107
  숫자가 커져도(1,200 같은 것) 이름 위로 넘칠 뿐 줄을 바꾸지 않는다.
106
108
  */
109
+ button[tab] md-icon {
110
+ font-size: 20px;
111
+ width: 20px;
112
+ height: 20px;
113
+ }
107
114
  span[badge] {
108
115
  position: absolute;
109
116
  /* 이름 위에 겹치되 위로 붙지 않게 — 탭 가운데 글자와 눈높이를 조금 맞춘다 */
@@ -120,6 +127,7 @@ export class FigureSide extends localize(i18next)(LitElement) {
120
127
  }
121
128
 
122
129
  figure-inspector,
130
+ figure-animations,
123
131
  figure-report,
124
132
  figure-settings {
125
133
  flex: 1;
@@ -139,6 +147,9 @@ export class FigureSide extends localize(i18next)(LitElement) {
139
147
  곳이라 변환된 FigureSource 를 받는다. 변환은 모델러 페이지가 한 번만 해서 내려보낸다.
140
148
  */
141
149
  @property({ type: String }) figureName = ''
150
+ /** 카탈로그 분류·태그. 속성 탭이 보여 주고 바꾼다. */
151
+ @property({ type: String }) figureCategory?: string
152
+ @property({ type: Array }) figureTags?: string[]
142
153
  /** 이력 탭이 발행하고 되돌리는 데 필요한 것들. 정본은 저작면이 든다. */
143
154
  @property({ type: String }) figureId?: string
144
155
  @property({ type: String }) figureState?: 'draft' | 'released'
@@ -156,19 +167,34 @@ export class FigureSide extends localize(i18next)(LitElement) {
156
167
  /** 판정이 알려 준 개수. 탭 이름표에 붙는다. */
157
168
  @state() private instances?: number
158
169
 
170
+ /**
171
+ * 이 도형이 든 clip 수. 탭 아이콘에 붙는다.
172
+ *
173
+ * 아이콘만으로는 「여기에 무언가 있다」를 알 수 없다. 대부분의 도형은 움직이지 않으므로
174
+ * 이 탭은 늘 비어 있을 것이고, 그러면 움직이는 도형을 열었을 때 그 사실이 눈에 걸려야 한다.
175
+ * 0 이면 아무것도 안 붙인다 — 「0」을 붙이면 없는 것이 있는 것처럼 자리를 차지한다.
176
+ */
177
+ private get clipCount(): number | undefined {
178
+ const n = this.board?.animations?.length ?? 0
179
+ return n === 0 ? undefined : n
180
+ }
181
+
159
182
  render() {
160
183
  return html`
161
184
  <div tabs>
162
- ${this.renderTab('properties', i18next.t('figure.label.properties'))}
163
- ${this.renderTab('capability', i18next.t('figure.label.tab-capability'))}
164
- ${this.renderTab('verdict', i18next.t('figure.label.maturity'), this.instances)}
165
- ${this.renderTab('view', i18next.t('figure.label.tab-environment'))}
166
- ${this.renderTab('history', i18next.t('figure.label.history'))}
185
+ ${this.renderTab('properties', 'tune', i18next.t('figure.label.properties'))}
186
+ ${this.renderTab('capability', 'conveyor_belt', i18next.t('figure.label.tab-capability'))}
187
+ ${this.renderTab('animation', 'animation', i18next.t('figure.label.animation'), this.clipCount)}
188
+ ${this.renderTab('verdict', 'insights', i18next.t('figure.label.maturity'), this.instances)}
189
+ ${this.renderTab('view', 'wb_sunny', i18next.t('figure.label.tab-environment'))}
190
+ ${this.renderTab('history', 'history', i18next.t('figure.label.history'))}
167
191
  </div>
168
192
 
169
193
  <figure-inspector
170
194
  ?off=${this.tab !== 'properties'}
171
195
  .figureName=${this.figureName}
196
+ .figureCategory=${this.figureCategory}
197
+ .figureTags=${this.figureTags}
172
198
  .board=${this.board}
173
199
  .parts=${this.parts}
174
200
  .selected=${this.selected}
@@ -180,6 +206,12 @@ export class FigureSide extends localize(i18next)(LitElement) {
180
206
  .parts=${this.parts}
181
207
  ></figure-capabilities>
182
208
 
209
+ <figure-animations
210
+ ?off=${this.tab !== 'animation'}
211
+ .board=${this.board}
212
+ .parts=${this.parts}
213
+ ></figure-animations>
214
+
183
215
  <figure-report
184
216
  ?off=${this.tab !== 'verdict'}
185
217
  .source=${this.source}
@@ -201,10 +233,26 @@ export class FigureSide extends localize(i18next)(LitElement) {
201
233
  `
202
234
  }
203
235
 
204
- private renderTab(tab: Tab, label: string, badge?: number) {
236
+ /**
237
+ * 탭 하나 — **아이콘이 이름을 대신한다.**
238
+ *
239
+ * 탭이 다섯이 되면서 이름이 좁은 칸을 나눠 쓰다 접혔다. 앞으로 더 늘어날 자리이므로 이름을
240
+ * 줄여 짜 넣는 대신 아이콘으로 바꾼다.
241
+ *
242
+ * **이름을 버리지는 않는다.** 아이콘만으로는 무엇인지 모르는 사람이 있고, 특히 「능력」처럼
243
+ * 이 도구에만 있는 개념이 그렇다. 그래서 이름은 `title` 로 남아 마우스를 올리면 나오고,
244
+ * `aria-label` 로 읽는 기계에도 그대로 간다.
245
+ */
246
+ private renderTab(tab: Tab, icon: string, label: string, badge?: number) {
205
247
  return html`
206
- <button tab ?on=${this.tab === tab} @click=${() => (this.tab = tab)}>
207
- ${label}${badge == null ? '' : html`<span badge>${badge.toLocaleString()}</span>`}
248
+ <button
249
+ tab
250
+ ?on=${this.tab === tab}
251
+ title=${label}
252
+ aria-label=${label}
253
+ @click=${() => (this.tab = tab)}
254
+ >
255
+ <md-icon>${icon}</md-icon>${badge == null ? '' : html`<span badge>${badge.toLocaleString()}</span>`}
208
256
  </button>
209
257
  `
210
258
  }
@@ -36,10 +36,21 @@ const CAPACITY = 'font-weight: 700; color: var(--md-sys-color-primary);'
36
36
  const UNMEASURED = 'font-style: italic; color: var(--md-sys-color-outline);'
37
37
  const RELEASED = 'font-weight: 700; color: var(--md-sys-color-primary);'
38
38
  const DRAFT = 'color: var(--md-sys-color-tertiary);'
39
- /** 조용히 적는 숫자. 낮은 것만 점으로 눈에 걸리게 한다. */
40
- const SCORE = 'display: inline-flex; align-items: center; gap: 4px; font-variant-numeric: tabular-nums;'
39
+ /**
40
+ * 조용히 적는 숫자. 낮은 것만 점으로 눈에 걸리게 한다.
41
+ *
42
+ * **`inline-flex` 를 쓰지 않는다.** 썼더니 점이 뜨는 카드에서만 이 값이 옆의 두 값과 밑줄이
43
+ * 어긋났다 — inline-flex 상자의 밑줄은 **첫 항목**에서 오는데, 첫 항목이 글자 없는 점이라
44
+ * 밑줄이 없어 브라우저가 상자 아래를 밑줄로 삼는다. 점이 없는 카드(50 이상)는 멀쩡했으므로
45
+ * 한 줄에 세 값이 나란히 설 때만 눈에 띄었다.
46
+ *
47
+ * 그냥 인라인으로 두면 이 상자의 밑줄이 곧 숫자의 밑줄이라 옆의 둘과 맞는다. 점은 제 몫으로
48
+ * 글자 가운데에 맞춘다.
49
+ */
50
+ const SCORE = 'font-variant-numeric: tabular-nums;'
41
51
  const LOW_DOT =
42
- 'width: 5px; height: 5px; border-radius: 50%; background-color: var(--md-sys-color-error); flex: none;'
52
+ 'display: inline-block; width: 5px; height: 5px; margin-right: 4px; vertical-align: middle; ' +
53
+ 'border-radius: 50%; background-color: var(--md-sys-color-error);'
43
54
 
44
55
  /*
45
56
  * `as typeof PageView` 로 조이지 않는다 — 그렇게 하면 p13n 이 얹어 주는
@@ -179,7 +190,7 @@ export class FigureListPage extends FigureListPageBase {
179
190
  this.grist.searchText = search
180
191
  },
181
192
  value: this.grist?.searchText || '',
182
- placeholder: i18next.t('figure.text.search-figure-by-name-or-type')
193
+ placeholder: i18next.t('figure.text.search-figure')
183
194
  },
184
195
  filter: {
185
196
  handler: () => {
@@ -200,6 +211,7 @@ export class FigureListPage extends FigureListPageBase {
200
211
  return html`
201
212
  <ox-grist
202
213
  mode="CARD"
214
+ @search-tag=${(e: CustomEvent) => this.findByTag(e.detail.tag)}
203
215
  .config=${this.gristConfig}
204
216
  .fetchHandler=${this.fetchHandler.bind(this)}
205
217
  .personalConfigProvider=${this.getPagePreferenceProvider('ox-grist')!}
@@ -219,6 +231,21 @@ export class FigureListPage extends FigureListPageBase {
219
231
  `
220
232
  }
221
233
 
234
+ /**
235
+ * 카드의 태그 알약을 눌렀다 — **그 태그로 목록을 거른다.**
236
+ *
237
+ * 검색창에 그 말을 채워 넣는다. 격자에 조건만 걸고 창을 비워 두면 저작자는 **왜 목록이
238
+ * 줄었는지 모른 채** 보게 되고, 되돌리려면 무엇을 지워야 하는지도 알 수 없다.
239
+ *
240
+ * 서버가 `tags` 뿐 아니라 이름·타입·설명·분류를 함께 훑으므로, 그 말이 다른 칸에 든 도형도
241
+ * 같이 온다. 태그만 정확히 거르는 것이 아니라 **찾기**다.
242
+ */
243
+ private findByTag(tag: string) {
244
+ this.grist.searchText = tag
245
+ /* 창의 값은 `context` 에서 나온다 — 다시 알려야 헤더가 새 값을 그린다. */
246
+ this.updateContext()
247
+ }
248
+
222
249
  async pageInitialized() {
223
250
  this.gristConfig = {
224
251
  /*
@@ -300,6 +327,31 @@ export class FigureListPage extends FigureListPageBase {
300
327
  sortable: true,
301
328
  width: 160
302
329
  },
330
+ {
331
+ /*
332
+ 태그 — **카드에서 곁줄로 보이고 검색에 걸린다.**
333
+
334
+ 서버가 이미 `type`·`name`·`description`·`category`·`tags` 를 함께 훑는다. 그런데
335
+ 화면에는 태그가 어디에도 안 보여서, 저작자가 적은 태그가 **자기 눈에 돌아오지 않았다** —
336
+ 적어 둔 것이 쓰이는지 알 수 없으면 아무도 적지 않는다.
337
+
338
+ 정렬은 걸지 않는다. 배열이라 무엇을 기준으로 줄을 세우는지 답할 수 없다.
339
+
340
+ **카드에서는 이 컬럼이 그리지 않는다.** 본문에 한 줄로 두었더니 태그가 있는 카드만
341
+ 높아져 격자가 어긋났다 — 개수가 도형마다 다르므로 본문에 두는 한 남는 문제다.
342
+ 카드에서는 `figure-thumb` 이 그림 위에 얹는다. 여기 남은 것은 표 모드의 칸이다.
343
+ */
344
+ type: 'string',
345
+ name: 'tags',
346
+ header: i18next.t('figure.label.tags'),
347
+ record: {
348
+ editable: false,
349
+ renderer: (value: string[] | null) => (value || []).join(' · ')
350
+ },
351
+ filter: 'search',
352
+ sortable: false,
353
+ width: 180
354
+ },
303
355
  {
304
356
  type: 'string',
305
357
  name: 'state',
@@ -1,18 +1,6 @@
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'
16
4
  import '../modeller/figure-canvas.js'
17
5
  import { captureThumbnail } from '../modeller/figure-thumbnail.js'
18
6
  import '../modeller/figure-preview.js'
@@ -25,7 +13,6 @@ import { customElement, state } from 'lit/decorators.js'
25
13
  import { i18next, localize } from '@operato/i18n'
26
14
  import { navigate, PageView } from '@operato/shell'
27
15
  import { validate } from '@hatiolab/figure-model'
28
- import { activePalette } from '@hatiolab/things-scene'
29
16
  import type { DetailLevel, FigureSource, PrimitiveKind } from '@hatiolab/figure-model'
30
17
 
31
18
  import * as edits from '../modeller/part-edits.js'
@@ -147,50 +134,10 @@ export class FigureModellerPage extends FigureModellerPageBase {
147
134
  div[edits] {
148
135
  flex: 1;
149
136
  display: grid;
150
- grid-template-columns: 220px 1fr 320px 0;
137
+ grid-template-columns: 220px 1fr 320px;
151
138
  min-height: 0;
152
139
  overflow: hidden;
153
140
  }
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
- }
194
141
 
195
142
  /*
196
143
  **세 칸의 자리를 못 박는다.**
@@ -584,8 +531,6 @@ export class FigureModellerPage extends FigureModellerPageBase {
584
531
  * 미저장 표시(`dirty`)는 그대로 남으므로 데이터를 잃지는 않았다. 잃은 것은 **사유**다.
585
532
  */
586
533
  @state() private saveFailure = ''
587
- /** 대화 도크가 열려 있나. 저작면의 보기 상태라 저장하지 않는다. */
588
- @state() private dockOpen = false
589
534
  /** 저작 보조가 낸 후보. 받기 전에는 원본 데이터가 아니다. */
590
535
  @state() private proposal?: FigureSource
591
536
  /** 그 후보의 셈. 무엇을 받는 것인지 숫자로도 보여 준다. */
@@ -645,14 +590,6 @@ export class FigureModellerPage extends FigureModellerPageBase {
645
590
  @proposed=${(e: CustomEvent) => this.receive(e.detail.proposal)}
646
591
  ></figure-ask>
647
592
  ${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>
656
593
  <button save ?disabled=${!this.canSave} @click=${() => this.save()}>
657
594
  ${this.saving ? i18next.t('figure.text.saving-figure') : i18next.t('figure.button.save')}
658
595
  </button>
@@ -662,8 +599,8 @@ export class FigureModellerPage extends FigureModellerPageBase {
662
599
  edits
663
600
  ?collapse-left=${this.collapsed.left}
664
601
  ?collapse-right=${this.collapsed.right}
665
- ?dock=${this.dockOpen}
666
602
  @figure-rename=${(e: CustomEvent) => this.rename(e.detail.name)}
603
+ @figure-catalog=${(e: CustomEvent) => this.catalog(e.detail)}
667
604
  @figure-released=${(e: CustomEvent) => this.released(e.detail.figure)}
668
605
  @figure-reverted=${() => this.figure?.id && this.load(this.figure.id)}
669
606
  @add-part=${(e: CustomEvent) => this.addPart(e.detail.primitive)}
@@ -727,6 +664,8 @@ export class FigureModellerPage extends FigureModellerPageBase {
727
664
 
728
665
  <figure-side
729
666
  .figureName=${this.figure?.name ?? ''}
667
+ .figureCategory=${this.figure?.category}
668
+ .figureTags=${this.figure?.tags}
730
669
  .figureId=${this.figure?.id ?? ''}
731
670
  .figureState=${this.figure?.state}
732
671
  .figureVersion=${this.figure?.version ?? 0}
@@ -738,71 +677,10 @@ export class FigureModellerPage extends FigureModellerPageBase {
738
677
  .view=${this.view}
739
678
  @update-view=${(e: CustomEvent) => (this.view = e.detail.view)}
740
679
  ></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>
750
680
  </div>
751
681
  `
752
682
  }
753
683
 
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
-
806
684
  /**
807
685
  * 가운데 칸 위의 띠 — 무엇을 보고 있나와, 양쪽 칸을 접는 손잡이.
808
686
  *
@@ -1135,6 +1013,19 @@ export class FigureModellerPage extends FigureModellerPageBase {
1135
1013
  this.dirty = true
1136
1014
  }
1137
1015
 
1016
+ /**
1017
+ * 분류와 태그를 고친다.
1018
+ *
1019
+ * 정본(`source`)이 아니라 **카탈로그의 값**이다 — 도형이 무엇으로 생겼는지가 아니라 어디에
1020
+ * 걸리는지다. 그래서 점수도 발행도 흔들리지 않는다.
1021
+ */
1022
+ private catalog(patch: { category?: string; tags?: string[] }) {
1023
+ if (!this.figure) return
1024
+
1025
+ this.figure = { ...this.figure, ...patch }
1026
+ this.dirty = true
1027
+ }
1028
+
1138
1029
  /**
1139
1030
  * 부품 목록을 갈아 끼운다.
1140
1031
  *
@@ -1339,6 +1230,12 @@ export class FigureModellerPage extends FigureModellerPageBase {
1339
1230
  if (this.figure.id) {
1340
1231
  const result = await updateFigure(this.figure.id, {
1341
1232
  name: this.figure.name,
1233
+ /*
1234
+ 카탈로그의 값도 함께 보낸다. 화면에서 고쳐 놓고 저장에 안 실으면 저장 뒤에 조용히
1235
+ 사라진다 — 저작자는 저장했다고 보고 다시 열면 없다.
1236
+ */
1237
+ category: this.figure.category,
1238
+ tags: this.figure.tags,
1342
1239
  source: sourceJson,
1343
1240
  thumbnail
1344
1241
  })
@@ -1355,6 +1252,8 @@ export class FigureModellerPage extends FigureModellerPageBase {
1355
1252
  const result = await createFigure({
1356
1253
  type: this.folded.type,
1357
1254
  name: this.figure.name,
1255
+ category: this.figure.category,
1256
+ tags: this.figure.tags,
1358
1257
  source: sourceJson,
1359
1258
  thumbnail
1360
1259
  })
package/client/types.ts CHANGED
@@ -14,7 +14,10 @@ export interface Figure {
14
14
  type: string
15
15
  name: string
16
16
  description?: string
17
+ /** 분류 하나 — 팔레트에서 묶어 보이는 기준. */
17
18
  category?: string
19
+ /** 태그 여럿 — 분류로는 못 하는 찾기. */
20
+ tags?: string[]
18
21
  state?: 'draft' | 'released'
19
22
  /** 재활용 점수 0~100. 서버가 정본에서 계산한다 — 화면이 보내는 값이 아니다. */
20
23
  score?: number
@@ -63,6 +66,7 @@ export interface NewFigure {
63
66
  name: string
64
67
  description?: string
65
68
  category?: string
69
+ tags?: string[]
66
70
  source: string
67
71
  properties?: string
68
72
  state?: 'draft' | 'released'
@@ -79,6 +83,8 @@ export interface FigurePatch {
79
83
  name?: string
80
84
  description?: string
81
85
  category?: string
86
+ /** 빈 배열은 「전부 지운다」, 안 주는 것은 「건드리지 않는다」. */
87
+ tags?: string[]
82
88
  source?: string
83
89
  properties?: string
84
90
  state?: 'draft' | 'released'
@@ -105,6 +105,65 @@ export class FigureThumb extends localize(i18next)(LitElement) {
105
105
  border-radius: 4px;
106
106
  }
107
107
 
108
+ /*
109
+ 태그 — **그림 위에 얹는다.**
110
+
111
+ 카드 본문에 한 줄로 두었더니 태그가 있는 카드만 그만큼 높아져 격자가 들쭉날쭉해졌다.
112
+ 태그는 도형마다 개수가 다르므로 본문에 두는 한 이 문제가 남는다. 그림 위는 이미 자리가
113
+ 잡혀 있고(초안 표시·삭제), 얹어도 카드 높이가 변하지 않는다.
114
+
115
+ 아래쪽에 둔다 — 위는 초안 표시와 삭제 단추가 쓴다.
116
+
117
+ **투명한 알약이다.** 면을 꽉 채우면 그림 위에 흰 띠가 생겨 형상을 가린다 — 카탈로그에서
118
+ 먼저 보아야 하는 것은 형상이다. 그래서 바탕은 거의 비우고, 읽히는 것은 뒤를 흐리는 것과
119
+ 실낱 테두리가 맡는다. 흐림이 안 되는 환경에서도 테두리가 남아 글자가 형상에 묻히지 않는다.
120
+ */
121
+ div[tags] {
122
+ position: absolute;
123
+ right: 6px;
124
+ bottom: 6px;
125
+ left: 6px;
126
+ display: flex;
127
+ flex-wrap: wrap-reverse;
128
+ justify-content: flex-end;
129
+ gap: 3px;
130
+ /* 태그가 많아도 그림을 덮지 않는다 — 넘치는 것은 위로 잘린다 */
131
+ max-height: 44px;
132
+ overflow: hidden;
133
+ /* 알약 사이의 빈 자리는 그림이 받는다 — 거기서 누르면 저작면이 열린다 */
134
+ pointer-events: none;
135
+ }
136
+ /*
137
+ **알약은 누를 수 있다.** 누르면 그 태그로 목록을 거른다.
138
+
139
+ 태그를 눈에 보이게 한 다음의 물음은 「그래서 이걸로 뭘 하나」다. 같은 태그가 붙은 것을
140
+ 보려고 태그를 눈으로 읽어 검색창에 옮겨 적게 두면, 보여 준 뜻이 절반만 산다.
141
+
142
+ 카드 자체가 저작면을 여는 자리이므로 알약에서 그 클릭을 멈춘다 — 안 멈추면 태그를 누른
143
+ 사람이 편집기로 끌려간다.
144
+ */
145
+ div[tags] button {
146
+ pointer-events: auto;
147
+ cursor: pointer;
148
+ padding: 0 7px;
149
+ font: var(--label-font, inherit);
150
+ font-size: 0.66rem;
151
+ line-height: 1.7;
152
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
153
+ background-color: color-mix(in srgb, var(--md-sys-color-surface, #fff) 34%, transparent);
154
+ border: 1px solid color-mix(in srgb, var(--md-sys-color-outline, #888) 42%, transparent);
155
+ backdrop-filter: blur(4px);
156
+ border-radius: 999em;
157
+ white-space: nowrap;
158
+ }
159
+ div[tags] button:hover,
160
+ div[tags] button:focus-visible {
161
+ color: var(--md-sys-color-on-surface);
162
+ background-color: color-mix(in srgb, var(--md-sys-color-surface, #fff) 72%, transparent);
163
+ border-color: var(--md-sys-color-primary);
164
+ outline: none;
165
+ }
166
+
108
167
  button[remove] {
109
168
  position: absolute;
110
169
  top: 5px;
@@ -155,6 +214,25 @@ export class FigureThumb extends localize(i18next)(LitElement) {
155
214
  </div>
156
215
  `}
157
216
  ${figure.state === 'released' ? '' : html`<span draft>${i18next.t('figure.label.draft')}</span>`}
217
+ ${figure.tags?.length
218
+ ? html`<div tags>
219
+ ${figure.tags.map(
220
+ tag => html`
221
+ <button
222
+ title=${i18next.t('figure.text.find-by-this-tag')}
223
+ @click=${(e: Event) => {
224
+ e.stopPropagation()
225
+ this.dispatchEvent(
226
+ new CustomEvent('search-tag', { detail: { tag }, bubbles: true, composed: true })
227
+ )
228
+ }}
229
+ >
230
+ ${tag}
231
+ </button>
232
+ `
233
+ )}
234
+ </div>`
235
+ : ''}
158
236
 
159
237
  <button
160
238
  remove
@@ -34,6 +34,7 @@ const FIGURE_LIST_FIELDS = `
34
34
  name
35
35
  description
36
36
  category
37
+ tags
37
38
  state
38
39
  version
39
40
  score