@neta-art/cohub 3.0.0 → 3.2.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/README.md +33 -2
- package/dist/{board.d.ts → board/animation.d.ts} +4 -2
- package/dist/{chunks/board.js → board/animation.js} +5 -44
- package/dist/board/codec.d.ts +25 -0
- package/dist/board/codec.js +252 -0
- package/dist/board/core/bindings.d.ts +45 -0
- package/dist/board/core/bindings.js +162 -0
- package/dist/board/core/draw-geometry.d.ts +31 -0
- package/dist/board/core/draw-geometry.js +183 -0
- package/dist/board/core/export-assets.d.ts +14 -0
- package/dist/board/core/export-assets.js +22 -0
- package/dist/board/core/export-plan.d.ts +84 -0
- package/dist/board/core/export-plan.js +128 -0
- package/dist/board/core/file-preview.d.ts +176 -0
- package/dist/board/core/file-preview.js +271 -0
- package/dist/board/core/palette.d.ts +42 -0
- package/dist/board/core/palette.js +138 -0
- package/dist/board/core/shape-definition.d.ts +35 -0
- package/dist/board/core/shape-definition.js +64 -0
- package/dist/board/core/shape-types.d.ts +144 -0
- package/dist/board/core/shape-types.js +27 -0
- package/dist/board/core/text-metrics.d.ts +18 -0
- package/dist/board/core/text-metrics.js +43 -0
- package/dist/board/export/index.d.ts +56 -0
- package/dist/board/export/index.js +86 -0
- package/dist/board/export/scene.d.ts +35 -0
- package/dist/board/export/scene.js +69 -0
- package/dist/board/geometry.d.ts +131 -0
- package/dist/board/geometry.js +468 -0
- package/dist/board/headless/index.d.ts +69 -0
- package/dist/board/headless/index.js +150 -0
- package/dist/board/image-key.d.ts +19 -0
- package/dist/board/image-key.js +34 -0
- package/dist/board/index.d.ts +16 -0
- package/dist/board/index.js +16 -0
- package/dist/board/render/index.d.ts +6 -0
- package/dist/board/render/index.js +6 -0
- package/dist/board/render/palette.d.ts +5 -0
- package/dist/board/render/palette.js +30 -0
- package/dist/board/render/renderers/arrow-card-renderer.d.ts +5 -0
- package/dist/board/render/renderers/arrow-card-renderer.js +157 -0
- package/dist/board/render/renderers/base-card-renderer.d.ts +46 -0
- package/dist/board/render/renderers/base-card-renderer.js +153 -0
- package/dist/board/render/renderers/board-renderer-registry.d.ts +85 -0
- package/dist/board/render/renderers/board-renderer-registry.js +43 -0
- package/dist/board/render/renderers/draw-card-renderer.d.ts +5 -0
- package/dist/board/render/renderers/draw-card-renderer.js +82 -0
- package/dist/board/render/renderers/far-plate.d.ts +39 -0
- package/dist/board/render/renderers/far-plate.js +92 -0
- package/dist/board/render/renderers/file-card-renderer.d.ts +33 -0
- package/dist/board/render/renderers/file-card-renderer.js +371 -0
- package/dist/board/render/renderers/frame-card-renderer.d.ts +5 -0
- package/dist/board/render/renderers/frame-card-renderer.js +99 -0
- package/dist/board/render/renderers/geo-card-renderer.d.ts +5 -0
- package/dist/board/render/renderers/geo-card-renderer.js +128 -0
- package/dist/board/render/renderers/image-card-renderer.d.ts +8 -0
- package/dist/board/render/renderers/image-card-renderer.js +138 -0
- package/dist/board/render/renderers/note-card-renderer.d.ts +5 -0
- package/dist/board/render/renderers/note-card-renderer.js +112 -0
- package/dist/board/render/renderers/text-card-renderer.d.ts +7 -0
- package/dist/board/render/renderers/text-card-renderer.js +94 -0
- package/dist/board/render/renderers/unknown-card-renderer.d.ts +5 -0
- package/dist/board/render/renderers/unknown-card-renderer.js +102 -0
- package/dist/board/render/renderers/video-card-renderer.d.ts +7 -0
- package/dist/board/render/renderers/video-card-renderer.js +146 -0
- package/dist/board/render/text-measurement.d.ts +30 -0
- package/dist/board/render/text-measurement.js +57 -0
- package/dist/board/render/text-resolution.d.ts +16 -0
- package/dist/board/render/text-resolution.js +43 -0
- package/dist/board/render/themes/board-theme-registry.d.ts +20 -0
- package/dist/board/render/themes/board-theme-registry.js +13 -0
- package/dist/board/render/themes/clean-theme.d.ts +5 -0
- package/dist/board/render/themes/clean-theme.js +102 -0
- package/dist/chunks/http.d.ts +10 -40
- package/dist/chunks/http.js +26 -4
- package/dist/chunks/transport.js +4 -2
- package/dist/chunks/websocket.d.ts +832 -21
- package/dist/chunks/websocket.js +19 -2
- package/dist/http.d.ts +2 -3
- package/dist/index.d.ts +100 -5
- package/dist/index.js +391 -1
- package/dist/protocol/dist/board-constants.d.ts +23 -0
- package/dist/protocol/dist/board-constants.js +60 -0
- package/dist/protocol/dist/board-document.d.ts +1124 -0
- package/dist/protocol/dist/board-document.js +331 -0
- package/dist/protocol/dist/board.d.ts +186 -0
- package/dist/protocol/dist/board.js +207 -0
- package/dist/protocol/dist/index.d.ts +5 -0
- package/dist/protocol/dist/index.js +5 -0
- package/dist/protocol/dist/provenance.js +12 -0
- package/dist/protocol/dist/realtime/board-awareness.d.ts +1 -0
- package/dist/protocol/dist/realtime/board-awareness.js +117 -0
- package/dist/protocol/dist/realtime/types.d.ts +2 -0
- package/package.json +39 -4
- package/dist/board.js +0 -2
- package/dist/chunks/board.d.ts +0 -2589
|
@@ -0,0 +1,468 @@
|
|
|
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
|
+
const RESIZE_HANDLES = [
|
|
201
|
+
"nw",
|
|
202
|
+
"n",
|
|
203
|
+
"ne",
|
|
204
|
+
"e",
|
|
205
|
+
"se",
|
|
206
|
+
"s",
|
|
207
|
+
"sw",
|
|
208
|
+
"w"
|
|
209
|
+
];
|
|
210
|
+
/**
|
|
211
|
+
* Hit-test the continuous edge zones of a rotated frame. Corners are inset so
|
|
212
|
+
* their resize handles retain priority even on small nodes.
|
|
213
|
+
*/
|
|
214
|
+
function frameEdgeHandleAt(frame, point, zoom, screenRadius = 8) {
|
|
215
|
+
const rect = frameRect(frame);
|
|
216
|
+
const center = rectCenter(rect);
|
|
217
|
+
const local = frame.rotation ? rotatePointAround(point, center, -degToRad(frame.rotation)) : point;
|
|
218
|
+
const radius = screenRadius / Math.max(zoom, 1e-4);
|
|
219
|
+
const insetX = Math.min(rect.width / 3, radius * 1.5);
|
|
220
|
+
const insetY = Math.min(rect.height / 3, radius * 1.5);
|
|
221
|
+
let closest = null;
|
|
222
|
+
let closestDistance = Number.POSITIVE_INFINITY;
|
|
223
|
+
if (local.x >= rect.x + insetX && local.x <= rect.x + rect.width - insetX) {
|
|
224
|
+
const north = Math.abs(local.y - rect.y);
|
|
225
|
+
const south = Math.abs(local.y - (rect.y + rect.height));
|
|
226
|
+
if (north <= radius && north < closestDistance) {
|
|
227
|
+
closest = "n";
|
|
228
|
+
closestDistance = north;
|
|
229
|
+
}
|
|
230
|
+
if (south <= radius && south < closestDistance) {
|
|
231
|
+
closest = "s";
|
|
232
|
+
closestDistance = south;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (local.y >= rect.y + insetY && local.y <= rect.y + rect.height - insetY) {
|
|
236
|
+
const west = Math.abs(local.x - rect.x);
|
|
237
|
+
const east = Math.abs(local.x - (rect.x + rect.width));
|
|
238
|
+
if (west <= radius && west < closestDistance) {
|
|
239
|
+
closest = "w";
|
|
240
|
+
closestDistance = west;
|
|
241
|
+
}
|
|
242
|
+
if (east <= radius && east < closestDistance) closest = "e";
|
|
243
|
+
}
|
|
244
|
+
return closest;
|
|
245
|
+
}
|
|
246
|
+
/** Direction of a handle from the rect center: -1, 0, or 1 per axis. */
|
|
247
|
+
const HANDLE_DIRECTION = {
|
|
248
|
+
nw: {
|
|
249
|
+
x: -1,
|
|
250
|
+
y: -1
|
|
251
|
+
},
|
|
252
|
+
n: {
|
|
253
|
+
x: 0,
|
|
254
|
+
y: -1
|
|
255
|
+
},
|
|
256
|
+
ne: {
|
|
257
|
+
x: 1,
|
|
258
|
+
y: -1
|
|
259
|
+
},
|
|
260
|
+
e: {
|
|
261
|
+
x: 1,
|
|
262
|
+
y: 0
|
|
263
|
+
},
|
|
264
|
+
se: {
|
|
265
|
+
x: 1,
|
|
266
|
+
y: 1
|
|
267
|
+
},
|
|
268
|
+
s: {
|
|
269
|
+
x: 0,
|
|
270
|
+
y: 1
|
|
271
|
+
},
|
|
272
|
+
sw: {
|
|
273
|
+
x: -1,
|
|
274
|
+
y: 1
|
|
275
|
+
},
|
|
276
|
+
w: {
|
|
277
|
+
x: -1,
|
|
278
|
+
y: 0
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
/** Position of a handle on an unrotated rect. */
|
|
282
|
+
function handlePosition(rect, handle) {
|
|
283
|
+
const direction = HANDLE_DIRECTION[handle];
|
|
284
|
+
return worldPoint(rect.x + rect.width / 2 + direction.x * rect.width / 2, rect.y + rect.height / 2 + direction.y * rect.height / 2);
|
|
285
|
+
}
|
|
286
|
+
/** Resize a frame to exact dimensions while keeping the opposite handle fixed. */
|
|
287
|
+
function resizeFrameToSize(frame, handle, width, height) {
|
|
288
|
+
const direction = HANDLE_DIRECTION[handle];
|
|
289
|
+
const rect = frameRect(frame);
|
|
290
|
+
const center = rectCenter(rect);
|
|
291
|
+
const rad = degToRad(frame.rotation || 0);
|
|
292
|
+
const anchorLocal = {
|
|
293
|
+
x: -direction.x * rect.width / 2,
|
|
294
|
+
y: -direction.y * rect.height / 2
|
|
295
|
+
};
|
|
296
|
+
const anchor = rotatePointAround({
|
|
297
|
+
x: center.x + anchorLocal.x,
|
|
298
|
+
y: center.y + anchorLocal.y
|
|
299
|
+
}, center, rad);
|
|
300
|
+
const centerLocal = {
|
|
301
|
+
x: direction.x * width / 2,
|
|
302
|
+
y: direction.y * height / 2
|
|
303
|
+
};
|
|
304
|
+
const nextCenter = rotatePointAround({
|
|
305
|
+
x: anchor.x + centerLocal.x,
|
|
306
|
+
y: anchor.y + centerLocal.y
|
|
307
|
+
}, anchor, rad);
|
|
308
|
+
return {
|
|
309
|
+
x: nextCenter.x - width / 2,
|
|
310
|
+
y: nextCenter.y - height / 2,
|
|
311
|
+
width,
|
|
312
|
+
height,
|
|
313
|
+
rotation: frame.rotation || 0
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Resize a frame by dragging a handle to a world-space pointer position.
|
|
318
|
+
* The edge/corner opposite the handle stays anchored. Works correctly for
|
|
319
|
+
* rotated frames by performing the resize in the frame's local space.
|
|
320
|
+
*/
|
|
321
|
+
function resizeFrame(frame, handle, pointer, minSize = 24, keepAspect = false) {
|
|
322
|
+
const direction = HANDLE_DIRECTION[handle];
|
|
323
|
+
const rect = frameRect(frame);
|
|
324
|
+
const center = rectCenter(rect);
|
|
325
|
+
const rad = degToRad(frame.rotation || 0);
|
|
326
|
+
const aspect = rect.width / Math.max(rect.height, 1e-4);
|
|
327
|
+
const anchorLocal = {
|
|
328
|
+
x: -direction.x * rect.width / 2,
|
|
329
|
+
y: -direction.y * rect.height / 2
|
|
330
|
+
};
|
|
331
|
+
const anchor = rotatePointAround({
|
|
332
|
+
x: center.x + anchorLocal.x,
|
|
333
|
+
y: center.y + anchorLocal.y
|
|
334
|
+
}, center, rad);
|
|
335
|
+
const toLocal = rotatePointAround(pointer, anchor, -rad);
|
|
336
|
+
const local = {
|
|
337
|
+
x: toLocal.x - anchor.x,
|
|
338
|
+
y: toLocal.y - anchor.y
|
|
339
|
+
};
|
|
340
|
+
let width = direction.x !== 0 ? clamp(direction.x * local.x, minSize, Number.POSITIVE_INFINITY) : rect.width;
|
|
341
|
+
let height = direction.y !== 0 ? clamp(direction.y * local.y, minSize, Number.POSITIVE_INFINITY) : rect.height;
|
|
342
|
+
if (keepAspect) {
|
|
343
|
+
if (direction.x !== 0 && direction.y !== 0) if (Math.abs(width / aspect) > height) height = width / aspect;
|
|
344
|
+
else width = height * aspect;
|
|
345
|
+
else if (direction.x !== 0) height = width / aspect;
|
|
346
|
+
else if (direction.y !== 0) width = height * aspect;
|
|
347
|
+
width = Math.max(minSize, width);
|
|
348
|
+
height = Math.max(minSize, height);
|
|
349
|
+
}
|
|
350
|
+
return resizeFrameToSize(frame, handle, width, height);
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Proportionally scale a group of frames by dragging a corner handle of their
|
|
354
|
+
* combined bounds. Individual rotations are preserved.
|
|
355
|
+
*/
|
|
356
|
+
function scaleFrames(frames, bounds, handle, pointer, minSize = 24, scaleRange) {
|
|
357
|
+
const direction = HANDLE_DIRECTION[handle];
|
|
358
|
+
if (direction.x === 0 || direction.y === 0) return frames;
|
|
359
|
+
const anchor = {
|
|
360
|
+
x: bounds.x + bounds.width / 2 - direction.x * bounds.width / 2,
|
|
361
|
+
y: bounds.y + bounds.height / 2 - direction.y * bounds.height / 2
|
|
362
|
+
};
|
|
363
|
+
const original = handlePosition(bounds, handle);
|
|
364
|
+
const originalDist = Math.hypot(original.x - anchor.x, original.y - anchor.y);
|
|
365
|
+
const nextDist = Math.hypot(pointer.x - anchor.x, pointer.y - anchor.y);
|
|
366
|
+
if (originalDist === 0) return frames;
|
|
367
|
+
const frameMinScale = frames.reduce((minimum, frame) => Math.max(minimum, minSize / Math.max(1e-4, Math.min(frame.width, frame.height))), 0);
|
|
368
|
+
const minScale = Math.max(frameMinScale, scaleRange?.min ?? 0);
|
|
369
|
+
const maxScale = Math.max(minScale, scaleRange?.max ?? Number.POSITIVE_INFINITY);
|
|
370
|
+
const scale = clamp(nextDist / originalDist, minScale, maxScale);
|
|
371
|
+
return frames.map((frame) => {
|
|
372
|
+
const center = rectCenter(frameRect(frame));
|
|
373
|
+
const nextCenter = {
|
|
374
|
+
x: anchor.x + (center.x - anchor.x) * scale,
|
|
375
|
+
y: anchor.y + (center.y - anchor.y) * scale
|
|
376
|
+
};
|
|
377
|
+
const width = frame.width * scale;
|
|
378
|
+
const height = frame.height * scale;
|
|
379
|
+
return {
|
|
380
|
+
x: nextCenter.x - width / 2,
|
|
381
|
+
y: nextCenter.y - height / 2,
|
|
382
|
+
width,
|
|
383
|
+
height,
|
|
384
|
+
rotation: frame.rotation || 0
|
|
385
|
+
};
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
/** Angle (degrees) from a center to a world-space pointer. */
|
|
389
|
+
function angleFromCenter(center, pointer) {
|
|
390
|
+
return radToDeg(Math.atan2(pointer.y - center.y, pointer.x - center.x));
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Rotate a set of frames around a pivot by a delta (degrees). Each frame's
|
|
394
|
+
* center orbits the pivot and its own rotation increases by the delta.
|
|
395
|
+
*/
|
|
396
|
+
function rotateFrames(frames, pivot, deltaDeg) {
|
|
397
|
+
const rad = degToRad(deltaDeg);
|
|
398
|
+
return frames.map((frame) => {
|
|
399
|
+
const nextCenter = rotatePointAround(rectCenter(frameRect(frame)), pivot, rad);
|
|
400
|
+
return {
|
|
401
|
+
x: nextCenter.x - frame.width / 2,
|
|
402
|
+
y: nextCenter.y - frame.height / 2,
|
|
403
|
+
width: frame.width,
|
|
404
|
+
height: frame.height,
|
|
405
|
+
rotation: (frame.rotation || 0) + deltaDeg
|
|
406
|
+
};
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
/** Normalize a rotation into the [-180, 180] range for tidy persistence. */
|
|
410
|
+
function normalizeRotation(degrees) {
|
|
411
|
+
let value = degrees % 360;
|
|
412
|
+
if (value > 180) value -= 360;
|
|
413
|
+
if (value < -180) value += 360;
|
|
414
|
+
return Math.abs(value) < .01 ? 0 : value;
|
|
415
|
+
}
|
|
416
|
+
/** Convert a surface-relative screen point into world space. */
|
|
417
|
+
function pointToWorld(point, viewport) {
|
|
418
|
+
return worldPoint((point.x - viewport.x) / viewport.zoom, (point.y - viewport.y) / viewport.zoom);
|
|
419
|
+
}
|
|
420
|
+
function worldToScreen(point, viewport) {
|
|
421
|
+
return screenPoint(point.x * viewport.zoom + viewport.x, point.y * viewport.zoom + viewport.y);
|
|
422
|
+
}
|
|
423
|
+
function screenToWorld(clientX, clientY, rect, viewport) {
|
|
424
|
+
return pointToWorld(screenPoint(clientX - rect.left, clientY - rect.top), viewport);
|
|
425
|
+
}
|
|
426
|
+
/** Zoom while keeping a surface-relative screen point fixed under the cursor. */
|
|
427
|
+
function zoomAround(viewport, anchor, nextZoom) {
|
|
428
|
+
const current = normalizeViewport(viewport);
|
|
429
|
+
const zoom = Number.isFinite(nextZoom) ? clampZoom(nextZoom) : current.zoom;
|
|
430
|
+
const world = pointToWorld(anchor, current);
|
|
431
|
+
return {
|
|
432
|
+
x: anchor.x - world.x * zoom,
|
|
433
|
+
y: anchor.y - world.y * zoom,
|
|
434
|
+
zoom
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
function panBy(viewport, dx, dy) {
|
|
438
|
+
return {
|
|
439
|
+
...viewport,
|
|
440
|
+
x: viewport.x + dx,
|
|
441
|
+
y: viewport.y + dy
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
/** Compute a viewport that frames the given world rect within the surface. */
|
|
445
|
+
function fitToContent(content, surface, padding = 64) {
|
|
446
|
+
const availableW = Math.max(1, surface.width - padding * 2);
|
|
447
|
+
const availableH = Math.max(1, surface.height - padding * 2);
|
|
448
|
+
const zoom = clampZoom(Math.min(availableW / content.width, availableH / content.height));
|
|
449
|
+
return {
|
|
450
|
+
x: surface.width / 2 - (content.x + content.width / 2) * zoom,
|
|
451
|
+
y: surface.height / 2 - (content.y + content.height / 2) * zoom,
|
|
452
|
+
zoom
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/** World-space rectangle currently visible in the board stage. */
|
|
456
|
+
function visibleWorldRect(viewport, surfaceWidth, surfaceHeight) {
|
|
457
|
+
const width = Math.max(0, surfaceWidth);
|
|
458
|
+
const height = Math.max(0, surfaceHeight);
|
|
459
|
+
const zoom = viewport.zoom || 1;
|
|
460
|
+
return {
|
|
461
|
+
x: -viewport.x / zoom,
|
|
462
|
+
y: -viewport.y / zoom,
|
|
463
|
+
width: width / zoom,
|
|
464
|
+
height: height / zoom
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
//#endregion
|
|
468
|
+
export { CORNER_RESIZE_HANDLES, 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, 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 };
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import "../../protocol/dist/board-constants.js";
|
|
2
|
+
import { installBoardTextMeasurement } from "../render/text-measurement.js";
|
|
3
|
+
import { renderBoardExport } from "../export/index.js";
|
|
4
|
+
//#region src/board/headless/index.ts
|
|
5
|
+
/**
|
|
6
|
+
* A minimal WebGL stand-in.
|
|
7
|
+
*
|
|
8
|
+
* `ParticleContainerPipe` builds a `GlProgram` when the renderer registers its
|
|
9
|
+
* pipes, and that probes shader precision through a throwaway context — even
|
|
10
|
+
* though the Canvas2D backend never draws with it. Returning a stub keeps
|
|
11
|
+
* registration working; nothing here is ever used to render.
|
|
12
|
+
*/
|
|
13
|
+
const WEBGL_PROBE_STUB = {
|
|
14
|
+
getShaderPrecisionFormat: () => ({
|
|
15
|
+
precision: 1,
|
|
16
|
+
rangeMin: 1,
|
|
17
|
+
rangeMax: 1
|
|
18
|
+
}),
|
|
19
|
+
getExtension: () => null,
|
|
20
|
+
getParameter: () => 0
|
|
21
|
+
};
|
|
22
|
+
function isWebGLContext(kind) {
|
|
23
|
+
return kind.startsWith("webgl") || kind === "experimental-webgl";
|
|
24
|
+
}
|
|
25
|
+
async function loadCanvasModule() {
|
|
26
|
+
try {
|
|
27
|
+
return await import("@napi-rs/canvas");
|
|
28
|
+
} catch (cause) {
|
|
29
|
+
throw new Error("Board image export needs the optional '@napi-rs/canvas' package. Install it with: npm i @napi-rs/canvas", { cause });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Node has no rAF; Pixi's scheduler only needs a callback pump. */
|
|
33
|
+
function installAnimationFrameShim() {
|
|
34
|
+
const globals = globalThis;
|
|
35
|
+
if (globals.requestAnimationFrame) return () => {};
|
|
36
|
+
const timers = /* @__PURE__ */ new Set();
|
|
37
|
+
globals.requestAnimationFrame = (cb) => {
|
|
38
|
+
const timer = setTimeout(() => {
|
|
39
|
+
timers.delete(timer);
|
|
40
|
+
cb(Date.now());
|
|
41
|
+
}, 16);
|
|
42
|
+
timer.unref?.();
|
|
43
|
+
timers.add(timer);
|
|
44
|
+
return timer;
|
|
45
|
+
};
|
|
46
|
+
globals.cancelAnimationFrame = (id) => {
|
|
47
|
+
clearTimeout(id);
|
|
48
|
+
timers.delete(id);
|
|
49
|
+
};
|
|
50
|
+
return () => {
|
|
51
|
+
for (const timer of timers) clearTimeout(timer);
|
|
52
|
+
timers.clear();
|
|
53
|
+
globals.requestAnimationFrame = void 0;
|
|
54
|
+
globals.cancelAnimationFrame = void 0;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Create a Canvas2D renderer backed by Skia.
|
|
59
|
+
*
|
|
60
|
+
* `skipExtensionImports` is essential: it stops Pixi from auto-loading its
|
|
61
|
+
* browser environment bundle, which would pull in DOM-only pipes.
|
|
62
|
+
*/
|
|
63
|
+
async function createBoardHeadlessRenderer(options = {}) {
|
|
64
|
+
const canvasModule = options.canvasModule ?? await loadCanvasModule();
|
|
65
|
+
const { CanvasGraphicsContextSystem, CanvasGraphicsPipe, CanvasRenderer, CanvasRendererTextSystem, CanvasTextPipe, CanvasTextSystem, DOMAdapter, extensions, GraphicsContextSystem, GraphicsPipe, ImageSource, Texture } = await import("pixi.js");
|
|
66
|
+
extensions.add(CanvasGraphicsPipe, GraphicsPipe, CanvasGraphicsContextSystem, GraphicsContextSystem, CanvasRendererTextSystem, CanvasTextSystem, CanvasTextPipe);
|
|
67
|
+
for (const font of options.fonts ?? []) canvasModule.GlobalFonts.registerFromPath(font.path, font.family ?? "Geist");
|
|
68
|
+
function createCanvas(width = 1, height = 1) {
|
|
69
|
+
const canvas = canvasModule.createCanvas(Math.max(1, Math.ceil(width)), Math.max(1, Math.ceil(height)));
|
|
70
|
+
const nativeGetContext = canvas.getContext.bind(canvas);
|
|
71
|
+
canvas.getContext = (kind, ...rest) => isWebGLContext(String(kind)) ? WEBGL_PROBE_STUB : nativeGetContext(kind, ...rest);
|
|
72
|
+
return canvas;
|
|
73
|
+
}
|
|
74
|
+
DOMAdapter.set({
|
|
75
|
+
createCanvas: (width, height) => createCanvas(width, height),
|
|
76
|
+
createImage: () => new canvasModule.Image(),
|
|
77
|
+
getCanvasRenderingContext2D: () => createCanvas(1, 1).getContext("2d").constructor,
|
|
78
|
+
getWebGLRenderingContext: () => WEBGL_PROBE_STUB,
|
|
79
|
+
getNavigator: () => ({
|
|
80
|
+
userAgent: "cohub-board-headless",
|
|
81
|
+
gpu: null
|
|
82
|
+
}),
|
|
83
|
+
getBaseUrl: () => "file://",
|
|
84
|
+
getFontFaceSet: () => null,
|
|
85
|
+
fetch: (url, init) => fetch(url, init),
|
|
86
|
+
parseXML: () => {
|
|
87
|
+
throw new Error("XML parsing is not available in headless board export.");
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
installBoardTextMeasurement();
|
|
91
|
+
const restoreAnimationFrame = installAnimationFrameShim();
|
|
92
|
+
const renderer = new CanvasRenderer();
|
|
93
|
+
await renderer.init({
|
|
94
|
+
width: 1,
|
|
95
|
+
height: 1,
|
|
96
|
+
backgroundAlpha: 0,
|
|
97
|
+
antialias: true,
|
|
98
|
+
resolution: 1,
|
|
99
|
+
skipExtensionImports: true
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
renderer,
|
|
103
|
+
canvasModule,
|
|
104
|
+
decodeImage: async (bytes, mimeType = "image/png") => {
|
|
105
|
+
const image = new canvasModule.Image();
|
|
106
|
+
const base64 = Buffer.from(bytes).toString("base64");
|
|
107
|
+
await new Promise((resolve, reject) => {
|
|
108
|
+
image.onload = () => resolve();
|
|
109
|
+
image.onerror = (error) => reject(error);
|
|
110
|
+
image.src = `data:${mimeType};base64,${base64}`;
|
|
111
|
+
});
|
|
112
|
+
return new Texture({ source: new ImageSource({
|
|
113
|
+
resource: image,
|
|
114
|
+
width: image.width,
|
|
115
|
+
height: image.height
|
|
116
|
+
}) });
|
|
117
|
+
},
|
|
118
|
+
destroy: () => {
|
|
119
|
+
renderer.destroy();
|
|
120
|
+
restoreAnimationFrame();
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const MIME = {
|
|
125
|
+
png: "image/png",
|
|
126
|
+
jpeg: "image/jpeg",
|
|
127
|
+
webp: "image/webp"
|
|
128
|
+
};
|
|
129
|
+
function boardHeadlessMimeType(format) {
|
|
130
|
+
return MIME[format];
|
|
131
|
+
}
|
|
132
|
+
/** Render a document to encoded image bytes. Returns null for an empty region. */
|
|
133
|
+
function exportBoardImageBytes(headless, document, options = {}) {
|
|
134
|
+
const { format = "png", quality = .92, textures, ...rest } = options;
|
|
135
|
+
const result = renderBoardExport(headless.renderer, document, {
|
|
136
|
+
...rest,
|
|
137
|
+
...textures ? { textures } : {}
|
|
138
|
+
});
|
|
139
|
+
if (!result) return null;
|
|
140
|
+
const canvas = result.canvas;
|
|
141
|
+
const bytes = format === "png" ? canvas.toBuffer(MIME.png) : canvas.toBuffer(MIME[format], Math.round(quality * 100));
|
|
142
|
+
return {
|
|
143
|
+
bytes: new Uint8Array(bytes),
|
|
144
|
+
plan: result.plan,
|
|
145
|
+
warnings: result.warnings,
|
|
146
|
+
format
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
//#endregion
|
|
150
|
+
export { boardHeadlessMimeType, createBoardHeadlessRenderer, exportBoardImageBytes };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { BoardItem } from "../protocol/dist/board-document.js";
|
|
2
|
+
//#region src/board/image-key.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Stable cache key for an item's image resource, or null when it has none.
|
|
5
|
+
*
|
|
6
|
+
* Space files and remote URLs are namespaced so they can never collide, and file
|
|
7
|
+
* cards contribute their cover image — which means covers ride the exact same
|
|
8
|
+
* reference counting, LRU cooling pool and viewport preloading as image nodes,
|
|
9
|
+
* with no second loader to keep in step. The exporter resolves the same keys, so
|
|
10
|
+
* an exported card shows the same picture the editor does.
|
|
11
|
+
*/
|
|
12
|
+
declare function imageAssetKey(item: BoardItem): string | null;
|
|
13
|
+
/** The source a key resolves from, recovered from its namespace prefix. */
|
|
14
|
+
declare function boardImageKeySource(key: string): {
|
|
15
|
+
kind: "file" | "url";
|
|
16
|
+
value: string;
|
|
17
|
+
} | null;
|
|
18
|
+
//#endregion
|
|
19
|
+
export { boardImageKeySource, imageAssetKey };
|