@beyondwork/docx-react-component 1.0.19 → 1.0.20

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 (70) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +336 -0
  3. package/src/api/session-state.ts +2 -0
  4. package/src/core/commands/formatting-commands.ts +1 -1
  5. package/src/core/commands/index.ts +14 -2
  6. package/src/core/search/search-text.ts +28 -0
  7. package/src/core/state/editor-state.ts +3 -0
  8. package/src/index.ts +21 -0
  9. package/src/io/docx-session.ts +363 -17
  10. package/src/io/export/serialize-comments.ts +104 -34
  11. package/src/io/export/serialize-footnotes.ts +198 -1
  12. package/src/io/export/serialize-headers-footers.ts +203 -10
  13. package/src/io/export/serialize-main-document.ts +83 -3
  14. package/src/io/export/split-review-boundaries.ts +181 -19
  15. package/src/io/normalize/normalize-text.ts +82 -8
  16. package/src/io/ooxml/highlight-colors.ts +39 -0
  17. package/src/io/ooxml/parse-comments.ts +85 -19
  18. package/src/io/ooxml/parse-fields.ts +396 -0
  19. package/src/io/ooxml/parse-footnotes.ts +240 -2
  20. package/src/io/ooxml/parse-headers-footers.ts +431 -7
  21. package/src/io/ooxml/parse-inline-media.ts +15 -1
  22. package/src/io/ooxml/parse-main-document.ts +396 -14
  23. package/src/io/ooxml/parse-revisions.ts +317 -38
  24. package/src/legal/bookmarks.ts +44 -0
  25. package/src/legal/cross-references.ts +59 -1
  26. package/src/model/canonical-document.ts +117 -1
  27. package/src/model/snapshot.ts +85 -1
  28. package/src/review/store/revision-store.ts +6 -0
  29. package/src/review/store/revision-types.ts +1 -0
  30. package/src/runtime/document-navigation.ts +52 -13
  31. package/src/runtime/document-runtime.ts +1521 -75
  32. package/src/runtime/read-only-diagnostics-runtime.ts +8 -0
  33. package/src/runtime/session-capabilities.ts +33 -3
  34. package/src/runtime/surface-projection.ts +86 -25
  35. package/src/runtime/table-schema.ts +2 -2
  36. package/src/runtime/view-state.ts +24 -6
  37. package/src/runtime/workflow-markup.ts +349 -0
  38. package/src/ui/WordReviewEditor.tsx +850 -1315
  39. package/src/ui/editor-command-bag.ts +120 -0
  40. package/src/ui/editor-runtime-boundary.ts +1422 -0
  41. package/src/ui/editor-shell-view.tsx +134 -0
  42. package/src/ui/editor-surface-controller.tsx +51 -0
  43. package/src/ui/headless/revision-decoration-model.ts +4 -4
  44. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  45. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  46. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  47. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  48. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  49. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +27 -2
  50. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  51. package/src/ui-tailwind/editor-surface/perf-probe.ts +86 -14
  52. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +2 -2
  53. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
  54. package/src/ui-tailwind/editor-surface/pm-position-map.ts +1 -1
  55. package/src/ui-tailwind/editor-surface/pm-schema.ts +139 -8
  56. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +98 -48
  57. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
  58. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  59. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +174 -48
  60. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  61. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +7 -7
  62. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  63. package/src/ui-tailwind/review/tw-review-rail.tsx +3 -3
  64. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  65. package/src/ui-tailwind/theme/editor-theme.css +4 -0
  66. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +543 -5
  67. package/src/ui-tailwind/tw-review-workspace.tsx +316 -19
  68. package/src/validation/compatibility-engine.ts +27 -4
  69. package/src/validation/compatibility-report.ts +1 -0
  70. package/src/validation/docx-comment-proof.ts +220 -0
