@beyondwork/docx-react-component 1.0.85 → 1.0.87
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api/public-types.ts +49 -0
- package/src/api/v3/ui/chrome-composition.ts +2 -11
- package/src/api/v3/ui/chrome.ts +6 -8
- package/src/index.ts +5 -0
- package/src/io/export/serialize-main-document.ts +215 -6
- package/src/io/ooxml/parse-drawing.ts +15 -1
- package/src/io/ooxml/parse-fields.ts +410 -12
- package/src/model/canonical-document.ts +177 -2
- package/src/model/layout/page-layout-snapshot.ts +2 -0
- package/src/model/layout/runtime-page-graph-types.ts +6 -0
- package/src/preservation/store.ts +4 -5
- package/src/runtime/document-outline.ts +80 -0
- package/src/runtime/document-runtime.ts +338 -13
- package/src/runtime/formatting/field/page-number-format.ts +49 -0
- package/src/runtime/formatting/field/resolver.ts +61 -40
- package/src/runtime/layout/layout-engine-instance.ts +18 -1
- package/src/runtime/layout/layout-engine-version.ts +19 -1
- package/src/runtime/layout/measurement-backend-canvas.ts +21 -9
- package/src/runtime/layout/measurement-backend-empirical.ts +18 -4
- package/src/runtime/layout/page-graph.ts +13 -2
- package/src/runtime/layout/paginated-layout-engine.ts +440 -117
- package/src/runtime/layout/project-block-fragments.ts +87 -4
- package/src/runtime/layout/resolve-page-fields.ts +8 -5
- package/src/runtime/layout/table-row-split.ts +97 -23
- package/src/runtime/surface-projection.ts +227 -27
- package/src/shell/session-bootstrap.ts +6 -1
- package/src/ui/WordReviewEditor.tsx +112 -33
- package/src/ui/editor-command-bag.ts +4 -0
- package/src/ui/editor-shell-view.tsx +1 -0
- package/src/ui/editor-surface-controller.tsx +1 -0
- package/src/ui/headless/revision-decoration-model.ts +11 -13
- package/src/ui-tailwind/chrome/editor-action-registry.ts +7 -26
- package/src/ui-tailwind/chrome/responsive-chrome.ts +2 -2
- package/src/ui-tailwind/chrome/tw-alert-banner.tsx +27 -0
- package/src/ui-tailwind/chrome/tw-detach-handle.tsx +6 -2
- package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +57 -6
- package/src/ui-tailwind/editor-surface/page-slice-util.ts +17 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +5 -0
- package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +34 -0
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +146 -20
- package/src/ui-tailwind/editor-surface/pm-position-map.ts +8 -2
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +41 -44
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +1 -1
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +8 -3
- package/src/ui-tailwind/review/tw-review-rail.tsx +2 -0
- package/src/ui-tailwind/review/tw-revision-sidebar.tsx +75 -31
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +159 -8
- package/src/ui-tailwind/review-workspace/types.ts +4 -0
- package/src/ui-tailwind/review-workspace/use-review-rail-state.ts +1 -1
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +8 -10
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +21 -17
- package/src/ui-tailwind/tw-review-workspace.tsx +27 -2
|
@@ -3,13 +3,47 @@ import { Plugin } from "prosemirror-state";
|
|
|
3
3
|
export interface ContextualInteractionCallbacks {
|
|
4
4
|
onCommentActivated?: (commentId: string) => void;
|
|
5
5
|
onRevisionActivated?: (revisionId: string) => void;
|
|
6
|
+
onRevisionHovered?: (revisionId: string | null) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function findRevisionId(target: EventTarget | null): string | null {
|
|
10
|
+
const element = target as HTMLElement | null;
|
|
11
|
+
return element?.closest?.("[data-revision-id]")?.getAttribute("data-revision-id") ?? null;
|
|
6
12
|
}
|
|
7
13
|
|
|
8
14
|
export function createContextualInteractionPlugin(
|
|
9
15
|
callbacks: ContextualInteractionCallbacks,
|
|
10
16
|
): Plugin {
|
|
17
|
+
let hoveredRevisionId: string | null = null;
|
|
18
|
+
|
|
11
19
|
return new Plugin({
|
|
12
20
|
props: {
|
|
21
|
+
handleDOMEvents: {
|
|
22
|
+
mouseover(_view, event) {
|
|
23
|
+
const revisionId = findRevisionId(event.target);
|
|
24
|
+
if (!revisionId || revisionId === hoveredRevisionId) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
hoveredRevisionId = revisionId;
|
|
28
|
+
callbacks.onRevisionHovered?.(revisionId);
|
|
29
|
+
return false;
|
|
30
|
+
},
|
|
31
|
+
mouseout(_view, event) {
|
|
32
|
+
const revisionId = findRevisionId(event.target);
|
|
33
|
+
if (!revisionId) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
const relatedRevisionId = findRevisionId(event.relatedTarget);
|
|
37
|
+
if (relatedRevisionId === revisionId) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (hoveredRevisionId === revisionId) {
|
|
41
|
+
hoveredRevisionId = null;
|
|
42
|
+
callbacks.onRevisionHovered?.(null);
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
},
|
|
46
|
+
},
|
|
13
47
|
handleClick(_view, _pos, event) {
|
|
14
48
|
const target = event.target as HTMLElement | null;
|
|
15
49
|
const commentId = target?.closest?.("[data-comment-id]")?.getAttribute("data-comment-id");
|
|
@@ -4,10 +4,12 @@ import type { CommentDecorationModel } from "../../ui/headless/comment-decoratio
|
|
|
4
4
|
import { getCommentHighlightClass, type MarkupDisplay } from "../../ui/headless/comment-decoration-model";
|
|
5
5
|
import type {
|
|
6
6
|
RevisionDecorationModel,
|
|
7
|
+
RevisionDecorationEntry,
|
|
7
8
|
RevisionDisplayFlags,
|
|
8
9
|
} from "../../ui/headless/revision-decoration-model";
|
|
9
10
|
import {
|
|
10
11
|
buildClassFromRevisionDisplay,
|
|
12
|
+
getAuthorColor,
|
|
11
13
|
getRevisionHighlightClass,
|
|
12
14
|
} from "../../ui/headless/revision-decoration-model";
|
|
13
15
|
import type {
|
|
@@ -46,6 +48,106 @@ type RailDecorationSpec = {
|
|
|
46
48
|
attrs: Record<string, string>;
|
|
47
49
|
};
|
|
48
50
|
|
|
51
|
+
function sanitizeRevisionAuthorColor(raw: unknown): string | null {
|
|
52
|
+
if (typeof raw !== "string") return null;
|
|
53
|
+
const value = raw.trim();
|
|
54
|
+
if (/^var\(--color-chart-categorical-[1-8]\)$/.test(value)) return value;
|
|
55
|
+
return sanitizeHostCssColor(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveRevisionAuthorColor(
|
|
59
|
+
rev: RevisionDecorationEntry,
|
|
60
|
+
display?: RevisionDisplayFlags,
|
|
61
|
+
): string | undefined {
|
|
62
|
+
return sanitizeRevisionAuthorColor(display?.authorColor) ?? getAuthorColor(rev.authorId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildRevisionAuthorStyle(
|
|
66
|
+
kind: RevisionDecorationEntry["kind"],
|
|
67
|
+
authorColor: string | undefined,
|
|
68
|
+
): string | undefined {
|
|
69
|
+
if (!authorColor) return undefined;
|
|
70
|
+
|
|
71
|
+
const backgroundStrength =
|
|
72
|
+
kind === "deletion" ? "8%" : kind === "insertion" ? "10%" : "9%";
|
|
73
|
+
return [
|
|
74
|
+
`--wre-revision-author: ${authorColor}`,
|
|
75
|
+
"color: var(--wre-revision-author)",
|
|
76
|
+
`background-color: color-mix(in srgb, var(--wre-revision-author) ${backgroundStrength}, transparent)`,
|
|
77
|
+
`text-decoration-color: var(--wre-revision-author)`,
|
|
78
|
+
"text-decoration-thickness: 2px",
|
|
79
|
+
"text-underline-offset: 2px",
|
|
80
|
+
"box-decoration-break: clone",
|
|
81
|
+
"-webkit-box-decoration-break: clone",
|
|
82
|
+
].join("; ");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function labelRevisionKind(kind: RevisionDecorationEntry["kind"]): string {
|
|
86
|
+
switch (kind) {
|
|
87
|
+
case "insertion":
|
|
88
|
+
return "Insertion";
|
|
89
|
+
case "deletion":
|
|
90
|
+
return "Deletion";
|
|
91
|
+
case "formatting":
|
|
92
|
+
return "Formatting change";
|
|
93
|
+
case "move":
|
|
94
|
+
return "Move";
|
|
95
|
+
case "property-change":
|
|
96
|
+
return "Property change";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildRevisionInlineAttrs(
|
|
101
|
+
rev: RevisionDecorationEntry,
|
|
102
|
+
className: string,
|
|
103
|
+
display?: RevisionDisplayFlags,
|
|
104
|
+
): Record<string, string> {
|
|
105
|
+
const attrs: Record<string, string> = {
|
|
106
|
+
class: className,
|
|
107
|
+
"data-revision-id": rev.revisionId,
|
|
108
|
+
"data-revision-kind": rev.kind,
|
|
109
|
+
};
|
|
110
|
+
if (rev.authorId) {
|
|
111
|
+
attrs["data-revision-author-id"] = rev.authorId;
|
|
112
|
+
attrs.title = `${labelRevisionKind(rev.kind)} by ${rev.authorId}`;
|
|
113
|
+
}
|
|
114
|
+
if (rev.authorPaletteIndex !== undefined) {
|
|
115
|
+
attrs["data-revision-author-index"] = String(rev.authorPaletteIndex);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const style = buildRevisionAuthorStyle(
|
|
119
|
+
rev.kind,
|
|
120
|
+
resolveRevisionAuthorColor(rev, display),
|
|
121
|
+
);
|
|
122
|
+
if (style) {
|
|
123
|
+
attrs.style = style;
|
|
124
|
+
}
|
|
125
|
+
return attrs;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildRevisionBoundaryAttrs(
|
|
129
|
+
rev: RevisionDecorationEntry,
|
|
130
|
+
display?: RevisionDisplayFlags,
|
|
131
|
+
): Record<string, string> {
|
|
132
|
+
const attrs: Record<string, string> = {
|
|
133
|
+
class: "text-insert font-semibold",
|
|
134
|
+
"data-revision-id": rev.revisionId,
|
|
135
|
+
"data-revision-kind": rev.kind,
|
|
136
|
+
};
|
|
137
|
+
if (rev.authorId) {
|
|
138
|
+
attrs["data-revision-author-id"] = rev.authorId;
|
|
139
|
+
attrs.title = `${labelRevisionKind(rev.kind)} by ${rev.authorId}`;
|
|
140
|
+
}
|
|
141
|
+
if (rev.authorPaletteIndex !== undefined) {
|
|
142
|
+
attrs["data-revision-author-index"] = String(rev.authorPaletteIndex);
|
|
143
|
+
}
|
|
144
|
+
const authorColor = resolveRevisionAuthorColor(rev, display);
|
|
145
|
+
if (authorColor) {
|
|
146
|
+
attrs.style = `color: ${authorColor}`;
|
|
147
|
+
}
|
|
148
|
+
return attrs;
|
|
149
|
+
}
|
|
150
|
+
|
|
49
151
|
/**
|
|
50
152
|
* Validate and normalize a host-supplied CSS color before interpolating it
|
|
51
153
|
* into an inline-style string. Accepts only the narrow subset a
|
|
@@ -466,6 +568,7 @@ export function buildDecorations(
|
|
|
466
568
|
Decoration.inline(cleanPmFrom, cleanPmTo, {
|
|
467
569
|
class: "hidden",
|
|
468
570
|
"data-revision-id": rev.revisionId,
|
|
571
|
+
"data-revision-kind": rev.kind,
|
|
469
572
|
}),
|
|
470
573
|
);
|
|
471
574
|
revisionCount += 1;
|
|
@@ -480,17 +583,28 @@ export function buildDecorations(
|
|
|
480
583
|
// Suggestions styling is always shown regardless of showTrackedChanges toggle.
|
|
481
584
|
if (suggestionsEnabled) {
|
|
482
585
|
if (rev.kind === "insertion") {
|
|
586
|
+
const insertionClass =
|
|
587
|
+
buildClassFromRevisionDisplay(revDisplayFlags) ||
|
|
588
|
+
getRevisionHighlightClass(revisionModel, rev.from, rev.to, "all");
|
|
483
589
|
decorations.push(
|
|
484
|
-
Decoration.inline(
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
590
|
+
Decoration.inline(
|
|
591
|
+
pmFrom,
|
|
592
|
+
pmTo,
|
|
593
|
+
buildRevisionInlineAttrs(rev, insertionClass, revDisplayFlags),
|
|
594
|
+
),
|
|
488
595
|
);
|
|
489
596
|
decorations.push(
|
|
490
597
|
Decoration.widget(pmFrom, () => {
|
|
491
598
|
const el = document.createElement("span");
|
|
492
599
|
el.textContent = "[";
|
|
493
|
-
|
|
600
|
+
const attrs = buildRevisionBoundaryAttrs(rev, revDisplayFlags);
|
|
601
|
+
for (const [name, value] of Object.entries(attrs)) {
|
|
602
|
+
if (name === "class") {
|
|
603
|
+
el.className = value;
|
|
604
|
+
} else {
|
|
605
|
+
el.setAttribute(name, value);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
494
608
|
el.setAttribute("contenteditable", "false");
|
|
495
609
|
return el;
|
|
496
610
|
}, { side: -1, key: `${rev.revisionId}-open` }),
|
|
@@ -499,30 +613,41 @@ export function buildDecorations(
|
|
|
499
613
|
Decoration.widget(pmTo, () => {
|
|
500
614
|
const el = document.createElement("span");
|
|
501
615
|
el.textContent = "]";
|
|
502
|
-
|
|
616
|
+
const attrs = buildRevisionBoundaryAttrs(rev, revDisplayFlags);
|
|
617
|
+
for (const [name, value] of Object.entries(attrs)) {
|
|
618
|
+
if (name === "class") {
|
|
619
|
+
el.className = value;
|
|
620
|
+
} else {
|
|
621
|
+
el.setAttribute(name, value);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
503
624
|
el.setAttribute("contenteditable", "false");
|
|
504
625
|
return el;
|
|
505
626
|
}, { side: 1, key: `${rev.revisionId}-close` }),
|
|
506
627
|
);
|
|
507
628
|
revisionCount += 1;
|
|
508
629
|
} else if (rev.kind === "deletion") {
|
|
630
|
+
const deletionClass =
|
|
631
|
+
buildClassFromRevisionDisplay(revDisplayFlags) ||
|
|
632
|
+
getRevisionHighlightClass(revisionModel, rev.from, rev.to, "all");
|
|
509
633
|
decorations.push(
|
|
510
|
-
Decoration.inline(
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
634
|
+
Decoration.inline(
|
|
635
|
+
pmFrom,
|
|
636
|
+
pmTo,
|
|
637
|
+
buildRevisionInlineAttrs(rev, deletionClass, revDisplayFlags),
|
|
638
|
+
),
|
|
514
639
|
);
|
|
515
640
|
revisionCount += 1;
|
|
516
641
|
} else if (rev.kind === "property-change" || rev.kind === "formatting") {
|
|
517
642
|
const propertyChangeClass =
|
|
518
643
|
buildClassFromRevisionDisplay(revDisplayFlags) ||
|
|
519
|
-
"underline decoration-accent/
|
|
644
|
+
"rounded-[2px] bg-accent-soft/70 px-[1px] underline decoration-accent/80 decoration-dotted decoration-2 underline-offset-2";
|
|
520
645
|
decorations.push(
|
|
521
|
-
Decoration.inline(
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
646
|
+
Decoration.inline(
|
|
647
|
+
pmFrom,
|
|
648
|
+
pmTo,
|
|
649
|
+
buildRevisionInlineAttrs(rev, propertyChangeClass, revDisplayFlags),
|
|
650
|
+
),
|
|
526
651
|
);
|
|
527
652
|
revisionCount += 1;
|
|
528
653
|
}
|
|
@@ -547,10 +672,11 @@ export function buildDecorations(
|
|
|
547
672
|
if (!cls) continue;
|
|
548
673
|
|
|
549
674
|
decorations.push(
|
|
550
|
-
Decoration.inline(
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
675
|
+
Decoration.inline(
|
|
676
|
+
pmFrom,
|
|
677
|
+
pmTo,
|
|
678
|
+
buildRevisionInlineAttrs(rev, cls, displayFlags),
|
|
679
|
+
),
|
|
554
680
|
);
|
|
555
681
|
revisionCount += 1;
|
|
556
682
|
}
|
|
@@ -87,13 +87,19 @@ function walkBlocks(
|
|
|
87
87
|
break;
|
|
88
88
|
}
|
|
89
89
|
case "opaque_block": {
|
|
90
|
+
const placeholderSize =
|
|
91
|
+
block.state === "placeholder-culled" &&
|
|
92
|
+
typeof block.placeholderSize === "number" &&
|
|
93
|
+
Number.isFinite(block.placeholderSize)
|
|
94
|
+
? Math.max(1, block.placeholderSize)
|
|
95
|
+
: 1;
|
|
90
96
|
entries.push({
|
|
91
97
|
runtimeStart: block.from,
|
|
92
98
|
pmStart: nextPmCursor,
|
|
93
99
|
runtimeEnd: block.to,
|
|
94
|
-
pmEnd: nextPmCursor +
|
|
100
|
+
pmEnd: nextPmCursor + placeholderSize,
|
|
95
101
|
});
|
|
96
|
-
nextPmCursor +=
|
|
102
|
+
nextPmCursor += placeholderSize;
|
|
97
103
|
break;
|
|
98
104
|
}
|
|
99
105
|
case "sdt_block": {
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
} from "./pm-command-bridge";
|
|
52
52
|
import { buildDecorations } from "./pm-decorations";
|
|
53
53
|
import { buildPageBreakDecorations } from "./pm-page-break-decorations";
|
|
54
|
+
import { findBlockIndexRangeForPage } from "./page-slice-util.ts";
|
|
54
55
|
import { DecorationSet } from "prosemirror-view";
|
|
55
56
|
import type { WordReviewEditorLayoutFacet } from "../../api/public-types.ts";
|
|
56
57
|
import { buildPagePreviewMaps } from "../../api/public-types";
|
|
@@ -63,7 +64,10 @@ import {
|
|
|
63
64
|
} from "./perf-probe";
|
|
64
65
|
import { buildPositionMap, type PositionMap } from "./pm-position-map";
|
|
65
66
|
import { createLocalEditSessionState } from "./local-edit-session-state";
|
|
66
|
-
import {
|
|
67
|
+
import {
|
|
68
|
+
createFastTextEditLane,
|
|
69
|
+
getTextCommandRefreshClass,
|
|
70
|
+
} from "./fast-text-edit-lane";
|
|
67
71
|
import { createPredictedTxGate } from "./predicted-tx-gate";
|
|
68
72
|
import { replaceStatePreservingPosition } from "./preserve-position";
|
|
69
73
|
import {
|
|
@@ -148,9 +152,12 @@ function buildPageBreakDecorationsFromProps(
|
|
|
148
152
|
: undefined;
|
|
149
153
|
|
|
150
154
|
// L7 Phase 2 Task 2.2.4a — compute per-page block-index ranges from the
|
|
151
|
-
// render frame's page offsets + the surface blocks list.
|
|
152
|
-
// `from`/`to` offset;
|
|
153
|
-
//
|
|
155
|
+
// render frame's page offsets + the surface blocks list. Each block has a
|
|
156
|
+
// `from`/`to` offset; a block belongs to every page whose offset window it
|
|
157
|
+
// overlaps. That matters for large tables/objects that can straddle a page
|
|
158
|
+
// boundary: matching only `block.from` would omit the active block from the
|
|
159
|
+
// page marker, cull it on the next viewport refresh, and remap the caret to
|
|
160
|
+
// the wrong PM position during snapshot replacement.
|
|
154
161
|
// This map is passed into `buildPageBreakDecorations` so the chrome widgets
|
|
155
162
|
// carry `data-page-first-block-index` / `data-page-last-block-index`
|
|
156
163
|
// attributes needed by `useVisibleBlockRange`.
|
|
@@ -160,24 +167,9 @@ function buildPageBreakDecorationsFromProps(
|
|
|
160
167
|
for (let pi = 0; pi < frame.pages.length; pi++) {
|
|
161
168
|
const page = frame.pages[pi]!;
|
|
162
169
|
if (page.page.isBlankFiller) continue;
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
? frame.pages[pi + 1]!.page.startOffset
|
|
167
|
-
: Infinity;
|
|
168
|
-
let first = -1;
|
|
169
|
-
let last = -1;
|
|
170
|
-
for (let bi = 0; bi < surfaceBlocks.length; bi++) {
|
|
171
|
-
const block = surfaceBlocks[bi]!;
|
|
172
|
-
const blockFrom = block.from; // from is required on all SurfaceBlockSnapshot variants
|
|
173
|
-
// Block belongs to this page if its start falls within the page's offset window.
|
|
174
|
-
if (blockFrom >= pageStart && blockFrom < pageEnd) {
|
|
175
|
-
if (first === -1) first = bi;
|
|
176
|
-
last = bi;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
if (first !== -1) {
|
|
180
|
-
blockIndexRangeByPageIndex.set(page.page.pageIndex, { first, last });
|
|
170
|
+
const range = findBlockIndexRangeForPage(surfaceBlocks, page.page);
|
|
171
|
+
if (range) {
|
|
172
|
+
blockIndexRangeByPageIndex.set(page.page.pageIndex, range);
|
|
181
173
|
}
|
|
182
174
|
}
|
|
183
175
|
}
|
|
@@ -290,6 +282,7 @@ export interface TwProseMirrorSurfaceProps {
|
|
|
290
282
|
}) => void;
|
|
291
283
|
onCommentActivated?: (commentId: string) => void;
|
|
292
284
|
onRevisionActivated?: (revisionId: string) => void;
|
|
285
|
+
onRevisionHovered?: (revisionId: string | null) => void;
|
|
293
286
|
onSelectionToolbarAnchorChange?: (anchor: SelectionToolbarAnchor | null) => void;
|
|
294
287
|
mediaPreviews?: Record<string, MediaPreviewDescriptor>;
|
|
295
288
|
workflowScopes?: readonly WorkflowScope[];
|
|
@@ -441,7 +434,7 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
441
434
|
const suppressSelectionEchoRef = useRef(false);
|
|
442
435
|
const sessionRef = useRef<import("./local-edit-session-state").LocalEditSessionState | null>(null);
|
|
443
436
|
const laneRef = useRef<import("./fast-text-edit-lane").FastTextEditLane | null>(null);
|
|
444
|
-
const
|
|
437
|
+
const equivalentAckLedgerRef = useRef<Map<string, string>>(new Map());
|
|
445
438
|
const selectionToolbarFrameRef = useRef<number | null>(null);
|
|
446
439
|
const lastSelectionToolbarMeasurementRef = useRef<{
|
|
447
440
|
key: string | null;
|
|
@@ -653,10 +646,11 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
653
646
|
createContextualInteractionPlugin({
|
|
654
647
|
onCommentActivated: (commentId) => props.onCommentActivated?.(commentId),
|
|
655
648
|
onRevisionActivated: (revisionId) => props.onRevisionActivated?.(revisionId),
|
|
649
|
+
onRevisionHovered: (revisionId) => props.onRevisionHovered?.(revisionId),
|
|
656
650
|
}),
|
|
657
651
|
createSearchPlugin(),
|
|
658
652
|
];
|
|
659
|
-
}, [props.awareness, props.onCommentActivated, props.onRevisionActivated]);
|
|
653
|
+
}, [props.awareness, props.onCommentActivated, props.onRevisionActivated, props.onRevisionHovered]);
|
|
660
654
|
|
|
661
655
|
const applyDecorationProps = useCallback(
|
|
662
656
|
(view: EditorView, positionMap: PositionMap): void => {
|
|
@@ -771,6 +765,7 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
771
765
|
useEffect(() => {
|
|
772
766
|
if (!props.dispatchRuntimeCommand || !sessionRef.current) {
|
|
773
767
|
laneRef.current = null;
|
|
768
|
+
equivalentAckLedgerRef.current.clear();
|
|
774
769
|
return;
|
|
775
770
|
}
|
|
776
771
|
// Wave 1 Slice E1/E2 — lane observability.
|
|
@@ -815,28 +810,27 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
815
810
|
toRuntime,
|
|
816
811
|
);
|
|
817
812
|
},
|
|
818
|
-
onEquivalentAck: () => {
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
equivalentAckKeyRef.current = documentBuildKeyRef.current;
|
|
813
|
+
onEquivalentAck: (ack) => {
|
|
814
|
+
if (
|
|
815
|
+
ack.opId &&
|
|
816
|
+
ack.newRevisionToken &&
|
|
817
|
+
getTextCommandRefreshClass(ack) === "local-text-equivalent"
|
|
818
|
+
) {
|
|
819
|
+
equivalentAckLedgerRef.current.set(ack.newRevisionToken, ack.opId);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
equivalentAckLedgerRef.current.clear();
|
|
829
823
|
},
|
|
830
824
|
onAdjustedAck: () => {
|
|
831
825
|
// Adjusted path: allow the rebuild effect to run (it will call
|
|
832
826
|
// view.updateState with the canonical snapshot).
|
|
833
|
-
|
|
827
|
+
equivalentAckLedgerRef.current.clear();
|
|
834
828
|
},
|
|
835
829
|
onRejectedAck: () => {
|
|
836
|
-
|
|
830
|
+
equivalentAckLedgerRef.current.clear();
|
|
837
831
|
},
|
|
838
832
|
onStructuralDivergence: () => {
|
|
839
|
-
|
|
833
|
+
equivalentAckLedgerRef.current.clear();
|
|
840
834
|
},
|
|
841
835
|
});
|
|
842
836
|
}, [props.dispatchRuntimeCommand, scopeTagRegistry]);
|
|
@@ -852,13 +846,16 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
852
846
|
// ack, the PM doc already matches the canonical snapshot. Update tracking
|
|
853
847
|
// refs and decorations without rebuilding the PM state.
|
|
854
848
|
//
|
|
855
|
-
// INVARIANT:
|
|
856
|
-
//
|
|
857
|
-
//
|
|
858
|
-
|
|
849
|
+
// INVARIANT: equivalent acks are tracked by revision token and op id, not
|
|
850
|
+
// by the previous build key. This keeps the short-circuit valid if the ack
|
|
851
|
+
// and render snapshot stop arriving in the same synchronous React pass.
|
|
852
|
+
const equivalentAckOpId =
|
|
853
|
+
sessionRef.current && !sessionRef.current.hasPending()
|
|
854
|
+
? equivalentAckLedgerRef.current.get(snapshot.revisionToken)
|
|
855
|
+
: undefined;
|
|
859
856
|
if (
|
|
860
857
|
viewRef.current &&
|
|
861
|
-
|
|
858
|
+
equivalentAckOpId !== undefined &&
|
|
862
859
|
sessionRef.current &&
|
|
863
860
|
!sessionRef.current.hasPending() &&
|
|
864
861
|
sessionRef.current.getBaseRevisionToken() === snapshot.revisionToken
|
|
@@ -868,7 +865,7 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
868
865
|
positionMapRef.current = buildPositionMap(surface);
|
|
869
866
|
documentBuildKeyRef.current = documentBuildKey;
|
|
870
867
|
applyDecorationProps(viewRef.current, positionMapRef.current);
|
|
871
|
-
|
|
868
|
+
equivalentAckLedgerRef.current.delete(snapshot.revisionToken);
|
|
872
869
|
if (pendingTypingProbeRef.current) {
|
|
873
870
|
finishPerfProbe(pendingTypingProbeRef.current);
|
|
874
871
|
pendingTypingProbeRef.current = null;
|
|
@@ -172,7 +172,7 @@ function resolveBlockRangeFromOffsetSpan(input: {
|
|
|
172
172
|
const block = blocks[index];
|
|
173
173
|
if (!block) continue;
|
|
174
174
|
if (block.from >= endOffset) break;
|
|
175
|
-
if (block.from
|
|
175
|
+
if (block.from < endOffset && block.to > startOffset) {
|
|
176
176
|
if (first < 0) first = index;
|
|
177
177
|
last = index;
|
|
178
178
|
}
|
|
@@ -153,6 +153,7 @@ function CommentThreadCard(props: {
|
|
|
153
153
|
}, [presentation]);
|
|
154
154
|
const leadEntry = thread.entries[0];
|
|
155
155
|
const isDraftThread = thread.status === "open" && thread.entryCount === 1 && isEmptyCommentBody(leadEntry?.body);
|
|
156
|
+
const isLinkedRevisionThread = thread.linkedRevisionId != null;
|
|
156
157
|
const isOwnComment = props.currentUserId != null && leadEntry?.authorId === props.currentUserId;
|
|
157
158
|
const canEdit = isOwnComment && thread.status === "open" && props.onEditBody != null;
|
|
158
159
|
const hasNoBody = isEmptyCommentBody(leadEntry?.body);
|
|
@@ -205,6 +206,7 @@ function CommentThreadCard(props: {
|
|
|
205
206
|
{formatCommentDate(thread.createdAt)}
|
|
206
207
|
</span>
|
|
207
208
|
<span className="flex-1" />
|
|
209
|
+
{isLinkedRevisionThread ? <StatusBadge label="tracked change" tone="revision" /> : null}
|
|
208
210
|
{isDraftThread ? <StatusBadge label="draft" tone="draft" /> : null}
|
|
209
211
|
{thread.status === "resolved" ? <StatusBadge label="resolved" tone="resolved" /> : null}
|
|
210
212
|
</div>
|
|
@@ -222,7 +224,9 @@ function CommentThreadCard(props: {
|
|
|
222
224
|
body={leadEntry?.body ?? ""}
|
|
223
225
|
autoFocus={isActive && hasNoBody}
|
|
224
226
|
onSave={(newBody) => props.onEditBody?.(thread.commentId, newBody)}
|
|
225
|
-
label={isDraftThread
|
|
227
|
+
label={isDraftThread
|
|
228
|
+
? (isLinkedRevisionThread ? "Tracked change discussion" : "New comment")
|
|
229
|
+
: undefined}
|
|
226
230
|
/>
|
|
227
231
|
) : presentation ? (
|
|
228
232
|
<CommentMarkdownRenderer
|
|
@@ -247,7 +251,7 @@ function CommentThreadCard(props: {
|
|
|
247
251
|
props.onOpenComment?.(thread);
|
|
248
252
|
}}
|
|
249
253
|
>
|
|
250
|
-
New comment
|
|
254
|
+
{isLinkedRevisionThread ? "Tracked change discussion" : "New comment"}
|
|
251
255
|
</p>
|
|
252
256
|
) : null}
|
|
253
257
|
|
|
@@ -494,11 +498,12 @@ function formatCommentDate(raw: string): string {
|
|
|
494
498
|
}
|
|
495
499
|
}
|
|
496
500
|
|
|
497
|
-
function StatusBadge(props: { label: string; tone: "resolved" | "detached" | "draft" }) {
|
|
501
|
+
function StatusBadge(props: { label: string; tone: "resolved" | "detached" | "draft" | "revision" }) {
|
|
498
502
|
const styles: Record<string, string> = {
|
|
499
503
|
resolved: "text-insert bg-insert-soft",
|
|
500
504
|
detached: "text-comment bg-warning-soft",
|
|
501
505
|
draft: "text-secondary bg-subtle",
|
|
506
|
+
revision: "text-accent bg-accent-soft",
|
|
502
507
|
};
|
|
503
508
|
return (
|
|
504
509
|
<span
|
|
@@ -111,6 +111,7 @@ export interface TwReviewRailProps {
|
|
|
111
111
|
onAddReply?: (commentId: string, body: string) => void;
|
|
112
112
|
onEditBody?: (commentId: string, body: string) => void;
|
|
113
113
|
onOpenRevision?: (revision: TrackedChangeEntrySnapshot) => void;
|
|
114
|
+
onReplyToRevision?: (revision: TrackedChangeEntrySnapshot) => void;
|
|
114
115
|
onAcceptRevision?: (revisionId: string) => void;
|
|
115
116
|
onRejectRevision?: (revisionId: string) => void;
|
|
116
117
|
onAcceptAllChanges?: () => void;
|
|
@@ -285,6 +286,7 @@ export function TwReviewRail(props: TwReviewRailProps) {
|
|
|
285
286
|
markupDisplay={props.markupDisplay}
|
|
286
287
|
activeRevisionId={props.activeRevisionId}
|
|
287
288
|
onOpenRevision={props.onOpenRevision}
|
|
289
|
+
onReplyToRevision={props.onReplyToRevision}
|
|
288
290
|
onAcceptRevision={props.onAcceptRevision}
|
|
289
291
|
onRejectRevision={props.onRejectRevision}
|
|
290
292
|
onAcceptAllChanges={props.onAcceptAllChanges}
|