@beyondwork/docx-react-component 1.0.47 → 1.0.49

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 (80) hide show
  1. package/README.md +16 -11
  2. package/package.json +30 -41
  3. package/src/api/public-types.ts +199 -13
  4. package/src/compare/diff-engine.ts +4 -0
  5. package/src/core/commands/add-scope.ts +257 -0
  6. package/src/core/commands/formatting-commands.ts +2 -0
  7. package/src/core/commands/index.ts +9 -1
  8. package/src/core/commands/text-commands.ts +3 -1
  9. package/src/core/schema/text-schema.ts +95 -1
  10. package/src/core/selection/anchor-conversion.ts +112 -0
  11. package/src/core/selection/review-anchors.ts +108 -3
  12. package/src/core/state/text-transaction.ts +103 -7
  13. package/src/internal/harness-debug-ports.ts +168 -0
  14. package/src/io/chart-preview-resolver.ts +59 -1
  15. package/src/io/docx-session.ts +226 -38
  16. package/src/io/export/serialize-main-document.ts +46 -0
  17. package/src/io/export/serialize-paragraph-formatting.ts +8 -0
  18. package/src/io/export/serialize-run-formatting.ts +10 -1
  19. package/src/io/export/serialize-settings.ts +421 -0
  20. package/src/io/export/serialize-styles.ts +10 -0
  21. package/src/io/normalize/normalize-text.ts +1 -0
  22. package/src/io/ooxml/chart/chart-style-table.ts +543 -0
  23. package/src/io/ooxml/chart/color-palette.ts +101 -0
  24. package/src/io/ooxml/chart/compose-series-color.ts +147 -0
  25. package/src/io/ooxml/chart/parse-axis.ts +277 -0
  26. package/src/io/ooxml/chart/parse-chart-space.ts +885 -0
  27. package/src/io/ooxml/chart/parse-series.ts +635 -0
  28. package/src/io/ooxml/chart/resolve-color.ts +261 -0
  29. package/src/io/ooxml/chart/types.ts +439 -0
  30. package/src/io/ooxml/parse-block-structure.ts +99 -0
  31. package/src/io/ooxml/parse-complex-content.ts +90 -2
  32. package/src/io/ooxml/parse-main-document.ts +156 -1
  33. package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
  34. package/src/io/ooxml/parse-run-formatting.ts +49 -0
  35. package/src/io/ooxml/parse-scope-markers.ts +184 -0
  36. package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
  37. package/src/io/ooxml/parse-settings.ts +97 -1
  38. package/src/io/ooxml/parse-styles.ts +65 -0
  39. package/src/io/ooxml/parse-theme.ts +2 -127
  40. package/src/io/ooxml/property-grab-bag.ts +211 -0
  41. package/src/io/ooxml/xml-attr-helpers.ts +59 -1
  42. package/src/io/ooxml/xml-parser.ts +142 -0
  43. package/src/model/canonical-document.ts +160 -0
  44. package/src/model/scope-markers.ts +144 -0
  45. package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
  46. package/src/runtime/collab/checkpoint-election.ts +75 -0
  47. package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
  48. package/src/runtime/collab/checkpoint-store.ts +115 -0
  49. package/src/runtime/collab/event-types.ts +27 -0
  50. package/src/runtime/collab/index.ts +29 -0
  51. package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
  52. package/src/runtime/collab/runtime-collab-sync.ts +330 -0
  53. package/src/runtime/collab/workflow-shared.ts +247 -0
  54. package/src/runtime/document-locations.ts +1 -9
  55. package/src/runtime/document-outline.ts +1 -9
  56. package/src/runtime/document-runtime.ts +288 -65
  57. package/src/runtime/editor-surface/capabilities.ts +63 -50
  58. package/src/runtime/hyperlink-color-resolver.ts +119 -0
  59. package/src/runtime/layout/layout-engine-version.ts +8 -1
  60. package/src/runtime/prerender/cache-envelope.ts +19 -7
  61. package/src/runtime/prerender/cache-key.ts +25 -14
  62. package/src/runtime/prerender/canonical-document-hash.ts +63 -0
  63. package/src/runtime/prerender/customxml-cache.ts +211 -0
  64. package/src/runtime/prerender/customxml-probe.ts +78 -0
  65. package/src/runtime/prerender/prerender-document.ts +74 -7
  66. package/src/runtime/scope-resolver.ts +148 -0
  67. package/src/runtime/scope-tag-registry.ts +10 -0
  68. package/src/runtime/surface-projection.ts +102 -37
  69. package/src/runtime/theme-color-resolver.ts +188 -0
  70. package/src/runtime/workflow-markup.ts +7 -18
  71. package/src/ui/WordReviewEditor.tsx +48 -2
  72. package/src/ui/editor-runtime-boundary.ts +42 -1
  73. package/src/ui/headless/selection-helpers.ts +10 -23
  74. package/src/ui/runtime-shortcut-dispatch.ts +12 -7
  75. package/src/ui/unsupported-previews-policy.ts +23 -0
  76. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
  77. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  78. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
  79. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
  80. package/src/ui-tailwind/tw-review-workspace.tsx +16 -1
