@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
|
@@ -12,8 +12,6 @@ import { css, html } from 'lit';
|
|
|
12
12
|
import { customElement, state } from 'lit/decorators.js';
|
|
13
13
|
import { i18next, localize } from '@operato/i18n';
|
|
14
14
|
import { navigate, PageView } from '@operato/shell';
|
|
15
|
-
import { openOverlay } from '@operato/layout';
|
|
16
|
-
import { requestFigureAI } from '../modeller/figure-ai-target.js';
|
|
17
15
|
import { validate } from '@hatiolab/figure-model';
|
|
18
16
|
import * as edits from '../modeller/part-edits.js';
|
|
19
17
|
import * as proposals from '../modeller/proposal.js';
|
|
@@ -132,6 +130,9 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
|
|
|
132
130
|
this.saveFailure = '';
|
|
133
131
|
/** 인라인 요청창과 AI 도크가 함께 쓰는 현재 Figure의 제한된 세션 피드백. */
|
|
134
132
|
this.proposalFeedback = [];
|
|
133
|
+
this.undoStack = [];
|
|
134
|
+
this.redoStack = [];
|
|
135
|
+
this.lastHistoryTime = 0;
|
|
135
136
|
this.announceAiContext = () => {
|
|
136
137
|
if (!this.active)
|
|
137
138
|
return;
|
|
@@ -150,6 +151,24 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
|
|
|
150
151
|
this.receive(detail.proposal);
|
|
151
152
|
detail.accepted = true;
|
|
152
153
|
};
|
|
154
|
+
this.handleKeyDown = (e) => {
|
|
155
|
+
if (!this.active || this.mode !== 'edit' || this.proposalSession)
|
|
156
|
+
return;
|
|
157
|
+
const target = e.composedPath()[0];
|
|
158
|
+
const tag = target?.tagName?.toLowerCase();
|
|
159
|
+
if (tag === 'input' || tag === 'textarea' || target?.isContentEditable)
|
|
160
|
+
return;
|
|
161
|
+
if ((e.metaKey || e.ctrlKey) && !e.altKey) {
|
|
162
|
+
if (e.key === 'z' && !e.shiftKey) {
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
this.undo();
|
|
165
|
+
}
|
|
166
|
+
else if ((e.key === 'z' && e.shiftKey) || e.key === 'y') {
|
|
167
|
+
e.preventDefault();
|
|
168
|
+
this.redo();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
};
|
|
153
172
|
this.loadGeneration = 0;
|
|
154
173
|
}
|
|
155
174
|
static { this.styles = css `
|
|
@@ -223,90 +242,9 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
|
|
|
223
242
|
display: none;
|
|
224
243
|
}
|
|
225
244
|
|
|
226
|
-
/*
|
|
227
|
-
머리줄 — 이름 · 타입 · 미저장 표시 · 말로 시키는 줄 · 저장을 **한 줄에** 담는다.
|
|
228
|
-
|
|
229
|
-
이름은 페이지 제목에도 나온다(셸이 context.title 로 그린다). 그래도 여기 입력칸을 두는
|
|
230
|
-
이유는 **고치는 자리**가 필요해서다 — 제목은 읽는 것이고 이 칸은 바꾸는 것이다. 두 줄로
|
|
231
|
-
벌리는 대신 한 줄에 모아 캔버스에 높이를 넘긴다.
|
|
232
|
-
*/
|
|
233
|
-
div[header] {
|
|
234
|
-
display: flex;
|
|
235
|
-
align-items: center;
|
|
236
|
-
gap: var(--spacing-medium, 8px);
|
|
237
|
-
padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
|
|
238
|
-
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
239
|
-
}
|
|
240
|
-
button[ai-support] {
|
|
241
|
-
height: 28px;
|
|
242
|
-
padding: 0 12px;
|
|
243
|
-
border: 1px solid var(--md-sys-color-outline-variant);
|
|
244
|
-
border-radius: 6px;
|
|
245
|
-
background: var(--md-sys-color-primary-container);
|
|
246
|
-
color: var(--md-sys-color-on-primary-container);
|
|
247
|
-
font: inherit;
|
|
248
|
-
cursor: pointer;
|
|
249
|
-
}
|
|
250
|
-
input[name] {
|
|
251
|
-
flex: none;
|
|
252
|
-
width: 180px;
|
|
253
|
-
padding: 5px 9px;
|
|
254
|
-
font: var(--input-field-font, inherit);
|
|
255
|
-
font-size: 0.82rem;
|
|
256
|
-
color: var(--md-sys-color-on-surface);
|
|
257
|
-
background-color: var(--md-sys-color-surface-container-lowest);
|
|
258
|
-
border: 1px solid var(--md-sys-color-outline-variant);
|
|
259
|
-
border-radius: 6px;
|
|
260
|
-
}
|
|
261
|
-
/* 타입 이름은 만든 뒤 고칠 수 없다 — 그 사실이 화면에서 보여야 한다 */
|
|
262
|
-
code[type] {
|
|
263
|
-
padding: 4px 8px;
|
|
264
|
-
font-family: var(--mono-font, monospace);
|
|
265
|
-
font-size: 0.76rem;
|
|
266
|
-
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
267
|
-
background-color: var(--md-sys-color-surface-container);
|
|
268
|
-
border-radius: 6px;
|
|
269
|
-
}
|
|
270
|
-
span[dirty] {
|
|
271
|
-
font: var(--label-font, inherit);
|
|
272
|
-
font-size: 0.74rem;
|
|
273
|
-
color: var(--md-sys-color-tertiary);
|
|
274
|
-
}
|
|
275
245
|
div[spacer] {
|
|
276
246
|
flex: 1;
|
|
277
247
|
}
|
|
278
|
-
button[save] {
|
|
279
|
-
padding: 6px 16px;
|
|
280
|
-
font: var(--label-font, inherit);
|
|
281
|
-
font-size: 0.76rem;
|
|
282
|
-
font-weight: 600;
|
|
283
|
-
color: var(--md-sys-color-on-primary, #fff);
|
|
284
|
-
background-color: var(--md-sys-color-primary);
|
|
285
|
-
border: none;
|
|
286
|
-
border-radius: 6px;
|
|
287
|
-
cursor: pointer;
|
|
288
|
-
}
|
|
289
|
-
button[save][disabled] {
|
|
290
|
-
opacity: 0.4;
|
|
291
|
-
cursor: default;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/*
|
|
295
|
-
저장이 거절된 사유. 단추 **바로 왼쪽**에 둔다 — 누른 자리에서 답을 봐야 한다. 길면 줄이지만
|
|
296
|
-
전문은 title 로 남긴다(서버 문장이 길 수 있다).
|
|
297
|
-
*/
|
|
298
|
-
span[save-failed] {
|
|
299
|
-
max-width: 280px;
|
|
300
|
-
overflow: hidden;
|
|
301
|
-
text-overflow: ellipsis;
|
|
302
|
-
white-space: nowrap;
|
|
303
|
-
padding: 4px 8px;
|
|
304
|
-
font: var(--label-font, inherit);
|
|
305
|
-
font-size: 0.72rem;
|
|
306
|
-
color: var(--md-sys-color-on-error-container, var(--md-sys-color-on-surface));
|
|
307
|
-
background-color: var(--md-sys-color-error-container);
|
|
308
|
-
border-radius: 6px;
|
|
309
|
-
}
|
|
310
248
|
|
|
311
249
|
/*
|
|
312
250
|
칸을 벡터 저작도구의 관례대로 놓는다 — 왼쪽에 무엇이 있나(레이어), 오른쪽에
|
|
@@ -329,16 +267,17 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
|
|
|
329
267
|
}
|
|
330
268
|
|
|
331
269
|
/*
|
|
332
|
-
편집 · 미리보기
|
|
270
|
+
편집 · 미리보기 전환 및 조작 띠.
|
|
333
271
|
|
|
334
272
|
가운데 칸 위에 얇게 둔다. 무엇을 보고 있는지가 늘 보여야 하고, 두 화면이 같은
|
|
335
273
|
자리를 쓰므로 어느 쪽인지 모르면 편집이 안 먹는 것처럼 읽힌다.
|
|
336
274
|
*/
|
|
337
275
|
div[modes] {
|
|
338
276
|
display: flex;
|
|
277
|
+
align-items: center;
|
|
339
278
|
flex: none;
|
|
340
279
|
gap: 2px;
|
|
341
|
-
padding:
|
|
280
|
+
padding: 4px var(--spacing-medium, 8px);
|
|
342
281
|
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
343
282
|
}
|
|
344
283
|
div[modes] button[mode] {
|
|
@@ -359,13 +298,36 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
|
|
|
359
298
|
color: var(--md-sys-color-on-primary-container);
|
|
360
299
|
background-color: var(--md-sys-color-primary-container);
|
|
361
300
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
301
|
+
div[modes] div[separator] {
|
|
302
|
+
width: 1px;
|
|
303
|
+
height: 16px;
|
|
304
|
+
margin: 0 4px;
|
|
305
|
+
background-color: var(--md-sys-color-outline-variant);
|
|
306
|
+
}
|
|
307
|
+
div[modes] button[history] {
|
|
308
|
+
display: flex;
|
|
309
|
+
align-items: center;
|
|
310
|
+
justify-content: center;
|
|
311
|
+
width: 26px;
|
|
312
|
+
height: 26px;
|
|
313
|
+
padding: 0;
|
|
314
|
+
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
315
|
+
background: none;
|
|
316
|
+
border: none;
|
|
317
|
+
border-radius: 6px;
|
|
318
|
+
cursor: pointer;
|
|
319
|
+
}
|
|
320
|
+
div[modes] button[history]:hover:not([disabled]) {
|
|
321
|
+
background-color: var(--md-sys-color-surface-container);
|
|
322
|
+
color: var(--md-sys-color-on-surface);
|
|
323
|
+
}
|
|
324
|
+
div[modes] button[history][disabled] {
|
|
325
|
+
opacity: 0.3;
|
|
326
|
+
cursor: default;
|
|
327
|
+
}
|
|
328
|
+
div[modes] button[history] md-icon {
|
|
329
|
+
--md-icon-size: 18px;
|
|
330
|
+
}
|
|
369
331
|
div[modes] button[collapse] {
|
|
370
332
|
display: flex;
|
|
371
333
|
align-items: center;
|
|
@@ -383,6 +345,46 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
|
|
|
383
345
|
div[modes] button[collapse] md-icon {
|
|
384
346
|
--md-icon-size: 18px;
|
|
385
347
|
}
|
|
348
|
+
div[modes] span[dirty] {
|
|
349
|
+
align-self: center;
|
|
350
|
+
margin-right: 6px;
|
|
351
|
+
font: var(--label-font, inherit);
|
|
352
|
+
font-size: 0.72rem;
|
|
353
|
+
color: var(--md-sys-color-tertiary);
|
|
354
|
+
}
|
|
355
|
+
div[modes] span[save-failed] {
|
|
356
|
+
align-self: center;
|
|
357
|
+
max-width: 240px;
|
|
358
|
+
overflow: hidden;
|
|
359
|
+
text-overflow: ellipsis;
|
|
360
|
+
white-space: nowrap;
|
|
361
|
+
margin-right: 6px;
|
|
362
|
+
padding: 3px 7px;
|
|
363
|
+
font: var(--label-font, inherit);
|
|
364
|
+
font-size: 0.7rem;
|
|
365
|
+
color: var(--md-sys-color-on-error-container, var(--md-sys-color-on-surface));
|
|
366
|
+
background-color: var(--md-sys-color-error-container);
|
|
367
|
+
border-radius: 4px;
|
|
368
|
+
}
|
|
369
|
+
div[modes] button[save] {
|
|
370
|
+
padding: 4px 14px;
|
|
371
|
+
margin-right: 4px;
|
|
372
|
+
font: var(--label-font, inherit);
|
|
373
|
+
font-size: 0.74rem;
|
|
374
|
+
font-weight: 600;
|
|
375
|
+
color: var(--md-sys-color-on-primary, #fff);
|
|
376
|
+
background-color: var(--md-sys-color-primary);
|
|
377
|
+
border: none;
|
|
378
|
+
border-radius: 6px;
|
|
379
|
+
cursor: pointer;
|
|
380
|
+
}
|
|
381
|
+
div[modes] button[save]:hover:not([disabled]) {
|
|
382
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
|
383
|
+
}
|
|
384
|
+
div[modes] button[save][disabled] {
|
|
385
|
+
opacity: 0.35;
|
|
386
|
+
cursor: default;
|
|
387
|
+
}
|
|
386
388
|
|
|
387
389
|
/*
|
|
388
390
|
후보가 오면 가운데를 둘로 나눈다.
|
|
@@ -600,8 +602,10 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
|
|
|
600
602
|
super.connectedCallback();
|
|
601
603
|
window.addEventListener('figure-ai-context-request', this.announceAiContext);
|
|
602
604
|
window.addEventListener('figure-ai-review', this.acceptDockProposal);
|
|
605
|
+
window.addEventListener('keydown', this.handleKeyDown);
|
|
603
606
|
}
|
|
604
607
|
disconnectedCallback() {
|
|
608
|
+
window.removeEventListener('keydown', this.handleKeyDown);
|
|
605
609
|
window.removeEventListener('figure-ai-context-request', this.announceAiContext);
|
|
606
610
|
window.removeEventListener('figure-ai-review', this.acceptDockProposal);
|
|
607
611
|
window.dispatchEvent(new CustomEvent('figure-ai-context', { detail: undefined }));
|
|
@@ -626,27 +630,6 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
|
|
|
626
630
|
render() {
|
|
627
631
|
const source = this.folded;
|
|
628
632
|
return html `
|
|
629
|
-
<div header>
|
|
630
|
-
<!--
|
|
631
|
-
이름과 타입은 **속성 패널**에 있다(figure-inspector 의 도형 절). 여기 있던 이름 칸은
|
|
632
|
-
하네스 시절의 자리였다 — 앱에서는 셸이 페이지 제목으로 같은 이름을 그리므로 글자가 두 번
|
|
633
|
-
나오고, 캔버스가 주인공인 화면에서 그만큼 높이를 잃었다.
|
|
634
|
-
-->
|
|
635
|
-
${this.dirty ? html `<span dirty>${i18next.t('figure.text.unsaved-changes')}</span>` : ''}
|
|
636
|
-
<!--
|
|
637
|
-
말로 시키는 줄이 머리줄의 남는 폭을 쓴다. 따로 두면 화면 위가 두 줄이 되고, 그 위 줄은
|
|
638
|
-
이름을 페이지 제목과 겹쳐 적는다.
|
|
639
|
-
-->
|
|
640
|
-
<button ai-support @click=${() => {
|
|
641
|
-
requestFigureAI({ figureId: this.figure?.id, source: this.folded });
|
|
642
|
-
openOverlay('figure-ai-dock', { backdrop: false });
|
|
643
|
-
}}>AI 지원</button>
|
|
644
|
-
${this.saveFailure ? html `<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
|
|
645
|
-
<button save ?disabled=${!this.canSave} @click=${() => this.save()}>
|
|
646
|
-
${this.saving ? i18next.t('figure.text.saving-figure') : i18next.t('figure.button.save')}
|
|
647
|
-
</button>
|
|
648
|
-
</div>
|
|
649
|
-
|
|
650
633
|
<div
|
|
651
634
|
edits
|
|
652
635
|
?collapse-left=${this.collapsed.left}
|
|
@@ -687,6 +670,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
|
|
|
687
670
|
<div lane>
|
|
688
671
|
<h4>${i18next.t('figure.label.now')}</h4>
|
|
689
672
|
<figure-canvas
|
|
673
|
+
.figureId=${this.figure?.id || this.figure?.type}
|
|
690
674
|
.board=${this.board}
|
|
691
675
|
.parts=${this.parts}
|
|
692
676
|
.picked=${this.pickedName}
|
|
@@ -711,9 +695,11 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
|
|
|
711
695
|
: this.mode === 'preview'
|
|
712
696
|
? html `<figure-preview .source=${source}></figure-preview>`
|
|
713
697
|
: html `<figure-canvas
|
|
698
|
+
.figureId=${this.figure?.id || this.figure?.type}
|
|
714
699
|
.board=${this.board}
|
|
715
700
|
.parts=${this.parts}
|
|
716
701
|
.picked=${this.pickedName}
|
|
702
|
+
.view=${this.view}
|
|
717
703
|
></figure-canvas>`}
|
|
718
704
|
</div>
|
|
719
705
|
|
|
@@ -765,7 +751,33 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
|
|
|
765
751
|
`;
|
|
766
752
|
return html `<div modes>
|
|
767
753
|
${this.renderCollapse('left')}${this.proposalSession ? '' : html `${one('edit')}${one('preview')}`}
|
|
754
|
+
${this.mode === 'edit' && !this.proposalSession
|
|
755
|
+
? html `
|
|
756
|
+
<div separator></div>
|
|
757
|
+
<button
|
|
758
|
+
history
|
|
759
|
+
?disabled=${this.undoStack.length === 0}
|
|
760
|
+
title="${i18next.t('figure.button.undo')} (Ctrl+Z)"
|
|
761
|
+
@click=${() => this.undo()}
|
|
762
|
+
>
|
|
763
|
+
<md-icon>undo</md-icon>
|
|
764
|
+
</button>
|
|
765
|
+
<button
|
|
766
|
+
history
|
|
767
|
+
?disabled=${this.redoStack.length === 0}
|
|
768
|
+
title="${i18next.t('figure.button.redo')} (Ctrl+Y)"
|
|
769
|
+
@click=${() => this.redo()}
|
|
770
|
+
>
|
|
771
|
+
<md-icon>redo</md-icon>
|
|
772
|
+
</button>
|
|
773
|
+
`
|
|
774
|
+
: ''}
|
|
768
775
|
<div spacer></div>
|
|
776
|
+
${this.saveFailure ? html `<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
|
|
777
|
+
${this.dirty ? html `<span dirty>${i18next.t('figure.text.unsaved-changes')}</span>` : ''}
|
|
778
|
+
<button save ?disabled=${!this.canSave} @click=${() => this.save()}>
|
|
779
|
+
${this.saving ? i18next.t('figure.text.saving-figure') : i18next.t('figure.button.save')}
|
|
780
|
+
</button>
|
|
769
781
|
${this.renderCollapse('right')}
|
|
770
782
|
</div>`;
|
|
771
783
|
}
|
|
@@ -956,6 +968,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
|
|
|
956
968
|
return;
|
|
957
969
|
if (this.proposalBaseSource !== undefined && this.proposalBaseSource !== JSON.stringify(this.folded))
|
|
958
970
|
return;
|
|
971
|
+
this.recordHistory();
|
|
959
972
|
const taken = proposals.applyProposal(this.folded, this.proposalSession.source, this.proposalSession.picked);
|
|
960
973
|
const { board, parts } = fromFigureSource(taken);
|
|
961
974
|
this.board = board;
|
|
@@ -1020,7 +1033,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
|
|
|
1020
1033
|
}
|
|
1021
1034
|
const id = lifecycle?.resourceId;
|
|
1022
1035
|
if (id) {
|
|
1023
|
-
if (id !== this.figure?.id) {
|
|
1036
|
+
if (changes.active || id !== this.figure?.id) {
|
|
1024
1037
|
await this.load(id);
|
|
1025
1038
|
}
|
|
1026
1039
|
return;
|
|
@@ -1037,6 +1050,14 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
|
|
|
1037
1050
|
들고 있는 것이 **저장된 도형이면**(id 가 있으면) 새로 시작한다. 아직 저장 안 한 새 도형을
|
|
1038
1051
|
들고 있으면 그대로 둔다 — 페이지를 다시 들렀다고 저작 중인 것을 버리지 않는다.
|
|
1039
1052
|
*/
|
|
1053
|
+
if (!this.figure) {
|
|
1054
|
+
this.startNew();
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
if (this.figure.id) {
|
|
1058
|
+
this.startNew();
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1040
1061
|
if (shouldStartNewFigure(changes, !!this.figure, this.figure?.id)) {
|
|
1041
1062
|
this.startNew();
|
|
1042
1063
|
return;
|
|
@@ -1079,6 +1100,9 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
|
|
|
1079
1100
|
this.selected = -1;
|
|
1080
1101
|
this.dirty = false;
|
|
1081
1102
|
this.violations = [];
|
|
1103
|
+
this.undoStack = [];
|
|
1104
|
+
this.redoStack = [];
|
|
1105
|
+
this.lastCoalesceKey = undefined;
|
|
1082
1106
|
}
|
|
1083
1107
|
startNew() {
|
|
1084
1108
|
++this.loadGeneration;
|
|
@@ -1096,6 +1120,9 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
|
|
|
1096
1120
|
this.selected = -1;
|
|
1097
1121
|
this.dirty = true;
|
|
1098
1122
|
this.violations = [];
|
|
1123
|
+
this.undoStack = [];
|
|
1124
|
+
this.redoStack = [];
|
|
1125
|
+
this.lastCoalesceKey = undefined;
|
|
1099
1126
|
}
|
|
1100
1127
|
rename(name) {
|
|
1101
1128
|
if (!this.figure)
|
|
@@ -1115,13 +1142,56 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
|
|
|
1115
1142
|
this.figure = { ...this.figure, ...patch };
|
|
1116
1143
|
this.dirty = true;
|
|
1117
1144
|
}
|
|
1145
|
+
snapshotState() {
|
|
1146
|
+
return {
|
|
1147
|
+
board: this.board ? structuredClone(this.board) : undefined,
|
|
1148
|
+
parts: structuredClone(this.parts),
|
|
1149
|
+
selected: this.selected
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
recordHistory(coalesceKey) {
|
|
1153
|
+
const now = Date.now();
|
|
1154
|
+
if (coalesceKey && this.lastCoalesceKey === coalesceKey && now - this.lastHistoryTime < 600) {
|
|
1155
|
+
this.lastHistoryTime = now;
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
this.lastCoalesceKey = coalesceKey;
|
|
1159
|
+
this.lastHistoryTime = now;
|
|
1160
|
+
this.undoStack = [...this.undoStack.slice(-49), this.snapshotState()];
|
|
1161
|
+
this.redoStack = [];
|
|
1162
|
+
}
|
|
1163
|
+
undo() {
|
|
1164
|
+
if (this.undoStack.length === 0)
|
|
1165
|
+
return;
|
|
1166
|
+
const prev = this.undoStack[this.undoStack.length - 1];
|
|
1167
|
+
this.undoStack = this.undoStack.slice(0, -1);
|
|
1168
|
+
this.redoStack = [this.snapshotState(), ...this.redoStack.slice(0, 49)];
|
|
1169
|
+
this.board = prev.board ? structuredClone(prev.board) : undefined;
|
|
1170
|
+
this.parts = structuredClone(prev.parts);
|
|
1171
|
+
this.selected = this.parts.length ? Math.max(-1, Math.min(prev.selected, this.parts.length - 1)) : -1;
|
|
1172
|
+
this.dirty = true;
|
|
1173
|
+
this.lastCoalesceKey = undefined;
|
|
1174
|
+
}
|
|
1175
|
+
redo() {
|
|
1176
|
+
if (this.redoStack.length === 0)
|
|
1177
|
+
return;
|
|
1178
|
+
const next = this.redoStack[0];
|
|
1179
|
+
this.redoStack = this.redoStack.slice(1);
|
|
1180
|
+
this.undoStack = [...this.undoStack.slice(-49), this.snapshotState()];
|
|
1181
|
+
this.board = next.board ? structuredClone(next.board) : undefined;
|
|
1182
|
+
this.parts = structuredClone(next.parts);
|
|
1183
|
+
this.selected = this.parts.length ? Math.max(-1, Math.min(next.selected, this.parts.length - 1)) : -1;
|
|
1184
|
+
this.dirty = true;
|
|
1185
|
+
this.lastCoalesceKey = undefined;
|
|
1186
|
+
}
|
|
1118
1187
|
/**
|
|
1119
1188
|
* 부품 목록을 갈아 끼운다.
|
|
1120
1189
|
*
|
|
1121
1190
|
* 편집 규칙은 `part-edits` 가 갖는다 — 화면과 테스트가 같은 것을 쓰게 하려고 떼어
|
|
1122
1191
|
* 놓았다. 여기서는 결과를 담고 선택 인덱스를 정리한다.
|
|
1123
1192
|
*/
|
|
1124
|
-
put(parts, selected = this.selected) {
|
|
1193
|
+
put(parts, selected = this.selected, coalesceKey) {
|
|
1194
|
+
this.recordHistory(coalesceKey);
|
|
1125
1195
|
this.parts = parts;
|
|
1126
1196
|
this.selected = Math.min(selected, parts.length - 1);
|
|
1127
1197
|
this.dirty = true;
|
|
@@ -1142,7 +1212,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
|
|
|
1142
1212
|
this.put(edits.movePart(this.parts, index, to), to);
|
|
1143
1213
|
}
|
|
1144
1214
|
updatePart(index, part) {
|
|
1145
|
-
this.put(edits.updatePart(this.parts, index, part));
|
|
1215
|
+
this.put(edits.updatePart(this.parts, index, part), this.selected, `part:${part.name || index}`);
|
|
1146
1216
|
}
|
|
1147
1217
|
/**
|
|
1148
1218
|
* 판 자체의 값을 갈아 끼운다 — 능력처럼 부품보다 위에 있는 것들.
|
|
@@ -1151,6 +1221,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
|
|
|
1151
1221
|
* 고칠 때마다 부품 목록이 새 배열이 되고, 그리는 쪽이 전부 다시 그린다.
|
|
1152
1222
|
*/
|
|
1153
1223
|
updateBoard(board) {
|
|
1224
|
+
this.recordHistory('board');
|
|
1154
1225
|
this.board = board;
|
|
1155
1226
|
this.dirty = true;
|
|
1156
1227
|
}
|
|
@@ -1167,6 +1238,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
|
|
|
1167
1238
|
changeDetailLevel(level) {
|
|
1168
1239
|
if (!this.board)
|
|
1169
1240
|
return;
|
|
1241
|
+
this.recordHistory('detailLevel');
|
|
1170
1242
|
this.board = { ...this.board, detailLevel: level };
|
|
1171
1243
|
this.dirty = true;
|
|
1172
1244
|
}
|
|
@@ -1203,7 +1275,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
|
|
|
1203
1275
|
partChanged(name, part) {
|
|
1204
1276
|
const at = this.parts.findIndex(item => item.name === name);
|
|
1205
1277
|
if (at >= 0)
|
|
1206
|
-
this.put(edits.updatePart(this.parts, at, part));
|
|
1278
|
+
this.put(edits.updatePart(this.parts, at, part), this.selected, `drag:${name}`);
|
|
1207
1279
|
}
|
|
1208
1280
|
/**
|
|
1209
1281
|
* 정밀 배치 도구.
|
|
@@ -1405,6 +1477,14 @@ __decorate([
|
|
|
1405
1477
|
state(),
|
|
1406
1478
|
__metadata("design:type", Array)
|
|
1407
1479
|
], FigureModellerPage.prototype, "proposalFeedback", void 0);
|
|
1480
|
+
__decorate([
|
|
1481
|
+
state(),
|
|
1482
|
+
__metadata("design:type", Array)
|
|
1483
|
+
], FigureModellerPage.prototype, "undoStack", void 0);
|
|
1484
|
+
__decorate([
|
|
1485
|
+
state(),
|
|
1486
|
+
__metadata("design:type", Array)
|
|
1487
|
+
], FigureModellerPage.prototype, "redoStack", void 0);
|
|
1408
1488
|
FigureModellerPage = __decorate([
|
|
1409
1489
|
customElement('figure-modeller-page')
|
|
1410
1490
|
], FigureModellerPage);
|