@kolkrabbi/kol-component 0.184.0 → 0.186.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 +1 -1
- package/src/atoms/SelectionOverlay.jsx +83 -17
- package/src/hooks/canvasZoom.js +19 -0
- package/src/hooks/useDragResize.js +319 -0
- package/src/index.js +10 -1
- package/src/organisms/Canvas.jsx +9 -6
- package/src/utilities/EditorShell.jsx +98 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.186.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",
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { useContext } from 'react'
|
|
2
|
+
import { CanvasZoomContext } from '../hooks/canvasZoom.js'
|
|
3
|
+
|
|
1
4
|
/* taxonomy-ok: presentational transform-chrome overlay. It nests no KOL
|
|
2
5
|
* component (pure inline-styled squares + label), so by the letter of the
|
|
3
6
|
* molecule test it reads as an atom — but the lobby spec places it as a
|
|
@@ -7,23 +10,40 @@
|
|
|
7
10
|
/**
|
|
8
11
|
* SelectionOverlay — pure transform chrome for a selected box.
|
|
9
12
|
*
|
|
10
|
-
* Renders a dashed outline, 8 named resize handles,
|
|
11
|
-
* label, all positioned in the **same 1080-virtual
|
|
12
|
-
* target lives in (pairs with Canvas's scale layer —
|
|
13
|
-
* the box inside the same scale layer). Each handle
|
|
14
|
-
* `data-handle="NW|N|NE|E|SE|S|SW|W"` attribute so a parent's
|
|
15
|
-
* can start the right
|
|
16
|
-
* math lives in the consumer, which reads
|
|
13
|
+
* Renders a dashed outline, 8 named resize handles, a rotate handle and a
|
|
14
|
+
* `W × H` dimension label, all positioned in the **same 1080-virtual
|
|
15
|
+
* coordinate space** the target lives in (pairs with Canvas's scale layer —
|
|
16
|
+
* place it as a sibling of the box inside the same scale layer). Each handle
|
|
17
|
+
* carries a `data-handle="NW|N|NE|E|SE|S|SW|W|ROT"` attribute so a parent's
|
|
18
|
+
* pointer router can start the right drag mode. No interaction logic of its
|
|
19
|
+
* own — the drag math lives in the consumer, which reads
|
|
20
|
+
* `e.target.dataset.handle`.
|
|
17
21
|
*
|
|
18
|
-
* Ported from
|
|
22
|
+
* Ported from kol-fxr's editor with the `layer` model reduced to a flat `box`
|
|
19
23
|
* (per lobby spec): renders nothing when there's no positional box.
|
|
20
24
|
*
|
|
21
|
-
*
|
|
25
|
+
* ZOOM COMPENSATION IS THE POINT (restored 2026-09-03,
|
|
26
|
+
* `editor-set-is-behind-its-source`). The chrome renders in virtual px INSIDE
|
|
27
|
+
* the canvas's zoomed transform, so every screen-constant dimension — handle
|
|
28
|
+
* size, outline width, the rotate handle's offset, the label — divides by the
|
|
29
|
+
* live zoom from `CanvasZoomContext`. The first port hardcoded `1px` / `10px`
|
|
30
|
+
* / `marginTop: 6`, so at 3× the handles drew 30px and the label ballooned;
|
|
31
|
+
* kol-fxr measured it and reverted the adoption. Outside a `PanZoomViewport`
|
|
32
|
+
* the context is 1 and every division is a no-op, so a static canvas is
|
|
33
|
+
* unaffected.
|
|
34
|
+
*
|
|
35
|
+
* The label also counter-SCALES rather than just re-sizing: `scale(1/zoom)`
|
|
36
|
+
* with a top-left origin keeps its padding, radius and letter-spacing
|
|
37
|
+
* screen-constant too, which a font-size alone does not.
|
|
38
|
+
*
|
|
39
|
+
* @param {{x:number,y:number,w:number,h:number,rotation?:number}} box virtual-coord position + size; null/x==null → renders nothing. `rotation` in degrees turns the chrome with the box about its centre
|
|
22
40
|
* @param {boolean} showHandles render the 8 resize handles (default true)
|
|
41
|
+
* @param {boolean} showRotate render the rotate handle (default: follows `showHandles`) — independent because a path hides the resize handles, node-edit owning their geometry, and still rotates
|
|
23
42
|
* @param {boolean} showLabel render the `W × H` dimension label (default true)
|
|
24
|
-
* @param {number} handleSize handle square size in virtual px (default 10)
|
|
43
|
+
* @param {number} handleSize handle square size in virtual px BEFORE zoom compensation (default 10)
|
|
25
44
|
* @param {string} accentColor outline + handle + label color (default var(--kol-accent-primary))
|
|
26
45
|
* @param {Function} labelFormatter (box) => string — dimension readout (default `${round(w)} × ${round(h)}`)
|
|
46
|
+
* @param {string} rotateTitle tooltip on the rotate handle (default 'Rotate')
|
|
27
47
|
*/
|
|
28
48
|
const HANDLE_DIRS = [
|
|
29
49
|
{ dir: 'NW', cursor: 'nwse-resize', x: 0, y: 0 },
|
|
@@ -36,17 +56,35 @@ const HANDLE_DIRS = [
|
|
|
36
56
|
{ dir: 'W', cursor: 'ew-resize', x: 0, y: 0.5 },
|
|
37
57
|
]
|
|
38
58
|
|
|
59
|
+
/* The rotate handle's float above the top edge, in virtual px before zoom
|
|
60
|
+
* compensation — fxr's number. */
|
|
61
|
+
const ROTATE_OFFSET = 22
|
|
62
|
+
|
|
39
63
|
export default function SelectionOverlay({
|
|
40
64
|
box,
|
|
41
65
|
showHandles = true,
|
|
66
|
+
showRotate,
|
|
42
67
|
showLabel = true,
|
|
43
68
|
handleSize = 10,
|
|
44
69
|
accentColor = 'var(--kol-accent-primary)',
|
|
45
70
|
labelFormatter = (b) => `${Math.round(b.w)} × ${Math.round(b.h)}`,
|
|
71
|
+
rotateTitle = 'Rotate',
|
|
46
72
|
}) {
|
|
73
|
+
/* Chrome renders in virtual px inside the zoomed transform — divide by zoom
|
|
74
|
+
* so handles / outline / label stay screen-constant at any zoom. 1 outside a
|
|
75
|
+
* PanZoomViewport, which makes every division below a no-op. */
|
|
76
|
+
const zoom = useContext(CanvasZoomContext)
|
|
77
|
+
|
|
47
78
|
if (!box || box.x == null) return null /* no positional box → no chrome */
|
|
48
79
|
|
|
49
80
|
const { x, y, w, h } = box
|
|
81
|
+
const size = handleSize / zoom
|
|
82
|
+
const hairline = 1 / zoom
|
|
83
|
+
const rotate = showRotate ?? showHandles
|
|
84
|
+
/* `rotation` may arrive as a BINDING OBJECT on an animated prop; chrome uses
|
|
85
|
+
* the base 0 rather than throwing on `${{…}}deg` — editing chrome over
|
|
86
|
+
* animated transforms is a consumer-side v1 limitation, and fxr's guard. */
|
|
87
|
+
const rot = typeof box.rotation === 'number' ? box.rotation : 0
|
|
50
88
|
|
|
51
89
|
return (
|
|
52
90
|
<div
|
|
@@ -54,6 +92,9 @@ export default function SelectionOverlay({
|
|
|
54
92
|
position: 'absolute',
|
|
55
93
|
left: x, top: y,
|
|
56
94
|
width: w, height: h,
|
|
95
|
+
/* the chrome rotates WITH the box (centre origin) so the wireframe and
|
|
96
|
+
* the handles hug the actually-rendered box, not its unrotated slot */
|
|
97
|
+
transform: rot ? `rotate(${rot}deg)` : undefined,
|
|
57
98
|
pointerEvents: 'none',
|
|
58
99
|
zIndex: 100,
|
|
59
100
|
}}
|
|
@@ -61,22 +102,43 @@ export default function SelectionOverlay({
|
|
|
61
102
|
<div
|
|
62
103
|
style={{
|
|
63
104
|
position: 'absolute', inset: 0,
|
|
64
|
-
outline:
|
|
105
|
+
outline: `${hairline}px dashed ${accentColor}`,
|
|
65
106
|
outlineOffset: 0,
|
|
66
107
|
}}
|
|
67
108
|
/>
|
|
109
|
+
{/* rotate handle — a circle floating above the top edge; a drag rotates
|
|
110
|
+
* the box about its centre. Independent of the resize handles: a path
|
|
111
|
+
* hides those (node-edit owns their geometry) and still rotates. */}
|
|
112
|
+
{rotate && (
|
|
113
|
+
<div
|
|
114
|
+
data-handle="ROT"
|
|
115
|
+
title={rotateTitle}
|
|
116
|
+
style={{
|
|
117
|
+
position: 'absolute',
|
|
118
|
+
left: `calc(50% - ${size / 2}px)`,
|
|
119
|
+
top: -(ROTATE_OFFSET / zoom),
|
|
120
|
+
width: size,
|
|
121
|
+
height: size,
|
|
122
|
+
borderRadius: '50%',
|
|
123
|
+
background: 'white',
|
|
124
|
+
border: `${hairline}px solid ${accentColor}`,
|
|
125
|
+
cursor: 'grab',
|
|
126
|
+
pointerEvents: 'auto',
|
|
127
|
+
}}
|
|
128
|
+
/>
|
|
129
|
+
)}
|
|
68
130
|
{showHandles && HANDLE_DIRS.map(({ dir, cursor, x: hx, y: hy }) => (
|
|
69
131
|
<div
|
|
70
132
|
key={dir}
|
|
71
133
|
data-handle={dir}
|
|
72
134
|
style={{
|
|
73
135
|
position: 'absolute',
|
|
74
|
-
left: `calc(${hx * 100}% - ${
|
|
75
|
-
top: `calc(${hy * 100}% - ${
|
|
76
|
-
width:
|
|
77
|
-
height:
|
|
136
|
+
left: `calc(${hx * 100}% - ${size / 2}px)`,
|
|
137
|
+
top: `calc(${hy * 100}% - ${size / 2}px)`,
|
|
138
|
+
width: size,
|
|
139
|
+
height: size,
|
|
78
140
|
background: 'white',
|
|
79
|
-
border:
|
|
141
|
+
border: `${hairline}px solid ${accentColor}`,
|
|
80
142
|
cursor,
|
|
81
143
|
pointerEvents: 'auto',
|
|
82
144
|
}}
|
|
@@ -88,7 +150,11 @@ export default function SelectionOverlay({
|
|
|
88
150
|
position: 'absolute',
|
|
89
151
|
left: 0,
|
|
90
152
|
top: '100%',
|
|
91
|
-
marginTop: 6,
|
|
153
|
+
marginTop: 6 / zoom,
|
|
154
|
+
/* counter-scale, not just a smaller font: padding, radius and
|
|
155
|
+
* tracking have to stay screen-constant too */
|
|
156
|
+
transform: `scale(${1 / zoom})`,
|
|
157
|
+
transformOrigin: 'top left',
|
|
92
158
|
fontFamily: 'var(--kol-font-family-mono)',
|
|
93
159
|
fontSize: 10,
|
|
94
160
|
letterSpacing: '0.04em',
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createContext } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CanvasZoomContext — the canvas viewport's live zoom factor.
|
|
5
|
+
*
|
|
6
|
+
* Lives in `src/hooks` rather than beside `Canvas` because BOTH tiers need it:
|
|
7
|
+
* the organism publishes it (`PanZoomViewport`) and the atoms consume it
|
|
8
|
+
* (`SelectionOverlay` divides every screen-constant dimension by it). An atom
|
|
9
|
+
* importing `../organisms/Canvas.jsx` is an upward import and the taxonomy
|
|
10
|
+
* gate is right to refuse it — so the shared value moves down to the tier
|
|
11
|
+
* neither side owns, the same reason `glyphLadders.js` sits here.
|
|
12
|
+
*
|
|
13
|
+
* Defaults to 1, so a consumer reads it unconditionally and a canvas without a
|
|
14
|
+
* pan-zoom viewport turns every `/ zoom` into a no-op.
|
|
15
|
+
*
|
|
16
|
+
* `@kolkrabbi/kol-component` exports it from the barrel as `CanvasZoomContext`,
|
|
17
|
+
* and `Canvas.jsx` re-exports it under the same name it always had.
|
|
18
|
+
*/
|
|
19
|
+
export const CanvasZoomContext = createContext(1)
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
/* THE SAME GRAB AS THE SHELL RAIL (OneGrabGestureBothRails, kol-fxr 2026-08-30 —
|
|
3
|
+
* user: *"why dont we use the grab animation and other sidenav settings to be
|
|
4
|
+
* consistent?"*). fxr's /labs shows both rails at once and only the left one
|
|
5
|
+
* woke as the cursor neared it, because each package had built the gesture
|
|
6
|
+
* itself. `useGrabEdge` lives in kol-component, the one package both can reach —
|
|
7
|
+
* kol-shell dropped its framework peer in 0.16.0, so neither could import the
|
|
8
|
+
* other's.
|
|
9
|
+
*
|
|
10
|
+
* THIS HOOK FOLLOWED IT DOWN 2026-09-03 (`editor-set-is-behind-its-source`,
|
|
11
|
+
* kol-fxr). `EditorShell` is a kol-component export whose rails are fixed-px
|
|
12
|
+
* and unresizable, and the fix is this hook — but kol-component may not import
|
|
13
|
+
* kol-framework (ARCHITECTURE §3, no reverse deps), so the shared hook moved to
|
|
14
|
+
* the tier both sides reach, exactly as its own comment argues for the gesture
|
|
15
|
+
* it wraps. kol-framework re-exports it under the same name, so `SideNav` and
|
|
16
|
+
* every consumer import keep working unchanged. */
|
|
17
|
+
import useGrabEdge from './useGrabEdge.js'
|
|
18
|
+
|
|
19
|
+
/* Grab-edge resize + collapse for SideNav — THE single control since 0.17.0
|
|
20
|
+
* (user build order 2026-08-09, completing the SideNavGrabResize brief: the
|
|
21
|
+
* chip Button is gone in both states; the pill-marked edge does everything).
|
|
22
|
+
* Logic lifted from the hand-tested brand proto (kol-website _tmp
|
|
23
|
+
* useDragResizeProto.js), which forked this hook's 0.16.0 form.
|
|
24
|
+
*
|
|
25
|
+
* - CLICK TOGGLES expand↔collapse: pointerup with < 3px of travel is a
|
|
26
|
+
* click, never a resize — drags only start past the slop, so toggle
|
|
27
|
+
* presses can't jitter the rail.
|
|
28
|
+
* - DRAG resizes; live width goes to --kol-sidenav-w on :root so the grid,
|
|
29
|
+
* shell-header brand block and aside follow one number. Dragging under
|
|
30
|
+
* --kol-sidenav-snap stamps :root[data-sidenav="collapsed"].
|
|
31
|
+
* - SNAP-TO-DEFAULT: releasing within --kol-sidenav-snap-default of the
|
|
32
|
+
* stylesheet default clears the override — you land exactly on default,
|
|
33
|
+
* never 249px or 263px.
|
|
34
|
+
* - DOUBLE-CLICK RESET IS GONE — it cannot coexist with click-toggle (two
|
|
35
|
+
* clicks would toggle-toggle-reset). Home keeps the reset; the snap band
|
|
36
|
+
* covers pointer users.
|
|
37
|
+
* - Keyboard on the focused separator: arrows resize by --kol-sidenav-step
|
|
38
|
+
* (ArrowLeft past the snap collapses), Home resets, Enter/Space toggle.
|
|
39
|
+
* - Width + state survive reload ('kol-sidenav' keeps the consumers'
|
|
40
|
+
* existing 'collapsed'|'expanded' schema; width under its own key).
|
|
41
|
+
*
|
|
42
|
+
* Every value the gesture needs is a --<token>-* custom property in the
|
|
43
|
+
* consumer's CSS — no literals here except the click slop, which is a
|
|
44
|
+
* gesture constant, not chrome. Missing tokens leave the gesture inert.
|
|
45
|
+
*
|
|
46
|
+
* SIDE-AGNOSTIC since ThreeColumnEditorShell (kol-fxr, 2026-08-15): the name
|
|
47
|
+
* family and the drag direction are both arguments now, so a right-hand
|
|
48
|
+
* inspector rail reuses this gesture — pointer, keyboard, snap, collapse and
|
|
49
|
+
* persistence — instead of reimplementing it. The bullets above describe the
|
|
50
|
+
* DEFAULT ('kol-sidenav', side 'left'), which is byte-identical to 0.17.0. */
|
|
51
|
+
|
|
52
|
+
const CLICK_SLOP_PX = 3
|
|
53
|
+
|
|
54
|
+
const root = () => document.documentElement
|
|
55
|
+
|
|
56
|
+
/* Every name the gesture touches, derived from ONE token (ThreeColumnEditorShell,
|
|
57
|
+
* filed from kol-fxr 2026-08-15). The default token reproduces the hardcoded
|
|
58
|
+
* 0.17.0 names EXACTLY — 'kol-sidenav' → data-sidenav, --kol-sidenav-w,
|
|
59
|
+
* storage 'kol-sidenav'/'kol-sidenav-w' — so SideNav and every existing caller
|
|
60
|
+
* are untouched by this generalisation.
|
|
61
|
+
*
|
|
62
|
+
* The data-attribute drops the `kol-` prefix because that is what the shipped
|
|
63
|
+
* CSS already selects (`:root[data-sidenav="collapsed"]`), not a new scheme. */
|
|
64
|
+
export function buildNames(token) {
|
|
65
|
+
const base = token.replace(/^kol-/, '')
|
|
66
|
+
return {
|
|
67
|
+
stateKey: token,
|
|
68
|
+
widthKey: `${token}-w`,
|
|
69
|
+
collapsedAttr: `data-${base}`,
|
|
70
|
+
draggingAttr: `data-${base}-dragging`,
|
|
71
|
+
wVar: `--${token}-w`,
|
|
72
|
+
collapsedVar: `--${token}-w-collapsed`,
|
|
73
|
+
snapVar: `--${token}-snap`,
|
|
74
|
+
stepVar: `--${token}-step`,
|
|
75
|
+
snapDefaultVar: `--${token}-snap-default`,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* Resolve a length token to px, or null when it is absent/unparsable. */
|
|
80
|
+
function readVarPx(name) {
|
|
81
|
+
const raw = getComputedStyle(root()).getPropertyValue(name).trim()
|
|
82
|
+
const n = parseFloat(raw)
|
|
83
|
+
if (!raw || Number.isNaN(n)) return null
|
|
84
|
+
return raw.endsWith('rem') ? n * parseFloat(getComputedStyle(root()).fontSize) : n
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* Imperative DOM writes — pointermove must never re-render the nav tree.
|
|
88
|
+
* React state syncs from the DOM at rest (release / key press / reset). */
|
|
89
|
+
const stampCollapsed = (n, on) => {
|
|
90
|
+
if (on) root().setAttribute(n.collapsedAttr, 'collapsed')
|
|
91
|
+
else root().removeAttribute(n.collapsedAttr)
|
|
92
|
+
}
|
|
93
|
+
const writeWidth = (n, px) => {
|
|
94
|
+
if (px == null) root().style.removeProperty(n.wVar)
|
|
95
|
+
else root().style.setProperty(n.wVar, `${px}px`)
|
|
96
|
+
}
|
|
97
|
+
const readBack = (n) => {
|
|
98
|
+
const inline = parseFloat(root().style.getPropertyValue(n.wVar))
|
|
99
|
+
return {
|
|
100
|
+
collapsed: root().getAttribute(n.collapsedAttr) === 'collapsed',
|
|
101
|
+
widthPx: Number.isNaN(inline) ? null : inline,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* @param ref the panel being resized — its measured width seeds the drag
|
|
106
|
+
* @param options { token, side }
|
|
107
|
+
* token — the CSS/storage name family. Default 'kol-sidenav' (SideNav).
|
|
108
|
+
* A right-hand inspector passes its own, e.g. 'kol-rail', so the two
|
|
109
|
+
* rails never share one :root variable and drag together.
|
|
110
|
+
* side — which EDGE the grab handle sits on. 'left' (default) is a rail on
|
|
111
|
+
* the left of the viewport whose handle is on its right edge, so
|
|
112
|
+
* rightward drag = wider. 'right' inverts both the pointer sign and
|
|
113
|
+
* the arrow keys.
|
|
114
|
+
* persistWidth — remember a dragged WIDTH across sessions. **Default false**
|
|
115
|
+
* (sidenav-drag-width-outranks-breakpoints, kol-client-olina
|
|
116
|
+
* 2026-09-03; user: *"why would you want that in persistent memory?
|
|
117
|
+
* and even if you did, shouldnt it be an opt in via prop? off by
|
|
118
|
+
* default"*).
|
|
119
|
+
*
|
|
120
|
+
* It used to be unconditional, and the width was replayed on boot as
|
|
121
|
+
* an INLINE custom property on `:root` — which outranks every
|
|
122
|
+
* stylesheet rule, so one drag on a laptop killed both shipped rungs
|
|
123
|
+
* (`--kol-sidenav-w: 264px` and the 320px `min-width:1536px` rule)
|
|
124
|
+
* permanently, on every machine, with no UI saying so and no selector
|
|
125
|
+
* a consumer could beat. Measured at 1600×900: a stored 210 rendered
|
|
126
|
+
* 210 and the 1536 rung did nothing. A one-off gesture had become a
|
|
127
|
+
* permanent global.
|
|
128
|
+
*
|
|
129
|
+
* Off, a drag still works and lasts the session; the rail follows the
|
|
130
|
+
* stylesheet and its breakpoints on the next boot. On, the old
|
|
131
|
+
* behaviour returns for an app that genuinely wants the rail
|
|
132
|
+
* remembered — and it is still an inline stamp, so an app opting in is
|
|
133
|
+
* choosing to outrank its own breakpoints. That precedence is worth
|
|
134
|
+
* inverting, but it is a bigger change than this ticket asked for.
|
|
135
|
+
*
|
|
136
|
+
* STATE is untouched: collapsed/expanded still persists via
|
|
137
|
+
* `stateKey`, unconditionally, as it always did. The ticket says the
|
|
138
|
+
* two are separate questions and only asks about the width. */
|
|
139
|
+
export default function useDragResize(ref, options = {}) {
|
|
140
|
+
const { token = 'kol-sidenav', side = 'left', defaultCollapsed = false, persistWidth = false } = options
|
|
141
|
+
/* -1 on a right-hand rail: the same rightward pointer travel that widens a
|
|
142
|
+
* left rail must NARROW a right one, because its handle faces the canvas. */
|
|
143
|
+
const dir = side === 'right' ? -1 : 1
|
|
144
|
+
const names = useMemo(() => buildNames(token), [token])
|
|
145
|
+
|
|
146
|
+
/* the handle's own ref, so the shared gesture can find it. Returned in
|
|
147
|
+
* `grabProps`, so a consumer already spreading those gets the wake, the
|
|
148
|
+
* travel and the dwell with no change. */
|
|
149
|
+
const grabRef = useRef(null)
|
|
150
|
+
useGrabEdge(grabRef)
|
|
151
|
+
const drag = useRef(null) // { startX, startW, snapPx, maxPx, moved } during a drag
|
|
152
|
+
const defaultPx = useRef(null)
|
|
153
|
+
const collapsedPx = useRef(null)
|
|
154
|
+
const [collapsed, setCollapsed] = useState(false)
|
|
155
|
+
const [widthPx, setWidthPx] = useState(null) // null = stylesheet default
|
|
156
|
+
|
|
157
|
+
const syncAndPersist = () => {
|
|
158
|
+
const { collapsed: c, widthPx: w } = readBack(names)
|
|
159
|
+
setCollapsed(c)
|
|
160
|
+
setWidthPx(w)
|
|
161
|
+
try {
|
|
162
|
+
localStorage.setItem(names.stateKey, c ? 'collapsed' : 'expanded')
|
|
163
|
+
/* width only when asked for; off, the key is not written AND any key a
|
|
164
|
+
* previous version left behind is cleared, so a consumer that bumps stops
|
|
165
|
+
* replaying a width it never opted into */
|
|
166
|
+
if (!persistWidth || w == null) localStorage.removeItem(names.widthKey)
|
|
167
|
+
else localStorage.setItem(names.widthKey, String(Math.round(w)))
|
|
168
|
+
} catch { /* storage blocked */ }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const toggleCollapsed = () => {
|
|
172
|
+
const { collapsed: c } = readBack(names)
|
|
173
|
+
stampCollapsed(names, !c)
|
|
174
|
+
syncAndPersist()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* Boot: capture the stylesheet defaults BEFORE any inline override lands,
|
|
178
|
+
* then restore the persisted width/state. */
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
defaultPx.current = readVarPx(names.wVar)
|
|
181
|
+
collapsedPx.current = readVarPx(names.collapsedVar)
|
|
182
|
+
let w = null
|
|
183
|
+
let stored = null
|
|
184
|
+
try {
|
|
185
|
+
/* off → never read it, and drop a key an earlier version wrote */
|
|
186
|
+
if (persistWidth) w = parseFloat(localStorage.getItem(names.widthKey)) || null
|
|
187
|
+
else localStorage.removeItem(names.widthKey)
|
|
188
|
+
stored = localStorage.getItem(names.stateKey)
|
|
189
|
+
} catch { /* storage blocked */ }
|
|
190
|
+
/* nothing stored → the consumer's boot state (RailSideNavPixelParity,
|
|
191
|
+
* 2026-08-28): an app rail boots collapsed, the brand sidebar boots open;
|
|
192
|
+
* the first drag or click persists and the default never speaks again */
|
|
193
|
+
const c = stored ? stored === 'collapsed' : !!defaultCollapsed
|
|
194
|
+
if (w) { writeWidth(names, w); setWidthPx(w) }
|
|
195
|
+
if (c) { stampCollapsed(names, true); setCollapsed(true) }
|
|
196
|
+
}, [names, defaultCollapsed, persistWidth])
|
|
197
|
+
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
const onMove = (e) => {
|
|
200
|
+
if (!drag.current) return
|
|
201
|
+
const d = drag.current
|
|
202
|
+
const dx = e.clientX - d.startX
|
|
203
|
+
/* Below the slop the pointer is still a CLICK — resizing from the
|
|
204
|
+
* first pixel would jitter the rail on every toggle press. */
|
|
205
|
+
if (!d.moved) {
|
|
206
|
+
if (Math.abs(dx) < CLICK_SLOP_PX) return
|
|
207
|
+
d.moved = true
|
|
208
|
+
}
|
|
209
|
+
const next = d.startW + dx * dir
|
|
210
|
+
if (next < d.snapPx) {
|
|
211
|
+
stampCollapsed(names, true)
|
|
212
|
+
} else {
|
|
213
|
+
stampCollapsed(names, false)
|
|
214
|
+
writeWidth(names, Math.min(next, d.maxPx))
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const onUp = () => {
|
|
218
|
+
if (!drag.current) return
|
|
219
|
+
const { moved } = drag.current
|
|
220
|
+
drag.current = null
|
|
221
|
+
root().removeAttribute(names.draggingAttr)
|
|
222
|
+
document.body.style.cursor = ''
|
|
223
|
+
document.body.style.userSelect = ''
|
|
224
|
+
if (!moved) { toggleCollapsed(); return } // a click, not a drag
|
|
225
|
+
/* Snap-to-default: release near the stylesheet default clears the
|
|
226
|
+
* override entirely. */
|
|
227
|
+
const { collapsed: c, widthPx: w } = readBack(names)
|
|
228
|
+
const band = readVarPx(names.snapDefaultVar) ?? readVarPx(names.stepVar) ?? 16
|
|
229
|
+
if (!c && w != null && defaultPx.current != null && Math.abs(w - defaultPx.current) <= band) {
|
|
230
|
+
writeWidth(names, null)
|
|
231
|
+
}
|
|
232
|
+
syncAndPersist()
|
|
233
|
+
}
|
|
234
|
+
window.addEventListener('pointermove', onMove)
|
|
235
|
+
window.addEventListener('pointerup', onUp)
|
|
236
|
+
return () => {
|
|
237
|
+
window.removeEventListener('pointermove', onMove)
|
|
238
|
+
window.removeEventListener('pointerup', onUp)
|
|
239
|
+
}
|
|
240
|
+
}, [names, dir])
|
|
241
|
+
|
|
242
|
+
const onPointerDown = (e) => {
|
|
243
|
+
const snapPx = readVarPx(names.snapVar)
|
|
244
|
+
if (defaultPx.current == null || snapPx == null) return // tokens absent → inert
|
|
245
|
+
e.preventDefault()
|
|
246
|
+
drag.current = {
|
|
247
|
+
startX: e.clientX,
|
|
248
|
+
startW: ref.current?.getBoundingClientRect().width ?? defaultPx.current,
|
|
249
|
+
snapPx,
|
|
250
|
+
/* mirror's ceiling (default × 3), resolved from the token not hardcoded */
|
|
251
|
+
maxPx: defaultPx.current * 3,
|
|
252
|
+
moved: false,
|
|
253
|
+
}
|
|
254
|
+
/* the grid's grid-template-columns ease would trail the pointer —
|
|
255
|
+
* kol-framework.css suspends it while this attribute is stamped */
|
|
256
|
+
root().setAttribute(names.draggingAttr, '')
|
|
257
|
+
document.body.style.cursor = 'col-resize'
|
|
258
|
+
document.body.style.userSelect = 'none'
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const resetToDefault = () => {
|
|
262
|
+
stampCollapsed(names, false)
|
|
263
|
+
writeWidth(names, null)
|
|
264
|
+
syncAndPersist()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const onKeyDown = (e) => {
|
|
268
|
+
const snapPx = readVarPx(names.snapVar)
|
|
269
|
+
const stepPx = readVarPx(names.stepVar)
|
|
270
|
+
if (defaultPx.current == null || snapPx == null || stepPx == null) return
|
|
271
|
+
const { collapsed: c, widthPx: w } = readBack(names)
|
|
272
|
+
const current = w ?? defaultPx.current
|
|
273
|
+
/* The arrow that GROWS is the one pointing away from the rail's own edge —
|
|
274
|
+
* ArrowRight on a left rail, ArrowLeft on a right one. Same inversion the
|
|
275
|
+
* pointer gets, so keyboard and drag never disagree. */
|
|
276
|
+
const growKey = dir === 1 ? 'ArrowRight' : 'ArrowLeft'
|
|
277
|
+
const shrinkKey = dir === 1 ? 'ArrowLeft' : 'ArrowRight'
|
|
278
|
+
if (e.key === growKey) {
|
|
279
|
+
e.preventDefault()
|
|
280
|
+
if (c) stampCollapsed(names, false)
|
|
281
|
+
else writeWidth(names, Math.min(current + stepPx, defaultPx.current * 3))
|
|
282
|
+
syncAndPersist()
|
|
283
|
+
} else if (e.key === shrinkKey) {
|
|
284
|
+
e.preventDefault()
|
|
285
|
+
if (c) return
|
|
286
|
+
const next = current - stepPx
|
|
287
|
+
if (next < snapPx) stampCollapsed(names, true)
|
|
288
|
+
else writeWidth(names, next)
|
|
289
|
+
syncAndPersist()
|
|
290
|
+
} else if (e.key === 'Home') {
|
|
291
|
+
e.preventDefault()
|
|
292
|
+
resetToDefault()
|
|
293
|
+
} else if (e.key === 'Enter' || e.key === ' ') {
|
|
294
|
+
e.preventDefault()
|
|
295
|
+
toggleCollapsed()
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
collapsed,
|
|
301
|
+
toggleCollapsed,
|
|
302
|
+
grabProps: {
|
|
303
|
+
ref: grabRef,
|
|
304
|
+
/* the pill is drawn by `.kol-rail-grab` (kol-animation.css) — the hook
|
|
305
|
+
* only supplies `is-near` and `--kol-rail-grab-y`. A consumer's own
|
|
306
|
+
* className, spread after this, still wins. */
|
|
307
|
+
className: 'kol-rail-grab',
|
|
308
|
+
role: 'separator',
|
|
309
|
+
'aria-orientation': 'vertical',
|
|
310
|
+
'aria-label': 'Resize navigation',
|
|
311
|
+
'aria-valuenow': Math.round(collapsed ? collapsedPx.current : (widthPx ?? defaultPx.current)) || undefined,
|
|
312
|
+
'aria-valuemin': collapsedPx.current == null ? undefined : Math.round(collapsedPx.current),
|
|
313
|
+
'aria-valuemax': defaultPx.current == null ? undefined : Math.round(defaultPx.current * 3),
|
|
314
|
+
tabIndex: 0,
|
|
315
|
+
onPointerDown,
|
|
316
|
+
onKeyDown,
|
|
317
|
+
},
|
|
318
|
+
}
|
|
319
|
+
}
|
package/src/index.js
CHANGED
|
@@ -111,7 +111,11 @@ export { default as TabsRow } from './molecules/TabsRow.jsx'
|
|
|
111
111
|
/* monorepo sets (P6–P10) — organism members. Foundry members live in the
|
|
112
112
|
standalone @kolkrabbi/kol-foundry package (with the type-specimen kit +
|
|
113
113
|
live-font effects moved there 2026-07-09) — never re-exported here. */
|
|
114
|
-
|
|
114
|
+
/* `CanvasZoomContext` and `PanZoomViewport` are the load-bearing pair for an
|
|
115
|
+
* editor: the viewport publishes the zoom, every piece of editing chrome reads
|
|
116
|
+
* it. Absent from this barrel until 0.185.0, which is why the first port of
|
|
117
|
+
* this set could not keep its chrome screen-constant. */
|
|
118
|
+
export { default as Canvas, CanvasFrame, PanViewport, PanZoomViewport, CanvasZoomContext, useFps, CANVAS_VIRTUAL_W, DEFAULT_ASPECTS, CANVAS_DEFAULTS } from './organisms/Canvas.jsx'
|
|
115
119
|
export { default as EditorShell } from './utilities/EditorShell.jsx'
|
|
116
120
|
export { default as GalleryCarousel } from './organisms/GalleryCarousel.jsx'
|
|
117
121
|
export { default as AsciiCursor } from './utilities/AsciiCursor.jsx'
|
|
@@ -189,6 +193,11 @@ export { default as useCoarsePointer } from './hooks/useCoarsePointer.js'
|
|
|
189
193
|
export { default as useInViewAttention } from './hooks/useInViewAttention.js'
|
|
190
194
|
export { default as useAxisAnimation } from './hooks/useAxisAnimation.js'
|
|
191
195
|
export { useEyedropper, pickFromCanvasElement } from './hooks/useEyedropper.js'
|
|
196
|
+
/* The rail gesture's other half. It lived in kol-framework until 2026-09-03 and
|
|
197
|
+
* moved here for the same reason `useGrabEdge` did: kol-component's own
|
|
198
|
+
* `EditorShell` needs resizable rails and cannot import framework. framework
|
|
199
|
+
* re-exports it, so no consumer specifier changed. */
|
|
200
|
+
export { default as useDragResize } from './hooks/useDragResize.js'
|
|
192
201
|
export { default as usePlaceholders } from './hooks/usePlaceholders.js'
|
|
193
202
|
export { resolveCssVar, resolveCssColor, isLight } from './hooks/cssVar.js'
|
|
194
203
|
|
package/src/organisms/Canvas.jsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
import { CanvasZoomContext } from '../hooks/canvasZoom.js'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Canvas — the editor's aspect-ratio stage.
|
|
@@ -31,11 +32,13 @@ import { createContext, useCallback, useEffect, useLayoutEffect, useRef, useStat
|
|
|
31
32
|
* pan-only viewport is not this component, it is a third of it.
|
|
32
33
|
*/
|
|
33
34
|
|
|
34
|
-
/*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
|
|
35
|
+
/* The viewport's live zoom factor — editing chrome divides its virtual-px
|
|
36
|
+
* dimensions by it to stay screen-constant. Defined in `hooks/canvasZoom.js`
|
|
37
|
+
* because the atoms consume it and an atom may not import an organism (the
|
|
38
|
+
* taxonomy gate, correctly); re-exported here under the name it has always
|
|
39
|
+
* had, so `import { CanvasZoomContext } from '@kolkrabbi/kol-component'` and
|
|
40
|
+
* every existing deep import keep working. */
|
|
41
|
+
export { CanvasZoomContext }
|
|
39
42
|
|
|
40
43
|
/* Fixed virtual canvas width — children render in this pixel space and the
|
|
41
44
|
* outer rect scales to fit the viewport via CSS transform. Height is derived
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { useRef } from 'react'
|
|
1
2
|
import Divider from '../atoms/Divider.jsx'
|
|
3
|
+
import useDragResize from '../hooks/useDragResize.js'
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* EditorShell — the two-rail editor layout frame.
|
|
@@ -7,33 +9,56 @@ import Divider from '../atoms/Divider.jsx'
|
|
|
7
9
|
* ├────────┬──────────────────────────┬──────────┤
|
|
8
10
|
* │ left │ [canvasHeader] │ right │
|
|
9
11
|
* │ rail │ children (canvas) │ rail │
|
|
12
|
+
* │ │ [canvasFooter] │ │
|
|
10
13
|
* └────────┴──────────────────────────┴──────────┘
|
|
11
14
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* conditional render instead of a CSS rule).
|
|
15
|
+
* Rails flank a fluid canvas column, all under an optional topbar. Hairlines
|
|
16
|
+
* between regions are composed from the DS `Divider`. Headers and footers
|
|
17
|
+
* render only when their slot is filled, so an unused one contributes no
|
|
18
|
+
* border or gap (the source's `:empty` collapse, expressed as a conditional
|
|
19
|
+
* render instead of a CSS rule).
|
|
18
20
|
*
|
|
19
|
-
* Ported from
|
|
21
|
+
* Ported from kol-fxr's editor with the app couplings dropped (per lobby
|
|
20
22
|
* spec): the panel-registry + `panelsForSlot`/`SLOTS` indirection is replaced
|
|
21
|
-
* by plain ReactNode slots
|
|
22
|
-
*
|
|
23
|
-
* `
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* by plain ReactNode slots; the `MenuTop` / `ShortcutsOverlay` imports become
|
|
24
|
+
* the `topbar` / `overlays` slots; the editor stylesheet import is gone; the
|
|
25
|
+
* `#0E0E11` dark canvas is a `canvasBg` prop. `data-editor-keep-selection`
|
|
26
|
+
* stays as an opt-in click-away hook, not baked behavior.
|
|
27
|
+
*
|
|
28
|
+
* THREE GAPS CLOSED 2026-09-03 (`editor-set-is-behind-its-source`, kol-fxr,
|
|
29
|
+
* which adopted this and reverted):
|
|
30
|
+
*
|
|
31
|
+
* 1. **The rails are CSS-width now.** `railWidth` was a px number written
|
|
32
|
+
* straight onto the element, so a consumer stylesheet had nothing to target
|
|
33
|
+
* and the rail could not follow a breakpoint. Each rail's width now reads
|
|
34
|
+
* `var(--kol-editor-{side}-w, {railWidth}px)`, so the prop is the default
|
|
35
|
+
* and CSS — a media query, a consumer's own rule, a drag — wins over it.
|
|
36
|
+
* 2. **`resizable` gives both rails the estate's grab gesture** through
|
|
37
|
+
* `useDragResize`, the same hook `SideNav` and kol-shell's `NavRail` wear
|
|
38
|
+
* (that hook moved from kol-framework to kol-component in this same pass —
|
|
39
|
+
* a component-tier shell cannot import framework, ARCHITECTURE §3). Each
|
|
40
|
+
* rail owns its own token, so the two never drag together.
|
|
41
|
+
* 3. **The `.kol-editor-*` class hooks are emitted**, which is what a
|
|
42
|
+
* consumer's stylesheet targets (fxr's `kol-labs.css` styles this frame by
|
|
43
|
+
* name). They are HOOKS, not styling: the layout stays here in Tailwind, so
|
|
44
|
+
* a consumer without those rules renders identically.
|
|
45
|
+
*
|
|
46
|
+
* Footer slots (`leftFooter`, `rightFooter`, `canvasFooter`) also came back —
|
|
47
|
+
* the source fills all three and the port had none.
|
|
27
48
|
*
|
|
28
49
|
* @param {ReactNode} topbar top bar spanning the full width (optional)
|
|
29
50
|
* @param {ReactNode} leftHeader left rail header (optional; renders a hairline when set)
|
|
30
51
|
* @param {ReactNode} left left rail body (scrolls independently)
|
|
52
|
+
* @param {ReactNode} leftFooter left rail footer, pinned under the scrolling body (optional)
|
|
31
53
|
* @param {ReactNode} canvasHeader sub-bar spanning only the canvas column, e.g. a tool palette (optional)
|
|
32
54
|
* @param {ReactNode} children the canvas region (fills the fluid column)
|
|
55
|
+
* @param {ReactNode} canvasFooter bar under the canvas, e.g. a timeline or a status line (optional)
|
|
33
56
|
* @param {ReactNode} rightHeader right rail header (optional)
|
|
34
57
|
* @param {ReactNode} right right rail body (scrolls independently)
|
|
58
|
+
* @param {ReactNode} rightFooter right rail footer (optional)
|
|
35
59
|
* @param {ReactNode} overlays floating overlays rendered above the frame (optional)
|
|
36
|
-
* @param {number} railWidth rail
|
|
60
|
+
* @param {number} railWidth DEFAULT rail width in px (default 320) — the fallback in `var(--kol-editor-{side}-w, …)`, so CSS and a drag both outrank it
|
|
61
|
+
* @param {boolean} resizable give both rails the drag-resize grab edge (default false)
|
|
37
62
|
* @param {string} canvasBg canvas column background (default var(--kol-surface-primary))
|
|
38
63
|
* @param {string|number} height shell height (default '100dvh'; pass a bounded value to embed)
|
|
39
64
|
* @param {string} className extra classes merged onto the shell root
|
|
@@ -42,12 +67,16 @@ export default function EditorShell({
|
|
|
42
67
|
topbar,
|
|
43
68
|
leftHeader,
|
|
44
69
|
left,
|
|
70
|
+
leftFooter,
|
|
45
71
|
canvasHeader,
|
|
46
72
|
children,
|
|
73
|
+
canvasFooter,
|
|
47
74
|
rightHeader,
|
|
48
75
|
right,
|
|
76
|
+
rightFooter,
|
|
49
77
|
overlays,
|
|
50
78
|
railWidth = 320,
|
|
79
|
+
resizable = false,
|
|
51
80
|
canvasBg = 'var(--kol-surface-primary)',
|
|
52
81
|
height = '100dvh',
|
|
53
82
|
className = '',
|
|
@@ -55,7 +84,7 @@ export default function EditorShell({
|
|
|
55
84
|
return (
|
|
56
85
|
<div
|
|
57
86
|
data-editor-keep-selection
|
|
58
|
-
className={`flex flex-col overflow-hidden bg-surface-primary ${className}`.trim()}
|
|
87
|
+
className={`kol-editor-shell flex flex-col overflow-hidden bg-surface-primary ${className}`.trim()}
|
|
59
88
|
style={{ height }}
|
|
60
89
|
>
|
|
61
90
|
{topbar && (
|
|
@@ -65,27 +94,37 @@ export default function EditorShell({
|
|
|
65
94
|
</>
|
|
66
95
|
)}
|
|
67
96
|
|
|
68
|
-
<div className="flex flex-1 min-h-0">
|
|
69
|
-
<Rail header={leftHeader} width={railWidth}
|
|
97
|
+
<div className="kol-editor-grid flex flex-1 min-h-0">
|
|
98
|
+
<Rail side="left" header={leftHeader} footer={leftFooter} width={railWidth} resizable={resizable}>
|
|
99
|
+
{left}
|
|
100
|
+
</Rail>
|
|
70
101
|
<Divider variant="vertical" />
|
|
71
102
|
|
|
72
|
-
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
|
103
|
+
<div className="kol-editor-canvas-column flex flex-col flex-1 min-w-0 min-h-0">
|
|
73
104
|
{canvasHeader && (
|
|
74
105
|
<>
|
|
75
|
-
<div className="shrink-0">{canvasHeader}</div>
|
|
106
|
+
<div className="kol-editor-canvas-header shrink-0">{canvasHeader}</div>
|
|
76
107
|
<Divider />
|
|
77
108
|
</>
|
|
78
109
|
)}
|
|
79
110
|
<main
|
|
80
|
-
className="flex-1 min-h-0 select-none"
|
|
111
|
+
className="kol-editor-canvas flex-1 min-h-0 select-none"
|
|
81
112
|
style={{ background: canvasBg }}
|
|
82
113
|
>
|
|
83
114
|
{children}
|
|
84
115
|
</main>
|
|
116
|
+
{canvasFooter && (
|
|
117
|
+
<>
|
|
118
|
+
<Divider />
|
|
119
|
+
<div className="kol-editor-canvas-footer shrink-0">{canvasFooter}</div>
|
|
120
|
+
</>
|
|
121
|
+
)}
|
|
85
122
|
</div>
|
|
86
123
|
|
|
87
124
|
<Divider variant="vertical" />
|
|
88
|
-
<Rail header={rightHeader} width={railWidth}
|
|
125
|
+
<Rail side="right" header={rightHeader} footer={rightFooter} width={railWidth} resizable={resizable}>
|
|
126
|
+
{right}
|
|
127
|
+
</Rail>
|
|
89
128
|
</div>
|
|
90
129
|
|
|
91
130
|
{overlays}
|
|
@@ -93,19 +132,50 @@ export default function EditorShell({
|
|
|
93
132
|
)
|
|
94
133
|
}
|
|
95
134
|
|
|
96
|
-
/* Rail —
|
|
97
|
-
*
|
|
98
|
-
* stretching the whole shell.
|
|
99
|
-
|
|
135
|
+
/* Rail — an aside with an optional header (+ hairline) over a scrolling body,
|
|
136
|
+
* and an optional footer pinned under it. `min-h-0` lets the body's overflow
|
|
137
|
+
* scroll instead of stretching the whole shell.
|
|
138
|
+
*
|
|
139
|
+
* The width is a CSS custom property with the prop as its fallback, so the
|
|
140
|
+
* cascade can move it; `useDragResize` writes that same property while
|
|
141
|
+
* dragging. Each side carries its own token — `kol-editor-left` /
|
|
142
|
+
* `kol-editor-right` — because two rails sharing one `:root` variable drag
|
|
143
|
+
* together, which is the bug the hook's own docs record. */
|
|
144
|
+
function Rail({ side, header, footer, width, resizable, children }) {
|
|
145
|
+
/* The hook measures the rail it resizes (`ref.current.getBoundingClientRect`
|
|
146
|
+
* seeds the drag), so this is the element ref, not the handle's — the handle
|
|
147
|
+
* gets its own from `grabProps`. */
|
|
148
|
+
const railRef = useRef(null)
|
|
149
|
+
const { grabProps } = useDragResize(railRef, {
|
|
150
|
+
token: `kol-editor-${side}`,
|
|
151
|
+
/* which EDGE the handle sits on: a left rail's handle faces the canvas on
|
|
152
|
+
* its right, so rightward drag widens; a right rail inverts both. */
|
|
153
|
+
side,
|
|
154
|
+
})
|
|
155
|
+
|
|
100
156
|
return (
|
|
101
|
-
<aside
|
|
157
|
+
<aside
|
|
158
|
+
ref={railRef}
|
|
159
|
+
className={`kol-editor-${side} relative flex flex-col min-h-0 shrink-0`}
|
|
160
|
+
style={{ width: `var(--kol-editor-${side}-w, ${typeof width === 'number' ? `${width}px` : width})` }}
|
|
161
|
+
>
|
|
102
162
|
{header && (
|
|
103
163
|
<>
|
|
104
|
-
<div className="shrink-0">{header}</div>
|
|
164
|
+
<div className="kol-editor-rail-header shrink-0">{header}</div>
|
|
165
|
+
<Divider />
|
|
166
|
+
</>
|
|
167
|
+
)}
|
|
168
|
+
<div className="kol-editor-rail-body flex-1 min-h-0 overflow-y-auto">{children}</div>
|
|
169
|
+
{footer && (
|
|
170
|
+
<>
|
|
105
171
|
<Divider />
|
|
172
|
+
<div className="kol-editor-rail-footer shrink-0">{footer}</div>
|
|
106
173
|
</>
|
|
107
174
|
)}
|
|
108
|
-
|
|
175
|
+
{/* `.kol-rail-grab` is the drawing (kol-animation.css); the hook supplies
|
|
176
|
+
the proximity wake and the travel. Rendered only when asked, so a
|
|
177
|
+
static shell has no extra hit area over its rail edge. */}
|
|
178
|
+
{resizable && <div {...grabProps} className={`kol-rail-grab kol-rail-grab--${side}`} />}
|
|
109
179
|
</aside>
|
|
110
180
|
)
|
|
111
181
|
}
|