@neta-art/cohub 12.2.0 → 12.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +77 -3
  2. package/dist/board/editor/clipboard.js +1 -1
  3. package/dist/board/editor/creation.js +1 -1
  4. package/dist/board/editor/document.d.ts +3 -1
  5. package/dist/board/editor/document.js +8 -6
  6. package/dist/board/editor/editor.d.ts +7 -3
  7. package/dist/board/editor/editor.js +4 -2
  8. package/dist/board/editor/index.d.ts +3 -3
  9. package/dist/board/editor/index.js +2 -2
  10. package/dist/board/editor/interaction.d.ts +23 -2
  11. package/dist/board/editor/interaction.js +93 -4
  12. package/dist/board/editor/query.d.ts +1 -1
  13. package/dist/board/editor/query.js +3 -7
  14. package/dist/board/editor/selection.js +1 -1
  15. package/dist/board/editor/tool.d.ts +4 -1
  16. package/dist/board/editor/tool.js +6 -1
  17. package/dist/board/export/index.js +1 -1
  18. package/dist/board/export/node/scripts.js +1 -1
  19. package/dist/board/export/scene.js +1 -1
  20. package/dist/board/model/asset-key.d.ts +9 -1
  21. package/dist/board/model/asset-key.js +15 -3
  22. package/dist/board/model/index.d.ts +5 -5
  23. package/dist/board/model/index.js +18 -18
  24. package/dist/board/model/items/create.js +2 -2
  25. package/dist/board/model/items/definition.d.ts +9 -1
  26. package/dist/board/model/items/index.d.ts +2 -2
  27. package/dist/board/model/items/registry.d.ts +2 -1
  28. package/dist/board/model/items/registry.js +1 -0
  29. package/dist/board/model/sampling.js +1 -1
  30. package/dist/board/model/text-metrics.d.ts +21 -1
  31. package/dist/board/model/text-metrics.js +24 -1
  32. package/dist/board/model/themes.d.ts +15 -8
  33. package/dist/board/model/themes.js +15 -10
  34. package/dist/board/render/index.d.ts +8 -6
  35. package/dist/board/render/index.js +7 -5
  36. package/dist/board/render/labels.d.ts +15 -0
  37. package/dist/board/render/labels.js +14 -0
  38. package/dist/board/render/lod.d.ts +9 -1
  39. package/dist/board/render/lod.js +7 -1
  40. package/dist/board/render/media-interaction.d.ts +5 -2
  41. package/dist/board/render/media-interaction.js +15 -3
  42. package/dist/board/render/renderers/arrow-card-renderer.js +7 -5
  43. package/dist/board/render/renderers/audio-card-renderer.js +1 -1
  44. package/dist/board/render/renderers/board-renderer-registry.d.ts +12 -1
  45. package/dist/board/render/renderers/board-renderer-registry.js +9 -4
  46. package/dist/board/render/renderers/draw-card-renderer.js +1 -1
  47. package/dist/board/render/renderers/far-plate.d.ts +4 -1
  48. package/dist/board/render/renderers/far-plate.js +5 -1
  49. package/dist/board/render/renderers/file-card-renderer.js +46 -26
  50. package/dist/board/render/renderers/frame-card-renderer.js +9 -3
  51. package/dist/board/render/renderers/image-card-renderer.js +2 -2
  52. package/dist/board/render/renderers/model-card-renderer.js +5 -5
  53. package/dist/board/render/renderers/shape-card-renderer.js +1 -1
  54. package/dist/board/render/renderers/task-card-renderer.js +2 -2
  55. package/dist/board/render/renderers/video-card-renderer.js +3 -3
  56. package/dist/board/render/script-export.js +1 -1
  57. package/dist/board/render/script-host.js +3 -3
  58. package/dist/board/render/style.d.ts +1 -0
  59. package/dist/board/render/style.js +3 -1
  60. package/dist/board/render/text-edit.d.ts +32 -0
  61. package/dist/board/render/text-edit.js +134 -0
  62. package/dist/board/render/text-fit.d.ts +1 -3
  63. package/dist/board/render/text-fit.js +4 -43
  64. package/dist/board/render/text-measurement.d.ts +12 -2
  65. package/dist/board/render/text-measurement.js +55 -10
  66. package/dist/board/replica/index.d.ts +4 -2
  67. package/dist/board/replica/index.js +28 -17
  68. package/dist/board/replica/indexeddb.d.ts +16 -0
  69. package/dist/board/replica/indexeddb.js +155 -0
  70. package/dist/board/stage/assets.d.ts +16 -4
  71. package/dist/board/stage/assets.js +155 -30
  72. package/dist/board/stage/bench.d.ts +35 -0
  73. package/dist/board/stage/bench.js +153 -0
  74. package/dist/board/stage/device.d.ts +41 -0
  75. package/dist/board/stage/device.js +62 -0
  76. package/dist/board/stage/frame-budget.d.ts +19 -0
  77. package/dist/board/stage/frame-budget.js +69 -0
  78. package/dist/board/stage/idle.d.ts +4 -0
  79. package/dist/board/stage/idle.js +26 -0
  80. package/dist/board/stage/index.d.ts +9 -5
  81. package/dist/board/stage/index.js +5 -2
  82. package/dist/board/stage/presses.d.ts +21 -0
  83. package/dist/board/stage/presses.js +66 -0
  84. package/dist/board/stage/scene.d.ts +4 -0
  85. package/dist/board/stage/scene.js +45 -13
  86. package/dist/board/stage/stage.d.ts +17 -2
  87. package/dist/board/stage/stage.js +314 -95
  88. package/dist/board/stage/texture-pool.d.ts +6 -4
  89. package/dist/board/stage/texture-pool.js +42 -8
  90. package/dist/chunks/environment.d.ts +6 -0
  91. package/dist/chunks/http.d.ts +9 -1
  92. package/dist/chunks/http.js +791 -4
  93. package/dist/index.d.ts +52 -1
  94. package/dist/index.js +65 -733
  95. package/dist/protocol/dist/board-model.d.ts +15 -1
  96. package/dist/protocol/dist/board-model.js +12 -2
  97. package/dist/protocol/dist/board-patch.js +1 -1
  98. package/dist/protocol/dist/board-schema.d.ts +2 -1
  99. package/dist/protocol/dist/board-schema.js +4 -1
  100. package/dist/protocol/dist/core/media.js +4 -0
  101. package/dist/protocol/dist/index.d.ts +2 -2
  102. package/package.json +2 -1
