@beyondwork/docx-react-component 1.0.56 → 1.0.57
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 +1 -1
- package/src/api/public-types.ts +157 -0
- package/src/compare/diff-engine.ts +3 -0
- package/src/core/commands/formatting-commands.ts +1 -0
- package/src/core/commands/index.ts +17 -11
- package/src/core/selection/mapping.ts +18 -1
- package/src/core/selection/review-anchors.ts +29 -18
- package/src/io/chart-preview-resolver.ts +175 -41
- package/src/io/docx-session.ts +57 -2
- package/src/io/export/serialize-main-document.ts +82 -0
- package/src/io/export/serialize-styles.ts +61 -3
- package/src/io/export/table-properties-xml.ts +19 -4
- package/src/io/normalize/normalize-text.ts +33 -0
- package/src/io/ooxml/parse-anchor.ts +182 -0
- package/src/io/ooxml/parse-drawing.ts +319 -0
- package/src/io/ooxml/parse-fields.ts +115 -2
- package/src/io/ooxml/parse-fill.ts +215 -0
- package/src/io/ooxml/parse-font-table.ts +190 -0
- package/src/io/ooxml/parse-footnotes.ts +52 -1
- package/src/io/ooxml/parse-main-document.ts +241 -1
- package/src/io/ooxml/parse-numbering.ts +96 -0
- package/src/io/ooxml/parse-picture.ts +107 -0
- package/src/io/ooxml/parse-settings.ts +34 -0
- package/src/io/ooxml/parse-shapes.ts +87 -0
- package/src/io/ooxml/parse-solid-fill.ts +11 -0
- package/src/io/ooxml/parse-styles.ts +74 -1
- package/src/io/ooxml/parse-theme.ts +60 -0
- package/src/io/paste/html-clipboard.ts +449 -0
- package/src/io/paste/word-clipboard.ts +5 -1
- package/src/legal/_document-root.ts +26 -0
- package/src/legal/bookmarks.ts +4 -3
- package/src/legal/cross-references.ts +3 -2
- package/src/legal/defined-terms.ts +2 -1
- package/src/legal/signature-blocks.ts +2 -1
- package/src/model/canonical-document.ts +415 -3
- package/src/runtime/chart/chart-model-store.ts +73 -10
- package/src/runtime/document-runtime.ts +693 -41
- package/src/runtime/edit-ops/index.ts +129 -0
- package/src/runtime/event-refresh-hints.ts +7 -0
- package/src/runtime/field-resolver.ts +341 -0
- package/src/runtime/footnote-resolver.ts +55 -0
- package/src/runtime/hyperlink-color-resolver.ts +13 -10
- package/src/runtime/object-grab/index.ts +51 -0
- package/src/runtime/paragraph-style-resolver.ts +105 -0
- package/src/runtime/resolved-numbering-geometry.ts +12 -0
- package/src/runtime/selection/cursor-ops.ts +186 -15
- package/src/runtime/selection/index.ts +17 -1
- package/src/runtime/structure-ops/index.ts +77 -0
- package/src/runtime/styles-cascade.ts +33 -0
- package/src/runtime/surface-projection.ts +186 -12
- package/src/runtime/theme-color-resolver.ts +189 -44
- package/src/runtime/units.ts +46 -0
- package/src/runtime/view-state.ts +13 -2
- package/src/ui/WordReviewEditor.tsx +168 -10
- package/src/ui/editor-runtime-boundary.ts +94 -1
- package/src/ui/editor-shell-view.tsx +1 -1
- package/src/ui/runtime-shortcut-dispatch.ts +17 -3
- package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
- package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
- package/src/ui-tailwind/chart/render/area.tsx +22 -4
- package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
- package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
- package/src/ui-tailwind/chart/render/combo.tsx +37 -4
- package/src/ui-tailwind/chart/render/line.tsx +28 -5
- package/src/ui-tailwind/chart/render/pie.tsx +36 -16
- package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
- package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
- package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
- package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
- package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
- package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
- package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
- package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -0
- package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
- package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
- package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
- package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
- package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
- package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
- package/src/ui-tailwind/editor-surface/pm-schema.ts +188 -11
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +28 -2
- package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
- package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
- package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
- package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
- package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
- package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
- package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
- package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
- package/src/ui-tailwind/theme/editor-theme.css +1 -0
- package/src/ui-tailwind/theme/tokens.css +6 -0
- package/src/ui-tailwind/theme/tokens.ts +10 -0
- package/src/validation/compatibility-engine.ts +2 -0
- package/src/validation/docx-comment-proof.ts +12 -3
|
@@ -12,6 +12,11 @@ import {
|
|
|
12
12
|
type EditorState,
|
|
13
13
|
type EditorWarning as InternalEditorWarning,
|
|
14
14
|
} from "../core/state/editor-state.ts";
|
|
15
|
+
import {
|
|
16
|
+
logicalPositionToUnitIndex,
|
|
17
|
+
parseTextStory,
|
|
18
|
+
serializeTextStory,
|
|
19
|
+
} from "../core/schema/text-schema.ts";
|
|
15
20
|
import type {
|
|
16
21
|
AddCommentParams,
|
|
17
22
|
AddCommentReplyResult,
|
|
@@ -61,6 +66,7 @@ import type {
|
|
|
61
66
|
SurfaceInlineSegment,
|
|
62
67
|
StoryTextStreamSnapshot,
|
|
63
68
|
TextCommandAck,
|
|
69
|
+
TocRefreshTrigger,
|
|
64
70
|
TocSnapshot,
|
|
65
71
|
StyleCatalogSnapshot,
|
|
66
72
|
TocRefreshOptions,
|
|
@@ -115,6 +121,8 @@ import {
|
|
|
115
121
|
snapCommentAnchorAwayFromTable,
|
|
116
122
|
} from "../core/selection/review-anchors.ts";
|
|
117
123
|
import { buildBookmarkNameMap } from "../legal/bookmarks.ts";
|
|
124
|
+
import { createFieldResolver, type FieldResolver } from "./field-resolver.ts";
|
|
125
|
+
import { createFootnoteResolver, type FootnoteResolver } from "./footnote-resolver.ts";
|
|
118
126
|
import {
|
|
119
127
|
describeOpaqueFragment,
|
|
120
128
|
findOpaqueFragmentsIntersectingRange,
|
|
@@ -128,7 +136,7 @@ import {
|
|
|
128
136
|
} from "../review/store/revision-store.ts";
|
|
129
137
|
import { createSuggestionsSnapshot } from "./suggestions-snapshot.ts";
|
|
130
138
|
import { validateSelectionAgainstDocument } from "./selection/post-edit-validator.ts";
|
|
131
|
-
import { resolveScope } from "./scope-resolver.ts";
|
|
139
|
+
import { collectScopeLocations, resolveScope } from "./scope-resolver.ts";
|
|
132
140
|
import {
|
|
133
141
|
insertScopeMarkers,
|
|
134
142
|
removeScopeMarkers,
|
|
@@ -205,13 +213,16 @@ import {
|
|
|
205
213
|
createEditorViewStateSnapshot,
|
|
206
214
|
type ViewState,
|
|
207
215
|
} from "./view-state.ts";
|
|
216
|
+
import { ThemeColorResolver } from "./theme-color-resolver.ts";
|
|
208
217
|
import type {
|
|
209
218
|
BlockNode,
|
|
219
|
+
CanonicalDocument,
|
|
210
220
|
FieldNode,
|
|
211
221
|
FieldRefreshStatus,
|
|
212
222
|
InlineNode,
|
|
213
223
|
PageMargins,
|
|
214
224
|
ParagraphNode,
|
|
225
|
+
SectionProperties,
|
|
215
226
|
SubPartsCatalog,
|
|
216
227
|
} from "../model/canonical-document.ts";
|
|
217
228
|
import {
|
|
@@ -240,6 +251,14 @@ import type {
|
|
|
240
251
|
EditorStatePersister,
|
|
241
252
|
} from "../api/editor-state-types.ts";
|
|
242
253
|
import { collectEditorStateForSerialize } from "./editor-state-integration.ts";
|
|
254
|
+
import { serializeFragmentToWordML } from "../io/paste/word-clipboard.ts";
|
|
255
|
+
import {
|
|
256
|
+
createObjectGrabState,
|
|
257
|
+
deselectObject as grabDeselectObject,
|
|
258
|
+
getGrabbedObject as grabGetGrabbedObject,
|
|
259
|
+
selectObject as grabSelectObject,
|
|
260
|
+
type ObjectGrabState,
|
|
261
|
+
} from "./object-grab/index.ts";
|
|
243
262
|
import type { EditorStatePayload } from "../io/ooxml/workflow-payload.ts";
|
|
244
263
|
import type { SharedWorkflowState } from "./collab/workflow-shared.ts";
|
|
245
264
|
import { formatPageNumber } from "./page-number-format.ts";
|
|
@@ -277,8 +296,77 @@ export interface DocumentRuntime {
|
|
|
277
296
|
getRenderSnapshot(): RuntimeRenderSnapshot;
|
|
278
297
|
getCanonicalDocument(): CanonicalDocumentEnvelope;
|
|
279
298
|
getSourcePackage(): EditorSessionState["sourcePackage"] | undefined;
|
|
299
|
+
/** Return the parsed fontTable, if present in the loaded package. */
|
|
300
|
+
getFontTable(): CanonicalDocumentEnvelope["fontTable"];
|
|
301
|
+
/**
|
|
302
|
+
* Convenience accessor — return the `CanonicalFontEntry` for `name`, or
|
|
303
|
+
* undefined when the loaded package has no fontTable or no matching entry.
|
|
304
|
+
*/
|
|
305
|
+
getFontEntry(
|
|
306
|
+
name: string,
|
|
307
|
+
): NonNullable<CanonicalDocumentEnvelope["fontTable"]>["fonts"][string] | undefined;
|
|
280
308
|
replaceText(text: string, target?: EditorAnchorProjection, formatting?: TextFormattingDirective): void;
|
|
281
309
|
insertFragment(fragment: CanonicalDocumentFragment, target?: EditorAnchorProjection): void;
|
|
310
|
+
/**
|
|
311
|
+
* I2 Tier B Slice 4b — serialize the selection range to a
|
|
312
|
+
* `CanonicalDocumentFragment` and store it in an internal clipboard buffer.
|
|
313
|
+
* No document mutation. `target` defaults to the current selection.
|
|
314
|
+
*/
|
|
315
|
+
copy(target?: EditorAnchorProjection): void;
|
|
316
|
+
/**
|
|
317
|
+
* I2 Tier B Slice 4b — `copy(target)` + delete the range. Safe on empty /
|
|
318
|
+
* collapsed ranges (no-op).
|
|
319
|
+
*/
|
|
320
|
+
cut(target?: EditorAnchorProjection): void;
|
|
321
|
+
/**
|
|
322
|
+
* I2 Tier B Slice 4b — return the last fragment stored via `cut` / `copy`,
|
|
323
|
+
* or `null` when no clipboard operation has been performed yet. Mirrors
|
|
324
|
+
* what a browser `clipboardData.getData("web application/x-clip")` would
|
|
325
|
+
* return in a system-clipboard-aware build; for now hosts can use this to
|
|
326
|
+
* feed `insertFragment` directly.
|
|
327
|
+
*/
|
|
328
|
+
getClipboardBuffer(): CanonicalDocumentFragment | null;
|
|
329
|
+
/**
|
|
330
|
+
* v5 close-out — return the current clipboard buffer serialized to the
|
|
331
|
+
* wire formats browsers/Word accept, or `null` when no clipboard op has
|
|
332
|
+
* been performed. Hosts pair this with `navigator.clipboard.write` inside
|
|
333
|
+
* their own DOM `copy`/`cut` event handler; the editor does not install
|
|
334
|
+
* the DOM handler itself because hosts often route cut/copy through their
|
|
335
|
+
* own protocol. Three formats: WordML (`application/x-docx-fragment`),
|
|
336
|
+
* HTML (`text/html`), and plain text (`text/plain`).
|
|
337
|
+
*/
|
|
338
|
+
getClipboardWireFormats(): { wordml: string; html: string; plainText: string } | null;
|
|
339
|
+
/**
|
|
340
|
+
* R.3 ObjectGrabLayer — grab an inline / floating object (image, shape)
|
|
341
|
+
* by its stable id. Single-select model. Local-only state; not broadcast
|
|
342
|
+
* through collab. Lane 6 P11 paints the chrome handles.
|
|
343
|
+
*/
|
|
344
|
+
selectObject(objectId: string): void;
|
|
345
|
+
/**
|
|
346
|
+
* R.3 — release any grabbed object. Safe to call when nothing is grabbed.
|
|
347
|
+
*/
|
|
348
|
+
deselectObject(): void;
|
|
349
|
+
/**
|
|
350
|
+
* R.3 — return the currently grabbed object id, or `null` when no
|
|
351
|
+
* object is grabbed.
|
|
352
|
+
*/
|
|
353
|
+
getGrabbedObject(): string | null;
|
|
354
|
+
/**
|
|
355
|
+
* R.5.a — open an action bracket. Hosts use this to group compound edits
|
|
356
|
+
* (paste → insertFragment, cut → copy+delete, agent suggestion-apply) so
|
|
357
|
+
* snapshot emission + collab broadcast + undo grouping see them as one
|
|
358
|
+
* action. Nested brackets are tracked by depth; only the outermost
|
|
359
|
+
* `endAction` completes the bracket.
|
|
360
|
+
*
|
|
361
|
+
* Phase 1 (Item E): this API ships opt-in. Commands don't auto-bracket
|
|
362
|
+
* themselves yet — hosts that want single-undo paste must call
|
|
363
|
+
* `startAction` / `endAction` around their `insertFragment` call.
|
|
364
|
+
*/
|
|
365
|
+
startAction(name: string): void;
|
|
366
|
+
/** R.5.a — close one level of action bracketing. Unbalanced calls are no-ops. */
|
|
367
|
+
endAction(): void;
|
|
368
|
+
/** R.5.a — `true` when the runtime is inside one or more action brackets. */
|
|
369
|
+
isInAction(): boolean;
|
|
282
370
|
applyActiveStoryTextCommand(command: ActiveStoryTextCommand): TextCommandAck;
|
|
283
371
|
dispatch(command: EditorCommand): void;
|
|
284
372
|
/**
|
|
@@ -382,6 +470,20 @@ export interface DocumentRuntime {
|
|
|
382
470
|
}): DocumentSectionSnapshot | null;
|
|
383
471
|
describeEventImpact(event: WordReviewEditorEvent): SnapshotRefreshHints;
|
|
384
472
|
getFieldSnapshot(): FieldSnapshot;
|
|
473
|
+
/**
|
|
474
|
+
* CO3.5 — Field resolver exposing `resolve(entry)` for PAGE / NUMPAGES /
|
|
475
|
+
* PAGEREF / REF / STYLEREF. TOC entries return `undefined`. Reads
|
|
476
|
+
* `layoutEngine.getPageGraph()` + the active page index + the bookmark
|
|
477
|
+
* name map + paragraph start-offsets, so it updates naturally with the
|
|
478
|
+
* current document state.
|
|
479
|
+
*/
|
|
480
|
+
getFieldResolver(): FieldResolver;
|
|
481
|
+
/**
|
|
482
|
+
* CO3.5 — Footnote / endnote resolver. Returns `undefined` when the
|
|
483
|
+
* document has no footnote collection attached (no `footnotes.xml`
|
|
484
|
+
* part).
|
|
485
|
+
*/
|
|
486
|
+
getFootnoteResolver(): FootnoteResolver | undefined;
|
|
385
487
|
updateFields(options?: UpdateFieldsOptions): UpdateFieldsResult;
|
|
386
488
|
updateTableOfContents(options?: TocRefreshOptions): TocRefreshResult;
|
|
387
489
|
getSessionState(): EditorSessionState;
|
|
@@ -638,6 +740,12 @@ export function createDocumentRuntime(
|
|
|
638
740
|
// checks this flag) runs during construction.
|
|
639
741
|
let analyticsEmitScheduled = false;
|
|
640
742
|
|
|
743
|
+
// V6c — heading fingerprint for TOC auto-invalidation. Compared in notify();
|
|
744
|
+
// a mismatch schedules a microtask refresh of TOC fields.
|
|
745
|
+
let lastHeadingFingerprint: string = "";
|
|
746
|
+
let tocAutoRefreshScheduled = false;
|
|
747
|
+
let pendingTocTrigger: TocRefreshTrigger | null = null;
|
|
748
|
+
|
|
641
749
|
// Schema 1.2 — editor-state channel (policy, resolver/persister, debounce queues).
|
|
642
750
|
// Instantiated once per runtime; forwarded to the public interface.
|
|
643
751
|
const editorStateChannel = createEditorStateChannel();
|
|
@@ -693,7 +801,21 @@ export function createDocumentRuntime(
|
|
|
693
801
|
canonicalDocument: options.initialCanonicalDocument,
|
|
694
802
|
fatalError: options.fatalError as never,
|
|
695
803
|
});
|
|
804
|
+
// I2 Tier B Slice 4b — internal clipboard buffer for cut/copy. System-
|
|
805
|
+
// clipboard write lands with Slice 5 drag; for now hosts read this via
|
|
806
|
+
// `getClipboardBuffer()` and feed it to `insertFragment`.
|
|
807
|
+
let clipboardBuffer: CanonicalDocumentFragment | null = null;
|
|
808
|
+
// R.3 ObjectGrabLayer — local-only grab state for inline / floating objects.
|
|
809
|
+
// Not broadcast through collab; each peer has their own (mirrors text selection).
|
|
810
|
+
let grabState: ObjectGrabState = createObjectGrabState();
|
|
811
|
+
// R.5.a action bracketing — depth counter. `startAction` increments,
|
|
812
|
+
// `endAction` decrements (clamped at 0). `isInAction` returns `depth > 0`.
|
|
813
|
+
// Phase 2 (follow-up) will use this to gate snapshot emission so nested
|
|
814
|
+
// commands observe a single boundary.
|
|
815
|
+
let actionDepth = 0;
|
|
816
|
+
const actionStack: string[] = [];
|
|
696
817
|
storySelections.set(storyTargetKey(MAIN_STORY_TARGET), state.selection);
|
|
818
|
+
lastHeadingFingerprint = computeHeadingFingerprint(state.document);
|
|
697
819
|
|
|
698
820
|
// Runtime-owned paginated layout engine (Phase 1+ of the layout facet work).
|
|
699
821
|
// The engine caches graph + resolved-formatting + fragment mapper keyed on
|
|
@@ -736,17 +858,18 @@ export function createDocumentRuntime(
|
|
|
736
858
|
canonicalDocument: () => state.document,
|
|
737
859
|
renderKernel: () => renderKernelRef,
|
|
738
860
|
getWorkflowRailInput: () => {
|
|
739
|
-
|
|
740
|
-
|
|
861
|
+
const normalizedWorkflowOverlay = getNormalizedWorkflowOverlay();
|
|
862
|
+
if (!normalizedWorkflowOverlay) return null;
|
|
863
|
+
const activeWorkItemId = normalizedWorkflowOverlay.activeWorkItemId ?? null;
|
|
741
864
|
const activeWorkItem =
|
|
742
865
|
activeWorkItemId !== null
|
|
743
|
-
?
|
|
866
|
+
? normalizedWorkflowOverlay.workItems?.find(
|
|
744
867
|
(item) => item.workItemId === activeWorkItemId,
|
|
745
868
|
)
|
|
746
869
|
: undefined;
|
|
747
870
|
return {
|
|
748
|
-
scopes:
|
|
749
|
-
candidates:
|
|
871
|
+
scopes: normalizedWorkflowOverlay.scopes,
|
|
872
|
+
candidates: normalizedWorkflowOverlay.candidates,
|
|
750
873
|
activeWorkItemScopeIds: activeWorkItem?.scopeIds ?? [],
|
|
751
874
|
activeStory,
|
|
752
875
|
};
|
|
@@ -933,6 +1056,13 @@ export function createDocumentRuntime(
|
|
|
933
1056
|
snapshot: WorkflowScopeSnapshot;
|
|
934
1057
|
}
|
|
935
1058
|
| undefined;
|
|
1059
|
+
let cachedNormalizedWorkflowOverlay:
|
|
1060
|
+
| {
|
|
1061
|
+
document: CanonicalDocumentEnvelope;
|
|
1062
|
+
workflowOverlay: WorkflowOverlay;
|
|
1063
|
+
normalized: WorkflowOverlay;
|
|
1064
|
+
}
|
|
1065
|
+
| undefined;
|
|
936
1066
|
let cachedWorkflowMarkupSnapshot:
|
|
937
1067
|
| {
|
|
938
1068
|
revisionToken: string;
|
|
@@ -1258,10 +1388,12 @@ export function createDocumentRuntime(
|
|
|
1258
1388
|
}
|
|
1259
1389
|
}
|
|
1260
1390
|
|
|
1261
|
-
|
|
1391
|
+
const normalizedWorkflowOverlay = getNormalizedWorkflowOverlay();
|
|
1392
|
+
if (normalizedWorkflowOverlay) {
|
|
1262
1393
|
const matchingScope = getMatchingWorkflowScope(selection);
|
|
1394
|
+
const activeScopes = getEffectiveWorkflowScopes(normalizedWorkflowOverlay);
|
|
1263
1395
|
|
|
1264
|
-
if (!matchingScope &&
|
|
1396
|
+
if (!matchingScope && activeScopes.length > 0) {
|
|
1265
1397
|
reasons.push({
|
|
1266
1398
|
code: "outside_workflow_scope",
|
|
1267
1399
|
message: "Selection is outside any active workflow scope.",
|
|
@@ -1557,20 +1689,114 @@ export function createDocumentRuntime(
|
|
|
1557
1689
|
return left.from < right.to && right.from < left.to;
|
|
1558
1690
|
}
|
|
1559
1691
|
|
|
1560
|
-
function
|
|
1692
|
+
function workflowAnchorsEqual(
|
|
1693
|
+
left: EditorAnchorProjection,
|
|
1694
|
+
right: EditorAnchorProjection,
|
|
1695
|
+
): boolean {
|
|
1696
|
+
if (left.kind !== right.kind) return false;
|
|
1697
|
+
switch (left.kind) {
|
|
1698
|
+
case "range":
|
|
1699
|
+
return (
|
|
1700
|
+
right.kind === "range" &&
|
|
1701
|
+
left.from === right.from &&
|
|
1702
|
+
left.to === right.to &&
|
|
1703
|
+
left.assoc.start === right.assoc.start &&
|
|
1704
|
+
left.assoc.end === right.assoc.end
|
|
1705
|
+
);
|
|
1706
|
+
case "node":
|
|
1707
|
+
return right.kind === "node" && left.at === right.at;
|
|
1708
|
+
case "detached":
|
|
1709
|
+
return (
|
|
1710
|
+
right.kind === "detached" &&
|
|
1711
|
+
left.reason === right.reason &&
|
|
1712
|
+
left.lastKnownRange.from === right.lastKnownRange.from &&
|
|
1713
|
+
left.lastKnownRange.to === right.lastKnownRange.to
|
|
1714
|
+
);
|
|
1715
|
+
default:
|
|
1716
|
+
return false;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
function normalizeWorkflowOverlayForDocument(
|
|
1721
|
+
document: CanonicalDocumentEnvelope,
|
|
1722
|
+
overlay: WorkflowOverlay,
|
|
1723
|
+
): WorkflowOverlay {
|
|
1724
|
+
if (
|
|
1725
|
+
cachedNormalizedWorkflowOverlay &&
|
|
1726
|
+
cachedNormalizedWorkflowOverlay.document === document &&
|
|
1727
|
+
cachedNormalizedWorkflowOverlay.workflowOverlay === overlay
|
|
1728
|
+
) {
|
|
1729
|
+
return cachedNormalizedWorkflowOverlay.normalized;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
const scopeIdCounts = new Map<string, number>();
|
|
1733
|
+
for (const scope of overlay.scopes) {
|
|
1734
|
+
scopeIdCounts.set(scope.scopeId, (scopeIdCounts.get(scope.scopeId) ?? 0) + 1);
|
|
1735
|
+
}
|
|
1736
|
+
const locations = collectScopeLocations(document);
|
|
1737
|
+
let changed = false;
|
|
1738
|
+
const normalizedScopes = overlay.scopes.map((scope) => {
|
|
1739
|
+
if ((scopeIdCounts.get(scope.scopeId) ?? 0) !== 1) {
|
|
1740
|
+
return scope;
|
|
1741
|
+
}
|
|
1742
|
+
const location = locations.get(scope.scopeId);
|
|
1743
|
+
if (
|
|
1744
|
+
!location ||
|
|
1745
|
+
location.startPos === undefined ||
|
|
1746
|
+
location.endPos === undefined
|
|
1747
|
+
) {
|
|
1748
|
+
return scope;
|
|
1749
|
+
}
|
|
1750
|
+
const nextAnchor: EditorAnchorProjection = {
|
|
1751
|
+
kind: "range",
|
|
1752
|
+
from: Math.min(location.startPos, location.endPos),
|
|
1753
|
+
to: Math.max(location.startPos, location.endPos),
|
|
1754
|
+
assoc: { start: -1, end: 1 },
|
|
1755
|
+
};
|
|
1756
|
+
if (workflowAnchorsEqual(scope.anchor, nextAnchor)) {
|
|
1757
|
+
return scope;
|
|
1758
|
+
}
|
|
1759
|
+
changed = true;
|
|
1760
|
+
return {
|
|
1761
|
+
...scope,
|
|
1762
|
+
anchor: nextAnchor,
|
|
1763
|
+
};
|
|
1764
|
+
});
|
|
1765
|
+
|
|
1766
|
+
const normalized = changed
|
|
1767
|
+
? {
|
|
1768
|
+
...overlay,
|
|
1769
|
+
scopes: normalizedScopes,
|
|
1770
|
+
}
|
|
1771
|
+
: overlay;
|
|
1772
|
+
cachedNormalizedWorkflowOverlay = {
|
|
1773
|
+
document,
|
|
1774
|
+
workflowOverlay: overlay,
|
|
1775
|
+
normalized,
|
|
1776
|
+
};
|
|
1777
|
+
return normalized;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
function getNormalizedWorkflowOverlay(): WorkflowOverlay | null {
|
|
1561
1781
|
if (!workflowOverlay) return null;
|
|
1782
|
+
return normalizeWorkflowOverlayForDocument(state.document, workflowOverlay);
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
function deriveWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null {
|
|
1786
|
+
const normalizedWorkflowOverlay = getNormalizedWorkflowOverlay();
|
|
1787
|
+
if (!normalizedWorkflowOverlay) return null;
|
|
1562
1788
|
const blockedReasons = getCachedInteractionGuardSnapshot().blockedReasons;
|
|
1563
|
-
const activeItem =
|
|
1564
|
-
?
|
|
1565
|
-
(item) => item.workItemId ===
|
|
1789
|
+
const activeItem = normalizedWorkflowOverlay.activeWorkItemId
|
|
1790
|
+
? normalizedWorkflowOverlay.workItems?.find(
|
|
1791
|
+
(item) => item.workItemId === normalizedWorkflowOverlay.activeWorkItemId,
|
|
1566
1792
|
)
|
|
1567
1793
|
: undefined;
|
|
1568
1794
|
return {
|
|
1569
1795
|
overlayPresent: true,
|
|
1570
|
-
activeWorkItemId:
|
|
1796
|
+
activeWorkItemId: normalizedWorkflowOverlay.activeWorkItemId ?? null,
|
|
1571
1797
|
activeWorkItem: activeItem,
|
|
1572
|
-
scopes:
|
|
1573
|
-
candidates:
|
|
1798
|
+
scopes: normalizedWorkflowOverlay.scopes,
|
|
1799
|
+
candidates: normalizedWorkflowOverlay.candidates ?? [],
|
|
1574
1800
|
blockedReasons,
|
|
1575
1801
|
};
|
|
1576
1802
|
}
|
|
@@ -1590,15 +1816,16 @@ export function createDocumentRuntime(
|
|
|
1590
1816
|
}
|
|
1591
1817
|
|
|
1592
1818
|
function getEffectiveWorkflowScopes(overlay: WorkflowOverlay): WorkflowOverlay["scopes"] {
|
|
1593
|
-
const
|
|
1819
|
+
const normalizedOverlay = normalizeWorkflowOverlayForDocument(state.document, overlay);
|
|
1820
|
+
const activeWorkItemId = normalizedOverlay.activeWorkItemId ?? null;
|
|
1594
1821
|
const activeWorkItemScopeIds =
|
|
1595
1822
|
activeWorkItemId === null
|
|
1596
1823
|
? null
|
|
1597
1824
|
: new Set(
|
|
1598
|
-
|
|
1825
|
+
normalizedOverlay.workItems?.find((item) => item.workItemId === activeWorkItemId)?.scopeIds ?? [],
|
|
1599
1826
|
);
|
|
1600
1827
|
|
|
1601
|
-
return
|
|
1828
|
+
return normalizedOverlay.scopes.filter((scope) => {
|
|
1602
1829
|
const scopeStoryTarget = scope.storyTarget ?? MAIN_STORY_TARGET;
|
|
1603
1830
|
if (!storyTargetsEqual(scopeStoryTarget, activeStory)) {
|
|
1604
1831
|
return false;
|
|
@@ -1844,12 +2071,13 @@ export function createDocumentRuntime(
|
|
|
1844
2071
|
const tCompat = performance.now();
|
|
1845
2072
|
const compat = toPublicCompatibilityReport(createDerivedCompatibility(state));
|
|
1846
2073
|
perfCounters.increment("ctxa.compat.us", Math.round((performance.now() - tCompat) * 1000));
|
|
2074
|
+
const normalizedWorkflowOverlay = getNormalizedWorkflowOverlay();
|
|
1847
2075
|
|
|
1848
2076
|
const tBuild = performance.now();
|
|
1849
2077
|
const snapshot = createRuntimeContextAnalyticsSnapshot({
|
|
1850
2078
|
query,
|
|
1851
2079
|
renderSnapshot: cachedRenderSnapshot,
|
|
1852
|
-
workflowOverlay,
|
|
2080
|
+
workflowOverlay: normalizedWorkflowOverlay,
|
|
1853
2081
|
workflowScopeSnapshot: wfScope,
|
|
1854
2082
|
interactionGuardSnapshot: wfGuard,
|
|
1855
2083
|
workflowMarkupSnapshot: wfMarkup,
|
|
@@ -2022,6 +2250,47 @@ export function createDocumentRuntime(
|
|
|
2022
2250
|
}
|
|
2023
2251
|
}
|
|
2024
2252
|
|
|
2253
|
+
function invalidateDerivedRuntimeCaches(): void {
|
|
2254
|
+
cachedSurface = undefined;
|
|
2255
|
+
cachedCompatibility = undefined;
|
|
2256
|
+
cachedComments = undefined;
|
|
2257
|
+
cachedTrackedChanges = undefined;
|
|
2258
|
+
cachedSuggestions = undefined;
|
|
2259
|
+
cachedReviewWork = undefined;
|
|
2260
|
+
cachedPageLayout = undefined;
|
|
2261
|
+
cachedNavigation = undefined;
|
|
2262
|
+
cachedViewStateSnapshot = undefined;
|
|
2263
|
+
cachedInteractionGuardSnapshot = undefined;
|
|
2264
|
+
cachedWorkflowScopeSnapshot = undefined;
|
|
2265
|
+
cachedNormalizedWorkflowOverlay = undefined;
|
|
2266
|
+
cachedWorkflowMarkupSnapshot = undefined;
|
|
2267
|
+
cachedContextAnalyticsSnapshots.clear();
|
|
2268
|
+
lastEmittedContextAnalyticsSnapshots = undefined;
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
function hydrateCanonicalDocumentInternally(
|
|
2272
|
+
document: CanonicalDocumentEnvelope,
|
|
2273
|
+
): boolean {
|
|
2274
|
+
if (document === state.document) {
|
|
2275
|
+
return false;
|
|
2276
|
+
}
|
|
2277
|
+
const previousDocument = state.document;
|
|
2278
|
+
state = {
|
|
2279
|
+
...state,
|
|
2280
|
+
document,
|
|
2281
|
+
};
|
|
2282
|
+
if (previousDocument.subParts !== document.subParts) {
|
|
2283
|
+
fontLoader.refresh(collectFontLoaderInput(document));
|
|
2284
|
+
layoutEngine.invalidateMeasurementCache();
|
|
2285
|
+
}
|
|
2286
|
+
invalidateDerivedRuntimeCaches();
|
|
2287
|
+
cachedRenderSnapshot = refreshRenderSnapshot();
|
|
2288
|
+
for (const listener of listeners) {
|
|
2289
|
+
listener();
|
|
2290
|
+
}
|
|
2291
|
+
return true;
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2025
2294
|
function getCachedViewStateSnapshot(): EditorViewStateSnapshot {
|
|
2026
2295
|
const activeStoryKey = storyTargetKey(activeStory);
|
|
2027
2296
|
const pageLayout = cachedRenderSnapshot.pageLayout;
|
|
@@ -2131,7 +2400,11 @@ export function createDocumentRuntime(
|
|
|
2131
2400
|
const r5ScratchReplayState: typeof state = { ...state };
|
|
2132
2401
|
const r5ScratchReplaySnapshot: typeof cachedRenderSnapshot = { ...cachedRenderSnapshot };
|
|
2133
2402
|
|
|
2134
|
-
|
|
2403
|
+
const runtime: DocumentRuntime & {
|
|
2404
|
+
hydrateCanonicalDocumentInternally(
|
|
2405
|
+
document: CanonicalDocumentEnvelope,
|
|
2406
|
+
): boolean;
|
|
2407
|
+
} = {
|
|
2135
2408
|
subscribe(listener) {
|
|
2136
2409
|
listeners.add(listener);
|
|
2137
2410
|
return () => {
|
|
@@ -2153,6 +2426,12 @@ export function createDocumentRuntime(
|
|
|
2153
2426
|
getSourcePackage() {
|
|
2154
2427
|
return state.sourcePackage;
|
|
2155
2428
|
},
|
|
2429
|
+
getFontTable() {
|
|
2430
|
+
return state.document.fontTable;
|
|
2431
|
+
},
|
|
2432
|
+
getFontEntry(name: string) {
|
|
2433
|
+
return state.document.fontTable?.fonts[name];
|
|
2434
|
+
},
|
|
2156
2435
|
emitBlockedCommand(command, reasons) {
|
|
2157
2436
|
emit({
|
|
2158
2437
|
type: "command_blocked",
|
|
@@ -2480,6 +2759,11 @@ export function createDocumentRuntime(
|
|
|
2480
2759
|
insertFragment(fragment, target) {
|
|
2481
2760
|
// I2 Tier B Slice 1 — dispatch `fragment.insert` against the active story. The
|
|
2482
2761
|
// runtime command handler routes into `applyFragmentInsert` (structure-ops).
|
|
2762
|
+
// v5 B1: auto-bracketed via R.5.a so a host that wants single-undo paste
|
|
2763
|
+
// gets one action. Idempotent on nested brackets: if a caller already
|
|
2764
|
+
// opened `startAction`, this bracket just increments / decrements depth.
|
|
2765
|
+
actionDepth += 1;
|
|
2766
|
+
actionStack.push("insertFragment");
|
|
2483
2767
|
try {
|
|
2484
2768
|
const timestamp = clock();
|
|
2485
2769
|
applyTextCommandInActiveStory(
|
|
@@ -2495,7 +2779,143 @@ export function createDocumentRuntime(
|
|
|
2495
2779
|
);
|
|
2496
2780
|
} catch (error) {
|
|
2497
2781
|
emitError(toRuntimeError(error));
|
|
2782
|
+
} finally {
|
|
2783
|
+
actionDepth -= 1;
|
|
2784
|
+
actionStack.pop();
|
|
2785
|
+
}
|
|
2786
|
+
},
|
|
2787
|
+
copy(target) {
|
|
2788
|
+
// I2 Tier B Slice 4b — serialize the selection range to a fragment, store
|
|
2789
|
+
// it in the internal buffer. Does NOT mutate the document.
|
|
2790
|
+
// v5 A1 fix: story-aware extraction — footnote / header / endnote
|
|
2791
|
+
// selections read the right content tree, not main body.
|
|
2792
|
+
try {
|
|
2793
|
+
const selection = target ? createSelectionFromPublicAnchor(target) : state.selection;
|
|
2794
|
+
const fragment = extractSelectionFragment(state.document, selection, activeStory);
|
|
2795
|
+
clipboardBuffer = fragment;
|
|
2796
|
+
} catch (error) {
|
|
2797
|
+
emitError(toRuntimeError(error));
|
|
2798
|
+
}
|
|
2799
|
+
},
|
|
2800
|
+
cut(target) {
|
|
2801
|
+
// I2 Tier B Slice 4b — copy into buffer, then delete the range by
|
|
2802
|
+
// replacing it with empty text. (Empty fragment.insert is a deliberate
|
|
2803
|
+
// no-op in the splicer per Slice 1, so we use the text.insert path with
|
|
2804
|
+
// an empty string to delete the selected content.)
|
|
2805
|
+
// v5 B1: auto-bracketed so a host that wants single-undo paste-at-drop
|
|
2806
|
+
// gets one action even if its own bracket isn't open.
|
|
2807
|
+
try {
|
|
2808
|
+
const selection = target ? createSelectionFromPublicAnchor(target) : state.selection;
|
|
2809
|
+
const fragment = extractSelectionFragment(state.document, selection, activeStory);
|
|
2810
|
+
clipboardBuffer = fragment;
|
|
2811
|
+
if (selection.anchor !== selection.head) {
|
|
2812
|
+
actionDepth += 1;
|
|
2813
|
+
actionStack.push("cut");
|
|
2814
|
+
try {
|
|
2815
|
+
const timestamp = clock();
|
|
2816
|
+
applyTextCommandInActiveStory(
|
|
2817
|
+
{
|
|
2818
|
+
type: "text.insert",
|
|
2819
|
+
text: "",
|
|
2820
|
+
origin: createOrigin("api", timestamp),
|
|
2821
|
+
},
|
|
2822
|
+
{
|
|
2823
|
+
selection,
|
|
2824
|
+
blockedCommandName: "cut",
|
|
2825
|
+
},
|
|
2826
|
+
);
|
|
2827
|
+
} finally {
|
|
2828
|
+
actionDepth -= 1;
|
|
2829
|
+
actionStack.pop();
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
} catch (error) {
|
|
2833
|
+
emitError(toRuntimeError(error));
|
|
2834
|
+
}
|
|
2835
|
+
},
|
|
2836
|
+
getClipboardBuffer() {
|
|
2837
|
+
return clipboardBuffer;
|
|
2838
|
+
},
|
|
2839
|
+
getClipboardWireFormats() {
|
|
2840
|
+
// v5 close-out — serialize the buffer to WordML + HTML + plain text for
|
|
2841
|
+
// host-owned `navigator.clipboard.write`. Returns null if nothing has
|
|
2842
|
+
// been cut/copied yet. Zero allocation on the null path.
|
|
2843
|
+
if (!clipboardBuffer || clipboardBuffer.blocks.length === 0) return null;
|
|
2844
|
+
const wordml = serializeFragmentToWordML(clipboardBuffer);
|
|
2845
|
+
// Minimal HTML: walk paragraph children emitting <p> wrappers with
|
|
2846
|
+
// text content. Keeps parity with our own parser (round-trips
|
|
2847
|
+
// cleanly) without implementing full CSS export.
|
|
2848
|
+
const htmlParts: string[] = [];
|
|
2849
|
+
const plainParts: string[] = [];
|
|
2850
|
+
for (const block of clipboardBuffer.blocks) {
|
|
2851
|
+
if (block.type !== "paragraph") continue;
|
|
2852
|
+
const runs: string[] = [];
|
|
2853
|
+
const plainRuns: string[] = [];
|
|
2854
|
+
for (const child of block.children) {
|
|
2855
|
+
if (child.type === "text") {
|
|
2856
|
+
const text = (child.text ?? "")
|
|
2857
|
+
.replace(/&/g, "&")
|
|
2858
|
+
.replace(/</g, "<")
|
|
2859
|
+
.replace(/>/g, ">");
|
|
2860
|
+
let wrapped = text;
|
|
2861
|
+
const marks = child.marks ?? [];
|
|
2862
|
+
if (marks.some((m) => m.type === "bold")) wrapped = `<b>${wrapped}</b>`;
|
|
2863
|
+
if (marks.some((m) => m.type === "italic")) wrapped = `<i>${wrapped}</i>`;
|
|
2864
|
+
if (marks.some((m) => m.type === "underline")) wrapped = `<u>${wrapped}</u>`;
|
|
2865
|
+
if (marks.some((m) => m.type === "strikethrough")) wrapped = `<s>${wrapped}</s>`;
|
|
2866
|
+
runs.push(wrapped);
|
|
2867
|
+
plainRuns.push(child.text ?? "");
|
|
2868
|
+
} else if (child.type === "hard_break") {
|
|
2869
|
+
runs.push("<br/>");
|
|
2870
|
+
plainRuns.push("\n");
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
htmlParts.push(`<p>${runs.join("")}</p>`);
|
|
2874
|
+
plainParts.push(plainRuns.join(""));
|
|
2498
2875
|
}
|
|
2876
|
+
return {
|
|
2877
|
+
wordml,
|
|
2878
|
+
html: htmlParts.join(""),
|
|
2879
|
+
plainText: plainParts.join("\n"),
|
|
2880
|
+
};
|
|
2881
|
+
},
|
|
2882
|
+
selectObject(objectId) {
|
|
2883
|
+
// R.3 — local grab state mutation. No command dispatch; this is pure UI
|
|
2884
|
+
// state that chrome (Lane 6 P11) reads to paint handles.
|
|
2885
|
+
//
|
|
2886
|
+
// v5 A3: when the grab state actually changes, notify subscribers so
|
|
2887
|
+
// chrome can re-render. We do NOT bump `revisionToken` — grab state is
|
|
2888
|
+
// local UI state, not a document mutation, so collab/autosave/undo
|
|
2889
|
+
// should not observe a change.
|
|
2890
|
+
const next = grabSelectObject(grabState, objectId);
|
|
2891
|
+
if (next === grabState) return;
|
|
2892
|
+
grabState = next;
|
|
2893
|
+
for (const listener of listeners) {
|
|
2894
|
+
listener();
|
|
2895
|
+
}
|
|
2896
|
+
},
|
|
2897
|
+
deselectObject() {
|
|
2898
|
+
const next = grabDeselectObject(grabState);
|
|
2899
|
+
if (next === grabState) return;
|
|
2900
|
+
grabState = next;
|
|
2901
|
+
for (const listener of listeners) {
|
|
2902
|
+
listener();
|
|
2903
|
+
}
|
|
2904
|
+
},
|
|
2905
|
+
getGrabbedObject() {
|
|
2906
|
+
return grabGetGrabbedObject(grabState);
|
|
2907
|
+
},
|
|
2908
|
+
startAction(name) {
|
|
2909
|
+
actionDepth += 1;
|
|
2910
|
+
actionStack.push(name);
|
|
2911
|
+
},
|
|
2912
|
+
endAction() {
|
|
2913
|
+
if (actionDepth === 0) return; // unbalanced — ignore
|
|
2914
|
+
actionDepth -= 1;
|
|
2915
|
+
actionStack.pop();
|
|
2916
|
+
},
|
|
2917
|
+
isInAction() {
|
|
2918
|
+
return actionDepth > 0;
|
|
2499
2919
|
},
|
|
2500
2920
|
applyActiveStoryTextCommand(command) {
|
|
2501
2921
|
try {
|
|
@@ -2553,15 +2973,14 @@ export function createDocumentRuntime(
|
|
|
2553
2973
|
anchor,
|
|
2554
2974
|
);
|
|
2555
2975
|
if (rejectionReason !== null) {
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
? "DOCX comments cannot currently anchor mid-run within a paragraph adjacent to a table boundary — snap the range to a paragraph or word boundary and retry."
|
|
2559
|
-
: "DOCX comments must use a non-empty range that stays within a single story and does not cross table or opaque-fragment boundaries.";
|
|
2976
|
+
// Post-O8: only `invalid_comment_anchor` remains as a rejection
|
|
2977
|
+
// reason (empty range / crosses opaque block / out-of-story).
|
|
2560
2978
|
const error: InternalEditorError = {
|
|
2561
2979
|
errorId: createSessionId("comment-anchor", clock()),
|
|
2562
2980
|
code: "validation_failed",
|
|
2563
2981
|
isFatal: false,
|
|
2564
|
-
message
|
|
2982
|
+
message:
|
|
2983
|
+
"DOCX comments must use a non-empty range that stays within a single story and does not cross table or opaque-fragment boundaries.",
|
|
2565
2984
|
source: "runtime",
|
|
2566
2985
|
details: {
|
|
2567
2986
|
reason: rejectionReason,
|
|
@@ -2681,7 +3100,7 @@ export function createDocumentRuntime(
|
|
|
2681
3100
|
});
|
|
2682
3101
|
}
|
|
2683
3102
|
|
|
2684
|
-
const resolved = resolveScope(
|
|
3103
|
+
const resolved = resolveScope(nextDocument, scopeId);
|
|
2685
3104
|
const publicAnchor: EditorAnchorProjection =
|
|
2686
3105
|
resolved && resolved.kind === "range"
|
|
2687
3106
|
? resolved
|
|
@@ -2741,21 +3160,19 @@ export function createDocumentRuntime(
|
|
|
2741
3160
|
};
|
|
2742
3161
|
},
|
|
2743
3162
|
getScope(scopeId) {
|
|
3163
|
+
const normalizedScope =
|
|
3164
|
+
getNormalizedWorkflowOverlay()?.scopes.find((scope) => scope.scopeId === scopeId) ??
|
|
3165
|
+
null;
|
|
3166
|
+
if (normalizedScope) {
|
|
3167
|
+
return normalizedScope;
|
|
3168
|
+
}
|
|
2744
3169
|
const resolved = resolveScope(state.document, scopeId);
|
|
2745
3170
|
if (!resolved) {
|
|
2746
|
-
|
|
2747
|
-
return stored ?? null;
|
|
2748
|
-
}
|
|
2749
|
-
const stored = workflowOverlay?.scopes.find((s) => s.scopeId === scopeId);
|
|
2750
|
-
if (!stored) {
|
|
2751
|
-
return {
|
|
2752
|
-
scopeId,
|
|
2753
|
-
mode: "comment",
|
|
2754
|
-
anchor: resolved,
|
|
2755
|
-
};
|
|
3171
|
+
return null;
|
|
2756
3172
|
}
|
|
2757
3173
|
return {
|
|
2758
|
-
|
|
3174
|
+
scopeId,
|
|
3175
|
+
mode: "comment",
|
|
2759
3176
|
anchor: resolved,
|
|
2760
3177
|
};
|
|
2761
3178
|
},
|
|
@@ -2907,6 +3324,33 @@ export function createDocumentRuntime(
|
|
|
2907
3324
|
getDocumentNavigationSnapshot() {
|
|
2908
3325
|
return getCachedDocumentNavigationSnapshot(state, activeStory);
|
|
2909
3326
|
},
|
|
3327
|
+
getFieldResolver(): FieldResolver {
|
|
3328
|
+
const pageGraph = layoutEngine.getPageGraph({
|
|
3329
|
+
document: state.document,
|
|
3330
|
+
viewState: {
|
|
3331
|
+
activeStory,
|
|
3332
|
+
workspaceMode: viewState.workspaceMode,
|
|
3333
|
+
zoomLevel: viewState.zoomLevel,
|
|
3334
|
+
},
|
|
3335
|
+
});
|
|
3336
|
+
const navigation = getCachedDocumentNavigationSnapshot(state, activeStory);
|
|
3337
|
+
const bookmarkMap = buildBookmarkNameMap(state.document);
|
|
3338
|
+
const paragraphContexts = collectParagraphContexts(state.document.content.children);
|
|
3339
|
+
const paragraphOffsets = paragraphContexts.map((p) => p.startOffset);
|
|
3340
|
+
return createFieldResolver({
|
|
3341
|
+
pageGraph,
|
|
3342
|
+
activePageIndex: navigation.activePageIndex,
|
|
3343
|
+
bookmarkMap,
|
|
3344
|
+
paragraphOffsets,
|
|
3345
|
+
styles: state.document.styles,
|
|
3346
|
+
contentRoot: state.document.content as unknown as import("./field-resolver.ts").DocumentContainerNode,
|
|
3347
|
+
});
|
|
3348
|
+
},
|
|
3349
|
+
getFootnoteResolver(): FootnoteResolver | undefined {
|
|
3350
|
+
const collection = state.document.subParts?.footnoteCollection;
|
|
3351
|
+
if (!collection) return undefined;
|
|
3352
|
+
return createFootnoteResolver(collection, collectSectionPropertiesInOrder(state.document));
|
|
3353
|
+
},
|
|
2910
3354
|
layout: layoutFacet,
|
|
2911
3355
|
getCurrentLocation() {
|
|
2912
3356
|
const navigation = getCachedDocumentNavigationSnapshot(state, activeStory);
|
|
@@ -3090,6 +3534,7 @@ export function createDocumentRuntime(
|
|
|
3090
3534
|
},
|
|
3091
3535
|
getSessionState() {
|
|
3092
3536
|
const compatibility = createDerivedCompatibility(state);
|
|
3537
|
+
const normalizedWorkflowOverlay = getNormalizedWorkflowOverlay();
|
|
3093
3538
|
return editorSessionStateFromPersistedSnapshot(
|
|
3094
3539
|
{
|
|
3095
3540
|
...(createPersistedEditorSnapshot(state, {
|
|
@@ -3098,7 +3543,7 @@ export function createDocumentRuntime(
|
|
|
3098
3543
|
compatibility,
|
|
3099
3544
|
protectionSnapshot,
|
|
3100
3545
|
}) as unknown as PersistedEditorSnapshot),
|
|
3101
|
-
workflowOverlay:
|
|
3546
|
+
workflowOverlay: normalizedWorkflowOverlay ?? undefined,
|
|
3102
3547
|
workflowMetadata: deriveWorkflowMetadataSnapshot(),
|
|
3103
3548
|
},
|
|
3104
3549
|
);
|
|
@@ -3144,7 +3589,7 @@ export function createDocumentRuntime(
|
|
|
3144
3589
|
return { schemaVersion: "host-annotation-overlay/1", data: snap };
|
|
3145
3590
|
}
|
|
3146
3591
|
case "workflowOverlay": {
|
|
3147
|
-
const ov =
|
|
3592
|
+
const ov = getNormalizedWorkflowOverlay();
|
|
3148
3593
|
if (!ov) return null;
|
|
3149
3594
|
return { schemaVersion: "workflow-overlay/1", data: ov };
|
|
3150
3595
|
}
|
|
@@ -3179,10 +3624,11 @@ export function createDocumentRuntime(
|
|
|
3179
3624
|
overlay,
|
|
3180
3625
|
origin: createOrigin("api", clock()),
|
|
3181
3626
|
});
|
|
3627
|
+
const normalizedWorkflowOverlay = getNormalizedWorkflowOverlay();
|
|
3182
3628
|
editorStateChannel.recordMutation("workflowOverlay", {
|
|
3183
3629
|
namespace: "workflowOverlay",
|
|
3184
3630
|
schemaVersion: "workflow-overlay/1",
|
|
3185
|
-
data: overlay,
|
|
3631
|
+
data: normalizedWorkflowOverlay ?? overlay,
|
|
3186
3632
|
});
|
|
3187
3633
|
},
|
|
3188
3634
|
clearWorkflowOverlay() {
|
|
@@ -3192,7 +3638,7 @@ export function createDocumentRuntime(
|
|
|
3192
3638
|
});
|
|
3193
3639
|
},
|
|
3194
3640
|
getWorkflowOverlay() {
|
|
3195
|
-
return
|
|
3641
|
+
return getNormalizedWorkflowOverlay();
|
|
3196
3642
|
},
|
|
3197
3643
|
setSharedWorkflowState(state) {
|
|
3198
3644
|
if (state === sharedWorkflowState) return;
|
|
@@ -3341,6 +3787,9 @@ export function createDocumentRuntime(
|
|
|
3341
3787
|
get editorStateChannel() {
|
|
3342
3788
|
return editorStateChannel;
|
|
3343
3789
|
},
|
|
3790
|
+
hydrateCanonicalDocumentInternally(document: CanonicalDocumentEnvelope) {
|
|
3791
|
+
return hydrateCanonicalDocumentInternally(document);
|
|
3792
|
+
},
|
|
3344
3793
|
getPerfCountersSnapshot() {
|
|
3345
3794
|
return perfCounters.snapshot();
|
|
3346
3795
|
},
|
|
@@ -3364,6 +3813,8 @@ export function createDocumentRuntime(
|
|
|
3364
3813
|
},
|
|
3365
3814
|
};
|
|
3366
3815
|
|
|
3816
|
+
return runtime;
|
|
3817
|
+
|
|
3367
3818
|
function applyHistory(direction: "undo" | "redo"): void {
|
|
3368
3819
|
const source = direction === "undo" ? history.past : history.future;
|
|
3369
3820
|
const target = source.pop();
|
|
@@ -3486,6 +3937,18 @@ export function createDocumentRuntime(
|
|
|
3486
3937
|
next: EditorState,
|
|
3487
3938
|
transaction: EditorTransaction,
|
|
3488
3939
|
): void {
|
|
3940
|
+
// V6c — heading-fingerprint comparison schedules an automatic TOC rebuild
|
|
3941
|
+
// when paragraph styleIds or heading text drift. Short-circuit on
|
|
3942
|
+
// document-identity equality (selection-only commits) to skip the walk.
|
|
3943
|
+
if (previous.document !== next.document) {
|
|
3944
|
+
const nextFingerprint = computeHeadingFingerprint(next.document);
|
|
3945
|
+
if (nextFingerprint !== lastHeadingFingerprint) {
|
|
3946
|
+
const trigger = deriveTocTrigger(lastHeadingFingerprint, nextFingerprint);
|
|
3947
|
+
lastHeadingFingerprint = nextFingerprint;
|
|
3948
|
+
scheduleTocAutoRefresh(trigger);
|
|
3949
|
+
}
|
|
3950
|
+
}
|
|
3951
|
+
|
|
3489
3952
|
const emittedSuggestionIds = new Set<string>();
|
|
3490
3953
|
if (previous.isDirty !== next.isDirty) {
|
|
3491
3954
|
emit({
|
|
@@ -3982,6 +4445,80 @@ export function createDocumentRuntime(
|
|
|
3982
4445
|
});
|
|
3983
4446
|
}
|
|
3984
4447
|
|
|
4448
|
+
// V6c — TOC auto-refresh scheduler. Mirrors scheduleContextAnalyticsEmit's
|
|
4449
|
+
// microtask-coalesce shape. Bursts of heading edits within one synchronous
|
|
4450
|
+
// call stack collapse to a single rebuild + a single toc_auto_refreshed
|
|
4451
|
+
// event. Trigger flags accumulate across coalesced edits.
|
|
4452
|
+
function scheduleTocAutoRefresh(trigger: TocRefreshTrigger): void {
|
|
4453
|
+
if (pendingTocTrigger) {
|
|
4454
|
+
pendingTocTrigger = {
|
|
4455
|
+
headingContentChanged:
|
|
4456
|
+
pendingTocTrigger.headingContentChanged || trigger.headingContentChanged,
|
|
4457
|
+
headingStructureChanged:
|
|
4458
|
+
pendingTocTrigger.headingStructureChanged || trigger.headingStructureChanged,
|
|
4459
|
+
};
|
|
4460
|
+
} else {
|
|
4461
|
+
pendingTocTrigger = trigger;
|
|
4462
|
+
}
|
|
4463
|
+
if (tocAutoRefreshScheduled) {
|
|
4464
|
+
perfCounters.increment("toc.autoRefresh.coalesced");
|
|
4465
|
+
return;
|
|
4466
|
+
}
|
|
4467
|
+
tocAutoRefreshScheduled = true;
|
|
4468
|
+
queueMicrotask(() => {
|
|
4469
|
+
tocAutoRefreshScheduled = false;
|
|
4470
|
+
const flushedTrigger = pendingTocTrigger;
|
|
4471
|
+
pendingTocTrigger = null;
|
|
4472
|
+
if (!flushedTrigger) return;
|
|
4473
|
+
const t = performance.now();
|
|
4474
|
+
const refreshed = refreshDocumentTableOfContents(
|
|
4475
|
+
state.document,
|
|
4476
|
+
state.selection.head,
|
|
4477
|
+
activeStory,
|
|
4478
|
+
undefined,
|
|
4479
|
+
(pageIndex: number) => layoutFacet.getDisplayPageNumber(pageIndex),
|
|
4480
|
+
);
|
|
4481
|
+
perfCounters.increment("toc.autoRefresh.us", Math.round((performance.now() - t) * 1000));
|
|
4482
|
+
if (!refreshed.changed) {
|
|
4483
|
+
perfCounters.increment("toc.autoRefresh.noopRebuild");
|
|
4484
|
+
return;
|
|
4485
|
+
}
|
|
4486
|
+
// Replay through executeEditorCommand so history, mapping, and the
|
|
4487
|
+
// downstream notify() stay consistent. The replay's notify() will
|
|
4488
|
+
// recompute the heading fingerprint and find no change (TOC field
|
|
4489
|
+
// text is not heading text), so this does not loop.
|
|
4490
|
+
const ctx = {
|
|
4491
|
+
timestamp: clock(),
|
|
4492
|
+
documentMode: getEffectiveDocumentMode(state.selection),
|
|
4493
|
+
defaultAuthorId: defaultAuthorId ?? undefined,
|
|
4494
|
+
renderSnapshot: cachedRenderSnapshot,
|
|
4495
|
+
} as const;
|
|
4496
|
+
try {
|
|
4497
|
+
const transaction = executeEditorCommand(
|
|
4498
|
+
state,
|
|
4499
|
+
{
|
|
4500
|
+
type: "document.replace",
|
|
4501
|
+
document: refreshed.document,
|
|
4502
|
+
mapping: createEmptyMapping(),
|
|
4503
|
+
protectionSelection: refreshed.protectionSelection,
|
|
4504
|
+
origin: createOrigin("api", clock()),
|
|
4505
|
+
},
|
|
4506
|
+
ctx,
|
|
4507
|
+
);
|
|
4508
|
+
commit(transaction);
|
|
4509
|
+
} catch (error) {
|
|
4510
|
+
emitError(toRuntimeError(error));
|
|
4511
|
+
return;
|
|
4512
|
+
}
|
|
4513
|
+
emit({
|
|
4514
|
+
type: "toc_auto_refreshed",
|
|
4515
|
+
documentId: state.documentId,
|
|
4516
|
+
entryCount: refreshed.result.entryCount,
|
|
4517
|
+
trigger: flushedTrigger,
|
|
4518
|
+
});
|
|
4519
|
+
});
|
|
4520
|
+
}
|
|
4521
|
+
|
|
3985
4522
|
function emit(event: DocumentRuntimeEvent): void {
|
|
3986
4523
|
perfCounters.increment(`emit.${event.type}.calls`);
|
|
3987
4524
|
const t0 = performance.now();
|
|
@@ -3998,6 +4535,7 @@ export function createDocumentRuntime(
|
|
|
3998
4535
|
switch (command.type) {
|
|
3999
4536
|
case "workflow.set-overlay": {
|
|
4000
4537
|
workflowOverlay = structuredClone(command.overlay);
|
|
4538
|
+
cachedNormalizedWorkflowOverlay = undefined;
|
|
4001
4539
|
cachedRenderSnapshot = refreshRenderSnapshot();
|
|
4002
4540
|
const snapshot = deriveWorkflowScopeSnapshot()!;
|
|
4003
4541
|
emit({
|
|
@@ -4016,6 +4554,7 @@ export function createDocumentRuntime(
|
|
|
4016
4554
|
}
|
|
4017
4555
|
case "workflow.clear-overlay": {
|
|
4018
4556
|
workflowOverlay = null;
|
|
4557
|
+
cachedNormalizedWorkflowOverlay = undefined;
|
|
4019
4558
|
cachedRenderSnapshot = refreshRenderSnapshot();
|
|
4020
4559
|
emit({
|
|
4021
4560
|
type: "workflow_active_work_item_changed",
|
|
@@ -4415,6 +4954,61 @@ function createSelectionFromPublicAnchor(
|
|
|
4415
4954
|
}
|
|
4416
4955
|
}
|
|
4417
4956
|
|
|
4957
|
+
/**
|
|
4958
|
+
* I2 Tier B Slice 4b — extract the selection range from a document as a
|
|
4959
|
+
* `CanonicalDocumentFragment`. The fragment preserves text + marks +
|
|
4960
|
+
* hard-breaks + paragraph-breaks + tabs that fall inside the range.
|
|
4961
|
+
*
|
|
4962
|
+
* Uses the linear story layer (`parseTextStory` + `logicalPositionToUnitIndex`
|
|
4963
|
+
* + `serializeTextStory`) so the result is a properly-structured block list
|
|
4964
|
+
* that `insertFragment` can splice back in. Collapsed ranges return an empty
|
|
4965
|
+
* fragment.
|
|
4966
|
+
*
|
|
4967
|
+
* Story-aware (v5 A1 fix): `activeStory` selects which content tree the
|
|
4968
|
+
* selection offsets apply to. `getStoryBlocks` is the same helper
|
|
4969
|
+
* `applyTextCommandInActiveStory` uses, so cut/copy in footnote / header /
|
|
4970
|
+
* endnote stories extract from the right content rather than from main body.
|
|
4971
|
+
*
|
|
4972
|
+
* Complex content (tables, opaque blocks) inside the range serializes per
|
|
4973
|
+
* the underlying story layer's semantics — for a richer table-aware
|
|
4974
|
+
* clipboard, callers should use `serializeFragmentToWordML` (Slice 4a) on
|
|
4975
|
+
* the result of this extraction.
|
|
4976
|
+
*/
|
|
4977
|
+
function extractSelectionFragment(
|
|
4978
|
+
document: CanonicalDocumentEnvelope,
|
|
4979
|
+
selection: import("../core/state/editor-state.ts").SelectionSnapshot,
|
|
4980
|
+
activeStory: EditorStoryTarget,
|
|
4981
|
+
): CanonicalDocumentFragment {
|
|
4982
|
+
const from = Math.min(selection.anchor, selection.head);
|
|
4983
|
+
const to = Math.max(selection.anchor, selection.head);
|
|
4984
|
+
if (from === to) {
|
|
4985
|
+
return { blocks: [] };
|
|
4986
|
+
}
|
|
4987
|
+
// Resolve the content node for the active story. For main body this is the
|
|
4988
|
+
// document root; for secondary stories (header / footer / footnote / endnote)
|
|
4989
|
+
// we wrap the story's blocks in a doc root so parseTextStory produces
|
|
4990
|
+
// offsets in the same frame as the selection.
|
|
4991
|
+
const storyBlocks = getStoryBlocks(document, activeStory);
|
|
4992
|
+
const storyContent =
|
|
4993
|
+
activeStory.kind === "main"
|
|
4994
|
+
? document.content
|
|
4995
|
+
: { type: "doc" as const, children: [...storyBlocks] };
|
|
4996
|
+
const story = parseTextStory(storyContent);
|
|
4997
|
+
const unitFrom = logicalPositionToUnitIndex(story.units, from, "before");
|
|
4998
|
+
const unitTo = logicalPositionToUnitIndex(story.units, to, "after");
|
|
4999
|
+
const slicedUnits = story.units.slice(unitFrom, unitTo);
|
|
5000
|
+
if (slicedUnits.length === 0) {
|
|
5001
|
+
return { blocks: [] };
|
|
5002
|
+
}
|
|
5003
|
+
const slicedStory = {
|
|
5004
|
+
firstParagraph: story.firstParagraph,
|
|
5005
|
+
units: slicedUnits,
|
|
5006
|
+
size: 0,
|
|
5007
|
+
};
|
|
5008
|
+
const root = serializeTextStory(slicedStory);
|
|
5009
|
+
return { blocks: root.children };
|
|
5010
|
+
}
|
|
5011
|
+
|
|
4418
5012
|
/**
|
|
4419
5013
|
* Collect the stable ids of comment threads whose entry differs
|
|
4420
5014
|
* (present in one side but not the other, OR present in both but
|
|
@@ -4960,6 +5554,31 @@ function extractFieldDisplayText(field: FieldNode): string {
|
|
|
4960
5554
|
return flattenInlineDisplayText(field.children);
|
|
4961
5555
|
}
|
|
4962
5556
|
|
|
5557
|
+
// V6c — heading fingerprint over (styleId, visible text) pairs in document
|
|
5558
|
+
// order. Walks top-level paragraphs only; matches what
|
|
5559
|
+
// buildHeadingOutline()/createDocumentNavigationSnapshot read.
|
|
5560
|
+
function computeHeadingFingerprint(
|
|
5561
|
+
document: CanonicalDocumentEnvelope,
|
|
5562
|
+
): string {
|
|
5563
|
+
const parts: string[] = [];
|
|
5564
|
+
for (const block of document.content.children) {
|
|
5565
|
+
if (block.type !== "paragraph") continue;
|
|
5566
|
+
const styleId = block.styleId ?? "";
|
|
5567
|
+
if (!styleId.toLowerCase().startsWith("heading")) continue;
|
|
5568
|
+
parts.push(`${styleId}\u0001${flattenInlineDisplayText(block.children)}`);
|
|
5569
|
+
}
|
|
5570
|
+
return parts.join("\u0002");
|
|
5571
|
+
}
|
|
5572
|
+
|
|
5573
|
+
function deriveTocTrigger(prev: string, next: string): TocRefreshTrigger {
|
|
5574
|
+
const prevCount = prev === "" ? 0 : prev.split("\u0002").length;
|
|
5575
|
+
const nextCount = next === "" ? 0 : next.split("\u0002").length;
|
|
5576
|
+
return {
|
|
5577
|
+
headingStructureChanged: prevCount !== nextCount,
|
|
5578
|
+
headingContentChanged: prevCount === nextCount,
|
|
5579
|
+
};
|
|
5580
|
+
}
|
|
5581
|
+
|
|
4963
5582
|
function flattenInlineDisplayText(children: readonly InlineNode[]): string {
|
|
4964
5583
|
return children
|
|
4965
5584
|
.map((child) => {
|
|
@@ -5368,6 +5987,17 @@ function refreshBlocksWithCursor(
|
|
|
5368
5987
|
return { blocks: nextBlocks, cursor, previousParagraph };
|
|
5369
5988
|
}
|
|
5370
5989
|
|
|
5990
|
+
/**
|
|
5991
|
+
* Get a ThemeColorResolver for the given document, or undefined if the
|
|
5992
|
+
* document has no theme part. Use this rather than reaching into subParts.canonicalTheme.
|
|
5993
|
+
*/
|
|
5994
|
+
export function getThemeColorResolver(
|
|
5995
|
+
doc: CanonicalDocument,
|
|
5996
|
+
): ThemeColorResolver | undefined {
|
|
5997
|
+
const ct = doc.subParts?.canonicalTheme;
|
|
5998
|
+
return ct ? new ThemeColorResolver(ct) : undefined;
|
|
5999
|
+
}
|
|
6000
|
+
|
|
5371
6001
|
function refreshInlineNodesWithCursor(
|
|
5372
6002
|
nodes: readonly InlineNode[],
|
|
5373
6003
|
visitField: (field: FieldNode, range: { from: number; to: number }) => FieldNode,
|
|
@@ -5701,6 +6331,28 @@ function collectParagraphContexts(blocks: readonly BlockNode[]): ParagraphContex
|
|
|
5701
6331
|
return paragraphs;
|
|
5702
6332
|
}
|
|
5703
6333
|
|
|
6334
|
+
/**
|
|
6335
|
+
* Collect every section's `SectionProperties` in document order. Each
|
|
6336
|
+
* `section_break` block contributes its own section; the final section
|
|
6337
|
+
* (the implicit one after the last break) is read from
|
|
6338
|
+
* `subParts.finalSectionProperties`. Used by `getFootnoteResolver()` so
|
|
6339
|
+
* per-section `footnotePr`/`endnotePr` can be resolved by section index.
|
|
6340
|
+
*/
|
|
6341
|
+
function collectSectionPropertiesInOrder(
|
|
6342
|
+
document: CanonicalDocumentEnvelope,
|
|
6343
|
+
): SectionProperties[] {
|
|
6344
|
+
const sections: SectionProperties[] = [];
|
|
6345
|
+
for (const block of document.content.children) {
|
|
6346
|
+
if (block.type === "section_break" && block.sectionProperties) {
|
|
6347
|
+
sections.push(block.sectionProperties);
|
|
6348
|
+
}
|
|
6349
|
+
}
|
|
6350
|
+
if (document.subParts?.finalSectionProperties) {
|
|
6351
|
+
sections.push(document.subParts.finalSectionProperties);
|
|
6352
|
+
}
|
|
6353
|
+
return sections;
|
|
6354
|
+
}
|
|
6355
|
+
|
|
5704
6356
|
function collectParagraphContextsFromBlocks(
|
|
5705
6357
|
blocks: readonly BlockNode[],
|
|
5706
6358
|
paragraphs: ParagraphContext[],
|