@erclx/canon 5.14.1 → 5.16.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 +3 -2
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/draft-screencast/REQUIREMENT.md +2 -1
- package/claude/skills/draft-screencast/SKILL.md +14 -9
- package/claude/skills/draft-wiki/SKILL.md +1 -1
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/read-frames/SKILL.md +3 -2
- package/claude/skills/record-screencast/REQUIREMENT.md +3 -0
- package/claude/skills/record-screencast/SKILL.md +28 -5
- package/claude/skills/search-craft/REQUIREMENT.md +57 -0
- package/claude/skills/search-craft/SKILL.md +83 -0
- package/docs/agents/canvas.md +36 -0
- package/docs/agents/commands.md +3 -0
- package/docs/agents/demo.md +34 -7
- package/docs/agents/index.md +1 -0
- package/docs/agents/migrate.md +1 -1
- package/docs/agents/sandbox.md +25 -0
- package/docs/target/migrations.md +1 -1
- package/docs/workflow/ai-workflow.md +2 -3
- package/docs/workflow/skill-map.md +2 -1
- package/governance/renames.toml +7 -5
- package/governance/rules/canon/630-requirements.md +1 -1
- package/package.json +5 -2
- package/scripts/lib/sandbox-git.sh +2 -2
- package/src/canvas/address.ts +184 -0
- package/src/canvas/capture.ts +172 -0
- package/src/canvas/client/app.tsx +87 -0
- package/src/canvas/client/inspector.tsx +121 -0
- package/src/canvas/client/layers.tsx +118 -0
- package/src/canvas/client/main.tsx +62 -0
- package/src/canvas/client/pages-panel.tsx +157 -0
- package/src/canvas/client/shell.css +449 -0
- package/src/canvas/client/state.ts +372 -0
- package/src/canvas/client/surface.tsx +465 -0
- package/src/canvas/content.ts +617 -0
- package/src/canvas/index.html +14 -0
- package/src/canvas/server.ts +471 -0
- package/src/canvas/tokens.ts +71 -0
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/seeds.ts +0 -1
- package/src/cli.ts +2 -0
- package/src/commands/canvas.ts +522 -0
- package/src/commands/claude.ts +9 -14
- package/src/commands/demo.ts +6 -6
- package/src/commands/migrate.ts +1 -1
- package/src/commands/sandbox.ts +108 -0
- package/src/context/classify/prompts.ts +1 -1
- package/src/context/folders.ts +0 -1
- package/src/demo/compile.ts +8 -4
- package/src/demo/container.ts +1 -1
- package/src/gov/renames.ts +3 -2
- package/src/help.ts +1 -0
- package/src/migrate/rename.ts +5 -4
- package/src/record-root.ts +1 -0
- package/src/sandbox/equivalence.ts +847 -0
- package/src/serve/static.ts +25 -11
- package/src/surface-root.ts +0 -1
- package/src/teach/browser/compile.ts +44 -0
- package/src/teach/browser/dismiss.ts +76 -0
- package/src/teach/browser/focus-line.ts +19 -0
- package/src/teach/browser/glossary-filter.ts +44 -0
- package/src/teach/browser/head.ts +30 -0
- package/src/teach/browser/quiz.ts +19 -0
- package/src/teach/browser/sidebar.ts +233 -0
- package/src/teach/browser/theme.ts +18 -0
- package/src/teach/nav.ts +19 -374
- package/standards/architecture.md +1 -1
- package/standards/context.md +1 -1
- package/standards/design.md +1 -1
- package/standards/index.md +0 -1
- package/standards/requirements.md +4 -6
- package/standards/wireframes.md +2 -2
- package/tooling/claude/reference.md +0 -1
- package/tooling/claude/seeds/canon/REQUIREMENTS.md +0 -2
- package/governance/rules/canon/645-decisions.md +0 -12
- package/standards/decisions.md +0 -105
- package/tooling/claude/seeds/canon/decisions/index.md +0 -8
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
import type { JSX, RefObject } from 'preact'
|
|
3
|
+
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
4
|
+
import { addressOf, documentElements, elementAt } from '@/canvas/address'
|
|
5
|
+
import type { Frame } from '@/canvas/content'
|
|
6
|
+
import {
|
|
7
|
+
currentPage,
|
|
8
|
+
draggingFrame,
|
|
9
|
+
fitView,
|
|
10
|
+
type FrameRef,
|
|
11
|
+
frameDocuments,
|
|
12
|
+
frameKey,
|
|
13
|
+
frameTheme,
|
|
14
|
+
frameVersions,
|
|
15
|
+
hoveredElement,
|
|
16
|
+
moveFrameTo,
|
|
17
|
+
panBy,
|
|
18
|
+
previewMove,
|
|
19
|
+
registerFrameDocument,
|
|
20
|
+
selectElement,
|
|
21
|
+
selectFrame,
|
|
22
|
+
selection,
|
|
23
|
+
type Theme,
|
|
24
|
+
toggleFrameTheme,
|
|
25
|
+
view,
|
|
26
|
+
zoomAt,
|
|
27
|
+
} from '@/canvas/client/state'
|
|
28
|
+
|
|
29
|
+
/** One wheel notch or one button press. */
|
|
30
|
+
const ZOOM_STEP = 1.2
|
|
31
|
+
|
|
32
|
+
/** Screen pixels a press travels before it reads as a drag and not a click. */
|
|
33
|
+
const DRAG_THRESHOLD = 3
|
|
34
|
+
|
|
35
|
+
/** Surface units one arrow press moves a frame, and one with Shift held. */
|
|
36
|
+
const NUDGE = 10
|
|
37
|
+
const NUDGE_LARGE = 50
|
|
38
|
+
|
|
39
|
+
const ARROWS: Readonly<Record<string, { x: number; y: number } | undefined>> = {
|
|
40
|
+
ArrowLeft: { x: -1, y: 0 },
|
|
41
|
+
ArrowRight: { x: 1, y: 0 },
|
|
42
|
+
ArrowUp: { x: 0, y: -1 },
|
|
43
|
+
ArrowDown: { x: 0, y: 1 },
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Gesture {
|
|
47
|
+
readonly startX: number
|
|
48
|
+
readonly startY: number
|
|
49
|
+
readonly originX: number
|
|
50
|
+
readonly originY: number
|
|
51
|
+
x: number
|
|
52
|
+
y: number
|
|
53
|
+
moved: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SurfaceProps {
|
|
57
|
+
readonly viewportRef: RefObject<HTMLDivElement | null>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A wheel turn in shell coordinates, wherever the pointer sat. */
|
|
61
|
+
interface WheelTurn {
|
|
62
|
+
readonly deltaX: number
|
|
63
|
+
readonly deltaY: number
|
|
64
|
+
readonly isZoom: boolean
|
|
65
|
+
readonly clientX: number
|
|
66
|
+
readonly clientY: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The frame's own document is a separate window, so an element in it fails an
|
|
71
|
+
* `instanceof Element` against the shell's window. The node type is the test
|
|
72
|
+
* that holds across both.
|
|
73
|
+
*/
|
|
74
|
+
function asElement(target: EventTarget | null): Element | undefined {
|
|
75
|
+
const node = target as Node | null
|
|
76
|
+
return node?.nodeType === 1 ? (node as Element) : undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ElementOutline({
|
|
80
|
+
element,
|
|
81
|
+
kind,
|
|
82
|
+
}: {
|
|
83
|
+
readonly element: Element
|
|
84
|
+
readonly kind: 'hover' | 'selected'
|
|
85
|
+
}): JSX.Element {
|
|
86
|
+
const rect = element.getBoundingClientRect()
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
class="element-outline"
|
|
90
|
+
data-outline={kind}
|
|
91
|
+
aria-hidden="true"
|
|
92
|
+
style={{
|
|
93
|
+
left: `${rect.left}px`,
|
|
94
|
+
top: `${rect.top}px`,
|
|
95
|
+
width: `${rect.width}px`,
|
|
96
|
+
height: `${rect.height}px`,
|
|
97
|
+
}}
|
|
98
|
+
/>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function frameSrc(page: string, frame: Frame, version: number): string {
|
|
103
|
+
const path = `/frames/${encodeURIComponent(page)}/${encodeURIComponent(frame.file)}`
|
|
104
|
+
return version === 0 ? path : `${path}?v=${version}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Sets the frame document's theme the way the token stylesheet reads it. The
|
|
109
|
+
* frame shares the shell's origin, so its document is reachable once loaded.
|
|
110
|
+
*/
|
|
111
|
+
function applyFrameTheme(iframe: HTMLIFrameElement | null, value: Theme): void {
|
|
112
|
+
const root = iframe?.contentDocument?.documentElement
|
|
113
|
+
if (!root) return
|
|
114
|
+
root.dataset.theme = value
|
|
115
|
+
root.style.colorScheme = value
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function FrameView({
|
|
119
|
+
page,
|
|
120
|
+
frame,
|
|
121
|
+
onWheelTurn,
|
|
122
|
+
}: {
|
|
123
|
+
readonly page: string
|
|
124
|
+
readonly frame: Frame
|
|
125
|
+
readonly onWheelTurn: (turn: WheelTurn) => void
|
|
126
|
+
}): JSX.Element {
|
|
127
|
+
const key = frameKey(page, frame)
|
|
128
|
+
const version = frameVersions.value.get(key) ?? 0
|
|
129
|
+
const value = frameTheme(key)
|
|
130
|
+
const iframe = useRef<HTMLIFrameElement>(null)
|
|
131
|
+
const gesture = useRef<Gesture | undefined>(undefined)
|
|
132
|
+
const ref: FrameRef = { page, frame: frame.name }
|
|
133
|
+
const isSelected =
|
|
134
|
+
selection.value?.page === page && selection.value.frame === frame.name
|
|
135
|
+
/* Bumped when the frame document scrolls, so the outlines follow it. */
|
|
136
|
+
const [, setScrolled] = useState(0)
|
|
137
|
+
const doc = frameDocuments.value.get(key)
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
applyFrameTheme(iframe.current, value)
|
|
141
|
+
}, [value])
|
|
142
|
+
|
|
143
|
+
/*
|
|
144
|
+
* The frame shares the shell's origin, so its document takes listeners
|
|
145
|
+
* directly. A click selects the element under it and goes no further, so a
|
|
146
|
+
* link or a submit button in a draft never navigates the frame away.
|
|
147
|
+
*/
|
|
148
|
+
const handleLoad = () => {
|
|
149
|
+
const element = iframe.current
|
|
150
|
+
const loaded = element?.contentDocument
|
|
151
|
+
applyFrameTheme(element, frameTheme(key))
|
|
152
|
+
if (!element || !loaded) return
|
|
153
|
+
registerFrameDocument(key, loaded)
|
|
154
|
+
|
|
155
|
+
loaded.addEventListener('click', (event) => {
|
|
156
|
+
event.preventDefault()
|
|
157
|
+
event.stopPropagation()
|
|
158
|
+
const target = asElement(event.target)
|
|
159
|
+
const address = target && addressOf(loaded, target)
|
|
160
|
+
if (address) void selectElement(ref, key, address)
|
|
161
|
+
})
|
|
162
|
+
loaded.addEventListener('submit', (event) => event.preventDefault())
|
|
163
|
+
loaded.addEventListener('mouseover', (event) => {
|
|
164
|
+
const target = asElement(event.target)
|
|
165
|
+
const index = target ? documentElements(loaded).indexOf(target) : -1
|
|
166
|
+
hoveredElement.value = index === -1 ? undefined : { key, index }
|
|
167
|
+
})
|
|
168
|
+
loaded.addEventListener('mouseleave', () => {
|
|
169
|
+
if (hoveredElement.value?.key === key) hoveredElement.value = undefined
|
|
170
|
+
})
|
|
171
|
+
loaded.addEventListener('scroll', () => setScrolled((n) => n + 1))
|
|
172
|
+
loaded.addEventListener(
|
|
173
|
+
'wheel',
|
|
174
|
+
(event) => {
|
|
175
|
+
event.preventDefault()
|
|
176
|
+
const rect = element.getBoundingClientRect()
|
|
177
|
+
const { zoom } = view.value
|
|
178
|
+
onWheelTurn({
|
|
179
|
+
deltaX: event.deltaX,
|
|
180
|
+
deltaY: event.deltaY,
|
|
181
|
+
isZoom: event.ctrlKey || event.metaKey,
|
|
182
|
+
clientX: rect.left + event.clientX * zoom,
|
|
183
|
+
clientY: rect.top + event.clientY * zoom,
|
|
184
|
+
})
|
|
185
|
+
},
|
|
186
|
+
{ passive: false },
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const picked =
|
|
191
|
+
isSelected && doc && selection.value?.element
|
|
192
|
+
? selection.value.element
|
|
193
|
+
: undefined
|
|
194
|
+
const selectedNode =
|
|
195
|
+
doc && picked && !picked.stale ? elementAt(doc, picked) : undefined
|
|
196
|
+
const hovered = hoveredElement.value
|
|
197
|
+
const hoveredNode =
|
|
198
|
+
doc && hovered?.key === key
|
|
199
|
+
? documentElements(doc)[hovered.index]
|
|
200
|
+
: undefined
|
|
201
|
+
|
|
202
|
+
/* The label means the frame itself, so it also drops a picked element. */
|
|
203
|
+
const select = () => {
|
|
204
|
+
if (!isSelected || selection.value?.element) void selectFrame(ref)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
208
|
+
if (event.button !== 0) return
|
|
209
|
+
if ((event.target as HTMLElement | null)?.closest('.frame-theme')) return
|
|
210
|
+
gesture.current = {
|
|
211
|
+
startX: event.clientX,
|
|
212
|
+
startY: event.clientY,
|
|
213
|
+
originX: frame.x,
|
|
214
|
+
originY: frame.y,
|
|
215
|
+
x: frame.x,
|
|
216
|
+
y: frame.y,
|
|
217
|
+
moved: false,
|
|
218
|
+
}
|
|
219
|
+
;(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId)
|
|
220
|
+
select()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const handlePointerMove = (event: PointerEvent) => {
|
|
224
|
+
const current = gesture.current
|
|
225
|
+
if (!current) return
|
|
226
|
+
const dx = event.clientX - current.startX
|
|
227
|
+
const dy = event.clientY - current.startY
|
|
228
|
+
if (!current.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return
|
|
229
|
+
const { zoom } = view.value
|
|
230
|
+
current.moved = true
|
|
231
|
+
current.x = Math.round(current.originX + dx / zoom)
|
|
232
|
+
current.y = Math.round(current.originY + dy / zoom)
|
|
233
|
+
draggingFrame.value = frame.name
|
|
234
|
+
previewMove(ref, current.x, current.y)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const handlePointerUp = () => {
|
|
238
|
+
const current = gesture.current
|
|
239
|
+
gesture.current = undefined
|
|
240
|
+
draggingFrame.value = undefined
|
|
241
|
+
if (current?.moved) void moveFrameTo(ref, current.x, current.y)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const handlePointerCancel = () => {
|
|
245
|
+
const current = gesture.current
|
|
246
|
+
gesture.current = undefined
|
|
247
|
+
draggingFrame.value = undefined
|
|
248
|
+
if (current?.moved) previewMove(ref, current.originX, current.originY)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/*
|
|
252
|
+
* The keyboard path for a drag, since a pointer gesture alone leaves a
|
|
253
|
+
* frame unmovable without a mouse.
|
|
254
|
+
*/
|
|
255
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
256
|
+
if (event.target !== event.currentTarget) return
|
|
257
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
258
|
+
event.preventDefault()
|
|
259
|
+
select()
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
const step = event.shiftKey ? NUDGE_LARGE : NUDGE
|
|
263
|
+
const delta = ARROWS[event.key]
|
|
264
|
+
if (!delta || !isSelected) return
|
|
265
|
+
event.preventDefault()
|
|
266
|
+
void moveFrameTo(ref, frame.x + delta.x * step, frame.y + delta.y * step)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const next = value === 'dark' ? 'light' : 'dark'
|
|
270
|
+
return (
|
|
271
|
+
<figure
|
|
272
|
+
class="frame"
|
|
273
|
+
data-frame={frame.name}
|
|
274
|
+
data-selected={isSelected ? 'true' : undefined}
|
|
275
|
+
data-dragging={draggingFrame.value === frame.name ? 'true' : undefined}
|
|
276
|
+
tabIndex={0}
|
|
277
|
+
aria-label={`${frame.name}, ${frame.width} by ${frame.height}`}
|
|
278
|
+
aria-current={isSelected ? 'true' : undefined}
|
|
279
|
+
onKeyDown={handleKeyDown}
|
|
280
|
+
style={{
|
|
281
|
+
left: `${frame.x}px`,
|
|
282
|
+
top: `${frame.y}px`,
|
|
283
|
+
width: `${frame.width}px`,
|
|
284
|
+
}}
|
|
285
|
+
>
|
|
286
|
+
{/* The frame's handle, since a press inside the frame picks an element. */}
|
|
287
|
+
<figcaption
|
|
288
|
+
class="frame-label"
|
|
289
|
+
onPointerDown={handlePointerDown}
|
|
290
|
+
onPointerMove={handlePointerMove}
|
|
291
|
+
onPointerUp={handlePointerUp}
|
|
292
|
+
onPointerCancel={handlePointerCancel}
|
|
293
|
+
>
|
|
294
|
+
<span class="frame-name" title={frame.name}>
|
|
295
|
+
{frame.name}
|
|
296
|
+
</span>
|
|
297
|
+
<span class="frame-size">
|
|
298
|
+
{frame.width} × {frame.height}
|
|
299
|
+
</span>
|
|
300
|
+
<button
|
|
301
|
+
type="button"
|
|
302
|
+
class="frame-theme"
|
|
303
|
+
aria-label={`Show ${frame.name} in ${next} theme`}
|
|
304
|
+
title={`Show in ${next} theme`}
|
|
305
|
+
onClick={() => toggleFrameTheme(key)}
|
|
306
|
+
>
|
|
307
|
+
{value === 'dark' ? 'Dark' : 'Light'}
|
|
308
|
+
</button>
|
|
309
|
+
</figcaption>
|
|
310
|
+
<div class="frame-body">
|
|
311
|
+
<iframe
|
|
312
|
+
ref={iframe}
|
|
313
|
+
title={frame.name}
|
|
314
|
+
src={frameSrc(page, frame, version)}
|
|
315
|
+
width={frame.width}
|
|
316
|
+
height={frame.height}
|
|
317
|
+
style={{ height: `${frame.height}px` }}
|
|
318
|
+
onLoad={handleLoad}
|
|
319
|
+
/>
|
|
320
|
+
{hoveredNode && hoveredNode !== selectedNode ? (
|
|
321
|
+
<ElementOutline element={hoveredNode} kind="hover" />
|
|
322
|
+
) : null}
|
|
323
|
+
{selectedNode ? (
|
|
324
|
+
<ElementOutline element={selectedNode} kind="selected" />
|
|
325
|
+
) : null}
|
|
326
|
+
</div>
|
|
327
|
+
</figure>
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function viewportSize(viewport: RefObject<HTMLDivElement | null>): {
|
|
332
|
+
width: number
|
|
333
|
+
height: number
|
|
334
|
+
} {
|
|
335
|
+
const rect = viewport.current?.getBoundingClientRect()
|
|
336
|
+
return { width: rect?.width ?? 0, height: rect?.height ?? 0 }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function Toolbar({ viewportRef }: SurfaceProps): JSX.Element {
|
|
340
|
+
const zoomCentered = (factor: number) => {
|
|
341
|
+
const { width, height } = viewportSize(viewportRef)
|
|
342
|
+
zoomAt(factor, width / 2, height / 2)
|
|
343
|
+
}
|
|
344
|
+
return (
|
|
345
|
+
<div class="toolbar" role="toolbar" aria-label="Zoom">
|
|
346
|
+
<button
|
|
347
|
+
type="button"
|
|
348
|
+
class="icon-button"
|
|
349
|
+
aria-label="Zoom out"
|
|
350
|
+
onClick={() => zoomCentered(1 / ZOOM_STEP)}
|
|
351
|
+
>
|
|
352
|
+
−
|
|
353
|
+
</button>
|
|
354
|
+
<output class="zoom" aria-live="polite">
|
|
355
|
+
{Math.round(view.value.zoom * 100)}%
|
|
356
|
+
</output>
|
|
357
|
+
<button
|
|
358
|
+
type="button"
|
|
359
|
+
class="icon-button"
|
|
360
|
+
aria-label="Zoom in"
|
|
361
|
+
onClick={() => zoomCentered(ZOOM_STEP)}
|
|
362
|
+
>
|
|
363
|
+
+
|
|
364
|
+
</button>
|
|
365
|
+
<button
|
|
366
|
+
type="button"
|
|
367
|
+
class="text-button"
|
|
368
|
+
onClick={() => {
|
|
369
|
+
const { width, height } = viewportSize(viewportRef)
|
|
370
|
+
fitView(width, height)
|
|
371
|
+
}}
|
|
372
|
+
>
|
|
373
|
+
Fit
|
|
374
|
+
</button>
|
|
375
|
+
</div>
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
|
|
380
|
+
const page = currentPage.value
|
|
381
|
+
const drag = useRef<{ x: number; y: number } | undefined>(undefined)
|
|
382
|
+
|
|
383
|
+
/* Fits a page once when it opens, never on a reload of the same page. */
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
const { width, height } = viewportSize(viewportRef)
|
|
386
|
+
fitView(width, height)
|
|
387
|
+
}, [page?.name])
|
|
388
|
+
|
|
389
|
+
const handleWheelTurn = (turn: WheelTurn) => {
|
|
390
|
+
if (turn.isZoom) {
|
|
391
|
+
const rect = viewportRef.current?.getBoundingClientRect()
|
|
392
|
+
const factor = turn.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP
|
|
393
|
+
zoomAt(
|
|
394
|
+
factor,
|
|
395
|
+
turn.clientX - (rect?.left ?? 0),
|
|
396
|
+
turn.clientY - (rect?.top ?? 0),
|
|
397
|
+
)
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
panBy(-turn.deltaX, -turn.deltaY)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const handleWheel = (event: WheelEvent) => {
|
|
404
|
+
event.preventDefault()
|
|
405
|
+
handleWheelTurn({
|
|
406
|
+
deltaX: event.deltaX,
|
|
407
|
+
deltaY: event.deltaY,
|
|
408
|
+
isZoom: event.ctrlKey || event.metaKey,
|
|
409
|
+
clientX: event.clientX,
|
|
410
|
+
clientY: event.clientY,
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
415
|
+
const target = event.target as HTMLElement | null
|
|
416
|
+
if (target?.closest('.frame, .toolbar')) return
|
|
417
|
+
drag.current = { x: event.clientX, y: event.clientY }
|
|
418
|
+
viewportRef.current?.setPointerCapture?.(event.pointerId)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const handlePointerMove = (event: PointerEvent) => {
|
|
422
|
+
const start = drag.current
|
|
423
|
+
if (!start) return
|
|
424
|
+
panBy(event.clientX - start.x, event.clientY - start.y)
|
|
425
|
+
drag.current = { x: event.clientX, y: event.clientY }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const handlePointerUp = () => {
|
|
429
|
+
drag.current = undefined
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const { x, y, zoom } = view.value
|
|
433
|
+
return (
|
|
434
|
+
<main class="surface" aria-label="Canvas">
|
|
435
|
+
<div
|
|
436
|
+
ref={viewportRef}
|
|
437
|
+
class="viewport"
|
|
438
|
+
onWheel={handleWheel}
|
|
439
|
+
onPointerDown={handlePointerDown}
|
|
440
|
+
onPointerMove={handlePointerMove}
|
|
441
|
+
onPointerUp={handlePointerUp}
|
|
442
|
+
onPointerCancel={handlePointerUp}
|
|
443
|
+
>
|
|
444
|
+
<div
|
|
445
|
+
class="plane"
|
|
446
|
+
style={{
|
|
447
|
+
transform: `translate(${x}px, ${y}px) scale(${zoom})`,
|
|
448
|
+
/* Lets an outline inside the plane hold its screen weight at any zoom. */
|
|
449
|
+
'--outline-scale': String(1 / zoom),
|
|
450
|
+
}}
|
|
451
|
+
>
|
|
452
|
+
{page?.frames.map((frame) => (
|
|
453
|
+
<FrameView
|
|
454
|
+
key={frame.name}
|
|
455
|
+
page={page.name}
|
|
456
|
+
frame={frame}
|
|
457
|
+
onWheelTurn={handleWheelTurn}
|
|
458
|
+
/>
|
|
459
|
+
))}
|
|
460
|
+
</div>
|
|
461
|
+
</div>
|
|
462
|
+
<Toolbar viewportRef={viewportRef} />
|
|
463
|
+
</main>
|
|
464
|
+
)
|
|
465
|
+
}
|