@things-factory/figure-ui 10.1.3

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 (112) hide show
  1. package/client/bootstrap.ts +16 -0
  2. package/client/graphql/index.ts +209 -0
  3. package/client/index.ts +2 -0
  4. package/client/modeller/figure-ask.ts +247 -0
  5. package/client/modeller/figure-canvas.ts +482 -0
  6. package/client/modeller/figure-inspector.ts +1054 -0
  7. package/client/modeller/figure-parts.ts +479 -0
  8. package/client/modeller/figure-preview.ts +381 -0
  9. package/client/modeller/figure-report.ts +895 -0
  10. package/client/modeller/figure-settings.ts +285 -0
  11. package/client/modeller/figure-side.ts +162 -0
  12. package/client/modeller/figure-source.test.ts +336 -0
  13. package/client/modeller/figure-source.ts +338 -0
  14. package/client/modeller/figure-view.ts +79 -0
  15. package/client/modeller/maturity.ts +288 -0
  16. package/client/modeller/part-edits.test.ts +252 -0
  17. package/client/modeller/part-edits.ts +246 -0
  18. package/client/modeller/proposal.ts +142 -0
  19. package/client/modeller/scene-budget.ts +102 -0
  20. package/client/pages/figure-list-page.ts +274 -0
  21. package/client/pages/figure-modeller-page.ts +1067 -0
  22. package/client/route.ts +11 -0
  23. package/client/themes/brand.css +51 -0
  24. package/client/themes/dark.css +64 -0
  25. package/client/themes/light.css +95 -0
  26. package/client/tsconfig.json +19 -0
  27. package/client/types.ts +96 -0
  28. package/client/viewparts/figure-card.ts +226 -0
  29. package/dist-client/bootstrap.d.ts +14 -0
  30. package/dist-client/bootstrap.js +17 -0
  31. package/dist-client/bootstrap.js.map +1 -0
  32. package/dist-client/graphql/index.d.ts +41 -0
  33. package/dist-client/graphql/index.js +173 -0
  34. package/dist-client/graphql/index.js.map +1 -0
  35. package/dist-client/index.d.ts +2 -0
  36. package/dist-client/index.js +3 -0
  37. package/dist-client/index.js.map +1 -0
  38. package/dist-client/modeller/figure-ask.d.ts +36 -0
  39. package/dist-client/modeller/figure-ask.js +256 -0
  40. package/dist-client/modeller/figure-ask.js.map +1 -0
  41. package/dist-client/modeller/figure-canvas.d.ts +128 -0
  42. package/dist-client/modeller/figure-canvas.js +431 -0
  43. package/dist-client/modeller/figure-canvas.js.map +1 -0
  44. package/dist-client/modeller/figure-inspector.d.ts +149 -0
  45. package/dist-client/modeller/figure-inspector.js +991 -0
  46. package/dist-client/modeller/figure-inspector.js.map +1 -0
  47. package/dist-client/modeller/figure-parts.d.ts +64 -0
  48. package/dist-client/modeller/figure-parts.js +478 -0
  49. package/dist-client/modeller/figure-parts.js.map +1 -0
  50. package/dist-client/modeller/figure-preview.d.ts +62 -0
  51. package/dist-client/modeller/figure-preview.js +360 -0
  52. package/dist-client/modeller/figure-preview.js.map +1 -0
  53. package/dist-client/modeller/figure-report.d.ts +134 -0
  54. package/dist-client/modeller/figure-report.js +881 -0
  55. package/dist-client/modeller/figure-report.js.map +1 -0
  56. package/dist-client/modeller/figure-settings.d.ts +39 -0
  57. package/dist-client/modeller/figure-settings.js +277 -0
  58. package/dist-client/modeller/figure-settings.js.map +1 -0
  59. package/dist-client/modeller/figure-side.d.ts +41 -0
  60. package/dist-client/modeller/figure-side.js +175 -0
  61. package/dist-client/modeller/figure-side.js.map +1 -0
  62. package/dist-client/modeller/figure-source.d.ts +196 -0
  63. package/dist-client/modeller/figure-source.js +153 -0
  64. package/dist-client/modeller/figure-source.js.map +1 -0
  65. package/dist-client/modeller/figure-view.d.ts +57 -0
  66. package/dist-client/modeller/figure-view.js +36 -0
  67. package/dist-client/modeller/figure-view.js.map +1 -0
  68. package/dist-client/modeller/maturity.d.ts +55 -0
  69. package/dist-client/modeller/maturity.js +212 -0
  70. package/dist-client/modeller/maturity.js.map +1 -0
  71. package/dist-client/modeller/part-edits.d.ts +64 -0
  72. package/dist-client/modeller/part-edits.js +218 -0
  73. package/dist-client/modeller/part-edits.js.map +1 -0
  74. package/dist-client/modeller/proposal.d.ts +45 -0
  75. package/dist-client/modeller/proposal.js +96 -0
  76. package/dist-client/modeller/proposal.js.map +1 -0
  77. package/dist-client/modeller/scene-budget.d.ts +78 -0
  78. package/dist-client/modeller/scene-budget.js +80 -0
  79. package/dist-client/modeller/scene-budget.js.map +1 -0
  80. package/dist-client/pages/figure-list-page.d.ts +40 -0
  81. package/dist-client/pages/figure-list-page.js +280 -0
  82. package/dist-client/pages/figure-list-page.js.map +1 -0
  83. package/dist-client/pages/figure-modeller-page.d.ts +219 -0
  84. package/dist-client/pages/figure-modeller-page.js +1045 -0
  85. package/dist-client/pages/figure-modeller-page.js.map +1 -0
  86. package/dist-client/route.d.ts +1 -0
  87. package/dist-client/route.js +11 -0
  88. package/dist-client/route.js.map +1 -0
  89. package/dist-client/themes/brand.css +51 -0
  90. package/dist-client/themes/dark.css +64 -0
  91. package/dist-client/themes/light.css +95 -0
  92. package/dist-client/types.d.ts +93 -0
  93. package/dist-client/types.js +2 -0
  94. package/dist-client/types.js.map +1 -0
  95. package/dist-client/viewparts/figure-card.d.ts +24 -0
  96. package/dist-client/viewparts/figure-card.js +221 -0
  97. package/dist-client/viewparts/figure-card.js.map +1 -0
  98. package/dist-server/index.d.ts +7 -0
  99. package/dist-server/index.js +3 -0
  100. package/dist-server/index.js.map +1 -0
  101. package/package.json +41 -0
  102. package/server/index.ts +7 -0
  103. package/server/tsconfig.json +18 -0
  104. package/test/brand-palette.test.ts +188 -0
  105. package/test/heading-scale-guard.test.ts +144 -0
  106. package/test/resolve-ts-siblings.mjs +67 -0
  107. package/things-factory.config.js +11 -0
  108. package/translations/en.json +247 -0
  109. package/translations/ja.json +247 -0
  110. package/translations/ko.json +247 -0
  111. package/translations/ms.json +247 -0
  112. package/translations/zh.json +247 -0
