@erclx/canon 5.27.0 → 5.29.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/draft-ready/SKILL.md +3 -1
- package/claude/skills/git-pr/references/evidence.md +1 -1
- package/claude/skills/review-address/REQUIREMENT.md +4 -2
- package/claude/skills/review-address/SKILL.md +16 -7
- package/claude/skills/review-address/references/rebase-conflicts.md +4 -2
- package/claude/skills/review-craft/references/ui.md +4 -4
- package/claude/skills/review-ui/REQUIREMENT.md +2 -2
- package/claude/skills/review-ui/SKILL.md +13 -11
- package/claude/skills/review-ui/references/driving.md +8 -8
- package/claude/skills/review-ui/references/post.md +19 -7
- package/claude/skills/review-ui/references/tick.md +22 -0
- package/claude/skills/role-orchestrator/REQUIREMENT.md +15 -15
- package/claude/skills/role-orchestrator/SKILL.md +24 -26
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +18 -16
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +12 -2
- package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -4
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +5 -5
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +11 -2
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +6 -6
- package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +2 -2
- package/claude/skills/role-orchestrator/scripts/poll.ts +304 -10
- package/claude/skills/role-planner/REQUIREMENT.md +1 -1
- package/claude/skills/role-planner/SKILL.md +2 -2
- package/claude/skills/role-reviewer/SKILL.md +1 -1
- package/claude/skills/role-worker/SKILL.md +3 -2
- package/claude/skills/ui-checklist/SKILL.md +1 -1
- package/docs/agents/canvas.md +12 -4
- package/docs/agents/gate.md +3 -1
- package/docs/agents/index.md +2 -1
- package/docs/agents/pr-evidence.md +15 -14
- package/docs/agents/pr-tick.md +72 -0
- package/docs/agents/sandbox.md +2 -2
- package/docs/agents/tasks-plans.md +1 -1
- package/docs/agents/tasks-validate.md +18 -18
- package/docs/agents/tasks.md +1 -1
- package/docs/workflow/operating-model.md +10 -15
- package/docs/workflow/parallel-features.md +7 -7
- package/package.json +1 -1
- package/src/audits/baseline.ts +1 -1
- package/src/audits/run.ts +1 -1
- package/src/canvas/capture.ts +1 -1
- package/src/canvas/client/app.tsx +105 -4
- package/src/canvas/client/inspector/field.tsx +61 -2
- package/src/canvas/client/inspector/fields.css +78 -0
- package/src/canvas/client/inspector/token-select.tsx +109 -0
- package/src/canvas/client/inspector/values.ts +74 -2
- package/src/canvas/client/inspector.tsx +134 -15
- package/src/canvas/client/keys.ts +71 -0
- package/src/canvas/client/pages-panel.tsx +5 -1
- package/src/canvas/client/panel-handle.tsx +90 -0
- package/src/canvas/client/selection.tsx +3 -1
- package/src/canvas/client/shell.css +194 -4
- package/src/canvas/client/state.ts +255 -11
- package/src/canvas/client/surface.tsx +231 -18
- package/src/canvas/client/tools.tsx +153 -0
- package/src/canvas/content.ts +66 -1
- package/src/canvas/edit.ts +207 -25
- package/src/canvas/history.ts +116 -0
- package/src/canvas/server.ts +119 -13
- package/src/canvas/tokens.ts +1 -1
- package/src/claude/seeds.ts +2 -2
- package/src/claude/skills-reach.ts +1 -1
- package/src/{exec.ts → cli/exec.ts} +2 -2
- 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 +2 -2
- package/src/commands/capture.ts +1 -1
- package/src/commands/census.ts +1 -1
- package/src/commands/claude.ts +5 -5
- 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 +12 -4
- package/src/commands/docs.ts +2 -2
- package/src/commands/driver.ts +9 -1
- package/src/commands/feedback.ts +3 -3
- package/src/commands/gate.ts +10 -3
- package/src/commands/gov.ts +3 -3
- package/src/commands/indexes.ts +1 -1
- package/src/commands/init.ts +11 -3
- 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 +2 -2
- package/src/commands/pr.ts +324 -9
- package/src/commands/records.ts +1 -1
- package/src/commands/repo.ts +1 -1
- package/src/commands/sandbox.ts +11 -14
- package/src/commands/secrets.ts +9 -1
- package/src/commands/sessions.ts +1 -1
- package/src/commands/slides.ts +2 -2
- package/src/commands/standards.ts +1 -1
- package/src/commands/sync.ts +3 -3
- package/src/commands/targets.ts +1 -1
- package/src/commands/tasks.ts +20 -3
- package/src/commands/teach.ts +1 -1
- package/src/commands/tooling.ts +2 -2
- package/src/commands/transcripts.ts +2 -2
- package/src/commands/upgrade.ts +2 -2
- package/src/commands/wiki.ts +2 -2
- package/src/commands/worktrees.ts +1 -1
- package/src/context/architecture.ts +1 -1
- package/src/context/citations.ts +2 -2
- package/src/context/classify/extract.ts +1 -1
- package/src/context/folders.ts +2 -2
- package/src/context/requirements.ts +1 -1
- package/src/docs/read.ts +1 -1
- package/src/feedback/github.ts +1 -1
- package/src/gate/measures.ts +2 -2
- package/src/gate/sequencer.ts +55 -3
- package/src/gate/stages.ts +8 -0
- package/src/gate/suite-lock.ts +214 -0
- package/src/init/run.ts +1 -1
- package/src/intake/folder.ts +1 -1
- package/src/labels/map.ts +1 -1
- package/src/labels/phase.ts +3 -3
- package/src/migrate/record-layout.ts +1 -1
- package/src/migrate/records.ts +11 -11
- package/src/migrate/rename.ts +1 -1
- package/src/migrate/scratch-evidence.ts +1 -1
- package/src/migrate/surface-roots.ts +6 -6
- package/src/pr/evidence.ts +188 -16
- package/src/pr/tick.ts +56 -0
- package/src/process/harness.ts +1 -1
- package/src/records/backup.ts +1 -1
- package/src/records/ordinal.ts +1 -1
- package/src/records/prune.ts +1 -1
- package/src/records/size.ts +1 -1
- package/src/records/stale-canonical.ts +1 -1
- package/src/records/stale.ts +1 -1
- package/src/records/validate.ts +2 -2
- package/src/{project-root.ts → roots/project.ts} +2 -2
- package/src/{record-root.ts → roots/record.ts} +1 -1
- package/src/sandbox/coverage.ts +11 -12
- package/src/sandbox/equivalence.ts +57 -26
- package/src/sandbox/headless/run.ts +1 -1
- package/src/sandbox/hooks.ts +1 -1
- package/src/sandbox/provision.ts +79 -29
- package/src/sandbox/scenario.ts +588 -0
- package/src/serve/report.ts +1 -1
- package/src/sessions/live.ts +4 -1
- package/src/sessions/transfer/bundle.ts +1 -1
- package/src/slides/decks.ts +1 -1
- package/src/standards/read.ts +1 -1
- package/src/sync/engine.ts +2 -2
- package/src/sync/git.ts +1 -1
- package/src/sync/layout.ts +2 -2
- package/src/sync/stamp.ts +1 -1
- package/src/sync/workflow.ts +1 -1
- package/src/targets/validate.ts +2 -2
- package/src/tasks/answers.ts +1 -1
- package/src/tasks/archive.ts +1 -1
- package/src/tasks/label.ts +1 -1
- package/src/tasks/reach.ts +1 -1
- package/src/tasks/validate.ts +38 -14
- package/src/teach/nav.ts +1 -1
- package/src/teach/workspace.ts +1 -1
- package/src/tooling/inject.ts +2 -2
- package/src/tooling/read.ts +1 -1
- package/src/tooling/scan.ts +1 -1
- package/src/tooling/verify-stack.ts +1 -1
- package/src/version/installed.ts +1 -1
- package/src/version/skew.ts +1 -1
- package/standards/board.md +3 -3
- package/standards/plan.md +13 -7
- package/tsconfig.json +1 -1
- /package/src/{cli-run.ts → cli/run.ts} +0 -0
- /package/src/{ui.ts → cli/ui.ts} +0 -0
- /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
- /package/src/{surface-root.ts → roots/surface.ts} +0 -0
|
@@ -1,30 +1,41 @@
|
|
|
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
|
+
import { type SurfaceAction, surfaceAction } from '@/canvas/client/keys'
|
|
6
7
|
import {
|
|
8
|
+
activeTool,
|
|
7
9
|
currentPage,
|
|
8
10
|
draggingFrame,
|
|
11
|
+
editingFrames,
|
|
9
12
|
fitView,
|
|
10
13
|
type FrameRef,
|
|
11
14
|
frameDocuments,
|
|
12
15
|
frameKey,
|
|
13
16
|
frameTheme,
|
|
14
17
|
frameVersions,
|
|
18
|
+
historyNotice,
|
|
15
19
|
hoveredElement,
|
|
16
20
|
moveFrameTo,
|
|
17
21
|
panBy,
|
|
22
|
+
panelsHidden,
|
|
23
|
+
panelWidths,
|
|
18
24
|
previewMove,
|
|
19
25
|
previewResize,
|
|
26
|
+
redo,
|
|
20
27
|
registerFrameDocument,
|
|
21
28
|
resizeElement,
|
|
22
29
|
resizeFrameTo,
|
|
23
30
|
selectElement,
|
|
24
31
|
selectFrame,
|
|
25
32
|
selection,
|
|
33
|
+
shownTool,
|
|
34
|
+
spacePan,
|
|
26
35
|
type Theme,
|
|
27
36
|
toggleFrameTheme,
|
|
37
|
+
togglePanels,
|
|
38
|
+
undo,
|
|
28
39
|
view,
|
|
29
40
|
zoomAt,
|
|
30
41
|
} from '@/canvas/client/state'
|
|
@@ -34,6 +45,7 @@ import {
|
|
|
34
45
|
type OverlayBox,
|
|
35
46
|
SelectionOverlay,
|
|
36
47
|
} from '@/canvas/client/selection'
|
|
48
|
+
import { ToolStrip } from '@/canvas/client/tools'
|
|
37
49
|
|
|
38
50
|
/** One wheel notch or one button press. */
|
|
39
51
|
const ZOOM_STEP = 1.2
|
|
@@ -82,6 +94,37 @@ function asElement(target: EventTarget | null): Element | undefined {
|
|
|
82
94
|
return node?.nodeType === 1 ? (node as Element) : undefined
|
|
83
95
|
}
|
|
84
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Whether a key belongs to the element it was typed in rather than to the
|
|
99
|
+
* canvas: any key in a field, and Space on a control it activates. A frame's
|
|
100
|
+
* draft can hold its own fields, and their keys arrive through the forwarding.
|
|
101
|
+
*/
|
|
102
|
+
function isOwnedByTarget(event: KeyboardEvent, action: SurfaceAction): boolean {
|
|
103
|
+
const target = asElement(event.target)
|
|
104
|
+
if (!target) return false
|
|
105
|
+
if ((target as HTMLElement).isContentEditable) return true
|
|
106
|
+
if (target.closest('input, textarea, select')) return true
|
|
107
|
+
return (
|
|
108
|
+
action === 'pan-hold' && target.closest('button, a[href], summary') !== null
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A press read as a second click selects a word or a whole frame even while
|
|
114
|
+
* panning, so no selection may start in the shell or a frame then.
|
|
115
|
+
*/
|
|
116
|
+
function blockSelectionWhilePanning(event: Event): void {
|
|
117
|
+
if (shownTool.value === 'pan') event.preventDefault()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Drops any selection in the shell and in every loaded frame document. */
|
|
121
|
+
function clearSelections(): void {
|
|
122
|
+
document.getSelection()?.removeAllRanges()
|
|
123
|
+
for (const doc of frameDocuments.value.values()) {
|
|
124
|
+
doc.getSelection()?.removeAllRanges()
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
85
128
|
/** A computed length in pixels, or the fallback for one that is not. */
|
|
86
129
|
function pixels(value: string, fallback: number): number {
|
|
87
130
|
const parsed = Number.parseFloat(value)
|
|
@@ -211,10 +254,12 @@ function FrameView({
|
|
|
211
254
|
page,
|
|
212
255
|
frame,
|
|
213
256
|
onWheelTurn,
|
|
257
|
+
onKey,
|
|
214
258
|
}: {
|
|
215
259
|
readonly page: string
|
|
216
260
|
readonly frame: Frame
|
|
217
261
|
readonly onWheelTurn: (turn: WheelTurn) => void
|
|
262
|
+
readonly onKey: (event: KeyboardEvent) => void
|
|
218
263
|
}): JSX.Element {
|
|
219
264
|
const key = frameKey(page, frame)
|
|
220
265
|
const version = frameVersions.value.get(key) ?? 0
|
|
@@ -227,6 +272,7 @@ function FrameView({
|
|
|
227
272
|
/* Bumped when the frame document scrolls, so the outlines follow it. */
|
|
228
273
|
const [, setScrolled] = useState(0)
|
|
229
274
|
const doc = frameDocuments.value.get(key)
|
|
275
|
+
const editing = editingFrames.value.get(key)
|
|
230
276
|
|
|
231
277
|
useEffect(() => {
|
|
232
278
|
applyFrameTheme(iframe.current, value)
|
|
@@ -277,6 +323,16 @@ function FrameView({
|
|
|
277
323
|
},
|
|
278
324
|
{ passive: false },
|
|
279
325
|
)
|
|
326
|
+
/*
|
|
327
|
+
* A click inside the frame moves focus into its window, so the surface
|
|
328
|
+
* hears no key after the first pick unless the frame passes it on.
|
|
329
|
+
*/
|
|
330
|
+
loaded.addEventListener('keydown', onKey)
|
|
331
|
+
loaded.addEventListener('keyup', onKey)
|
|
332
|
+
loaded.addEventListener('selectstart', blockSelectionWhilePanning)
|
|
333
|
+
loaded.defaultView?.addEventListener('blur', () => {
|
|
334
|
+
spacePan.value = undefined
|
|
335
|
+
})
|
|
280
336
|
}
|
|
281
337
|
|
|
282
338
|
const picked =
|
|
@@ -298,6 +354,8 @@ function FrameView({
|
|
|
298
354
|
|
|
299
355
|
const handlePointerDown = (event: PointerEvent) => {
|
|
300
356
|
if (event.button !== 0) return
|
|
357
|
+
/* A pan drag starting on the label belongs to the surface behind it. */
|
|
358
|
+
if (shownTool.value === 'pan') return
|
|
301
359
|
if ((event.target as HTMLElement | null)?.closest('.frame-theme')) return
|
|
302
360
|
gesture.current = {
|
|
303
361
|
startX: event.clientX,
|
|
@@ -346,7 +404,7 @@ function FrameView({
|
|
|
346
404
|
*/
|
|
347
405
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
348
406
|
if (event.target !== event.currentTarget) return
|
|
349
|
-
if (event.key === 'Enter'
|
|
407
|
+
if (event.key === 'Enter') {
|
|
350
408
|
event.preventDefault()
|
|
351
409
|
select()
|
|
352
410
|
return
|
|
@@ -368,6 +426,16 @@ function FrameView({
|
|
|
368
426
|
void moveFrameTo(ref, frame.x + delta.x * step, frame.y + delta.y * step)
|
|
369
427
|
}
|
|
370
428
|
|
|
429
|
+
/*
|
|
430
|
+
* Space held pans, so it selects on release instead, and only when no drag
|
|
431
|
+
* moved the view while it was down. This runs before the surface's own
|
|
432
|
+
* release clears the pan.
|
|
433
|
+
*/
|
|
434
|
+
const handleKeyUp = (event: KeyboardEvent) => {
|
|
435
|
+
if (event.target !== event.currentTarget || event.key !== ' ') return
|
|
436
|
+
if (spacePan.value !== 'dragged') select()
|
|
437
|
+
}
|
|
438
|
+
|
|
371
439
|
const frameBox = (): OverlayBox => ({
|
|
372
440
|
x: frame.x,
|
|
373
441
|
y: frame.y,
|
|
@@ -383,10 +451,12 @@ function FrameView({
|
|
|
383
451
|
data-selected={isSelected ? 'true' : undefined}
|
|
384
452
|
data-picked={picked ? 'true' : undefined}
|
|
385
453
|
data-dragging={draggingFrame.value === frame.name ? 'true' : undefined}
|
|
454
|
+
data-editing={editing ? 'true' : undefined}
|
|
386
455
|
tabIndex={0}
|
|
387
|
-
aria-label={`${frame.name}, ${frame.width} by ${frame.height}`}
|
|
456
|
+
aria-label={`${frame.name}, ${frame.width} by ${frame.height}${editing ? `, ${editing.by} editing` : ''}`}
|
|
388
457
|
aria-current={isSelected ? 'true' : undefined}
|
|
389
458
|
onKeyDown={handleKeyDown}
|
|
459
|
+
onKeyUp={handleKeyUp}
|
|
390
460
|
style={{
|
|
391
461
|
left: `${frame.x}px`,
|
|
392
462
|
top: `${frame.y}px`,
|
|
@@ -407,6 +477,10 @@ function FrameView({
|
|
|
407
477
|
<span class="frame-size">
|
|
408
478
|
{frame.width} × {frame.height}
|
|
409
479
|
</span>
|
|
480
|
+
{/* Rendered empty while unmarked, so a mark landing is announced. */}
|
|
481
|
+
<span class="frame-editing" aria-live="polite">
|
|
482
|
+
{editing ? `${editing.by} editing` : ''}
|
|
483
|
+
</span>
|
|
410
484
|
<button
|
|
411
485
|
type="button"
|
|
412
486
|
class="frame-theme"
|
|
@@ -461,18 +535,35 @@ function viewportSize(viewport: RefObject<HTMLDivElement | null>): {
|
|
|
461
535
|
return { width: rect?.width ?? 0, height: rect?.height ?? 0 }
|
|
462
536
|
}
|
|
463
537
|
|
|
538
|
+
/** The zoom toolbar's actions, which its keys call as well. */
|
|
539
|
+
function zoomCentered(
|
|
540
|
+
viewport: RefObject<HTMLDivElement | null>,
|
|
541
|
+
factor: number,
|
|
542
|
+
): void {
|
|
543
|
+
const { width, height } = viewportSize(viewport)
|
|
544
|
+
zoomAt(factor, width / 2, height / 2)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Fits the page clear of the tool strip floating over the viewport's left. */
|
|
548
|
+
function fitViewport(viewport: RefObject<HTMLDivElement | null>): void {
|
|
549
|
+
const rect = viewport.current?.getBoundingClientRect()
|
|
550
|
+
const strip = viewport.current?.parentElement
|
|
551
|
+
?.querySelector('.tools')
|
|
552
|
+
?.getBoundingClientRect()
|
|
553
|
+
const inset = rect && strip ? Math.max(0, strip.right - rect.left) : 0
|
|
554
|
+
fitView(rect?.width ?? 0, rect?.height ?? 0, undefined, inset)
|
|
555
|
+
}
|
|
556
|
+
|
|
464
557
|
function Toolbar({ viewportRef }: SurfaceProps): JSX.Element {
|
|
465
|
-
const zoomCentered = (factor: number) => {
|
|
466
|
-
const { width, height } = viewportSize(viewportRef)
|
|
467
|
-
zoomAt(factor, width / 2, height / 2)
|
|
468
|
-
}
|
|
469
558
|
return (
|
|
470
559
|
<div class="toolbar" role="toolbar" aria-label="Zoom">
|
|
471
560
|
<button
|
|
472
561
|
type="button"
|
|
473
562
|
class="icon-button"
|
|
474
563
|
aria-label="Zoom out"
|
|
475
|
-
|
|
564
|
+
aria-keyshortcuts="-"
|
|
565
|
+
title="Zoom out (-)"
|
|
566
|
+
onClick={() => zoomCentered(viewportRef, 1 / ZOOM_STEP)}
|
|
476
567
|
>
|
|
477
568
|
−
|
|
478
569
|
</button>
|
|
@@ -483,17 +574,18 @@ function Toolbar({ viewportRef }: SurfaceProps): JSX.Element {
|
|
|
483
574
|
type="button"
|
|
484
575
|
class="icon-button"
|
|
485
576
|
aria-label="Zoom in"
|
|
486
|
-
|
|
577
|
+
aria-keyshortcuts="+"
|
|
578
|
+
title="Zoom in (+)"
|
|
579
|
+
onClick={() => zoomCentered(viewportRef, ZOOM_STEP)}
|
|
487
580
|
>
|
|
488
581
|
+
|
|
489
582
|
</button>
|
|
490
583
|
<button
|
|
491
584
|
type="button"
|
|
492
585
|
class="text-button"
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}}
|
|
586
|
+
aria-keyshortcuts="Shift+1"
|
|
587
|
+
title="Fit (Shift+1)"
|
|
588
|
+
onClick={() => fitViewport(viewportRef)}
|
|
497
589
|
>
|
|
498
590
|
Fit
|
|
499
591
|
</button>
|
|
@@ -507,10 +599,25 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
|
|
|
507
599
|
|
|
508
600
|
/* Fits a page once when it opens, never on a reload of the same page. */
|
|
509
601
|
useEffect(() => {
|
|
510
|
-
|
|
511
|
-
fitView(width, height)
|
|
602
|
+
fitViewport(viewportRef)
|
|
512
603
|
}, [page?.name])
|
|
513
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
|
+
|
|
514
621
|
const handleWheelTurn = (turn: WheelTurn) => {
|
|
515
622
|
if (turn.isZoom) {
|
|
516
623
|
const rect = viewportRef.current?.getBoundingClientRect()
|
|
@@ -536,9 +643,77 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
|
|
|
536
643
|
})
|
|
537
644
|
}
|
|
538
645
|
|
|
646
|
+
/* A pan held when the window loses focus never hears its Space come up. */
|
|
647
|
+
useEffect(() => {
|
|
648
|
+
const endPan = () => {
|
|
649
|
+
spacePan.value = undefined
|
|
650
|
+
}
|
|
651
|
+
window.addEventListener('blur', endPan)
|
|
652
|
+
document.addEventListener('selectstart', blockSelectionWhilePanning)
|
|
653
|
+
return () => {
|
|
654
|
+
window.removeEventListener('blur', endPan)
|
|
655
|
+
document.removeEventListener('selectstart', blockSelectionWhilePanning)
|
|
656
|
+
}
|
|
657
|
+
}, [])
|
|
658
|
+
|
|
659
|
+
const runAction = (action: SurfaceAction) => {
|
|
660
|
+
switch (action) {
|
|
661
|
+
case 'tool-move':
|
|
662
|
+
activeTool.value = 'move'
|
|
663
|
+
return
|
|
664
|
+
case 'tool-pan':
|
|
665
|
+
activeTool.value = 'pan'
|
|
666
|
+
return
|
|
667
|
+
case 'zoom-in':
|
|
668
|
+
zoomCentered(viewportRef, ZOOM_STEP)
|
|
669
|
+
return
|
|
670
|
+
case 'zoom-out':
|
|
671
|
+
zoomCentered(viewportRef, 1 / ZOOM_STEP)
|
|
672
|
+
return
|
|
673
|
+
case 'zoom-fit':
|
|
674
|
+
fitViewport(viewportRef)
|
|
675
|
+
return
|
|
676
|
+
case 'pan-hold':
|
|
677
|
+
spacePan.value ??= 'held'
|
|
678
|
+
return
|
|
679
|
+
case 'pan-release':
|
|
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()
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/*
|
|
694
|
+
* The surface's one key handler, which every frame document feeds too. The
|
|
695
|
+
* default is prevented only for a key press it takes, so Space stops
|
|
696
|
+
* scrolling a frame it pans and every other key keeps its own meaning. A
|
|
697
|
+
* release always ends the pan and never claims the keyup, since a button
|
|
698
|
+
* fires its Space activation there.
|
|
699
|
+
*/
|
|
700
|
+
const handleKey = (event: KeyboardEvent) => {
|
|
701
|
+
const action = surfaceAction(event)
|
|
702
|
+
if (!action) return
|
|
703
|
+
if (action === 'pan-release') {
|
|
704
|
+
runAction(action)
|
|
705
|
+
return
|
|
706
|
+
}
|
|
707
|
+
if (isOwnedByTarget(event, action)) return
|
|
708
|
+
event.preventDefault()
|
|
709
|
+
runAction(action)
|
|
710
|
+
}
|
|
711
|
+
|
|
539
712
|
const handlePointerDown = (event: PointerEvent) => {
|
|
540
713
|
const target = event.target as HTMLElement | null
|
|
541
|
-
if (target?.closest('.frame
|
|
714
|
+
if (shownTool.value !== 'pan' && target?.closest('.frame')) return
|
|
715
|
+
/* A selection left over would paint over every frame the pan crosses. */
|
|
716
|
+
if (shownTool.value === 'pan') clearSelections()
|
|
542
717
|
drag.current = { x: event.clientX, y: event.clientY }
|
|
543
718
|
viewportRef.current?.setPointerCapture?.(event.pointerId)
|
|
544
719
|
}
|
|
@@ -546,22 +721,53 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
|
|
|
546
721
|
const handlePointerMove = (event: PointerEvent) => {
|
|
547
722
|
const start = drag.current
|
|
548
723
|
if (!start) return
|
|
549
|
-
|
|
724
|
+
const dx = event.clientX - start.x
|
|
725
|
+
const dy = event.clientY - start.y
|
|
726
|
+
panBy(dx, dy)
|
|
550
727
|
drag.current = { x: event.clientX, y: event.clientY }
|
|
728
|
+
if (spacePan.value === 'held' && (dx !== 0 || dy !== 0)) {
|
|
729
|
+
spacePan.value = 'dragged'
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/*
|
|
734
|
+
* A press selects text by default, a word or a whole frame when it reads as
|
|
735
|
+
* a second click, and neither `selectstart` nor a clear at pointer down
|
|
736
|
+
* reaches that. Under a held Space, focus stays where it was so the frame
|
|
737
|
+
* still hears the release. Under the Pan tool the press takes focus itself,
|
|
738
|
+
* as the default would have, so a field it leaves commits and the next key
|
|
739
|
+
* reaches the layer.
|
|
740
|
+
*/
|
|
741
|
+
const handleMouseDown = (event: MouseEvent) => {
|
|
742
|
+
if (shownTool.value !== 'pan') return
|
|
743
|
+
event.preventDefault()
|
|
744
|
+
if (!spacePan.value) {
|
|
745
|
+
const surface = (event.currentTarget as HTMLElement).closest('main')
|
|
746
|
+
surface?.focus({ preventScroll: true })
|
|
747
|
+
}
|
|
551
748
|
}
|
|
552
749
|
|
|
553
750
|
const handlePointerUp = () => {
|
|
751
|
+
if (drag.current && shownTool.value === 'pan') clearSelections()
|
|
554
752
|
drag.current = undefined
|
|
555
753
|
}
|
|
556
754
|
|
|
557
755
|
const { x, y, zoom } = view.value
|
|
558
756
|
return (
|
|
559
|
-
<main
|
|
757
|
+
<main
|
|
758
|
+
class="surface"
|
|
759
|
+
aria-label="Canvas"
|
|
760
|
+
tabIndex={0}
|
|
761
|
+
data-tool={shownTool.value}
|
|
762
|
+
onKeyDown={handleKey}
|
|
763
|
+
onKeyUp={handleKey}
|
|
764
|
+
>
|
|
560
765
|
<div
|
|
561
766
|
ref={viewportRef}
|
|
562
767
|
class="viewport"
|
|
563
768
|
onWheel={handleWheel}
|
|
564
769
|
onPointerDown={handlePointerDown}
|
|
770
|
+
onMouseDown={handleMouseDown}
|
|
565
771
|
onPointerMove={handlePointerMove}
|
|
566
772
|
onPointerUp={handlePointerUp}
|
|
567
773
|
onPointerCancel={handlePointerUp}
|
|
@@ -580,11 +786,18 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
|
|
|
580
786
|
page={page.name}
|
|
581
787
|
frame={frame}
|
|
582
788
|
onWheelTurn={handleWheelTurn}
|
|
789
|
+
onKey={handleKey}
|
|
583
790
|
/>
|
|
584
791
|
))}
|
|
585
792
|
</div>
|
|
586
793
|
</div>
|
|
794
|
+
<ToolStrip />
|
|
587
795
|
<Toolbar viewportRef={viewportRef} />
|
|
796
|
+
{historyNotice.value ? (
|
|
797
|
+
<p class="history-notice" role="status">
|
|
798
|
+
{historyNotice.value}
|
|
799
|
+
</p>
|
|
800
|
+
) : null}
|
|
588
801
|
</main>
|
|
589
802
|
)
|
|
590
803
|
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
import type { JSX } from 'preact'
|
|
3
|
+
import {
|
|
4
|
+
activeTool,
|
|
5
|
+
history,
|
|
6
|
+
panelsHidden,
|
|
7
|
+
redo,
|
|
8
|
+
shownTool,
|
|
9
|
+
type Tool,
|
|
10
|
+
togglePanels,
|
|
11
|
+
undo,
|
|
12
|
+
} from '@/canvas/client/state'
|
|
13
|
+
|
|
14
|
+
interface ToolButton {
|
|
15
|
+
readonly tool: Tool
|
|
16
|
+
readonly name: string
|
|
17
|
+
readonly key: string
|
|
18
|
+
readonly icon: JSX.Element
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const TOOLS: readonly ToolButton[] = [
|
|
22
|
+
{
|
|
23
|
+
tool: 'move',
|
|
24
|
+
name: 'Move',
|
|
25
|
+
key: 'V',
|
|
26
|
+
icon: (
|
|
27
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
28
|
+
<path d="M3.5 2.5l9 4.2-3.9 1.2-1.4 3.8z" />
|
|
29
|
+
</svg>
|
|
30
|
+
),
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
tool: 'pan',
|
|
34
|
+
name: 'Pan',
|
|
35
|
+
key: 'H',
|
|
36
|
+
icon: (
|
|
37
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
38
|
+
<path d="M5.5 8.5V4a1 1 0 0 1 2 0v3.5M7.5 7V2.8a1 1 0 0 1 2 0V7M9.5 7V3.6a1 1 0 0 1 2 0V8M11.5 7.5a1 1 0 0 1 2 0v2.2A4.8 4.8 0 0 1 8.7 14.5h-.6a4.3 4.3 0 0 1-3.2-1.4L2.6 10.6a1 1 0 0 1 1.5-1.4l1.4 1.3" />
|
|
39
|
+
</svg>
|
|
40
|
+
),
|
|
41
|
+
},
|
|
42
|
+
]
|
|
43
|
+
|
|
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
|
+
*/
|
|
86
|
+
export function ToolStrip(): JSX.Element {
|
|
87
|
+
const shown = shownTool.value
|
|
88
|
+
const { canUndo, canRedo } = history.value
|
|
89
|
+
return (
|
|
90
|
+
<div
|
|
91
|
+
class="tools"
|
|
92
|
+
role="toolbar"
|
|
93
|
+
aria-label="Tools"
|
|
94
|
+
aria-orientation="vertical"
|
|
95
|
+
>
|
|
96
|
+
{TOOLS.map(({ tool, name, key, icon }) => (
|
|
97
|
+
<button
|
|
98
|
+
key={tool}
|
|
99
|
+
type="button"
|
|
100
|
+
class="tool-button"
|
|
101
|
+
aria-label={`${name} (${key})`}
|
|
102
|
+
aria-keyshortcuts={key}
|
|
103
|
+
aria-pressed={shown === tool ? 'true' : 'false'}
|
|
104
|
+
title={`${name} (${key})`}
|
|
105
|
+
data-tool={tool}
|
|
106
|
+
onClick={() => {
|
|
107
|
+
activeTool.value = tool
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
{icon}
|
|
111
|
+
</button>
|
|
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 />
|
|
130
|
+
</div>
|
|
131
|
+
)
|
|
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
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
resolveAddress,
|
|
19
19
|
type SourceElement,
|
|
20
20
|
} from '@/canvas/address'
|
|
21
|
-
import { recordDir } from '@/record
|
|
21
|
+
import { recordDir } from '@/roots/record'
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* The content format. A page is a folder under the record root's `canvas/`, a
|
|
@@ -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
|