@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.
- package/client/graphql/index.ts +87 -12
- package/client/modeller/figure-ask.ts +14 -4
- package/client/modeller/figure-canvas.ts +53 -0
- package/client/modeller/figure-history-panel.ts +324 -0
- package/client/modeller/figure-inspector.ts +78 -2
- package/client/modeller/figure-side.ts +22 -1
- package/client/modeller/figure-thumbnail.ts +78 -0
- package/client/pages/figure-list-page.ts +391 -195
- package/client/pages/figure-modeller-page.ts +114 -19
- package/client/types.ts +17 -0
- package/dist-client/graphql/index.d.ts +24 -4
- package/dist-client/graphql/index.js +70 -9
- package/dist-client/graphql/index.js.map +1 -1
- package/dist-client/modeller/figure-ask.js +14 -4
- package/dist-client/modeller/figure-ask.js.map +1 -1
- package/dist-client/modeller/figure-canvas.d.ts +11 -0
- package/dist-client/modeller/figure-canvas.js +42 -0
- package/dist-client/modeller/figure-canvas.js.map +1 -1
- package/dist-client/modeller/figure-history-panel.d.ts +42 -0
- package/dist-client/modeller/figure-history-panel.js +343 -0
- package/dist-client/modeller/figure-history-panel.js.map +1 -0
- package/dist-client/modeller/figure-inspector.d.ts +17 -0
- package/dist-client/modeller/figure-inspector.js +77 -2
- package/dist-client/modeller/figure-inspector.js.map +1 -1
- package/dist-client/modeller/figure-side.d.ts +7 -0
- package/dist-client/modeller/figure-side.js +43 -0
- package/dist-client/modeller/figure-side.js.map +1 -1
- package/dist-client/modeller/figure-thumbnail.d.ts +3 -0
- package/dist-client/modeller/figure-thumbnail.js +59 -0
- package/dist-client/modeller/figure-thumbnail.js.map +1 -0
- package/dist-client/pages/figure-list-page.d.ts +45 -17
- package/dist-client/pages/figure-list-page.js +374 -209
- package/dist-client/pages/figure-list-page.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.d.ts +27 -0
- package/dist-client/pages/figure-modeller-page.js +109 -19
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -0
- package/dist-client/types.d.ts +19 -0
- package/dist-client/types.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +5 -3
- package/translations/en.json +19 -0
- package/translations/ja.json +21 -2
- package/translations/ko.json +21 -2
- package/translations/ms.json +21 -2
- package/translations/zh.json +21 -2
- package/client/viewparts/figure-card.ts +0 -226
- package/dist-client/viewparts/figure-card.d.ts +0 -24
- package/dist-client/viewparts/figure-card.js +0 -221
- package/dist-client/viewparts/figure-card.js.map +0 -1
package/client/graphql/index.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import gql from 'graphql-tag'
|
|
2
2
|
import { client } from '@operato/graphql'
|
|
3
3
|
|
|
4
|
-
import type {
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
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:
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
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
|
|