@beyondwork/docx-react-component 1.0.38 → 1.0.40

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 (85) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +305 -6
  3. package/src/core/commands/table-structure-commands.ts +31 -2
  4. package/src/core/commands/text-commands.ts +122 -2
  5. package/src/index.ts +9 -0
  6. package/src/io/docx-session.ts +1 -0
  7. package/src/io/export/serialize-numbering.ts +42 -8
  8. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  9. package/src/io/export/serialize-run-formatting.ts +90 -0
  10. package/src/io/export/serialize-styles.ts +212 -0
  11. package/src/io/ooxml/parse-fields.ts +10 -3
  12. package/src/io/ooxml/parse-numbering.ts +41 -1
  13. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  14. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  15. package/src/io/ooxml/parse-styles.ts +31 -0
  16. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  17. package/src/io/ooxml/xml-element.ts +19 -0
  18. package/src/model/canonical-document.ts +83 -3
  19. package/src/runtime/collab/event-types.ts +165 -0
  20. package/src/runtime/collab/index.ts +22 -0
  21. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  22. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  23. package/src/runtime/document-runtime.ts +141 -18
  24. package/src/runtime/layout/docx-font-loader.ts +30 -11
  25. package/src/runtime/layout/index.ts +2 -0
  26. package/src/runtime/layout/inert-layout-facet.ts +3 -0
  27. package/src/runtime/layout/layout-engine-instance.ts +69 -2
  28. package/src/runtime/layout/layout-invalidation.ts +14 -5
  29. package/src/runtime/layout/page-graph.ts +36 -0
  30. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  31. package/src/runtime/layout/paginated-layout-engine.ts +342 -28
  32. package/src/runtime/layout/project-block-fragments.ts +154 -20
  33. package/src/runtime/layout/public-facet.ts +81 -1
  34. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  35. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  36. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  37. package/src/runtime/layout/table-render-plan.ts +21 -1
  38. package/src/runtime/numbering-prefix.ts +5 -0
  39. package/src/runtime/paragraph-style-resolver.ts +194 -0
  40. package/src/runtime/render/render-kernel.ts +5 -1
  41. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  42. package/src/runtime/surface-projection.ts +129 -9
  43. package/src/runtime/table-schema.ts +11 -0
  44. package/src/runtime/workflow-rail-segments.ts +149 -1
  45. package/src/ui/WordReviewEditor.tsx +302 -5
  46. package/src/ui/editor-command-bag.ts +4 -0
  47. package/src/ui/editor-runtime-boundary.ts +16 -0
  48. package/src/ui/editor-shell-view.tsx +22 -0
  49. package/src/ui/editor-surface-controller.tsx +9 -1
  50. package/src/ui/headless/chrome-registry.ts +34 -5
  51. package/src/ui/headless/scoped-chrome-policy.ts +29 -0
  52. package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
  53. package/src/ui-tailwind/chrome/role-action-sets.ts +14 -8
  54. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +80 -0
  55. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +7 -10
  56. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +11 -0
  57. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  58. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  59. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +101 -21
  60. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +353 -0
  61. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +5 -1
  62. package/src/ui-tailwind/chrome-overlay/index.ts +2 -6
  63. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +82 -18
  64. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +133 -0
  65. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +386 -0
  66. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +140 -69
  67. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  68. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +28 -3
  69. package/src/ui-tailwind/editor-surface/pm-decorations.ts +7 -2
  70. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  71. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  72. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +170 -63
  73. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  74. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  75. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -78
  76. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  77. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  78. package/src/ui-tailwind/index.ts +6 -5
  79. package/src/ui-tailwind/theme/editor-theme.css +108 -15
  80. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +122 -1
  81. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +36 -3
  82. package/src/ui-tailwind/tw-review-workspace.tsx +207 -54
  83. package/src/runtime/collab-review-sync.ts +0 -254
  84. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +0 -95
  85. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -1,4 +1,4 @@
