@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,183 @@
|
|
|
1
|
+
//#region src/board/core/draw-geometry.ts
|
|
2
|
+
/** Radius of a sample in world units given the stroke size and pressure. */
|
|
3
|
+
function sampleRadius(size, pressure) {
|
|
4
|
+
const clamped = Math.min(1, Math.max(0, pressure));
|
|
5
|
+
return Math.max(.5, size / 2 * (.5 + clamped));
|
|
6
|
+
}
|
|
7
|
+
/** Axis-aligned bounds of a stroke in its local space, padded by stroke width. */
|
|
8
|
+
function computeDrawBounds(points, size) {
|
|
9
|
+
if (points.length === 0) return {
|
|
10
|
+
x: 0,
|
|
11
|
+
y: 0,
|
|
12
|
+
width: 1,
|
|
13
|
+
height: 1
|
|
14
|
+
};
|
|
15
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
16
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
17
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
18
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
19
|
+
for (const point of points) {
|
|
20
|
+
const r = sampleRadius(size, point.p);
|
|
21
|
+
minX = Math.min(minX, point.x - r);
|
|
22
|
+
minY = Math.min(minY, point.y - r);
|
|
23
|
+
maxX = Math.max(maxX, point.x + r);
|
|
24
|
+
maxY = Math.max(maxY, point.y + r);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
x: minX,
|
|
28
|
+
y: minY,
|
|
29
|
+
width: Math.max(1, maxX - minX),
|
|
30
|
+
height: Math.max(1, maxY - minY)
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Ramer–Douglas–Peucker simplification. Reduces point count for low-zoom
|
|
35
|
+
* rendering without touching the persisted raw samples. Returns indices into
|
|
36
|
+
* the input so callers can keep pressure alongside the simplified path.
|
|
37
|
+
*/
|
|
38
|
+
function simplifyDrawIndices(points, tolerance) {
|
|
39
|
+
const n = points.length;
|
|
40
|
+
if (n <= 2 || tolerance <= 0) return points.map((_, i) => i);
|
|
41
|
+
const keep = new Array(n).fill(false);
|
|
42
|
+
keep[0] = true;
|
|
43
|
+
keep[n - 1] = true;
|
|
44
|
+
const stack = [[0, n - 1]];
|
|
45
|
+
while (stack.length > 0) {
|
|
46
|
+
const segment = stack.pop();
|
|
47
|
+
if (!segment) break;
|
|
48
|
+
const [start, end] = segment;
|
|
49
|
+
let maxDist = -1;
|
|
50
|
+
let index = -1;
|
|
51
|
+
const a = points[start];
|
|
52
|
+
const b = points[end];
|
|
53
|
+
if (!a || !b) continue;
|
|
54
|
+
for (let i = start + 1; i < end; i += 1) {
|
|
55
|
+
const point = points[i];
|
|
56
|
+
if (!point) continue;
|
|
57
|
+
const d = perpendicularDistance(point, a, b);
|
|
58
|
+
if (d > maxDist) {
|
|
59
|
+
maxDist = d;
|
|
60
|
+
index = i;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (maxDist > tolerance && index !== -1) {
|
|
64
|
+
keep[index] = true;
|
|
65
|
+
stack.push([start, index], [index, end]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const out = [];
|
|
69
|
+
for (let i = 0; i < n; i += 1) if (keep[i]) out.push(i);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
function perpendicularDistance(point, a, b) {
|
|
73
|
+
const dx = b.x - a.x;
|
|
74
|
+
const dy = b.y - a.y;
|
|
75
|
+
const lengthSq = dx * dx + dy * dy;
|
|
76
|
+
if (lengthSq === 0) return Math.hypot(point.x - a.x, point.y - a.y);
|
|
77
|
+
const t = ((point.x - a.x) * dx + (point.y - a.y) * dy) / lengthSq;
|
|
78
|
+
const projX = a.x + t * dx;
|
|
79
|
+
const projY = a.y + t * dy;
|
|
80
|
+
return Math.hypot(point.x - projX, point.y - projY);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Build a closed outline polygon for a variable-width stroke. The left side is
|
|
84
|
+
* offset outward along each segment normal by the sample radius, the right side
|
|
85
|
+
* mirrored, producing a smooth ribbon that widens with pressure. Suitable for a
|
|
86
|
+
* filled Pixi polygon. Returns an empty array for degenerate input.
|
|
87
|
+
*/
|
|
88
|
+
function buildStrokeOutline(points, size) {
|
|
89
|
+
const n = points.length;
|
|
90
|
+
if (n === 0) return [];
|
|
91
|
+
const first = points[0];
|
|
92
|
+
if (!first) return [];
|
|
93
|
+
if (n === 1) {
|
|
94
|
+
const r = sampleRadius(size, first.p);
|
|
95
|
+
const p = first;
|
|
96
|
+
const k = r * Math.SQRT1_2;
|
|
97
|
+
return [
|
|
98
|
+
{
|
|
99
|
+
x: p.x,
|
|
100
|
+
y: p.y - r
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
x: p.x + k,
|
|
104
|
+
y: p.y - k
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
x: p.x + r,
|
|
108
|
+
y: p.y
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
x: p.x + k,
|
|
112
|
+
y: p.y + k
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
x: p.x,
|
|
116
|
+
y: p.y + r
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
x: p.x - k,
|
|
120
|
+
y: p.y + k
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
x: p.x - r,
|
|
124
|
+
y: p.y
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
x: p.x - k,
|
|
128
|
+
y: p.y - k
|
|
129
|
+
}
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
const left = [];
|
|
133
|
+
const right = [];
|
|
134
|
+
for (let i = 0; i < n; i += 1) {
|
|
135
|
+
const current = points[i];
|
|
136
|
+
const prev = points[Math.max(0, i - 1)] ?? current;
|
|
137
|
+
const next = points[Math.min(n - 1, i + 1)] ?? current;
|
|
138
|
+
if (!current || !prev || !next) continue;
|
|
139
|
+
let dx = next.x - prev.x;
|
|
140
|
+
let dy = next.y - prev.y;
|
|
141
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
142
|
+
dx /= len;
|
|
143
|
+
dy /= len;
|
|
144
|
+
const nx = -dy;
|
|
145
|
+
const ny = dx;
|
|
146
|
+
const r = sampleRadius(size, current.p);
|
|
147
|
+
left.push({
|
|
148
|
+
x: current.x + nx * r,
|
|
149
|
+
y: current.y + ny * r
|
|
150
|
+
});
|
|
151
|
+
right.push({
|
|
152
|
+
x: current.x - nx * r,
|
|
153
|
+
y: current.y - ny * r
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return [...left, ...right.reverse()];
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Distance from a world point to the stroke's polyline, in the shape's local
|
|
160
|
+
* space. Used for hit testing: a hit registers within half the stroke width plus
|
|
161
|
+
* a small tolerance. `local` is the point expressed in the draw item's frame.
|
|
162
|
+
*/
|
|
163
|
+
function distanceToStroke(points, local) {
|
|
164
|
+
if (points.length === 0) return Number.POSITIVE_INFINITY;
|
|
165
|
+
const first = points[0];
|
|
166
|
+
if (!first) return Number.POSITIVE_INFINITY;
|
|
167
|
+
if (points.length === 1) return Math.hypot(local.x - first.x, local.y - first.y);
|
|
168
|
+
let min = Number.POSITIVE_INFINITY;
|
|
169
|
+
for (let i = 0; i < points.length - 1; i += 1) {
|
|
170
|
+
const from = points[i];
|
|
171
|
+
const to = points[i + 1];
|
|
172
|
+
if (!from || !to) continue;
|
|
173
|
+
const d = perpendicularDistance({
|
|
174
|
+
x: local.x,
|
|
175
|
+
y: local.y,
|
|
176
|
+
p: 0
|
|
177
|
+
}, from, to);
|
|
178
|
+
if (d < min) min = d;
|
|
179
|
+
}
|
|
180
|
+
return min;
|
|
181
|
+
}
|
|
182
|
+
//#endregion
|
|
183
|
+
export { buildStrokeOutline, computeDrawBounds, distanceToStroke, sampleRadius, simplifyDrawIndices };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { BoardItem } from "../../protocol/dist/board-document.js";
|
|
2
|
+
//#region src/board/core/export-assets.d.ts
|
|
3
|
+
/** Bound source textures independently from the final output bitmap budget. */
|
|
4
|
+
declare const BOARD_EXPORT_MAX_TEXTURES = 64;
|
|
5
|
+
type BoardExportAssetSelection = {
|
|
6
|
+
/** One representative item for each selected preview key. */
|
|
7
|
+
items: BoardItem[];
|
|
8
|
+
keys: string[];
|
|
9
|
+
omittedKeys: string[];
|
|
10
|
+
};
|
|
11
|
+
/** Select a stable, document-ordered set of unique preview assets to load. */
|
|
12
|
+
declare function selectBoardExportAssets(items: BoardItem[], assetKey: (item: BoardItem) => string | null, maxTextures?: number): BoardExportAssetSelection;
|
|
13
|
+
//#endregion
|
|
14
|
+
export { BOARD_EXPORT_MAX_TEXTURES, BoardExportAssetSelection, selectBoardExportAssets };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/board/core/export-assets.ts
|
|
2
|
+
/** Bound source textures independently from the final output bitmap budget. */
|
|
3
|
+
const BOARD_EXPORT_MAX_TEXTURES = 64;
|
|
4
|
+
/** Select a stable, document-ordered set of unique preview assets to load. */
|
|
5
|
+
function selectBoardExportAssets(items, assetKey, maxTextures = 64) {
|
|
6
|
+
const limit = Number.isSafeInteger(maxTextures) ? Math.max(0, maxTextures) : 64;
|
|
7
|
+
const selected = /* @__PURE__ */ new Map();
|
|
8
|
+
const omitted = /* @__PURE__ */ new Set();
|
|
9
|
+
for (const item of items) {
|
|
10
|
+
const key = assetKey(item);
|
|
11
|
+
if (!key || selected.has(key) || omitted.has(key)) continue;
|
|
12
|
+
if (selected.size < limit) selected.set(key, item);
|
|
13
|
+
else omitted.add(key);
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
items: [...selected.values()],
|
|
17
|
+
keys: [...selected.keys()],
|
|
18
|
+
omittedKeys: [...omitted]
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
export { BOARD_EXPORT_MAX_TEXTURES, selectBoardExportAssets };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { BoardDocument, BoardItem } from "../../protocol/dist/board-document.js";
|
|
2
|
+
import { Rect } from "../geometry.js";
|
|
3
|
+
import { FrameLookup } from "./bindings.js";
|
|
4
|
+
//#region src/board/core/export-plan.d.ts
|
|
5
|
+
/** What part of the board to capture. */
|
|
6
|
+
type BoardExportRegion =
|
|
7
|
+
/** Everything in the document. */
|
|
8
|
+
{
|
|
9
|
+
kind: "all";
|
|
10
|
+
} |
|
|
11
|
+
/** Exactly these items, whatever else overlaps them. */
|
|
12
|
+
{
|
|
13
|
+
kind: "items";
|
|
14
|
+
ids: string[];
|
|
15
|
+
} |
|
|
16
|
+
/** A frame item's rect, treated as a page: its chrome is excluded. */
|
|
17
|
+
{
|
|
18
|
+
kind: "frame";
|
|
19
|
+
id: string;
|
|
20
|
+
} |
|
|
21
|
+
/** An explicit world-space rect. */
|
|
22
|
+
{
|
|
23
|
+
kind: "rect";
|
|
24
|
+
rect: Rect;
|
|
25
|
+
};
|
|
26
|
+
type BoardExportPlanInput = {
|
|
27
|
+
document: BoardDocument;
|
|
28
|
+
region: BoardExportRegion;
|
|
29
|
+
/** Output pixels per world unit. Defaults to 2 (retina-grade). */
|
|
30
|
+
scale?: number;
|
|
31
|
+
/** World-space breathing room around the content. Frames use 0. */
|
|
32
|
+
padding?: number;
|
|
33
|
+
maxEdge?: number;
|
|
34
|
+
maxPixels?: number;
|
|
35
|
+
};
|
|
36
|
+
type BoardExportPlan = {
|
|
37
|
+
/** World-space rect captured, padding included. */
|
|
38
|
+
world: Rect;
|
|
39
|
+
/** Scale actually used, after clamping. */
|
|
40
|
+
scale: number;
|
|
41
|
+
/** Scale asked for, before clamping. */
|
|
42
|
+
requestedScale: number;
|
|
43
|
+
/** Output size in pixels. */
|
|
44
|
+
width: number;
|
|
45
|
+
height: number;
|
|
46
|
+
/** Items to draw, in document order. */
|
|
47
|
+
items: BoardItem[];
|
|
48
|
+
/** True when `scale` had to be reduced to fit the size budget. */
|
|
49
|
+
clamped: boolean;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Normalise a document before it reaches the renderers.
|
|
53
|
+
*
|
|
54
|
+
* Renderers read shape fields directly and assume schema defaults have been
|
|
55
|
+
* applied — a hand-built or partially-migrated document would otherwise reach
|
|
56
|
+
* them with missing fields. Parsing here means every entry point (CLI, web,
|
|
57
|
+
* tests) gets the same guarantee, and an invalid document fails with a schema
|
|
58
|
+
* error instead of a render-time crash.
|
|
59
|
+
*/
|
|
60
|
+
declare function normalizeBoardDocument(document: BoardDocument): BoardDocument;
|
|
61
|
+
/**
|
|
62
|
+
* Hard ceilings.
|
|
63
|
+
*
|
|
64
|
+
* `MAX_EDGE` stays under the 8192 texture limit that essentially every WebGL2
|
|
65
|
+
* and Canvas2D backend guarantees. `MAX_PIXELS` bounds peak memory instead of
|
|
66
|
+
* edge length — a 8192×8192 RGBA buffer alone is 256MB, which is enough to kill
|
|
67
|
+
* a browser tab, so the total is capped well below the square of the edge.
|
|
68
|
+
*/
|
|
69
|
+
declare const BOARD_EXPORT_MAX_EDGE = 8192;
|
|
70
|
+
declare const BOARD_EXPORT_MAX_PIXELS = 32000000;
|
|
71
|
+
declare const BOARD_EXPORT_DEFAULT_SCALE = 2;
|
|
72
|
+
declare const BOARD_EXPORT_DEFAULT_PADDING = 32;
|
|
73
|
+
/** Above this, an export is still produced but the caller is warned. */
|
|
74
|
+
declare const BOARD_EXPORT_ITEM_WARN_THRESHOLD = 2000;
|
|
75
|
+
declare function boardFrameLookup(document: BoardDocument): FrameLookup;
|
|
76
|
+
/** Bounds of a single item, resolving arrow endpoints through their bindings. */
|
|
77
|
+
declare function exportItemBounds(item: BoardItem, getFrame: FrameLookup): Rect;
|
|
78
|
+
/**
|
|
79
|
+
* Resolve a region into a concrete capture plan, or null when there is nothing
|
|
80
|
+
* to draw. Callers treat null as "empty selection", not as an error.
|
|
81
|
+
*/
|
|
82
|
+
declare function planBoardExport(input: BoardExportPlanInput): BoardExportPlan | null;
|
|
83
|
+
//#endregion
|
|
84
|
+
export { BOARD_EXPORT_DEFAULT_PADDING, BOARD_EXPORT_DEFAULT_SCALE, BOARD_EXPORT_ITEM_WARN_THRESHOLD, BOARD_EXPORT_MAX_EDGE, BOARD_EXPORT_MAX_PIXELS, BoardExportPlan, BoardExportPlanInput, BoardExportRegion, boardFrameLookup, exportItemBounds, normalizeBoardDocument, planBoardExport };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { BoardDocumentSchema } from "../../protocol/dist/board-document.js";
|
|
2
|
+
import { itemBounds, rectsIntersect, unionRects } from "../geometry.js";
|
|
3
|
+
import { arrowBounds } from "./bindings.js";
|
|
4
|
+
//#region src/board/core/export-plan.ts
|
|
5
|
+
/**
|
|
6
|
+
* Export planning — pure geometry, no renderer.
|
|
7
|
+
*
|
|
8
|
+
* Every platform (browser, CLI) resolves *what* to capture and *how large* the
|
|
9
|
+
* result may be through this module, so a `--scale 4` on a huge board behaves
|
|
10
|
+
* identically everywhere and no caller can accidentally ask the GPU for a
|
|
11
|
+
* texture it cannot allocate.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Normalise a document before it reaches the renderers.
|
|
15
|
+
*
|
|
16
|
+
* Renderers read shape fields directly and assume schema defaults have been
|
|
17
|
+
* applied — a hand-built or partially-migrated document would otherwise reach
|
|
18
|
+
* them with missing fields. Parsing here means every entry point (CLI, web,
|
|
19
|
+
* tests) gets the same guarantee, and an invalid document fails with a schema
|
|
20
|
+
* error instead of a render-time crash.
|
|
21
|
+
*/
|
|
22
|
+
function normalizeBoardDocument(document) {
|
|
23
|
+
return BoardDocumentSchema.parse(document);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Hard ceilings.
|
|
27
|
+
*
|
|
28
|
+
* `MAX_EDGE` stays under the 8192 texture limit that essentially every WebGL2
|
|
29
|
+
* and Canvas2D backend guarantees. `MAX_PIXELS` bounds peak memory instead of
|
|
30
|
+
* edge length — a 8192×8192 RGBA buffer alone is 256MB, which is enough to kill
|
|
31
|
+
* a browser tab, so the total is capped well below the square of the edge.
|
|
32
|
+
*/
|
|
33
|
+
const BOARD_EXPORT_MAX_EDGE = 8192;
|
|
34
|
+
const BOARD_EXPORT_MAX_PIXELS = 32e6;
|
|
35
|
+
const BOARD_EXPORT_DEFAULT_SCALE = 2;
|
|
36
|
+
const BOARD_EXPORT_DEFAULT_PADDING = 32;
|
|
37
|
+
/** Above this, an export is still produced but the caller is warned. */
|
|
38
|
+
const BOARD_EXPORT_ITEM_WARN_THRESHOLD = 2e3;
|
|
39
|
+
function boardFrameLookup(document) {
|
|
40
|
+
const frames = new Map(document.items.map((item) => [item.id, item.frame]));
|
|
41
|
+
return (id) => frames.get(id);
|
|
42
|
+
}
|
|
43
|
+
/** Bounds of a single item, resolving arrow endpoints through their bindings. */
|
|
44
|
+
function exportItemBounds(item, getFrame) {
|
|
45
|
+
if (item.type === "arrow") return arrowBounds(item, getFrame) ?? itemBounds(item.frame);
|
|
46
|
+
return itemBounds(item.frame);
|
|
47
|
+
}
|
|
48
|
+
function resolveRegion(document, region, getFrame) {
|
|
49
|
+
switch (region.kind) {
|
|
50
|
+
case "all": return {
|
|
51
|
+
items: document.items,
|
|
52
|
+
rect: unionRects(document.items.map((item) => exportItemBounds(item, getFrame))),
|
|
53
|
+
padding: null
|
|
54
|
+
};
|
|
55
|
+
case "items": {
|
|
56
|
+
const wanted = new Set(region.ids);
|
|
57
|
+
const items = document.items.filter((item) => wanted.has(item.id));
|
|
58
|
+
return {
|
|
59
|
+
items,
|
|
60
|
+
rect: unionRects(items.map((item) => exportItemBounds(item, getFrame))),
|
|
61
|
+
padding: null
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
case "frame": {
|
|
65
|
+
const frame = document.items.find((item) => item.id === region.id);
|
|
66
|
+
if (!frame) return {
|
|
67
|
+
items: [],
|
|
68
|
+
rect: null,
|
|
69
|
+
padding: null
|
|
70
|
+
};
|
|
71
|
+
const rect = itemBounds(frame.frame);
|
|
72
|
+
return {
|
|
73
|
+
items: document.items.filter((item) => item.id !== region.id && rectsIntersect(exportItemBounds(item, getFrame), rect)),
|
|
74
|
+
rect,
|
|
75
|
+
padding: 0
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
case "rect": {
|
|
79
|
+
const rect = region.rect;
|
|
80
|
+
return {
|
|
81
|
+
items: document.items.filter((item) => rectsIntersect(exportItemBounds(item, getFrame), rect)),
|
|
82
|
+
rect,
|
|
83
|
+
padding: 0
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Largest scale that keeps the output inside both the edge and pixel budgets.
|
|
90
|
+
*
|
|
91
|
+
* There is deliberately no lower bound: a floor here would silently let a very
|
|
92
|
+
* large world exceed the budgets it exists to enforce (a 1e6×1e6 region at a
|
|
93
|
+
* 0.01 floor is 1e8 pixels). Callers see the reduction via `plan.clamped`.
|
|
94
|
+
*/
|
|
95
|
+
function clampScale(requested, world, maxEdge, maxPixels) {
|
|
96
|
+
const byEdge = Math.min(maxEdge / world.width, maxEdge / world.height);
|
|
97
|
+
const byArea = Math.sqrt(maxPixels / (world.width * world.height));
|
|
98
|
+
return Math.min(requested, byEdge, byArea);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Resolve a region into a concrete capture plan, or null when there is nothing
|
|
102
|
+
* to draw. Callers treat null as "empty selection", not as an error.
|
|
103
|
+
*/
|
|
104
|
+
function planBoardExport(input) {
|
|
105
|
+
const { document, region, scale: requestedScale = 2, maxEdge = BOARD_EXPORT_MAX_EDGE, maxPixels = BOARD_EXPORT_MAX_PIXELS } = input;
|
|
106
|
+
const resolved = resolveRegion(document, region, boardFrameLookup(document));
|
|
107
|
+
if (!resolved.rect) return null;
|
|
108
|
+
const padding = input.padding ?? resolved.padding ?? 32;
|
|
109
|
+
const world = {
|
|
110
|
+
x: resolved.rect.x - padding,
|
|
111
|
+
y: resolved.rect.y - padding,
|
|
112
|
+
width: Math.max(1, resolved.rect.width + padding * 2),
|
|
113
|
+
height: Math.max(1, resolved.rect.height + padding * 2)
|
|
114
|
+
};
|
|
115
|
+
const safeRequest = Number.isFinite(requestedScale) && requestedScale > 0 ? requestedScale : 2;
|
|
116
|
+
const scale = clampScale(safeRequest, world, maxEdge, maxPixels);
|
|
117
|
+
return {
|
|
118
|
+
world,
|
|
119
|
+
scale,
|
|
120
|
+
requestedScale,
|
|
121
|
+
width: Math.max(1, Math.floor(world.width * scale)),
|
|
122
|
+
height: Math.max(1, Math.floor(world.height * scale)),
|
|
123
|
+
items: resolved.items,
|
|
124
|
+
clamped: scale < safeRequest - 1e-6
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
//#endregion
|
|
128
|
+
export { BOARD_EXPORT_DEFAULT_PADDING, BOARD_EXPORT_DEFAULT_SCALE, BOARD_EXPORT_ITEM_WARN_THRESHOLD, BOARD_EXPORT_MAX_EDGE, BOARD_EXPORT_MAX_PIXELS, boardFrameLookup, exportItemBounds, normalizeBoardDocument, planBoardExport };
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
//#region src/board/core/file-preview.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* File-card preview derivation — pure, renderer-agnostic, dependency-free.
|
|
4
|
+
*
|
|
5
|
+
* A board file card is a *thumbnail entry point* to a workspace file, never a
|
|
6
|
+
* second copy of it. So everything here derives display facts from file content
|
|
7
|
+
* that the caller already has, and the resulting snapshot is treated strictly as
|
|
8
|
+
* a cache keyed by the file's mtime: the file on disk stays the single source of
|
|
9
|
+
* truth, and nothing in this module ever writes back to it.
|
|
10
|
+
*
|
|
11
|
+
* Three presentation tiers fall out of which facts are available, so an
|
|
12
|
+
* unrecognised file degrades instead of being rejected:
|
|
13
|
+
*
|
|
14
|
+
* - `cover` — a cover image was declared in markdown frontmatter;
|
|
15
|
+
* - `text` — text-like content yielded a readable excerpt;
|
|
16
|
+
* - `blank` — anything else (binary, empty, oversized): icon, name and type.
|
|
17
|
+
*/
|
|
18
|
+
/** Hard cap on a stored excerpt. Board cards show a few lines at most, and the
|
|
19
|
+
* snapshot rides along in every board transaction — so this stays small. */
|
|
20
|
+
declare const FILE_EXCERPT_MAX_CHARS = 480;
|
|
21
|
+
/** Files above this size are shown as `blank`; we never pull them for a preview. */
|
|
22
|
+
declare const FILE_EXCERPT_MAX_BYTES: number;
|
|
23
|
+
type FilePreviewKind = "cover" | "text" | "blank";
|
|
24
|
+
/**
|
|
25
|
+
* Cached display facts for a file node. Every field is derived from the file and
|
|
26
|
+
* carries `mtimeMs` so a stale snapshot is detectable rather than silently wrong.
|
|
27
|
+
*/
|
|
28
|
+
type BoardFileSnapshotFacts = {
|
|
29
|
+
title?: string;
|
|
30
|
+
mimeType?: string;
|
|
31
|
+
size?: number;
|
|
32
|
+
mtimeMs?: number;
|
|
33
|
+
/** Cleaned leading prose, capped at FILE_EXCERPT_MAX_CHARS. */
|
|
34
|
+
excerpt?: string;
|
|
35
|
+
/** Cover declared as a path inside the space, resolved against the file's dir. */
|
|
36
|
+
coverPath?: string;
|
|
37
|
+
/** Cover declared as an absolute `https:` URL. */
|
|
38
|
+
coverUrl?: string;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Which tier a card renders at. Derived rather than stored: presentation follows
|
|
42
|
+
* from the facts present, so there is no second piece of state to fall out of
|
|
43
|
+
* sync with them.
|
|
44
|
+
*/
|
|
45
|
+
declare function filePreviewKind(snapshot: BoardFileSnapshotFacts | undefined): FilePreviewKind;
|
|
46
|
+
/** Short uppercase type label for the card's meta line (`MD`, `JSON`, `FILE`). */
|
|
47
|
+
declare function fileTypeLabel(path: string): string;
|
|
48
|
+
/** Human-readable byte size for the meta line. */
|
|
49
|
+
declare function formatFileSize(bytes: number | undefined): string;
|
|
50
|
+
/**
|
|
51
|
+
* A cover reference, classified by how it must be loaded.
|
|
52
|
+
*
|
|
53
|
+
* `http:` is rejected in favour of `https:` (a board should not downgrade the
|
|
54
|
+
* page to mixed content), and `data:`/`blob:` are rejected because they would
|
|
55
|
+
* embed opaque, unbounded bytes into board data that no other client can verify.
|
|
56
|
+
* Everything else is treated as a path inside the space.
|
|
57
|
+
*/
|
|
58
|
+
type ResolvedCover = {
|
|
59
|
+
kind: "url";
|
|
60
|
+
url: string;
|
|
61
|
+
} | {
|
|
62
|
+
kind: "path";
|
|
63
|
+
path: string;
|
|
64
|
+
} | null;
|
|
65
|
+
/** Normalise a space-relative path: resolve `.`/`..` against the file's dir. */
|
|
66
|
+
declare function resolveSpacePath(fromFilePath: string, ref: string): string;
|
|
67
|
+
/**
|
|
68
|
+
* Classify a raw cover reference from frontmatter.
|
|
69
|
+
*
|
|
70
|
+
* Remote covers are allowed on purpose — a lot of real markdown points at a CDN.
|
|
71
|
+
* The trade-off is that opening such a board issues a request to that third
|
|
72
|
+
* party; the renderer degrades silently if it fails, and nothing is retried in a
|
|
73
|
+
* loop.
|
|
74
|
+
*/
|
|
75
|
+
declare function resolveCoverRef(fromFilePath: string, raw: string | undefined | null): ResolvedCover;
|
|
76
|
+
/** A frontmatter block's raw body plus the content that followed it. */
|
|
77
|
+
type SplitSource = {
|
|
78
|
+
frontmatter: string | null;
|
|
79
|
+
body: string;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Split leading YAML frontmatter from a markdown source. Deliberately minimal:
|
|
83
|
+
* this only needs the raw block so a handful of scalar keys can be read, not a
|
|
84
|
+
* YAML parser.
|
|
85
|
+
*/
|
|
86
|
+
declare function splitFrontmatter(source: string): SplitSource;
|
|
87
|
+
/**
|
|
88
|
+
* Read the first cover-ish scalar from a frontmatter block.
|
|
89
|
+
*
|
|
90
|
+
* Only top-level keys are considered (a leading-space line is nested and skipped)
|
|
91
|
+
* so `cover:` under some other mapping cannot be mistaken for the file's own.
|
|
92
|
+
*/
|
|
93
|
+
declare function readCoverFromFrontmatter(frontmatter: string | null): string | null;
|
|
94
|
+
/**
|
|
95
|
+
* Reduce source text to a short, readable excerpt.
|
|
96
|
+
*
|
|
97
|
+
* The goal is a card-sized hint of what the file is about, so markdown
|
|
98
|
+
* decoration is flattened rather than rendered: fenced code is dropped whole
|
|
99
|
+
* (it reads as noise at thumbnail size), headings and emphasis lose their
|
|
100
|
+
* markers, links keep their text, and blank runs collapse.
|
|
101
|
+
*/
|
|
102
|
+
declare function buildFileExcerpt(source: string, limit?: number): string;
|
|
103
|
+
type BuildSnapshotInput = {
|
|
104
|
+
path: string;
|
|
105
|
+
/** File text, when it could be read as text. Omit for binary/oversized. */
|
|
106
|
+
content?: string | null;
|
|
107
|
+
title?: string;
|
|
108
|
+
mimeType?: string | null;
|
|
109
|
+
size?: number;
|
|
110
|
+
mtimeMs?: number;
|
|
111
|
+
};
|
|
112
|
+
/** Basename of a path, used as the default card title. */
|
|
113
|
+
declare function fileBaseName(path: string): string;
|
|
114
|
+
/**
|
|
115
|
+
* Build the cached display facts for a file node.
|
|
116
|
+
*
|
|
117
|
+
* Content is optional by design: a snapshot built without it still produces a
|
|
118
|
+
* usable `blank` card, so a node can be created the instant a file is dropped
|
|
119
|
+
* and enriched later without blocking on a read.
|
|
120
|
+
*/
|
|
121
|
+
declare function buildFileSnapshot(input: BuildSnapshotInput): BoardFileSnapshotFacts;
|
|
122
|
+
/** Whether a snapshot's cached facts still describe the file on disk. */
|
|
123
|
+
declare function isFileSnapshotFresh(snapshot: BoardFileSnapshotFacts | undefined, file: {
|
|
124
|
+
mtimeMs?: number;
|
|
125
|
+
size?: number;
|
|
126
|
+
}): boolean;
|
|
127
|
+
/** Whether a file is small enough that fetching a text preview is worthwhile. */
|
|
128
|
+
declare function shouldFetchFileExcerpt(input: {
|
|
129
|
+
mimeType?: string | null;
|
|
130
|
+
size?: number;
|
|
131
|
+
}): boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Whether a referenced file could be read, and if not, whether that is known to
|
|
134
|
+
* be permanent.
|
|
135
|
+
*
|
|
136
|
+
* This distinction is the whole point: a board is a long-lived document, and a
|
|
137
|
+
* card should not claim a file is gone because the network blipped. It is also
|
|
138
|
+
* why availability is client-local transient state and never written to the
|
|
139
|
+
* node — see the note in board-file-preview-source.
|
|
140
|
+
*/
|
|
141
|
+
type FileAvailability = "ok" | "missing" | "unavailable";
|
|
142
|
+
/**
|
|
143
|
+
* Classify a failed read.
|
|
144
|
+
*
|
|
145
|
+
* Only a 404 (or 410 Gone) is treated as the file being absent. Everything else
|
|
146
|
+
* — offline, 5xx, timeout, 401/403 — is `unavailable`: the file may well exist and
|
|
147
|
+
* simply not be reachable by this client right now.
|
|
148
|
+
*/
|
|
149
|
+
declare function availabilityFromError(error: unknown): FileAvailability;
|
|
150
|
+
/**
|
|
151
|
+
* Cache key for a file within a space.
|
|
152
|
+
*
|
|
153
|
+
* A path only means anything relative to its space, and identical paths across
|
|
154
|
+
* spaces are the norm ("README.md"), so every preview cache is keyed by both. The
|
|
155
|
+
* separator is NUL because it cannot appear in a space id or a path, so no path can
|
|
156
|
+
* be crafted to collide with another space's entry.
|
|
157
|
+
*/
|
|
158
|
+
declare function filePreviewScope(spaceId: string, path: string): string;
|
|
159
|
+
/**
|
|
160
|
+
* Cache key for one *version* of a file. The mtime is part of the key so a changed
|
|
161
|
+
* file misses the cache instead of serving a stale excerpt or cover.
|
|
162
|
+
*/
|
|
163
|
+
declare function filePreviewMemoKey(spaceId: string, path: string, mtimeMs?: number): string;
|
|
164
|
+
/**
|
|
165
|
+
* Fold a freshly read snapshot into the cached one.
|
|
166
|
+
*
|
|
167
|
+
* `complete` decides whether the incoming facts supersede the cached ones or are
|
|
168
|
+
* merged over them, and the difference is not cosmetic. A snapshot omits fields the
|
|
169
|
+
* file does not have, so merging a complete read would resurrect a cover or an
|
|
170
|
+
* excerpt the file no longer contains — permanently, since the stale value is then
|
|
171
|
+
* committed back. Merging is right only for an incomplete read, where an absent
|
|
172
|
+
* field means "could not establish", not "not there".
|
|
173
|
+
*/
|
|
174
|
+
declare function mergeFileSnapshot(cached: BoardFileSnapshotFacts | undefined, incoming: BoardFileSnapshotFacts, complete: boolean): BoardFileSnapshotFacts;
|
|
175
|
+
//#endregion
|
|
176
|
+
export { BoardFileSnapshotFacts, BuildSnapshotInput, FILE_EXCERPT_MAX_BYTES, FILE_EXCERPT_MAX_CHARS, FileAvailability, FilePreviewKind, ResolvedCover, availabilityFromError, buildFileExcerpt, buildFileSnapshot, fileBaseName, filePreviewKind, filePreviewMemoKey, filePreviewScope, fileTypeLabel, formatFileSize, isFileSnapshotFresh, mergeFileSnapshot, readCoverFromFrontmatter, resolveCoverRef, resolveSpacePath, shouldFetchFileExcerpt, splitFrontmatter };
|