@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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { FigureAsset } from '@hatiolab/figure-model'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* A save refused because the server holds another source than the one this editor opened (FIGURE_SOURCE_CONFLICT):
|
|
5
|
+
* what differs between the edits on screen and the server's latest, by part, so the person chooses with it in view --
|
|
6
|
+
* keep the edits as a copy, look at the latest, or write over it (2026-11-01 audit item 6). Read apart from the screen
|
|
7
|
+
* so the rule is tested without a browser.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface SaveConflictChanges {
|
|
11
|
+
/** Parts on both sides, made differently. */
|
|
12
|
+
changed: string[]
|
|
13
|
+
/** Parts only the edits on screen have. */
|
|
14
|
+
mineOnly: string[]
|
|
15
|
+
/** Parts only the server's latest has. */
|
|
16
|
+
theirsOnly: string[]
|
|
17
|
+
/** Something not of one part differs too: the name, the figure's inputs, its capabilities, its clips... */
|
|
18
|
+
otherChanged: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Node {
|
|
22
|
+
id: string
|
|
23
|
+
op: string
|
|
24
|
+
}
|
|
25
|
+
interface Model {
|
|
26
|
+
nodes: Node[]
|
|
27
|
+
constants?: { id: string }[]
|
|
28
|
+
inputs?: { id: string }[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Look = { target?: unknown }
|
|
32
|
+
const looksOf = (asset: FigureAsset) => ((asset as unknown as { appearance?: Look[] }).appearance ?? [])
|
|
33
|
+
|
|
34
|
+
/** Every node, constant and input by id, each with the values the figure gives it; a part's look under the part. */
|
|
35
|
+
function itemsOf(asset: FigureAsset): Map<string, string> {
|
|
36
|
+
const model = asset.document.model as unknown as Model
|
|
37
|
+
const design = (asset.designInputs ?? {}) as Record<string, unknown>
|
|
38
|
+
const state = (asset.stateDefaults ?? {}) as Record<string, unknown>
|
|
39
|
+
const items = new Map<string, string>()
|
|
40
|
+
for (const node of model.nodes) items.set(node.id, JSON.stringify(['node', node]))
|
|
41
|
+
for (const constant of model.constants ?? []) items.set(constant.id, JSON.stringify(['constant', constant]))
|
|
42
|
+
for (const input of model.inputs ?? []) items.set(input.id, JSON.stringify(['input', input, design[input.id] ?? null, state[input.id] ?? null]))
|
|
43
|
+
const looks = new Map<string, Look[]>()
|
|
44
|
+
for (const look of looksOf(asset)) if (typeof look.target === 'string') looks.set(look.target, [...(looks.get(look.target) ?? []), look])
|
|
45
|
+
for (const [target, of] of looks) items.set(`${target}.appearance`, JSON.stringify(['look', of]))
|
|
46
|
+
return items
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A part and all it is made of: figure-model names a part's nodes, constants and inputs under it (`lid.shape`,
|
|
51
|
+
* `lid.size.y`), and its arguments point at their outputs, not at node ids.
|
|
52
|
+
*/
|
|
53
|
+
function closureOf(asset: FigureAsset, part: string): Set<string> {
|
|
54
|
+
return new Set([...itemsOf(asset).keys()].filter(id => id === part || id.startsWith(part + '.')))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const partsOf = (asset: FigureAsset) => (asset.document.model as unknown as Model).nodes.filter(node => node.op === 'place@1').map(node => node.id)
|
|
58
|
+
|
|
59
|
+
const madeOf = (items: Map<string, string>, ids: Set<string>) => JSON.stringify([...ids].sort().map(id => [id, items.get(id) ?? null]))
|
|
60
|
+
|
|
61
|
+
/** Everything of an asset but its items: its name, palette, the look not of one part, capabilities, version. */
|
|
62
|
+
function restOf(asset: FigureAsset): string {
|
|
63
|
+
const { document, designInputs: _design, stateDefaults: _state, appearance: _looks, ...rest } = asset as FigureAsset & Record<string, unknown>
|
|
64
|
+
const { model: _model, ...documentRest } = document as unknown as Record<string, unknown>
|
|
65
|
+
return JSON.stringify([rest, documentRest, looksOf(asset).filter(look => typeof look.target !== 'string')])
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function saveConflictChangesOf(mine: FigureAsset, theirs: FigureAsset): SaveConflictChanges {
|
|
69
|
+
const mineItems = itemsOf(mine),
|
|
70
|
+
theirItems = itemsOf(theirs)
|
|
71
|
+
const mineParts = partsOf(mine),
|
|
72
|
+
theirParts = partsOf(theirs)
|
|
73
|
+
const covered = new Set<string>()
|
|
74
|
+
const changed: string[] = []
|
|
75
|
+
for (const part of mineParts) {
|
|
76
|
+
const ours = closureOf(mine, part)
|
|
77
|
+
ours.forEach(id => covered.add(id))
|
|
78
|
+
if (!theirParts.includes(part)) continue
|
|
79
|
+
const their = closureOf(theirs, part)
|
|
80
|
+
their.forEach(id => covered.add(id))
|
|
81
|
+
if (madeOf(mineItems, ours) !== madeOf(theirItems, their)) changed.push(part)
|
|
82
|
+
}
|
|
83
|
+
for (const part of theirParts) if (!mineParts.includes(part)) closureOf(theirs, part).forEach(id => covered.add(id))
|
|
84
|
+
const outside = (items: Map<string, string>) => JSON.stringify([...items].filter(([id]) => !covered.has(id)).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
|
|
85
|
+
return {
|
|
86
|
+
changed,
|
|
87
|
+
mineOnly: mineParts.filter(part => !theirParts.includes(part)),
|
|
88
|
+
theirsOnly: theirParts.filter(part => !mineParts.includes(part)),
|
|
89
|
+
otherChanged: restOf(mine) !== restOf(theirs) || outside(mineItems) !== outside(theirItems)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -3,10 +3,10 @@ import { customElement, state } from 'lit/decorators.js'
|
|
|
3
3
|
import { navigate, PageView } from '@operato/shell'
|
|
4
4
|
import { getPathInfo } from '@operato/utils'
|
|
5
5
|
import { modellerPathOf } from '../modeller/modeller-route.js'
|
|
6
|
-
import { createFigureProductSession } from '../modeller/product-session.js'
|
|
6
|
+
import { createFigureProductSession, type FigureCatalogPatch } from '../modeller/product-session.js'
|
|
7
7
|
import { figureProductStore } from '../graphql/store.js'
|
|
8
8
|
import { i18next } from '@operato/i18n'
|
|
9
|
-
import { fetchFigure, fetchFigureCategories, fetchFigureNamespaces, fetchFigureTypeNames
|
|
9
|
+
import { fetchFigure, fetchFigureCategories, fetchFigureNamespaces, fetchFigureTypeNames } from '../graphql/index.js'
|
|
10
10
|
import { figureProposalOutcome } from '../figure-assistant.js'
|
|
11
11
|
import { availableRecipes, recipeGuide } from '../modeller/recipe-source.js'
|
|
12
12
|
import type { RecipeTemplate } from '../modeller/recipes.js'
|
|
@@ -141,13 +141,15 @@ export class FigureModellerPage extends PageView {
|
|
|
141
141
|
@state() private row: { state?: 'draft' | 'released'; version: number; namespace?: string; type?: string; category?: string; tags?: string[] } = {
|
|
142
142
|
version: 0
|
|
143
143
|
}
|
|
144
|
-
@state() private typeError = ''
|
|
145
144
|
/** Why a command from the side did nothing. Shown beside the tabs — the editor stays, since nothing in it was lost. */
|
|
146
145
|
@state() private commandError = ''
|
|
147
146
|
/** What the verdict says blocks release now, handed to the editor's bar. */
|
|
148
147
|
@state() private releaseBlockers: FigureGateFinding[] = []
|
|
149
148
|
@state() private budget?: FigureBudgetLine
|
|
150
149
|
private wasDirty = false
|
|
150
|
+
private wasSaving = false
|
|
151
|
+
/** The row reading asked last: an answer to an earlier one, come back after, is not the row now. */
|
|
152
|
+
private rowReading = 0
|
|
151
153
|
private titled?: string | null
|
|
152
154
|
/** The figure the AI dock was last told about - another id is another conversation. */
|
|
153
155
|
private assistantFor?: string
|
|
@@ -229,8 +231,21 @@ export class FigureModellerPage extends PageView {
|
|
|
229
231
|
navigate(modellerPathOf({ id: view.id }), true)
|
|
230
232
|
}
|
|
231
233
|
/* 저장이 끝난 순간 행을 다시 읽는다 — 발행된 피규어를 고쳐 저장하면 서버가 초안으로 내린다. */
|
|
232
|
-
|
|
234
|
+
/*
|
|
235
|
+
Read again after every save that ends, not only one that leaves nothing unsaved: a field changed while it was
|
|
236
|
+
being written is still held, and is compared with what the row holds now (2026-11-01 second re-check).
|
|
237
|
+
*/
|
|
238
|
+
if (view.id && !view.saving && ((this.wasDirty && !view.dirty) || this.wasSaving)) {
|
|
239
|
+
/*
|
|
240
|
+
What the save wrote is the row's from the moment it answered: the next change is compared with it at once, not
|
|
241
|
+
with the row as it was until the reading comes back -- a value typed in between was let go, then written over
|
|
242
|
+
(2026-11-01 third re-check). The old ones do not flash back either.
|
|
243
|
+
*/
|
|
244
|
+
if (this.wasSaving) this.row = { ...this.row, ...view.catalogWritten }
|
|
245
|
+
this.readRow(view.id)
|
|
246
|
+
}
|
|
233
247
|
this.wasDirty = !!view.dirty
|
|
248
|
+
this.wasSaving = !!view.saving
|
|
234
249
|
}
|
|
235
250
|
/*
|
|
236
251
|
A released figure changed on the server -- its source saved, or its category or tags -- comes back a draft, and a
|
|
@@ -242,8 +257,10 @@ export class FigureModellerPage extends PageView {
|
|
|
242
257
|
if (before === 'released' && after === 'draft') notify({ level: 'info', message: i18next.t('figure.text.released-now-draft') })
|
|
243
258
|
}
|
|
244
259
|
private async readRow(id: string) {
|
|
260
|
+
const reading = ++this.rowReading
|
|
245
261
|
try {
|
|
246
262
|
const figure = await fetchFigure(id)
|
|
263
|
+
if (reading !== this.rowReading) return
|
|
247
264
|
if (this.session.read().id === id) this.toldDemoted(this.row.state, figure.state)
|
|
248
265
|
if (this.session.read().id === id)
|
|
249
266
|
this.row = {
|
|
@@ -279,15 +296,24 @@ export class FigureModellerPage extends PageView {
|
|
|
279
296
|
this.row = { ...this.row, type: wanted || undefined }
|
|
280
297
|
return
|
|
281
298
|
}
|
|
282
|
-
if (!wanted
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
this.typeError = ''
|
|
286
|
-
this.row = { ...this.row, type: result.figure.type }
|
|
287
|
-
} catch (error) {
|
|
288
|
-
this.typeError = error instanceof Error ? error.message : String(error)
|
|
289
|
-
input.value = this.row.type ?? ''
|
|
299
|
+
if (!wanted) {
|
|
300
|
+
input.value = this.shownRow().type ?? ''
|
|
301
|
+
return
|
|
290
302
|
}
|
|
303
|
+
this.editCatalog({ type: wanted })
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Category, tags and type: the catalogue's, kept on the row, not in the asset -- held by the session and written by
|
|
307
|
+
* 「저장」 with the figure (2026-11-01 audit item 5). The server's refusal is said where a save's is.
|
|
308
|
+
*/
|
|
309
|
+
private editCatalog(patch: FigureCatalogPatch) {
|
|
310
|
+
const { type, category, tags } = this.row
|
|
311
|
+
this.session.editCatalog(patch, { type, category, tags })
|
|
312
|
+
this.changed()
|
|
313
|
+
}
|
|
314
|
+
/** The row as the screen shows it: what the server holds, with the catalogue fields changed and not saved yet. */
|
|
315
|
+
private shownRow() {
|
|
316
|
+
return { ...this.row, ...this.session.read().catalog }
|
|
291
317
|
}
|
|
292
318
|
/** A command from the properties tab — rename, the mounting face. */
|
|
293
319
|
private onCommand = (event: Event) => {
|
|
@@ -312,29 +338,18 @@ export class FigureModellerPage extends PageView {
|
|
|
312
338
|
: String(error)
|
|
313
339
|
}
|
|
314
340
|
}
|
|
315
|
-
|
|
316
|
-
* Category and tags — the catalogue's, kept on the row, not in the asset. Written at once; the server's
|
|
317
|
-
* refusal is shown as it gave it.
|
|
318
|
-
*/
|
|
319
|
-
private onCatalog = async (event: Event) => {
|
|
341
|
+
private onCatalog = (event: Event) => {
|
|
320
342
|
const patch = (event as CustomEvent).detail as { category?: string; tags?: string[] }
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
tags: result.figure.tags ?? undefined,
|
|
332
|
-
...(result.figure.state ? { state: result.figure.state as 'draft' | 'released' } : {}),
|
|
333
|
-
...(result.figure.version !== undefined ? { version: result.figure.version ?? 0 } : {})
|
|
334
|
-
}
|
|
335
|
-
} catch (error) {
|
|
336
|
-
this.commandError = error instanceof Error ? error.message : String(error)
|
|
337
|
-
}
|
|
343
|
+
if (this.session.read().id && patch) this.editCatalog(patch)
|
|
344
|
+
}
|
|
345
|
+
/*
|
|
346
|
+
The edits kept as a copy after a conflict: a new figure from here. The row and address of the figure left are let
|
|
347
|
+
go, so the first save asks a new identifier and the copy's address replaces this one once saved.
|
|
348
|
+
*/
|
|
349
|
+
private detached = () => {
|
|
350
|
+
this.row = { version: 0 }
|
|
351
|
+
this.routeId = undefined
|
|
352
|
+
this.withdrawAssistant()
|
|
338
353
|
}
|
|
339
354
|
private released = (event: Event) => {
|
|
340
355
|
const figure = (event as CustomEvent).detail?.figure
|
|
@@ -376,10 +391,11 @@ export class FigureModellerPage extends PageView {
|
|
|
376
391
|
}
|
|
377
392
|
/*
|
|
378
393
|
Reloading or closing the tab is not a move the shell asks `canDeactivate` about: a new figure not yet saved was lost
|
|
379
|
-
without a word (2026-09-26). The browser's own question is the only one it allows there.
|
|
394
|
+
without a word (2026-09-26). The browser's own question is the only one it allows there. Asked for a page left
|
|
395
|
+
behind too: the shell keeps it, with its edits, and closing the tab loses them all the same (2026-11-01 audit).
|
|
380
396
|
*/
|
|
381
397
|
private holdUnsaved = (event: BeforeUnloadEvent) => {
|
|
382
|
-
if (!this.
|
|
398
|
+
if (!this.session.read().dirty) return
|
|
383
399
|
event.preventDefault()
|
|
384
400
|
event.returnValue = ''
|
|
385
401
|
}
|
|
@@ -444,7 +460,6 @@ export class FigureModellerPage extends PageView {
|
|
|
444
460
|
if (id) {
|
|
445
461
|
/* The row is read after the figure opens; until then the last figure's type must not stand in the field (C2). */
|
|
446
462
|
this.row = { version: 0 }
|
|
447
|
-
this.typeError = ''
|
|
448
463
|
const opening = this.session.open(id, { discard })
|
|
449
464
|
this.changed()
|
|
450
465
|
await opening
|
|
@@ -452,7 +467,6 @@ export class FigureModellerPage extends PageView {
|
|
|
452
467
|
} else {
|
|
453
468
|
this.session.startNew({ discard, name: i18next.t('figure.text.new-figure-name') })
|
|
454
469
|
this.row = { version: 0 }
|
|
455
|
-
this.typeError = ''
|
|
456
470
|
this.takeNewFigureProposal()
|
|
457
471
|
}
|
|
458
472
|
this.routeId = id
|
|
@@ -517,16 +531,18 @@ export class FigureModellerPage extends PageView {
|
|
|
517
531
|
render() {
|
|
518
532
|
if (this.error) return html`<p role="alert" class="error">${this.error}</p>`
|
|
519
533
|
const view = this.session.read()
|
|
534
|
+
const shown = this.shownRow()
|
|
520
535
|
/* A new figure's third way to start: the app's assistant dock, with a request put in its input for the author to finish and send (B, U04). */
|
|
521
|
-
return html`<figure-editor .session=${this.session} .releaseState=${this.row} .releaseBlockers=${this.releaseBlockers} .templates=${this.templates} .guideOf=${this.guideOf} .thumbnailOf=${this.thumbnailOf} .firstSave=${this.askFirstSave} .revision=${this.revision} .budget=${this.budget} aiDraft @ask-ai-draft=${() => askAssistant({ draft: i18next.t('figure.text.ai-draft-request') })} @changed=${this.changed}>
|
|
536
|
+
return html`<figure-editor .session=${this.session} .releaseState=${this.row} .releaseBlockers=${this.releaseBlockers} .templates=${this.templates} .guideOf=${this.guideOf} .thumbnailOf=${this.thumbnailOf} .firstSave=${this.askFirstSave} .revision=${this.revision} .budget=${this.budget} ?active=${this.active} aiDraft @figure-detached=${this.detached} @ask-ai-draft=${() => askAssistant({ draft: i18next.t('figure.text.ai-draft-request') })} @changed=${this.changed}>
|
|
522
537
|
${this.commandError ? html`<p slot="notice" fit-error role="alert">${this.commandError}</p>` : ''}
|
|
523
538
|
<figure-properties
|
|
524
539
|
slot="figure"
|
|
525
540
|
.revision=${this.session.revisionNow()}
|
|
526
541
|
.figureId=${view.id}
|
|
527
|
-
.figureCategory=${
|
|
542
|
+
.figureCategory=${shown.category}
|
|
543
|
+
.figureCategorySaved=${this.row.category}
|
|
528
544
|
.figureCategories=${this.categories}
|
|
529
|
-
.figureTags=${
|
|
545
|
+
.figureTags=${shown.tags}
|
|
530
546
|
@command=${this.onCommand}
|
|
531
547
|
@catalog=${this.onCatalog}
|
|
532
548
|
>
|
|
@@ -535,11 +551,10 @@ export class FigureModellerPage extends PageView {
|
|
|
535
551
|
${this.row.version > 0
|
|
536
552
|
? html`<code>${this.row.type}</code><small>${i18next.t('figure.text.type-name-cannot-change')}</small>`
|
|
537
553
|
: html`<input
|
|
538
|
-
.value=${
|
|
554
|
+
.value=${shown.type ?? ''}
|
|
539
555
|
placeholder=${i18next.t('figure.text.figure-type-placeholder')}
|
|
540
556
|
@change=${this.renameType}
|
|
541
557
|
/>`}
|
|
542
|
-
${this.typeError ? html`<small role="alert">${this.typeError}</small>` : ''}
|
|
543
558
|
</label>
|
|
544
559
|
</figure-properties>
|
|
545
560
|
<figure-verdict
|
|
@@ -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
|
/** source is sent intact. No conversion into the old parts/base authoring representation. */
|
|
6
6
|
export const figureProductStore = {
|
|
@@ -58,6 +58,7 @@ export const figureProductStore = {
|
|
|
58
58
|
if (!result?.id || !result.source)
|
|
59
59
|
throw Error('서버가 저장 결과를 돌려주지 않았습니다.');
|
|
60
60
|
return result;
|
|
61
|
-
}
|
|
61
|
+
},
|
|
62
|
+
updateCatalog: (id, patch) => updateFigure(id, patch)
|
|
62
63
|
};
|
|
63
64
|
//# sourceMappingURL=store.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.js","sourceRoot":"","sources":["../../client/graphql/store.ts"],"names":[],"mappings":"AAAA,OAAO,GAAG,MAAM,aAAa,CAAA;AAC7B,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;
|
|
1
|
+
{"version":3,"file":"store.js","sourceRoot":"","sources":["../../client/graphql/store.ts"],"names":[],"mappings":"AAAA,OAAO,GAAG,MAAM,aAAa,CAAA;AAC7B,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AACzC,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AACtD,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAGtC,6FAA6F;AAC7F,MAAM,CAAC,MAAM,kBAAkB,GAAuB;IACpD,KAAK,CAAC,IAAI,CAAC,EAAE;QACX,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,EAAE,CAAC,CAAA;QACpC,IAAI,CAAC,MAAM,EAAE,MAAM;YAAE,MAAM,KAAK,CAAC,kBAAkB,CAAC,CAAA;QACpD,OAAO,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAA;IACjD,CAAC;IACD,4GAA4G;IAC5G,KAAK,CAAC,QAAQ,CAAC,EAAE;QACf,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC;YAClC,KAAK,EAAE,GAAG,CAAA;;;;;;;;;OAST;YACD,SAAS,EAAE,EAAE,EAAE,EAAE;YACjB,WAAW,EAAE,cAAc;SAC5B,CAAC,CAAA;QACF,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,EAAE,MAAkG,CAAA;QAChI,IAAI,CAAC,MAAM,EAAE,MAAM;YAAE,MAAM,KAAK,CAAC,kBAAkB,CAAC,CAAA;QACpD,OAAO,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,EAAE,MAAM,CAAC,aAAa,IAAI,SAAS,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,CAAA;IACtH,CAAC;IACD,KAAK,CAAC,IAAI,CAAC,OAAO;QAChB,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC;YAC3C,QAAQ,EAAE,GAAG,CAAA;;;;;;;;;;;;;;;;OAgBZ;YACD,SAAS,EAAE,OAAO;SACnB,CAAC,CAAC,CAAA;QACH,IAAI,QAAQ,CAAC,MAAM,EAAE,MAAM;YACzB,MAAM,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,EAAE,IAAI,EAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAwC,CAAC,UAAU,EAAE,IAAI,EAAE,CAAC,CAAA;QACnK,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,EAAE,UAA6C,CAAA;QAC3E,IAAI,CAAC,MAAM,EAAE,EAAE,IAAI,CAAC,MAAM,CAAC,MAAM;YAAE,MAAM,KAAK,CAAC,wBAAwB,CAAC,CAAA;QACxE,OAAO,MAAM,CAAA;IACf,CAAC;IACD,aAAa,EAAE,CAAC,EAAE,EAAE,KAAK,EAAE,EAAE,CAAC,YAAY,CAAC,EAAE,EAAE,KAAK,CAAC;CACtD,CAAA","sourcesContent":["import gql from 'graphql-tag'\nimport { client } from '@operato/graphql'\nimport { fetchFigure, updateFigure } from './index.js'\nimport { reached } from './reached.js'\nimport type { FigureProductStore, FigureProductRecord } from '../modeller/product-session.js'\n\n/** source is sent intact. No conversion into the old parts/base authoring representation. */\nexport const figureProductStore: FigureProductStore = {\n async load(id) {\n const record = await fetchFigure(id)\n if (!record?.source) throw Error('Figure 정본이 없습니다.')\n return { id: record.id, source: record.source }\n },\n /* What the server holds of every field a save writes, read back after a save whose outcome is not known. */\n async readBack(id) {\n const response = await client.query({\n query: gql`\n query ($id: String!) {\n figure(id: $id) {\n id\n source\n thumbnailHash\n type\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n const figure = response.data?.figure as { id: string; source: string; thumbnailHash?: string | null; type?: string } | undefined\n if (!figure?.source) throw Error('Figure 정본이 없습니다.')\n return { id: figure.id, source: figure.source, thumbnailHash: figure.thumbnailHash ?? undefined, type: figure.type }\n },\n async save(request) {\n const response = await reached(client.mutate({\n mutation: gql`\n mutation SaveFigure($source: String!, $id: String, $expectedSource: String, $creationId: String, $thumbnail: String, $type: String, $proposalKeys: [String!], $namespace: String) {\n saveFigure(\n source: $source\n id: $id\n expectedSource: $expectedSource\n creationId: $creationId\n thumbnail: $thumbnail\n type: $type\n proposalKeys: $proposalKeys\n namespace: $namespace\n ) {\n id\n source\n }\n }\n `,\n variables: request\n }))\n if (response.errors?.length)\n throw Object.assign(Error(response.errors.map(e => e.message).join('\\n')), { code: (response.errors[0] as { extensions?: { code?: string } }).extensions?.code })\n const result = response.data?.saveFigure as FigureProductRecord | undefined\n if (!result?.id || !result.source) throw Error('서버가 저장 결과를 돌려주지 않았습니다.')\n return result\n },\n updateCatalog: (id, patch) => updateFigure(id, patch)\n}\n"]}
|
|
@@ -13,6 +13,7 @@ import './figure-fasten-dialog.js';
|
|
|
13
13
|
import './figure-controls.js';
|
|
14
14
|
import './figure-clips.js';
|
|
15
15
|
import './figure-data-panel.js';
|
|
16
|
+
import './figure-save-conflict.js';
|
|
16
17
|
import './figure-preview.js';
|
|
17
18
|
import './figure-settings.js';
|
|
18
19
|
import './figure-capability-panel.js';
|
|
@@ -45,6 +46,11 @@ export declare class FigureEditor extends LitElement {
|
|
|
45
46
|
/** Why the last recipe was refused, in the kernel's words. */
|
|
46
47
|
private recipeError;
|
|
47
48
|
revision: number;
|
|
49
|
+
/**
|
|
50
|
+
* The page in front (the shell's `active`). A page left keeps its editor, on the window: a Backspace pressed on
|
|
51
|
+
* another page removed the part picked here, out of sight (2026-11-01 audit). Being on the document is not being seen.
|
|
52
|
+
*/
|
|
53
|
+
active: boolean;
|
|
48
54
|
/** The 「?」 beside a group's title and the sentence it opens. */
|
|
49
55
|
private readonly help;
|
|
50
56
|
/** The cost line the parts list shows, from the maturity report. */
|
|
@@ -116,6 +122,8 @@ export declare class FigureEditor extends LitElement {
|
|
|
116
122
|
*/
|
|
117
123
|
private renderGizmoTools;
|
|
118
124
|
private keys;
|
|
125
|
+
/** 「화면 맞춤」 fits the canvas in front: in the preview it fitted the edit canvas hidden behind it (2026-11-01 audit). */
|
|
126
|
+
private fitShown;
|
|
119
127
|
/** The part Ctrl+C took: pasted as the kernel's duplicate. Not kept past the editor, nor written anywhere. */
|
|
120
128
|
private copied;
|
|
121
129
|
/** A copied part still in the figure is duplicated -- a part of its own, named by figure-model; one gone, nothing. */
|
|
@@ -166,6 +174,12 @@ export declare class FigureEditor extends LitElement {
|
|
|
166
174
|
private holdAction;
|
|
167
175
|
private changed;
|
|
168
176
|
private save;
|
|
177
|
+
/**
|
|
178
|
+
* After a conflict (2026-11-01 audit item 6): what differs from the server's latest, and the way on the person
|
|
179
|
+
* 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
|
|
180
|
+
* address, the edits staying here; writing over is asked once more, since the other's change is lost.
|
|
181
|
+
*/
|
|
182
|
+
private resolveConflict;
|
|
169
183
|
private add;
|
|
170
184
|
/**
|
|
171
185
|
* A GLB made into a mesh part (ADR-0129): the server reads and keeps it, the author picks the whole file or one node
|
|
@@ -6,9 +6,10 @@ import { isMacOS } from '@operato/utils';
|
|
|
6
6
|
import { unitWord } from './unit-words.js';
|
|
7
7
|
import '@operato/layout';
|
|
8
8
|
import { openPopup } from '@operato/layout';
|
|
9
|
+
import { OxPrompt } from '@operato/popup';
|
|
9
10
|
import { customElement, property, state } from 'lit/decorators.js';
|
|
10
11
|
import { live } from 'lit/directives/live.js';
|
|
11
|
-
import { figureFastenAxesOf, figurePartFaceOf, PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditOf, figureSizeEditorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, figureRowOf, figureFaceOverhangOf } from '@hatiolab/figure-model';
|
|
12
|
+
import { parseFigureAsset, figureFastenAxesOf, figurePartFaceOf, PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditOf, figureSizeEditorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, figureRowOf, figureFaceOverhangOf } from '@hatiolab/figure-model';
|
|
12
13
|
import './figure-recipe-catalog.js';
|
|
13
14
|
import { captureFigureThumbnail } from './thumbnail.js';
|
|
14
15
|
import { drawableFigureParts } from './renderer.js';
|
|
@@ -25,6 +26,8 @@ import './figure-fasten-dialog.js';
|
|
|
25
26
|
import './figure-controls.js';
|
|
26
27
|
import './figure-clips.js';
|
|
27
28
|
import './figure-data-panel.js';
|
|
29
|
+
import './figure-save-conflict.js';
|
|
30
|
+
import { saveConflictChangesOf } from './save-conflict.js';
|
|
28
31
|
import './figure-preview.js';
|
|
29
32
|
import './figure-settings.js';
|
|
30
33
|
import { DEFAULT_VIEW } from '@things-factory/figure-viewer/dist-client/figure-view.js';
|
|
@@ -79,6 +82,11 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
79
82
|
/** Why the last recipe was refused, in the kernel's words. */
|
|
80
83
|
this.recipeError = '';
|
|
81
84
|
this.revision = 0;
|
|
85
|
+
/**
|
|
86
|
+
* The page in front (the shell's `active`). A page left keeps its editor, on the window: a Backspace pressed on
|
|
87
|
+
* another page removed the part picked here, out of sight (2026-11-01 audit). Being on the document is not being seen.
|
|
88
|
+
*/
|
|
89
|
+
this.active = true;
|
|
82
90
|
/** The 「?」 beside a group's title and the sentence it opens. */
|
|
83
91
|
this.help = new HelpToggles(this);
|
|
84
92
|
this.selected = '';
|
|
@@ -132,8 +140,9 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
132
140
|
* The section editor open takes the keys for its own: Esc cancels it, Delete deletes a point, Ctrl+Z undoes a point.
|
|
133
141
|
* Heard here first, they deselected the part, deleted the part and undid the figure's last step (2026-09-30).
|
|
134
142
|
*/
|
|
135
|
-
return !!view && !view.loading && !view.saving && !!view.context && this.isConnected && !this.sectionEditing;
|
|
143
|
+
return !!view && !view.loading && !view.saving && !!view.context && this.isConnected && this.active && !this.sectionEditing;
|
|
136
144
|
},
|
|
145
|
+
previewing: () => this.middle === 'preview',
|
|
137
146
|
scene: () => this.renderRoot.querySelector('figure-canvas'),
|
|
138
147
|
selected: () => this.selected,
|
|
139
148
|
act: action => this.keyAction(action)
|
|
@@ -234,6 +243,13 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
234
243
|
</button>`)}
|
|
235
244
|
</span>`;
|
|
236
245
|
}
|
|
246
|
+
/** 「화면 맞춤」 fits the canvas in front: in the preview it fitted the edit canvas hidden behind it (2026-11-01 audit). */
|
|
247
|
+
fitShown() {
|
|
248
|
+
if (this.middle === 'preview')
|
|
249
|
+
this.renderRoot.querySelector('figure-preview')?.fitView();
|
|
250
|
+
else
|
|
251
|
+
this.renderRoot.querySelector('figure-canvas')?.fit();
|
|
252
|
+
}
|
|
237
253
|
/** A copied part still in the figure is duplicated -- a part of its own, named by figure-model; one gone, nothing. */
|
|
238
254
|
pasteCopied() {
|
|
239
255
|
const asset = this.session?.revisionNow()?.asset;
|
|
@@ -449,13 +465,62 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
449
465
|
this.error = '';
|
|
450
466
|
}
|
|
451
467
|
catch (error) {
|
|
468
|
+
/* Another editor saved first: the ways on are asked, not a refusal said that saving again would only repeat. */
|
|
469
|
+
if (this.session.read().conflict)
|
|
470
|
+
void this.resolveConflict();
|
|
452
471
|
/* Not reached: whether it was written is not known, and the edits are still here -- said so, not "not saved". */
|
|
453
|
-
|
|
472
|
+
else
|
|
473
|
+
this.error = error?.code === 'SAVE_UNREACHABLE' ? i18next.t('figure.text.save-unreachable') : this.said(error);
|
|
454
474
|
}
|
|
455
475
|
finally {
|
|
456
476
|
this.changed();
|
|
457
477
|
}
|
|
458
478
|
}
|
|
479
|
+
/**
|
|
480
|
+
* After a conflict (2026-11-01 audit item 6): what differs from the server's latest, and the way on the person
|
|
481
|
+
* 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
|
|
482
|
+
* address, the edits staying here; writing over is asked once more, since the other's change is lost.
|
|
483
|
+
*/
|
|
484
|
+
async resolveConflict() {
|
|
485
|
+
const session = this.session;
|
|
486
|
+
const latest = session.read().conflict?.latest;
|
|
487
|
+
const mine = session.revisionNow()?.asset;
|
|
488
|
+
let changes = null;
|
|
489
|
+
try {
|
|
490
|
+
changes = latest && mine ? saveConflictChangesOf(mine, parseFigureAsset(latest)) : null;
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
/* A latest this figure-model cannot read is not compared; it is still offered to keep a copy or to look at it. */
|
|
494
|
+
}
|
|
495
|
+
const choice = await openPopup(html `<figure-save-conflict .changes=${changes}></figure-save-conflict>`, {
|
|
496
|
+
backdrop: true,
|
|
497
|
+
size: 'small',
|
|
498
|
+
title: i18next.t('figure.title.save-conflict')
|
|
499
|
+
}).result;
|
|
500
|
+
if (this.session !== session || !session.read().conflict)
|
|
501
|
+
return;
|
|
502
|
+
if (choice === 'latest')
|
|
503
|
+
window.open(location.href, '_blank', 'noopener');
|
|
504
|
+
else if (choice === 'copy') {
|
|
505
|
+
session.detachAsCopy();
|
|
506
|
+
/* The page forgets the row of the figure left: the copy's identifier is asked, not the other's offered. */
|
|
507
|
+
this.dispatchEvent(new CustomEvent('figure-detached', { bubbles: true, composed: true }));
|
|
508
|
+
this.changed();
|
|
509
|
+
await this.save();
|
|
510
|
+
}
|
|
511
|
+
else if (choice === 'overwrite') {
|
|
512
|
+
const agreed = await OxPrompt.open({
|
|
513
|
+
type: 'warning',
|
|
514
|
+
text: i18next.t('figure.text.overwrite-latest-warning'),
|
|
515
|
+
confirmButton: { text: i18next.t('figure.button.overwrite-latest') },
|
|
516
|
+
cancelButton: { text: i18next.t('figure.button.keep-editing') }
|
|
517
|
+
});
|
|
518
|
+
if (!agreed || this.session !== session || !session.read().conflict)
|
|
519
|
+
return;
|
|
520
|
+
session.overwriteConflict();
|
|
521
|
+
await this.save();
|
|
522
|
+
}
|
|
523
|
+
}
|
|
459
524
|
add(kind) {
|
|
460
525
|
if (!this.session)
|
|
461
526
|
return;
|
|
@@ -1634,7 +1699,7 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
1634
1699
|
whatever is picked: the size tab was 「반응형」 with nothing picked and 「배치」 with a part (sweep 2026-10-27 J2).
|
|
1635
1700
|
*/
|
|
1636
1701
|
const name = i18next.t('figure.label.tab-short-' + tab);
|
|
1637
|
-
return html `<button tab ?on=${this.tab === tab} title=${label} aria-label=${name} @click=${() => (this.tab = tab)}>
|
|
1702
|
+
return html `<button tab ?on=${this.tab === tab} aria-pressed=${String(this.tab === tab)} title=${label} aria-label=${name} @click=${() => (this.tab = tab)}>
|
|
1638
1703
|
<md-icon>${icon}</md-icon><span tab-label aria-hidden="true">${name}</span>
|
|
1639
1704
|
</button>`;
|
|
1640
1705
|
}
|
|
@@ -1675,6 +1740,7 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
1675
1740
|
.kept=${kept}
|
|
1676
1741
|
.pointBlocked=${this.pointBlockedOf(drawn)}
|
|
1677
1742
|
.revision=${this.session?.read().revision ?? 0}
|
|
1743
|
+
?active=${this.active}
|
|
1678
1744
|
.faceMapping=${this.faceMapping?.from === 'section-editor' ? this.faceMapping.need : undefined}
|
|
1679
1745
|
@command=${(e) => {
|
|
1680
1746
|
/* Held for a choice of where things go: the editor stays open, the choice shown in it. */
|
|
@@ -1837,7 +1903,7 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
1837
1903
|
.pane=${this.panes}
|
|
1838
1904
|
@canvas-undo=${() => this.run(() => this.session.undo())}
|
|
1839
1905
|
@canvas-redo=${() => this.run(() => this.session.redo())}
|
|
1840
|
-
@canvas-fit=${() => this.
|
|
1906
|
+
@canvas-fit=${() => this.fitShown()}
|
|
1841
1907
|
>
|
|
1842
1908
|
${this.renderGizmoTools(!unavailable && this.middle === 'edit')}
|
|
1843
1909
|
${this.renderOverlayButton('view', 'wb_sunny', 'figure.label.tab-environment')}
|
|
@@ -2178,6 +2244,10 @@ __decorate([
|
|
|
2178
2244
|
property({ type: Number }),
|
|
2179
2245
|
__metadata("design:type", Object)
|
|
2180
2246
|
], FigureEditor.prototype, "revision", void 0);
|
|
2247
|
+
__decorate([
|
|
2248
|
+
property({ type: Boolean }),
|
|
2249
|
+
__metadata("design:type", Object)
|
|
2250
|
+
], FigureEditor.prototype, "active", void 0);
|
|
2181
2251
|
__decorate([
|
|
2182
2252
|
property({ attribute: false }),
|
|
2183
2253
|
__metadata("design:type", Object)
|