@things-factory/figure-ui 10.1.13 → 10.1.14
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/modeller/figure-canvas.ts +10 -4
- package/client/pages/figure-modeller-page.ts +198 -115
- package/dist-client/modeller/figure-canvas.d.ts +1 -0
- package/dist-client/modeller/figure-canvas.js +10 -4
- package/dist-client/modeller/figure-canvas.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.d.ts +9 -0
- package/dist-client/pages/figure-modeller-page.js +197 -117
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/test/ai-proposal-contract.test.ts +19 -0
- package/test/modeller-undo-redo.test.ts +33 -0
- package/translations/en.json +2 -0
- package/translations/ja.json +2 -0
- package/translations/ko.json +2 -0
- package/translations/ms.json +2 -0
- package/translations/zh.json +2 -0
|
@@ -70,6 +70,7 @@ interface SceneBoard {
|
|
|
70
70
|
selected?: SceneComponent[]
|
|
71
71
|
app?: unknown
|
|
72
72
|
addComponent?: (component: unknown) => void
|
|
73
|
+
removeComponent?: (component: unknown) => void
|
|
73
74
|
on?: (event: string, handler: (...args: unknown[]) => void) => void
|
|
74
75
|
/** 판 위의 루트. 선택 알림이 여기서 난다. */
|
|
75
76
|
root?: {
|
|
@@ -82,7 +83,8 @@ interface SceneComponent {
|
|
|
82
83
|
state: Record<string, unknown>
|
|
83
84
|
set: (props: Record<string, unknown> | string, value?: unknown) => void
|
|
84
85
|
on?: (event: string, handler: (...args: unknown[]) => void) => void
|
|
85
|
-
|
|
86
|
+
removeSelf?: (completely?: boolean) => void
|
|
87
|
+
dispose?: () => void
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
/**
|
|
@@ -181,6 +183,7 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
|
|
|
181
183
|
* 이미 씬 모델이므로, 그러면 값을 하나 고칠 때마다 왕복 변환이 한 번씩 일어난다.
|
|
182
184
|
* 무손실이긴 하지만 하는 일이 없다 — 보드가 곧 보드이고 부품이 곧 컴포넌트다.
|
|
183
185
|
*/
|
|
186
|
+
@property({ type: String }) figureId?: string
|
|
184
187
|
@property({ type: Object }) board?: BoardModel
|
|
185
188
|
/** 보기 설정. 자산이 아니라 작업대다 — 저장되지 않는다(`figure-view.ts`). */
|
|
186
189
|
@property({ type: Object }) view: ViewSettings = DEFAULT_VIEW
|
|
@@ -272,7 +275,7 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
|
|
|
272
275
|
willUpdate(changed: Map<string, unknown>) {
|
|
273
276
|
if (!this.hasUpdated) return
|
|
274
277
|
|
|
275
|
-
if (changed.has('board') || changed.has('parts')) this.standUp()
|
|
278
|
+
if (changed.has('board') || changed.has('parts') || changed.has('figureId')) this.standUp()
|
|
276
279
|
if (changed.has('view')) this.applyView()
|
|
277
280
|
if (changed.has('picked')) this.showPicked()
|
|
278
281
|
}
|
|
@@ -314,7 +317,7 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
|
|
|
314
317
|
return
|
|
315
318
|
}
|
|
316
319
|
|
|
317
|
-
const signature = `${board.width}x${board.height}x${board.depth}`
|
|
320
|
+
const signature = `${this.figureId ?? ''}:${board.width}x${board.height}x${board.depth}`
|
|
318
321
|
if (this.scene && this.standing === signature) {
|
|
319
322
|
this.syncParts()
|
|
320
323
|
return
|
|
@@ -455,7 +458,10 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
|
|
|
455
458
|
|
|
456
459
|
// 없어진 것을 제거한다
|
|
457
460
|
for (const [name, component] of standing) {
|
|
458
|
-
if (!wanted.has(name))
|
|
461
|
+
if (!wanted.has(name)) {
|
|
462
|
+
root.removeComponent?.(component)
|
|
463
|
+
component.dispose?.()
|
|
464
|
+
}
|
|
459
465
|
}
|
|
460
466
|
|
|
461
467
|
for (const part of this.parts) {
|
|
@@ -188,90 +188,9 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
188
188
|
display: none;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
/*
|
|
192
|
-
머리줄 — 이름 · 타입 · 미저장 표시 · 말로 시키는 줄 · 저장을 **한 줄에** 담는다.
|
|
193
|
-
|
|
194
|
-
이름은 페이지 제목에도 나온다(셸이 context.title 로 그린다). 그래도 여기 입력칸을 두는
|
|
195
|
-
이유는 **고치는 자리**가 필요해서다 — 제목은 읽는 것이고 이 칸은 바꾸는 것이다. 두 줄로
|
|
196
|
-
벌리는 대신 한 줄에 모아 캔버스에 높이를 넘긴다.
|
|
197
|
-
*/
|
|
198
|
-
div[header] {
|
|
199
|
-
display: flex;
|
|
200
|
-
align-items: center;
|
|
201
|
-
gap: var(--spacing-medium, 8px);
|
|
202
|
-
padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
|
|
203
|
-
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
204
|
-
}
|
|
205
|
-
button[ai-support] {
|
|
206
|
-
height: 28px;
|
|
207
|
-
padding: 0 12px;
|
|
208
|
-
border: 1px solid var(--md-sys-color-outline-variant);
|
|
209
|
-
border-radius: 6px;
|
|
210
|
-
background: var(--md-sys-color-primary-container);
|
|
211
|
-
color: var(--md-sys-color-on-primary-container);
|
|
212
|
-
font: inherit;
|
|
213
|
-
cursor: pointer;
|
|
214
|
-
}
|
|
215
|
-
input[name] {
|
|
216
|
-
flex: none;
|
|
217
|
-
width: 180px;
|
|
218
|
-
padding: 5px 9px;
|
|
219
|
-
font: var(--input-field-font, inherit);
|
|
220
|
-
font-size: 0.82rem;
|
|
221
|
-
color: var(--md-sys-color-on-surface);
|
|
222
|
-
background-color: var(--md-sys-color-surface-container-lowest);
|
|
223
|
-
border: 1px solid var(--md-sys-color-outline-variant);
|
|
224
|
-
border-radius: 6px;
|
|
225
|
-
}
|
|
226
|
-
/* 타입 이름은 만든 뒤 고칠 수 없다 — 그 사실이 화면에서 보여야 한다 */
|
|
227
|
-
code[type] {
|
|
228
|
-
padding: 4px 8px;
|
|
229
|
-
font-family: var(--mono-font, monospace);
|
|
230
|
-
font-size: 0.76rem;
|
|
231
|
-
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
232
|
-
background-color: var(--md-sys-color-surface-container);
|
|
233
|
-
border-radius: 6px;
|
|
234
|
-
}
|
|
235
|
-
span[dirty] {
|
|
236
|
-
font: var(--label-font, inherit);
|
|
237
|
-
font-size: 0.74rem;
|
|
238
|
-
color: var(--md-sys-color-tertiary);
|
|
239
|
-
}
|
|
240
191
|
div[spacer] {
|
|
241
192
|
flex: 1;
|
|
242
193
|
}
|
|
243
|
-
button[save] {
|
|
244
|
-
padding: 6px 16px;
|
|
245
|
-
font: var(--label-font, inherit);
|
|
246
|
-
font-size: 0.76rem;
|
|
247
|
-
font-weight: 600;
|
|
248
|
-
color: var(--md-sys-color-on-primary, #fff);
|
|
249
|
-
background-color: var(--md-sys-color-primary);
|
|
250
|
-
border: none;
|
|
251
|
-
border-radius: 6px;
|
|
252
|
-
cursor: pointer;
|
|
253
|
-
}
|
|
254
|
-
button[save][disabled] {
|
|
255
|
-
opacity: 0.4;
|
|
256
|
-
cursor: default;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/*
|
|
260
|
-
저장이 거절된 사유. 단추 **바로 왼쪽**에 둔다 — 누른 자리에서 답을 봐야 한다. 길면 줄이지만
|
|
261
|
-
전문은 title 로 남긴다(서버 문장이 길 수 있다).
|
|
262
|
-
*/
|
|
263
|
-
span[save-failed] {
|
|
264
|
-
max-width: 280px;
|
|
265
|
-
overflow: hidden;
|
|
266
|
-
text-overflow: ellipsis;
|
|
267
|
-
white-space: nowrap;
|
|
268
|
-
padding: 4px 8px;
|
|
269
|
-
font: var(--label-font, inherit);
|
|
270
|
-
font-size: 0.72rem;
|
|
271
|
-
color: var(--md-sys-color-on-error-container, var(--md-sys-color-on-surface));
|
|
272
|
-
background-color: var(--md-sys-color-error-container);
|
|
273
|
-
border-radius: 6px;
|
|
274
|
-
}
|
|
275
194
|
|
|
276
195
|
/*
|
|
277
196
|
칸을 벡터 저작도구의 관례대로 놓는다 — 왼쪽에 무엇이 있나(레이어), 오른쪽에
|
|
@@ -294,16 +213,17 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
294
213
|
}
|
|
295
214
|
|
|
296
215
|
/*
|
|
297
|
-
편집 · 미리보기
|
|
216
|
+
편집 · 미리보기 전환 및 조작 띠.
|
|
298
217
|
|
|
299
218
|
가운데 칸 위에 얇게 둔다. 무엇을 보고 있는지가 늘 보여야 하고, 두 화면이 같은
|
|
300
219
|
자리를 쓰므로 어느 쪽인지 모르면 편집이 안 먹는 것처럼 읽힌다.
|
|
301
220
|
*/
|
|
302
221
|
div[modes] {
|
|
303
222
|
display: flex;
|
|
223
|
+
align-items: center;
|
|
304
224
|
flex: none;
|
|
305
225
|
gap: 2px;
|
|
306
|
-
padding:
|
|
226
|
+
padding: 4px var(--spacing-medium, 8px);
|
|
307
227
|
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
308
228
|
}
|
|
309
229
|
div[modes] button[mode] {
|
|
@@ -324,13 +244,36 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
324
244
|
color: var(--md-sys-color-on-primary-container);
|
|
325
245
|
background-color: var(--md-sys-color-primary-container);
|
|
326
246
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
247
|
+
div[modes] div[separator] {
|
|
248
|
+
width: 1px;
|
|
249
|
+
height: 16px;
|
|
250
|
+
margin: 0 4px;
|
|
251
|
+
background-color: var(--md-sys-color-outline-variant);
|
|
252
|
+
}
|
|
253
|
+
div[modes] button[history] {
|
|
254
|
+
display: flex;
|
|
255
|
+
align-items: center;
|
|
256
|
+
justify-content: center;
|
|
257
|
+
width: 26px;
|
|
258
|
+
height: 26px;
|
|
259
|
+
padding: 0;
|
|
260
|
+
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
261
|
+
background: none;
|
|
262
|
+
border: none;
|
|
263
|
+
border-radius: 6px;
|
|
264
|
+
cursor: pointer;
|
|
265
|
+
}
|
|
266
|
+
div[modes] button[history]:hover:not([disabled]) {
|
|
267
|
+
background-color: var(--md-sys-color-surface-container);
|
|
268
|
+
color: var(--md-sys-color-on-surface);
|
|
269
|
+
}
|
|
270
|
+
div[modes] button[history][disabled] {
|
|
271
|
+
opacity: 0.3;
|
|
272
|
+
cursor: default;
|
|
273
|
+
}
|
|
274
|
+
div[modes] button[history] md-icon {
|
|
275
|
+
--md-icon-size: 18px;
|
|
276
|
+
}
|
|
334
277
|
div[modes] button[collapse] {
|
|
335
278
|
display: flex;
|
|
336
279
|
align-items: center;
|
|
@@ -348,6 +291,46 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
348
291
|
div[modes] button[collapse] md-icon {
|
|
349
292
|
--md-icon-size: 18px;
|
|
350
293
|
}
|
|
294
|
+
div[modes] span[dirty] {
|
|
295
|
+
align-self: center;
|
|
296
|
+
margin-right: 6px;
|
|
297
|
+
font: var(--label-font, inherit);
|
|
298
|
+
font-size: 0.72rem;
|
|
299
|
+
color: var(--md-sys-color-tertiary);
|
|
300
|
+
}
|
|
301
|
+
div[modes] span[save-failed] {
|
|
302
|
+
align-self: center;
|
|
303
|
+
max-width: 240px;
|
|
304
|
+
overflow: hidden;
|
|
305
|
+
text-overflow: ellipsis;
|
|
306
|
+
white-space: nowrap;
|
|
307
|
+
margin-right: 6px;
|
|
308
|
+
padding: 3px 7px;
|
|
309
|
+
font: var(--label-font, inherit);
|
|
310
|
+
font-size: 0.7rem;
|
|
311
|
+
color: var(--md-sys-color-on-error-container, var(--md-sys-color-on-surface));
|
|
312
|
+
background-color: var(--md-sys-color-error-container);
|
|
313
|
+
border-radius: 4px;
|
|
314
|
+
}
|
|
315
|
+
div[modes] button[save] {
|
|
316
|
+
padding: 4px 14px;
|
|
317
|
+
margin-right: 4px;
|
|
318
|
+
font: var(--label-font, inherit);
|
|
319
|
+
font-size: 0.74rem;
|
|
320
|
+
font-weight: 600;
|
|
321
|
+
color: var(--md-sys-color-on-primary, #fff);
|
|
322
|
+
background-color: var(--md-sys-color-primary);
|
|
323
|
+
border: none;
|
|
324
|
+
border-radius: 6px;
|
|
325
|
+
cursor: pointer;
|
|
326
|
+
}
|
|
327
|
+
div[modes] button[save]:hover:not([disabled]) {
|
|
328
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
|
329
|
+
}
|
|
330
|
+
div[modes] button[save][disabled] {
|
|
331
|
+
opacity: 0.35;
|
|
332
|
+
cursor: default;
|
|
333
|
+
}
|
|
351
334
|
|
|
352
335
|
/*
|
|
353
336
|
후보가 오면 가운데를 둘로 나눈다.
|
|
@@ -580,6 +563,11 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
580
563
|
/** 인라인 요청창과 AI 도크가 함께 쓰는 현재 Figure의 제한된 세션 피드백. */
|
|
581
564
|
@state() private proposalFeedback: ProposalFeedback[] = []
|
|
582
565
|
|
|
566
|
+
@state() private undoStack: { board?: BoardModel; parts: PartModel[]; selected: number }[] = []
|
|
567
|
+
@state() private redoStack: { board?: BoardModel; parts: PartModel[]; selected: number }[] = []
|
|
568
|
+
private lastCoalesceKey?: string
|
|
569
|
+
private lastHistoryTime = 0
|
|
570
|
+
|
|
583
571
|
/**
|
|
584
572
|
* 저장 형식으로 변환한 것. 씬 모델이 바뀔 때만 다시 만든다.
|
|
585
573
|
*
|
|
@@ -640,15 +628,34 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
640
628
|
super.connectedCallback()
|
|
641
629
|
window.addEventListener('figure-ai-context-request', this.announceAiContext)
|
|
642
630
|
window.addEventListener('figure-ai-review', this.acceptDockProposal)
|
|
631
|
+
window.addEventListener('keydown', this.handleKeyDown)
|
|
643
632
|
}
|
|
644
633
|
|
|
645
634
|
disconnectedCallback() {
|
|
635
|
+
window.removeEventListener('keydown', this.handleKeyDown)
|
|
646
636
|
window.removeEventListener('figure-ai-context-request', this.announceAiContext)
|
|
647
637
|
window.removeEventListener('figure-ai-review', this.acceptDockProposal)
|
|
648
638
|
window.dispatchEvent(new CustomEvent('figure-ai-context', { detail: undefined }))
|
|
649
639
|
super.disconnectedCallback()
|
|
650
640
|
}
|
|
651
641
|
|
|
642
|
+
private handleKeyDown = (e: KeyboardEvent) => {
|
|
643
|
+
if (!this.active || this.mode !== 'edit' || this.proposalSession) return
|
|
644
|
+
const target = e.composedPath()[0] as HTMLElement | undefined
|
|
645
|
+
const tag = target?.tagName?.toLowerCase()
|
|
646
|
+
if (tag === 'input' || tag === 'textarea' || target?.isContentEditable) return
|
|
647
|
+
|
|
648
|
+
if ((e.metaKey || e.ctrlKey) && !e.altKey) {
|
|
649
|
+
if (e.key === 'z' && !e.shiftKey) {
|
|
650
|
+
e.preventDefault()
|
|
651
|
+
this.undo()
|
|
652
|
+
} else if ((e.key === 'z' && e.shiftKey) || e.key === 'y') {
|
|
653
|
+
e.preventDefault()
|
|
654
|
+
this.redo()
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
652
659
|
get context() {
|
|
653
660
|
return {
|
|
654
661
|
title: this.figure?.name ?? i18next.t('figure.title.new-figure'),
|
|
@@ -670,27 +677,6 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
670
677
|
const source = this.folded
|
|
671
678
|
|
|
672
679
|
return html`
|
|
673
|
-
<div header>
|
|
674
|
-
<!--
|
|
675
|
-
이름과 타입은 **속성 패널**에 있다(figure-inspector 의 도형 절). 여기 있던 이름 칸은
|
|
676
|
-
하네스 시절의 자리였다 — 앱에서는 셸이 페이지 제목으로 같은 이름을 그리므로 글자가 두 번
|
|
677
|
-
나오고, 캔버스가 주인공인 화면에서 그만큼 높이를 잃었다.
|
|
678
|
-
-->
|
|
679
|
-
${this.dirty ? html`<span dirty>${i18next.t('figure.text.unsaved-changes')}</span>` : ''}
|
|
680
|
-
<!--
|
|
681
|
-
말로 시키는 줄이 머리줄의 남는 폭을 쓴다. 따로 두면 화면 위가 두 줄이 되고, 그 위 줄은
|
|
682
|
-
이름을 페이지 제목과 겹쳐 적는다.
|
|
683
|
-
-->
|
|
684
|
-
<button ai-support @click=${() => {
|
|
685
|
-
requestFigureAI({ figureId: this.figure?.id, source: this.folded })
|
|
686
|
-
openOverlay('figure-ai-dock', { backdrop: false })
|
|
687
|
-
}}>AI 지원</button>
|
|
688
|
-
${this.saveFailure ? html`<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
|
|
689
|
-
<button save ?disabled=${!this.canSave} @click=${() => this.save()}>
|
|
690
|
-
${this.saving ? i18next.t('figure.text.saving-figure') : i18next.t('figure.button.save')}
|
|
691
|
-
</button>
|
|
692
|
-
</div>
|
|
693
|
-
|
|
694
680
|
<div
|
|
695
681
|
edits
|
|
696
682
|
?collapse-left=${this.collapsed.left}
|
|
@@ -731,6 +717,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
731
717
|
<div lane>
|
|
732
718
|
<h4>${i18next.t('figure.label.now')}</h4>
|
|
733
719
|
<figure-canvas
|
|
720
|
+
.figureId=${this.figure?.id || this.figure?.type}
|
|
734
721
|
.board=${this.board}
|
|
735
722
|
.parts=${this.parts}
|
|
736
723
|
.picked=${this.pickedName}
|
|
@@ -755,9 +742,11 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
755
742
|
: this.mode === 'preview'
|
|
756
743
|
? html`<figure-preview .source=${source}></figure-preview>`
|
|
757
744
|
: html`<figure-canvas
|
|
745
|
+
.figureId=${this.figure?.id || this.figure?.type}
|
|
758
746
|
.board=${this.board}
|
|
759
747
|
.parts=${this.parts}
|
|
760
748
|
.picked=${this.pickedName}
|
|
749
|
+
.view=${this.view}
|
|
761
750
|
></figure-canvas>`}
|
|
762
751
|
</div>
|
|
763
752
|
|
|
@@ -811,7 +800,33 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
811
800
|
|
|
812
801
|
return html`<div modes>
|
|
813
802
|
${this.renderCollapse('left')}${this.proposalSession ? '' : html`${one('edit')}${one('preview')}`}
|
|
803
|
+
${this.mode === 'edit' && !this.proposalSession
|
|
804
|
+
? html`
|
|
805
|
+
<div separator></div>
|
|
806
|
+
<button
|
|
807
|
+
history
|
|
808
|
+
?disabled=${this.undoStack.length === 0}
|
|
809
|
+
title="${i18next.t('figure.button.undo')} (Ctrl+Z)"
|
|
810
|
+
@click=${() => this.undo()}
|
|
811
|
+
>
|
|
812
|
+
<md-icon>undo</md-icon>
|
|
813
|
+
</button>
|
|
814
|
+
<button
|
|
815
|
+
history
|
|
816
|
+
?disabled=${this.redoStack.length === 0}
|
|
817
|
+
title="${i18next.t('figure.button.redo')} (Ctrl+Y)"
|
|
818
|
+
@click=${() => this.redo()}
|
|
819
|
+
>
|
|
820
|
+
<md-icon>redo</md-icon>
|
|
821
|
+
</button>
|
|
822
|
+
`
|
|
823
|
+
: ''}
|
|
814
824
|
<div spacer></div>
|
|
825
|
+
${this.saveFailure ? html`<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
|
|
826
|
+
${this.dirty ? html`<span dirty>${i18next.t('figure.text.unsaved-changes')}</span>` : ''}
|
|
827
|
+
<button save ?disabled=${!this.canSave} @click=${() => this.save()}>
|
|
828
|
+
${this.saving ? i18next.t('figure.text.saving-figure') : i18next.t('figure.button.save')}
|
|
829
|
+
</button>
|
|
815
830
|
${this.renderCollapse('right')}
|
|
816
831
|
</div>`
|
|
817
832
|
}
|
|
@@ -1013,6 +1028,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1013
1028
|
if (!this.proposalSession) return
|
|
1014
1029
|
if (this.proposalBaseSource !== undefined && this.proposalBaseSource !== JSON.stringify(this.folded)) return
|
|
1015
1030
|
|
|
1031
|
+
this.recordHistory()
|
|
1016
1032
|
const taken = proposals.applyProposal(this.folded, this.proposalSession.source, this.proposalSession.picked)
|
|
1017
1033
|
const { board, parts } = fromFigureSource(taken)
|
|
1018
1034
|
|
|
@@ -1084,7 +1100,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1084
1100
|
const id = lifecycle?.resourceId
|
|
1085
1101
|
|
|
1086
1102
|
if (id) {
|
|
1087
|
-
if (id !== this.figure?.id) {
|
|
1103
|
+
if (changes.active || id !== this.figure?.id) {
|
|
1088
1104
|
await this.load(id)
|
|
1089
1105
|
}
|
|
1090
1106
|
return
|
|
@@ -1102,6 +1118,16 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1102
1118
|
들고 있는 것이 **저장된 도형이면**(id 가 있으면) 새로 시작한다. 아직 저장 안 한 새 도형을
|
|
1103
1119
|
들고 있으면 그대로 둔다 — 페이지를 다시 들렀다고 저작 중인 것을 버리지 않는다.
|
|
1104
1120
|
*/
|
|
1121
|
+
if (!this.figure) {
|
|
1122
|
+
this.startNew()
|
|
1123
|
+
return
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (this.figure.id) {
|
|
1127
|
+
this.startNew()
|
|
1128
|
+
return
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1105
1131
|
if (shouldStartNewFigure(changes, !!this.figure, this.figure?.id)) {
|
|
1106
1132
|
this.startNew()
|
|
1107
1133
|
return
|
|
@@ -1149,6 +1175,9 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1149
1175
|
this.selected = -1
|
|
1150
1176
|
this.dirty = false
|
|
1151
1177
|
this.violations = []
|
|
1178
|
+
this.undoStack = []
|
|
1179
|
+
this.redoStack = []
|
|
1180
|
+
this.lastCoalesceKey = undefined
|
|
1152
1181
|
}
|
|
1153
1182
|
|
|
1154
1183
|
private startNew() {
|
|
@@ -1168,6 +1197,9 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1168
1197
|
this.selected = -1
|
|
1169
1198
|
this.dirty = true
|
|
1170
1199
|
this.violations = []
|
|
1200
|
+
this.undoStack = []
|
|
1201
|
+
this.redoStack = []
|
|
1202
|
+
this.lastCoalesceKey = undefined
|
|
1171
1203
|
}
|
|
1172
1204
|
|
|
1173
1205
|
private rename(name: string) {
|
|
@@ -1190,13 +1222,62 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1190
1222
|
this.dirty = true
|
|
1191
1223
|
}
|
|
1192
1224
|
|
|
1225
|
+
private snapshotState(): { board?: BoardModel; parts: PartModel[]; selected: number } {
|
|
1226
|
+
return {
|
|
1227
|
+
board: this.board ? structuredClone(this.board) : undefined,
|
|
1228
|
+
parts: structuredClone(this.parts),
|
|
1229
|
+
selected: this.selected
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
private recordHistory(coalesceKey?: string) {
|
|
1234
|
+
const now = Date.now()
|
|
1235
|
+
if (coalesceKey && this.lastCoalesceKey === coalesceKey && now - this.lastHistoryTime < 600) {
|
|
1236
|
+
this.lastHistoryTime = now
|
|
1237
|
+
return
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
this.lastCoalesceKey = coalesceKey
|
|
1241
|
+
this.lastHistoryTime = now
|
|
1242
|
+
|
|
1243
|
+
this.undoStack = [...this.undoStack.slice(-49), this.snapshotState()]
|
|
1244
|
+
this.redoStack = []
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
private undo() {
|
|
1248
|
+
if (this.undoStack.length === 0) return
|
|
1249
|
+
const prev = this.undoStack[this.undoStack.length - 1]
|
|
1250
|
+
this.undoStack = this.undoStack.slice(0, -1)
|
|
1251
|
+
this.redoStack = [this.snapshotState(), ...this.redoStack.slice(0, 49)]
|
|
1252
|
+
|
|
1253
|
+
this.board = prev.board ? structuredClone(prev.board) : undefined
|
|
1254
|
+
this.parts = structuredClone(prev.parts)
|
|
1255
|
+
this.selected = this.parts.length ? Math.max(-1, Math.min(prev.selected, this.parts.length - 1)) : -1
|
|
1256
|
+
this.dirty = true
|
|
1257
|
+
this.lastCoalesceKey = undefined
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
private redo() {
|
|
1261
|
+
if (this.redoStack.length === 0) return
|
|
1262
|
+
const next = this.redoStack[0]
|
|
1263
|
+
this.redoStack = this.redoStack.slice(1)
|
|
1264
|
+
this.undoStack = [...this.undoStack.slice(-49), this.snapshotState()]
|
|
1265
|
+
|
|
1266
|
+
this.board = next.board ? structuredClone(next.board) : undefined
|
|
1267
|
+
this.parts = structuredClone(next.parts)
|
|
1268
|
+
this.selected = this.parts.length ? Math.max(-1, Math.min(next.selected, this.parts.length - 1)) : -1
|
|
1269
|
+
this.dirty = true
|
|
1270
|
+
this.lastCoalesceKey = undefined
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1193
1273
|
/**
|
|
1194
1274
|
* 부품 목록을 갈아 끼운다.
|
|
1195
1275
|
*
|
|
1196
1276
|
* 편집 규칙은 `part-edits` 가 갖는다 — 화면과 테스트가 같은 것을 쓰게 하려고 떼어
|
|
1197
1277
|
* 놓았다. 여기서는 결과를 담고 선택 인덱스를 정리한다.
|
|
1198
1278
|
*/
|
|
1199
|
-
private put(parts: PartModel[], selected = this.selected) {
|
|
1279
|
+
private put(parts: PartModel[], selected = this.selected, coalesceKey?: string) {
|
|
1280
|
+
this.recordHistory(coalesceKey)
|
|
1200
1281
|
this.parts = parts
|
|
1201
1282
|
this.selected = Math.min(selected, parts.length - 1)
|
|
1202
1283
|
this.dirty = true
|
|
@@ -1221,7 +1302,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1221
1302
|
}
|
|
1222
1303
|
|
|
1223
1304
|
private updatePart(index: number, part: PartModel) {
|
|
1224
|
-
this.put(edits.updatePart(this.parts, index, part))
|
|
1305
|
+
this.put(edits.updatePart(this.parts, index, part), this.selected, `part:${part.name || index}`)
|
|
1225
1306
|
}
|
|
1226
1307
|
|
|
1227
1308
|
/**
|
|
@@ -1231,6 +1312,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1231
1312
|
* 고칠 때마다 부품 목록이 새 배열이 되고, 그리는 쪽이 전부 다시 그린다.
|
|
1232
1313
|
*/
|
|
1233
1314
|
private updateBoard(board: BoardModel) {
|
|
1315
|
+
this.recordHistory('board')
|
|
1234
1316
|
this.board = board
|
|
1235
1317
|
this.dirty = true
|
|
1236
1318
|
}
|
|
@@ -1248,6 +1330,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1248
1330
|
private changeDetailLevel(level: DetailLevel) {
|
|
1249
1331
|
if (!this.board) return
|
|
1250
1332
|
|
|
1333
|
+
this.recordHistory('detailLevel')
|
|
1251
1334
|
this.board = { ...this.board, detailLevel: level }
|
|
1252
1335
|
this.dirty = true
|
|
1253
1336
|
}
|
|
@@ -1286,7 +1369,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1286
1369
|
*/
|
|
1287
1370
|
private partChanged(name: string, part: PartModel) {
|
|
1288
1371
|
const at = this.parts.findIndex(item => item.name === name)
|
|
1289
|
-
if (at >= 0) this.put(edits.updatePart(this.parts, at, part))
|
|
1372
|
+
if (at >= 0) this.put(edits.updatePart(this.parts, at, part), this.selected, `drag:${name}`)
|
|
1290
1373
|
}
|
|
1291
1374
|
|
|
1292
1375
|
/**
|
|
@@ -41,6 +41,7 @@ export declare class FigureCanvas extends FigureCanvas_base {
|
|
|
41
41
|
* 이미 씬 모델이므로, 그러면 값을 하나 고칠 때마다 왕복 변환이 한 번씩 일어난다.
|
|
42
42
|
* 무손실이긴 하지만 하는 일이 없다 — 보드가 곧 보드이고 부품이 곧 컴포넌트다.
|
|
43
43
|
*/
|
|
44
|
+
figureId?: string;
|
|
44
45
|
board?: BoardModel;
|
|
45
46
|
/** 보기 설정. 자산이 아니라 작업대다 — 저장되지 않는다(`figure-view.ts`). */
|
|
46
47
|
view: ViewSettings;
|
|
@@ -187,7 +187,7 @@ let FigureCanvas = class FigureCanvas extends localize(i18next)(LitElement) {
|
|
|
187
187
|
willUpdate(changed) {
|
|
188
188
|
if (!this.hasUpdated)
|
|
189
189
|
return;
|
|
190
|
-
if (changed.has('board') || changed.has('parts'))
|
|
190
|
+
if (changed.has('board') || changed.has('parts') || changed.has('figureId'))
|
|
191
191
|
this.standUp();
|
|
192
192
|
if (changed.has('view'))
|
|
193
193
|
this.applyView();
|
|
@@ -226,7 +226,7 @@ let FigureCanvas = class FigureCanvas extends localize(i18next)(LitElement) {
|
|
|
226
226
|
this.cannot = i18next.t('figure.text.canvas-appears-once-a-part-exists');
|
|
227
227
|
return;
|
|
228
228
|
}
|
|
229
|
-
const signature = `${board.width}x${board.height}x${board.depth}`;
|
|
229
|
+
const signature = `${this.figureId ?? ''}:${board.width}x${board.height}x${board.depth}`;
|
|
230
230
|
if (this.scene && this.standing === signature) {
|
|
231
231
|
this.syncParts();
|
|
232
232
|
return;
|
|
@@ -360,8 +360,10 @@ let FigureCanvas = class FigureCanvas extends localize(i18next)(LitElement) {
|
|
|
360
360
|
const standing = this.partsOnBoard();
|
|
361
361
|
// 없어진 것을 제거한다
|
|
362
362
|
for (const [name, component] of standing) {
|
|
363
|
-
if (!wanted.has(name))
|
|
364
|
-
|
|
363
|
+
if (!wanted.has(name)) {
|
|
364
|
+
root.removeComponent?.(component);
|
|
365
|
+
component.dispose?.();
|
|
366
|
+
}
|
|
365
367
|
}
|
|
366
368
|
for (const part of this.parts) {
|
|
367
369
|
const found = standing.get(part.name);
|
|
@@ -443,6 +445,10 @@ let FigureCanvas = class FigureCanvas extends localize(i18next)(LitElement) {
|
|
|
443
445
|
this.standing = undefined;
|
|
444
446
|
}
|
|
445
447
|
};
|
|
448
|
+
__decorate([
|
|
449
|
+
property({ type: String }),
|
|
450
|
+
__metadata("design:type", String)
|
|
451
|
+
], FigureCanvas.prototype, "figureId", void 0);
|
|
446
452
|
__decorate([
|
|
447
453
|
property({ type: Object }),
|
|
448
454
|
__metadata("design:type", Object)
|