@@ -0,0 +1,134 @@
1
+ import { BOARD_FONT_STACK } from "../../protocol/dist/board-constants.js";
2
+ import { layoutBoardText } from "../../protocol/dist/board-layout.js";
3
+ import { degToRad, rotatePointAround, worldPoint, worldToScreen } from "../model/geometry.js";
4
+ import { resolveSceneArrow } from "../model/arrow-geometry.js";
5
+ import { BOARD_FRAME_LABEL_LINE_HEIGHT, frameLabelOffset, layoutShapeLabel } from "../model/text-metrics.js";
6
+ import { boardTextStyle, canvasFont, canvasFontFamily, layoutTextLines } from "./text-measurement.js";
7
+ import { ARROW_LABEL, arrowLabelAnchor } from "./labels.js";
8
+ //#region src/board/render/text-edit.ts
9
+ function place({ center, ...box }, viewport) {
10
+ const screen = worldToScreen(center, viewport);
11
+ return {
12
+ left: screen.x - box.width / 2 - box.padding.x,
13
+ top: screen.y - box.height / 2 - box.padding.y,
14
+ ...box
15
+ };
16
+ }
17
+ function label(draft, font, lineHeight) {
18
+ const { lines, width } = layoutTextLines(draft, canvasFont(font));
19
+ return {
20
+ width: Math.max(width, font.fontSize * 4),
21
+ height: lines.length * lineHeight
22
+ };
23
+ }
24
+ function boardTextEditLayout(item, { viewport, scene, style, draft }) {
25
+ const { frame } = item;
26
+ const rotation = frame.rotation || 0;
27
+ const center = worldPoint(frame.x + frame.width / 2, frame.y + frame.height / 2);
28
+ const { zoom } = viewport;
29
+ if (item.type === "text") {
30
+ const { props } = item;
31
+ const text = layoutBoardText({
32
+ ...props,
33
+ text: draft
34
+ });
35
+ const scale = zoom * frame.width / text.width;
36
+ return place({
37
+ center,
38
+ width: text.width,
39
+ height: text.height,
40
+ padding: {
41
+ x: 1 / scale,
42
+ y: 0
43
+ },
44
+ scale,
45
+ rotation,
46
+ font: boardTextStyle(props),
47
+ lineHeight: text.lineHeight,
48
+ textAlign: props.align ?? "left",
49
+ whiteSpace: props.width ? "pre-wrap" : "pre",
50
+ live: true
51
+ }, viewport);
52
+ }
53
+ if (item.type === "shape") {
54
+ const { props } = item;
55
+ const text = layoutShapeLabel({
56
+ text: draft,
57
+ fontSize: props.fontSize
58
+ }, frame.width);
59
+ return place({
60
+ center,
61
+ width: Math.max(0, frame.width - 16),
62
+ height: Math.min(frame.height, text.height),
63
+ padding: {
64
+ x: 8,
65
+ y: Math.max(0, (frame.height - text.height) / 2)
66
+ },
67
+ scale: zoom,
68
+ rotation,
69
+ font: boardTextStyle({
70
+ font: "sans",
71
+ fontSize: props.fontSize,
72
+ fontWeight: 500
73
+ }),
74
+ lineHeight: text.lineHeight,
75
+ textAlign: props.align ?? "center",
76
+ whiteSpace: "pre-wrap",
77
+ live: false
78
+ }, viewport);
79
+ }
80
+ if (item.type === "frame") {
81
+ const fontSize = style?.frameLabelSize ?? 12;
82
+ const lineHeight = fontSize * BOARD_FRAME_LABEL_LINE_HEIGHT;
83
+ const font = {
84
+ fontFamily: canvasFontFamily(style?.font ?? "\"Geist Variable\", \"Geist\", \"Noto Sans CJK SC\", \"Noto Sans SC\", \"PingFang SC\", \"Microsoft YaHei\", system-ui, -apple-system, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Noto Color Emoji\", \"Noto Sans Math\", \"Noto Sans Symbols 2\", \"DejaVu Sans\", \"FreeSans\", \"FreeSerif\", sans-serif"),
85
+ fontSize,
86
+ fontWeight: "600"
87
+ };
88
+ const size = label(draft, font, lineHeight);
89
+ const { x, y } = frameLabelOffset(fontSize);
90
+ const local = worldPoint(frame.x + x + size.width / 2, frame.y + y + size.height / 2);
91
+ return place({
92
+ center: rotation ? rotatePointAround(local, center, degToRad(rotation)) : local,
93
+ ...size,
94
+ padding: {
95
+ x: 4,
96
+ y: 2
97
+ },
98
+ scale: zoom,
99
+ rotation,
100
+ font,
101
+ lineHeight,
102
+ textAlign: "left",
103
+ whiteSpace: "pre",
104
+ live: false
105
+ }, viewport);
106
+ }
107
+ if (item.type === "arrow") {
108
+ const { fontSize } = item.props;
109
+ const font = {
110
+ fontFamily: canvasFontFamily(BOARD_FONT_STACK),
111
+ fontSize,
112
+ fontWeight: ARROW_LABEL.fontWeight
113
+ };
114
+ const lineHeight = fontSize * ARROW_LABEL.lineHeight;
115
+ return place({
116
+ center: arrowLabelAnchor(resolveSceneArrow(item, scene), item.style),
117
+ ...label(draft, font, lineHeight),
118
+ padding: {
119
+ x: fontSize * ARROW_LABEL.padX,
120
+ y: fontSize * ARROW_LABEL.padY
121
+ },
122
+ scale: zoom,
123
+ rotation: 0,
124
+ font,
125
+ lineHeight,
126
+ textAlign: "center",
127
+ whiteSpace: "pre",
128
+ live: false
129
+ }, viewport);
130
+ }
131
+ return null;
132
+ }
133
+ //#endregion
134
+ export { boardTextEditLayout };
@@ -1,8 +1,6 @@
1
1
  import { Text } from "pixi.js";
