@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.
Files changed (127) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +10 -9
  3. package/claude/.claude-plugin/plugin.json +1 -1
  4. package/claude/skills/canon-cli/SKILL.md +5 -5
  5. package/claude/skills/create-skill/SKILL.md +1 -1
  6. package/claude/skills/design-identity/REQUIREMENT.md +2 -2
  7. package/claude/skills/design-identity/SKILL.md +6 -6
  8. package/claude/skills/design-record/REQUIREMENT.md +2 -2
  9. package/claude/skills/design-record/SKILL.md +4 -4
  10. package/claude/skills/design-record/references/folder.md +1 -1
  11. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  12. package/claude/skills/document-health/SKILL.md +6 -6
  13. package/claude/skills/document-repair/SKILL.md +5 -5
  14. package/claude/skills/draw-figure/REQUIREMENT.md +1 -1
  15. package/claude/skills/draw-figure/SKILL.md +1 -1
  16. package/claude/skills/pick-by-eye/REQUIREMENT.md +4 -4
  17. package/claude/skills/pick-by-eye/SKILL.md +34 -41
  18. package/claude/skills/pick-by-eye/references/board.md +42 -0
  19. package/claude/skills/pick-by-eye/references/live-arms.md +3 -3
  20. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  21. package/claude/skills/target-check/SKILL.md +6 -8
  22. package/claude/skills/target-check/references/domains.md +1 -4
  23. package/claude/skills/target-setup/SKILL.md +8 -8
  24. package/claude/skills/target-update/SKILL.md +5 -5
  25. package/claude/skills/teach-craft/REQUIREMENT.md +1 -1
  26. package/claude/skills/teach-craft/SKILL.md +32 -1
  27. package/claude/skills/teach-craft/references/adopted.md +113 -0
  28. package/claude/skills/teach-craft/references/lesson-craft.md +10 -2
  29. package/claude/skills/teach-craft/references/pedagogy.md +29 -2
  30. package/docs/agents/install-and-sync.md +2 -10
  31. package/docs/agents/output-shape.md +11 -10
  32. package/docs/target/migrations.md +8 -3
  33. package/docs/target/projects.md +1 -1
  34. package/docs/target/sync.md +1 -1
  35. package/docs/workflow/skill-map.md +0 -1
  36. package/docs/workflow/visual-design-workflow.md +1 -4
  37. package/governance/detect.toml +1 -6
  38. package/governance/renames.toml +0 -1
  39. package/package.json +2 -5
  40. package/scripts/core/install-check.sh +1 -1
  41. package/scripts/lib/ui.sh +12 -65
  42. package/src/audits/run.ts +1 -1
  43. package/src/claude/cases/misc.ts +0 -5
  44. package/src/claude/routing.ts +1 -1
  45. package/src/claude/skills-audit.ts +1 -0
  46. package/src/cli/help.ts +0 -3
  47. package/src/cli/run.ts +5 -6
  48. package/src/cli/ui.ts +87 -28
  49. package/src/cli.ts +6 -2
  50. package/src/commands/claude.ts +2 -3
  51. package/src/commands/design.ts +20 -324
  52. package/src/commands/gate.ts +5 -3
  53. package/src/commands/gov.ts +11 -21
  54. package/src/commands/labels.ts +1 -1
  55. package/src/commands/pr.ts +1 -1
  56. package/src/commands/sync.ts +5 -6
  57. package/src/commands/tooling.ts +5 -8
  58. package/src/commands/transcripts.ts +6 -11
  59. package/src/design/regen.ts +5 -32
  60. package/src/design/render.ts +1 -1
  61. package/src/design/tokens.ts +3 -2
  62. package/src/gate/sequencer.ts +1 -1
  63. package/src/gate/stages.ts +4 -20
  64. package/src/gov/audit.ts +1 -6
  65. package/src/hooks/post-merge.ts +2 -5
  66. package/src/markdown/scope.ts +2 -4
  67. package/src/process/harness.ts +3 -4
  68. package/src/sandbox/equivalence.ts +0 -1
  69. package/src/sandbox/provision.ts +8 -13
  70. package/src/sandbox/scenario.ts +6 -12
  71. package/src/shipped/references.ts +1 -2
  72. package/src/sync/check.ts +4 -29
  73. package/src/sync/engine.ts +3 -8
  74. package/src/sync/stamp.ts +6 -6
  75. package/src/sync/target.ts +1 -4
  76. package/src/sync/workflow.ts +3 -5
  77. package/src/tooling/verify-stack.ts +1 -1
  78. package/tooling/base/configs/.husky/post-merge +6 -10
  79. package/tooling/claude/reference.md +1 -1
  80. package/tooling/claude/seeds/canon/DESIGN.md +1 -1
  81. package/claude/skills/canvas-board/REQUIREMENT.md +0 -35
  82. package/claude/skills/canvas-board/SKILL.md +0 -143
  83. package/claude/skills/canvas-board/references/canvas.md +0 -170
  84. package/governance/rules/ui/canvas.md +0 -13
  85. package/scripts/core/check-color-source.sh +0 -51
  86. package/src/canvas/address.ts +0 -227
  87. package/src/canvas/arrange.ts +0 -66
  88. package/src/canvas/capture.ts +0 -391
  89. package/src/canvas/client/app.tsx +0 -188
  90. package/src/canvas/client/inspector/align-grid.tsx +0 -162
  91. package/src/canvas/client/inspector/color-field.tsx +0 -398
  92. package/src/canvas/client/inspector/color-inputs.tsx +0 -139
  93. package/src/canvas/client/inspector/color-picker.tsx +0 -668
  94. package/src/canvas/client/inspector/color-space.ts +0 -240
  95. package/src/canvas/client/inspector/field.tsx +0 -263
  96. package/src/canvas/client/inspector/fields.css +0 -763
  97. package/src/canvas/client/inspector/section.tsx +0 -27
  98. package/src/canvas/client/inspector/size-field.tsx +0 -69
  99. package/src/canvas/client/inspector/token-select.tsx +0 -109
  100. package/src/canvas/client/inspector/values.ts +0 -297
  101. package/src/canvas/client/inspector.tsx +0 -692
  102. package/src/canvas/client/keys.ts +0 -71
  103. package/src/canvas/client/layers.tsx +0 -118
  104. package/src/canvas/client/main.tsx +0 -71
  105. package/src/canvas/client/pages-panel.tsx +0 -212
  106. package/src/canvas/client/panel-handle.tsx +0 -90
  107. package/src/canvas/client/selection.tsx +0 -199
  108. package/src/canvas/client/shell.css +0 -762
  109. package/src/canvas/client/state.ts +0 -792
  110. package/src/canvas/client/surface.tsx +0 -803
  111. package/src/canvas/client/theme-panel.tsx +0 -55
  112. package/src/canvas/client/tools.tsx +0 -153
  113. package/src/canvas/content.ts +0 -781
  114. package/src/canvas/edit.ts +0 -575
  115. package/src/canvas/editing.ts +0 -203
  116. package/src/canvas/fit.ts +0 -238
  117. package/src/canvas/global.d.ts +0 -15
  118. package/src/canvas/history.ts +0 -116
  119. package/src/canvas/index.html +0 -20
  120. package/src/canvas/server.ts +0 -766
  121. package/src/canvas/tokens.ts +0 -221
  122. package/src/commands/canvas.ts +0 -1097
  123. package/src/design/adapter.ts +0 -59
  124. package/src/design/base.css +0 -203
  125. package/src/design/brand-favicon.ts +0 -18
  126. package/src/design/neutral.ts +0 -127
  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
- }