@@ -0,0 +1,34 @@
1
+ import React from "react";
2
+
3
+ export interface ActiveObjectContext {
4
+ kind: "textbox" | "shape";
5
+ display: "inline" | "floating";
6
+ }
7
+
8
+ export interface TwObjectContextToolbarProps {
9
+ activeObject: ActiveObjectContext;
10
+ }
11
+
12
+ export function TwObjectContextToolbar(props: TwObjectContextToolbarProps) {
13
+ const label = props.activeObject.kind === "textbox" ? "Text box" : "Shape";
14
+
15
+ return (
16
+ <div
17
+ data-testid="object-context-toolbar"
18
+ className="flex flex-wrap items-center gap-2 rounded-xl border border-border bg-canvas px-3 py-2 shadow-sm"
19
+ >
20
+ <span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
21
+ Object
22
+ </span>
23
+ <span className="rounded-full bg-surface px-2 py-1 text-[10px] font-medium uppercase tracking-[0.1em] text-secondary">
24
+ {label}
25
+ </span>
26
+ <span className="rounded-full bg-surface px-2 py-1 text-[10px] font-medium uppercase tracking-[0.1em] text-secondary">
27
+ {props.activeObject.display}
28
+ </span>
29
+ <span className="text-xs text-secondary">
30
+ Object selection is active.
31
+ </span>
32
+ </div>
33
+ );
34
+ }
@@ -1,7 +1,7 @@
1
1
  import React, { forwardRef } from "react";
2
2
  import type { FocusEventHandler } from "react";
3
3
  import * as Tooltip from "@radix-ui/react-tooltip";
4
- import { Bold, Italic, MessageSquare, Underline } from "lucide-react";
4
+ import { Baseline, Bold, Highlighter, Italic, MessageSquare, Underline } from "lucide-react";
5
5
 
6
6
  import type { SelectionToolbarModel } from "../../ui/headless/selection-toolbar-model";
7
7
  import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
@@ -14,6 +14,8 @@ export interface TwSelectionToolbarProps {
14
14
  onToggleBold?: () => void;
15
15
  onToggleItalic?: () => void;
16
16
  onToggleUnderline?: () => void;
17
+ onSetTextColor?: (color: string) => void;
18
+ onSetHighlightColor?: (color: string | null) => void;
17
19
  onAddComment?: () => void;
18
20
  }
19
21
 
@@ -60,6 +62,20 @@ export const TwSelectionToolbar = forwardRef<HTMLDivElement, TwSelectionToolbarP
60
62
  disabled={formattingDisabled}
61
63
  onClick={props.onToggleUnderline}
62
64
  />
65
+ <ToolbarActionButton
66
+ icon={<Baseline className="h-3.5 w-3.5" />}
67
+ label="Text color blue"
68
+ pressed={false}
69
+ disabled={formattingDisabled}
70
+ onClick={() => props.onSetTextColor?.("#1660a8")}
71
+ />
72
+ <ToolbarActionButton
73
+ icon={<Highlighter className="h-3.5 w-3.5" />}
74
+ label="Highlight yellow"
75
+ pressed={false}
76
+ disabled={formattingDisabled}
77
+ onClick={() => props.onSetHighlightColor?.("#ffff00")}
78
+ />
63
79
 
64
80
  <div className="mx-0.5 h-4 w-px bg-border" />
65
81
 
@@ -86,9 +102,18 @@ export const TwSelectionToolbar = forwardRef<HTMLDivElement, TwSelectionToolbarP
86
102
  </Tooltip.Portal>
87
103
  </Tooltip.Root>
88
104
 
