@erclx/canon 5.28.0 → 5.30.0
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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/review-ui/references/post.md +7 -5
- package/docs/agents/canvas.md +8 -0
- package/docs/agents/pr-evidence.md +7 -7
- package/package.json +1 -1
- package/src/canvas/client/app.tsx +105 -4
- package/src/canvas/client/inspector.tsx +13 -4
- package/src/canvas/client/keys.ts +23 -6
- package/src/canvas/client/main.tsx +9 -0
- package/src/canvas/client/pages-panel.tsx +5 -1
- package/src/canvas/client/panel-handle.tsx +90 -0
- package/src/canvas/client/shell.css +86 -2
- package/src/canvas/client/state.ts +159 -6
- package/src/canvas/client/surface.tsx +37 -1
- package/src/canvas/client/tools.tsx +91 -2
- package/src/canvas/content.ts +65 -0
- package/src/canvas/edit.ts +195 -23
- package/src/canvas/history.ts +116 -0
- package/src/canvas/server.ts +126 -13
- package/src/{exec.ts → cli/exec.ts} +1 -1
- package/src/{help.ts → cli/help.ts} +1 -1
- package/src/cli.ts +1 -1
- package/src/commands/audits.ts +9 -1
- package/src/commands/autoship.ts +1 -1
- package/src/commands/canvas.ts +1 -1
- package/src/commands/capture.ts +1 -1
- package/src/commands/census.ts +1 -1
- package/src/commands/claude.ts +2 -2
- package/src/commands/comments.ts +1 -1
- package/src/commands/context.ts +1 -1
- package/src/commands/demo.ts +9 -1
- package/src/commands/deps.ts +1 -1
- package/src/commands/design.ts +9 -1
- package/src/commands/docs.ts +1 -1
- package/src/commands/driver.ts +9 -1
- package/src/commands/feedback.ts +1 -1
- package/src/commands/gate.ts +1 -1
- package/src/commands/gov.ts +1 -1
- package/src/commands/indexes.ts +1 -1
- package/src/commands/init.ts +10 -2
- package/src/commands/intake.ts +1 -1
- package/src/commands/inventory.ts +9 -1
- package/src/commands/labels.ts +1 -1
- package/src/commands/markdown.ts +1 -1
- package/src/commands/migrate.ts +1 -1
- package/src/commands/pr.ts +141 -2
- package/src/commands/records.ts +1 -1
- package/src/commands/repo.ts +1 -1
- package/src/commands/sandbox.ts +1 -1
- package/src/commands/secrets.ts +9 -1
- package/src/commands/sessions.ts +1 -1
- package/src/commands/slides.ts +1 -1
- package/src/commands/standards.ts +1 -1
- package/src/commands/sync.ts +2 -2
- package/src/commands/targets.ts +1 -1
- package/src/commands/tasks.ts +1 -1
- package/src/commands/teach.ts +1 -1
- package/src/commands/tooling.ts +1 -1
- package/src/commands/transcripts.ts +1 -1
- package/src/commands/upgrade.ts +1 -1
- package/src/commands/wiki.ts +1 -1
- package/src/commands/worktrees.ts +1 -1
- package/src/design/brand-favicon.ts +18 -0
- package/src/gate/measures.ts +1 -1
- package/src/init/run.ts +1 -1
- package/src/migrate/rename.ts +1 -1
- package/src/pr/evidence.ts +41 -7
- package/src/roots/project.ts +1 -1
- package/src/sandbox/headless/run.ts +1 -1
- package/src/sandbox/provision.ts +1 -1
- package/src/sandbox/scenario.ts +1 -1
- package/src/serve/report.ts +1 -1
- package/src/sync/engine.ts +1 -1
- package/src/sync/git.ts +1 -1
- package/src/sync/workflow.ts +1 -1
- package/src/targets/validate.ts +1 -1
- package/src/teach/nav.ts +5 -12
- package/src/tooling/inject.ts +1 -1
- package/src/tooling/verify-stack.ts +1 -1
- /package/src/{cli-run.ts → cli/run.ts} +0 -0
- /package/src/{ui.ts → cli/ui.ts} +0 -0
|
@@ -63,6 +63,13 @@ export interface PagesRecord {
|
|
|
63
63
|
readonly selection?: SelectionRef | null
|
|
64
64
|
/** The live marks, which a server older than the mark leaves out. */
|
|
65
65
|
readonly editing?: readonly EditingRef[]
|
|
66
|
+
/** Whether the server holds an edit to undo or redo, absent before undo shipped. */
|
|
67
|
+
readonly history?: HistoryFlags
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface HistoryFlags {
|
|
71
|
+
readonly canUndo: boolean
|
|
72
|
+
readonly canRedo: boolean
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
export interface ChangeEvent {
|
|
@@ -128,6 +135,60 @@ export const writeError = signal<string | undefined>(undefined)
|
|
|
128
135
|
|
|
129
136
|
export const leftTab = signal<LeftTab>('pages')
|
|
130
137
|
|
|
138
|
+
/** Whether the operator hid both side panels to see the whole surface. */
|
|
139
|
+
export const panelsHidden = signal(false)
|
|
140
|
+
|
|
141
|
+
export function togglePanels(): void {
|
|
142
|
+
panelsHidden.value = !panelsHidden.value
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type PanelSide = 'left' | 'right'
|
|
146
|
+
|
|
147
|
+
export interface PanelWidths {
|
|
148
|
+
readonly left: number
|
|
149
|
+
readonly right: number
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The 15rem and 16rem columns the shell opened at before panels resized. */
|
|
153
|
+
export const DEFAULT_PANEL_WIDTHS: PanelWidths = { left: 240, right: 256 }
|
|
154
|
+
export const MIN_PANEL_WIDTH = 180
|
|
155
|
+
export const MAX_PANEL_WIDTH = 480
|
|
156
|
+
/** What a panel never squeezes the surface below, the reflow floor. */
|
|
157
|
+
export const MIN_SURFACE_WIDTH = 320
|
|
158
|
+
|
|
159
|
+
export const panelWidths = signal<PanelWidths>(DEFAULT_PANEL_WIDTHS)
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Holds one panel between the floor and the ceiling, and under whatever keeps
|
|
163
|
+
* the surface at its own floor beside the other panel. The floor wins on a
|
|
164
|
+
* window too narrow for both.
|
|
165
|
+
*/
|
|
166
|
+
export function clampPanelWidth(
|
|
167
|
+
side: PanelSide,
|
|
168
|
+
width: number,
|
|
169
|
+
windowWidth: number,
|
|
170
|
+
widths: PanelWidths = panelWidths.value,
|
|
171
|
+
): number {
|
|
172
|
+
const other = side === 'left' ? widths.right : widths.left
|
|
173
|
+
const ceiling = Math.min(
|
|
174
|
+
MAX_PANEL_WIDTH,
|
|
175
|
+
windowWidth - other - MIN_SURFACE_WIDTH,
|
|
176
|
+
)
|
|
177
|
+
const fallback = DEFAULT_PANEL_WIDTHS[side]
|
|
178
|
+
const wanted = Number.isFinite(width) ? width : fallback
|
|
179
|
+
return Math.round(Math.max(MIN_PANEL_WIDTH, Math.min(ceiling, wanted)))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function setPanelWidth(
|
|
183
|
+
side: PanelSide,
|
|
184
|
+
width: number,
|
|
185
|
+
windowWidth: number,
|
|
186
|
+
): void {
|
|
187
|
+
const next = clampPanelWidth(side, width, windowWidth)
|
|
188
|
+
if (panelWidths.value[side] === next) return
|
|
189
|
+
panelWidths.value = { ...panelWidths.value, [side]: next }
|
|
190
|
+
}
|
|
191
|
+
|
|
131
192
|
/** The tool the strip has lit, which a Space pan overrides while held. */
|
|
132
193
|
export const activeTool = signal<Tool>('move')
|
|
133
194
|
|
|
@@ -149,6 +210,14 @@ const SAVED_MS = 2500
|
|
|
149
210
|
/** Why the last edit wrote nothing, until the next one lands. */
|
|
150
211
|
export const editRefusal = signal<string | undefined>(undefined)
|
|
151
212
|
|
|
213
|
+
const NO_HISTORY: HistoryFlags = { canUndo: false, canRedo: false }
|
|
214
|
+
|
|
215
|
+
/** What the server's history holds, as the last page record reported it. */
|
|
216
|
+
export const history = signal<HistoryFlags>(NO_HISTORY)
|
|
217
|
+
|
|
218
|
+
/** Why the last undo or redo wrote nothing, until the next one runs. */
|
|
219
|
+
export const historyNotice = signal<string | undefined>(undefined)
|
|
220
|
+
|
|
152
221
|
/**
|
|
153
222
|
* The hash an edit answered by frame key, until that frame reloads. A second
|
|
154
223
|
* edit sent before the reload is made from the file the first one wrote, not
|
|
@@ -275,6 +344,7 @@ export function applyRecord(record: PagesRecord): void {
|
|
|
275
344
|
tokens.value = record.tokens
|
|
276
345
|
selection.value = record.selection ?? undefined
|
|
277
346
|
applyEditing(record.pages, record.editing ?? [])
|
|
347
|
+
history.value = record.history ?? NO_HISTORY
|
|
278
348
|
loadError.value = undefined
|
|
279
349
|
isLoaded.value = true
|
|
280
350
|
}
|
|
@@ -395,11 +465,22 @@ const EDIT_NOTICES: Readonly<Record<string, string | undefined>> = {
|
|
|
395
465
|
'Could not save the text, since this element holds other elements.',
|
|
396
466
|
}
|
|
397
467
|
|
|
468
|
+
let stepCount = 0
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* A token the edits of one gesture share, so the server merges them into one
|
|
472
|
+
* undo. The time keeps it apart from a token an earlier shell load sent.
|
|
473
|
+
*/
|
|
474
|
+
export function newStep(): string {
|
|
475
|
+
stepCount += 1
|
|
476
|
+
return `${Date.now().toString(36)}-${stepCount}`
|
|
477
|
+
}
|
|
478
|
+
|
|
398
479
|
/**
|
|
399
480
|
* Posts one property change to the element the address names. A refusal
|
|
400
481
|
* says why and reloads the frame, so a pending value never sits over a file
|
|
401
482
|
* that moved under it. Resolves to what the write answered, or undefined when
|
|
402
|
-
* nothing was written.
|
|
483
|
+
* nothing was written. Edits carrying one step undo as one.
|
|
403
484
|
*/
|
|
404
485
|
export async function editElement(
|
|
405
486
|
ref: FrameRef,
|
|
@@ -407,6 +488,7 @@ export async function editElement(
|
|
|
407
488
|
address: ElementAddress,
|
|
408
489
|
property: string,
|
|
409
490
|
value: string,
|
|
491
|
+
step?: string,
|
|
410
492
|
fetchImpl: typeof fetch = fetch,
|
|
411
493
|
): Promise<{ readonly hash?: string } | undefined> {
|
|
412
494
|
const hash = editedHashes.value.get(key) ?? address.hash
|
|
@@ -420,6 +502,7 @@ export async function editElement(
|
|
|
420
502
|
element: { ...address, hash },
|
|
421
503
|
property,
|
|
422
504
|
value,
|
|
505
|
+
...(step !== undefined && { step }),
|
|
423
506
|
}),
|
|
424
507
|
})
|
|
425
508
|
const body: unknown = await response.json().catch(() => undefined)
|
|
@@ -440,6 +523,7 @@ export async function editElement(
|
|
|
440
523
|
return undefined
|
|
441
524
|
}
|
|
442
525
|
editRefusal.value = undefined
|
|
526
|
+
historyNotice.value = undefined
|
|
443
527
|
const saved = pendingEdit.value
|
|
444
528
|
savedEdit.value = saved
|
|
445
529
|
setTimeout(() => {
|
|
@@ -459,10 +543,11 @@ export async function editElement(
|
|
|
459
543
|
}
|
|
460
544
|
|
|
461
545
|
/**
|
|
462
|
-
* Writes an element's new size as one edit per changed axis, width first
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
* first edit sends
|
|
546
|
+
* Writes an element's new size as one edit per changed axis, width first,
|
|
547
|
+
* sharing one step so a single undo restores both. The second carries the
|
|
548
|
+
* hash the first answered, so a frame reloading between the two cannot leave
|
|
549
|
+
* it holding the hash of a file already rewritten. A refused first edit sends
|
|
550
|
+
* no second.
|
|
466
551
|
*/
|
|
467
552
|
export async function resizeElement(
|
|
468
553
|
ref: FrameRef,
|
|
@@ -472,6 +557,7 @@ export async function resizeElement(
|
|
|
472
557
|
height: number | undefined,
|
|
473
558
|
fetchImpl: typeof fetch = fetch,
|
|
474
559
|
): Promise<void> {
|
|
560
|
+
const step = newStep()
|
|
475
561
|
let current = address
|
|
476
562
|
if (width !== undefined) {
|
|
477
563
|
const written = await editElement(
|
|
@@ -480,16 +566,79 @@ export async function resizeElement(
|
|
|
480
566
|
current,
|
|
481
567
|
'width',
|
|
482
568
|
`${width}px`,
|
|
569
|
+
step,
|
|
483
570
|
fetchImpl,
|
|
484
571
|
)
|
|
485
572
|
if (!written) return
|
|
486
573
|
if (written.hash) current = { ...current, hash: written.hash }
|
|
487
574
|
}
|
|
488
575
|
if (height !== undefined) {
|
|
489
|
-
await editElement(
|
|
576
|
+
await editElement(
|
|
577
|
+
ref,
|
|
578
|
+
key,
|
|
579
|
+
current,
|
|
580
|
+
'height',
|
|
581
|
+
`${height}px`,
|
|
582
|
+
step,
|
|
583
|
+
fetchImpl,
|
|
584
|
+
)
|
|
490
585
|
}
|
|
491
586
|
}
|
|
492
587
|
|
|
588
|
+
const HISTORY_NOTICES = {
|
|
589
|
+
undo: 'Could not undo, since that element or frame changed after your edit. Nothing was written.',
|
|
590
|
+
redo: 'Could not redo, since that element or frame changed after the undo. Nothing was written.',
|
|
591
|
+
} as const
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Asks the server to step its history and rereads the page list for the new
|
|
595
|
+
* flags. A written step clears every hash an edit answered, since the frame
|
|
596
|
+
* it named was just rewritten under it. The frame itself reloads on the
|
|
597
|
+
* change event the write raises.
|
|
598
|
+
*/
|
|
599
|
+
async function stepHistory(
|
|
600
|
+
direction: 'undo' | 'redo',
|
|
601
|
+
fetchImpl: typeof fetch,
|
|
602
|
+
): Promise<void> {
|
|
603
|
+
try {
|
|
604
|
+
const response = await fetchImpl(`/api/history/${direction}`, {
|
|
605
|
+
method: 'POST',
|
|
606
|
+
headers: { 'content-type': 'application/json' },
|
|
607
|
+
body: '{}',
|
|
608
|
+
})
|
|
609
|
+
const body: unknown = await response.json().catch(() => undefined)
|
|
610
|
+
const outcome =
|
|
611
|
+
typeof body === 'object' && body !== null && 'outcome' in body
|
|
612
|
+
? body.outcome
|
|
613
|
+
: undefined
|
|
614
|
+
if (!response.ok) {
|
|
615
|
+
historyNotice.value = `Could not ${direction} (status ${response.status}). Try again.`
|
|
616
|
+
} else if (outcome === 'dropped') {
|
|
617
|
+
historyNotice.value = HISTORY_NOTICES[direction]
|
|
618
|
+
} else if (outcome === 'applied') {
|
|
619
|
+
const done = direction === 'undo' ? 'Undone' : 'Redone'
|
|
620
|
+
historyNotice.value = done
|
|
621
|
+
setTimeout(() => {
|
|
622
|
+
if (historyNotice.value === done) historyNotice.value = undefined
|
|
623
|
+
}, SAVED_MS)
|
|
624
|
+
} else {
|
|
625
|
+
historyNotice.value = undefined
|
|
626
|
+
}
|
|
627
|
+
if (outcome === 'applied') editedHashes.value = new Map()
|
|
628
|
+
} catch (error) {
|
|
629
|
+
historyNotice.value = `Could not ${direction} (${error instanceof Error ? error.message : 'unknown'}). Check canon canvas serve is still running.`
|
|
630
|
+
}
|
|
631
|
+
await loadPages(fetchImpl)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export function undo(fetchImpl: typeof fetch = fetch): Promise<void> {
|
|
635
|
+
return stepHistory('undo', fetchImpl)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export function redo(fetchImpl: typeof fetch = fetch): Promise<void> {
|
|
639
|
+
return stepHistory('redo', fetchImpl)
|
|
640
|
+
}
|
|
641
|
+
|
|
493
642
|
/**
|
|
494
643
|
* Moves a frame on the surface without writing, which a drag calls on every
|
|
495
644
|
* pointer move. The next reread replaces it with what the layout holds.
|
|
@@ -636,4 +785,8 @@ export function resetState(): void {
|
|
|
636
785
|
savedEdit.value = undefined
|
|
637
786
|
editRefusal.value = undefined
|
|
638
787
|
editedHashes.value = new Map()
|
|
788
|
+
history.value = NO_HISTORY
|
|
789
|
+
historyNotice.value = undefined
|
|
790
|
+
panelsHidden.value = false
|
|
791
|
+
panelWidths.value = DEFAULT_PANEL_WIDTHS
|
|
639
792
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** @jsxImportSource preact */
|
|
2
2
|
import type { JSX, RefObject } from 'preact'
|
|
3
|
-
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks'
|
|
4
4
|
import { addressOf, documentElements, elementAt } from '@/canvas/address'
|
|
5
5
|
import type { Frame } from '@/canvas/content'
|
|
6
6
|
import { type SurfaceAction, surfaceAction } from '@/canvas/client/keys'
|
|
@@ -15,11 +15,15 @@ import {
|
|
|
15
15
|
frameKey,
|
|
16
16
|
frameTheme,
|
|
17
17
|
frameVersions,
|
|
18
|
+
historyNotice,
|
|
18
19
|
hoveredElement,
|
|
19
20
|
moveFrameTo,
|
|
20
21
|
panBy,
|
|
22
|
+
panelsHidden,
|
|
23
|
+
panelWidths,
|
|
21
24
|
previewMove,
|
|
22
25
|
previewResize,
|
|
26
|
+
redo,
|
|
23
27
|
registerFrameDocument,
|
|
24
28
|
resizeElement,
|
|
25
29
|
resizeFrameTo,
|
|
@@ -30,6 +34,8 @@ import {
|
|
|
30
34
|
spacePan,
|
|
31
35
|
type Theme,
|
|
32
36
|
toggleFrameTheme,
|
|
37
|
+
togglePanels,
|
|
38
|
+
undo,
|
|
33
39
|
view,
|
|
34
40
|
zoomAt,
|
|
35
41
|
} from '@/canvas/client/state'
|
|
@@ -596,6 +602,22 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
|
|
|
596
602
|
fitViewport(viewportRef)
|
|
597
603
|
}, [page?.name])
|
|
598
604
|
|
|
605
|
+
/*
|
|
606
|
+
* A panel hiding, showing, or resizing moves the viewport's left edge, so
|
|
607
|
+
* the view shifts by as much the other way and the frames hold still on
|
|
608
|
+
* screen. Reading the flag subscribes this component to it.
|
|
609
|
+
*/
|
|
610
|
+
const viewportLeft = useRef<number | undefined>(undefined)
|
|
611
|
+
const isHidden = panelsHidden.value
|
|
612
|
+
const leftWidth = panelWidths.value.left
|
|
613
|
+
useLayoutEffect(() => {
|
|
614
|
+
const left = viewportRef.current?.getBoundingClientRect().left
|
|
615
|
+
if (left === undefined) return
|
|
616
|
+
const before = viewportLeft.current
|
|
617
|
+
viewportLeft.current = left
|
|
618
|
+
if (before !== undefined && before !== left) panBy(before - left, 0)
|
|
619
|
+
}, [isHidden, leftWidth])
|
|
620
|
+
|
|
599
621
|
const handleWheelTurn = (turn: WheelTurn) => {
|
|
600
622
|
if (turn.isZoom) {
|
|
601
623
|
const rect = viewportRef.current?.getBoundingClientRect()
|
|
@@ -656,6 +678,15 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
|
|
|
656
678
|
return
|
|
657
679
|
case 'pan-release':
|
|
658
680
|
spacePan.value = undefined
|
|
681
|
+
return
|
|
682
|
+
case 'undo':
|
|
683
|
+
void undo()
|
|
684
|
+
return
|
|
685
|
+
case 'redo':
|
|
686
|
+
void redo()
|
|
687
|
+
return
|
|
688
|
+
case 'panels-toggle':
|
|
689
|
+
togglePanels()
|
|
659
690
|
}
|
|
660
691
|
}
|
|
661
692
|
|
|
@@ -762,6 +793,11 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
|
|
|
762
793
|
</div>
|
|
763
794
|
<ToolStrip />
|
|
764
795
|
<Toolbar viewportRef={viewportRef} />
|
|
796
|
+
{historyNotice.value ? (
|
|
797
|
+
<p class="history-notice" role="status">
|
|
798
|
+
{historyNotice.value}
|
|
799
|
+
</p>
|
|
800
|
+
) : null}
|
|
765
801
|
</main>
|
|
766
802
|
)
|
|
767
803
|
}
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
/** @jsxImportSource preact */
|
|
2
2
|
import type { JSX } from 'preact'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
activeTool,
|
|
5
|
+
history,
|
|
6
|
+
panelsHidden,
|
|
7
|
+
redo,
|
|
8
|
+
shownTool,
|
|
9
|
+
type Tool,
|
|
10
|
+
togglePanels,
|
|
11
|
+
undo,
|
|
12
|
+
} from '@/canvas/client/state'
|
|
4
13
|
|
|
5
14
|
interface ToolButton {
|
|
6
15
|
readonly tool: Tool
|
|
@@ -32,9 +41,51 @@ const TOOLS: readonly ToolButton[] = [
|
|
|
32
41
|
},
|
|
33
42
|
]
|
|
34
43
|
|
|
35
|
-
|
|
44
|
+
interface HistoryButton {
|
|
45
|
+
readonly name: 'Undo' | 'Redo'
|
|
46
|
+
readonly shortcut: string
|
|
47
|
+
readonly keys: string
|
|
48
|
+
readonly run: () => Promise<void>
|
|
49
|
+
readonly icon: JSX.Element
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The modifier the platform names for undo, Cmd on macOS and Ctrl elsewhere. */
|
|
53
|
+
const MOD = /Mac|iPhone|iPad/.test(globalThis.navigator?.platform ?? '')
|
|
54
|
+
? 'Cmd'
|
|
55
|
+
: 'Ctrl'
|
|
56
|
+
|
|
57
|
+
const HISTORY_BUTTONS: readonly HistoryButton[] = [
|
|
58
|
+
{
|
|
59
|
+
name: 'Undo',
|
|
60
|
+
shortcut: `${MOD}+Z`,
|
|
61
|
+
keys: 'Control+Z Meta+Z',
|
|
62
|
+
run: () => undo(),
|
|
63
|
+
icon: (
|
|
64
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
65
|
+
<path d="M5.5 3.5L2.5 6.5l3 3M2.5 6.5h7a4 4 0 0 1 0 8H7" />
|
|
66
|
+
</svg>
|
|
67
|
+
),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'Redo',
|
|
71
|
+
shortcut: `${MOD}+Shift+Z`,
|
|
72
|
+
keys: 'Control+Shift+Z Meta+Shift+Z Control+Y',
|
|
73
|
+
run: () => redo(),
|
|
74
|
+
icon: (
|
|
75
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
76
|
+
<path d="M10.5 3.5l3 3-3 3M13.5 6.5h-7a4 4 0 0 0 0 8H9" />
|
|
77
|
+
</svg>
|
|
78
|
+
),
|
|
79
|
+
},
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The tool strip floating at the surface's left edge, one button a tool, then
|
|
84
|
+
* undo and redo, each disabled while the server holds nothing to step.
|
|
85
|
+
*/
|
|
36
86
|
export function ToolStrip(): JSX.Element {
|
|
37
87
|
const shown = shownTool.value
|
|
88
|
+
const { canUndo, canRedo } = history.value
|
|
38
89
|
return (
|
|
39
90
|
<div
|
|
40
91
|
class="tools"
|
|
@@ -59,6 +110,44 @@ export function ToolStrip(): JSX.Element {
|
|
|
59
110
|
{icon}
|
|
60
111
|
</button>
|
|
61
112
|
))}
|
|
113
|
+
<span class="tools-divider" aria-hidden="true" />
|
|
114
|
+
{HISTORY_BUTTONS.map(({ name, shortcut, keys, run, icon }) => (
|
|
115
|
+
<button
|
|
116
|
+
key={name}
|
|
117
|
+
type="button"
|
|
118
|
+
class="tool-button"
|
|
119
|
+
aria-label={`${name} (${shortcut})`}
|
|
120
|
+
aria-keyshortcuts={keys}
|
|
121
|
+
title={`${name} (${shortcut})`}
|
|
122
|
+
disabled={name === 'Undo' ? !canUndo : !canRedo}
|
|
123
|
+
onClick={() => void run()}
|
|
124
|
+
>
|
|
125
|
+
{icon}
|
|
126
|
+
</button>
|
|
127
|
+
))}
|
|
128
|
+
<span class="tools-divider" aria-hidden="true" />
|
|
129
|
+
<PanelsButton />
|
|
62
130
|
</div>
|
|
63
131
|
)
|
|
64
132
|
}
|
|
133
|
+
|
|
134
|
+
/** Named by what a press does now, so the panels come back by mouse too. */
|
|
135
|
+
function PanelsButton(): JSX.Element {
|
|
136
|
+
const name = panelsHidden.value ? 'Show panels' : 'Hide panels'
|
|
137
|
+
return (
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
class="tool-button"
|
|
141
|
+
aria-label={`${name} (\\)`}
|
|
142
|
+
aria-keyshortcuts="\\"
|
|
143
|
+
title={`${name} (\\)`}
|
|
144
|
+
data-panels={panelsHidden.value ? 'hidden' : 'shown'}
|
|
145
|
+
onClick={togglePanels}
|
|
146
|
+
>
|
|
147
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
148
|
+
<rect x="2" y="3" width="12" height="10" rx="1.5" />
|
|
149
|
+
{panelsHidden.value ? null : <path d="M5.5 3v10M10.5 3v10" />}
|
|
150
|
+
</svg>
|
|
151
|
+
</button>
|
|
152
|
+
)
|
|
153
|
+
}
|
package/src/canvas/content.ts
CHANGED
|
@@ -419,6 +419,12 @@ export interface Position {
|
|
|
419
419
|
readonly y: number
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
+
/**
|
|
423
|
+
* Hears the box a write replaced, undefined when the layout held none, and the
|
|
424
|
+
* box it wrote, apart from the returned record, which the CLI prints.
|
|
425
|
+
*/
|
|
426
|
+
export type BoxObserver = (replaced: Box | undefined, written: Box) => void
|
|
427
|
+
|
|
422
428
|
/**
|
|
423
429
|
* Moves one frame and keeps its size. A frame the layout never named takes the
|
|
424
430
|
* default size it was already drawn at, so the move does not resize it.
|
|
@@ -428,6 +434,7 @@ export function moveFrame(
|
|
|
428
434
|
page: string,
|
|
429
435
|
frame: string,
|
|
430
436
|
to: Position,
|
|
437
|
+
observe?: BoxObserver,
|
|
431
438
|
): FrameOutcome {
|
|
432
439
|
if (!isValidName(page)) {
|
|
433
440
|
return refuse('invalid-name', `${page} is not a valid page name`)
|
|
@@ -460,6 +467,7 @@ export function moveFrame(
|
|
|
460
467
|
}
|
|
461
468
|
|
|
462
469
|
writeLayout(dir, new Map([...boxes, [frame, box]]))
|
|
470
|
+
observe?.(boxes.get(frame), box)
|
|
463
471
|
return { ok: true, page, frame, file, box }
|
|
464
472
|
})
|
|
465
473
|
}
|
|
@@ -474,6 +482,7 @@ export function resizeFrame(
|
|
|
474
482
|
page: string,
|
|
475
483
|
frame: string,
|
|
476
484
|
box: Box,
|
|
485
|
+
observe?: BoxObserver,
|
|
477
486
|
): FrameOutcome {
|
|
478
487
|
if (!isValidName(page)) {
|
|
479
488
|
return refuse('invalid-name', `${page} is not a valid page name`)
|
|
@@ -509,10 +518,66 @@ export function resizeFrame(
|
|
|
509
518
|
}
|
|
510
519
|
|
|
511
520
|
writeLayout(dir, new Map([...boxes, [frame, written]]))
|
|
521
|
+
observe?.(boxes.get(frame), written)
|
|
512
522
|
return { ok: true, page, frame, file, box: written }
|
|
513
523
|
})
|
|
514
524
|
}
|
|
515
525
|
|
|
526
|
+
function isSameBox(a: Box | undefined, b: Box | undefined): boolean {
|
|
527
|
+
if (a === undefined || b === undefined) return a === b
|
|
528
|
+
return (
|
|
529
|
+
a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
|
|
530
|
+
)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export type BoxRestore =
|
|
534
|
+
| { readonly ok: true }
|
|
535
|
+
| ContentRefused
|
|
536
|
+
| {
|
|
537
|
+
readonly ok: false
|
|
538
|
+
readonly reason: 'changed'
|
|
539
|
+
readonly detail: string
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Writes a recorded box back under the layout lock, only while the layout
|
|
544
|
+
* still holds the box the record expects, where undefined expects none.
|
|
545
|
+
* Restoring to no box drops the frame's entry, so the reader places it in the
|
|
546
|
+
* default row again.
|
|
547
|
+
*/
|
|
548
|
+
export function restoreBox(
|
|
549
|
+
root: string,
|
|
550
|
+
page: string,
|
|
551
|
+
frame: string,
|
|
552
|
+
expected: Box | undefined,
|
|
553
|
+
to: Box | undefined,
|
|
554
|
+
): BoxRestore {
|
|
555
|
+
const dir = pagePath(root, page)
|
|
556
|
+
if (!isDirectory(dir)) return refuse('no-page', `page ${page} does not exist`)
|
|
557
|
+
if (!existsSync(join(dir, `${frame}${FRAME_EXTENSION}`))) {
|
|
558
|
+
return refuse('no-frame', `frame ${frame} does not exist on ${page}`)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return withFileLock(join(dir, LAYOUT_FILE), (): BoxRestore => {
|
|
562
|
+
const { boxes, malformed } = readLayout(dir)
|
|
563
|
+
if (malformed) {
|
|
564
|
+
return refuse('malformed-layout', `${page}/${LAYOUT_FILE} does not parse`)
|
|
565
|
+
}
|
|
566
|
+
if (!isSameBox(boxes.get(frame), expected)) {
|
|
567
|
+
return {
|
|
568
|
+
ok: false,
|
|
569
|
+
reason: 'changed',
|
|
570
|
+
detail: `${page}/${frame} moved since this change, so nothing was written`,
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
const next = new Map(boxes)
|
|
574
|
+
if (to === undefined) next.delete(frame)
|
|
575
|
+
else next.set(frame, to)
|
|
576
|
+
writeLayout(dir, next)
|
|
577
|
+
return { ok: true }
|
|
578
|
+
})
|
|
579
|
+
}
|
|
580
|
+
|
|
516
581
|
export interface Selection {
|
|
517
582
|
readonly page: string
|
|
518
583
|
readonly frame: string
|