@things-factory/figure-ui 10.1.37 → 10.1.39
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 +53 -0
- package/client/graphql/v3-store.ts +8 -2
- package/client/modeller/figure-history-panel.ts +33 -87
- package/client/modeller/figure-v3-parts.ts +365 -0
- package/client/modeller/figure-v3-verdict.ts +247 -0
- package/client/modeller/gate-findings.ts +105 -0
- package/client/modeller/v3-editor.ts +11 -2
- package/client/modeller/v3-occupancy.ts +22 -0
- package/client/modeller/v3-product-session.ts +23 -4
- package/client/modeller/v3-renderer.ts +27 -18
- package/client/modeller/v3-thumbnail.ts +97 -0
- package/client/pages/figure-v3-modeller-page.ts +272 -12
- package/client/route.ts +8 -0
- package/dist-client/graphql/index.d.ts +8 -0
- package/dist-client/graphql/index.js +51 -0
- package/dist-client/graphql/index.js.map +1 -1
- package/dist-client/graphql/v3-store.js +8 -2
- package/dist-client/graphql/v3-store.js.map +1 -1
- package/dist-client/modeller/figure-history-panel.d.ts +21 -0
- package/dist-client/modeller/figure-history-panel.js +34 -87
- package/dist-client/modeller/figure-history-panel.js.map +1 -1
- package/dist-client/modeller/figure-v3-parts.d.ts +41 -0
- package/dist-client/modeller/figure-v3-parts.js +352 -0
- package/dist-client/modeller/figure-v3-parts.js.map +1 -0
- package/dist-client/modeller/figure-v3-verdict.d.ts +50 -0
- package/dist-client/modeller/figure-v3-verdict.js +268 -0
- package/dist-client/modeller/figure-v3-verdict.js.map +1 -0
- package/dist-client/modeller/gate-findings.d.ts +12 -0
- package/dist-client/modeller/gate-findings.js +102 -0
- package/dist-client/modeller/gate-findings.js.map +1 -0
- package/dist-client/modeller/v3-editor.js +11 -2
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-occupancy.d.ts +14 -0
- package/dist-client/modeller/v3-occupancy.js +22 -0
- package/dist-client/modeller/v3-occupancy.js.map +1 -0
- package/dist-client/modeller/v3-product-session.d.ts +44 -2
- package/dist-client/modeller/v3-product-session.js +13 -2
- package/dist-client/modeller/v3-product-session.js.map +1 -1
- package/dist-client/modeller/v3-renderer.d.ts +9 -0
- package/dist-client/modeller/v3-renderer.js +22 -30
- package/dist-client/modeller/v3-renderer.js.map +1 -1
- package/dist-client/modeller/v3-thumbnail.d.ts +3 -0
- package/dist-client/modeller/v3-thumbnail.js +74 -0
- package/dist-client/modeller/v3-thumbnail.js.map +1 -0
- package/dist-client/pages/figure-v3-modeller-page.d.ts +52 -1
- package/dist-client/pages/figure-v3-modeller-page.js +294 -12
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/route.js +7 -0
- package/dist-client/route.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/resolve-ts-siblings.mjs +18 -0
- package/test/v3-product-write.test.ts +14 -2
- package/test/v3-release-panel.test.ts +73 -0
- package/test/v3-verdict.test.ts +82 -0
- package/things-factory.config.js +6 -1
- package/translations/en.json +79 -1
- package/translations/ja.json +79 -1
- package/translations/ko.json +79 -1
- package/translations/ms.json +79 -1
- package/translations/zh.json +79 -1
package/client/graphql/index.ts
CHANGED
|
@@ -335,6 +335,59 @@ export async function inspectFigure(id: string): Promise<FigureInspection> {
|
|
|
335
335
|
return unwrap<FigureInspection>(response, 'inspectFigure')
|
|
336
336
|
}
|
|
337
337
|
|
|
338
|
+
/**
|
|
339
|
+
* 편집 중인 원본의 발행 판정 — 저장하지 않은 것을 잰다.
|
|
340
|
+
*
|
|
341
|
+
* 발행 패널의 `inspectFigure` 는 저장된 원본을 재므로 고친 결과를 저장한 뒤에야 보여 준다. 편집하면서 막히는
|
|
342
|
+
* 것을 보려면 편집 중인 원본을 보내야 한다. 판정은 서버의 같은 문(`inspectAnySource`)이 하고 문장도 같다 —
|
|
343
|
+
* 화면이 제 판정을 따로 가지면 두 자리가 다른 말을 한다. `id` 가 있으면 서버가 그 행의 타입과 직전 발행판을 읽는다.
|
|
344
|
+
*/
|
|
345
|
+
export async function inspectFigureDraft(source: string, id?: string): Promise<FigureInspection> {
|
|
346
|
+
const response = await client.query({
|
|
347
|
+
query: gql`
|
|
348
|
+
query InspectFigureDraft($source: String!, $id: String) {
|
|
349
|
+
inspectFigureDraft(source: $source, id: $id) {
|
|
350
|
+
blocked
|
|
351
|
+
findings {
|
|
352
|
+
code
|
|
353
|
+
message
|
|
354
|
+
why
|
|
355
|
+
how
|
|
356
|
+
at
|
|
357
|
+
fixes {
|
|
358
|
+
findingWay
|
|
359
|
+
part
|
|
360
|
+
axis
|
|
361
|
+
from
|
|
362
|
+
to
|
|
363
|
+
resolved
|
|
364
|
+
safe
|
|
365
|
+
}
|
|
366
|
+
fixPlan {
|
|
367
|
+
resolved
|
|
368
|
+
safe
|
|
369
|
+
fixes {
|
|
370
|
+
findingWay
|
|
371
|
+
part
|
|
372
|
+
axis
|
|
373
|
+
from
|
|
374
|
+
to
|
|
375
|
+
resolved
|
|
376
|
+
safe
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
blocking
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
`,
|
|
384
|
+
variables: { source, id },
|
|
385
|
+
fetchPolicy: 'network-only'
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
return unwrap<FigureInspection>(response, 'inspectFigureDraft')
|
|
389
|
+
}
|
|
390
|
+
|
|
338
391
|
/** 발행된 판들 — 최근 것부터 열 개. */
|
|
339
392
|
/**
|
|
340
393
|
* 직전 발행판의 정본. 한 번도 발행 안 했으면 `undefined`.
|
|
@@ -13,8 +13,14 @@ export const v3ProductStore: V3ProductStore = {
|
|
|
13
13
|
async save(request) {
|
|
14
14
|
const response = await client.mutate({
|
|
15
15
|
mutation: gql`
|
|
16
|
-
mutation SaveFigureV3($source: String!, $id: String, $expectedSource: String, $creationId: String) {
|
|
17
|
-
saveFigureV3(
|
|
16
|
+
mutation SaveFigureV3($source: String!, $id: String, $expectedSource: String, $creationId: String, $thumbnail: String) {
|
|
17
|
+
saveFigureV3(
|
|
18
|
+
source: $source
|
|
19
|
+
id: $id
|
|
20
|
+
expectedSource: $expectedSource
|
|
21
|
+
creationId: $creationId
|
|
22
|
+
thumbnail: $thumbnail
|
|
23
|
+
) {
|
|
18
24
|
id
|
|
19
25
|
source
|
|
20
26
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import '@material/web/icon/icon.js'
|
|
2
2
|
|
|
3
|
+
import { gateFindingStyles, renderGateFinding } from './gate-findings.js'
|
|
3
4
|
import { css, html, LitElement, nothing } from 'lit'
|
|
4
5
|
import { customElement, property, state } from 'lit/decorators.js'
|
|
5
6
|
|
|
@@ -31,6 +32,7 @@ import type { FigureGateFinding, FigureGateFix, FigureGateFixPlan, FigureInspect
|
|
|
31
32
|
@customElement('figure-history-panel')
|
|
32
33
|
export class FigureHistoryPanel extends localize(i18next)(LitElement) {
|
|
33
34
|
static styles = [
|
|
35
|
+
gateFindingStyles,
|
|
34
36
|
ScrollbarStyles,
|
|
35
37
|
css`
|
|
36
38
|
:host {
|
|
@@ -191,77 +193,6 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
|
|
|
191
193
|
게이트 사유. 막는 것은 색으로도 말하고, 알리는 것은 조용히 둔다 — 둘을 같은 톤으로 두면
|
|
192
194
|
「이 중 어느 것이 발행을 막았나」를 사람이 다시 세어야 한다.
|
|
193
195
|
*/
|
|
194
|
-
ul[gate] {
|
|
195
|
-
margin: var(--spacing-small, 6px) 0 0;
|
|
196
|
-
padding: 0;
|
|
197
|
-
list-style: none;
|
|
198
|
-
display: flex;
|
|
199
|
-
flex-direction: column;
|
|
200
|
-
gap: 3px;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
li[head] {
|
|
204
|
-
font: var(--label-font, inherit);
|
|
205
|
-
font-size: 0.72rem;
|
|
206
|
-
color: var(--md-sys-color-error);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
li[head][quiet] {
|
|
210
|
-
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
li[finding] {
|
|
214
|
-
display: flex;
|
|
215
|
-
flex-direction: column;
|
|
216
|
-
align-items: flex-start;
|
|
217
|
-
gap: 3px;
|
|
218
|
-
padding: 3px 6px;
|
|
219
|
-
font-size: 0.72rem;
|
|
220
|
-
line-height: 1.5;
|
|
221
|
-
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
222
|
-
background-color: var(--md-sys-color-surface-container);
|
|
223
|
-
border-radius: 4px;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
li[finding][blocking] {
|
|
227
|
-
color: var(--md-sys-color-on-error-container, var(--md-sys-color-on-surface));
|
|
228
|
-
background-color: var(--md-sys-color-error-container);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
li[finding] code[where] {
|
|
232
|
-
font-family: var(--mono-font, monospace);
|
|
233
|
-
font-size: 0.68rem;
|
|
234
|
-
opacity: 0.85;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
li[finding] details[affected] {
|
|
238
|
-
width: 100%;
|
|
239
|
-
color: inherit;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
li[finding] details[affected] summary {
|
|
243
|
-
cursor: pointer;
|
|
244
|
-
font-weight: 600;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/* 왜 나쁜지는 곁글로 — 읽어야 하지만 잰 것보다 먼저 눈에 들 필요는 없다 */
|
|
248
|
-
li[finding] span[why-line] {
|
|
249
|
-
opacity: 0.8;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/* 무엇을 바꾸면 되나 — 이 줄이 실제로 사람이 할 일이다. 연장 표를 달아 눈에 걸리게 한다 */
|
|
253
|
-
li[finding] span[how-line] {
|
|
254
|
-
display: flex;
|
|
255
|
-
align-items: flex-start;
|
|
256
|
-
gap: 4px;
|
|
257
|
-
padding-top: 2px;
|
|
258
|
-
font-weight: 600;
|
|
259
|
-
}
|
|
260
|
-
li[finding] span[how-line] md-icon {
|
|
261
|
-
--md-icon-size: 13px;
|
|
262
|
-
flex: none;
|
|
263
|
-
margin-top: 1px;
|
|
264
|
-
}
|
|
265
196
|
|
|
266
197
|
/* 설명으로 끝내지 않는다. 이 단추는 원본을 바꾸지 않고 모델러에 후보만 연다. */
|
|
267
198
|
li[finding] button[preview-fix] {
|
|
@@ -326,6 +257,18 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
|
|
|
326
257
|
@property({ type: Number }) figureVersion = 0
|
|
327
258
|
/** 저장하지 않은 것이 있나 — 있으면 발행을 막는다. */
|
|
328
259
|
@property({ type: Boolean }) dirty = false
|
|
260
|
+
/**
|
|
261
|
+
* 열린 도형의 형식. 발행 · 판본 · 복원은 figure id 로만 서버를 묻고 서버의 한 문이 두 형식을 다 읽으므로 두 화면이
|
|
262
|
+
* 이 패널을 함께 쓴다. 갈리는 것은 조치 단추 하나다 — 부품을 옮기는 두 조치는 형식마다 다른 편집이라, 받는
|
|
263
|
+
* 화면이 없는 단추를 그리면 눌러도 아무 일이 없다.
|
|
264
|
+
*/
|
|
265
|
+
@property({ type: String }) format: 'v2' | 'v3' = 'v2'
|
|
266
|
+
/**
|
|
267
|
+
* 행의 타입. 판정 하나(`type-is-placeholder`)가 이것을 본다 — V3 자산은 타입을 원본에 적지 않아서, 타입을 바꾸면
|
|
268
|
+
* 원본도 dirty 도 그대로다. 그래서 이 값이 바뀌면 판정을 다시 묻는다. 안 그러면 타입을 정한 뒤에도 「임시 타입」
|
|
269
|
+
* 판정이 남아 발행 단추가 잠긴 채다.
|
|
270
|
+
*/
|
|
271
|
+
@property({ type: String }) figureType?: string
|
|
329
272
|
/** 직전 발행판의 기준 상자. 외형 변화 안내에서 X·Z 복원 선택지에만 쓴다. */
|
|
330
273
|
@property({ attribute: false }) releasedBase?: { x: number; y: number; z: number }
|
|
331
274
|
|
|
@@ -348,7 +291,7 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
|
|
|
348
291
|
저장이 끝난 순간(dirty 가 풀림) 정본이 바뀌었으니 판정을 다시 묻는다. 저작 중(dirty)에는
|
|
349
292
|
묻지 않는다 — 서버에 있는 것은 저장 전 정본이라, 그 답은 화면에 있는 것과 다른 것을 말한다.
|
|
350
293
|
*/
|
|
351
|
-
if (changes.has('figureId') || changes.has('dirty')) {
|
|
294
|
+
if (changes.has('figureId') || changes.has('dirty') || changes.has('figureType')) {
|
|
352
295
|
void this.inspect()
|
|
353
296
|
}
|
|
354
297
|
}
|
|
@@ -468,23 +411,13 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
|
|
|
468
411
|
* 준다 — 화면이 문장을 지으면 서버가 거절할 때와 다른 말을 하게 된다.
|
|
469
412
|
*/
|
|
470
413
|
private renderFinding(finding: FigureGateFinding, blocking: boolean) {
|
|
471
|
-
return html`
|
|
472
|
-
|
|
473
|
-
<span msg>${finding.message}</span>
|
|
474
|
-
${finding.at ? html`
|
|
475
|
-
<details affected>
|
|
476
|
-
<summary>${i18next.t('figure.text.show-affected-parts')}</summary>
|
|
477
|
-
<code where>${finding.at}</code>
|
|
478
|
-
</details>
|
|
479
|
-
` : nothing}
|
|
480
|
-
${finding.why ? html`<span why-line>${finding.why}</span>` : nothing}
|
|
481
|
-
${finding.how ? html`<span how-line><md-icon>build</md-icon>${finding.how}</span>` : nothing}
|
|
482
|
-
${finding.code === 'part-outside-base'
|
|
414
|
+
return renderGateFinding(finding, blocking, html`
|
|
415
|
+
${this.offersFit(finding.code)
|
|
483
416
|
? html`<button fit-base @click=${() => this.fitBase()}>
|
|
484
417
|
${i18next.t('figure.button.fit-base-to-parts')}
|
|
485
418
|
</button>`
|
|
486
419
|
: nothing}
|
|
487
|
-
${finding.code === 'parts-off-placement-face'
|
|
420
|
+
${finding.code === 'parts-off-placement-face' && this.format === 'v2'
|
|
488
421
|
? html`<button fit-base @click=${() => this.settleOnFloor()}>
|
|
489
422
|
${i18next.t('figure.button.settle-on-floor')}
|
|
490
423
|
</button>`
|
|
@@ -516,8 +449,7 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
|
|
|
516
449
|
${i18next.t('figure.button.apply-recommended-fix')}
|
|
517
450
|
</button>
|
|
518
451
|
` : nothing}
|
|
519
|
-
|
|
520
|
-
`
|
|
452
|
+
`)
|
|
521
453
|
}
|
|
522
454
|
|
|
523
455
|
/**
|
|
@@ -548,6 +480,20 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
|
|
|
548
480
|
* 그래서 화면이 자기 자리에서 같은 동작을 낸다. 누르면 상자만 커지고 부품은 제자리이며, 저장은
|
|
549
481
|
* 여전히 저작자가 따로 한다.
|
|
550
482
|
*/
|
|
483
|
+
/**
|
|
484
|
+
* 「부품에 맞추기」를 내미는 판정. V2 는 기준 상자를 부품에 맞추고, V3 는 공간을 부품에서 다시 선언한다
|
|
485
|
+
* (`declare-occupancy`) — 둘 다 부품은 그대로 두고 선언만 바꾼다. V3 에서 공간을 아직 선언하지 않은 도형도
|
|
486
|
+
* 같은 단추로 선언한다: 새로 만든 V3 도형은 전부 그 판정으로 막히고, 화면에 다른 길이 없다.
|
|
487
|
+
*
|
|
488
|
+
* V3 의 장착면이 떠 있는 것은 여기 없다. 부품 하나를 평면에 붙이면 그 부품에 붙지 않은 것은 따라오지 않아
|
|
489
|
+
* V2 의 「바닥에 앉히기」와 뜻이 다르다. 판정의 「어떻게」가 할 일을 말한다.
|
|
490
|
+
*/
|
|
491
|
+
private offersFit(code: string): boolean {
|
|
492
|
+
return this.format === 'v3'
|
|
493
|
+
? code === 'part-outside-occupancy' || code === 'occupancy-undeclared'
|
|
494
|
+
: code === 'part-outside-base'
|
|
495
|
+
}
|
|
496
|
+
|
|
551
497
|
private fitBase() {
|
|
552
498
|
this.dispatchEvent(new CustomEvent('fit-base-to-parts', { bubbles: true, composed: true }))
|
|
553
499
|
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import '@material/web/icon/icon.js'
|
|
2
|
+
import { css, html, LitElement, nothing } from 'lit'
|
|
3
|
+
import { customElement, property } from 'lit/decorators.js'
|
|
4
|
+
import { i18next, localize } from '@operato/i18n'
|
|
5
|
+
import { ScrollbarStyles } from '@operato/styles'
|
|
6
|
+
import type { V3PrimitiveCreation } from '@hatiolab/figure-model'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 도형 팔레트와 부품 목록 — V3 자산의 것.
|
|
10
|
+
*
|
|
11
|
+
* `figure-parts`(V2) 와 **같은 자리, 같은 조작, 같은 낱말**이다. 저작자가 V2 에서 익힌 손이 그대로
|
|
12
|
+
* 가야 한다: 팔레트는 목록 위에, 눈 단추는 늘 자리를 차지하고, 조작은 고른 줄에서만 보인다.
|
|
13
|
+
*
|
|
14
|
+
* 다른 것은 읽는 대상뿐이다. V2 는 부품의 `primitive` 를 읽었고 여기서는 V3 평가가 준 `provider` 를
|
|
15
|
+
* 읽는다. 「숨김」은 여기서도 **작업대의 것**이다 — 저장되지 않고, 자산의 `visibility`(상태에 따라
|
|
16
|
+
* 보이고 안 보이는 것)와는 다른 것이다. 그것은 속성 패널의 몫이다.
|
|
17
|
+
*
|
|
18
|
+
* 이 컴포넌트는 정본을 만지지 않는다. 무엇을 하겠다는 것만 알린다 — V2 와 같은 규칙, 같은 사건 이름.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** 한 줄 — V3 평가에서 이 패널이 읽는 것만. */
|
|
22
|
+
export interface V3PartRow {
|
|
23
|
+
id: string
|
|
24
|
+
provider: string
|
|
25
|
+
/** 작업대에서 잠깐 가린 것. 저장되지 않는다. */
|
|
26
|
+
hidden?: boolean
|
|
27
|
+
/** 자산이 지금 상태에서 보이지 않는다고 한 것. 저장되는 것이고, 여기서는 표시만 한다. */
|
|
28
|
+
invisible?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 제공자마다의 아이콘. Material Symbols 이름이다. */
|
|
32
|
+
const PROVIDER_ICON: Record<string, string> = {
|
|
33
|
+
box: 'deployed_code',
|
|
34
|
+
cylinder: 'database',
|
|
35
|
+
ellipticCylinder: 'database',
|
|
36
|
+
frustum: 'change_history',
|
|
37
|
+
sphere: 'circle',
|
|
38
|
+
roundedBox: 'crop_square',
|
|
39
|
+
roundedBoxXZ: 'crop_square',
|
|
40
|
+
hollowBox: 'check_box_outline_blank',
|
|
41
|
+
hollowBoxXZ: 'check_box_outline_blank',
|
|
42
|
+
polygon: 'pentagon',
|
|
43
|
+
portal: 'door_front'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 팔레트 — 저작 API 가 만들 수 있는 것만 보인다. */
|
|
47
|
+
const RECIPES: Array<{ kind: V3PrimitiveCreation['kind']; icon: string; label: string }> = [
|
|
48
|
+
{ kind: 'box', icon: 'deployed_code', label: 'figure.label.primitive-cube' },
|
|
49
|
+
{ kind: 'cylinder', icon: 'database', label: 'figure.label.primitive-cylinder' },
|
|
50
|
+
{ kind: 'rounded-box', icon: 'crop_square', label: 'figure.label.primitive-rect' },
|
|
51
|
+
{ kind: 'portal', icon: 'door_front', label: 'figure.label.primitive-portal' }
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
@customElement('figure-v3-parts')
|
|
55
|
+
export class FigureV3Parts extends localize(i18next)(LitElement) {
|
|
56
|
+
static styles = [
|
|
57
|
+
ScrollbarStyles,
|
|
58
|
+
css`
|
|
59
|
+
:host {
|
|
60
|
+
display: flex;
|
|
61
|
+
flex-direction: column;
|
|
62
|
+
min-height: 0;
|
|
63
|
+
overflow: hidden;
|
|
64
|
+
background-color: var(--md-sys-color-surface-container-lowest);
|
|
65
|
+
border-right: 1px solid var(--md-sys-color-outline-variant);
|
|
66
|
+
color: var(--md-sys-color-on-surface);
|
|
67
|
+
font: var(--label-font, inherit);
|
|
68
|
+
font-size: 0.76rem;
|
|
69
|
+
}
|
|
70
|
+
section {
|
|
71
|
+
padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
|
|
72
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
73
|
+
}
|
|
74
|
+
section[list] {
|
|
75
|
+
flex: 1;
|
|
76
|
+
min-height: 0;
|
|
77
|
+
overflow-y: auto;
|
|
78
|
+
}
|
|
79
|
+
h3 {
|
|
80
|
+
display: flex;
|
|
81
|
+
align-items: baseline;
|
|
82
|
+
justify-content: space-between;
|
|
83
|
+
gap: 6px;
|
|
84
|
+
margin: 0 0 6px 0;
|
|
85
|
+
font-size: var(--ui-t-micro, 11px);
|
|
86
|
+
font-weight: 700;
|
|
87
|
+
letter-spacing: 0.04em;
|
|
88
|
+
text-transform: uppercase;
|
|
89
|
+
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
90
|
+
}
|
|
91
|
+
span[room] {
|
|
92
|
+
font-size: 0.7rem;
|
|
93
|
+
font-weight: 400;
|
|
94
|
+
letter-spacing: 0;
|
|
95
|
+
text-transform: none;
|
|
96
|
+
}
|
|
97
|
+
div[palette] {
|
|
98
|
+
display: grid;
|
|
99
|
+
grid-template-columns: repeat(2, 1fr);
|
|
100
|
+
gap: 4px;
|
|
101
|
+
}
|
|
102
|
+
button[shape] {
|
|
103
|
+
display: flex;
|
|
104
|
+
flex-direction: column;
|
|
105
|
+
align-items: center;
|
|
106
|
+
gap: 2px;
|
|
107
|
+
padding: 7px 2px 5px 2px;
|
|
108
|
+
font: var(--label-font, inherit);
|
|
109
|
+
font-size: 0.66rem;
|
|
110
|
+
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
111
|
+
background: none;
|
|
112
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
113
|
+
border-radius: 6px;
|
|
114
|
+
cursor: pointer;
|
|
115
|
+
}
|
|
116
|
+
button[shape]:hover:not([disabled]) {
|
|
117
|
+
color: var(--md-sys-color-on-primary-container);
|
|
118
|
+
background-color: var(--md-sys-color-primary-container);
|
|
119
|
+
border-color: transparent;
|
|
120
|
+
}
|
|
121
|
+
button[shape][disabled] {
|
|
122
|
+
opacity: 0.35;
|
|
123
|
+
cursor: default;
|
|
124
|
+
}
|
|
125
|
+
button[shape] md-icon {
|
|
126
|
+
--md-icon-size: 20px;
|
|
127
|
+
}
|
|
128
|
+
ol {
|
|
129
|
+
margin: 0;
|
|
130
|
+
padding: 0;
|
|
131
|
+
list-style: none;
|
|
132
|
+
display: flex;
|
|
133
|
+
flex-direction: column;
|
|
134
|
+
gap: 2px;
|
|
135
|
+
}
|
|
136
|
+
li {
|
|
137
|
+
display: flex;
|
|
138
|
+
align-items: center;
|
|
139
|
+
gap: 6px;
|
|
140
|
+
padding: 4px 6px;
|
|
141
|
+
border-radius: 6px;
|
|
142
|
+
cursor: pointer;
|
|
143
|
+
}
|
|
144
|
+
li:hover {
|
|
145
|
+
background-color: var(--md-sys-color-surface-container);
|
|
146
|
+
}
|
|
147
|
+
li[selected] {
|
|
148
|
+
color: var(--md-sys-color-on-primary-container);
|
|
149
|
+
background-color: var(--md-sys-color-primary-container);
|
|
150
|
+
}
|
|
151
|
+
li md-icon {
|
|
152
|
+
--md-icon-size: 16px;
|
|
153
|
+
flex: none;
|
|
154
|
+
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
155
|
+
}
|
|
156
|
+
button[eye][all] {
|
|
157
|
+
margin-left: auto;
|
|
158
|
+
opacity: 0.75;
|
|
159
|
+
}
|
|
160
|
+
button[eye][all]:hover {
|
|
161
|
+
opacity: 1;
|
|
162
|
+
}
|
|
163
|
+
button[eye] {
|
|
164
|
+
display: flex;
|
|
165
|
+
flex: none;
|
|
166
|
+
padding: 1px;
|
|
167
|
+
color: inherit;
|
|
168
|
+
background: none;
|
|
169
|
+
border: none;
|
|
170
|
+
border-radius: 4px;
|
|
171
|
+
cursor: pointer;
|
|
172
|
+
opacity: 0;
|
|
173
|
+
}
|
|
174
|
+
li:hover button[eye],
|
|
175
|
+
li[selected] button[eye],
|
|
176
|
+
button[eye][off] {
|
|
177
|
+
opacity: 1;
|
|
178
|
+
}
|
|
179
|
+
button[eye][off] {
|
|
180
|
+
color: var(--md-sys-color-tertiary);
|
|
181
|
+
}
|
|
182
|
+
button[eye] md-icon {
|
|
183
|
+
--md-icon-size: 15px;
|
|
184
|
+
color: inherit;
|
|
185
|
+
}
|
|
186
|
+
li[off] span[label],
|
|
187
|
+
li[off] > md-icon,
|
|
188
|
+
li[invisible] span[label],
|
|
189
|
+
li[invisible] > md-icon {
|
|
190
|
+
opacity: 0.45;
|
|
191
|
+
}
|
|
192
|
+
li[selected] md-icon {
|
|
193
|
+
color: inherit;
|
|
194
|
+
}
|
|
195
|
+
span[label] {
|
|
196
|
+
flex: 1;
|
|
197
|
+
min-width: 0;
|
|
198
|
+
overflow: hidden;
|
|
199
|
+
text-overflow: ellipsis;
|
|
200
|
+
white-space: nowrap;
|
|
201
|
+
font-size: 0.78rem;
|
|
202
|
+
}
|
|
203
|
+
span[label] small {
|
|
204
|
+
margin-left: 4px;
|
|
205
|
+
font-size: 0.66rem;
|
|
206
|
+
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
207
|
+
}
|
|
208
|
+
div[ops] {
|
|
209
|
+
display: none;
|
|
210
|
+
gap: 1px;
|
|
211
|
+
}
|
|
212
|
+
li[selected] div[ops] {
|
|
213
|
+
display: flex;
|
|
214
|
+
}
|
|
215
|
+
div[ops] button {
|
|
216
|
+
display: flex;
|
|
217
|
+
padding: 2px;
|
|
218
|
+
color: inherit;
|
|
219
|
+
background: none;
|
|
220
|
+
border: none;
|
|
221
|
+
border-radius: 4px;
|
|
222
|
+
cursor: pointer;
|
|
223
|
+
}
|
|
224
|
+
div[ops] button:hover:not([disabled]) {
|
|
225
|
+
background-color: var(--md-sys-color-surface-container-lowest);
|
|
226
|
+
}
|
|
227
|
+
div[ops] button[disabled] {
|
|
228
|
+
opacity: 0.3;
|
|
229
|
+
cursor: default;
|
|
230
|
+
}
|
|
231
|
+
div[ops] md-icon {
|
|
232
|
+
--md-icon-size: 15px;
|
|
233
|
+
color: inherit;
|
|
234
|
+
}
|
|
235
|
+
p[quiet] {
|
|
236
|
+
margin: 0;
|
|
237
|
+
font-size: 0.76rem;
|
|
238
|
+
line-height: 1.6;
|
|
239
|
+
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
240
|
+
}
|
|
241
|
+
`
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
@property({ type: Array }) parts: V3PartRow[] = []
|
|
245
|
+
/** 고른 부품의 id. 자리(index)가 아니다 — V3 부품은 id 로 안정하게 가리킨다. */
|
|
246
|
+
@property({ type: String }) selected = ''
|
|
247
|
+
/** 편집할 수 없을 때(불러오는 중 · 저장 중) 팔레트와 조작을 잠근다. */
|
|
248
|
+
@property({ type: Boolean }) locked = false
|
|
249
|
+
|
|
250
|
+
render() {
|
|
251
|
+
return html`
|
|
252
|
+
<section>
|
|
253
|
+
<h3>${i18next.t('figure.label.shapes')}</h3>
|
|
254
|
+
<div palette>
|
|
255
|
+
${RECIPES.map(
|
|
256
|
+
recipe => html`
|
|
257
|
+
<button
|
|
258
|
+
shape
|
|
259
|
+
?disabled=${this.locked}
|
|
260
|
+
title=${i18next.t(recipe.label)}
|
|
261
|
+
@click=${() => this.ask('add-part', { kind: recipe.kind })}
|
|
262
|
+
>
|
|
263
|
+
<md-icon>${recipe.icon}</md-icon>
|
|
264
|
+
<span>${i18next.t(recipe.label)}</span>
|
|
265
|
+
</button>
|
|
266
|
+
`
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
</section>
|
|
270
|
+
|
|
271
|
+
<section list>
|
|
272
|
+
<h3>
|
|
273
|
+
<span>${i18next.t('figure.label.parts')}</span>
|
|
274
|
+
<span room>${this.parts.length}</span>
|
|
275
|
+
${this.renderShowAll()}
|
|
276
|
+
</h3>
|
|
277
|
+
${this.parts.length === 0
|
|
278
|
+
? html`<p quiet>${i18next.t('figure.text.no-parts-yet')}</p>`
|
|
279
|
+
: html`<ol>
|
|
280
|
+
${this.parts.map((part, index) => this.renderRow(part, index))}
|
|
281
|
+
</ol>`}
|
|
282
|
+
</section>
|
|
283
|
+
`
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 한꺼번에 감추고 한꺼번에 되돌린다 — 하나라도 감춰져 있으면 다 되돌리고, 다 보이면 다 감춘다. */
|
|
287
|
+
private renderShowAll() {
|
|
288
|
+
if (this.parts.length === 0) return nothing
|
|
289
|
+
const anyHidden = this.parts.some(part => part.hidden)
|
|
290
|
+
return html`
|
|
291
|
+
<button
|
|
292
|
+
eye
|
|
293
|
+
all
|
|
294
|
+
?off=${!anyHidden}
|
|
295
|
+
title=${i18next.t(anyHidden ? 'figure.text.show-all-parts' : 'figure.text.hide-all-parts')}
|
|
296
|
+
@click=${(e: Event) => {
|
|
297
|
+
e.stopPropagation()
|
|
298
|
+
this.ask('set-all-parts-hidden', { hidden: !anyHidden })
|
|
299
|
+
}}
|
|
300
|
+
>
|
|
301
|
+
<md-icon>${anyHidden ? 'visibility' : 'visibility_off'}</md-icon>
|
|
302
|
+
</button>
|
|
303
|
+
`
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private renderRow(part: V3PartRow, index: number) {
|
|
307
|
+
const selected = part.id === this.selected
|
|
308
|
+
const off = !!part.hidden
|
|
309
|
+
return html`
|
|
310
|
+
<li
|
|
311
|
+
?selected=${selected}
|
|
312
|
+
?off=${off}
|
|
313
|
+
?invisible=${!!part.invisible}
|
|
314
|
+
@click=${() => this.ask('select-part', { id: part.id })}
|
|
315
|
+
>
|
|
316
|
+
<button
|
|
317
|
+
eye
|
|
318
|
+
?off=${off}
|
|
319
|
+
title=${i18next.t(off ? 'figure.text.show-this-part' : 'figure.text.hide-this-part')}
|
|
320
|
+
@click=${(e: Event) => {
|
|
321
|
+
e.stopPropagation()
|
|
322
|
+
this.ask('toggle-part-hidden', { id: part.id })
|
|
323
|
+
}}
|
|
324
|
+
>
|
|
325
|
+
<md-icon>${off ? 'visibility_off' : 'visibility'}</md-icon>
|
|
326
|
+
</button>
|
|
327
|
+
<md-icon>${PROVIDER_ICON[part.provider] ?? 'deployed_code'}</md-icon>
|
|
328
|
+
<span label title=${part.id}
|
|
329
|
+
>${part.id}${part.invisible ? html`<small>${i18next.t('figure.label.state-hidden')}</small>` : nothing}</span
|
|
330
|
+
>
|
|
331
|
+
${selected ? this.renderOps(part.id, index) : nothing}
|
|
332
|
+
</li>
|
|
333
|
+
`
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** 고른 줄의 조작. 못 하는 것은 빼지 않고 흐리게 둔다 — 자리가 밀리면 연달아 지우다 엉뚱한 것을 누른다. */
|
|
337
|
+
private renderOps(id: string, index: number) {
|
|
338
|
+
const count = this.parts.length
|
|
339
|
+
const op = (name: string, icon: string, detail: object, enabled = true) => html`
|
|
340
|
+
<button
|
|
341
|
+
op=${name}
|
|
342
|
+
?disabled=${!enabled || this.locked}
|
|
343
|
+
title=${i18next.t('figure.button.' + name)}
|
|
344
|
+
@click=${(e: Event) => {
|
|
345
|
+
e.stopPropagation()
|
|
346
|
+
if (enabled && !this.locked) this.ask(name, detail)
|
|
347
|
+
}}
|
|
348
|
+
>
|
|
349
|
+
<md-icon>${icon}</md-icon>
|
|
350
|
+
</button>
|
|
351
|
+
`
|
|
352
|
+
return html`
|
|
353
|
+
<div ops>
|
|
354
|
+
${op('move-part-up', 'arrow_upward', { id, to: index - 1 }, index > 0)}
|
|
355
|
+
${op('move-part-down', 'arrow_downward', { id, to: index + 1 }, index < count - 1)}
|
|
356
|
+
${op('duplicate-part', 'content_copy', { id })} ${op('remove-part', 'delete', { id })}
|
|
357
|
+
</div>
|
|
358
|
+
`
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** 정본을 고치지 않는다 — 무엇을 하겠다는 것만 알린다. */
|
|
362
|
+
private ask(what: string, detail: object) {
|
|
363
|
+
this.dispatchEvent(new CustomEvent(what, { detail, bubbles: true, composed: true }))
|
|
364
|
+
}
|
|
365
|
+
}
|