@beyondwork/docx-react-component 1.0.19 → 1.0.21

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 (71) hide show
  1. package/package.json +44 -25
  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 +915 -1314
  39. package/src/ui/editor-command-bag.ts +120 -0
  40. package/src/ui/editor-runtime-boundary.ts +1448 -0
  41. package/src/ui/editor-shell-view.tsx +134 -0
  42. package/src/ui/editor-surface-controller.tsx +55 -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/workflow-surface-blocked-rails.ts +94 -0
  46. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  47. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  48. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  49. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  50. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +27 -2
  51. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  52. package/src/ui-tailwind/editor-surface/perf-probe.ts +86 -14
  53. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +2 -2
  54. package/src/ui-tailwind/editor-surface/pm-decorations.ts +237 -0
  55. package/src/ui-tailwind/editor-surface/pm-position-map.ts +1 -1
  56. package/src/ui-tailwind/editor-surface/pm-schema.ts +139 -8
  57. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +98 -48
  58. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +55 -0
  59. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  60. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +190 -48
  61. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  62. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +7 -7
  63. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  64. package/src/ui-tailwind/review/tw-review-rail.tsx +3 -3
  65. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  66. package/src/ui-tailwind/theme/editor-theme.css +130 -0
  67. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +543 -5
  68. package/src/ui-tailwind/tw-review-workspace.tsx +316 -19
  69. package/src/validation/compatibility-engine.ts +27 -4
  70. package/src/validation/compatibility-report.ts +1 -0
  71. package/src/validation/docx-comment-proof.ts +220 -0
@@ -4,9 +4,156 @@ 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 {
8
+ EditorAnchorProjection,
9
+ EditorStoryTarget,
10
+ WorkflowBlockedCommandReason,
11
+ WorkflowCandidateRange,
12
+ WorkflowScope,
13
+ } from "../../api/public-types";
14
+ import { MAIN_STORY_TARGET, storyTargetsEqual } from "../../core/selection/mapping.ts";
7
15
  import type { PositionMap } from "./pm-position-map";
8
16
  import type { Node as PMNode } from "prosemirror-model";
9
17
 
