@kolkrabbi/kol-component 0.184.0 → 0.185.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.184.0",
3
+ "version": "0.185.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, and a `W × H` dimension
11
- * label, all positioned in the **same 1080-virtual coordinate space** the
12
- * target lives in (pairs with Canvas's scale layer — place it as a sibling of
13
- * the box inside the same scale layer). Each handle carries a
14
- * `data-handle="NW|N|NE|E|SE|S|SW|W"` attribute so a parent's pointer router
15
- * can start the right resize mode. No interaction logic of its own — the drag
16
- * math lives in the consumer, which reads `e.target.dataset.handle`.
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 the brand editor with the `layer` model reduced to a flat `box`
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
- * @param {{x:number,y:number,w:number,h:number}} box virtual-coord position + size; null/x==null → renders nothing
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: `1px dashed ${accentColor}`,
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}% - ${handleSize / 2}px)`,
75
- top: `calc(${hy * 100}% - ${handleSize / 2}px)`,
76
- width: handleSize,
77
- height: handleSize,
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: `1px solid ${accentColor}`,
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)
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
- export { default as Canvas, CanvasFrame, PanViewport, CANVAS_VIRTUAL_W, DEFAULT_ASPECTS, CANVAS_DEFAULTS } from './organisms/Canvas.jsx'
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'
@@ -1,4 +1,5 @@
1
- import { createContext, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
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
- /* 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)
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