@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.
- package/README.md +16 -11
- package/package.json +30 -41
- package/src/api/public-types.ts +199 -13
- package/src/compare/diff-engine.ts +4 -0
- package/src/core/commands/add-scope.ts +257 -0
- package/src/core/commands/formatting-commands.ts +2 -0
- package/src/core/commands/index.ts +9 -1
- package/src/core/commands/text-commands.ts +3 -1
- package/src/core/schema/text-schema.ts +95 -1
- package/src/core/selection/anchor-conversion.ts +112 -0
- package/src/core/selection/review-anchors.ts +108 -3
- package/src/core/state/text-transaction.ts +103 -7
- package/src/internal/harness-debug-ports.ts +168 -0
- package/src/io/chart-preview-resolver.ts +59 -1
- package/src/io/docx-session.ts +226 -38
- package/src/io/export/serialize-main-document.ts +46 -0
- package/src/io/export/serialize-paragraph-formatting.ts +8 -0
- package/src/io/export/serialize-run-formatting.ts +10 -1
- package/src/io/export/serialize-settings.ts +421 -0
- package/src/io/export/serialize-styles.ts +10 -0
- package/src/io/normalize/normalize-text.ts +1 -0
- package/src/io/ooxml/chart/chart-style-table.ts +543 -0
- package/src/io/ooxml/chart/color-palette.ts +101 -0
- package/src/io/ooxml/chart/compose-series-color.ts +147 -0
- package/src/io/ooxml/chart/parse-axis.ts +277 -0
- package/src/io/ooxml/chart/parse-chart-space.ts +885 -0
- package/src/io/ooxml/chart/parse-series.ts +635 -0
- package/src/io/ooxml/chart/resolve-color.ts +261 -0
- package/src/io/ooxml/chart/types.ts +439 -0
- package/src/io/ooxml/parse-block-structure.ts +99 -0
- package/src/io/ooxml/parse-complex-content.ts +90 -2
- package/src/io/ooxml/parse-main-document.ts +156 -1
- package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
- package/src/io/ooxml/parse-run-formatting.ts +49 -0
- package/src/io/ooxml/parse-scope-markers.ts +184 -0
- package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
- package/src/io/ooxml/parse-settings.ts +97 -1
- package/src/io/ooxml/parse-styles.ts +65 -0
- package/src/io/ooxml/parse-theme.ts +2 -127
- package/src/io/ooxml/property-grab-bag.ts +211 -0
- package/src/io/ooxml/xml-attr-helpers.ts +59 -1
- package/src/io/ooxml/xml-parser.ts +142 -0
- package/src/model/canonical-document.ts +160 -0
- package/src/model/scope-markers.ts +144 -0
- package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
- package/src/runtime/collab/checkpoint-election.ts +75 -0
- package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
- package/src/runtime/collab/checkpoint-store.ts +115 -0
- package/src/runtime/collab/event-types.ts +27 -0
- package/src/runtime/collab/index.ts +29 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
- package/src/runtime/collab/runtime-collab-sync.ts +330 -0
- package/src/runtime/collab/workflow-shared.ts +247 -0
- package/src/runtime/document-locations.ts +1 -9
- package/src/runtime/document-outline.ts +1 -9
- package/src/runtime/document-runtime.ts +288 -65
- package/src/runtime/editor-surface/capabilities.ts +63 -50
- package/src/runtime/hyperlink-color-resolver.ts +119 -0
- package/src/runtime/layout/layout-engine-version.ts +8 -1
- package/src/runtime/prerender/cache-envelope.ts +19 -7
- package/src/runtime/prerender/cache-key.ts +25 -14
- package/src/runtime/prerender/canonical-document-hash.ts +63 -0
- package/src/runtime/prerender/customxml-cache.ts +211 -0
- package/src/runtime/prerender/customxml-probe.ts +78 -0
- package/src/runtime/prerender/prerender-document.ts +74 -7
- package/src/runtime/scope-resolver.ts +148 -0
- package/src/runtime/scope-tag-registry.ts +10 -0
- package/src/runtime/surface-projection.ts +102 -37
- package/src/runtime/theme-color-resolver.ts +188 -0
- package/src/runtime/workflow-markup.ts +7 -18
- package/src/ui/WordReviewEditor.tsx +48 -2
- package/src/ui/editor-runtime-boundary.ts +42 -1
- package/src/ui/headless/selection-helpers.ts +10 -23
- package/src/ui/runtime-shortcut-dispatch.ts +12 -7
- package/src/ui/unsupported-previews-policy.ts +23 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
- 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
|
+
}
|