@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.
@@ -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: 5px var(--spacing-medium, 8px);
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);