@cueloop/client 0.1.0-alpha.83 → 0.1.0-alpha.84
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 +5 -5
- package/src/App.diff.test.tsx +4 -3
- package/src/App.export.test.tsx +1 -1
- package/src/App.readonly.test.tsx +1 -1
- package/src/App.submit-confirm.test.tsx +59 -17
- package/src/App.test.tsx +77 -3
- package/src/App.tsx +15 -7
- package/src/App.walk.test.tsx +1 -1
- package/src/components/ConfirmCard.tsx +11 -14
- package/src/components/DiffContentView.tsx +3 -2
- package/src/components/ProjectTreeView.test.tsx +17 -1
- package/src/components/ProjectTreeView.tsx +16 -2
- package/src/components/ThreadView.selection.test.tsx +31 -2
- package/src/components/ThreadView.tsx +4 -3
- package/src/components/__snapshots__/stories.test.tsx.snap +23 -23
- package/src/config.ts +2 -2
- package/src/intent-dispatch.test.ts +2 -2
- package/src/intent-dispatch.ts +2 -4
- package/src/keymap.ts +0 -2
- package/src/thread-controller.curate.test.ts +36 -10
- package/src/thread-controller.switch-cache.test.ts +1 -1
- package/src/thread-controller.ts +175 -46
- package/src/tree-view.ts +6 -1
- package/src/use-annotation-surface.tsx +72 -12
- package/src/use-terminal-virtualizer.ts +26 -0
- package/src/view-plan.test.ts +66 -1
- package/src/view-plan.ts +281 -9
package/src/view-plan.test.ts
CHANGED
|
@@ -19,7 +19,15 @@ import {
|
|
|
19
19
|
styledRunsFor,
|
|
20
20
|
workRangeForRendered,
|
|
21
21
|
} from "./view-plan";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
applyTextCuts,
|
|
24
|
+
cutBlock,
|
|
25
|
+
mergeTextCut,
|
|
26
|
+
parseBlocks,
|
|
27
|
+
restoreBlock,
|
|
28
|
+
restoreLine,
|
|
29
|
+
type Annotation,
|
|
30
|
+
} from "@cueloop/schema";
|
|
23
31
|
|
|
24
32
|
const BASE = `# Plan
|
|
25
33
|
|
|
@@ -54,6 +62,14 @@ describe("buildDisplay reconciliation", () => {
|
|
|
54
62
|
expect(mod.work!.text).toContain("writes");
|
|
55
63
|
});
|
|
56
64
|
|
|
65
|
+
test("a partial Cut stays one modified block even when only a short prefix survives", () => {
|
|
66
|
+
const base = "A seeded plan so cueloop dev always has a thread to open.";
|
|
67
|
+
const display = buildDisplay(base, "A seed");
|
|
68
|
+
|
|
69
|
+
expect(display).toHaveLength(1);
|
|
70
|
+
expect(display[0]!.type).toBe("mod");
|
|
71
|
+
});
|
|
72
|
+
|
|
57
73
|
test("unrelated cut + insert do NOT merge into a mod", () => {
|
|
58
74
|
// Arrange
|
|
59
75
|
const working = BASE.replace("- second item\n", "") + "\nA totally new closing note.\n";
|
|
@@ -81,6 +97,55 @@ describe("buildDisplay reconciliation", () => {
|
|
|
81
97
|
});
|
|
82
98
|
|
|
83
99
|
describe("blockRuns + overlayMarks", () => {
|
|
100
|
+
test("a partial-word Cut leaves surviving characters plain instead of inserted", () => {
|
|
101
|
+
const base = "It lands under unin Threads then in the sidebar rather than a project.";
|
|
102
|
+
const firstStart = base.indexOf("under");
|
|
103
|
+
const firstEnd = firstStart + "under".length;
|
|
104
|
+
const secondStart = base.indexOf("Threads");
|
|
105
|
+
const secondEnd = secondStart + "Threads".length;
|
|
106
|
+
const cuts = mergeTextCut(
|
|
107
|
+
base,
|
|
108
|
+
mergeTextCut(base, [], firstStart, firstEnd),
|
|
109
|
+
secondStart,
|
|
110
|
+
secondEnd,
|
|
111
|
+
);
|
|
112
|
+
const [block] = buildDisplay(base, applyTextCuts(base, cuts), cuts);
|
|
113
|
+
const runs = blockRuns(block!, true);
|
|
114
|
+
|
|
115
|
+
expect(runs.filter((run) => run.role === "del").map((run) => run.text)).toEqual([
|
|
116
|
+
"under",
|
|
117
|
+
"Threads",
|
|
118
|
+
]);
|
|
119
|
+
expect(
|
|
120
|
+
runs
|
|
121
|
+
.filter((run) => run.role !== "del")
|
|
122
|
+
.map((run) => run.text)
|
|
123
|
+
.join(""),
|
|
124
|
+
).toBe("It lands unin then in the sidebar rather than a project.");
|
|
125
|
+
expect(runs.some((run) => run.role === "ins")).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("a cross-block Cut keeps every survivor plain and strikes only selected characters", () => {
|
|
129
|
+
const base = "Keep before remove this.\n\n- remove that keep after";
|
|
130
|
+
const cuts = mergeTextCut(base, [], 12, 40);
|
|
131
|
+
const display = buildDisplay(base, applyTextCuts(base, cuts), cuts);
|
|
132
|
+
const runs = display.flatMap((block) => blockRuns(block, true));
|
|
133
|
+
|
|
134
|
+
expect(runs.filter((run) => run.role === "ins")).toEqual([]);
|
|
135
|
+
expect(
|
|
136
|
+
runs
|
|
137
|
+
.filter((run) => run.role === "del")
|
|
138
|
+
.map((run) => run.text)
|
|
139
|
+
.join(""),
|
|
140
|
+
).toBe(base.slice(12, 40).replace("\n\n- ", ""));
|
|
141
|
+
expect(
|
|
142
|
+
runs
|
|
143
|
+
.filter((run) => run.role !== "del")
|
|
144
|
+
.map((run) => run.text)
|
|
145
|
+
.join(""),
|
|
146
|
+
).toBe("Keep before keep after");
|
|
147
|
+
});
|
|
148
|
+
|
|
84
149
|
test("mod blocks emit word-diff runs that reconstruct both sides", () => {
|
|
85
150
|
// Arrange
|
|
86
151
|
const block = buildDisplay("plain old text here\n", "plain new text here\n")[0]!;
|
package/src/view-plan.ts
CHANGED
|
@@ -13,8 +13,12 @@ import {
|
|
|
13
13
|
lcsDiff,
|
|
14
14
|
parseBlocks,
|
|
15
15
|
resolveAnchor,
|
|
16
|
+
applyTextCuts,
|
|
17
|
+
blockTextSourceSegments,
|
|
16
18
|
type Annotation,
|
|
17
19
|
type Block,
|
|
20
|
+
type BlockTextSourceSegment,
|
|
21
|
+
type TextCut,
|
|
18
22
|
} from "@cueloop/schema";
|
|
19
23
|
import { wordLevelChanges } from "./diff-intraline";
|
|
20
24
|
import { spanRangeInBlock, type CharRange, type TextSpan } from "./thread-selection";
|
|
@@ -31,6 +35,10 @@ export interface DisplayBlock {
|
|
|
31
35
|
work?: Block;
|
|
32
36
|
/** Block in the submitted revision (absent for add). */
|
|
33
37
|
base?: Block;
|
|
38
|
+
/** Source projection for mapping a visible base-text selection back to Markdown. */
|
|
39
|
+
baseSourceSegments?: readonly BlockTextSourceSegment[];
|
|
40
|
+
/** Exact base-text ranges removed by character Cut. */
|
|
41
|
+
textCutRanges?: CharRange[];
|
|
34
42
|
orderedItemNumber?: number;
|
|
35
43
|
}
|
|
36
44
|
|
|
@@ -50,9 +58,54 @@ export function renderedText(block: DisplayBlock): string {
|
|
|
50
58
|
.join("");
|
|
51
59
|
}
|
|
52
60
|
|
|
61
|
+
/** Exact character runs when one contiguous insertion or deletion produced the new text. */
|
|
62
|
+
function singleRangeTextChanges(
|
|
63
|
+
oldText: string,
|
|
64
|
+
newText: string,
|
|
65
|
+
): ReturnType<typeof wordLevelChanges> | null {
|
|
66
|
+
if (oldText === newText || oldText.length === newText.length) return null;
|
|
67
|
+
let prefixLength = 0;
|
|
68
|
+
|
|
69
|
+
while (
|
|
70
|
+
prefixLength < oldText.length &&
|
|
71
|
+
prefixLength < newText.length &&
|
|
72
|
+
oldText[prefixLength] === newText[prefixLength]
|
|
73
|
+
)
|
|
74
|
+
prefixLength++;
|
|
75
|
+
let suffixLength = 0;
|
|
76
|
+
const shorterLength = Math.min(oldText.length, newText.length);
|
|
77
|
+
|
|
78
|
+
while (
|
|
79
|
+
suffixLength < shorterLength - prefixLength &&
|
|
80
|
+
oldText[oldText.length - 1 - suffixLength] === newText[newText.length - 1 - suffixLength]
|
|
81
|
+
)
|
|
82
|
+
suffixLength++;
|
|
83
|
+
if (prefixLength + suffixLength !== shorterLength) return null;
|
|
84
|
+
const commonPrefix = oldText.slice(0, prefixLength);
|
|
85
|
+
const commonSuffix = oldText.slice(oldText.length - suffixLength);
|
|
86
|
+
const changed =
|
|
87
|
+
oldText.length > newText.length
|
|
88
|
+
? {
|
|
89
|
+
text: oldText.slice(prefixLength, oldText.length - suffixLength),
|
|
90
|
+
kind: "removed" as const,
|
|
91
|
+
}
|
|
92
|
+
: {
|
|
93
|
+
text: newText.slice(prefixLength, newText.length - suffixLength),
|
|
94
|
+
kind: "added" as const,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return [
|
|
98
|
+
{ text: commonPrefix, kind: "common" as const },
|
|
99
|
+
changed,
|
|
100
|
+
{ text: commonSuffix, kind: "common" as const },
|
|
101
|
+
].filter((change) => change.text.length > 0);
|
|
102
|
+
}
|
|
103
|
+
|
|
53
104
|
/** Word-overlap similarity gate: an unrelated cut + insert must not merge. */
|
|
54
|
-
function similar(baseBlock: Block, workBlock: Block): boolean {
|
|
105
|
+
function similar(baseBlock: Block, workBlock: Block, expectedText?: string): boolean {
|
|
55
106
|
if (baseBlock.kind !== workBlock.kind) return false;
|
|
107
|
+
if (expectedText === workBlock.text) return true;
|
|
108
|
+
if (singleRangeTextChanges(baseBlock.text, workBlock.text)) return true;
|
|
56
109
|
const words = (text: string) => new Set(text.toLowerCase().split(/\s+/).filter(Boolean));
|
|
57
110
|
const baseWords = words(baseBlock.text);
|
|
58
111
|
const workWords = words(workBlock.text);
|
|
@@ -65,8 +118,84 @@ function similar(baseBlock: Block, workBlock: Block): boolean {
|
|
|
65
118
|
}
|
|
66
119
|
|
|
67
120
|
/** Reconcile base blocks vs working blocks into the display list. */
|
|
68
|
-
export function buildDisplay(
|
|
121
|
+
export function buildDisplay(
|
|
122
|
+
baseContent: string,
|
|
123
|
+
workingContent?: string,
|
|
124
|
+
textCuts: readonly TextCut[] = [],
|
|
125
|
+
): DisplayBlock[] {
|
|
69
126
|
const baseBlocks = parseBlocks(baseContent);
|
|
127
|
+
const exactCuts =
|
|
128
|
+
textCuts.length > 0 && workingContent === applyTextCuts(baseContent, textCuts) ? textCuts : [];
|
|
129
|
+
const sourceSegments =
|
|
130
|
+
exactCuts.length > 0 ? blockTextSourceSegments(baseContent, baseBlocks) : [];
|
|
131
|
+
const cutData = new Map<Block, Pick<DisplayBlock, "baseSourceSegments" | "textCutRanges">>();
|
|
132
|
+
let cutIndex = 0;
|
|
133
|
+
|
|
134
|
+
baseBlocks.forEach((block, blockIndex) => {
|
|
135
|
+
const segments = sourceSegments[blockIndex];
|
|
136
|
+
|
|
137
|
+
if (!segments?.length) return;
|
|
138
|
+
const sourceStart = segments[0]!.sourceStart;
|
|
139
|
+
const last = segments.at(-1)!;
|
|
140
|
+
const sourceEnd = last.sourceStart + last.textEnd - last.textStart;
|
|
141
|
+
|
|
142
|
+
while (cutIndex < exactCuts.length && exactCuts[cutIndex]!.end <= sourceStart) cutIndex++;
|
|
143
|
+
const ranges: CharRange[] = [];
|
|
144
|
+
|
|
145
|
+
for (let index = cutIndex; index < exactCuts.length; index++) {
|
|
146
|
+
const cut = exactCuts[index]!;
|
|
147
|
+
|
|
148
|
+
if (cut.start >= sourceEnd) break;
|
|
149
|
+
const start = blockTextBoundary(segments, block.text.length, cut.start);
|
|
150
|
+
const end = blockTextBoundary(segments, block.text.length, cut.end);
|
|
151
|
+
|
|
152
|
+
if (end > start) ranges.push({ start, end });
|
|
153
|
+
}
|
|
154
|
+
const data: Pick<DisplayBlock, "baseSourceSegments" | "textCutRanges"> = {
|
|
155
|
+
baseSourceSegments: segments,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
if (ranges.length > 0) data.textCutRanges = ranges;
|
|
159
|
+
cutData.set(block, data);
|
|
160
|
+
});
|
|
161
|
+
const blockCutData = (block: Block) => cutData.get(block) ?? {};
|
|
162
|
+
const expectedBlockText = (block: Block): string | undefined => {
|
|
163
|
+
const data = blockCutData(block);
|
|
164
|
+
|
|
165
|
+
if (!data.textCutRanges) return undefined;
|
|
166
|
+
let text = block.text;
|
|
167
|
+
|
|
168
|
+
for (let index = data.textCutRanges.length - 1; index >= 0; index--) {
|
|
169
|
+
const { start, end } = data.textCutRanges[index]!;
|
|
170
|
+
|
|
171
|
+
text = text.slice(0, start) + text.slice(end);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return text;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
if (exactCuts.length > 0) {
|
|
178
|
+
const display = baseBlocks.map((base): DisplayBlock => {
|
|
179
|
+
const data = blockCutData(base);
|
|
180
|
+
|
|
181
|
+
if (!data.textCutRanges) {
|
|
182
|
+
return { type: "same", kind: base.kind, base, work: base, ...data };
|
|
183
|
+
}
|
|
184
|
+
const text = expectedBlockText(base)!;
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
type: "mod",
|
|
188
|
+
kind: base.kind,
|
|
189
|
+
base,
|
|
190
|
+
work: { ...base, text },
|
|
191
|
+
...data,
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
numberOrderedListItems(display);
|
|
196
|
+
|
|
197
|
+
return display;
|
|
198
|
+
}
|
|
70
199
|
|
|
71
200
|
if (workingContent === undefined) {
|
|
72
201
|
const display: DisplayBlock[] = baseBlocks.map((block) => ({
|
|
@@ -74,6 +203,7 @@ export function buildDisplay(baseContent: string, workingContent?: string): Disp
|
|
|
74
203
|
kind: block.kind,
|
|
75
204
|
work: block,
|
|
76
205
|
base: block,
|
|
206
|
+
...blockCutData(block),
|
|
77
207
|
}));
|
|
78
208
|
|
|
79
209
|
numberOrderedListItems(display);
|
|
@@ -112,20 +242,32 @@ export function buildDisplay(baseContent: string, workingContent?: string): Disp
|
|
|
112
242
|
let addedIndex = 0;
|
|
113
243
|
|
|
114
244
|
while (deletedIndex < deletedBlocks.length && addedIndex < addedBlocks.length) {
|
|
115
|
-
if (
|
|
245
|
+
if (
|
|
246
|
+
similar(
|
|
247
|
+
deletedBlocks[deletedIndex]!,
|
|
248
|
+
addedBlocks[addedIndex]!,
|
|
249
|
+
expectedBlockText(deletedBlocks[deletedIndex]!),
|
|
250
|
+
)
|
|
251
|
+
) {
|
|
252
|
+
const base = deletedBlocks[deletedIndex]!;
|
|
253
|
+
|
|
116
254
|
display.push({
|
|
117
255
|
type: "mod",
|
|
118
256
|
kind: addedBlocks[addedIndex]!.kind,
|
|
119
|
-
base
|
|
257
|
+
base,
|
|
120
258
|
work: addedBlocks[addedIndex]!,
|
|
259
|
+
...blockCutData(base),
|
|
121
260
|
});
|
|
122
261
|
deletedIndex++;
|
|
123
262
|
addedIndex++;
|
|
124
263
|
} else if (deletedBlocks.length - deletedIndex >= addedBlocks.length - addedIndex) {
|
|
264
|
+
const base = deletedBlocks[deletedIndex]!;
|
|
265
|
+
|
|
125
266
|
display.push({
|
|
126
267
|
type: "del",
|
|
127
|
-
kind:
|
|
128
|
-
base
|
|
268
|
+
kind: base.kind,
|
|
269
|
+
base,
|
|
270
|
+
...blockCutData(base),
|
|
129
271
|
});
|
|
130
272
|
deletedIndex++;
|
|
131
273
|
} else {
|
|
@@ -138,11 +280,15 @@ export function buildDisplay(baseContent: string, workingContent?: string): Disp
|
|
|
138
280
|
}
|
|
139
281
|
}
|
|
140
282
|
while (deletedIndex < deletedBlocks.length) {
|
|
283
|
+
const base = deletedBlocks[deletedIndex]!;
|
|
284
|
+
|
|
141
285
|
display.push({
|
|
142
286
|
type: "del",
|
|
143
|
-
kind:
|
|
144
|
-
base
|
|
287
|
+
kind: base.kind,
|
|
288
|
+
base,
|
|
289
|
+
...blockCutData(base),
|
|
145
290
|
});
|
|
291
|
+
deletedIndex++;
|
|
146
292
|
}
|
|
147
293
|
while (addedIndex < addedBlocks.length) {
|
|
148
294
|
display.push({
|
|
@@ -157,6 +303,21 @@ export function buildDisplay(baseContent: string, workingContent?: string): Disp
|
|
|
157
303
|
return display;
|
|
158
304
|
}
|
|
159
305
|
|
|
306
|
+
function blockTextBoundary(
|
|
307
|
+
segments: readonly BlockTextSourceSegment[],
|
|
308
|
+
textLength: number,
|
|
309
|
+
sourceOffset: number,
|
|
310
|
+
): number {
|
|
311
|
+
for (const segment of segments) {
|
|
312
|
+
const sourceEnd = segment.sourceStart + segment.textEnd - segment.textStart;
|
|
313
|
+
|
|
314
|
+
if (sourceOffset < segment.sourceStart) return segment.textStart;
|
|
315
|
+
if (sourceOffset <= sourceEnd) return segment.textStart + sourceOffset - segment.sourceStart;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return textLength;
|
|
319
|
+
}
|
|
320
|
+
|
|
160
321
|
function numberOrderedListItems(display: DisplayBlock[]): void {
|
|
161
322
|
let runLength = 0;
|
|
162
323
|
|
|
@@ -186,6 +347,8 @@ export interface StyleRun {
|
|
|
186
347
|
role: RunRole;
|
|
187
348
|
/** Offset of this run in the block's working text; null for del runs. */
|
|
188
349
|
start: number | null;
|
|
350
|
+
/** Offset in the submitted block when an exact Cut preserves source provenance. */
|
|
351
|
+
baseStart?: number;
|
|
189
352
|
annotationId?: string;
|
|
190
353
|
/** Link target for `link` runs. */
|
|
191
354
|
href?: string;
|
|
@@ -266,6 +429,75 @@ export function marksByDisplay(
|
|
|
266
429
|
return marksByIndex;
|
|
267
430
|
}
|
|
268
431
|
|
|
432
|
+
function exactCutRuns(block: DisplayBlock, literal: boolean): StyleRun[] | null {
|
|
433
|
+
if (!block.base || !block.work || !block.textCutRanges) return null;
|
|
434
|
+
const localCuts = block.textCutRanges;
|
|
435
|
+
const expected = localCuts.reduceRight(
|
|
436
|
+
(text, cut) => text.slice(0, cut.start) + text.slice(cut.end),
|
|
437
|
+
block.base.text,
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
if (expected !== block.work.text) return null;
|
|
441
|
+
const sourceRuns = literal
|
|
442
|
+
? [{ text: block.base.text, role: "plain" as const, start: 0 }]
|
|
443
|
+
: inlineStyleRuns(block.base.text, 0);
|
|
444
|
+
const runs: StyleRun[] = [];
|
|
445
|
+
let cutIndex = 0;
|
|
446
|
+
let deletedBefore = 0;
|
|
447
|
+
|
|
448
|
+
for (const sourceRun of sourceRuns) {
|
|
449
|
+
if (sourceRun.start === null) continue;
|
|
450
|
+
const runStart = sourceRun.start;
|
|
451
|
+
const runEnd = runStart + sourceRun.text.length;
|
|
452
|
+
let position = runStart;
|
|
453
|
+
|
|
454
|
+
while (cutIndex < localCuts.length && localCuts[cutIndex]!.end <= position) {
|
|
455
|
+
deletedBefore += localCuts[cutIndex]!.end - localCuts[cutIndex]!.start;
|
|
456
|
+
cutIndex++;
|
|
457
|
+
}
|
|
458
|
+
while (position < runEnd) {
|
|
459
|
+
const cut = localCuts[cutIndex];
|
|
460
|
+
|
|
461
|
+
if (!cut || cut.start >= runEnd) {
|
|
462
|
+
runs.push({
|
|
463
|
+
...sourceRun,
|
|
464
|
+
text: block.base.text.slice(position, runEnd),
|
|
465
|
+
start: position - deletedBefore,
|
|
466
|
+
baseStart: position,
|
|
467
|
+
});
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
if (position < cut.start) {
|
|
471
|
+
const commonEnd = Math.min(runEnd, cut.start);
|
|
472
|
+
|
|
473
|
+
runs.push({
|
|
474
|
+
...sourceRun,
|
|
475
|
+
text: block.base.text.slice(position, commonEnd),
|
|
476
|
+
start: position - deletedBefore,
|
|
477
|
+
baseStart: position,
|
|
478
|
+
});
|
|
479
|
+
position = commonEnd;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
const removedEnd = Math.min(runEnd, cut.end);
|
|
483
|
+
|
|
484
|
+
runs.push({
|
|
485
|
+
text: block.base.text.slice(position, removedEnd),
|
|
486
|
+
role: "del",
|
|
487
|
+
start: null,
|
|
488
|
+
baseStart: position,
|
|
489
|
+
});
|
|
490
|
+
position = removedEnd;
|
|
491
|
+
if (position === cut.end) {
|
|
492
|
+
deletedBefore += cut.end - cut.start;
|
|
493
|
+
cutIndex++;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return runs;
|
|
499
|
+
}
|
|
500
|
+
|
|
269
501
|
/** Base runs for a display block: plain text, or word-diff for mod blocks. */
|
|
270
502
|
export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
|
|
271
503
|
const literal =
|
|
@@ -274,8 +506,16 @@ export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
|
|
|
274
506
|
block.kind === "table" ||
|
|
275
507
|
block.kind === "frontmatter";
|
|
276
508
|
|
|
509
|
+
if (markup) {
|
|
510
|
+
const exact = exactCutRuns(block, literal);
|
|
511
|
+
|
|
512
|
+
if (exact) return exact;
|
|
513
|
+
}
|
|
514
|
+
|
|
277
515
|
if (block.type === "mod" && markup) {
|
|
278
|
-
const changes =
|
|
516
|
+
const changes =
|
|
517
|
+
singleRangeTextChanges(block.base!.text, block.work!.text) ??
|
|
518
|
+
wordLevelChanges(block.base!.text, block.work!.text);
|
|
279
519
|
const runs: StyleRun[] = [];
|
|
280
520
|
let workOffset = 0;
|
|
281
521
|
|
|
@@ -572,6 +812,38 @@ export function workRangeForRendered(
|
|
|
572
812
|
return range;
|
|
573
813
|
}
|
|
574
814
|
|
|
815
|
+
/** Base-text range covered by a rendered selection in an exact Cut projection. */
|
|
816
|
+
export function baseRangeForRendered(
|
|
817
|
+
runs: StyleRun[],
|
|
818
|
+
renderedStart: number,
|
|
819
|
+
renderedEnd: number,
|
|
820
|
+
): { start: number; end: number } | null {
|
|
821
|
+
let rendered = 0;
|
|
822
|
+
let range: { start: number; end: number } | null = null;
|
|
823
|
+
|
|
824
|
+
for (const run of runs) {
|
|
825
|
+
const runRenderedStart = rendered;
|
|
826
|
+
const runRenderedEnd = rendered + run.text.length;
|
|
827
|
+
|
|
828
|
+
rendered = runRenderedEnd;
|
|
829
|
+
if (run.baseStart === undefined) continue;
|
|
830
|
+
const overlapStart = Math.max(renderedStart, runRenderedStart);
|
|
831
|
+
const overlapEnd = Math.min(renderedEnd, runRenderedEnd);
|
|
832
|
+
|
|
833
|
+
if (overlapEnd <= overlapStart) continue;
|
|
834
|
+
const baseStart = run.baseStart + overlapStart - runRenderedStart;
|
|
835
|
+
const baseEnd = run.baseStart + overlapEnd - runRenderedStart;
|
|
836
|
+
|
|
837
|
+
if (!range) range = { start: baseStart, end: baseEnd };
|
|
838
|
+
else {
|
|
839
|
+
range.start = Math.min(range.start, baseStart);
|
|
840
|
+
range.end = Math.max(range.end, baseEnd);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
return range;
|
|
845
|
+
}
|
|
846
|
+
|
|
575
847
|
/**
|
|
576
848
|
* Map a rendered-text selection back to the work-text range an anchor is cut
|
|
577
849
|
* from. The read-only surface yields rendered offsets (markers concealed); an
|