18
+ type RailDecorationSpec = {
19
+ railKind: "scope" | "candidate" | "blocked";
20
+ className: string;
21
+ attrs: Record<string, string>;
22
+ };
23
+
24
+ function getWorkflowInlineClass(scope: WorkflowScope): string {
25
+ if (scope.mode === "edit") return "wre-workflow-inline wre-workflow-inline-edit";
26
+ if (scope.mode === "suggest") return "wre-workflow-inline wre-workflow-inline-suggest";
27
+ if (scope.mode === "comment") return "wre-workflow-inline wre-workflow-inline-comment";
28
+ return "wre-workflow-inline wre-workflow-inline-view";
29
+ }
30
+
31
+ function getWorkflowRailClass(scope: WorkflowScope): string {
32
+ if (scope.mode === "edit") return "wre-workflow-rail wre-workflow-rail-edit";
33
+ if (scope.mode === "suggest") return "wre-workflow-rail wre-workflow-rail-suggest";
34
+ if (scope.mode === "comment") return "wre-workflow-rail wre-workflow-rail-comment";
35
+ return "wre-workflow-rail wre-workflow-rail-view";
36
+ }
37
+
38
+ function getWorkflowCandidateInlineClass(): string {
39
+ return "wre-workflow-inline wre-workflow-inline-candidate";
40
+ }
41
+
42
+ function getWorkflowCandidateRailClass(): string {
43
+ return "wre-workflow-rail wre-workflow-rail-candidate";
44
+ }
45
+
46
+ function getWorkflowBlockedInlineClass(reason: WorkflowBlockedCommandReason): string {
47
+ if (reason.code === "workflow_blocked_import") {
48
+ return "wre-workflow-inline wre-workflow-inline-blocked-import";
49
+ }
50
+ return "wre-workflow-inline wre-workflow-inline-preserve-only";
51
+ }
52
+
53
+ function getWorkflowBlockedRailClass(reason: WorkflowBlockedCommandReason): string {
54
+ if (reason.code === "workflow_blocked_import") {
55
+ return "wre-workflow-rail wre-workflow-rail-blocked-import";
56
+ }
57
+ return "wre-workflow-rail wre-workflow-rail-preserve-only";
58
+ }
59
+
60
+ function hasBlockChildren(node: PMNode): boolean {
61
+ for (let index = 0; index < node.childCount; index += 1) {
62
+ if (node.child(index).isBlock) {
63
+ return true;
64
+ }
65
+ }
66
+ return false;
67
+ }
68
+
69
+ function collectRailRanges(doc: PMNode, from: number, to: number): Array<{ from: number; to: number }> {
70
+ const effectiveTo = Math.max(to, from + 1);
71
+ const ranges = new Map<string, { from: number; to: number }>();
72
+ let fallbackFrom: number | null = null;
73
+ let fallbackTo: number | null = null;
74
+ let fallbackSize: number | null = null;
75
+
76
+ doc.descendants((node, pos) => {
77
+ if (!node.isBlock || node.type.name === "doc") {
78
+ return true;
79
+ }
80
+
81
+ const nodeFrom = pos;
82
+ const nodeTo = pos + node.nodeSize;
83
+ if (nodeTo <= from || nodeFrom >= effectiveTo) {
84
+ return true;
85
+ }
86
+
87
+ if (!hasBlockChildren(node)) {
88
+ ranges.set(`${nodeFrom}:${nodeTo}`, { from: nodeFrom, to: nodeTo });
89
+ return true;
90
+ }
91
+
92
+ if (nodeFrom <= from && nodeTo >= effectiveTo) {
93
+ const size = nodeTo - nodeFrom;
94
+ if (fallbackSize === null || size < fallbackSize) {
95
+ fallbackFrom = nodeFrom;
96
+ fallbackTo = nodeTo;
97
+ fallbackSize = size;
98
+ }
99
+ }
100
+
101
+ return true;
102
+ });
103
+
104
+ if (ranges.size > 0) {
105
+ return [...ranges.values()];
106
+ }
107
+
108
+ if (fallbackFrom !== null && fallbackTo !== null) {
109
+ return [{ from: fallbackFrom, to: fallbackTo }];
110
+ }
111
+
112
+ return [];
113
+ }
114
+
115
+ function buildAnchorPmRange(
116
+ anchor: EditorAnchorProjection,
117
+ positionMap: PositionMap,
118
+ ): { from: number; to: number; allowInline: boolean } | null {
119
+ if (anchor.kind === "detached") {
120
+ return null;
121
+ }
122
+
123
+ if (anchor.kind === "range") {
124
+ return {
125
+ from: positionMap.runtimeToPm(anchor.from),
126
+ to: positionMap.runtimeToPm(anchor.to),
127
+ allowInline: true,
128
+ };
129
+ }
130
+
131
+ const pmAt = positionMap.runtimeToPm(anchor.at);
132
+ return {
133
+ from: pmAt,
134
+ to: pmAt + 1,
135
+ allowInline: false,
136
+ };
137
+ }
138
+
139
+ function pushRailDecorations(
140
+ decorations: Decoration[],
141
+ doc: PMNode,
142
+ from: number,
143
+ to: number,
144
+ spec: RailDecorationSpec,
145
+ ): void {
146
+ for (const range of collectRailRanges(doc, from, to)) {
147
+ decorations.push(
148
+ Decoration.node(range.from, range.to, {
149
+ class: spec.className,
150
+ "data-workflow-rail": spec.railKind,
151
+ ...spec.attrs,
152
+ }),
153
+ );
154
+ }
155
+ }
156
+
10
157
  /**
11
158
  * Build ProseMirror DecorationSet from runtime comment and revision models.
12
159
  *
@@ -21,6 +168,10 @@ export function buildDecorations(
21
168
  revisionModel: RevisionDecorationModel | undefined,
22
169
  markupDisplay: MarkupDisplay,
23
170
  showTrackedChanges = true,
171
+ workflowScopes?: readonly WorkflowScope[],
172
+ activeStory: EditorStoryTarget = MAIN_STORY_TARGET,
173
+ workflowCandidates?: readonly WorkflowCandidateRange[],
174
+ workflowBlockedReasons?: readonly WorkflowBlockedCommandReason[],
24
175
  ): DecorationSet {
25
176
  const decorations: Decoration[] = [];
26
177
 
@@ -94,5 +245,91 @@ export function buildDecorations(
94
245
  }
95
246
  }
96
247
 
248
+ if (workflowScopes) {
249
+ for (const scope of workflowScopes) {
250
+ const scopeStoryTarget = scope.storyTarget ?? MAIN_STORY_TARGET;
251
+ if (!storyTargetsEqual(scopeStoryTarget, activeStory)) continue;
252
+ const pmRange = buildAnchorPmRange(scope.anchor, positionMap);
253
+ if (!pmRange) continue;
254
+
255
+ if (pmRange.allowInline && pmRange.from < pmRange.to) {
256
+ decorations.push(
257
+ Decoration.inline(pmRange.from, pmRange.to, {
258
+ class: getWorkflowInlineClass(scope),
259
+ "data-workflow-scope-id": scope.scopeId,
260
+ "data-workflow-scope-mode": scope.mode,
261
+ }),
262
+ );
263
+ }
264
+
265
+ pushRailDecorations(decorations, doc, pmRange.from, pmRange.to, {
266
+ railKind: "scope",
267
+ className: getWorkflowRailClass(scope),
268
+ attrs: {
269
+ "data-workflow-scope-id": scope.scopeId,
270
+ "data-workflow-scope-mode": scope.mode,
271
+ },
272
+ });
273
+ }
274
+ }
275
+
276
+ if (workflowCandidates) {
277
+ for (const candidate of workflowCandidates) {
278
+ const candidateStoryTarget = candidate.storyTarget ?? MAIN_STORY_TARGET;
279
+ if (!storyTargetsEqual(candidateStoryTarget, activeStory)) continue;
280
+ const pmRange = buildAnchorPmRange(candidate.anchor, positionMap);
281
+ if (!pmRange) continue;
282
+
283
+ if (pmRange.allowInline && pmRange.from < pmRange.to) {
284
+ decorations.push(
285
+ Decoration.inline(pmRange.from, pmRange.to, {
286
+ class: getWorkflowCandidateInlineClass(),
287
+ "data-workflow-candidate-id": candidate.candidateId,
288
+ }),
289
+ );
290
+ }
291
+
292
+ pushRailDecorations(decorations, doc, pmRange.from, pmRange.to, {
293
+ railKind: "candidate",
294
+ className: getWorkflowCandidateRailClass(),
295
+ attrs: {
296
+ "data-workflow-candidate-id": candidate.candidateId,
297
+ },
298
+ });
299
+ }
300
+ }
301
+
302
+ if (workflowBlockedReasons) {
303
+ for (const reason of workflowBlockedReasons) {
304
+ if (
305
+ reason.code !== "workflow_preserve_only" &&
306
+ reason.code !== "workflow_blocked_import"
307
+ ) {
308
+ continue;
309
+ }
310
+ const reasonStoryTarget = reason.storyTarget ?? MAIN_STORY_TARGET;
311
+ if (!storyTargetsEqual(reasonStoryTarget, activeStory) || !reason.anchor) continue;
312
+ const pmRange = buildAnchorPmRange(reason.anchor, positionMap);
313
+ if (!pmRange) continue;
314
+
315
+ if (pmRange.allowInline && pmRange.from < pmRange.to) {
316
+ decorations.push(
317
+ Decoration.inline(pmRange.from, pmRange.to, {
318
+ class: getWorkflowBlockedInlineClass(reason),
319
+ "data-workflow-blocked-code": reason.code,
320
+ }),
321
+ );
322
+ }
323
+
324
+ pushRailDecorations(decorations, doc, pmRange.from, pmRange.to, {
325
+ railKind: "blocked",
326
+ className: getWorkflowBlockedRailClass(reason),
327
+ attrs: {
328
+ "data-workflow-blocked-code": reason.code,
329
+ },
330
+ });
331
+ }
332
+ }
333
+
97
334
  return DecorationSet.create(doc, decorations);
98
335
  }
@@ -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",