89
- {contextLabel ? (
105
+ {model.previewText ? (
90
106
  <>
91
107
  <div className="mx-0.5 h-4 w-px bg-border" />
108
+ <span className="max-w-[8rem] truncate text-[11px] text-secondary">
109
+ {model.previewText}
110
+ </span>
111
+ </>
112
+ ) : null}
113
+
114
+ {contextLabel ? (
115
+ <>
116
+ {!model.previewText ? <div className="mx-0.5 h-4 w-px bg-border" /> : null}
92
117
  <span
93
118
  className={`min-w-0 max-w-[11rem] truncate rounded-full px-2 py-0.5 text-[10px] font-medium tracking-[0.08em] ${
94
119
  model.badges.some((badge) => badge.tone === "accent")
@@ -0,0 +1,128 @@
1
+ import React from "react";
2
+
3
+ import type { StyleCatalogSnapshot } from "../../api/public-types";
4
+ import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
5
+
6
+ export interface TwTableContextToolbarProps {
7
+ disabled: boolean;
8
+ tableStyles: StyleCatalogSnapshot["tables"];
9
+ onSetTableStyle?: (styleId: string) => void;
10
+ onAddRowBefore?: () => void;
11
+ onAddRowAfter?: () => void;
12
+ onAddColumnBefore?: () => void;
13
+ onAddColumnAfter?: () => void;
14
+ onDeleteRow?: () => void;
15
+ onDeleteColumn?: () => void;
16
+ onMergeCells?: () => void;
17
+ onSplitCell?: () => void;
18
+ onSetCellBackground?: (color: string) => void;
19
+ onDeleteTable?: () => void;
20
+ }
21
+
22
+ const CELL_COLORS = [
23
+ "#ffffff",
24
+ "#f0f0ee",
25
+ "#dbeafe",
26
+ "#fef3c7",
27
+ "#dcfce7",
28
+ "#fce7f3",
29
+ ] as const;
30
+
31
+ export function TwTableContextToolbar(props: TwTableContextToolbarProps) {
32
+ return (
33
+ <div
34
+ data-testid="table-context-toolbar"
35
+ className="flex flex-wrap items-center gap-2 rounded-xl border border-border bg-canvas px-3 py-2 shadow-sm"
36
+ >
37
+ <span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
38
+ Table
39
+ </span>
40
+
41
+ <select
42
+ aria-label="Table style"
43
+ className="h-8 rounded-md border border-border bg-canvas px-2 text-xs text-primary disabled:opacity-40"
44
+ disabled={props.disabled || props.tableStyles.length === 0 || !props.onSetTableStyle}
45
+ onMouseDown={preserveEditorSelectionMouseDown}
46
+ onChange={(event) => props.onSetTableStyle?.(event.target.value)}
47
+ defaultValue=""
48
+ >
49
+ <option value="" disabled>Table style</option>
50
+ {props.tableStyles.map((style) => (
51
+ <option key={style.styleId} value={style.styleId}>
52
+ {style.displayName}
53
+ </option>
54
+ ))}
55
+ </select>
56
+
57
+ <ToolbarButton ariaLabel="Add row above" disabled={props.disabled} onClick={props.onAddRowBefore}>
58
+ Row above
59
+ </ToolbarButton>
60
+ <ToolbarButton ariaLabel="Add row below" disabled={props.disabled} onClick={props.onAddRowAfter}>
61
+ Row below
62
+ </ToolbarButton>
63
+ <ToolbarButton ariaLabel="Delete row" disabled={props.disabled} onClick={props.onDeleteRow}>
64
+ Delete row
65
+ </ToolbarButton>
66
+ <ToolbarButton ariaLabel="Add column left" disabled={props.disabled} onClick={props.onAddColumnBefore}>
67
+ Column left
68
+ </ToolbarButton>
69
+ <ToolbarButton ariaLabel="Add column right" disabled={props.disabled} onClick={props.onAddColumnAfter}>
70
+ Column right
71
+ </ToolbarButton>
72
+ <ToolbarButton ariaLabel="Delete column" disabled={props.disabled} onClick={props.onDeleteColumn}>
73
+ Delete column
74
+ </ToolbarButton>
75
+ <ToolbarButton ariaLabel="Merge cells" disabled={props.disabled} onClick={props.onMergeCells}>
76
+ Merge
77
+ </ToolbarButton>
78
+ <ToolbarButton ariaLabel="Split cell" disabled={props.disabled} onClick={props.onSplitCell}>
79
+ Split
80
+ </ToolbarButton>
81
+
82
+ <div className="flex items-center gap-1">
83
+ <span className="text-[11px] text-secondary">Fill</span>
84
+ {CELL_COLORS.map((color) => (
85
+ <button
86
+ key={color}
87
+ type="button"
88
+ aria-label={`Set cell fill ${color}`}
89
+ disabled={props.disabled || !props.onSetCellBackground}
90
+ onMouseDown={preserveEditorSelectionMouseDown}
91
+ onClick={() => props.onSetCellBackground?.(color)}
92
+ className="h-6 w-6 rounded border border-border disabled:opacity-40"
93
+ style={{ backgroundColor: color }}
94
+ />
95
+ ))}
96
+ </div>
97
+
98
+ <ToolbarButton ariaLabel="Delete table" danger disabled={props.disabled} onClick={props.onDeleteTable}>
99
+ Delete table
100
+ </ToolbarButton>
101
+ </div>
102
+ );
103
+ }
104
+
105
+ function ToolbarButton(props: {
106
+ ariaLabel: string;
107
+ children: React.ReactNode;
108
+ danger?: boolean;
109
+ disabled: boolean;
110
+ onClick?: () => void;
111
+ }) {
112
+ return (
113
+ <button
114
+ type="button"
115
+ aria-label={props.ariaLabel}
116
+ disabled={props.disabled || !props.onClick}
117
+ onMouseDown={preserveEditorSelectionMouseDown}
118
+ onClick={props.onClick}
119
+ className={`inline-flex h-8 items-center rounded-md px-2 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
120
+ props.danger
121
+ ? "text-danger hover:bg-danger/10"
122
+ : "text-primary hover:bg-surface"
123
+ }`}
124
+ >
125
+ {props.children}
126
+ </button>
127
+ );
128
+ }
@@ -1,4 +1,16 @@
1
- export type PerfProbeKind = "typing" | "selection";
1
+ export type PerfProbeKind =
2
+ | "typing"
3
+ | "selection"
4
+ | "runtime.create"
5
+ | "snapshot.surface"
6
+ | "snapshot.compatibility"
7
+ | "snapshot.navigation"
8
+ | "pm.rebuild"
9
+ | "pm.decorations"
10
+ | "pm.mount"
11
+ | "shell.render"
12
+ | "workspace.chrome"
13
+ | "selection.sync";
2
14
 
3
15
  export interface PerfProbeSample {
4
16
  token: string;
@@ -18,14 +30,13 @@ interface PerfProbeState {
18
30
  pending?: Record<string, PendingProbe>;
19
31
  samples?: PerfProbeSample[];
20
32
  maxSamples?: number;
33
+ invalidationCounts?: Record<string, number>;
21
34
  }
22
35
 
23
36
  export interface PerfProbeSummary {
24
37
  samples: PerfProbeSample[];
25
- latest: {
26
- typing: PerfProbeSample | null;
27
- selection: PerfProbeSample | null;
28
- };
38
+ latest: Partial<Record<PerfProbeKind, PerfProbeSample | null>>;
39
+ invalidationCounts: Record<string, number>;
29
40
  }
30
41
 
31
42
  declare global {
@@ -69,16 +80,47 @@ export function finishPerfProbe(token: string | null | undefined): PerfProbeSamp
69
80
  recordedAt: Date.now(),
70
81
  };
71
82
 
72
- state.samples ??= [];
73
- state.samples.push(sample);
74
- const maxSamples = state.maxSamples ?? 20;
75
- if (state.samples.length > maxSamples) {
76
- state.samples.splice(0, state.samples.length - maxSamples);
83
+ pushSample(state, sample);
84
+
85
+ return sample;
86
+ }
87
+
88
+ export function recordPerfSample(
89
+ kind: PerfProbeKind,
90
+ durationMs = 0,
91
+ ): PerfProbeSample | null {
92
+ const state = getEnabledState();
93
+ if (!state) {
94
+ return null;
77
95
  }
78
96
 
97
+ const token = `${kind}-${state.nextToken ?? 0}`;
98
+ state.nextToken = (state.nextToken ?? 0) + 1;
99
+ const sample: PerfProbeSample = {
100
+ token,
101
+ kind,
102
+ durationMs,
103
+ recordedAt: Date.now(),
104
+ };
105
+ pushSample(state, sample);
79
106
  return sample;
80
107
  }
81
108
 
109
+ export function incrementInvalidationCounter(
110
+ counter: string,
111
+ amount = 1,
112
+ ): number {
113
+ const state = getEnabledState();
114
+ if (!state) {
115
+ return 0;
116
+ }
117
+
118
+ state.invalidationCounts ??= {};
119
+ state.invalidationCounts[counter] =
120
+ (state.invalidationCounts[counter] ?? 0) + amount;
121
+ return state.invalidationCounts[counter]!;
122
+ }
123
+
82
124
  export function getLatestPerfSummary(): PerfProbeSummary | null {
83
125
  const state = getEnabledState();
84
126
  const samples = state?.samples ?? [];
@@ -88,13 +130,22 @@ export function getLatestPerfSummary(): PerfProbeSummary | null {
88
130
 
89
131
  return {
90
132
  samples: [...samples],
91
- latest: {
92
- typing: [...samples].reverse().find((sample) => sample.kind === "typing") ?? null,
93
- selection: [...samples].reverse().find((sample) => sample.kind === "selection") ?? null,
94
- },
133
+ latest: buildLatestSampleMap(samples),
134
+ invalidationCounts: { ...(state.invalidationCounts ?? {}) },
95
135
  };
96
136
  }
97
137
 
138
+ export function resetPerfProbeState(): void {
139
+ const state = getEnabledState();
140
+ if (!state) {
141
+ return;
142
+ }
143
+ state.nextToken = 0;
144
+ state.pending = {};
145
+ state.samples = [];
146
+ state.invalidationCounts = {};
147
+ }
148
+
98
149
  function getEnabledState(): PerfProbeState | null {
99
150
  if (typeof window === "undefined") {
100
151
  return null;
@@ -105,3 +156,24 @@ function getEnabledState(): PerfProbeState | null {
105
156
  }
106
157
  return state;
107
158
  }
159
+
160
+ function pushSample(state: PerfProbeState, sample: PerfProbeSample): void {
161
+ state.samples ??= [];
162
+ state.samples.push(sample);
163
+ const maxSamples = state.maxSamples ?? 20;
164
+ if (state.samples.length > maxSamples) {
165
+ state.samples.splice(0, state.samples.length - maxSamples);
166
+ }
167
+ }
168
+
169
+ function buildLatestSampleMap(
170
+ samples: PerfProbeSample[],
171
+ ): Partial<Record<PerfProbeKind, PerfProbeSample | null>> {
172
+ const latest: Partial<Record<PerfProbeKind, PerfProbeSample | null>> = {};
173
+ for (const sample of [...samples].reverse()) {
174
+ if (latest[sample.kind] === undefined) {
175
+ latest[sample.kind] = sample;
176
+ }
177
+ }
178
+ return latest;
179
+ }
@@ -72,9 +72,9 @@ export function createCommandBridgePlugins(
72
72
  return;
73
73
  }
74
74
 
75
- const { from, to } = view.state.selection;
75
+ const { anchor, head } = view.state.selection;
76
76
  callbacks.onSelectionChange(
77
- createSelectionSnapshot(posMap.pmToRuntime(from), posMap.pmToRuntime(to)),
77
+ createSelectionSnapshot(posMap.pmToRuntime(anchor), posMap.pmToRuntime(head)),
78
78
  );
79
79
  }
80
80
  },
@@ -4,6 +4,8 @@ import type { CommentDecorationModel } from "../../ui/headless/comment-decoratio
4
4
  import { getCommentHighlightClass, type MarkupDisplay } from "../../ui/headless/comment-decoration-model";
5
5
  import type { RevisionDecorationModel } from "../../ui/headless/revision-decoration-model";
6
6
  import { getRevisionHighlightClass } from "../../ui/headless/revision-decoration-model";
7
+ import type { EditorStoryTarget, WorkflowScope } from "../../api/public-types";
8
+ import { MAIN_STORY_TARGET, storyTargetsEqual } from "../../core/selection/mapping.ts";
7
9
  import type { PositionMap } from "./pm-position-map";
8
10
  import type { Node as PMNode } from "prosemirror-model";
9
11
 
@@ -21,6 +23,8 @@ export function buildDecorations(
21
23
  revisionModel: RevisionDecorationModel | undefined,
22
24
  markupDisplay: MarkupDisplay,
23
25
  showTrackedChanges = true,
26
+ workflowScopes?: readonly WorkflowScope[],
27
+ activeStory: EditorStoryTarget = MAIN_STORY_TARGET,
24
28
  ): DecorationSet {
25
29
  const decorations: Decoration[] = [];
26
30
 
@@ -94,5 +98,36 @@ export function buildDecorations(
94
98
  }
95
99
  }
96
100
 
101
+ // Walk workflow scopes and create inline decorations for scope emphasis.
102
+ if (workflowScopes) {
103
+ for (const scope of workflowScopes) {
104
+ const scopeStoryTarget = scope.storyTarget ?? MAIN_STORY_TARGET;
105
+ if (!storyTargetsEqual(scopeStoryTarget, activeStory)) continue;
106
+ if (scope.anchor.kind === "detached") continue;
107
+ const from = scope.anchor.kind === "range" ? scope.anchor.from : scope.anchor.at;
108
+ const to = scope.anchor.kind === "range" ? scope.anchor.to : scope.anchor.at;
109
+ const pmFrom = positionMap.runtimeToPm(from);
110
+ const pmTo = positionMap.runtimeToPm(to);
111
+ if (pmFrom >= pmTo) continue;
112
+
113
+ const modeClass =
114
+ scope.mode === "edit"
115
+ ? "bg-blue-50/40 ring-1 ring-blue-200/50"
116
+ : scope.mode === "suggest"
117
+ ? "bg-amber-50/40 ring-1 ring-amber-200/50"
118
+ : scope.mode === "comment"
119
+ ? "bg-green-50/40 ring-1 ring-green-200/50"
120
+ : "bg-gray-50/40 ring-1 ring-gray-200/50";
121
+
122
+ decorations.push(
123
+ Decoration.inline(pmFrom, pmTo, {
124
+ class: modeClass,
125
+ "data-workflow-scope-id": scope.scopeId,
126
+ "data-workflow-scope-mode": scope.mode,
127
+ }),
128
+ );
129
+ }
130
+ }
131
+
97
132
  return DecorationSet.create(doc, decorations);
98
133
  }
@@ -29,7 +29,7 @@ export function buildPositionMap(surface: EditorSurfaceSnapshot): PositionMap {
29
29
  }
30
30
 
31
31
  for (const entry of entries) {
32
- if (runtimePos >= entry.runtimeStart && runtimePos <= entry.runtimeEnd) {
32
+ if (runtimePos >= entry.runtimeStart && runtimePos < entry.runtimeEnd) {
33
33
  return entry.pmStart + (runtimePos - entry.runtimeStart);
34
34
  }
35
35
  if (runtimePos < entry.runtimeStart) {
@@ -16,14 +16,21 @@ function resolveHeadingLevel(
16
16
  ): number | null {
17
17
  if (styleId) {
18
18
  const normalized = styleId.toLowerCase();
19
- const headingMatch = /^heading([1-6])$/.exec(normalized);
19
+ const compact = normalized.replace(/[\s_-]+/g, "");
20
+ const headingMatch = /^heading([1-6])$/.exec(compact);
20
21
  if (headingMatch) {
21
22
  return Number.parseInt(headingMatch[1], 10);
22
23
  }
23
- if (normalized === "title") {
24
+ if (compact === "title") {
24
25
  return 1;
25
26
  }
26
- if (normalized === "subtitle") {
27
+ if (compact === "subtitle") {
28
+ return 2;
29
+ }
30
+ if (compact === "tocheading") {
31
+ return 1;
32
+ }
33
+ if (/^(appendix|schedule|annex|exhibit|attachment)(heading|title)$/.test(compact)) {
27
34
  return 2;
28
35
  }
29
36
  }
@@ -74,6 +81,24 @@ function safeCssColor(raw: string | null | undefined): string | null {
74
81
  return null;
75
82
  }
76
83
 
84
+ function sanitizeLinkHref(raw: string | null | undefined): string | null {
85
+ if (!raw) return null;
86
+ const trimmed = raw.trim();
87
+ if (trimmed.startsWith("#")) {
88
+ return trimmed;
89
+ }
90
+ const lower = trimmed.toLowerCase();
91
+ if (
92
+ lower.startsWith("http://") ||
93
+ lower.startsWith("https://") ||
94
+ lower.startsWith("mailto:") ||
95
+ lower.startsWith("tel:")
96
+ ) {
97
+ return trimmed;
98
+ }
99
+ return null;
100
+ }
101
+
77
102
  /**
78
103
  * ProseMirror schema for the supported live surface slice.
79
104
  *
@@ -101,9 +126,14 @@ export const editorSchema = new Schema({
101
126
  spacingAfter: { default: null },
102
127
  lineSpacing: { default: null },
103
128
  lineRule: { default: null },
129
+ contextualSpacing: { default: null },
130
+ listContinuation: { default: null },
131
+ contextualSpacingBefore: { default: null },
132
+ contextualSpacingAfter: { default: null },
104
133
  indentLeft: { default: null },
105
134
  indentRight: { default: null },
106
135
  indentFirstLine: { default: null },
136
+ indentHanging: { default: null },
107
137
  shadingFill: { default: null },
108
138
  borderTop: { default: null },
109
139
  borderBottom: { default: null },
@@ -128,9 +158,13 @@ export const editorSchema = new Schema({
128
158
  const safeAlign = alignment === "both" ? "justify" : alignment;
129
159
  if (safeAlign && SAFE_ALIGNMENT.has(safeAlign)) styles.push(`text-align: ${safeAlign}`);
130
160
  const spacingBefore = node.attrs.spacingBefore as number | null;
131
- if (spacingBefore) styles.push(`margin-top: ${spacingBefore / 20}px`);
161
+ const contextualSpacingBefore = node.attrs.contextualSpacingBefore as boolean | null;
162
+ if (contextualSpacingBefore) styles.push("margin-top: 0");
163
+ else if (spacingBefore) styles.push(`margin-top: ${spacingBefore / 20}px`);
164
+ const contextualSpacingAfter = node.attrs.contextualSpacingAfter as boolean | null;
132
165
  const spacingAfter = node.attrs.spacingAfter as number | null;
133
- if (spacingAfter) styles.push(`margin-bottom: ${spacingAfter / 20}px`);
166
+ if (contextualSpacingAfter) styles.push("margin-bottom: 0");
167
+ else if (spacingAfter) styles.push(`margin-bottom: ${spacingAfter / 20}px`);
134
168
  const lineSpacing = node.attrs.lineSpacing as number | null;
135
169
  const lineRule = node.attrs.lineRule as string | null;
136
170
  if (lineSpacing && lineRule === "auto") styles.push(`line-height: ${lineSpacing / 240}`);
@@ -141,7 +175,9 @@ export const editorSchema = new Schema({
141
175
  const indentRight = node.attrs.indentRight as number | null;
142
176
  if (indentRight) styles.push(`padding-right: ${indentRight / 20}px`);
143
177
  const indentFirstLine = node.attrs.indentFirstLine as number | null;
144
- if (indentFirstLine) styles.push(`text-indent: ${indentFirstLine / 20}px`);
178
+ const indentHanging = node.attrs.indentHanging as number | null;
179
+ if (indentHanging) styles.push(`text-indent: -${indentHanging / 20}px`);
180
+ else if (indentFirstLine) styles.push(`text-indent: ${indentFirstLine / 20}px`);
145
181
  const shadingColor = safeHexColor(node.attrs.shadingFill as string | null);
146
182
  if (shadingColor) styles.push(`background-color: ${shadingColor}`);
147
183
  for (const [side, attrName] of [["top", "borderTop"], ["bottom", "borderBottom"], ["left", "borderLeft"], ["right", "borderRight"]] as const) {
@@ -160,6 +196,24 @@ export const editorSchema = new Schema({
160
196
  if (headingLevel) {
161
197
  attrs["data-heading-level"] = String(headingLevel);
162
198
  }
199
+ const isNumbered = node.attrs.numberingInstanceId !== null;
200
+ if (isNumbered) {
201
+ attrs["data-numbered"] = "true";
202
+ }
203
+ const contextualSpacing = node.attrs.contextualSpacing as boolean | null;
204
+ if (contextualSpacing) {
205
+ attrs["data-contextual-spacing"] = "true";
206
+ }
207
+ const listContinuation = node.attrs.listContinuation as boolean | null;
208
+ if (listContinuation) {
209
+ attrs["data-list-continuation"] = "true";
210
+ }
211
+ if (contextualSpacingBefore) {
212
+ attrs["data-contextual-spacing-before"] = "true";
213
+ }
214
+ if (contextualSpacingAfter) {
215
+ attrs["data-contextual-spacing-after"] = "true";
216
+ }
163
217
  if (styles.length > 0) attrs.style = styles.join("; ");
164
218
  const numberingPrefix = node.attrs.numberingPrefix as string | null;
165
219
  const numberingLevel = node.attrs.numberingLevel as number | null;
@@ -277,10 +331,43 @@ export const editorSchema = new Schema({
277
331
  state: { default: "editable" },
278
332
  display: { default: "inline" },
279
333
  detail: { default: null },
334
+ src: { default: null },
335
+ widthEmu: { default: null },
336
+ heightEmu: { default: null },
280
337
  },
281
338
  toDOM(node) {
282
339
  const isMissing = node.attrs.state === "missing";
283
340
  const isFloating = node.attrs.display === "floating";
341
+ const src = node.attrs.src as string | null;
342
+ const widthEmu = node.attrs.widthEmu as number | null;
343
+ const heightEmu = node.attrs.heightEmu as number | null;
344
+ if (!isMissing && src) {
345
+ const widthPx = widthEmu ? Math.max(24, Math.round(widthEmu / 9525)) : undefined;
346
+ const heightPx = heightEmu ? Math.max(24, Math.round(heightEmu / 9525)) : undefined;
347
+ const style = [
348
+ "display:inline-block",
349
+ "vertical-align:middle",
350
+ "margin:0 4px",
351
+ widthPx ? `width:${widthPx}px` : "",
352
+ heightPx ? `height:${heightPx}px` : "",
353
+ ].filter(Boolean).join(";");
354
+ return [
355
+ "span",
356
+ {
357
+ class: "inline-flex items-center rounded",
358
+ "data-node-type": "image",
359
+ title: (node.attrs.detail as string) ?? (node.attrs.altText as string) ?? "Image",
360
+ },
361
+ [
362
+ "img",
363
+ {
364
+ src,
365
+ alt: (node.attrs.altText as string) ?? "",
366
+ style,
367
+ },
368
+ ],
369
+ ];
370
+ }
284
371
  return [
285
372
  "span",
286
373
  {
@@ -380,6 +467,39 @@ export const editorSchema = new Schema({
380
467
  },
381
468
  },
382
469
 
470
+ field_ref_atom: {
471
+ inline: true,
472
+ group: "inline",
473
+ atom: true,
474
+ selectable: false,
475
+ attrs: {
476
+ fieldFamily: { default: "REF" },
477
+ fieldTarget: { default: null },
478
+ instruction: { default: "" },
479
+ refreshStatus: { default: "stale" },
480
+ label: { default: "Field" },
481
+ },
482
+ toDOM(node) {
483
+ const refreshStatus = node.attrs.refreshStatus as string;
484
+ const statusClass =
485
+ refreshStatus === "current"
486
+ ? "text-blue-700 bg-blue-50 border-blue-200"
487
+ : refreshStatus === "unresolvable"
488
+ ? "text-amber-800 bg-amber-50 border-amber-200"
489
+ : "text-slate-700 bg-slate-50 border-slate-200";
490
+ return [
491
+ "span",
492
+ {
493
+ class: `inline-flex items-center gap-1 mx-0.5 px-1.5 py-0.5 rounded text-xs border ${statusClass}`,
494
+ "data-node-type": "field_ref_atom",
495
+ "data-field-family": node.attrs.fieldFamily as string,
496
+ title: node.attrs.instruction as string,
497
+ },
498
+ (node.attrs.label as string) || "Field",
499
+ ];
500
+ },
501
+ },
502
+
383
503
  table: tableNodeSpec,
384
504
  table_row: tableRowNodeSpec,
385
505
  table_cell: tableCellNodeSpec,
@@ -724,15 +844,26 @@ export const editorSchema = new Schema({
724
844
  {
725
845
  tag: "a[href]",
726
846
  getAttrs(dom) {
727
- return { href: (dom as HTMLElement).getAttribute("href") };
847
+ return { href: sanitizeLinkHref((dom as HTMLElement).getAttribute("href")) ?? "" };
728
848
  },
729
849
  },
730
850
  ],
731
851
  toDOM(mark) {
852
+ const href = sanitizeLinkHref(mark.attrs.href as string);
853
+ if (!href) {
854
+ return [
855
+ "span",
856
+ {
857
+ class: "text-accent underline decoration-1 underline-offset-2",
858
+ "data-invalid-link": "true",
859
+ },
860
+ 0,
861
+ ];
862
+ }
732
863
  return [
733
864
  "a",
734
865
  {
735
- href: mark.attrs.href as string,
866
+ href,
736
867
  class: "text-accent underline decoration-1 underline-offset-2",
737
868
  target: "_blank",
738
869
  rel: "noopener noreferrer",