@things-factory/figure-ui 10.1.3 → 10.1.5

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 (50) hide show
  1. package/client/graphql/index.ts +87 -12
  2. package/client/modeller/figure-ask.ts +14 -4
  3. package/client/modeller/figure-canvas.ts +53 -0
  4. package/client/modeller/figure-history-panel.ts +324 -0
  5. package/client/modeller/figure-inspector.ts +78 -2
  6. package/client/modeller/figure-side.ts +22 -1
  7. package/client/modeller/figure-thumbnail.ts +78 -0
  8. package/client/pages/figure-list-page.ts +391 -195
  9. package/client/pages/figure-modeller-page.ts +114 -19
  10. package/client/types.ts +17 -0
  11. package/dist-client/graphql/index.d.ts +24 -4
  12. package/dist-client/graphql/index.js +70 -9
  13. package/dist-client/graphql/index.js.map +1 -1
  14. package/dist-client/modeller/figure-ask.js +14 -4
  15. package/dist-client/modeller/figure-ask.js.map +1 -1
  16. package/dist-client/modeller/figure-canvas.d.ts +11 -0
  17. package/dist-client/modeller/figure-canvas.js +42 -0
  18. package/dist-client/modeller/figure-canvas.js.map +1 -1
  19. package/dist-client/modeller/figure-history-panel.d.ts +42 -0
  20. package/dist-client/modeller/figure-history-panel.js +343 -0
  21. package/dist-client/modeller/figure-history-panel.js.map +1 -0
  22. package/dist-client/modeller/figure-inspector.d.ts +17 -0
  23. package/dist-client/modeller/figure-inspector.js +77 -2
  24. package/dist-client/modeller/figure-inspector.js.map +1 -1
  25. package/dist-client/modeller/figure-side.d.ts +7 -0
  26. package/dist-client/modeller/figure-side.js +43 -0
  27. package/dist-client/modeller/figure-side.js.map +1 -1
  28. package/dist-client/modeller/figure-thumbnail.d.ts +3 -0
  29. package/dist-client/modeller/figure-thumbnail.js +59 -0
  30. package/dist-client/modeller/figure-thumbnail.js.map +1 -0
  31. package/dist-client/pages/figure-list-page.d.ts +45 -17
  32. package/dist-client/pages/figure-list-page.js +374 -209
  33. package/dist-client/pages/figure-list-page.js.map +1 -1
  34. package/dist-client/pages/figure-modeller-page.d.ts +27 -0
  35. package/dist-client/pages/figure-modeller-page.js +109 -19
  36. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  37. package/dist-client/tsconfig.tsbuildinfo +1 -0
  38. package/dist-client/types.d.ts +19 -0
  39. package/dist-client/types.js.map +1 -1
  40. package/dist-server/tsconfig.tsbuildinfo +1 -0
  41. package/package.json +5 -3
  42. package/translations/en.json +19 -0
  43. package/translations/ja.json +21 -2
  44. package/translations/ko.json +21 -2
  45. package/translations/ms.json +21 -2
  46. package/translations/zh.json +21 -2
  47. package/client/viewparts/figure-card.ts +0 -226
  48. package/dist-client/viewparts/figure-card.d.ts +0 -24
  49. package/dist-client/viewparts/figure-card.js +0 -221
  50. package/dist-client/viewparts/figure-card.js.map +0 -1
@@ -1,7 +1,15 @@
1
1
  import gql from 'graphql-tag'
2
2
  import { client } from '@operato/graphql'
3
3
 
4
- import type { Figure, FigureFinding, FigureListResult, FigurePatch, FigureProposal, NewFigure } from '../types.js'
4
+ import type {
5
+ Figure,
6
+ FigureFinding,
7
+ FigureListResult,
8
+ FigurePatch,
9
+ FigureProposal,
10
+ FigureVersion,
11
+ NewFigure
12
+ } from '../types.js'
5
13
 