1
- import { Fragment, type Node as PMNode } from "prosemirror-model";
1
+ import { Fragment, type Mark, type Node as PMNode, type Schema } from "prosemirror-model";
2
2
  import { EditorState, NodeSelection, type Plugin, Selection, TextSelection } from "prosemirror-state";
3
3
 
4
4
  import type {
@@ -17,6 +17,123 @@ export interface PMStateResult {
17
17
  positionMap: PositionMap;
18
18
  }
19
19
 
20
+ /**
21
+ * Test-friendly wrapper: build a PM EditorState from a minimal snapshot with no
22
+ * selection or plugins. The snapshot is cast to EditorSurfaceSnapshot so callers
23
+ * can pass partial objects in tests.
24
+ */
25
+ export function buildPmStateFromSnapshot(snapshot: EditorSurfaceSnapshot): EditorState {
26
+ const doc = buildPMDoc(snapshot, {}, false);
27
+ return EditorState.create({ doc, plugins: [] });
28
+ }
29
+
30
+ /**
31
+ * Build the set of PM marks for a text segment by merging direct marks with the
32
+ * cascaded resolvedRunFormatting. Direct marks win over cascade values.
33
+ */
34
+ function applyEffectiveMarks(
35
+ segment: Extract<SurfaceInlineSegment, { kind: "text" }>,
36
+ schema: Schema,
37
+ ): Mark[] {
38
+ const marks: Mark[] = [];
39
+ const directList = segment.marks ?? [];
40
+ const directAttrs = segment.markAttrs ?? {};
41
+ const cascade = segment.resolvedRunFormatting ?? {};
42
+
43
+ // Boolean marks: direct presence wins; direct absence (explicit false in markAttrs is not
44
+ // representable in the marks array, so we check the marks array truthiness).
45
+ // If the mark is absent from the direct marks list but cascade supplies it, apply it.
46
+ const hasDirect = (name: string) => directList.includes(name as never);
47
+
48
+ // bold
49
+ const directBold = hasDirect("bold");
50
+ const cascadeBold = cascade.bold === true;
51
+ if (directBold || (!("bold" in directAttrs) && cascadeBold)) {
52
+ marks.push(schema.marks.bold.create());
53
+ }
54
+
55
+ // italic
56
+ const directItalic = hasDirect("italic");
57
+ const cascadeItalic = cascade.italic === true;
58
+ if (directItalic || (!("italic" in directAttrs) && cascadeItalic)) {
59
+ marks.push(schema.marks.italic.create());
60
+ }
61
+
62
+ // underline
63
+ const directUnderline = hasDirect("underline");
64
+ const cascadeUnderline = cascade.underline && cascade.underline !== "none";
65
+ if (directUnderline || (!("underline" in directAttrs) && cascadeUnderline)) {
66
+ marks.push(schema.marks.underline.create());
67
+ }
68
+
69
+ // strikethrough
70
+ const directStrikethrough = hasDirect("strikethrough");
71
+ const cascadeStrikethrough = cascade.strikethrough === true;
72
+ if (directStrikethrough || (!("strikethrough" in directAttrs) && cascadeStrikethrough)) {
73
+ marks.push(schema.marks.strikethrough.create());
74
+ }
75
+
76
+ // pass-through marks from marks array (no cascade equivalent)
77
+ if (hasDirect("doubleStrikethrough")) marks.push(schema.marks.doubleStrikethrough.create());
78
+ if (hasDirect("vanish")) marks.push(schema.marks.vanish.create());
79
+ if (hasDirect("emboss")) marks.push(schema.marks.emboss.create());
80
+ if (hasDirect("imprint")) marks.push(schema.marks.imprint.create());
81
+ if (hasDirect("shadow")) marks.push(schema.marks.shadow.create());
82
+
83
+ // smallCaps / allCaps
84
+ if (hasDirect("smallCaps")) marks.push(schema.marks.small_caps.create());
85
+ if (hasDirect("allCaps")) marks.push(schema.marks.all_caps.create());
86
+
87
+ // superscript / subscript from marks array
88
+ if (hasDirect("superscript")) marks.push(schema.marks.superscript.create());
89
+ if (hasDirect("subscript")) marks.push(schema.marks.subscript.create());
90
+
91
+ // fontSize: direct.markAttrs.fontSize is in half-points; cascade.fontSizeHalfPoints is half-points
92
+ const directFontSize = typeof directAttrs.fontSize === "number" ? directAttrs.fontSize / 2 : undefined;
93
+ const cascadeFontSize = typeof cascade.fontSizeHalfPoints === "number" ? cascade.fontSizeHalfPoints / 2 : undefined;
94
+ const effectiveFontSize = directFontSize ?? cascadeFontSize;
95
+ if (typeof effectiveFontSize === "number") {
96
+ marks.push(schema.marks.font_size.create({ size: effectiveFontSize }));
97
+ }
98
+
99
+ // fontFamily: direct.markAttrs.fontFamily wins over cascade
100
+ const effectiveFontFamily = directAttrs.fontFamily ?? cascade.fontFamily ?? cascade.fontFamilyAscii;
101
+ if (effectiveFontFamily) {
102
+ marks.push(schema.marks.font_family.create({ family: effectiveFontFamily }));
103
+ }
104
+
105
+ // textColor: direct.markAttrs.textColor wins over cascade.colorHex
106
+ if (directAttrs.textFill && !directAttrs.textColor) {
107
+ const colorMatch = directAttrs.textFill.match(/\bval="([0-9A-Fa-f]{6})"/);
108
+ if (colorMatch) {
109
+ marks.push(schema.marks.text_color.create({ color: `#${colorMatch[1]}` }));
110
+ }
111
+ } else {
112
+ const directColor = directAttrs.textColor ? `#${directAttrs.textColor}` : undefined;
113
+ const cascadeColor = cascade.colorHex && cascade.colorHex !== "auto" ? `#${cascade.colorHex}` : undefined;
114
+ const effectiveColor = directColor ?? cascadeColor;
115
+ if (effectiveColor) {
116
+ marks.push(schema.marks.text_color.create({ color: effectiveColor }));
117
+ }
118
+ }
119
+
120
+ // highlight / background color
121
+ const effectiveBg = directAttrs.backgroundColor;
122
+ if (effectiveBg) {
123
+ marks.push(schema.marks.highlight.create({ color: `#${effectiveBg}` }));
124
+ }
125
+
126
+ // charSpacing / kerning
127
+ if (typeof directAttrs.charSpacing === "number") {
128
+ marks.push(schema.marks.char_spacing.create({ value: directAttrs.charSpacing }));
129
+ }
130
+ if (typeof directAttrs.kerning === "number") {
131
+ marks.push(schema.marks.font_kerning.create({ threshold: directAttrs.kerning }));
132
+ }
133
+
134
+ return marks;
135
+ }
136
+
20
137
  export interface MediaPreviewDescriptor {
21
138
  src: string;
22
139
  widthEmu?: number;
@@ -222,6 +339,9 @@ function buildParagraph(
222
339
  (previousParagraph.styleId ?? "__default__") === (block.styleId ?? "__default__"),
223
340
  );
224
341
 
342
+ const cascade = block.resolvedParagraphFormatting;
343
+ const cascadeBorders = cascade?.borders as Record<string, unknown> | undefined;
344
+
225
345
  return editorSchema.nodes.paragraph.create(
226
346
  {
227
347
  styleId: block.styleId ?? null,
@@ -233,12 +353,12 @@ function buildParagraph(
233
353
  numberingSuffix:
234
354
  (block as typeof block & { numberingSuffix?: string }).numberingSuffix ??
235
355
  null,
236
- alignment: block.alignment ?? null,
356
+ alignment: block.alignment ?? cascade?.alignment ?? null,
237
357
  spacingBefore: paragraphLayout.spacing?.before ?? null,
238
358
  spacingAfter: paragraphLayout.spacing?.after ?? null,
239
359
  lineSpacing: paragraphLayout.spacing?.line ?? null,
240
360
  lineRule: paragraphLayout.spacing?.lineRule ?? null,
241
- contextualSpacing: block.contextualSpacing ?? null,
361
+ contextualSpacing: block.contextualSpacing ?? cascade?.contextualSpacing ?? null,
242
362
  listContinuation: listContinuation || null,
243
363
  contextualSpacingBefore: contextualSpacingBefore || null,
244
364
  contextualSpacingAfter: contextualSpacingAfter || null,
@@ -248,14 +368,15 @@ function buildParagraph(
248
368
  indentHanging: paragraphLayout.indentation?.hanging ?? null,
249
369
  numberingMarkerWidth: paragraphLayout.markerLane?.width ?? null,
250
370
  numberingMarkerJustification: paragraphLayout.markerJustification ?? null,
251
- shadingFill: block.shading?.fill ?? null,
252
- borderTop: (block.borders as Record<string, unknown>)?.top ?? null,
253
- borderBottom: (block.borders as Record<string, unknown>)?.bottom ?? null,
254
- borderLeft: (block.borders as Record<string, unknown>)?.left ?? null,
255
- borderRight: (block.borders as Record<string, unknown>)?.right ?? null,
256
- outlineLevel: block.outlineLevel ?? null,
257
- bidi: block.bidi ?? null,
258
- pageBreakBefore: block.pageBreakBefore ?? null,
371
+ numberingMarkerRunProperties: block.resolvedNumbering?.markerRunProperties ?? null,
372
+ shadingFill: block.shading?.fill ?? cascade?.shading?.fill ?? null,
373
+ borderTop: (block.borders as Record<string, unknown>)?.top ?? cascadeBorders?.top ?? null,
374
+ borderBottom: (block.borders as Record<string, unknown>)?.bottom ?? cascadeBorders?.bottom ?? null,
375
+ borderLeft: (block.borders as Record<string, unknown>)?.left ?? cascadeBorders?.left ?? null,
376
+ borderRight: (block.borders as Record<string, unknown>)?.right ?? cascadeBorders?.right ?? null,
377
+ outlineLevel: block.outlineLevel ?? cascade?.outlineLevel ?? null,
378
+ bidi: block.bidi ?? cascade?.bidi ?? null,
379
+ pageBreakBefore: block.pageBreakBefore ?? cascade?.pageBreakBefore ?? null,
259
380
  hiddenTextOnly: fullyVanishedParagraph || null,
260
381
  },
261
382
  content.length > 0 ? Fragment.from(content) : undefined,
@@ -275,9 +396,16 @@ function resolveParagraphLayout(
275
396
  NonNullable<Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>["resolvedNumbering"]>["geometry"]["markerJustification"]
276
397
  > | undefined;
277
398
  } {
399
+ const cascadeFormatting = block.resolvedParagraphFormatting;
278
400
  return {
279
- spacing: block.resolvedNumbering?.geometry.spacing ?? block.spacing,
280
- indentation: block.resolvedNumbering?.geometry.indentation ?? block.indentation,
401
+ spacing:
402
+ block.resolvedNumbering?.geometry.spacing ??
403
+ block.spacing ??
404
+ cascadeFormatting?.spacing,
405
+ indentation:
406
+ block.resolvedNumbering?.geometry.indentation ??
407
+ block.indentation ??
408
+ cascadeFormatting?.indentation,
281
409
  tabStops: block.resolvedNumbering?.geometry.tabStops ?? block.tabStops ?? [],
282
410
  markerLane: block.resolvedNumbering?.geometry.markerLane,
283
411
  markerJustification: block.resolvedNumbering?.geometry.markerJustification,
@@ -293,55 +421,7 @@ function buildInlineContent(
293
421
  case "text": {
294
422
  if (!segment.text) return [];
295
423
 
296
- // Build PM marks from segment marks
297
- let marks = editorSchema.marks.bold.isInSet([])
298
- ? [] // shouldn't happen, just type safety
299
- : [];
300
-
301
- const pmMarks = [];
302
- if (segment.marks) {
303
- for (const mark of segment.marks) {
304
- // Map surface mark names that differ from PM schema mark names
305
- if (mark === "smallCaps") {
306
- pmMarks.push(editorSchema.marks.small_caps.create());
307
- continue;
308
- }
309
- if (mark === "allCaps") {
310
- pmMarks.push(editorSchema.marks.all_caps.create());
311
- continue;
312
- }
313
- const pmMark = editorSchema.marks[mark];
314
- if (pmMark) {
315
- pmMarks.push(pmMark.create());
316
- }
317
- }
318
- }
319
- if (segment.kind === "text" && segment.markAttrs) {
320
- if (segment.markAttrs.backgroundColor) {
321
- pmMarks.push(editorSchema.marks.highlight.create({ color: `#${segment.markAttrs.backgroundColor}` }));
322
- }
323
- if (segment.markAttrs.fontFamily) {
324
- pmMarks.push(editorSchema.marks.font_family.create({ family: segment.markAttrs.fontFamily }));
325
- }
326
- if (segment.markAttrs.fontSize) {
327
- pmMarks.push(editorSchema.marks.font_size.create({ size: segment.markAttrs.fontSize / 2 }));
328
- }
329
- if (segment.markAttrs.textColor) {
330
- pmMarks.push(editorSchema.marks.text_color.create({ color: `#${segment.markAttrs.textColor}` }));
331
- }
332
- if (segment.markAttrs.charSpacing) {
333
- pmMarks.push(editorSchema.marks.char_spacing.create({ value: segment.markAttrs.charSpacing }));
334
- }
335
- if (segment.markAttrs.kerning) {
336
- pmMarks.push(editorSchema.marks.font_kerning.create({ threshold: segment.markAttrs.kerning }));
337
- }
338
- if (segment.markAttrs.textFill && !segment.markAttrs.textColor) {
339
- const colorMatch = segment.markAttrs.textFill.match(/\bval="([0-9A-Fa-f]{6})"/);
340
- if (colorMatch) {
341
- pmMarks.push(editorSchema.marks.text_color.create({ color: `#${colorMatch[1]}` }));
342
- }
343
- }
344
- }
424
+ const pmMarks = applyEffectiveMarks(segment, editorSchema);
345
425
  if (segment.hyperlinkHref) {
346
426
  pmMarks.push(editorSchema.marks.link.create({ href: segment.hyperlinkHref }));
347
427
  }
@@ -433,6 +513,7 @@ function buildTable(
433
513
  borderRight: cell.borderRight ?? null,
434
514
  borderBottom: cell.borderBottom ?? null,
435
515
  borderLeft: cell.borderLeft ?? null,
516
+ bandClasses: cell.bandClasses ?? null,
436
517
  },
437
518
  Fragment.from(cellContent),
438
519
  ),
@@ -460,6 +541,7 @@ function buildTable(
460
541
  tblLookLastColumn: block.tblLook?.lastColumn ?? false,
461
542
  tblLookNoHBand: block.tblLook?.noHBand ?? false,
462
543
  tblLookNoVBand: block.tblLook?.noVBand ?? false,
544
+ tblLookVal: block.tblLook?.val ?? null,
463
545
  },
464
546
  Fragment.from(rows),
465
547
  );
@@ -496,6 +578,23 @@ function buildSdtBlock(
496
578
  );
497
579
  }
498
580
 
581
+ /**
582
+ * Labels surface-projection emits for preserve-only complex fragments
583
+ * (charts, SmartArt, drawing shapes, WordArt, legacy VML). These have
584
+ * no first-class rendering — when the debug preview toggle is off, they
585
+ * collapse to a zero-dimension quiet marker so the reviewer's document
586
+ * view stays clean. Toggling `showUnsupportedObjectPreviews` on swaps
587
+ * them to the richer atom node specs that ship the preserved detail.
588
+ */
589
+ const UNSUPPORTED_COMPLEX_PREVIEW_LABELS = new Set<string>([
590
+ "Embedded chart",
591
+ "SmartArt diagram",
592
+ "Drawing shape",
593
+ "Text box",
594
+ "WordArt",
595
+ "Legacy VML drawing",
596
+ ]);
597
+
499
598
  /**
500
599
  * Map an opaque_inline surface segment to a dedicated complex-rendering PM atom
501
600
  * node when the label identifies a known complex content type, or fall back to
@@ -553,12 +652,20 @@ function buildOpaqueInlineOrComplexAtom(
553
652
  });
554
653
  }
555
654
 
655
+ // Preserve-only complex fragments without the debug toggle: collapse
656
+ // to a zero-dimension quiet marker so the document view matches the
657
+ // dev-drawer copy ("off by default"). The fragment stays in the
658
+ // canonical document, so export round-trips remain lossless.
659
+ const effectivePresentation =
660
+ segment.presentation ??
661
+ (UNSUPPORTED_COMPLEX_PREVIEW_LABELS.has(label) ? "quiet-marker" : "inline-chip");
662
+
556
663
  return editorSchema.nodes.opaque_inline.create({
557
664
  fragmentId: segment.fragmentId,
558
665
  warningId: segment.warningId,
559
666
  label,
560
667
  detail,
561
- presentation: segment.presentation ?? "inline-chip",
668
+ presentation: effectivePresentation,
562
669
  displayText: segment.displayText ?? null,
563
670
  });
564
671
  }
@@ -0,0 +1,179 @@
1
+ import { Plugin, PluginKey } from "prosemirror-state";
2
+ import type { EditorState, Transaction } from "prosemirror-state";
3
+ import { Decoration, DecorationSet } from "prosemirror-view";
4
+ import type { EditorView } from "prosemirror-view";
5
+ import type { Awareness } from "y-protocols/awareness";
6
+
7
+ import type { EditorStoryTarget } from "../../api/public-types";
8
+ import { storyTargetsEqual } from "../../core/selection/mapping";
9
+ import type { PositionMap } from "./pm-position-map";
10
+ import {
11
+ getRemoteCursorStates,
12
+ type RemoteCursorState,
13
+ } from "../../runtime/collab/remote-cursor-awareness";
14
+
15
+ interface RemoteCursorPluginState {
16
+ cursors: RemoteCursorState[];
17
+ }
18
+
19
+ export const remoteCursorPluginKey = new PluginKey<RemoteCursorPluginState>(
20
+ "remoteCursor",
21
+ );
22
+
23
+ export interface RemoteCursorPluginOptions {
24
+ awareness: Awareness;
25
+ localClientId: number;
26
+ getPositionMap: () => PositionMap | null;
27
+ getActiveStory: () => EditorStoryTarget;
28
+ }
29
+
30
+ export function createRemoteCursorPlugin(
31
+ options: RemoteCursorPluginOptions,
32
+ ): Plugin<RemoteCursorPluginState> {
33
+ const { awareness, localClientId, getPositionMap, getActiveStory } = options;
34
+
35
+ function computeCursors(): RemoteCursorState[] {
36
+ const activeStory = getActiveStory();
37
+ const allCursors = getRemoteCursorStates(awareness, localClientId);
38
+ return allCursors.filter((cursor) =>
39
+ storyTargetsEqual(cursor.storyTarget, activeStory),
40
+ );
41
+ }
42
+
43
+ function buildDecorations(state: EditorState): DecorationSet {
44
+ const positionMap = getPositionMap();
45
+ if (!positionMap) {
46
+ return DecorationSet.empty;
47
+ }
48
+
49
+ const pluginState = remoteCursorPluginKey.getState(state);
50
+ const cursors = pluginState?.cursors ?? [];
51
+ if (cursors.length === 0) {
52
+ return DecorationSet.empty;
53
+ }
54
+
55
+ const decorations: Decoration[] = [];
56
+
57
+ for (const cursor of cursors) {
58
+ const pmAnchor = positionMap.runtimeToPm(cursor.anchor);
59
+ const pmHead = positionMap.runtimeToPm(cursor.head);
60
+
61
+ const from = Math.min(pmAnchor, pmHead);
62
+ const to = Math.max(pmAnchor, pmHead);
63
+
64
+ if (from < 1 || to > state.doc.content.size) {
65
+ continue;
66
+ }
67
+
68
+ if (from !== to) {
69
+ decorations.push(
70
+ Decoration.inline(from, to, {
71
+ style: `background-color: ${cursor.color}33;`,
72
+ class: "remote-cursor-selection",
73
+ }),
74
+ );
75
+ }
76
+
77
+ decorations.push(
78
+ Decoration.widget(to, () => createCursorWidget(cursor), {
79
+ side: 1,
80
+ key: cursor.userId,
81
+ }),
82
+ );
83
+ }
84
+
85
+ return DecorationSet.create(state.doc, decorations);
86
+ }
87
+
88
+ const plugin = new Plugin<RemoteCursorPluginState>({
89
+ key: remoteCursorPluginKey,
90
+
91
+ state: {
92
+ init(): RemoteCursorPluginState {
93
+ return { cursors: computeCursors() };
94
+ },
95
+
96
+ apply(
97
+ tr: Transaction,
98
+ pluginState: RemoteCursorPluginState,
99
+ ): RemoteCursorPluginState {
100
+ const meta = tr.getMeta(remoteCursorPluginKey);
101
+ if (meta !== undefined) {
102
+ return meta;
103
+ }
104
+ return pluginState;
105
+ },
106
+ },
107
+
108
+ props: {
109
+ decorations(state: EditorState): DecorationSet {
110
+ return buildDecorations(state);
111
+ },
112
+ },
113
+
114
+ view(editorView: EditorView) {
115
+ const onChange = (): void => {
116
+ const newCursors = computeCursors();
117
+ const tr = editorView.state.tr.setMeta(remoteCursorPluginKey, {
118
+ cursors: newCursors,
119
+ });
120
+ editorView.dispatch(tr);
121
+ };
122
+
123
+ awareness.on("change", onChange);
124
+
125
+ return {
126
+ destroy() {
127
+ awareness.off("change", onChange);
128
+ },
129
+ };
130
+ },
131
+ });
132
+
133
+ return plugin;
134
+ }
135
+
136
+ function createCursorWidget(cursor: RemoteCursorState): HTMLElement {
137
+ const container = document.createElement("span");
138
+ container.className = "remote-cursor-widget";
139
+ container.style.cssText = `
140
+ position: relative;
141
+ display: inline-block;
142
+ pointer-events: none;
143
+ `;
144
+
145
+ const caret = document.createElement("span");
146
+ caret.style.cssText = `
147
+ position: absolute;
148
+ left: -1px;
149
+ top: -0.2em;
150
+ width: 2px;
151
+ height: 1.2em;
152
+ background-color: ${cursor.color};
153
+ border-radius: 1px;
154
+ `;
155
+ container.appendChild(caret);
156
+
157
+ const label = document.createElement("span");
158
+ label.style.cssText = `
159
+ position: absolute;
160
+ left: 0;
161
+ top: -1.4em;
162
+ font-size: 11px;
163
+ font-family: system-ui, -apple-system, sans-serif;
164
+ font-weight: 500;
165
+ color: white;
166
+ background-color: ${cursor.color};
167
+ padding: 1px 4px;
168
+ border-radius: 3px;
169
+ white-space: nowrap;
170
+ line-height: 1.3;
171
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
172
+ `;
173
+ label.textContent = cursor.displayName;
174
+ container.appendChild(label);
175
+
176
+ return container;
177
+ }
178
+
179
+ export type { RemoteCursorState };