@neta-art/cohub 3.1.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +33 -2
  2. package/dist/{board.d.ts → board/animation.d.ts} +4 -2
  3. package/dist/{chunks/board.js → board/animation.js} +5 -44
  4. package/dist/board/codec.d.ts +25 -0
  5. package/dist/board/codec.js +243 -0
  6. package/dist/board/core/bindings.d.ts +45 -0
  7. package/dist/board/core/bindings.js +162 -0
  8. package/dist/board/core/draw-geometry.d.ts +30 -0
  9. package/dist/board/core/draw-geometry.js +175 -0
  10. package/dist/board/core/export-assets.d.ts +14 -0
  11. package/dist/board/core/export-assets.js +22 -0
  12. package/dist/board/core/export-plan.d.ts +84 -0
  13. package/dist/board/core/export-plan.js +128 -0
  14. package/dist/board/core/file-preview.d.ts +178 -0
  15. package/dist/board/core/file-preview.js +282 -0
  16. package/dist/board/core/palette.d.ts +42 -0
  17. package/dist/board/core/palette.js +166 -0
  18. package/dist/board/core/shape-definition.d.ts +35 -0
  19. package/dist/board/core/shape-definition.js +64 -0
  20. package/dist/board/core/shape-types.d.ts +140 -0
  21. package/dist/board/core/shape-types.js +27 -0
  22. package/dist/board/core/text-metrics.d.ts +18 -0
  23. package/dist/board/core/text-metrics.js +43 -0
  24. package/dist/board/core/tool-styles.d.ts +52 -0
  25. package/dist/board/core/tool-styles.js +51 -0
  26. package/dist/board/export/index.d.ts +56 -0
  27. package/dist/board/export/index.js +86 -0
  28. package/dist/board/export/scene.d.ts +35 -0
  29. package/dist/board/export/scene.js +69 -0
  30. package/dist/board/geometry.d.ts +139 -0
  31. package/dist/board/geometry.js +492 -0
  32. package/dist/board/headless/index.d.ts +69 -0
  33. package/dist/board/headless/index.js +150 -0
  34. package/dist/board/image-key.d.ts +19 -0
  35. package/dist/board/image-key.js +34 -0
  36. package/dist/board/index.d.ts +17 -0
  37. package/dist/board/index.js +17 -0
  38. package/dist/board/render/index.d.ts +6 -0
  39. package/dist/board/render/index.js +6 -0
  40. package/dist/board/render/palette.d.ts +5 -0
  41. package/dist/board/render/palette.js +30 -0
  42. package/dist/board/render/renderers/arrow-card-renderer.d.ts +5 -0
  43. package/dist/board/render/renderers/arrow-card-renderer.js +157 -0
  44. package/dist/board/render/renderers/base-card-renderer.d.ts +46 -0
  45. package/dist/board/render/renderers/base-card-renderer.js +153 -0
  46. package/dist/board/render/renderers/board-renderer-registry.d.ts +85 -0
  47. package/dist/board/render/renderers/board-renderer-registry.js +41 -0
  48. package/dist/board/render/renderers/draw-card-renderer.d.ts +5 -0
  49. package/dist/board/render/renderers/draw-card-renderer.js +82 -0
  50. package/dist/board/render/renderers/far-plate.d.ts +39 -0
  51. package/dist/board/render/renderers/far-plate.js +92 -0
  52. package/dist/board/render/renderers/file-card-renderer.d.ts +40 -0
  53. package/dist/board/render/renderers/file-card-renderer.js +350 -0
  54. package/dist/board/render/renderers/frame-card-renderer.d.ts +5 -0
  55. package/dist/board/render/renderers/frame-card-renderer.js +99 -0
  56. package/dist/board/render/renderers/geo-card-renderer.d.ts +5 -0
  57. package/dist/board/render/renderers/geo-card-renderer.js +128 -0
  58. package/dist/board/render/renderers/image-card-renderer.d.ts +8 -0
  59. package/dist/board/render/renderers/image-card-renderer.js +138 -0
  60. package/dist/board/render/renderers/text-card-renderer.d.ts +7 -0
  61. package/dist/board/render/renderers/text-card-renderer.js +94 -0
  62. package/dist/board/render/renderers/unknown-card-renderer.d.ts +5 -0
  63. package/dist/board/render/renderers/unknown-card-renderer.js +102 -0
  64. package/dist/board/render/renderers/video-card-renderer.d.ts +7 -0
  65. package/dist/board/render/renderers/video-card-renderer.js +146 -0
  66. package/dist/board/render/text-measurement.d.ts +30 -0
  67. package/dist/board/render/text-measurement.js +57 -0
  68. package/dist/board/render/text-resolution.d.ts +16 -0
  69. package/dist/board/render/text-resolution.js +43 -0
  70. package/dist/board/render/themes/board-theme-registry.d.ts +22 -0
  71. package/dist/board/render/themes/board-theme-registry.js +13 -0
  72. package/dist/board/render/themes/clean-theme.d.ts +5 -0
  73. package/dist/board/render/themes/clean-theme.js +105 -0
  74. package/dist/chunks/http.d.ts +2 -3
  75. package/dist/chunks/http.js +4 -0
  76. package/dist/chunks/websocket.d.ts +851 -353
  77. package/dist/http.d.ts +1 -2
  78. package/dist/index.d.ts +100 -5
  79. package/dist/index.js +391 -1
  80. package/dist/protocol/dist/board-constants.d.ts +23 -0
  81. package/dist/protocol/dist/board-constants.js +61 -0
  82. package/dist/protocol/dist/board-document.d.ts +1047 -0
  83. package/dist/protocol/dist/board-document.js +321 -0
  84. package/dist/protocol/dist/board.d.ts +186 -0
  85. package/dist/protocol/dist/board.js +207 -0
  86. package/dist/protocol/dist/index.d.ts +5 -0
  87. package/dist/protocol/dist/index.js +5 -0
  88. package/dist/protocol/dist/provenance.js +12 -0
  89. package/dist/protocol/dist/realtime/board-awareness.d.ts +1 -0
  90. package/dist/protocol/dist/realtime/board-awareness.js +119 -0
  91. package/dist/protocol/dist/realtime/types.d.ts +2 -0
  92. package/package.json +40 -4
  93. package/dist/board.js +0 -2
  94. package/dist/chunks/board.d.ts +0 -2589
