@erclx/canon 5.53.0 → 5.55.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/CHANGELOG.md +18 -0
- package/README.md +10 -9
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +5 -5
- package/claude/skills/create-skill/SKILL.md +1 -1
- package/claude/skills/design-identity/REQUIREMENT.md +2 -2
- package/claude/skills/design-identity/SKILL.md +6 -6
- package/claude/skills/design-record/REQUIREMENT.md +2 -2
- package/claude/skills/design-record/SKILL.md +4 -4
- package/claude/skills/design-record/references/folder.md +1 -1
- package/claude/skills/design-taste/REQUIREMENT.md +1 -1
- package/claude/skills/document-health/SKILL.md +6 -6
- package/claude/skills/document-repair/SKILL.md +5 -5
- package/claude/skills/draw-figure/REQUIREMENT.md +1 -1
- package/claude/skills/draw-figure/SKILL.md +1 -1
- package/claude/skills/pick-by-eye/REQUIREMENT.md +4 -4
- package/claude/skills/pick-by-eye/SKILL.md +34 -41
- package/claude/skills/pick-by-eye/references/board.md +42 -0
- package/claude/skills/pick-by-eye/references/live-arms.md +3 -3
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/target-check/SKILL.md +6 -8
- package/claude/skills/target-check/references/domains.md +1 -4
- package/claude/skills/target-setup/SKILL.md +8 -8
- package/claude/skills/target-update/SKILL.md +5 -5
- package/claude/skills/teach-craft/REQUIREMENT.md +1 -1
- package/claude/skills/teach-craft/SKILL.md +32 -1
- package/claude/skills/teach-craft/references/adopted.md +113 -0
- package/claude/skills/teach-craft/references/lesson-craft.md +10 -2
- package/claude/skills/teach-craft/references/pedagogy.md +29 -2
- package/docs/agents/install-and-sync.md +2 -10
- package/docs/agents/output-shape.md +11 -10
- package/docs/target/migrations.md +8 -3
- package/docs/target/projects.md +1 -1
- package/docs/target/sync.md +1 -1
- package/docs/workflow/skill-map.md +0 -1
- package/docs/workflow/visual-design-workflow.md +1 -4
- package/governance/detect.toml +1 -6
- package/governance/renames.toml +0 -1
- package/package.json +2 -5
- package/scripts/core/install-check.sh +1 -1
- package/scripts/lib/ui.sh +12 -65
- package/src/audits/run.ts +1 -1
- package/src/claude/cases/misc.ts +0 -5
- package/src/claude/routing.ts +1 -1
- package/src/claude/skills-audit.ts +1 -0
- package/src/cli/help.ts +0 -3
- package/src/cli/run.ts +5 -6
- package/src/cli/ui.ts +87 -28
- package/src/cli.ts +6 -2
- package/src/commands/claude.ts +2 -3
- package/src/commands/design.ts +20 -324
- package/src/commands/gate.ts +5 -3
- package/src/commands/gov.ts +11 -21
- package/src/commands/labels.ts +1 -1
- package/src/commands/pr.ts +1 -1
- package/src/commands/sync.ts +5 -6
- package/src/commands/tooling.ts +5 -8
- package/src/commands/transcripts.ts +6 -11
- package/src/design/regen.ts +5 -32
- package/src/design/render.ts +1 -1
- package/src/design/tokens.ts +3 -2
- package/src/gate/sequencer.ts +1 -1
- package/src/gate/stages.ts +4 -20
- package/src/gov/audit.ts +1 -6
- package/src/hooks/post-merge.ts +2 -5
- package/src/markdown/scope.ts +2 -4
- package/src/process/harness.ts +3 -4
- package/src/sandbox/equivalence.ts +0 -1
- package/src/sandbox/provision.ts +8 -13
- package/src/sandbox/scenario.ts +6 -12
- package/src/shipped/references.ts +1 -2
- package/src/sync/check.ts +4 -29
- package/src/sync/engine.ts +3 -8
- package/src/sync/stamp.ts +6 -6
- package/src/sync/target.ts +1 -4
- package/src/sync/workflow.ts +3 -5
- package/src/tooling/verify-stack.ts +1 -1
- package/tooling/base/configs/.husky/post-merge +6 -10
- package/tooling/claude/reference.md +1 -1
- package/tooling/claude/seeds/canon/DESIGN.md +1 -1
- package/claude/skills/canvas-board/REQUIREMENT.md +0 -35
- package/claude/skills/canvas-board/SKILL.md +0 -143
- package/claude/skills/canvas-board/references/canvas.md +0 -170
- package/governance/rules/ui/canvas.md +0 -13
- package/scripts/core/check-color-source.sh +0 -51
- package/src/canvas/address.ts +0 -227
- package/src/canvas/arrange.ts +0 -66
- package/src/canvas/capture.ts +0 -391
- package/src/canvas/client/app.tsx +0 -188
- package/src/canvas/client/inspector/align-grid.tsx +0 -162
- package/src/canvas/client/inspector/color-field.tsx +0 -398
- package/src/canvas/client/inspector/color-inputs.tsx +0 -139
- package/src/canvas/client/inspector/color-picker.tsx +0 -668
- package/src/canvas/client/inspector/color-space.ts +0 -240
- package/src/canvas/client/inspector/field.tsx +0 -263
- package/src/canvas/client/inspector/fields.css +0 -763
- package/src/canvas/client/inspector/section.tsx +0 -27
- package/src/canvas/client/inspector/size-field.tsx +0 -69
- package/src/canvas/client/inspector/token-select.tsx +0 -109
- package/src/canvas/client/inspector/values.ts +0 -297
- package/src/canvas/client/inspector.tsx +0 -692
- package/src/canvas/client/keys.ts +0 -71
- package/src/canvas/client/layers.tsx +0 -118
- package/src/canvas/client/main.tsx +0 -71
- package/src/canvas/client/pages-panel.tsx +0 -212
- package/src/canvas/client/panel-handle.tsx +0 -90
- package/src/canvas/client/selection.tsx +0 -199
- package/src/canvas/client/shell.css +0 -762
- package/src/canvas/client/state.ts +0 -792
- package/src/canvas/client/surface.tsx +0 -803
- package/src/canvas/client/theme-panel.tsx +0 -55
- package/src/canvas/client/tools.tsx +0 -153
- package/src/canvas/content.ts +0 -781
- package/src/canvas/edit.ts +0 -575
- package/src/canvas/editing.ts +0 -203
- package/src/canvas/fit.ts +0 -238
- package/src/canvas/global.d.ts +0 -15
- package/src/canvas/history.ts +0 -116
- package/src/canvas/index.html +0 -20
- package/src/canvas/server.ts +0 -766
- package/src/canvas/tokens.ts +0 -221
- package/src/commands/canvas.ts +0 -1097
- package/src/design/adapter.ts +0 -59
- package/src/design/base.css +0 -203
- package/src/design/brand-favicon.ts +0 -18
- package/src/design/neutral.ts +0 -127
- package/src/design/project-fonts.ts +0 -392
|
@@ -1,803 +0,0 @@
|
|
|
1
|
-
/** @jsxImportSource preact */
|
|
2
|
-
import type { JSX, RefObject } from 'preact'
|
|
3
|
-
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks'
|
|
4
|
-
import { addressOf, documentElements, elementAt } from '@/canvas/address'
|
|
5
|
-
import type { Frame } from '@/canvas/content'
|
|
6
|
-
import { type SurfaceAction, surfaceAction } from '@/canvas/client/keys'
|
|
7
|
-
import {
|
|
8
|
-
activeTool,
|
|
9
|
-
currentPage,
|
|
10
|
-
draggingFrame,
|
|
11
|
-
editingFrames,
|
|
12
|
-
fitView,
|
|
13
|
-
type FrameRef,
|
|
14
|
-
frameDocuments,
|
|
15
|
-
frameKey,
|
|
16
|
-
frameTheme,
|
|
17
|
-
frameVersions,
|
|
18
|
-
historyNotice,
|
|
19
|
-
hoveredElement,
|
|
20
|
-
moveFrameTo,
|
|
21
|
-
panBy,
|
|
22
|
-
panelsHidden,
|
|
23
|
-
panelWidths,
|
|
24
|
-
previewMove,
|
|
25
|
-
previewResize,
|
|
26
|
-
redo,
|
|
27
|
-
registerFrameDocument,
|
|
28
|
-
resizeElement,
|
|
29
|
-
resizeFrameTo,
|
|
30
|
-
selectElement,
|
|
31
|
-
selectFrame,
|
|
32
|
-
selection,
|
|
33
|
-
shownTool,
|
|
34
|
-
spacePan,
|
|
35
|
-
type Theme,
|
|
36
|
-
toggleFrameTheme,
|
|
37
|
-
togglePanels,
|
|
38
|
-
undo,
|
|
39
|
-
view,
|
|
40
|
-
zoomAt,
|
|
41
|
-
} from '@/canvas/client/state'
|
|
42
|
-
import {
|
|
43
|
-
DRAG_THRESHOLD,
|
|
44
|
-
HoverOutline,
|
|
45
|
-
type OverlayBox,
|
|
46
|
-
SelectionOverlay,
|
|
47
|
-
} from '@/canvas/client/selection'
|
|
48
|
-
import { ToolStrip } from '@/canvas/client/tools'
|
|
49
|
-
|
|
50
|
-
/** One wheel notch or one button press. */
|
|
51
|
-
const ZOOM_STEP = 1.2
|
|
52
|
-
|
|
53
|
-
/** Surface units one arrow press moves a frame, and one with Shift held. */
|
|
54
|
-
const NUDGE = 10
|
|
55
|
-
const NUDGE_LARGE = 50
|
|
56
|
-
|
|
57
|
-
const ARROWS: Readonly<Record<string, { x: number; y: number } | undefined>> = {
|
|
58
|
-
ArrowLeft: { x: -1, y: 0 },
|
|
59
|
-
ArrowRight: { x: 1, y: 0 },
|
|
60
|
-
ArrowUp: { x: 0, y: -1 },
|
|
61
|
-
ArrowDown: { x: 0, y: 1 },
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
interface Gesture {
|
|
65
|
-
readonly startX: number
|
|
66
|
-
readonly startY: number
|
|
67
|
-
readonly originX: number
|
|
68
|
-
readonly originY: number
|
|
69
|
-
x: number
|
|
70
|
-
y: number
|
|
71
|
-
moved: boolean
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export interface SurfaceProps {
|
|
75
|
-
readonly viewportRef: RefObject<HTMLDivElement | null>
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** A wheel turn in shell coordinates, wherever the pointer sat. */
|
|
79
|
-
interface WheelTurn {
|
|
80
|
-
readonly deltaX: number
|
|
81
|
-
readonly deltaY: number
|
|
82
|
-
readonly isZoom: boolean
|
|
83
|
-
readonly clientX: number
|
|
84
|
-
readonly clientY: number
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* The frame's own document is a separate window, so an element in it fails an
|
|
89
|
-
* `instanceof Element` against the shell's window. The node type is the test
|
|
90
|
-
* that holds across both.
|
|
91
|
-
*/
|
|
92
|
-
function asElement(target: EventTarget | null): Element | undefined {
|
|
93
|
-
const node = target as Node | null
|
|
94
|
-
return node?.nodeType === 1 ? (node as Element) : undefined
|
|
95
|
-
}
|
|
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
|
-
|
|
128
|
-
/** A computed length in pixels, or the fallback for one that is not. */
|
|
129
|
-
function pixels(value: string, fallback: number): number {
|
|
130
|
-
const parsed = Number.parseFloat(value)
|
|
131
|
-
return value.endsWith('px') && Number.isFinite(parsed) ? parsed : fallback
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* The selected element's overlay. A handle drag previews the size as inline
|
|
136
|
-
* style on the element and writes it once on release, the way the inspector's
|
|
137
|
-
* scrub does. The drag measures the border box while `width` and `height` set
|
|
138
|
-
* whatever box the element sizes by, so the delta is added to the computed
|
|
139
|
-
* value rather than written as the box itself.
|
|
140
|
-
*/
|
|
141
|
-
function ElementSelection({
|
|
142
|
-
node,
|
|
143
|
-
doc,
|
|
144
|
-
frameRef,
|
|
145
|
-
docKey,
|
|
146
|
-
}: {
|
|
147
|
-
readonly node: Element
|
|
148
|
-
readonly doc: Document
|
|
149
|
-
readonly frameRef: FrameRef
|
|
150
|
-
readonly docKey: string
|
|
151
|
-
}): JSX.Element {
|
|
152
|
-
const start = useRef<
|
|
153
|
-
| {
|
|
154
|
-
readonly width: number
|
|
155
|
-
readonly height: number
|
|
156
|
-
readonly inlineWidth: string
|
|
157
|
-
readonly inlineHeight: string
|
|
158
|
-
}
|
|
159
|
-
| undefined
|
|
160
|
-
>(undefined)
|
|
161
|
-
const style = (node as HTMLElement).style
|
|
162
|
-
|
|
163
|
-
const measure = () => {
|
|
164
|
-
const rect = node.getBoundingClientRect()
|
|
165
|
-
return { x: rect.left, y: rect.top, width: rect.width, height: rect.height }
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const cssSize = (box: OverlayBox, origin: OverlayBox) => {
|
|
169
|
-
const from = start.current
|
|
170
|
-
return {
|
|
171
|
-
width: Math.round(
|
|
172
|
-
(from?.width ?? origin.width) + box.width - origin.width,
|
|
173
|
-
),
|
|
174
|
-
height: Math.round(
|
|
175
|
-
(from?.height ?? origin.height) + box.height - origin.height,
|
|
176
|
-
),
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const handleStart = () => {
|
|
181
|
-
const computed = doc.defaultView?.getComputedStyle(node)
|
|
182
|
-
const rect = node.getBoundingClientRect()
|
|
183
|
-
start.current = {
|
|
184
|
-
width: pixels(computed?.width ?? '', rect.width),
|
|
185
|
-
height: pixels(computed?.height ?? '', rect.height),
|
|
186
|
-
inlineWidth: style.getPropertyValue('width'),
|
|
187
|
-
inlineHeight: style.getPropertyValue('height'),
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const handlePreview = (box: OverlayBox, from: OverlayBox) => {
|
|
192
|
-
const size = cssSize(box, from)
|
|
193
|
-
style.setProperty('width', `${size.width}px`)
|
|
194
|
-
style.setProperty('height', `${size.height}px`)
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const restore = () => {
|
|
198
|
-
const from = start.current
|
|
199
|
-
if (!from) return
|
|
200
|
-
style.setProperty('width', from.inlineWidth)
|
|
201
|
-
style.setProperty('height', from.inlineHeight)
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const handleCommit = (box: OverlayBox, from: OverlayBox) => {
|
|
205
|
-
const size = cssSize(box, from)
|
|
206
|
-
const address = addressOf(doc, node)
|
|
207
|
-
const isUnchanged = box.width === from.width && box.height === from.height
|
|
208
|
-
if (!address || isUnchanged) {
|
|
209
|
-
restore()
|
|
210
|
-
return
|
|
211
|
-
}
|
|
212
|
-
void resizeElement(
|
|
213
|
-
frameRef,
|
|
214
|
-
docKey,
|
|
215
|
-
address,
|
|
216
|
-
box.width === from.width ? undefined : size.width,
|
|
217
|
-
box.height === from.height ? undefined : size.height,
|
|
218
|
-
)
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const handleCancel = restore
|
|
222
|
-
|
|
223
|
-
return (
|
|
224
|
-
<SelectionOverlay
|
|
225
|
-
kind="element"
|
|
226
|
-
measure={measure}
|
|
227
|
-
hasChip
|
|
228
|
-
isMeasuredWhileDragging
|
|
229
|
-
onStart={handleStart}
|
|
230
|
-
onPreview={handlePreview}
|
|
231
|
-
onCommit={handleCommit}
|
|
232
|
-
onCancel={handleCancel}
|
|
233
|
-
/>
|
|
234
|
-
)
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
export function frameSrc(page: string, frame: Frame, version: number): string {
|
|
238
|
-
const path = `/frames/${encodeURIComponent(page)}/${encodeURIComponent(frame.file)}`
|
|
239
|
-
return version === 0 ? path : `${path}?v=${version}`
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Sets the frame document's theme the way the token stylesheet reads it. The
|
|
244
|
-
* frame shares the shell's origin, so its document is reachable once loaded.
|
|
245
|
-
*/
|
|
246
|
-
function applyFrameTheme(iframe: HTMLIFrameElement | null, value: Theme): void {
|
|
247
|
-
const root = iframe?.contentDocument?.documentElement
|
|
248
|
-
if (!root) return
|
|
249
|
-
root.dataset.theme = value
|
|
250
|
-
root.style.colorScheme = value
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function FrameView({
|
|
254
|
-
page,
|
|
255
|
-
frame,
|
|
256
|
-
onWheelTurn,
|
|
257
|
-
onKey,
|
|
258
|
-
}: {
|
|
259
|
-
readonly page: string
|
|
260
|
-
readonly frame: Frame
|
|
261
|
-
readonly onWheelTurn: (turn: WheelTurn) => void
|
|
262
|
-
readonly onKey: (event: KeyboardEvent) => void
|
|
263
|
-
}): JSX.Element {
|
|
264
|
-
const key = frameKey(page, frame)
|
|
265
|
-
const version = frameVersions.value.get(key) ?? 0
|
|
266
|
-
const value = frameTheme(key)
|
|
267
|
-
const iframe = useRef<HTMLIFrameElement>(null)
|
|
268
|
-
const gesture = useRef<Gesture | undefined>(undefined)
|
|
269
|
-
const ref: FrameRef = { page, frame: frame.name }
|
|
270
|
-
const isSelected =
|
|
271
|
-
selection.value?.page === page && selection.value.frame === frame.name
|
|
272
|
-
/* Bumped when the frame document scrolls, so the outlines follow it. */
|
|
273
|
-
const [, setScrolled] = useState(0)
|
|
274
|
-
const doc = frameDocuments.value.get(key)
|
|
275
|
-
const editing = editingFrames.value.get(key)
|
|
276
|
-
|
|
277
|
-
useEffect(() => {
|
|
278
|
-
applyFrameTheme(iframe.current, value)
|
|
279
|
-
}, [value])
|
|
280
|
-
|
|
281
|
-
/*
|
|
282
|
-
* The frame shares the shell's origin, so its document takes listeners
|
|
283
|
-
* directly. A click selects the element under it and goes no further, so a
|
|
284
|
-
* link or a submit button in a draft never navigates the frame away.
|
|
285
|
-
*/
|
|
286
|
-
const handleLoad = () => {
|
|
287
|
-
const element = iframe.current
|
|
288
|
-
const loaded = element?.contentDocument
|
|
289
|
-
applyFrameTheme(element, frameTheme(key))
|
|
290
|
-
if (!element || !loaded) return
|
|
291
|
-
registerFrameDocument(key, loaded)
|
|
292
|
-
|
|
293
|
-
loaded.addEventListener('click', (event) => {
|
|
294
|
-
event.preventDefault()
|
|
295
|
-
event.stopPropagation()
|
|
296
|
-
const target = asElement(event.target)
|
|
297
|
-
const address = target && addressOf(loaded, target)
|
|
298
|
-
if (address) void selectElement(ref, key, address)
|
|
299
|
-
})
|
|
300
|
-
loaded.addEventListener('submit', (event) => event.preventDefault())
|
|
301
|
-
loaded.addEventListener('mouseover', (event) => {
|
|
302
|
-
const target = asElement(event.target)
|
|
303
|
-
const index = target ? documentElements(loaded).indexOf(target) : -1
|
|
304
|
-
hoveredElement.value = index === -1 ? undefined : { key, index }
|
|
305
|
-
})
|
|
306
|
-
loaded.addEventListener('mouseleave', () => {
|
|
307
|
-
if (hoveredElement.value?.key === key) hoveredElement.value = undefined
|
|
308
|
-
})
|
|
309
|
-
loaded.addEventListener('scroll', () => setScrolled((n) => n + 1))
|
|
310
|
-
loaded.addEventListener(
|
|
311
|
-
'wheel',
|
|
312
|
-
(event) => {
|
|
313
|
-
event.preventDefault()
|
|
314
|
-
const rect = element.getBoundingClientRect()
|
|
315
|
-
const { zoom } = view.value
|
|
316
|
-
onWheelTurn({
|
|
317
|
-
deltaX: event.deltaX,
|
|
318
|
-
deltaY: event.deltaY,
|
|
319
|
-
isZoom: event.ctrlKey || event.metaKey,
|
|
320
|
-
clientX: rect.left + event.clientX * zoom,
|
|
321
|
-
clientY: rect.top + event.clientY * zoom,
|
|
322
|
-
})
|
|
323
|
-
},
|
|
324
|
-
{ passive: false },
|
|
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
|
-
})
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
const picked =
|
|
339
|
-
isSelected && doc && selection.value?.element
|
|
340
|
-
? selection.value.element
|
|
341
|
-
: undefined
|
|
342
|
-
const selectedNode =
|
|
343
|
-
doc && picked && !picked.stale ? elementAt(doc, picked) : undefined
|
|
344
|
-
const hovered = hoveredElement.value
|
|
345
|
-
const hoveredNode =
|
|
346
|
-
doc && hovered?.key === key
|
|
347
|
-
? documentElements(doc)[hovered.index]
|
|
348
|
-
: undefined
|
|
349
|
-
|
|
350
|
-
/* The label means the frame itself, so it also drops a picked element. */
|
|
351
|
-
const select = () => {
|
|
352
|
-
if (!isSelected || selection.value?.element) void selectFrame(ref)
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const handlePointerDown = (event: PointerEvent) => {
|
|
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
|
|
359
|
-
if ((event.target as HTMLElement | null)?.closest('.frame-theme')) return
|
|
360
|
-
gesture.current = {
|
|
361
|
-
startX: event.clientX,
|
|
362
|
-
startY: event.clientY,
|
|
363
|
-
originX: frame.x,
|
|
364
|
-
originY: frame.y,
|
|
365
|
-
x: frame.x,
|
|
366
|
-
y: frame.y,
|
|
367
|
-
moved: false,
|
|
368
|
-
}
|
|
369
|
-
;(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId)
|
|
370
|
-
select()
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
const handlePointerMove = (event: PointerEvent) => {
|
|
374
|
-
const current = gesture.current
|
|
375
|
-
if (!current) return
|
|
376
|
-
const dx = event.clientX - current.startX
|
|
377
|
-
const dy = event.clientY - current.startY
|
|
378
|
-
if (!current.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return
|
|
379
|
-
const { zoom } = view.value
|
|
380
|
-
current.moved = true
|
|
381
|
-
current.x = Math.round(current.originX + dx / zoom)
|
|
382
|
-
current.y = Math.round(current.originY + dy / zoom)
|
|
383
|
-
draggingFrame.value = frame.name
|
|
384
|
-
previewMove(ref, current.x, current.y)
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
const handlePointerUp = () => {
|
|
388
|
-
const current = gesture.current
|
|
389
|
-
gesture.current = undefined
|
|
390
|
-
draggingFrame.value = undefined
|
|
391
|
-
if (current?.moved) void moveFrameTo(ref, current.x, current.y)
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
const handlePointerCancel = () => {
|
|
395
|
-
const current = gesture.current
|
|
396
|
-
gesture.current = undefined
|
|
397
|
-
draggingFrame.value = undefined
|
|
398
|
-
if (current?.moved) previewMove(ref, current.originX, current.originY)
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/*
|
|
402
|
-
* The keyboard path for a drag, since a pointer gesture alone leaves a
|
|
403
|
-
* frame unmovable without a mouse.
|
|
404
|
-
*/
|
|
405
|
-
const handleKeyDown = (event: KeyboardEvent) => {
|
|
406
|
-
if (event.target !== event.currentTarget) return
|
|
407
|
-
if (event.key === 'Enter') {
|
|
408
|
-
event.preventDefault()
|
|
409
|
-
select()
|
|
410
|
-
return
|
|
411
|
-
}
|
|
412
|
-
const step = event.shiftKey ? NUDGE_LARGE : NUDGE
|
|
413
|
-
const delta = ARROWS[event.key]
|
|
414
|
-
if (!delta || !isSelected) return
|
|
415
|
-
event.preventDefault()
|
|
416
|
-
/* The keyboard path for a handle drag: right and down grow the frame. */
|
|
417
|
-
if (event.ctrlKey || event.metaKey) {
|
|
418
|
-
void resizeFrameTo(ref, {
|
|
419
|
-
x: frame.x,
|
|
420
|
-
y: frame.y,
|
|
421
|
-
width: Math.max(1, frame.width + delta.x * step),
|
|
422
|
-
height: Math.max(1, frame.height + delta.y * step),
|
|
423
|
-
})
|
|
424
|
-
return
|
|
425
|
-
}
|
|
426
|
-
void moveFrameTo(ref, frame.x + delta.x * step, frame.y + delta.y * step)
|
|
427
|
-
}
|
|
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
|
-
|
|
439
|
-
const frameBox = (): OverlayBox => ({
|
|
440
|
-
x: frame.x,
|
|
441
|
-
y: frame.y,
|
|
442
|
-
width: frame.width,
|
|
443
|
-
height: frame.height,
|
|
444
|
-
})
|
|
445
|
-
|
|
446
|
-
const next = value === 'dark' ? 'light' : 'dark'
|
|
447
|
-
return (
|
|
448
|
-
<figure
|
|
449
|
-
class="frame"
|
|
450
|
-
data-frame={frame.name}
|
|
451
|
-
data-selected={isSelected ? 'true' : undefined}
|
|
452
|
-
data-picked={picked ? 'true' : undefined}
|
|
453
|
-
data-dragging={draggingFrame.value === frame.name ? 'true' : undefined}
|
|
454
|
-
data-editing={editing ? 'true' : undefined}
|
|
455
|
-
tabIndex={0}
|
|
456
|
-
aria-label={`${frame.name}, ${frame.width} by ${frame.height}${editing ? `, ${editing.by} editing` : ''}`}
|
|
457
|
-
aria-current={isSelected ? 'true' : undefined}
|
|
458
|
-
onKeyDown={handleKeyDown}
|
|
459
|
-
onKeyUp={handleKeyUp}
|
|
460
|
-
style={{
|
|
461
|
-
left: `${frame.x}px`,
|
|
462
|
-
top: `${frame.y}px`,
|
|
463
|
-
width: `${frame.width}px`,
|
|
464
|
-
}}
|
|
465
|
-
>
|
|
466
|
-
{/* The frame's handle, since a press inside the frame picks an element. */}
|
|
467
|
-
<figcaption
|
|
468
|
-
class="frame-label"
|
|
469
|
-
onPointerDown={handlePointerDown}
|
|
470
|
-
onPointerMove={handlePointerMove}
|
|
471
|
-
onPointerUp={handlePointerUp}
|
|
472
|
-
onPointerCancel={handlePointerCancel}
|
|
473
|
-
>
|
|
474
|
-
<span class="frame-name" title={frame.name}>
|
|
475
|
-
{frame.name}
|
|
476
|
-
</span>
|
|
477
|
-
<span class="frame-size">
|
|
478
|
-
{frame.width} × {frame.height}
|
|
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>
|
|
484
|
-
<button
|
|
485
|
-
type="button"
|
|
486
|
-
class="frame-theme"
|
|
487
|
-
aria-label={`Show ${frame.name} in ${next} theme`}
|
|
488
|
-
title={`Show in ${next} theme`}
|
|
489
|
-
onClick={() => toggleFrameTheme(key)}
|
|
490
|
-
>
|
|
491
|
-
{value === 'dark' ? 'Dark' : 'Light'}
|
|
492
|
-
</button>
|
|
493
|
-
</figcaption>
|
|
494
|
-
<div class="frame-body">
|
|
495
|
-
<iframe
|
|
496
|
-
ref={iframe}
|
|
497
|
-
title={frame.name}
|
|
498
|
-
src={frameSrc(page, frame, version)}
|
|
499
|
-
width={frame.width}
|
|
500
|
-
height={frame.height}
|
|
501
|
-
style={{ height: `${frame.height}px` }}
|
|
502
|
-
onLoad={handleLoad}
|
|
503
|
-
/>
|
|
504
|
-
{hoveredNode && hoveredNode !== selectedNode ? (
|
|
505
|
-
<HoverOutline element={hoveredNode} />
|
|
506
|
-
) : null}
|
|
507
|
-
{isSelected && !picked ? (
|
|
508
|
-
<SelectionOverlay
|
|
509
|
-
kind="frame"
|
|
510
|
-
measure={frameBox}
|
|
511
|
-
offset={{ x: frame.x, y: frame.y }}
|
|
512
|
-
onPreview={(box) => previewResize(ref, box)}
|
|
513
|
-
onCommit={(box) => void resizeFrameTo(ref, box)}
|
|
514
|
-
onCancel={(origin) => previewResize(ref, origin)}
|
|
515
|
-
/>
|
|
516
|
-
) : null}
|
|
517
|
-
{doc && selectedNode ? (
|
|
518
|
-
<ElementSelection
|
|
519
|
-
node={selectedNode}
|
|
520
|
-
doc={doc}
|
|
521
|
-
frameRef={ref}
|
|
522
|
-
docKey={key}
|
|
523
|
-
/>
|
|
524
|
-
) : null}
|
|
525
|
-
</div>
|
|
526
|
-
</figure>
|
|
527
|
-
)
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
function viewportSize(viewport: RefObject<HTMLDivElement | null>): {
|
|
531
|
-
width: number
|
|
532
|
-
height: number
|
|
533
|
-
} {
|
|
534
|
-
const rect = viewport.current?.getBoundingClientRect()
|
|
535
|
-
return { width: rect?.width ?? 0, height: rect?.height ?? 0 }
|
|
536
|
-
}
|
|
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
|
-
|
|
557
|
-
function Toolbar({ viewportRef }: SurfaceProps): JSX.Element {
|
|
558
|
-
return (
|
|
559
|
-
<div class="toolbar" role="toolbar" aria-label="Zoom">
|
|
560
|
-
<button
|
|
561
|
-
type="button"
|
|
562
|
-
class="icon-button"
|
|
563
|
-
aria-label="Zoom out"
|
|
564
|
-
aria-keyshortcuts="-"
|
|
565
|
-
title="Zoom out (-)"
|
|
566
|
-
onClick={() => zoomCentered(viewportRef, 1 / ZOOM_STEP)}
|
|
567
|
-
>
|
|
568
|
-
−
|
|
569
|
-
</button>
|
|
570
|
-
<output class="zoom" aria-live="polite">
|
|
571
|
-
{Math.round(view.value.zoom * 100)}%
|
|
572
|
-
</output>
|
|
573
|
-
<button
|
|
574
|
-
type="button"
|
|
575
|
-
class="icon-button"
|
|
576
|
-
aria-label="Zoom in"
|
|
577
|
-
aria-keyshortcuts="+"
|
|
578
|
-
title="Zoom in (+)"
|
|
579
|
-
onClick={() => zoomCentered(viewportRef, ZOOM_STEP)}
|
|
580
|
-
>
|
|
581
|
-
+
|
|
582
|
-
</button>
|
|
583
|
-
<button
|
|
584
|
-
type="button"
|
|
585
|
-
class="text-button"
|
|
586
|
-
aria-keyshortcuts="Shift+1"
|
|
587
|
-
title="Fit (Shift+1)"
|
|
588
|
-
onClick={() => fitViewport(viewportRef)}
|
|
589
|
-
>
|
|
590
|
-
Fit
|
|
591
|
-
</button>
|
|
592
|
-
</div>
|
|
593
|
-
)
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
|
|
597
|
-
const page = currentPage.value
|
|
598
|
-
const drag = useRef<{ x: number; y: number } | undefined>(undefined)
|
|
599
|
-
|
|
600
|
-
/* Fits a page once when it opens, never on a reload of the same page. */
|
|
601
|
-
useEffect(() => {
|
|
602
|
-
fitViewport(viewportRef)
|
|
603
|
-
}, [page?.name])
|
|
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
|
-
|
|
621
|
-
const handleWheelTurn = (turn: WheelTurn) => {
|
|
622
|
-
if (turn.isZoom) {
|
|
623
|
-
const rect = viewportRef.current?.getBoundingClientRect()
|
|
624
|
-
const factor = turn.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP
|
|
625
|
-
zoomAt(
|
|
626
|
-
factor,
|
|
627
|
-
turn.clientX - (rect?.left ?? 0),
|
|
628
|
-
turn.clientY - (rect?.top ?? 0),
|
|
629
|
-
)
|
|
630
|
-
return
|
|
631
|
-
}
|
|
632
|
-
panBy(-turn.deltaX, -turn.deltaY)
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
const handleWheel = (event: WheelEvent) => {
|
|
636
|
-
event.preventDefault()
|
|
637
|
-
handleWheelTurn({
|
|
638
|
-
deltaX: event.deltaX,
|
|
639
|
-
deltaY: event.deltaY,
|
|
640
|
-
isZoom: event.ctrlKey || event.metaKey,
|
|
641
|
-
clientX: event.clientX,
|
|
642
|
-
clientY: event.clientY,
|
|
643
|
-
})
|
|
644
|
-
}
|
|
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
|
-
|
|
712
|
-
const handlePointerDown = (event: PointerEvent) => {
|
|
713
|
-
const target = event.target as HTMLElement | null
|
|
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()
|
|
717
|
-
drag.current = { x: event.clientX, y: event.clientY }
|
|
718
|
-
viewportRef.current?.setPointerCapture?.(event.pointerId)
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
const handlePointerMove = (event: PointerEvent) => {
|
|
722
|
-
const start = drag.current
|
|
723
|
-
if (!start) return
|
|
724
|
-
const dx = event.clientX - start.x
|
|
725
|
-
const dy = event.clientY - start.y
|
|
726
|
-
panBy(dx, dy)
|
|
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
|
-
}
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
const handlePointerUp = () => {
|
|
751
|
-
if (drag.current && shownTool.value === 'pan') clearSelections()
|
|
752
|
-
drag.current = undefined
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
const { x, y, zoom } = view.value
|
|
756
|
-
return (
|
|
757
|
-
<main
|
|
758
|
-
class="surface"
|
|
759
|
-
aria-label="Canvas"
|
|
760
|
-
tabIndex={0}
|
|
761
|
-
data-tool={shownTool.value}
|
|
762
|
-
onKeyDown={handleKey}
|
|
763
|
-
onKeyUp={handleKey}
|
|
764
|
-
>
|
|
765
|
-
<div
|
|
766
|
-
ref={viewportRef}
|
|
767
|
-
class="viewport"
|
|
768
|
-
onWheel={handleWheel}
|
|
769
|
-
onPointerDown={handlePointerDown}
|
|
770
|
-
onMouseDown={handleMouseDown}
|
|
771
|
-
onPointerMove={handlePointerMove}
|
|
772
|
-
onPointerUp={handlePointerUp}
|
|
773
|
-
onPointerCancel={handlePointerUp}
|
|
774
|
-
>
|
|
775
|
-
<div
|
|
776
|
-
class="plane"
|
|
777
|
-
style={{
|
|
778
|
-
transform: `translate(${x}px, ${y}px) scale(${zoom})`,
|
|
779
|
-
/* Lets an outline inside the plane hold its screen weight at any zoom. */
|
|
780
|
-
'--outline-scale': String(1 / zoom),
|
|
781
|
-
}}
|
|
782
|
-
>
|
|
783
|
-
{page?.frames.map((frame) => (
|
|
784
|
-
<FrameView
|
|
785
|
-
key={frame.name}
|
|
786
|
-
page={page.name}
|
|
787
|
-
frame={frame}
|
|
788
|
-
onWheelTurn={handleWheelTurn}
|
|
789
|
-
onKey={handleKey}
|
|
790
|
-
/>
|
|
791
|
-
))}
|
|
792
|
-
</div>
|
|
793
|
-
</div>
|
|
794
|
-
<ToolStrip />
|
|
795
|
-
<Toolbar viewportRef={viewportRef} />
|
|
796
|
-
{historyNotice.value ? (
|
|
797
|
-
<p class="history-notice" role="status">
|
|
798
|
-
{historyNotice.value}
|
|
799
|
-
</p>
|
|
800
|
-
) : null}
|
|
801
|
-
</main>
|
|
802
|
-
)
|
|
803
|
-
}
|