@neta-art/cohub 8.10.2 → 8.12.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/dist/board/animation.js +14 -2
- package/dist/board/core/file-preview.d.ts +20 -141
- package/dist/board/core/file-preview.js +25 -202
- package/dist/board/core/file-snapshot.d.ts +106 -0
- package/dist/board/core/file-snapshot.js +503 -0
- package/dist/board/index.d.ts +4 -2
- package/dist/board/index.js +4 -2
- package/dist/board/mutation.js +2 -1
- package/dist/board/render/board-background.d.ts +16 -0
- package/dist/board/render/{themes/clean-theme.js → board-background.js} +28 -35
- package/dist/board/render/index.d.ts +3 -3
- package/dist/board/render/index.js +2 -2
- package/dist/board/render/renderers/file-card-renderer.js +62 -17
- package/dist/board/replay.d.ts +52 -0
- package/dist/board/replay.js +261 -0
- package/dist/board/semantic-document.d.ts +9 -2
- package/dist/board/semantic-document.js +1 -3
- package/dist/chunks/environment.d.ts +272 -7
- package/dist/chunks/environment.js +1 -0
- package/dist/chunks/http.d.ts +109 -7
- package/dist/chunks/http.js +47 -10
- package/dist/chunks/websocket.d.ts +1 -1
- package/dist/http.d.ts +3 -3
- package/dist/index.d.ts +65 -4
- package/dist/index.js +514 -64
- package/dist/protocol/dist/board-animation.d.ts +1 -0
- package/dist/protocol/dist/board-animation.js +34 -0
- package/dist/protocol/dist/board-authoring.d.ts +2 -1
- package/dist/protocol/dist/board-capability-registry.js +53 -3
- package/dist/protocol/dist/board-codec.js +149 -2
- package/dist/protocol/dist/board-constants.js +29 -7
- package/dist/protocol/dist/board-document.d.ts +30 -16
- package/dist/protocol/dist/board-document.js +15 -12
- package/dist/protocol/dist/board-effect.d.ts +4 -2
- package/dist/protocol/dist/board-effect.js +3 -2
- package/dist/protocol/dist/board.d.ts +148 -3
- package/dist/protocol/dist/board.js +7 -0
- package/dist/protocol/dist/index.d.ts +4 -2
- package/dist/protocol/dist/provenance.d.ts +21 -0
- package/dist/types.d.ts +1 -1
- package/docs/app-runtime-guide.md +28 -3
- package/package.json +1 -1
- package/dist/board/render/themes/board-theme-registry.d.ts +0 -22
- package/dist/board/render/themes/board-theme-registry.js +0 -13
- package/dist/board/render/themes/clean-theme.d.ts +0 -5
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import { parseBoardCssColor } from "
|
|
1
|
+
import { parseBoardCssColor } from "./css-color.js";
|
|
2
2
|
import { Container, Graphics, RenderTexture, TilingSprite } from "pixi.js";
|
|
3
|
-
//#region src/board/render/
|
|
3
|
+
//#region src/board/render/board-background.ts
|
|
4
4
|
const partsByContainer = /* @__PURE__ */ new WeakMap();
|
|
5
|
-
/** Positive modulo so tile offsets stay valid for negative viewport offsets. */
|
|
6
5
|
function wrap(value, period) {
|
|
7
6
|
return (value % period + period) % period;
|
|
8
7
|
}
|
|
9
|
-
function buildGridTexture(context, size, color, opacity, kind
|
|
8
|
+
function buildGridTexture(context, size, color, opacity, kind) {
|
|
10
9
|
const graphics = new Graphics();
|
|
11
10
|
if (kind === "grid") graphics.moveTo(0, .5).lineTo(size, .5).moveTo(.5, 0).lineTo(.5, size).stroke({
|
|
12
11
|
color,
|
|
@@ -46,11 +45,10 @@ function sync(parts, context) {
|
|
|
46
45
|
parts.lastBg = bgColor;
|
|
47
46
|
parts.lastBgAlpha = bgAlpha;
|
|
48
47
|
}
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const kind = appearance.background?.kind === "grid" ? "grid" : "dots";
|
|
48
|
+
const visible = document.appearance.grid?.visible === true;
|
|
49
|
+
const size = Math.max(4, document.appearance.grid?.size ?? 24);
|
|
50
|
+
const opacity = document.appearance.grid?.opacity ?? .12;
|
|
51
|
+
const kind = document.appearance.background?.kind === "grid" ? "grid" : "dots";
|
|
54
52
|
const key = `${kind}|${size}|${palette.border}|${opacity}`;
|
|
55
53
|
if (!visible) {
|
|
56
54
|
if (parts.sprite) parts.sprite.visible = false;
|
|
@@ -80,30 +78,25 @@ function sync(parts, context) {
|
|
|
80
78
|
sprite.tileScale.set(viewport.zoom);
|
|
81
79
|
sprite.tilePosition.set(wrap(viewport.x, step), wrap(viewport.y, step));
|
|
82
80
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
updateBackground: (container, context) => {
|
|
104
|
-
const parts = partsByContainer.get(container);
|
|
105
|
-
if (parts) sync(parts, context);
|
|
106
|
-
}
|
|
107
|
-
};
|
|
81
|
+
function createBoardBackground(context) {
|
|
82
|
+
const container = new Container({ label: "board-background" });
|
|
83
|
+
const fill = new Graphics();
|
|
84
|
+
container.addChild(fill);
|
|
85
|
+
partsByContainer.set(container, {
|
|
86
|
+
fill,
|
|
87
|
+
sprite: null,
|
|
88
|
+
textureKey: "",
|
|
89
|
+
lastWidth: -1,
|
|
90
|
+
lastHeight: -1,
|
|
91
|
+
lastBg: NaN,
|
|
92
|
+
lastBgAlpha: NaN
|
|
93
|
+
});
|
|
94
|
+
sync(partsByContainer.get(container), context);
|
|
95
|
+
return container;
|
|
96
|
+
}
|
|
97
|
+
function updateBoardBackground(container, context) {
|
|
98
|
+
const parts = partsByContainer.get(container);
|
|
99
|
+
if (parts) sync(parts, context);
|
|
100
|
+
}
|
|
108
101
|
//#endregion
|
|
109
|
-
export {
|
|
102
|
+
export { createBoardBackground, updateBoardBackground };
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
+
import { BoardCardRenderer, BoardRenderContext, BoardRenderPalette, boardCardRenderersForTest, getBoardCardRenderer, registerBoardCardRenderer } from "./renderers/board-renderer-registry.js";
|
|
2
|
+
import { BoardBackgroundContext, createBoardBackground, updateBoardBackground } from "./board-background.js";
|
|
1
3
|
import { ConnectionLayer, ConnectionRenderInput, createConnectionLayer, framesFromItems } from "./connection-layer.js";
|
|
2
4
|
import { parseBoardCssColor } from "./css-color.js";
|
|
3
5
|
import { BoardMediaAction, boardMediaActionAt, mediaPlayBadgeHit, mediaPlayBadgeVisible } from "./media-interaction.js";
|
|
4
|
-
import { BoardCardRenderer, BoardRenderContext, BoardRenderPalette, boardCardRenderersForTest, getBoardCardRenderer, registerBoardCardRenderer } from "./renderers/board-renderer-registry.js";
|
|
5
6
|
import { defaultBoardPalette } from "./palette.js";
|
|
6
7
|
import { TASK_CARD_FULL_DETAIL_ZOOM } from "./renderers/task-card-renderer.js";
|
|
7
8
|
import { ensureBoardTextMeasurement, installBoardTextMeasurement } from "./text-measurement.js";
|
|
8
9
|
import { getBoardResolution, syncTextResolution, syncTextWrapWidth, textResolutionForZoom, textZoomBucket } from "./text-resolution.js";
|
|
9
|
-
import { BoardThemeContext, BoardThemeRenderer, getBoardThemeRenderer, registerBoardThemeRenderer } from "./themes/board-theme-registry.js";
|
|
10
10
|
import { VIDEO_THUMBNAIL_MAX_EDGE, VideoNaturalSize, loadVideoThumbnailTexture, videoTextureNaturalSize, videoThumbnailSize } from "./video-thumbnail.js";
|
|
11
|
-
export { BoardCardRenderer, BoardMediaAction, BoardRenderContext, BoardRenderPalette,
|
|
11
|
+
export { BoardBackgroundContext, BoardCardRenderer, BoardMediaAction, BoardRenderContext, BoardRenderPalette, ConnectionLayer, ConnectionRenderInput, TASK_CARD_FULL_DETAIL_ZOOM, VIDEO_THUMBNAIL_MAX_EDGE, VideoNaturalSize, boardCardRenderersForTest, boardMediaActionAt, createBoardBackground, createConnectionLayer, defaultBoardPalette, ensureBoardTextMeasurement, framesFromItems, getBoardCardRenderer, getBoardResolution, installBoardTextMeasurement, loadVideoThumbnailTexture, mediaPlayBadgeHit, mediaPlayBadgeVisible, parseBoardCssColor, registerBoardCardRenderer, syncTextResolution, syncTextWrapWidth, textResolutionForZoom, textZoomBucket, updateBoardBackground, videoTextureNaturalSize, videoThumbnailSize };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseBoardCssColor } from "./css-color.js";
|
|
2
|
+
import { createBoardBackground, updateBoardBackground } from "./board-background.js";
|
|
2
3
|
import { getBoardResolution, syncTextResolution, syncTextWrapWidth, textResolutionForZoom, textZoomBucket } from "./text-resolution.js";
|
|
3
4
|
import { createConnectionLayer, framesFromItems } from "./connection-layer.js";
|
|
4
5
|
import { TASK_CARD_FULL_DETAIL_ZOOM } from "./renderers/task-card-renderer.js";
|
|
@@ -6,6 +7,5 @@ import { boardMediaActionAt, mediaPlayBadgeHit, mediaPlayBadgeVisible } from "./
|
|
|
6
7
|
import { defaultBoardPalette } from "./palette.js";
|
|
7
8
|
import { ensureBoardTextMeasurement, installBoardTextMeasurement } from "./text-measurement.js";
|
|
8
9
|
import { boardCardRenderersForTest, getBoardCardRenderer, registerBoardCardRenderer } from "./renderers/board-renderer-registry.js";
|
|
9
|
-
import { getBoardThemeRenderer, registerBoardThemeRenderer } from "./themes/board-theme-registry.js";
|
|
10
10
|
import { VIDEO_THUMBNAIL_MAX_EDGE, loadVideoThumbnailTexture, videoTextureNaturalSize, videoThumbnailSize } from "./video-thumbnail.js";
|
|
11
|
-
export { TASK_CARD_FULL_DETAIL_ZOOM, VIDEO_THUMBNAIL_MAX_EDGE, boardCardRenderersForTest, boardMediaActionAt, createConnectionLayer, defaultBoardPalette, ensureBoardTextMeasurement, framesFromItems, getBoardCardRenderer, getBoardResolution,
|
|
11
|
+
export { TASK_CARD_FULL_DETAIL_ZOOM, VIDEO_THUMBNAIL_MAX_EDGE, boardCardRenderersForTest, boardMediaActionAt, createBoardBackground, createConnectionLayer, defaultBoardPalette, ensureBoardTextMeasurement, framesFromItems, getBoardCardRenderer, getBoardResolution, installBoardTextMeasurement, loadVideoThumbnailTexture, mediaPlayBadgeHit, mediaPlayBadgeVisible, parseBoardCssColor, registerBoardCardRenderer, syncTextResolution, syncTextWrapWidth, textResolutionForZoom, textZoomBucket, updateBoardBackground, videoTextureNaturalSize, videoThumbnailSize };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { BOARD_FONT_STACK } from "../../../protocol/dist/board-constants.js";
|
|
2
|
-
import {
|
|
1
|
+
import { BOARD_FONT_STACK, BOARD_MONO_FONT_STACK } from "../../../protocol/dist/board-constants.js";
|
|
2
|
+
import { fileCategory, fileStem } from "../../core/file-snapshot.js";
|
|
3
|
+
import { fileCategoryAccent, filePreviewKind, fileTypeLabel } from "../../core/file-preview.js";
|
|
3
4
|
import { syncTextResolution } from "../text-resolution.js";
|
|
4
5
|
import { positionShell } from "./base-card-renderer.js";
|
|
5
6
|
import { drawFarPlate } from "./far-plate.js";
|
|
@@ -31,8 +32,10 @@ const TITLE_LINE = TITLE_SIZE * 1.35;
|
|
|
31
32
|
const TITLE_MAX_LINES = 2;
|
|
32
33
|
const EXCERPT_SIZE = 11;
|
|
33
34
|
const EXCERPT_LINE = EXCERPT_SIZE * 1.45;
|
|
34
|
-
const EXCERPT_MAX_LINES =
|
|
35
|
+
const EXCERPT_MAX_LINES = 3;
|
|
36
|
+
const TYPE_MARK_SIZE = 28;
|
|
35
37
|
const GAP = 4;
|
|
38
|
+
const STRIPE = 2;
|
|
36
39
|
/** Zoom below which the title is dropped (glyphs are sub-pixel). */
|
|
37
40
|
const LOD_TITLE_ZOOM = .35;
|
|
38
41
|
/** Zoom below which the excerpt is dropped. */
|
|
@@ -168,8 +171,12 @@ function sync(container, item, context) {
|
|
|
168
171
|
const coverFailed = Boolean(key && !texture && context.hasError(key));
|
|
169
172
|
const fileState = context.fileState(item.ref.path);
|
|
170
173
|
const band = coverHeight(item, height);
|
|
174
|
+
const kind = filePreviewKind(item.snapshot);
|
|
175
|
+
const category = fileCategory(item.ref.path, item.snapshot?.mimeType);
|
|
176
|
+
const accent = fileCategoryAccent(category, context.palette);
|
|
171
177
|
syncTextResolution(parts.title, parts.titleRes, context.zoom);
|
|
172
178
|
syncTextResolution(parts.excerpt, parts.excerptRes, context.zoom);
|
|
179
|
+
syncTextResolution(parts.typeMark, parts.typeMarkRes, context.zoom);
|
|
173
180
|
const visualSig = [
|
|
174
181
|
key ?? "",
|
|
175
182
|
width,
|
|
@@ -180,9 +187,12 @@ function sync(container, item, context) {
|
|
|
180
187
|
texture ? `${texture.width}x${texture.height}` : "none",
|
|
181
188
|
coverFailed,
|
|
182
189
|
fileState,
|
|
190
|
+
kind,
|
|
191
|
+
category,
|
|
183
192
|
context.colorScheme,
|
|
184
193
|
context.palette.surface,
|
|
185
|
-
context.palette.hover
|
|
194
|
+
context.palette.hover,
|
|
195
|
+
accent
|
|
186
196
|
].join("|");
|
|
187
197
|
if (visualSig !== parts.visualSig) {
|
|
188
198
|
parts.visualSig = visualSig;
|
|
@@ -218,41 +228,61 @@ function sync(container, item, context) {
|
|
|
218
228
|
}
|
|
219
229
|
parts.cover.visible = showCover;
|
|
220
230
|
parts.coverMask.visible = showCover;
|
|
221
|
-
if (!band) parts.plate.rect(1, 1,
|
|
222
|
-
color:
|
|
223
|
-
alpha: .
|
|
231
|
+
if (!band) parts.plate.rect(1, 1, STRIPE, height - 2).fill({
|
|
232
|
+
color: accent,
|
|
233
|
+
alpha: .55
|
|
224
234
|
});
|
|
225
235
|
parts.title.visible = detail !== "plate";
|
|
226
236
|
parts.excerpt.visible = detail === "full";
|
|
237
|
+
parts.typeMark.visible = kind === "blank" && detail !== "plate";
|
|
227
238
|
}
|
|
228
|
-
if (detail === "plate")
|
|
229
|
-
|
|
239
|
+
if (detail === "plate") {
|
|
240
|
+
parts.typeMark.visible = false;
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const title = item.snapshot?.title || fileStem(item.ref.path);
|
|
230
244
|
const excerpt = item.snapshot?.excerpt ?? "";
|
|
245
|
+
const mark = fileTypeLabel(item.ref.path);
|
|
231
246
|
const innerWidth = Math.max(1, width - 20);
|
|
247
|
+
const showTypeMark = kind === "blank";
|
|
232
248
|
const textSig = [
|
|
233
249
|
title,
|
|
234
250
|
excerpt,
|
|
251
|
+
mark,
|
|
235
252
|
detail,
|
|
236
253
|
innerWidth,
|
|
237
254
|
band,
|
|
238
255
|
height,
|
|
256
|
+
kind,
|
|
239
257
|
context.palette.text,
|
|
240
|
-
context.palette.muted
|
|
258
|
+
context.palette.muted,
|
|
259
|
+
accent
|
|
241
260
|
].join("|");
|
|
242
261
|
if (textSig === parts.textSig) return;
|
|
243
262
|
parts.textSig = textSig;
|
|
244
263
|
const top = band > 0 ? band + PADDING * .8 : PADDING;
|
|
245
264
|
const contentBottom = height - PADDING;
|
|
246
|
-
|
|
265
|
+
let cursor = top;
|
|
266
|
+
if (showTypeMark) {
|
|
267
|
+
const markSize = Math.max(18, Math.min(TYPE_MARK_SIZE, Math.round(height * .22)));
|
|
268
|
+
parts.typeMark.style.fill = accent;
|
|
269
|
+
parts.typeMark.style.fontSize = markSize;
|
|
270
|
+
parts.typeMark.style.lineHeight = markSize * 1.1;
|
|
271
|
+
if (parts.typeMark.text !== mark) parts.typeMark.text = mark;
|
|
272
|
+
parts.typeMark.position.set(PADDING, cursor);
|
|
273
|
+
parts.typeMark.visible = true;
|
|
274
|
+
cursor += parts.typeMark.height + GAP;
|
|
275
|
+
} else parts.typeMark.visible = false;
|
|
276
|
+
const titleLines = linesInRoom(Math.max(0, contentBottom - cursor), TITLE_LINE, TITLE_MAX_LINES);
|
|
247
277
|
parts.title.style.fill = context.palette.text;
|
|
248
278
|
fitTextToLines(parts.title, title, titleLines, innerWidth);
|
|
249
|
-
parts.title.position.set(PADDING,
|
|
279
|
+
parts.title.position.set(PADDING, cursor);
|
|
250
280
|
parts.title.visible = titleLines > 0;
|
|
251
281
|
if (detail !== "full") {
|
|
252
282
|
parts.excerpt.visible = false;
|
|
253
283
|
return;
|
|
254
284
|
}
|
|
255
|
-
const excerptTop =
|
|
285
|
+
const excerptTop = cursor + (titleLines > 0 ? parts.title.height + GAP : 0);
|
|
256
286
|
const excerptLines = linesInRoom(contentBottom - excerptTop, EXCERPT_LINE, EXCERPT_MAX_LINES);
|
|
257
287
|
const showExcerpt = Boolean(excerpt) && excerptLines > 0;
|
|
258
288
|
if (showExcerpt) {
|
|
@@ -302,8 +332,20 @@ const fileCardRenderer = {
|
|
|
302
332
|
resolution,
|
|
303
333
|
roundPixels: true
|
|
304
334
|
});
|
|
335
|
+
const typeMark = new Text({
|
|
336
|
+
text: "",
|
|
337
|
+
style: {
|
|
338
|
+
fill: context.palette.muted,
|
|
339
|
+
fontFamily: BOARD_MONO_FONT_STACK,
|
|
340
|
+
fontSize: TYPE_MARK_SIZE,
|
|
341
|
+
fontWeight: "700",
|
|
342
|
+
lineHeight: TYPE_MARK_SIZE * 1.1
|
|
343
|
+
},
|
|
344
|
+
resolution,
|
|
345
|
+
roundPixels: true
|
|
346
|
+
});
|
|
305
347
|
body.mask = clip;
|
|
306
|
-
body.addChild(cover, coverMask, title, excerpt);
|
|
348
|
+
body.addChild(cover, coverMask, typeMark, title, excerpt);
|
|
307
349
|
root.addChild(plate, body, clip);
|
|
308
350
|
partsByContainer.set(root, {
|
|
309
351
|
root,
|
|
@@ -312,12 +354,14 @@ const fileCardRenderer = {
|
|
|
312
354
|
clip,
|
|
313
355
|
cover,
|
|
314
356
|
coverMask,
|
|
357
|
+
typeMark,
|
|
315
358
|
title,
|
|
316
359
|
excerpt,
|
|
317
360
|
visualSig: "",
|
|
318
361
|
textSig: "",
|
|
319
362
|
titleRes: { resolution },
|
|
320
|
-
excerptRes: { resolution }
|
|
363
|
+
excerptRes: { resolution },
|
|
364
|
+
typeMarkRes: { resolution }
|
|
321
365
|
});
|
|
322
366
|
if (item.type === "file") sync(root, item, context);
|
|
323
367
|
return root;
|
|
@@ -330,11 +374,12 @@ const fileCardRenderer = {
|
|
|
330
374
|
* would mean one draw call per distinct image and defeat the batch.
|
|
331
375
|
*/
|
|
332
376
|
renderFar: (graphics, item, context) => {
|
|
377
|
+
const category = fileCategory(item.type === "file" ? item.ref.path : "", item.type === "file" ? item.snapshot?.mimeType : void 0);
|
|
333
378
|
drawFarPlate(graphics, item.frame, {
|
|
334
379
|
fill: context.palette.surface,
|
|
335
380
|
fillAlpha: .96,
|
|
336
|
-
accent: context.palette
|
|
337
|
-
accentAlpha: .
|
|
381
|
+
accent: fileCategoryAccent(category, context.palette),
|
|
382
|
+
accentAlpha: .45
|
|
338
383
|
});
|
|
339
384
|
},
|
|
340
385
|
destroy: (container) => {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { RequestSource } from "../protocol/dist/provenance.js";
|
|
2
|
+
import { BoardTransactionsPage } from "../protocol/dist/board.js";
|
|
3
|
+
import { BoardDocument } from "../protocol/dist/board-document.js";
|
|
4
|
+
import "../protocol/dist/index.js";
|
|
5
|
+
//#region src/board/replay.d.ts
|
|
6
|
+
type BoardReplayActorKind = "human" | "cli" | "agent";
|
|
7
|
+
/** One step on the replay timeline. */
|
|
8
|
+
type BoardReplayEntry = {
|
|
9
|
+
version: number;
|
|
10
|
+
actorId: string;
|
|
11
|
+
kind: BoardReplayActorKind;
|
|
12
|
+
/** Epoch milliseconds. */
|
|
13
|
+
at: number;
|
|
14
|
+
/** Whether the step changed anything visible in the render document. */
|
|
15
|
+
visual: boolean;
|
|
16
|
+
};
|
|
17
|
+
type BoardReplayPlayer = ReturnType<typeof createBoardReplayPlayer>;
|
|
18
|
+
declare function boardReplayActorKind(source: RequestSource | null): BoardReplayActorKind;
|
|
19
|
+
/**
|
|
20
|
+
* Create a player positioned at the live version described by the first page.
|
|
21
|
+
*
|
|
22
|
+
* `transactions` are newest-first as served; the player keeps them oldest-first.
|
|
23
|
+
* Older pages can be added with `prepend`, and newer transactions that arrive
|
|
24
|
+
* live can be added with `append`, so the timeline grows in both directions
|
|
25
|
+
* without a reset.
|
|
26
|
+
*/
|
|
27
|
+
declare function createBoardReplayPlayer(page: BoardTransactionsPage): {
|
|
28
|
+
/** Oldest version the loaded transactions can rewind to. */
|
|
29
|
+
readonly floor: number;
|
|
30
|
+
/** Newest version known to the player. */
|
|
31
|
+
readonly head: number;
|
|
32
|
+
readonly entries: readonly BoardReplayEntry[];
|
|
33
|
+
/** Current position. */
|
|
34
|
+
readonly version: number;
|
|
35
|
+
seek: (version: number) => number;
|
|
36
|
+
documentAt: (version: number) => BoardDocument;
|
|
37
|
+
/** Item ids touched by the transaction that produced `version`, for camera follow. */
|
|
38
|
+
changedItemIds(version: number): string[];
|
|
39
|
+
/** Add an older page (as served, newest-first). */
|
|
40
|
+
prepend(older: BoardTransactionsPage): void;
|
|
41
|
+
/**
|
|
42
|
+
* Add transactions that landed after the current head. Forward payloads are
|
|
43
|
+
* enough to extend the timeline; the scrub position is untouched.
|
|
44
|
+
*
|
|
45
|
+
* Returns false when the page does not reach back to the head: the chain
|
|
46
|
+
* would have a hole, so nothing is appended and the caller must fetch an
|
|
47
|
+
* older page (`before` = the page's oldest version) and try again.
|
|
48
|
+
*/
|
|
49
|
+
append(latest: BoardTransactionsPage): boolean;
|
|
50
|
+
};
|
|
51
|
+
//#endregion
|
|
52
|
+
export { BoardReplayActorKind, BoardReplayEntry, BoardReplayPlayer, boardReplayActorKind, createBoardReplayPlayer };
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { BOARD_DOCUMENT_KIND } from "../protocol/dist/board.js";
|
|
2
|
+
import { BoardAppearanceSchema, parseBoardDocument } from "../protocol/dist/board-document.js";
|
|
3
|
+
import { boardNodeToAuthoringItem } from "../protocol/dist/board-codec.js";
|
|
4
|
+
import { DEFAULT_BOARD_APPEARANCE, boardAuthoringItemToDocumentItem } from "./semantic-document.js";
|
|
5
|
+
//#region src/board/replay.ts
|
|
6
|
+
function boardReplayActorKind(source) {
|
|
7
|
+
if (!source) return "human";
|
|
8
|
+
if (source.toolCallId) return "agent";
|
|
9
|
+
return source.via === "cli" ? "cli" : "human";
|
|
10
|
+
}
|
|
11
|
+
function isRecord(value) {
|
|
12
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
/** Apply one stored operation (forward payload or inverse) to the mutable state. */
|
|
15
|
+
function applyOperation(state, type, payload) {
|
|
16
|
+
switch (type) {
|
|
17
|
+
case "board.patch": {
|
|
18
|
+
const patch = isRecord(payload.patch) ? payload.patch : {};
|
|
19
|
+
if (isRecord(patch.metadata)) state.metadata = patch.metadata;
|
|
20
|
+
if (isRecord(patch.metadataPatch)) state.metadata = {
|
|
21
|
+
...state.metadata,
|
|
22
|
+
...patch.metadataPatch
|
|
23
|
+
};
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
case "node.create": {
|
|
27
|
+
const node = payload.node;
|
|
28
|
+
if (node) state.nodes.set(node.nodeId, node);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
case "node.patch": {
|
|
32
|
+
const nodeId = payload.nodeId;
|
|
33
|
+
const current = state.nodes.get(nodeId);
|
|
34
|
+
if (current && isRecord(payload.patch)) state.nodes.set(nodeId, {
|
|
35
|
+
...current,
|
|
36
|
+
...payload.patch,
|
|
37
|
+
nodeId
|
|
38
|
+
});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
case "node.delete":
|
|
42
|
+
state.nodes.delete(payload.nodeId);
|
|
43
|
+
return;
|
|
44
|
+
case "connection.create": {
|
|
45
|
+
const connection = payload.connection;
|
|
46
|
+
if (connection) state.connections.set(connection.id, connection);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
case "connection.patch": {
|
|
50
|
+
const connectionId = payload.connectionId;
|
|
51
|
+
const current = state.connections.get(connectionId);
|
|
52
|
+
if (current && isRecord(payload.patch)) state.connections.set(connectionId, {
|
|
53
|
+
...current,
|
|
54
|
+
...payload.patch,
|
|
55
|
+
id: connectionId
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
case "connection.delete":
|
|
60
|
+
state.connections.delete(payload.connectionId);
|
|
61
|
+
return;
|
|
62
|
+
default: return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Inverses are stored either as a full operation (`{ type, payload }`) or, for
|
|
67
|
+
* `board.patch`, as a bare `{ patch }`. Normalise both to `(type, payload)`.
|
|
68
|
+
*/
|
|
69
|
+
function inverseOf(operation) {
|
|
70
|
+
const inverse = operation.inverse;
|
|
71
|
+
if (!inverse) return null;
|
|
72
|
+
if (typeof inverse.type === "string" && isRecord(inverse.payload)) return {
|
|
73
|
+
type: inverse.type,
|
|
74
|
+
payload: inverse.payload
|
|
75
|
+
};
|
|
76
|
+
if (operation.type === "board.patch" && isRecord(inverse.patch)) return {
|
|
77
|
+
type: "board.patch",
|
|
78
|
+
payload: { patch: inverse.patch }
|
|
79
|
+
};
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
function operationItemIds(operation) {
|
|
83
|
+
const payload = operation.payload;
|
|
84
|
+
switch (operation.type) {
|
|
85
|
+
case "node.create": return isRecord(payload.node) && typeof payload.node.nodeId === "string" ? [payload.node.nodeId] : [];
|
|
86
|
+
case "node.patch":
|
|
87
|
+
case "node.delete": return typeof payload.nodeId === "string" ? [payload.nodeId] : [];
|
|
88
|
+
default: return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const VISUAL_OPERATION_TYPES = /* @__PURE__ */ new Set([
|
|
92
|
+
"board.patch",
|
|
93
|
+
"node.create",
|
|
94
|
+
"node.patch",
|
|
95
|
+
"node.delete",
|
|
96
|
+
"connection.create",
|
|
97
|
+
"connection.patch",
|
|
98
|
+
"connection.delete"
|
|
99
|
+
]);
|
|
100
|
+
function entryOf(transaction) {
|
|
101
|
+
return {
|
|
102
|
+
version: transaction.version,
|
|
103
|
+
actorId: transaction.actorId,
|
|
104
|
+
kind: boardReplayActorKind(transaction.source),
|
|
105
|
+
at: Date.parse(transaction.createdAt),
|
|
106
|
+
visual: transaction.operations.some((operation) => VISUAL_OPERATION_TYPES.has(operation.type))
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function appearanceOf(metadata) {
|
|
110
|
+
const parsed = BoardAppearanceSchema.safeParse(metadata.appearance);
|
|
111
|
+
return parsed.success ? parsed.data : DEFAULT_BOARD_APPEARANCE;
|
|
112
|
+
}
|
|
113
|
+
function project(state) {
|
|
114
|
+
const nodes = [...state.nodes.values()].sort((a, b) => (a.orderKey ?? "").localeCompare(b.orderKey ?? ""));
|
|
115
|
+
return parseBoardDocument({
|
|
116
|
+
kind: BOARD_DOCUMENT_KIND,
|
|
117
|
+
version: 1,
|
|
118
|
+
appearance: appearanceOf(state.metadata),
|
|
119
|
+
viewport: {
|
|
120
|
+
x: 0,
|
|
121
|
+
y: 0,
|
|
122
|
+
zoom: 1
|
|
123
|
+
},
|
|
124
|
+
items: nodes.map((node) => boardAuthoringItemToDocumentItem(boardNodeToAuthoringItem(node))),
|
|
125
|
+
connections: [...state.connections.values()]
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function stateFromSnapshot(snapshot) {
|
|
129
|
+
return {
|
|
130
|
+
nodes: new Map(snapshot.nodes.map(({ boardId: _boardId, version: _version, createdAt: _c, updatedAt: _u, ...node }) => [node.nodeId, node])),
|
|
131
|
+
connections: new Map(snapshot.connections.map(({ boardId: _boardId, revision: _revision, createdAt: _c, updatedAt: _u, ...connection }) => [connection.id, connection])),
|
|
132
|
+
metadata: snapshot.board.metadata
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Create a player positioned at the live version described by the first page.
|
|
137
|
+
*
|
|
138
|
+
* `transactions` are newest-first as served; the player keeps them oldest-first.
|
|
139
|
+
* Older pages can be added with `prepend`, and newer transactions that arrive
|
|
140
|
+
* live can be added with `append`, so the timeline grows in both directions
|
|
141
|
+
* without a reset.
|
|
142
|
+
*/
|
|
143
|
+
function createBoardReplayPlayer(page) {
|
|
144
|
+
if (!page.snapshot) throw new Error("Board replay needs the first transactions page (with snapshot).");
|
|
145
|
+
const liveVersion = page.board.version;
|
|
146
|
+
const newest = page.transactions.reduce((max, transaction) => Math.max(max, transaction.version), 0);
|
|
147
|
+
if (page.transactions.length > 0 && newest !== liveVersion) throw new Error(`Board replay page is inconsistent: board is at v${liveVersion}, newest transaction is v${newest}.`);
|
|
148
|
+
let transactions = [...page.transactions].sort((a, b) => a.version - b.version);
|
|
149
|
+
let entries = transactions.map(entryOf);
|
|
150
|
+
const state = stateFromSnapshot(page.snapshot);
|
|
151
|
+
let cursor = liveVersion;
|
|
152
|
+
const documents = /* @__PURE__ */ new Map();
|
|
153
|
+
/** Oldest version reachable with the transactions loaded so far. */
|
|
154
|
+
let floor = transactions[0]?.baseVersion ?? liveVersion;
|
|
155
|
+
/**
|
|
156
|
+
* Index of the first transaction whose version is `>= version`. Versions are
|
|
157
|
+
* strictly increasing but not dense (a no-op mutation leaves a gap), so
|
|
158
|
+
* binary-search by value rather than indexing by offset.
|
|
159
|
+
*/
|
|
160
|
+
function lowerBound(version) {
|
|
161
|
+
let low = 0;
|
|
162
|
+
let high = transactions.length;
|
|
163
|
+
while (low < high) {
|
|
164
|
+
const mid = low + high >> 1;
|
|
165
|
+
if (transactions[mid].version < version) low = mid + 1;
|
|
166
|
+
else high = mid;
|
|
167
|
+
}
|
|
168
|
+
return low;
|
|
169
|
+
}
|
|
170
|
+
function transactionProducing(version) {
|
|
171
|
+
const candidate = transactions[lowerBound(version)];
|
|
172
|
+
return candidate?.version === version ? candidate : void 0;
|
|
173
|
+
}
|
|
174
|
+
/** Undo the transaction that produced `cursor`. */
|
|
175
|
+
function stepBackward() {
|
|
176
|
+
const transaction = transactionProducing(cursor);
|
|
177
|
+
if (!transaction) return false;
|
|
178
|
+
for (let index = transaction.operations.length - 1; index >= 0; index -= 1) {
|
|
179
|
+
const inverse = inverseOf(transaction.operations[index]);
|
|
180
|
+
if (inverse) applyOperation(state, inverse.type, inverse.payload);
|
|
181
|
+
}
|
|
182
|
+
cursor = transaction.baseVersion;
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
function stepForward(next) {
|
|
186
|
+
for (const operation of next.operations) applyOperation(state, operation.type, operation.payload);
|
|
187
|
+
cursor = next.version;
|
|
188
|
+
}
|
|
189
|
+
function head() {
|
|
190
|
+
return transactions.at(-1)?.version ?? liveVersion;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Move to `version`, clamped to the loaded range. Returns the version reached,
|
|
194
|
+
* which is the nearest loaded version at or below the request.
|
|
195
|
+
*/
|
|
196
|
+
function seek(version) {
|
|
197
|
+
const target = Math.max(floor, Math.min(head(), version));
|
|
198
|
+
while (cursor > target && stepBackward());
|
|
199
|
+
for (let next = transactions[lowerBound(cursor + 1)]; next && next.version <= target; next = transactions[lowerBound(cursor + 1)]) stepForward(next);
|
|
200
|
+
return cursor;
|
|
201
|
+
}
|
|
202
|
+
function documentAt(version) {
|
|
203
|
+
const reached = seek(version);
|
|
204
|
+
const cached = documents.get(reached);
|
|
205
|
+
if (cached) return cached;
|
|
206
|
+
const document = project(state);
|
|
207
|
+
documents.set(reached, document);
|
|
208
|
+
return document;
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
/** Oldest version the loaded transactions can rewind to. */
|
|
212
|
+
get floor() {
|
|
213
|
+
return floor;
|
|
214
|
+
},
|
|
215
|
+
/** Newest version known to the player. */
|
|
216
|
+
get head() {
|
|
217
|
+
return head();
|
|
218
|
+
},
|
|
219
|
+
get entries() {
|
|
220
|
+
return entries;
|
|
221
|
+
},
|
|
222
|
+
/** Current position. */
|
|
223
|
+
get version() {
|
|
224
|
+
return cursor;
|
|
225
|
+
},
|
|
226
|
+
seek,
|
|
227
|
+
documentAt,
|
|
228
|
+
/** Item ids touched by the transaction that produced `version`, for camera follow. */
|
|
229
|
+
changedItemIds(version) {
|
|
230
|
+
return [...new Set(transactionProducing(version)?.operations.flatMap(operationItemIds))];
|
|
231
|
+
},
|
|
232
|
+
/** Add an older page (as served, newest-first). */
|
|
233
|
+
prepend(older) {
|
|
234
|
+
const current = floor;
|
|
235
|
+
const fresh = older.transactions.filter((transaction) => transaction.version <= current).sort((a, b) => a.version - b.version);
|
|
236
|
+
if (fresh.length === 0) return;
|
|
237
|
+
transactions = [...fresh, ...transactions];
|
|
238
|
+
entries = [...fresh.map(entryOf), ...entries];
|
|
239
|
+
floor = fresh[0].baseVersion;
|
|
240
|
+
},
|
|
241
|
+
/**
|
|
242
|
+
* Add transactions that landed after the current head. Forward payloads are
|
|
243
|
+
* enough to extend the timeline; the scrub position is untouched.
|
|
244
|
+
*
|
|
245
|
+
* Returns false when the page does not reach back to the head: the chain
|
|
246
|
+
* would have a hole, so nothing is appended and the caller must fetch an
|
|
247
|
+
* older page (`before` = the page's oldest version) and try again.
|
|
248
|
+
*/
|
|
249
|
+
append(latest) {
|
|
250
|
+
const current = head();
|
|
251
|
+
const fresh = latest.transactions.filter((transaction) => transaction.version > current).sort((a, b) => a.version - b.version);
|
|
252
|
+
if (fresh.length === 0) return true;
|
|
253
|
+
if (fresh[0].baseVersion > current) return false;
|
|
254
|
+
transactions = [...transactions, ...fresh];
|
|
255
|
+
entries = [...entries, ...fresh.map(entryOf)];
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
//#endregion
|
|
261
|
+
export { boardReplayActorKind, createBoardReplayPlayer };
|
|
@@ -4,7 +4,15 @@ import { BoardDocument, BoardItem } from "../protocol/dist/board-document.js";
|
|
|
4
4
|
import "../protocol/dist/index.js";
|
|
5
5
|
//#region src/board/semantic-document.d.ts
|
|
6
6
|
declare const DEFAULT_BOARD_APPEARANCE: {
|
|
7
|
-
theme
|
|
7
|
+
theme?: string | undefined;
|
|
8
|
+
motion?: {
|
|
9
|
+
enter?: {
|
|
10
|
+
kind: string;
|
|
11
|
+
kindVersion: number;
|
|
12
|
+
params: Record<string, unknown>;
|
|
13
|
+
} | undefined;
|
|
14
|
+
} | undefined;
|
|
15
|
+
mood?: "arcane" | "clean" | "cyber" | "natural" | "playful" | undefined;
|
|
8
16
|
background: {
|
|
9
17
|
kind: "custom" | "dots" | "grid" | "image" | "shader" | "solid";
|
|
10
18
|
color?: string | undefined;
|
|
@@ -18,7 +26,6 @@ declare const DEFAULT_BOARD_APPEARANCE: {
|
|
|
18
26
|
size: number;
|
|
19
27
|
opacity: number;
|
|
20
28
|
};
|
|
21
|
-
mood: "arcane" | "clean" | "cyber" | "natural" | "playful";
|
|
22
29
|
};
|
|
23
30
|
/** Convert a public authoring Item to the renderer/editor document shape. */
|
|
24
31
|
declare function boardAuthoringItemToDocumentItem(item: BoardAuthoringItem): BoardItem;
|
|
@@ -5,14 +5,12 @@ import { boardDrawPointsToWorld } from "../protocol/dist/board-geometry.js";
|
|
|
5
5
|
import { boardAuthoringItemToNode } from "../protocol/dist/board-codec.js";
|
|
6
6
|
//#region src/board/semantic-document.ts
|
|
7
7
|
const DEFAULT_BOARD_APPEARANCE = BoardAppearanceSchema.parse({
|
|
8
|
-
theme: "clean",
|
|
9
8
|
background: { kind: "solid" },
|
|
10
9
|
grid: {
|
|
11
10
|
visible: false,
|
|
12
11
|
size: 24,
|
|
13
12
|
opacity: .12
|
|
14
|
-
}
|
|
15
|
-
mood: "clean"
|
|
13
|
+
}
|
|
16
14
|
});
|
|
17
15
|
/** Convert a public authoring Item to the renderer/editor document shape. */
|
|
18
16
|
function boardAuthoringItemToDocumentItem(item) {
|