@beyondwork/docx-react-component 1.0.41 → 1.0.43
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 +38 -37
- package/src/api/awareness-identity-types.ts +35 -0
- package/src/api/comment-negotiation-types.ts +130 -0
- package/src/api/comment-presentation-types.ts +106 -0
- package/src/api/editor-state-types.ts +110 -0
- package/src/api/external-custody-types.ts +74 -0
- package/src/api/participants-types.ts +18 -0
- package/src/api/public-types.ts +541 -5
- package/src/api/scope-metadata-resolver-types.ts +88 -0
- package/src/core/commands/formatting-commands.ts +1 -1
- package/src/core/commands/index.ts +601 -9
- package/src/core/search/search-text.ts +15 -2
- package/src/index.ts +131 -1
- package/src/io/docx-session.ts +672 -2
- package/src/io/export/escape-xml-attribute.ts +26 -0
- package/src/io/export/external-send.ts +188 -0
- package/src/io/export/serialize-comments.ts +13 -16
- package/src/io/export/serialize-footnotes.ts +17 -24
- package/src/io/export/serialize-headers-footers.ts +17 -24
- package/src/io/export/serialize-main-document.ts +59 -62
- package/src/io/export/serialize-numbering.ts +20 -27
- package/src/io/export/serialize-runtime-revisions.ts +2 -9
- package/src/io/export/serialize-tables.ts +8 -15
- package/src/io/export/table-properties-xml.ts +25 -32
- package/src/io/import/external-reimport.ts +40 -0
- package/src/io/load-scheduler.ts +230 -0
- package/src/io/normalize/normalize-text.ts +83 -0
- package/src/io/ooxml/bw-xml.ts +244 -0
- package/src/io/ooxml/canonicalize-payload.ts +301 -0
- package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
- package/src/io/ooxml/comment-presentation-payload.ts +311 -0
- package/src/io/ooxml/external-custody-payload.ts +102 -0
- package/src/io/ooxml/participants-payload.ts +97 -0
- package/src/io/ooxml/payload-signature.ts +112 -0
- package/src/io/ooxml/workflow-payload-validator.ts +367 -0
- package/src/io/ooxml/workflow-payload.ts +317 -7
- package/src/runtime/awareness-identity.ts +173 -0
- package/src/runtime/collab/event-types.ts +27 -0
- package/src/runtime/collab-session-bridge.ts +157 -0
- package/src/runtime/collab-session-facet.ts +193 -0
- package/src/runtime/collab-session.ts +273 -0
- package/src/runtime/comment-negotiation-sync.ts +91 -0
- package/src/runtime/comment-negotiation.ts +158 -0
- package/src/runtime/comment-presentation.ts +223 -0
- package/src/runtime/document-runtime.ts +639 -124
- package/src/runtime/editor-state-channel.ts +544 -0
- package/src/runtime/editor-state-integration.ts +217 -0
- package/src/runtime/external-send-runtime.ts +117 -0
- package/src/runtime/layout/docx-font-loader.ts +11 -30
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +4 -0
- package/src/runtime/layout/layout-engine-instance.ts +139 -14
- package/src/runtime/layout/page-graph.ts +79 -7
- package/src/runtime/layout/paginated-layout-engine.ts +441 -48
- package/src/runtime/layout/public-facet.ts +585 -14
- package/src/runtime/layout/table-row-split.ts +316 -0
- package/src/runtime/markdown-sanitizer.ts +132 -0
- package/src/runtime/participants.ts +134 -0
- package/src/runtime/perf-counters.ts +28 -0
- package/src/runtime/render/render-frame-types.ts +17 -0
- package/src/runtime/render/render-kernel.ts +172 -29
- package/src/runtime/resign-payload.ts +120 -0
- package/src/runtime/surface-projection.ts +10 -5
- package/src/runtime/tamper-gate.ts +157 -0
- package/src/runtime/workflow-markup.ts +80 -16
- package/src/runtime/workflow-rail-segments.ts +244 -5
- package/src/ui/WordReviewEditor.tsx +654 -45
- package/src/ui/editor-command-bag.ts +14 -0
- package/src/ui/editor-runtime-boundary.ts +111 -11
- package/src/ui/editor-shell-view.tsx +21 -0
- package/src/ui/editor-surface-controller.tsx +5 -0
- package/src/ui/headless/selection-helpers.ts +10 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
- package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
- package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
- package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
- package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
- package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
- package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
- package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
- package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
- package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
- package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
- package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
- package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
- package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
- package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +10 -256
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
- package/src/ui-tailwind/index.ts +37 -1
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
- package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
- package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
- package/src/ui-tailwind/theme/editor-theme.css +25 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +455 -118
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* editor-state-integration.ts — Schema 1.2 Task D + E helpers.
|
|
3
|
+
*
|
|
4
|
+
* Factored out of document-runtime.ts / docx-session.ts to keep
|
|
5
|
+
* those files focused. Two entry points:
|
|
6
|
+
*
|
|
7
|
+
* - `hydrateEditorStateFromEnvelope`: called right after the runtime
|
|
8
|
+
* is created and the envelope parsed; drives the load-path.
|
|
9
|
+
* - `collectEditorStateForSerialize`: called inside exportDocx before
|
|
10
|
+
* `buildWorkflowPayloadParts`; drives the save-path.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { EditorStateNamespace } from "../api/editor-state-types.ts";
|
|
14
|
+
import type { EditorStateChannel } from "./editor-state-channel.ts";
|
|
15
|
+
import type {
|
|
16
|
+
EditorStatePayload,
|
|
17
|
+
EditorStatePayloadNamespaceEntry,
|
|
18
|
+
} from "../io/ooxml/workflow-payload.ts";
|
|
19
|
+
|
|
20
|
+
// All namespaces the runtime currently knows about.
|
|
21
|
+
export const ALL_EDITOR_STATE_NAMESPACES: readonly EditorStateNamespace[] = [
|
|
22
|
+
"hostAnnotations",
|
|
23
|
+
"workflowOverlay",
|
|
24
|
+
"workflowMetadata",
|
|
25
|
+
"workItems",
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Load-path: hydrateEditorStateFromEnvelope
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
export interface HydrateEditorStateArgs {
|
|
33
|
+
/** The editorState block parsed from the workflow-payload envelope. */
|
|
34
|
+
editorState: EditorStatePayload;
|
|
35
|
+
channel: EditorStateChannel;
|
|
36
|
+
/**
|
|
37
|
+
* Called for each namespace whose blob has been resolved/loaded.
|
|
38
|
+
* Responsible for applying the blob to the appropriate subsystem store
|
|
39
|
+
* (e.g. calling runtime.setHostAnnotationOverlay).
|
|
40
|
+
*/
|
|
41
|
+
applyBlob: (namespace: EditorStateNamespace, data: unknown) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Drives the load-path for schema 1.2 editor-state entries.
|
|
46
|
+
*
|
|
47
|
+
* - Unknown namespaces → preserved opaquely, `unknown_namespace` event.
|
|
48
|
+
* - Inline entries under in-document policy → applied directly.
|
|
49
|
+
* - Keyed entries → resolver called; result applied (or failure handled).
|
|
50
|
+
* - Policy mismatch → `policy_migrated` event.
|
|
51
|
+
*
|
|
52
|
+
* Returns a promise that resolves when all namespaces have been
|
|
53
|
+
* processed. Rejects only when `onResolveError` is `"block"` for a
|
|
54
|
+
* namespace and the resolver fails — the caller should fail the load.
|
|
55
|
+
*/
|
|
56
|
+
export async function hydrateEditorStateFromEnvelope(
|
|
57
|
+
args: HydrateEditorStateArgs,
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
const { editorState, channel, applyBlob } = args;
|
|
60
|
+
|
|
61
|
+
// Record unknown namespaces first — they are preserved opaquely by
|
|
62
|
+
// the payload layer; we emit the warning event AND hand the raw XML
|
|
63
|
+
// to the channel so the next save round-trips it verbatim.
|
|
64
|
+
for (const unknown of editorState.unknownNamespaces ?? []) {
|
|
65
|
+
channel.recordUnknownNamespace(unknown.name, { rawXml: unknown.rawXml });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const entry of editorState.entries) {
|
|
69
|
+
await hydrateEntry({ entry, channel, applyBlob });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function hydrateEntry(args: {
|
|
74
|
+
entry: EditorStatePayloadNamespaceEntry;
|
|
75
|
+
channel: EditorStateChannel;
|
|
76
|
+
applyBlob: (namespace: EditorStateNamespace, data: unknown) => void;
|
|
77
|
+
}): Promise<void> {
|
|
78
|
+
const { entry, channel, applyBlob } = args;
|
|
79
|
+
const ns = entry.namespace;
|
|
80
|
+
const policyEntry = channel.getPolicyEntry(ns);
|
|
81
|
+
|
|
82
|
+
// Malformed inline JSON: surface as a load failure; don't apply.
|
|
83
|
+
if (entry.malformedInline) {
|
|
84
|
+
channel.recordLoadFailure({
|
|
85
|
+
namespace: ns,
|
|
86
|
+
error: new Error(`Malformed inline JSON for namespace "${ns}"`),
|
|
87
|
+
fallback: policyEntry.onResolveError === "block" ? "empty" : policyEntry.onResolveError,
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Policy-migration detection: compare payload-written location vs
|
|
93
|
+
// current policy location.
|
|
94
|
+
const payloadLocation: string = entry.storageRef
|
|
95
|
+
? entry.storageRef.location
|
|
96
|
+
: "in-document";
|
|
97
|
+
if (payloadLocation !== policyEntry.location) {
|
|
98
|
+
channel.recordPolicyMigration({
|
|
99
|
+
namespace: ns,
|
|
100
|
+
from: payloadLocation as import("../api/editor-state-types.ts").EditorStateLocation,
|
|
101
|
+
to: policyEntry.location,
|
|
102
|
+
key: entry.storageRef?.entryKey,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Inline path: apply directly when both payload and policy agree on
|
|
107
|
+
// in-document.
|
|
108
|
+
if (entry.inline !== undefined && policyEntry.location === "in-document") {
|
|
109
|
+
applyBlob(ns, entry.inline);
|
|
110
|
+
channel.recordLoaded(ns, {
|
|
111
|
+
namespace: ns,
|
|
112
|
+
schemaVersion: entry.schemaVersion,
|
|
113
|
+
data: entry.inline,
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Keyed path: set the key from the payload, then call the resolver.
|
|
119
|
+
if (entry.storageRef) {
|
|
120
|
+
channel.setKey(ns, entry.storageRef.entryKey);
|
|
121
|
+
// Under keyed policy the resolver wins over any inline blob.
|
|
122
|
+
const result = await channel.resolve(ns, entry.storageRef.entryKey);
|
|
123
|
+
if (result.blob !== null) {
|
|
124
|
+
applyBlob(ns, result.blob.data);
|
|
125
|
+
if (!result.appliedFallback) {
|
|
126
|
+
channel.recordLoaded(ns, result.blob);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// result.blob === null → failure already handled by channel (event
|
|
130
|
+
// emitted, fallback mode applied). Nothing more to apply.
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Mismatch: payload is inline but policy is keyed. Apply inline as
|
|
135
|
+
// fallback (no key to resolve against).
|
|
136
|
+
if (entry.inline !== undefined) {
|
|
137
|
+
applyBlob(ns, entry.inline);
|
|
138
|
+
channel.recordLoaded(ns, {
|
|
139
|
+
namespace: ns,
|
|
140
|
+
schemaVersion: entry.schemaVersion,
|
|
141
|
+
data: entry.inline,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Save-path: collectEditorStateForSerialize
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
export interface CollectEditorStateArgs {
|
|
151
|
+
channel: EditorStateChannel;
|
|
152
|
+
/**
|
|
153
|
+
* Returns the current in-memory blob for a namespace, or null if
|
|
154
|
+
* the namespace has no data to persist.
|
|
155
|
+
*/
|
|
156
|
+
getNamespaceData: (ns: EditorStateNamespace) => { schemaVersion: string; data: unknown } | null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Builds the `EditorStatePayload` for the serializer.
|
|
161
|
+
*
|
|
162
|
+
* 1. Flushes any pending debounced persists so the docx captures
|
|
163
|
+
* the last-known-good state for rowstore namespaces.
|
|
164
|
+
* 2. For each namespace with data: emits inline or storageRef per policy.
|
|
165
|
+
* 3. Returns undefined when no namespaces have data — the serializer
|
|
166
|
+
* then omits `<bw:editorState>` entirely (downgrade to 1.1/1.0).
|
|
167
|
+
*/
|
|
168
|
+
export async function collectEditorStateForSerialize(
|
|
169
|
+
args: CollectEditorStateArgs,
|
|
170
|
+
): Promise<EditorStatePayload | undefined> {
|
|
171
|
+
const { channel, getNamespaceData } = args;
|
|
172
|
+
|
|
173
|
+
// Flush pending debounced persists before serialize resolves.
|
|
174
|
+
await channel.flush();
|
|
175
|
+
|
|
176
|
+
const entries: EditorStatePayloadNamespaceEntry[] = [];
|
|
177
|
+
|
|
178
|
+
for (const ns of ALL_EDITOR_STATE_NAMESPACES) {
|
|
179
|
+
const current = getNamespaceData(ns);
|
|
180
|
+
if (!current) continue;
|
|
181
|
+
|
|
182
|
+
const policyEntry = channel.getPolicyEntry(ns);
|
|
183
|
+
|
|
184
|
+
if (policyEntry.location === "in-document") {
|
|
185
|
+
entries.push({
|
|
186
|
+
namespace: ns,
|
|
187
|
+
schemaVersion: current.schemaVersion,
|
|
188
|
+
inline: current.data,
|
|
189
|
+
});
|
|
190
|
+
} else {
|
|
191
|
+
const key = channel.getKey(ns);
|
|
192
|
+
if (!key) continue; // Keyed policy without a key — can't serialize ref.
|
|
193
|
+
entries.push({
|
|
194
|
+
namespace: ns,
|
|
195
|
+
schemaVersion: current.schemaVersion,
|
|
196
|
+
storageRef: {
|
|
197
|
+
location: policyEntry.location as Exclude<
|
|
198
|
+
import("../api/editor-state-types.ts").EditorStateLocation,
|
|
199
|
+
"in-document"
|
|
200
|
+
>,
|
|
201
|
+
entryKey: key,
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const unknownEntries = channel.getUnknownEntries();
|
|
208
|
+
const unknownNamespaces = unknownEntries
|
|
209
|
+
.filter((u): u is typeof u & { rawXml: string } => typeof u.rawXml === "string")
|
|
210
|
+
.map((u) => ({ name: u.name, rawXml: u.rawXml }));
|
|
211
|
+
|
|
212
|
+
if (entries.length === 0 && unknownNamespaces.length === 0) return undefined;
|
|
213
|
+
return {
|
|
214
|
+
entries,
|
|
215
|
+
...(unknownNamespaces.length > 0 ? { unknownNamespaces } : {}),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {
|
|
2
|
+
sendToExternal,
|
|
3
|
+
type SendToExternalBlock,
|
|
4
|
+
type SendToExternalResult,
|
|
5
|
+
} from "../io/export/external-send.ts";
|
|
6
|
+
import type {
|
|
7
|
+
ExternalCustodyResolver,
|
|
8
|
+
} from "../api/external-custody-types.ts";
|
|
9
|
+
import type { CollabSessionBridge } from "./collab-session-bridge.ts";
|
|
10
|
+
import { resignPayload } from "./resign-payload.ts";
|
|
11
|
+
import type { PayloadSigner } from "../io/ooxml/payload-signature.ts";
|
|
12
|
+
import type { TamperGate } from "./tamper-gate.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Runtime-level composition of the P7 pure pipeline with:
|
|
16
|
+
* - the P8c `CollabSessionBridge` (snapshots of negotiation /
|
|
17
|
+
* presentation / participants)
|
|
18
|
+
* - the P8e `TamperGate` (blocks when `metadataIntegrity === "tampered"`)
|
|
19
|
+
* - the P8a `resignPayload()` hook (every write re-signs)
|
|
20
|
+
*
|
|
21
|
+
* Callers supply the raw workflow-payload XML alongside the collab
|
|
22
|
+
* state. On success the result carries:
|
|
23
|
+
* - the custody receipt (to be emitted inside bw:extensions)
|
|
24
|
+
* - the kept snapshots (to replace the pre-send facet state)
|
|
25
|
+
* - the re-signed payload XML (to persist in the shipped docx)
|
|
26
|
+
*
|
|
27
|
+
* This helper does NOT rewrite `word/document.xml` / `word/comments.xml`
|
|
28
|
+
* or the three companion parts; the caller owns the zip rewrite using
|
|
29
|
+
* `result.stripped.commentIds`. This keeps the runtime layer isolated
|
|
30
|
+
* from OPC packaging.
|
|
31
|
+
*/
|
|
32
|
+
export interface RuntimeSendToExternalArgs {
|
|
33
|
+
bridge: CollabSessionBridge;
|
|
34
|
+
tamperGate: TamperGate;
|
|
35
|
+
signer: PayloadSigner;
|
|
36
|
+
|
|
37
|
+
/** The raw `<bw:workflowPayload …>…</bw:workflowPayload>` XML. */
|
|
38
|
+
payloadXml: string;
|
|
39
|
+
|
|
40
|
+
role: "author" | "reviewer" | "observer";
|
|
41
|
+
|
|
42
|
+
originDocumentId: string;
|
|
43
|
+
originPayloadId: string;
|
|
44
|
+
/** sha256:{hex} of canonicalized word/document.xml at send time. */
|
|
45
|
+
originContentHash: string;
|
|
46
|
+
|
|
47
|
+
resolver: ExternalCustodyResolver;
|
|
48
|
+
recipient: string;
|
|
49
|
+
sentBy: string;
|
|
50
|
+
archiveRef: string;
|
|
51
|
+
|
|
52
|
+
/** Optional deterministic overrides for tests. */
|
|
53
|
+
custodyId?: string;
|
|
54
|
+
now?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type RuntimeSendToExternalResult =
|
|
58
|
+
| { ok: false; reason: "collab_role_restricted" }
|
|
59
|
+
| { ok: false; reason: "metadata_tampered" }
|
|
60
|
+
| {
|
|
61
|
+
ok: true;
|
|
62
|
+
custody: SendToExternalResult["custody"];
|
|
63
|
+
kept: SendToExternalResult["kept"];
|
|
64
|
+
stripped: SendToExternalResult["stripped"];
|
|
65
|
+
/** Re-signed `<bw:workflowPayload …>…</bw:workflowPayload>` ready to persist. */
|
|
66
|
+
payloadXml: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export async function runtimeSendToExternal(
|
|
70
|
+
args: RuntimeSendToExternalArgs,
|
|
71
|
+
): Promise<RuntimeSendToExternalResult> {
|
|
72
|
+
const guard = args.tamperGate.guard();
|
|
73
|
+
if (!guard.ok) {
|
|
74
|
+
return { ok: false, reason: guard.reason };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const presentation = args.bridge.getCommentPresentationSnapshot();
|
|
78
|
+
const negotiation = args.bridge.getCommentNegotiationSnapshot();
|
|
79
|
+
const participants = args.bridge.getParticipantRoster();
|
|
80
|
+
|
|
81
|
+
const pipelineArgs = {
|
|
82
|
+
presentation,
|
|
83
|
+
negotiation,
|
|
84
|
+
participants,
|
|
85
|
+
role: args.role,
|
|
86
|
+
metadataIntegrity:
|
|
87
|
+
args.tamperGate.state === "unsigned" ? "verified" : args.tamperGate.state,
|
|
88
|
+
originDocumentId: args.originDocumentId,
|
|
89
|
+
originPayloadId: args.originPayloadId,
|
|
90
|
+
originContentHash: args.originContentHash,
|
|
91
|
+
resolver: args.resolver,
|
|
92
|
+
recipient: args.recipient,
|
|
93
|
+
sentBy: args.sentBy,
|
|
94
|
+
archiveRef: args.archiveRef,
|
|
95
|
+
...(args.custodyId !== undefined ? { custodyId: args.custodyId } : {}),
|
|
96
|
+
...(args.now !== undefined ? { now: args.now } : {}),
|
|
97
|
+
} as const;
|
|
98
|
+
|
|
99
|
+
const pipeline: SendToExternalBlock = await sendToExternal(pipelineArgs);
|
|
100
|
+
if (!pipeline.ok) {
|
|
101
|
+
return { ok: false, reason: pipeline.reason };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const { payloadXml } = await resignPayload({
|
|
105
|
+
payloadXml: args.payloadXml,
|
|
106
|
+
signer: args.signer,
|
|
107
|
+
...(args.now !== undefined ? { now: args.now } : {}),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
custody: pipeline.result.custody,
|
|
113
|
+
kept: pipeline.result.kept,
|
|
114
|
+
stripped: pipeline.result.stripped,
|
|
115
|
+
payloadXml,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -45,35 +45,12 @@ export interface DocxFontLoader {
|
|
|
45
45
|
refresh(input: FontLoaderInput): void;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
interface MinimalFontFace {
|
|
49
|
-
load(): Promise<MinimalFontFace>;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
interface MinimalFontFaceDescriptors {
|
|
53
|
-
weight?: string;
|
|
54
|
-
style?: string;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
interface MinimalFontFaceConstructor {
|
|
58
|
-
new (
|
|
59
|
-
family: string,
|
|
60
|
-
source: ArrayBuffer | ArrayBufferView | string,
|
|
61
|
-
descriptors?: MinimalFontFaceDescriptors,
|
|
62
|
-
): MinimalFontFace;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
interface MinimalFontFaceSet {
|
|
66
|
-
add(face: MinimalFontFace): void;
|
|
67
|
-
check(font: string): boolean;
|
|
68
|
-
ready: Promise<MinimalFontFaceSet>;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
48
|
export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
|
|
72
|
-
const globalDocument = (globalThis as unknown as { document?: { fonts?: unknown } }).document;
|
|
73
49
|
const supported =
|
|
74
|
-
|
|
50
|
+
typeof document !== "undefined" &&
|
|
75
51
|
typeof (globalThis as { FontFace?: unknown }).FontFace !== "undefined" &&
|
|
76
|
-
|
|
52
|
+
// Guard against jsdom which exposes FontFace but not document.fonts
|
|
53
|
+
Boolean((document as Document & { fonts?: FontFaceSet }).fonts);
|
|
77
54
|
|
|
78
55
|
let current: FontLoaderInput = initial;
|
|
79
56
|
let readyPromise: Promise<void>;
|
|
@@ -81,7 +58,7 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
|
|
|
81
58
|
|
|
82
59
|
function run(input: FontLoaderInput): Promise<void> {
|
|
83
60
|
if (!supported) return Promise.resolve();
|
|
84
|
-
const fontSet =
|
|
61
|
+
const fontSet = (document as Document & { fonts?: FontFaceSet }).fonts;
|
|
85
62
|
if (!fontSet) return Promise.resolve();
|
|
86
63
|
|
|
87
64
|
const pending: Array<Promise<unknown>> = [];
|
|
@@ -93,8 +70,10 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
|
|
|
93
70
|
|
|
94
71
|
for (const [descriptor, data] of variantsOf(variants)) {
|
|
95
72
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
73
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
74
|
+
const FontFaceCtor = (globalThis as any).FontFace as {
|
|
75
|
+
new (family: string, source: BufferSource, descriptors?: FontFaceDescriptors): FontFace;
|
|
76
|
+
};
|
|
98
77
|
const face = new FontFaceCtor(family, data, descriptor);
|
|
99
78
|
pending.push(
|
|
100
79
|
face.load().then((loaded) => {
|
|
@@ -109,6 +88,8 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
|
|
|
109
88
|
}
|
|
110
89
|
}
|
|
111
90
|
|
|
91
|
+
// Mark declared families as registered if the browser already resolves
|
|
92
|
+
// them (e.g. system fonts like Calibri, Arial).
|
|
112
93
|
for (const family of input.families) {
|
|
113
94
|
try {
|
|
114
95
|
const probe = `12px "${family.replace(/"/g, "'")}", serif`;
|
|
@@ -146,7 +127,7 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
|
|
|
146
127
|
|
|
147
128
|
function* variantsOf(
|
|
148
129
|
variants: EmbeddedFontBytes,
|
|
149
|
-
): IterableIterator<[
|
|
130
|
+
): IterableIterator<[FontFaceDescriptors, ArrayBuffer]> {
|
|
150
131
|
if (variants.regular) {
|
|
151
132
|
yield [{ weight: "400", style: "normal" }, variants.regular];
|
|
152
133
|
}
|
|
@@ -35,6 +35,9 @@ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
|
|
|
35
35
|
getDisplayPageNumber: () => null,
|
|
36
36
|
getLineBoxes: () => [],
|
|
37
37
|
getLineBoxesForRegion: () => [],
|
|
38
|
+
getStoryRegionsOnPage: () => [],
|
|
39
|
+
getStoryBlocksForRegion: () => [],
|
|
40
|
+
getDocumentEndnoteBlocks: () => [],
|
|
38
41
|
getFragmentsForPage: () => [],
|
|
39
42
|
getPageFormatCatalog: () => PAGE_FORMAT_CATALOG,
|
|
40
43
|
getActivePageFormat: () => null,
|
|
@@ -54,6 +57,7 @@ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
|
|
|
54
57
|
whenMeasurementReady: () => Promise.resolve(),
|
|
55
58
|
getFirstPageIndexForBlock: () => null,
|
|
56
59
|
swapMeasurementProvider: () => undefined,
|
|
60
|
+
invalidateMeasurementCache: () => undefined,
|
|
57
61
|
getTableRenderPlan: () => null,
|
|
58
62
|
getDirtyFieldFamilies: () => [],
|
|
59
63
|
getFieldDirtinessReport: () => emptyReport,
|
|
@@ -104,7 +104,19 @@ export interface LayoutEngineEvent {
|
|
|
104
104
|
| "incremental_relayout"
|
|
105
105
|
| "page_count_changed"
|
|
106
106
|
| "page_field_dirtied"
|
|
107
|
-
| "measurement_backend_ready"
|
|
107
|
+
| "measurement_backend_ready"
|
|
108
|
+
/**
|
|
109
|
+
* P14.b — coalesced "the engine just finished a build" event. Emitted
|
|
110
|
+
* exactly once per `fullRebuild` / `incrementalRelayout` AFTER the
|
|
111
|
+
* granular events, carrying the union of dirty-field families, page-
|
|
112
|
+
* count delta, and page-range info. Subscribers that only need to
|
|
113
|
+
* react to "something layout-affecting changed" can listen to this
|
|
114
|
+
* single event and skip the multi-event subscription pattern that
|
|
115
|
+
* triggered N React re-renders per applyPatch. The granular events
|
|
116
|
+
* still fire for backward compat with consumers (TwStatusBar fidelity
|
|
117
|
+
* badge, etc.) that care about specific kinds.
|
|
118
|
+
*/
|
|
119
|
+
| "layout_committed";
|
|
108
120
|
revision: number;
|
|
109
121
|
previousPageCount?: number;
|
|
110
122
|
currentPageCount?: number;
|
|
@@ -113,6 +125,17 @@ export interface LayoutEngineEvent {
|
|
|
113
125
|
fidelity?: LayoutMeasurementProvider["fidelity"];
|
|
114
126
|
/** First dirty page index for incremental_relayout events. */
|
|
115
127
|
firstDirtyPageIndex?: number;
|
|
128
|
+
/**
|
|
129
|
+
* P14.b — page-count delta for `layout_committed`. Present when the
|
|
130
|
+
* commit produced a different total page count than the prior graph.
|
|
131
|
+
*/
|
|
132
|
+
pageCountDelta?: { previous: number; current: number };
|
|
133
|
+
/**
|
|
134
|
+
* P14.b — when `layout_committed` came from a bounded incremental
|
|
135
|
+
* relayout, the page range that was re-paginated. Absent for full
|
|
136
|
+
* rebuilds.
|
|
137
|
+
*/
|
|
138
|
+
pageRange?: { fromPageIndex: number; toPageIndex: number };
|
|
116
139
|
}
|
|
117
140
|
|
|
118
141
|
export interface LayoutEngineInstance {
|
|
@@ -149,6 +172,16 @@ export interface LayoutEngineInstance {
|
|
|
149
172
|
|
|
150
173
|
// ---- measurement plumbing --------------------------------------------
|
|
151
174
|
swapMeasurementProvider(provider: LayoutMeasurementProvider): void;
|
|
175
|
+
/**
|
|
176
|
+
* Invalidate the active measurement provider's internal caches (canvas
|
|
177
|
+
* glyph / run-width LRU) AND clear the engine's cached page graph so
|
|
178
|
+
* the next query re-paginates with fresh measurements. Host runtime
|
|
179
|
+
* calls this after `docxFontLoader.refresh(...)` registers new
|
|
180
|
+
* FontFace families — without this call the canvas backend's glyph
|
|
181
|
+
* cache keeps returning pre-refresh widths for already-measured
|
|
182
|
+
* glyphs, and the cached page graph keeps its stale page boundaries.
|
|
183
|
+
*/
|
|
184
|
+
invalidateMeasurementCache(): void;
|
|
152
185
|
}
|
|
153
186
|
|
|
154
187
|
// ---------------------------------------------------------------------------
|
|
@@ -263,16 +296,24 @@ export function createLayoutEngine(
|
|
|
263
296
|
);
|
|
264
297
|
const pages = pageStack.pages;
|
|
265
298
|
const stories = resolvePageStories(pages);
|
|
266
|
-
const
|
|
299
|
+
const bodyFragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
|
|
267
300
|
mainSurface,
|
|
268
301
|
pages,
|
|
269
302
|
pageStack.splits,
|
|
270
303
|
);
|
|
304
|
+
// P8.1b — merge per-note fragments (regionKind: "footnote-area") into the
|
|
305
|
+
// main fragments map so buildPageGraph sees them alongside body fragments.
|
|
306
|
+
const fragmentsByPageIndex = new Map(bodyFragmentsByPageIndex);
|
|
307
|
+
for (const [pageIndex, noteFragments] of (pageStack.noteFragmentsByPageIndex ?? new Map())) {
|
|
308
|
+
const existing = fragmentsByPageIndex.get(pageIndex) ?? [];
|
|
309
|
+
fragmentsByPageIndex.set(pageIndex, [...existing, ...noteFragments]);
|
|
310
|
+
}
|
|
271
311
|
const graph = buildPageGraph({
|
|
272
312
|
pages,
|
|
273
313
|
sections,
|
|
274
314
|
stories,
|
|
275
315
|
fragmentsByPageIndex,
|
|
316
|
+
noteAllocationsByPageIndex: pageStack.noteAllocationsByPageIndex,
|
|
276
317
|
});
|
|
277
318
|
|
|
278
319
|
// Field dirtiness diff from previous graph
|
|
@@ -284,14 +325,30 @@ export function createLayoutEngine(
|
|
|
284
325
|
const formatting = buildResolvedFormattingState(document, mainSurface);
|
|
285
326
|
|
|
286
327
|
const currentPageCount = resolveTotalPageCount(pages);
|
|
328
|
+
let pageCountDelta: { previous: number; current: number } | undefined;
|
|
287
329
|
if (currentPageCount !== previousPageCount) {
|
|
330
|
+
pageCountDelta = { previous: previousPageCount, current: currentPageCount };
|
|
331
|
+
previousPageCount = currentPageCount;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// MUST publish cache before emit: re-entrant getPageGraph() calls from
|
|
335
|
+
// subscribers during emit would otherwise trigger runaway rebuilds.
|
|
336
|
+
cachedKey = {
|
|
337
|
+
content: document.content,
|
|
338
|
+
styles: document.styles,
|
|
339
|
+
subParts: document.subParts,
|
|
340
|
+
};
|
|
341
|
+
cachedGraph = graph;
|
|
342
|
+
cachedFormatting = formatting;
|
|
343
|
+
cachedMapper = createPageFragmentMapper(graph);
|
|
344
|
+
|
|
345
|
+
if (pageCountDelta) {
|
|
288
346
|
emit({
|
|
289
347
|
kind: "page_count_changed",
|
|
290
348
|
revision: graph.revision,
|
|
291
|
-
previousPageCount,
|
|
292
|
-
currentPageCount,
|
|
349
|
+
previousPageCount: pageCountDelta.previous,
|
|
350
|
+
currentPageCount: pageCountDelta.current,
|
|
293
351
|
});
|
|
294
|
-
previousPageCount = currentPageCount;
|
|
295
352
|
}
|
|
296
353
|
|
|
297
354
|
if (dirtyFamilies.length > 0) {
|
|
@@ -308,14 +365,16 @@ export function createLayoutEngine(
|
|
|
308
365
|
...(reason ? { reason } : {}),
|
|
309
366
|
});
|
|
310
367
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
368
|
+
emit({
|
|
369
|
+
kind: "layout_committed",
|
|
370
|
+
revision: graph.revision,
|
|
371
|
+
...(reason ? { reason } : {}),
|
|
372
|
+
...(dirtyFamilies.length > 0
|
|
373
|
+
? { dirtyFieldFamilies: dirtyFamilies }
|
|
374
|
+
: {}),
|
|
375
|
+
...(pageCountDelta ? { pageCountDelta } : {}),
|
|
376
|
+
});
|
|
377
|
+
|
|
319
378
|
return graph;
|
|
320
379
|
}
|
|
321
380
|
|
|
@@ -362,16 +421,23 @@ export function createLayoutEngine(
|
|
|
362
421
|
const freshStories = resolvePageStories(freshSnapshots);
|
|
363
422
|
// Project fragments for the fresh tail pages, threading paragraph
|
|
364
423
|
// line-range splits produced by intra-paragraph pagination.
|
|
365
|
-
const
|
|
424
|
+
const freshBodyFragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
|
|
366
425
|
mainSurface,
|
|
367
426
|
freshSnapshots,
|
|
368
427
|
freshResult.splits,
|
|
369
428
|
);
|
|
429
|
+
// P8.1b — merge per-note fragments into the fresh fragments map.
|
|
430
|
+
const freshFragmentsByPageIndex = new Map(freshBodyFragmentsByPageIndex);
|
|
431
|
+
for (const [pageIndex, noteFragments] of (freshResult.noteFragmentsByPageIndex ?? new Map())) {
|
|
432
|
+
const existing = freshFragmentsByPageIndex.get(pageIndex) ?? [];
|
|
433
|
+
freshFragmentsByPageIndex.set(pageIndex, [...existing, ...noteFragments]);
|
|
434
|
+
}
|
|
370
435
|
const freshGraph = buildPageGraph({
|
|
371
436
|
pages: freshSnapshots,
|
|
372
437
|
sections,
|
|
373
438
|
stories: freshStories,
|
|
374
439
|
fragmentsByPageIndex: freshFragmentsByPageIndex,
|
|
440
|
+
noteAllocationsByPageIndex: freshResult.noteAllocationsByPageIndex,
|
|
375
441
|
});
|
|
376
442
|
const freshNodes = freshGraph.pages;
|
|
377
443
|
|
|
@@ -388,7 +454,9 @@ export function createLayoutEngine(
|
|
|
388
454
|
const currentPageCount = resolveTotalPageCount(
|
|
389
455
|
deriveDocumentPageSnapshots(splicedGraph),
|
|
390
456
|
);
|
|
457
|
+
let pageCountDelta: { previous: number; current: number } | undefined;
|
|
391
458
|
if (currentPageCount !== previousPageCount) {
|
|
459
|
+
pageCountDelta = { previous: previousPageCount, current: currentPageCount };
|
|
392
460
|
emit({
|
|
393
461
|
kind: "page_count_changed",
|
|
394
462
|
revision: splicedGraph.revision,
|
|
@@ -413,6 +481,30 @@ export function createLayoutEngine(
|
|
|
413
481
|
firstDirtyPageIndex: firstDirty,
|
|
414
482
|
});
|
|
415
483
|
|
|
484
|
+
// P14.b — coalesced commit event for the bounded-incremental path.
|
|
485
|
+
//
|
|
486
|
+
// Page-range semantics: the current `incrementalRelayout` path uses
|
|
487
|
+
// `buildPageStackFromWithSplits` + `spliceGraph`, which always
|
|
488
|
+
// re-paginates from `firstDirty` through the document tail (we
|
|
489
|
+
// discard the prior tail and replace it with the freshly-paginated
|
|
490
|
+
// pages). So `toPageIndex = pages.length - 1` is correct for every
|
|
491
|
+
// commit produced by this path. Future bounded-middle splices
|
|
492
|
+
// (e.g., a middle-style change that doesn't touch the tail) would
|
|
493
|
+
// need to track an explicit upper bound — guard the assumption
|
|
494
|
+
// here so the contract drift becomes a test failure rather than a
|
|
495
|
+
// silent over-iteration in consumers (Chrome overlay, render kernel
|
|
496
|
+
// diff).
|
|
497
|
+
emit({
|
|
498
|
+
kind: "layout_committed",
|
|
499
|
+
revision: splicedGraph.revision,
|
|
500
|
+
reason: pending.reason,
|
|
501
|
+
pageRange: { fromPageIndex: firstDirty, toPageIndex: splicedGraph.pages.length - 1 },
|
|
502
|
+
...(dirtyFamilies.length > 0
|
|
503
|
+
? { dirtyFieldFamilies: dirtyFamilies }
|
|
504
|
+
: {}),
|
|
505
|
+
...(pageCountDelta ? { pageCountDelta } : {}),
|
|
506
|
+
});
|
|
507
|
+
|
|
416
508
|
cachedKey = {
|
|
417
509
|
content: document.content,
|
|
418
510
|
styles: document.styles,
|
|
@@ -605,13 +697,46 @@ export function createLayoutEngine(
|
|
|
605
697
|
},
|
|
606
698
|
|
|
607
699
|
swapMeasurementProvider(provider) {
|
|
700
|
+
const previousFidelity = measurementProvider.fidelity;
|
|
608
701
|
measurementProvider = provider;
|
|
702
|
+
// Hardening: a backend swap changes the measurement numerics the
|
|
703
|
+
// cached graph was built against. Empirical → canvas typically
|
|
704
|
+
// reduces line counts (canvas-accurate glyph widths pack more
|
|
705
|
+
// text per line); canvas → canvas-with-font-loading applies the
|
|
706
|
+
// correct FontFace metrics for embedded DOCX fonts. Either way,
|
|
707
|
+
// the cached graph is stale — invalidate so the next
|
|
708
|
+
// `getGraph()` query re-paginates with the new provider. Skip
|
|
709
|
+
// invalidation when fidelity is unchanged (e.g., an empirical
|
|
710
|
+
// → empirical swap, or a canvas fallback that resolved back to
|
|
711
|
+
// the same backend) so we don't churn.
|
|
712
|
+
if (previousFidelity !== provider.fidelity) {
|
|
713
|
+
cachedKey = null;
|
|
714
|
+
cachedGraph = null;
|
|
715
|
+
cachedFormatting = null;
|
|
716
|
+
cachedMapper = null;
|
|
717
|
+
}
|
|
609
718
|
emit({
|
|
610
719
|
kind: "measurement_backend_ready",
|
|
611
720
|
revision: cachedGraph?.revision ?? 0,
|
|
612
721
|
fidelity: provider.fidelity,
|
|
613
722
|
});
|
|
614
723
|
},
|
|
724
|
+
/**
|
|
725
|
+
* Invalidate the current measurement provider's internal glyph /
|
|
726
|
+
* run-width cache. Called by the host runtime after
|
|
727
|
+
* `fontLoader.refresh(...)` so canvas-backed measurements re-read
|
|
728
|
+
* the newly-registered FontFaces instead of returning stale widths
|
|
729
|
+
* from the pre-refresh glyph cache. The graph cache itself is
|
|
730
|
+
* also cleared because a font change can shift line breaks and
|
|
731
|
+
* therefore page boundaries.
|
|
732
|
+
*/
|
|
733
|
+
invalidateMeasurementCache() {
|
|
734
|
+
measurementProvider.invalidateCache();
|
|
735
|
+
cachedKey = null;
|
|
736
|
+
cachedGraph = null;
|
|
737
|
+
cachedFormatting = null;
|
|
738
|
+
cachedMapper = null;
|
|
739
|
+
},
|
|
615
740
|
};
|
|
616
741
|
}
|
|
617
742
|
|