2
2
  //#region src/board/render/text-fit.d.ts
3
- declare function ellipsizeWrappedLines(lines: string[], maxLines: number): string;
4
- declare function fitLineWithEllipsis(line: string, wrapWidth: number, measureWidth: (value: string) => number): string;
5
3
  declare function fitTextToLines(text: Text, value: string, maxLines: number, wrapWidth: number): void;
6
4
  declare function fitTextToWidth(text: Text, value: string, width: number): void;
7
5
  //#endregion
8
- export { ellipsizeWrappedLines, fitLineWithEllipsis, fitTextToLines, fitTextToWidth };
6
+ export { fitTextToLines, fitTextToWidth };
@@ -1,49 +1,10 @@
1
- import { layoutTextLines } from "./text-measurement.js";
1
+ import { fitLineWithEllipsis, layoutTextLines } from "./text-measurement.js";
2
2
  import { CanvasTextMetrics, fontStringFromTextStyle } from "pixi.js";
3
3
  //#region src/board/render/text-fit.ts
4
- function ellipsizeWrappedLines(lines, maxLines) {
5
- if (maxLines <= 0 || lines.length === 0) return "";
6
- if (lines.length <= maxLines) return lines.join("\n");
7
- const kept = lines.slice(0, maxLines);
8
- const lastIndex = kept.length - 1;
9
- const last = (kept[lastIndex] ?? "").replace(/\s+$/u, "");
10
- kept[lastIndex] = last ? `${last}…` : "…";
11
- return kept.join("\n");
12
- }
13
- function fitLineWithEllipsis(line, wrapWidth, measureWidth) {
14
- const trimmed = line.replace(/\s+$/u, "");
15
- if (!trimmed) return "…";
16
- const full = `${trimmed}…`;
17
- if (measureWidth(full) <= wrapWidth) return full;
18
- let lo = 0;
19
- let hi = trimmed.length;
20
- let best = "…";
21
- while (lo <= hi) {
22
- const mid = lo + hi >> 1;
23
- const candidate = mid > 0 ? `${trimmed.slice(0, mid)}…` : "…";
24
- if (measureWidth(candidate) <= wrapWidth) {
25
- best = candidate;
26
- lo = mid + 1;
27
- } else hi = mid - 1;
28
- }
29
- return best;
30
- }
31
4
  function fitTextToLines(text, value, maxLines, wrapWidth) {
32
- const width = Math.max(1, wrapWidth);
33
5
  text.style.wordWrap = false;
34
- if (!value || maxLines <= 0) {
35
- if (text.text !== "") text.text = "";
36
- return;
37
- }
38
- const charCap = Math.max(maxLines * Math.ceil(width), maxLines * 4);
39
- const sample = value.length > charCap ? value.slice(0, charCap) : value;
40
- const { lines } = layoutTextLines(sample, fontStringFromTextStyle(text.style), width);
41
- const kept = lines.slice(0, maxLines);
42
- if (lines.length > maxLines || sample !== value) {
43
- const lastIndex = kept.length - 1;
44
- kept[lastIndex] = fitLineWithEllipsis(kept[lastIndex] ?? "", width, (candidate) => CanvasTextMetrics.measureText(candidate, text.style).width);
45
- }
46
- const fitted = kept.join("\n");
6
+ const { lines } = layoutTextLines(value, fontStringFromTextStyle(text.style), Math.max(1, wrapWidth), { maxLines });
7
+ const fitted = lines.join("\n");
47
8
  if (text.text !== fitted) text.text = fitted;
48
9
  }