6
14
  /**
7
15
  * 응답에서 값을 꺼낸다. **서버가 말한 사유를 그대로 올린다.**
@@ -39,6 +47,7 @@ const FIGURE_LIST_FIELDS = `
39
47
  description
40
48
  category
41
49
  state
50
+ version
42
51
  score
43
52
  triangles
44
53
  groups
@@ -48,17 +57,19 @@ const FIGURE_LIST_FIELDS = `
48
57
  updater { id name }
49
58
  `
50
59
 
60
+ /**
61
+ * 목록을 가져온다.
62
+ *
63
+ * 걸러 보기·정렬·쪽 나누기를 **그대로 넘긴다.** 화면이 `search`·`state` 같은 이름을 따로 만들어
64
+ * filter 로 옮기던 것을 그만두었다 — 그 이름들은 격자(ox-grist)가 이미 컬럼 설정에서 만들어 주고,
65
+ * 중간에 한 벌 더 두면 격자가 아는 조건과 서버에 가는 조건이 어긋난다.
66
+ */
51
67
  export async function fetchFigureList(params: {
52
68
  page?: number
53
69
  limit?: number
54
- search?: string
55
- category?: string
56
- state?: string
70
+ filters?: unknown[]
71
+ sortings?: { name: string; desc?: boolean }[]
57
72
  }): Promise<FigureListResult> {
58
- const filters: { name: string; operator: string; value: unknown }[] = []
59
- if (params.category) filters.push({ name: 'category', operator: 'eq', value: params.category })
60
- if (params.state) filters.push({ name: 'state', operator: 'eq', value: params.state })
61
-
62
73
  const response = await client.query({
63
74
  query: gql`
64
75
  query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {
@@ -69,9 +80,9 @@ export async function fetchFigureList(params: {
69
80
  }
70
81
  `,
71
82
  variables: {
72
- filters,
83
+ filters: params.filters ?? [],
73
84
  pagination: { page: params.page ?? 1, limit: params.limit ?? 30 },
74
- sortings: [{ name: 'updatedAt', desc: true }]
85
+ sortings: params.sortings?.length ? params.sortings : [{ name: 'updatedAt', desc: true }]
75
86
  },
76
87
  fetchPolicy: 'network-only'
77
88
  })
@@ -88,7 +99,6 @@ export async function fetchFigure(id: string): Promise<Figure> {
88
99
  ${FIGURE_LIST_FIELDS}
89
100
  source
90
101
  properties
91
- version
92
102
  }
93
103
  }
94
104
  `,
@@ -145,7 +155,7 @@ export async function updateFigure(id: string, patch: FigurePatch): Promise<Save
145
155
  mutation: gql`
146
156
  mutation ($id: String!, $patch: FigurePatch!) {
147
157
  updateFigure(id: $id, patch: $patch) {
148
- figure { ${FIGURE_LIST_FIELDS} source version }
158
+ figure { ${FIGURE_LIST_FIELDS} source }
149
159
  violations { code message at }
150
160
  }
151
161
  }