@@ -12,6 +12,14 @@ import {
12
12
  expectUuid,
13
13
  stableStringify,
14
14
  } from "./cds-1.0.0.ts";
15
+ import type {
16
+ ScopeMarkerStartNode,
17
+ ScopeMarkerEndNode,
18
+ } from "./scope-markers.ts";
19
+ import type { ChartModel } from "../io/ooxml/chart/types.ts";
20
+
21
+ export type { ScopeMarkerStartNode, ScopeMarkerEndNode } from "./scope-markers.ts";
22
+ export type { ChartModel } from "../io/ooxml/chart/types.ts";
15
23
 
16
24
  const CANONICAL_DOCUMENT_TOP_LEVEL_KEYS = [
17
25
  "schemaVersion",
@@ -97,6 +105,14 @@ export interface ParagraphStyleDefinition {
97
105
  isDefault: boolean;
98
106
  paragraphProperties?: CanonicalParagraphFormatting;
99
107
  runProperties?: CanonicalRunFormatting;
108
+ /**
109
+ * Style ID of the linked character style (from `<w:link w:val="..."/>`).
110
+ * Populated during parse; the second-pass resolver in `parse-styles.ts`
111
+ * synthesizes the reciprocal link on the partner when the source only
112
+ * declares the relationship on one side. Mirrors LibreOffice's
113
+ * `StyleSheetTable.cxx:535` second pass.
114
+ */
115
+ linkedStyleId?: string;
100
116
  }
101
117
 
102
118
  export interface ParagraphStyleNumberingReference {
@@ -111,6 +127,12 @@ export interface CharacterStyleDefinition {
111
127
  kind: "character";
112
128
  isDefault: boolean;
113
129
  runProperties?: CanonicalRunFormatting;
130
+ /**
131
+ * Style ID of the linked paragraph style (from `<w:link w:val="..."/>`).
132
+ * See `ParagraphStyleDefinition.linkedStyleId` for the second-pass
133
+ * reciprocal-resolution contract.
134
+ */
135
+ linkedStyleId?: string;
114
136
  }
115
137
 
116
138
  export interface TableStyleDefinition {
@@ -257,9 +279,63 @@ export interface ThemeDefinition {
257
279
  fontScheme?: ThemeFontScheme;
258
280
  }
259
281
 
282
+ /**
283
+ * One <w:compatSetting> entry under <w:compat>. Word emits multiple of these
284
+ * with the same `name` across different `uri` namespaces, so the canonical
285
+ * shape is an ordered tuple list rather than a name→value map.
286
+ *
287
+ * `value` is preserved as the raw `w:val` string (e.g. "15", "1", "0") so the
288
+ * future serializer can re-emit byte-stable diffs.
289
+ */
290
+ export interface CompatSetting {
291
+ name: string;
292
+ uri: string;
293
+ value: string;
294
+ }
295
+
260
296
  export interface DocumentSettings {
261
297
  evenAndOddHeaders?: boolean;
262
298
  zoomLevel?: "pageWidth" | "onePage" | number;
299
+ /**
300
+ * Ordered list of <w:compatSetting> entries inside <w:compat>. Insertion
301
+ * order is preserved for serializer diff stability.
302
+ */
303
+ compatSettings?: CompatSetting[];
304
+ /**
305
+ * Boolean flag children of <w:compat> that are NOT <w:compatSetting>
306
+ * (e.g. <w:spaceForUL/>, <w:doNotExpandShiftReturn/>). Keyed by local
307
+ * element name. The value reflects ST_OnOff semantics: missing `w:val` is
308
+ * true; explicit `w:val="0"`/`"false"` is false.
309
+ */
310
+ compatFlags?: Record<string, boolean>;
311
+ /**
312
+ * Settings-level (NOT inside <w:compat>) compat-adjacent boolean flags
313
+ * such as <w:doNotEmbedSmartTags/>. Kept in a separate field from
314
+ * compatFlags because the OOXML location differs and the future
315
+ * serializer must re-emit them at root, not inside <w:compat>.
316
+ */
317
+ rootCompatFlags?: Record<string, boolean>;
318
+ /**
319
+ * <w:themeFontLang> attribute bag, captured verbatim so the future
320
+ * serializer can re-emit unknown attributes Word may add.
321
+ *
322
+ * Distinguishes three states:
323
+ * - undefined: the element was absent.
324
+ * - {}: the element existed with no attributes.
325
+ * - { "w:val": "en-US", … }: attributes preserved with their qualified
326
+ * names so the serializer round-trips namespace prefixes intact.
327
+ */
328
+ themeFontLang?: Record<string, string>;
329
+ /**
330
+ * Local names of every direct child of <w:settings> that this parser does
331
+ * not model individually. Insertion order preserved; duplicates retained.
332
+ *
333
+ * Diagnostic only — round-trip is served by whole-part preservation while
334
+ * settings.xml stays out of `ownedOutputPaths`. The future serializer will
335
+ * use this list as a validator assertion that every preserved child still
336
+ * appears in the re-emitted output.
337
+ */
338
+ unmodelledSettingsChildren?: string[];
263
339
  }
264
340
 
265
341
  export interface SubPartsCatalog {
@@ -305,6 +381,8 @@ export type DocumentNode =
305
381
  | FieldNode
306
382
  | BookmarkStartNode
307
383
  | BookmarkEndNode
384
+ | ScopeMarkerStartNode
385
+ | ScopeMarkerEndNode
308
386
  | SectionBreakNode
309
387
  | OpaqueInlineNode
310
388
  | OpaqueBlockNode
@@ -394,10 +472,34 @@ export interface CanonicalRunFormatting {
394
472
  */
395
473
  colorHex?: string;
396
474
  colorThemeSlot?: string;
475
+ /**
476
+ * `w:themeTint` hex byte (0x00–0xFF) read from `<w:color>`. ECMA-376
477
+ * §17.18.85. Applied at render/cascade time against `colorThemeSlot`:
478
+ * luminance mod = (1 - tint/255) * L + tint/255 (shifts hue toward
479
+ * white; 0xFF means no modulation). Preserved as the raw hex string
480
+ * for byte-stable round-trip; resolution math lives in the runtime
481
+ * theme-color resolver.
482
+ */
483
+ colorThemeTint?: string;
484
+ /**
485
+ * `w:themeShade` hex byte (0x00–0xFF) from `<w:color>`. ECMA-376
486
+ * §17.18.83. Applied at render/cascade time: luminance mod =
487
+ * shade/255 * L (darkens toward black; 0xFF means no modulation).
488
+ */
489
+ colorThemeShade?: string;
397
490
  highlight?: string;
398
491
  characterSpacingTwips?: number;
399
492
  characterStyleId?: string;
400
493
  languageCode?: string;
494
+ /**
495
+ * Unmodelled direct children of `<w:rPr>` captured verbatim for round-trip.
496
+ * See `src/io/ooxml/property-grab-bag.ts` and
497
+ * `CanonicalParagraphFormatting.unknownPropertyChildren` for the full
498
+ * pattern. Preserves extension-namespace properties like `<w14:textOutline>`,
499
+ * `<w:em>`, `<w:kern>` through parse→serialize round-trip even though the
500
+ * runtime does not model them.
501
+ */
502
+ unknownPropertyChildren?: UnknownPropertyChild[];
401
503
  }
402
504
 
403
505
  /** Body of an OOXML `<w:pPr>` (paragraph properties). All fields optional; absence = "not specified at this level". */
@@ -418,6 +520,32 @@ export interface CanonicalParagraphFormatting {
418
520
  suppressLineNumbers?: boolean;
419
521
  suppressAutoHyphens?: boolean;
420
522
  paragraphMarkRunProperties?: CanonicalRunFormatting;
523
+ /**
524
+ * Unmodelled direct children of `<w:pPr>` captured verbatim for round-trip.
525
+ * See `src/io/ooxml/property-grab-bag.ts` for the mechanism and Lane 3 O2
526
+ * plan for the LibreOffice `PropertyMap.hxx:82` precedent.
527
+ *
528
+ * Each entry carries the source XML for an unmodelled child element plus
529
+ * its qualified name. On export, entries are re-emitted after the
530
+ * modelled children so Word-extension properties like `<w15:collapsed>`
531
+ * or `<w:kinsoku>` survive a parse→serialize round-trip even though the
532
+ * runtime doesn't understand them.
533
+ */
534
+ unknownPropertyChildren?: UnknownPropertyChild[];
535
+ }
536
+
537
+ /**
538
+ * A single unmodelled direct child of an OOXML property container (pPr,
539
+ * rPr, tcPr, trPr, tblPr, sectPr). Captured verbatim so the serializer
540
+ * re-emits source bytes without loss. See `src/io/ooxml/property-grab-bag.ts`
541
+ * for the helper that computes the diff against a per-container modelled-
542
+ * child allow-list.
543
+ */
544
+ export interface UnknownPropertyChild {
545
+ /** Qualified element name as it appeared in source, e.g. "w15:collapsed". */
546
+ elementName: string;
547
+ /** Verbatim source XML for the child element, including closing/self-closing form. */
548
+ rawXml: string;
421
549
  }
422
550
 
423
551
  /** Body of an OOXML `<w:docDefaults>` — baseline formatting applied before style chain. */
@@ -818,6 +946,15 @@ export interface SectionProperties {
818
946
  footerReferences?: HeaderFooterReference[];
819
947
  sectionType?: "continuous" | "nextPage" | "evenPage" | "oddPage" | "nextColumn";
820
948
  titlePage?: boolean;
949
+ /**
950
+ * Unmodelled direct children of `<w:sectPr>` captured verbatim for
951
+ * round-trip. Mirrors `CanonicalParagraphFormatting.unknownPropertyChildren`
952
+ * and `CanonicalRunFormatting.unknownPropertyChildren` (Lane 3 O2 Slices
953
+ * 1+2). Preserves extension-namespace properties like
954
+ * `<w15:footnoteColumns>` and Word-internal section knobs through a
955
+ * parse→serialize round-trip when the runtime mutates section properties.
956
+ */
957
+ unknownPropertyChildren?: UnknownPropertyChild[];
821
958
  }
822
959
 
823
960
  export interface PageSize {
@@ -890,6 +1027,8 @@ export type InlineNode =
890
1027
  | FieldNode
891
1028
  | BookmarkStartNode
892
1029
  | BookmarkEndNode
1030
+ | ScopeMarkerStartNode
1031
+ | ScopeMarkerEndNode
893
1032
  | OpaqueInlineNode
894
1033
  | FootnoteRefNode
895
1034
  | ChartPreviewNode
@@ -992,6 +1131,23 @@ export interface OpaqueInlineNode {
992
1131
  export interface ChartPreviewNode {
993
1132
  type: "chart_preview";
994
1133
  previewMediaId?: string;
1134
+ /**
1135
+ * Typed chart data model parsed from the `c:chartSpace` part, when
1136
+ * available. Populated at import time by the Stage 1 chart parser
1137
+ * (`src/io/ooxml/chart/parse-chart-space.ts`). Undefined when the chart
1138
+ * part cannot be located, fails to parse, or has no chart-family match
1139
+ * — consumers fall back to the fallback bitmap (`previewMediaId`) or the
1140
+ * typed badge in that case.
1141
+ *
1142
+ * **`rawXml` is the authoritative round-trip source** regardless of
1143
+ * whether `parsedData` is populated. `parsedData` is a read-only
1144
+ * projection of that rawXml; mutating it would diverge the two fields
1145
+ * and silently degrade export fidelity. The `readonly` modifier
1146
+ * enforces this at the type level — any future collab-replay that
1147
+ * needs to edit a chart must round-trip through `rawXml`, not patch
1148
+ * `parsedData` in place.
1149
+ */
1150
+ readonly parsedData?: ChartModel;
995
1151
  rawXml: string;
996
1152
  }
997
1153
 
@@ -1658,6 +1814,10 @@ function validateDocumentNode(
1658
1814
  case "bookmark_end":
1659
1815
  expectString(record.bookmarkId, `${path}.bookmarkId`, issues);
1660
1816
  return;
1817
+ case "scope_marker_start":
1818
+ case "scope_marker_end":
1819
+ expectString(record.scopeId, `${path}.scopeId`, issues);
1820
+ return;
1661
1821
  case "section_break":
1662
1822
  return;
1663
1823
  case "text":
@@ -0,0 +1,144 @@
1
+ import type {
2
+ CanonicalDocument,
3
+ DocumentNode,
4
+ DocumentRootNode,
5
+ } from "./canonical-document.ts";
6
+
7
+ /**
8
+ * Inline zero-width marker that opens a workflow scope. Modeled on
9
+ * `BookmarkStartNode` — the marker lives IN the document so PM handles
10
+ * position bookkeeping for free. `scopeId` is the key; metadata persistence
11
+ * is orthogonal and owned by `WorkflowOverlay` + customXml payloads.
12
+ */
13
+ export interface ScopeMarkerStartNode {
14
+ type: "scope_marker_start";
15
+ scopeId: string;
16
+ }
17
+
18
+ /**
19
+ * Inline zero-width marker that closes a workflow scope opened by a matching
20
+ * `ScopeMarkerStartNode` with the same `scopeId`.
21
+ */
22
+ export interface ScopeMarkerEndNode {
23
+ type: "scope_marker_end";
24
+ scopeId: string;
25
+ }
26
+
27
+ export type ScopeMarkerNode = ScopeMarkerStartNode | ScopeMarkerEndNode;
28
+
29
+ export function isScopeMarkerNode(node: unknown): node is ScopeMarkerNode {
30
+ if (typeof node !== "object" || node === null) return false;
31
+ const t = (node as { type?: unknown }).type;
32
+ return t === "scope_marker_start" || t === "scope_marker_end";
33
+ }
34
+
35
+ export interface ScopeMarkerWalkEntry {
36
+ scopeId: string;
37
+ source: "canonical";
38
+ status: "paired" | "start-only" | "end-only";
39
+ startIndex?: number;
40
+ endIndex?: number;
41
+ }
42
+
43
+ interface OpenScopeMarker {
44
+ scopeId: string;
45
+ startIndex: number;
46
+ }
47
+
48
+ /**
49
+ * Walk the canonical document in pre-order and return one entry per scope
50
+ * detected. A paired entry carries both `startIndex` + `endIndex`; an
51
+ * unpaired entry has only the surviving side filled.
52
+ */
53
+ export function collectScopeMarkers(
54
+ document: Pick<CanonicalDocument, "content"> | DocumentNode,
55
+ ): ScopeMarkerWalkEntry[] {
56
+ const root = ("content" in document
57
+ ? document.content
58
+ : document) as DocumentNode | DocumentRootNode;
59
+ const sequence: ScopeMarkerNode[] = [];
60
+
61
+ walkDocument(root, (node) => {
62
+ if (isScopeMarkerNode(node)) {
63
+ sequence.push(node);
64
+ }
65
+ });
66
+
67
+ const open = new Map<string, OpenScopeMarker[]>();
68
+ const results: ScopeMarkerWalkEntry[] = [];
69
+
70
+ for (let index = 0; index < sequence.length; index += 1) {
71
+ const marker = sequence[index]!;
72
+ if (marker.type === "scope_marker_start") {
73
+ const stack = open.get(marker.scopeId) ?? [];
74
+ stack.push({ scopeId: marker.scopeId, startIndex: index });
75
+ open.set(marker.scopeId, stack);
76
+ continue;
77
+ }
78
+
79
+ const stack = open.get(marker.scopeId);
80
+ const opener = stack?.pop();
81
+ if (stack && stack.length === 0) {
82
+ open.delete(marker.scopeId);
83
+ }
84
+
85
+ if (opener) {
86
+ results.push({
87
+ scopeId: marker.scopeId,
88
+ source: "canonical",
89
+ status: "paired",
90
+ startIndex: opener.startIndex,
91
+ endIndex: index,
92
+ });
93
+ continue;
94
+ }
95
+
96
+ results.push({
97
+ scopeId: marker.scopeId,
98
+ source: "canonical",
99
+ status: "end-only",
100
+ endIndex: index,
101
+ });
102
+ }
103
+
104
+ for (const stack of open.values()) {
105
+ for (const opener of stack) {
106
+ results.push({
107
+ scopeId: opener.scopeId,
108
+ source: "canonical",
109
+ status: "start-only",
110
+ startIndex: opener.startIndex,
111
+ });
112
+ }
113
+ }
114
+
115
+ return results.sort(
116
+ (left, right) =>
117
+ (left.startIndex ?? left.endIndex ?? Number.MAX_SAFE_INTEGER) -
118
+ (right.startIndex ?? right.endIndex ?? Number.MAX_SAFE_INTEGER) ||
119
+ left.scopeId.localeCompare(right.scopeId),
120
+ );
121
+ }
122
+
123
+ function walkDocument(
124
+ node: DocumentNode | DocumentRootNode,
125
+ visit: (node: DocumentNode) => void,
126
+ ): void {
127
+ visit(node as DocumentNode);
128
+
129
+ if ("children" in node && Array.isArray(node.children)) {
130
+ for (const child of node.children) {
131
+ walkDocument(child as DocumentNode, visit);
132
+ }
133
+ }
134
+
135
+ if (node.type === "table") {
136
+ for (const row of node.rows) {
137
+ walkDocument(row, visit);
138
+ }
139
+ } else if (node.type === "table_row") {
140
+ for (const cell of node.cells) {
141
+ walkDocument(cell, visit);
142
+ }
143
+ }
144
+ }
@@ -0,0 +1,99 @@
1
+ import type { CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
2
+ import { sha256Hex } from "../../io/source-package-provenance.ts";
3
+
4
+ /**
5
+ * Computes a deterministic fingerprint of the base-doc portion of a
6
+ * {@link CanonicalDocumentEnvelope}. The fingerprint is used by
7
+ * `createRuntimeCollabSync` to verify that every peer attaching to a
8
+ * shared `Y.Doc` started from the same canonical content.
9
+ *
10
+ * The fingerprint intentionally **excludes** session-local identity
11
+ * fields so two peers loading the same source `.docx` under different
12
+ * local session IDs still converge on the same hash:
13
+ *
14
+ * | Field | Included | Rationale |
15
+ * |---|---|---|
16
+ * | `schemaVersion` | yes | CDS contract version; mismatch is a different base |
17
+ * | `metadata` | yes | customProperties are part of the document |
18
+ * | `styles` | yes | style cascade is base content |
19
+ * | `numbering` | yes | numbering definitions are base content |
20
+ * | `media` | yes | embedded media are base content |
21
+ * | `content` | yes | the editable body tree |
22
+ * | `preservation` | yes | opaque fragments round-trip identically from the source |
23
+ * | `docId` | **no** | session-local |
24
+ * | `createdAt` / `updatedAt` | **no** | session-local timestamps |
25
+ * | `review` | **no** | tracked changes are session state |
26
+ * | `diagnostics` | **no** | validator output is session state |
27
+ * | `subParts` / `fieldRegistry` | **no** | may be partially session-computed |
28
+ *
29
+ * Hash pipeline: the subset is serialized via {@link stableStringify}
30
+ * (sorts object keys recursively, preserves array order, follows
31
+ * `JSON.stringify` semantics for `undefined`), encoded UTF-8, and fed
32
+ * to `sha256Hex` from `src/io/source-package-provenance.ts`. Returns a
33
+ * 64-char lowercase hex string.
34
+ *
35
+ * This function is pure and synchronous — safe to call at attach time
36
+ * without breaking the `createRuntimeCollabSync` disposer contract.
37
+ */
38
+ export function computeBaseDocFingerprint(envelope: CanonicalDocumentEnvelope): string {
39
+ const subset = {
40
+ schemaVersion: envelope.schemaVersion,
41
+ metadata: envelope.metadata,
42
+ styles: envelope.styles,
43
+ numbering: envelope.numbering,
44
+ media: envelope.media,
45
+ content: envelope.content,
46
+ preservation: envelope.preservation,
47
+ };
48
+ const serialized = stableStringify(subset);
49
+ const bytes = new TextEncoder().encode(serialized);
50
+ return sha256Hex(bytes);
51
+ }
52
+
53
+ /**
54
+ * Deterministic JSON-like serializer. Object keys are sorted
55
+ * alphabetically at every depth; arrays preserve order; `undefined`
56
+ * values in objects are skipped, `undefined` values in arrays become
57
+ * `null` — matching `JSON.stringify` semantics. Primitives emit
58
+ * identically to `JSON.stringify`.
59
+ *
60
+ * Exported for unit testing and for callers that want to hash a
61
+ * narrower slice of the envelope.
62
+ */
63
+ export function stableStringify(value: unknown): string {
64
+ if (value === null) return "null";
65
+ if (value === undefined) return "null";
66
+
67
+ const t = typeof value;
68
+ if (t === "string" || t === "number" || t === "boolean") {
69
+ return JSON.stringify(value);
70
+ }
71
+
72
+ if (Array.isArray(value)) {
73
+ const parts: string[] = [];
74
+ for (const item of value) {
75
+ parts.push(item === undefined ? "null" : stableStringify(item));
76
+ }
77
+ return "[" + parts.join(",") + "]";
78
+ }
79
+
80
+ if (t === "object") {
81
+ const obj = value as Record<string, unknown>;
82
+ const keys: string[] = [];
83
+ for (const key of Object.keys(obj)) {
84
+ if (obj[key] !== undefined) {
85
+ keys.push(key);
86
+ }
87
+ }
88
+ keys.sort();
89
+ const parts: string[] = [];
90
+ for (const key of keys) {
91
+ parts.push(JSON.stringify(key) + ":" + stableStringify(obj[key]));
92
+ }
93
+ return "{" + parts.join(",") + "}";
94
+ }
95
+
96
+ // Functions, symbols, bigints — should not appear in a canonical
97
+ // envelope. Fall back to `null` to keep the output valid JSON-ish.
98
+ return "null";
99
+ }
@@ -0,0 +1,75 @@
1
+ import type { Awareness } from "y-protocols/awareness";
2
+
3
+ /**
4
+ * Election policy for "which peer should write the next checkpoint":
5
+ * the peer with the lowest `clientID` currently visible in
6
+ * `awareness.getStates()` wins. Deterministic, zero-coordination, and
7
+ * stable across the network (every peer independently computes the
8
+ * same winner).
9
+ *
10
+ * Properties:
11
+ * - `clientID` is stable per Y.Doc lifetime, so a peer does not drift
12
+ * between elected / not-elected while connected.
13
+ * - If the elected peer disconnects, `awareness` emits `change` with
14
+ * `removed`, and the next-lowest peer observes the change + becomes
15
+ * elected. No consensus handshake needed.
16
+ * - If awareness is empty (no peers have published state yet), no peer
17
+ * is elected — wait for `setLocalStateField` / `setLocalState` calls
18
+ * to land.
19
+ *
20
+ * NOT a leader-election primitive with fencing / lease semantics. Two
21
+ * peers racing at exactly the same split-second could both believe
22
+ * they're elected and both write a checkpoint. That is benign — both
23
+ * writes flow into the same `Y.Array<Checkpoint>`; joiners simply see
24
+ * two consecutive checkpoints and apply the last one. Bandwidth cost
25
+ * is one extra snapshot per race, no correctness impact.
26
+ */
27
+ export function isElectedCheckpointAuthor(
28
+ awareness: Awareness,
29
+ localClientId: number,
30
+ ): boolean {
31
+ const states = awareness.getStates();
32
+ if (states.size === 0) return false;
33
+ let minClientId: number | null = null;
34
+ for (const [clientId] of states) {
35
+ if (minClientId === null || clientId < minClientId) {
36
+ minClientId = clientId;
37
+ }
38
+ }
39
+ return minClientId === localClientId;
40
+ }
41
+
42
+ /**
43
+ * Subscribes to election-status changes for the local client. Fires
44
+ * immediately on subscribe with the current status, then on every
45
+ * awareness `change` where the elected status flips. Never fires
46
+ * consecutively with the same value.
47
+ *
48
+ * Returns an unsubscribe function.
49
+ */
50
+ export function subscribeElectedCheckpointAuthor(
51
+ awareness: Awareness,
52
+ localClientId: number,
53
+ listener: (isElected: boolean) => void,
54
+ ): () => void {
55
+ let lastValue: boolean | null = null;
56
+
57
+ function check(): void {
58
+ const elected = isElectedCheckpointAuthor(awareness, localClientId);
59
+ if (elected !== lastValue) {
60
+ lastValue = elected;
61
+ try {
62
+ listener(elected);
63
+ } catch {
64
+ // Listener errors are isolated.
65
+ }
66
+ }
67
+ }
68
+
69
+ awareness.on("change", check);
70
+ check();
71
+
72
+ return () => {
73
+ awareness.off("change", check);
74
+ };
75
+ }