@things-factory/figure-ui 10.1.241 → 10.1.244
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/graphql/store.ts +3 -2
- package/client/modeller/editor.ts +63 -5
- package/client/modeller/figure-parts.ts +18 -3
- package/client/modeller/figure-preview.ts +1 -1
- package/client/modeller/figure-properties.ts +14 -7
- package/client/modeller/figure-save-conflict.ts +73 -0
- package/client/modeller/figure-section-editor.ts +3 -0
- package/client/modeller/keys.ts +8 -2
- package/client/modeller/modeller-keys.ts +4 -1
- package/client/modeller/product-session.ts +107 -5
- package/client/modeller/save-conflict.ts +91 -0
- package/client/pages/figure-modeller-page.ts +58 -43
- package/dist-client/graphql/store.js +3 -2
- package/dist-client/graphql/store.js.map +1 -1
- package/dist-client/modeller/editor.d.ts +14 -0
- package/dist-client/modeller/editor.js +75 -5
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/figure-parts.js +18 -3
- package/dist-client/modeller/figure-parts.js.map +1 -1
- package/dist-client/modeller/figure-preview.d.ts +1 -1
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-properties.d.ts +4 -2
- package/dist-client/modeller/figure-properties.js +16 -7
- package/dist-client/modeller/figure-properties.js.map +1 -1
- package/dist-client/modeller/figure-save-conflict.d.ts +19 -0
- package/dist-client/modeller/figure-save-conflict.js +76 -0
- package/dist-client/modeller/figure-save-conflict.js.map +1 -0
- package/dist-client/modeller/figure-section-editor.d.ts +2 -0
- package/dist-client/modeller/figure-section-editor.js +8 -0
- package/dist-client/modeller/figure-section-editor.js.map +1 -1
- package/dist-client/modeller/keys.d.ts +5 -0
- package/dist-client/modeller/keys.js +5 -3
- package/dist-client/modeller/keys.js.map +1 -1
- package/dist-client/modeller/modeller-keys.d.ts +2 -0
- package/dist-client/modeller/modeller-keys.js +2 -1
- package/dist-client/modeller/modeller-keys.js.map +1 -1
- package/dist-client/modeller/product-session.d.ts +368 -22
- package/dist-client/modeller/product-session.js +107 -7
- package/dist-client/modeller/product-session.js.map +1 -1
- package/dist-client/modeller/save-conflict.d.ts +12 -0
- package/dist-client/modeller/save-conflict.js +63 -0
- package/dist-client/modeller/save-conflict.js.map +1 -0
- package/dist-client/pages/figure-modeller-page.d.ts +11 -5
- package/dist-client/pages/figure-modeller-page.js +58 -49
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/browser/category-page.js +3 -1
- package/test/browser/category.check.mjs +6 -0
- package/test/browser/product-page.check.mjs +268 -6
- package/test/browser/product-page.js +4 -1
- package/test/browser/screen.check.mjs +3 -0
- package/test/browser/section.check.mjs +21 -0
- package/test/keys.test.ts +8 -0
- package/test/product-session.test.ts +137 -1
- package/test/save-conflict.test.ts +41 -0
- package/test/save-landed.test.ts +75 -0
- package/translations/en.json +12 -1
- package/translations/es.json +12 -1
- package/translations/ja.json +12 -1
- package/translations/ko.json +12 -1
- package/translations/ms.json +12 -1
- package/translations/zh.json +12 -1
package/client/graphql/store.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import gql from 'graphql-tag'
|
|
2
2
|
import { client } from '@operato/graphql'
|
|
3
|
-
import { fetchFigure } from './index.js'
|
|
3
|
+
import { fetchFigure, updateFigure } from './index.js'
|
|
4
4
|
import { reached } from './reached.js'
|
|
5
5
|
import type { FigureProductStore, FigureProductRecord } from '../modeller/product-session.js'
|
|
6
6
|
|
|
@@ -57,5 +57,6 @@ export const figureProductStore: FigureProductStore = {
|
|
|
57
57
|
const result = response.data?.saveFigure as FigureProductRecord | undefined
|
|
58
58
|
if (!result?.id || !result.source) throw Error('서버가 저장 결과를 돌려주지 않았습니다.')
|
|
59
59
|
return result
|
|
60
|
-
}
|
|
60
|
+
},
|
|
61
|
+
updateCatalog: (id, patch) => updateFigure(id, patch)
|
|
61
62
|
}
|
|
@@ -8,7 +8,7 @@ import { openPopup } from '@operato/layout'
|
|
|
8
8
|
import { OxPrompt } from '@operato/popup'
|
|
9
9
|
import { customElement, property, state } from 'lit/decorators.js'
|
|
10
10
|
import { live } from 'lit/directives/live.js'
|
|
11
|
-
import { figureFastenAxesOf, figurePartFaceOf, PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditOf, figureSizeEditorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang } from '@hatiolab/figure-model'
|
|
11
|
+
import { parseFigureAsset, figureFastenAxesOf, figurePartFaceOf, PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditOf, figureSizeEditorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang } from '@hatiolab/figure-model'
|
|
12
12
|
import type { RecipeTemplate } from './recipes.js'
|
|
13
13
|
import './figure-recipe-catalog.js'
|
|
14
14
|
import type { FigureAsset, FigureAuthoringAction, FigureEvaluatedShape, FigureInputChange, FigurePartEdit, FigurePrimitiveCreation, FigureProposalStep, FigureRecipe, FigureRecipeValue, FigurePlacement, FigureFaceMappingNeed, FigureFaceRemap } from '@hatiolab/figure-model'
|
|
@@ -28,6 +28,10 @@ import './figure-fasten-dialog.js'
|
|
|
28
28
|
import './figure-controls.js'
|
|
29
29
|
import './figure-clips.js'
|
|
30
30
|
import './figure-data-panel.js'
|
|
31
|
+
import type { FigurePreview } from './figure-preview.js'
|
|
32
|
+
import './figure-save-conflict.js'
|
|
33
|
+
import type { SaveConflictChoice } from './figure-save-conflict.js'
|
|
34
|
+
import { saveConflictChangesOf, type SaveConflictChanges } from './save-conflict.js'
|
|
31
35
|
import './figure-preview.js'
|
|
32
36
|
import './figure-settings.js'
|
|
33
37
|
import { DEFAULT_VIEW, type ViewSettings } from '@things-factory/figure-viewer/dist-client/figure-view.js'
|
|
@@ -118,6 +122,11 @@ export class FigureEditor extends LitElement {
|
|
|
118
122
|
/** Why the last recipe was refused, in the kernel's words. */
|
|
119
123
|
@state() private recipeError = ''
|
|
120
124
|
@property({ type: Number }) revision = 0
|
|
125
|
+
/**
|
|
126
|
+
* The page in front (the shell's `active`). A page left keeps its editor, on the window: a Backspace pressed on
|
|
127
|
+
* another page removed the part picked here, out of sight (2026-11-01 audit). Being on the document is not being seen.
|
|
128
|
+
*/
|
|
129
|
+
@property({ type: Boolean }) active = true
|
|
121
130
|
/** The 「?」 beside a group's title and the sentence it opens. */
|
|
122
131
|
private readonly help = new HelpToggles(this)
|
|
123
132
|
/** The cost line the parts list shows, from the maturity report. */
|
|
@@ -262,12 +271,18 @@ export class FigureEditor extends LitElement {
|
|
|
262
271
|
* The section editor open takes the keys for its own: Esc cancels it, Delete deletes a point, Ctrl+Z undoes a point.
|
|
263
272
|
* Heard here first, they deselected the part, deleted the part and undid the figure's last step (2026-09-30).
|
|
264
273
|
*/
|
|
265
|
-
return !!view && !view.loading && !view.saving && !!view.context && this.isConnected && !this.sectionEditing
|
|
274
|
+
return !!view && !view.loading && !view.saving && !!view.context && this.isConnected && this.active && !this.sectionEditing
|
|
266
275
|
},
|
|
276
|
+
previewing: () => this.middle === 'preview',
|
|
267
277
|
scene: () => this.renderRoot.querySelector<FigureCanvas>('figure-canvas'),
|
|
268
278
|
selected: () => this.selected,
|
|
269
279
|
act: action => this.keyAction(action)
|
|
270
280
|
})
|
|
281
|
+
/** 「화면 맞춤」 fits the canvas in front: in the preview it fitted the edit canvas hidden behind it (2026-11-01 audit). */
|
|
282
|
+
private fitShown() {
|
|
283
|
+
if (this.middle === 'preview') this.renderRoot.querySelector<FigurePreview>('figure-preview')?.fitView()
|
|
284
|
+
else this.renderRoot.querySelector<FigureCanvas>('figure-canvas')?.fit()
|
|
285
|
+
}
|
|
271
286
|
/** The part Ctrl+C took: pasted as the kernel's duplicate. Not kept past the editor, nor written anywhere. */
|
|
272
287
|
private copied = ''
|
|
273
288
|
/** A copied part still in the figure is duplicated -- a part of its own, named by figure-model; one gone, nothing. */
|
|
@@ -467,12 +482,54 @@ export class FigureEditor extends LitElement {
|
|
|
467
482
|
await promise
|
|
468
483
|
this.error = ''
|
|
469
484
|
} catch (error) {
|
|
485
|
+
/* Another editor saved first: the ways on are asked, not a refusal said that saving again would only repeat. */
|
|
486
|
+
if (this.session.read().conflict) void this.resolveConflict()
|
|
470
487
|
/* Not reached: whether it was written is not known, and the edits are still here -- said so, not "not saved". */
|
|
471
|
-
this.error = (error as { code?: string })?.code === 'SAVE_UNREACHABLE' ? i18next.t('figure.text.save-unreachable') : this.said(error)
|
|
488
|
+
else this.error = (error as { code?: string })?.code === 'SAVE_UNREACHABLE' ? i18next.t('figure.text.save-unreachable') : this.said(error)
|
|
472
489
|
} finally {
|
|
473
490
|
this.changed()
|
|
474
491
|
}
|
|
475
492
|
}
|
|
493
|
+
/**
|
|
494
|
+
* After a conflict (2026-11-01 audit item 6): what differs from the server's latest, and the way on the person
|
|
495
|
+
* chooses. A copy is a new figure saved by the first save's question; the latest opens in a new tab at this figure's
|
|
496
|
+
* address, the edits staying here; writing over is asked once more, since the other's change is lost.
|
|
497
|
+
*/
|
|
498
|
+
private async resolveConflict() {
|
|
499
|
+
const session = this.session!
|
|
500
|
+
const latest = session.read().conflict?.latest
|
|
501
|
+
const mine = session.revisionNow()?.asset
|
|
502
|
+
let changes: SaveConflictChanges | null = null
|
|
503
|
+
try {
|
|
504
|
+
changes = latest && mine ? saveConflictChangesOf(mine, parseFigureAsset(latest)) : null
|
|
505
|
+
} catch {
|
|
506
|
+
/* A latest this figure-model cannot read is not compared; it is still offered to keep a copy or to look at it. */
|
|
507
|
+
}
|
|
508
|
+
const choice = await openPopup(html`<figure-save-conflict .changes=${changes}></figure-save-conflict>`, {
|
|
509
|
+
backdrop: true,
|
|
510
|
+
size: 'small',
|
|
511
|
+
title: i18next.t('figure.title.save-conflict')
|
|
512
|
+
}).result as SaveConflictChoice | undefined
|
|
513
|
+
if (this.session !== session || !session.read().conflict) return
|
|
514
|
+
if (choice === 'latest') window.open(location.href, '_blank', 'noopener')
|
|
515
|
+
else if (choice === 'copy') {
|
|
516
|
+
session.detachAsCopy()
|
|
517
|
+
/* The page forgets the row of the figure left: the copy's identifier is asked, not the other's offered. */
|
|
518
|
+
this.dispatchEvent(new CustomEvent('figure-detached', { bubbles: true, composed: true }))
|
|
519
|
+
this.changed()
|
|
520
|
+
await this.save()
|
|
521
|
+
} else if (choice === 'overwrite') {
|
|
522
|
+
const agreed = await OxPrompt.open({
|
|
523
|
+
type: 'warning',
|
|
524
|
+
text: i18next.t('figure.text.overwrite-latest-warning'),
|
|
525
|
+
confirmButton: { text: i18next.t('figure.button.overwrite-latest') },
|
|
526
|
+
cancelButton: { text: i18next.t('figure.button.keep-editing') }
|
|
527
|
+
})
|
|
528
|
+
if (!agreed || this.session !== session || !session.read().conflict) return
|
|
529
|
+
session.overwriteConflict()
|
|
530
|
+
await this.save()
|
|
531
|
+
}
|
|
532
|
+
}
|
|
476
533
|
private add(kind: FigurePaletteKind) {
|
|
477
534
|
if (!this.session) return
|
|
478
535
|
this.run(() => {
|
|
@@ -1608,7 +1665,7 @@ export class FigureEditor extends LitElement {
|
|
|
1608
1665
|
whatever is picked: the size tab was 「반응형」 with nothing picked and 「배치」 with a part (sweep 2026-10-27 J2).
|
|
1609
1666
|
*/
|
|
1610
1667
|
const name = i18next.t('figure.label.tab-short-' + tab)
|
|
1611
|
-
return html`<button tab ?on=${this.tab === tab} title=${label} aria-label=${name} @click=${() => (this.tab = tab)}>
|
|
1668
|
+
return html`<button tab ?on=${this.tab === tab} aria-pressed=${String(this.tab === tab)} title=${label} aria-label=${name} @click=${() => (this.tab = tab)}>
|
|
1612
1669
|
<md-icon>${icon}</md-icon><span tab-label aria-hidden="true">${name}</span>
|
|
1613
1670
|
</button>`
|
|
1614
1671
|
}
|
|
@@ -1647,6 +1704,7 @@ export class FigureEditor extends LitElement {
|
|
|
1647
1704
|
.kept=${kept}
|
|
1648
1705
|
.pointBlocked=${this.pointBlockedOf(drawn)}
|
|
1649
1706
|
.revision=${this.session?.read().revision ?? 0}
|
|
1707
|
+
?active=${this.active}
|
|
1650
1708
|
.faceMapping=${this.faceMapping?.from === 'section-editor' ? this.faceMapping.need : undefined}
|
|
1651
1709
|
@command=${(e: CustomEvent) => {
|
|
1652
1710
|
/* Held for a choice of where things go: the editor stays open, the choice shown in it. */
|
|
@@ -1806,7 +1864,7 @@ export class FigureEditor extends LitElement {
|
|
|
1806
1864
|
.pane=${this.panes}
|
|
1807
1865
|
@canvas-undo=${() => this.run(() => this.session!.undo())}
|
|
1808
1866
|
@canvas-redo=${() => this.run(() => this.session!.redo())}
|
|
1809
|
-
@canvas-fit=${() => this.
|
|
1867
|
+
@canvas-fit=${() => this.fitShown()}
|
|
1810
1868
|
>
|
|
1811
1869
|
${this.renderGizmoTools(!unavailable && this.middle === 'edit')}
|
|
1812
1870
|
${this.renderOverlayButton('view', 'wb_sunny', 'figure.label.tab-environment')}
|
|
@@ -137,7 +137,8 @@ export class FigureParts extends localize(i18next)(LitElement) {
|
|
|
137
137
|
border-color: var(--md-sys-color-primary);
|
|
138
138
|
background: var(--md-sys-color-primary-container);
|
|
139
139
|
}
|
|
140
|
-
button[model-root]:focus-visible
|
|
140
|
+
button[model-root]:focus-visible,
|
|
141
|
+
button[pick]:focus-visible {
|
|
141
142
|
outline: 2px solid var(--md-sys-color-primary);
|
|
142
143
|
outline-offset: 1px;
|
|
143
144
|
}
|
|
@@ -405,6 +406,19 @@ export class FigureParts extends localize(i18next)(LitElement) {
|
|
|
405
406
|
li[selected] md-icon {
|
|
406
407
|
color: inherit;
|
|
407
408
|
}
|
|
409
|
+
button[pick] {
|
|
410
|
+
display: flex;
|
|
411
|
+
flex: 1;
|
|
412
|
+
min-width: 0;
|
|
413
|
+
padding: 0;
|
|
414
|
+
font: inherit;
|
|
415
|
+
color: inherit;
|
|
416
|
+
text-align: start;
|
|
417
|
+
background: none;
|
|
418
|
+
border: none;
|
|
419
|
+
border-radius: 4px;
|
|
420
|
+
cursor: pointer;
|
|
421
|
+
}
|
|
408
422
|
span[label] {
|
|
409
423
|
flex: 1;
|
|
410
424
|
min-width: 0;
|
|
@@ -652,6 +666,7 @@ export class FigureParts extends localize(i18next)(LitElement) {
|
|
|
652
666
|
private renderRow(part: FigurePartRow, index: number) {
|
|
653
667
|
const selected = part.id === this.selected
|
|
654
668
|
const off = !!part.hidden
|
|
669
|
+
/* The row is picked by the keyboard too, by its name's button: the eye was the only stop on a row, and it does not pick (2026-11-01 audit). */
|
|
655
670
|
return html`
|
|
656
671
|
<li
|
|
657
672
|
?selected=${selected}
|
|
@@ -671,11 +686,11 @@ export class FigureParts extends localize(i18next)(LitElement) {
|
|
|
671
686
|
<md-icon>${off ? 'visibility_off' : 'visibility'}</md-icon>
|
|
672
687
|
</button>
|
|
673
688
|
<md-icon>${KIND_ICON[part.kind] ?? 'deployed_code'}</md-icon>
|
|
674
|
-
<span label title=${part.id}
|
|
689
|
+
<button pick aria-pressed=${String(selected)}><span label title=${part.id}
|
|
675
690
|
>${partLabelOf(part.id)}${part.copies ? html` <small copies>× ${part.copies}</small>` : nothing}${part.invisible ? html`<small>${i18next.t('figure.label.state-hidden')}</small>` : nothing}${part.fastenedTo
|
|
676
691
|
? html`<small fastened-to title=${i18next.t('figure.label.fastened-to')}>${part.fastenedTo}</small>`
|
|
677
692
|
: nothing}</span
|
|
678
|
-
>
|
|
693
|
+
></button>
|
|
679
694
|
${selected ? this.renderOps(part.id, index) : nothing}
|
|
680
695
|
</li>
|
|
681
696
|
`
|
|
@@ -479,7 +479,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
479
479
|
}
|
|
480
480
|
|
|
481
481
|
/** The figure as stretched, whole in view: done once the slider is let go, so the ground does not slide under a drag. */
|
|
482
|
-
|
|
482
|
+
fitView() {
|
|
483
483
|
this.scene?.fit?.('ratio')
|
|
484
484
|
this.scene?.root?.fitCameraToModel?.()
|
|
485
485
|
}
|
|
@@ -28,8 +28,8 @@ const PLACEMENTS: Placement[] = ['floor', 'ceiling', 'center', 'wall']
|
|
|
28
28
|
* Two kinds of change leave this tab, and it does neither itself:
|
|
29
29
|
* - `command` carries an authoring command (rename, where it stands, taking the mounting face off). The page runs
|
|
30
30
|
* it through the session, so it is one step of the same undo history as every other edit, and is saved with it.
|
|
31
|
-
* - `catalog` carries a change to the row (category, tags). Those are not in the asset; the page
|
|
32
|
-
* to the saved row
|
|
31
|
+
* - `catalog` carries a change to the row (category, tags). Those are not in the asset; the page holds them and
|
|
32
|
+
* 「저장」 writes them to the saved row with the figure, so they need a saved figure. They are not undone.
|
|
33
33
|
*/
|
|
34
34
|
@customElement('figure-properties')
|
|
35
35
|
export class FigureProperties extends localize(i18next)(LitElement) {
|
|
@@ -61,6 +61,8 @@ export class FigureProperties extends localize(i18next)(LitElement) {
|
|
|
61
61
|
@property({ type: String }) figureCategory?: string
|
|
62
62
|
/** The categories the server allows (LV figure-server design G5): chosen from, and fixed once set. Null: free text. */
|
|
63
63
|
@property({ attribute: false }) figureCategories?: string[] | null
|
|
64
|
+
/** The category the server holds: with a vocabulary it is fixed; one chosen and not saved yet may be chosen again. */
|
|
65
|
+
@property({ type: String }) figureCategorySaved?: string
|
|
64
66
|
@property({ type: Array }) figureTags?: string[]
|
|
65
67
|
private facts(): FigureFigureFacts | undefined {
|
|
66
68
|
try {
|
|
@@ -122,8 +124,9 @@ export class FigureProperties extends localize(i18next)(LitElement) {
|
|
|
122
124
|
private renderName(facts: FigureFigureFacts) {
|
|
123
125
|
return html`
|
|
124
126
|
<div field>
|
|
125
|
-
<label>${i18next.t('figure.label.name')}</label>
|
|
127
|
+
<label for="figure-name">${i18next.t('figure.label.name')}</label>
|
|
126
128
|
<input
|
|
129
|
+
id="figure-name"
|
|
127
130
|
.value=${facts.name}
|
|
128
131
|
placeholder=${i18next.t('figure.text.figure-display-name')}
|
|
129
132
|
@change=${(event: Event) => {
|
|
@@ -148,8 +151,9 @@ export class FigureProperties extends localize(i18next)(LitElement) {
|
|
|
148
151
|
const current = facts.placement ?? 'floor'
|
|
149
152
|
return html`
|
|
150
153
|
<div field>
|
|
151
|
-
<label>${i18next.t('figure.label.figure-placement')}</label>
|
|
154
|
+
<label for="figure-placement">${i18next.t('figure.label.figure-placement')}</label>
|
|
152
155
|
<select
|
|
156
|
+
id="figure-placement"
|
|
153
157
|
.value=${live(current)}
|
|
154
158
|
@change=${(event: Event) => {
|
|
155
159
|
const select = event.target as HTMLSelectElement
|
|
@@ -213,17 +217,19 @@ export class FigureProperties extends localize(i18next)(LitElement) {
|
|
|
213
217
|
const saved = !!this.figureId
|
|
214
218
|
return html`
|
|
215
219
|
<div field>
|
|
216
|
-
<label>${i18next.t('figure.label.category')}</label>
|
|
220
|
+
<label for="figure-category">${i18next.t('figure.label.category')}</label>
|
|
217
221
|
${this.figureCategories?.length
|
|
218
222
|
? html`<select
|
|
223
|
+
id="figure-category"
|
|
219
224
|
category
|
|
220
|
-
?disabled=${!saved || !!this.
|
|
225
|
+
?disabled=${!saved || !!this.figureCategorySaved}
|
|
221
226
|
@change=${(e: Event) => this.catalog({ category: (e.target as HTMLSelectElement).value })}
|
|
222
227
|
>
|
|
223
228
|
${this.figureCategory ? nothing : html`<option value="" selected disabled>${i18next.t('figure.text.category-choose')}</option>`}
|
|
224
229
|
${this.figureCategories.map(code => html`<option value=${code} ?selected=${code === this.figureCategory}>${code}</option>`)}
|
|
225
230
|
</select>`
|
|
226
231
|
: html`<input
|
|
232
|
+
id="figure-category"
|
|
227
233
|
.value=${this.figureCategory ?? ''}
|
|
228
234
|
?disabled=${!saved}
|
|
229
235
|
placeholder=${i18next.t('figure.text.category-placeholder')}
|
|
@@ -231,8 +237,9 @@ export class FigureProperties extends localize(i18next)(LitElement) {
|
|
|
231
237
|
/>`}
|
|
232
238
|
</div>
|
|
233
239
|
<div field>
|
|
234
|
-
<label>${i18next.t('figure.label.tags')}</label>
|
|
240
|
+
<label for="figure-tags">${i18next.t('figure.label.tags')}</label>
|
|
235
241
|
<input
|
|
242
|
+
id="figure-tags"
|
|
236
243
|
.value=${(this.figureTags ?? []).join(', ')}
|
|
237
244
|
?disabled=${!saved}
|
|
238
245
|
placeholder=${i18next.t('figure.text.tags-placeholder')}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { LitElement, html, css, nothing } from 'lit'
|
|
2
|
+
import { customElement, property } from 'lit/decorators.js'
|
|
3
|
+
import { i18next } from '@operato/i18n'
|
|
4
|
+
import { closePopup } from '@operato/popup'
|
|
5
|
+
import '@operato/popup/ox-popup-actions.js'
|
|
6
|
+
import { CommonPopupStyles } from '@operato/styles'
|
|
7
|
+
import '@material/web/button/filled-button.js'
|
|
8
|
+
import '@material/web/button/outlined-button.js'
|
|
9
|
+
import '@material/web/button/text-button.js'
|
|
10
|
+
|
|
11
|
+
import { partLabelOf } from './fields.js'
|
|
12
|
+
import type { SaveConflictChanges } from './save-conflict.js'
|
|
13
|
+
|
|
14
|
+
/** What the person chose after a conflict; nothing when closed -- the edits stay, unsaved. */
|
|
15
|
+
export type SaveConflictChoice = 'copy' | 'latest' | 'overwrite'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A save refused because another editor saved this figure first (2026-11-01 audit item 6). The edits stay on screen;
|
|
19
|
+
* what differs from the server's latest is said by part, and the ways on are offered: keep the edits as a copy (first,
|
|
20
|
+
* since nothing is lost), look at the latest in a new tab, or write the edits over it.
|
|
21
|
+
*/
|
|
22
|
+
@customElement('figure-save-conflict')
|
|
23
|
+
export class FigureSaveConflict extends LitElement {
|
|
24
|
+
static styles = [
|
|
25
|
+
CommonPopupStyles,
|
|
26
|
+
css`
|
|
27
|
+
:host {
|
|
28
|
+
overflow-y: auto;
|
|
29
|
+
}
|
|
30
|
+
ul {
|
|
31
|
+
margin: 0;
|
|
32
|
+
padding-inline-start: 18px;
|
|
33
|
+
font-size: var(--popup-font-size-small);
|
|
34
|
+
}
|
|
35
|
+
li span {
|
|
36
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
37
|
+
}
|
|
38
|
+
div[ways] {
|
|
39
|
+
display: grid;
|
|
40
|
+
gap: 6px;
|
|
41
|
+
}
|
|
42
|
+
`
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
/** What differs from the server's latest; null when the latest could not be read. */
|
|
46
|
+
@property({ attribute: false }) changes: SaveConflictChanges | null = null
|
|
47
|
+
|
|
48
|
+
render() {
|
|
49
|
+
const changes = this.changes
|
|
50
|
+
const line = (key: string, parts: string[]) =>
|
|
51
|
+
parts.length ? html`<li><span>${i18next.t(key)}</span> ${parts.map(partLabelOf).join(', ')}</li>` : nothing
|
|
52
|
+
return html`
|
|
53
|
+
<div class="what">${i18next.t('figure.text.save-conflict-desc')}</div>
|
|
54
|
+
${changes
|
|
55
|
+
? html`<ul differs>
|
|
56
|
+
${line('figure.label.conflict-changed', changes.changed)} ${line('figure.label.conflict-mine-only', changes.mineOnly)}
|
|
57
|
+
${line('figure.label.conflict-theirs-only', changes.theirsOnly)}
|
|
58
|
+
${changes.otherChanged ? html`<li>${i18next.t('figure.text.conflict-other-changed')}</li>` : nothing}
|
|
59
|
+
</ul>`
|
|
60
|
+
: html`<div class="note">${i18next.t('figure.text.conflict-latest-unread')}</div>`}
|
|
61
|
+
<div ways>
|
|
62
|
+
<md-filled-button copy @click=${() => closePopup(this, 'copy')}>${i18next.t('figure.button.save-as-copy')}</md-filled-button>
|
|
63
|
+
<md-outlined-button latest @click=${() => closePopup(this, 'latest')}>${i18next.t('figure.button.open-latest')}</md-outlined-button>
|
|
64
|
+
${changes
|
|
65
|
+
? html`<md-text-button overwrite @click=${() => closePopup(this, 'overwrite')}>${i18next.t('figure.button.overwrite-latest')}</md-text-button>`
|
|
66
|
+
: nothing}
|
|
67
|
+
</div>
|
|
68
|
+
<ox-popup-actions>
|
|
69
|
+
<md-text-button slot="cancel" @click=${() => closePopup(this)}>${i18next.t('figure.button.keep-editing')}</md-text-button>
|
|
70
|
+
</ox-popup-actions>
|
|
71
|
+
`
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -273,6 +273,8 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
|
|
|
273
273
|
@property({ attribute: false }) pointBlocked = ''
|
|
274
274
|
/** The figure's revision now; the editor remembers the one it opened at. */
|
|
275
275
|
@property({ type: Number }) revision = 0
|
|
276
|
+
/** The page in front, as the modeller has it: a page left keeps this editor on the window, and its keys are not its (2026-11-01 audit item 1). */
|
|
277
|
+
@property({ type: Boolean }) active = true
|
|
276
278
|
/** What stands on the faces the last apply takes away (ADR-0100 A8): asked here, where the edit was made. */
|
|
277
279
|
@property({ attribute: false }) faceMapping?: FigureFaceMappingNeed
|
|
278
280
|
/** Narrow: the outline or the solid behind. */
|
|
@@ -344,6 +346,7 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
|
|
|
344
346
|
* would take Esc as "select nothing" and Delete as "delete the part".
|
|
345
347
|
*/
|
|
346
348
|
private keys = (e: KeyboardEvent) => {
|
|
349
|
+
if (!this.active) return
|
|
347
350
|
const inField = (e.composedPath()[0] as HTMLElement)?.tagName === 'INPUT'
|
|
348
351
|
/* A number being typed in the inside's drawing: Esc there puts the number back, not the editor away. */
|
|
349
352
|
if (inField && e.composedPath().some(el => (el as HTMLElement).tagName === 'FIGURE-CAVITY-DRAWING')) return
|
package/client/modeller/keys.ts
CHANGED
|
@@ -31,6 +31,11 @@ export interface KeyLike {
|
|
|
31
31
|
* Passing a key the scene already took would toggle the gizmo off again.
|
|
32
32
|
*/
|
|
33
33
|
inScene?: boolean
|
|
34
|
+
/**
|
|
35
|
+
* The preview is in front: it shows the figure, it does not edit it. A key that adds, takes away or moves a part is
|
|
36
|
+
* not taken there -- Backspace removed the part picked in the edit canvas, now hidden (2026-11-01 audit).
|
|
37
|
+
*/
|
|
38
|
+
previewing?: boolean
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
const TYPING = new Set(['INPUT', 'SELECT', 'TEXTAREA'])
|
|
@@ -43,12 +48,13 @@ export function keyActionOf(e: KeyLike, selected: string): KeyAction | null {
|
|
|
43
48
|
if (mod && key === 'z') return e.shiftKey ? 'redo' : 'undo'
|
|
44
49
|
if (mod && key === 'y') return 'redo'
|
|
45
50
|
if (mod && key === 'c') return selected ? 'copy' : 'hold'
|
|
46
|
-
if (mod && key === 'v') return e.shiftKey ? 'hold' : 'paste'
|
|
51
|
+
if (mod && key === 'v') return e.shiftKey || e.previewing ? 'hold' : 'paste'
|
|
47
52
|
if (mod && key === 'x') return 'hold'
|
|
48
53
|
if (!selected) return null
|
|
54
|
+
if (!mod && key === 'escape') return 'deselect'
|
|
55
|
+
if (e.previewing) return null
|
|
49
56
|
if (mod && key === 'd') return 'duplicate'
|
|
50
57
|
if (!mod && (key === 'delete' || key === 'backspace')) return 'remove'
|
|
51
|
-
if (!mod && key === 'escape') return 'deselect'
|
|
52
58
|
if (!mod && !e.shiftKey && !e.inScene && GIZMO_KEYS[key]) return GIZMO_KEYS[key]
|
|
53
59
|
return null
|
|
54
60
|
}
|
|
@@ -5,6 +5,8 @@ import { keyActionOf, type KeyAction } from './keys.js'
|
|
|
5
5
|
export interface ModellerKeysHost {
|
|
6
6
|
/** Whether the modeller takes keys now: a figure open, not loading or saving, and no section editor open. */
|
|
7
7
|
taking(): boolean
|
|
8
|
+
/** Whether the preview is in front of the edit canvas: it takes no key that edits parts (`keys.ts`). */
|
|
9
|
+
previewing(): boolean
|
|
8
10
|
/** The scene, whose keys the modeller's are too. */
|
|
9
11
|
scene(): Element | null
|
|
10
12
|
selected(): string
|
|
@@ -49,7 +51,8 @@ export class ModellerKeys implements ReactiveController {
|
|
|
49
51
|
shiftKey: e.shiftKey,
|
|
50
52
|
altKey: e.altKey,
|
|
51
53
|
target: path[0] as HTMLElement,
|
|
52
|
-
inScene: !!canvas && path.includes(canvas)
|
|
54
|
+
inScene: !!canvas && path.includes(canvas),
|
|
55
|
+
previewing: this.of.previewing()
|
|
53
56
|
},
|
|
54
57
|
this.of.selected()
|
|
55
58
|
)
|
|
@@ -35,6 +35,17 @@ export interface FigureProductRecord {
|
|
|
35
35
|
source: string
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The row's catalogue fields -- the type (before release), the category, the tags. Kept on the row, not in the asset,
|
|
40
|
+
* and saved with it by 「저장」: written at once on change, they stayed after 「저장하지 않고 떠나기」 and the save
|
|
41
|
+
* button never lit for them (2026-11-01 audit item 5).
|
|
42
|
+
*/
|
|
43
|
+
export interface FigureCatalogPatch {
|
|
44
|
+
type?: string
|
|
45
|
+
category?: string
|
|
46
|
+
tags?: string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
/** Persistence is injected; neither the model engine nor the renderer owns the product ID. */
|
|
39
50
|
export interface FigureProductStore {
|
|
40
51
|
load(id: string): Promise<FigureProductRecord>
|
|
@@ -54,8 +65,12 @@ export interface FigureProductStore {
|
|
|
54
65
|
/** Keys of the AI proposals whose steps the source holds: the server records them with the save. */
|
|
55
66
|
proposalKeys?: string[]
|
|
56
67
|
}): Promise<FigureProductRecord>
|
|
68
|
+
/** Writes the row's catalogue fields of a saved figure. */
|
|
69
|
+
updateCatalog(id: string, patch: FigureCatalogPatch): Promise<unknown>
|
|
57
70
|
}
|
|
58
71
|
|
|
72
|
+
const sameValue = (a: unknown, b: unknown) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null)
|
|
73
|
+
|
|
59
74
|
/** Product lifecycle boundary. A late response can never change another editor's asset or ID. */
|
|
60
75
|
export function createFigureProductSession(store: FigureProductStore) {
|
|
61
76
|
const workspace = createFigureEditorWorkspace()
|
|
@@ -73,6 +88,22 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
73
88
|
*/
|
|
74
89
|
let done: Array<string | undefined> = [],
|
|
75
90
|
undone: Array<string | undefined> = []
|
|
91
|
+
/*
|
|
92
|
+
A save refused because the server holds another source than the one opened (FIGURE_SOURCE_CONFLICT, not one of
|
|
93
|
+
this session's own sends): the edits stay, and the server's latest is kept here to compare with and, if the person
|
|
94
|
+
says so, to write over (2026-11-01 audit item 6). Undefined source: the latest could not be read.
|
|
95
|
+
*/
|
|
96
|
+
let conflict: { source?: string } | undefined
|
|
97
|
+
/* The catalogue fields changed and not saved yet; none of the history -- undo is the figure's steps. */
|
|
98
|
+
let catalog: FigureCatalogPatch = {}
|
|
99
|
+
/* The catalogue fields a save is writing now: what the row will hold, not what it held, until it is done. */
|
|
100
|
+
let catalogSending: FigureCatalogPatch | undefined
|
|
101
|
+
/*
|
|
102
|
+
The catalogue fields the last save wrote, known to be on the row from the moment it answered: a screen compares
|
|
103
|
+
with them before it has read the row again (2026-11-01 third re-check). Empty from the start of each save.
|
|
104
|
+
*/
|
|
105
|
+
let catalogWritten: FigureCatalogPatch = {}
|
|
106
|
+
const catalogHeld = () => Object.keys(catalog).length > 0
|
|
76
107
|
/* The author's size ends the last step moved without being asked (ADR-0087 decision 6), told once. */
|
|
77
108
|
let fitted: FigureSizeRangeFit[] = []
|
|
78
109
|
function tracked<T>(kind: 'do' | 'undo' | 'redo', run: () => T, tag?: string, setsRange = false): T {
|
|
@@ -94,7 +125,8 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
94
125
|
conversation is anchored on it, and its first save inserts the row under it, so the name holds across that save.
|
|
95
126
|
*/
|
|
96
127
|
function read() {
|
|
97
|
-
|
|
128
|
+
const view = workspace.read()
|
|
129
|
+
return { ...view, dirty: view.dirty || catalogHeld(), catalog: { ...catalog }, catalogWritten: { ...catalogWritten }, conflict: conflict ? { latest: conflict.source } : undefined, id, creationId, loading, saving, error }
|
|
98
130
|
}
|
|
99
131
|
/*
|
|
100
132
|
The version on screen, one object for as long as it is the version: the workspace hands out one asset per version
|
|
@@ -146,12 +178,15 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
146
178
|
error = undefined
|
|
147
179
|
saving = false
|
|
148
180
|
creationId = undefined
|
|
181
|
+
catalog = {}
|
|
182
|
+
catalogWritten = {}
|
|
183
|
+
conflict = undefined
|
|
149
184
|
done = []
|
|
150
185
|
undone = []
|
|
151
186
|
}
|
|
152
187
|
function canLeave(discard: boolean) {
|
|
153
188
|
if (saving) throw Error('저장이 끝난 뒤 모델을 전환해 주세요.')
|
|
154
|
-
if (workspace.read().dirty && !discard) throw Error('저장하지 않은 변경이 있습니다.')
|
|
189
|
+
if ((workspace.read().dirty || catalogHeld()) && !discard) throw Error('저장하지 않은 변경이 있습니다.')
|
|
155
190
|
}
|
|
156
191
|
return {
|
|
157
192
|
read,
|
|
@@ -162,6 +197,52 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
162
197
|
* -- the server writes over it only while it still holds this. Nothing for a figure not saved yet.
|
|
163
198
|
*/
|
|
164
199
|
confirmedSource: () => (id ? baseline : undefined),
|
|
200
|
+
/**
|
|
201
|
+
* A catalogue field changed, held for the next save; one changed back to what the row holds (`saved`) is no
|
|
202
|
+
* change. Only a saved figure has a row to write: a new figure's type goes with its first save. Taken while a save
|
|
203
|
+
* is on its way too: the field shows it, so it is held for the save after (2026-11-01 re-check). A field that save
|
|
204
|
+
* is writing is held whatever it is changed to -- back to the old value too, which the save is about to replace;
|
|
205
|
+
* the save done lets go only of a value it wrote (2026-11-01 second re-check).
|
|
206
|
+
*/
|
|
207
|
+
editCatalog(patch: FigureCatalogPatch, saved: FigureCatalogPatch) {
|
|
208
|
+
if (!id) throw refusal('SESSION_NOT_SAVED', '먼저 저장해 주세요.')
|
|
209
|
+
const next = { ...catalog }
|
|
210
|
+
for (const key of Object.keys(patch) as (keyof FigureCatalogPatch)[]) {
|
|
211
|
+
if (!(catalogSending && key in catalogSending) && sameValue(patch[key], saved[key])) delete next[key]
|
|
212
|
+
else (next as Record<string, unknown>)[key] = patch[key]
|
|
213
|
+
}
|
|
214
|
+
catalog = next
|
|
215
|
+
return read()
|
|
216
|
+
},
|
|
217
|
+
/**
|
|
218
|
+
* After a conflict, the edits written over the server's latest: the next save expects that latest, so it goes
|
|
219
|
+
* through only while the server still holds it -- a third change meanwhile is a conflict again.
|
|
220
|
+
*/
|
|
221
|
+
overwriteConflict() {
|
|
222
|
+
if (saving) throw refusal('SESSION_SAVING', '저장이 끝난 뒤 편집해 주세요.')
|
|
223
|
+
if (!conflict?.source) throw refusal('SESSION_NO_CONFLICT', '덮어쓸 최신본을 읽지 못했습니다.')
|
|
224
|
+
baseline = conflict.source
|
|
225
|
+
conflict = undefined
|
|
226
|
+
unconfirmed.clear()
|
|
227
|
+
return read()
|
|
228
|
+
},
|
|
229
|
+
/**
|
|
230
|
+
* After a conflict, the edits kept as a figure of their own: from here they are a new figure, under a new creation
|
|
231
|
+
* id, saved by the first save's question; the figure they came from is left as the server holds it.
|
|
232
|
+
*/
|
|
233
|
+
detachAsCopy() {
|
|
234
|
+
if (saving) throw refusal('SESSION_SAVING', '저장이 끝난 뒤 편집해 주세요.')
|
|
235
|
+
if (!conflict) throw refusal('SESSION_NO_CONFLICT', '충돌한 저장이 없습니다.')
|
|
236
|
+
id = undefined
|
|
237
|
+
baseline = undefined
|
|
238
|
+
creationId = crypto.randomUUID()
|
|
239
|
+
conflict = undefined
|
|
240
|
+
/* The category and tags changed go with the copy, written once its first save gives it a row; its type is asked anew. */
|
|
241
|
+
const { type: _type, ...kept } = catalog
|
|
242
|
+
catalog = kept
|
|
243
|
+
unconfirmed.clear()
|
|
244
|
+
return read()
|
|
245
|
+
},
|
|
165
246
|
/** A new figure, named in the screen's language by the page: the session has no words of its own. */
|
|
166
247
|
startNew({ discard = false, name }: { discard?: boolean; name: string }) {
|
|
167
248
|
canLeave(discard)
|
|
@@ -301,8 +382,30 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
301
382
|
const exported = workspace.export(control)
|
|
302
383
|
/* Read with the text it goes with: an edit while saving is not what this save holds. */
|
|
303
384
|
const proposalKeys = inEffect()
|
|
385
|
+
const sentCatalog = { ...catalog }
|
|
304
386
|
saving = true
|
|
387
|
+
catalogSending = sentCatalog
|
|
388
|
+
catalogWritten = {}
|
|
305
389
|
try {
|
|
390
|
+
/* Only the catalogue changed: the source is not written again -- a released figure would be made a draft for nothing. */
|
|
391
|
+
if (id === undefined || workspace.read().dirty) await saveSource()
|
|
392
|
+
if (requestEpoch !== epoch) return read()
|
|
393
|
+
if (id !== undefined && Object.keys(sentCatalog).length) {
|
|
394
|
+
await store.updateCatalog(id, sentCatalog)
|
|
395
|
+
if (requestEpoch !== epoch) return read()
|
|
396
|
+
catalogWritten = sentCatalog
|
|
397
|
+
/* What was sent is saved; a field changed again while it was on its way stays held. */
|
|
398
|
+
const next = { ...catalog }
|
|
399
|
+
for (const key of Object.keys(sentCatalog) as (keyof FigureCatalogPatch)[]) if (sameValue(next[key], sentCatalog[key])) delete next[key]
|
|
400
|
+
catalog = next
|
|
401
|
+
}
|
|
402
|
+
} finally {
|
|
403
|
+
if (requestEpoch === epoch) saving = false
|
|
404
|
+
catalogSending = undefined
|
|
405
|
+
}
|
|
406
|
+
return read()
|
|
407
|
+
|
|
408
|
+
async function saveSource() {
|
|
306
409
|
let record: FigureProductRecord
|
|
307
410
|
try {
|
|
308
411
|
record = await store.save({ id, source: exported.text, expectedSource: baseline, creationId, thumbnail, proposalKeys, ...(id || !type ? {} : { type }), ...(id || !namespace ? {} : { namespace }) })
|
|
@@ -341,6 +444,7 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
341
444
|
record = await store.save({ id, source: exported.text, expectedSource: baseline, creationId, thumbnail, proposalKeys, ...(id || !type ? {} : { type }), ...(id || !namespace ? {} : { namespace }) })
|
|
342
445
|
} else {
|
|
343
446
|
if (code === 'SAVE_UNREACHABLE') unconfirmed.set(exported.text, { thumbnail })
|
|
447
|
+
if (code === 'FIGURE_SOURCE_CONFLICT' && requestEpoch === epoch) conflict = { source: landed?.source }
|
|
344
448
|
throw error
|
|
345
449
|
}
|
|
346
450
|
}
|
|
@@ -352,15 +456,13 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
352
456
|
id = record.id
|
|
353
457
|
baseline = record.source
|
|
354
458
|
unconfirmed.clear()
|
|
459
|
+
conflict = undefined
|
|
355
460
|
/*
|
|
356
461
|
Edited while it was saving: what was sent is saved, the edits since are not -- they stay, and the model stays
|
|
357
462
|
unsaved (the workspace refuses a stale acknowledgement). Never marked saved, never wiped.
|
|
358
463
|
*/
|
|
359
464
|
if (workspace.read().revision === exported.revision) workspace.acknowledgeSaved(exported)
|
|
360
|
-
} finally {
|
|
361
|
-
if (requestEpoch === epoch) saving = false
|
|
362
465
|
}
|
|
363
|
-
return read()
|
|
364
466
|
}
|
|
365
467
|
}
|
|
366
468
|
}
|