@erclx/canon 5.26.0 → 5.28.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/README.md +2 -2
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/SKILL.md +2 -0
- package/claude/skills/auto-ship/SKILL.md +3 -11
- package/claude/skills/auto-ship/references/review-findings.md +20 -0
- package/claude/skills/backlog-triage/SKILL.md +2 -0
- package/claude/skills/bash-cli-script/SKILL.md +2 -0
- package/claude/skills/build-in-slices/SKILL.md +2 -0
- package/claude/skills/canon-cli/SKILL.md +2 -0
- package/claude/skills/canon-feedback/SKILL.md +2 -0
- package/claude/skills/canon-feedback-triage/SKILL.md +2 -0
- package/claude/skills/canon-operator/SKILL.md +2 -0
- package/claude/skills/canon-rollout/SKILL.md +3 -11
- package/claude/skills/canon-rollout/references/failure-recovery.md +20 -0
- package/claude/skills/canvas/SKILL.md +13 -0
- package/claude/skills/ci-workflow/SKILL.md +2 -0
- package/claude/skills/code-craft/SKILL.md +2 -0
- package/claude/skills/codebase-layout/SKILL.md +2 -0
- package/claude/skills/context-fold/SKILL.md +3 -17
- package/claude/skills/context-fold/references/completion-report.md +26 -0
- package/claude/skills/create-rule/SKILL.md +2 -0
- package/claude/skills/create-skill/SKILL.md +2 -0
- package/claude/skills/create-standard/SKILL.md +2 -0
- package/claude/skills/decision-escalate/SKILL.md +2 -0
- package/claude/skills/deploy-app/SKILL.md +2 -0
- package/claude/skills/deprecation-migration/SKILL.md +2 -0
- package/claude/skills/design-extract/SKILL.md +2 -0
- package/claude/skills/design-taste/SKILL.md +2 -0
- package/claude/skills/docs-sync/SKILL.md +2 -0
- package/claude/skills/document-health/SKILL.md +2 -0
- package/claude/skills/draft-and-pick/SKILL.md +2 -0
- package/claude/skills/draft-doc/SKILL.md +2 -0
- package/claude/skills/draft-doc/references/wiki.md +1 -1
- package/claude/skills/draft-figure/SKILL.md +2 -0
- package/claude/skills/draft-identity/SKILL.md +2 -0
- package/claude/skills/draft-ready/SKILL.md +5 -1
- package/claude/skills/draft-screencast/SKILL.md +2 -0
- package/claude/skills/draft-slides/SKILL.md +2 -0
- package/claude/skills/git-branch/SKILL.md +2 -0
- package/claude/skills/git-commit/SKILL.md +2 -0
- package/claude/skills/git-followup/SKILL.md +2 -0
- package/claude/skills/git-issue/SKILL.md +2 -0
- package/claude/skills/git-pr/SKILL.md +2 -0
- package/claude/skills/git-pr/references/evidence.md +7 -7
- package/claude/skills/git-ship/SKILL.md +2 -0
- package/claude/skills/git-split/SKILL.md +2 -0
- package/claude/skills/git-stage/SKILL.md +2 -0
- package/claude/skills/git-worktree/SKILL.md +2 -0
- package/claude/skills/index-lookup/SKILL.md +2 -0
- package/claude/skills/markdown-propose/SKILL.md +2 -0
- package/claude/skills/memory-capture/SKILL.md +2 -0
- package/claude/skills/memory-review/SKILL.md +2 -0
- package/claude/skills/plan-feature/SKILL.md +2 -0
- package/claude/skills/plan-groundwork/SKILL.md +2 -0
- package/claude/skills/plan-intake/SKILL.md +2 -0
- package/claude/skills/plan-intake-answer/SKILL.md +2 -0
- package/claude/skills/project-commands/SKILL.md +2 -0
- package/claude/skills/read-frames/SKILL.md +2 -0
- package/claude/skills/record-screencast/SKILL.md +2 -0
- package/claude/skills/repo-metadata/SKILL.md +2 -0
- package/claude/skills/restate-plainly/SKILL.md +2 -0
- package/claude/skills/review-address/REQUIREMENT.md +4 -2
- package/claude/skills/review-address/SKILL.md +19 -14
- package/claude/skills/review-address/references/post-review-findings.md +16 -0
- package/claude/skills/review-address/references/rebase-conflicts.md +4 -2
- package/claude/skills/review-branch/SKILL.md +2 -0
- package/claude/skills/review-craft/SKILL.md +2 -0
- package/claude/skills/review-craft/references/ui.md +6 -6
- package/claude/skills/review-pr/SKILL.md +4 -8
- package/claude/skills/review-pr/references/body-variants.md +52 -0
- package/claude/skills/review-pr/references/key-changes.md +16 -0
- package/claude/skills/review-pr/references/post.md +15 -43
- package/claude/skills/review-ui/REQUIREMENT.md +2 -2
- package/claude/skills/review-ui/SKILL.md +16 -12
- package/claude/skills/review-ui/references/driving.md +8 -8
- package/claude/skills/review-ui/references/post.md +13 -3
- 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 +31 -42
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +18 -16
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +13 -3
- package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +6 -34
- 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 +12 -23
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +6 -6
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
- package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
- 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 +4 -2
- package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
- package/claude/skills/role-reviewer/SKILL.md +3 -1
- package/claude/skills/role-worker/SKILL.md +5 -2
- package/claude/skills/search-craft/SKILL.md +2 -0
- package/claude/skills/seed-sync/SKILL.md +2 -0
- package/claude/skills/session-compact/SKILL.md +2 -0
- package/claude/skills/session-map/SKILL.md +2 -0
- package/claude/skills/session-relay/SKILL.md +2 -0
- package/claude/skills/session-resume/SKILL.md +2 -0
- package/claude/skills/session-worktree/SKILL.md +3 -11
- package/claude/skills/session-worktree/references/install-command.md +20 -0
- package/claude/skills/sketch-design/SKILL.md +2 -0
- package/claude/skills/systematic-debugging/SKILL.md +2 -0
- package/claude/skills/target-check/SKILL.md +2 -0
- package/claude/skills/target-setup/SKILL.md +2 -0
- package/claude/skills/task-board/SKILL.md +2 -0
- package/claude/skills/teach-workspace/SKILL.md +5 -15
- package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
- package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
- package/claude/skills/teach-workspace/references/revisit.md +22 -0
- package/claude/skills/test-craft/SKILL.md +2 -0
- package/claude/skills/test-first/SKILL.md +2 -0
- package/claude/skills/ui-checklist/SKILL.md +3 -1
- package/claude/skills/ux-audit/SKILL.md +2 -0
- package/claude/skills/ux-measure/SKILL.md +2 -0
- package/claude/skills/ux-walkthrough/SKILL.md +2 -0
- package/claude/skills/video-craft/SKILL.md +2 -0
- package/claude/skills/write-human/SKILL.md +2 -0
- package/claude/skills/youtube-transcripts/SKILL.md +2 -0
- package/docs/agents/canvas.md +24 -8
- package/docs/agents/capture.md +5 -5
- package/docs/agents/commands.md +2 -2
- package/docs/agents/gate.md +3 -1
- package/docs/agents/index.md +5 -4
- package/docs/agents/install-and-sync.md +7 -0
- package/docs/agents/key-changes.md +1 -1
- package/docs/agents/pr-evidence.md +47 -23
- package/docs/agents/pr-local.md +19 -13
- package/docs/agents/pr-preview.md +44 -21
- package/docs/agents/pr-tick.md +72 -0
- package/docs/agents/sandbox.md +30 -6
- package/docs/agents/scripting.md +8 -1
- package/docs/agents/skills-audit.md +2 -1
- 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 +15 -21
- package/docs/workflow/parallel-features.md +24 -3
- package/docs/workflow/skill-map.md +3 -1
- package/docs/workflow/zshrc-aliases.md +1 -1
- package/package.json +1 -1
- package/scripts/core/regen-hero.ts +1 -1
- package/scripts/lib/sandbox-path.sh +6 -162
- package/src/audits/baseline.ts +1 -1
- package/src/audits/run.ts +2 -2
- package/src/canvas/address.ts +12 -3
- package/src/canvas/capture.ts +1 -1
- package/src/canvas/client/inspector/align-grid.tsx +162 -0
- package/src/canvas/client/inspector/color-field.tsx +201 -171
- package/src/canvas/client/inspector/color-inputs.tsx +139 -0
- package/src/canvas/client/inspector/color-picker.tsx +668 -0
- package/src/canvas/client/inspector/color-space.ts +240 -0
- package/src/canvas/client/inspector/field.tsx +66 -3
- package/src/canvas/client/inspector/fields.css +473 -6
- package/src/canvas/client/inspector/size-field.tsx +69 -0
- package/src/canvas/client/inspector/token-select.tsx +109 -0
- package/src/canvas/client/inspector/values.ts +117 -3
- package/src/canvas/client/inspector.tsx +311 -15
- package/src/canvas/client/keys.ts +54 -0
- package/src/canvas/client/selection.tsx +199 -0
- package/src/canvas/client/shell.css +210 -12
- package/src/canvas/client/state.ts +165 -14
- package/src/canvas/client/surface.tsx +341 -39
- package/src/canvas/client/tools.tsx +64 -0
- package/src/canvas/content.ts +51 -2
- package/src/canvas/edit.ts +49 -6
- package/src/canvas/editing.ts +203 -0
- package/src/canvas/global.d.ts +15 -0
- package/src/canvas/server.ts +149 -12
- package/src/canvas/tokens.ts +60 -18
- package/src/census/count.ts +1 -1
- package/src/claude/seeds.ts +3 -3
- package/src/claude/skills-audit.ts +28 -0
- package/src/claude/skills-drift.ts +1 -1
- package/src/claude/skills-families.ts +36 -0
- package/src/claude/skills-list.ts +76 -15
- package/src/claude/skills-reach.ts +1 -1
- package/src/commands/audits.ts +2 -2
- package/src/commands/canvas.ts +180 -3
- package/src/commands/claude.ts +70 -12
- package/src/commands/design.ts +145 -5
- package/src/commands/docs.ts +1 -1
- package/src/commands/feedback.ts +2 -2
- package/src/commands/gate.ts +9 -2
- package/src/commands/gov.ts +3 -3
- package/src/commands/hooks.ts +1 -1
- package/src/commands/indexes.ts +1 -1
- package/src/commands/init.ts +2 -2
- package/src/commands/intake.ts +1 -1
- package/src/commands/migrate.ts +2 -2
- package/src/commands/pr.ts +288 -65
- package/src/commands/records.ts +1 -1
- package/src/commands/repo.ts +1 -1
- package/src/commands/sandbox.ts +64 -15
- package/src/commands/sessions.ts +1 -1
- package/src/commands/slides.ts +1 -1
- package/src/commands/sync.ts +2 -2
- package/src/commands/tasks.ts +21 -4
- package/src/commands/teach.ts +1 -1
- package/src/commands/tooling.ts +1 -1
- package/src/commands/transcripts.ts +2 -2
- package/src/commands/upgrade.ts +1 -1
- package/src/commands/wiki.ts +1 -1
- package/src/commands/worktrees.ts +1 -1
- package/src/comments/scan.ts +1 -1
- package/src/comments/trend.ts +1 -1
- package/src/context/architecture.ts +1 -1
- package/src/context/citations.ts +3 -3
- package/src/context/classify/extract.ts +3 -3
- package/src/context/folders.ts +2 -2
- package/src/context/requirements.ts +1 -1
- package/src/design/project-fonts.ts +392 -0
- package/src/docs/read.ts +1 -1
- package/src/exec.ts +1 -1
- package/src/feedback/github.ts +2 -2
- package/src/gate/measures.ts +48 -10
- package/src/gate/sequencer.ts +56 -4
- package/src/gate/stages.ts +17 -0
- package/src/gate/suite-lock.ts +214 -0
- package/src/{git-files.ts → git/files.ts} +2 -2
- package/src/{git-ignore.ts → git/ignore.ts} +1 -1
- package/src/{worktree.ts → git/worktree.ts} +1 -1
- package/src/gov/citations.ts +1 -1
- package/src/gov/install.ts +1 -1
- package/src/gov/superseded.ts +1 -1
- package/src/gov/test-order.ts +2 -2
- package/src/help.ts +1 -1
- package/src/hooks/post-merge.ts +1 -1
- package/src/indexes/walk.ts +1 -1
- package/src/intake/folder.ts +1 -1
- package/src/labels/audit.ts +1 -1
- package/src/labels/map.ts +1 -1
- package/src/labels/phase.ts +3 -3
- package/src/markdown/files.ts +1 -1
- package/src/migrate/apply.ts +1 -1
- package/src/migrate/record-layout.ts +1 -1
- package/src/migrate/records.ts +11 -11
- package/src/migrate/scratch-evidence.ts +1 -1
- package/src/migrate/surface-roots.ts +6 -6
- package/src/pr/bijection.ts +1 -1
- package/src/pr/evidence.ts +250 -59
- package/src/pr/local.ts +16 -10
- package/src/pr/preview.ts +78 -2
- package/src/pr/tick.ts +56 -0
- package/src/process/harness.ts +1 -1
- package/src/records/backup.ts +2 -2
- 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 +2 -2
- package/src/records/stale.ts +1 -1
- package/src/records/validate.ts +2 -2
- package/src/{project-root.ts → roots/project.ts} +1 -1
- 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/dispatch.ts +264 -0
- package/src/sandbox/headless/run.ts +653 -0
- package/src/sandbox/headless/watch.ts +145 -0
- package/src/sandbox/hooks.ts +1 -1
- package/src/sandbox/provision.ts +90 -29
- package/src/sandbox/scenario.ts +588 -0
- package/src/sandbox/tree.ts +4 -8
- package/src/secrets/scan.ts +1 -1
- package/src/sessions/claim.ts +1 -1
- package/src/sessions/live.ts +4 -1
- package/src/sessions/resolve.ts +1 -1
- package/src/sessions/transfer/bundle.ts +2 -2
- package/src/slides/decks.ts +1 -1
- package/src/slides/package/fonts.ts +6 -2
- package/src/standards/audit.ts +2 -2
- package/src/standards/read.ts +1 -1
- package/src/sync/check.ts +2 -2
- package/src/sync/engine.ts +3 -3
- package/src/sync/git.ts +1 -1
- package/src/sync/history.ts +1 -1
- package/src/sync/layout.ts +2 -2
- package/src/sync/reverse.ts +1 -1
- package/src/sync/stamp.ts +1 -1
- package/src/sync/target.ts +1 -1
- package/src/sync/workflow.ts +1 -1
- package/src/targets/pulls.ts +2 -2
- package/src/targets/sweep.ts +2 -2
- package/src/{target.ts → targets/validate.ts} +1 -1
- 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 +2 -2
- package/src/tasks/trunk.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/subfolder.ts +1 -1
- package/src/version/installed.ts +1 -1
- package/src/version/skew.ts +1 -1
- package/src/web/readme-citations.ts +1 -1
- package/src/worktrees/bare-flag.ts +1 -1
- package/src/worktrees/reclaim.ts +2 -2
- package/src/worktrees/remove.ts +1 -1
- package/standards/board.md +3 -3
- package/standards/canvas.md +2 -1
- package/standards/plan.md +13 -7
- package/standards/skill.md +1 -0
- package/standards/wiki.md +12 -3
- package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
- package/tsconfig.json +1 -1
- package/scripts/lib/sandbox-dispatch.sh +0 -190
- /package/src/{client-commands.ts → gate/client-commands.ts} +0 -0
- /package/src/{git-base.ts → git/base.ts} +0 -0
- /package/src/{git-env.ts → git/env.ts} +0 -0
- /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
- /package/src/{surface-root.ts → roots/surface.ts} +0 -0
- /package/src/{copy.ts → sync/copy.ts} +0 -0
|
@@ -3,9 +3,12 @@ import type { JSX, RefObject } from 'preact'
|
|
|
3
3
|
import { useEffect, 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,
|
|
@@ -16,22 +19,31 @@ import {
|
|
|
16
19
|
moveFrameTo,
|
|
17
20
|
panBy,
|
|
18
21
|
previewMove,
|
|
22
|
+
previewResize,
|
|
19
23
|
registerFrameDocument,
|
|
24
|
+
resizeElement,
|
|
25
|
+
resizeFrameTo,
|
|
20
26
|
selectElement,
|
|
21
27
|
selectFrame,
|
|
22
28
|
selection,
|
|
29
|
+
shownTool,
|
|
30
|
+
spacePan,
|
|
23
31
|
type Theme,
|
|
24
32
|
toggleFrameTheme,
|
|
25
33
|
view,
|
|
26
34
|
zoomAt,
|
|
27
35
|
} from '@/canvas/client/state'
|
|
36
|
+
import {
|
|
37
|
+
DRAG_THRESHOLD,
|
|
38
|
+
HoverOutline,
|
|
39
|
+
type OverlayBox,
|
|
40
|
+
SelectionOverlay,
|
|
41
|
+
} from '@/canvas/client/selection'
|
|
42
|
+
import { ToolStrip } from '@/canvas/client/tools'
|
|
28
43
|
|
|
29
44
|
/** One wheel notch or one button press. */
|
|
30
45
|
const ZOOM_STEP = 1.2
|
|
31
46
|
|
|
32
|
-
/** Screen pixels a press travels before it reads as a drag and not a click. */
|
|
33
|
-
const DRAG_THRESHOLD = 3
|
|
34
|
-
|
|
35
47
|
/** Surface units one arrow press moves a frame, and one with Shift held. */
|
|
36
48
|
const NUDGE = 10
|
|
37
49
|
const NUDGE_LARGE = 50
|
|
@@ -76,25 +88,142 @@ function asElement(target: EventTarget | null): Element | undefined {
|
|
|
76
88
|
return node?.nodeType === 1 ? (node as Element) : undefined
|
|
77
89
|
}
|
|
78
90
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Whether a key belongs to the element it was typed in rather than to the
|
|
93
|
+
* canvas: any key in a field, and Space on a control it activates. A frame's
|
|
94
|
+
* draft can hold its own fields, and their keys arrive through the forwarding.
|
|
95
|
+
*/
|
|
96
|
+
function isOwnedByTarget(event: KeyboardEvent, action: SurfaceAction): boolean {
|
|
97
|
+
const target = asElement(event.target)
|
|
98
|
+
if (!target) return false
|
|
99
|
+
if ((target as HTMLElement).isContentEditable) return true
|
|
100
|
+
if (target.closest('input, textarea, select')) return true
|
|
101
|
+
return (
|
|
102
|
+
action === 'pan-hold' && target.closest('button, a[href], summary') !== null
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A press read as a second click selects a word or a whole frame even while
|
|
108
|
+
* panning, so no selection may start in the shell or a frame then.
|
|
109
|
+
*/
|
|
110
|
+
function blockSelectionWhilePanning(event: Event): void {
|
|
111
|
+
if (shownTool.value === 'pan') event.preventDefault()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Drops any selection in the shell and in every loaded frame document. */
|
|
115
|
+
function clearSelections(): void {
|
|
116
|
+
document.getSelection()?.removeAllRanges()
|
|
117
|
+
for (const doc of frameDocuments.value.values()) {
|
|
118
|
+
doc.getSelection()?.removeAllRanges()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A computed length in pixels, or the fallback for one that is not. */
|
|
123
|
+
function pixels(value: string, fallback: number): number {
|
|
124
|
+
const parsed = Number.parseFloat(value)
|
|
125
|
+
return value.endsWith('px') && Number.isFinite(parsed) ? parsed : fallback
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The selected element's overlay. A handle drag previews the size as inline
|
|
130
|
+
* style on the element and writes it once on release, the way the inspector's
|
|
131
|
+
* scrub does. The drag measures the border box while `width` and `height` set
|
|
132
|
+
* whatever box the element sizes by, so the delta is added to the computed
|
|
133
|
+
* value rather than written as the box itself.
|
|
134
|
+
*/
|
|
135
|
+
function ElementSelection({
|
|
136
|
+
node,
|
|
137
|
+
doc,
|
|
138
|
+
frameRef,
|
|
139
|
+
docKey,
|
|
82
140
|
}: {
|
|
83
|
-
readonly
|
|
84
|
-
readonly
|
|
141
|
+
readonly node: Element
|
|
142
|
+
readonly doc: Document
|
|
143
|
+
readonly frameRef: FrameRef
|
|
144
|
+
readonly docKey: string
|
|
85
145
|
}): JSX.Element {
|
|
86
|
-
const
|
|
146
|
+
const start = useRef<
|
|
147
|
+
| {
|
|
148
|
+
readonly width: number
|
|
149
|
+
readonly height: number
|
|
150
|
+
readonly inlineWidth: string
|
|
151
|
+
readonly inlineHeight: string
|
|
152
|
+
}
|
|
153
|
+
| undefined
|
|
154
|
+
>(undefined)
|
|
155
|
+
const style = (node as HTMLElement).style
|
|
156
|
+
|
|
157
|
+
const measure = () => {
|
|
158
|
+
const rect = node.getBoundingClientRect()
|
|
159
|
+
return { x: rect.left, y: rect.top, width: rect.width, height: rect.height }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const cssSize = (box: OverlayBox, origin: OverlayBox) => {
|
|
163
|
+
const from = start.current
|
|
164
|
+
return {
|
|
165
|
+
width: Math.round(
|
|
166
|
+
(from?.width ?? origin.width) + box.width - origin.width,
|
|
167
|
+
),
|
|
168
|
+
height: Math.round(
|
|
169
|
+
(from?.height ?? origin.height) + box.height - origin.height,
|
|
170
|
+
),
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const handleStart = () => {
|
|
175
|
+
const computed = doc.defaultView?.getComputedStyle(node)
|
|
176
|
+
const rect = node.getBoundingClientRect()
|
|
177
|
+
start.current = {
|
|
178
|
+
width: pixels(computed?.width ?? '', rect.width),
|
|
179
|
+
height: pixels(computed?.height ?? '', rect.height),
|
|
180
|
+
inlineWidth: style.getPropertyValue('width'),
|
|
181
|
+
inlineHeight: style.getPropertyValue('height'),
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const handlePreview = (box: OverlayBox, from: OverlayBox) => {
|
|
186
|
+
const size = cssSize(box, from)
|
|
187
|
+
style.setProperty('width', `${size.width}px`)
|
|
188
|
+
style.setProperty('height', `${size.height}px`)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const restore = () => {
|
|
192
|
+
const from = start.current
|
|
193
|
+
if (!from) return
|
|
194
|
+
style.setProperty('width', from.inlineWidth)
|
|
195
|
+
style.setProperty('height', from.inlineHeight)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const handleCommit = (box: OverlayBox, from: OverlayBox) => {
|
|
199
|
+
const size = cssSize(box, from)
|
|
200
|
+
const address = addressOf(doc, node)
|
|
201
|
+
const isUnchanged = box.width === from.width && box.height === from.height
|
|
202
|
+
if (!address || isUnchanged) {
|
|
203
|
+
restore()
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
void resizeElement(
|
|
207
|
+
frameRef,
|
|
208
|
+
docKey,
|
|
209
|
+
address,
|
|
210
|
+
box.width === from.width ? undefined : size.width,
|
|
211
|
+
box.height === from.height ? undefined : size.height,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const handleCancel = restore
|
|
216
|
+
|
|
87
217
|
return (
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}}
|
|
218
|
+
<SelectionOverlay
|
|
219
|
+
kind="element"
|
|
220
|
+
measure={measure}
|
|
221
|
+
hasChip
|
|
222
|
+
isMeasuredWhileDragging
|
|
223
|
+
onStart={handleStart}
|
|
224
|
+
onPreview={handlePreview}
|
|
225
|
+
onCommit={handleCommit}
|
|
226
|
+
onCancel={handleCancel}
|
|
98
227
|
/>
|
|
99
228
|
)
|
|
100
229
|
}
|
|
@@ -119,10 +248,12 @@ function FrameView({
|
|
|
119
248
|
page,
|
|
120
249
|
frame,
|
|
121
250
|
onWheelTurn,
|
|
251
|
+
onKey,
|
|
122
252
|
}: {
|
|
123
253
|
readonly page: string
|
|
124
254
|
readonly frame: Frame
|
|
125
255
|
readonly onWheelTurn: (turn: WheelTurn) => void
|
|
256
|
+
readonly onKey: (event: KeyboardEvent) => void
|
|
126
257
|
}): JSX.Element {
|
|
127
258
|
const key = frameKey(page, frame)
|
|
128
259
|
const version = frameVersions.value.get(key) ?? 0
|
|
@@ -135,6 +266,7 @@ function FrameView({
|
|
|
135
266
|
/* Bumped when the frame document scrolls, so the outlines follow it. */
|
|
136
267
|
const [, setScrolled] = useState(0)
|
|
137
268
|
const doc = frameDocuments.value.get(key)
|
|
269
|
+
const editing = editingFrames.value.get(key)
|
|
138
270
|
|
|
139
271
|
useEffect(() => {
|
|
140
272
|
applyFrameTheme(iframe.current, value)
|
|
@@ -185,6 +317,16 @@ function FrameView({
|
|
|
185
317
|
},
|
|
186
318
|
{ passive: false },
|
|
187
319
|
)
|
|
320
|
+
/*
|
|
321
|
+
* A click inside the frame moves focus into its window, so the surface
|
|
322
|
+
* hears no key after the first pick unless the frame passes it on.
|
|
323
|
+
*/
|
|
324
|
+
loaded.addEventListener('keydown', onKey)
|
|
325
|
+
loaded.addEventListener('keyup', onKey)
|
|
326
|
+
loaded.addEventListener('selectstart', blockSelectionWhilePanning)
|
|
327
|
+
loaded.defaultView?.addEventListener('blur', () => {
|
|
328
|
+
spacePan.value = undefined
|
|
329
|
+
})
|
|
188
330
|
}
|
|
189
331
|
|
|
190
332
|
const picked =
|
|
@@ -206,6 +348,8 @@ function FrameView({
|
|
|
206
348
|
|
|
207
349
|
const handlePointerDown = (event: PointerEvent) => {
|
|
208
350
|
if (event.button !== 0) return
|
|
351
|
+
/* A pan drag starting on the label belongs to the surface behind it. */
|
|
352
|
+
if (shownTool.value === 'pan') return
|
|
209
353
|
if ((event.target as HTMLElement | null)?.closest('.frame-theme')) return
|
|
210
354
|
gesture.current = {
|
|
211
355
|
startX: event.clientX,
|
|
@@ -254,7 +398,7 @@ function FrameView({
|
|
|
254
398
|
*/
|
|
255
399
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
256
400
|
if (event.target !== event.currentTarget) return
|
|
257
|
-
if (event.key === 'Enter'
|
|
401
|
+
if (event.key === 'Enter') {
|
|
258
402
|
event.preventDefault()
|
|
259
403
|
select()
|
|
260
404
|
return
|
|
@@ -263,20 +407,50 @@ function FrameView({
|
|
|
263
407
|
const delta = ARROWS[event.key]
|
|
264
408
|
if (!delta || !isSelected) return
|
|
265
409
|
event.preventDefault()
|
|
410
|
+
/* The keyboard path for a handle drag: right and down grow the frame. */
|
|
411
|
+
if (event.ctrlKey || event.metaKey) {
|
|
412
|
+
void resizeFrameTo(ref, {
|
|
413
|
+
x: frame.x,
|
|
414
|
+
y: frame.y,
|
|
415
|
+
width: Math.max(1, frame.width + delta.x * step),
|
|
416
|
+
height: Math.max(1, frame.height + delta.y * step),
|
|
417
|
+
})
|
|
418
|
+
return
|
|
419
|
+
}
|
|
266
420
|
void moveFrameTo(ref, frame.x + delta.x * step, frame.y + delta.y * step)
|
|
267
421
|
}
|
|
268
422
|
|
|
423
|
+
/*
|
|
424
|
+
* Space held pans, so it selects on release instead, and only when no drag
|
|
425
|
+
* moved the view while it was down. This runs before the surface's own
|
|
426
|
+
* release clears the pan.
|
|
427
|
+
*/
|
|
428
|
+
const handleKeyUp = (event: KeyboardEvent) => {
|
|
429
|
+
if (event.target !== event.currentTarget || event.key !== ' ') return
|
|
430
|
+
if (spacePan.value !== 'dragged') select()
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const frameBox = (): OverlayBox => ({
|
|
434
|
+
x: frame.x,
|
|
435
|
+
y: frame.y,
|
|
436
|
+
width: frame.width,
|
|
437
|
+
height: frame.height,
|
|
438
|
+
})
|
|
439
|
+
|
|
269
440
|
const next = value === 'dark' ? 'light' : 'dark'
|
|
270
441
|
return (
|
|
271
442
|
<figure
|
|
272
443
|
class="frame"
|
|
273
444
|
data-frame={frame.name}
|
|
274
445
|
data-selected={isSelected ? 'true' : undefined}
|
|
446
|
+
data-picked={picked ? 'true' : undefined}
|
|
275
447
|
data-dragging={draggingFrame.value === frame.name ? 'true' : undefined}
|
|
448
|
+
data-editing={editing ? 'true' : undefined}
|
|
276
449
|
tabIndex={0}
|
|
277
|
-
aria-label={`${frame.name}, ${frame.width} by ${frame.height}`}
|
|
450
|
+
aria-label={`${frame.name}, ${frame.width} by ${frame.height}${editing ? `, ${editing.by} editing` : ''}`}
|
|
278
451
|
aria-current={isSelected ? 'true' : undefined}
|
|
279
452
|
onKeyDown={handleKeyDown}
|
|
453
|
+
onKeyUp={handleKeyUp}
|
|
280
454
|
style={{
|
|
281
455
|
left: `${frame.x}px`,
|
|
282
456
|
top: `${frame.y}px`,
|
|
@@ -297,6 +471,10 @@ function FrameView({
|
|
|
297
471
|
<span class="frame-size">
|
|
298
472
|
{frame.width} × {frame.height}
|
|
299
473
|
</span>
|
|
474
|
+
{/* Rendered empty while unmarked, so a mark landing is announced. */}
|
|
475
|
+
<span class="frame-editing" aria-live="polite">
|
|
476
|
+
{editing ? `${editing.by} editing` : ''}
|
|
477
|
+
</span>
|
|
300
478
|
<button
|
|
301
479
|
type="button"
|
|
302
480
|
class="frame-theme"
|
|
@@ -318,10 +496,25 @@ function FrameView({
|
|
|
318
496
|
onLoad={handleLoad}
|
|
319
497
|
/>
|
|
320
498
|
{hoveredNode && hoveredNode !== selectedNode ? (
|
|
321
|
-
<
|
|
499
|
+
<HoverOutline element={hoveredNode} />
|
|
500
|
+
) : null}
|
|
501
|
+
{isSelected && !picked ? (
|
|
502
|
+
<SelectionOverlay
|
|
503
|
+
kind="frame"
|
|
504
|
+
measure={frameBox}
|
|
505
|
+
offset={{ x: frame.x, y: frame.y }}
|
|
506
|
+
onPreview={(box) => previewResize(ref, box)}
|
|
507
|
+
onCommit={(box) => void resizeFrameTo(ref, box)}
|
|
508
|
+
onCancel={(origin) => previewResize(ref, origin)}
|
|
509
|
+
/>
|
|
322
510
|
) : null}
|
|
323
|
-
{selectedNode ? (
|
|
324
|
-
<
|
|
511
|
+
{doc && selectedNode ? (
|
|
512
|
+
<ElementSelection
|
|
513
|
+
node={selectedNode}
|
|
514
|
+
doc={doc}
|
|
515
|
+
frameRef={ref}
|
|
516
|
+
docKey={key}
|
|
517
|
+
/>
|
|
325
518
|
) : null}
|
|
326
519
|
</div>
|
|
327
520
|
</figure>
|
|
@@ -336,18 +529,35 @@ function viewportSize(viewport: RefObject<HTMLDivElement | null>): {
|
|
|
336
529
|
return { width: rect?.width ?? 0, height: rect?.height ?? 0 }
|
|
337
530
|
}
|
|
338
531
|
|
|
532
|
+
/** The zoom toolbar's actions, which its keys call as well. */
|
|
533
|
+
function zoomCentered(
|
|
534
|
+
viewport: RefObject<HTMLDivElement | null>,
|
|
535
|
+
factor: number,
|
|
536
|
+
): void {
|
|
537
|
+
const { width, height } = viewportSize(viewport)
|
|
538
|
+
zoomAt(factor, width / 2, height / 2)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Fits the page clear of the tool strip floating over the viewport's left. */
|
|
542
|
+
function fitViewport(viewport: RefObject<HTMLDivElement | null>): void {
|
|
543
|
+
const rect = viewport.current?.getBoundingClientRect()
|
|
544
|
+
const strip = viewport.current?.parentElement
|
|
545
|
+
?.querySelector('.tools')
|
|
546
|
+
?.getBoundingClientRect()
|
|
547
|
+
const inset = rect && strip ? Math.max(0, strip.right - rect.left) : 0
|
|
548
|
+
fitView(rect?.width ?? 0, rect?.height ?? 0, undefined, inset)
|
|
549
|
+
}
|
|
550
|
+
|
|
339
551
|
function Toolbar({ viewportRef }: SurfaceProps): JSX.Element {
|
|
340
|
-
const zoomCentered = (factor: number) => {
|
|
341
|
-
const { width, height } = viewportSize(viewportRef)
|
|
342
|
-
zoomAt(factor, width / 2, height / 2)
|
|
343
|
-
}
|
|
344
552
|
return (
|
|
345
553
|
<div class="toolbar" role="toolbar" aria-label="Zoom">
|
|
346
554
|
<button
|
|
347
555
|
type="button"
|
|
348
556
|
class="icon-button"
|
|
349
557
|
aria-label="Zoom out"
|
|
350
|
-
|
|
558
|
+
aria-keyshortcuts="-"
|
|
559
|
+
title="Zoom out (-)"
|
|
560
|
+
onClick={() => zoomCentered(viewportRef, 1 / ZOOM_STEP)}
|
|
351
561
|
>
|
|
352
562
|
−
|
|
353
563
|
</button>
|
|
@@ -358,17 +568,18 @@ function Toolbar({ viewportRef }: SurfaceProps): JSX.Element {
|
|
|
358
568
|
type="button"
|
|
359
569
|
class="icon-button"
|
|
360
570
|
aria-label="Zoom in"
|
|
361
|
-
|
|
571
|
+
aria-keyshortcuts="+"
|
|
572
|
+
title="Zoom in (+)"
|
|
573
|
+
onClick={() => zoomCentered(viewportRef, ZOOM_STEP)}
|
|
362
574
|
>
|
|
363
575
|
+
|
|
364
576
|
</button>
|
|
365
577
|
<button
|
|
366
578
|
type="button"
|
|
367
579
|
class="text-button"
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}}
|
|
580
|
+
aria-keyshortcuts="Shift+1"
|
|
581
|
+
title="Fit (Shift+1)"
|
|
582
|
+
onClick={() => fitViewport(viewportRef)}
|
|
372
583
|
>
|
|
373
584
|
Fit
|
|
374
585
|
</button>
|
|
@@ -382,8 +593,7 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
|
|
|
382
593
|
|
|
383
594
|
/* Fits a page once when it opens, never on a reload of the same page. */
|
|
384
595
|
useEffect(() => {
|
|
385
|
-
|
|
386
|
-
fitView(width, height)
|
|
596
|
+
fitViewport(viewportRef)
|
|
387
597
|
}, [page?.name])
|
|
388
598
|
|
|
389
599
|
const handleWheelTurn = (turn: WheelTurn) => {
|
|
@@ -411,9 +621,68 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
|
|
|
411
621
|
})
|
|
412
622
|
}
|
|
413
623
|
|
|
624
|
+
/* A pan held when the window loses focus never hears its Space come up. */
|
|
625
|
+
useEffect(() => {
|
|
626
|
+
const endPan = () => {
|
|
627
|
+
spacePan.value = undefined
|
|
628
|
+
}
|
|
629
|
+
window.addEventListener('blur', endPan)
|
|
630
|
+
document.addEventListener('selectstart', blockSelectionWhilePanning)
|
|
631
|
+
return () => {
|
|
632
|
+
window.removeEventListener('blur', endPan)
|
|
633
|
+
document.removeEventListener('selectstart', blockSelectionWhilePanning)
|
|
634
|
+
}
|
|
635
|
+
}, [])
|
|
636
|
+
|
|
637
|
+
const runAction = (action: SurfaceAction) => {
|
|
638
|
+
switch (action) {
|
|
639
|
+
case 'tool-move':
|
|
640
|
+
activeTool.value = 'move'
|
|
641
|
+
return
|
|
642
|
+
case 'tool-pan':
|
|
643
|
+
activeTool.value = 'pan'
|
|
644
|
+
return
|
|
645
|
+
case 'zoom-in':
|
|
646
|
+
zoomCentered(viewportRef, ZOOM_STEP)
|
|
647
|
+
return
|
|
648
|
+
case 'zoom-out':
|
|
649
|
+
zoomCentered(viewportRef, 1 / ZOOM_STEP)
|
|
650
|
+
return
|
|
651
|
+
case 'zoom-fit':
|
|
652
|
+
fitViewport(viewportRef)
|
|
653
|
+
return
|
|
654
|
+
case 'pan-hold':
|
|
655
|
+
spacePan.value ??= 'held'
|
|
656
|
+
return
|
|
657
|
+
case 'pan-release':
|
|
658
|
+
spacePan.value = undefined
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/*
|
|
663
|
+
* The surface's one key handler, which every frame document feeds too. The
|
|
664
|
+
* default is prevented only for a key press it takes, so Space stops
|
|
665
|
+
* scrolling a frame it pans and every other key keeps its own meaning. A
|
|
666
|
+
* release always ends the pan and never claims the keyup, since a button
|
|
667
|
+
* fires its Space activation there.
|
|
668
|
+
*/
|
|
669
|
+
const handleKey = (event: KeyboardEvent) => {
|
|
670
|
+
const action = surfaceAction(event)
|
|
671
|
+
if (!action) return
|
|
672
|
+
if (action === 'pan-release') {
|
|
673
|
+
runAction(action)
|
|
674
|
+
return
|
|
675
|
+
}
|
|
676
|
+
if (isOwnedByTarget(event, action)) return
|
|
677
|
+
event.preventDefault()
|
|
678
|
+
runAction(action)
|
|
679
|
+
}
|
|
680
|
+
|
|
414
681
|
const handlePointerDown = (event: PointerEvent) => {
|
|
415
682
|
const target = event.target as HTMLElement | null
|
|
416
|
-
if (target?.closest('.frame
|
|
683
|
+
if (shownTool.value !== 'pan' && target?.closest('.frame')) return
|
|
684
|
+
/* A selection left over would paint over every frame the pan crosses. */
|
|
685
|
+
if (shownTool.value === 'pan') clearSelections()
|
|
417
686
|
drag.current = { x: event.clientX, y: event.clientY }
|
|
418
687
|
viewportRef.current?.setPointerCapture?.(event.pointerId)
|
|
419
688
|
}
|
|
@@ -421,22 +690,53 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
|
|
|
421
690
|
const handlePointerMove = (event: PointerEvent) => {
|
|
422
691
|
const start = drag.current
|
|
423
692
|
if (!start) return
|
|
424
|
-
|
|
693
|
+
const dx = event.clientX - start.x
|
|
694
|
+
const dy = event.clientY - start.y
|
|
695
|
+
panBy(dx, dy)
|
|
425
696
|
drag.current = { x: event.clientX, y: event.clientY }
|
|
697
|
+
if (spacePan.value === 'held' && (dx !== 0 || dy !== 0)) {
|
|
698
|
+
spacePan.value = 'dragged'
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/*
|
|
703
|
+
* A press selects text by default, a word or a whole frame when it reads as
|
|
704
|
+
* a second click, and neither `selectstart` nor a clear at pointer down
|
|
705
|
+
* reaches that. Under a held Space, focus stays where it was so the frame
|
|
706
|
+
* still hears the release. Under the Pan tool the press takes focus itself,
|
|
707
|
+
* as the default would have, so a field it leaves commits and the next key
|
|
708
|
+
* reaches the layer.
|
|
709
|
+
*/
|
|
710
|
+
const handleMouseDown = (event: MouseEvent) => {
|
|
711
|
+
if (shownTool.value !== 'pan') return
|
|
712
|
+
event.preventDefault()
|
|
713
|
+
if (!spacePan.value) {
|
|
714
|
+
const surface = (event.currentTarget as HTMLElement).closest('main')
|
|
715
|
+
surface?.focus({ preventScroll: true })
|
|
716
|
+
}
|
|
426
717
|
}
|
|
427
718
|
|
|
428
719
|
const handlePointerUp = () => {
|
|
720
|
+
if (drag.current && shownTool.value === 'pan') clearSelections()
|
|
429
721
|
drag.current = undefined
|
|
430
722
|
}
|
|
431
723
|
|
|
432
724
|
const { x, y, zoom } = view.value
|
|
433
725
|
return (
|
|
434
|
-
<main
|
|
726
|
+
<main
|
|
727
|
+
class="surface"
|
|
728
|
+
aria-label="Canvas"
|
|
729
|
+
tabIndex={0}
|
|
730
|
+
data-tool={shownTool.value}
|
|
731
|
+
onKeyDown={handleKey}
|
|
732
|
+
onKeyUp={handleKey}
|
|
733
|
+
>
|
|
435
734
|
<div
|
|
436
735
|
ref={viewportRef}
|
|
437
736
|
class="viewport"
|
|
438
737
|
onWheel={handleWheel}
|
|
439
738
|
onPointerDown={handlePointerDown}
|
|
739
|
+
onMouseDown={handleMouseDown}
|
|
440
740
|
onPointerMove={handlePointerMove}
|
|
441
741
|
onPointerUp={handlePointerUp}
|
|
442
742
|
onPointerCancel={handlePointerUp}
|
|
@@ -455,10 +755,12 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
|
|
|
455
755
|
page={page.name}
|
|
456
756
|
frame={frame}
|
|
457
757
|
onWheelTurn={handleWheelTurn}
|
|
758
|
+
onKey={handleKey}
|
|
458
759
|
/>
|
|
459
760
|
))}
|
|
460
761
|
</div>
|
|
461
762
|
</div>
|
|
763
|
+
<ToolStrip />
|
|
462
764
|
<Toolbar viewportRef={viewportRef} />
|
|
463
765
|
</main>
|
|
464
766
|
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
import type { JSX } from 'preact'
|
|
3
|
+
import { activeTool, shownTool, type Tool } from '@/canvas/client/state'
|
|
4
|
+
|
|
5
|
+
interface ToolButton {
|
|
6
|
+
readonly tool: Tool
|
|
7
|
+
readonly name: string
|
|
8
|
+
readonly key: string
|
|
9
|
+
readonly icon: JSX.Element
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const TOOLS: readonly ToolButton[] = [
|
|
13
|
+
{
|
|
14
|
+
tool: 'move',
|
|
15
|
+
name: 'Move',
|
|
16
|
+
key: 'V',
|
|
17
|
+
icon: (
|
|
18
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
19
|
+
<path d="M3.5 2.5l9 4.2-3.9 1.2-1.4 3.8z" />
|
|
20
|
+
</svg>
|
|
21
|
+
),
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
tool: 'pan',
|
|
25
|
+
name: 'Pan',
|
|
26
|
+
key: 'H',
|
|
27
|
+
icon: (
|
|
28
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
29
|
+
<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" />
|
|
30
|
+
</svg>
|
|
31
|
+
),
|
|
32
|
+
},
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
/** The tool strip floating at the surface's left edge, one button a tool. */
|
|
36
|
+
export function ToolStrip(): JSX.Element {
|
|
37
|
+
const shown = shownTool.value
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
class="tools"
|
|
41
|
+
role="toolbar"
|
|
42
|
+
aria-label="Tools"
|
|
43
|
+
aria-orientation="vertical"
|
|
44
|
+
>
|
|
45
|
+
{TOOLS.map(({ tool, name, key, icon }) => (
|
|
46
|
+
<button
|
|
47
|
+
key={tool}
|
|
48
|
+
type="button"
|
|
49
|
+
class="tool-button"
|
|
50
|
+
aria-label={`${name} (${key})`}
|
|
51
|
+
aria-keyshortcuts={key}
|
|
52
|
+
aria-pressed={shown === tool ? 'true' : 'false'}
|
|
53
|
+
title={`${name} (${key})`}
|
|
54
|
+
data-tool={tool}
|
|
55
|
+
onClick={() => {
|
|
56
|
+
activeTool.value = tool
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
{icon}
|
|
60
|
+
</button>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
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
|
|
@@ -129,7 +129,7 @@ function refuse(reason: ContentRefusal, detail: string): ContentRefused {
|
|
|
129
129
|
return { ok: false, reason, detail }
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
function isValidName(name: string): boolean {
|
|
132
|
+
export function isValidName(name: string): boolean {
|
|
133
133
|
return NAME_PATTERN.test(name) && !name.includes('..')
|
|
134
134
|
}
|
|
135
135
|
|
|
@@ -464,6 +464,55 @@ export function moveFrame(
|
|
|
464
464
|
})
|
|
465
465
|
}
|
|
466
466
|
|
|
467
|
+
/**
|
|
468
|
+
* Writes one frame's whole box. A handle on the top or left edge moves the
|
|
469
|
+
* frame as it resizes it, so position and size land in one locked write rather
|
|
470
|
+
* than a move and a resize another writer could land between.
|
|
471
|
+
*/
|
|
472
|
+
export function resizeFrame(
|
|
473
|
+
root: string,
|
|
474
|
+
page: string,
|
|
475
|
+
frame: string,
|
|
476
|
+
box: Box,
|
|
477
|
+
): FrameOutcome {
|
|
478
|
+
if (!isValidName(page)) {
|
|
479
|
+
return refuse('invalid-name', `${page} is not a valid page name`)
|
|
480
|
+
}
|
|
481
|
+
if (!isValidName(frame)) {
|
|
482
|
+
return refuse('invalid-name', `${frame} is not a valid frame name`)
|
|
483
|
+
}
|
|
484
|
+
if (!Number.isFinite(box.x) || !Number.isFinite(box.y)) {
|
|
485
|
+
return refuse('invalid-position', 'x and y must be numbers')
|
|
486
|
+
}
|
|
487
|
+
const isPositive = (value: number) => Number.isFinite(value) && value > 0
|
|
488
|
+
if (!isPositive(box.width) || !isPositive(box.height)) {
|
|
489
|
+
return refuse('invalid-size', 'width and height must be positive numbers')
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const dir = pagePath(root, page)
|
|
493
|
+
if (!isDirectory(dir)) return refuse('no-page', `page ${page} does not exist`)
|
|
494
|
+
const file = `${frame}${FRAME_EXTENSION}`
|
|
495
|
+
if (!existsSync(join(dir, file))) {
|
|
496
|
+
return refuse('no-frame', `frame ${frame} does not exist on ${page}`)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return withLayoutLock(dir, () => {
|
|
500
|
+
const { boxes, malformed } = readLayout(dir)
|
|
501
|
+
if (malformed) {
|
|
502
|
+
return refuse('malformed-layout', `${page}/${LAYOUT_FILE} does not parse`)
|
|
503
|
+
}
|
|
504
|
+
const written: Box = {
|
|
505
|
+
x: box.x,
|
|
506
|
+
y: box.y,
|
|
507
|
+
width: box.width,
|
|
508
|
+
height: box.height,
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
writeLayout(dir, new Map([...boxes, [frame, written]]))
|
|
512
|
+
return { ok: true, page, frame, file, box: written }
|
|
513
|
+
})
|
|
514
|
+
}
|
|
515
|
+
|
|
467
516
|
export interface Selection {
|
|
468
517
|
readonly page: string
|
|
469
518
|
readonly frame: string
|