@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.
Files changed (118) hide show
  1. package/package.json +38 -37
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/editor-state-types.ts +110 -0
  6. package/src/api/external-custody-types.ts +74 -0
  7. package/src/api/participants-types.ts +18 -0
  8. package/src/api/public-types.ts +541 -5
  9. package/src/api/scope-metadata-resolver-types.ts +88 -0
  10. package/src/core/commands/formatting-commands.ts +1 -1
  11. package/src/core/commands/index.ts +601 -9
  12. package/src/core/search/search-text.ts +15 -2
  13. package/src/index.ts +131 -1
  14. package/src/io/docx-session.ts +672 -2
  15. package/src/io/export/escape-xml-attribute.ts +26 -0
  16. package/src/io/export/external-send.ts +188 -0
  17. package/src/io/export/serialize-comments.ts +13 -16
  18. package/src/io/export/serialize-footnotes.ts +17 -24
  19. package/src/io/export/serialize-headers-footers.ts +17 -24
  20. package/src/io/export/serialize-main-document.ts +59 -62
  21. package/src/io/export/serialize-numbering.ts +20 -27
  22. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  23. package/src/io/export/serialize-tables.ts +8 -15
  24. package/src/io/export/table-properties-xml.ts +25 -32
  25. package/src/io/import/external-reimport.ts +40 -0
  26. package/src/io/load-scheduler.ts +230 -0
  27. package/src/io/normalize/normalize-text.ts +83 -0
  28. package/src/io/ooxml/bw-xml.ts +244 -0
  29. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  30. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  31. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  32. package/src/io/ooxml/external-custody-payload.ts +102 -0
  33. package/src/io/ooxml/participants-payload.ts +97 -0
  34. package/src/io/ooxml/payload-signature.ts +112 -0
  35. package/src/io/ooxml/workflow-payload-validator.ts +367 -0
  36. package/src/io/ooxml/workflow-payload.ts +317 -7
  37. package/src/runtime/awareness-identity.ts +173 -0
  38. package/src/runtime/collab/event-types.ts +27 -0
  39. package/src/runtime/collab-session-bridge.ts +157 -0
  40. package/src/runtime/collab-session-facet.ts +193 -0
  41. package/src/runtime/collab-session.ts +273 -0
  42. package/src/runtime/comment-negotiation-sync.ts +91 -0
  43. package/src/runtime/comment-negotiation.ts +158 -0
  44. package/src/runtime/comment-presentation.ts +223 -0
  45. package/src/runtime/document-runtime.ts +639 -124
  46. package/src/runtime/editor-state-channel.ts +544 -0
  47. package/src/runtime/editor-state-integration.ts +217 -0
  48. package/src/runtime/external-send-runtime.ts +117 -0
  49. package/src/runtime/layout/docx-font-loader.ts +11 -30
  50. package/src/runtime/layout/index.ts +2 -0
  51. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  52. package/src/runtime/layout/layout-engine-instance.ts +139 -14
  53. package/src/runtime/layout/page-graph.ts +79 -7
  54. package/src/runtime/layout/paginated-layout-engine.ts +441 -48
  55. package/src/runtime/layout/public-facet.ts +585 -14
  56. package/src/runtime/layout/table-row-split.ts +316 -0
  57. package/src/runtime/markdown-sanitizer.ts +132 -0
  58. package/src/runtime/participants.ts +134 -0
  59. package/src/runtime/perf-counters.ts +28 -0
  60. package/src/runtime/render/render-frame-types.ts +17 -0
  61. package/src/runtime/render/render-kernel.ts +172 -29
  62. package/src/runtime/resign-payload.ts +120 -0
  63. package/src/runtime/surface-projection.ts +10 -5
  64. package/src/runtime/tamper-gate.ts +157 -0
  65. package/src/runtime/workflow-markup.ts +80 -16
  66. package/src/runtime/workflow-rail-segments.ts +244 -5
  67. package/src/ui/WordReviewEditor.tsx +654 -45
  68. package/src/ui/editor-command-bag.ts +14 -0
  69. package/src/ui/editor-runtime-boundary.ts +111 -11
  70. package/src/ui/editor-shell-view.tsx +21 -0
  71. package/src/ui/editor-surface-controller.tsx +5 -0
  72. package/src/ui/headless/selection-helpers.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  74. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  75. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  76. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  77. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  78. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  79. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  80. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  81. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  82. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  83. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  84. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  85. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  86. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  87. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  88. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  89. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  90. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
  91. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  92. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  93. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  94. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  95. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  96. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  97. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  98. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  99. package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
  100. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  101. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  102. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  103. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +10 -256
  104. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  105. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  106. package/src/ui-tailwind/index.ts +37 -1
  107. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  108. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  109. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  110. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  111. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  112. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  113. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  114. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  115. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  116. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  117. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  118. package/src/ui-tailwind/tw-review-workspace.tsx +455 -118
@@ -12,6 +12,54 @@ import type {
12
12
  WorkflowScope,
13
13
  WorkflowWorkItem,
14
14
  } from "../../api/public-types.ts";
