@beyondwork/docx-react-component 1.0.48 → 1.0.49
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/README.md +16 -11
- package/package.json +30 -41
- package/src/api/public-types.ts +84 -12
- package/src/core/commands/index.ts +9 -1
- package/src/core/commands/text-commands.ts +3 -1
- package/src/core/selection/anchor-conversion.ts +112 -0
- package/src/core/selection/review-anchors.ts +108 -3
- package/src/core/state/text-transaction.ts +86 -2
- package/src/internal/harness-debug-ports.ts +168 -0
- package/src/io/chart-preview-resolver.ts +32 -1
- package/src/io/export/serialize-main-document.ts +9 -0
- package/src/io/export/serialize-paragraph-formatting.ts +8 -0
- package/src/io/export/serialize-run-formatting.ts +10 -1
- package/src/io/ooxml/chart/chart-style-table.ts +543 -0
- package/src/io/ooxml/chart/color-palette.ts +101 -0
- package/src/io/ooxml/chart/compose-series-color.ts +147 -0
- package/src/io/ooxml/chart/parse-chart-space.ts +118 -46
- package/src/io/ooxml/chart/parse-series.ts +76 -11
- package/src/io/ooxml/chart/resolve-color.ts +16 -6
- package/src/io/ooxml/chart/types.ts +30 -11
- package/src/io/ooxml/parse-complex-content.ts +6 -3
- package/src/io/ooxml/parse-main-document.ts +41 -0
- package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
- package/src/io/ooxml/parse-run-formatting.ts +49 -0
- package/src/io/ooxml/property-grab-bag.ts +211 -0
- package/src/model/canonical-document.ts +69 -3
- package/src/runtime/collab/index.ts +7 -0
- package/src/runtime/collab/runtime-collab-sync.ts +51 -0
- package/src/runtime/collab/workflow-shared.ts +247 -0
- package/src/runtime/document-locations.ts +1 -9
- package/src/runtime/document-outline.ts +1 -9
- package/src/runtime/document-runtime.ts +74 -49
- package/src/runtime/hyperlink-color-resolver.ts +119 -0
- package/src/runtime/surface-projection.ts +94 -36
- package/src/runtime/theme-color-resolver.ts +188 -0
- package/src/runtime/workflow-markup.ts +7 -18
- package/src/ui/WordReviewEditor.tsx +18 -2
- package/src/ui/editor-runtime-boundary.ts +36 -0
- package/src/ui/headless/selection-helpers.ts +10 -23
- package/src/ui/unsupported-previews-policy.ts +23 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +16 -1
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
|
|
3
|
+
import type { CollabBlockReason } from "../../api/comment-negotiation-types.ts";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Public types
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export interface SharedWorkflowState {
|
|
10
|
+
lockedMode?: "editing" | "suggesting" | "commenting" | "viewing";
|
|
11
|
+
roundDeadline?: string; // ISO-8601
|
|
12
|
+
assignedReviewers?: string[]; // userIds
|
|
13
|
+
workItemId?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CreateWorkflowSharedOptions {
|
|
17
|
+
ydoc: Y.Doc;
|
|
18
|
+
role: "author" | "reviewer" | "observer";
|
|
19
|
+
/**
|
|
20
|
+
* Reserved for future audit use — emit `{ actor: localAuthorId }` on
|
|
21
|
+
* writes once the audit path lands. Currently unused at runtime;
|
|
22
|
+
* optional so callers without an author-id scheme don't have to
|
|
23
|
+
* fabricate one.
|
|
24
|
+
*/
|
|
25
|
+
localAuthorId?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type WorkflowSharedResult =
|
|
29
|
+
| { ok: true }
|
|
30
|
+
| { ok: false; reason: CollabBlockReason };
|
|
31
|
+
|
|
32
|
+
export interface WorkflowSharedHandle {
|
|
33
|
+
get(): SharedWorkflowState;
|
|
34
|
+
setLockedMode(mode: SharedWorkflowState["lockedMode"]): WorkflowSharedResult;
|
|
35
|
+
setRoundDeadline(deadline: string | undefined): WorkflowSharedResult;
|
|
36
|
+
setAssignedReviewers(reviewers: string[]): WorkflowSharedResult;
|
|
37
|
+
setWorkItemId(id: string | undefined): WorkflowSharedResult;
|
|
38
|
+
subscribe(listener: (state: SharedWorkflowState) => void): () => void;
|
|
39
|
+
destroy(): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Y.Map key constants
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
const WORKFLOW_MAP_NAME = "workflow";
|
|
47
|
+
const KEY_LOCKED_MODE = "lockedMode";
|
|
48
|
+
const KEY_ROUND_DEADLINE = "roundDeadline";
|
|
49
|
+
const KEY_ASSIGNED_REVIEWERS = "assignedReviewers";
|
|
50
|
+
const KEY_WORK_ITEM_ID = "workItemId";
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Shallow-equality helpers
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
function arrayShallowEqual(
|
|
57
|
+
a: string[] | undefined,
|
|
58
|
+
b: string[] | undefined,
|
|
59
|
+
): boolean {
|
|
60
|
+
if (a === b) return true;
|
|
61
|
+
if (a == null || b == null) return false;
|
|
62
|
+
if (a.length !== b.length) return false;
|
|
63
|
+
for (let i = 0; i < a.length; i++) {
|
|
64
|
+
if (a[i] !== b[i]) return false;
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stateShallowEqual(
|
|
70
|
+
a: SharedWorkflowState,
|
|
71
|
+
b: SharedWorkflowState,
|
|
72
|
+
): boolean {
|
|
73
|
+
return (
|
|
74
|
+
a.lockedMode === b.lockedMode &&
|
|
75
|
+
a.roundDeadline === b.roundDeadline &&
|
|
76
|
+
a.workItemId === b.workItemId &&
|
|
77
|
+
arrayShallowEqual(a.assignedReviewers, b.assignedReviewers)
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Factory
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Creates a handle over `ydoc.getMap<unknown>("workflow")` that propagates
|
|
87
|
+
* shared workflow state — `lockedMode`, `roundDeadline`, `assignedReviewers`,
|
|
88
|
+
* and `workItemId` — across collab peers via per-key Yjs LWW conflict
|
|
89
|
+
* resolution.
|
|
90
|
+
*
|
|
91
|
+
* Role-gating (§7 lane plan):
|
|
92
|
+
* - observer: all writes refused with `collab_observer_readonly`.
|
|
93
|
+
* - reviewer: only `setAssignedReviewers` allowed; other writes refused with
|
|
94
|
+
* `collab_role_restricted`.
|
|
95
|
+
* - author: all writes allowed.
|
|
96
|
+
*/
|
|
97
|
+
export function createWorkflowShared(
|
|
98
|
+
options: CreateWorkflowSharedOptions,
|
|
99
|
+
): WorkflowSharedHandle {
|
|
100
|
+
const { ydoc, role } = options;
|
|
101
|
+
// Reserved for future audit path — see CreateWorkflowSharedOptions.localAuthorId.
|
|
102
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
103
|
+
const _localAuthorId = options.localAuthorId;
|
|
104
|
+
|
|
105
|
+
const yMap = ydoc.getMap<unknown>(WORKFLOW_MAP_NAME);
|
|
106
|
+
|
|
107
|
+
const listeners = new Set<(state: SharedWorkflowState) => void>();
|
|
108
|
+
let destroyed = false;
|
|
109
|
+
let lastEmitted: SharedWorkflowState | null = null;
|
|
110
|
+
|
|
111
|
+
// -------------------------------------------------------------------------
|
|
112
|
+
// Read helper
|
|
113
|
+
// -------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
function readState(): SharedWorkflowState {
|
|
116
|
+
const state: SharedWorkflowState = {};
|
|
117
|
+
const lockedMode = yMap.get(KEY_LOCKED_MODE);
|
|
118
|
+
if (lockedMode !== undefined) {
|
|
119
|
+
state.lockedMode = lockedMode as SharedWorkflowState["lockedMode"];
|
|
120
|
+
}
|
|
121
|
+
const roundDeadline = yMap.get(KEY_ROUND_DEADLINE);
|
|
122
|
+
if (roundDeadline !== undefined) {
|
|
123
|
+
state.roundDeadline = roundDeadline as string;
|
|
124
|
+
}
|
|
125
|
+
const assignedReviewers = yMap.get(KEY_ASSIGNED_REVIEWERS);
|
|
126
|
+
if (assignedReviewers !== undefined) {
|
|
127
|
+
state.assignedReviewers = [...(assignedReviewers as string[])];
|
|
128
|
+
}
|
|
129
|
+
const workItemId = yMap.get(KEY_WORK_ITEM_ID);
|
|
130
|
+
if (workItemId !== undefined) {
|
|
131
|
+
state.workItemId = workItemId as string;
|
|
132
|
+
}
|
|
133
|
+
return state;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// -------------------------------------------------------------------------
|
|
137
|
+
// Observer — dedup-fires listeners on any Y.Map change
|
|
138
|
+
// -------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
function onMapChange(): void {
|
|
141
|
+
if (destroyed) return;
|
|
142
|
+
if (listeners.size === 0) {
|
|
143
|
+
lastEmitted = null;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const next = readState();
|
|
147
|
+
if (lastEmitted !== null && stateShallowEqual(lastEmitted, next)) {
|
|
148
|
+
return; // deduplicated — same state, skip fire
|
|
149
|
+
}
|
|
150
|
+
lastEmitted = next;
|
|
151
|
+
for (const listener of [...listeners]) {
|
|
152
|
+
try {
|
|
153
|
+
listener(next);
|
|
154
|
+
} catch {
|
|
155
|
+
// Listener exceptions are isolated; the handle continues.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
yMap.observe(onMapChange);
|
|
161
|
+
|
|
162
|
+
// -------------------------------------------------------------------------
|
|
163
|
+
// Role-gating helpers
|
|
164
|
+
// -------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
function checkObserver(): WorkflowSharedResult | null {
|
|
167
|
+
if (role === "observer") {
|
|
168
|
+
return { ok: false, reason: "collab_observer_readonly" };
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function checkReviewerRestricted(): WorkflowSharedResult | null {
|
|
174
|
+
if (role === "reviewer") {
|
|
175
|
+
return { ok: false, reason: "collab_role_restricted" };
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// -------------------------------------------------------------------------
|
|
181
|
+
// Handle
|
|
182
|
+
// -------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
get(): SharedWorkflowState {
|
|
186
|
+
return readState();
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
setLockedMode(mode): WorkflowSharedResult {
|
|
190
|
+
const denied = checkObserver() ?? checkReviewerRestricted();
|
|
191
|
+
if (denied) return denied;
|
|
192
|
+
if (mode === undefined) {
|
|
193
|
+
yMap.delete(KEY_LOCKED_MODE);
|
|
194
|
+
} else {
|
|
195
|
+
yMap.set(KEY_LOCKED_MODE, mode);
|
|
196
|
+
}
|
|
197
|
+
return { ok: true };
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
setRoundDeadline(deadline): WorkflowSharedResult {
|
|
201
|
+
const denied = checkObserver() ?? checkReviewerRestricted();
|
|
202
|
+
if (denied) return denied;
|
|
203
|
+
if (deadline === undefined) {
|
|
204
|
+
yMap.delete(KEY_ROUND_DEADLINE);
|
|
205
|
+
} else {
|
|
206
|
+
yMap.set(KEY_ROUND_DEADLINE, deadline);
|
|
207
|
+
}
|
|
208
|
+
return { ok: true };
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
setAssignedReviewers(reviewers): WorkflowSharedResult {
|
|
212
|
+
const denied = checkObserver();
|
|
213
|
+
if (denied) return denied;
|
|
214
|
+
// reviewer is allowed here — do NOT call checkReviewerRestricted().
|
|
215
|
+
yMap.set(KEY_ASSIGNED_REVIEWERS, [...reviewers]);
|
|
216
|
+
return { ok: true };
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
setWorkItemId(id): WorkflowSharedResult {
|
|
220
|
+
const denied = checkObserver() ?? checkReviewerRestricted();
|
|
221
|
+
if (denied) return denied;
|
|
222
|
+
if (id === undefined) {
|
|
223
|
+
yMap.delete(KEY_WORK_ITEM_ID);
|
|
224
|
+
} else {
|
|
225
|
+
yMap.set(KEY_WORK_ITEM_ID, id);
|
|
226
|
+
}
|
|
227
|
+
return { ok: true };
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
subscribe(listener): () => void {
|
|
231
|
+
listeners.add(listener);
|
|
232
|
+
// Reset dedup baseline so the next change always fires fresh.
|
|
233
|
+
lastEmitted = null;
|
|
234
|
+
return () => {
|
|
235
|
+
listeners.delete(listener);
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
destroy(): void {
|
|
240
|
+
if (destroyed) return;
|
|
241
|
+
destroyed = true;
|
|
242
|
+
yMap.unobserve(onMapChange);
|
|
243
|
+
listeners.clear();
|
|
244
|
+
lastEmitted = null;
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
} from "../api/public-types";
|
|
19
19
|
import type { CanonicalDocumentEnvelope } from "../core/state/editor-state.ts";
|
|
20
20
|
import { MAIN_STORY_TARGET } from "../core/selection/mapping.ts";
|
|
21
|
+
import { createPublicRangeAnchor } from "../core/selection/anchor-conversion.ts";
|
|
21
22
|
import {
|
|
22
23
|
createDocumentSectionSnapshots,
|
|
23
24
|
findBookmarkNameForOffset,
|
|
@@ -47,15 +48,6 @@ function createLocationId(anchor: EditorAnchorProjection, storyTarget?: EditorSt
|
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
function createPublicRangeAnchor(from: number, to: number): EditorAnchorProjection {
|
|
51
|
-
return {
|
|
52
|
-
kind: "range",
|
|
53
|
-
from,
|
|
54
|
-
to,
|
|
55
|
-
assoc: { start: -1, end: 1 },
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
51
|
function resolveOffsetMetadata(
|
|
60
52
|
navigation: DocumentNavigationSnapshot,
|
|
61
53
|
offset: number,
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
import type { CanonicalDocumentEnvelope } from "../core/state/editor-state.ts";
|
|
19
19
|
import { createSelectionSnapshot } from "../core/state/editor-state.ts";
|
|
20
20
|
import { MAIN_STORY_TARGET } from "../core/selection/mapping.ts";
|
|
21
|
+
import { createPublicRangeAnchor } from "../core/selection/anchor-conversion.ts";
|
|
21
22
|
import { parseTocLevelRange } from "../io/ooxml/parse-fields.ts";
|
|
22
23
|
import { buildPageLayoutSnapshot, buildResolvedSections } from "./document-layout.ts";
|
|
23
24
|
import { createDocumentNavigationSnapshot } from "./document-navigation.ts";
|
|
@@ -34,15 +35,6 @@ function getAnchorOffset(anchor: EditorAnchorProjection): number | undefined {
|
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
function createPublicRangeAnchor(from: number, to: number): EditorAnchorProjection {
|
|
38
|
-
return {
|
|
39
|
-
kind: "range",
|
|
40
|
-
from,
|
|
41
|
-
to,
|
|
42
|
-
assoc: { start: -1, end: 1 },
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
38
|
export function resolveHeadingPath(
|
|
47
39
|
headings: readonly DocumentHeadingSnapshot[],
|
|
48
40
|
offset: number | undefined,
|
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
DocumentTextToken,
|
|
31
31
|
EditorSessionState,
|
|
32
32
|
EditorAnchorProjection,
|
|
33
|
+
TextFormattingDirective,
|
|
33
34
|
EditorError,
|
|
34
35
|
EditorStoryTarget,
|
|
35
36
|
EditorViewStateSnapshot,
|
|
@@ -104,7 +105,14 @@ import {
|
|
|
104
105
|
storyTargetsEqual,
|
|
105
106
|
type EditorAnchorProjection as InternalEditorAnchorProjection,
|
|
106
107
|
} from "../core/selection/mapping.ts";
|
|
107
|
-
import {
|
|
108
|
+
import {
|
|
109
|
+
toInternalAnchorProjection,
|
|
110
|
+
toPublicAnchorProjection,
|
|
111
|
+
} from "../core/selection/anchor-conversion.ts";
|
|
112
|
+
import {
|
|
113
|
+
commentAnchorRejectionReason,
|
|
114
|
+
snapCommentAnchorAwayFromTable,
|
|
115
|
+
} from "../core/selection/review-anchors.ts";
|
|
108
116
|
import { buildBookmarkNameMap } from "../legal/bookmarks.ts";
|
|
109
117
|
import {
|
|
110
118
|
describeOpaqueFragment,
|
|
@@ -232,6 +240,7 @@ import type {
|
|
|
232
240
|
} from "../api/editor-state-types.ts";
|
|
233
241
|
import { collectEditorStateForSerialize } from "./editor-state-integration.ts";
|
|
234
242
|
import type { EditorStatePayload } from "../io/ooxml/workflow-payload.ts";
|
|
243
|
+
import type { SharedWorkflowState } from "./collab/workflow-shared.ts";
|
|
235
244
|
|
|
236
245
|
/** Internal extension of ExportDocxOptions that threads the collected
|
|
237
246
|
* editorState payload from the runtime to the docx serializer. */
|
|
@@ -265,7 +274,7 @@ export interface DocumentRuntime {
|
|
|
265
274
|
getRenderSnapshot(): RuntimeRenderSnapshot;
|
|
266
275
|
getCanonicalDocument(): CanonicalDocumentEnvelope;
|
|
267
276
|
getSourcePackage(): EditorSessionState["sourcePackage"] | undefined;
|
|
268
|
-
replaceText(text: string, target?: EditorAnchorProjection): void;
|
|
277
|
+
replaceText(text: string, target?: EditorAnchorProjection, formatting?: TextFormattingDirective): void;
|
|
269
278
|
applyActiveStoryTextCommand(command: ActiveStoryTextCommand): TextCommandAck;
|
|
270
279
|
dispatch(command: EditorCommand): void;
|
|
271
280
|
/**
|
|
@@ -359,6 +368,7 @@ export interface DocumentRuntime {
|
|
|
359
368
|
setWorkflowOverlay(overlay: WorkflowOverlay): void;
|
|
360
369
|
clearWorkflowOverlay(): void;
|
|
361
370
|
getWorkflowOverlay(): WorkflowOverlay | null;
|
|
371
|
+
setSharedWorkflowState(state: SharedWorkflowState | null): void;
|
|
362
372
|
getWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null;
|
|
363
373
|
getInteractionGuardSnapshot(): InteractionGuardSnapshot;
|
|
364
374
|
getWorkflowMarkupSnapshot(): WorkflowMarkupSnapshot;
|
|
@@ -629,6 +639,9 @@ export function createDocumentRuntime(
|
|
|
629
639
|
?? options.initialSnapshot?.workflowMetadata?.entries
|
|
630
640
|
?? [];
|
|
631
641
|
let hostAnnotationOverlay: HostAnnotationOverlay | null = null;
|
|
642
|
+
// P13 Slice B: shared workflow state from the collab Y.Map "workflow".
|
|
643
|
+
// Set via setSharedWorkflowState(); observed by evaluateWorkflowBlockedReasons.
|
|
644
|
+
let sharedWorkflowState: SharedWorkflowState | null = null;
|
|
632
645
|
const initialPersistedSnapshot = options.initialSessionState
|
|
633
646
|
? persistedSnapshotFromEditorSessionState(options.initialSessionState, {
|
|
634
647
|
savedAt: options.initialSessionState.updatedAt,
|
|
@@ -873,6 +886,7 @@ export function createDocumentRuntime(
|
|
|
873
886
|
documentMode: DocumentMode;
|
|
874
887
|
protectionSnapshot: ProtectionSnapshot;
|
|
875
888
|
workflowOverlay: WorkflowOverlay | null;
|
|
889
|
+
sharedWorkflowState: SharedWorkflowState | null;
|
|
876
890
|
snapshot: InteractionGuardSnapshot;
|
|
877
891
|
}
|
|
878
892
|
| undefined;
|
|
@@ -1140,6 +1154,29 @@ export function createDocumentRuntime(
|
|
|
1140
1154
|
commandType?: string,
|
|
1141
1155
|
): WorkflowBlockedCommandReason[] {
|
|
1142
1156
|
const reasons: WorkflowBlockedCommandReason[] = [];
|
|
1157
|
+
// P13 Slice B: shared lockedMode overrides all other scope checks when
|
|
1158
|
+
// non-editing. Short-circuit: no other scope reason applies when the round
|
|
1159
|
+
// is locked (the round state supersedes scope/overlay-level gating).
|
|
1160
|
+
// Emit a reason code whose effectiveMode mapping matches the mode intent:
|
|
1161
|
+
// "commenting" → workflow_comment_only (maps to effectiveMode: "comment")
|
|
1162
|
+
// "viewing" → workflow_view_only (maps to effectiveMode: "view")
|
|
1163
|
+
// "suggesting" → workflow_round_locked (no existing mapping; stays "blocked"
|
|
1164
|
+
// for this slice — full suggesting-mode semantics will be a
|
|
1165
|
+
// future slice that hooks getEffectiveDocumentMode instead).
|
|
1166
|
+
if (sharedWorkflowState?.lockedMode && sharedWorkflowState.lockedMode !== "editing") {
|
|
1167
|
+
const lockedMode = sharedWorkflowState.lockedMode;
|
|
1168
|
+
const code: WorkflowBlockedCommandReason["code"] =
|
|
1169
|
+
lockedMode === "commenting"
|
|
1170
|
+
? "workflow_comment_only"
|
|
1171
|
+
: lockedMode === "viewing"
|
|
1172
|
+
? "workflow_view_only"
|
|
1173
|
+
: "workflow_round_locked";
|
|
1174
|
+
reasons.push({
|
|
1175
|
+
code,
|
|
1176
|
+
message: `Round is locked to ${lockedMode} mode.`,
|
|
1177
|
+
});
|
|
1178
|
+
return reasons;
|
|
1179
|
+
}
|
|
1143
1180
|
const selectionBounds = {
|
|
1144
1181
|
from: Math.min(selection.anchor, selection.head),
|
|
1145
1182
|
to: Math.max(selection.anchor, selection.head),
|
|
@@ -1552,7 +1589,8 @@ export function createDocumentRuntime(
|
|
|
1552
1589
|
cachedInteractionGuardSnapshot.readOnly === state.readOnly &&
|
|
1553
1590
|
cachedInteractionGuardSnapshot.documentMode === viewState.documentMode &&
|
|
1554
1591
|
cachedInteractionGuardSnapshot.protectionSnapshot === protectionSnapshot &&
|
|
1555
|
-
cachedInteractionGuardSnapshot.workflowOverlay === workflowOverlay
|
|
1592
|
+
cachedInteractionGuardSnapshot.workflowOverlay === workflowOverlay &&
|
|
1593
|
+
cachedInteractionGuardSnapshot.sharedWorkflowState === sharedWorkflowState
|
|
1556
1594
|
) {
|
|
1557
1595
|
return cachedInteractionGuardSnapshot.snapshot;
|
|
1558
1596
|
}
|
|
@@ -1613,6 +1651,7 @@ export function createDocumentRuntime(
|
|
|
1613
1651
|
documentMode: viewState.documentMode,
|
|
1614
1652
|
protectionSnapshot,
|
|
1615
1653
|
workflowOverlay,
|
|
1654
|
+
sharedWorkflowState,
|
|
1616
1655
|
snapshot,
|
|
1617
1656
|
};
|
|
1618
1657
|
return snapshot;
|
|
@@ -2256,13 +2295,14 @@ export function createDocumentRuntime(
|
|
|
2256
2295
|
getDefaultAuthorId() {
|
|
2257
2296
|
return defaultAuthorId;
|
|
2258
2297
|
},
|
|
2259
|
-
replaceText(text, target) {
|
|
2298
|
+
replaceText(text, target, formatting) {
|
|
2260
2299
|
try {
|
|
2261
2300
|
const timestamp = clock();
|
|
2262
2301
|
applyTextCommandInActiveStory(
|
|
2263
2302
|
{
|
|
2264
2303
|
type: "text.insert",
|
|
2265
2304
|
text,
|
|
2305
|
+
...(formatting ? { formatting } : {}),
|
|
2266
2306
|
origin: createOrigin("api", timestamp),
|
|
2267
2307
|
},
|
|
2268
2308
|
{
|
|
@@ -2309,22 +2349,39 @@ export function createDocumentRuntime(
|
|
|
2309
2349
|
throw toStructuredRuntimeException(error);
|
|
2310
2350
|
}
|
|
2311
2351
|
const commentId = createEntityId("comment", state.document.review.comments, clock());
|
|
2312
|
-
|
|
2352
|
+
let anchor = params.anchor
|
|
2313
2353
|
? toInternalAnchorProjection(params.anchor)
|
|
2314
2354
|
: state.selection.activeRange;
|
|
2315
|
-
|
|
2355
|
+
let selection = params.anchor
|
|
2316
2356
|
? createSelectionFromPublicAnchor(params.anchor)
|
|
2317
2357
|
: state.selection;
|
|
2318
|
-
if (
|
|
2358
|
+
if (params.snapToSafeBoundary === true) {
|
|
2359
|
+
const snapped = snapCommentAnchorAwayFromTable(
|
|
2360
|
+
cachedRenderSnapshot.surface,
|
|
2361
|
+
anchor,
|
|
2362
|
+
);
|
|
2363
|
+
if (snapped !== null && snapped !== anchor) {
|
|
2364
|
+
anchor = snapped;
|
|
2365
|
+
selection = createSelectionFromPublicAnchor(toPublicAnchorProjection(snapped));
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
const rejectionReason = commentAnchorRejectionReason(
|
|
2369
|
+
cachedRenderSnapshot.surface,
|
|
2370
|
+
anchor,
|
|
2371
|
+
);
|
|
2372
|
+
if (rejectionReason !== null) {
|
|
2373
|
+
const message =
|
|
2374
|
+
rejectionReason === "comment_anchor_table_adjacent"
|
|
2375
|
+
? "DOCX comments cannot currently anchor mid-run within a paragraph adjacent to a table boundary — snap the range to a paragraph or word boundary and retry."
|
|
2376
|
+
: "DOCX comments must use a non-empty range that stays within a single story and does not cross table or opaque-fragment boundaries.";
|
|
2319
2377
|
const error: InternalEditorError = {
|
|
2320
2378
|
errorId: createSessionId("comment-anchor", clock()),
|
|
2321
2379
|
code: "validation_failed",
|
|
2322
2380
|
isFatal: false,
|
|
2323
|
-
message
|
|
2324
|
-
"DOCX comments must use a non-empty range that stays within a single story and does not cross table or opaque-fragment boundaries.",
|
|
2381
|
+
message,
|
|
2325
2382
|
source: "runtime",
|
|
2326
2383
|
details: {
|
|
2327
|
-
reason:
|
|
2384
|
+
reason: rejectionReason,
|
|
2328
2385
|
},
|
|
2329
2386
|
};
|
|
2330
2387
|
emitError(error);
|
|
@@ -2954,6 +3011,13 @@ export function createDocumentRuntime(
|
|
|
2954
3011
|
getWorkflowOverlay() {
|
|
2955
3012
|
return workflowOverlay;
|
|
2956
3013
|
},
|
|
3014
|
+
setSharedWorkflowState(state) {
|
|
3015
|
+
if (state === sharedWorkflowState) return;
|
|
3016
|
+
sharedWorkflowState = state;
|
|
3017
|
+
// Invalidate guard/scope caches so next derivation reflects the new state.
|
|
3018
|
+
cachedInteractionGuardSnapshot = undefined;
|
|
3019
|
+
cachedWorkflowScopeSnapshot = undefined;
|
|
3020
|
+
},
|
|
2957
3021
|
getWorkflowScopeSnapshot() {
|
|
2958
3022
|
return getCachedWorkflowScopeSnapshot();
|
|
2959
3023
|
},
|
|
@@ -4152,45 +4216,6 @@ function toPublicSelectionSnapshot(
|
|
|
4152
4216
|
};
|
|
4153
4217
|
}
|
|
4154
4218
|
|
|
4155
|
-
function toPublicAnchorProjection(
|
|
4156
|
-
anchor: InternalEditorAnchorProjection,
|
|
4157
|
-
): EditorAnchorProjection {
|
|
4158
|
-
switch (anchor.kind) {
|
|
4159
|
-
case "range":
|
|
4160
|
-
return {
|
|
4161
|
-
kind: "range",
|
|
4162
|
-
from: anchor.range.from,
|
|
4163
|
-
to: anchor.range.to,
|
|
4164
|
-
assoc: anchor.assoc,
|
|
4165
|
-
};
|
|
4166
|
-
case "node":
|
|
4167
|
-
return {
|
|
4168
|
-
kind: "node",
|
|
4169
|
-
at: anchor.at,
|
|
4170
|
-
assoc: anchor.assoc,
|
|
4171
|
-
};
|
|
4172
|
-
case "detached":
|
|
4173
|
-
return {
|
|
4174
|
-
kind: "detached",
|
|
4175
|
-
lastKnownRange: anchor.lastKnownRange,
|
|
4176
|
-
reason: anchor.reason,
|
|
4177
|
-
};
|
|
4178
|
-
}
|
|
4179
|
-
}
|
|
4180
|
-
|
|
4181
|
-
function toInternalAnchorProjection(
|
|
4182
|
-
anchor: EditorAnchorProjection,
|
|
4183
|
-
): InternalEditorAnchorProjection {
|
|
4184
|
-
switch (anchor.kind) {
|
|
4185
|
-
case "range":
|
|
4186
|
-
return createRangeAnchor(anchor.from, anchor.to, anchor.assoc);
|
|
4187
|
-
case "node":
|
|
4188
|
-
return createNodeAnchor(anchor.at, anchor.assoc);
|
|
4189
|
-
case "detached":
|
|
4190
|
-
return createDetachedAnchor(anchor.lastKnownRange, anchor.reason);
|
|
4191
|
-
}
|
|
4192
|
-
}
|
|
4193
|
-
|
|
4194
4219
|
function createSelectionFromPublicAnchor(
|
|
4195
4220
|
anchor: EditorAnchorProjection,
|
|
4196
4221
|
): import("../core/state/editor-state.ts").SelectionSnapshot {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lane 3 V7 — hyperlink color cascade.
|
|
3
|
+
*
|
|
4
|
+
* OOXML convention: runs inside `<w:hyperlink>` inherit the `Hyperlink`
|
|
5
|
+
* character style implicitly, EVEN WHEN source XML does not declare an
|
|
6
|
+
* explicit `<w:rStyle w:val="Hyperlink"/>` on each run. Word applies the
|
|
7
|
+
* style based on the containing hyperlink element's context alone.
|
|
8
|
+
*
|
|
9
|
+
* Our existing cascade (`resolveEffectiveRunFormatting`) only applies the
|
|
10
|
+
* character-style chain when `input.characterStyleId` is populated — so
|
|
11
|
+
* runs inside hyperlinks that lacked explicit rStyle were inheriting
|
|
12
|
+
* whatever the paragraph style said (usually black body text).
|
|
13
|
+
*
|
|
14
|
+
* This module closes that gap by resolving hyperlink color via a
|
|
15
|
+
* four-tier fallback chain:
|
|
16
|
+
*
|
|
17
|
+
* 1. Direct color on the run (`colorHex !== "auto"`) — wins outright.
|
|
18
|
+
* 2. Character-style cascade — forces Hyperlink style participation.
|
|
19
|
+
* 3. Theme hlink slot (`ResolvedTheme.colors.hlink`).
|
|
20
|
+
* 4. Hardcoded Word default `#0563C1`.
|
|
21
|
+
*
|
|
22
|
+
* The resolver also honors `colorThemeSlot` + `colorThemeTint`/`colorThemeShade`
|
|
23
|
+
* from L2.c by delegating to `resolveThemeColorHex`.
|
|
24
|
+
*
|
|
25
|
+
* Contract: the returned `CanonicalRunFormatting` is the effective cascade
|
|
26
|
+
* result with `colorHex` concretized to a non-theme hex (or `"auto"`). The
|
|
27
|
+
* original `colorThemeSlot` / `colorThemeTint` / `colorThemeShade` fields
|
|
28
|
+
* are preserved on the returned object so downstream code (or re-export
|
|
29
|
+
* via the canonical document) still sees the theme reference.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type {
|
|
33
|
+
CanonicalRunFormatting,
|
|
34
|
+
ResolvedTheme,
|
|
35
|
+
StylesCatalog,
|
|
36
|
+
} from "../model/canonical-document.ts";
|
|
37
|
+
import { resolveThemeColor } from "../io/ooxml/parse-theme.ts";
|
|
38
|
+
import { resolveThemeColorHex } from "./theme-color-resolver.ts";
|
|
39
|
+
import {
|
|
40
|
+
resolveEffectiveRunFormatting,
|
|
41
|
+
type RunResolveInput,
|
|
42
|
+
} from "./paragraph-style-resolver.ts";
|
|
43
|
+
|
|
44
|
+
export const HYPERLINK_CHARACTER_STYLE_ID = "Hyperlink";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Microsoft Word's default hyperlink color (applied when neither the
|
|
48
|
+
* Hyperlink character style nor the theme's `hlink` slot supplies one).
|
|
49
|
+
* Matches Word 2013+ fresh-document rendering.
|
|
50
|
+
*/
|
|
51
|
+
export const DEFAULT_HYPERLINK_COLOR_HEX = "0563C1";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve effective run formatting for a hyperlink-inner run. Honors the
|
|
55
|
+
* Hyperlink character style implicitly, resolves any theme-slot color
|
|
56
|
+
* references, and applies the Word-default fallback when upstream data
|
|
57
|
+
* is absent.
|
|
58
|
+
*
|
|
59
|
+
* `input.characterStyleId` is respected when the caller already passed
|
|
60
|
+
* one (e.g., source XML had an explicit `<w:rStyle>` overriding the
|
|
61
|
+
* implicit Hyperlink). Only when it is absent does the resolver inject
|
|
62
|
+
* `"Hyperlink"` itself.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveHyperlinkRunFormatting(
|
|
65
|
+
input: RunResolveInput,
|
|
66
|
+
catalog: StylesCatalog | undefined,
|
|
67
|
+
theme: ResolvedTheme | undefined,
|
|
68
|
+
): CanonicalRunFormatting {
|
|
69
|
+
// V7a — auto-apply the Hyperlink character style when the caller did
|
|
70
|
+
// not supply one (runs inside <w:hyperlink> typically lack explicit
|
|
71
|
+
// rStyle; Word applies the style by context).
|
|
72
|
+
const augmentedInput: RunResolveInput =
|
|
73
|
+
input.characterStyleId === undefined
|
|
74
|
+
? { ...input, characterStyleId: HYPERLINK_CHARACTER_STYLE_ID }
|
|
75
|
+
: input;
|
|
76
|
+
|
|
77
|
+
const cascade = resolveEffectiveRunFormatting(augmentedInput, catalog);
|
|
78
|
+
|
|
79
|
+
// V7b — concretize the color through the theme resolver + Word default.
|
|
80
|
+
const resolvedColor = resolveHyperlinkColorHex(cascade, theme);
|
|
81
|
+
if (resolvedColor && resolvedColor !== cascade.colorHex) {
|
|
82
|
+
return { ...cascade, colorHex: resolvedColor };
|
|
83
|
+
}
|
|
84
|
+
return cascade;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Four-tier hyperlink color fallback. Exported for targeted testing; use
|
|
89
|
+
* `resolveHyperlinkRunFormatting` as the primary entry point.
|
|
90
|
+
*/
|
|
91
|
+
export function resolveHyperlinkColorHex(
|
|
92
|
+
cascade: Pick<
|
|
93
|
+
CanonicalRunFormatting,
|
|
94
|
+
"colorHex" | "colorThemeSlot" | "colorThemeTint" | "colorThemeShade"
|
|
95
|
+
>,
|
|
96
|
+
theme: ResolvedTheme | undefined,
|
|
97
|
+
): string | undefined {
|
|
98
|
+
// Tier 1 — direct non-auto hex wins.
|
|
99
|
+
if (cascade.colorHex && cascade.colorHex !== "auto") {
|
|
100
|
+
return cascade.colorHex;
|
|
101
|
+
}
|
|
102
|
+
// Tier 2 — theme-slot reference from the cascade (which now includes the
|
|
103
|
+
// Hyperlink style's rPr — typically `<w:color w:themeColor="hlink"/>`).
|
|
104
|
+
if (cascade.colorThemeSlot) {
|
|
105
|
+
const viaTheme = resolveThemeColorHex(cascade, theme);
|
|
106
|
+
if (viaTheme && viaTheme !== "auto") {
|
|
107
|
+
return viaTheme;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Tier 3 — theme hlink slot even when the cascade never wrote a slot
|
|
111
|
+
// reference. This catches docs whose Hyperlink style lacks a color
|
|
112
|
+
// declaration entirely but whose theme defines hlink.
|
|
113
|
+
const themeHlink = resolveThemeColor(theme, "hlink");
|
|
114
|
+
if (themeHlink) {
|
|
115
|
+
return themeHlink;
|
|
116
|
+
}
|
|
117
|
+
// Tier 4 — Word's hardcoded default.
|
|
118
|
+
return DEFAULT_HYPERLINK_COLOR_HEX;
|
|
119
|
+
}
|