@beyondwork/docx-react-component 1.0.38 → 1.0.40
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/package.json +41 -31
- package/src/api/public-types.ts +305 -6
- package/src/core/commands/table-structure-commands.ts +31 -2
- package/src/core/commands/text-commands.ts +122 -2
- package/src/index.ts +9 -0
- package/src/io/docx-session.ts +1 -0
- package/src/io/export/serialize-numbering.ts +42 -8
- package/src/io/export/serialize-paragraph-formatting.ts +152 -0
- package/src/io/export/serialize-run-formatting.ts +90 -0
- package/src/io/export/serialize-styles.ts +212 -0
- package/src/io/ooxml/parse-fields.ts +10 -3
- package/src/io/ooxml/parse-numbering.ts +41 -1
- package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
- package/src/io/ooxml/parse-run-formatting.ts +129 -0
- package/src/io/ooxml/parse-styles.ts +31 -0
- package/src/io/ooxml/xml-attr-helpers.ts +60 -0
- package/src/io/ooxml/xml-element.ts +19 -0
- package/src/model/canonical-document.ts +83 -3
- package/src/runtime/collab/event-types.ts +165 -0
- package/src/runtime/collab/index.ts +22 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
- package/src/runtime/collab/runtime-collab-sync.ts +273 -0
- package/src/runtime/document-runtime.ts +141 -18
- package/src/runtime/layout/docx-font-loader.ts +30 -11
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +3 -0
- package/src/runtime/layout/layout-engine-instance.ts +69 -2
- package/src/runtime/layout/layout-invalidation.ts +14 -5
- package/src/runtime/layout/page-graph.ts +36 -0
- package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
- package/src/runtime/layout/paginated-layout-engine.ts +342 -28
- package/src/runtime/layout/project-block-fragments.ts +154 -20
- package/src/runtime/layout/public-facet.ts +81 -1
- package/src/runtime/layout/resolve-page-fields.ts +70 -0
- package/src/runtime/layout/resolve-page-previews.ts +185 -0
- package/src/runtime/layout/resolved-formatting-state.ts +30 -26
- package/src/runtime/layout/table-render-plan.ts +21 -1
- package/src/runtime/numbering-prefix.ts +5 -0
- package/src/runtime/paragraph-style-resolver.ts +194 -0
- package/src/runtime/render/render-kernel.ts +5 -1
- package/src/runtime/resolved-numbering-geometry.ts +9 -1
- package/src/runtime/surface-projection.ts +129 -9
- package/src/runtime/table-schema.ts +11 -0
- package/src/runtime/workflow-rail-segments.ts +149 -1
- package/src/ui/WordReviewEditor.tsx +302 -5
- package/src/ui/editor-command-bag.ts +4 -0
- package/src/ui/editor-runtime-boundary.ts +16 -0
- package/src/ui/editor-shell-view.tsx +22 -0
- package/src/ui/editor-surface-controller.tsx +9 -1
- package/src/ui/headless/chrome-registry.ts +34 -5
- package/src/ui/headless/scoped-chrome-policy.ts +29 -0
- package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
- package/src/ui-tailwind/chrome/role-action-sets.ts +14 -8
- package/src/ui-tailwind/chrome/tw-mode-dock.tsx +80 -0
- package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +7 -10
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +11 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
- package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +101 -21
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +353 -0
- package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +5 -1
- package/src/ui-tailwind/chrome-overlay/index.ts +2 -6
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +82 -18
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +133 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +386 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +140 -69
- package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +28 -3
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +7 -2
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +170 -63
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -78
- package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
- package/src/ui-tailwind/index.ts +6 -5
- package/src/ui-tailwind/theme/editor-theme.css +108 -15
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +122 -1
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +36 -3
- package/src/ui-tailwind/tw-review-workspace.tsx +207 -54
- package/src/runtime/collab-review-sync.ts +0 -254
- package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +0 -95
- package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import type {
|
|
11
11
|
CellShading,
|
|
12
12
|
CharacterStyleDefinition,
|
|
13
|
+
DocumentDefaults,
|
|
13
14
|
LatentStyleDefinition,
|
|
14
15
|
ParagraphStyleDefinition,
|
|
15
16
|
StylesCatalog,
|
|
@@ -37,6 +38,8 @@ import {
|
|
|
37
38
|
readTableWidth,
|
|
38
39
|
} from "./parse-tables.ts";
|
|
39
40
|
import { toCanonicalNumberingInstanceId } from "./parse-numbering.ts";
|
|
41
|
+
import { readRunProperties } from "./parse-run-formatting.ts";
|
|
42
|
+
import { readParagraphProperties } from "./parse-paragraph-formatting.ts";
|
|
40
43
|
|
|
41
44
|
// ---------------------------------------------------------------------------
|
|
42
45
|
// Inline XML node types (same pattern as parse-numbering.ts)
|
|
@@ -116,11 +119,28 @@ export function parseStylesXml(xml: string): ParseStylesResult {
|
|
|
116
119
|
const characters: Record<string, CharacterStyleDefinition> = {};
|
|
117
120
|
const tables: Record<string, TableStyleDefinition> = {};
|
|
118
121
|
const latentStyles: Record<string, LatentStyleDefinition> = {};
|
|
122
|
+
let docDefaults: DocumentDefaults | undefined;
|
|
119
123
|
|
|
120
124
|
for (const child of stylesElement.children) {
|
|
121
125
|
if (child.type !== "element") continue;
|
|
122
126
|
const local = localName(child.name);
|
|
123
127
|
|
|
128
|
+
if (local === "docDefaults") {
|
|
129
|
+
const pPrDefault = findChildElementOptional(child, "pPrDefault");
|
|
130
|
+
const rPrDefault = findChildElementOptional(child, "rPrDefault");
|
|
131
|
+
const pPrNode = pPrDefault ? findChildElementOptional(pPrDefault, "pPr") : undefined;
|
|
132
|
+
const rPrNode = rPrDefault ? findChildElementOptional(rPrDefault, "rPr") : undefined;
|
|
133
|
+
const paragraph = readParagraphProperties(pPrNode);
|
|
134
|
+
const run = readRunProperties(rPrNode);
|
|
135
|
+
if (paragraph || run) {
|
|
136
|
+
docDefaults = {
|
|
137
|
+
...(paragraph ? { paragraph } : {}),
|
|
138
|
+
...(run ? { run } : {}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
124
144
|
if (local === "style") {
|
|
125
145
|
const styleType = child.attributes["w:type"] ?? child.attributes.type;
|
|
126
146
|
const styleId = child.attributes["w:styleId"] ?? child.attributes.styleId;
|
|
@@ -135,6 +155,10 @@ export function parseStylesXml(xml: string): ParseStylesResult {
|
|
|
135
155
|
const nextStyle = readLinkedStyleId(child, "next");
|
|
136
156
|
const outlineLevel = readParagraphStyleOutlineLevel(child);
|
|
137
157
|
const numbering = readParagraphStyleNumbering(child);
|
|
158
|
+
const pPrNode = findChildElementOptional(child, "pPr");
|
|
159
|
+
const rPrNode = findChildElementOptional(child, "rPr");
|
|
160
|
+
const paragraphProperties = readParagraphProperties(pPrNode);
|
|
161
|
+
const runProperties = readRunProperties(rPrNode);
|
|
138
162
|
paragraphs[styleId] = {
|
|
139
163
|
styleId,
|
|
140
164
|
displayName,
|
|
@@ -144,16 +168,21 @@ export function parseStylesXml(xml: string): ParseStylesResult {
|
|
|
144
168
|
...(nextStyle ? { nextStyle } : {}),
|
|
145
169
|
...(outlineLevel !== undefined ? { outlineLevel } : {}),
|
|
146
170
|
...(numbering ? { numbering } : {}),
|
|
171
|
+
...(paragraphProperties ? { paragraphProperties } : {}),
|
|
172
|
+
...(runProperties ? { runProperties } : {}),
|
|
147
173
|
};
|
|
148
174
|
break;
|
|
149
175
|
}
|
|
150
176
|
case "character": {
|
|
177
|
+
const rPrNode = findChildElementOptional(child, "rPr");
|
|
178
|
+
const runProperties = readRunProperties(rPrNode);
|
|
151
179
|
characters[styleId] = {
|
|
152
180
|
styleId,
|
|
153
181
|
displayName,
|
|
154
182
|
kind: "character",
|
|
155
183
|
isDefault,
|
|
156
184
|
...(basedOn ? { basedOn } : {}),
|
|
185
|
+
...(runProperties ? { runProperties } : {}),
|
|
157
186
|
};
|
|
158
187
|
break;
|
|
159
188
|
}
|
|
@@ -194,6 +223,8 @@ export function parseStylesXml(xml: string): ParseStylesResult {
|
|
|
194
223
|
characters,
|
|
195
224
|
tables,
|
|
196
225
|
...(hasLatent ? { latentStyles } : {}),
|
|
226
|
+
fromPackage: true,
|
|
227
|
+
...(docDefaults ? { docDefaults } : {}),
|
|
197
228
|
},
|
|
198
229
|
fromPackage: true,
|
|
199
230
|
diagnostics,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared XML helpers for OOXML parsers.
|
|
3
|
+
*
|
|
4
|
+
* These functions factor out the common pattern of reading namespaced-prefixed
|
|
5
|
+
* attributes (e.g., `w:val`) with fallback to the unprefixed form, handling
|
|
6
|
+
* the OOXML `ST_OnOff` toggle semantics (missing child = undefined, present
|
|
7
|
+
* with no val = true, `val="0"|"false"|"off"` = false), and reading integers.
|
|
8
|
+
*
|
|
9
|
+
* Used by parse-run-formatting.ts, parse-paragraph-formatting.ts, and (later)
|
|
10
|
+
* parse-styles.ts, parse-numbering.ts.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { XmlElementNode } from "./xml-element.ts";
|
|
14
|
+
|
|
15
|
+
export function localName(name: string): string {
|
|
16
|
+
const sep = name.indexOf(":");
|
|
17
|
+
return sep >= 0 ? name.slice(sep + 1) : name;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function findChildOptional(
|
|
21
|
+
node: XmlElementNode,
|
|
22
|
+
local: string,
|
|
23
|
+
): XmlElementNode | undefined {
|
|
24
|
+
return node.children.find(
|
|
25
|
+
(c): c is XmlElementNode => c.type === "element" && localName(c.name) === local,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** ST_OnOff: missing child → undefined; present bare or w:val="1|true|on" → true; w:val="0|false|off" → false. */
|
|
30
|
+
export function readOnOff(node: XmlElementNode | undefined): boolean | undefined {
|
|
31
|
+
if (!node) return undefined;
|
|
32
|
+
const raw = node.attributes["w:val"] ?? node.attributes.val;
|
|
33
|
+
if (raw === undefined) return true;
|
|
34
|
+
const n = raw.toLowerCase();
|
|
35
|
+
if (n === "0" || n === "false" || n === "off") return false;
|
|
36
|
+
if (n === "1" || n === "true" || n === "on") return true;
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Read the child's `w:val` attribute as an int. Returns undefined if missing or not a finite integer. */
|
|
41
|
+
export function readIntVal(node: XmlElementNode | undefined): number | undefined {
|
|
42
|
+
if (!node) return undefined;
|
|
43
|
+
const raw = node.attributes["w:val"] ?? node.attributes.val;
|
|
44
|
+
if (raw === undefined) return undefined;
|
|
45
|
+
const v = Number.parseInt(raw, 10);
|
|
46
|
+
return Number.isFinite(v) ? v : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Read an arbitrary attribute from a node as an int, with namespace fallback. */
|
|
50
|
+
export function readIntAttr(node: XmlElementNode, attr: string): number | undefined {
|
|
51
|
+
const raw = node.attributes[attr] ?? node.attributes[attr.replace(/^w:/, "")];
|
|
52
|
+
if (raw === undefined) return undefined;
|
|
53
|
+
const v = Number.parseInt(raw, 10);
|
|
54
|
+
return Number.isFinite(v) ? v : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Read an arbitrary attribute from a node as a string, with namespace fallback. */
|
|
58
|
+
export function readStringAttr(node: XmlElementNode, attr: string): string | undefined {
|
|
59
|
+
return node.attributes[attr] ?? node.attributes[attr.replace(/^w:/, "")];
|
|
60
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface XmlElementNode {
|
|
2
|
+
type: "element";
|
|
3
|
+
name: string;
|
|
4
|
+
attributes: Record<string, string>;
|
|
5
|
+
children: Array<XmlElementNode | XmlTextNode>;
|
|
6
|
+
/** Optional source offset (start) — parsers that track offsets may populate. */
|
|
7
|
+
start?: number;
|
|
8
|
+
/** Optional source offset (end) — parsers that track offsets may populate. */
|
|
9
|
+
end?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface XmlTextNode {
|
|
13
|
+
type: "text";
|
|
14
|
+
text: string;
|
|
15
|
+
start?: number;
|
|
16
|
+
end?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type XmlNode = XmlElementNode | XmlTextNode;
|
|
@@ -83,6 +83,7 @@ export interface StylesCatalog {
|
|
|
83
83
|
tables: Record<string, TableStyleDefinition>;
|
|
84
84
|
latentStyles?: Record<string, LatentStyleDefinition>;
|
|
85
85
|
fromPackage?: boolean;
|
|
86
|
+
docDefaults?: DocumentDefaults;
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
export interface ParagraphStyleDefinition {
|
|
@@ -94,6 +95,8 @@ export interface ParagraphStyleDefinition {
|
|
|
94
95
|
displayName: string;
|
|
95
96
|
kind: "paragraph";
|
|
96
97
|
isDefault: boolean;
|
|
98
|
+
paragraphProperties?: CanonicalParagraphFormatting;
|
|
99
|
+
runProperties?: CanonicalRunFormatting;
|
|
97
100
|
}
|
|
98
101
|
|
|
99
102
|
export interface ParagraphStyleNumberingReference {
|
|
@@ -107,6 +110,7 @@ export interface CharacterStyleDefinition {
|
|
|
107
110
|
displayName: string;
|
|
108
111
|
kind: "character";
|
|
109
112
|
isDefault: boolean;
|
|
113
|
+
runProperties?: CanonicalRunFormatting;
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
export interface TableStyleDefinition {
|
|
@@ -136,6 +140,12 @@ export interface NumberingCatalog {
|
|
|
136
140
|
export interface AbstractNumberingDefinition {
|
|
137
141
|
abstractNumberingId: string;
|
|
138
142
|
levels: NumberingLevelDefinition[];
|
|
143
|
+
nsid?: string;
|
|
144
|
+
multiLevelType?: "singleLevel" | "multilevel" | "hybridMultilevel";
|
|
145
|
+
/** ECMA-376 17.9.26 `<w:tmpl>` — template code identifying the abstractNum's origin template (ST_LongHexNumber). Preserved for round-trip. */
|
|
146
|
+
tplc?: string;
|
|
147
|
+
styleLink?: string;
|
|
148
|
+
numStyleLink?: string;
|
|
139
149
|
}
|
|
140
150
|
|
|
141
151
|
export interface NumberingLevelParagraphGeometry {
|
|
@@ -154,6 +164,8 @@ export interface NumberingLevelDefinition {
|
|
|
154
164
|
isLegalNumbering?: boolean;
|
|
155
165
|
suffix?: "tab" | "space" | "nothing";
|
|
156
166
|
paragraphGeometry?: NumberingLevelParagraphGeometry;
|
|
167
|
+
runProperties?: CanonicalRunFormatting;
|
|
168
|
+
restartAfterLevel?: number;
|
|
157
169
|
}
|
|
158
170
|
|
|
159
171
|
export interface NumberingLevelOverrideDefinition {
|
|
@@ -165,6 +177,8 @@ export interface NumberingLevelOverrideDefinition {
|
|
|
165
177
|
isLegalNumbering?: boolean;
|
|
166
178
|
suffix?: "tab" | "space" | "nothing";
|
|
167
179
|
paragraphGeometry?: NumberingLevelParagraphGeometry;
|
|
180
|
+
runProperties?: CanonicalRunFormatting;
|
|
181
|
+
restartAfterLevel?: number;
|
|
168
182
|
}
|
|
169
183
|
|
|
170
184
|
export interface NumberingInstance {
|
|
@@ -350,6 +364,68 @@ export interface ParagraphShading {
|
|
|
350
364
|
val?: string;
|
|
351
365
|
}
|
|
352
366
|
|
|
367
|
+
/** Body of an OOXML `<w:rPr>` (run properties). All fields optional; absence = "not specified at this level". */
|
|
368
|
+
export interface CanonicalRunFormatting {
|
|
369
|
+
bold?: boolean;
|
|
370
|
+
italic?: boolean;
|
|
371
|
+
underline?: "single" | "double" | "thick" | "dotted" | "dash" | "wave" | "none";
|
|
372
|
+
strikethrough?: boolean;
|
|
373
|
+
doubleStrikethrough?: boolean;
|
|
374
|
+
vanish?: boolean;
|
|
375
|
+
allCaps?: boolean;
|
|
376
|
+
smallCaps?: boolean;
|
|
377
|
+
verticalAlign?: "baseline" | "superscript" | "subscript";
|
|
378
|
+
/**
|
|
379
|
+
* Convenience alias for the primary font family — the first non-empty of
|
|
380
|
+
* `fontFamilyAscii` → `fontFamilyHAnsi` → `fontFamilyEastAsia` → `fontFamilyCs`.
|
|
381
|
+
* Script-aware consumers should read the specific `fontFamily{Ascii,HAnsi,EastAsia,Cs}`
|
|
382
|
+
* fields directly.
|
|
383
|
+
*/
|
|
384
|
+
fontFamily?: string;
|
|
385
|
+
fontFamilyAscii?: string;
|
|
386
|
+
fontFamilyHAnsi?: string;
|
|
387
|
+
fontFamilyEastAsia?: string;
|
|
388
|
+
fontFamilyCs?: string;
|
|
389
|
+
fontSizeHalfPoints?: number;
|
|
390
|
+
fontSizeCsHalfPoints?: number;
|
|
391
|
+
/**
|
|
392
|
+
* Color value from `<w:color w:val>`. Either an OOXML hex (e.g., `"2E74B5"`)
|
|
393
|
+
* or the sentinel `"auto"` (which serializers must round-trip verbatim).
|
|
394
|
+
*/
|
|
395
|
+
colorHex?: string;
|
|
396
|
+
colorThemeSlot?: string;
|
|
397
|
+
highlight?: string;
|
|
398
|
+
characterSpacingTwips?: number;
|
|
399
|
+
characterStyleId?: string;
|
|
400
|
+
languageCode?: string;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Body of an OOXML `<w:pPr>` (paragraph properties). All fields optional; absence = "not specified at this level". */
|
|
404
|
+
export interface CanonicalParagraphFormatting {
|
|
405
|
+
spacing?: ParagraphSpacing;
|
|
406
|
+
indentation?: ParagraphIndentation;
|
|
407
|
+
alignment?: "left" | "center" | "right" | "both" | "distribute" | "start" | "end";
|
|
408
|
+
borders?: ParagraphBorders;
|
|
409
|
+
shading?: ParagraphShading;
|
|
410
|
+
tabStops?: TabStop[];
|
|
411
|
+
contextualSpacing?: boolean;
|
|
412
|
+
keepNext?: boolean;
|
|
413
|
+
keepLines?: boolean;
|
|
414
|
+
widowControl?: boolean;
|
|
415
|
+
pageBreakBefore?: boolean;
|
|
416
|
+
outlineLevel?: number;
|
|
417
|
+
bidi?: boolean;
|
|
418
|
+
suppressLineNumbers?: boolean;
|
|
419
|
+
suppressAutoHyphens?: boolean;
|
|
420
|
+
paragraphMarkRunProperties?: CanonicalRunFormatting;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Body of an OOXML `<w:docDefaults>` — baseline formatting applied before style chain. */
|
|
424
|
+
export interface DocumentDefaults {
|
|
425
|
+
paragraph?: CanonicalParagraphFormatting;
|
|
426
|
+
run?: CanonicalRunFormatting;
|
|
427
|
+
}
|
|
428
|
+
|
|
353
429
|
export interface ParagraphNode {
|
|
354
430
|
type: "paragraph";
|
|
355
431
|
styleId?: string;
|
|
@@ -594,15 +670,19 @@ export interface AltChunkNode {
|
|
|
594
670
|
* These families have stable registry IDs, dependency metadata, and
|
|
595
671
|
* runtime-owned refresh behavior.
|
|
596
672
|
*/
|
|
597
|
-
export type SupportedFieldFamily =
|
|
673
|
+
export type SupportedFieldFamily =
|
|
674
|
+
| "REF"
|
|
675
|
+
| "PAGEREF"
|
|
676
|
+
| "NOTEREF"
|
|
677
|
+
| "TOC"
|
|
678
|
+
| "PAGE"
|
|
679
|
+
| "NUMPAGES";
|
|
598
680
|
|
|
599
681
|
/**
|
|
600
682
|
* Unsupported field families that remain preserve-only.
|
|
601
683
|
* They survive round-trip but do not participate in runtime refresh.
|
|
602
684
|
*/
|
|
603
685
|
export type PreserveOnlyFieldFamily =
|
|
604
|
-
| "PAGE"
|
|
605
|
-
| "NUMPAGES"
|
|
606
686
|
| "DATE"
|
|
607
687
|
| "TIME"
|
|
608
688
|
| "AUTHOR"
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { EditorCommand } from "../../core/commands/index.ts";
|
|
2
|
+
import type { SelectionSnapshot } from "../../core/state/editor-state.ts";
|
|
3
|
+
import type { EditorStoryTarget } from "../../api/public-types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A single delta event recorded in the shared collaboration event log.
|
|
7
|
+
*
|
|
8
|
+
* The collaboration model for the runtime layer is:
|
|
9
|
+
* `currentState = replay(baseDocxBytes, eventLog)`
|
|
10
|
+
*
|
|
11
|
+
* Every client loads the same base `.docx`, then the event log — stored as
|
|
12
|
+
* a `Y.Array<CommandEvent>` on the shared `Y.Doc` — is replayed through
|
|
13
|
+
* the runtime's `executeEditorCommand` pipeline to arrive at the current
|
|
14
|
+
* canonical document state. Because all mutations flow through the
|
|
15
|
+
* runtime, export/download and every other downstream read see the full,
|
|
16
|
+
* up-to-date state automatically.
|
|
17
|
+
*
|
|
18
|
+
* Events are meant to be small deltas (text insertions, comment adds,
|
|
19
|
+
* change accepts, etc.). They are NOT full-document snapshots.
|
|
20
|
+
*
|
|
21
|
+
* `document.replace` is a special case: some formatting/table/list
|
|
22
|
+
* operations currently produce `document.replace` commands that carry
|
|
23
|
+
* a full `CanonicalDocumentEnvelope`. These events are bandwidth-heavy
|
|
24
|
+
* but functionally correct. A follow-up will promote them to first-class
|
|
25
|
+
* delta commands.
|
|
26
|
+
*/
|
|
27
|
+
export interface CommandEvent {
|
|
28
|
+
/** UUID — dedup key for defensive idempotency. */
|
|
29
|
+
eventId: string;
|
|
30
|
+
/** `Y.Doc.clientID` of the originating client. */
|
|
31
|
+
originClientId: number;
|
|
32
|
+
/** User identity for attribution (e.g. `currentUser.userId`). */
|
|
33
|
+
authorId: string;
|
|
34
|
+
/** ISO-8601 UTC timestamp at origin. */
|
|
35
|
+
timestamp: string;
|
|
36
|
+
/** The exact `EditorCommand` that produced this delta. */
|
|
37
|
+
command: EditorCommand;
|
|
38
|
+
/** Context required for deterministic replay on remote clients. */
|
|
39
|
+
context: {
|
|
40
|
+
documentMode?: "editing" | "suggesting" | "viewing" | "commenting";
|
|
41
|
+
defaultAuthorId?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Selection at the moment the command was dispatched on the origin
|
|
44
|
+
* client. Replay uses this to restore the caret before applying the
|
|
45
|
+
* command so text insertions, backspaces, etc. land at the position
|
|
46
|
+
* the author intended rather than the replaying client's current
|
|
47
|
+
* caret (which is arbitrary).
|
|
48
|
+
*/
|
|
49
|
+
preSelection?: SelectionSnapshot;
|
|
50
|
+
/**
|
|
51
|
+
* Active story at the moment the command was dispatched. Replay
|
|
52
|
+
* must target the same story (main, header, footer, note, ...) so
|
|
53
|
+
* mutations apply to the intended region.
|
|
54
|
+
*/
|
|
55
|
+
activeStory?: EditorStoryTarget;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Command types that are broadcast to all collaborators.
|
|
61
|
+
*
|
|
62
|
+
* Any command whose type is in this set represents a mutation to the
|
|
63
|
+
* shared document state (content, comments, tracked changes, review
|
|
64
|
+
* decisions). Local-only commands are intentionally excluded — see
|
|
65
|
+
* `LOCAL_ONLY_COMMAND_TYPES`.
|
|
66
|
+
*/
|
|
67
|
+
export const BROADCAST_COMMAND_TYPES: ReadonlySet<EditorCommand["type"]> = new Set<
|
|
68
|
+
EditorCommand["type"]
|
|
69
|
+
>([
|
|
70
|
+
"text.insert",
|
|
71
|
+
"text.delete-backward",
|
|
72
|
+
"text.delete-forward",
|
|
73
|
+
"text.insert-tab",
|
|
74
|
+
"text.insert-hard-break",
|
|
75
|
+
"paragraph.split",
|
|
76
|
+
"document.replace",
|
|
77
|
+
"comment.add",
|
|
78
|
+
"comment.resolve",
|
|
79
|
+
"comment.reopen",
|
|
80
|
+
"comment.add-reply",
|
|
81
|
+
"comment.edit-body",
|
|
82
|
+
"change.accept",
|
|
83
|
+
"change.reject",
|
|
84
|
+
"change.accept-all",
|
|
85
|
+
"change.reject-all",
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Command types that are NEVER broadcast. These are intentionally
|
|
90
|
+
* per-client: cursor/selection state (handled by Awareness instead),
|
|
91
|
+
* UI focus, local warnings, and the local history stack.
|
|
92
|
+
*/
|
|
93
|
+
export const LOCAL_ONLY_COMMAND_TYPES: ReadonlySet<EditorCommand["type"]> = new Set<
|
|
94
|
+
EditorCommand["type"]
|
|
95
|
+
>([
|
|
96
|
+
"selection.set",
|
|
97
|
+
"comment.open",
|
|
98
|
+
"warning.add",
|
|
99
|
+
"warning.clear",
|
|
100
|
+
"history.undo",
|
|
101
|
+
"history.redo",
|
|
102
|
+
"runtime.focus",
|
|
103
|
+
"runtime.set-read-only",
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Returns `true` when the command is local-only and must NOT be broadcast
|
|
108
|
+
* on the shared collaboration event log.
|
|
109
|
+
*/
|
|
110
|
+
export function isLocalOnlyCommand(command: EditorCommand): boolean {
|
|
111
|
+
return LOCAL_ONLY_COMMAND_TYPES.has(command.type);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Returns `true` when the command must be broadcast on the shared event
|
|
116
|
+
* log. Defensive helper — equivalent to `!isLocalOnlyCommand(command)`
|
|
117
|
+
* for every known command type, but raises visibility if a new command
|
|
118
|
+
* type is added without being classified (both predicates would be
|
|
119
|
+
* `false`, which is easier to spot in tests).
|
|
120
|
+
*/
|
|
121
|
+
export function isBroadcastCommand(command: EditorCommand): boolean {
|
|
122
|
+
return BROADCAST_COMMAND_TYPES.has(command.type);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface CreateCommandEventInput {
|
|
126
|
+
command: EditorCommand;
|
|
127
|
+
originClientId: number;
|
|
128
|
+
authorId: string;
|
|
129
|
+
timestamp: string;
|
|
130
|
+
context?: CommandEvent["context"];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Build a `CommandEvent` to append to the shared event log. Callers are
|
|
135
|
+
* responsible for ensuring `command` is a broadcast-eligible command.
|
|
136
|
+
*/
|
|
137
|
+
export function createCommandEvent(input: CreateCommandEventInput): CommandEvent {
|
|
138
|
+
return {
|
|
139
|
+
eventId: generateEventId(),
|
|
140
|
+
originClientId: input.originClientId,
|
|
141
|
+
authorId: input.authorId,
|
|
142
|
+
timestamp: input.timestamp,
|
|
143
|
+
command: input.command,
|
|
144
|
+
context: {
|
|
145
|
+
documentMode: input.context?.documentMode,
|
|
146
|
+
defaultAuthorId: input.context?.defaultAuthorId,
|
|
147
|
+
preSelection: input.context?.preSelection,
|
|
148
|
+
activeStory: input.context?.activeStory,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let eventIdCounter = 0;
|
|
154
|
+
|
|
155
|
+
function generateEventId(): string {
|
|
156
|
+
// A short, collision-resistant-enough id for local dedup. Clients
|
|
157
|
+
// distinguish their own events by `originClientId` first; this id is
|
|
158
|
+
// a defensive fallback against replays of identical client-scoped
|
|
159
|
+
// sequences. No crypto requirements here.
|
|
160
|
+
eventIdCounter = (eventIdCounter + 1) >>> 0;
|
|
161
|
+
const random = Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, "0");
|
|
162
|
+
const counter = eventIdCounter.toString(16).padStart(8, "0");
|
|
163
|
+
const time = Date.now().toString(16);
|
|
164
|
+
return `evt-${time}-${counter}-${random}`;
|
|
165
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createRuntimeCollabSync,
|
|
3
|
+
createRuntimeCommandAppliedBridge,
|
|
4
|
+
type RuntimeCollabSyncHandle,
|
|
5
|
+
type RuntimeCollabSyncOptions,
|
|
6
|
+
type RuntimeCommandAppliedBridge,
|
|
7
|
+
type RuntimeCommandAppliedListener,
|
|
8
|
+
} from "./runtime-collab-sync.ts";
|
|
9
|
+
export {
|
|
10
|
+
createCommandEvent,
|
|
11
|
+
isBroadcastCommand,
|
|
12
|
+
isLocalOnlyCommand,
|
|
13
|
+
type CommandEvent,
|
|
14
|
+
type CreateCommandEventInput,
|
|
15
|
+
} from "./event-types.ts";
|
|
16
|
+
export {
|
|
17
|
+
clearLocalCursorState,
|
|
18
|
+
getCursorColorForUser,
|
|
19
|
+
getRemoteCursorStates,
|
|
20
|
+
setLocalCursorState,
|
|
21
|
+
type RemoteCursorState,
|
|
22
|
+
} from "./remote-cursor-awareness.ts";
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Awareness } from "y-protocols/awareness";
|
|
2
|
+
import type { EditorStoryTarget } from "../../api/public-types";
|
|
3
|
+
|
|
4
|
+
export interface RemoteCursorState {
|
|
5
|
+
userId: string;
|
|
6
|
+
displayName: string;
|
|
7
|
+
color: string;
|
|
8
|
+
anchor: number;
|
|
9
|
+
head: number;
|
|
10
|
+
storyTarget: EditorStoryTarget;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const CURSOR_STATE_KEY = "cursor";
|
|
14
|
+
|
|
15
|
+
const CURSOR_COLORS = [
|
|
16
|
+
"#e11d48",
|
|
17
|
+
"#ea580c",
|
|
18
|
+
"#ca8a04",
|
|
19
|
+
"#16a34a",
|
|
20
|
+
"#0891b2",
|
|
21
|
+
"#2563eb",
|
|
22
|
+
"#7c3aed",
|
|
23
|
+
"#c026d3",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function hashUserId(userId: string): number {
|
|
27
|
+
let hash = 0;
|
|
28
|
+
for (let i = 0; i < userId.length; i++) {
|
|
29
|
+
const char = userId.charCodeAt(i);
|
|
30
|
+
hash = (hash << 5) - hash + char;
|
|
31
|
+
hash = hash & hash;
|
|
32
|
+
}
|
|
33
|
+
return Math.abs(hash);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getCursorColorForUser(userId: string): string {
|
|
37
|
+
const index = hashUserId(userId) % CURSOR_COLORS.length;
|
|
38
|
+
return CURSOR_COLORS[index];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function setLocalCursorState(
|
|
42
|
+
awareness: Awareness,
|
|
43
|
+
state: RemoteCursorState,
|
|
44
|
+
): void {
|
|
45
|
+
awareness.setLocalStateField(CURSOR_STATE_KEY, state);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function clearLocalCursorState(awareness: Awareness): void {
|
|
49
|
+
const localState = awareness.getLocalState();
|
|
50
|
+
if (localState && CURSOR_STATE_KEY in localState) {
|
|
51
|
+
awareness.setLocalStateField(CURSOR_STATE_KEY, null);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const SAFE_HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
|
|
56
|
+
|
|
57
|
+
export function isSafeCursorColor(value: unknown): value is string {
|
|
58
|
+
return typeof value === "string" && SAFE_HEX_COLOR_PATTERN.test(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getRemoteCursorStates(
|
|
62
|
+
awareness: Awareness,
|
|
63
|
+
localClientId: number,
|
|
64
|
+
): RemoteCursorState[] {
|
|
65
|
+
const states = awareness.getStates();
|
|
66
|
+
const result: RemoteCursorState[] = [];
|
|
67
|
+
|
|
68
|
+
for (const [clientId, clientState] of states) {
|
|
69
|
+
if (clientId === localClientId) continue;
|
|
70
|
+
|
|
71
|
+
const cursorState = clientState[CURSOR_STATE_KEY];
|
|
72
|
+
if (!cursorState) continue;
|
|
73
|
+
|
|
74
|
+
if (
|
|
75
|
+
typeof cursorState.userId === "string" &&
|
|
76
|
+
typeof cursorState.displayName === "string" &&
|
|
77
|
+
typeof cursorState.anchor === "number" &&
|
|
78
|
+
typeof cursorState.head === "number" &&
|
|
79
|
+
cursorState.storyTarget &&
|
|
80
|
+
typeof cursorState.storyTarget.kind === "string"
|
|
81
|
+
) {
|
|
82
|
+
const safeColor = isSafeCursorColor(cursorState.color)
|
|
83
|
+
? cursorState.color
|
|
84
|
+
: getCursorColorForUser(cursorState.userId);
|
|
85
|
+
result.push({
|
|
86
|
+
...(cursorState as RemoteCursorState),
|
|
87
|
+
color: safeColor,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return result;
|
|
93
|
+
}
|