@@ -207,3 +217,68 @@ export async function proposeFigure(request: {
207
217
 
208
218
  return unwrap<FigureProposal>(response, 'proposeFigure')
209
219
  }
220
+
221
+ /**
222
+ * 발행한다 — 판 번호가 오르고, 그 순간이 판본으로 남는다.
223
+ *
224
+ * 이미 발행된 것을 다시 발행하면 서버가 거절한다. 고쳐서 다시 내려면 저장이 먼저이고, 저장은
225
+ * 발행을 푼다(초안으로 돌아온다).
226
+ */
227
+ export async function releaseFigure(id: string, comment?: string): Promise<Figure> {
228
+ const response = await client.mutate({
229
+ mutation: gql`
230
+ mutation ReleaseFigure($id: String!, $comment: String) {
231
+ releaseFigure(id: $id, comment: $comment) {
232
+ id
233
+ version
234
+ state
235
+ updatedAt
236
+ }
237
+ }
238
+ `,
239
+ variables: { id, comment }
240
+ })
241
+
242
+ return unwrap<Figure>(response, 'releaseFigure')
243
+ }
244
+
245
+ /** 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. */
246
+ export async function revertFigureVersion(id: string, version: number): Promise<SaveResult> {
247
+ const response = await client.mutate({
248
+ mutation: gql`
249
+ mutation RevertFigureVersion($id: String!, $version: Float!) {
250
+ revertFigureVersion(id: $id, version: $version) {
251
+ figure { ${FIGURE_LIST_FIELDS} source }
252
+ violations { code message at }
253
+ }
254
+ }
255
+ `,
256
+ variables: { id, version }
257
+ })
258
+
259
+ return unwrap<SaveResult>(response, 'revertFigureVersion')
260
+ }
261
+
262
+ /** 발행된 판들 — 최근 것부터 열 개. */
263
+ export async function fetchFigureVersions(id: string): Promise<FigureVersion[]> {
264
+ const response = await client.query({
265
+ query: gql`
266
+ query FigureVersions($id: String!) {
267
+ figureVersions(id: $id) {
268
+ version
269
+ comment
270
+ state
271
+ updatedAt
272
+ updater { id name }
273
+ score
274
+ triangles
275
+ groups
276
+ }
277
+ }
278
+ `,
279
+ variables: { id },
280
+ fetchPolicy: 'network-only'
281
+ })
282
+
283
+ return unwrap<FigureVersion[]>(response, 'figureVersions') ?? []
284
+ }
@@ -31,10 +31,19 @@ import type { FigureProposal } from '../types.js'
31
31
  @customElement('figure-ask')
32
32
  export class FigureAsk extends localize(i18next)(LitElement) {
33
33
  static styles = css`
34
+ /*
35
+ 이 부품은 **자기 줄을 갖지 않는다.**
36
+
37
+ 전에는 테두리와 여백으로 스스로 한 줄이 되었다. 저작면의 머리줄 바로 아래에 놓이니
38
+ 화면 위쪽이 두 줄이 되었고, 위 줄은 이름을 페이지 제목과 겹쳐 적고 있었다. 캔버스가
39
+ 주인공인 화면에서 그 두 줄은 비싸다.
40
+
41
+ 이제 머리줄 안에 들어가 그 줄의 남는 폭을 쓴다. 테두리·여백은 담는 줄이 갖는다.
42
+ */
34
43
  :host {
35
- display: block;
36
- background-color: var(--md-sys-color-surface-container-lowest);
37
- border-bottom: 1px solid var(--md-sys-color-outline-variant);
44
+ display: flex;
45
+ flex: 1;
46
+ min-width: 0;
38
47
  font: var(--label-font, inherit);
39
48
  /*
40
49
  이 줄의 글자 크기를 여기서 정한다.
@@ -49,9 +58,10 @@ export class FigureAsk extends localize(i18next)(LitElement) {
49
58
 
50
59
  form {
51
60
  display: flex;
61
+ flex: 1;
62
+ min-width: 0;
52
63
  align-items: center;
53
64
  gap: var(--spacing-medium, 8px);
54
- padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
55
65
  }
56
66
 
57
67
  md-icon[lead] {
@@ -204,6 +204,59 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
204
204
  @state() private cannot = ''
205
205
 
206
206
  private scene?: SceneHandle
207
+
208
+ /**
209
+ * 지금 세워진 것을 그림으로 낸다 — 카드에 쓸 썸네일.
210
+ *
211
+ * 부르는 쪽은 이 부품을 **숨은 자리에 480×360 으로 하나 더 세워** 부른다
212
+ * (`figure-thumbnail.ts`). 보이는 저작면을 찍지 않는 이유는, 저작자가 돌려 둔 각도와 확대,
213
+ * 그리고 창 크기에 따라 달라지는 칸 비율이 그대로 카드에 박히기 때문이다 — 나사를 확대해 보던
214
+ * 중에 저장하면 그 자산의 얼굴이 나사가 된다. 목록은 견주는 자리라 프레임이 일정해야 한다.
215
+ *
216
+ * 못 찍으면 `undefined` 다. 빈 그림을 만들어 「그림이 있다」고 말하지 않는다.
217
+ */
218
+ async snapshot(width = 480, height = 360): Promise<string | undefined> {
219
+ /*
220
+ **3D 를 낼 수 있을 때만 찍는다.**
221
+
222
+ 씬의 `toDataURL` 은 **2D 씬을 렌더한다.** 그것으로 찍어 보니 흰 종이 한 장이 나왔다
223
+ (480×291 · 834바이트 · 순백 — 2D 판의 면인 `fillStyle: PAPER` 이 화면 전체였다). 3D 픽셀은
224
+ `model-layer.renderer3d`(THREE.WebGLRenderer) 쪽에 있고, 그 렌더러는
225
+ `preserveDrawingBuffer: false` 라 **그린 직후 같은 task 에서** 읽어야 한다. 그 절차를 아는
226
+ 것은 씬이므로, things-scene 이 그 일을 공개 API 로 내주면 여기서 부른다.
227
+
228
+ 아직 없으면 `undefined` 다. 흰 종이를 썸네일이라고 저장하면 카드가 「그림이 있다」고 말하면서
229
+ 아무것도 보여 주지 않고, 저장할 때마다 판 번호만 올라간다 — 없는 것보다 나쁘다.
230
+ */
231
+ const layer = this.scene?.root as
232
+ | (SceneBoard & {
233
+ snapshot3d?: (
234
+ width?: number,
235
+ height?: number,
236
+ type?: string,
237
+ quality?: number
238
+ ) => Promise<string | undefined>
239
+ })
240
+ | undefined
241
+
242
+ if (!layer?.snapshot3d) {
243
+ return undefined
244
+ }
245
+
246
+ try {
247
+ /*
248
+ 알파가 필요 없고 카드에 들어갈 그림이라 webp 로 찍는다. png 는 같은 그림이 서너 배 무겁고,
249
+ 그 무게가 도형마다 DB 와 목록 응답에 실린다. webp 를 못 만드는 브라우저는 캔버스가 알아서
250
+ png 를 돌려준다 — 우리가 판정하지 않는다.
251
+ */
252
+ const url = await layer.snapshot3d(width, height, 'image/webp', 0.85)
253
+ return typeof url === 'string' && url.startsWith('data:') ? url : undefined
254
+ } catch (e) {
255
+ /* 원인을 지어내지 않는다. 못 찍은 것이 저장을 막지는 않는다. */
256
+ console.warn(`[thumbnail] 찍지 못했습니다 — ${(e as Error).message}`)
257
+ return undefined
258
+ }
259
+ }
207
260
  /** 지금 씬이 서 있는 판. 이것이 달라지면 다시 세운다. */
208
261
  private standing?: string
209
262
  /** 칸 크기를 따라가게 지켜본다. 안 지켜보면 0x0 으로 고정된다. */
@@ -0,0 +1,324 @@
1
+ import '@material/web/icon/icon.js'
2
+
3
+ import { css, html, LitElement, nothing } from 'lit'
4
+ import { customElement, property, state } from 'lit/decorators.js'
5
+
6
+ import { i18next, localize } from '@operato/i18n'
7
+
8
+ import { fetchFigureVersions, releaseFigure, revertFigureVersion } from '../graphql/index.js'
9
+ import type { FigureVersion } from '../types.js'
10
+
11
+ /**
12
+ * 이력 — **발행하는 자리이자, 발행된 판들을 보는 자리.**
13
+ *
14
+ * ## 왜 저장 옆이 아니라 여기인가
15
+ *
16
+ * 저장은 하루에 수십 번이고 발행은 드물다. 두 단추를 나란히 두면 저장하려다 발행을 누른다.
17
+ * 그리고 발행은 **소비처에게 하는 약속**이라, 무엇을 약속하는지(지금 몇 판인가 · 저장 안 한 것이
18
+ * 있나 · 지난 판이 무엇이었나)를 함께 보여 주는 자리에 있어야 한다.
19
+ *
20
+ * ## 저장하지 않은 것이 있으면 발행하지 않는다
21
+ *
22
+ * 발행은 **저장된 것**을 내보낸다. 화면에 고친 것이 남아 있는 채로 발행하면, 저작자가 보고 있는
23
+ * 것과 약속한 것이 다르다. 그래서 막고, 왜 막혔는지 적는다.
24
+ *
25
+ * ## 되돌리기는 초안으로 돌아온다
26
+ *
27
+ * 옛 판의 정본·속성·그림이 돌아오고 이름·설명은 그대로다. 되살렸다고 그 판이 다시 발행되지는
28
+ * 않는다 — 다시 내보내려면 발행을 한 번 더 하고, 그때 번호는 이미 쓴 다음 번호를 받는다.
29
+ */
30
+ @customElement('figure-history-panel')
31
+ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
32
+ static styles = css`
33
+ :host {
34
+ display: flex;
35
+ flex-direction: column;
36
+ min-height: 0;
37
+ overflow-y: auto;
38
+ background-color: var(--md-sys-color-surface-container-lowest);
39
+ color: var(--md-sys-color-on-surface);
40
+ font: var(--label-font, inherit);
41
+ font-size: 0.76rem;
42
+ }
43
+
44
+ :host([off]) {
45
+ display: none;
46
+ }
47
+
48
+ section {
49
+ padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
50
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
51
+ }
52
+
53
+ h3 {
54
+ margin: 0 0 6px 0;
55
+ font-size: var(--ui-t-micro, 11px);
56
+ font-weight: 700;
57
+ letter-spacing: 0.04em;
58
+ text-transform: uppercase;
59
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
60
+ }
61
+
62
+ /* 지금 무엇인가 — 발행을 누르기 전에 읽는 한 줄 */
63
+ div[now] {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 6px;
67
+ margin-bottom: 8px;
68
+ }
69
+
70
+ span[state] {
71
+ padding: 2px 8px;
72
+ border-radius: 10px;
73
+ font-size: 0.7rem;
74
+ font-weight: 700;
75
+ color: var(--md-sys-color-on-secondary-container);
76
+ background-color: var(--md-sys-color-secondary-container);
77
+ }
78
+
79
+ span[state][released] {
80
+ color: var(--md-sys-color-on-primary);
81
+ background-color: var(--md-sys-color-primary);
82
+ }
83
+
84
+ span[ver] {
85
+ font-family: var(--mono-font, monospace);
86
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
87
+ }
88
+
89
+ input,
90
+ button {
91
+ font: var(--input-field-font, inherit);
92
+ font-size: 0.76rem;
93
+ }
94
+
95
+ input[note] {
96
+ width: 100%;
97
+ box-sizing: border-box;
98
+ padding: 5px 8px;
99
+ color: var(--md-sys-color-on-surface);
100
+ background-color: var(--md-sys-color-surface-container-lowest);
101
+ border: 1px solid var(--md-sys-color-outline-variant);
102
+ border-radius: 6px;
103
+ }
104
+
105
+ button[release] {
106
+ width: 100%;
107
+ margin-top: 6px;
108
+ padding: 6px 0;
109
+ font-weight: 700;
110
+ color: var(--ui-on-accent, #fff);
111
+ background-color: var(--md-sys-color-primary);
112
+ border: 0;
113
+ border-radius: 6px;
114
+ cursor: pointer;
115
+ }
116
+
117
+ button[release][disabled] {
118
+ opacity: 0.45;
119
+ cursor: default;
120
+ }
121
+
122
+ /* 왜 못 누르는지 — 눌러 보고 알게 하지 않는다 */
123
+ p[why] {
124
+ margin: 6px 0 0 0;
125
+ font-size: 0.7rem;
126
+ line-height: 1.5;
127
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
128
+ }
129
+
130
+ p[failed] {
131
+ margin: 6px 0 0 0;
132
+ font-size: 0.7rem;
133
+ line-height: 1.5;
134
+ color: var(--md-sys-color-error);
135
+ }
136
+
137
+ ul {
138
+ margin: 0;
139
+ padding: 0;
140
+ list-style: none;
141
+ }
142
+
143
+ li {
144
+ display: grid;
145
+ grid-template-columns: auto 1fr auto;
146
+ gap: 4px 8px;
147
+ align-items: baseline;
148
+ padding: 7px 0;
149
+ border-bottom: 1px dashed var(--md-sys-color-outline-variant);
150
+ }
151
+
152
+ li:last-child {
153
+ border-bottom: 0;
154
+ }
155
+
156
+ li span[no] {
157
+ font-family: var(--mono-font, monospace);
158
+ font-weight: 700;
159
+ }
160
+
161
+ li span[when] {
162
+ grid-column: 2 / 4;
163
+ font-size: 0.7rem;
164
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
165
+ }
166
+
167
+ li span[comment] {
168
+ grid-column: 1 / 4;
169
+ line-height: 1.5;
170
+ }
171
+
172
+ li button[revert] {
173
+ padding: 2px 8px;
174
+ color: var(--md-sys-color-on-surface);
175
+ background: none;
176
+ border: 1px solid var(--md-sys-color-outline-variant);
177
+ border-radius: 6px;
178
+ cursor: pointer;
179
+ }
180
+
181
+ li button[revert]:hover {
182
+ color: var(--md-sys-color-on-primary-container);
183
+ background-color: var(--md-sys-color-primary-container);
184
+ border-color: transparent;
185
+ }
186
+
187
+ p[quiet] {
188
+ margin: 0;
189
+ line-height: 1.6;
190
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
191
+ }
192
+ `
193
+
194
+ /** 지금 열려 있는 도형. 없으면(새로 만드는 중) 발행할 것이 없다. */
195
+ @property({ type: String }) figureId?: string
196
+ @property({ type: String }) figureState?: 'draft' | 'released'
197
+ @property({ type: Number }) figureVersion = 0
198
+ /** 저장하지 않은 것이 있나 — 있으면 발행을 막는다. */
199
+ @property({ type: Boolean }) dirty = false
200
+
201
+ @state() private versions: FigureVersion[] = []
202
+ @state() private busy = false
203
+ @state() private failure = ''
204
+
205
+ updated(changes: Map<string, unknown>) {
206
+ if (changes.has('figureId') || changes.has('figureVersion')) {
207
+ void this.reload()
208
+ }
209
+ }
210
+
211
+ private async reload() {
212
+ if (!this.figureId) {
213
+ this.versions = []
214
+ return
215
+ }
216
+
217
+ try {
218
+ this.versions = await fetchFigureVersions(this.figureId)
219
+ this.failure = ''
220
+ } catch (e) {
221
+ this.failure = (e as Error).message
222
+ }
223
+ }
224
+
225
+ render() {
226
+ return html`
227
+ <section>
228
+ <h3>${i18next.t('label.release')}</h3>
229
+ ${this.figureId ? this.renderRelease() : html`<p quiet>${i18next.t('text.save-the-figure-before-releasing')}</p>`}
230
+ </section>
231
+
232
+ <section>
233
+ <h3>${i18next.t('label.released-versions')}</h3>
234
+ ${this.versions.length
235
+ ? html`<ul>
236
+ ${this.versions.map(version => this.renderVersion(version))}
237
+ </ul>`
238
+ : html`<p quiet>${i18next.t('text.nothing-released-yet')}</p>`}
239
+ </section>
240
+ `
241
+ }
242
+
243
+ private renderRelease() {
244
+ const released = this.figureState === 'released'
245
+ /* 막는 이유는 하나씩 말한다. 「발행할 수 없습니다」로 뭉치면 무엇을 하면 되는지 모른다. */
246
+ const why = this.dirty
247
+ ? i18next.t('text.release-needs-a-save-first')
248
+ : released
249
+ ? i18next.t('text.already-released-edit-to-release-again')
250
+ : ''
251
+
252
+ return html`
253
+ <div now>
254
+ <span state ?released=${released}>${i18next.t(released ? 'label.released' : 'label.draft')}</span>
255
+ <span ver>${i18next.t('text.version-number', { version: this.figureVersion ?? 0 })}</span>
256
+ </div>
257
+
258
+ <input note placeholder=${i18next.t('text.what-changed-in-this-release')} ?disabled=${!!why || this.busy} />
259
+
260
+ <button release ?disabled=${!!why || this.busy} @click=${() => this.release()}>
261
+ ${this.busy ? i18next.t('text.releasing') : i18next.t('button.release')}
262
+ </button>
263
+
264
+ ${why ? html`<p why>${why}</p>` : nothing}
265
+ ${this.failure ? html`<p failed>${this.failure}</p>` : nothing}
266
+ `
267
+ }
268
+
269
+ private renderVersion(version: FigureVersion) {
270
+ return html`
271
+ <li>
272
+ <span no>${i18next.t('text.version-number', { version: version.version })}</span>
273
+ <span when>
274
+ ${version.updatedAt ? new Date(version.updatedAt).toLocaleString() : ''}
275
+ ${version.updater?.name ? ` · ${version.updater.name}` : ''}
276
+ </span>
277
+ <button revert ?disabled=${this.busy} @click=${() => this.revert(version.version)}>
278
+ ${i18next.t('button.revert')}
279
+ </button>
280
+ ${version.comment ? html`<span comment>${version.comment}</span>` : nothing}
281
+ </li>
282
+ `
283
+ }
284
+
285
+ private async release() {
286
+ const input = this.renderRoot.querySelector('input[note]') as HTMLInputElement
287
+ const comment = input?.value?.trim()
288
+
289
+ this.busy = true
290
+ this.failure = ''
291
+
292
+ try {
293
+ const figure = await releaseFigure(this.figureId!, comment || undefined)
294
+ if (input) input.value = ''
295
+ /* 정본을 든 자리가 갱신한다 — 여기서 페이지 상태를 고쳐 쓰지 않는다. */
296
+ this.dispatchEvent(new CustomEvent('figure-released', { detail: { figure }, bubbles: true, composed: true }))
297
+ await this.reload()
298
+ } catch (e) {
299
+ this.failure = (e as Error).message
300
+ } finally {
301
+ this.busy = false
302
+ }
303
+ }
304
+
305
+ private async revert(version: number) {
306
+ /* 되돌아갈 수 없는 조작은 아니지만(판본은 남는다), 지금 화면의 것이 덮인다 — 이름으로 확인한다. */
307
+ if (!confirm(i18next.t('text.revert-to-version-confirm', { version }))) {
308
+ return
309
+ }
310
+
311
+ this.busy = true
312
+ this.failure = ''
313
+
314
+ try {
315
+ const result = await revertFigureVersion(this.figureId!, version)
316
+ this.dispatchEvent(new CustomEvent('figure-reverted', { detail: result, bubbles: true, composed: true }))
317
+ await this.reload()
318
+ } catch (e) {
319
+ this.failure = (e as Error).message
320
+ } finally {
321
+ this.busy = false
322
+ }
323
+ }
324
+ }
@@ -1,3 +1,5 @@
1
+ import '@material/web/icon/icon.js'
2
+
1
3
  import { css, html, LitElement, nothing, svg } from 'lit'
2
4
  import { customElement, property } from 'lit/decorators.js'
3
5
 
@@ -452,10 +454,36 @@ export class FigureInspector extends localize(i18next)(LitElement) {
452
454
  line-height: 1.6;
453
455
  color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
454
456
  }
457
+
458
+ /*
459
+ 고칠 수 없는 값 — 입력칸처럼 보이지 않게 한다.
460
+
461
+ 전에는 그냥 글자였고, 옆의 이름 칸과 나란히 있으니 눌러도 되는 것처럼 보였다. 자물쇠와
462
+ 칸 없는 바탕이 「읽는 값」이라고 먼저 말한다.
463
+ */
464
+ code[fixed] {
465
+ display: inline-flex;
466
+ align-items: center;
467
+ gap: 4px;
468
+ padding: 4px 8px;
469
+ border-radius: 6px;
470
+ background-color: var(--md-sys-color-surface-container-high, rgba(0, 0, 0, 0.05));
471
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
472
+ font-family: var(--mono-font, monospace);
473
+ font-size: 0.76rem;
474
+ }
475
+
476
+ code[fixed] md-icon {
477
+ --md-icon-size: 13px;
478
+ opacity: 0.7;
479
+ }
455
480
  `
456
481
  ]
457
482
 
458
483
  /** 보드. 위치를 보드 중심 기준으로 바꾸려면 이것이 있어야 한다. */
484
+ /** 도형의 표시 이름. 정본은 저작면이 들고, 여기서는 보여 주고 바꾼다(`figure-rename` 으로 알린다). */
485
+ @property({ type: String }) figureName = ''
486
+
459
487
  @property({ type: Object }) board?: BoardModel
460
488
  /** 보드 위의 부품들. 씬 모델 그대로다. */
461
489
  @property({ type: Array }) parts: PartModel[] = []
@@ -470,6 +498,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
470
498
  const part = this.chosen
471
499
  if (!part || !this.board) {
472
500
  return html`
501
+ ${this.renderFigure()}
473
502
  <section>
474
503
  <h3>${i18next.t('label.properties')}</h3>
475
504
  <p quiet>${i18next.t('text.select-a-part-to-edit')}</p>
@@ -481,8 +510,55 @@ export class FigureInspector extends localize(i18next)(LitElement) {
481
510
  const shown = partToFigure(part, this.board)
482
511
 
483
512
  return html`
484
- ${this.renderIdentity(shown)} ${this.renderPlacement(shown)} ${this.renderShape(shown)}
485
- ${this.renderMaterial(shown)} ${this.renderBehaviour(shown)}
513
+ ${this.renderFigure()} ${this.renderIdentity(shown)} ${this.renderPlacement(shown)}
514
+ ${this.renderShape(shown)} ${this.renderMaterial(shown)} ${this.renderBehaviour(shown)}
515
+ `
516
+ }
517
+
518
+ /**
519
+ * 도형 자체 — 이름과 타입.
520
+ *
521
+ * ## 왜 머리줄이 아니라 여기인가
522
+ *
523
+ * 저작면 위에 줄을 하나 더 두고 이름 칸을 놓았었다. 앱에서는 셸이 페이지 제목으로 그 이름을
524
+ * 이미 그리므로 같은 글자가 두 번 나오고, 캔버스가 주인공인 화면에서 그 줄만큼 높이를 잃었다.
525
+ * 속성 패널은 「이 도형이 무엇인가」를 다루는 자리이므로 이름도 여기 산다.
526
+ *
527
+ * ## 타입은 읽기만 한다
528
+ *
529
+ * 저장되는 식별자다. 배치된 보드가 이 이름으로 타입을 찾으므로 만든 뒤에는 못 바꾼다.
530
+ */
531
+ private renderFigure() {
532
+ return html`
533
+ <section>
534
+ <h3>${i18next.t('label.figure')}</h3>
535
+ <div field>
536
+ <label>${i18next.t('label.name')}</label>
537
+ <input
538
+ .value=${this.figureName}
539
+ placeholder=${i18next.t('text.figure-display-name')}
540
+ @change=${(e: Event) =>
541
+ this.dispatchEvent(
542
+ new CustomEvent('figure-rename', {
543
+ detail: { name: (e.target as HTMLInputElement).value },
544
+ bubbles: true,
545
+ composed: true
546
+ })
547
+ )}
548
+ />
549
+ </div>
550
+ <div field>
551
+ <label>${i18next.t('label.type')}</label>
552
+ <code fixed title=${i18next.t('text.type-name-cannot-change')}>
553
+ <md-icon>lock</md-icon>${this.board?.figureType ?? ''}
554
+ </code>
555
+ </div>
556
+ <!--
557
+ 왜 못 고치는지 **적어 둔다.** 전에는 tooltip 에만 있었고, 그러면 눌러 보고 안 되는 것을
558
+ 알게 된다 — 화면이 안 되는 이유를 말하지 않으면 사용자가 자기 실수라고 생각한다.
559
+ -->
560
+ <p quiet>${i18next.t('text.type-name-cannot-change')}</p>
561
+ </section>
486
562
  `
487
563
  }
488
564