@beyondwork/docx-react-component 1.0.97 → 1.0.99
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 +14 -0
- package/src/io/ooxml/payload-signature.ts +101 -0
- package/src/runtime/layout/layout-engine-version.ts +7 -1
- package/src/session/export/stateful-export-pipeline.ts +18 -2
- package/src/session/export/stateful-export.ts +21 -1
- package/src/ui/headless/comment-decoration-model.ts +20 -7
- package/src/ui-tailwind/editor-surface/preserve-position.ts +12 -2
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +14 -3
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beyondwork/docx-react-component",
|
|
3
3
|
"publisher": "beyondwork",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.99",
|
|
5
5
|
"description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": [
|
package/src/api/public-types.ts
CHANGED
|
@@ -2640,6 +2640,20 @@ export interface AddScopeResult {
|
|
|
2640
2640
|
export interface ExportDocxOptions {
|
|
2641
2641
|
fileName?: string;
|
|
2642
2642
|
reason?: string;
|
|
2643
|
+
/**
|
|
2644
|
+
* Optional signer for the workflow-payload `<bw:signature>` block
|
|
2645
|
+
* (coord-06 cleanup §3). When present, `buildWorkflowPayloadParts`
|
|
2646
|
+
* computes the signature over the canonicalized payload XML (bw-
|
|
2647
|
+
* canon/1; `<bw:signature>` excluded from hashing) and injects a
|
|
2648
|
+
* `<bw:signature>` element inside `<bw:workflowPayload>`. Reload
|
|
2649
|
+
* passes the extracted signature to `collabSession.attach({ payload
|
|
2650
|
+
* })` where the tamper-gate transitions to `verified` / `tampered`.
|
|
2651
|
+
*
|
|
2652
|
+
* Hosts that don't pass a signer get the trust-on-first-use
|
|
2653
|
+
* (unsigned) behaviour that shipped before — the payload part is
|
|
2654
|
+
* written without a `<bw:signature>` element.
|
|
2655
|
+
*/
|
|
2656
|
+
signer?: import("../io/ooxml/payload-signature.ts").PayloadSigner;
|
|
2643
2657
|
/**
|
|
2644
2658
|
* Controls the browser download fallback used by the mounted
|
|
2645
2659
|
* `WordReviewEditor` ref when no host/datastore `saveExport` adapter is
|
|
@@ -49,6 +49,107 @@ export async function verifyWorkflowPayloadXml(
|
|
|
49
49
|
return verifier.verify(bytes, sig);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Coord-06 cleanup §3 (2026-04-24) — sign the workflow payload XML and
|
|
54
|
+
* inject a `<bw:signature>` block into the payload root. The block is
|
|
55
|
+
* excluded from canonicalization before hashing (see
|
|
56
|
+
* `canonicalize-payload.ts::stripSignature`) so the signature is
|
|
57
|
+
* self-consistent: writer and reader both canonicalize-with-signature-
|
|
58
|
+
* stripped, so the same bytes hash.
|
|
59
|
+
*
|
|
60
|
+
* The injection happens inside the root `<bw:workflowPayload>` element,
|
|
61
|
+
* before the closing tag. Idempotent — re-signing on export re-injects
|
|
62
|
+
* a fresh signature block, stripping any prior one first.
|
|
63
|
+
*/
|
|
64
|
+
export async function signAndInjectWorkflowPayloadSignature(
|
|
65
|
+
xml: string,
|
|
66
|
+
signer: PayloadSigner,
|
|
67
|
+
now: string = new Date().toISOString(),
|
|
68
|
+
): Promise<string> {
|
|
69
|
+
const withoutPriorSignature = stripSignatureBlockFromPayload(xml);
|
|
70
|
+
const signature = await signWorkflowPayloadXml(withoutPriorSignature, signer, now);
|
|
71
|
+
return injectSignatureBlock(withoutPriorSignature, signature);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse a `<bw:signature ...>base64...</bw:signature>` block out of a
|
|
76
|
+
* workflow payload XML string. Returns `null` when the payload is
|
|
77
|
+
* unsigned (no `<bw:signature>` found) or when the block is malformed
|
|
78
|
+
* (missing required attributes / unparseable). Malformed returns null
|
|
79
|
+
* rather than throwing — the caller flips the tamper-gate to
|
|
80
|
+
* `"unsigned"` on null and to `"tampered"` only when verification
|
|
81
|
+
* fails on a well-formed signature block.
|
|
82
|
+
*/
|
|
83
|
+
export function extractWorkflowPayloadSignature(
|
|
84
|
+
xml: string,
|
|
85
|
+
): PayloadSignature | null {
|
|
86
|
+
// Match `<bw:signature ...>...</bw:signature>` or self-closing.
|
|
87
|
+
const re =
|
|
88
|
+
/<bw:signature\b([^>]*?)(?:\/>|>([\s\S]*?)<\/bw:signature>)/u;
|
|
89
|
+
const m = re.exec(xml);
|
|
90
|
+
if (!m) return null;
|
|
91
|
+
const attrs = parseAttributes(m[1] ?? "");
|
|
92
|
+
const body = (m[2] ?? "").trim();
|
|
93
|
+
const algorithm = attrs.algorithm;
|
|
94
|
+
const keyId = attrs.keyId;
|
|
95
|
+
const signedAt = attrs.signedAt;
|
|
96
|
+
const canonicalizationProfile = attrs.canonicalizationProfile;
|
|
97
|
+
if (!algorithm || !keyId || !signedAt || !canonicalizationProfile) return null;
|
|
98
|
+
if (algorithm !== "hmac-sha256" && algorithm !== "ed25519") return null;
|
|
99
|
+
if (canonicalizationProfile !== "bw-canon/1") return null;
|
|
100
|
+
if (!body) return null;
|
|
101
|
+
return {
|
|
102
|
+
algorithm,
|
|
103
|
+
keyId,
|
|
104
|
+
signedAt,
|
|
105
|
+
canonicalizationProfile,
|
|
106
|
+
value: body,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function stripSignatureBlockFromPayload(xml: string): string {
|
|
111
|
+
return xml.replace(
|
|
112
|
+
/<bw:signature\b[^>]*?(?:\/>|>[\s\S]*?<\/bw:signature>)\s*/u,
|
|
113
|
+
"",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function injectSignatureBlock(xml: string, sig: PayloadSignature): string {
|
|
118
|
+
const block =
|
|
119
|
+
`<bw:signature algorithm="${escapeAttr(sig.algorithm)}"` +
|
|
120
|
+
` keyId="${escapeAttr(sig.keyId)}"` +
|
|
121
|
+
` signedAt="${escapeAttr(sig.signedAt)}"` +
|
|
122
|
+
` canonicalizationProfile="${escapeAttr(sig.canonicalizationProfile)}">` +
|
|
123
|
+
`${sig.value}</bw:signature>`;
|
|
124
|
+
// Inject just before the closing `</bw:workflowPayload>` tag so the
|
|
125
|
+
// block is a direct child of the root element. Falls back to a
|
|
126
|
+
// root-tag rewrite if the closing tag isn't the expected shape
|
|
127
|
+
// (e.g. the root is renamed in a future schema bump).
|
|
128
|
+
const closeRe = /<\/bw:workflowPayload>\s*$/u;
|
|
129
|
+
if (closeRe.test(xml)) {
|
|
130
|
+
return xml.replace(closeRe, `${block}</bw:workflowPayload>`);
|
|
131
|
+
}
|
|
132
|
+
return xml.replace(/<\/([^>]+)>\s*$/u, `${block}</$1>`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseAttributes(source: string): Record<string, string> {
|
|
136
|
+
const out: Record<string, string> = {};
|
|
137
|
+
const re = /([a-zA-Z_][\w:.-]*)\s*=\s*"([^"]*)"/gu;
|
|
138
|
+
let m: RegExpExecArray | null;
|
|
139
|
+
while ((m = re.exec(source)) !== null) {
|
|
140
|
+
out[m[1]] = m[2];
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function escapeAttr(value: string): string {
|
|
146
|
+
return value
|
|
147
|
+
.replaceAll("&", "&")
|
|
148
|
+
.replaceAll('"', """)
|
|
149
|
+
.replaceAll("<", "<")
|
|
150
|
+
.replaceAll(">", ">");
|
|
151
|
+
}
|
|
152
|
+
|
|
52
153
|
// HMAC-SHA256 helpers ----------------------------------------------------
|
|
53
154
|
|
|
54
155
|
/**
|
|
@@ -1025,8 +1025,14 @@
|
|
|
1025
1025
|
* without an upstream bump. Bump here so persisted cache envelopes
|
|
1026
1026
|
* re-derive; no algorithm change beyond the floating-drawing clip
|
|
1027
1027
|
* widening already covered by the resurrected tests.
|
|
1028
|
+
*
|
|
1029
|
+
* 62 — nested break detection inside wrapped surface blocks. Page and
|
|
1030
|
+
* column break detection now walks `sdt_block` children and table-cell
|
|
1031
|
+
* content instead of checking only top-level paragraphs. This honors
|
|
1032
|
+
* `<w:br w:type="page"/>` inside cover-page SDTs such as the CCEP SOW
|
|
1033
|
+
* template, changing page assignment from the v61 cache shape.
|
|
1028
1034
|
*/
|
|
1029
|
-
export const LAYOUT_ENGINE_VERSION =
|
|
1035
|
+
export const LAYOUT_ENGINE_VERSION = 62 as const;
|
|
1030
1036
|
|
|
1031
1037
|
/**
|
|
1032
1038
|
* Serialization schema version for the LayCache payload (the cache envelope
|
|
@@ -164,6 +164,17 @@ export function ensureHostMetadataParts(
|
|
|
164
164
|
});
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Return shape reports whether a payload part was written AND exposes
|
|
169
|
+
* the built XML + path so the export pipeline can post-process (e.g.
|
|
170
|
+
* coord-06 §3 sign-and-inject `<bw:signature>`). Returns `null` when
|
|
171
|
+
* no payload part was written (no runtime-owned content).
|
|
172
|
+
*/
|
|
173
|
+
export interface EnsureWorkflowPayloadPartsResult {
|
|
174
|
+
readonly payloadPartPath: string;
|
|
175
|
+
readonly payloadPartXml: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
167
178
|
export function ensureWorkflowPayloadParts(
|
|
168
179
|
exportSession: ExportSession,
|
|
169
180
|
sessionState: EditorSessionState,
|
|
@@ -174,7 +185,7 @@ export function ensureWorkflowPayloadParts(
|
|
|
174
185
|
itemPropsPartPath: string;
|
|
175
186
|
},
|
|
176
187
|
editorState?: EditorStatePayload,
|
|
177
|
-
):
|
|
188
|
+
): EnsureWorkflowPayloadPartsResult | null {
|
|
178
189
|
const payloadParts = buildWorkflowPayloadParts({
|
|
179
190
|
sourcePackage,
|
|
180
191
|
workflowMetadata: sessionState.workflowMetadata,
|
|
@@ -188,7 +199,7 @@ export function ensureWorkflowPayloadParts(
|
|
|
188
199
|
producerVersion: sessionState.editorBuild,
|
|
189
200
|
});
|
|
190
201
|
if (!payloadParts) {
|
|
191
|
-
return;
|
|
202
|
+
return null;
|
|
192
203
|
}
|
|
193
204
|
if (
|
|
194
205
|
payloadParts.payloadPartPath !== resolvedPartPaths.payloadPartPath ||
|
|
@@ -228,6 +239,11 @@ export function ensureWorkflowPayloadParts(
|
|
|
228
239
|
target: WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH,
|
|
229
240
|
preferredId: "rIdBwWorkflowCustomProps",
|
|
230
241
|
});
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
payloadPartPath: payloadParts.payloadPartPath,
|
|
245
|
+
payloadPartXml: payloadParts.payloadPartXml,
|
|
246
|
+
};
|
|
231
247
|
}
|
|
232
248
|
|
|
233
249
|
export function hasHostSafeMetadataPackageStructure(sourcePackage: OpcPackage): boolean {
|
|
@@ -93,6 +93,7 @@ import {
|
|
|
93
93
|
ensureWorkflowPayloadParts,
|
|
94
94
|
serializeProtectionRangesIntoDocumentXml,
|
|
95
95
|
} from "./stateful-export-pipeline.ts";
|
|
96
|
+
import { signAndInjectWorkflowPayloadSignature } from "../../io/ooxml/payload-signature.ts";
|
|
96
97
|
import {
|
|
97
98
|
assertExportNotBlockedByCompatibility,
|
|
98
99
|
assertNoBlockingPreservedComments,
|
|
@@ -612,7 +613,7 @@ export async function runStatefulExport(
|
|
|
612
613
|
|
|
613
614
|
// Schema 1.2: pass through editorState payload collected by the
|
|
614
615
|
// runtime channel, with any offload entries folded in above.
|
|
615
|
-
ensureWorkflowPayloadParts(
|
|
616
|
+
const payloadResult = ensureWorkflowPayloadParts(
|
|
616
617
|
exportSession,
|
|
617
618
|
sessionState,
|
|
618
619
|
currentDocument,
|
|
@@ -621,6 +622,25 @@ export async function runStatefulExport(
|
|
|
621
622
|
editorStateWithEmbeddings,
|
|
622
623
|
);
|
|
623
624
|
|
|
625
|
+
// coord-06 cleanup §3 — if the host supplied a PayloadSigner, sign
|
|
626
|
+
// the built payload XML and rewrite the customXml/item1.xml part with
|
|
627
|
+
// a `<bw:signature>` block injected inside the root element. The
|
|
628
|
+
// canonicalization pass strips the block before hashing so signer
|
|
629
|
+
// and verifier agree; see `src/io/ooxml/canonicalize-payload.ts`.
|
|
630
|
+
if (options?.signer && payloadResult) {
|
|
631
|
+
const signedXml = await signAndInjectWorkflowPayloadSignature(
|
|
632
|
+
payloadResult.payloadPartXml,
|
|
633
|
+
options.signer,
|
|
634
|
+
);
|
|
635
|
+
const payloadPart = state.sourcePackage.parts.get(payloadResult.payloadPartPath);
|
|
636
|
+
exportSession.replaceOwnedPart({
|
|
637
|
+
path: payloadResult.payloadPartPath,
|
|
638
|
+
bytes: new TextEncoder().encode(signedXml),
|
|
639
|
+
contentType: payloadPart?.contentType ?? "application/xml",
|
|
640
|
+
compression: payloadPart?.compression,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
624
644
|
return {
|
|
625
645
|
bytes: exportSession.serialize(),
|
|
626
646
|
mimeType: DOCX_MIME_TYPE,
|
|
@@ -133,6 +133,13 @@ export function normalizeMarkupDisplay(
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
const COMMENT_ACTIVE_MARKER_CLASS =
|
|
137
|
+
"bg-comment-soft underline decoration-comment decoration-2 underline-offset-4 decoration-solid box-decoration-clone rounded-[2px] shadow-[inset_0_-2px_0_var(--color-comment)]";
|
|
138
|
+
const COMMENT_OPEN_MARKER_CLASS =
|
|
139
|
+
"bg-comment-soft underline decoration-comment decoration-2 underline-offset-4 decoration-solid box-decoration-clone rounded-[2px]";
|
|
140
|
+
const COMMENT_RESOLVED_MARKER_CLASS =
|
|
141
|
+
"bg-comment-soft underline decoration-comment/60 decoration-1 underline-offset-4 decoration-solid box-decoration-clone rounded-[2px]";
|
|
142
|
+
|
|
136
143
|
export function getCommentHighlightClass(
|
|
137
144
|
model: CommentDecorationModel | undefined,
|
|
138
145
|
from: number,
|
|
@@ -147,23 +154,29 @@ export function getCommentHighlightClass(
|
|
|
147
154
|
switch (normalizeMarkupDisplay(markupDisplay)) {
|
|
148
155
|
case "no-markup":
|
|
149
156
|
case "original":
|
|
150
|
-
|
|
157
|
+
if (state.hasActive) {
|
|
158
|
+
return COMMENT_ACTIVE_MARKER_CLASS;
|
|
159
|
+
}
|
|
160
|
+
if (state.hasOpen) {
|
|
161
|
+
return COMMENT_OPEN_MARKER_CLASS;
|
|
162
|
+
}
|
|
163
|
+
return COMMENT_RESOLVED_MARKER_CLASS;
|
|
151
164
|
case "simple-markup":
|
|
152
165
|
if (state.hasActive) {
|
|
153
|
-
return
|
|
166
|
+
return COMMENT_ACTIVE_MARKER_CLASS;
|
|
154
167
|
}
|
|
155
168
|
if (state.hasOpen) {
|
|
156
|
-
return
|
|
169
|
+
return COMMENT_OPEN_MARKER_CLASS;
|
|
157
170
|
}
|
|
158
|
-
return
|
|
171
|
+
return COMMENT_RESOLVED_MARKER_CLASS;
|
|
159
172
|
case "all-markup":
|
|
160
173
|
if (state.hasActive) {
|
|
161
|
-
return
|
|
174
|
+
return COMMENT_ACTIVE_MARKER_CLASS;
|
|
162
175
|
}
|
|
163
176
|
if (state.hasOpen) {
|
|
164
|
-
return
|
|
177
|
+
return COMMENT_OPEN_MARKER_CLASS;
|
|
165
178
|
}
|
|
166
|
-
return
|
|
179
|
+
return COMMENT_RESOLVED_MARKER_CLASS;
|
|
167
180
|
}
|
|
168
181
|
}
|
|
169
182
|
|
|
@@ -199,6 +199,14 @@ export interface ReplaceStateOptions extends PreservePositionOptions {
|
|
|
199
199
|
* Tests override it to capture the release callback.
|
|
200
200
|
*/
|
|
201
201
|
scheduleMicrotask?: (callback: () => void) => void;
|
|
202
|
+
/**
|
|
203
|
+
* Synchronous work that must happen after `view.updateState(newState)`
|
|
204
|
+
* but before the optional scroll restore. The ProseMirror surface uses
|
|
205
|
+
* this to dispatch runtime decoration inputs into the PM plugin while
|
|
206
|
+
* the same captured scroll anchor still protects the whole replacement
|
|
207
|
+
* cycle.
|
|
208
|
+
*/
|
|
209
|
+
afterUpdateState?: () => void;
|
|
202
210
|
}
|
|
203
211
|
|
|
204
212
|
/**
|
|
@@ -212,8 +220,9 @@ export interface ReplaceStateOptions extends PreservePositionOptions {
|
|
|
212
220
|
* `preserveScrollAnchor: true` and a live `geometryFacet`
|
|
213
221
|
* 2. suppressionRef.current = true
|
|
214
222
|
* 3. view.updateState(newState) ← PM may fire selection events here
|
|
215
|
-
* 4. (
|
|
216
|
-
* 5.
|
|
223
|
+
* 4. options.afterUpdateState?.() ← optional decoration/plugin sync
|
|
224
|
+
* 5. (optional) restore scroll anchor
|
|
225
|
+
* 6. queueMicrotask(() => suppressionRef.current = false)
|
|
217
226
|
*
|
|
218
227
|
* The microtask release guarantees the flag is still `true` for any
|
|
219
228
|
* synchronous selection-change handler that fires during (3), and
|
|
@@ -242,6 +251,7 @@ export function replaceStatePreservingPosition(
|
|
|
242
251
|
: null;
|
|
243
252
|
options.suppressionRef.current = true;
|
|
244
253
|
options.view.updateState(newState);
|
|
254
|
+
options.afterUpdateState?.();
|
|
245
255
|
if (preserved) {
|
|
246
256
|
const restored = restorePosition(preserved, options);
|
|
247
257
|
if (!restored) {
|
|
@@ -936,6 +936,7 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
936
936
|
recordPerfSample("pm.rebuild");
|
|
937
937
|
incrementInvalidationCounter("pm.laneA.rebuilds");
|
|
938
938
|
|
|
939
|
+
let appliedDecorationProps = false;
|
|
939
940
|
if (!viewRef.current) {
|
|
940
941
|
const view = new EditorView(mountRef.current, {
|
|
941
942
|
state,
|
|
@@ -960,32 +961,42 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
960
961
|
// would move by more than the small local-edit budget, the helper
|
|
961
962
|
// refuses that exact target but restores the captured scrollTop so
|
|
962
963
|
// a PM/browser-origin top jump is not accepted as the final state.
|
|
964
|
+
// The runtime-decoration plugin is also updated inside the funnel:
|
|
965
|
+
// its meta transaction can rebuild page-break/widget decorations
|
|
966
|
+
// synchronously, so it must be included before the final restore.
|
|
963
967
|
//
|
|
964
968
|
// Ordering invariant is regression-guarded by
|
|
965
969
|
// `test/ui-tailwind/editor-surface/preserve-position-ordering.test.ts`.
|
|
970
|
+
const view = viewRef.current;
|
|
966
971
|
const scrollAnchorPolicy = pendingRebuildScrollAnchorRef.current;
|
|
967
972
|
pendingRebuildScrollAnchorRef.current = null;
|
|
968
973
|
const preserveScrollAnchor = shouldPreserveScrollAnchorForRebuild({
|
|
969
974
|
policy: scrollAnchorPolicy,
|
|
970
|
-
view
|
|
975
|
+
view,
|
|
971
976
|
geometryFacet: props.geometryFacet,
|
|
972
977
|
previousStory: lastBuiltStoryRef.current,
|
|
973
978
|
nextStory: snapshot.activeStory,
|
|
974
979
|
});
|
|
975
980
|
replaceStatePreservingPosition(
|
|
976
981
|
{
|
|
977
|
-
view
|
|
982
|
+
view,
|
|
978
983
|
geometryFacet: props.geometryFacet,
|
|
979
984
|
suppressionRef: suppressSelectionEchoRef,
|
|
980
985
|
preserveScrollAnchor,
|
|
981
986
|
maxScrollDeltaPx: BOUNDED_TYPING_SCROLL_RESTORE_MAX_DELTA_PX,
|
|
987
|
+
afterUpdateState: () => {
|
|
988
|
+
applyDecorationProps(view, positionMap);
|
|
989
|
+
appliedDecorationProps = true;
|
|
990
|
+
},
|
|
982
991
|
},
|
|
983
992
|
state,
|
|
984
993
|
);
|
|
985
994
|
}
|
|
986
995
|
documentBuildKeyRef.current = documentBuildKey;
|
|
987
996
|
lastBuiltStoryRef.current = snapshot.activeStory;
|
|
988
|
-
|
|
997
|
+
if (!appliedDecorationProps) {
|
|
998
|
+
applyDecorationProps(viewRef.current, positionMap);
|
|
999
|
+
}
|
|
989
1000
|
|
|
990
1001
|
if (activeSearchRef.current) {
|
|
991
1002
|
applySearch(
|