15
+ import type { EditorStateNamespace, EditorStateLocation } from "../../api/editor-state-types.ts";
16
+ import {
17
+ validateWorkflowPayloadEnvelope,
18
+ type ValidatorIssue,
19
+ } from "./workflow-payload-validator.ts";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Schema 1.2 editor-state types (edge-of-module shape, channel-free)
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface EditorStatePayloadNamespaceEntry {
26
+ namespace: EditorStateNamespace;
27
+ schemaVersion: string;
28
+ /** JSON-serializable blob (CDATA-wrapped on serialize). Exactly one of inline/storageRef. */
29
+ inline?: unknown;
30
+ storageRef?: { location: Exclude<EditorStateLocation, "in-document">; entryKey: string };
31
+ /**
32
+ * Parser-internal flag: set when the `<bw:inline>` CDATA block contained
33
+ * malformed JSON. Hydration surfaces this as `editor_state_part_load_failed`
34
+ * rather than silently dropping the entry.
35
+ */
36
+ malformedInline?: boolean;
37
+ }
38
+
39
+ export interface EditorStatePayload {
40
+ entries: EditorStatePayloadNamespaceEntry[];
41
+ /** Unknown namespaces preserved for round-trip — raw XML fragment per entry, keyed by name. */
42
+ unknownNamespaces?: Array<{ name: string; rawXml: string }>;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Schema 1.1 parser helpers (fail-closed per spec §8.2)
47
+ // ---------------------------------------------------------------------------
48
+
49
+ function parseClosedEnum<T extends string>(
50
+ raw: string | undefined,
51
+ allowed: readonly T[],
52
+ ): T | undefined {
53
+ if (raw === undefined) return undefined;
54
+ return (allowed as readonly string[]).includes(raw) ? (raw as T) : undefined;
55
+ }
56
+
57
+ function parseNonNegativeInt(raw: string | undefined): number | undefined {
58
+ if (raw === undefined) return undefined;
59
+ const n = Number(raw);
60
+ if (!Number.isInteger(n) || n < 0) return undefined;
61
+ return n;
62
+ }
15
63
 
16
64
  export const WORKFLOW_PAYLOAD_PART_PATH = "/customXml/item1.xml";
17
65
  export const WORKFLOW_PAYLOAD_ITEM_PROPS_PART_PATH = "/customXml/itemProps1.xml";
@@ -39,6 +87,9 @@ type EmbeddedWorkflowPayloadDescriptor = {
39
87
  export interface WorkflowPayloadEnvelope {
40
88
  workflowMetadata: WorkflowMetadataSnapshot;
41
89
  workflowOverlay?: WorkflowOverlay;
90
+ /** Present only when the payload was version 1.2+ and carried a non-empty <bw:editorState> block. */
91
+ editorState?: EditorStatePayload;
92
+ validatorIssues?: readonly ValidatorIssue[];
42
93
  }
43
94
 
44
95
  export function getDocumentBackedWorkflowMetadata(
@@ -63,6 +114,8 @@ export function buildWorkflowPayloadParts(input: {
63
114
  sourcePackage: OpcPackage;
64
115
  workflowMetadata: WorkflowMetadataSnapshot | undefined;
65
116
  workflowOverlay?: WorkflowOverlay;
117
+ /** Optional schema 1.2 editor-state block. Omitted when empty. */
118
+ editorState?: EditorStatePayload;
66
119
  documentId: string;
67
120
  createdAt: string;
68
121
  updatedAt: string;
@@ -102,6 +155,7 @@ export function buildWorkflowPayloadParts(input: {
102
155
  producerVersion: input.producerVersion,
103
156
  workflowMetadata,
104
157
  workflowOverlay: input.workflowOverlay,
158
+ editorState: input.editorState,
105
159
  preservedExtensionsXml: getPreservedExtensionsXml(input.sourcePackage, descriptor.payloadPartPath),
106
160
  });
107
161
  const itemPropsXml = buildItemPropsXml(descriptor.itemId);
@@ -144,7 +198,8 @@ export function parseWorkflowPayloadEnvelopeFromPackage(
144
198
  }
145
199
 
146
200
  const xml = decodeUtf8(payloadPart.bytes);
147
- const entryMatches = [...xml.matchAll(/<bw:entry\b([^>]*)>([\s\S]*?)<\/bw:entry>/g)];
201
+ // Match both self-closing (<bw:entry .../>) and body-bearing (<bw:entry ...>body</bw:entry>)
202
+ const entryMatches = [...xml.matchAll(/<bw:entry\b([^>]*)(?:\/>|>([\s\S]*?)<\/bw:entry>)/g)];
148
203
  const definitions: WorkflowMetadataDefinition[] = [];
149
204
  const entries: WorkflowMetadataEntry[] = [];
150
205
 
@@ -183,6 +238,14 @@ export function parseWorkflowPayloadEnvelopeFromPackage(
183
238
  if (!entryId || !metadataId) {
184
239
  continue;
185
240
  }
241
+
242
+ const entryMetadataPersistence = parseClosedEnum(
243
+ attributes.metadataPersistence,
244
+ ["internal", "external", "inherit"] as const,
245
+ );
246
+ const entryStorageRef = attributes.storageRef;
247
+ const entryMetadataVersion = parseNonNegativeInt(attributes.metadataVersion);
248
+
186
249
  entries.push({
187
250
  entryId,
188
251
  metadataId,
@@ -198,15 +261,191 @@ export function parseWorkflowPayloadEnvelopeFromPackage(
198
261
  ...(attributes.scope?.startsWith("workItem:")
199
262
  ? { workItemId: attributes.scope.slice("workItem:".length) }
200
263
  : {}),
264
+ ...(entryMetadataPersistence !== undefined ? { metadataPersistence: entryMetadataPersistence } : {}),
265
+ ...(entryStorageRef !== undefined ? { storageRef: entryStorageRef } : {}),
266
+ ...(entryMetadataVersion !== undefined ? { metadataVersion: entryMetadataVersion } : {}),
201
267
  });
202
268
  }
203
269
 
270
+ const validatorIssues = validateWorkflowPayloadEnvelope(xml);
271
+ const editorState = parseEditorStateXml(xml);
204
272
  return {
205
273
  workflowMetadata: {
206
274
  definitions,
207
275
  entries,
208
276
  },
209
277
  workflowOverlay: parseWorkflowOverlay(xml),
278
+ ...(editorState !== undefined ? { editorState } : {}),
279
+ ...(validatorIssues.length > 0 ? { validatorIssues } : {}),
280
+ };
281
+ }
282
+
283
+ function needsSchemaV11(input: {
284
+ workflowMetadata: WorkflowMetadataSnapshot;
285
+ workflowOverlay?: WorkflowOverlay;
286
+ }): boolean {
287
+ if (input.workflowOverlay?.metadataPersistence !== undefined) {
288
+ return true;
289
+ }
290
+ if (
291
+ input.workflowOverlay?.scopes?.some(
292
+ (s) => s.metadataPersistence !== undefined,
293
+ )
294
+ ) {
295
+ return true;
296
+ }
297
+ if (
298
+ input.workflowOverlay?.scopes?.some((s) =>
299
+ s.metadata?.some(
300
+ (f) =>
301
+ f.metadataPersistence !== undefined ||
302
+ f.storageRef !== undefined ||
303
+ f.metadataVersion !== undefined,
304
+ ),
305
+ )
306
+ ) {
307
+ return true;
308
+ }
309
+ if (
310
+ input.workflowMetadata.entries.some(
311
+ (e) =>
312
+ e.metadataPersistence !== undefined ||
313
+ e.storageRef !== undefined ||
314
+ e.metadataVersion !== undefined,
315
+ )
316
+ ) {
317
+ return true;
318
+ }
319
+ return false;
320
+ }
321
+
322
+ /** Returns true when the payload has at least one namespace entry or unknown namespace to emit. */
323
+ function hasNonEmptyEditorState(es: EditorStatePayload | undefined): boolean {
324
+ if (!es) return false;
325
+ return (es.entries.length > 0) || ((es.unknownNamespaces?.length ?? 0) > 0);
326
+ }
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // Known namespace names (closed set for round-trip; unknown names are opaque)
330
+ // ---------------------------------------------------------------------------
331
+
332
+ const KNOWN_EDITOR_STATE_NAMESPACES: readonly EditorStateNamespace[] = [
333
+ "hostAnnotations",
334
+ "workflowOverlay",
335
+ "workflowMetadata",
336
+ "workItems",
337
+ ];
338
+
339
+ const KNOWN_STORAGE_LOCATIONS: ReadonlyArray<Exclude<EditorStateLocation, "in-document">> = [
340
+ "rowstore",
341
+ "key-only",
342
+ ];
343
+
344
+ /**
345
+ * Escapes `]]>` inside CDATA content using the standard XML split:
346
+ * `]]>` → `]]]]><![CDATA[>`
347
+ */
348
+ function escapeCdata(text: string): string {
349
+ return text.replace(/\]\]>/g, "]]]]><![CDATA[>");
350
+ }
351
+
352
+ /**
353
+ * Builds the `<bw:editorState>…</bw:editorState>` block.
354
+ * Returns an empty string when both entries and unknownNamespaces are empty.
355
+ */
356
+ export function buildEditorStateXml(payload: EditorStatePayload): string {
357
+ if (!hasNonEmptyEditorState(payload)) {
358
+ return "";
359
+ }
360
+
361
+ const knownLines = payload.entries.map((entry) => {
362
+ const nsOpen = `<bw:namespace name="${escapeXml(entry.namespace)}" schemaVersion="${escapeXml(entry.schemaVersion)}">`;
363
+ let content: string;
364
+ if (entry.storageRef !== undefined) {
365
+ content = `<bw:storageRef location="${escapeXml(entry.storageRef.location)}" entryKey="${escapeXml(entry.storageRef.entryKey)}" />`;
366
+ } else {
367
+ const json = escapeCdata(JSON.stringify(entry.inline));
368
+ content = `<bw:inline><![CDATA[${json}]]></bw:inline>`;
369
+ }
370
+ return `${nsOpen}${content}</bw:namespace>`;
371
+ });
372
+
373
+ const unknownLines = (payload.unknownNamespaces ?? []).map((u) => u.rawXml);
374
+
375
+ const innerXml = [...knownLines, ...unknownLines].join("\n");
376
+ return `<bw:editorState>\n${indentLines(innerXml, 2)}\n</bw:editorState>`;
377
+ }
378
+
379
+ /**
380
+ * Parses the `<bw:editorState>` block from a full workflow payload XML string.
381
+ * Returns `undefined` when no block is present.
382
+ * Malformed JSON is silently skipped (validator flags it separately).
383
+ */
384
+ export function parseEditorStateXml(xml: string): EditorStatePayload | undefined {
385
+ const blockMatch = xml.match(/<bw:editorState\b[^>]*>([\s\S]*?)<\/bw:editorState>/u);
386
+ if (!blockMatch) {
387
+ return undefined;
388
+ }
389
+ const blockBody = blockMatch[1] ?? "";
390
+
391
+ const entries: EditorStatePayloadNamespaceEntry[] = [];
392
+ const unknownNamespaces: Array<{ name: string; rawXml: string }> = [];
393
+
394
+ // Match each <bw:namespace ... > ... </bw:namespace>
395
+ const nsRe = /<bw:namespace\b([^>]*)>([\s\S]*?)<\/bw:namespace>/gu;
396
+ for (const nsMatch of blockBody.matchAll(nsRe)) {
397
+ const attrsStr = nsMatch[1] ?? "";
398
+ const nsBody = nsMatch[2] ?? "";
399
+ const rawXml = nsMatch[0] ?? "";
400
+ const attrs = parseAttributes(attrsStr);
401
+ const name = attrs.name ?? "";
402
+ const schemaVersion = attrs.schemaVersion ?? "";
403
+
404
+ if (!(KNOWN_EDITOR_STATE_NAMESPACES as readonly string[]).includes(name)) {
405
+ unknownNamespaces.push({ name, rawXml });
406
+ continue;
407
+ }
408
+
409
+ const namespace = name as EditorStateNamespace;
410
+
411
+ // Parse <bw:storageRef ... />
412
+ const storageRefMatch = nsBody.match(/<bw:storageRef\b([^>]*)\/>/u);
413
+ if (storageRefMatch) {
414
+ const refAttrs = parseAttributes(storageRefMatch[1] ?? "");
415
+ const location = refAttrs.location as Exclude<EditorStateLocation, "in-document"> | undefined;
416
+ const entryKey = refAttrs.entryKey ?? "";
417
+ entries.push({
418
+ namespace,
419
+ schemaVersion,
420
+ storageRef: { location: location ?? "rowstore", entryKey },
421
+ });
422
+ continue;
423
+ }
424
+
425
+ // Parse <bw:inline>...</bw:inline> — extract CDATA content
426
+ const inlineMatch = nsBody.match(/<bw:inline\b[^>]*>([\s\S]*?)<\/bw:inline>/u);
427
+ if (inlineMatch) {
428
+ const raw = inlineMatch[1] ?? "";
429
+ // Extract CDATA content — handles split CDATA sections from ]]> escaping
430
+ const cdataText = raw.replace(/<!\[CDATA\[|\]\]>/g, "").trim();
431
+ try {
432
+ const parsed = JSON.parse(cdataText) as unknown;
433
+ entries.push({ namespace, schemaVersion, inline: parsed });
434
+ } catch {
435
+ // Malformed JSON: surface to the runtime so the host sees a
436
+ // load-failure event rather than silently losing the entry.
437
+ entries.push({ namespace, schemaVersion, malformedInline: true });
438
+ }
439
+ }
440
+ }
441
+
442
+ if (entries.length === 0 && unknownNamespaces.length === 0) {
443
+ return undefined;
444
+ }
445
+
446
+ return {
447
+ entries,
448
+ ...(unknownNamespaces.length > 0 ? { unknownNamespaces } : {}),
210
449
  };
211
450
  }
212
451
 
@@ -217,8 +456,16 @@ function buildPayloadXml(input: {
217
456
  producerVersion: string;
218
457
  workflowMetadata: WorkflowMetadataSnapshot;
219
458
  workflowOverlay?: WorkflowOverlay;
459
+ editorState?: EditorStatePayload;
220
460
  preservedExtensionsXml: string;
221
461
  }): string {
462
+ const hasEditorState = hasNonEmptyEditorState(input.editorState);
463
+ const schemaVersion = hasEditorState
464
+ ? "1.2"
465
+ : needsSchemaV11(input)
466
+ ? "1.1"
467
+ : "1.0";
468
+
222
469
  const definitionEntriesXml = input.workflowMetadata.definitions
223
470
  .map((definition) => [
224
471
  `<bw:entry`,
@@ -237,7 +484,7 @@ function buildPayloadXml(input: {
237
484
  const serializedValue = serializeWorkflowMetadataValue(entry.value);
238
485
  const storyTargetAttributes = serializeWorkflowStoryTarget(entry.storyTarget);
239
486
 
240
- return [
487
+ const baseAttrs = [
241
488
  `<bw:entry`,
242
489
  ` key="${escapeXml(entry.entryId)}"`,
243
490
  ` metadataId="${escapeXml(entry.metadataId)}"`,
@@ -245,8 +492,24 @@ function buildPayloadXml(input: {
245
492
  ` valueType="${escapeXml(serializedValue.type)}"`,
246
493
  ` scope="${escapeXml(entry.scopeId ? `scope:${entry.scopeId}` : entry.workItemId ? `workItem:${entry.workItemId}` : "document")}"`,
247
494
  storyTargetAttributes,
248
- `>${escapeXml(serializedValue.text)}</bw:entry>`,
495
+ entry.metadataPersistence && entry.metadataPersistence !== "inherit"
496
+ ? ` metadataPersistence="${escapeXml(entry.metadataPersistence)}"`
497
+ : "",
498
+ entry.metadataPersistence === "external" && entry.storageRef
499
+ ? ` storageRef="${escapeXml(entry.storageRef)}"`
500
+ : "",
501
+ typeof entry.metadataVersion === "number" &&
502
+ Number.isInteger(entry.metadataVersion) &&
503
+ entry.metadataVersion >= 0
504
+ ? ` metadataVersion="${entry.metadataVersion.toString()}"`
505
+ : "",
249
506
  ].join("");
507
+
508
+ if (entry.metadataPersistence === "external") {
509
+ return `${baseAttrs}/>`;
510
+ }
511
+
512
+ return `${baseAttrs}>${escapeXml(serializedValue.text)}</bw:entry>`;
250
513
  })
251
514
  .filter((value) => value.length > 0)
252
515
  .join("\n");
@@ -256,9 +519,11 @@ function buildPayloadXml(input: {
256
519
  .filter((value) => value.trim().length > 0)
257
520
  .join("\n");
258
521
 
522
+ const editorStateXml = hasEditorState ? buildEditorStateXml(input.editorState!) : "";
523
+
259
524
  return [
260
525
  `<?xml version="1.0" encoding="UTF-8"?>`,
261
- `<bw:workflowPayload xmlns:bw="urn:beyondwork:workflow-payload:1" version="1.0" payloadId="${escapeXml(input.descriptor.payloadId)}" itemId="${escapeXml(input.descriptor.itemId)}" documentId="${escapeXml(input.descriptor.documentId)}" createdAt="${escapeXml(input.createdAt)}" updatedAt="${escapeXml(input.updatedAt)}">`,
526
+ `<bw:workflowPayload xmlns:bw="urn:beyondwork:workflow-payload:1" version="${schemaVersion}" payloadId="${escapeXml(input.descriptor.payloadId)}" itemId="${escapeXml(input.descriptor.itemId)}" documentId="${escapeXml(input.descriptor.documentId)}" createdAt="${escapeXml(input.createdAt)}" updatedAt="${escapeXml(input.updatedAt)}">`,
262
527
  ` <bw:manifest>`,
263
528
  ` <bw:producer name="@beyondwork/docx-react-component" version="${escapeXml(input.producerVersion)}" />`,
264
529
  ` <bw:compatibility rebindMode="best-effort" preserveUnknownExtensions="true" />`,
@@ -267,6 +532,7 @@ function buildPayloadXml(input: {
267
532
  definitionEntriesXml ? indentLines(definitionEntriesXml, 4) : "",
268
533
  metadataEntriesXml ? indentLines(metadataEntriesXml, 4) : "",
269
534
  ` </bw:metadata>`,
535
+ editorStateXml,
270
536
  extensionsXml
271
537
  ? ` <bw:extensions>\n${indentLines(extensionsXml, 4)}\n </bw:extensions>`
272
538
  : ` <bw:extensions />`,
@@ -357,6 +623,9 @@ function buildWorkblockExtensionXml(workflowOverlay: WorkflowOverlay | undefined
357
623
  workflowOverlay.activeWorkItemId
358
624
  ? ` activeWorkItemId="${escapeXml(workflowOverlay.activeWorkItemId)}"`
359
625
  : "",
626
+ workflowOverlay.metadataPersistence
627
+ ? ` metadataPersistence="${escapeXml(workflowOverlay.metadataPersistence)}"`
628
+ : "",
360
629
  `>`,
361
630
  ].join(""),
362
631
  ` <bw:workItems>`,
@@ -380,6 +649,9 @@ function buildWorkflowScopeXml(scope: WorkflowScope): string {
380
649
  scope.workItemId ? ` workItemRef="${escapeXml(scope.workItemId)}"` : "",
381
650
  scope.label ? ` label="${escapeXml(scope.label)}"` : "",
382
651
  scope.domain ? ` domain="${escapeXml(scope.domain)}"` : "",
652
+ scope.metadataPersistence && scope.metadataPersistence !== "inherit"
653
+ ? ` metadataPersistence="${escapeXml(scope.metadataPersistence)}"`
654
+ : "",
383
655
  `>`,
384
656
  indentLines(buildWorkflowAnchorXml(scope.anchor, scope.storyTarget), 2),
385
657
  scopeMetadataXml ? indentLines(scopeMetadataXml, 2) : "",
@@ -397,12 +669,28 @@ function buildWorkflowScopeMetadataXml(scopeMetadata: WorkflowScope["metadata"])
397
669
  scopeMetadata
398
670
  .map((field) => {
399
671
  const serialized = serializeWorkflowScopeMetadataValue(field.valueType, field.value);
400
- return [
672
+ const baseAttrs = [
401
673
  `<bw:field`,
402
674
  ` key="${escapeXml(field.key)}"`,
403
675
  serialized.valueType ? ` type="${escapeXml(serialized.valueType)}"` : "",
404
- `>${escapeXml(serialized.text)}</bw:field>`,
676
+ field.metadataPersistence && field.metadataPersistence !== "inherit"
677
+ ? ` metadataPersistence="${escapeXml(field.metadataPersistence)}"`
678
+ : "",
679
+ field.metadataPersistence === "external" && field.storageRef
680
+ ? ` storageRef="${escapeXml(field.storageRef)}"`
681
+ : "",
682
+ typeof field.metadataVersion === "number" &&
683
+ Number.isInteger(field.metadataVersion) &&
684
+ field.metadataVersion >= 0
685
+ ? ` metadataVersion="${field.metadataVersion.toString()}"`
686
+ : "",
405
687
  ].join("");
688
+
689
+ if (field.metadataPersistence === "external") {
690
+ return `${baseAttrs}/>`;
691
+ }
692
+
693
+ return `${baseAttrs}>${escapeXml(serialized.text)}</bw:field>`;
406
694
  })
407
695
  .join("\n"),
408
696
  2,
@@ -571,11 +859,17 @@ function parseWorkflowOverlay(xml: string): WorkflowOverlay | undefined {
571
859
  .map((match) => parseWorkflowWorkItem(match[1] ?? "", match[2] ?? ""))
572
860
  .filter((item): item is WorkflowWorkItem => item !== null);
573
861
 
862
+ const overlayMetadataPersistence = parseClosedEnum(
863
+ overlayAttributes.metadataPersistence,
864
+ ["internal", "external"] as const,
865
+ );
866
+
574
867
  return {
575
868
  overlayVersion: (overlayAttributes.overlayVersion as WorkflowOverlay["overlayVersion"]) ?? "workflow-overlay/1",
576
869
  activeWorkItemId: overlayAttributes.activeWorkItemId ?? null,
577
870
  scopes,
578
871
  ...(workItems.length > 0 ? { workItems } : {}),
872
+ ...(overlayMetadataPersistence !== undefined ? { metadataPersistence: overlayMetadataPersistence } : {}),
579
873
  };
580
874
  }
581
875
 
@@ -588,6 +882,11 @@ function parseWorkflowScope(attributesSource: string, body: string): WorkflowSco
588
882
  const anchor = anchorMatch
589
883
  ? parseWorkflowOverlayAnchor(anchorMatch[1] ?? "", anchorMatch[2] ?? "")
590
884
  : createDefaultRangeAnchor(0, 0);
885
+ const scopeMetadataPersistence = parseClosedEnum(
886
+ attributes.metadataPersistence,
887
+ ["internal", "external", "inherit"] as const,
888
+ );
889
+
591
890
  return {
592
891
  scopeId: attributes.id,
593
892
  version: attributes.version !== undefined ? Number(attributes.version) : undefined,
@@ -598,6 +897,7 @@ function parseWorkflowScope(attributesSource: string, body: string): WorkflowSco
598
897
  label: attributes.label,
599
898
  domain: attributes.domain as WorkflowScope["domain"],
600
899
  metadata: parseWorkflowScopeMetadata(body),
900
+ ...(scopeMetadataPersistence !== undefined ? { metadataPersistence: scopeMetadataPersistence } : {}),
601
901
  };
602
902
  }
603
903
 
@@ -606,7 +906,8 @@ function parseWorkflowScopeMetadata(body: string): WorkflowScope["metadata"] | u
606
906
  if (!metadataMatch) {
607
907
  return undefined;
608
908
  }
609
- const fields = [...(metadataMatch[1] ?? "").matchAll(/<bw:field\b([^>]*)>([\s\S]*?)<\/bw:field>/gu)]
909
+ // Match both self-closing (<bw:field .../>) and body-bearing (<bw:field ...>body</bw:field>)
910
+ const fields = [...(metadataMatch[1] ?? "").matchAll(/<bw:field\b([^>]*)(?:\/>|>([\s\S]*?)<\/bw:field>)/gu)]
610
911
  .map((match) => parseWorkflowScopeMetadataField(match[1] ?? "", match[2] ?? ""))
611
912
  .filter((field): field is NonNullable<WorkflowScope["metadata"]>[number] => field !== null);
612
913
  return fields.length > 0 ? fields : undefined;
@@ -621,10 +922,19 @@ function parseWorkflowScopeMetadataField(
621
922
  return null;
622
923
  }
623
924
  const valueType = attributes.type as NonNullable<WorkflowScope["metadata"]>[number]["valueType"] | undefined;
925
+ const fieldMetadataPersistence = parseClosedEnum(
926
+ attributes.metadataPersistence,
927
+ ["internal", "external", "inherit"] as const,
928
+ );
929
+ const fieldStorageRef = attributes.storageRef;
930
+ const fieldMetadataVersion = parseNonNegativeInt(attributes.metadataVersion);
624
931
  return {
625
932
  key: attributes.key,
626
933
  ...(valueType ? { valueType } : {}),
627
934
  value: parseWorkflowScopeMetadataValue(valueType, body),
935
+ ...(fieldMetadataPersistence !== undefined ? { metadataPersistence: fieldMetadataPersistence } : {}),
936
+ ...(fieldStorageRef !== undefined ? { storageRef: fieldStorageRef } : {}),
937
+ ...(fieldMetadataVersion !== undefined ? { metadataVersion: fieldMetadataVersion } : {}),
628
938
  };
629
939
  }
630
940
 
@@ -0,0 +1,173 @@
1
+ import type { Awareness } from "y-protocols/awareness";
2
+
3
+ import type {
4
+ AwarenessIdentity,
5
+ AwarenessPeer,
6
+ CollabPosture,
7
+ PresenceSnapshot,
8
+ TransportStatus,
9
+ } from "../api/awareness-identity-types.ts";
10
+
11
+ const IDENTITY_FIELD = "identity";
12
+
13
+ const ROLE_VOCAB = new Set<AwarenessIdentity["role"]>([
14
+ "author",
15
+ "reviewer",
16
+ "observer",
17
+ ]);
18
+ const KIND_VOCAB = new Set<AwarenessIdentity["authorKind"]>([
19
+ "human",
20
+ "agent",
21
+ "system",
22
+ ]);
23
+
24
+ /**
25
+ * Publishes the local client's `identity` record onto the Awareness
26
+ * channel. Every connected peer sees it through
27
+ * `getPresenceSnapshot`. Safe to call repeatedly — re-writes the
28
+ * field, which Awareness treats as a full replacement.
29
+ */
30
+ export function setLocalIdentity(
31
+ awareness: Awareness,
32
+ identity: AwarenessIdentity,
33
+ ): void {
34
+ awareness.setLocalStateField(IDENTITY_FIELD, validate(identity));
35
+ }
36
+
37
+ /**
38
+ * Clears the local `identity` field. Typically called on disconnect or
39
+ * role downgrade so stale presence rows don't linger.
40
+ */
41
+ export function clearLocalIdentity(awareness: Awareness): void {
42
+ awareness.setLocalStateField(IDENTITY_FIELD, null);
43
+ }
44
+
45
+ export interface PresenceSnapshotArgs {
46
+ awareness?: Awareness;
47
+ transportStatus?: TransportStatus;
48
+ queuedLocalEvents?: number;
49
+ /** Used to filter presence by the active story. When omitted, every peer is returned. */
50
+ activeStoryFilter?: string;
51
+ }
52
+
53
+ /**
54
+ * Builds a `PresenceSnapshot` from the current Awareness map + a
55
+ * host-supplied transport + queue signal. Unknown or malformed
56
+ * identity entries are silently dropped (fail-closed — never render a
57
+ * partially-trusted peer).
58
+ */
59
+ export function getPresenceSnapshot(args: PresenceSnapshotArgs = {}): PresenceSnapshot {
60
+ const peers: AwarenessPeer[] = [];
61
+ if (args.awareness) {
62
+ for (const [clientId, state] of args.awareness.getStates()) {
63
+ const raw = (state as Record<string, unknown>)[IDENTITY_FIELD];
64
+ const identity = coerceIdentity(raw);
65
+ if (!identity) continue;
66
+ if (
67
+ args.activeStoryFilter !== undefined &&
68
+ identity.activeStoryId !== undefined &&
69
+ identity.activeStoryId !== args.activeStoryFilter
70
+ ) {
71
+ continue;
72
+ }
73
+ peers.push({ clientId, ...identity });
74
+ }
75
+ }
76
+ return {
77
+ peers,
78
+ transportStatus: args.transportStatus ?? "offline",
79
+ queuedLocalEvents: args.queuedLocalEvents ?? 0,
80
+ };
81
+ }
82
+
83
+ export interface CollabPostureArgs {
84
+ awareness?: Awareness;
85
+ transportStatus?: TransportStatus;
86
+ }
87
+
88
+ /**
89
+ * Derives the local user's posture from the Awareness map. Role comes
90
+ * from the local-state `identity`; transport comes from the
91
+ * host-supplied signal; peer count is the presence size minus self.
92
+ *
93
+ * Returns a default "unattached author" posture when no Awareness is
94
+ * wired — this matches the fail-open behaviour of the facet (host
95
+ * without a Y.Doc gets an author posture so single-user docs work).
96
+ */
97
+ export function getCollabPosture(args: CollabPostureArgs = {}): CollabPosture {
98
+ if (!args.awareness) {
99
+ return { role: "author", transport: "none", peers: 0 };
100
+ }
101
+ const localClientId = args.awareness.clientID;
102
+ const localState = args.awareness.getLocalState() as
103
+ | Record<string, unknown>
104
+ | null;
105
+ const localIdentity = coerceIdentity(localState?.[IDENTITY_FIELD]);
106
+ const role = localIdentity?.role ?? "author";
107
+
108
+ let peers = 0;
109
+ for (const [clientId, state] of args.awareness.getStates()) {
110
+ if (clientId === localClientId) continue;
111
+ const identity = coerceIdentity(
112
+ (state as Record<string, unknown>)[IDENTITY_FIELD],
113
+ );
114
+ if (identity) peers += 1;
115
+ }
116
+
117
+ return {
118
+ role,
119
+ transport: args.transportStatus === "offline" ? "attached" : "attached",
120
+ peers,
121
+ };
122
+ }
123
+
124
+ function coerceIdentity(raw: unknown): AwarenessIdentity | undefined {
125
+ if (!raw || typeof raw !== "object") return undefined;
126
+ const candidate = raw as Record<string, unknown>;
127
+ const userId = candidate["userId"];
128
+ const displayName = candidate["displayName"];
129
+ const role = candidate["role"];
130
+ const authorKind = candidate["authorKind"];
131
+ if (
132
+ typeof userId !== "string" ||
133
+ userId === "" ||
134
+ typeof displayName !== "string" ||
135
+ displayName === "" ||
136
+ typeof role !== "string" ||
137
+ !ROLE_VOCAB.has(role as AwarenessIdentity["role"]) ||
138
+ typeof authorKind !== "string" ||
139
+ !KIND_VOCAB.has(authorKind as AwarenessIdentity["authorKind"])
140
+ ) {
141
+ return undefined;
142
+ }
143
+ const identity: AwarenessIdentity = {
144
+ userId,
145
+ displayName,
146
+ role: role as AwarenessIdentity["role"],
147
+ authorKind: authorKind as AwarenessIdentity["authorKind"],
148
+ };
149
+ const collabIdentity = candidate["collabIdentity"];
150
+ if (typeof collabIdentity === "string" && collabIdentity !== "") {
151
+ identity.collabIdentity = collabIdentity;
152
+ }
153
+ const activeStoryId = candidate["activeStoryId"];
154
+ if (typeof activeStoryId === "string" && activeStoryId !== "") {
155
+ identity.activeStoryId = activeStoryId;
156
+ }
157
+ return identity;
158
+ }
159
+
160
+ function validate(identity: AwarenessIdentity): AwarenessIdentity {
161
+ if (!identity.userId) throw new Error("awareness identity: userId required");
162
+ if (!identity.displayName)
163
+ throw new Error("awareness identity: displayName required");
164
+ if (!ROLE_VOCAB.has(identity.role)) {
165
+ throw new Error(`awareness identity: unknown role ${identity.role}`);
166
+ }
167
+ if (!KIND_VOCAB.has(identity.authorKind)) {
168
+ throw new Error(
169
+ `awareness identity: unknown authorKind ${identity.authorKind}`,
170
+ );
171
+ }
172
+ return identity;
173
+ }
@@ -83,6 +83,33 @@ export const BROADCAST_COMMAND_TYPES: ReadonlySet<EditorCommand["type"]> = new S
83
83
  "change.reject",
84
84
  "change.accept-all",
85
85
  "change.reject-all",
86
+ "workflow.set-overlay",
87
+ "workflow.clear-overlay",
88
+ "workflow.set-metadata-definitions",
89
+ "workflow.clear-metadata-definitions",
90
+ "workflow.set-metadata-entries",
91
+ "workflow.clear-metadata-entries",
92
+ "host-annotation.set-overlay",
93
+ "host-annotation.clear-overlay",
94
+ "formatting.apply",
95
+ "style.set-paragraph",
96
+ "style.set-table",
97
+ "list.toggle",
98
+ "list.indent",
99
+ "list.outdent",
100
+ "list.restart-numbering",
101
+ "list.continue-numbering",
102
+ "table.apply-structure",
103
+ "image.insert",
104
+ "image.set-layout",
105
+ "image.set-frame",
106
+ "section.insert-break",
107
+ "section.delete-break",
108
+ "section.update-layout",
109
+ "section.set-page-numbering",
110
+ "section.set-header-footer-link",
111
+ "content.insert-page-break",
112
+ "content.insert-table",
86
113
  ]);
87
114
 
88
115
  /**