@@ -0,0 +1,479 @@
1
+ import '@material/web/icon/icon.js'
2
+
3
+ import { css, html, LitElement, nothing } from 'lit'
4
+ import { customElement, property } from 'lit/decorators.js'
5
+
6
+ import { i18next, localize } from '@operato/i18n'
7
+ import { ScrollbarStyles } from '@operato/styles'
8
+ import { DETAIL_LEVELS, PART_LIMIT, PRIMITIVE_KINDS } from '@hatiolab/figure-model'
9
+ import type { DetailLevel, PrimitiveKind } from '@hatiolab/figure-model'
10
+
11
+ import type { PartModel } from './figure-source.js'
12
+
13
+ /** 도형마다의 아이콘. Material Symbols 이름이다. */
14
+ const SHAPE_ICON: Record<PrimitiveKind, string> = {
15
+ cube: 'deployed_code',
16
+ wall: 'rectangle',
17
+ cylinder: 'database',
18
+ sphere: 'circle',
19
+ rect: 'crop_square',
20
+ polygon: 'pentagon'
21
+ }
22
+
23
+ /**
24
+ * 도형 팔레트와 부품 목록.
25
+ *
26
+ * ## 팔레트를 목록 위에 둔다
27
+ *
28
+ * 처음 온 사람은 「무엇을 놓을 수 있나」를 먼저 알아야 한다. 빈 목록 아래에 팔레트를
29
+ * 두면 아무것도 없는 화면에서 다음 동작이 안 보인다.
30
+ *
31
+ * ## 한도를 미리 보여 준다
32
+ *
33
+ * 부품 수 한도는 디테일 등급이 정한다(`PART_LIMIT`). 넘긴 뒤에 판정 패널이
34
+ * 나무라는 것보다, **넣기 전에 몇 개 남았는지** 보이는 것이 낫다.
35
+ *
36
+ * ## 고치지 않는다
37
+ *
38
+ * 이 컴포넌트는 정본을 만지지 않는다. 무엇을 하겠다는 것만 알린다 — 정본 한 벌을
39
+ * 저작면이 갖고, 거기서만 바뀐다.
40
+ */
41
+ @customElement('figure-parts')
42
+ export class FigureParts extends localize(i18next)(LitElement) {
43
+ static styles = [
44
+ ScrollbarStyles,
45
+ css`
46
+ :host {
47
+ display: flex;
48
+ flex-direction: column;
49
+ min-height: 0;
50
+ overflow: hidden;
51
+ background-color: var(--md-sys-color-surface-container-lowest);
52
+ border-right: 1px solid var(--md-sys-color-outline-variant);
53
+ color: var(--md-sys-color-on-surface);
54
+ font: var(--label-font, inherit);
55
+ /*
56
+ 이 패널의 본문 크기를 여기서 한 번 정한다.
57
+
58
+ 전에는 규칙마다 따로 적었다. 그러면 빠뜨린 규칙이 본문 기본 크기(1rem)를
59
+ 물려받아 그 줄만 제목처럼 커진다. 오류가 아니라서 화면을 띄워 봐야 안다.
60
+ 이 파일에서만 두 번 났다.
61
+
62
+ form 요소(input · select · button)는 글꼴을 물려받지 않으므로 각자 적어야 한다.
63
+ 그것 말고는 여기 값을 따른다.
64
+ */
65
+ font-size: 0.76rem;
66
+ }
67
+
68
+ section {
69
+ padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
70
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
71
+ }
72
+ section[list] {
73
+ flex: 1;
74
+ min-height: 0;
75
+ overflow-y: auto;
76
+ }
77
+ h3 {
78
+ display: flex;
79
+ align-items: baseline;
80
+ justify-content: space-between;
81
+ gap: 6px;
82
+ margin: 0 0 6px 0;
83
+ font-size: var(--ui-t-micro, 11px);
84
+ font-weight: 700;
85
+ letter-spacing: 0.04em;
86
+ text-transform: uppercase;
87
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
88
+ }
89
+ /*
90
+ 부품 수와 한도.
91
+
92
+ 전에는 이 숫자가 「도형」 절 제목에 붙어 있었다. 부품 수인데 도형 옆에 있으니
93
+ 「도형 8종 중 2종」으로 읽힌다 — 실제로 그렇게 읽혔다. 세는 대상이 있는 절로
94
+ 옮기고, 한도가 어디서 나온 값인지(디테일 등급)를 함께 적는다.
95
+ */
96
+ span[room] {
97
+ font-size: 0.7rem;
98
+ font-weight: 400;
99
+ letter-spacing: 0;
100
+ text-transform: none;
101
+ }
102
+ span[room][full] {
103
+ color: var(--md-sys-color-error);
104
+ }
105
+
106
+ /* ── 팔레트 ─────────────────────────────────────────────────────── */
107
+ div[palette] {
108
+ display: grid;
109
+ grid-template-columns: repeat(3, 1fr);
110
+ gap: 4px;
111
+ }
112
+ button[shape] {
113
+ display: flex;
114
+ flex-direction: column;
115
+ align-items: center;
116
+ gap: 2px;
117
+ padding: 7px 2px 5px 2px;
118
+ font: var(--label-font, inherit);
119
+ font-size: 0.66rem;
120
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
121
+ background: none;
122
+ border: 1px solid var(--md-sys-color-outline-variant);
123
+ border-radius: 6px;
124
+ cursor: pointer;
125
+ }
126
+ button[shape]:hover:not([disabled]) {
127
+ color: var(--md-sys-color-on-primary-container);
128
+ background-color: var(--md-sys-color-primary-container);
129
+ border-color: transparent;
130
+ }
131
+ button[shape][disabled] {
132
+ opacity: 0.35;
133
+ cursor: default;
134
+ }
135
+ button[shape] md-icon {
136
+ --md-icon-size: 20px;
137
+ }
138
+
139
+ /* ── 부품 목록 ──────────────────────────────────────────────────── */
140
+ ol {
141
+ margin: 0;
142
+ padding: 0;
143
+ list-style: none;
144
+ display: flex;
145
+ flex-direction: column;
146
+ gap: 2px;
147
+ }
148
+ li {
149
+ display: flex;
150
+ align-items: center;
151
+ gap: 6px;
152
+ padding: 4px 6px;
153
+ border-radius: 6px;
154
+ cursor: pointer;
155
+ }
156
+ li:hover {
157
+ background-color: var(--md-sys-color-surface-container);
158
+ }
159
+ li[selected] {
160
+ color: var(--md-sys-color-on-primary-container);
161
+ background-color: var(--md-sys-color-primary-container);
162
+ }
163
+ li md-icon {
164
+ --md-icon-size: 16px;
165
+ flex: none;
166
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
167
+ }
168
+
169
+ /*
170
+ 숨김 단추.
171
+
172
+ 고른 줄에서만 보이는 조작(div[ops])과 달리 **늘 자리를 차지한다.** 무엇이 숨겨져
173
+ 있는지는 목록을 훑을 때 한눈에 보여야 한다 — 눌러 봐야 알면 숨겨 둔 것을 잊는다.
174
+ 다만 켜지지 않은 눈은 줄에 마우스를 올렸을 때만 보인다. 여덟 개 줄에 회색 눈이
175
+ 여덟 개 떠 있으면 목록이 시끄럽다.
176
+ */
177
+ /* 머리줄의 것은 늘 보인다 — 줄에 마우스를 올려야 나타나는 것이 아니다 */
178
+ button[eye][all] {
179
+ margin-left: auto;
180
+ opacity: 0.75;
181
+ }
182
+ button[eye][all]:hover {
183
+ opacity: 1;
184
+ }
185
+ button[eye] {
186
+ display: flex;
187
+ flex: none;
188
+ padding: 1px;
189
+ color: inherit;
190
+ background: none;
191
+ border: none;
192
+ border-radius: 4px;
193
+ cursor: pointer;
194
+ opacity: 0;
195
+ }
196
+ li:hover button[eye],
197
+ li[selected] button[eye],
198
+ button[eye][off] {
199
+ opacity: 1;
200
+ }
201
+ button[eye][off] {
202
+ color: var(--md-sys-color-tertiary);
203
+ }
204
+ button[eye] md-icon {
205
+ --md-icon-size: 15px;
206
+ color: inherit;
207
+ }
208
+
209
+ /* 숨긴 줄은 목록에서도 옅게 — 미리보기에서 faint 로 보이는 것과 같은 뜻이어야 한다 */
210
+ li[off] span[label],
211
+ li[off] > md-icon {
212
+ opacity: 0.45;
213
+ }
214
+ li[selected] md-icon {
215
+ color: inherit;
216
+ }
217
+ span[label] {
218
+ flex: 1;
219
+ min-width: 0;
220
+ overflow: hidden;
221
+ text-overflow: ellipsis;
222
+ white-space: nowrap;
223
+ font-size: 0.78rem;
224
+ }
225
+
226
+ /* 조작은 고른 줄에서만 보인다 — 늘 보이면 목록이 시끄럽다 */
227
+ div[ops] {
228
+ display: none;
229
+ gap: 1px;
230
+ }
231
+ li[selected] div[ops] {
232
+ display: flex;
233
+ }
234
+ div[ops] button {
235
+ display: flex;
236
+ padding: 2px;
237
+ color: inherit;
238
+ background: none;
239
+ border: none;
240
+ border-radius: 4px;
241
+ cursor: pointer;
242
+ }
243
+ div[ops] button:hover:not([disabled]) {
244
+ background-color: var(--md-sys-color-surface-container-lowest);
245
+ }
246
+ div[ops] button[disabled] {
247
+ opacity: 0.3;
248
+ cursor: default;
249
+ }
250
+ div[ops] md-icon {
251
+ --md-icon-size: 15px;
252
+ color: inherit;
253
+ }
254
+
255
+ /*
256
+ 디테일 등급 선택.
257
+
258
+ 전에는 등급이 "16 / 20 · L 등급" 안의 글자 하나로만 있었다. S·M·L 이 무엇인지
259
+ 어디에도 없었고, 한도를 다 쓰면 "등급을 올리세요" 라고 해 놓고 올릴 칸이 없었다.
260
+
261
+ 부품 목록 위에 둔다. 등급이 정하는 것이 바로 아래 목록의 한도이고, 팔레트가
262
+ 잠기는 것도 이 값 때문이다. 결과가 보이는 자리에서 고치게 한다.
263
+ */
264
+ div[level] {
265
+ display: flex;
266
+ align-items: center;
267
+ gap: 6px;
268
+ margin: 0 0 7px 0;
269
+ }
270
+ div[level] label {
271
+ flex: none;
272
+ margin: 0;
273
+ font-size: 0.68rem;
274
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
275
+ }
276
+ div[level] select {
277
+ flex: 1;
278
+ min-width: 0;
279
+ padding: 3px 5px;
280
+ font: var(--input-field-font, inherit);
281
+ font-size: 0.72rem;
282
+ color: var(--md-sys-color-on-surface);
283
+ background-color: var(--md-sys-color-surface-container-lowest);
284
+ border: 1px solid var(--md-sys-color-outline-variant);
285
+ border-radius: 6px;
286
+ }
287
+
288
+ /* 팔레트가 잠긴 이유를 말한다. 흐린 단추만 두면 왜 안 눌리는지 모른다 */
289
+ p[full] {
290
+ margin: 6px 0 0 0;
291
+ font-size: 0.72rem;
292
+ line-height: 1.5;
293
+ color: var(--md-sys-color-error);
294
+ }
295
+
296
+ p[quiet] {
297
+ margin: 0;
298
+ font-size: 0.76rem;
299
+ line-height: 1.6;
300
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
301
+ }
302
+ `
303
+ ]
304
+
305
+ /**
306
+ * 보드 위의 부품들. **씬 모델 그대로 받는다.**
307
+ *
308
+ * 이 패널은 이름과 도형만 읽으므로 어느 쪽 형태로 받아도 화면은 같다. 그래도 씬
309
+ * 모델로 받는 것은 모델러 페이지가 데이터를 **한 벌만** 갖게 하기 위해서다. 여기만
310
+ * FigureSource 로 받으면 페이지가 이 패널을 위해 변환본을 따로 들고 있어야 하고,
311
+ * 그 변환본이 언제 낡는지를 또 관리해야 한다.
312
+ */
313
+ @property({ type: Array }) parts: PartModel[] = []
314
+ /** 디테일 등급. 부품 수 한도가 여기서 결정된다. */
315
+ @property({ type: String }) level: DetailLevel = 'M'
316
+ /** 고른 부품의 자리. 고르지 않았으면 -1. */
317
+ @property({ type: Number }) selected = -1
318
+
319
+ render() {
320
+ const parts = this.parts ?? []
321
+ const level = this.level
322
+ const limit = PART_LIMIT[level]
323
+ const full = parts.length >= limit
324
+
325
+ return html`
326
+ <section>
327
+ <h3>${i18next.t('label.shapes')}</h3>
328
+ <div palette>
329
+ ${PRIMITIVE_KINDS.map(
330
+ kind => html`
331
+ <button
332
+ shape
333
+ ?disabled=${full}
334
+ title=${full ? i18next.t('text.part-limit-reached') : i18next.t('text.add-a-shape')}
335
+ @click=${() => this.ask('add-part', { primitive: kind })}
336
+ >
337
+ <md-icon>${SHAPE_ICON[kind]}</md-icon>
338
+ ${i18next.t('label.primitive-' + kind)}
339
+ </button>
340
+ `
341
+ )}
342
+ </div>
343
+ ${full
344
+ ? html`<p full>${i18next.t('text.part-limit-reached-short', { level, limit })}</p>`
345
+ : ''}
346
+ </section>
347
+
348
+ <section list>
349
+ <h3>
350
+ ${i18next.t('label.parts')}
351
+ <span room ?full=${full} title=${i18next.t('text.limit-comes-from-level')}>
352
+ ${i18next.t('text.n-of-limit', { used: parts.length, limit })}
353
+ </span>
354
+ ${this.renderShowAll(parts)}
355
+ </h3>
356
+
357
+ <div level>
358
+ <label for="detail-level">${i18next.t('label.detail-level')}</label>
359
+ <select
360
+ id="detail-level"
361
+ title=${i18next.t('text.detail-level-explained')}
362
+ @change=${(e: Event) =>
363
+ this.ask('change-detail-level', { level: (e.target as HTMLSelectElement).value })}
364
+ >
365
+ ${DETAIL_LEVELS.map(
366
+ one => html`
367
+ <option value=${one} ?selected=${one === level}>
368
+ ${one} ${i18next.t('label.detail-' + one)} ·
369
+ ${i18next.t('text.n-parts-at-most', { limit: PART_LIMIT[one] })}
370
+ </option>
371
+ `
372
+ )}
373
+ </select>
374
+ </div>
375
+
376
+ ${parts.length === 0
377
+ ? html`<p quiet>${i18next.t('text.pick-a-shape-above-to-start')}</p>`
378
+ : html`
379
+ <ol>
380
+ ${parts.map((part, index) => this.renderRow(part, index, parts.length))}
381
+ </ol>
382
+ `}
383
+ </section>
384
+ `
385
+ }
386
+
387
+ /**
388
+ * 한꺼번에 감추고 한꺼번에 되돌린다.
389
+ *
390
+ * 부품 스물짜리를 들여다볼 때 하나만 남기려면 열아홉을 눌러야 했다. 안쪽에 있는 것을
391
+ * 보려고 겉을 걷어내는 일은 저작 중에 늘 하는 일이다.
392
+ *
393
+ * 무엇을 할지는 **지금 상태가 정한다** — 하나라도 감춰져 있으면 다 되돌리고, 다
394
+ * 보이고 있으면 다 감춘다. 단추 하나로 오가는 것이 두 개보다 낫다.
395
+ */
396
+ private renderShowAll(parts: PartModel[]) {
397
+ if (parts.length === 0) return nothing
398
+
399
+ const anyHidden = parts.some(part => part.hidden)
400
+
401
+ return html`
402
+ <button
403
+ eye
404
+ all
405
+ ?off=${!anyHidden}
406
+ title=${i18next.t(anyHidden ? 'text.show-all-parts' : 'text.hide-all-parts')}
407
+ @click=${(e: Event) => {
408
+ e.stopPropagation()
409
+ this.ask('set-all-parts-hidden', { hidden: !anyHidden })
410
+ }}
411
+ >
412
+ <md-icon>${anyHidden ? 'visibility' : 'visibility_off'}</md-icon>
413
+ </button>
414
+ `
415
+ }
416
+
417
+ private renderRow(part: PartModel, index: number, count: number) {
418
+ const selected = index === this.selected
419
+ const off = !!part.hidden
420
+
421
+ return html`
422
+ <li ?selected=${selected} ?off=${off} @click=${() => this.ask('select-part', { index })}>
423
+ <button
424
+ eye
425
+ ?off=${off}
426
+ title=${i18next.t(off ? 'text.show-this-part' : 'text.hide-this-part')}
427
+ @click=${(e: Event) => {
428
+ // 눌렀을 때 줄 고르기가 함께 일어나지 않게 막는다 — 가리려던 것이지 고르려던 것이 아니다
429
+ e.stopPropagation()
430
+ this.ask('toggle-part-hidden', { index })
431
+ }}
432
+ >
433
+ <md-icon>${off ? 'visibility_off' : 'visibility'}</md-icon>
434
+ </button>
435
+ <md-icon>${SHAPE_ICON[part.primitive]}</md-icon>
436
+ <span label title=${part.name}>${part.name}</span>
437
+ ${selected ? this.renderOps(index, count) : nothing}
438
+ </li>
439
+ `
440
+ }
441
+
442
+ /**
443
+ * 고른 줄의 조작.
444
+ *
445
+ * 못 하는 것을 **빼지 않고 흐리게 둔다.** 빼면 남은 단추가 그 자리로 밀려서, 첫
446
+ * 줄과 마지막 줄에서 「지우기」가 다른 자리에 온다 — 연달아 지우다 엉뚱한 것을
447
+ * 누르게 된다.
448
+ *
449
+ * 눌렀을 때 줄 고르기가 다시 일어나지 않게 전파를 멈춘다.
450
+ */
451
+ private renderOps(index: number, count: number) {
452
+ const op = (name: string, icon: string, detail: object, enabled = true) => html`
453
+ <button
454
+ op=${name}
455
+ ?disabled=${!enabled}
456
+ title=${i18next.t('button.' + name)}
457
+ @click=${(e: Event) => {
458
+ e.stopPropagation()
459
+ if (enabled) this.ask(name, detail)
460
+ }}
461
+ >
462
+ <md-icon>${icon}</md-icon>
463
+ </button>
464
+ `
465
+
466
+ return html`
467
+ <div ops>
468
+ ${op('move-part-up', 'arrow_upward', { index, to: index - 1 }, index > 0)}
469
+ ${op('move-part-down', 'arrow_downward', { index, to: index + 1 }, index < count - 1)}
470
+ ${op('duplicate-part', 'content_copy', { index })} ${op('remove-part', 'delete', { index })}
471
+ </div>
472
+ `
473
+ }
474
+
475
+ /** 정본을 고치지 않는다 — 무엇을 하겠다는 것만 알린다. */
476
+ private ask(what: string, detail: object) {
477
+ this.dispatchEvent(new CustomEvent(what, { detail, bubbles: true, composed: true }))
478
+ }
479
+ }