@beyondwork/docx-react-component 1.0.79 → 1.0.80
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 +17 -8
- package/src/api/v3/ai/resolve.ts +13 -7
- package/src/api/v3/runtime/workflow.ts +0 -9
- package/src/core/commands/add-scope.ts +110 -84
- package/src/runtime/formatting/formatting-types.ts +16 -0
- package/src/runtime/formatting/revision-display.ts +16 -10
- package/src/runtime/scopes/compile-scope-bundle.ts +9 -1
- package/src/runtime/scopes/compile-scope.ts +16 -0
- package/src/runtime/scopes/enumerate-scopes.ts +116 -3
- package/src/runtime/scopes/replaceability.ts +16 -0
- package/src/runtime/scopes/replacement/apply.ts +13 -3
- package/src/runtime/scopes/resolve-reference.ts +5 -0
- package/src/runtime/scopes/scope-kinds/scope.ts +87 -0
- package/src/runtime/scopes/scope-range.ts +11 -0
- package/src/runtime/workflow/coordinator.ts +3 -6
- package/src/runtime/workflow/scope-writer.ts +5 -26
- package/src/ui/headless/revision-decoration-model.ts +10 -0
- package/src/ui-tailwind/theme/editor-theme.css +10 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beyondwork/docx-react-component",
|
|
3
3
|
"publisher": "beyondwork",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.80",
|
|
5
5
|
"description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": [
|
package/src/api/public-types.ts
CHANGED
|
@@ -1211,6 +1211,18 @@ export type SurfaceInlineSegment =
|
|
|
1211
1211
|
* is enforced by `test/runtime/formatting/production-boundary.test.ts`.
|
|
1212
1212
|
*/
|
|
1213
1213
|
revisionDisplay?: {
|
|
1214
|
+
/**
|
|
1215
|
+
* Identity of the attached revision. Render consumers emit
|
|
1216
|
+
* `data-revision-id` and route sidebar scroll-to-revision off
|
|
1217
|
+
* this field rather than re-deriving from the review store.
|
|
1218
|
+
*/
|
|
1219
|
+
revisionId: string;
|
|
1220
|
+
/**
|
|
1221
|
+
* The revision's kind. Mirrors `RevisionRecord.kind` on the
|
|
1222
|
+
* canonical document. Render consumers branch on this (e.g.
|
|
1223
|
+
* widget-bracket placement in suggestions mode).
|
|
1224
|
+
*/
|
|
1225
|
+
kind: "insertion" | "deletion" | "formatting" | "move" | "property-change";
|
|
1214
1226
|
markupMode: "clean" | "simple" | "all";
|
|
1215
1227
|
hidden?: boolean;
|
|
1216
1228
|
strikethrough?: boolean;
|
|
@@ -2517,23 +2529,20 @@ export interface AddScopeResult {
|
|
|
2517
2529
|
/**
|
|
2518
2530
|
* Structured diagnostic for the marker-plant attempt. Omitted on the
|
|
2519
2531
|
* happy path for back-compat; populated with `planted: false` + a
|
|
2520
|
-
* typed reason when the plant was refused (
|
|
2521
|
-
*
|
|
2522
|
-
*
|
|
2523
|
-
*
|
|
2532
|
+
* typed reason when the plant was refused (non-paragraph target,
|
|
2533
|
+
* out-of-bounds, empty-document). Cross-paragraph ranges plant
|
|
2534
|
+
* successfully as of 2026-04-24 — see the multi-paragraph-scopes
|
|
2535
|
+
* slice. Callers that previously assumed every returned `scopeId`
|
|
2536
|
+
* was live should migrate to checking this field.
|
|
2524
2537
|
*/
|
|
2525
2538
|
plantStatus?: {
|
|
2526
2539
|
readonly planted: false;
|
|
2527
2540
|
readonly reason:
|
|
2528
|
-
| "cross-paragraph-range"
|
|
2529
2541
|
| "non-paragraph-target"
|
|
2530
2542
|
| "range-out-of-bounds"
|
|
2531
2543
|
| "empty-document";
|
|
2532
2544
|
readonly requestedFrom: number;
|
|
2533
2545
|
readonly requestedTo: number;
|
|
2534
|
-
/** Present on `cross-paragraph-range`. */
|
|
2535
|
-
readonly fromBlockIndex?: number;
|
|
2536
|
-
readonly toBlockIndex?: number;
|
|
2537
2546
|
/** Present on `non-paragraph-target`. */
|
|
2538
2547
|
readonly blockIndex?: number;
|
|
2539
2548
|
readonly blockKind?: string;
|
package/src/api/v3/ai/resolve.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @endStateApi v3 — `ai.resolve` family.
|
|
3
3
|
*
|
|
4
|
-
* Slice 3 of refactor/08
|
|
5
|
-
* `live-with-adapter
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* Slice 3 of refactor/08 graduated `resolveReference` to
|
|
5
|
+
* `live-with-adapter`. Post-KI-P9 (2026-04-24) the reference union
|
|
6
|
+
* carries **three durable reference kinds**: `scope-id`, `semantic-path`,
|
|
7
|
+
* and `natural-language`. Positional queries (`offset` / `range`) were
|
|
8
|
+
* split into `queryScopeAtPosition(at)` / `queryScopeInRange(from, to)`
|
|
9
|
+
* one-shot APIs that return `ScopeHandle | null` directly — the
|
|
10
|
+
* type-system split prevents a positional hint from being cached or
|
|
11
|
+
* round-tripped as a reference across a mutation.
|
|
12
|
+
*
|
|
13
|
+
* Offset / range lookups still honor precise nested-kind ranges (fields,
|
|
14
|
+
* table rows, table cells) via the scope-compiler's specificity
|
|
15
|
+
* tie-breaker — see `docs/architecture/08-semantic-scope-compiler.md`
|
|
16
|
+
* §"Resolve-reference precision" for the specificity table.
|
|
11
17
|
*
|
|
12
18
|
* The `natural-language` hint remains **partial by design**. The live
|
|
13
19
|
* path is a deterministic substring matcher over:
|
|
@@ -161,14 +161,11 @@ export type CreateScopeFromAnchorResult =
|
|
|
161
161
|
| "from-negative"
|
|
162
162
|
| "to-less-than-from"
|
|
163
163
|
| "range-exceeds-story-length"
|
|
164
|
-
| "cross-paragraph-range"
|
|
165
164
|
| "non-paragraph-target"
|
|
166
165
|
| "empty-document";
|
|
167
166
|
readonly from: number;
|
|
168
167
|
readonly to: number;
|
|
169
168
|
readonly storyLength: number;
|
|
170
|
-
readonly fromBlockIndex?: number;
|
|
171
|
-
readonly toBlockIndex?: number;
|
|
172
169
|
readonly blockIndex?: number;
|
|
173
170
|
readonly blockKind?: string;
|
|
174
171
|
/** Agent-actionable single-sentence explanation. Safe to surface to LLM tool replies as-is. */
|
|
@@ -540,12 +537,6 @@ export function createWorkflowFamily(runtime: RuntimeApiHandle) {
|
|
|
540
537
|
from: adapterResult.from,
|
|
541
538
|
to: adapterResult.to,
|
|
542
539
|
storyLength: adapterResult.storyLength,
|
|
543
|
-
...(adapterResult.fromBlockIndex !== undefined
|
|
544
|
-
? { fromBlockIndex: adapterResult.fromBlockIndex }
|
|
545
|
-
: {}),
|
|
546
|
-
...(adapterResult.toBlockIndex !== undefined
|
|
547
|
-
? { toBlockIndex: adapterResult.toBlockIndex }
|
|
548
|
-
: {}),
|
|
549
540
|
...(adapterResult.blockIndex !== undefined
|
|
550
541
|
? { blockIndex: adapterResult.blockIndex }
|
|
551
542
|
: {}),
|
|
@@ -16,6 +16,10 @@ import type { CanonicalDocumentEnvelope } from "../state/editor-state.ts";
|
|
|
16
16
|
* knowing whether the scope was ever live. Every non-`"planted"`
|
|
17
17
|
* status is now typed so callers can discriminate + surface
|
|
18
18
|
* agent-actionable recovery hints.
|
|
19
|
+
*
|
|
20
|
+
* Cross-paragraph ranges are first-class (planted) as of the
|
|
21
|
+
* 2026-04-24 multi-paragraph-scopes slice. The `cross-paragraph-range`
|
|
22
|
+
* failure variant no longer exists — markers now span paragraphs.
|
|
19
23
|
*/
|
|
20
24
|
export type InsertScopeMarkersResult =
|
|
21
25
|
| {
|
|
@@ -25,14 +29,6 @@ export type InsertScopeMarkersResult =
|
|
|
25
29
|
/** Absolute span that got planted (after from/to normalization). */
|
|
26
30
|
readonly plantedRange: { readonly from: number; readonly to: number };
|
|
27
31
|
}
|
|
28
|
-
| {
|
|
29
|
-
readonly status: "cross-paragraph-range";
|
|
30
|
-
readonly scopeId: string;
|
|
31
|
-
readonly from: number;
|
|
32
|
-
readonly to: number;
|
|
33
|
-
readonly fromBlockIndex: number;
|
|
34
|
-
readonly toBlockIndex: number;
|
|
35
|
-
}
|
|
36
32
|
| {
|
|
37
33
|
readonly status: "non-paragraph-target";
|
|
38
34
|
readonly scopeId: string;
|
|
@@ -60,13 +56,17 @@ export type InsertScopeMarkersResult =
|
|
|
60
56
|
* pair of `scope_marker_start` / `scope_marker_end` inline nodes got
|
|
61
57
|
* inserted at `[from, to]`.
|
|
62
58
|
*
|
|
59
|
+
* Same-paragraph ranges insert both markers into the single owning
|
|
60
|
+
* paragraph. Cross-paragraph ranges place the start marker inside the
|
|
61
|
+
* paragraph containing `from` and the end marker inside the paragraph
|
|
62
|
+
* containing `to`; intermediate top-level blocks (paragraphs, tables,
|
|
63
|
+
* section breaks) are passed through unmodified and fall inside the
|
|
64
|
+
* scope by document order.
|
|
65
|
+
*
|
|
63
66
|
* Failure modes (all previously silent — pre-2026-04-24):
|
|
64
|
-
* - `
|
|
65
|
-
* top-level paragraph blocks. Multi-block marker scopes are not
|
|
66
|
-
* yet wired; narrow the range to land inside one paragraph.
|
|
67
|
-
* - `non-paragraph-target` — the target range lands inside a
|
|
67
|
+
* - `non-paragraph-target` — either `from` or `to` lands inside a
|
|
68
68
|
* non-paragraph block (table / SDT / section_break). Marker
|
|
69
|
-
* scopes only plant inside paragraphs
|
|
69
|
+
* scopes only plant inside paragraphs.
|
|
70
70
|
* - `range-out-of-bounds` — `to` exceeds the main story length.
|
|
71
71
|
* - `empty-document` — the document has no root / no children.
|
|
72
72
|
*
|
|
@@ -82,10 +82,10 @@ export function insertScopeMarkers(
|
|
|
82
82
|
to: number;
|
|
83
83
|
},
|
|
84
84
|
): InsertScopeMarkersResult {
|
|
85
|
-
const { scopeId
|
|
85
|
+
const { scopeId } = params;
|
|
86
86
|
const root = document.content as DocumentRootNode;
|
|
87
|
-
const normalizedFrom = Math.min(from, to);
|
|
88
|
-
const normalizedTo = Math.max(from, to);
|
|
87
|
+
const normalizedFrom = Math.min(params.from, params.to);
|
|
88
|
+
const normalizedTo = Math.max(params.from, params.to);
|
|
89
89
|
|
|
90
90
|
if (!root || root.type !== "doc" || root.children.length === 0) {
|
|
91
91
|
return {
|
|
@@ -96,9 +96,15 @@ export function insertScopeMarkers(
|
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
99
|
+
// Pre-pass — locate which block each of `from` and `to` resolves to,
|
|
100
|
+
// compute story length, and build per-paragraph slot envelopes.
|
|
101
|
+
// **Invariant:** the cursor arithmetic here MUST match
|
|
102
|
+
// `src/runtime/scopes/position-map.ts::computeBlockPositions`
|
|
103
|
+
// (same `inlineLength`, same `+1` per-block boundary). Layer 04
|
|
104
|
+
// cannot change one walker without the other or cross-paragraph
|
|
105
|
+
// marker insertion silently drifts vs. enumerated scope ranges.
|
|
106
|
+
type ParaSlot = { index: number; from: number; to: number };
|
|
107
|
+
const paraSlots: ParaSlot[] = [];
|
|
102
108
|
let cursor = 0;
|
|
103
109
|
let fromBlockIndex = -1;
|
|
104
110
|
let fromBlockKind: string | null = null;
|
|
@@ -115,15 +121,24 @@ export function insertScopeMarkers(
|
|
|
115
121
|
(total, child) => total + inlineLength(child as InlineNode),
|
|
116
122
|
0,
|
|
117
123
|
);
|
|
124
|
+
paraSlots.push({ index: i, from: blockFrom, to: blockFrom + blockLength });
|
|
118
125
|
} else {
|
|
119
126
|
blockLength = 1;
|
|
120
127
|
}
|
|
121
128
|
const blockTo = blockFrom + blockLength;
|
|
122
|
-
if (
|
|
129
|
+
if (
|
|
130
|
+
fromBlockIndex === -1 &&
|
|
131
|
+
normalizedFrom >= blockFrom &&
|
|
132
|
+
normalizedFrom <= blockTo
|
|
133
|
+
) {
|
|
123
134
|
fromBlockIndex = i;
|
|
124
135
|
fromBlockKind = block.type;
|
|
125
136
|
}
|
|
126
|
-
if (
|
|
137
|
+
if (
|
|
138
|
+
toBlockIndex === -1 &&
|
|
139
|
+
normalizedTo >= blockFrom &&
|
|
140
|
+
normalizedTo <= blockTo
|
|
141
|
+
) {
|
|
127
142
|
toBlockIndex = i;
|
|
128
143
|
toBlockKind = block.type;
|
|
129
144
|
}
|
|
@@ -150,62 +165,81 @@ export function insertScopeMarkers(
|
|
|
150
165
|
storyLength,
|
|
151
166
|
};
|
|
152
167
|
}
|
|
153
|
-
if (
|
|
168
|
+
if (fromBlockKind !== "paragraph" || toBlockKind !== "paragraph") {
|
|
169
|
+
// Either endpoint lands on a non-paragraph top-level block
|
|
170
|
+
// (table / section_break / sdt). Report the first non-paragraph
|
|
171
|
+
// offender for diagnostic clarity.
|
|
172
|
+
const nonParaIndex =
|
|
173
|
+
fromBlockKind !== "paragraph" ? fromBlockIndex : toBlockIndex;
|
|
174
|
+
const nonParaKind =
|
|
175
|
+
fromBlockKind !== "paragraph" ? fromBlockKind : toBlockKind;
|
|
154
176
|
return {
|
|
155
|
-
status: "
|
|
177
|
+
status: "non-paragraph-target",
|
|
156
178
|
scopeId,
|
|
157
179
|
from: normalizedFrom,
|
|
158
180
|
to: normalizedTo,
|
|
159
|
-
|
|
160
|
-
|
|
181
|
+
blockIndex: nonParaIndex,
|
|
182
|
+
blockKind: nonParaKind ?? "unknown",
|
|
161
183
|
};
|
|
162
184
|
}
|
|
163
|
-
|
|
185
|
+
|
|
186
|
+
const startSlot = paraSlots.find((s) => s.index === fromBlockIndex)!;
|
|
187
|
+
const endSlot = paraSlots.find((s) => s.index === toBlockIndex)!;
|
|
188
|
+
|
|
189
|
+
// Same-paragraph fast path: inject both markers into one paragraph.
|
|
190
|
+
// Preserves all existing single-paragraph behaviour byte-for-byte
|
|
191
|
+
// (regression pin [C9]).
|
|
192
|
+
if (startSlot.index === endSlot.index) {
|
|
193
|
+
const newChildren = injectMarkersIntoInlineList(
|
|
194
|
+
(root.children[startSlot.index] as ParagraphNode).children as InlineNode[],
|
|
195
|
+
scopeId,
|
|
196
|
+
normalizedFrom - startSlot.from,
|
|
197
|
+
normalizedTo - startSlot.from,
|
|
198
|
+
"both",
|
|
199
|
+
);
|
|
200
|
+
const children = root.children.map((block, i) =>
|
|
201
|
+
i === startSlot.index
|
|
202
|
+
? ({ ...block, children: newChildren } as ParagraphNode)
|
|
203
|
+
: block,
|
|
204
|
+
);
|
|
164
205
|
return {
|
|
165
|
-
status: "
|
|
206
|
+
status: "planted",
|
|
207
|
+
document: { ...document, content: { ...root, children } },
|
|
166
208
|
scopeId,
|
|
167
|
-
from: normalizedFrom,
|
|
168
|
-
to: normalizedTo,
|
|
169
|
-
blockIndex: fromBlockIndex,
|
|
170
|
-
blockKind: fromBlockKind ?? "unknown",
|
|
209
|
+
plantedRange: { from: normalizedFrom, to: normalizedTo },
|
|
171
210
|
};
|
|
172
211
|
}
|
|
173
212
|
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
if (blockIndex < root.children.length - 1) cursor += 1;
|
|
188
|
-
return block;
|
|
213
|
+
// Cross-paragraph path — start marker goes in the start-bearing
|
|
214
|
+
// paragraph; end marker goes in the end-bearing paragraph;
|
|
215
|
+
// intermediate blocks pass through unchanged.
|
|
216
|
+
const children = root.children.map((block, i) => {
|
|
217
|
+
if (i === startSlot.index) {
|
|
218
|
+
const newChildren = injectMarkersIntoInlineList(
|
|
219
|
+
(block as ParagraphNode).children as InlineNode[],
|
|
220
|
+
scopeId,
|
|
221
|
+
normalizedFrom - startSlot.from,
|
|
222
|
+
Number.POSITIVE_INFINITY,
|
|
223
|
+
"start-only",
|
|
224
|
+
);
|
|
225
|
+
return { ...block, children: newChildren } as ParagraphNode;
|
|
189
226
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
return
|
|
227
|
+
if (i === endSlot.index) {
|
|
228
|
+
const newChildren = injectMarkersIntoInlineList(
|
|
229
|
+
(block as ParagraphNode).children as InlineNode[],
|
|
230
|
+
scopeId,
|
|
231
|
+
Number.NEGATIVE_INFINITY,
|
|
232
|
+
normalizedTo - endSlot.from,
|
|
233
|
+
"end-only",
|
|
234
|
+
);
|
|
235
|
+
return { ...block, children: newChildren } as ParagraphNode;
|
|
236
|
+
}
|
|
237
|
+
return block;
|
|
201
238
|
});
|
|
202
239
|
|
|
203
240
|
return {
|
|
204
241
|
status: "planted",
|
|
205
|
-
document: {
|
|
206
|
-
...document,
|
|
207
|
-
content: { ...root, children },
|
|
208
|
-
},
|
|
242
|
+
document: { ...document, content: { ...root, children } },
|
|
209
243
|
scopeId,
|
|
210
244
|
plantedRange: { from: normalizedFrom, to: normalizedTo },
|
|
211
245
|
};
|
|
@@ -273,28 +307,28 @@ function injectMarkersIntoInlineList(
|
|
|
273
307
|
scopeId: string,
|
|
274
308
|
startOffset: number,
|
|
275
309
|
endOffset: number,
|
|
310
|
+
mode: "both" | "start-only" | "end-only",
|
|
276
311
|
): InlineNode[] {
|
|
277
|
-
const start: ScopeMarkerStartNode = {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
type: "scope_marker_end",
|
|
283
|
-
scopeId,
|
|
284
|
-
};
|
|
312
|
+
const start: ScopeMarkerStartNode = { type: "scope_marker_start", scopeId };
|
|
313
|
+
const end: ScopeMarkerEndNode = { type: "scope_marker_end", scopeId };
|
|
314
|
+
|
|
315
|
+
const needStart = mode === "both" || mode === "start-only";
|
|
316
|
+
const needEnd = mode === "both" || mode === "end-only";
|
|
285
317
|
|
|
286
318
|
const output: InlineNode[] = [];
|
|
287
319
|
let cursor = 0;
|
|
288
|
-
let startEmitted =
|
|
289
|
-
let endEmitted =
|
|
320
|
+
let startEmitted = !needStart;
|
|
321
|
+
let endEmitted = !needEnd;
|
|
290
322
|
|
|
291
323
|
for (const node of inlines) {
|
|
292
324
|
const length = inlineLength(node);
|
|
293
325
|
const nodeStart = cursor;
|
|
294
326
|
const nodeEnd = cursor + length;
|
|
295
327
|
|
|
296
|
-
const startInside =
|
|
297
|
-
|
|
328
|
+
const startInside =
|
|
329
|
+
needStart && !startEmitted && startOffset >= nodeStart && startOffset <= nodeEnd;
|
|
330
|
+
const endInside =
|
|
331
|
+
needEnd && !endEmitted && endOffset >= nodeStart && endOffset <= nodeEnd;
|
|
298
332
|
|
|
299
333
|
if (!startInside && !endInside) {
|
|
300
334
|
output.push(node);
|
|
@@ -302,10 +336,7 @@ function injectMarkersIntoInlineList(
|
|
|
302
336
|
continue;
|
|
303
337
|
}
|
|
304
338
|
|
|
305
|
-
// Currently only text nodes support splitting for an internal cut.
|
|
306
339
|
if (node.type !== "text") {
|
|
307
|
-
// For non-text nodes, markers land at the node boundary closest to the
|
|
308
|
-
// target offset — avoids mid-atom splits which would corrupt the node.
|
|
309
340
|
if (startInside && !startEmitted && startOffset <= nodeStart) {
|
|
310
341
|
output.push(start);
|
|
311
342
|
startEmitted = true;
|
|
@@ -330,11 +361,7 @@ function injectMarkersIntoInlineList(
|
|
|
330
361
|
const text = node.text;
|
|
331
362
|
const chars = Array.from(text);
|
|
332
363
|
const marks = node.marks;
|
|
333
|
-
const pieces: {
|
|
334
|
-
cut: number;
|
|
335
|
-
emit: "start" | "end";
|
|
336
|
-
}[] = [];
|
|
337
|
-
|
|
364
|
+
const pieces: { cut: number; emit: "start" | "end" }[] = [];
|
|
338
365
|
if (startInside) pieces.push({ cut: startOffset - nodeStart, emit: "start" });
|
|
339
366
|
if (endInside) pieces.push({ cut: endOffset - nodeStart, emit: "end" });
|
|
340
367
|
pieces.sort((a, b) => a.cut - b.cut || (a.emit === "start" ? -1 : 1));
|
|
@@ -370,12 +397,11 @@ function injectMarkersIntoInlineList(
|
|
|
370
397
|
cursor = nodeEnd;
|
|
371
398
|
}
|
|
372
399
|
|
|
373
|
-
|
|
374
|
-
if (!startEmitted) {
|
|
400
|
+
if (needStart && !startEmitted) {
|
|
375
401
|
output.push(start);
|
|
376
402
|
startEmitted = true;
|
|
377
403
|
}
|
|
378
|
-
if (!endEmitted) {
|
|
404
|
+
if (needEnd && !endEmitted) {
|
|
379
405
|
output.push(end);
|
|
380
406
|
endEmitted = true;
|
|
381
407
|
}
|
|
@@ -110,6 +110,22 @@ export interface EffectiveFieldDisplay {
|
|
|
110
110
|
* visual vocabulary (CSS class, inline style, decoration).
|
|
111
111
|
*/
|
|
112
112
|
export interface RevisionDisplayFlags {
|
|
113
|
+
/**
|
|
114
|
+
* The attached revision's id. Enables render consumers to emit
|
|
115
|
+
* `data-revision-id` + route sidebar scroll-to-revision off the
|
|
116
|
+
* authoritative L03 flags rather than re-deriving from the review-store
|
|
117
|
+
* side channel. Present whenever `input.revision !== undefined` drove
|
|
118
|
+
* flag emission.
|
|
119
|
+
*/
|
|
120
|
+
readonly revisionId: string;
|
|
121
|
+
/**
|
|
122
|
+
* The revision's kind. Mirrors `RevisionRecord.kind` from the canonical
|
|
123
|
+
* document (`src/model/canonical-document.ts`). Render consumers use
|
|
124
|
+
* this to branch on insertion/deletion/move/property-change variants
|
|
125
|
+
* (e.g. widget-bracket placement in suggestions mode) without reading
|
|
126
|
+
* the review store.
|
|
127
|
+
*/
|
|
128
|
+
readonly kind: "insertion" | "deletion" | "formatting" | "move" | "property-change";
|
|
113
129
|
readonly markupMode: "clean" | "simple" | "all";
|
|
114
130
|
/** Hide the run entirely (e.g. open deletion in "clean" mode). */
|
|
115
131
|
readonly hidden?: boolean;
|
|
@@ -44,16 +44,16 @@ export function applyRevisionDisplay(
|
|
|
44
44
|
switch (markupMode) {
|
|
45
45
|
case "clean": {
|
|
46
46
|
if (revision.kind === "deletion" && revision.status === "open") {
|
|
47
|
-
return buildFlags(markupMode, authorColor, { hidden: true });
|
|
47
|
+
return buildFlags(revision, markupMode, authorColor, { hidden: true });
|
|
48
48
|
}
|
|
49
|
-
return buildFlags(markupMode, authorColor);
|
|
49
|
+
return buildFlags(revision, markupMode, authorColor);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
case "simple": {
|
|
53
53
|
// Content visible, de-emphasized. No strikethrough / underline
|
|
54
54
|
// markers — the consumer renders a muted style (opacity /
|
|
55
55
|
// secondary color) uniformly.
|
|
56
|
-
return buildFlags(markupMode, authorColor, { deemphasize: true });
|
|
56
|
+
return buildFlags(revision, markupMode, authorColor, { deemphasize: true });
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
case "all": {
|
|
@@ -65,18 +65,18 @@ export function applyRevisionDisplay(
|
|
|
65
65
|
// add the revision strikethrough flag — avoids double-struck
|
|
66
66
|
// glyphs in render.
|
|
67
67
|
if (run.strikethrough === true) {
|
|
68
|
-
return buildFlags(markupMode, authorColor);
|
|
68
|
+
return buildFlags(revision, markupMode, authorColor);
|
|
69
69
|
}
|
|
70
|
-
return buildFlags(markupMode, authorColor, { strikethrough: true });
|
|
70
|
+
return buildFlags(revision, markupMode, authorColor, { strikethrough: true });
|
|
71
71
|
}
|
|
72
72
|
case "insertion": {
|
|
73
73
|
// Mirror short-circuit for insertions. If the run already carries
|
|
74
74
|
// a single-underline via direct formatting, skip the insertion
|
|
75
75
|
// underline so the render doesn't over-paint.
|
|
76
76
|
if (run.underline === "single") {
|
|
77
|
-
return buildFlags(markupMode, authorColor);
|
|
77
|
+
return buildFlags(revision, markupMode, authorColor);
|
|
78
78
|
}
|
|
79
|
-
return buildFlags(markupMode, authorColor, { insertionUnderline: true });
|
|
79
|
+
return buildFlags(revision, markupMode, authorColor, { insertionUnderline: true });
|
|
80
80
|
}
|
|
81
81
|
case "formatting":
|
|
82
82
|
case "property-change":
|
|
@@ -84,20 +84,26 @@ export function applyRevisionDisplay(
|
|
|
84
84
|
// Paragraph-level markers (change-bar, move-from/to arrows) are
|
|
85
85
|
// owned by the paragraph projection. For runs that carry these
|
|
86
86
|
// revision kinds, fall through to the author-color-only posture.
|
|
87
|
-
return buildFlags(markupMode, authorColor);
|
|
87
|
+
return buildFlags(revision, markupMode, authorColor);
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
|
-
return buildFlags(markupMode, authorColor);
|
|
90
|
+
return buildFlags(revision, markupMode, authorColor);
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
function buildFlags(
|
|
96
|
+
revision: RevisionRecord,
|
|
96
97
|
markupMode: RevisionMarkupMode,
|
|
97
98
|
authorColor: string | undefined,
|
|
98
|
-
extras: Omit<
|
|
99
|
+
extras: Omit<
|
|
100
|
+
RevisionDisplayFlags,
|
|
101
|
+
"revisionId" | "kind" | "markupMode" | "authorColor"
|
|
102
|
+
> = {},
|
|
99
103
|
): RevisionDisplayFlags {
|
|
100
104
|
return {
|
|
105
|
+
revisionId: revision.changeId,
|
|
106
|
+
kind: revision.kind,
|
|
101
107
|
markupMode,
|
|
102
108
|
...(authorColor !== undefined ? { authorColor } : {}),
|
|
103
109
|
...extras,
|
|
@@ -18,7 +18,11 @@ import type {
|
|
|
18
18
|
WorkflowOverlay,
|
|
19
19
|
} from "./_scope-dependencies.ts";
|
|
20
20
|
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
buildParagraphIndexMap,
|
|
23
|
+
buildSectionIndexByBlockIndex,
|
|
24
|
+
compileScope,
|
|
25
|
+
} from "./compile-scope.ts";
|
|
22
26
|
import type { EnumeratedScope } from "./enumerate-scopes.ts";
|
|
23
27
|
import { enumerateScopes } from "./enumerate-scopes.ts";
|
|
24
28
|
import { composeEvidence } from "./evidence.ts";
|
|
@@ -157,6 +161,10 @@ export function compileScopeBundleById(
|
|
|
157
161
|
...(fullDoc ? { document: fullDoc } : {}),
|
|
158
162
|
...(inputs.overlay !== undefined ? { overlay: inputs.overlay } : {}),
|
|
159
163
|
paragraphIndexByBlockIndex: buildParagraphIndexMap(inputs.document),
|
|
164
|
+
// Thread the section-index map up-front so the `kind: "scope"` compile
|
|
165
|
+
// arm does not re-walk the document. Shared between dispatch + the
|
|
166
|
+
// `paragraph`/`heading`/`list-item` + `scope` kinds that read it.
|
|
167
|
+
sectionIndexByBlockIndex: buildSectionIndexByBlockIndex(inputs.document),
|
|
160
168
|
});
|
|
161
169
|
if (!compiled) return null;
|
|
162
170
|
return compileScopeBundle(compiled, { ...inputs, scopes });
|
|
@@ -61,6 +61,7 @@ import { compileHeadingScope } from "./scope-kinds/heading.ts";
|
|
|
61
61
|
import { compileListItemScope } from "./scope-kinds/list-item.ts";
|
|
62
62
|
import { compileParagraphScope } from "./scope-kinds/paragraph.ts";
|
|
63
63
|
import { compileRevisionScope } from "./scope-kinds/revision.ts";
|
|
64
|
+
import { compileScopeKind } from "./scope-kinds/scope.ts";
|
|
64
65
|
import { compileTableScope } from "./scope-kinds/table.ts";
|
|
65
66
|
import { compileTableCellScope } from "./scope-kinds/table-cell.ts";
|
|
66
67
|
import { compileTableRowScope } from "./scope-kinds/table-row.ts";
|
|
@@ -256,6 +257,21 @@ export function compileScope(
|
|
|
256
257
|
return compileCommentThreadScope(entry);
|
|
257
258
|
case "revision":
|
|
258
259
|
return compileRevisionScope(entry);
|
|
260
|
+
case "scope": {
|
|
261
|
+
if (!options.document) return null;
|
|
262
|
+
let scopeSectionMap = options.sectionIndexByBlockIndex;
|
|
263
|
+
if (!scopeSectionMap) {
|
|
264
|
+
scopeSectionMap = buildSectionIndexByBlockIndex(options.document);
|
|
265
|
+
}
|
|
266
|
+
const scopeSectionIndex = scopeSectionMap.get(entry.startBlockIndex);
|
|
267
|
+
return compileScopeKind(entry, {
|
|
268
|
+
document: options.document,
|
|
269
|
+
...(workflow ? { workflow } : {}),
|
|
270
|
+
...(typeof scopeSectionIndex === "number"
|
|
271
|
+
? { sectionIndex: scopeSectionIndex }
|
|
272
|
+
: {}),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
259
275
|
default:
|
|
260
276
|
return null;
|
|
261
277
|
}
|
|
@@ -121,11 +121,29 @@ export interface RevisionEnumeratedScope {
|
|
|
121
121
|
readonly classifications: readonly string[];
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Marker-backed scope whose start + end markers live in different
|
|
126
|
+
* top-level paragraphs. The reserved `"scope"` kind slot in the 13-kind
|
|
127
|
+
* taxonomy (`SemanticScopeKind`) exists to carry these cross-paragraph
|
|
128
|
+
* pairs without forcing them through the paragraph arm — the
|
|
129
|
+
* start-bearing paragraph continues to enumerate as `kind: "paragraph"`
|
|
130
|
+
* + `provenance: "derived"`, and this entry represents the pair as a
|
|
131
|
+
* whole.
|
|
132
|
+
*/
|
|
133
|
+
export interface ScopeEnumeratedScope {
|
|
134
|
+
readonly kind: "scope";
|
|
135
|
+
readonly handle: ScopeHandle;
|
|
136
|
+
readonly startBlockIndex: number;
|
|
137
|
+
readonly endBlockIndex: number;
|
|
138
|
+
readonly classifications: readonly string[];
|
|
139
|
+
}
|
|
140
|
+
|
|
124
141
|
/**
|
|
125
142
|
* Discriminated by `kind`. Paragraph-bearing entries carry `paragraph`;
|
|
126
143
|
* table-bearing entries carry the matching canonical node; field entries
|
|
127
144
|
* carry the inline `FieldNode` + containing paragraph; review-store
|
|
128
|
-
* entries carry the thread / revision record directly
|
|
145
|
+
* entries carry the thread / revision record directly; multi-paragraph
|
|
146
|
+
* marker pairs carry the pair of block indices.
|
|
129
147
|
*/
|
|
130
148
|
export type EnumeratedScope =
|
|
131
149
|
| ParagraphLikeEnumeratedScope
|
|
@@ -134,7 +152,8 @@ export type EnumeratedScope =
|
|
|
134
152
|
| TableCellEnumeratedScope
|
|
135
153
|
| FieldEnumeratedScope
|
|
136
154
|
| CommentThreadEnumeratedScope
|
|
137
|
-
| RevisionEnumeratedScope
|
|
155
|
+
| RevisionEnumeratedScope
|
|
156
|
+
| ScopeEnumeratedScope;
|
|
138
157
|
|
|
139
158
|
export interface EnumerateScopesInputs {
|
|
140
159
|
readonly overlay?: WorkflowOverlay | null;
|
|
@@ -424,6 +443,57 @@ function enumerateRevisions(
|
|
|
424
443
|
});
|
|
425
444
|
}
|
|
426
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Pre-pass: for each paired marker across multiple paragraphs, return
|
|
448
|
+
* { scopeId, startBlockIndex, endBlockIndex }. Same-paragraph pairs are
|
|
449
|
+
* NOT returned here — they continue to enumerate through the paragraph
|
|
450
|
+
* arm as `kind: "paragraph"` + `provenance: "marker-backed"`. An
|
|
451
|
+
* unmatched marker (only start or only end in the doc) is skipped here;
|
|
452
|
+
* detachment reporting lives in `resolveScope`, not in enumeration.
|
|
453
|
+
*/
|
|
454
|
+
function locateMultiParagraphMarkerPairs(
|
|
455
|
+
root: DocumentRootNode,
|
|
456
|
+
): Array<{ scopeId: string; startBlockIndex: number; endBlockIndex: number }> {
|
|
457
|
+
type Open = { scopeId: string; blockIndex: number };
|
|
458
|
+
const open = new Map<string, Open>();
|
|
459
|
+
const pairs: Array<{
|
|
460
|
+
scopeId: string;
|
|
461
|
+
startBlockIndex: number;
|
|
462
|
+
endBlockIndex: number;
|
|
463
|
+
}> = [];
|
|
464
|
+
for (let i = 0; i < root.children.length; i += 1) {
|
|
465
|
+
const block = root.children[i];
|
|
466
|
+
if (!block || block.type !== "paragraph") continue;
|
|
467
|
+
for (const child of block.children) {
|
|
468
|
+
if (child.type === "scope_marker_start") {
|
|
469
|
+
open.set(child.scopeId, { scopeId: child.scopeId, blockIndex: i });
|
|
470
|
+
} else if (child.type === "scope_marker_end") {
|
|
471
|
+
const opener = open.get(child.scopeId);
|
|
472
|
+
if (!opener) continue;
|
|
473
|
+
if (opener.blockIndex !== i) {
|
|
474
|
+
pairs.push({
|
|
475
|
+
scopeId: child.scopeId,
|
|
476
|
+
startBlockIndex: opener.blockIndex,
|
|
477
|
+
endBlockIndex: i,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
open.delete(child.scopeId);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// Pairs are pushed in close-order by the walk above — for nested or
|
|
485
|
+
// partially-overlapping pairs the inner/earlier-closing pair appears
|
|
486
|
+
// first. Sort by startBlockIndex (break ties on scopeId) so downstream
|
|
487
|
+
// consumers see entries in document order, and S3 determinism is
|
|
488
|
+
// explicit in the sort rather than implicit in walk timing.
|
|
489
|
+
pairs.sort(
|
|
490
|
+
(a, b) =>
|
|
491
|
+
a.startBlockIndex - b.startBlockIndex ||
|
|
492
|
+
a.scopeId.localeCompare(b.scopeId),
|
|
493
|
+
);
|
|
494
|
+
return pairs;
|
|
495
|
+
}
|
|
496
|
+
|
|
427
497
|
export function enumerateScopes(
|
|
428
498
|
document: Pick<CanonicalDocument, "content" | "docId" | "review"> | CanonicalDocumentEnvelope,
|
|
429
499
|
inputs: EnumerateScopesInputs = {},
|
|
@@ -436,6 +506,10 @@ export function enumerateScopes(
|
|
|
436
506
|
const documentId = (envelope.docId as unknown as string) ?? "";
|
|
437
507
|
const classificationIndex = buildClassificationIndex(inputs.overlay);
|
|
438
508
|
const knownOverlayScopeIds = new Set(classificationIndex.keys());
|
|
509
|
+
const multiParagraphPairs = locateMultiParagraphMarkerPairs(root);
|
|
510
|
+
const multiParagraphScopeIds = new Set(
|
|
511
|
+
multiParagraphPairs.map((p) => p.scopeId),
|
|
512
|
+
);
|
|
439
513
|
|
|
440
514
|
const results: EnumeratedScope[] = [];
|
|
441
515
|
for (let index = 0; index < root.children.length; index += 1) {
|
|
@@ -443,10 +517,19 @@ export function enumerateScopes(
|
|
|
443
517
|
if (!block) continue;
|
|
444
518
|
|
|
445
519
|
if (block.type === "paragraph") {
|
|
446
|
-
const
|
|
520
|
+
const rawMarkerScopeId = paragraphFirstMarkerStart(
|
|
447
521
|
block,
|
|
448
522
|
knownOverlayScopeIds,
|
|
449
523
|
);
|
|
524
|
+
// Multi-paragraph pairs are emitted below as kind: "scope" and
|
|
525
|
+
// must NOT also promote their start-bearing paragraph to
|
|
526
|
+
// marker-backed — the paragraph stays derived and the separate
|
|
527
|
+
// `scope` entry represents the pair as a whole.
|
|
528
|
+
const markerScopeId =
|
|
529
|
+
rawMarkerScopeId !== null &&
|
|
530
|
+
!multiParagraphScopeIds.has(rawMarkerScopeId)
|
|
531
|
+
? rawMarkerScopeId
|
|
532
|
+
: null;
|
|
450
533
|
const kind = detectParagraphKind(block);
|
|
451
534
|
const semanticPath = buildParagraphSemanticPath(kind, index, block);
|
|
452
535
|
const scopeId =
|
|
@@ -587,6 +670,36 @@ export function enumerateScopes(
|
|
|
587
670
|
}
|
|
588
671
|
}
|
|
589
672
|
|
|
673
|
+
// Cross-paragraph marker pairs — emit one `kind: "scope"` entry per
|
|
674
|
+
// pair, ordered by start-block index (preserving document order and
|
|
675
|
+
// S3 determinism across compiles).
|
|
676
|
+
for (const pair of multiParagraphPairs) {
|
|
677
|
+
const semanticPath = ["body", "scope", pair.scopeId];
|
|
678
|
+
const hint = stableRefHintForScopeId(pair.scopeId, inputs.overlay);
|
|
679
|
+
const stableRef: ScopeHandle["stableRef"] =
|
|
680
|
+
hint === "semantic-path"
|
|
681
|
+
? { kind: "semantic-path", value: semanticPath.join("/") }
|
|
682
|
+
: { kind: "scope-id", value: pair.scopeId };
|
|
683
|
+
const handle: ScopeHandle = {
|
|
684
|
+
scopeId: pair.scopeId,
|
|
685
|
+
documentId,
|
|
686
|
+
storyTarget: MAIN_STORY,
|
|
687
|
+
semanticPath,
|
|
688
|
+
stableRef,
|
|
689
|
+
provenance: "marker-backed",
|
|
690
|
+
rangePrecision: "marker-backed",
|
|
691
|
+
};
|
|
692
|
+
const classifications =
|
|
693
|
+
classificationIndex.get(pair.scopeId) ?? Object.freeze<string[]>([]);
|
|
694
|
+
results.push({
|
|
695
|
+
kind: "scope",
|
|
696
|
+
handle,
|
|
697
|
+
startBlockIndex: pair.startBlockIndex,
|
|
698
|
+
endBlockIndex: pair.endBlockIndex,
|
|
699
|
+
classifications,
|
|
700
|
+
} satisfies ScopeEnumeratedScope);
|
|
701
|
+
}
|
|
702
|
+
|
|
590
703
|
// Review-store scopes — threads + revisions — enumerate after document
|
|
591
704
|
// walk so their block ordering in `results` stays stable (all block scopes
|
|
592
705
|
// first, then review).
|
|
@@ -47,6 +47,22 @@ export function deriveReplaceability(
|
|
|
47
47
|
reason: "marker-backed-preserves-anchor",
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
|
+
// Multi-paragraph marker-backed scopes — the `scope` kind slot. Replace
|
|
51
|
+
// semantics across multiple blocks are not yet compiler-backed; callers
|
|
52
|
+
// may read but should not full-replace until Task N of the
|
|
53
|
+
// multi-paragraph plan wires block-granular replacement.
|
|
54
|
+
if (kind === "scope") {
|
|
55
|
+
if (provenance === "marker-backed") {
|
|
56
|
+
return {
|
|
57
|
+
level: "preserve-only",
|
|
58
|
+
reason: "multi-paragraph-replace-not-implemented",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
level: "blocked",
|
|
63
|
+
reason: "scope-kind-requires-markers",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
50
66
|
switch (kind) {
|
|
51
67
|
case "paragraph":
|
|
52
68
|
return { level: "full", reason: "derived-default" };
|
|
@@ -199,10 +199,20 @@ export function applyScopeReplacement(
|
|
|
199
199
|
resolvedScope.kind === "paragraph" ||
|
|
200
200
|
resolvedScope.kind === "heading" ||
|
|
201
201
|
resolvedScope.kind === "list-item";
|
|
202
|
+
// Multi-paragraph `scope` kind (Task 7 of the 2026-04-24 plan): the
|
|
203
|
+
// compiler has no replacement lowering for cross-paragraph marker
|
|
204
|
+
// spans yet. Replaceability already declares `preserve-only` with
|
|
205
|
+
// `reason: "multi-paragraph-replace-not-implemented"`; apply mirrors
|
|
206
|
+
// that reason into the refusal taxonomy suffix so consumers reading
|
|
207
|
+
// `blockers[0]` / `reason` see the actionable sub-reason directly
|
|
208
|
+
// (rather than the bare `compile-refused:scope`). Grammar matches
|
|
209
|
+
// §10 `compile-refused:<kind>:<sub-reason>` (74a45eaf, 2026-04-23).
|
|
202
210
|
const blocker =
|
|
203
|
-
|
|
204
|
-
?
|
|
205
|
-
:
|
|
211
|
+
resolvedScope.kind === "scope"
|
|
212
|
+
? "compile-refused:scope:multi-paragraph-replace-not-implemented"
|
|
213
|
+
: paragraphLike && proposed.operation !== "replace"
|
|
214
|
+
? `compile-refused:${resolvedScope.kind}:operation-not-implemented:${proposed.operation}`
|
|
215
|
+
: `compile-refused:${resolvedScope.kind}`;
|
|
206
216
|
const refused: ValidationResult = {
|
|
207
217
|
safe: false,
|
|
208
218
|
blockedReasons: Object.freeze([blocker]),
|
|
@@ -307,6 +307,11 @@ function extractNLHaystack(entry: EnumeratedScope): string {
|
|
|
307
307
|
case "revision":
|
|
308
308
|
return `${entry.revision.kind} ${entry.revision.authorId ?? ""}`
|
|
309
309
|
.toLowerCase();
|
|
310
|
+
case "scope":
|
|
311
|
+
// Cross-paragraph marker pair — no inline text of its own;
|
|
312
|
+
// semantic-path matching (`body/scope/<id>`) covers it, and the
|
|
313
|
+
// per-paragraph entries inside the pair carry their own haystacks.
|
|
314
|
+
return "";
|
|
310
315
|
default: {
|
|
311
316
|
const _never: never = entry;
|
|
312
317
|
void _never;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile `kind: "scope"` entries — multi-paragraph marker-backed scopes.
|
|
3
|
+
*
|
|
4
|
+
* Aggregates the spanning paragraphs' text into `content.text` (joined by
|
|
5
|
+
* `\n`), projects a bounded formatting summary (no single paragraphStyleId
|
|
6
|
+
* is authoritative across multiple paragraphs; we surface none), and stays
|
|
7
|
+
* `partial: true` because layout + geometry projections are not yet
|
|
8
|
+
* compiler-backed for multi-block scopes.
|
|
9
|
+
*
|
|
10
|
+
* Replaceability is `"preserve-only"` for now — see
|
|
11
|
+
* `replaceability.ts::deriveReplaceability`.
|
|
12
|
+
*
|
|
13
|
+
* Determinism (S3): pure projection of (spanning paragraphs' text,
|
|
14
|
+
* classifications, provenance, sectionIndex). No ambient state.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type {
|
|
18
|
+
CanonicalDocument,
|
|
19
|
+
DocumentRootNode,
|
|
20
|
+
ParagraphNode,
|
|
21
|
+
} from "../../../model/canonical-document.ts";
|
|
22
|
+
import type { CanonicalDocumentEnvelope } from "../../../core/state/editor-state.ts";
|
|
23
|
+
import type { ScopeEnumeratedScope } from "../enumerate-scopes.ts";
|
|
24
|
+
import { deriveReplaceability } from "../replaceability.ts";
|
|
25
|
+
import type {
|
|
26
|
+
SemanticScope,
|
|
27
|
+
SemanticScopeWorkflow,
|
|
28
|
+
} from "../semantic-scope-types.ts";
|
|
29
|
+
|
|
30
|
+
import { extractParagraphText, buildExcerpt } from "./_paragraph-text.ts";
|
|
31
|
+
|
|
32
|
+
export interface CompileScopeKindOptions {
|
|
33
|
+
readonly document: CanonicalDocument | CanonicalDocumentEnvelope;
|
|
34
|
+
readonly workflow?: SemanticScopeWorkflow;
|
|
35
|
+
/**
|
|
36
|
+
* 0-based section index of the scope's **first** spanning block
|
|
37
|
+
* (matches paragraph-kind semantics; agents reading layout.sectionIndex
|
|
38
|
+
* for routing get the scope's home section).
|
|
39
|
+
*/
|
|
40
|
+
readonly sectionIndex?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function compileScopeKind(
|
|
44
|
+
entry: ScopeEnumeratedScope,
|
|
45
|
+
options: CompileScopeKindOptions,
|
|
46
|
+
): SemanticScope {
|
|
47
|
+
const envelope = options.document as CanonicalDocumentEnvelope;
|
|
48
|
+
const root: DocumentRootNode =
|
|
49
|
+
"content" in envelope
|
|
50
|
+
? (envelope.content as DocumentRootNode)
|
|
51
|
+
: (options.document as unknown as DocumentRootNode);
|
|
52
|
+
|
|
53
|
+
const texts: string[] = [];
|
|
54
|
+
for (let i = entry.startBlockIndex; i <= entry.endBlockIndex; i += 1) {
|
|
55
|
+
const block = root.children[i];
|
|
56
|
+
if (!block || block.type !== "paragraph") continue;
|
|
57
|
+
texts.push(extractParagraphText(block as ParagraphNode));
|
|
58
|
+
}
|
|
59
|
+
const text = texts.join("\n");
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
handle: entry.handle,
|
|
63
|
+
kind: "scope",
|
|
64
|
+
classifications: entry.classifications,
|
|
65
|
+
content: {
|
|
66
|
+
text,
|
|
67
|
+
excerpt: buildExcerpt(text),
|
|
68
|
+
},
|
|
69
|
+
formatting: {},
|
|
70
|
+
layout:
|
|
71
|
+
typeof options.sectionIndex === "number"
|
|
72
|
+
? { sectionIndex: options.sectionIndex }
|
|
73
|
+
: {},
|
|
74
|
+
geometry: {},
|
|
75
|
+
workflow: options.workflow ?? { scopeIds: [], effectiveMode: "edit" },
|
|
76
|
+
replaceability: deriveReplaceability("scope", entry.handle.provenance),
|
|
77
|
+
audit: {
|
|
78
|
+
source: "runtime",
|
|
79
|
+
derivedFrom:
|
|
80
|
+
entry.classifications.length > 0
|
|
81
|
+
? ["canonical", "workflow-overlay"]
|
|
82
|
+
: ["canonical"],
|
|
83
|
+
confidence: "medium",
|
|
84
|
+
},
|
|
85
|
+
partial: true,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -165,6 +165,17 @@ export function resolveScopeRange(
|
|
|
165
165
|
return anchorToRange(entry.thread.anchor);
|
|
166
166
|
case "revision":
|
|
167
167
|
return anchorToRange(entry.revision.anchor);
|
|
168
|
+
case "scope": {
|
|
169
|
+
// Cross-paragraph marker pair. The marker-range lookup at the top
|
|
170
|
+
// of this function (stableRef.kind === "scope-id") normally wins
|
|
171
|
+
// first. This branch handles the case where the handle's stableRef
|
|
172
|
+
// was overridden to `semantic-path` via the `stableRefHint` seam —
|
|
173
|
+
// we fall back to spanning the start-block low to end-block high.
|
|
174
|
+
const startRange = positionMap.blocks.get(entry.startBlockIndex);
|
|
175
|
+
const endRange = positionMap.blocks.get(entry.endBlockIndex);
|
|
176
|
+
if (!startRange || !endRange) return null;
|
|
177
|
+
return { from: startRange.from, to: endRange.to };
|
|
178
|
+
}
|
|
168
179
|
default: {
|
|
169
180
|
const never: never = entry;
|
|
170
181
|
void never;
|
|
@@ -824,12 +824,9 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
|
|
|
824
824
|
plantStatus: {
|
|
825
825
|
planted: false,
|
|
826
826
|
reason: plantResult.status,
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
toBlockIndex: plantResult.toBlockIndex,
|
|
831
|
-
}
|
|
832
|
-
: {}),
|
|
827
|
+
// Cross-paragraph ranges now plant successfully (2026-04-24
|
|
828
|
+
// multi-paragraph-scopes slice) — that refusal variant is
|
|
829
|
+
// retired. Remaining failure reasons carry diagnostic fields:
|
|
833
830
|
...(plantResult.status === "non-paragraph-target"
|
|
834
831
|
? {
|
|
835
832
|
blockIndex: plantResult.blockIndex,
|
|
@@ -277,15 +277,11 @@ export type CreateScopeFromAnchorResult =
|
|
|
277
277
|
| "from-negative"
|
|
278
278
|
| "to-less-than-from"
|
|
279
279
|
| "range-exceeds-story-length"
|
|
280
|
-
| "cross-paragraph-range"
|
|
281
280
|
| "non-paragraph-target"
|
|
282
281
|
| "empty-document";
|
|
283
282
|
readonly from: number;
|
|
284
283
|
readonly to: number;
|
|
285
284
|
readonly storyLength: number;
|
|
286
|
-
/** Cross-paragraph only — the two block indices the range straddled. */
|
|
287
|
-
readonly fromBlockIndex?: number;
|
|
288
|
-
readonly toBlockIndex?: number;
|
|
289
285
|
/** Non-paragraph target only — the offending block's index and kind. */
|
|
290
286
|
readonly blockIndex?: number;
|
|
291
287
|
readonly blockKind?: string;
|
|
@@ -301,7 +297,6 @@ export type CreateScopeFromAnchorResult =
|
|
|
301
297
|
* don't want to pattern-match on `reason`. Examples:
|
|
302
298
|
* "clamp-from-to-zero", "swap-from-and-to",
|
|
303
299
|
* "clamp-to-to-storyLength-or-pick-a-different-range",
|
|
304
|
-
* "narrow-to-single-paragraph",
|
|
305
300
|
* "pick-a-paragraph-target".
|
|
306
301
|
*/
|
|
307
302
|
readonly nextStep: string;
|
|
@@ -432,31 +427,15 @@ export function createScopeFromAnchor(
|
|
|
432
427
|
});
|
|
433
428
|
|
|
434
429
|
// Pre-2026-04-24 the coordinator silently returned a minted scopeId
|
|
435
|
-
// even when insertScopeMarkers refused to plant (
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
//
|
|
430
|
+
// even when insertScopeMarkers refused to plant (non-paragraph
|
|
431
|
+
// target, out-of-bounds after the story-length check passed).
|
|
432
|
+
// Cross-paragraph ranges now plant successfully (2026-04-24
|
|
433
|
+
// multi-paragraph-scopes slice), so that refusal variant is retired.
|
|
434
|
+
// Remaining reasons translate into the same `range-invalid` shape
|
|
439
435
|
// used by the bounds checks above so the caller gets one uniform
|
|
440
436
|
// discriminator to branch on.
|
|
441
437
|
if (result.plantStatus && result.plantStatus.planted === false) {
|
|
442
438
|
const ps = result.plantStatus;
|
|
443
|
-
if (ps.reason === "cross-paragraph-range") {
|
|
444
|
-
return {
|
|
445
|
-
status: "range-invalid",
|
|
446
|
-
reason: "cross-paragraph-range",
|
|
447
|
-
from,
|
|
448
|
-
to,
|
|
449
|
-
storyLength,
|
|
450
|
-
fromBlockIndex: ps.fromBlockIndex ?? -1,
|
|
451
|
-
toBlockIndex: ps.toBlockIndex ?? -1,
|
|
452
|
-
message:
|
|
453
|
-
`createScopeFromAnchor refused: range [${from}, ${to}] straddles ` +
|
|
454
|
-
`paragraphs ${ps.fromBlockIndex} and ${ps.toBlockIndex}. Marker-backed ` +
|
|
455
|
-
`scopes only plant inside a single paragraph today. Narrow the range to ` +
|
|
456
|
-
`land inside one paragraph, or create two separate scopes.`,
|
|
457
|
-
nextStep: "narrow-to-single-paragraph",
|
|
458
|
-
};
|
|
459
|
-
}
|
|
460
439
|
if (ps.reason === "non-paragraph-target") {
|
|
461
440
|
return {
|
|
462
441
|
status: "range-invalid",
|
|
@@ -155,6 +155,16 @@ export function getRevisionRangeState(
|
|
|
155
155
|
* by `test/runtime/formatting/production-boundary.test.ts`.
|
|
156
156
|
*/
|
|
157
157
|
export interface RevisionDisplayFlags {
|
|
158
|
+
/**
|
|
159
|
+
* Identity of the attached revision. Mirrors
|
|
160
|
+
* `SurfaceInlineSegment.revisionDisplay.revisionId`.
|
|
161
|
+
*/
|
|
162
|
+
revisionId: string;
|
|
163
|
+
/**
|
|
164
|
+
* Mirrors `RevisionRecord.kind`. Consumers branch on insertion /
|
|
165
|
+
* deletion variants without reading the review store.
|
|
166
|
+
*/
|
|
167
|
+
kind: "insertion" | "deletion" | "formatting" | "move" | "property-change";
|
|
158
168
|
markupMode: "clean" | "simple" | "all";
|
|
159
169
|
hidden?: boolean;
|
|
160
170
|
strikethrough?: boolean;
|
|
@@ -1075,7 +1075,16 @@
|
|
|
1075
1075
|
}
|
|
1076
1076
|
|
|
1077
1077
|
.wre-page-band:hover {
|
|
1078
|
-
|
|
1078
|
+
/*
|
|
1079
|
+
* N3 audit (2026-04-24): the prior `color-mix(bg-muted 80%, surface 20%)`
|
|
1080
|
+
* blended two near-adjacent tokens — in dark mode (#17211C vs #182420)
|
|
1081
|
+
* the result moved ≤2 RGB units from the base, leaving no visible
|
|
1082
|
+
* hover affordance. `--color-bg-hover` is the token the design system
|
|
1083
|
+
* already dedicates to this signal (light #EAF6EF vs muted #F7FAF8;
|
|
1084
|
+
* dark #21342A vs muted #17211C) and resolves the dark-mode legibility
|
|
1085
|
+
* gap without regressing light-mode subtlety.
|
|
1086
|
+
*/
|
|
1087
|
+
background-color: var(--color-bg-hover);
|
|
1079
1088
|
}
|
|
1080
1089
|
|
|
1081
1090
|
.wre-page-band[data-active="true"] {
|