@things-factory/figure-ui 10.1.10 → 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-ai-target.ts +11 -0
- package/client/modeller/figure-ask.ts +8 -5
- package/client/modeller/figure-canvas.ts +10 -4
- package/client/modeller/new-figure-entry.ts +4 -0
- package/client/pages/figure-modeller-page.ts +241 -120
- package/dist-client/modeller/figure-ai-target.d.ts +8 -0
- package/dist-client/modeller/figure-ai-target.js +12 -0
- package/dist-client/modeller/figure-ai-target.js.map +1 -0
- package/dist-client/modeller/figure-ask.d.ts +1 -0
- package/dist-client/modeller/figure-ask.js +12 -5
- package/dist-client/modeller/figure-ask.js.map +1 -1
- 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/modeller/new-figure-entry.d.ts +2 -0
- package/dist-client/modeller/new-figure-entry.js +5 -0
- package/dist-client/modeller/new-figure-entry.js.map +1 -0
- package/dist-client/pages/figure-modeller-page.d.ts +14 -0
- package/dist-client/pages/figure-modeller-page.js +240 -120
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/test/ai-proposal-contract.test.ts +24 -3
- package/test/ai-target.test.ts +25 -0
- package/test/modeller-undo-redo.test.ts +33 -0
- package/test/new-figure-entry.test.ts +15 -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
|
@@ -4,6 +4,7 @@ import '../modeller/figure-ask.js'
|
|
|
4
4
|
import '../modeller/figure-canvas.js'
|
|
5
5
|
import { captureThumbnail } from '../modeller/figure-thumbnail.js'
|
|
6
6
|
import '../modeller/figure-preview.js'
|
|
7
|
+
import { shouldStartNewFigure } from '../modeller/new-figure-entry.js'
|
|
7
8
|
import '../modeller/figure-parts.js'
|
|
8
9
|
import '../modeller/figure-inspector.js'
|
|
9
10
|
|
|
@@ -12,6 +13,8 @@ import { customElement, state } from 'lit/decorators.js'
|
|
|
12
13
|
|
|
13
14
|
import { i18next, localize } from '@operato/i18n'
|
|
14
15
|
import { navigate, PageView } from '@operato/shell'
|
|
16
|
+
import { openOverlay } from '@operato/layout'
|
|
17
|
+
import { requestFigureAI } from '../modeller/figure-ai-target.js'
|
|
15
18
|
import { validate } from '@hatiolab/figure-model'
|
|
16
19
|
import type { DetailLevel, FigureSource, PrimitiveKind } from '@hatiolab/figure-model'
|
|
17
20
|
|
|
@@ -185,84 +188,9 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
185
188
|
display: none;
|
|
186
189
|
}
|
|
187
190
|
|
|
188
|
-
/*
|
|
189
|
-
머리줄 — 이름 · 타입 · 미저장 표시 · 말로 시키는 줄 · 저장을 **한 줄에** 담는다.
|
|
190
|
-
|
|
191
|
-
이름은 페이지 제목에도 나온다(셸이 context.title 로 그린다). 그래도 여기 입력칸을 두는
|
|
192
|
-
이유는 **고치는 자리**가 필요해서다 — 제목은 읽는 것이고 이 칸은 바꾸는 것이다. 두 줄로
|
|
193
|
-
벌리는 대신 한 줄에 모아 캔버스에 높이를 넘긴다.
|
|
194
|
-
*/
|
|
195
|
-
div[header] {
|
|
196
|
-
display: flex;
|
|
197
|
-
align-items: center;
|
|
198
|
-
gap: var(--spacing-medium, 8px);
|
|
199
|
-
padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
|
|
200
|
-
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
201
|
-
}
|
|
202
|
-
div[header] figure-ask {
|
|
203
|
-
flex: 1;
|
|
204
|
-
min-width: 0;
|
|
205
|
-
}
|
|
206
|
-
input[name] {
|
|
207
|
-
flex: none;
|
|
208
|
-
width: 180px;
|
|
209
|
-
padding: 5px 9px;
|
|
210
|
-
font: var(--input-field-font, inherit);
|
|
211
|
-
font-size: 0.82rem;
|
|
212
|
-
color: var(--md-sys-color-on-surface);
|
|
213
|
-
background-color: var(--md-sys-color-surface-container-lowest);
|
|
214
|
-
border: 1px solid var(--md-sys-color-outline-variant);
|
|
215
|
-
border-radius: 6px;
|
|
216
|
-
}
|
|
217
|
-
/* 타입 이름은 만든 뒤 고칠 수 없다 — 그 사실이 화면에서 보여야 한다 */
|
|
218
|
-
code[type] {
|
|
219
|
-
padding: 4px 8px;
|
|
220
|
-
font-family: var(--mono-font, monospace);
|
|
221
|
-
font-size: 0.76rem;
|
|
222
|
-
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
223
|
-
background-color: var(--md-sys-color-surface-container);
|
|
224
|
-
border-radius: 6px;
|
|
225
|
-
}
|
|
226
|
-
span[dirty] {
|
|
227
|
-
font: var(--label-font, inherit);
|
|
228
|
-
font-size: 0.74rem;
|
|
229
|
-
color: var(--md-sys-color-tertiary);
|
|
230
|
-
}
|
|
231
191
|
div[spacer] {
|
|
232
192
|
flex: 1;
|
|
233
193
|
}
|
|
234
|
-
button[save] {
|
|
235
|
-
padding: 6px 16px;
|
|
236
|
-
font: var(--label-font, inherit);
|
|
237
|
-
font-size: 0.76rem;
|
|
238
|
-
font-weight: 600;
|
|
239
|
-
color: var(--md-sys-color-on-primary, #fff);
|
|
240
|
-
background-color: var(--md-sys-color-primary);
|
|
241
|
-
border: none;
|
|
242
|
-
border-radius: 6px;
|
|
243
|
-
cursor: pointer;
|
|
244
|
-
}
|
|
245
|
-
button[save][disabled] {
|
|
246
|
-
opacity: 0.4;
|
|
247
|
-
cursor: default;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/*
|
|
251
|
-
저장이 거절된 사유. 단추 **바로 왼쪽**에 둔다 — 누른 자리에서 답을 봐야 한다. 길면 줄이지만
|
|
252
|
-
전문은 title 로 남긴다(서버 문장이 길 수 있다).
|
|
253
|
-
*/
|
|
254
|
-
span[save-failed] {
|
|
255
|
-
max-width: 280px;
|
|
256
|
-
overflow: hidden;
|
|
257
|
-
text-overflow: ellipsis;
|
|
258
|
-
white-space: nowrap;
|
|
259
|
-
padding: 4px 8px;
|
|
260
|
-
font: var(--label-font, inherit);
|
|
261
|
-
font-size: 0.72rem;
|
|
262
|
-
color: var(--md-sys-color-on-error-container, var(--md-sys-color-on-surface));
|
|
263
|
-
background-color: var(--md-sys-color-error-container);
|
|
264
|
-
border-radius: 6px;
|
|
265
|
-
}
|
|
266
194
|
|
|
267
195
|
/*
|
|
268
196
|
칸을 벡터 저작도구의 관례대로 놓는다 — 왼쪽에 무엇이 있나(레이어), 오른쪽에
|
|
@@ -285,16 +213,17 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
285
213
|
}
|
|
286
214
|
|
|
287
215
|
/*
|
|
288
|
-
편집 · 미리보기
|
|
216
|
+
편집 · 미리보기 전환 및 조작 띠.
|
|
289
217
|
|
|
290
218
|
가운데 칸 위에 얇게 둔다. 무엇을 보고 있는지가 늘 보여야 하고, 두 화면이 같은
|
|
291
219
|
자리를 쓰므로 어느 쪽인지 모르면 편집이 안 먹는 것처럼 읽힌다.
|
|
292
220
|
*/
|
|
293
221
|
div[modes] {
|
|
294
222
|
display: flex;
|
|
223
|
+
align-items: center;
|
|
295
224
|
flex: none;
|
|
296
225
|
gap: 2px;
|
|
297
|
-
padding:
|
|
226
|
+
padding: 4px var(--spacing-medium, 8px);
|
|
298
227
|
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
299
228
|
}
|
|
300
229
|
div[modes] button[mode] {
|
|
@@ -315,13 +244,36 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
315
244
|
color: var(--md-sys-color-on-primary-container);
|
|
316
245
|
background-color: var(--md-sys-color-primary-container);
|
|
317
246
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
+
}
|
|
325
277
|
div[modes] button[collapse] {
|
|
326
278
|
display: flex;
|
|
327
279
|
align-items: center;
|
|
@@ -339,6 +291,46 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
339
291
|
div[modes] button[collapse] md-icon {
|
|
340
292
|
--md-icon-size: 18px;
|
|
341
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
|
+
}
|
|
342
334
|
|
|
343
335
|
/*
|
|
344
336
|
후보가 오면 가운데를 둘로 나눈다.
|
|
@@ -571,6 +563,11 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
571
563
|
/** 인라인 요청창과 AI 도크가 함께 쓰는 현재 Figure의 제한된 세션 피드백. */
|
|
572
564
|
@state() private proposalFeedback: ProposalFeedback[] = []
|
|
573
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
|
+
|
|
574
571
|
/**
|
|
575
572
|
* 저장 형식으로 변환한 것. 씬 모델이 바뀔 때만 다시 만든다.
|
|
576
573
|
*
|
|
@@ -609,11 +606,56 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
609
606
|
}
|
|
610
607
|
}
|
|
611
608
|
|
|
609
|
+
private announceAiContext = () => {
|
|
610
|
+
if (!this.active) return
|
|
611
|
+
window.dispatchEvent(new CustomEvent('figure-ai-context', {
|
|
612
|
+
detail: { figureId: this.figure?.id, source: this.folded }
|
|
613
|
+
}))
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private acceptDockProposal = (event: Event) => {
|
|
617
|
+
const detail = (event as CustomEvent).detail
|
|
618
|
+
if (!this.active || (detail?.figureId ?? '') !== (this.figure?.id ?? '')) return
|
|
619
|
+
if (detail?.draftType && detail.draftType !== this.folded?.type) return
|
|
620
|
+
if (detail?.baseSource && detail.baseSource !== JSON.stringify(this.folded)) return
|
|
621
|
+
this.receive(detail.proposal)
|
|
622
|
+
detail.accepted = true
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private proposalBaseSource?: string
|
|
626
|
+
|
|
627
|
+
connectedCallback() {
|
|
628
|
+
super.connectedCallback()
|
|
629
|
+
window.addEventListener('figure-ai-context-request', this.announceAiContext)
|
|
630
|
+
window.addEventListener('figure-ai-review', this.acceptDockProposal)
|
|
631
|
+
window.addEventListener('keydown', this.handleKeyDown)
|
|
632
|
+
}
|
|
633
|
+
|
|
612
634
|
disconnectedCallback() {
|
|
635
|
+
window.removeEventListener('keydown', this.handleKeyDown)
|
|
636
|
+
window.removeEventListener('figure-ai-context-request', this.announceAiContext)
|
|
637
|
+
window.removeEventListener('figure-ai-review', this.acceptDockProposal)
|
|
613
638
|
window.dispatchEvent(new CustomEvent('figure-ai-context', { detail: undefined }))
|
|
614
639
|
super.disconnectedCallback()
|
|
615
640
|
}
|
|
616
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
|
+
|
|
617
659
|
get context() {
|
|
618
660
|
return {
|
|
619
661
|
title: this.figure?.name ?? i18next.t('figure.title.new-figure'),
|
|
@@ -635,29 +677,6 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
635
677
|
const source = this.folded
|
|
636
678
|
|
|
637
679
|
return html`
|
|
638
|
-
<div header>
|
|
639
|
-
<!--
|
|
640
|
-
이름과 타입은 **속성 패널**에 있다(figure-inspector 의 도형 절). 여기 있던 이름 칸은
|
|
641
|
-
하네스 시절의 자리였다 — 앱에서는 셸이 페이지 제목으로 같은 이름을 그리므로 글자가 두 번
|
|
642
|
-
나오고, 캔버스가 주인공인 화면에서 그만큼 높이를 잃었다.
|
|
643
|
-
-->
|
|
644
|
-
${this.dirty ? html`<span dirty>${i18next.t('figure.text.unsaved-changes')}</span>` : ''}
|
|
645
|
-
<!--
|
|
646
|
-
말로 시키는 줄이 머리줄의 남는 폭을 쓴다. 따로 두면 화면 위가 두 줄이 되고, 그 위 줄은
|
|
647
|
-
이름을 페이지 제목과 겹쳐 적는다.
|
|
648
|
-
-->
|
|
649
|
-
<figure-ask
|
|
650
|
-
.source=${source}
|
|
651
|
-
.type=${source?.type ?? ''}
|
|
652
|
-
.feedback=${this.proposalFeedback}
|
|
653
|
-
@proposed=${(e: CustomEvent) => this.receive(e.detail.proposal)}
|
|
654
|
-
></figure-ask>
|
|
655
|
-
${this.saveFailure ? html`<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
|
|
656
|
-
<button save ?disabled=${!this.canSave} @click=${() => this.save()}>
|
|
657
|
-
${this.saving ? i18next.t('figure.text.saving-figure') : i18next.t('figure.button.save')}
|
|
658
|
-
</button>
|
|
659
|
-
</div>
|
|
660
|
-
|
|
661
680
|
<div
|
|
662
681
|
edits
|
|
663
682
|
?collapse-left=${this.collapsed.left}
|
|
@@ -698,6 +717,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
698
717
|
<div lane>
|
|
699
718
|
<h4>${i18next.t('figure.label.now')}</h4>
|
|
700
719
|
<figure-canvas
|
|
720
|
+
.figureId=${this.figure?.id || this.figure?.type}
|
|
701
721
|
.board=${this.board}
|
|
702
722
|
.parts=${this.parts}
|
|
703
723
|
.picked=${this.pickedName}
|
|
@@ -722,9 +742,11 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
722
742
|
: this.mode === 'preview'
|
|
723
743
|
? html`<figure-preview .source=${source}></figure-preview>`
|
|
724
744
|
: html`<figure-canvas
|
|
745
|
+
.figureId=${this.figure?.id || this.figure?.type}
|
|
725
746
|
.board=${this.board}
|
|
726
747
|
.parts=${this.parts}
|
|
727
748
|
.picked=${this.pickedName}
|
|
749
|
+
.view=${this.view}
|
|
728
750
|
></figure-canvas>`}
|
|
729
751
|
</div>
|
|
730
752
|
|
|
@@ -778,7 +800,33 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
778
800
|
|
|
779
801
|
return html`<div modes>
|
|
780
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
|
+
: ''}
|
|
781
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>
|
|
782
830
|
${this.renderCollapse('right')}
|
|
783
831
|
</div>`
|
|
784
832
|
}
|
|
@@ -842,6 +890,8 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
842
890
|
private renderDecision() {
|
|
843
891
|
if (!this.proposalSession) return ''
|
|
844
892
|
|
|
893
|
+
const stale = this.proposalBaseSource !== undefined && this.proposalBaseSource !== JSON.stringify(this.folded)
|
|
894
|
+
|
|
845
895
|
const changes = proposals.diffProposal(this.folded, this.proposalSession.source)
|
|
846
896
|
const picked = proposals.applyProposal(this.folded, this.proposalSession.source, this.proposalSession.picked)
|
|
847
897
|
const { errors, violations } = validate(picked)
|
|
@@ -851,7 +901,8 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
851
901
|
<span>${this.proposalSession.title || i18next.t('figure.text.assistant-proposed-a-figure')}</span>
|
|
852
902
|
<div spacer></div>
|
|
853
903
|
<button drop @click=${() => this.discard()}>${i18next.t('figure.button.discard')}</button>
|
|
854
|
-
|
|
904
|
+
${stale ? html`<span role="alert">원본이 변경되었습니다. 후보를 취소하고 AI 지원에서 다시 요청하세요.</span>` : ''}
|
|
905
|
+
<button take ?disabled=${stale || errors.length > 0 || this.proposalSession.picked.size === 0} @click=${() => this.take()}>
|
|
855
906
|
${i18next.t('figure.button.take-n-changes', { n: this.proposalSession.picked.size })}
|
|
856
907
|
</button>
|
|
857
908
|
</div>
|
|
@@ -941,6 +992,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
941
992
|
* 무엇이 자기 것이고 무엇이 기계 것인지 모르게 된다.
|
|
942
993
|
*/
|
|
943
994
|
private receive(proposal: FigureProposal) {
|
|
995
|
+
this.proposalBaseSource = JSON.stringify(this.folded)
|
|
944
996
|
this.proposalSession = proposalSessions.openAiProposal(this.folded, proposal)
|
|
945
997
|
}
|
|
946
998
|
|
|
@@ -951,6 +1003,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
951
1003
|
*/
|
|
952
1004
|
private previewSizingFix(fix: FigureGateFix) {
|
|
953
1005
|
if (!this.folded) return
|
|
1006
|
+
this.proposalBaseSource = JSON.stringify(this.folded)
|
|
954
1007
|
this.proposalSession = proposalSessions.openSizingFix(this.folded, fix)
|
|
955
1008
|
}
|
|
956
1009
|
|
|
@@ -973,7 +1026,9 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
973
1026
|
*/
|
|
974
1027
|
private take() {
|
|
975
1028
|
if (!this.proposalSession) return
|
|
1029
|
+
if (this.proposalBaseSource !== undefined && this.proposalBaseSource !== JSON.stringify(this.folded)) return
|
|
976
1030
|
|
|
1031
|
+
this.recordHistory()
|
|
977
1032
|
const taken = proposals.applyProposal(this.folded, this.proposalSession.source, this.proposalSession.picked)
|
|
978
1033
|
const { board, parts } = fromFigureSource(taken)
|
|
979
1034
|
|
|
@@ -1011,6 +1066,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1011
1066
|
private discard(reportFeedback = true) {
|
|
1012
1067
|
if (reportFeedback) this.reportProposalFeedback('discarded')
|
|
1013
1068
|
this.proposalSession = undefined
|
|
1069
|
+
this.proposalBaseSource = undefined
|
|
1014
1070
|
}
|
|
1015
1071
|
|
|
1016
1072
|
/**
|
|
@@ -1037,13 +1093,14 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1037
1093
|
|
|
1038
1094
|
async pageUpdated(changes: Record<string, unknown>, lifecycle: { resourceId?: string }) {
|
|
1039
1095
|
if (!this.active) {
|
|
1096
|
+
++this.loadGeneration
|
|
1040
1097
|
return
|
|
1041
1098
|
}
|
|
1042
1099
|
|
|
1043
1100
|
const id = lifecycle?.resourceId
|
|
1044
1101
|
|
|
1045
1102
|
if (id) {
|
|
1046
|
-
if (id !== this.figure?.id) {
|
|
1103
|
+
if (changes.active || id !== this.figure?.id) {
|
|
1047
1104
|
await this.load(id)
|
|
1048
1105
|
}
|
|
1049
1106
|
return
|
|
@@ -1067,22 +1124,24 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1067
1124
|
}
|
|
1068
1125
|
|
|
1069
1126
|
if (this.figure.id) {
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
*/
|
|
1074
|
-
if (this.dirty && !confirm(i18next.t('figure.text.discard-changes-and-start-new'))) {
|
|
1075
|
-
navigate(`figure-modeller/${this.figure.id}`, true)
|
|
1076
|
-
return
|
|
1077
|
-
}
|
|
1127
|
+
this.startNew()
|
|
1128
|
+
return
|
|
1129
|
+
}
|
|
1078
1130
|
|
|
1131
|
+
if (shouldStartNewFigure(changes, !!this.figure, this.figure?.id)) {
|
|
1079
1132
|
this.startNew()
|
|
1133
|
+
return
|
|
1080
1134
|
}
|
|
1081
1135
|
}
|
|
1082
1136
|
|
|
1137
|
+
private loadGeneration = 0
|
|
1083
1138
|
private async load(id: string) {
|
|
1139
|
+
const generation = ++this.loadGeneration
|
|
1084
1140
|
const figure = await fetchFigure(id)
|
|
1141
|
+
if (generation !== this.loadGeneration || !this.active) return
|
|
1085
1142
|
|
|
1143
|
+
this.proposalSession = undefined
|
|
1144
|
+
this.proposalBaseSource = undefined
|
|
1086
1145
|
this.figure = figure
|
|
1087
1146
|
|
|
1088
1147
|
/*
|
|
@@ -1116,9 +1175,17 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1116
1175
|
this.selected = -1
|
|
1117
1176
|
this.dirty = false
|
|
1118
1177
|
this.violations = []
|
|
1178
|
+
this.undoStack = []
|
|
1179
|
+
this.redoStack = []
|
|
1180
|
+
this.lastCoalesceKey = undefined
|
|
1119
1181
|
}
|
|
1120
1182
|
|
|
1121
1183
|
private startNew() {
|
|
1184
|
+
++this.loadGeneration
|
|
1185
|
+
this.proposalSession = undefined
|
|
1186
|
+
this.proposalBaseSource = undefined
|
|
1187
|
+
this.proposalFeedback = []
|
|
1188
|
+
this.mode = 'edit'
|
|
1122
1189
|
// 타입 이름을 사람이 정하기 전까지는 임시 이름을 쓴다. 저장할 때 확정한다.
|
|
1123
1190
|
const draftType = `FIGURE_${Date.now().toString(36).toUpperCase()}`
|
|
1124
1191
|
const { board, parts } = startingModel(draftType)
|
|
@@ -1130,6 +1197,9 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1130
1197
|
this.selected = -1
|
|
1131
1198
|
this.dirty = true
|
|
1132
1199
|
this.violations = []
|
|
1200
|
+
this.undoStack = []
|
|
1201
|
+
this.redoStack = []
|
|
1202
|
+
this.lastCoalesceKey = undefined
|
|
1133
1203
|
}
|
|
1134
1204
|
|
|
1135
1205
|
private rename(name: string) {
|
|
@@ -1152,13 +1222,62 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1152
1222
|
this.dirty = true
|
|
1153
1223
|
}
|
|
1154
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
|
+
|
|
1155
1273
|
/**
|
|
1156
1274
|
* 부품 목록을 갈아 끼운다.
|
|
1157
1275
|
*
|
|
1158
1276
|
* 편집 규칙은 `part-edits` 가 갖는다 — 화면과 테스트가 같은 것을 쓰게 하려고 떼어
|
|
1159
1277
|
* 놓았다. 여기서는 결과를 담고 선택 인덱스를 정리한다.
|
|
1160
1278
|
*/
|
|
1161
|
-
private put(parts: PartModel[], selected = this.selected) {
|
|
1279
|
+
private put(parts: PartModel[], selected = this.selected, coalesceKey?: string) {
|
|
1280
|
+
this.recordHistory(coalesceKey)
|
|
1162
1281
|
this.parts = parts
|
|
1163
1282
|
this.selected = Math.min(selected, parts.length - 1)
|
|
1164
1283
|
this.dirty = true
|
|
@@ -1183,7 +1302,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1183
1302
|
}
|
|
1184
1303
|
|
|
1185
1304
|
private updatePart(index: number, part: PartModel) {
|
|
1186
|
-
this.put(edits.updatePart(this.parts, index, part))
|
|
1305
|
+
this.put(edits.updatePart(this.parts, index, part), this.selected, `part:${part.name || index}`)
|
|
1187
1306
|
}
|
|
1188
1307
|
|
|
1189
1308
|
/**
|
|
@@ -1193,6 +1312,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1193
1312
|
* 고칠 때마다 부품 목록이 새 배열이 되고, 그리는 쪽이 전부 다시 그린다.
|
|
1194
1313
|
*/
|
|
1195
1314
|
private updateBoard(board: BoardModel) {
|
|
1315
|
+
this.recordHistory('board')
|
|
1196
1316
|
this.board = board
|
|
1197
1317
|
this.dirty = true
|
|
1198
1318
|
}
|
|
@@ -1210,6 +1330,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1210
1330
|
private changeDetailLevel(level: DetailLevel) {
|
|
1211
1331
|
if (!this.board) return
|
|
1212
1332
|
|
|
1333
|
+
this.recordHistory('detailLevel')
|
|
1213
1334
|
this.board = { ...this.board, detailLevel: level }
|
|
1214
1335
|
this.dirty = true
|
|
1215
1336
|
}
|
|
@@ -1248,7 +1369,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
|
|
|
1248
1369
|
*/
|
|
1249
1370
|
private partChanged(name: string, part: PartModel) {
|
|
1250
1371
|
const at = this.parts.findIndex(item => item.name === name)
|
|
1251
|
-
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}`)
|
|
1252
1373
|
}
|
|
1253
1374
|
|
|
1254
1375
|
/**
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Explicit AI entry survives lazy creation of the app-level dock. */
|
|
2
|
+
let pending;
|
|
3
|
+
export function requestFigureAI(target) {
|
|
4
|
+
pending = structuredClone(target);
|
|
5
|
+
window.dispatchEvent(new CustomEvent('figure-ai-target'));
|
|
6
|
+
}
|
|
7
|
+
export function takeFigureAITarget() {
|
|
8
|
+
const target = pending;
|
|
9
|
+
pending = undefined;
|
|
10
|
+
return target;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=figure-ai-target.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"figure-ai-target.js","sourceRoot":"","sources":["../../client/modeller/figure-ai-target.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,IAAI,OAA4D,CAAA;AAChE,MAAM,UAAU,eAAe,CAAC,MAA+C;IAC7E,OAAO,GAAG,eAAe,CAAC,MAAM,CAAC,CAAA;IACjC,MAAM,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,kBAAkB,CAAC,CAAC,CAAA;AAC3D,CAAC;AACD,MAAM,UAAU,kBAAkB;IAChC,MAAM,MAAM,GAAG,OAAO,CAAA;IACtB,OAAO,GAAG,SAAS,CAAA;IACnB,OAAO,MAAM,CAAA;AACf,CAAC","sourcesContent":["/** Explicit AI entry survives lazy creation of the app-level dock. */\nlet pending: { figureId?: string; source?: unknown } | undefined\nexport function requestFigureAI(target: { figureId?: string; source?: unknown }) {\n pending = structuredClone(target)\n window.dispatchEvent(new CustomEvent('figure-ai-target'))\n}\nexport function takeFigureAITarget() {\n const target = pending\n pending = undefined\n return target\n}\n"]}
|
|
@@ -30,6 +30,7 @@ let FigureAsk = class FigureAsk extends localize(i18next)(LitElement) {
|
|
|
30
30
|
this.type = '';
|
|
31
31
|
/** 이 모델러 세션에서 사람이 명시적으로 남긴 이전 후보 반응. */
|
|
32
32
|
this.feedback = [];
|
|
33
|
+
this.imageOnly = false;
|
|
33
34
|
this.asking = false;
|
|
34
35
|
this.failure = '';
|
|
35
36
|
}
|
|
@@ -61,6 +62,7 @@ let FigureAsk = class FigureAsk extends localize(i18next)(LitElement) {
|
|
|
61
62
|
|
|
62
63
|
form {
|
|
63
64
|
display: flex;
|
|
65
|
+
flex-wrap: wrap;
|
|
64
66
|
flex: 1;
|
|
65
67
|
min-width: 0;
|
|
66
68
|
align-items: center;
|
|
@@ -176,7 +178,7 @@ let FigureAsk = class FigureAsk extends localize(i18next)(LitElement) {
|
|
|
176
178
|
render() {
|
|
177
179
|
const revising = !!this.source;
|
|
178
180
|
return html `
|
|
179
|
-
<form @submit=${(e) => this.submit(e)}>
|
|
181
|
+
<form title="이미지의 형태와 비율을 참고한 후보를 만듭니다. 저장은 하지 않습니다." @submit=${(e) => this.submit(e)}>
|
|
180
182
|
<md-icon lead>auto_awesome</md-icon>
|
|
181
183
|
<span mode>${i18next.t(revising ? 'figure.text.ask-to-revise' : 'figure.text.ask-to-create')}</span>
|
|
182
184
|
<input
|
|
@@ -191,7 +193,7 @@ let FigureAsk = class FigureAsk extends localize(i18next)(LitElement) {
|
|
|
191
193
|
: i18next.t('figure.button.attach-a-picture')}
|
|
192
194
|
<input type="file" accept="image/*" @change=${(e) => this.pick(e)} />
|
|
193
195
|
</label>
|
|
194
|
-
<button go ?disabled=${this.asking}>
|
|
196
|
+
<button go ?disabled=${this.asking || (this.imageOnly && !this.picture)}>
|
|
195
197
|
${this.asking ? i18next.t('figure.text.asking') : i18next.t('figure.button.ask')}
|
|
196
198
|
</button>
|
|
197
199
|
</form>
|
|
@@ -214,15 +216,16 @@ let FigureAsk = class FigureAsk extends localize(i18next)(LitElement) {
|
|
|
214
216
|
e.preventDefault();
|
|
215
217
|
const input = this.renderRoot.querySelector('input[ask]');
|
|
216
218
|
const prompt = input?.value?.trim();
|
|
217
|
-
if (!prompt || this.asking) {
|
|
219
|
+
if (!prompt || this.asking || (this.imageOnly && !this.picture)) {
|
|
218
220
|
return;
|
|
219
221
|
}
|
|
220
222
|
this.asking = true;
|
|
221
223
|
this.failure = '';
|
|
224
|
+
const baseSource = this.source ? JSON.stringify(this.source) : undefined;
|
|
222
225
|
try {
|
|
223
226
|
const proposal = await proposeFigure({
|
|
224
227
|
prompt,
|
|
225
|
-
base:
|
|
228
|
+
base: baseSource,
|
|
226
229
|
type: this.source ? undefined : this.type,
|
|
227
230
|
// 그리는 쪽이 풀 수 있는 토큰만 보낸다. 서버는 이 목록 밖의 색을 거절한다.
|
|
228
231
|
palette: Object.keys(activePalette()),
|
|
@@ -231,7 +234,7 @@ let FigureAsk = class FigureAsk extends localize(i18next)(LitElement) {
|
|
|
231
234
|
feedback: this.feedback,
|
|
232
235
|
image: this.picture
|
|
233
236
|
});
|
|
234
|
-
this.dispatchEvent(new CustomEvent('proposed', { detail: { proposal }, bubbles: true, composed: true }));
|
|
237
|
+
this.dispatchEvent(new CustomEvent('proposed', { detail: { proposal, baseSource }, bubbles: true, composed: true }));
|
|
235
238
|
input.value = '';
|
|
236
239
|
this.picture = undefined;
|
|
237
240
|
}
|
|
@@ -256,6 +259,10 @@ __decorate([
|
|
|
256
259
|
property({ type: Array }),
|
|
257
260
|
__metadata("design:type", Array)
|
|
258
261
|
], FigureAsk.prototype, "feedback", void 0);
|
|
262
|
+
__decorate([
|
|
263
|
+
property({ type: Boolean }),
|
|
264
|
+
__metadata("design:type", Object)
|
|
265
|
+
], FigureAsk.prototype, "imageOnly", void 0);
|
|
259
266
|
__decorate([
|
|
260
267
|
state(),
|
|
261
268
|
__metadata("design:type", Object)
|