@beyondwork/docx-react-component 1.0.78 → 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 +60 -1
- package/src/api/v3/ai/resolve.ts +13 -7
- package/src/api/v3/runtime/workflow.ts +12 -2
- package/src/core/commands/add-scope.ts +222 -69
- package/src/runtime/document-runtime.ts +77 -2
- package/src/runtime/formatting/formatting-types.ts +16 -0
- package/src/runtime/formatting/revision-display.ts +16 -10
- package/src/runtime/layout/inert-layout-facet.ts +1 -0
- package/src/runtime/layout/layout-engine-version.ts +27 -1
- package/src/runtime/layout/public-facet.ts +35 -0
- 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 +60 -10
- package/src/runtime/workflow/scope-writer.ts +69 -2
- package/src/ui/headless/revision-decoration-model.ts +10 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +20 -1
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +17 -2
- package/src/ui-tailwind/theme/editor-theme.css +10 -1
|
@@ -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,
|
|
@@ -65,6 +65,7 @@ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
|
|
|
65
65
|
invalidateMeasurementCache: () => undefined,
|
|
66
66
|
getTableRenderPlan: () => null,
|
|
67
67
|
getTableBodyYOffsetOnPage: () => null,
|
|
68
|
+
getBlockHeightsTwips: () => new Map(),
|
|
68
69
|
getDirtyFieldFamilies: () => [],
|
|
69
70
|
getFieldDirtinessReport: () => emptyReport,
|
|
70
71
|
setVisibleBlockRange: () => undefined,
|
|
@@ -955,8 +955,34 @@
|
|
|
955
955
|
* underlying layout algorithm is unchanged — persisted envelopes
|
|
956
956
|
* remain shape-compatible. Bump is defensive so any consumer that
|
|
957
957
|
* keyed on the facet contract refreshes the cache.
|
|
958
|
+
*
|
|
959
|
+
* 57 — viewport-cull flicker fix. The pre-v57 PM placeholder emitted
|
|
960
|
+
* for `placeholder-culled` opaque blocks rendered at
|
|
961
|
+
* `min-height: 20px` regardless of the real block's visual height
|
|
962
|
+
* (`src/ui-tailwind/editor-surface/pm-schema.ts`), because neither
|
|
963
|
+
* L03 surface-projection nor L11 PM state-build had access to
|
|
964
|
+
* layout-derived heights. Scrolling into a culled region inflated
|
|
965
|
+
* every block in turn, dragging content below the scroll pointer —
|
|
966
|
+
* the long-standing "paragraphs jump around pagination gaps"
|
|
967
|
+
* flicker.
|
|
968
|
+
*
|
|
969
|
+
* L04 now exposes `WordReviewEditorLayoutFacet.getBlockHeightsTwips():
|
|
970
|
+
* ReadonlyMap<string, number>` — one entry per blockId, value = sum
|
|
971
|
+
* of that block's fragments' `heightTwips`. Cached per
|
|
972
|
+
* `graph.revision`. `DocumentRuntime` reads the map after each
|
|
973
|
+
* surface-projection pass and enriches every
|
|
974
|
+
* `placeholder-culled` `opaque_block` with
|
|
975
|
+
* `placeholderHeightTwips`. The PM schema's paragraph node gained
|
|
976
|
+
* a `placeholderHeightTwips` attr; `toDOM` emits
|
|
977
|
+
* `height: ${twips/20}pt` instead of the `min-height: 20px`
|
|
978
|
+
* fallback when the attr is set.
|
|
979
|
+
*
|
|
980
|
+
* Pagination itself is untouched — this is purely a render-surface
|
|
981
|
+
* fix. Cache envelopes from v56 invalidate because the exposed
|
|
982
|
+
* facet surface grew one public method; any consumer relying on
|
|
983
|
+
* the prior interface shape re-derives its cache key under v57.
|
|
958
984
|
*/
|
|
959
|
-
export const LAYOUT_ENGINE_VERSION =
|
|
985
|
+
export const LAYOUT_ENGINE_VERSION = 57 as const;
|
|
960
986
|
|
|
961
987
|
/**
|
|
962
988
|
* Serialization schema version for the LayCache payload (the cache envelope
|
|
@@ -569,6 +569,19 @@ export interface WordReviewEditorLayoutFacet {
|
|
|
569
569
|
*/
|
|
570
570
|
getTableBodyYOffsetOnPage(blockId: string, pageIndex: number): number | null;
|
|
571
571
|
|
|
572
|
+
/**
|
|
573
|
+
* Viewport-cull height resolver — returns total rendered height (twips)
|
|
574
|
+
* for every block in the current page graph, computed as the sum of each
|
|
575
|
+
* fragment's `heightTwips` grouped by `blockId`. Consumers (the render
|
|
576
|
+
* surface builder in particular) use this to size `placeholder-culled`
|
|
577
|
+
* opaque stubs so the scrollable canvas does not change height when a
|
|
578
|
+
* block realizes during scroll.
|
|
579
|
+
*
|
|
580
|
+
* Returns an empty map on the inert facet or before the first successful
|
|
581
|
+
* pagination pass. Cached per `graph.revision`.
|
|
582
|
+
*/
|
|
583
|
+
getBlockHeightsTwips(): ReadonlyMap<string, number>;
|
|
584
|
+
|
|
572
585
|
// Fields ---------------------------------------------------------------
|
|
573
586
|
getDirtyFieldFamilies(): readonly string[];
|
|
574
587
|
getFieldDirtinessReport(): PublicFieldDirtinessReport;
|
|
@@ -707,6 +720,13 @@ export function createLayoutFacet(
|
|
|
707
720
|
revision: number;
|
|
708
721
|
blocks: readonly PublicRegionBlock[] | null;
|
|
709
722
|
} = { revision: -1, blocks: null };
|
|
723
|
+
// Viewport-cull flicker fix — per-revision cache for getBlockHeightsTwips.
|
|
724
|
+
// One entry per blockId; value is the sum of that block's fragments'
|
|
725
|
+
// `heightTwips`. Busts on `graph.revision` change.
|
|
726
|
+
let blockHeightsCache: {
|
|
727
|
+
revision: number;
|
|
728
|
+
map: ReadonlyMap<string, number> | null;
|
|
729
|
+
} = { revision: -1, map: null };
|
|
710
730
|
|
|
711
731
|
function resolveCanonicalDocument(): CanonicalDocumentEnvelope {
|
|
712
732
|
if (input.canonicalDocument) {
|
|
@@ -1234,6 +1254,21 @@ export function createLayoutFacet(
|
|
|
1234
1254
|
return null;
|
|
1235
1255
|
},
|
|
1236
1256
|
|
|
1257
|
+
getBlockHeightsTwips() {
|
|
1258
|
+
const graph = currentGraph();
|
|
1259
|
+
if (blockHeightsCache.revision === graph.revision && blockHeightsCache.map) {
|
|
1260
|
+
return blockHeightsCache.map;
|
|
1261
|
+
}
|
|
1262
|
+
const map = new Map<string, number>();
|
|
1263
|
+
for (const frag of graph.fragments) {
|
|
1264
|
+
const prev = map.get(frag.blockId) ?? 0;
|
|
1265
|
+
map.set(frag.blockId, prev + frag.heightTwips);
|
|
1266
|
+
}
|
|
1267
|
+
const frozen: ReadonlyMap<string, number> = map;
|
|
1268
|
+
blockHeightsCache = { revision: graph.revision, map: frozen };
|
|
1269
|
+
return frozen;
|
|
1270
|
+
},
|
|
1271
|
+
|
|
1237
1272
|
getDirtyFieldFamilies() {
|
|
1238
1273
|
return engine.getDirtyFieldFamilies();
|
|
1239
1274
|
},
|
|
@@ -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;
|
|
@@ -791,19 +791,69 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
|
|
|
791
791
|
return { scopeId, anchor: params.anchor };
|
|
792
792
|
}
|
|
793
793
|
|
|
794
|
-
const
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
794
|
+
const plantResult = insertScopeMarkers(deps.getDocument(), {
|
|
795
|
+
scopeId,
|
|
796
|
+
from: anchor.from,
|
|
797
|
+
to: anchor.to,
|
|
798
|
+
});
|
|
798
799
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
800
|
+
// Plant failed — pre-2026-04-24 this returned silently with a dead
|
|
801
|
+
// scopeId; callers later saw `scope-not-resolvable`. Now surface
|
|
802
|
+
// the typed failure on the AddScopeResult so consumers can detect
|
|
803
|
+
// the plant-failed path without a round-trip through
|
|
804
|
+
// resolveReference.
|
|
805
|
+
if (plantResult.status !== "planted") {
|
|
806
|
+
const callerAssoc: { readonly start: -1 | 1; readonly end: -1 | 1 } =
|
|
807
|
+
params.anchor.kind === "range"
|
|
808
|
+
? params.anchor.assoc
|
|
809
|
+
: { start: -1, end: 1 };
|
|
810
|
+
// Return the caller's input range as an informational range
|
|
811
|
+
// anchor. The authoritative failure signal is `scopeId: ""` +
|
|
812
|
+
// `plantStatus.planted === false`. The detached-anchor shape has
|
|
813
|
+
// a fixed reason enum (`deleted|invalidatedByStructureChange|
|
|
814
|
+
// importAmbiguity`) that doesn't cover plant-refused, so we keep
|
|
815
|
+
// the range kind and let callers discriminate via plantStatus.
|
|
816
|
+
return {
|
|
817
|
+
scopeId: "",
|
|
818
|
+
anchor: {
|
|
819
|
+
kind: "range",
|
|
820
|
+
from: anchor.from,
|
|
821
|
+
to: anchor.to,
|
|
822
|
+
assoc: callerAssoc,
|
|
823
|
+
},
|
|
824
|
+
plantStatus: {
|
|
825
|
+
planted: false,
|
|
826
|
+
reason: plantResult.status,
|
|
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:
|
|
830
|
+
...(plantResult.status === "non-paragraph-target"
|
|
831
|
+
? {
|
|
832
|
+
blockIndex: plantResult.blockIndex,
|
|
833
|
+
blockKind: plantResult.blockKind,
|
|
834
|
+
}
|
|
835
|
+
: {}),
|
|
836
|
+
...(plantResult.status === "range-out-of-bounds"
|
|
837
|
+
? { storyLength: plantResult.storyLength }
|
|
838
|
+
: {}),
|
|
839
|
+
requestedFrom: plantResult.from,
|
|
840
|
+
requestedTo: plantResult.to,
|
|
841
|
+
},
|
|
842
|
+
};
|
|
843
|
+
// Intentionally NOT dispatching document.replace or workflow.set-overlay —
|
|
844
|
+
// a failed plant must not leave a half-registered scope. Prevents the
|
|
845
|
+
// pre-fix "overlay carries scopeId but canonical tree has no markers"
|
|
846
|
+
// state that produced `scope-not-resolvable` on every follow-up call.
|
|
805
847
|
}
|
|
806
848
|
|
|
849
|
+
const nextDocument = plantResult.document;
|
|
850
|
+
|
|
851
|
+
deps.dispatch({
|
|
852
|
+
type: "document.replace",
|
|
853
|
+
document: nextDocument,
|
|
854
|
+
origin: { source: "api", at: clock() },
|
|
855
|
+
});
|
|
856
|
+
|
|
807
857
|
// Coord-06 §13d — preserve the caller's assoc on the public anchor.
|
|
808
858
|
// resolveScope re-derives the range from the inserted markers but emits
|
|
809
859
|
// a hardcoded { start: -1, end: 1 }; without this override the caller's
|