@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,82 @@
|
|
|
1
|
+
import { buildStrokeOutline, computeDrawBounds } from "../../core/draw-geometry.js";
|
|
2
|
+
import { pickBoardColor } from "../../core/palette.js";
|
|
3
|
+
import { drawFarStroke } from "./far-plate.js";
|
|
4
|
+
import { positionShell } from "./base-card-renderer.js";
|
|
5
|
+
import { Container, Graphics } from "pixi.js";
|
|
6
|
+
//#region src/board/render/renderers/draw-card-renderer.ts
|
|
7
|
+
const partsByContainer = /* @__PURE__ */ new WeakMap();
|
|
8
|
+
function sync(container, item, context) {
|
|
9
|
+
const parts = partsByContainer.get(container);
|
|
10
|
+
if (!parts) return;
|
|
11
|
+
positionShell(parts.root, item);
|
|
12
|
+
const selected = context.selectedIds.has(item.id);
|
|
13
|
+
const hovered = context.hoveredId === item.id;
|
|
14
|
+
const color = pickBoardColor(context.colors, item.color, context.colorScheme);
|
|
15
|
+
const sig = [
|
|
16
|
+
item.points.length,
|
|
17
|
+
item.size,
|
|
18
|
+
item.color,
|
|
19
|
+
selected,
|
|
20
|
+
hovered,
|
|
21
|
+
context.colorScheme,
|
|
22
|
+
color.stroke
|
|
23
|
+
].join("|");
|
|
24
|
+
if (sig !== parts.sig || item.points !== parts.points) {
|
|
25
|
+
parts.sig = sig;
|
|
26
|
+
parts.points = item.points;
|
|
27
|
+
parts.baseWidth = computeDrawBounds(item.points, item.size).width;
|
|
28
|
+
parts.stroke.clear();
|
|
29
|
+
const outline = buildStrokeOutline(item.points, item.size);
|
|
30
|
+
const origin = outline[0];
|
|
31
|
+
if (origin && outline.length >= 3) {
|
|
32
|
+
parts.stroke.moveTo(origin.x, origin.y);
|
|
33
|
+
for (let i = 1; i < outline.length; i += 1) {
|
|
34
|
+
const point = outline[i];
|
|
35
|
+
if (point) parts.stroke.lineTo(point.x, point.y);
|
|
36
|
+
}
|
|
37
|
+
parts.stroke.closePath();
|
|
38
|
+
parts.stroke.fill({
|
|
39
|
+
color: color.stroke,
|
|
40
|
+
alpha: selected || hovered ? 1 : .92
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const previewScale = item.frame.width / Math.max(1e-4, parts.baseWidth);
|
|
45
|
+
parts.stroke.scale.set(Number.isFinite(previewScale) ? previewScale : 1);
|
|
46
|
+
}
|
|
47
|
+
const drawCardRenderer = {
|
|
48
|
+
id: "draw-card",
|
|
49
|
+
canRender: (item) => item.type === "draw",
|
|
50
|
+
create: (item, context) => {
|
|
51
|
+
const root = new Container();
|
|
52
|
+
const stroke = new Graphics();
|
|
53
|
+
root.addChild(stroke);
|
|
54
|
+
partsByContainer.set(root, {
|
|
55
|
+
root,
|
|
56
|
+
stroke,
|
|
57
|
+
sig: "",
|
|
58
|
+
points: null,
|
|
59
|
+
baseWidth: 0
|
|
60
|
+
});
|
|
61
|
+
if (item.type === "draw") sync(root, item, context);
|
|
62
|
+
return root;
|
|
63
|
+
},
|
|
64
|
+
update: (container, item, context) => {
|
|
65
|
+
if (item.type === "draw") sync(container, item, context);
|
|
66
|
+
},
|
|
67
|
+
renderFar: (graphics, item, context) => {
|
|
68
|
+
if (item.type !== "draw") return;
|
|
69
|
+
const color = pickBoardColor(context.colors, item.color, context.colorScheme);
|
|
70
|
+
drawFarStroke(graphics, item.points, {
|
|
71
|
+
color: color.stroke,
|
|
72
|
+
width: item.size,
|
|
73
|
+
alpha: .92
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
destroy: (container) => {
|
|
77
|
+
partsByContainer.get(container)?.root.destroy({ children: true });
|
|
78
|
+
partsByContainer.delete(container);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
//#endregion
|
|
82
|
+
export { drawCardRenderer };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { BoardFrame } from "../../../protocol/dist/board-document.js";
|
|
2
|
+
import { Graphics } from "pixi.js";
|
|
3
|
+
//#region src/board/render/renderers/far-plate.d.ts
|
|
4
|
+
type FarPlateStyle = {
|
|
5
|
+
/** Plate body fill. */
|
|
6
|
+
fill: number;
|
|
7
|
+
fillAlpha?: number;
|
|
8
|
+
/** Optional accent band along the top edge, identifying the content kind. */
|
|
9
|
+
accent?: number;
|
|
10
|
+
accentAlpha?: number;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Draw one card as a far-LOD plate: a filled body plus an optional top accent
|
|
14
|
+
* band. Selection and hover are intentionally not drawn here — the interaction
|
|
15
|
+
* overlay already renders selection in world space, above this layer.
|
|
16
|
+
*/
|
|
17
|
+
declare function drawFarPlate(graphics: Graphics, frame: BoardFrame, style: FarPlateStyle): void;
|
|
18
|
+
/**
|
|
19
|
+
* Draw one card as a far-LOD polyline.
|
|
20
|
+
*
|
|
21
|
+
* The counterpart to `drawFarPlate` for shapes whose meaning is a path rather than
|
|
22
|
+
* a region — strokes and arrows. It stays in the shared `Graphics`, so these keep
|
|
23
|
+
* their place in the batch and therefore in document order: an unbatched shape
|
|
24
|
+
* would be a live container, drawn above every plate no matter where the document
|
|
25
|
+
* puts it.
|
|
26
|
+
*
|
|
27
|
+
* Points are decimated to a bound, because a freehand stroke can hold thousands of
|
|
28
|
+
* samples that are sub-pixel apart at this zoom.
|
|
29
|
+
*/
|
|
30
|
+
declare function drawFarStroke(graphics: Graphics, points: ReadonlyArray<{
|
|
31
|
+
x: number;
|
|
32
|
+
y: number;
|
|
33
|
+
}>, style: {
|
|
34
|
+
color: number;
|
|
35
|
+
width: number;
|
|
36
|
+
alpha?: number;
|
|
37
|
+
}): void;
|
|
38
|
+
//#endregion
|
|
39
|
+
export { FarPlateStyle, drawFarPlate, drawFarStroke };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { degToRad } from "../../geometry.js";
|
|
2
|
+
//#region src/board/render/renderers/far-plate.ts
|
|
3
|
+
/** Upper bound on points kept when a stroke is drawn at far LOD. */
|
|
4
|
+
const FAR_STROKE_MAX_POINTS = 24;
|
|
5
|
+
/** Accent band height as a fraction of the plate, clamped to sane world units. */
|
|
6
|
+
const ACCENT_RATIO = .14;
|
|
7
|
+
const ACCENT_MIN = 2;
|
|
8
|
+
const ACCENT_MAX = 10;
|
|
9
|
+
/**
|
|
10
|
+
* Trace a frame's rectangle, honouring rotation. Unrotated frames take the
|
|
11
|
+
* fast `rect` path; rotated ones emit four explicit corners (still one path,
|
|
12
|
+
* no arcs) so the far layer never needs a per-card transform.
|
|
13
|
+
*/
|
|
14
|
+
function traceFrame(graphics, frame, inset = 0, heightOverride) {
|
|
15
|
+
const x = frame.x + inset;
|
|
16
|
+
const y = frame.y + inset;
|
|
17
|
+
const width = Math.max(0, frame.width - inset * 2);
|
|
18
|
+
const height = Math.max(0, (heightOverride ?? frame.height) - inset * 2);
|
|
19
|
+
if (!frame.rotation) {
|
|
20
|
+
graphics.rect(x, y, width, height);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const cx = frame.x + frame.width / 2;
|
|
24
|
+
const cy = frame.y + frame.height / 2;
|
|
25
|
+
const angle = degToRad(frame.rotation);
|
|
26
|
+
const cos = Math.cos(angle);
|
|
27
|
+
const sin = Math.sin(angle);
|
|
28
|
+
const corner = (px, py) => {
|
|
29
|
+
const dx = px - cx;
|
|
30
|
+
const dy = py - cy;
|
|
31
|
+
return {
|
|
32
|
+
x: cx + dx * cos - dy * sin,
|
|
33
|
+
y: cy + dx * sin + dy * cos
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
const a = corner(x, y);
|
|
37
|
+
const b = corner(x + width, y);
|
|
38
|
+
const c = corner(x + width, y + height);
|
|
39
|
+
const d = corner(x, y + height);
|
|
40
|
+
graphics.moveTo(a.x, a.y).lineTo(b.x, b.y).lineTo(c.x, c.y).lineTo(d.x, d.y).closePath();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Draw one card as a far-LOD plate: a filled body plus an optional top accent
|
|
44
|
+
* band. Selection and hover are intentionally not drawn here — the interaction
|
|
45
|
+
* overlay already renders selection in world space, above this layer.
|
|
46
|
+
*/
|
|
47
|
+
function drawFarPlate(graphics, frame, style) {
|
|
48
|
+
if (frame.width <= 0 || frame.height <= 0) return;
|
|
49
|
+
traceFrame(graphics, frame);
|
|
50
|
+
graphics.fill({
|
|
51
|
+
color: style.fill,
|
|
52
|
+
alpha: style.fillAlpha ?? 1
|
|
53
|
+
});
|
|
54
|
+
if (style.accent === void 0) return;
|
|
55
|
+
traceFrame(graphics, frame, 0, Math.min(ACCENT_MAX, Math.max(ACCENT_MIN, frame.height * ACCENT_RATIO)));
|
|
56
|
+
graphics.fill({
|
|
57
|
+
color: style.accent,
|
|
58
|
+
alpha: style.accentAlpha ?? .9
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Draw one card as a far-LOD polyline.
|
|
63
|
+
*
|
|
64
|
+
* The counterpart to `drawFarPlate` for shapes whose meaning is a path rather than
|
|
65
|
+
* a region — strokes and arrows. It stays in the shared `Graphics`, so these keep
|
|
66
|
+
* their place in the batch and therefore in document order: an unbatched shape
|
|
67
|
+
* would be a live container, drawn above every plate no matter where the document
|
|
68
|
+
* puts it.
|
|
69
|
+
*
|
|
70
|
+
* Points are decimated to a bound, because a freehand stroke can hold thousands of
|
|
71
|
+
* samples that are sub-pixel apart at this zoom.
|
|
72
|
+
*/
|
|
73
|
+
function drawFarStroke(graphics, points, style) {
|
|
74
|
+
if (points.length < 2) return;
|
|
75
|
+
const step = Math.max(1, Math.ceil(points.length / FAR_STROKE_MAX_POINTS));
|
|
76
|
+
const first = points[0];
|
|
77
|
+
if (!first) return;
|
|
78
|
+
graphics.moveTo(first.x, first.y);
|
|
79
|
+
for (let i = step; i < points.length; i += step) {
|
|
80
|
+
const point = points[i];
|
|
81
|
+
if (point) graphics.lineTo(point.x, point.y);
|
|
82
|
+
}
|
|
83
|
+
const last = points[points.length - 1];
|
|
84
|
+
if (last) graphics.lineTo(last.x, last.y);
|
|
85
|
+
graphics.stroke({
|
|
86
|
+
color: style.color,
|
|
87
|
+
width: style.width,
|
|
88
|
+
alpha: style.alpha ?? .9
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
//#endregion
|
|
92
|
+
export { drawFarPlate, drawFarStroke };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { BoardFileItem, BoardItem } from "../../../protocol/dist/board-document.js";
|
|
2
|
+
import { BoardCardRenderer } from "./board-renderer-registry.js";
|
|
3
|
+
import { Text } from "pixi.js";
|
|
4
|
+
//#region src/board/render/renderers/file-card-renderer.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Join already-wrapped lines and append an ellipsis to the last one.
|
|
7
|
+
* Pure helper so the clamp can be unit-tested without a Pixi canvas.
|
|
8
|
+
*/
|
|
9
|
+
declare function ellipsizeWrappedLines(lines: string[], maxLines: number): string;
|
|
10
|
+
/**
|
|
11
|
+
* Shorten `line` so `line + "…"` fits in `wrapWidth`.
|
|
12
|
+
*
|
|
13
|
+
* Binary search over the prefix: O(log n) single-line measures. Callers pass a
|
|
14
|
+
* measure callback so this stays free of Pixi types and easy to unit-test.
|
|
15
|
+
*/
|
|
16
|
+
declare function fitLineWithEllipsis(line: string, wrapWidth: number, measureWidth: (value: string) => number): string;
|
|
17
|
+
/**
|
|
18
|
+
* Fit a Pixi text node into a fixed line budget.
|
|
19
|
+
*
|
|
20
|
+
* Pixi Text has no built-in max-lines / ellipsis, so we measure with the same
|
|
21
|
+
* style the node will render, keep the leading wrapped lines, and mark a cut
|
|
22
|
+
* with an ellipsis. Returning early when everything fits avoids an extra
|
|
23
|
+
* texture rebuild on the common path.
|
|
24
|
+
*
|
|
25
|
+
* Cost is one wrap of a length-capped sample, plus at most a binary search on
|
|
26
|
+
* the last kept line. No character-by-character multi-line remeasure.
|
|
27
|
+
*/
|
|
28
|
+
declare function fitTextToLines(text: Text, value: string, maxLines: number, wrapWidth: number): void;
|
|
29
|
+
declare const fileCardRenderer: BoardCardRenderer;
|
|
30
|
+
/** Helper kept for type narrowing in tests. */
|
|
31
|
+
declare function isFileItem(item: BoardItem): item is BoardFileItem;
|
|
32
|
+
//#endregion
|
|
33
|
+
export { ellipsizeWrappedLines, fileCardRenderer, fitLineWithEllipsis, fitTextToLines, isFileItem };
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { BOARD_FONT_STACK, BOARD_MONO_FONT_STACK } from "../../../protocol/dist/board-constants.js";
|
|
2
|
+
import { fileBaseName, filePreviewKind, fileTypeLabel, formatFileSize } from "../../core/file-preview.js";
|
|
3
|
+
import { syncTextResolution } from "../text-resolution.js";
|
|
4
|
+
import { drawFarPlate } from "./far-plate.js";
|
|
5
|
+
import { positionShell } from "./base-card-renderer.js";
|
|
6
|
+
import { CanvasTextMetrics, Container, Graphics, Sprite, Text, Texture } from "pixi.js";
|
|
7
|
+
//#region src/board/render/renderers/file-card-renderer.ts
|
|
8
|
+
/**
|
|
9
|
+
* File card renderer.
|
|
10
|
+
*
|
|
11
|
+
* The card is an *entry point* to a workspace file, so it shows only what helps
|
|
12
|
+
* you recognise and pick it: an optional cover, the name, a couple of lines of
|
|
13
|
+
* excerpt, and the type. Everything else belongs to the file preview that opens
|
|
14
|
+
* when the card is activated.
|
|
15
|
+
*
|
|
16
|
+
* Cost is managed through three levels of detail keyed on zoom, because a board
|
|
17
|
+
* is expected to hold thousands of these. Pixi `Text` is a rasterised texture,
|
|
18
|
+
* so below roughly one CSS pixel per glyph it can only ever resolve to a smudge;
|
|
19
|
+
* the tiers below simply stop paying for what cannot be read anyway.
|
|
20
|
+
*
|
|
21
|
+
* Absolute floor: content never paints outside the node frame. A rounded clip
|
|
22
|
+
* mask is the hard boundary; line-clamped title/excerpt keep the interior tidy.
|
|
23
|
+
*/
|
|
24
|
+
const RADIUS = 4;
|
|
25
|
+
const PADDING = 10;
|
|
26
|
+
const COVER_RATIO = .56;
|
|
27
|
+
/** Minimum body height reserved under a cover so the title stays readable. */
|
|
28
|
+
const MIN_BODY_FOR_COVER = 38;
|
|
29
|
+
const TITLE_SIZE = 13;
|
|
30
|
+
const TITLE_LINE = TITLE_SIZE * 1.35;
|
|
31
|
+
const TITLE_MAX_LINES = 2;
|
|
32
|
+
const EXCERPT_SIZE = 11;
|
|
33
|
+
const EXCERPT_LINE = EXCERPT_SIZE * 1.45;
|
|
34
|
+
const EXCERPT_MAX_LINES = 4;
|
|
35
|
+
const META_SIZE = 9;
|
|
36
|
+
const META_LINE = META_SIZE * 1.3;
|
|
37
|
+
const GAP = 4;
|
|
38
|
+
/** Zoom below which the title is dropped (glyphs are sub-pixel). */
|
|
39
|
+
const LOD_TITLE_ZOOM = .35;
|
|
40
|
+
/** Zoom below which the excerpt and meta line are dropped. */
|
|
41
|
+
const LOD_BODY_ZOOM = .6;
|
|
42
|
+
/** Upper bound on dashes in the unavailable-state edge, regardless of width. */
|
|
43
|
+
const MAX_DASHES = 40;
|
|
44
|
+
const partsByContainer = /* @__PURE__ */ new WeakMap();
|
|
45
|
+
/** Detail tier for a zoom level. */
|
|
46
|
+
function detailFor(zoom) {
|
|
47
|
+
if (zoom < LOD_TITLE_ZOOM) return "plate";
|
|
48
|
+
if (zoom < LOD_BODY_ZOOM) return "title";
|
|
49
|
+
return "full";
|
|
50
|
+
}
|
|
51
|
+
/** Cover band height for a card, or 0 when it has no cover. */
|
|
52
|
+
function coverHeight(item, height) {
|
|
53
|
+
if (filePreviewKind(item.snapshot) !== "cover") return 0;
|
|
54
|
+
const ideal = Math.round(height * COVER_RATIO);
|
|
55
|
+
return Math.max(0, Math.min(ideal, height - MIN_BODY_FOR_COVER));
|
|
56
|
+
}
|
|
57
|
+
function metaLine(item, state) {
|
|
58
|
+
const type = fileTypeLabel(item.ref.path);
|
|
59
|
+
if (state === "missing") return `${type} · missing`;
|
|
60
|
+
if (state === "unavailable") return `${type} · unavailable`;
|
|
61
|
+
const size = formatFileSize(item.snapshot?.size);
|
|
62
|
+
return size ? `${type} · ${size}` : type;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Join already-wrapped lines and append an ellipsis to the last one.
|
|
66
|
+
* Pure helper so the clamp can be unit-tested without a Pixi canvas.
|
|
67
|
+
*/
|
|
68
|
+
function ellipsizeWrappedLines(lines, maxLines) {
|
|
69
|
+
if (maxLines <= 0 || lines.length === 0) return "";
|
|
70
|
+
if (lines.length <= maxLines) return lines.join("\n");
|
|
71
|
+
const kept = lines.slice(0, maxLines);
|
|
72
|
+
const lastIndex = kept.length - 1;
|
|
73
|
+
const last = (kept[lastIndex] ?? "").replace(/\s+$/u, "");
|
|
74
|
+
kept[lastIndex] = last ? `${last}…` : "…";
|
|
75
|
+
return kept.join("\n");
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Shorten `line` so `line + "…"` fits in `wrapWidth`.
|
|
79
|
+
*
|
|
80
|
+
* Binary search over the prefix: O(log n) single-line measures. Callers pass a
|
|
81
|
+
* measure callback so this stays free of Pixi types and easy to unit-test.
|
|
82
|
+
*/
|
|
83
|
+
function fitLineWithEllipsis(line, wrapWidth, measureWidth) {
|
|
84
|
+
const trimmed = line.replace(/\s+$/u, "");
|
|
85
|
+
if (!trimmed) return "…";
|
|
86
|
+
const full = `${trimmed}…`;
|
|
87
|
+
if (measureWidth(full) <= wrapWidth) return full;
|
|
88
|
+
let lo = 0;
|
|
89
|
+
let hi = trimmed.length;
|
|
90
|
+
let best = "…";
|
|
91
|
+
while (lo <= hi) {
|
|
92
|
+
const mid = lo + hi >> 1;
|
|
93
|
+
const candidate = mid > 0 ? `${trimmed.slice(0, mid)}…` : "…";
|
|
94
|
+
if (measureWidth(candidate) <= wrapWidth) {
|
|
95
|
+
best = candidate;
|
|
96
|
+
lo = mid + 1;
|
|
97
|
+
} else hi = mid - 1;
|
|
98
|
+
}
|
|
99
|
+
return best;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Fit a Pixi text node into a fixed line budget.
|
|
103
|
+
*
|
|
104
|
+
* Pixi Text has no built-in max-lines / ellipsis, so we measure with the same
|
|
105
|
+
* style the node will render, keep the leading wrapped lines, and mark a cut
|
|
106
|
+
* with an ellipsis. Returning early when everything fits avoids an extra
|
|
107
|
+
* texture rebuild on the common path.
|
|
108
|
+
*
|
|
109
|
+
* Cost is one wrap of a length-capped sample, plus at most a binary search on
|
|
110
|
+
* the last kept line. No character-by-character multi-line remeasure.
|
|
111
|
+
*/
|
|
112
|
+
function fitTextToLines(text, value, maxLines, wrapWidth) {
|
|
113
|
+
const width = Math.max(1, wrapWidth);
|
|
114
|
+
text.style.wordWrapWidth = width;
|
|
115
|
+
if (!value || maxLines <= 0) {
|
|
116
|
+
if (text.text !== "") text.text = "";
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const charCap = Math.max(maxLines * Math.ceil(width), maxLines * 4);
|
|
120
|
+
const sample = value.length > charCap ? value.slice(0, charCap) : value;
|
|
121
|
+
const metrics = CanvasTextMetrics.measureText(sample, text.style);
|
|
122
|
+
if (!(metrics.lines.length > maxLines || sample !== value)) {
|
|
123
|
+
if (text.text !== value) text.text = value;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const keptCount = Math.min(maxLines, metrics.lines.length);
|
|
127
|
+
const kept = metrics.lines.slice(0, keptCount);
|
|
128
|
+
const lastIndex = kept.length - 1;
|
|
129
|
+
const probe = text.style.clone();
|
|
130
|
+
probe.wordWrap = false;
|
|
131
|
+
kept[lastIndex] = fitLineWithEllipsis(kept[lastIndex] ?? "", width, (candidate) => CanvasTextMetrics.measureText(candidate, probe).width);
|
|
132
|
+
const fitted = kept.join("\n");
|
|
133
|
+
if (text.text !== fitted) text.text = fitted;
|
|
134
|
+
}
|
|
135
|
+
/** How many full lines of `lineHeight` fit in `room`, capped at `max`. */
|
|
136
|
+
function linesInRoom(room, lineHeight, max) {
|
|
137
|
+
if (room < lineHeight * .85) return 0;
|
|
138
|
+
return Math.max(0, Math.min(max, Math.floor((room + .5) / lineHeight)));
|
|
139
|
+
}
|
|
140
|
+
/** Fill the cover band, cropping overflow (the band's aspect rarely matches). */
|
|
141
|
+
function layoutCover(sprite, width, height, texture) {
|
|
142
|
+
const tw = texture.width;
|
|
143
|
+
const th = texture.height;
|
|
144
|
+
if (!tw || !th || !height) return;
|
|
145
|
+
const scale = Math.max(width / tw, height / th);
|
|
146
|
+
sprite.width = tw * scale;
|
|
147
|
+
sprite.height = th * scale;
|
|
148
|
+
sprite.x = (width - sprite.width) / 2;
|
|
149
|
+
sprite.y = 0;
|
|
150
|
+
}
|
|
151
|
+
function syncClip(parts, width, height) {
|
|
152
|
+
parts.clip.clear().roundRect(0, 0, Math.max(1, width), Math.max(1, height), RADIUS).fill({ color: 16777215 });
|
|
153
|
+
}
|
|
154
|
+
function sync(container, item, context) {
|
|
155
|
+
const parts = partsByContainer.get(container);
|
|
156
|
+
if (!parts) return;
|
|
157
|
+
positionShell(parts.root, item);
|
|
158
|
+
const { width, height } = item.frame;
|
|
159
|
+
const selected = context.selectedIds.has(item.id);
|
|
160
|
+
const hovered = context.hoveredId === item.id;
|
|
161
|
+
const detail = detailFor(context.zoom);
|
|
162
|
+
const key = context.assetKey(item);
|
|
163
|
+
const texture = key ? context.getTexture(key) : null;
|
|
164
|
+
const coverFailed = Boolean(key && !texture && context.hasError(key));
|
|
165
|
+
const fileState = context.fileState(item.ref.path);
|
|
166
|
+
const band = coverHeight(item, height);
|
|
167
|
+
syncTextResolution(parts.title, parts.titleRes, context.zoom);
|
|
168
|
+
syncTextResolution(parts.excerpt, parts.excerptRes, context.zoom);
|
|
169
|
+
syncTextResolution(parts.meta, parts.metaRes, context.zoom);
|
|
170
|
+
const visualSig = [
|
|
171
|
+
key ?? "",
|
|
172
|
+
width,
|
|
173
|
+
height,
|
|
174
|
+
selected,
|
|
175
|
+
hovered,
|
|
176
|
+
detail,
|
|
177
|
+
texture ? `${texture.width}x${texture.height}` : "none",
|
|
178
|
+
coverFailed,
|
|
179
|
+
fileState,
|
|
180
|
+
context.colorScheme,
|
|
181
|
+
context.palette.surface
|
|
182
|
+
].join("|");
|
|
183
|
+
if (visualSig !== parts.visualSig) {
|
|
184
|
+
parts.visualSig = visualSig;
|
|
185
|
+
syncClip(parts, width, height);
|
|
186
|
+
parts.plate.clear();
|
|
187
|
+
parts.plate.roundRect(0, 0, width, height, RADIUS).fill({
|
|
188
|
+
color: context.palette.surface,
|
|
189
|
+
alpha: .98
|
|
190
|
+
}).roundRect(0, 0, width, height, RADIUS).stroke({
|
|
191
|
+
color: selected ? context.palette.brand : hovered ? context.palette.muted : context.palette.border,
|
|
192
|
+
width: selected ? 2 : 1,
|
|
193
|
+
alpha: selected ? .95 : .85
|
|
194
|
+
});
|
|
195
|
+
if (fileState !== "ok") {
|
|
196
|
+
const dash = Math.max(6, width / MAX_DASHES / 2);
|
|
197
|
+
for (let x = 0; x < width; x += dash * 2) parts.plate.moveTo(x, .5).lineTo(Math.min(x + dash, width), .5);
|
|
198
|
+
parts.plate.stroke({
|
|
199
|
+
color: context.palette.muted,
|
|
200
|
+
width: 2,
|
|
201
|
+
alpha: fileState === "missing" ? .9 : .5
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
const showCover = band > 0 && Boolean(texture);
|
|
205
|
+
if (showCover && texture) {
|
|
206
|
+
layoutCover(parts.cover, width, band, texture);
|
|
207
|
+
if (parts.cover.texture !== texture) parts.cover.texture = texture;
|
|
208
|
+
parts.coverMask.clear().roundRect(0, 0, width, band, RADIUS).fill({ color: 16777215 });
|
|
209
|
+
parts.coverMask.rect(0, band - RADIUS, width, RADIUS).fill({ color: 16777215 });
|
|
210
|
+
}
|
|
211
|
+
parts.cover.visible = showCover;
|
|
212
|
+
parts.coverMask.visible = showCover;
|
|
213
|
+
if (band > 0 && !texture) parts.plate.rect(1, 1, width - 2, band - 1).fill({
|
|
214
|
+
color: context.palette.hover,
|
|
215
|
+
alpha: coverFailed ? .35 : .6
|
|
216
|
+
});
|
|
217
|
+
if (!band) parts.plate.rect(1, 1, 2, height - 2).fill({
|
|
218
|
+
color: context.palette.muted,
|
|
219
|
+
alpha: .35
|
|
220
|
+
});
|
|
221
|
+
parts.title.visible = detail !== "plate";
|
|
222
|
+
parts.excerpt.visible = detail === "full";
|
|
223
|
+
parts.meta.visible = detail === "full";
|
|
224
|
+
}
|
|
225
|
+
if (detail === "plate") return;
|
|
226
|
+
const title = item.snapshot?.title || fileBaseName(item.ref.path);
|
|
227
|
+
const excerpt = item.snapshot?.excerpt ?? "";
|
|
228
|
+
const meta = metaLine(item, fileState);
|
|
229
|
+
const innerWidth = Math.max(1, width - PADDING * 2);
|
|
230
|
+
const textSig = [
|
|
231
|
+
title,
|
|
232
|
+
excerpt,
|
|
233
|
+
meta,
|
|
234
|
+
detail,
|
|
235
|
+
innerWidth,
|
|
236
|
+
band,
|
|
237
|
+
height,
|
|
238
|
+
context.palette.text,
|
|
239
|
+
context.palette.muted
|
|
240
|
+
].join("|");
|
|
241
|
+
if (textSig === parts.textSig) return;
|
|
242
|
+
parts.textSig = textSig;
|
|
243
|
+
const top = band > 0 ? band + PADDING * .8 : PADDING;
|
|
244
|
+
const bottom = height - PADDING;
|
|
245
|
+
const metaFits = detail === "full" && bottom - top >= 33.25;
|
|
246
|
+
const contentBottom = bottom - (metaFits ? META_LINE : 0) - (metaFits ? GAP : 0);
|
|
247
|
+
const titleLines = linesInRoom(Math.max(0, contentBottom - top), TITLE_LINE, TITLE_MAX_LINES);
|
|
248
|
+
parts.title.style.fill = context.palette.text;
|
|
249
|
+
fitTextToLines(parts.title, title, titleLines, innerWidth);
|
|
250
|
+
parts.title.position.set(PADDING, top);
|
|
251
|
+
parts.title.visible = titleLines > 0;
|
|
252
|
+
if (detail !== "full") {
|
|
253
|
+
parts.excerpt.visible = false;
|
|
254
|
+
parts.meta.visible = false;
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (metaFits) {
|
|
258
|
+
if (parts.meta.text !== meta) parts.meta.text = meta;
|
|
259
|
+
parts.meta.style.fill = context.palette.muted;
|
|
260
|
+
parts.meta.position.set(PADDING, height - PADDING - Math.min(parts.meta.height, META_LINE));
|
|
261
|
+
parts.meta.visible = true;
|
|
262
|
+
} else parts.meta.visible = false;
|
|
263
|
+
const excerptTop = top + (titleLines > 0 ? parts.title.height + GAP : 0);
|
|
264
|
+
const excerptLines = linesInRoom(contentBottom - excerptTop, EXCERPT_LINE, EXCERPT_MAX_LINES);
|
|
265
|
+
const showExcerpt = Boolean(excerpt) && excerptLines > 0;
|
|
266
|
+
if (showExcerpt) {
|
|
267
|
+
parts.excerpt.style.fill = context.palette.muted;
|
|
268
|
+
fitTextToLines(parts.excerpt, excerpt, excerptLines, innerWidth);
|
|
269
|
+
parts.excerpt.position.set(PADDING, excerptTop);
|
|
270
|
+
}
|
|
271
|
+
parts.excerpt.visible = showExcerpt;
|
|
272
|
+
}
|
|
273
|
+
const fileCardRenderer = {
|
|
274
|
+
id: "file-card",
|
|
275
|
+
canRender: (item) => item.type === "file",
|
|
276
|
+
create: (item, context) => {
|
|
277
|
+
const root = new Container();
|
|
278
|
+
const plate = new Graphics();
|
|
279
|
+
const body = new Container();
|
|
280
|
+
const clip = new Graphics();
|
|
281
|
+
const cover = new Sprite(Texture.EMPTY);
|
|
282
|
+
const coverMask = new Graphics();
|
|
283
|
+
cover.mask = coverMask;
|
|
284
|
+
const resolution = 1;
|
|
285
|
+
const title = new Text({
|
|
286
|
+
text: "",
|
|
287
|
+
style: {
|
|
288
|
+
fill: context.palette.text,
|
|
289
|
+
fontFamily: BOARD_FONT_STACK,
|
|
290
|
+
fontSize: TITLE_SIZE,
|
|
291
|
+
fontWeight: "600",
|
|
292
|
+
wordWrap: true,
|
|
293
|
+
breakWords: true,
|
|
294
|
+
lineHeight: TITLE_LINE
|
|
295
|
+
},
|
|
296
|
+
resolution,
|
|
297
|
+
roundPixels: true
|
|
298
|
+
});
|
|
299
|
+
const excerpt = new Text({
|
|
300
|
+
text: "",
|
|
301
|
+
style: {
|
|
302
|
+
fill: context.palette.muted,
|
|
303
|
+
fontFamily: BOARD_FONT_STACK,
|
|
304
|
+
fontSize: EXCERPT_SIZE,
|
|
305
|
+
fontWeight: "400",
|
|
306
|
+
wordWrap: true,
|
|
307
|
+
breakWords: true,
|
|
308
|
+
lineHeight: EXCERPT_LINE
|
|
309
|
+
},
|
|
310
|
+
resolution,
|
|
311
|
+
roundPixels: true
|
|
312
|
+
});
|
|
313
|
+
const meta = new Text({
|
|
314
|
+
text: "",
|
|
315
|
+
style: {
|
|
316
|
+
fill: context.palette.muted,
|
|
317
|
+
fontFamily: BOARD_MONO_FONT_STACK,
|
|
318
|
+
fontSize: META_SIZE,
|
|
319
|
+
fontWeight: "500"
|
|
320
|
+
},
|
|
321
|
+
resolution,
|
|
322
|
+
roundPixels: true
|
|
323
|
+
});
|
|
324
|
+
body.mask = clip;
|
|
325
|
+
body.addChild(cover, coverMask, title, excerpt, meta);
|
|
326
|
+
root.addChild(plate, body, clip);
|
|
327
|
+
partsByContainer.set(root, {
|
|
328
|
+
root,
|
|
329
|
+
plate,
|
|
330
|
+
body,
|
|
331
|
+
clip,
|
|
332
|
+
cover,
|
|
333
|
+
coverMask,
|
|
334
|
+
title,
|
|
335
|
+
excerpt,
|
|
336
|
+
meta,
|
|
337
|
+
visualSig: "",
|
|
338
|
+
textSig: "",
|
|
339
|
+
titleRes: { resolution },
|
|
340
|
+
excerptRes: { resolution },
|
|
341
|
+
metaRes: { resolution }
|
|
342
|
+
});
|
|
343
|
+
if (item.type === "file") sync(root, item, context);
|
|
344
|
+
return root;
|
|
345
|
+
},
|
|
346
|
+
update: (container, item, context) => {
|
|
347
|
+
if (item.type === "file") sync(container, item, context);
|
|
348
|
+
},
|
|
349
|
+
/**
|
|
350
|
+
* Far LOD: a plate with a muted accent band. Sampling the real cover here
|
|
351
|
+
* would mean one draw call per distinct image and defeat the batch.
|
|
352
|
+
*/
|
|
353
|
+
renderFar: (graphics, item, context) => {
|
|
354
|
+
drawFarPlate(graphics, item.frame, {
|
|
355
|
+
fill: context.palette.surface,
|
|
356
|
+
fillAlpha: .96,
|
|
357
|
+
accent: context.palette.muted,
|
|
358
|
+
accentAlpha: .4
|
|
359
|
+
});
|
|
360
|
+
},
|
|
361
|
+
destroy: (container) => {
|
|
362
|
+
partsByContainer.get(container)?.root.destroy({ children: true });
|
|
363
|
+
partsByContainer.delete(container);
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
/** Helper kept for type narrowing in tests. */
|
|
367
|
+
function isFileItem(item) {
|
|
368
|
+
return item.type === "file";
|
|
369
|
+
}
|
|
370
|
+
//#endregion
|
|
371
|
+
export { ellipsizeWrappedLines, fileCardRenderer, fitLineWithEllipsis, fitTextToLines, isFileItem };
|