49
10
  function fitTextToWidth(text, value, width) {
@@ -53,4 +14,4 @@ function fitTextToWidth(text, value, width) {
53
14
  if (text.text !== fitted) text.text = fitted;
54
15
  }
55
16
  //#endregion
56
- export { ellipsizeWrappedLines, fitLineWithEllipsis, fitTextToLines, fitTextToWidth };
17
+ export { fitTextToLines, fitTextToWidth };
@@ -15,10 +15,20 @@ type BoardTextStyle = {
15
15
  declare function canvasFontFamily(families: string): string;
16
16
  declare function boardTextStyle({ font, fontSize, fontWeight }: BoardTextFont): BoardTextStyle;
17
17
  declare function canvasFont({ fontFamily, fontSize, fontWeight }: BoardTextStyle): string;
18
- declare function layoutTextLines(text: string, font: string, maxWidth?: number): BoardTextLines;
18
+ declare function measureTextWidth(text: string, font: string): number;
19
+ type BoardTextWhiteSpace = "normal" | "pre-wrap";
20
+ type BoardTextLineOptions = {
21
+ whiteSpace?: BoardTextWhiteSpace;
22
+ maxLines?: number;
23
+ };
24
+ type BoardTextLineLayout = BoardTextLines & {
25
+ truncated: boolean;
26
+ };
27
+ declare function layoutTextLines(text: string, font: string, maxWidth?: number, { whiteSpace, maxLines }?: BoardTextLineOptions): BoardTextLineLayout;
28
+ declare function fitLineWithEllipsis(line: string, width: number, measureWidth: (value: string) => number): string;
19
29
  declare function cssBaselineShift(style: TextStyle): number;
20
30
  declare function installBoardTextMeasurement(): void;
21
31
  declare function ensureBoardTextMeasurement(): void;
22
32
  declare function onBoardTextFontsChange(listener: () => void): () => void;
23
33
  //#endregion
24
- export { BOARD_FONT_STACKS, BoardTextStyle, boardTextStyle, canvasFont, canvasFontFamily, cssBaselineShift, ensureBoardTextMeasurement, installBoardTextMeasurement, layoutTextLines, onBoardTextFontsChange };
34
+ export { BOARD_FONT_STACKS, BoardTextLineLayout, BoardTextLineOptions, BoardTextStyle, BoardTextWhiteSpace, boardTextStyle, canvasFont, canvasFontFamily, cssBaselineShift, ensureBoardTextMeasurement, fitLineWithEllipsis, installBoardTextMeasurement, layoutTextLines, measureTextWidth, onBoardTextFontsChange };
@@ -43,8 +43,8 @@ const measuredFonts = /* @__PURE__ */ new Set();
43
43
  const measuredFamilies = /* @__PURE__ */ new Set();
44
44
  const TRAILING_SPACE = /[ \t]+$/u;
45
45
  const unquote = (name) => name.trim().replace(/^["']|["']$/gu, "");
46
- function prepareText(text, font) {
47
- const key = `${font}\n${text}`;
46
+ function prepareText(text, font, whiteSpace) {
47
+ const key = `${whiteSpace}\n${font}\n${text}`;
48
48
  let value = prepared.get(key);
49
49
  if (value) {
50
50
  prepared.delete(key);
@@ -55,30 +55,75 @@ function prepareText(text, font) {
55
55
  measuredFonts.add(font);
56
56
  for (const name of /px\s+(.+)$/u.exec(font)?.[1]?.split(",") ?? []) measuredFamilies.add(unquote(name));
57
57
  }
58
- value = prepareWithSegments(text, font, { whiteSpace: "pre-wrap" });
58
+ value = prepareWithSegments(text, font, { whiteSpace });
59
59
  if (prepared.size >= PREPARED_LIMIT) prepared.delete(prepared.keys().next().value);
60
60
  prepared.set(key, value);
61
61
  return value;
62
62
  }
63
- function layoutTextLines(text, font, maxWidth = Number.POSITIVE_INFINITY) {
63
+ let measureContext;
64
+ function measureTextWidth(text, font) {
65
+ measureContext ??= DOMAdapter.get().createCanvas(1, 1).getContext("2d");
66
+ if (!measureContext) return 0;
67
+ measureContext.font = font;
68
+ return measureContext.measureText(text).width;
69
+ }
70
+ function layoutTextLines(text, font, maxWidth = Number.POSITIVE_INFINITY, { whiteSpace = "pre-wrap", maxLines = Number.POSITIVE_INFINITY } = {}) {
64
71
  const lines = [];
65
- let width = 0;
66
- for (const paragraph of text.replace(/\r\n?/gu, "\n").replaceAll(" ", " ").split("\n")) {
72
+ const widths = [];
73
+ if (maxLines <= 0) return {
74
+ lines,
75
+ width: 0,
76
+ truncated: text.length > 0
77
+ };
78
+ let source = whiteSpace === "normal" ? text.replace(/\s+/gu, " ").trim() : text.replace(/\r\n?/gu, "\n").replaceAll(" ", " ");
79
+ const cap = Number.isFinite(maxLines) && Number.isFinite(maxWidth) ? maxLines * (Math.ceil(maxWidth) + 1) : Number.POSITIVE_INFINITY;
80
+ let truncated = source.length > cap;
81
+ if (truncated) source = source.slice(0, cap);
82
+ const paragraphs = whiteSpace === "normal" ? source ? [source] : [] : source.split("\n");
83
+ for (const paragraph of paragraphs) {
84
+ if (lines.length > maxLines) break;
67
85
  if (!paragraph) {
68
86
  lines.push("");
87
+ widths.push(0);
69
88
  continue;
70
89
  }
71
- const wrapped = layoutWithLines(prepareText(paragraph, font), maxWidth, 1).lines;
90
+ const wrapped = layoutWithLines(prepareText(paragraph, font, whiteSpace), maxWidth, 1).lines;
72
91
  for (const [index, line] of wrapped.entries()) {
73
92
  lines.push(index === wrapped.length - 1 ? line.text : line.text.replace(TRAILING_SPACE, ""));
74
- width = Math.max(width, line.width);
93
+ widths.push(line.width);
75
94
  }
76
95
  }
96
+ if (lines.length > maxLines) {
97
+ truncated = true;
98
+ lines.length = maxLines;
99
+ widths.length = maxLines;
100
+ }
101
+ if (truncated && lines.length > 0) {
102
+ const last = lines.length - 1;
103
+ lines[last] = fitLineWithEllipsis(lines[last], maxWidth, (value) => measureTextWidth(value, font));
104
+ widths[last] = measureTextWidth(lines[last], font);
105
+ }
77
106
  return {
78
107
  lines,
79
- width
108
+ width: widths.reduce((max, width) => Math.max(max, width), 0),
109
+ truncated
80
110
  };
81
111
  }
112
+ const graphemes = typeof Intl.Segmenter === "function" ? new Intl.Segmenter(void 0, { granularity: "grapheme" }) : null;
113
+ function fitLineWithEllipsis(line, width, measureWidth) {
114
+ const trimmed = line.replace(/\s+$/u, "");
115
+ if (!trimmed) return "…";
116
+ if (measureWidth(`${trimmed}…`) <= width) return `${trimmed}…`;
117
+ const parts = graphemes ? Array.from(graphemes.segment(trimmed), ({ segment }) => segment) : Array.from(trimmed);
118
+ let lo = 0;
119
+ let hi = parts.length - 1;
120
+ while (lo < hi) {
121
+ const mid = lo + hi + 1 >> 1;
122
+ if (measureWidth(`${parts.slice(0, mid).join("")}…`) <= width) lo = mid;
123
+ else hi = mid - 1;
124
+ }
125
+ return `${parts.slice(0, lo).join("")}…`;
126
+ }
82
127
  const baselineShifts = /* @__PURE__ */ new Map();
83
128
  function cssBaselineShift(style) {
84
129
  const font = fontStringFromTextStyle(style);
@@ -128,4 +173,4 @@ function onBoardTextFontsChange(listener) {
128
173
  return () => fontListeners.delete(listener);
129
174
  }
130
175
  //#endregion
131
- export { BOARD_FONT_STACKS, boardTextStyle, canvasFont, canvasFontFamily, cssBaselineShift, ensureBoardTextMeasurement, installBoardTextMeasurement, layoutTextLines, onBoardTextFontsChange };
176
+ export { BOARD_FONT_STACKS, boardTextStyle, canvasFont, canvasFontFamily, cssBaselineShift, ensureBoardTextMeasurement, fitLineWithEllipsis, installBoardTextMeasurement, layoutTextLines, measureTextWidth, onBoardTextFontsChange };
@@ -1,6 +1,7 @@
1
1
  import { BoardDelta, BoardDocument, BoardPatch } from "../../protocol/dist/board-model.js";
2
2
  import { BoardApplyResult, BoardPlaybackSnapshot, BoardReadResult } from "../../protocol/dist/board.js";
3
3
  import "../../protocol/dist/index.js";
4
+ import { BoardReplicaDatabaseOptions, BoardReplicaScope, clearBoardReplicaStorage, createIndexedDbBoardReplicaStorage } from "./indexeddb.js";
4
5
  //#region src/board/replica/index.d.ts
5
6
  type BoardPendingPatch = {
6
7
  mutationId: string;
@@ -27,6 +28,7 @@ type BoardRemote = {
27
28
  playback: BoardPlaybackSnapshot | null;
28
29
  };
29
30
  }) => void;
31
+ resync?: () => void;
30
32
  }): () => void;
31
33
  };
32
34
  type BoardReplicaStorage = {
@@ -62,9 +64,9 @@ declare function createBoardReplica(options: BoardReplicaOptions): {
62
64
  receive: (payload: BoardChangedPayload) => void;
63
65
  receivePlayback: (next: BoardPlaybackSnapshot | null) => void;
64
66
  refresh: () => Promise<void>;
65
- retry(): Promise<void>;
67
+ retry: () => Promise<void>;
66
68
  dispose(): void;
67
69
  };
68
70
  type BoardReplica = ReturnType<typeof createBoardReplica>;
69
71
  //#endregion
70
- export { BoardChangedPayload, BoardPendingPatch, BoardRemote, BoardReplica, BoardReplicaOptions, BoardReplicaState, BoardReplicaStorage, createBoardReplica };
72
+ export { BoardChangedPayload, BoardPendingPatch, BoardRemote, BoardReplica, type BoardReplicaDatabaseOptions, BoardReplicaOptions, type BoardReplicaScope, BoardReplicaState, BoardReplicaStorage, clearBoardReplicaStorage, createBoardReplica, createIndexedDbBoardReplicaStorage };
@@ -1,6 +1,7 @@
1
1
  import { applyBoardDelta, applyBoardPatchToDocument, parseBoardDocument } from "../../protocol/dist/board-patch.js";
2
2
  import { createBoardEntityId } from "../model/items/id.js";
3
3
  import { shareBoardDocument } from "../model/document-share.js";
4
+ import { clearBoardReplicaStorage, createIndexedDbBoardReplicaStorage } from "./indexeddb.js";
4
5
  //#region src/board/replica/index.ts
5
6
  function memoryStorage() {
6
7
  return {
@@ -47,6 +48,7 @@ function createBoardReplica(options) {
47
48
  let retryTimer = null;
48
49
  let retryDelay = RETRY_BASE_MS;
49
50
  let disposed = false;
51
+ let restored = false;
50
52
  let unpersisted = null;
51
53
  let persistTimer = null;
52
54
  function subscribeRemote() {
@@ -54,7 +56,8 @@ function createBoardReplica(options) {
54
56
  try {
55
57
  return remote.subscribe({
56
58
  changed: (event) => receive(event.payload),
57
- playback: (event) => receivePlayback(event.payload.playback)
59
+ playback: (event) => receivePlayback(event.payload.playback),
60
+ resync: () => void retry()
58
61
  });
59
62
  } catch {
60
63
  return null;
@@ -157,7 +160,7 @@ function createBoardReplica(options) {
157
160
  retryDelay = Math.min(RETRY_MAX_MS, retryDelay * 2);
158
161
  }
159
162
  async function flush() {
160
- if (sending || disposed || !loaded) return;
163
+ if (sending || disposed || !loaded || !restored) return;
161
164
  sending = true;
162
165
  try {
163
166
  while (pending.length && !disposed) {
@@ -217,6 +220,19 @@ function createBoardReplica(options) {
217
220
  }
218
221
  emit();
219
222
  }
223
+ function retry() {
224
+ if (disposed) return Promise.resolve();
225
+ if (retryTimer) clearTimeout(retryTimer);
226
+ retryTimer = null;
227
+ retryDelay = RETRY_BASE_MS;
228
+ readError = null;
229
+ writeError = null;
230
+ emit();
231
+ return refresh().then(flush);
232
+ }
233
+ function onOnline() {
234
+ if (pending.length || readError || writeError) retry();
235
+ }
220
236
  function receivePlayback(next) {
221
237
  playbackRevision += 1;
222
238
  playback = next;
@@ -232,13 +248,15 @@ function createBoardReplica(options) {
232
248
  },
233
249
  async start() {
234
250
  unsubscribeRemote ??= subscribeRemote();
251
+ globalThis.addEventListener?.("online", onOnline);
235
252
  const [cached, stored] = await Promise.all([storage.readDocument().catch(() => null), storage.listPending().catch(() => [])]);
236
253
  if (disposed) return;
237
- pending = stored;
238
- if (cached && !server) {
239
- const parsed = parseBoardDocument(cached.document);
240
- if (parsed.ok) adoptServer(parsed.document, cached.version, false);
241
- }
254
+ const known = new Set(stored.map((entry) => entry.mutationId));
255
+ pending = [...stored, ...pending.filter((entry) => !known.has(entry.mutationId))];
256
+ restored = true;
257
+ const parsed = cached && !server ? parseBoardDocument(cached.document) : null;
258
+ if (cached && parsed?.ok) adoptServer(parsed.document, cached.version, false);
259
+ else recompute();
242
260
  emit();
243
261
  await refresh();
244
262
  await flush();
@@ -256,17 +274,10 @@ function createBoardReplica(options) {
256
274
  receive,
257
275
  receivePlayback,
258
276
  refresh,
259
- retry() {
260
- if (retryTimer) clearTimeout(retryTimer);
261
- retryTimer = null;
262
- retryDelay = RETRY_BASE_MS;
263
- readError = null;
264
- writeError = null;
265
- emit();
266
- return refresh().then(flush);
267
- },
277
+ retry,
268
278
  dispose() {
269
279
  disposed = true;
280
+ globalThis.removeEventListener?.("online", onOnline);
270
281
  unsubscribeRemote?.();
271
282
  unsubscribeRemote = null;
272
283
  listeners.clear();
@@ -276,4 +287,4 @@ function createBoardReplica(options) {
276
287
  };
277
288
  }
278
289
  //#endregion
279
- export { createBoardReplica };
290
+ export { clearBoardReplicaStorage, createBoardReplica, createIndexedDbBoardReplicaStorage };
@@ -0,0 +1,16 @@
1
+ import { BoardReplicaStorage } from "./index.js";
2
+ //#region src/board/replica/indexeddb.d.ts
3
+ type BoardReplicaScope = {
4
+ userKey: string;
5
+ spaceId: string;
6
+ boardId: string;
7
+ };
8
+ type BoardReplicaDatabaseOptions = {
9
+ database?: string;
10
+ };
11
+ declare function createIndexedDbBoardReplicaStorage(scope: BoardReplicaScope, options?: BoardReplicaDatabaseOptions): BoardReplicaStorage;
12
+ declare function clearBoardReplicaStorage(options?: BoardReplicaDatabaseOptions & {
13
+ userKey?: string;
14
+ }): Promise<void>;
15
+ //#endregion
16
+ export { BoardReplicaDatabaseOptions, BoardReplicaScope, clearBoardReplicaStorage, createIndexedDbBoardReplicaStorage };
@@ -0,0 +1,155 @@
1
+ //#region src/board/replica/indexeddb.ts
2
+ const DATABASE = "cohub-board";
3
+ const VERSION = 1;
4
+ const OPEN_TIMEOUT_MS = 3e3;
5
+ const connections = /* @__PURE__ */ new Map();
6
+ function open(name) {
7
+ const existing = connections.get(name);
8
+ if (existing) return existing;
9
+ const opening = new Promise((resolve, reject) => {
10
+ const factory = globalThis.indexedDB;
11
+ if (!factory) {
12
+ reject(/* @__PURE__ */ new Error("IndexedDB is unavailable."));
13
+ return;
14
+ }
15
+ let late = false;
16
+ const timer = setTimeout(() => {
17
+ late = true;
18
+ reject(/* @__PURE__ */ new Error("IndexedDB did not open in time."));
19
+ }, OPEN_TIMEOUT_MS);
20
+ const request = factory.open(name, VERSION);
21
+ request.onupgradeneeded = () => {
22
+ const db = request.result;
23
+ db.createObjectStore("documents", { keyPath: "board" }).createIndex("user", "userKey");
24
+ const pending = db.createObjectStore("pending", { keyPath: ["board", "mutationId"] });
25
+ pending.createIndex("board", "board");
26
+ pending.createIndex("user", "userKey");
27
+ };
28
+ request.onsuccess = () => {
29
+ clearTimeout(timer);
30
+ const db = request.result;
31
+ if (late) {
32
+ db.close();
33
+ return;
34
+ }
35
+ const forget = () => {
36
+ if (connections.get(name) === opening) connections.delete(name);
37
+ };
38
+ db.onversionchange = () => {
39
+ db.close();
40
+ forget();
41
+ };
42
+ db.onclose = forget;
43
+ resolve(db);
44
+ };
45
+ request.onerror = () => {
46
+ clearTimeout(timer);
47
+ reject(request.error ?? /* @__PURE__ */ new Error("IndexedDB failed to open."));
48
+ };
49
+ });
50
+ connections.set(name, opening);
51
+ opening.catch(() => {
52
+ if (connections.get(name) === opening) connections.delete(name);
53
+ });
54
+ return opening;
55
+ }
56
+ async function transact(name, stores, mode, work, retry = true) {
57
+ const db = await open(name);
58
+ let tx;
59
+ try {
60
+ tx = db.transaction(stores, mode);
61
+ } catch (error) {
62
+ if (!retry) throw error;
63
+ db.close();
64
+ connections.delete(name);
65
+ return transact(name, stores, mode, work, false);
66
+ }
67
+ return new Promise((resolve, reject) => {
68
+ const result = work(tx);
69
+ tx.oncomplete = () => resolve(result());
70
+ tx.onerror = () => reject(tx.error ?? /* @__PURE__ */ new Error("IndexedDB transaction failed."));
71
+ tx.onabort = () => reject(tx.error ?? /* @__PURE__ */ new Error("IndexedDB transaction aborted."));
72
+ });
73
+ }
74
+ function deleteByIndex(store, index, key) {
75
+ const request = store.index(index).openKeyCursor(IDBKeyRange.only(key));
76
+ request.onsuccess = () => {
77
+ const cursor = request.result;
78
+ if (!cursor) return;
79
+ store.delete(cursor.primaryKey);
80
+ cursor.continue();
81
+ };
82
+ }
83
+ let lastOrder = 0;
84
+ function createIndexedDbBoardReplicaStorage(scope, options = {}) {
85
+ const name = options.database ?? DATABASE;
86
+ const board = JSON.stringify([
87
+ scope.userKey,
88
+ scope.spaceId,
89
+ scope.boardId
90
+ ]);
91
+ return {
92
+ listPending: () => transact(name, ["pending"], "readonly", (tx) => {
93
+ const request = tx.objectStore("pending").index("board").getAll(IDBKeyRange.only(board));
94
+ return () => request.result.sort((a, b) => a.order - b.order).map(({ mutationId, patch }) => ({
95
+ mutationId,
96
+ patch
97
+ }));
98
+ }),
99
+ putPending: ({ mutationId, patch }) => transact(name, ["pending"], "readwrite", (tx) => {
100
+ lastOrder = Math.max(Date.now(), lastOrder + 1);
101
+ const record = {
102
+ board,
103
+ mutationId,
104
+ userKey: scope.userKey,
105
+ order: lastOrder,
106
+ patch
107
+ };
108
+ tx.objectStore("pending").put(record);
109
+ return () => void 0;
110
+ }),
111
+ deletePending: (mutationId) => transact(name, ["pending"], "readwrite", (tx) => {
112
+ tx.objectStore("pending").delete([board, mutationId]);
113
+ return () => void 0;
114
+ }),
115
+ readDocument: () => transact(name, ["documents"], "readonly", (tx) => {
116
+ const request = tx.objectStore("documents").get(board);
117
+ return () => {
118
+ const record = request.result;
119
+ return record ? {
120
+ version: record.version,
121
+ document: record.document
122
+ } : null;
123
+ };
124
+ }),
125
+ writeDocument: (version, document) => transact(name, ["documents"], "readwrite", (tx) => {
126
+ const store = tx.objectStore("documents");
127
+ const request = store.get(board);
128
+ request.onsuccess = () => {
129
+ const current = request.result;
130
+ if (current && current.version > version) return;
131
+ const record = {
132
+ board,
133
+ userKey: scope.userKey,
134
+ version,
135
+ document
136
+ };
137
+ store.put(record);
138
+ };
139
+ return () => void 0;
140
+ })
141
+ };
142
+ }
143
+ async function clearBoardReplicaStorage(options = {}) {
144
+ const { userKey } = options;
145
+ await transact(options.database ?? DATABASE, ["documents", "pending"], "readwrite", (tx) => {
146
+ for (const name of ["documents", "pending"]) {
147
+ const store = tx.objectStore(name);
148
+ if (userKey === void 0) store.clear();
149
+ else deleteByIndex(store, "user", userKey);
150
+ }
151
+ return () => void 0;
152
+ });
153
+ }
154
+ //#endregion
155
+ export { clearBoardReplicaStorage, createIndexedDbBoardReplicaStorage };