@kolkrabbi/kol-component 0.183.0 → 0.184.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.184.0",
|
|
4
4
|
"description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -59,7 +59,14 @@ const RADIUS_CLASSES = {
|
|
|
59
59
|
full: 'rounded-full',
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
/* The halo ring is THEME-AWARE. It was the literal `0 0 0 1px #000, 0 0 0 2px
|
|
63
|
+
* #505050` carried in from the macOS port, which put a pure-black ring on a
|
|
64
|
+
* rgb(250,250,250) page in light theme — measured by kol-fxr on the swatch
|
|
65
|
+
* chips (`editor-set-is-behind-its-source`, 2026-09-03). These are the two
|
|
66
|
+
* tokens its own SwatchControls draws, and in dark they resolve to ≈ the
|
|
67
|
+
* port's original values, so the look the variant was named for is unchanged
|
|
68
|
+
* where it was correct. */
|
|
69
|
+
const HALO_SHADOW = '0 0 0 1px var(--kol-surface-primary), 0 0 0 2px var(--kol-fg-32)'
|
|
63
70
|
|
|
64
71
|
export default function ColorSwatch({
|
|
65
72
|
hex,
|
|
@@ -235,7 +235,14 @@ export default function ContentCard({
|
|
|
235
235
|
* the trailing edge, both bottom-aligned so the buttons sit on the last line
|
|
236
236
|
* of copy rather than floating beside the title. */
|
|
237
237
|
const hasPlate = hasText || actions != null
|
|
238
|
-
|
|
238
|
+
/* FRAMED follows the EFFECTIVE fill, not the variant's. `bg` (0.183.0) let a
|
|
239
|
+
* consumer ground an unframed variant, and this line still read the table —
|
|
240
|
+
* so `article` + `bg` painted the ground but kept `framed` false, which
|
|
241
|
+
* dropped the card's own `overflow-hidden rounded-*` AND left `mediaRadius`
|
|
242
|
+
* true: a rounded still floating inside a square grey card, plate corners
|
|
243
|
+
* square (kol-client-hrafn, screenshot-confirmed on 0.183.0, fixed 0.183.1).
|
|
244
|
+
* One prop is not done until every derivation reads it. */
|
|
245
|
+
const framed = box.border != null || (bg ?? box.bg) != null
|
|
239
246
|
|
|
240
247
|
const textNode = hasPlate ? (
|
|
241
248
|
<div
|
package/src/organisms/Canvas.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react'
|
|
1
|
+
import { createContext, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Canvas — the editor's aspect-ratio stage.
|
|
@@ -13,17 +13,30 @@ import { useEffect, useRef, useState } from 'react'
|
|
|
13
13
|
* shares it, so a consumer's drag math converts screen deltas to virtual by
|
|
14
14
|
* dividing by the live scale (frameWidth / 1080).
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
* `Canvas` (the letterbox, default export),
|
|
18
|
-
*
|
|
19
|
-
* viewport.
|
|
16
|
+
* Ships from this file: `CanvasZoomContext`, `CanvasFrame` (the bare scale
|
|
17
|
+
* layer), `Canvas` (the letterbox, default export), `PanZoomViewport` (pan +
|
|
18
|
+
* zoom + rulers + guides), `PanViewport` (pan only) and `useFps`.
|
|
20
19
|
*
|
|
21
|
-
* Ported from
|
|
20
|
+
* Ported from kol-fxr's editor with three app-couplings dropped (per lobby
|
|
22
21
|
* spec): the static `ASPECTS` table → an `aspects` prop (+ DEFAULT_ASPECTS);
|
|
23
22
|
* the `#0E0E11` dark bg default → `transparent` (let the theme own the stage);
|
|
24
23
|
* and the hardwired `kol-grid-bg` grid → a `backdrop` slot the consumer fills.
|
|
24
|
+
*
|
|
25
|
+
* ZOOM CAME BACK 2026-09-03 (`editor-set-is-behind-its-source`, kol-fxr). The
|
|
26
|
+
* first port took Space-and-drag pan only and left behind wheel zoom, the zoom
|
|
27
|
+
* clamps, `zoomAt`, the rulers, the guides and — load-bearing —
|
|
28
|
+
* `CanvasZoomContext`, which every piece of editing chrome reads to stay
|
|
29
|
+
* screen-constant. fxr bumped to current, ran the adoption and reverted:
|
|
30
|
+
* without the context its `SelectionOverlay` drew 30px handles at 3×. A
|
|
31
|
+
* pan-only viewport is not this component, it is a third of it.
|
|
25
32
|
*/
|
|
26
33
|
|
|
34
|
+
/* Current viewport zoom factor — consumed by editing chrome (selection
|
|
35
|
+
* handles, path nodes) to render at a screen-constant size by dividing their
|
|
36
|
+
* virtual-px dimensions by the zoom. Defaults to 1 for canvases without a
|
|
37
|
+
* PanZoomViewport, so a consumer can read it unconditionally. */
|
|
38
|
+
export const CanvasZoomContext = createContext(1)
|
|
39
|
+
|
|
27
40
|
/* Fixed virtual canvas width — children render in this pixel space and the
|
|
28
41
|
* outer rect scales to fit the viewport via CSS transform. Height is derived
|
|
29
42
|
* from the active aspect ratio. Load-bearing: the coordinate contract. */
|
|
@@ -95,6 +108,11 @@ export function CanvasFrame({
|
|
|
95
108
|
return (
|
|
96
109
|
<div
|
|
97
110
|
ref={rectRef}
|
|
111
|
+
/* The rulers and the guides locate the frame by this attribute and read
|
|
112
|
+
* its on-screen rect, which already folds in the letterbox, the fit
|
|
113
|
+
* scale and the pan/zoom transform — so `screen = left + virtual*pxPer`
|
|
114
|
+
* holds at any zoom with no second coordinate system. */
|
|
115
|
+
data-canvas-frame
|
|
98
116
|
className="relative w-full"
|
|
99
117
|
style={{
|
|
100
118
|
aspectRatio: ratio,
|
|
@@ -159,8 +177,15 @@ export function CanvasFrame({
|
|
|
159
177
|
* @param {string} bgColor frame background fill
|
|
160
178
|
* @param {string} guideColor guide border + label color
|
|
161
179
|
* @param {'center'|'start'} align vertical placement in the letterbox
|
|
162
|
-
* @param {boolean} panEnabled wrap in a Space+drag
|
|
180
|
+
* @param {boolean} panEnabled wrap in a PanZoomViewport — Space+drag pan, wheel/pinch zoom, rulers, guides
|
|
163
181
|
* @param {ReactNode} backdrop node placed behind the frame in the pan viewport (e.g. a grid)
|
|
182
|
+
* @param {number} gutter px breathing room the letterbox leaves around the frame (default 48)
|
|
183
|
+
* @param {'contain'|'cover'} fit `cover` overflows the viewport instead of letterboxing — a display-side crop; the composition is untouched
|
|
184
|
+
* @param {boolean} rulers draw the virtual-px rulers (default true, `panEnabled` only)
|
|
185
|
+
* @param {{h: number[], v: number[]}} guides ruler-guide positions in VIRTUAL px; with `setGuides`, the guides layer renders and is draggable
|
|
186
|
+
* @param {Function} setGuides updater for `guides` — the viewport owns no guide state, the consumer does
|
|
187
|
+
* @param {boolean} guidesInteractive let guides be grabbed and created (default true)
|
|
188
|
+
* @param {Function} onSpaceTap fires when Space was pressed and released WITHOUT panning — a tap, not a drag. fxr binds its transport play/pause here; unset, a tap does nothing
|
|
164
189
|
* @param {ReactNode} children rendered in the 1080-virtual scale layer
|
|
165
190
|
*/
|
|
166
191
|
export default function Canvas({
|
|
@@ -172,18 +197,30 @@ export default function Canvas({
|
|
|
172
197
|
align = 'center',
|
|
173
198
|
panEnabled = false,
|
|
174
199
|
backdrop,
|
|
200
|
+
gutter = 48,
|
|
201
|
+
fit = 'contain',
|
|
202
|
+
rulers = true,
|
|
203
|
+
guides,
|
|
204
|
+
setGuides,
|
|
205
|
+
guidesInteractive = true,
|
|
206
|
+
onSpaceTap,
|
|
175
207
|
children,
|
|
176
208
|
}) {
|
|
177
209
|
const { ratio } = resolveAspect(aspect, customRatio, aspects)
|
|
178
210
|
|
|
211
|
+
/* fit='cover': the frame overflows the viewport instead of letterboxing —
|
|
212
|
+
* a display-side crop (the composition itself is untouched). */
|
|
179
213
|
const letterbox = (
|
|
180
214
|
<div
|
|
181
|
-
className={`flex ${align === 'start' ? 'items-start' : 'items-center'} justify-center w-full h-full`}
|
|
215
|
+
className={`flex ${align === 'start' ? 'items-start' : 'items-center'} justify-center w-full h-full ${fit === 'cover' ? 'overflow-hidden' : ''}`}
|
|
182
216
|
style={{ containerType: 'size' }}
|
|
183
217
|
>
|
|
184
218
|
<div
|
|
219
|
+
className="shrink-0"
|
|
185
220
|
style={{
|
|
186
|
-
width:
|
|
221
|
+
width: fit === 'cover'
|
|
222
|
+
? `max(100cqw, calc(100cqh * ${ratio}))`
|
|
223
|
+
: `min(calc(100cqw - ${gutter}px), calc((100cqh - ${gutter}px) * ${ratio}))`,
|
|
187
224
|
}}
|
|
188
225
|
>
|
|
189
226
|
<CanvasFrame
|
|
@@ -200,11 +237,27 @@ export default function Canvas({
|
|
|
200
237
|
)
|
|
201
238
|
|
|
202
239
|
if (!panEnabled) return letterbox
|
|
203
|
-
return
|
|
240
|
+
return (
|
|
241
|
+
<PanZoomViewport
|
|
242
|
+
backdrop={backdrop}
|
|
243
|
+
showRulers={rulers}
|
|
244
|
+
guides={guides}
|
|
245
|
+
setGuides={setGuides}
|
|
246
|
+
guidesInteractive={guidesInteractive}
|
|
247
|
+
onSpaceTap={onSpaceTap}
|
|
248
|
+
>
|
|
249
|
+
{letterbox}
|
|
250
|
+
</PanZoomViewport>
|
|
251
|
+
)
|
|
204
252
|
}
|
|
205
253
|
|
|
206
254
|
/**
|
|
207
|
-
* PanViewport — Space + drag pan wrapper.
|
|
255
|
+
* PanViewport — Space + drag pan wrapper. **Pan only, no zoom.**
|
|
256
|
+
*
|
|
257
|
+
* Kept for a consumer composing its own viewport, and because it is what
|
|
258
|
+
* shipped. An editor wants `PanZoomViewport` (below): this one publishes no
|
|
259
|
+
* `CanvasZoomContext`, so editing chrome inside it cannot stay
|
|
260
|
+
* screen-constant, which is the defect kol-fxr measured on 2026-09-03.
|
|
208
261
|
*
|
|
209
262
|
* Hold Space → cursor `grab`. Mousedown while held → drag-pan, cursor
|
|
210
263
|
* `grabbing`. The pan transform applies to the child div that holds the frame;
|
|
@@ -297,3 +350,632 @@ export function PanViewport({ backdrop, children }) {
|
|
|
297
350
|
</div>
|
|
298
351
|
)
|
|
299
352
|
}
|
|
353
|
+
|
|
354
|
+
/* ── Pan + zoom ────────────────────────────────────────────────────────────
|
|
355
|
+
* Ported verbatim from kol-fxr `src/editor/shell/Canvas.jsx` (2026-09-03,
|
|
356
|
+
* `editor-set-is-behind-its-source`). Two app couplings became seams: the
|
|
357
|
+
* Space-tap `transport.toggle()` is now `onSpaceTap`, and the hardwired
|
|
358
|
+
* `.kol-grid-bg` div is the `backdrop` node this package already took. The
|
|
359
|
+
* clamps, the anchor math, the wheel handling and the chip layout are the
|
|
360
|
+
* source's — fxr is the reference for this set. */
|
|
361
|
+
|
|
362
|
+
const ZOOM_MIN = 0.1
|
|
363
|
+
const ZOOM_MAX = 8
|
|
364
|
+
|
|
365
|
+
/* Anchor a zoom change at a screen point (sx, sy relative to the viewport
|
|
366
|
+
* top-left) so the content under the cursor stays put. Transform is
|
|
367
|
+
* `translate(x,y) scale(zoom)` with origin 0,0, so screen = p*zoom + pan;
|
|
368
|
+
* inverting for a fixed p gives the new pan below. */
|
|
369
|
+
function zoomAt(v, factor, sx, sy) {
|
|
370
|
+
const z2 = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, v.zoom * factor))
|
|
371
|
+
return {
|
|
372
|
+
zoom: z2,
|
|
373
|
+
x: sx - (sx - v.x) * (z2 / v.zoom),
|
|
374
|
+
y: sy - (sy - v.y) * (z2 / v.zoom),
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/* useFps(enabled) — live framerate, measured only while `enabled` (the RAF
|
|
379
|
+
* idles when off); returns frames per second, updated twice a second.
|
|
380
|
+
* Exported because a consumer's own stage corner renders the same chip.
|
|
381
|
+
*
|
|
382
|
+
* A plain block comment on purpose: `@param` belongs to a destructured props
|
|
383
|
+
* signature, and the props gate pairs a `/** … *\/` block with the next such
|
|
384
|
+
* export — a JSDoc'd positional hook hands its `@param` to the component
|
|
385
|
+
* below it, which is exactly the false positive it reported on this file. */
|
|
386
|
+
export function useFps(enabled) {
|
|
387
|
+
const [fps, setFps] = useState(0)
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
if (!enabled) return
|
|
390
|
+
let raf, frames = 0, last = performance.now()
|
|
391
|
+
const loop = (now) => {
|
|
392
|
+
frames++
|
|
393
|
+
if (now - last >= 500) {
|
|
394
|
+
setFps(Math.round((frames * 1000) / (now - last)))
|
|
395
|
+
frames = 0
|
|
396
|
+
last = now
|
|
397
|
+
}
|
|
398
|
+
raf = requestAnimationFrame(loop)
|
|
399
|
+
}
|
|
400
|
+
raf = requestAnimationFrame(loop)
|
|
401
|
+
return () => cancelAnimationFrame(raf)
|
|
402
|
+
}, [enabled])
|
|
403
|
+
return fps
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function isTypingTarget(el) {
|
|
407
|
+
if (!el) return false
|
|
408
|
+
const tag = el.tagName
|
|
409
|
+
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* PanZoomViewport — infinite-canvas viewport (pan + zoom), and the one that
|
|
414
|
+
* publishes `CanvasZoomContext`.
|
|
415
|
+
*
|
|
416
|
+
* Pan: hold Space + drag (cursor grab/grabbing), or two-finger trackpad
|
|
417
|
+
* scroll. Zoom: Cmd/Ctrl + wheel or trackpad pinch, anchored at the pointer;
|
|
418
|
+
* Cmd+0 resets, Cmd+= / Cmd+- step-zoom at the viewport center. A single
|
|
419
|
+
* `translate() scale()` on the transform layer carries both, so a consumer's
|
|
420
|
+
* screen→virtual math needs no zoom awareness. Pointer events on the
|
|
421
|
+
* transform layer disable while Space is held so layer mousedowns don't fire
|
|
422
|
+
* mid-pan. `f` toggles an fps chip beside the zoom readout.
|
|
423
|
+
*
|
|
424
|
+
* @param {ReactNode} backdrop placed behind the frame (grid / dark bg); the consumer owns its sizing — oversize it so panning never reveals an edge
|
|
425
|
+
* @param {boolean} showRulers draw the virtual-px rulers (default true)
|
|
426
|
+
* @param {{h: number[], v: number[]}} guides guide positions in VIRTUAL px
|
|
427
|
+
* @param {Function} setGuides updater for `guides`; without both, no guides layer renders
|
|
428
|
+
* @param {boolean} guidesInteractive allow grab + create (default true)
|
|
429
|
+
* @param {Function} onSpaceTap fires on a Space press released without panning
|
|
430
|
+
* @param {ReactNode} children the letterboxed frame
|
|
431
|
+
*/
|
|
432
|
+
export function PanZoomViewport({
|
|
433
|
+
children,
|
|
434
|
+
backdrop,
|
|
435
|
+
showRulers = true,
|
|
436
|
+
guides,
|
|
437
|
+
setGuides,
|
|
438
|
+
guidesInteractive = true,
|
|
439
|
+
onSpaceTap,
|
|
440
|
+
}) {
|
|
441
|
+
const containerRef = useRef(null)
|
|
442
|
+
const [spaceHeld, setSpaceHeld] = useState(false)
|
|
443
|
+
/* Space tap vs Space+drag: the ref records whether a pan drag consumed this
|
|
444
|
+
* Space press (set on pan mousedown), so keyup can tell them apart. */
|
|
445
|
+
const spacePannedRef = useRef(false)
|
|
446
|
+
const [dragging, setDragging] = useState(false)
|
|
447
|
+
const [view, setView] = useState({ zoom: 1, x: 0, y: 0 })
|
|
448
|
+
const dragStart = useRef(null)
|
|
449
|
+
const [showFps, setShowFps] = useState(false)
|
|
450
|
+
const fps = useFps(showFps)
|
|
451
|
+
/* Ref mirror so the keyup listener calls the latest callback without
|
|
452
|
+
* rebinding the window listeners on every consumer re-render. */
|
|
453
|
+
const spaceTapRef = useRef(onSpaceTap)
|
|
454
|
+
spaceTapRef.current = onSpaceTap
|
|
455
|
+
|
|
456
|
+
/* Space toggles pan mode; Cmd+0 / Cmd+= / Cmd+- drive zoom from the
|
|
457
|
+
* keyboard (centered on the viewport). Skipped while typing in a field. */
|
|
458
|
+
useEffect(() => {
|
|
459
|
+
const isInputTarget = (el) =>
|
|
460
|
+
el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)
|
|
461
|
+
const onKeyDown = (e) => {
|
|
462
|
+
if (isInputTarget(e.target)) return
|
|
463
|
+
if (e.code === 'Space') {
|
|
464
|
+
e.preventDefault()
|
|
465
|
+
if (!e.repeat) spacePannedRef.current = false
|
|
466
|
+
setSpaceHeld(true)
|
|
467
|
+
return
|
|
468
|
+
}
|
|
469
|
+
if (e.metaKey || e.ctrlKey) {
|
|
470
|
+
if (e.key === '0') {
|
|
471
|
+
e.preventDefault()
|
|
472
|
+
setView({ zoom: 1, x: 0, y: 0 })
|
|
473
|
+
} else if (e.key === '=' || e.key === '+') {
|
|
474
|
+
e.preventDefault()
|
|
475
|
+
const r = containerRef.current?.getBoundingClientRect()
|
|
476
|
+
setView((v) => zoomAt(v, 1.2, (r?.width ?? 0) / 2, (r?.height ?? 0) / 2))
|
|
477
|
+
} else if (e.key === '-') {
|
|
478
|
+
e.preventDefault()
|
|
479
|
+
const r = containerRef.current?.getBoundingClientRect()
|
|
480
|
+
setView((v) => zoomAt(v, 1 / 1.2, (r?.width ?? 0) / 2, (r?.height ?? 0) / 2))
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
const onKeyUp = (e) => {
|
|
485
|
+
if (e.code === 'Space') {
|
|
486
|
+
setSpaceHeld(false)
|
|
487
|
+
setDragging(false)
|
|
488
|
+
dragStart.current = null
|
|
489
|
+
/* No pan happened → this was a tap. fxr binds play/pause here; the
|
|
490
|
+
* same input guard as keydown so typing a space in a field is not a
|
|
491
|
+
* tap. Unset, a tap does nothing. */
|
|
492
|
+
if (!spacePannedRef.current && !isInputTarget(e.target)) spaceTapRef.current?.()
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
window.addEventListener('keydown', onKeyDown)
|
|
496
|
+
window.addEventListener('keyup', onKeyUp)
|
|
497
|
+
return () => {
|
|
498
|
+
window.removeEventListener('keydown', onKeyDown)
|
|
499
|
+
window.removeEventListener('keyup', onKeyUp)
|
|
500
|
+
}
|
|
501
|
+
}, [])
|
|
502
|
+
|
|
503
|
+
useEffect(() => {
|
|
504
|
+
if (!dragging) return
|
|
505
|
+
const onMove = (e) => {
|
|
506
|
+
if (!dragStart.current) return
|
|
507
|
+
const { x: sx, y: sy } = dragStart.current
|
|
508
|
+
setView((v) => ({ ...v, x: e.clientX - sx, y: e.clientY - sy }))
|
|
509
|
+
}
|
|
510
|
+
const onUp = () => {
|
|
511
|
+
setDragging(false)
|
|
512
|
+
dragStart.current = null
|
|
513
|
+
}
|
|
514
|
+
window.addEventListener('mousemove', onMove)
|
|
515
|
+
window.addEventListener('mouseup', onUp)
|
|
516
|
+
return () => {
|
|
517
|
+
window.removeEventListener('mousemove', onMove)
|
|
518
|
+
window.removeEventListener('mouseup', onUp)
|
|
519
|
+
}
|
|
520
|
+
}, [dragging])
|
|
521
|
+
|
|
522
|
+
/* Wheel: Cmd/Ctrl+wheel or trackpad pinch (ctrlKey) → zoom at pointer;
|
|
523
|
+
* plain two-finger scroll → pan. Native non-passive listener so we can
|
|
524
|
+
* preventDefault the browser's page-zoom / scroll. */
|
|
525
|
+
useEffect(() => {
|
|
526
|
+
const node = containerRef.current
|
|
527
|
+
if (!node) return
|
|
528
|
+
const onWheel = (e) => {
|
|
529
|
+
e.preventDefault()
|
|
530
|
+
const rect = node.getBoundingClientRect()
|
|
531
|
+
const sx = e.clientX - rect.left
|
|
532
|
+
const sy = e.clientY - rect.top
|
|
533
|
+
if (e.ctrlKey || e.metaKey) {
|
|
534
|
+
setView((v) => zoomAt(v, Math.exp(-e.deltaY * 0.0015), sx, sy))
|
|
535
|
+
} else {
|
|
536
|
+
setView((v) => ({ ...v, x: v.x - e.deltaX, y: v.y - e.deltaY }))
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
node.addEventListener('wheel', onWheel, { passive: false })
|
|
540
|
+
return () => node.removeEventListener('wheel', onWheel)
|
|
541
|
+
}, [])
|
|
542
|
+
|
|
543
|
+
/* A consumer's zoom TOOL announces clicks as `kol:zoom-at` (client coords +
|
|
544
|
+
* factor) rather than reaching into this state — anchored zoom at the
|
|
545
|
+
* pointer. Same idiom as `kol:guide-drag-start` below. */
|
|
546
|
+
useEffect(() => {
|
|
547
|
+
const onZoomEvt = (e) => {
|
|
548
|
+
const node = containerRef.current
|
|
549
|
+
if (!node) return
|
|
550
|
+
const { clientX, clientY, factor } = e.detail
|
|
551
|
+
const rect = node.getBoundingClientRect()
|
|
552
|
+
setView((v) => zoomAt(v, factor, clientX - rect.left, clientY - rect.top))
|
|
553
|
+
}
|
|
554
|
+
window.addEventListener('kol:zoom-at', onZoomEvt)
|
|
555
|
+
return () => window.removeEventListener('kol:zoom-at', onZoomEvt)
|
|
556
|
+
}, [])
|
|
557
|
+
|
|
558
|
+
/* `f` toggles the fps chip (measured only while shown). Guarded against
|
|
559
|
+
* typing targets so text fields don't flip it. */
|
|
560
|
+
useEffect(() => {
|
|
561
|
+
const onKey = (e) => {
|
|
562
|
+
if (e.key !== 'f' && e.key !== 'F') return
|
|
563
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return
|
|
564
|
+
if (isTypingTarget(e.target)) return
|
|
565
|
+
setShowFps((v) => !v)
|
|
566
|
+
}
|
|
567
|
+
window.addEventListener('keydown', onKey)
|
|
568
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
569
|
+
}, [])
|
|
570
|
+
|
|
571
|
+
const onMouseDown = (e) => {
|
|
572
|
+
if (!spaceHeld) return
|
|
573
|
+
e.preventDefault()
|
|
574
|
+
spacePannedRef.current = true
|
|
575
|
+
dragStart.current = { x: e.clientX - view.x, y: e.clientY - view.y }
|
|
576
|
+
setDragging(true)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/* Only override the cursor while actively panning (Space-held / dragging).
|
|
580
|
+
* At rest, leave it unset so consumers above can apply their own cursor
|
|
581
|
+
* (a tool-driven cursor) and have it visible across the backdrop AND the
|
|
582
|
+
* canvas frame, not just the frame area. */
|
|
583
|
+
const cursor = dragging ? 'grabbing' : spaceHeld ? 'grab' : undefined
|
|
584
|
+
const atRest = view.zoom === 1 && view.x === 0 && view.y === 0
|
|
585
|
+
|
|
586
|
+
return (
|
|
587
|
+
<CanvasZoomContext.Provider value={view.zoom}>
|
|
588
|
+
<div
|
|
589
|
+
ref={containerRef}
|
|
590
|
+
className="relative w-full h-full overflow-hidden select-none"
|
|
591
|
+
style={cursor ? { cursor } : undefined}
|
|
592
|
+
onMouseDown={onMouseDown}
|
|
593
|
+
>
|
|
594
|
+
{/* No transition on the transform: editing chrome (selection
|
|
595
|
+
wireframe, path nodes) sizes itself by 1/zoom from React state,
|
|
596
|
+
which updates instantly — an eased CSS transform lags behind and
|
|
597
|
+
makes the chrome visibly pop. Instant zoom keeps chrome, rulers and
|
|
598
|
+
stage in the same frame. */}
|
|
599
|
+
<div
|
|
600
|
+
className="absolute inset-0"
|
|
601
|
+
style={{
|
|
602
|
+
transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})`,
|
|
603
|
+
transformOrigin: '0 0',
|
|
604
|
+
pointerEvents: spaceHeld ? 'none' : 'auto',
|
|
605
|
+
}}
|
|
606
|
+
>
|
|
607
|
+
{backdrop}
|
|
608
|
+
<div className="relative w-full h-full">{children}</div>
|
|
609
|
+
</div>
|
|
610
|
+
|
|
611
|
+
{/* Ruler guides — viewport-level so each line spans the whole visible
|
|
612
|
+
canvas area (readable against the rulers), under the rulers, above
|
|
613
|
+
the canvas content. */}
|
|
614
|
+
{guides && setGuides && (
|
|
615
|
+
<CanvasGuides
|
|
616
|
+
containerRef={containerRef}
|
|
617
|
+
view={view}
|
|
618
|
+
guides={guides}
|
|
619
|
+
setGuides={setGuides}
|
|
620
|
+
interactive={guidesInteractive && !spaceHeld}
|
|
621
|
+
/>
|
|
622
|
+
)}
|
|
623
|
+
|
|
624
|
+
{showRulers && <CanvasRuler containerRef={containerRef} view={view} disabled={spaceHeld} />}
|
|
625
|
+
|
|
626
|
+
{/* Zoom % + fps — matching chips. Zoom (click resets to 100% /
|
|
627
|
+
centered) first, fps to its right, shown while `f` toggles it. */}
|
|
628
|
+
<div className="absolute bottom-3 right-3 z-[3] flex items-center gap-2">
|
|
629
|
+
<button
|
|
630
|
+
type="button"
|
|
631
|
+
onClick={() => setView({ zoom: 1, x: 0, y: 0 })}
|
|
632
|
+
className="px-2 py-1 rounded border border-fg-08 bg-surface-secondary kol-mono-12 text-emphasis tabular-nums"
|
|
633
|
+
style={{ opacity: atRest && !showFps ? 0.55 : 1 }}
|
|
634
|
+
title="Reset zoom (⌘0)"
|
|
635
|
+
>
|
|
636
|
+
{Math.round(view.zoom * 100)}%
|
|
637
|
+
</button>
|
|
638
|
+
{showFps && (
|
|
639
|
+
<span
|
|
640
|
+
className="px-2 py-1 rounded border border-fg-08 bg-surface-secondary kol-mono-12 text-emphasis tabular-nums"
|
|
641
|
+
title="Framerate — press F to hide"
|
|
642
|
+
>
|
|
643
|
+
{fps} fps
|
|
644
|
+
</span>
|
|
645
|
+
)}
|
|
646
|
+
</div>
|
|
647
|
+
</div>
|
|
648
|
+
</CanvasZoomContext.Provider>
|
|
649
|
+
)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/* ── Rulers + guides ─────────────────────────────────────────────────────── */
|
|
653
|
+
|
|
654
|
+
const RULER = 18 /* px thickness of each ruler bar */
|
|
655
|
+
const RULER_STEPS = [1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000, 5000]
|
|
656
|
+
|
|
657
|
+
/* Smallest 1-2-5 virtual step whose on-screen spacing clears `target` px, so
|
|
658
|
+
* labels never crowd regardless of zoom. */
|
|
659
|
+
function niceStep(pxPer, target = 80) {
|
|
660
|
+
for (const s of RULER_STEPS) if (s * pxPer >= target) return s
|
|
661
|
+
return RULER_STEPS[RULER_STEPS.length - 1]
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/* Virtual ticks visible across [0, spanScreen], given where virtual-0 sits on
|
|
665
|
+
* screen (originScreen) and the screen-px-per-virtual-px scale. */
|
|
666
|
+
function ticksFor(originScreen, pxPer, spanScreen, step) {
|
|
667
|
+
const vMin = (0 - originScreen) / pxPer
|
|
668
|
+
const vMax = (spanScreen - originScreen) / pxPer
|
|
669
|
+
const first = Math.ceil(vMin / step) * step
|
|
670
|
+
const out = []
|
|
671
|
+
for (let v = first; v <= vMax; v += step) out.push({ v: Math.round(v), s: originScreen + v * pxPer })
|
|
672
|
+
return out
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/* Frame geometry inside the viewport — locates the tagged
|
|
676
|
+
* `[data-canvas-frame]` and reads its on-screen rect (which already folds in
|
|
677
|
+
* the letterbox, fit-scale, and the pan/zoom transform) relative to the
|
|
678
|
+
* container, so `screen = left/top + virtual * pxPer` holds at any zoom with
|
|
679
|
+
* no separate math. `vh` is the frame's height in virtual px (for clamping
|
|
680
|
+
* horizontal guides). Re-measures on every `view` change and on container
|
|
681
|
+
* resize. Shared by CanvasRuler and CanvasGuides so ruler labels and guide
|
|
682
|
+
* lines can never disagree. */
|
|
683
|
+
function useFrameGeom(containerRef, view) {
|
|
684
|
+
const [geom, setGeom] = useState(null)
|
|
685
|
+
|
|
686
|
+
const measure = useCallback(() => {
|
|
687
|
+
const el = containerRef.current
|
|
688
|
+
if (!el) return
|
|
689
|
+
const frame = el.querySelector('[data-canvas-frame]')
|
|
690
|
+
const crect = el.getBoundingClientRect()
|
|
691
|
+
if (!frame || crect.width === 0) { setGeom(null); return }
|
|
692
|
+
const frect = frame.getBoundingClientRect()
|
|
693
|
+
const pxPer = frect.width / CANVAS_VIRTUAL_W
|
|
694
|
+
setGeom({
|
|
695
|
+
left: frect.left - crect.left,
|
|
696
|
+
top: frect.top - crect.top,
|
|
697
|
+
pxPer,
|
|
698
|
+
vh: pxPer > 0 ? frect.height / pxPer : 0,
|
|
699
|
+
cw: crect.width,
|
|
700
|
+
ch: crect.height,
|
|
701
|
+
})
|
|
702
|
+
}, [containerRef])
|
|
703
|
+
|
|
704
|
+
/* A zoom that ANIMATES its transform (a consumer's eased step-zoom) would
|
|
705
|
+
* have a single measure read the pre-animation rect, and the labels would
|
|
706
|
+
* lag the whole tween. Re-measure per animation frame until the frame rect
|
|
707
|
+
* stops moving (2 stable frames); an instant zoom settles immediately,
|
|
708
|
+
* costing a couple of no-op frames. */
|
|
709
|
+
useLayoutEffect(() => {
|
|
710
|
+
measure()
|
|
711
|
+
let raf
|
|
712
|
+
let prevKey
|
|
713
|
+
let stable = 0
|
|
714
|
+
const tick = () => {
|
|
715
|
+
const frame = containerRef.current?.querySelector('[data-canvas-frame]')
|
|
716
|
+
if (!frame) return
|
|
717
|
+
const r = frame.getBoundingClientRect()
|
|
718
|
+
const key = `${r.left}|${r.top}|${r.width}`
|
|
719
|
+
if (key !== prevKey) {
|
|
720
|
+
prevKey = key
|
|
721
|
+
stable = 0
|
|
722
|
+
measure()
|
|
723
|
+
} else if (++stable >= 2) {
|
|
724
|
+
return
|
|
725
|
+
}
|
|
726
|
+
raf = requestAnimationFrame(tick)
|
|
727
|
+
}
|
|
728
|
+
raf = requestAnimationFrame(tick)
|
|
729
|
+
return () => cancelAnimationFrame(raf)
|
|
730
|
+
}, [measure, view, containerRef])
|
|
731
|
+
|
|
732
|
+
useEffect(() => {
|
|
733
|
+
const el = containerRef.current
|
|
734
|
+
if (!el) return
|
|
735
|
+
const ro = new ResizeObserver(measure)
|
|
736
|
+
ro.observe(el)
|
|
737
|
+
return () => ro.disconnect()
|
|
738
|
+
}, [measure, containerRef])
|
|
739
|
+
|
|
740
|
+
return geom
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* CanvasRuler — top + left rulers in virtual-canvas px, mapped through the
|
|
745
|
+
* measured frame geometry (see useFrameGeom).
|
|
746
|
+
*
|
|
747
|
+
* Dragging off a ruler starts a new guide: the ruler only ANNOUNCES the
|
|
748
|
+
* gesture via a `kol:guide-drag-start` CustomEvent (same idiom as
|
|
749
|
+
* `kol:zoom-at`) — CanvasGuides owns the guide drag, and the positions live in
|
|
750
|
+
* the consumer's state. Canvases without a guides layer no-op. `disabled`
|
|
751
|
+
* (Space-held pan) lets the pointerdown bubble to the pan handler instead.
|
|
752
|
+
*/
|
|
753
|
+
function CanvasRuler({ containerRef, view, disabled = false }) {
|
|
754
|
+
const geom = useFrameGeom(containerRef, view)
|
|
755
|
+
|
|
756
|
+
const startGuideDrag = (axis) => (e) => {
|
|
757
|
+
if (disabled || e.button !== 0) return
|
|
758
|
+
e.preventDefault()
|
|
759
|
+
window.dispatchEvent(new CustomEvent('kol:guide-drag-start', {
|
|
760
|
+
detail: { axis, clientX: e.clientX, clientY: e.clientY },
|
|
761
|
+
}))
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (!geom || geom.pxPer <= 0) return null
|
|
765
|
+
const step = niceStep(geom.pxPer)
|
|
766
|
+
const hTicks = ticksFor(geom.left, geom.pxPer, geom.cw, step)
|
|
767
|
+
const vTicks = ticksFor(geom.top, geom.pxPer, geom.ch, step)
|
|
768
|
+
|
|
769
|
+
/* Ruler chrome rides the themed fg ramp so it flips with light/dark: an 8%
|
|
770
|
+
* bar with fg ticks/labels, all naturally contrast-correct in both themes —
|
|
771
|
+
* no hardcoded greys. */
|
|
772
|
+
const tickColor = 'var(--kol-fg-48)'
|
|
773
|
+
const textColor = 'var(--kol-fg-64)'
|
|
774
|
+
const barBg = 'var(--kol-fg-08)'
|
|
775
|
+
const borderColor = 'var(--kol-fg-16)'
|
|
776
|
+
const labelStyle = { fontFamily: 'var(--kol-font-family-mono)', fontSize: 9 }
|
|
777
|
+
|
|
778
|
+
return (
|
|
779
|
+
<>
|
|
780
|
+
<svg width="100%" height={RULER}
|
|
781
|
+
onPointerDown={startGuideDrag('h')}
|
|
782
|
+
style={{ position: 'absolute', top: 0, left: 0, zIndex: 4, cursor: 'row-resize' }}>
|
|
783
|
+
<rect x={0} y={0} width="100%" height={RULER} fill={barBg} />
|
|
784
|
+
{hTicks.map(({ v, s }) => (
|
|
785
|
+
<g key={v}>
|
|
786
|
+
<line x1={s} y1={RULER - 5} x2={s} y2={RULER} stroke={tickColor} strokeWidth={1} />
|
|
787
|
+
<text x={s + 2} y={9} fill={textColor} style={labelStyle}>{v}</text>
|
|
788
|
+
</g>
|
|
789
|
+
))}
|
|
790
|
+
<line x1={0} y1={RULER - 0.5} x2="100%" y2={RULER - 0.5} stroke={borderColor} strokeWidth={1} />
|
|
791
|
+
</svg>
|
|
792
|
+
<svg width={RULER} height="100%"
|
|
793
|
+
onPointerDown={startGuideDrag('v')}
|
|
794
|
+
style={{ position: 'absolute', top: 0, left: 0, zIndex: 4, cursor: 'col-resize' }}>
|
|
795
|
+
<rect x={0} y={0} width={RULER} height="100%" fill={barBg} />
|
|
796
|
+
{vTicks.map(({ v, s }) => (
|
|
797
|
+
<g key={v}>
|
|
798
|
+
<line x1={RULER - 5} y1={s} x2={RULER} y2={s} stroke={tickColor} strokeWidth={1} />
|
|
799
|
+
<text x={9} y={s - 2} fill={textColor} style={labelStyle}
|
|
800
|
+
transform={`rotate(-90 9 ${s - 2})`}>{v}</text>
|
|
801
|
+
</g>
|
|
802
|
+
))}
|
|
803
|
+
<line x1={RULER - 0.5} y1={0} x2={RULER - 0.5} y2="100%" stroke={borderColor} strokeWidth={1} />
|
|
804
|
+
</svg>
|
|
805
|
+
<div style={{ position: 'absolute', top: 0, left: 0, width: RULER, height: RULER, background: barBg, borderRight: `1px solid ${borderColor}`, borderBottom: `1px solid ${borderColor}`, zIndex: 4, pointerEvents: 'none' }} />
|
|
806
|
+
</>
|
|
807
|
+
)
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/* A ruler guide — 1px accent line spanning the full viewport (guides read
|
|
811
|
+
* against the rulers, not just the frame). Pointer events live on a slop
|
|
812
|
+
* wrapper around the line so the grab zone stays ~5px; everything is screen px
|
|
813
|
+
* at the viewport level, so no zoom compensation is needed here. */
|
|
814
|
+
function GuideLine({ axis, screenPos, interactive, onGrab }) {
|
|
815
|
+
const slop = 5
|
|
816
|
+
const h = axis === 'h'
|
|
817
|
+
return (
|
|
818
|
+
<div
|
|
819
|
+
onPointerDown={interactive ? onGrab : undefined}
|
|
820
|
+
/* Stop the compat mousedown from reaching handlers underneath (pan /
|
|
821
|
+
* click-away) in browsers that fire it despite the canceled
|
|
822
|
+
* pointerdown. */
|
|
823
|
+
onMouseDown={interactive ? (e) => e.stopPropagation() : undefined}
|
|
824
|
+
style={{
|
|
825
|
+
position: 'absolute',
|
|
826
|
+
...(h
|
|
827
|
+
? { left: 0, top: screenPos - slop, width: '100%', height: slop * 2 + 1, cursor: 'row-resize' }
|
|
828
|
+
: { left: screenPos - slop, top: 0, width: slop * 2 + 1, height: '100%', cursor: 'col-resize' }),
|
|
829
|
+
pointerEvents: interactive ? 'auto' : 'none',
|
|
830
|
+
}}
|
|
831
|
+
>
|
|
832
|
+
<div
|
|
833
|
+
style={{
|
|
834
|
+
position: 'absolute',
|
|
835
|
+
...(h
|
|
836
|
+
? { left: 0, top: slop, width: '100%', height: 1 }
|
|
837
|
+
: { left: slop, top: 0, width: 1, height: '100%' }),
|
|
838
|
+
background: 'var(--kol-accent-primary)',
|
|
839
|
+
}}
|
|
840
|
+
/>
|
|
841
|
+
</div>
|
|
842
|
+
)
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* CanvasGuides — ruler guides rendered at the viewport level so each line
|
|
847
|
+
* spans the entire visible canvas area instead of clipping to the letterbox
|
|
848
|
+
* frame. Positions are stored in virtual canvas px and threaded down as props
|
|
849
|
+
* — the viewport is chrome and owns no guide state; the screen mapping is the
|
|
850
|
+
* same frame-rect geometry the rulers use: screen = frameLeft/Top + virtual *
|
|
851
|
+
* pxPer.
|
|
852
|
+
*
|
|
853
|
+
* Interaction:
|
|
854
|
+
* • grab a line (±5px slop) to move it — row/col-resize cursors
|
|
855
|
+
* • drag off a ruler to create (CanvasRuler announces the gesture via the
|
|
856
|
+
* `kol:guide-drag-start` CustomEvent; this layer owns the drag)
|
|
857
|
+
* • release at virtual pos < 0 (back over the source ruler, or past the
|
|
858
|
+
* frame edge toward it) deletes instead of committing
|
|
859
|
+
*
|
|
860
|
+
* Drags use window-level POINTER events — the ruler cancels its pointerdown,
|
|
861
|
+
* which suppresses the whole compatibility mouse-event stream for the
|
|
862
|
+
* interaction, so mousemove/mouseup would never fire.
|
|
863
|
+
*/
|
|
864
|
+
function CanvasGuides({ containerRef, view, guides, setGuides, interactive }) {
|
|
865
|
+
const geom = useFrameGeom(containerRef, view)
|
|
866
|
+
/* Ref mirror so the drag listeners read fresh geometry without rebinding. */
|
|
867
|
+
const geomRef = useRef(null)
|
|
868
|
+
geomRef.current = geom
|
|
869
|
+
const [guideDrag, setGuideDrag] = useState(null) /* { axis:'h'|'v', index:number|null, pos:number } | null */
|
|
870
|
+
|
|
871
|
+
/* client coords → virtual canvas px, via the measured frame geometry. */
|
|
872
|
+
const toVirtual = useCallback((clientX, clientY) => {
|
|
873
|
+
const el = containerRef.current
|
|
874
|
+
const g = geomRef.current
|
|
875
|
+
if (!el || !g || g.pxPer <= 0) return { vx: 0, vy: 0 }
|
|
876
|
+
const crect = el.getBoundingClientRect()
|
|
877
|
+
return {
|
|
878
|
+
vx: (clientX - crect.left - g.left) / g.pxPer,
|
|
879
|
+
vy: (clientY - crect.top - g.top) / g.pxPer,
|
|
880
|
+
}
|
|
881
|
+
}, [containerRef])
|
|
882
|
+
|
|
883
|
+
/* A pointerdown on a ruler dispatches kol:guide-drag-start (see
|
|
884
|
+
* CanvasRuler); this opens a new-guide drag at the pointer. */
|
|
885
|
+
useEffect(() => {
|
|
886
|
+
const onStart = (e) => {
|
|
887
|
+
const { axis, clientX, clientY } = e.detail
|
|
888
|
+
const { vx, vy } = toVirtual(clientX, clientY)
|
|
889
|
+
setGuideDrag({ axis, index: null, pos: Math.round(axis === 'h' ? vy : vx) })
|
|
890
|
+
}
|
|
891
|
+
window.addEventListener('kol:guide-drag-start', onStart)
|
|
892
|
+
return () => window.removeEventListener('kol:guide-drag-start', onStart)
|
|
893
|
+
}, [toVirtual])
|
|
894
|
+
|
|
895
|
+
/* Window-level listeners while a guide drag is live. Commit on pointerup:
|
|
896
|
+
* append (new) or move (existing); pos < 0 deletes / discards. Positions
|
|
897
|
+
* clamp to the far canvas edge and round to whole virtual px. */
|
|
898
|
+
useEffect(() => {
|
|
899
|
+
if (!guideDrag) return
|
|
900
|
+
const posFrom = (e) => {
|
|
901
|
+
const { vx, vy } = toVirtual(e.clientX, e.clientY)
|
|
902
|
+
return Math.round(guideDrag.axis === 'h' ? vy : vx)
|
|
903
|
+
}
|
|
904
|
+
const onMove = (e) => setGuideDrag((d) => d && { ...d, pos: posFrom(e) })
|
|
905
|
+
const onUp = (e) => {
|
|
906
|
+
const pos = posFrom(e)
|
|
907
|
+
const { axis, index } = guideDrag
|
|
908
|
+
const max = axis === 'h' ? Math.round(geomRef.current?.vh ?? 0) : CANVAS_VIRTUAL_W
|
|
909
|
+
setGuides((g) => {
|
|
910
|
+
const arr = [...g[axis]]
|
|
911
|
+
if (pos < 0) {
|
|
912
|
+
if (index != null) arr.splice(index, 1) /* dropped on the ruler → delete */
|
|
913
|
+
} else if (index == null) {
|
|
914
|
+
arr.push(Math.min(pos, max))
|
|
915
|
+
} else {
|
|
916
|
+
arr[index] = Math.min(pos, max)
|
|
917
|
+
}
|
|
918
|
+
return { ...g, [axis]: arr }
|
|
919
|
+
})
|
|
920
|
+
setGuideDrag(null)
|
|
921
|
+
}
|
|
922
|
+
window.addEventListener('pointermove', onMove)
|
|
923
|
+
window.addEventListener('pointerup', onUp)
|
|
924
|
+
return () => {
|
|
925
|
+
window.removeEventListener('pointermove', onMove)
|
|
926
|
+
window.removeEventListener('pointerup', onUp)
|
|
927
|
+
}
|
|
928
|
+
}, [guideDrag, toVirtual, setGuides])
|
|
929
|
+
|
|
930
|
+
if (!geom || geom.pxPer <= 0) return null
|
|
931
|
+
const screenFor = (axis, pos) =>
|
|
932
|
+
axis === 'h' ? geom.top + pos * geom.pxPer : geom.left + pos * geom.pxPer
|
|
933
|
+
const canGrab = interactive && !guideDrag
|
|
934
|
+
const grab = (axis, index, pos) => (e) => {
|
|
935
|
+
if (e.button !== 0) return
|
|
936
|
+
e.stopPropagation()
|
|
937
|
+
e.preventDefault()
|
|
938
|
+
setGuideDrag({ axis, index, pos })
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
return (
|
|
942
|
+
/* z-[3]: under the rulers (zIndex 4), above the transform layer (canvas
|
|
943
|
+
content). pointer-events-none wrapper — only the slop zones re-enable. */
|
|
944
|
+
<div className="absolute inset-0 pointer-events-none z-[3]">
|
|
945
|
+
{guides.h.map((y, i) => (
|
|
946
|
+
guideDrag?.axis === 'h' && guideDrag.index === i ? null : (
|
|
947
|
+
<GuideLine
|
|
948
|
+
key={`gh-${i}`} axis="h" screenPos={screenFor('h', y)}
|
|
949
|
+
interactive={canGrab} onGrab={grab('h', i, y)}
|
|
950
|
+
/>
|
|
951
|
+
)
|
|
952
|
+
))}
|
|
953
|
+
{guides.v.map((x, i) => (
|
|
954
|
+
guideDrag?.axis === 'v' && guideDrag.index === i ? null : (
|
|
955
|
+
<GuideLine
|
|
956
|
+
key={`gv-${i}`} axis="v" screenPos={screenFor('v', x)}
|
|
957
|
+
interactive={canGrab} onGrab={grab('v', i, x)}
|
|
958
|
+
/>
|
|
959
|
+
)
|
|
960
|
+
))}
|
|
961
|
+
{guideDrag && (
|
|
962
|
+
<>
|
|
963
|
+
<GuideLine
|
|
964
|
+
axis={guideDrag.axis}
|
|
965
|
+
screenPos={screenFor(guideDrag.axis, guideDrag.pos)}
|
|
966
|
+
interactive={false}
|
|
967
|
+
/>
|
|
968
|
+
{/* Full-viewport cursor shield — keeps the row/col-resize cursor
|
|
969
|
+
while the pointer roams outside the dragged line's slop zone. */}
|
|
970
|
+
<div
|
|
971
|
+
className="absolute inset-0"
|
|
972
|
+
style={{
|
|
973
|
+
cursor: guideDrag.axis === 'h' ? 'row-resize' : 'col-resize',
|
|
974
|
+
pointerEvents: 'auto',
|
|
975
|
+
}}
|
|
976
|
+
/>
|
|
977
|
+
</>
|
|
978
|
+
)}
|
|
979
|
+
</div>
|
|
980
|
+
)
|
|
981
|
+
}
|
|
@@ -427,7 +427,7 @@ export function WheelTriangle({ hue, sat, val, onChangeHue, onChangeSV }) {
|
|
|
427
427
|
xmlns="http://www.w3.org/1999/xhtml"
|
|
428
428
|
style={{
|
|
429
429
|
width: '100%', height: '100%',
|
|
430
|
-
background: `conic-gradient(from
|
|
430
|
+
background: `conic-gradient(from 90deg,
|
|
431
431
|
hsl(0,100%,50%), hsl(60,100%,50%), hsl(120,100%,50%),
|
|
432
432
|
hsl(180,100%,50%), hsl(240,100%,50%), hsl(300,100%,50%),
|
|
433
433
|
hsl(360,100%,50%))`,
|