@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
|
@@ -4,10 +4,33 @@
|
|
|
4
4
|
* back through `toCssValue`, so rounding never writes a value nobody typed.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
/** A length that runs below zero, so a bare number is pixels and unclamped. */
|
|
8
|
+
const SIGNED_LENGTHS = new Set(['letter-spacing'])
|
|
9
|
+
|
|
7
10
|
/** Properties whose bare numbers are pixel lengths. */
|
|
8
|
-
const LENGTHS = new Set([
|
|
11
|
+
const LENGTHS = new Set([
|
|
12
|
+
'width',
|
|
13
|
+
'height',
|
|
14
|
+
'padding',
|
|
15
|
+
'gap',
|
|
16
|
+
'font-size',
|
|
17
|
+
'border-radius',
|
|
18
|
+
'border-top-left-radius',
|
|
19
|
+
'border-top-right-radius',
|
|
20
|
+
'border-bottom-right-radius',
|
|
21
|
+
'border-bottom-left-radius',
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
/** Properties a size mode writes, each mode by the name the field shows. */
|
|
25
|
+
const SIZES = new Set(['width', 'height'])
|
|
26
|
+
const SIZE_MODES: Readonly<Record<string, string>> = {
|
|
27
|
+
Fill: '100%',
|
|
28
|
+
Fit: 'fit-content',
|
|
29
|
+
}
|
|
9
30
|
|
|
10
31
|
const PX = /^(-?\d*\.?\d+)px$/
|
|
32
|
+
const EM = /^(-?\d*\.?\d+)em$/
|
|
33
|
+
const NUMBER = /^-?\d*\.?\d+$/
|
|
11
34
|
|
|
12
35
|
function formatPx(token: string): string {
|
|
13
36
|
const match = token.match(PX)
|
|
@@ -27,7 +50,20 @@ export function isNoFill(value: string): boolean {
|
|
|
27
50
|
export function displayValue(property: string, raw: string): string {
|
|
28
51
|
const value = raw.trim()
|
|
29
52
|
if (property === 'gap' && (value === '' || value === 'normal')) return ''
|
|
53
|
+
if (property === 'letter-spacing') {
|
|
54
|
+
if (value === 'normal') return ''
|
|
55
|
+
const em = value.match(EM)
|
|
56
|
+
if (em) return `${Math.round(Number(em[1]) * 1000) / 1000}em`
|
|
57
|
+
}
|
|
30
58
|
if (property === 'background-color' && isNoFill(value)) return ''
|
|
59
|
+
if (property === 'opacity' && value !== '')
|
|
60
|
+
return `${Math.round(Number(value) * 100)}%`
|
|
61
|
+
if (SIZES.has(property)) {
|
|
62
|
+
const mode = Object.keys(SIZE_MODES).find(
|
|
63
|
+
(name) => SIZE_MODES[name] === value,
|
|
64
|
+
)
|
|
65
|
+
if (mode) return mode
|
|
66
|
+
}
|
|
31
67
|
if (value === 'auto') return 'Auto'
|
|
32
68
|
if (value === 'normal') return 'Normal'
|
|
33
69
|
return value.split(/\s+/).map(formatPx).join(' ')
|
|
@@ -35,14 +71,34 @@ export function displayValue(property: string, raw: string): string {
|
|
|
35
71
|
|
|
36
72
|
export function toCssValue(property: string, typed: string): string {
|
|
37
73
|
const value = typed.trim()
|
|
74
|
+
/* A line height has no auto keyword, so the field's Auto is its normal. */
|
|
75
|
+
if (property === 'line-height' && value.toLowerCase() === 'auto') {
|
|
76
|
+
return 'normal'
|
|
77
|
+
}
|
|
38
78
|
if (value === 'Auto' || value === 'Normal') return value.toLowerCase()
|
|
39
|
-
if (
|
|
79
|
+
if (SIZES.has(property) && SIZE_MODES[value]) return SIZE_MODES[value]
|
|
80
|
+
if (!LENGTHS.has(property) && !SIGNED_LENGTHS.has(property)) return value
|
|
40
81
|
return value
|
|
41
82
|
.split(/\s+/)
|
|
42
83
|
.map((token) => (/^-?\d*\.?\d+$/.test(token) ? `${token}px` : token))
|
|
43
84
|
.join(' ')
|
|
44
85
|
}
|
|
45
86
|
|
|
87
|
+
/** A typed opacity percent as the fraction CSS takes, refused outside 0 to 100. */
|
|
88
|
+
export function opacityToCss(typed: string): string | undefined {
|
|
89
|
+
const percent = readOpacity(typed)
|
|
90
|
+
return percent === undefined ? undefined : String(percent / 100)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The radius all four corners share, read in the order the longhands run,
|
|
95
|
+
* or undefined where any corner differs and the panel shows each one.
|
|
96
|
+
*/
|
|
97
|
+
export function sharedRadius(corners: readonly string[]): string | undefined {
|
|
98
|
+
const [first] = corners
|
|
99
|
+
return corners.every((corner) => corner === first) ? first : undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
46
102
|
/**
|
|
47
103
|
* Holds a scrubbed number inside what the property accepts. The writer takes
|
|
48
104
|
* any declaration that does not break the rule, so a negative length or a
|
|
@@ -50,10 +106,68 @@ export function toCssValue(property: string, typed: string): string {
|
|
|
50
106
|
*/
|
|
51
107
|
export function clampScrub(property: string, value: number): number {
|
|
52
108
|
if (property === 'font-weight') return Math.min(1000, Math.max(1, value))
|
|
53
|
-
if (LENGTHS.has(property)
|
|
109
|
+
if (LENGTHS.has(property) || property === 'line-height') {
|
|
110
|
+
return Math.max(0, value)
|
|
111
|
+
}
|
|
112
|
+
return value
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A line height as the panel shows it: a ratio of the font size, the way a
|
|
117
|
+
* unitless value writes it. A browser computes a unitless value to pixels, so
|
|
118
|
+
* a pixel value reads back as its ratio when the font size is in pixels too.
|
|
119
|
+
*/
|
|
120
|
+
export function lineHeightValue(raw: string, fontSize: string): string {
|
|
121
|
+
const value = raw.trim()
|
|
122
|
+
if (value === 'normal') return 'Auto'
|
|
123
|
+
const ratio = (number: number): string =>
|
|
124
|
+
String(Math.round(number * 100) / 100)
|
|
125
|
+
if (NUMBER.test(value)) return ratio(Number(value))
|
|
126
|
+
const height = value.match(PX)
|
|
127
|
+
const size = fontSize.trim().match(PX)
|
|
128
|
+
if (height && size && Number(size[1]) > 0) {
|
|
129
|
+
return ratio(Number(height[1]) / Number(size[1]))
|
|
130
|
+
}
|
|
54
131
|
return value
|
|
55
132
|
}
|
|
56
133
|
|
|
134
|
+
/** The first family of a stack, unquoted, which is the one the field names. */
|
|
135
|
+
export function firstFamily(stack: string): string {
|
|
136
|
+
const value = stack.trim()
|
|
137
|
+
if (value.startsWith('var(')) return value
|
|
138
|
+
const [first = ''] = value.split(',')
|
|
139
|
+
return first.trim().replace(/^(["'])(.*)\1$/, '$2')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Text alignment by the side it lands on, so the group presses the side the
|
|
144
|
+
* text paints on. The logical keywords turn on the element's direction.
|
|
145
|
+
*/
|
|
146
|
+
export function textAlignOf(computed: string, direction: string): string {
|
|
147
|
+
const value = computed.trim()
|
|
148
|
+
const isRtl = direction.trim() === 'rtl'
|
|
149
|
+
if (value === 'start') return isRtl ? 'right' : 'left'
|
|
150
|
+
if (value === 'end') return isRtl ? 'left' : 'right'
|
|
151
|
+
return value
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A value as the writer takes it. A whole declaration pasted from a
|
|
156
|
+
* stylesheet keeps its value alone, since the writer refuses the semicolon.
|
|
157
|
+
*/
|
|
158
|
+
export function declarationValue(property: string, typed: string): string {
|
|
159
|
+
const value = typed.trim().replace(/;\s*$/, '')
|
|
160
|
+
const prefix = `${property}:`
|
|
161
|
+
return value.toLowerCase().startsWith(prefix)
|
|
162
|
+
? value.slice(prefix.length).trim()
|
|
163
|
+
: value
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The token a value names when it is a bare `var()` and nothing else. */
|
|
167
|
+
export function tokenOf(value: string): string | undefined {
|
|
168
|
+
return value.trim().match(VAR)?.[1]
|
|
169
|
+
}
|
|
170
|
+
|
|
57
171
|
/** Units a scrub moves for each pixel the pointer travels. */
|
|
58
172
|
export function scrubStep(isCoarse: boolean): number {
|
|
59
173
|
return isCoarse ? 10 : 1
|
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
/** @jsxImportSource preact */
|
|
2
2
|
import type { JSX } from 'preact'
|
|
3
|
+
import { useState } from 'preact/hooks'
|
|
3
4
|
import { addressOf, elementAt, excerpt, isRawText } from '@/canvas/address'
|
|
5
|
+
import { AlignGrid } from '@/canvas/client/inspector/align-grid'
|
|
4
6
|
import { ColorField } from '@/canvas/client/inspector/color-field'
|
|
5
7
|
import {
|
|
6
8
|
Field,
|
|
7
9
|
ReadOnlyField,
|
|
8
10
|
type Scrub,
|
|
11
|
+
SelectField,
|
|
9
12
|
} from '@/canvas/client/inspector/field'
|
|
10
13
|
import { Section } from '@/canvas/client/inspector/section'
|
|
14
|
+
import { SizeField } from '@/canvas/client/inspector/size-field'
|
|
15
|
+
import { TokenSelect } from '@/canvas/client/inspector/token-select'
|
|
11
16
|
import {
|
|
12
17
|
clampScrub,
|
|
18
|
+
declarationValue,
|
|
13
19
|
displayValue,
|
|
20
|
+
firstFamily,
|
|
21
|
+
lineHeightValue,
|
|
22
|
+
opacityToCss,
|
|
14
23
|
parseColor,
|
|
24
|
+
sharedRadius,
|
|
25
|
+
textAlignOf,
|
|
15
26
|
toCssValue,
|
|
27
|
+
tokenOf,
|
|
16
28
|
} from '@/canvas/client/inspector/values'
|
|
17
29
|
import {
|
|
18
30
|
currentPage,
|
|
@@ -29,6 +41,7 @@ import {
|
|
|
29
41
|
tokens,
|
|
30
42
|
writeError,
|
|
31
43
|
} from '@/canvas/client/state'
|
|
44
|
+
import type { TokenKind } from '@/canvas/tokens'
|
|
32
45
|
import '@/canvas/client/inspector/fields.css'
|
|
33
46
|
|
|
34
47
|
/** Elements the parser never gives content, so they hold no text to edit. */
|
|
@@ -66,17 +79,67 @@ const SIZE_FIELDS: readonly StyleField[] = [
|
|
|
66
79
|
{ label: 'height', glyph: 'H', property: 'height' },
|
|
67
80
|
]
|
|
68
81
|
|
|
82
|
+
const DIRECTION_FIELD: StyleField = {
|
|
83
|
+
label: 'direction',
|
|
84
|
+
glyph: 'Dir',
|
|
85
|
+
property: 'flex-direction',
|
|
86
|
+
}
|
|
87
|
+
|
|
69
88
|
const FLEX_FIELDS: readonly StyleField[] = [
|
|
70
|
-
{ label: 'direction', glyph: 'Dir', property: 'flex-direction' },
|
|
71
89
|
{ label: 'gap', glyph: 'Gap', property: 'gap', placeholder: '0' },
|
|
72
|
-
{ label: 'padding', glyph: 'Pad', property: 'padding', isWide: true },
|
|
73
90
|
]
|
|
74
91
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
92
|
+
/** Padding applies to any box, so it sits in Layout rather than Flex. */
|
|
93
|
+
const PADDING_FIELD: StyleField = {
|
|
94
|
+
label: 'padding',
|
|
95
|
+
glyph: 'Pad',
|
|
96
|
+
property: 'padding',
|
|
97
|
+
isWide: true,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The corners in the order the radius shorthand lists them. */
|
|
101
|
+
const CORNER_FIELDS: readonly StyleField[] = [
|
|
102
|
+
{ label: 'top left radius', glyph: 'TL', property: 'border-top-left-radius' },
|
|
103
|
+
{
|
|
104
|
+
label: 'top right radius',
|
|
105
|
+
glyph: 'TR',
|
|
106
|
+
property: 'border-top-right-radius',
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
label: 'bottom right radius',
|
|
110
|
+
glyph: 'BR',
|
|
111
|
+
property: 'border-bottom-right-radius',
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
label: 'bottom left radius',
|
|
115
|
+
glyph: 'BL',
|
|
116
|
+
property: 'border-bottom-left-radius',
|
|
117
|
+
},
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
const FLEX_DISPLAYS = new Set(['flex', 'inline-flex'])
|
|
121
|
+
const GRID_DISPLAYS = new Set(['grid', 'inline-grid'])
|
|
122
|
+
|
|
123
|
+
const WEIGHTS = ['100', '200', '300', '400', '500', '600', '700', '800', '900']
|
|
124
|
+
|
|
125
|
+
/** Line height and letter spacing move in tenths, since their range is small. */
|
|
126
|
+
const SPACING_FIELDS: readonly StyleField[] = [
|
|
127
|
+
{ label: 'line height', glyph: 'LH', property: 'line-height' },
|
|
128
|
+
{
|
|
129
|
+
label: 'letter spacing',
|
|
130
|
+
glyph: 'LS',
|
|
131
|
+
property: 'letter-spacing',
|
|
132
|
+
placeholder: '0',
|
|
133
|
+
},
|
|
78
134
|
]
|
|
79
135
|
|
|
136
|
+
const TEXT_ALIGNS = [
|
|
137
|
+
{ value: 'left', path: 'M2.5 4h11M2.5 8h7M2.5 12h9' },
|
|
138
|
+
{ value: 'center', path: 'M2.5 4h11M4.5 8h7M3.5 12h9' },
|
|
139
|
+
{ value: 'right', path: 'M2.5 4h11M6.5 8h7M4.5 12h9' },
|
|
140
|
+
{ value: 'justify', path: 'M2.5 4h11M2.5 8h11M2.5 12h11' },
|
|
141
|
+
] as const
|
|
142
|
+
|
|
80
143
|
const FILL_FIELDS = [
|
|
81
144
|
{ label: 'color', property: 'color' },
|
|
82
145
|
{ label: 'background', property: 'background-color' },
|
|
@@ -175,21 +238,55 @@ function ElementFields({
|
|
|
175
238
|
const commit = (property: string) => (value: string) => {
|
|
176
239
|
if (address) void editElement(frameRef, key, address, property, value)
|
|
177
240
|
}
|
|
241
|
+
/*
|
|
242
|
+
* The writer takes one property an edit, so a control setting two posts
|
|
243
|
+
* them in turn and stops at the first refusal. Each carries the hash the
|
|
244
|
+
* last answered with in its own address, since a frame reloading between
|
|
245
|
+
* the two clears the stored one.
|
|
246
|
+
*/
|
|
247
|
+
const commitAll = async (
|
|
248
|
+
changes: readonly (readonly [string, string])[],
|
|
249
|
+
): Promise<void> => {
|
|
250
|
+
let current = address
|
|
251
|
+
for (const [property, value] of changes) {
|
|
252
|
+
if (!current) return
|
|
253
|
+
const written = await editElement(frameRef, key, current, property, value)
|
|
254
|
+
if (!written) return
|
|
255
|
+
if (written.hash) current = { ...current, hash: written.hash }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const corners = CORNER_FIELDS.map((field) =>
|
|
259
|
+
currentValue(node, field.property).trim(),
|
|
260
|
+
)
|
|
261
|
+
const radius = sharedRadius(corners)
|
|
262
|
+
const [isPerCorner, setPerCorner] = useState(radius === undefined)
|
|
263
|
+
const display = computedValue(node, 'display').trim()
|
|
264
|
+
const isFlex = FLEX_DISPLAYS.has(display)
|
|
265
|
+
const isWrapped = computedValue(node, 'flex-wrap').trim() === 'wrap'
|
|
178
266
|
const rect = node.getBoundingClientRect()
|
|
179
|
-
const font = currentValue(node, 'font-family')
|
|
180
267
|
const text = node.textContent ?? ''
|
|
181
|
-
const
|
|
268
|
+
const groupTokens = (kind: TokenKind): readonly string[] =>
|
|
182
269
|
tokens.value?.groups
|
|
183
|
-
?.find((group) => group.kind ===
|
|
270
|
+
?.find((group) => group.kind === kind)
|
|
184
271
|
?.tokens.map((token) => token.name) ?? []
|
|
272
|
+
const colorTokens = groupTokens('color')
|
|
273
|
+
const family = currentValue(node, 'font-family')
|
|
274
|
+
const familyToken = tokenOf(family)
|
|
275
|
+
const size = currentValue(node, 'font-size')
|
|
276
|
+
const sizeToken = tokenOf(size)
|
|
277
|
+
const textAlign = textAlignOf(
|
|
278
|
+
computedValue(node, 'text-align'),
|
|
279
|
+
computedValue(node, 'direction'),
|
|
280
|
+
)
|
|
185
281
|
|
|
186
282
|
/**
|
|
187
283
|
* Previews into the frame's own inline style and writes through the same
|
|
188
284
|
* edit as typing. A reload mid-drag replaces the document, so a release
|
|
189
285
|
* against one no longer on screen posts nothing.
|
|
190
286
|
*/
|
|
191
|
-
const scrubOf = (property: string): Scrub => ({
|
|
287
|
+
const scrubOf = (property: string, step?: number): Scrub => ({
|
|
192
288
|
clamp: (value) => clampScrub(property, value),
|
|
289
|
+
step,
|
|
193
290
|
begin: () => {
|
|
194
291
|
const original = inlineValue(node, property)
|
|
195
292
|
const style = (node as HTMLElement).style
|
|
@@ -237,15 +334,209 @@ function ElementFields({
|
|
|
237
334
|
glyph="Y"
|
|
238
335
|
value={String(Math.round(rect.top))}
|
|
239
336
|
/>
|
|
240
|
-
{SIZE_FIELDS.map(
|
|
337
|
+
{SIZE_FIELDS.map((field) => (
|
|
338
|
+
<SizeField
|
|
339
|
+
key={field.property}
|
|
340
|
+
label={field.label}
|
|
341
|
+
glyph={field.glyph}
|
|
342
|
+
initial={displayValue(
|
|
343
|
+
field.property,
|
|
344
|
+
currentValue(node, field.property),
|
|
345
|
+
)}
|
|
346
|
+
isBusy={isBusy}
|
|
347
|
+
onCommit={(typed) =>
|
|
348
|
+
commit(field.property)(toCssValue(field.property, typed))
|
|
349
|
+
}
|
|
350
|
+
scrub={'style' in node ? scrubOf(field.property) : undefined}
|
|
351
|
+
/>
|
|
352
|
+
))}
|
|
353
|
+
{styleField(PADDING_FIELD)}
|
|
241
354
|
</Section>
|
|
242
|
-
<Section
|
|
243
|
-
|
|
244
|
-
{
|
|
245
|
-
|
|
246
|
-
|
|
355
|
+
<Section
|
|
356
|
+
title="Flex"
|
|
357
|
+
action={
|
|
358
|
+
isFlex || GRID_DISPLAYS.has(display) ? null : (
|
|
359
|
+
<button
|
|
360
|
+
type="button"
|
|
361
|
+
class="section-icon"
|
|
362
|
+
aria-label="Add flex layout"
|
|
363
|
+
title="Add flex layout"
|
|
364
|
+
disabled={isBusy}
|
|
365
|
+
onClick={() => commit('display')('flex')}
|
|
366
|
+
>
|
|
367
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
368
|
+
<path d="M8 3.5v9M3.5 8h9" />
|
|
369
|
+
</svg>
|
|
370
|
+
</button>
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
>
|
|
374
|
+
{isFlex ? (
|
|
375
|
+
<>
|
|
376
|
+
<div class="flex-layout is-wide">
|
|
377
|
+
<AlignGrid
|
|
378
|
+
direction={computedValue(node, 'flex-direction').trim()}
|
|
379
|
+
justifyContent={computedValue(node, 'justify-content')}
|
|
380
|
+
alignItems={computedValue(node, 'align-items')}
|
|
381
|
+
isBusy={isBusy}
|
|
382
|
+
onPick={(alignment) =>
|
|
383
|
+
void commitAll([
|
|
384
|
+
['justify-content', alignment.justifyContent],
|
|
385
|
+
['align-items', alignment.alignItems],
|
|
386
|
+
])
|
|
387
|
+
}
|
|
388
|
+
/>
|
|
389
|
+
<div class="flex-layout-side">
|
|
390
|
+
{styleField(DIRECTION_FIELD)}
|
|
391
|
+
<button
|
|
392
|
+
type="button"
|
|
393
|
+
class="section-icon"
|
|
394
|
+
aria-label="Wrap"
|
|
395
|
+
title="Wrap"
|
|
396
|
+
aria-pressed={isWrapped}
|
|
397
|
+
disabled={isBusy}
|
|
398
|
+
onClick={() =>
|
|
399
|
+
commit('flex-wrap')(isWrapped ? 'nowrap' : 'wrap')
|
|
400
|
+
}
|
|
401
|
+
>
|
|
402
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
403
|
+
<path d="M2.5 4.5h9a2.5 2.5 0 0 1 0 5h-6M7.5 7.5l-2 2 2 2M2.5 12.5h1" />
|
|
404
|
+
</svg>
|
|
405
|
+
</button>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
{FLEX_FIELDS.map(styleField)}
|
|
409
|
+
</>
|
|
247
410
|
) : null}
|
|
248
411
|
</Section>
|
|
412
|
+
<Section
|
|
413
|
+
title="Appearance"
|
|
414
|
+
action={
|
|
415
|
+
<button
|
|
416
|
+
type="button"
|
|
417
|
+
class="section-icon"
|
|
418
|
+
aria-label="Per-corner radius"
|
|
419
|
+
title="Per-corner radius"
|
|
420
|
+
aria-pressed={isPerCorner}
|
|
421
|
+
onClick={() => setPerCorner(!isPerCorner)}
|
|
422
|
+
>
|
|
423
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
424
|
+
<path d="M3 6.5V5a2 2 0 0 1 2-2h1.5M9.5 3H11a2 2 0 0 1 2 2v1.5M13 9.5V11a2 2 0 0 1-2 2H9.5M6.5 13H5a2 2 0 0 1-2-2V9.5" />
|
|
425
|
+
</svg>
|
|
426
|
+
</button>
|
|
427
|
+
}
|
|
428
|
+
>
|
|
429
|
+
<Field
|
|
430
|
+
label="opacity"
|
|
431
|
+
glyph="Op"
|
|
432
|
+
initial={displayValue('opacity', computedValue(node, 'opacity'))}
|
|
433
|
+
isBusy={isBusy}
|
|
434
|
+
isWide={isPerCorner}
|
|
435
|
+
onCommit={(typed) => {
|
|
436
|
+
const value = opacityToCss(typed)
|
|
437
|
+
if (value !== undefined) commit('opacity')(value)
|
|
438
|
+
}}
|
|
439
|
+
/>
|
|
440
|
+
{isPerCorner ? (
|
|
441
|
+
CORNER_FIELDS.map(styleField)
|
|
442
|
+
) : (
|
|
443
|
+
<Field
|
|
444
|
+
label="radius"
|
|
445
|
+
glyph="R"
|
|
446
|
+
initial={displayValue('border-radius', radius ?? '')}
|
|
447
|
+
isBusy={isBusy}
|
|
448
|
+
placeholder="0"
|
|
449
|
+
onCommit={(typed) =>
|
|
450
|
+
commit('border-radius')(toCssValue('border-radius', typed))
|
|
451
|
+
}
|
|
452
|
+
scrub={'style' in node ? scrubOf('border-radius') : undefined}
|
|
453
|
+
/>
|
|
454
|
+
)}
|
|
455
|
+
</Section>
|
|
456
|
+
<Section title="Typography">
|
|
457
|
+
<TokenSelect
|
|
458
|
+
label="family"
|
|
459
|
+
glyph="Aa"
|
|
460
|
+
initial={familyToken ?? firstFamily(family)}
|
|
461
|
+
tokens={groupTokens('font-family')}
|
|
462
|
+
token={familyToken}
|
|
463
|
+
isRaw={isRawInline(node, 'font-family')}
|
|
464
|
+
isBusy={isBusy}
|
|
465
|
+
isWide
|
|
466
|
+
onCommit={(typed) =>
|
|
467
|
+
commit('font-family')(declarationValue('font-family', typed))
|
|
468
|
+
}
|
|
469
|
+
onPick={(name) => commit('font-family')(`var(${name})`)}
|
|
470
|
+
/>
|
|
471
|
+
<TokenSelect
|
|
472
|
+
label="size"
|
|
473
|
+
glyph="Size"
|
|
474
|
+
initial={sizeToken ?? displayValue('font-size', size)}
|
|
475
|
+
tokens={groupTokens('font-size')}
|
|
476
|
+
token={sizeToken}
|
|
477
|
+
isRaw={isRawInline(node, 'font-size')}
|
|
478
|
+
isBusy={isBusy}
|
|
479
|
+
onCommit={(typed) =>
|
|
480
|
+
commit('font-size')(toCssValue('font-size', typed))
|
|
481
|
+
}
|
|
482
|
+
onPick={(name) => commit('font-size')(`var(${name})`)}
|
|
483
|
+
scrub={'style' in node ? scrubOf('font-size') : undefined}
|
|
484
|
+
/>
|
|
485
|
+
<SelectField
|
|
486
|
+
label="weight"
|
|
487
|
+
glyph="Wt"
|
|
488
|
+
options={WEIGHTS}
|
|
489
|
+
value={currentValue(node, 'font-weight').trim()}
|
|
490
|
+
isBusy={isBusy}
|
|
491
|
+
onCommit={commit('font-weight')}
|
|
492
|
+
/>
|
|
493
|
+
{SPACING_FIELDS.map((field) => (
|
|
494
|
+
<Field
|
|
495
|
+
key={field.property}
|
|
496
|
+
label={field.label}
|
|
497
|
+
glyph={field.glyph}
|
|
498
|
+
initial={
|
|
499
|
+
field.property === 'line-height'
|
|
500
|
+
? lineHeightValue(
|
|
501
|
+
currentValue(node, 'line-height'),
|
|
502
|
+
computedValue(node, 'font-size'),
|
|
503
|
+
)
|
|
504
|
+
: displayValue(
|
|
505
|
+
field.property,
|
|
506
|
+
currentValue(node, field.property),
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
isBusy={isBusy}
|
|
510
|
+
placeholder={field.placeholder}
|
|
511
|
+
onCommit={(typed) =>
|
|
512
|
+
commit(field.property)(toCssValue(field.property, typed))
|
|
513
|
+
}
|
|
514
|
+
scrub={'style' in node ? scrubOf(field.property, 0.1) : undefined}
|
|
515
|
+
/>
|
|
516
|
+
))}
|
|
517
|
+
<div class="segmented is-wide" role="group" aria-label="text alignment">
|
|
518
|
+
{TEXT_ALIGNS.map((align) => (
|
|
519
|
+
<button
|
|
520
|
+
key={align.value}
|
|
521
|
+
type="button"
|
|
522
|
+
class="segment"
|
|
523
|
+
aria-label={`Align ${align.value}`}
|
|
524
|
+
title={`Align ${align.value}`}
|
|
525
|
+
aria-pressed={textAlign === align.value}
|
|
526
|
+
disabled={isBusy}
|
|
527
|
+
onClick={() => {
|
|
528
|
+
if (textAlign !== align.value) {
|
|
529
|
+
commit('text-align')(align.value)
|
|
530
|
+
}
|
|
531
|
+
}}
|
|
532
|
+
>
|
|
533
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
534
|
+
<path d={align.path} />
|
|
535
|
+
</svg>
|
|
536
|
+
</button>
|
|
537
|
+
))}
|
|
538
|
+
</div>
|
|
539
|
+
</Section>
|
|
249
540
|
<Section title="Fill">
|
|
250
541
|
{FILL_FIELDS.map((field) => {
|
|
251
542
|
const painted = computedValue(node, field.property)
|
|
@@ -261,11 +552,16 @@ function ElementFields({
|
|
|
261
552
|
label={field.label}
|
|
262
553
|
color={parseColor(inline || painted)}
|
|
263
554
|
text={inline || painted}
|
|
555
|
+
inline={inline}
|
|
264
556
|
painted={painted}
|
|
557
|
+
owner={`${key}:${address?.index ?? -1}:${field.property}`}
|
|
265
558
|
tokens={colorTokens}
|
|
266
559
|
paint={(name) => tokenPaint(doc, name)}
|
|
267
560
|
isRaw={isRawInline(node, field.property)}
|
|
268
561
|
isBusy={isBusy}
|
|
562
|
+
preview={
|
|
563
|
+
'style' in node ? scrubOf(field.property).begin : undefined
|
|
564
|
+
}
|
|
269
565
|
onCommit={commit(field.property)}
|
|
270
566
|
/>
|
|
271
567
|
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canvas key layer: one map from a key event to a surface action, which
|
|
3
|
+
* the surface and every loaded frame document call. It sees only what the
|
|
4
|
+
* surface forwards, so a key typed into a panel field never reaches it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type SurfaceAction =
|
|
8
|
+
| 'tool-move'
|
|
9
|
+
| 'tool-pan'
|
|
10
|
+
| 'zoom-in'
|
|
11
|
+
| 'zoom-out'
|
|
12
|
+
| 'zoom-fit'
|
|
13
|
+
| 'pan-hold'
|
|
14
|
+
| 'pan-release'
|
|
15
|
+
|
|
16
|
+
/** The fields of a `KeyboardEvent` the map reads, so a test needs no DOM. */
|
|
17
|
+
export interface KeyInput {
|
|
18
|
+
readonly type: string
|
|
19
|
+
readonly key: string
|
|
20
|
+
readonly code: string
|
|
21
|
+
readonly shiftKey: boolean
|
|
22
|
+
readonly ctrlKey: boolean
|
|
23
|
+
readonly metaKey: boolean
|
|
24
|
+
readonly altKey: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PLAIN_KEYS: Readonly<Record<string, SurfaceAction | undefined>> = {
|
|
28
|
+
v: 'tool-move',
|
|
29
|
+
h: 'tool-pan',
|
|
30
|
+
'=': 'zoom-in',
|
|
31
|
+
'+': 'zoom-in',
|
|
32
|
+
'-': 'zoom-out',
|
|
33
|
+
' ': 'pan-hold',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Cmd, Ctrl, and Alt combinations are the browser's or the system's, so any
|
|
38
|
+
* of them held on a press returns nothing. Space coming up releases whatever
|
|
39
|
+
* is held with it, or a modifier pressed mid-pan would leave the pan stuck.
|
|
40
|
+
* Shift and the 1 key fits by `code`, since the character Shift gives on that
|
|
41
|
+
* key differs by layout.
|
|
42
|
+
*/
|
|
43
|
+
export function surfaceAction(event: KeyInput): SurfaceAction | undefined {
|
|
44
|
+
if (event.type === 'keyup') {
|
|
45
|
+
return event.key === ' ' ? 'pan-release' : undefined
|
|
46
|
+
}
|
|
47
|
+
if (event.type !== 'keydown') return undefined
|
|
48
|
+
if (event.ctrlKey || event.metaKey || event.altKey) return undefined
|
|
49
|
+
if (event.shiftKey) {
|
|
50
|
+
if (event.code === 'Digit1') return 'zoom-fit'
|
|
51
|
+
return event.key === '+' ? 'zoom-in' : undefined
|
|
52
|
+
}
|
|
53
|
+
return PLAIN_KEYS[event.key.toLowerCase()]
|
|
54
|
+
}
|