@erclx/canon 5.15.0 → 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.
Files changed (35) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/draft-wiki/SKILL.md +1 -1
  3. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  4. package/claude/skills/search-craft/REQUIREMENT.md +57 -0
  5. package/claude/skills/search-craft/SKILL.md +83 -0
  6. package/docs/agents/canvas.md +36 -0
  7. package/docs/agents/commands.md +3 -3
  8. package/docs/agents/index.md +1 -0
  9. package/docs/workflow/skill-map.md +1 -0
  10. package/package.json +1 -1
  11. package/scripts/lib/sandbox-git.sh +2 -2
  12. package/src/canvas/address.ts +184 -0
  13. package/src/canvas/capture.ts +172 -0
  14. package/src/canvas/client/app.tsx +2 -0
  15. package/src/canvas/client/inspector.tsx +121 -0
  16. package/src/canvas/client/layers.tsx +118 -0
  17. package/src/canvas/client/pages-panel.tsx +47 -13
  18. package/src/canvas/client/shell.css +131 -1
  19. package/src/canvas/client/state.ts +186 -0
  20. package/src/canvas/client/surface.tsx +272 -20
  21. package/src/canvas/content.ts +307 -16
  22. package/src/canvas/server.ts +182 -13
  23. package/src/claude/cases/misc.ts +5 -0
  24. package/src/commands/canvas.ts +200 -3
  25. package/src/commands/migrate.ts +1 -1
  26. package/src/migrate/rename.ts +5 -4
  27. package/src/teach/browser/compile.ts +44 -0
  28. package/src/teach/browser/dismiss.ts +76 -0
  29. package/src/teach/browser/focus-line.ts +19 -0
  30. package/src/teach/browser/glossary-filter.ts +44 -0
  31. package/src/teach/browser/head.ts +30 -0
  32. package/src/teach/browser/quiz.ts +19 -0
  33. package/src/teach/browser/sidebar.ts +233 -0
  34. package/src/teach/browser/theme.ts +18 -0
  35. package/src/teach/nav.ts +19 -374
@@ -1,9 +1,12 @@
1
1
  import { computed, signal } from '@preact/signals'
2
+ import type { ElementAddress } from '@/canvas/address'
2
3
  import type { Frame, Page } from '@/canvas/content'
3
4
 
4
5
  /**
5
6
  * Everything the panels read. Type imports only from outside `client/`, since
6
7
  * this runs in the browser and a Node or Bun module would not bundle there.
8
+ * `@/canvas/address` is the one exception the client imports for values, since
9
+ * it imports nothing itself.
7
10
  */
8
11
 
9
12
  export type Theme = 'light' | 'dark'
@@ -14,9 +17,27 @@ export interface TokenSource {
14
17
  readonly notice?: string
15
18
  }
16
19
 
20
+ export interface FrameRef {
21
+ readonly page: string
22
+ readonly frame: string
23
+ }
24
+
25
+ export interface ElementRef {
26
+ readonly index: number
27
+ readonly tag: string
28
+ /** Set by the server once the frame file changed since the pick. */
29
+ readonly stale?: boolean
30
+ }
31
+
32
+ export interface SelectionRef extends FrameRef {
33
+ readonly element?: ElementRef
34
+ }
35
+
17
36
  export interface PagesRecord {
18
37
  readonly pages: readonly Page[]
19
38
  readonly tokens: TokenSource
39
+ /** Absent when nothing is selected or the selected frame is gone. */
40
+ readonly selection?: SelectionRef | null
20
41
  }
21
42
 
