@beyondwork/docx-react-component 1.0.48 → 1.0.50
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 +19 -11
- package/package.json +30 -41
- package/src/api/public-types.ts +103 -12
- package/src/core/commands/index.ts +30 -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-comments.ts +50 -5
- 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/io/paste/word-clipboard.ts +114 -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 +98 -50
- package/src/runtime/hyperlink-color-resolver.ts +119 -0
- package/src/runtime/layout/layout-engine-version.ts +11 -1
- package/src/runtime/layout/public-facet.ts +5 -12
- package/src/runtime/render/render-frame-types.ts +14 -0
- package/src/runtime/render/render-kernel.ts +40 -2
- package/src/runtime/structure-ops/fragment-insert.ts +134 -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 +22 -4
- package/src/ui/editor-runtime-boundary.ts +37 -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/editor-surface/pm-command-bridge.ts +60 -5
- 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
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from "./event-types.ts";
|
|
23
23
|
import { computeBaseDocFingerprint } from "./base-doc-fingerprint.ts";
|
|
24
24
|
import type { Checkpoint } from "./checkpoint-store.ts";
|
|
25
|
+
import { createWorkflowShared, type WorkflowSharedHandle } from "./workflow-shared.ts";
|
|
25
26
|
|
|
26
27
|
/** Shared Y.Map key — {@link SHARED_META_MAP_KEY}. */
|
|
27
28
|
const SHARED_META_MAP_KEY = "meta";
|
|
@@ -188,6 +189,17 @@ export interface RuntimeCollabSyncOptions {
|
|
|
188
189
|
runtime: DocumentRuntime;
|
|
189
190
|
authorId: string;
|
|
190
191
|
commandAppliedBridge: RuntimeCommandAppliedBridge;
|
|
192
|
+
/**
|
|
193
|
+
* Role of the local peer for workflow-shared gating. Defaults to `"author"`
|
|
194
|
+
* for backward compatibility with pre-P13 callers — ⚠ OMITTING THIS FIELD
|
|
195
|
+
* GRANTS AUTHOR-LEVEL WRITE ACCESS to the shared `workflow` Y.Map. Hosts
|
|
196
|
+
* that derive role from Awareness or an external auth layer should pass
|
|
197
|
+
* it explicitly to avoid silently promoting reviewers/observers to authors.
|
|
198
|
+
* - `"author"`: all workflow writes allowed.
|
|
199
|
+
* - `"reviewer"`: only `setAssignedReviewers` allowed; other writes refused.
|
|
200
|
+
* - `"observer"`: all writes refused with `collab_observer_readonly`.
|
|
201
|
+
*/
|
|
202
|
+
role?: "author" | "reviewer" | "observer";
|
|
191
203
|
}
|
|
192
204
|
|
|
193
205
|
export interface RuntimeCollabSyncHandle {
|
|
@@ -217,6 +229,14 @@ export interface RuntimeCollabSyncHandle {
|
|
|
217
229
|
* checkpoint covers previously-seen events).
|
|
218
230
|
*/
|
|
219
231
|
getAppliedEventCount(): number;
|
|
232
|
+
/**
|
|
233
|
+
* Returns the shared workflow handle backing the `workflow` Y.Map.
|
|
234
|
+
* Hosts can call `.setLockedMode()`, `.setRoundDeadline()`,
|
|
235
|
+
* `.setAssignedReviewers()`, and `.setWorkItemId()` to propagate
|
|
236
|
+
* state changes to other peers. Role gating (passed in `options.role`)
|
|
237
|
+
* enforces write permissions per §7 of the lane plan.
|
|
238
|
+
*/
|
|
239
|
+
getWorkflowShared(): WorkflowSharedHandle;
|
|
220
240
|
}
|
|
221
241
|
|
|
222
242
|
export function createRuntimeCommandAppliedBridge(): RuntimeCommandAppliedBridge {
|
|
@@ -332,6 +352,31 @@ export function createRuntimeCollabSync(
|
|
|
332
352
|
emit({ type: "collab_sync_attached", baseDocFingerprint });
|
|
333
353
|
}
|
|
334
354
|
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// Workflow shared state — P13 Slice C
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// Construct a WorkflowSharedHandle over `ydoc.getMap("workflow")`. The
|
|
359
|
+
// handle subscribes to Y.Map changes and propagates them to the runtime
|
|
360
|
+
// via `setSharedWorkflowState`. Role gating is enforced by the handle
|
|
361
|
+
// itself; the `role` option defaults to `"author"` for historical compat.
|
|
362
|
+
const effectiveRole = options.role ?? "author";
|
|
363
|
+
const workflowShared = createWorkflowShared({
|
|
364
|
+
ydoc,
|
|
365
|
+
role: effectiveRole,
|
|
366
|
+
localAuthorId: authorId,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// Seed initial state synchronously. For a fresh Y.Doc this is `{}`.
|
|
370
|
+
// For a late joiner it may already be populated — the seed ensures the
|
|
371
|
+
// runtime reflects pre-existing shared state at attach time (i.e. if a
|
|
372
|
+
// peer already set `lockedMode`, the new peer starts out locked).
|
|
373
|
+
runtime.setSharedWorkflowState(workflowShared.get());
|
|
374
|
+
|
|
375
|
+
const workflowUnsub = workflowShared.subscribe((state) => {
|
|
376
|
+
if (readOnly) return; // don't propagate while in read-only (post-mismatch)
|
|
377
|
+
runtime.setSharedWorkflowState(state);
|
|
378
|
+
});
|
|
379
|
+
|
|
335
380
|
const unsubscribeCommandApplied = commandAppliedBridge.subscribe((command, _transaction, context, meta) => {
|
|
336
381
|
if (readOnly) {
|
|
337
382
|
return;
|
|
@@ -453,6 +498,9 @@ export function createRuntimeCollabSync(
|
|
|
453
498
|
yEvents.unobserve(onYEventsChange);
|
|
454
499
|
yMeta.unobserve(checkFingerprintAgainstMeta);
|
|
455
500
|
yCheckpoints.unobserve(onCheckpointsChange);
|
|
501
|
+
workflowUnsub();
|
|
502
|
+
workflowShared.destroy();
|
|
503
|
+
runtime.setSharedWorkflowState(null);
|
|
456
504
|
listeners.clear();
|
|
457
505
|
},
|
|
458
506
|
subscribe(listener) {
|
|
@@ -481,6 +529,9 @@ export function createRuntimeCollabSync(
|
|
|
481
529
|
getAppliedEventCount() {
|
|
482
530
|
return appliedEventIds.size;
|
|
483
531
|
},
|
|
532
|
+
getWorkflowShared() {
|
|
533
|
+
return workflowShared;
|
|
534
|
+
},
|
|
484
535
|
};
|
|
485
536
|
|
|
486
537
|
function onYEventsChange(event: Y.YArrayEvent<CommandEvent>): void {
|
|
@@ -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,8 @@ import type {
|
|
|
30
30
|
DocumentTextToken,
|
|
31
31
|
EditorSessionState,
|
|
32
32
|
EditorAnchorProjection,
|
|
33
|
+
CanonicalDocumentFragment,
|
|
34
|
+
TextFormattingDirective,
|
|
33
35
|
EditorError,
|
|
34
36
|
EditorStoryTarget,
|
|
35
37
|
EditorViewStateSnapshot,
|
|
@@ -104,7 +106,14 @@ import {
|
|
|
104
106
|
storyTargetsEqual,
|
|
105
107
|
type EditorAnchorProjection as InternalEditorAnchorProjection,
|
|
106
108
|
} from "../core/selection/mapping.ts";
|
|
107
|
-
import {
|
|
109
|
+
import {
|
|
110
|
+
toInternalAnchorProjection,
|
|
111
|
+
toPublicAnchorProjection,
|
|
112
|
+
} from "../core/selection/anchor-conversion.ts";
|
|
113
|
+
import {
|
|
114
|
+
commentAnchorRejectionReason,
|
|
115
|
+
snapCommentAnchorAwayFromTable,
|
|
116
|
+
} from "../core/selection/review-anchors.ts";
|
|
108
117
|
import { buildBookmarkNameMap } from "../legal/bookmarks.ts";
|
|
109
118
|
import {
|
|
110
119
|
describeOpaqueFragment,
|
|
@@ -232,6 +241,7 @@ import type {
|
|
|
232
241
|
} from "../api/editor-state-types.ts";
|
|
233
242
|
import { collectEditorStateForSerialize } from "./editor-state-integration.ts";
|
|
234
243
|
import type { EditorStatePayload } from "../io/ooxml/workflow-payload.ts";
|
|
244
|
+
import type { SharedWorkflowState } from "./collab/workflow-shared.ts";
|
|
235
245
|
|
|
236
246
|
/** Internal extension of ExportDocxOptions that threads the collected
|
|
237
247
|
* editorState payload from the runtime to the docx serializer. */
|
|
@@ -257,7 +267,8 @@ export type ActiveStoryTextCommand =
|
|
|
257
267
|
| Extract<EditorCommand, { type: "text.insert-tab" }>
|
|
258
268
|
| Extract<EditorCommand, { type: "text.outdent-tab" }>
|
|
259
269
|
| Extract<EditorCommand, { type: "text.insert-hard-break" }>
|
|
260
|
-
| Extract<EditorCommand, { type: "paragraph.split" }
|
|
270
|
+
| Extract<EditorCommand, { type: "paragraph.split" }>
|
|
271
|
+
| Extract<EditorCommand, { type: "fragment.insert" }>;
|
|
261
272
|
|
|
262
273
|
export interface DocumentRuntime {
|
|
263
274
|
subscribe(listener: () => void): Unsubscribe;
|
|
@@ -265,7 +276,8 @@ export interface DocumentRuntime {
|
|
|
265
276
|
getRenderSnapshot(): RuntimeRenderSnapshot;
|
|
266
277
|
getCanonicalDocument(): CanonicalDocumentEnvelope;
|
|
267
278
|
getSourcePackage(): EditorSessionState["sourcePackage"] | undefined;
|
|
268
|
-
replaceText(text: string, target?: EditorAnchorProjection): void;
|
|
279
|
+
replaceText(text: string, target?: EditorAnchorProjection, formatting?: TextFormattingDirective): void;
|
|
280
|
+
insertFragment(fragment: CanonicalDocumentFragment, target?: EditorAnchorProjection): void;
|
|
269
281
|
applyActiveStoryTextCommand(command: ActiveStoryTextCommand): TextCommandAck;
|
|
270
282
|
dispatch(command: EditorCommand): void;
|
|
271
283
|
/**
|
|
@@ -359,6 +371,7 @@ export interface DocumentRuntime {
|
|
|
359
371
|
setWorkflowOverlay(overlay: WorkflowOverlay): void;
|
|
360
372
|
clearWorkflowOverlay(): void;
|
|
361
373
|
getWorkflowOverlay(): WorkflowOverlay | null;
|
|
374
|
+
setSharedWorkflowState(state: SharedWorkflowState | null): void;
|
|
362
375
|
getWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null;
|
|
363
376
|
getInteractionGuardSnapshot(): InteractionGuardSnapshot;
|
|
364
377
|
getWorkflowMarkupSnapshot(): WorkflowMarkupSnapshot;
|
|
@@ -629,6 +642,9 @@ export function createDocumentRuntime(
|
|
|
629
642
|
?? options.initialSnapshot?.workflowMetadata?.entries
|
|
630
643
|
?? [];
|
|
631
644
|
let hostAnnotationOverlay: HostAnnotationOverlay | null = null;
|
|
645
|
+
// P13 Slice B: shared workflow state from the collab Y.Map "workflow".
|
|
646
|
+
// Set via setSharedWorkflowState(); observed by evaluateWorkflowBlockedReasons.
|
|
647
|
+
let sharedWorkflowState: SharedWorkflowState | null = null;
|
|
632
648
|
const initialPersistedSnapshot = options.initialSessionState
|
|
633
649
|
? persistedSnapshotFromEditorSessionState(options.initialSessionState, {
|
|
634
650
|
savedAt: options.initialSessionState.updatedAt,
|
|
@@ -873,6 +889,7 @@ export function createDocumentRuntime(
|
|
|
873
889
|
documentMode: DocumentMode;
|
|
874
890
|
protectionSnapshot: ProtectionSnapshot;
|
|
875
891
|
workflowOverlay: WorkflowOverlay | null;
|
|
892
|
+
sharedWorkflowState: SharedWorkflowState | null;
|
|
876
893
|
snapshot: InteractionGuardSnapshot;
|
|
877
894
|
}
|
|
878
895
|
| undefined;
|
|
@@ -1140,6 +1157,29 @@ export function createDocumentRuntime(
|
|
|
1140
1157
|
commandType?: string,
|
|
1141
1158
|
): WorkflowBlockedCommandReason[] {
|
|
1142
1159
|
const reasons: WorkflowBlockedCommandReason[] = [];
|
|
1160
|
+
// P13 Slice B: shared lockedMode overrides all other scope checks when
|
|
1161
|
+
// non-editing. Short-circuit: no other scope reason applies when the round
|
|
1162
|
+
// is locked (the round state supersedes scope/overlay-level gating).
|
|
1163
|
+
// Emit a reason code whose effectiveMode mapping matches the mode intent:
|
|
1164
|
+
// "commenting" → workflow_comment_only (maps to effectiveMode: "comment")
|
|
1165
|
+
// "viewing" → workflow_view_only (maps to effectiveMode: "view")
|
|
1166
|
+
// "suggesting" → workflow_round_locked (no existing mapping; stays "blocked"
|
|
1167
|
+
// for this slice — full suggesting-mode semantics will be a
|
|
1168
|
+
// future slice that hooks getEffectiveDocumentMode instead).
|
|
1169
|
+
if (sharedWorkflowState?.lockedMode && sharedWorkflowState.lockedMode !== "editing") {
|
|
1170
|
+
const lockedMode = sharedWorkflowState.lockedMode;
|
|
1171
|
+
const code: WorkflowBlockedCommandReason["code"] =
|
|
1172
|
+
lockedMode === "commenting"
|
|
1173
|
+
? "workflow_comment_only"
|
|
1174
|
+
: lockedMode === "viewing"
|
|
1175
|
+
? "workflow_view_only"
|
|
1176
|
+
: "workflow_round_locked";
|
|
1177
|
+
reasons.push({
|
|
1178
|
+
code,
|
|
1179
|
+
message: `Round is locked to ${lockedMode} mode.`,
|
|
1180
|
+
});
|
|
1181
|
+
return reasons;
|
|
1182
|
+
}
|
|
1143
1183
|
const selectionBounds = {
|
|
1144
1184
|
from: Math.min(selection.anchor, selection.head),
|
|
1145
1185
|
to: Math.max(selection.anchor, selection.head),
|
|
@@ -1552,7 +1592,8 @@ export function createDocumentRuntime(
|
|
|
1552
1592
|
cachedInteractionGuardSnapshot.readOnly === state.readOnly &&
|
|
1553
1593
|
cachedInteractionGuardSnapshot.documentMode === viewState.documentMode &&
|
|
1554
1594
|
cachedInteractionGuardSnapshot.protectionSnapshot === protectionSnapshot &&
|
|
1555
|
-
cachedInteractionGuardSnapshot.workflowOverlay === workflowOverlay
|
|
1595
|
+
cachedInteractionGuardSnapshot.workflowOverlay === workflowOverlay &&
|
|
1596
|
+
cachedInteractionGuardSnapshot.sharedWorkflowState === sharedWorkflowState
|
|
1556
1597
|
) {
|
|
1557
1598
|
return cachedInteractionGuardSnapshot.snapshot;
|
|
1558
1599
|
}
|
|
@@ -1613,6 +1654,7 @@ export function createDocumentRuntime(
|
|
|
1613
1654
|
documentMode: viewState.documentMode,
|
|
1614
1655
|
protectionSnapshot,
|
|
1615
1656
|
workflowOverlay,
|
|
1657
|
+
sharedWorkflowState,
|
|
1616
1658
|
snapshot,
|
|
1617
1659
|
};
|
|
1618
1660
|
return snapshot;
|
|
@@ -2256,13 +2298,14 @@ export function createDocumentRuntime(
|
|
|
2256
2298
|
getDefaultAuthorId() {
|
|
2257
2299
|
return defaultAuthorId;
|
|
2258
2300
|
},
|
|
2259
|
-
replaceText(text, target) {
|
|
2301
|
+
replaceText(text, target, formatting) {
|
|
2260
2302
|
try {
|
|
2261
2303
|
const timestamp = clock();
|
|
2262
2304
|
applyTextCommandInActiveStory(
|
|
2263
2305
|
{
|
|
2264
2306
|
type: "text.insert",
|
|
2265
2307
|
text,
|
|
2308
|
+
...(formatting ? { formatting } : {}),
|
|
2266
2309
|
origin: createOrigin("api", timestamp),
|
|
2267
2310
|
},
|
|
2268
2311
|
{
|
|
@@ -2274,6 +2317,26 @@ export function createDocumentRuntime(
|
|
|
2274
2317
|
emitError(toRuntimeError(error));
|
|
2275
2318
|
}
|
|
2276
2319
|
},
|
|
2320
|
+
insertFragment(fragment, target) {
|
|
2321
|
+
// I2 Tier B Slice 1 — dispatch `fragment.insert` against the active story. The
|
|
2322
|
+
// runtime command handler routes into `applyFragmentInsert` (structure-ops).
|
|
2323
|
+
try {
|
|
2324
|
+
const timestamp = clock();
|
|
2325
|
+
applyTextCommandInActiveStory(
|
|
2326
|
+
{
|
|
2327
|
+
type: "fragment.insert",
|
|
2328
|
+
fragment,
|
|
2329
|
+
origin: createOrigin("api", timestamp),
|
|
2330
|
+
},
|
|
2331
|
+
{
|
|
2332
|
+
selection: target ? createSelectionFromPublicAnchor(target) : state.selection,
|
|
2333
|
+
blockedCommandName: "insertFragment",
|
|
2334
|
+
},
|
|
2335
|
+
);
|
|
2336
|
+
} catch (error) {
|
|
2337
|
+
emitError(toRuntimeError(error));
|
|
2338
|
+
}
|
|
2339
|
+
},
|
|
2277
2340
|
applyActiveStoryTextCommand(command) {
|
|
2278
2341
|
try {
|
|
2279
2342
|
return applyTextCommandInActiveStory(command);
|
|
@@ -2309,22 +2372,39 @@ export function createDocumentRuntime(
|
|
|
2309
2372
|
throw toStructuredRuntimeException(error);
|
|
2310
2373
|
}
|
|
2311
2374
|
const commentId = createEntityId("comment", state.document.review.comments, clock());
|
|
2312
|
-
|
|
2375
|
+
let anchor = params.anchor
|
|
2313
2376
|
? toInternalAnchorProjection(params.anchor)
|
|
2314
2377
|
: state.selection.activeRange;
|
|
2315
|
-
|
|
2378
|
+
let selection = params.anchor
|
|
2316
2379
|
? createSelectionFromPublicAnchor(params.anchor)
|
|
2317
2380
|
: state.selection;
|
|
2318
|
-
if (
|
|
2381
|
+
if (params.snapToSafeBoundary === true) {
|
|
2382
|
+
const snapped = snapCommentAnchorAwayFromTable(
|
|
2383
|
+
cachedRenderSnapshot.surface,
|
|
2384
|
+
anchor,
|
|
2385
|
+
);
|
|
2386
|
+
if (snapped !== null && snapped !== anchor) {
|
|
2387
|
+
anchor = snapped;
|
|
2388
|
+
selection = createSelectionFromPublicAnchor(toPublicAnchorProjection(snapped));
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
const rejectionReason = commentAnchorRejectionReason(
|
|
2392
|
+
cachedRenderSnapshot.surface,
|
|
2393
|
+
anchor,
|
|
2394
|
+
);
|
|
2395
|
+
if (rejectionReason !== null) {
|
|
2396
|
+
const message =
|
|
2397
|
+
rejectionReason === "comment_anchor_table_adjacent"
|
|
2398
|
+
? "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."
|
|
2399
|
+
: "DOCX comments must use a non-empty range that stays within a single story and does not cross table or opaque-fragment boundaries.";
|
|
2319
2400
|
const error: InternalEditorError = {
|
|
2320
2401
|
errorId: createSessionId("comment-anchor", clock()),
|
|
2321
2402
|
code: "validation_failed",
|
|
2322
2403
|
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.",
|
|
2404
|
+
message,
|
|
2325
2405
|
source: "runtime",
|
|
2326
2406
|
details: {
|
|
2327
|
-
reason:
|
|
2407
|
+
reason: rejectionReason,
|
|
2328
2408
|
},
|
|
2329
2409
|
};
|
|
2330
2410
|
emitError(error);
|
|
@@ -2954,6 +3034,13 @@ export function createDocumentRuntime(
|
|
|
2954
3034
|
getWorkflowOverlay() {
|
|
2955
3035
|
return workflowOverlay;
|
|
2956
3036
|
},
|
|
3037
|
+
setSharedWorkflowState(state) {
|
|
3038
|
+
if (state === sharedWorkflowState) return;
|
|
3039
|
+
sharedWorkflowState = state;
|
|
3040
|
+
// Invalidate guard/scope caches so next derivation reflects the new state.
|
|
3041
|
+
cachedInteractionGuardSnapshot = undefined;
|
|
3042
|
+
cachedWorkflowScopeSnapshot = undefined;
|
|
3043
|
+
},
|
|
2957
3044
|
getWorkflowScopeSnapshot() {
|
|
2958
3045
|
return getCachedWorkflowScopeSnapshot();
|
|
2959
3046
|
},
|
|
@@ -4152,45 +4239,6 @@ function toPublicSelectionSnapshot(
|
|
|
4152
4239
|
};
|
|
4153
4240
|
}
|
|
4154
4241
|
|
|
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
4242
|
function createSelectionFromPublicAnchor(
|
|
4195
4243
|
anchor: EditorAnchorProjection,
|
|
4196
4244
|
): import("../core/state/editor-state.ts").SelectionSnapshot {
|