@@ -0,0 +1,139 @@
1
+ import { BoardFrame, BoardViewport } from "../protocol/dist/board-document.js";
2
+ //#region src/board/geometry.d.ts
3
+ type Point = {
4
+ x: number;
5
+ y: number;
6
+ };
7
+ type Rect = {
8
+ x: number;
9
+ y: number;
10
+ width: number;
11
+ height: number;
12
+ };
13
+ type Size = {
14
+ width: number;
15
+ height: number;
16
+ };
17
+ declare const coordBrand: unique symbol;
18
+ /** Surface-relative screen coordinate, in CSS pixels. */
19
+ type ScreenPoint = {
20
+ x: number;
21
+ y: number;
22
+ readonly [coordBrand]: "screen";
23
+ };
24
+ /** Board world coordinate, in board units. */
25
+ type WorldPoint = {
26
+ x: number;
27
+ y: number;
28
+ readonly [coordBrand]: "world";
29
+ };
30
+ declare function screenPoint(x: number, y: number): ScreenPoint;
31
+ declare function worldPoint(x: number, y: number): WorldPoint;
32
+ declare const MIN_BOARD_ZOOM = 0.05;
33
+ declare const MAX_BOARD_ZOOM = 8;
34
+ /** Smallest an item can be resized to, in world units. */
35
+ declare const MIN_ITEM_SIZE = 24;
36
+ /** Extra world-space padding applied when fitting content into view. */
37
+ declare const FIT_PADDING = 64;
38
+ /**
39
+ * Fraction of the viewport's larger dimension used as an off-screen margin for
40
+ * both card culling and texture preloading. Keeping them equal means a card's
41
+ * texture is already requested before the card scrolls into view.
42
+ */
43
+ declare const VIEWPORT_MARGIN_RATIO = 0.5;
44
+ declare function clampZoom(zoom: number): number;
45
+ /** Keep transient camera state finite without discarding its last valid axes. */
46
+ declare function normalizeViewport(viewport: BoardViewport, fallback?: BoardViewport): BoardViewport;
47
+ declare function clamp(value: number, min: number, max: number): number;
48
+ declare function degToRad(degrees: number): number;
49
+ declare function radToDeg(radians: number): number;
50
+ declare function frameRect(frame: BoardFrame): Rect;
51
+ declare function rectCenter(rect: Rect): WorldPoint;
52
+ declare function rectsIntersect(a: Rect, b: Rect): boolean;
53
+ declare function rectContainsPoint(rect: Rect, point: Point, tolerance?: number): boolean;
54
+ declare function expandRect(rect: Rect, amount: number): Rect;
55
+ declare function unionRects(rects: Rect[]): Rect | null;
56
+ declare function rotatePointAround<P extends Point>(point: P, center: Point, angleRad: number): P;
57
+ /** Axis-aligned bounding box of a (possibly rotated) frame. */
58
+ declare function itemBounds(frame: BoardFrame): Rect;
59
+ declare function selectionBounds(frames: BoardFrame[]): Rect | null;
60
+ /** Exact point-in-frame test, accounting for rotation. */
61
+ declare function frameContainsPoint(frame: BoardFrame, point: WorldPoint): boolean;
62
+ /** World position of a resize handle on a single (possibly rotated) frame. */
63
+ declare function frameHandlePosition(frame: BoardFrame, handle: ResizeHandle): WorldPoint;
64
+ /** Corners in clockwise order, following the frame's rotation. */
65
+ declare function frameCorners(frame: BoardFrame): readonly WorldPoint[];
66
+ /** Screen-space radius (px) within which a pointer grabs a handle. */
67
+ declare const HANDLE_HIT_RADIUS = 8;
68
+ /** Screen-space offset (px) from the selection's lower-facing edge. */
69
+ declare const ROTATION_HANDLE_OFFSET = 28;
70
+ declare function frameRayIntersection(frame: Rect & {
71
+ rotation?: number;
72
+ }, pointer: WorldPoint): WorldPoint;
73
+ declare function rotationHandleAnchor(frame: Rect & {
74
+ rotation?: number;
75
+ }): WorldPoint;
76
+ declare function rotationHandlePosition(frame: Rect & {
77
+ rotation?: number;
78
+ }, zoom: number): WorldPoint;
79
+ type ResizeHandle = "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w";
80
+ declare const CORNER_RESIZE_HANDLES: readonly ["nw", "ne", "se", "sw"];
81
+ declare const EDGE_RESIZE_HANDLES: readonly ["n", "e", "s", "w"];
82
+ type CornerResizeHandle = (typeof CORNER_RESIZE_HANDLES)[number];
83
+ /** Screen-space depth of the rotation zone outside each resize corner. */
84
+ declare const CORNER_ROTATION_ZONE_WIDTH = 16;
85
+ declare const RESIZE_HANDLES: ResizeHandle[];
86
+ /**
87
+ * Hit-test the outward quadrant around each corner, outside its resize target.
88
+ * This gives fine pointers Figma-style corner rotation without adding chrome.
89
+ */
90
+ declare function frameCornerRotationHandleAt(frame: BoardFrame, point: WorldPoint, zoom: number, screenRadius?: number): CornerResizeHandle | null;
91
+ /**
92
+ * Hit-test the continuous edge zones of a rotated frame. Corners are inset so
93
+ * their resize handles retain priority even on small nodes.
94
+ */
95
+ declare function frameEdgeHandleAt(frame: BoardFrame, point: WorldPoint, zoom: number, screenRadius?: number): Extract<ResizeHandle, "n" | "e" | "s" | "w"> | null;
96
+ /** Direction of a handle from the rect center: -1, 0, or 1 per axis. */
97
+ declare const HANDLE_DIRECTION: Record<ResizeHandle, Point>;
98
+ /** Position of a handle on an unrotated rect. */
99
+ declare function handlePosition(rect: Rect, handle: ResizeHandle): WorldPoint;
100
+ /** Resize a frame to exact dimensions while keeping the opposite handle fixed. */
101
+ declare function resizeFrameToSize(frame: BoardFrame, handle: ResizeHandle, width: number, height: number): BoardFrame;
102
+ /**
103
+ * Resize a frame by dragging a handle to a world-space pointer position.
104
+ * The edge/corner opposite the handle stays anchored. Works correctly for
105
+ * rotated frames by performing the resize in the frame's local space.
106
+ */
107
+ declare function resizeFrame(frame: BoardFrame, handle: ResizeHandle, pointer: WorldPoint, minSize?: number,
108
+ /** Keep the original aspect ratio (Shift). */
109
+ keepAspect?: boolean): BoardFrame;
110
+ /**
111
+ * Proportionally scale a group of frames by dragging a corner handle of their
112
+ * combined bounds. Individual rotations are preserved.
113
+ */
114
+ declare function scaleFrames(frames: BoardFrame[], bounds: Rect, handle: ResizeHandle, pointer: WorldPoint, minSize?: number, scaleRange?: {
115
+ min?: number;
116
+ max?: number;
117
+ }): BoardFrame[];
118
+ /** Angle (degrees) from a center to a world-space pointer. */
119
+ declare function angleFromCenter(center: WorldPoint, pointer: WorldPoint): number;
120
+ /**
121
+ * Rotate a set of frames around a pivot by a delta (degrees). Each frame's
122
+ * center orbits the pivot and its own rotation increases by the delta.
123
+ */
124
+ declare function rotateFrames(frames: BoardFrame[], pivot: WorldPoint, deltaDeg: number): BoardFrame[];
125
+ /** Normalize a rotation into the [-180, 180] range for tidy persistence. */
126
+ declare function normalizeRotation(degrees: number): number;
127
+ /** Convert a surface-relative screen point into world space. */
128
+ declare function pointToWorld(point: ScreenPoint, viewport: BoardViewport): WorldPoint;
129
+ declare function worldToScreen(point: WorldPoint, viewport: BoardViewport): ScreenPoint;
130
+ declare function screenToWorld(clientX: number, clientY: number, rect: DOMRect, viewport: BoardViewport): WorldPoint;
131
+ /** Zoom while keeping a surface-relative screen point fixed under the cursor. */
132
+ declare function zoomAround(viewport: BoardViewport, anchor: ScreenPoint, nextZoom: number): BoardViewport;
133
+ declare function panBy(viewport: BoardViewport, dx: number, dy: number): BoardViewport;
134
+ /** Compute a viewport that frames the given world rect within the surface. */
135
+ declare function fitToContent(content: Rect, surface: Size, padding?: number): BoardViewport;
136
+ /** World-space rectangle currently visible in the board stage. */
137
+ declare function visibleWorldRect(viewport: BoardViewport, surfaceWidth: number, surfaceHeight: number): Rect;
138
+ //#endregion
139
+ export { CORNER_RESIZE_HANDLES, CORNER_ROTATION_ZONE_WIDTH, CornerResizeHandle, EDGE_RESIZE_HANDLES, FIT_PADDING, HANDLE_DIRECTION, HANDLE_HIT_RADIUS, MAX_BOARD_ZOOM, MIN_BOARD_ZOOM, MIN_ITEM_SIZE, Point, RESIZE_HANDLES, ROTATION_HANDLE_OFFSET, Rect, ResizeHandle, ScreenPoint, Size, VIEWPORT_MARGIN_RATIO, WorldPoint, angleFromCenter, clamp, clampZoom, degToRad, expandRect, fitToContent, frameContainsPoint, frameCornerRotationHandleAt, frameCorners, frameEdgeHandleAt, frameHandlePosition, frameRayIntersection, frameRect, handlePosition, itemBounds, normalizeRotation, normalizeViewport, panBy, pointToWorld, radToDeg, rectCenter, rectContainsPoint, rectsIntersect, resizeFrame, resizeFrameToSize, rotateFrames, rotatePointAround, rotationHandleAnchor, rotationHandlePosition, scaleFrames, screenPoint, screenToWorld, selectionBounds, unionRects, visibleWorldRect, worldPoint, worldToScreen, zoomAround };
@@ -0,0 +1,492 @@
1
+ //#region src/board/geometry.ts
2
+ function screenPoint(x, y) {
3
+ return {
4
+ x,
5
+ y
6
+ };
7
+ }
8
+ function worldPoint(x, y) {
9
+ return {
10
+ x,
11
+ y
12
+ };
13
+ }
14
+ const MIN_BOARD_ZOOM = .05;
15
+ const MAX_BOARD_ZOOM = 8;
16
+ const DEFAULT_BOARD_VIEWPORT = {
17
+ x: 0,
18
+ y: 0,
19
+ zoom: 1
20
+ };
21
+ /** Smallest an item can be resized to, in world units. */
22
+ const MIN_ITEM_SIZE = 24;
23
+ /** Extra world-space padding applied when fitting content into view. */
24
+ const FIT_PADDING = 64;
25
+ /**
26
+ * Fraction of the viewport's larger dimension used as an off-screen margin for
27
+ * both card culling and texture preloading. Keeping them equal means a card's
28
+ * texture is already requested before the card scrolls into view.
29
+ */
30
+ const VIEWPORT_MARGIN_RATIO = .5;
31
+ function clampZoom(zoom) {
32
+ const value = Number.isFinite(zoom) ? zoom : DEFAULT_BOARD_VIEWPORT.zoom;
33
+ return Math.min(8, Math.max(MIN_BOARD_ZOOM, value));
34
+ }
35
+ /** Keep transient camera state finite without discarding its last valid axes. */
36
+ function normalizeViewport(viewport, fallback = DEFAULT_BOARD_VIEWPORT) {
37
+ const fallbackX = Number.isFinite(fallback.x) ? fallback.x : DEFAULT_BOARD_VIEWPORT.x;
38
+ const fallbackY = Number.isFinite(fallback.y) ? fallback.y : DEFAULT_BOARD_VIEWPORT.y;
39
+ const fallbackZoom = Number.isFinite(fallback.zoom) ? clampZoom(fallback.zoom) : DEFAULT_BOARD_VIEWPORT.zoom;
40
+ return {
41
+ x: Number.isFinite(viewport.x) ? viewport.x : fallbackX,
42
+ y: Number.isFinite(viewport.y) ? viewport.y : fallbackY,
43
+ zoom: Number.isFinite(viewport.zoom) ? clampZoom(viewport.zoom) : fallbackZoom
44
+ };
45
+ }
46
+ function clamp(value, min, max) {
47
+ return Math.min(max, Math.max(min, value));
48
+ }
49
+ function degToRad(degrees) {
50
+ return degrees * Math.PI / 180;
51
+ }
52
+ function radToDeg(radians) {
53
+ return radians * 180 / Math.PI;
54
+ }
55
+ function frameRect(frame) {
56
+ return {
57
+ x: frame.x,
58
+ y: frame.y,
59
+ width: frame.width,
60
+ height: frame.height
61
+ };
62
+ }
63
+ function rectCenter(rect) {
64
+ return worldPoint(rect.x + rect.width / 2, rect.y + rect.height / 2);
65
+ }
66
+ function rectsIntersect(a, b) {
67
+ return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
68
+ }
69
+ function rectContainsPoint(rect, point, tolerance = 0) {
70
+ return point.x >= rect.x - tolerance && point.x <= rect.x + rect.width + tolerance && point.y >= rect.y - tolerance && point.y <= rect.y + rect.height + tolerance;
71
+ }
72
+ function expandRect(rect, amount) {
73
+ return {
74
+ x: rect.x - amount,
75
+ y: rect.y - amount,
76
+ width: rect.width + amount * 2,
77
+ height: rect.height + amount * 2
78
+ };
79
+ }
80
+ function unionRects(rects) {
81
+ if (rects.length === 0) return null;
82
+ let minX = Number.POSITIVE_INFINITY;
83
+ let minY = Number.POSITIVE_INFINITY;
84
+ let maxX = Number.NEGATIVE_INFINITY;
85
+ let maxY = Number.NEGATIVE_INFINITY;
86
+ for (const rect of rects) {
87
+ minX = Math.min(minX, rect.x);
88
+ minY = Math.min(minY, rect.y);
89
+ maxX = Math.max(maxX, rect.x + rect.width);
90
+ maxY = Math.max(maxY, rect.y + rect.height);
91
+ }
92
+ return {
93
+ x: minX,
94
+ y: minY,
95
+ width: maxX - minX,
96
+ height: maxY - minY
97
+ };
98
+ }
99
+ function rotatePointAround(point, center, angleRad) {
100
+ const cos = Math.cos(angleRad);
101
+ const sin = Math.sin(angleRad);
102
+ const dx = point.x - center.x;
103
+ const dy = point.y - center.y;
104
+ return {
105
+ x: center.x + dx * cos - dy * sin,
106
+ y: center.y + dx * sin + dy * cos
107
+ };
108
+ }
109
+ /** Axis-aligned bounding box of a (possibly rotated) frame. */
110
+ function itemBounds(frame) {
111
+ const rotation = frame.rotation || 0;
112
+ if (rotation === 0) return frameRect(frame);
113
+ const center = rectCenter(frameRect(frame));
114
+ const halfW = frame.width / 2;
115
+ const halfH = frame.height / 2;
116
+ const rad = degToRad(rotation);
117
+ const cos = Math.abs(Math.cos(rad));
118
+ const sin = Math.abs(Math.sin(rad));
119
+ const boundW = halfW * cos + halfH * sin;
120
+ const boundH = halfW * sin + halfH * cos;
121
+ return {
122
+ x: center.x - boundW,
123
+ y: center.y - boundH,
124
+ width: boundW * 2,
125
+ height: boundH * 2
126
+ };
127
+ }
128
+ function selectionBounds(frames) {
129
+ return unionRects(frames.map(itemBounds));
130
+ }
131
+ /** Exact point-in-frame test, accounting for rotation. */
132
+ function frameContainsPoint(frame, point) {
133
+ const rect = frameRect(frame);
134
+ const rotation = frame.rotation || 0;
135
+ let local = point;
136
+ if (rotation !== 0) local = rotatePointAround(point, rectCenter(rect), -degToRad(rotation));
137
+ return rectContainsPoint(rect, local);
138
+ }
139
+ /** World position of a resize handle on a single (possibly rotated) frame. */
140
+ function frameHandlePosition(frame, handle) {
141
+ const rect = frameRect(frame);
142
+ const position = handlePosition(rect, handle);
143
+ const rotation = frame.rotation || 0;
144
+ if (rotation === 0) return position;
145
+ return rotatePointAround(position, rectCenter(rect), degToRad(rotation));
146
+ }
147
+ /** Corners in clockwise order, following the frame's rotation. */
148
+ function frameCorners(frame) {
149
+ return [
150
+ "nw",
151
+ "ne",
152
+ "se",
153
+ "sw"
154
+ ].map((handle) => frameHandlePosition(frame, handle));
155
+ }
156
+ /** Screen-space radius (px) within which a pointer grabs a handle. */
157
+ const HANDLE_HIT_RADIUS = 8;
158
+ /** Screen-space offset (px) from the selection's lower-facing edge. */
159
+ const ROTATION_HANDLE_OFFSET = 28;
160
+ function frameRayIntersection(frame, pointer) {
161
+ const center = rectCenter(frame);
162
+ let dx = pointer.x - center.x;
163
+ let dy = pointer.y - center.y;
164
+ if (Math.hypot(dx, dy) < 1e-4) {
165
+ dx = 0;
166
+ dy = 1;
167
+ }
168
+ const rad = degToRad(frame.rotation || 0);
169
+ const cos = Math.cos(rad);
170
+ const sin = Math.sin(rad);
171
+ const localDx = dx * cos + dy * sin;
172
+ const localDy = -dx * sin + dy * cos;
173
+ const scaleX = Math.abs(localDx) > 1e-4 ? frame.width / 2 / Math.abs(localDx) : Number.POSITIVE_INFINITY;
174
+ const scaleY = Math.abs(localDy) > 1e-4 ? frame.height / 2 / Math.abs(localDy) : Number.POSITIVE_INFINITY;
175
+ const scale = Math.min(scaleX, scaleY);
176
+ return worldPoint(center.x + dx * scale, center.y + dy * scale);
177
+ }
178
+ function rotationHandleAnchor(frame) {
179
+ const center = rectCenter(frame);
180
+ return frameRayIntersection(frame, worldPoint(center.x, center.y + 1));
181
+ }
182
+ function rotationHandlePosition(frame, zoom) {
183
+ const center = rectCenter(frame);
184
+ const anchor = rotationHandleAnchor(frame);
185
+ const offset = 28 / Math.max(zoom, 1e-4);
186
+ return worldPoint(center.x, anchor.y + offset);
187
+ }
188
+ const CORNER_RESIZE_HANDLES = [
189
+ "nw",
190
+ "ne",
191
+ "se",
192
+ "sw"
193
+ ];
194
+ const EDGE_RESIZE_HANDLES = [
195
+ "n",
196
+ "e",
197
+ "s",
198
+ "w"
199
+ ];
200
+ /** Screen-space depth of the rotation zone outside each resize corner. */
201
+ const CORNER_ROTATION_ZONE_WIDTH = 16;
202
+ const RESIZE_HANDLES = [
203
+ "nw",
204
+ "n",
205
+ "ne",
206
+ "e",
207
+ "se",
208
+ "s",
209
+ "sw",
210
+ "w"
211
+ ];
212
+ /**
213
+ * Hit-test the outward quadrant around each corner, outside its resize target.
214
+ * This gives fine pointers Figma-style corner rotation without adding chrome.
215
+ */
216
+ function frameCornerRotationHandleAt(frame, point, zoom, screenRadius = 8) {
217
+ const rect = frameRect(frame);
218
+ const center = rectCenter(rect);
219
+ const local = frame.rotation ? rotatePointAround(point, center, -degToRad(frame.rotation)) : point;
220
+ const safeZoom = Math.max(zoom, 1e-4);
221
+ const innerRadius = screenRadius / safeZoom;
222
+ const outerRadius = (screenRadius + 16) / safeZoom;
223
+ for (const handle of CORNER_RESIZE_HANDLES) {
224
+ const corner = handlePosition(rect, handle);
225
+ const direction = HANDLE_DIRECTION[handle];
226
+ const outwardX = (local.x - corner.x) * direction.x;
227
+ const outwardY = (local.y - corner.y) * direction.y;
228
+ if (outwardX < -1e-4 || outwardY < -1e-4) continue;
229
+ const distance = Math.hypot(outwardX, outwardY);
230
+ if (distance > innerRadius && distance <= outerRadius) return handle;
231
+ }
232
+ return null;
233
+ }
234
+ /**
235
+ * Hit-test the continuous edge zones of a rotated frame. Corners are inset so
236
+ * their resize handles retain priority even on small nodes.
237
+ */
238
+ function frameEdgeHandleAt(frame, point, zoom, screenRadius = 8) {
239
+ const rect = frameRect(frame);
240
+ const center = rectCenter(rect);
241
+ const local = frame.rotation ? rotatePointAround(point, center, -degToRad(frame.rotation)) : point;
242
+ const radius = screenRadius / Math.max(zoom, 1e-4);
243
+ const insetX = Math.min(rect.width / 3, radius * 1.5);
244
+ const insetY = Math.min(rect.height / 3, radius * 1.5);
245
+ let closest = null;
246
+ let closestDistance = Number.POSITIVE_INFINITY;
247
+ if (local.x >= rect.x + insetX && local.x <= rect.x + rect.width - insetX) {
248
+ const north = Math.abs(local.y - rect.y);
249
+ const south = Math.abs(local.y - (rect.y + rect.height));
250
+ if (north <= radius && north < closestDistance) {
251
+ closest = "n";
252
+ closestDistance = north;
253
+ }
254
+ if (south <= radius && south < closestDistance) {
255
+ closest = "s";
256
+ closestDistance = south;
257
+ }
258
+ }
259
+ if (local.y >= rect.y + insetY && local.y <= rect.y + rect.height - insetY) {
260
+ const west = Math.abs(local.x - rect.x);
261
+ const east = Math.abs(local.x - (rect.x + rect.width));
262
+ if (west <= radius && west < closestDistance) {
263
+ closest = "w";
264
+ closestDistance = west;
265
+ }
266
+ if (east <= radius && east < closestDistance) closest = "e";
267
+ }
268
+ return closest;
269
+ }
270
+ /** Direction of a handle from the rect center: -1, 0, or 1 per axis. */
271
+ const HANDLE_DIRECTION = {
272
+ nw: {
273
+ x: -1,
274
+ y: -1
275
+ },
276
+ n: {
277
+ x: 0,
278
+ y: -1
279
+ },
280
+ ne: {
281
+ x: 1,
282
+ y: -1
283
+ },
284
+ e: {
285
+ x: 1,
286
+ y: 0
287
+ },
288
+ se: {
289
+ x: 1,
290
+ y: 1
291
+ },
292
+ s: {
293
+ x: 0,
294
+ y: 1
295
+ },
296
+ sw: {
297
+ x: -1,
298
+ y: 1
299
+ },
300
+ w: {
301
+ x: -1,
302
+ y: 0
303
+ }
304
+ };
305
+ /** Position of a handle on an unrotated rect. */
306
+ function handlePosition(rect, handle) {
307
+ const direction = HANDLE_DIRECTION[handle];
308
+ return worldPoint(rect.x + rect.width / 2 + direction.x * rect.width / 2, rect.y + rect.height / 2 + direction.y * rect.height / 2);
309
+ }
310
+ /** Resize a frame to exact dimensions while keeping the opposite handle fixed. */
311
+ function resizeFrameToSize(frame, handle, width, height) {
312
+ const direction = HANDLE_DIRECTION[handle];
313
+ const rect = frameRect(frame);
314
+ const center = rectCenter(rect);
315
+ const rad = degToRad(frame.rotation || 0);
316
+ const anchorLocal = {
317
+ x: -direction.x * rect.width / 2,
318
+ y: -direction.y * rect.height / 2
319
+ };
320
+ const anchor = rotatePointAround({
321
+ x: center.x + anchorLocal.x,
322
+ y: center.y + anchorLocal.y
323
+ }, center, rad);
324
+ const centerLocal = {
325
+ x: direction.x * width / 2,
326
+ y: direction.y * height / 2
327
+ };
328
+ const nextCenter = rotatePointAround({
329
+ x: anchor.x + centerLocal.x,
330
+ y: anchor.y + centerLocal.y
331
+ }, anchor, rad);
332
+ return {
333
+ x: nextCenter.x - width / 2,
334
+ y: nextCenter.y - height / 2,
335
+ width,
336
+ height,
337
+ rotation: frame.rotation || 0
338
+ };
339
+ }
340
+ /**
341
+ * Resize a frame by dragging a handle to a world-space pointer position.
342
+ * The edge/corner opposite the handle stays anchored. Works correctly for
343
+ * rotated frames by performing the resize in the frame's local space.
344
+ */
345
+ function resizeFrame(frame, handle, pointer, minSize = 24, keepAspect = false) {
346
+ const direction = HANDLE_DIRECTION[handle];
347
+ const rect = frameRect(frame);
348
+ const center = rectCenter(rect);
349
+ const rad = degToRad(frame.rotation || 0);
350
+ const aspect = rect.width / Math.max(rect.height, 1e-4);
351
+ const anchorLocal = {
352
+ x: -direction.x * rect.width / 2,
353
+ y: -direction.y * rect.height / 2
354
+ };
355
+ const anchor = rotatePointAround({
356
+ x: center.x + anchorLocal.x,
357
+ y: center.y + anchorLocal.y
358
+ }, center, rad);
359
+ const toLocal = rotatePointAround(pointer, anchor, -rad);
360
+ const local = {
361
+ x: toLocal.x - anchor.x,
362
+ y: toLocal.y - anchor.y
363
+ };
364
+ let width = direction.x !== 0 ? clamp(direction.x * local.x, minSize, Number.POSITIVE_INFINITY) : rect.width;
365
+ let height = direction.y !== 0 ? clamp(direction.y * local.y, minSize, Number.POSITIVE_INFINITY) : rect.height;
366
+ if (keepAspect) {
367
+ if (direction.x !== 0 && direction.y !== 0) if (Math.abs(width / aspect) > height) height = width / aspect;
368
+ else width = height * aspect;
369
+ else if (direction.x !== 0) height = width / aspect;
370
+ else if (direction.y !== 0) width = height * aspect;
371
+ width = Math.max(minSize, width);
372
+ height = Math.max(minSize, height);
373
+ }
374
+ return resizeFrameToSize(frame, handle, width, height);
375
+ }
376
+ /**
377
+ * Proportionally scale a group of frames by dragging a corner handle of their
378
+ * combined bounds. Individual rotations are preserved.
379
+ */
380
+ function scaleFrames(frames, bounds, handle, pointer, minSize = 24, scaleRange) {
381
+ const direction = HANDLE_DIRECTION[handle];
382
+ if (direction.x === 0 || direction.y === 0) return frames;
383
+ const anchor = {
384
+ x: bounds.x + bounds.width / 2 - direction.x * bounds.width / 2,
385
+ y: bounds.y + bounds.height / 2 - direction.y * bounds.height / 2
386
+ };
387
+ const original = handlePosition(bounds, handle);
388
+ const originalDist = Math.hypot(original.x - anchor.x, original.y - anchor.y);
389
+ const nextDist = Math.hypot(pointer.x - anchor.x, pointer.y - anchor.y);
390
+ if (originalDist === 0) return frames;
391
+ const frameMinScale = frames.reduce((minimum, frame) => Math.max(minimum, minSize / Math.max(1e-4, Math.min(frame.width, frame.height))), 0);
392
+ const minScale = Math.max(frameMinScale, scaleRange?.min ?? 0);
393
+ const maxScale = Math.max(minScale, scaleRange?.max ?? Number.POSITIVE_INFINITY);
394
+ const scale = clamp(nextDist / originalDist, minScale, maxScale);
395
+ return frames.map((frame) => {
396
+ const center = rectCenter(frameRect(frame));
397
+ const nextCenter = {
398
+ x: anchor.x + (center.x - anchor.x) * scale,
399
+ y: anchor.y + (center.y - anchor.y) * scale
400
+ };
401
+ const width = frame.width * scale;
402
+ const height = frame.height * scale;
403
+ return {
404
+ x: nextCenter.x - width / 2,
405
+ y: nextCenter.y - height / 2,
406
+ width,
407
+ height,
408
+ rotation: frame.rotation || 0
409
+ };
410
+ });
411
+ }
412
+ /** Angle (degrees) from a center to a world-space pointer. */
413
+ function angleFromCenter(center, pointer) {
414
+ return radToDeg(Math.atan2(pointer.y - center.y, pointer.x - center.x));
415
+ }
416
+ /**
417
+ * Rotate a set of frames around a pivot by a delta (degrees). Each frame's
418
+ * center orbits the pivot and its own rotation increases by the delta.
419
+ */
420
+ function rotateFrames(frames, pivot, deltaDeg) {
421
+ const rad = degToRad(deltaDeg);
422
+ return frames.map((frame) => {
423
+ const nextCenter = rotatePointAround(rectCenter(frameRect(frame)), pivot, rad);
424
+ return {
425
+ x: nextCenter.x - frame.width / 2,
426
+ y: nextCenter.y - frame.height / 2,
427
+ width: frame.width,
428
+ height: frame.height,
429
+ rotation: (frame.rotation || 0) + deltaDeg
430
+ };
431
+ });
432
+ }
433
+ /** Normalize a rotation into the [-180, 180] range for tidy persistence. */
434
+ function normalizeRotation(degrees) {
435
+ let value = degrees % 360;
436
+ if (value > 180) value -= 360;
437
+ if (value < -180) value += 360;
438
+ return Math.abs(value) < .01 ? 0 : value;
439
+ }
440
+ /** Convert a surface-relative screen point into world space. */
441
+ function pointToWorld(point, viewport) {
442
+ return worldPoint((point.x - viewport.x) / viewport.zoom, (point.y - viewport.y) / viewport.zoom);
443
+ }
444
+ function worldToScreen(point, viewport) {
445
+ return screenPoint(point.x * viewport.zoom + viewport.x, point.y * viewport.zoom + viewport.y);
446
+ }
447
+ function screenToWorld(clientX, clientY, rect, viewport) {
448
+ return pointToWorld(screenPoint(clientX - rect.left, clientY - rect.top), viewport);
449
+ }
450
+ /** Zoom while keeping a surface-relative screen point fixed under the cursor. */
451
+ function zoomAround(viewport, anchor, nextZoom) {
452
+ const current = normalizeViewport(viewport);
453
+ const zoom = Number.isFinite(nextZoom) ? clampZoom(nextZoom) : current.zoom;
454
+ const world = pointToWorld(anchor, current);
455
+ return {
456
+ x: anchor.x - world.x * zoom,
457
+ y: anchor.y - world.y * zoom,
458
+ zoom
459
+ };
460
+ }
461
+ function panBy(viewport, dx, dy) {
462
+ return {
463
+ ...viewport,
464
+ x: viewport.x + dx,
465
+ y: viewport.y + dy
466
+ };
467
+ }
468
+ /** Compute a viewport that frames the given world rect within the surface. */
469
+ function fitToContent(content, surface, padding = 64) {
470
+ const availableW = Math.max(1, surface.width - padding * 2);
471
+ const availableH = Math.max(1, surface.height - padding * 2);
472
+ const zoom = clampZoom(Math.min(availableW / content.width, availableH / content.height));
473
+ return {
474
+ x: surface.width / 2 - (content.x + content.width / 2) * zoom,
475
+ y: surface.height / 2 - (content.y + content.height / 2) * zoom,
476
+ zoom
477
+ };
478
+ }
479
+ /** World-space rectangle currently visible in the board stage. */
480
+ function visibleWorldRect(viewport, surfaceWidth, surfaceHeight) {
481
+ const width = Math.max(0, surfaceWidth);
482
+ const height = Math.max(0, surfaceHeight);
483
+ const zoom = viewport.zoom || 1;
484
+ return {
485
+ x: -viewport.x / zoom,
486
+ y: -viewport.y / zoom,
487
+ width: width / zoom,
488
+ height: height / zoom
489
+ };
490
+ }
491
+ //#endregion
492
+ export { CORNER_RESIZE_HANDLES, CORNER_ROTATION_ZONE_WIDTH, EDGE_RESIZE_HANDLES, FIT_PADDING, HANDLE_DIRECTION, HANDLE_HIT_RADIUS, MAX_BOARD_ZOOM, MIN_BOARD_ZOOM, MIN_ITEM_SIZE, RESIZE_HANDLES, ROTATION_HANDLE_OFFSET, VIEWPORT_MARGIN_RATIO, angleFromCenter, clamp, clampZoom, degToRad, expandRect, fitToContent, frameContainsPoint, frameCornerRotationHandleAt, frameCorners, frameEdgeHandleAt, frameHandlePosition, frameRayIntersection, frameRect, handlePosition, itemBounds, normalizeRotation, normalizeViewport, panBy, pointToWorld, radToDeg, rectCenter, rectContainsPoint, rectsIntersect, resizeFrame, resizeFrameToSize, rotateFrames, rotatePointAround, rotationHandleAnchor, rotationHandlePosition, scaleFrames, screenPoint, screenToWorld, selectionBounds, unionRects, visibleWorldRect, worldPoint, worldToScreen, zoomAround };
@@ -0,0 +1,69 @@
1
+ import { BoardDocument } from "../../protocol/dist/board-document.js";
2
+ import { BoardExportOptions, BoardExportResult } from "../export/index.js";
3
+ import { Renderer } from "pixi.js";
4
+ //#region src/board/headless/index.d.ts
5
+ type HeadlessCanvasModule = {
6
+ createCanvas: (width: number, height: number) => unknown;
7
+ Image: new () => unknown;
8
+ GlobalFonts: {
9
+ registerFromPath: (path: string, name?: string) => unknown;
10
+ has: (name: string) => boolean;
11
+ };
12
+ };
13
+ type BoardHeadlessFont = {
14
+ /** Absolute path to a font file (woff2, ttf and otf all work). */
15
+ path: string;
16
+ /** Family name renderers should ask for. Defaults to the board text family. */
17
+ family?: string;
18
+ };
19
+ type BoardHeadlessRendererOptions = {
20
+ /**
21
+ * Fonts to register before the first measurement. Board text asks for
22
+ * "Geist"; without a matching family the platform substitutes one, which
23
+ * changes glyph shapes and text metrics.
24
+ */
25
+ fonts?: BoardHeadlessFont[];
26
+ /** Override the canvas module (tests, or a pre-imported instance). */
27
+ canvasModule?: HeadlessCanvasModule;
28
+ };
29
+ /**
30
+ * A decoded image, opaque to callers.
31
+ *
32
+ * Node consumers (the CLI) should not need PixiJS in their own type surface just
33
+ * to hand textures back to the exporter, so the concrete `Texture` stays inside
34
+ * this module.
35
+ */
36
+ type BoardHeadlessTexture = {
37
+ readonly __boardTexture: unique symbol;
38
+ };
39
+ type BoardHeadlessRenderer = {
40
+ renderer: Renderer;
41
+ canvasModule: HeadlessCanvasModule;
42
+ /** Decode image bytes into a texture the exporter accepts. */
43
+ decodeImage: (bytes: Uint8Array, mimeType?: string) => Promise<BoardHeadlessTexture>;
44
+ destroy: () => void;
45
+ };
46
+ /**
47
+ * Create a Canvas2D renderer backed by Skia.
48
+ *
49
+ * `skipExtensionImports` is essential: it stops Pixi from auto-loading its
50
+ * browser environment bundle, which would pull in DOM-only pipes.
51
+ */
52
+ declare function createBoardHeadlessRenderer(options?: BoardHeadlessRendererOptions): Promise<BoardHeadlessRenderer>;
53
+ type BoardHeadlessExportFormat = "png" | "jpeg" | "webp";
54
+ type BoardHeadlessExportOptions = Omit<BoardExportOptions, "textures"> & {
55
+ /** Textures from `decodeImage`, keyed by image key. */
56
+ textures?: Map<string, BoardHeadlessTexture>;
57
+ format?: BoardHeadlessExportFormat;
58
+ /** JPEG/WebP quality, 0–1. Ignored for PNG. */
59
+ quality?: number;
60
+ };
61
+ type BoardHeadlessExportResult = Omit<BoardExportResult, "canvas"> & {
62
+ bytes: Uint8Array;
63
+ format: BoardHeadlessExportFormat;
64
+ };
65
+ declare function boardHeadlessMimeType(format: BoardHeadlessExportFormat): string;
66
+ /** Render a document to encoded image bytes. Returns null for an empty region. */
67
+ declare function exportBoardImageBytes(headless: BoardHeadlessRenderer, document: BoardDocument, options?: BoardHeadlessExportOptions): BoardHeadlessExportResult | null;
68
+ //#endregion
69
+ export { BoardHeadlessExportFormat, BoardHeadlessExportOptions, BoardHeadlessExportResult, BoardHeadlessFont, BoardHeadlessRenderer, BoardHeadlessRendererOptions, BoardHeadlessTexture, HeadlessCanvasModule, boardHeadlessMimeType, createBoardHeadlessRenderer, exportBoardImageBytes };