@things-factory/figure-ui 10.1.152 → 10.1.153
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/modeller/ai-proposal.ts +14 -13
- package/client/modeller/command-preview-controller.ts +210 -0
- package/client/modeller/editor-styles.ts +307 -0
- package/client/modeller/editor.ts +58 -619
- package/client/modeller/figure-first-save.ts +9 -1
- package/client/modeller/modeller-keys.ts +60 -0
- package/client/modeller/pane-fold.ts +61 -0
- package/client/modeller/panel-memory.ts +53 -0
- package/client/modeller/refusal-teller.ts +93 -0
- package/client/modeller/refusals.ts +18 -1
- package/dist-client/modeller/ai-proposal.d.ts +6 -5
- package/dist-client/modeller/ai-proposal.js +9 -9
- package/dist-client/modeller/ai-proposal.js.map +1 -1
- package/dist-client/modeller/clips.d.ts +1 -1
- package/dist-client/modeller/command-preview-controller.d.ts +75 -0
- package/dist-client/modeller/command-preview-controller.js +178 -0
- package/dist-client/modeller/command-preview-controller.js.map +1 -0
- package/dist-client/modeller/editor-styles.d.ts +2 -0
- package/dist-client/modeller/editor-styles.js +307 -0
- package/dist-client/modeller/editor-styles.js.map +1 -0
- package/dist-client/modeller/editor.d.ts +5 -50
- package/dist-client/modeller/editor.js +69 -641
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/figure-first-save.d.ts +1 -0
- package/dist-client/modeller/figure-first-save.js +8 -1
- package/dist-client/modeller/figure-first-save.js.map +1 -1
- package/dist-client/modeller/modeller-keys.d.ts +22 -0
- package/dist-client/modeller/modeller-keys.js +43 -0
- package/dist-client/modeller/modeller-keys.js.map +1 -0
- package/dist-client/modeller/pane-fold.d.ts +15 -0
- package/dist-client/modeller/pane-fold.js +60 -0
- package/dist-client/modeller/pane-fold.js.map +1 -0
- package/dist-client/modeller/panel-memory.d.ts +15 -0
- package/dist-client/modeller/panel-memory.js +54 -0
- package/dist-client/modeller/panel-memory.js.map +1 -0
- package/dist-client/modeller/refusal-teller.d.ts +36 -0
- package/dist-client/modeller/refusal-teller.js +81 -0
- package/dist-client/modeller/refusal-teller.js.map +1 -0
- package/dist-client/modeller/refusals.d.ts +10 -0
- package/dist-client/modeller/refusals.js +9 -0
- package/dist-client/modeller/refusals.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/test/browser/fix-alignment.check.mjs +13 -2
- package/test/browser/links.check.mjs +36 -9
- package/test/browser/modeller.check.mjs +25 -0
- package/test/browser/parts-page.js +9 -1
- package/test/browser/parts.check.mjs +22 -0
- package/test/browser/places-mesh.check.mjs +25 -9
- package/test/browser/product-page.check.mjs +42 -2
- package/test/browser/screen.check.mjs +33 -0
- package/test/clip-ai-proposal.test.ts +4 -1
- package/test/clip-consent.test.ts +9 -2
- package/test/layout.test.ts +3 -1
- package/test/refusals.test.ts +18 -10
- package/test/theme.test.ts +5 -15
|
@@ -63,6 +63,15 @@ export class FigureFirstSave extends LitElement {
|
|
|
63
63
|
if (offer.suggested !== undefined) this.suggested = offer.suggested
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/*
|
|
67
|
+
The name is focused here, each time the question opens. The browser honours `autofocus` once per document: the
|
|
68
|
+
question asked again opened with nothing focused, and any autofocus taken earlier on the page left even the first
|
|
69
|
+
opening unfocused (2026-10-11, product-page check).
|
|
70
|
+
*/
|
|
71
|
+
firstUpdated() {
|
|
72
|
+
this.renderRoot.querySelector<HTMLInputElement>('#name')?.focus()
|
|
73
|
+
}
|
|
74
|
+
|
|
66
75
|
render() {
|
|
67
76
|
return html`
|
|
68
77
|
<div class="what">${i18next.t('figure.text.save-new-figure-desc')}</div>
|
|
@@ -70,7 +79,6 @@ export class FigureFirstSave extends LitElement {
|
|
|
70
79
|
<label for="name">${i18next.t('figure.label.name')}</label>
|
|
71
80
|
<input
|
|
72
81
|
id="name"
|
|
73
|
-
autofocus
|
|
74
82
|
.value=${this.name}
|
|
75
83
|
placeholder=${i18next.t('figure.text.figure-name-example')}
|
|
76
84
|
@input=${(e: Event) => (this.name = (e.target as HTMLInputElement).value)}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ReactiveController, ReactiveControllerHost } from 'lit'
|
|
2
|
+
import { keyActionOf, type KeyAction } from './keys.js'
|
|
3
|
+
|
|
4
|
+
/** What the keys ask of the editor. */
|
|
5
|
+
export interface ModellerKeysHost {
|
|
6
|
+
/** Whether the modeller takes keys now: a figure open, not loading or saving, and no section editor open. */
|
|
7
|
+
taking(): boolean
|
|
8
|
+
/** The scene, whose keys the modeller's are too. */
|
|
9
|
+
scene(): Element | null
|
|
10
|
+
selected(): string
|
|
11
|
+
act(action: KeyAction): void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The modeller's keys (`keys.ts`): undo, redo, duplicate, remove, back to the whole figure, the gizmo's modes. Heard on
|
|
16
|
+
* the window, first (capture), while the editor is on the page.
|
|
17
|
+
*/
|
|
18
|
+
export class ModellerKeys implements ReactiveController {
|
|
19
|
+
constructor(
|
|
20
|
+
host: ReactiveControllerHost,
|
|
21
|
+
private readonly of: ModellerKeysHost
|
|
22
|
+
) {
|
|
23
|
+
host.addController(this)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
hostConnected() {
|
|
27
|
+
window.addEventListener('keydown', this.onKey, true)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
hostDisconnected() {
|
|
31
|
+
window.removeEventListener('keydown', this.onKey, true)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private onKey = (e: KeyboardEvent) => {
|
|
35
|
+
if (!this.of.taking()) return
|
|
36
|
+
const canvas = this.of.scene()
|
|
37
|
+
const path = e.composedPath()
|
|
38
|
+
const action = keyActionOf(
|
|
39
|
+
{
|
|
40
|
+
key: e.key,
|
|
41
|
+
ctrlKey: e.ctrlKey,
|
|
42
|
+
metaKey: e.metaKey,
|
|
43
|
+
shiftKey: e.shiftKey,
|
|
44
|
+
altKey: e.altKey,
|
|
45
|
+
target: path[0] as HTMLElement,
|
|
46
|
+
inScene: !!canvas && path.includes(canvas)
|
|
47
|
+
},
|
|
48
|
+
this.of.selected()
|
|
49
|
+
)
|
|
50
|
+
if (!action) return
|
|
51
|
+
e.preventDefault()
|
|
52
|
+
/*
|
|
53
|
+
The session's history is the only one. With the canvas focused the scene took Ctrl+Z as well, undoing on its own
|
|
54
|
+
board the last value the canvas had written -- which the canvas then told as a drag: an undo of a drag on a
|
|
55
|
+
part placed by a rule wrote the drag again (2026-09-25). Heard first (capture) and kept from the scene.
|
|
56
|
+
*/
|
|
57
|
+
e.stopPropagation()
|
|
58
|
+
this.of.act(action)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ReactiveController, ReactiveControllerHost } from 'lit'
|
|
2
|
+
import { i18next } from '@operato/i18n'
|
|
3
|
+
import type { OxThreePane } from '@operato/layout'
|
|
4
|
+
import { notify } from '@operato/layout'
|
|
5
|
+
|
|
6
|
+
/** Whether the narrow fold was said in this browser before; true from now on. Storage refused: said, as before. */
|
|
7
|
+
function foldSaid(): boolean {
|
|
8
|
+
try {
|
|
9
|
+
if (localStorage.getItem('figure-fold-said')) return true
|
|
10
|
+
localStorage.setItem('figure-fold-said', '1')
|
|
11
|
+
} catch {
|
|
12
|
+
/* No storage here: said every time, as it was. */
|
|
13
|
+
}
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
Narrow, the parts list gives way to the canvas rather than all three shrinking (designer 2026-09-27, U11): under
|
|
19
|
+
FOLD_START_UNDER px the canvas would be left under about 560 px beside both sides. The fold is said once, and the
|
|
20
|
+
bar's own toggle opens it again; opened by the author, it is not folded again until the screen has been wide.
|
|
21
|
+
Said once in this browser, not on every opening (2026-10-02): the list folds as before, the notice does not come back.
|
|
22
|
+
*/
|
|
23
|
+
export class PaneFold implements ReactiveController {
|
|
24
|
+
static readonly FOLD_START_UNDER = 1100
|
|
25
|
+
private foldedForWidth = false
|
|
26
|
+
private keptOpenByAuthor = false
|
|
27
|
+
private widthWatch = new ResizeObserver(([entry]) => this.fitTo(entry!.contentRect.width))
|
|
28
|
+
|
|
29
|
+
/** `panes` is the frame the bar folds, once it is drawn. */
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly host: ReactiveControllerHost & HTMLElement,
|
|
32
|
+
private readonly panes: () => OxThreePane | undefined
|
|
33
|
+
) {
|
|
34
|
+
host.addController(this)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
hostConnected() {
|
|
38
|
+
this.widthWatch.observe(this.host)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
hostDisconnected() {
|
|
42
|
+
this.widthWatch.disconnect()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fitTo(width: number) {
|
|
46
|
+
const panes = this.panes()
|
|
47
|
+
if (!panes || !width) return
|
|
48
|
+
if (this.foldedForWidth && !panes.foldStart) this.keptOpenByAuthor = true
|
|
49
|
+
if (width < PaneFold.FOLD_START_UNDER) {
|
|
50
|
+
if (!panes.foldStart && !this.keptOpenByAuthor) {
|
|
51
|
+
panes.foldStart = true
|
|
52
|
+
this.foldedForWidth = true
|
|
53
|
+
if (!foldSaid()) notify({ level: 'info', message: i18next.t('figure.text.parts-panel-folded-narrow') })
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
if (this.foldedForWidth && panes.foldStart) panes.foldStart = false
|
|
57
|
+
this.foldedForWidth = false
|
|
58
|
+
this.keptOpenByAuthor = false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Per model and per part, how far its properties panel was scrolled and which of its folds were open, so going back
|
|
3
|
+
to one finds it as it was left (designer 2026-09-27, B.1). The path stays held at the top, so a scroll kept cannot
|
|
4
|
+
hide which part the panel speaks of. Another model starts clean.
|
|
5
|
+
*/
|
|
6
|
+
export class PanelMemory {
|
|
7
|
+
private kept = new Map<string, { top: number; open: boolean[] }>()
|
|
8
|
+
private restoreFor?: string
|
|
9
|
+
|
|
10
|
+
/** `root` is where the properties panel is drawn: the editor's render root. */
|
|
11
|
+
constructor(private readonly root: () => ParentNode) {}
|
|
12
|
+
|
|
13
|
+
private panel() {
|
|
14
|
+
return this.root().querySelector<HTMLElement>('div[panel]')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private foldsOf(panel: HTMLElement): HTMLDetailsElement[] {
|
|
18
|
+
const folds = [...panel.querySelectorAll('details')]
|
|
19
|
+
for (const inner of panel.querySelectorAll('figure-links')) folds.push(...(inner.shadowRoot?.querySelectorAll('details') ?? []))
|
|
20
|
+
return folds
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Another model: nothing kept. */
|
|
24
|
+
clear() {
|
|
25
|
+
this.kept.clear()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The selection moves from `was` to `now` ('' is the model): the panel as `was` left it is kept, `now`'s put back after the update. */
|
|
29
|
+
leave(was: string, now: string) {
|
|
30
|
+
const panel = this.panel()
|
|
31
|
+
if (panel) this.kept.set(was || '@model', { top: panel.scrollTop, open: this.foldsOf(panel).map(d => d.open) })
|
|
32
|
+
this.restoreFor = now || '@model'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** After the update: the panel of the part chosen as it was left, while that part is still the one chosen. */
|
|
36
|
+
restore(selected: () => string) {
|
|
37
|
+
if (this.restoreFor === undefined) return
|
|
38
|
+
const key = this.restoreFor
|
|
39
|
+
this.restoreFor = undefined
|
|
40
|
+
/*
|
|
41
|
+
Put back as soon as the links have drawn, before any click can come: the updates are microtasks. Put back a
|
|
42
|
+
frame later, it closed a fold opened by a click in that frame (2026-10-11, the row on a bench under load).
|
|
43
|
+
*/
|
|
44
|
+
const panel = this.panel()
|
|
45
|
+
if (panel)
|
|
46
|
+
void Promise.all([...panel.querySelectorAll<HTMLElement & { updateComplete?: Promise<unknown> }>('figure-links')].map(el => el.updateComplete)).then(() => {
|
|
47
|
+
if ((selected() || '@model') !== key) return
|
|
48
|
+
const kept = this.kept.get(key)
|
|
49
|
+
if (kept) this.foldsOf(panel).forEach((fold, i) => i < kept.open.length && (fold.open = kept.open[i]!))
|
|
50
|
+
panel.scrollTop = kept?.top ?? 0
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { PropertyValues } from 'lit'
|
|
2
|
+
import { i18next } from '@operato/i18n'
|
|
3
|
+
import { notify } from '@operato/layout'
|
|
4
|
+
import { OxPrompt } from '@operato/popup'
|
|
5
|
+
import type { FigureAuthoringAction, FigureClipLoss } from '@hatiolab/figure-model'
|
|
6
|
+
import type { FigureProductSession } from './product-session.js'
|
|
7
|
+
import { clipLossOf } from './refusals.js'
|
|
8
|
+
|
|
9
|
+
/** The same link offered again with the size range narrowed, after a link the declared range could not hold. */
|
|
10
|
+
export type Retry = { label: string; action?: FigureAuthoringAction; run?: () => void }
|
|
11
|
+
|
|
12
|
+
/** What the teller reads of the editor, and what it asks of it. */
|
|
13
|
+
export interface RefusalHost {
|
|
14
|
+
session(): FigureProductSession | undefined
|
|
15
|
+
error(): string
|
|
16
|
+
notice(): string
|
|
17
|
+
retry(): Retry | undefined
|
|
18
|
+
/** Said as the editor's refusal. */
|
|
19
|
+
refuse(message: string): void
|
|
20
|
+
/** A narrowed link, confirmed: run as the editor runs a command. */
|
|
21
|
+
command(action: FigureAuthoringAction): void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Refusals and notices go to the app's shared surfaces, as every screen's do (user, 2026-09-27: 「왜 모두가 사용하는
|
|
26
|
+
* notification 오류 표현 방법을 사용하지 않고」 · 「혹은 oxprompt 에서 확인하고 진행하던지」). A refusal with a way on --
|
|
27
|
+
* delete and fit the space again, narrow the range -- asks in the app's prompt and goes on when confirmed; any
|
|
28
|
+
* other refusal is the app's notification, which stays until it is closed.
|
|
29
|
+
*/
|
|
30
|
+
export class RefusalTeller {
|
|
31
|
+
private toldViewError = ''
|
|
32
|
+
|
|
33
|
+
constructor(private readonly of: RefusalHost) {}
|
|
34
|
+
|
|
35
|
+
/** After the editor's update: what changed in its refusal and notice, and the session's own error, said once. */
|
|
36
|
+
tell(changed: PropertyValues) {
|
|
37
|
+
const error = this.of.error()
|
|
38
|
+
if (changed.has('error') && error) {
|
|
39
|
+
const retry = this.of.retry()
|
|
40
|
+
if (retry) this.ask(error, retry)
|
|
41
|
+
else notify({ level: 'error', message: error })
|
|
42
|
+
}
|
|
43
|
+
const notice = this.of.notice()
|
|
44
|
+
if (changed.has('notice') && notice) notify({ level: 'info', message: notice })
|
|
45
|
+
const viewError = this.of.session()?.read()?.error ?? ''
|
|
46
|
+
if (viewError && viewError !== this.toldViewError) notify({ level: 'error', message: viewError })
|
|
47
|
+
this.toldViewError = viewError
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/*
|
|
51
|
+
An edit that would stop clips is refused by figure-model with the clips named (CLIPS_DROPPED). The person is told
|
|
52
|
+
which, and why, before anything is written; agreed, the same edit is made again letting exactly those go, as one
|
|
53
|
+
step undo brings back (designer 2026-09-27).
|
|
54
|
+
*/
|
|
55
|
+
askClipLoss(error: unknown, again: () => void): boolean {
|
|
56
|
+
const clips = clipLossOf(error)
|
|
57
|
+
if (!clips) return false
|
|
58
|
+
/* The consent is for this edit, on this figure as it is now: held with it while the person reads. */
|
|
59
|
+
const session = this.of.session()!
|
|
60
|
+
const { context, revision } = session.read()
|
|
61
|
+
void this.confirmClipLoss(clips, again, { session, context: context!, revision: revision! })
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async confirmClipLoss(clips: FigureClipLoss[], again: () => void, asked: { session: FigureProductSession; context: string; revision: number }) {
|
|
66
|
+
const agreed = await OxPrompt.open({
|
|
67
|
+
type: 'warning',
|
|
68
|
+
text: i18next.t('figure.text.clips-would-go', { clips: clips.map(c => i18next.t(`figure.text.clip-${c.why}`, { clip: c.clip })).join(', ') }),
|
|
69
|
+
confirmButton: { text: i18next.t('figure.button.let-clips-go') },
|
|
70
|
+
cancelButton: { text: i18next.t('figure.button.keep-editing') }
|
|
71
|
+
})
|
|
72
|
+
if (!agreed) return
|
|
73
|
+
/* Changed while the person read -- another figure, or an edit in between: the consent was not for this; nothing is applied. */
|
|
74
|
+
const now = this.of.session() === asked.session ? asked.session.read() : undefined
|
|
75
|
+
if (!now || now.context !== asked.context || now.revision !== asked.revision) {
|
|
76
|
+
this.of.refuse(i18next.t('figure.text.clips-consent-stale'))
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
asked.session.withConsent({ dropClips: clips.map(c => c.id), context: asked.context, revision: asked.revision }, again)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private async ask(message: string, retry: Retry) {
|
|
83
|
+
const agreed = await OxPrompt.open({
|
|
84
|
+
type: 'question',
|
|
85
|
+
text: message,
|
|
86
|
+
confirmButton: { text: retry.label },
|
|
87
|
+
cancelButton: { text: i18next.t('figure.button.keep-editing') }
|
|
88
|
+
})
|
|
89
|
+
if (!agreed) return
|
|
90
|
+
if (retry.run) retry.run()
|
|
91
|
+
else this.of.command(retry.action!)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* English is for logs and readers of the kernel, and a screen that read it went quiet whenever a sentence changed or a
|
|
8
8
|
* new one came under the same code (ADR-0054). A refusal with no details the screen knows is said with its code.
|
|
9
9
|
*/
|
|
10
|
-
import { FIGURE_INSTANCE_SIZE_INPUTS, type FigureRefusalDetails } from '@hatiolab/figure-model'
|
|
10
|
+
import { FIGURE_INSTANCE_SIZE_INPUTS, type FigureClipLoss, type FigureRefusalDetails } from '@hatiolab/figure-model'
|
|
11
11
|
|
|
12
12
|
type Say = (key: string, params: Record<string, string>) => string
|
|
13
13
|
type Reason = FigureRefusalDetails['reason']
|
|
@@ -70,6 +70,23 @@ export function placementRefusalOf(error: unknown, say: Say): string | null {
|
|
|
70
70
|
})
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/** A refusal carried on in a record (an AI proposal's outcome) in figure-model's own shape: its code and details. */
|
|
74
|
+
export interface FigureRefusalRecord {
|
|
75
|
+
code?: string
|
|
76
|
+
details?: FigureRefusalDetails
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The record of a refusal: figure-model's code and details, copied as they are. */
|
|
80
|
+
export function refusalRecordOf(error: unknown): FigureRefusalRecord {
|
|
81
|
+
const { details } = (error ?? {}) as FigureRefusalRecord
|
|
82
|
+
return { code: codeOf(error), ...(details ? { details } : {}) }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The clips an edit would stop, as figure-model named them (CLIPS_DROPPED) -- the person is asked before any go; nothing for any other refusal. */
|
|
86
|
+
export function clipLossOf(error: unknown): FigureClipLoss[] | null {
|
|
87
|
+
return codeOf(error) === 'CLIPS_DROPPED' ? (detailsOf(error, 'clips-dropped')?.clips ?? []) : null
|
|
88
|
+
}
|
|
89
|
+
|
|
73
90
|
/** A part refused because the declared room reads it -- the one refusal deleting it with the room refitted answers. */
|
|
74
91
|
export function inDeclaredRoom(error: unknown): boolean {
|
|
75
92
|
return codeOf(error) === 'PART_REFERENCED' && !!detailsOf(error, 'in-declared-space')
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { FigureProposalStep } from '@hatiolab/figure-model';
|
|
2
2
|
import type { FigureProductSession } from './product-session.js';
|
|
3
|
+
import { type FigureRefusalRecord } from './refusals.js';
|
|
3
4
|
/** What the card carries, as `proposeFigureCandidate` returned it. */
|
|
4
5
|
export interface FigureProposalCard {
|
|
5
6
|
/** The proposal's own name, given by the tool: a save records it, a new figure made from it is created under it. */
|
|
@@ -10,8 +11,11 @@ export interface FigureProposalCard {
|
|
|
10
11
|
context?: string;
|
|
11
12
|
revision?: number;
|
|
12
13
|
}
|
|
13
|
-
/**
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The `figure-apply` event's detail. The page that takes it sets `applied`, and when refused `error` and the refusal in
|
|
16
|
+
* figure-model's shape (`code`, `details`, the `clips` it would stop).
|
|
17
|
+
*/
|
|
18
|
+
export interface FigureApplyDetail extends FigureRefusalRecord {
|
|
15
19
|
key?: string;
|
|
16
20
|
steps: FigureProposalStep[];
|
|
17
21
|
create: boolean;
|
|
@@ -19,9 +23,6 @@ export interface FigureApplyDetail {
|
|
|
19
23
|
revision?: number;
|
|
20
24
|
applied?: boolean;
|
|
21
25
|
error?: string;
|
|
22
|
-
code?: string;
|
|
23
|
-
/** The clips a refused proposal would have stopped (CLIPS_DROPPED), by name. */
|
|
24
|
-
clips?: string[];
|
|
25
26
|
}
|
|
26
27
|
export type Say = (key: string, params?: Record<string, string>) => string;
|
|
27
28
|
export interface FigureProposalTarget {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clipLossOf, refusalRecordOf } from './refusals.js';
|
|
1
2
|
/**
|
|
2
3
|
* The dock's "check the result" (ADR-0097 §5.6 item 5): what the server recorded of a proposal. Saved into figures:
|
|
3
4
|
* accepted, named. Nothing recorded: not known -- applied and not saved yet, undone, or never taken -- so `undefined`,
|
|
@@ -11,7 +12,9 @@ export function proposalOutcomeFrom(saved, say) {
|
|
|
11
12
|
}
|
|
12
13
|
/** The workspace refuses a request made on another version; said as what happened, not as the kernel's code. */
|
|
13
14
|
const STALE = new Set(['REVISION_CONFLICT', 'CONTEXT_CONFLICT']);
|
|
14
|
-
function refused(
|
|
15
|
+
function refused(refusal, say) {
|
|
16
|
+
const { code, error } = refusal;
|
|
17
|
+
const clips = clipLossOf(refusal);
|
|
15
18
|
/*
|
|
16
19
|
A proposal that would stop clips is refused whole; nobody but the person may agree to that, and a proposal is not
|
|
17
20
|
asked about -- the person edits by hand if they mean it (designer 2026-09-27).
|
|
@@ -20,8 +23,8 @@ function refused(code, error, say, clips) {
|
|
|
20
23
|
? say('figure.text.proposal-stale')
|
|
21
24
|
: code === 'NEW_FIGURE_UNSAVED'
|
|
22
25
|
? (error ?? '')
|
|
23
|
-
:
|
|
24
|
-
? say('figure.text.proposal-would-drop-clips', { clips: (
|
|
26
|
+
: clips
|
|
27
|
+
? say('figure.text.proposal-would-drop-clips', { clips: clips.map(c => c.clip).join(', ') })
|
|
25
28
|
: say('figure.text.proposal-refused', { reason: error ?? '' });
|
|
26
29
|
return { verdict: 'refused', errorCode: code ?? 'refused', note };
|
|
27
30
|
}
|
|
@@ -58,11 +61,8 @@ export function takeFigureOffer(session, detail, { onSavedFigure, say }) {
|
|
|
58
61
|
}
|
|
59
62
|
catch (error) {
|
|
60
63
|
detail.applied = false;
|
|
61
|
-
detail
|
|
64
|
+
Object.assign(detail, refusalRecordOf(error));
|
|
62
65
|
detail.error = error instanceof Error ? error.message : String(error);
|
|
63
|
-
const clips = error?.clips;
|
|
64
|
-
if (clips)
|
|
65
|
-
detail.clips = clips.map(c => c.clip);
|
|
66
66
|
return false;
|
|
67
67
|
}
|
|
68
68
|
}
|
|
@@ -80,7 +80,7 @@ export async function runFigureProposal(card, target, say) {
|
|
|
80
80
|
if (detail.applied)
|
|
81
81
|
return accepted();
|
|
82
82
|
if (detail.applied === false)
|
|
83
|
-
return refused(detail
|
|
83
|
+
return refused(detail, say);
|
|
84
84
|
/* A revision belongs to the figure the model saw; with that modeller gone there is nowhere true to apply it. */
|
|
85
85
|
if (!create)
|
|
86
86
|
return { verdict: 'unavailable', errorCode: 'modeller-not-open', note: say('figure.text.proposal-needs-modeller') };
|
|
@@ -89,7 +89,7 @@ export async function runFigureProposal(card, target, say) {
|
|
|
89
89
|
return accepted();
|
|
90
90
|
return opened.code === 'not-opened'
|
|
91
91
|
? { verdict: 'unavailable', errorCode: 'not-opened', note: say('figure.text.proposal-not-opened') }
|
|
92
|
-
: refused(opened
|
|
92
|
+
: refused(opened, say);
|
|
93
93
|
}
|
|
94
94
|
/*
|
|
95
95
|
A new figure's proposal handed to the modeller page across a navigation: the handler leaves it here and opens the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ai-proposal.js","sourceRoot":"","sources":["../../client/modeller/ai-proposal.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * An AI proposal reaching the modeller (ADR-0094). The dock's card carries the modeller's own commands; running it\n * applies them as one step of the modeller's history, undone whole, on the version the model saw. Nothing half made\n * is applied, and every outcome is said: applied, refused with the reason, or not possible here.\n *\n * Kept free of Lit, i18n and the shell so node tests read it directly; the handler and the page pass in what they own.\n */\nimport type { FigureProposalStep } from '@hatiolab/figure-model'\nimport type { FigureProductSession } from './product-session.js'\n\n/** What the card carries, as `proposeFigureCandidate` returned it. */\nexport interface FigureProposalCard {\n /** The proposal's own name, given by the tool: a save records it, a new figure made from it is created under it. */\n key?: string\n steps?: FigureProposalStep[]\n create?: boolean\n figureId?: string\n context?: string\n revision?: number\n}\n\n/** The `figure-apply` event's detail. The page that takes it sets `applied`, and `error` when refused. */\nexport interface FigureApplyDetail {\n key?: string\n steps: FigureProposalStep[]\n create: boolean\n context?: string\n revision?: number\n applied?: boolean\n error?: string\n code?: string\n /** The clips a refused proposal would have stopped (CLIPS_DROPPED), by name. */\n clips?: string[]\n}\n\nexport type Say = (key: string, params?: Record<string, string>) => string\n\nexport interface FigureProposalTarget {\n /** Offers the proposal to a modeller on screen. It comes back untouched when none took it. */\n offer(detail: FigureApplyDetail): FigureApplyDetail\n /** Opens a new figure in the modeller and applies the steps there; resolves once the page has, or has not. */\n openNew(steps: FigureProposalStep[], key?: string): Promise<{ applied: boolean; error?: string; code?: string }>\n}\n\nexport interface FigureProposalOutcome {\n verdict: 'accepted' | 'refused' | 'unavailable'\n errorCode?: string\n applied?: number\n note?: string\n}\n\n/**\n * The dock's \"check the result\" (ADR-0097 §5.6 item 5): what the server recorded of a proposal. Saved into figures:\n * accepted, named. Nothing recorded: not known -- applied and not saved yet, undone, or never taken -- so `undefined`,\n * which the dock keeps as \"not known\" and never turns into a refusal. Nothing is run again.\n */\nexport function proposalOutcomeFrom(saved: ReadonlyArray<{ figureId: string; type?: string; name?: string }>, say: Say): FigureProposalOutcome | undefined {\n if (!saved.length) return undefined\n const names = saved.map(f => f.name || f.type || f.figureId).join(', ')\n return { verdict: 'accepted', note: say('figure.text.proposal-saved-into', { names }) }\n}\n\n/** The workspace refuses a request made on another version; said as what happened, not as the kernel's code. */\nconst STALE = new Set(['REVISION_CONFLICT', 'CONTEXT_CONFLICT'])\n\nfunction refused(code: string | undefined, error: string | undefined, say: Say, clips?: string[]): FigureProposalOutcome {\n /*\n A proposal that would stop clips is refused whole; nobody but the person may agree to that, and a proposal is not\n asked about -- the person edits by hand if they mean it (designer 2026-09-27).\n */\n const note =\n code && STALE.has(code)\n ? say('figure.text.proposal-stale')\n : code === 'NEW_FIGURE_UNSAVED'\n ? (error ?? '')\n : code === 'CLIPS_DROPPED'\n ? say('figure.text.proposal-would-drop-clips', { clips: (clips ?? []).join(', ') })\n : say('figure.text.proposal-refused', { reason: error ?? '' })\n return { verdict: 'refused', errorCode: code ?? 'refused', note }\n}\n\n/**\n * The modeller page's way of taking an offer from the dock (`figure-apply`): input changes, or a proposal's commands\n * as one step (ADR-0094). A revision is applied on the version the model saw; a new figure's proposal only on a new\n * figure not yet touched, and a page on a saved figure leaves it untaken. Never with a consent to let clips go: a\n * proposal that would stop one is refused whole, the clips named (designer 2026-09-27). True when it was applied.\n */\nexport function takeFigureOffer(\n session: FigureProductSession,\n detail: FigureApplyDetail & { changes?: unknown },\n { onSavedFigure, say }: { onSavedFigure: boolean; say: Say }\n): boolean {\n const view = session.read()\n if (detail.create && onSavedFigure) return false\n if (detail.create && view.dirty) {\n /* A new figure already being made is the person's; a proposal does not go on top of it or replace it. */\n detail.applied = false\n detail.code = 'NEW_FIGURE_UNSAVED'\n detail.error = say('figure.text.proposal-new-figure-unsaved')\n return false\n }\n try {\n if (detail.create) {\n /* Created under the proposal's key: taken twice, it meets the first figure, never a second (ADR-0097 §5.6 item 5). */\n if (detail.key) session.createUnder(detail.key)\n session.applyProposal(detail.steps, view.revision!, view.context!, detail.key)\n } else if (detail.steps) session.applyProposal(detail.steps, detail.revision!, detail.context!, detail.key)\n else session.apply(detail.changes as never, 'ai', detail.revision, detail.context)\n detail.applied = true\n return true\n } catch (error) {\n detail.applied = false\n detail.code = (error as { code?: string })?.code\n detail.error = error instanceof Error ? error.message : String(error)\n const clips = (error as { clips?: Array<{ clip: string }> })?.clips\n if (clips) detail.clips = clips.map(c => c.clip)\n return false\n }\n}\n\nexport async function runFigureProposal(card: FigureProposalCard, target: FigureProposalTarget, say: Say): Promise<FigureProposalOutcome> {\n const steps = card?.steps\n if (!Array.isArray(steps) || !steps.length) return { verdict: 'unavailable', errorCode: 'no-steps' }\n const create = card.create === true\n const accepted = (): FigureProposalOutcome => ({\n verdict: 'accepted',\n applied: steps.length,\n note: say('figure.text.proposal-applied', { n: String(steps.length) })\n })\n\n const detail = target.offer({ key: card.key, steps, create, context: card.context, revision: card.revision })\n if (detail.applied) return accepted()\n if (detail.applied === false) return refused(detail.code, detail.error, say, detail.clips)\n\n /* A revision belongs to the figure the model saw; with that modeller gone there is nowhere true to apply it. */\n if (!create) return { verdict: 'unavailable', errorCode: 'modeller-not-open', note: say('figure.text.proposal-needs-modeller') }\n\n const opened = await target.openNew(steps, card.key)\n if (opened.applied) return accepted()\n return opened.code === 'not-opened'\n ? { verdict: 'unavailable', errorCode: 'not-opened', note: say('figure.text.proposal-not-opened') }\n : refused(opened.code, opened.error, say)\n}\n\n/*\n A new figure's proposal handed to the modeller page across a navigation: the handler leaves it here and opens the\n modeller on a new figure; the page takes it once it has started that figure. Left untaken, it lapses -- a later new\n figure must not receive steps nobody is waiting for.\n*/\nlet pending: { steps: FigureProposalStep[]; key?: string; settle: (result: { applied: boolean; error?: string; code?: string }) => void } | undefined\n\nexport function pendNewFigure(steps: FigureProposalStep[], key?: string, ms = 10_000): Promise<{ applied: boolean; error?: string; code?: string }> {\n pending?.settle({ applied: false, code: 'not-opened' })\n return new Promise(resolve => {\n const mine = {\n steps,\n key,\n settle: (result: { applied: boolean; error?: string; code?: string }) => {\n clearTimeout(timer)\n if (pending === mine) pending = undefined\n resolve(result)\n }\n }\n const timer = setTimeout(() => mine.settle({ applied: false, code: 'not-opened' }), ms)\n pending = mine\n })\n}\n\n/** For the page, on a new figure it has just started: the steps waiting for it, and where to say how it went. */\nexport function takePendingNewFigure():\n | { steps: FigureProposalStep[]; key?: string; settle: (result: { applied: boolean; error?: string; code?: string }) => void }\n | undefined {\n const taken = pending\n pending = undefined\n return taken\n}\n"]}
|
|
1
|
+
{"version":3,"file":"ai-proposal.js","sourceRoot":"","sources":["../../client/modeller/ai-proposal.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * An AI proposal reaching the modeller (ADR-0094). The dock's card carries the modeller's own commands; running it\n * applies them as one step of the modeller's history, undone whole, on the version the model saw. Nothing half made\n * is applied, and every outcome is said: applied, refused with the reason, or not possible here.\n *\n * Kept free of Lit, i18n and the shell so node tests read it directly; the handler and the page pass in what they own.\n */\nimport type { FigureProposalStep } from '@hatiolab/figure-model'\nimport type { FigureProductSession } from './product-session.js'\nimport { clipLossOf, refusalRecordOf, type FigureRefusalRecord } from './refusals.js'\n\n/** What the card carries, as `proposeFigureCandidate` returned it. */\nexport interface FigureProposalCard {\n /** The proposal's own name, given by the tool: a save records it, a new figure made from it is created under it. */\n key?: string\n steps?: FigureProposalStep[]\n create?: boolean\n figureId?: string\n context?: string\n revision?: number\n}\n\n/**\n * The `figure-apply` event's detail. The page that takes it sets `applied`, and when refused `error` and the refusal in\n * figure-model's shape (`code`, `details`, the `clips` it would stop).\n */\nexport interface FigureApplyDetail extends FigureRefusalRecord {\n key?: string\n steps: FigureProposalStep[]\n create: boolean\n context?: string\n revision?: number\n applied?: boolean\n error?: string\n}\n\nexport type Say = (key: string, params?: Record<string, string>) => string\n\nexport interface FigureProposalTarget {\n /** Offers the proposal to a modeller on screen. It comes back untouched when none took it. */\n offer(detail: FigureApplyDetail): FigureApplyDetail\n /** Opens a new figure in the modeller and applies the steps there; resolves once the page has, or has not. */\n openNew(steps: FigureProposalStep[], key?: string): Promise<{ applied: boolean; error?: string; code?: string }>\n}\n\nexport interface FigureProposalOutcome {\n verdict: 'accepted' | 'refused' | 'unavailable'\n errorCode?: string\n applied?: number\n note?: string\n}\n\n/**\n * The dock's \"check the result\" (ADR-0097 §5.6 item 5): what the server recorded of a proposal. Saved into figures:\n * accepted, named. Nothing recorded: not known -- applied and not saved yet, undone, or never taken -- so `undefined`,\n * which the dock keeps as \"not known\" and never turns into a refusal. Nothing is run again.\n */\nexport function proposalOutcomeFrom(saved: ReadonlyArray<{ figureId: string; type?: string; name?: string }>, say: Say): FigureProposalOutcome | undefined {\n if (!saved.length) return undefined\n const names = saved.map(f => f.name || f.type || f.figureId).join(', ')\n return { verdict: 'accepted', note: say('figure.text.proposal-saved-into', { names }) }\n}\n\n/** The workspace refuses a request made on another version; said as what happened, not as the kernel's code. */\nconst STALE = new Set(['REVISION_CONFLICT', 'CONTEXT_CONFLICT'])\n\nfunction refused(refusal: FigureRefusalRecord & { error?: string }, say: Say): FigureProposalOutcome {\n const { code, error } = refusal\n const clips = clipLossOf(refusal)\n /*\n A proposal that would stop clips is refused whole; nobody but the person may agree to that, and a proposal is not\n asked about -- the person edits by hand if they mean it (designer 2026-09-27).\n */\n const note =\n code && STALE.has(code)\n ? say('figure.text.proposal-stale')\n : code === 'NEW_FIGURE_UNSAVED'\n ? (error ?? '')\n : clips\n ? say('figure.text.proposal-would-drop-clips', { clips: clips.map(c => c.clip).join(', ') })\n : say('figure.text.proposal-refused', { reason: error ?? '' })\n return { verdict: 'refused', errorCode: code ?? 'refused', note }\n}\n\n/**\n * The modeller page's way of taking an offer from the dock (`figure-apply`): input changes, or a proposal's commands\n * as one step (ADR-0094). A revision is applied on the version the model saw; a new figure's proposal only on a new\n * figure not yet touched, and a page on a saved figure leaves it untaken. Never with a consent to let clips go: a\n * proposal that would stop one is refused whole, the clips named (designer 2026-09-27). True when it was applied.\n */\nexport function takeFigureOffer(\n session: FigureProductSession,\n detail: FigureApplyDetail & { changes?: unknown },\n { onSavedFigure, say }: { onSavedFigure: boolean; say: Say }\n): boolean {\n const view = session.read()\n if (detail.create && onSavedFigure) return false\n if (detail.create && view.dirty) {\n /* A new figure already being made is the person's; a proposal does not go on top of it or replace it. */\n detail.applied = false\n detail.code = 'NEW_FIGURE_UNSAVED'\n detail.error = say('figure.text.proposal-new-figure-unsaved')\n return false\n }\n try {\n if (detail.create) {\n /* Created under the proposal's key: taken twice, it meets the first figure, never a second (ADR-0097 §5.6 item 5). */\n if (detail.key) session.createUnder(detail.key)\n session.applyProposal(detail.steps, view.revision!, view.context!, detail.key)\n } else if (detail.steps) session.applyProposal(detail.steps, detail.revision!, detail.context!, detail.key)\n else session.apply(detail.changes as never, 'ai', detail.revision, detail.context)\n detail.applied = true\n return true\n } catch (error) {\n detail.applied = false\n Object.assign(detail, refusalRecordOf(error))\n detail.error = error instanceof Error ? error.message : String(error)\n return false\n }\n}\n\nexport async function runFigureProposal(card: FigureProposalCard, target: FigureProposalTarget, say: Say): Promise<FigureProposalOutcome> {\n const steps = card?.steps\n if (!Array.isArray(steps) || !steps.length) return { verdict: 'unavailable', errorCode: 'no-steps' }\n const create = card.create === true\n const accepted = (): FigureProposalOutcome => ({\n verdict: 'accepted',\n applied: steps.length,\n note: say('figure.text.proposal-applied', { n: String(steps.length) })\n })\n\n const detail = target.offer({ key: card.key, steps, create, context: card.context, revision: card.revision })\n if (detail.applied) return accepted()\n if (detail.applied === false) return refused(detail, say)\n\n /* A revision belongs to the figure the model saw; with that modeller gone there is nowhere true to apply it. */\n if (!create) return { verdict: 'unavailable', errorCode: 'modeller-not-open', note: say('figure.text.proposal-needs-modeller') }\n\n const opened = await target.openNew(steps, card.key)\n if (opened.applied) return accepted()\n return opened.code === 'not-opened'\n ? { verdict: 'unavailable', errorCode: 'not-opened', note: say('figure.text.proposal-not-opened') }\n : refused(opened, say)\n}\n\n/*\n A new figure's proposal handed to the modeller page across a navigation: the handler leaves it here and opens the\n modeller on a new figure; the page takes it once it has started that figure. Left untaken, it lapses -- a later new\n figure must not receive steps nobody is waiting for.\n*/\nlet pending: { steps: FigureProposalStep[]; key?: string; settle: (result: { applied: boolean; error?: string; code?: string }) => void } | undefined\n\nexport function pendNewFigure(steps: FigureProposalStep[], key?: string, ms = 10_000): Promise<{ applied: boolean; error?: string; code?: string }> {\n pending?.settle({ applied: false, code: 'not-opened' })\n return new Promise(resolve => {\n const mine = {\n steps,\n key,\n settle: (result: { applied: boolean; error?: string; code?: string }) => {\n clearTimeout(timer)\n if (pending === mine) pending = undefined\n resolve(result)\n }\n }\n const timer = setTimeout(() => mine.settle({ applied: false, code: 'not-opened' }), ms)\n pending = mine\n })\n}\n\n/** For the page, on a new figure it has just started: the steps waiting for it, and where to say how it went. */\nexport function takePendingNewFigure():\n | { steps: FigureProposalStep[]; key?: string; settle: (result: { applied: boolean; error?: string; code?: string }) => void }\n | undefined {\n const taken = pending\n pending = undefined\n return taken\n}\n"]}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
|
2
|
+
import { type FigureAuthoringAction } from '@hatiolab/figure-model';
|
|
3
|
+
import { type HeldPreview } from './command-preview.js';
|
|
4
|
+
import type { FigureProductSession } from './product-session.js';
|
|
5
|
+
import type { FigureRenderPart } from './renderer.js';
|
|
6
|
+
import { type FigureRevision } from './revision.js';
|
|
7
|
+
/** The words a panel is given when there are none: one array, so it does not read as a change. */
|
|
8
|
+
export declare const NO_WORDS: string[];
|
|
9
|
+
/** A command a form is trying, with the face it names when it names one. */
|
|
10
|
+
export type PreviewCandidate = {
|
|
11
|
+
action: FigureAuthoringAction;
|
|
12
|
+
face?: {
|
|
13
|
+
part: string;
|
|
14
|
+
face: string;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
/** What the preview reads of the editor. */
|
|
18
|
+
export interface CommandPreviewHost {
|
|
19
|
+
session(): FigureProductSession | undefined;
|
|
20
|
+
selected(): string;
|
|
21
|
+
/** The figure on screen at its version (`revision.ts`). */
|
|
22
|
+
shown(): FigureRevision | undefined;
|
|
23
|
+
/** Whether the properties panel, where the forms are, is in sight: the edit middle, the properties tab. */
|
|
24
|
+
formsInSight(): boolean;
|
|
25
|
+
/** A refusal in the person's words. */
|
|
26
|
+
said(error: unknown): string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A command seen before it is done (ADR-0097 §5.5): worked out on a copy for the figure, version, part and command it
|
|
30
|
+
* names, and shown only while all four are still the ones on screen. Nothing of it is saved or undone. And what taking
|
|
31
|
+
* the selected part off would do, worked out the same way. Both wait for the figure to rest; the editor taken down
|
|
32
|
+
* stops both.
|
|
33
|
+
*/
|
|
34
|
+
export declare class CommandPreview implements ReactiveController {
|
|
35
|
+
private readonly host;
|
|
36
|
+
private readonly of;
|
|
37
|
+
private preview?;
|
|
38
|
+
private drawn?;
|
|
39
|
+
private wordsSaid?;
|
|
40
|
+
private previewTimer?;
|
|
41
|
+
private detachTimer?;
|
|
42
|
+
private detachAsked?;
|
|
43
|
+
constructor(host: ReactiveControllerHost, of: CommandPreviewHost);
|
|
44
|
+
hostConnected(): void;
|
|
45
|
+
hostDisconnected(): void;
|
|
46
|
+
/** Drawn again only when it is another preview, as a state of the editor would be. */
|
|
47
|
+
private hold;
|
|
48
|
+
/**
|
|
49
|
+
* The fastening form's command, seen before it is done; null clears what was shown. What a command would do is worked
|
|
50
|
+
* out on a copy of the figure -- several compiles of it -- so it waits for the field to rest: a number typed digit by
|
|
51
|
+
* digit asks once, for the number it ends on. Clearing is at once, and the same question on the same version is not
|
|
52
|
+
* asked again.
|
|
53
|
+
*/
|
|
54
|
+
ask(candidate: PreviewCandidate | null): void;
|
|
55
|
+
private compute;
|
|
56
|
+
/** The preview, while it still speaks of what is on screen: this figure, this version, this part. */
|
|
57
|
+
current(): (HeldPreview & {
|
|
58
|
+
part: string;
|
|
59
|
+
}) | undefined;
|
|
60
|
+
/** What the preview says, the same array while it says the same thing. */
|
|
61
|
+
words(): string[];
|
|
62
|
+
/** What the canvas draws of a preview, made once per preview: a new list every render would stand the board up each time. */
|
|
63
|
+
drawnOf(p: (HeldPreview & {
|
|
64
|
+
part: string;
|
|
65
|
+
}) | undefined): {
|
|
66
|
+
ghost: FigureRenderPart[];
|
|
67
|
+
highlight: FigureRenderPart | null;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Said once the figure has rested: working it out is a preview of the command on a copy (tens of milliseconds), and the
|
|
71
|
+
* edit that made this version should be on screen before it. Until then nothing is said, as for a part with no joint.
|
|
72
|
+
*/
|
|
73
|
+
detachWords(shown: FigureRevision): string[];
|
|
74
|
+
private computeDetachWords;
|
|
75
|
+
}
|