22
43
  export interface ChangeEvent {
@@ -54,11 +75,51 @@ export const frameVersions = signal<ReadonlyMap<string, number>>(new Map())
54
75
 
55
76
  export const view = signal<View>({ x: 0, y: 0, zoom: 0.5 })
56
77
 
78
+ /**
79
+ * The one frame, or element in it, the operator pointed at, which Claude reads
80
+ * as "this one".
81
+ */
82
+ export const selection = signal<SelectionRef | undefined>(undefined)
83
+
84
+ /** Each loaded frame's document by frame key, replaced on every reload. */
85
+ export const frameDocuments = signal<ReadonlyMap<string, Document>>(new Map())
86
+
87
+ /** The element under the pointer, in a frame or in the layers tree. */
88
+ export const hoveredElement = signal<
89
+ { readonly key: string; readonly index: number } | undefined
90
+ >(undefined)
91
+
92
+ /** Frame keys whose layers are open in the pages panel. */
93
+ export const expandedFrames = signal<ReadonlySet<string>>(new Set())
94
+
95
+ /** Set while a frame follows the pointer, so the surface can style it. */
96
+ export const draggingFrame = signal<string | undefined>(undefined)
97
+
98
+ /** Why the last write to the canvas failed, until the next one succeeds. */
99
+ export const writeError = signal<string | undefined>(undefined)
100
+
57
101
  export const currentPage = computed<Page | undefined>(() => {
58
102
  const all = pages.value
59
103
  return all.find((page) => page.name === selectedPage.value) ?? all[0]
60
104
  })
61
105
 
106
+ /** The selected frame when it is on the page being shown. */
107
+ export const selectedFrame = computed<Frame | undefined>(() => {
108
+ const page = currentPage.value
109
+ const chosen = selection.value
110
+ if (!page || chosen?.page !== page.name) return undefined
111
+ return page.frames.find((frame) => frame.name === chosen.frame)
112
+ })
113
+
114
+ /** The selected element when its frame is on the page being shown. */
115
+ export const selectedElement = computed<
116
+ { readonly frame: Frame; readonly element: ElementRef } | undefined
117
+ >(() => {
118
+ const frame = selectedFrame.value
119
+ const element = selection.value?.element
120
+ return frame && element ? { frame, element } : undefined
121
+ })
122
+
62
123
  export function frameKey(page: string, frame: Pick<Frame, 'file'>): string {
63
124
  return `${page}/${frame.file}`
64
125
  }
@@ -77,9 +138,22 @@ export function toggleFrameTheme(key: string): void {
77
138
  frameThemes.value = next
78
139
  }
79
140
 
141
+ export function registerFrameDocument(key: string, doc: Document): void {
142
+ const next = new Map(frameDocuments.value)
143
+ next.set(key, doc)
144
+ frameDocuments.value = next
145
+ }
146
+
147
+ export function toggleLayers(key: string): void {
148
+ const next = new Set(expandedFrames.value)
149
+ if (!next.delete(key)) next.add(key)
150
+ expandedFrames.value = next
151
+ }
152
+
80
153
  export function applyRecord(record: PagesRecord): void {
81
154
  pages.value = record.pages
82
155
  tokens.value = record.tokens
156
+ selection.value = record.selection ?? undefined
83
157
  loadError.value = undefined
84
158
  isLoaded.value = true
85
159
  }
@@ -115,6 +189,112 @@ export async function applyChange(
115
189
  await loadPages(fetchImpl)
116
190
  }
117
191
 
192
+ /** What a refused element pick tells the operator, by the server's reason. */
193
+ const PICK_NOTICES: Readonly<Record<string, string | undefined>> = {
194
+ 'address-mismatch':
195
+ 'Could not select that element, since the browser and the file count the elements of this frame differently, as when the browser builds an element the file does not state.',
196
+ 'stale-address':
197
+ 'Could not select that element, since the frame changed before the pick arrived. Pick it again once the frame reloads.',
198
+ }
199
+
200
+ async function refusalReason(response: Response): Promise<string | undefined> {
201
+ try {
202
+ const body: unknown = await response.json()
203
+ return typeof body === 'object' && body !== null && 'reason' in body
204
+ ? String(body.reason)
205
+ : undefined
206
+ } catch {
207
+ return undefined
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Posts one write and reports a refusal in the panel rather than failing
213
+ * silently. A refusal also rereads the page list, so a position the server
214
+ * would not take does not stay on the surface.
215
+ */
216
+ async function write(
217
+ path: string,
218
+ body: unknown,
219
+ fetchImpl: typeof fetch,
220
+ ): Promise<void> {
221
+ try {
222
+ const response = await fetchImpl(path, {
223
+ method: 'POST',
224
+ headers: { 'content-type': 'application/json' },
225
+ body: JSON.stringify(body),
226
+ })
227
+ if (!response.ok) {
228
+ const reason = await refusalReason(response)
229
+ writeError.value =
230
+ (reason && PICK_NOTICES[reason]) ??
231
+ `Could not save (status ${response.status}). Check canon canvas serve is still running.`
232
+ await loadPages(fetchImpl)
233
+ return
234
+ }
235
+ writeError.value = undefined
236
+ } catch (error) {
237
+ writeError.value = `Could not save (${error instanceof Error ? error.message : 'unknown'}). Check canon canvas serve is still running.`
238
+ await loadPages(fetchImpl)
239
+ }
240
+ }
241
+
242
+ /** Selects a frame here at once and records it where Claude reads it. */
243
+ export async function selectFrame(
244
+ ref: FrameRef | undefined,
245
+ fetchImpl: typeof fetch = fetch,
246
+ ): Promise<void> {
247
+ selection.value = ref
248
+ await write('/api/selection', ref ?? {}, fetchImpl)
249
+ }
250
+
251
+ /**
252
+ * Selects one element of a frame, opens that frame's layers so the tree shows
253
+ * the pick, and records it where Claude reads it. The server checks the
254
+ * address against the file and refuses one it cannot match.
255
+ */
256
+ export async function selectElement(
257
+ ref: FrameRef,
258
+ key: string,
259
+ address: ElementAddress,
260
+ fetchImpl: typeof fetch = fetch,
261
+ ): Promise<void> {
262
+ selection.value = {
263
+ ...ref,
264
+ element: { index: address.index, tag: address.tag },
265
+ }
266
+ if (!expandedFrames.value.has(key)) toggleLayers(key)
267
+ await write('/api/selection', { ...ref, element: address }, fetchImpl)
268
+ }
269
+
270
+ /**
271
+ * Moves a frame on the surface without writing, which a drag calls on every
272
+ * pointer move. The next reread replaces it with what the layout holds.
273
+ */
274
+ export function previewMove(ref: FrameRef, x: number, y: number): void {
275
+ pages.value = pages.value.map((candidate) =>
276
+ candidate.name === ref.page
277
+ ? {
278
+ ...candidate,
279
+ frames: candidate.frames.map((frame) =>
280
+ frame.name === ref.frame ? { ...frame, x, y, placed: true } : frame,
281
+ ),
282
+ }
283
+ : candidate,
284
+ )
285
+ }
286
+
287
+ /** Shows the move and writes it through the server's shared layout writer. */
288
+ export async function moveFrameTo(
289
+ ref: FrameRef,
290
+ x: number,
291
+ y: number,
292
+ fetchImpl: typeof fetch = fetch,
293
+ ): Promise<void> {
294
+ previewMove(ref, x, y)
295
+ await write('/api/frames/move', { ...ref, x, y }, fetchImpl)
296
+ }
297
+
118
298
  export function clampZoom(zoom: number): number {
119
299
  return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom))
120
300
  }
@@ -183,4 +363,10 @@ export function resetState(): void {
183
363
  frameThemes.value = new Map()
184
364
  frameVersions.value = new Map()
185
365
  view.value = { x: 0, y: 0, zoom: 0.5 }
366
+ selection.value = undefined
367
+ frameDocuments.value = new Map()
368
+ hoveredElement.value = undefined
369
+ expandedFrames.value = new Set()
370
+ draggingFrame.value = undefined
371
+ writeError.value = undefined
186
372
  }
@@ -1,14 +1,25 @@
1
1
  /** @jsxImportSource preact */
2
2
  import type { JSX, RefObject } from 'preact'
3
- import { useEffect, useRef } from 'preact/hooks'
3
+ import { useEffect, useRef, useState } from 'preact/hooks'
4
+ import { addressOf, documentElements, elementAt } from '@/canvas/address'
4
5
  import type { Frame } from '@/canvas/content'
5
6
  import {
6
7
  currentPage,
8
+ draggingFrame,
7
9
  fitView,
10
+ type FrameRef,
11
+ frameDocuments,
8
12
  frameKey,
9
13
  frameTheme,
10
14
  frameVersions,
15
+ hoveredElement,
16
+ moveFrameTo,
11
17
  panBy,
18
+ previewMove,
19
+ registerFrameDocument,
20
+ selectElement,
21
+ selectFrame,
22
+ selection,
12
23
  type Theme,
13
24
  toggleFrameTheme,
14
25
  view,
@@ -18,10 +29,76 @@ import {
18
29
  /** One wheel notch or one button press. */
19
30
  const ZOOM_STEP = 1.2
20
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
+
21
56
  export interface SurfaceProps {
22
57
  readonly viewportRef: RefObject<HTMLDivElement | null>
23
58
  }
24
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
+
25
102
  export function frameSrc(page: string, frame: Frame, version: number): string {
26
103
  const path = `/frames/${encodeURIComponent(page)}/${encodeURIComponent(frame.file)}`
27
104
  return version === 0 ? path : `${path}?v=${version}`
@@ -41,31 +118,179 @@ function applyFrameTheme(iframe: HTMLIFrameElement | null, value: Theme): void {
41
118
  function FrameView({
42
119
  page,
43
120
  frame,
121
+ onWheelTurn,
44
122
  }: {
45
123
  readonly page: string
46
124
  readonly frame: Frame
125
+ readonly onWheelTurn: (turn: WheelTurn) => void
47
126
  }): JSX.Element {
48
127
  const key = frameKey(page, frame)
49
128
  const version = frameVersions.value.get(key) ?? 0
50
129
  const value = frameTheme(key)
51
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)
52
138
 
53
139
  useEffect(() => {
54
140
  applyFrameTheme(iframe.current, value)
55
141
  }, [value])
56
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
+
57
269
  const next = value === 'dark' ? 'light' : 'dark'
58
270
  return (
59
271
  <figure
60
272
  class="frame"
61
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}
62
280
  style={{
63
281
  left: `${frame.x}px`,
64
282
  top: `${frame.y}px`,
65
283
  width: `${frame.width}px`,
66
284
  }}
67
285
  >
68
- <figcaption class="frame-label">
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
+ >
69
294
  <span class="frame-name" title={frame.name}>
70
295
  {frame.name}
71
296
  </span>
@@ -82,15 +307,23 @@ function FrameView({
82
307
  {value === 'dark' ? 'Dark' : 'Light'}
83
308
  </button>
84
309
  </figcaption>
85
- <iframe
86
- ref={iframe}
87
- title={frame.name}
88
- src={frameSrc(page, frame, version)}
89
- width={frame.width}
90
- height={frame.height}
91
- style={{ height: `${frame.height}px` }}
92
- onLoad={() => applyFrameTheme(iframe.current, frameTheme(key))}
93
- />
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>
94
327
  </figure>
95
328
  )
96
329
  }
@@ -153,19 +386,29 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
153
386
  fitView(width, height)
154
387
  }, [page?.name])
155
388
 
156
- const handleWheel = (event: WheelEvent) => {
157
- event.preventDefault()
158
- if (event.ctrlKey || event.metaKey) {
389
+ const handleWheelTurn = (turn: WheelTurn) => {
390
+ if (turn.isZoom) {
159
391
  const rect = viewportRef.current?.getBoundingClientRect()
160
- const factor = event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP
392
+ const factor = turn.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP
161
393
  zoomAt(
162
394
  factor,
163
- event.clientX - (rect?.left ?? 0),
164
- event.clientY - (rect?.top ?? 0),
395
+ turn.clientX - (rect?.left ?? 0),
396
+ turn.clientY - (rect?.top ?? 0),
165
397
  )
166
398
  return
167
399
  }
168
- panBy(-event.deltaX, -event.deltaY)
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
+ })
169
412
  }
170
413
 
171
414
  const handlePointerDown = (event: PointerEvent) => {
@@ -200,10 +443,19 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
200
443
  >
201
444
  <div
202
445
  class="plane"
203
- style={{ transform: `translate(${x}px, ${y}px) scale(${zoom})` }}
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
+ }}
204
451
  >
205
452
  {page?.frames.map((frame) => (
206
- <FrameView key={frame.name} page={page.name} frame={frame} />
453
+ <FrameView
454
+ key={frame.name}
455
+ page={page.name}
456
+ frame={frame}
457
+ onWheelTurn={handleWheelTurn}
458
+ />
207
459
  ))}
208
460
  </div>
209
461
  </div>