@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/draft-wiki/SKILL.md +1 -1
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- 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 -3
- package/docs/agents/index.md +1 -0
- package/docs/workflow/skill-map.md +1 -0
- package/package.json +1 -1
- 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 +2 -0
- package/src/canvas/client/inspector.tsx +121 -0
- package/src/canvas/client/layers.tsx +118 -0
- package/src/canvas/client/pages-panel.tsx +47 -13
- package/src/canvas/client/shell.css +131 -1
- package/src/canvas/client/state.ts +186 -0
- package/src/canvas/client/surface.tsx +272 -20
- package/src/canvas/content.ts +307 -16
- package/src/canvas/server.ts +182 -13
- package/src/claude/cases/misc.ts +5 -0
- package/src/commands/canvas.ts +200 -3
- package/src/commands/migrate.ts +1 -1
- package/src/migrate/rename.ts +5 -4
- 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
|
@@ -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
|
-
|
|
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
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
157
|
-
|
|
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 =
|
|
392
|
+
const factor = turn.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP
|
|
161
393
|
zoomAt(
|
|
162
394
|
factor,
|
|
163
|
-
|
|
164
|
-
|
|
395
|
+
turn.clientX - (rect?.left ?? 0),
|
|
396
|
+
turn.clientY - (rect?.top ?? 0),
|
|
165
397
|
)
|
|
166
398
|
return
|
|
167
399
|
}
|
|
168
|
-
panBy(-
|
|
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={{
|
|
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
|
|
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>
|