@cueloop/client 0.1.0-alpha.44 → 0.1.0-alpha.46
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 +6 -5
- package/src/App.plan-review.test.tsx +16 -2
- package/src/App.readonly.test.tsx +3 -3
- package/src/App.submit-confirm.test.tsx +6 -8
- package/src/App.tsx +39 -23
- package/src/App.walk.test.tsx +2 -1
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/components/AnnotationCard.stories.tsx +7 -11
- package/src/components/AnnotationCard.tsx +13 -11
- package/src/components/ConfirmCard.stories.tsx +3 -24
- package/src/components/ConfirmCard.tsx +7 -15
- package/src/components/MarkerPopover.tsx +7 -1
- package/src/components/PlanSheet.tsx +115 -48
- package/src/components/PrototypeSheet.stories.tsx +20 -0
- package/src/components/PrototypeSheet.tsx +228 -0
- package/src/components/ReviewRail.stories.tsx +0 -2
- package/src/components/ReviewRail.tsx +2 -9
- package/src/components/__snapshots__/stories.test.tsx.snap +37 -9
- package/src/intent-dispatch.test.ts +1 -0
- package/src/prototype-browser.test.ts +55 -0
- package/src/prototype-browser.ts +218 -0
- package/src/session-controller.ts +14 -0
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +105 -0
- package/src/view-plan.ts +96 -9
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { cssBoxToCell, imageCellToCss } from "./prototype-browser";
|
|
3
|
+
|
|
4
|
+
const IMAGE = { x: 1, y: 1, width: 80, height: 40 };
|
|
5
|
+
const VIEWPORT = { width: 1280, height: 720 };
|
|
6
|
+
|
|
7
|
+
describe("imageCellToCss", () => {
|
|
8
|
+
test("maps a cell inside the image to the center of its viewport pixel band", () => {
|
|
9
|
+
// Act - the image's top-left cell (1,1)
|
|
10
|
+
const css = imageCellToCss({ x: 1, y: 1 }, IMAGE, VIEWPORT)!;
|
|
11
|
+
|
|
12
|
+
// Assert - half a cell into the first band
|
|
13
|
+
expect(css.x).toBeCloseTo((0.5 / 80) * 1280, 5);
|
|
14
|
+
expect(css.y).toBeCloseTo((0.5 / 40) * 720, 5);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("a click below the image's origin lands deeper in the page", () => {
|
|
18
|
+
// Act
|
|
19
|
+
const top = imageCellToCss({ x: 40, y: 2 }, IMAGE, VIEWPORT)!;
|
|
20
|
+
const bottom = imageCellToCss({ x: 40, y: 38 }, IMAGE, VIEWPORT)!;
|
|
21
|
+
|
|
22
|
+
// Assert
|
|
23
|
+
expect(bottom.y).toBeGreaterThan(top.y);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("a cell outside the image returns null", () => {
|
|
27
|
+
// Assert - the gutter left of and the row above the image
|
|
28
|
+
expect(imageCellToCss({ x: 0, y: 10 }, IMAGE, VIEWPORT)).toBeNull();
|
|
29
|
+
expect(imageCellToCss({ x: 10, y: 0 }, IMAGE, VIEWPORT)).toBeNull();
|
|
30
|
+
expect(imageCellToCss({ x: 200, y: 10 }, IMAGE, VIEWPORT)).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("cssBoxToCell", () => {
|
|
35
|
+
test("maps a viewport rect back to the image cells covering it", () => {
|
|
36
|
+
// Arrange - a rect covering the right half, lower half of the page
|
|
37
|
+
const box = { x: 640, y: 360, width: 320, height: 180 };
|
|
38
|
+
|
|
39
|
+
// Act
|
|
40
|
+
const cell = cssBoxToCell(box, IMAGE, VIEWPORT);
|
|
41
|
+
|
|
42
|
+
// Assert - x at half width -> 40 cells in from the image origin (1)
|
|
43
|
+
expect(cell.column).toBe(1 + Math.floor((640 / 1280) * 80));
|
|
44
|
+
expect(cell.row).toBe(1 + Math.floor((360 / 720) * 40));
|
|
45
|
+
expect(cell.columns).toBe(Math.round((320 / 1280) * 80));
|
|
46
|
+
expect(cell.rows).toBe(Math.round((180 / 720) * 40));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a zero-size rect still reserves at least one cell", () => {
|
|
50
|
+
// Assert
|
|
51
|
+
const cell = cssBoxToCell({ x: 0, y: 0, width: 0, height: 0 }, IMAGE, VIEWPORT);
|
|
52
|
+
expect(cell.columns).toBe(1);
|
|
53
|
+
expect(cell.rows).toBe(1);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Headless-Chromium backing for prototype review: render an HTML file to a PNG
|
|
4
|
+
* and resolve a click coordinate to a DOM element. puppeteer-core is imported
|
|
5
|
+
* lazily so plan/diff review and the test suite never load it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface PrototypeElement {
|
|
9
|
+
/** Stable CSS selector - the annotation anchor's authority. */
|
|
10
|
+
selector: string;
|
|
11
|
+
/** Short human label for the rail card (element text, else the selector). */
|
|
12
|
+
quote: string;
|
|
13
|
+
/** Element rectangle in CSS pixels within the captured viewport. */
|
|
14
|
+
box: ElementBox;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ElementBox {
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PrototypeViewport {
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PrototypeRenderer {
|
|
30
|
+
readonly viewport: PrototypeViewport;
|
|
31
|
+
screenshot(): Promise<Uint8Array>;
|
|
32
|
+
elementAt(cssX: number, cssY: number): Promise<PrototypeElement | null>;
|
|
33
|
+
highlight(selector: string | null): Promise<void>;
|
|
34
|
+
close(): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LaunchOptions {
|
|
38
|
+
filePath: string;
|
|
39
|
+
viewport: PrototypeViewport;
|
|
40
|
+
/** Absolute path to a Chrome/Chromium binary; falls back to the channel. */
|
|
41
|
+
executablePath?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Cell click -> CSS pixel inside the letterboxed image, or null when outside it. */
|
|
45
|
+
export function imageCellToCss(
|
|
46
|
+
event: { x: number; y: number },
|
|
47
|
+
image: { x: number; y: number; width: number; height: number },
|
|
48
|
+
viewport: PrototypeViewport,
|
|
49
|
+
): { x: number; y: number } | null {
|
|
50
|
+
const columnInImage = event.x - image.x;
|
|
51
|
+
const rowInImage = event.y - image.y;
|
|
52
|
+
if (
|
|
53
|
+
columnInImage < 0 ||
|
|
54
|
+
rowInImage < 0 ||
|
|
55
|
+
columnInImage >= image.width ||
|
|
56
|
+
rowInImage >= image.height
|
|
57
|
+
) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
x: ((columnInImage + 0.5) / image.width) * viewport.width,
|
|
62
|
+
y: ((rowInImage + 0.5) / image.height) * viewport.height,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** CSS pixel rect -> the image cell it maps to, for anchoring overlays. */
|
|
67
|
+
export function cssBoxToCell(
|
|
68
|
+
box: ElementBox,
|
|
69
|
+
image: { x: number; y: number; width: number; height: number },
|
|
70
|
+
viewport: PrototypeViewport,
|
|
71
|
+
): { column: number; row: number; columns: number; rows: number } {
|
|
72
|
+
const scaleColumn = image.width / viewport.width;
|
|
73
|
+
const scaleRow = image.height / viewport.height;
|
|
74
|
+
return {
|
|
75
|
+
column: image.x + Math.floor(box.x * scaleColumn),
|
|
76
|
+
row: image.y + Math.floor(box.y * scaleRow),
|
|
77
|
+
columns: Math.max(1, Math.round(box.width * scaleColumn)),
|
|
78
|
+
rows: Math.max(1, Math.round(box.height * scaleRow)),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function launchPrototypeRenderer(options: LaunchOptions): Promise<PrototypeRenderer> {
|
|
83
|
+
const puppeteer = (await import("puppeteer-core")).default;
|
|
84
|
+
const { pathToFileURL } = await import("node:url");
|
|
85
|
+
const browser = await puppeteer.launch({
|
|
86
|
+
executablePath: options.executablePath ?? chromeExecutable(),
|
|
87
|
+
headless: true,
|
|
88
|
+
args: ["--no-sandbox", "--hide-scrollbars", "--force-color-profile=srgb"],
|
|
89
|
+
defaultViewport: {
|
|
90
|
+
width: options.viewport.width,
|
|
91
|
+
height: options.viewport.height,
|
|
92
|
+
deviceScaleFactor: 2,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
// close the browser if page setup fails, so a launch error never leaks Chromium
|
|
96
|
+
const page = await browser.newPage().catch(async (error) => {
|
|
97
|
+
await browser.close();
|
|
98
|
+
throw error;
|
|
99
|
+
});
|
|
100
|
+
await page
|
|
101
|
+
.goto(pathToFileURL(options.filePath).href, { waitUntil: "networkidle0" })
|
|
102
|
+
.catch(async (error) => {
|
|
103
|
+
await browser.close();
|
|
104
|
+
throw error;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
viewport: options.viewport,
|
|
109
|
+
async screenshot() {
|
|
110
|
+
const buffer = await page.screenshot({ type: "png", encoding: "binary" });
|
|
111
|
+
return new Uint8Array(buffer as Buffer);
|
|
112
|
+
},
|
|
113
|
+
async elementAt(cssX, cssY) {
|
|
114
|
+
return (await page.evaluate(elementAtScript, cssX, cssY)) as PrototypeElement | null;
|
|
115
|
+
},
|
|
116
|
+
async highlight(selector) {
|
|
117
|
+
await page.evaluate(highlightScript, selector);
|
|
118
|
+
},
|
|
119
|
+
async close() {
|
|
120
|
+
await browser.close();
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Serialized into the page (self-contained for puppeteer): resolve the click to a component element. */
|
|
126
|
+
function elementAtScript(x: number, y: number): unknown {
|
|
127
|
+
const SEMANTIC = new Set([
|
|
128
|
+
"SECTION",
|
|
129
|
+
"ARTICLE",
|
|
130
|
+
"LI",
|
|
131
|
+
"NAV",
|
|
132
|
+
"HEADER",
|
|
133
|
+
"FOOTER",
|
|
134
|
+
"ASIDE",
|
|
135
|
+
"FORM",
|
|
136
|
+
]);
|
|
137
|
+
const isNamed = (element: Element): boolean =>
|
|
138
|
+
element.classList.length > 0 || SEMANTIC.has(element.tagName);
|
|
139
|
+
// a card wraps several children: climb to the nearest named container holding
|
|
140
|
+
// more than one child, else the nearest named ancestor, else the clicked leaf
|
|
141
|
+
const componentRoot = (start: Element): Element => {
|
|
142
|
+
let namedFallback: Element | null = null;
|
|
143
|
+
let current: Element | null = start;
|
|
144
|
+
while (current && current !== document.body) {
|
|
145
|
+
if (isNamed(current)) {
|
|
146
|
+
if (current.childElementCount > 1) return current;
|
|
147
|
+
namedFallback = namedFallback ?? current;
|
|
148
|
+
}
|
|
149
|
+
current = current.parentElement;
|
|
150
|
+
}
|
|
151
|
+
return namedFallback ?? start;
|
|
152
|
+
};
|
|
153
|
+
const selectorFor = (start: Element): string => {
|
|
154
|
+
const parts: string[] = [];
|
|
155
|
+
let current: Element | null = start;
|
|
156
|
+
while (current && current.nodeType === 1 && current !== document.documentElement) {
|
|
157
|
+
let part = current.tagName.toLowerCase();
|
|
158
|
+
if (current.id) {
|
|
159
|
+
parts.unshift(part + "#" + CSS.escape(current.id));
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
const parent: Element | null = current.parentElement;
|
|
163
|
+
if (parent) {
|
|
164
|
+
const twins = [...parent.children].filter((child) => child.tagName === current!.tagName);
|
|
165
|
+
if (twins.length > 1) part += ":nth-of-type(" + (twins.indexOf(current) + 1) + ")";
|
|
166
|
+
}
|
|
167
|
+
parts.unshift(part);
|
|
168
|
+
current = current.parentElement;
|
|
169
|
+
}
|
|
170
|
+
return parts.join(" > ");
|
|
171
|
+
};
|
|
172
|
+
const hit = document.elementFromPoint(x, y);
|
|
173
|
+
if (!(hit instanceof Element)) return null;
|
|
174
|
+
const node = componentRoot(hit);
|
|
175
|
+
const selector = selectorFor(node);
|
|
176
|
+
if (!selector) return null;
|
|
177
|
+
const text = (node.textContent || "").replace(/\s+/g, " ").trim();
|
|
178
|
+
const tag = node.tagName.toLowerCase();
|
|
179
|
+
const quote = text
|
|
180
|
+
? text.slice(0, 80)
|
|
181
|
+
: node.id
|
|
182
|
+
? tag + "#" + node.id
|
|
183
|
+
: node.classList.length
|
|
184
|
+
? tag + "." + node.classList[0]
|
|
185
|
+
: tag;
|
|
186
|
+
const rect = node.getBoundingClientRect();
|
|
187
|
+
return { selector, quote, box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function highlightScript(selector: string | null): void {
|
|
191
|
+
const ATTRIBUTE = "data-cueloop-selected";
|
|
192
|
+
const STYLE_ID = "cueloop-highlight-style";
|
|
193
|
+
document
|
|
194
|
+
.querySelectorAll("[" + ATTRIBUTE + "]")
|
|
195
|
+
.forEach((node) => node.removeAttribute(ATTRIBUTE));
|
|
196
|
+
if (!document.getElementById(STYLE_ID)) {
|
|
197
|
+
const style = document.createElement("style");
|
|
198
|
+
style.id = STYLE_ID;
|
|
199
|
+
style.textContent =
|
|
200
|
+
"[" + ATTRIBUTE + "]{outline:2px solid #f38ba8 !important;outline-offset:2px;}";
|
|
201
|
+
document.head.appendChild(style);
|
|
202
|
+
}
|
|
203
|
+
if (selector) {
|
|
204
|
+
const node = document.querySelector(selector);
|
|
205
|
+
if (node) node.setAttribute(ATTRIBUTE, "");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Standard Chrome install locations by platform; puppeteer-core ships no browser. */
|
|
210
|
+
function chromeExecutable(): string {
|
|
211
|
+
if (process.platform === "darwin") {
|
|
212
|
+
return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
|
213
|
+
}
|
|
214
|
+
if (process.platform === "win32") {
|
|
215
|
+
return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
|
|
216
|
+
}
|
|
217
|
+
return "/usr/bin/google-chrome";
|
|
218
|
+
}
|
|
@@ -197,6 +197,8 @@ export interface ReviewController {
|
|
|
197
197
|
end: number,
|
|
198
198
|
body: string,
|
|
199
199
|
): string | undefined;
|
|
200
|
+
/** Anchor a prototype comment to a DOM element by its selector. */
|
|
201
|
+
annotatePrototype(selector: string, quote: string, body: string): string | undefined;
|
|
200
202
|
/** Rewrite a stored annotation's body in place (the rail-card edit). */
|
|
201
203
|
updateAnnotation(id: string, body: string): void;
|
|
202
204
|
removeAnnotation(id: string): void;
|
|
@@ -679,6 +681,18 @@ class Controller implements ReviewController {
|
|
|
679
681
|
return wire.id;
|
|
680
682
|
}
|
|
681
683
|
|
|
684
|
+
annotatePrototype(selector: string, quote: string, body: string): string | undefined {
|
|
685
|
+
const session = this.snapshot.session;
|
|
686
|
+
if (!session) return undefined;
|
|
687
|
+
const anchor = { quote, prefix: "", suffix: "", selector };
|
|
688
|
+
const wire = { id: newAnnotationId(), kind: "comment", anchor, body };
|
|
689
|
+
const persisted = this.client!.sessionAnnotate(session.id, wire);
|
|
690
|
+
this.apply(persisted);
|
|
691
|
+
this.mirrorAnnotation(persisted, wire);
|
|
692
|
+
this.setStatus("comment added");
|
|
693
|
+
return wire.id;
|
|
694
|
+
}
|
|
695
|
+
|
|
682
696
|
updateAnnotation(id: string, body: string): void {
|
|
683
697
|
const session = this.snapshot.session;
|
|
684
698
|
if (!session) return;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
blockRuns,
|
|
4
|
+
buildDisplay,
|
|
5
|
+
displayText,
|
|
6
|
+
renderedOffsetFor,
|
|
7
|
+
workRangeForRendered,
|
|
8
|
+
} from "./view-plan";
|
|
9
|
+
|
|
10
|
+
// M0 (#232): lock the renderer's current StyleRun output and offset round-trips
|
|
11
|
+
// before the inline-tokenizer milestones (M1-M5) change blockRuns. The snapshots
|
|
12
|
+
// capture today's runs, so a later milestone's diff is explicit and reviewable.
|
|
13
|
+
// The round-trip assertions are the durable invariant: a visible word's rendered
|
|
14
|
+
// selection must always recover that word's *source* (block.text) offsets, even
|
|
15
|
+
// once inline markers are concealed - this is what keeps quote anchors exact.
|
|
16
|
+
|
|
17
|
+
const DOC = `# Heading with **bold**
|
|
18
|
+
|
|
19
|
+
A paragraph with **strong**, *em*, \`code\`, ~~strike~~ and a [label](https://example.com).
|
|
20
|
+
|
|
21
|
+
- a list item with *emphasis*
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
const blocks = buildDisplay(DOC);
|
|
25
|
+
|
|
26
|
+
describe("view-plan regression harness (M0)", () => {
|
|
27
|
+
test("blockRuns output is stable across the representative blocks", () => {
|
|
28
|
+
// Arrange / Act
|
|
29
|
+
const runsByBlock = blocks.map((block) => ({
|
|
30
|
+
type: block.type,
|
|
31
|
+
text: displayText(block),
|
|
32
|
+
runs: blockRuns(block, true),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
// Assert
|
|
36
|
+
expect(runsByBlock).toMatchSnapshot();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// A visible word's source offsets survive the rendered round-trip. Holds at M0
|
|
40
|
+
// (1:1 today) and must keep holding once M2 conceals markers and the rendered
|
|
41
|
+
// offset shifts - the recovered work range stays the word's block.text span.
|
|
42
|
+
const paragraph = blocks.find((block) => displayText(block).includes("strong"))!;
|
|
43
|
+
const paragraphRuns = blockRuns(paragraph, true);
|
|
44
|
+
const paragraphText = displayText(paragraph);
|
|
45
|
+
|
|
46
|
+
for (const word of ["paragraph", "strong", "strike", "label"]) {
|
|
47
|
+
test(`rendered selection of "${word}" recovers its block.text offsets`, () => {
|
|
48
|
+
// Arrange
|
|
49
|
+
const start = paragraphText.indexOf(word);
|
|
50
|
+
const rendered = renderedOffsetFor(paragraphRuns, start);
|
|
51
|
+
|
|
52
|
+
// Assert
|
|
53
|
+
expect(rendered).not.toBeNull();
|
|
54
|
+
expect(workRangeForRendered(paragraphRuns, rendered!, rendered! + word.length)).toEqual({
|
|
55
|
+
start,
|
|
56
|
+
end: start + word.length,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
});
|
package/src/view-plan.test.ts
CHANGED
|
@@ -6,7 +6,10 @@ import {
|
|
|
6
6
|
marksByDisplay,
|
|
7
7
|
nextWorkBlock,
|
|
8
8
|
overlayMarks,
|
|
9
|
+
renderedOffsetAtOrAfter,
|
|
10
|
+
renderedOffsetAtOrBefore,
|
|
9
11
|
renderedOffsetFor,
|
|
12
|
+
safeLinkHref,
|
|
10
13
|
spanFromRange,
|
|
11
14
|
spanKey,
|
|
12
15
|
startSpan,
|
|
@@ -125,6 +128,86 @@ describe("blockRuns + overlayMarks", () => {
|
|
|
125
128
|
});
|
|
126
129
|
});
|
|
127
130
|
|
|
131
|
+
describe("M4: link hrefs are scheme-validated for OSC 8 (#236)", () => {
|
|
132
|
+
test("trusts http(s) and mailto, drops everything else", () => {
|
|
133
|
+
// Assert
|
|
134
|
+
expect(safeLinkHref("https://example.com")).toBe("https://example.com");
|
|
135
|
+
expect(safeLinkHref("http://x")).toBe("http://x");
|
|
136
|
+
expect(safeLinkHref("mailto:a@b.c")).toBe("mailto:a@b.c");
|
|
137
|
+
expect(safeLinkHref("javascript:alert(1)")).toBeUndefined();
|
|
138
|
+
expect(safeLinkHref("./relative/path")).toBeUndefined();
|
|
139
|
+
expect(safeLinkHref(undefined)).toBeUndefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a control byte or space in the target drops the link", () => {
|
|
143
|
+
// Assert - ESC and BEL could splice into the OSC 8 escape stream
|
|
144
|
+
expect(safeLinkHref("https://x\x1b]8;;evil\x07")).toBeUndefined();
|
|
145
|
+
expect(safeLinkHref("https://x\x07")).toBeUndefined();
|
|
146
|
+
expect(safeLinkHref("https://x y")).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("blockRuns carries the link href through to the link run", () => {
|
|
150
|
+
// Act
|
|
151
|
+
const block = buildDisplay("see [docs](https://example.com) now\n")[0]!;
|
|
152
|
+
const link = blockRuns(block, true).find((run) => run.role === "link")!;
|
|
153
|
+
|
|
154
|
+
// Assert
|
|
155
|
+
expect(link.text).toBe("docs");
|
|
156
|
+
expect(link.href).toBe("https://example.com");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("M3: inline emphasis composes with word-diff (#235)", () => {
|
|
161
|
+
test("context emphasis survives; added text keeps the green diff role; markers concealed", () => {
|
|
162
|
+
// Arrange - "**this**" is unchanged (context, stays emphasized); "safe"->"sound" is the edit
|
|
163
|
+
const block = buildDisplay("keep **this** safe\n", "keep **this** sound\n")[0]!;
|
|
164
|
+
|
|
165
|
+
// Act
|
|
166
|
+
const runs = blockRuns(block, true);
|
|
167
|
+
|
|
168
|
+
// Assert - no marker text renders anywhere
|
|
169
|
+
expect(runs.every((run) => !run.text.includes("*"))).toBe(true);
|
|
170
|
+
// the unchanged emphasized word keeps its strong role (context, not diffed)
|
|
171
|
+
expect(runs.find((run) => run.text === "this")!.role).toBe("strong");
|
|
172
|
+
// the added word is green (ins wins over any emphasis), the removed one opaque
|
|
173
|
+
expect(runs.find((run) => run.text.includes("sound"))!.role).toBe("ins");
|
|
174
|
+
expect(runs.some((run) => run.role === "del" && run.start === null)).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("markers inside added text conceal while the run stays a positioned ins", () => {
|
|
178
|
+
// Arrange - an emphasized word is added into an otherwise unchanged line
|
|
179
|
+
const block = buildDisplay("keep it safe\n", "keep it **very** safe\n")[0]!;
|
|
180
|
+
|
|
181
|
+
// Act
|
|
182
|
+
const runs = blockRuns(block, true);
|
|
183
|
+
const added = runs.find((run) => run.text === "very")!;
|
|
184
|
+
|
|
185
|
+
// Assert - concealed markers, green role, and an exact work offset back into work text
|
|
186
|
+
expect(added.role).toBe("ins");
|
|
187
|
+
expect(added.start).not.toBeNull();
|
|
188
|
+
expect(block.work!.text.slice(added.start!, added.start! + "very".length)).toBe("very");
|
|
189
|
+
expect(runs.every((run) => !run.text.includes("*"))).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("an anchor over an emphasized context word round-trips to its work offsets", () => {
|
|
193
|
+
// Arrange
|
|
194
|
+
const block = buildDisplay("hold **steady** now\n", "hold **steady** later\n")[0]!;
|
|
195
|
+
const runs = blockRuns(block, true);
|
|
196
|
+
const work = block.work!.text;
|
|
197
|
+
const start = work.indexOf("steady");
|
|
198
|
+
|
|
199
|
+
// Act
|
|
200
|
+
const rendered = renderedOffsetFor(runs, start);
|
|
201
|
+
|
|
202
|
+
// Assert
|
|
203
|
+
expect(rendered).not.toBeNull();
|
|
204
|
+
expect(workRangeForRendered(runs, rendered!, rendered! + "steady".length)).toEqual({
|
|
205
|
+
start,
|
|
206
|
+
end: start + "steady".length,
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
128
211
|
describe("marksByDisplay", () => {
|
|
129
212
|
test("annotations resolve into display coordinates", () => {
|
|
130
213
|
// Arrange
|
|
@@ -217,6 +300,28 @@ describe("rendered/work offset mapping", () => {
|
|
|
217
300
|
expect(renderedOffsetFor(runs, 99)).toBeNull();
|
|
218
301
|
expect(workRangeForRendered(runs, 90, 99)).toBeNull();
|
|
219
302
|
});
|
|
303
|
+
|
|
304
|
+
test("a span starting/ending on concealed markers snaps to the visible word", () => {
|
|
305
|
+
// Arrange - the word span over "**very**" covers the markers, which have
|
|
306
|
+
// no rendered cells; the clamped lookups land on "very" itself
|
|
307
|
+
const text = "keep it **very** safe\n";
|
|
308
|
+
const runs = blockRuns(buildDisplay(text)[0]!, true);
|
|
309
|
+
const spanStart = text.indexOf("**very**");
|
|
310
|
+
const spanEnd = spanStart + "**very**".length;
|
|
311
|
+
|
|
312
|
+
// Act
|
|
313
|
+
const renderedStart = renderedOffsetAtOrAfter(runs, spanStart)!;
|
|
314
|
+
const renderedEnd = renderedOffsetAtOrBefore(runs, spanEnd - 1)!;
|
|
315
|
+
|
|
316
|
+
// Assert
|
|
317
|
+
const renderedText = runs.map((run) => run.text).join("");
|
|
318
|
+
expect(renderedText.slice(renderedStart, renderedEnd + 1)).toBe("very");
|
|
319
|
+
// exact hits behave like renderedOffsetFor
|
|
320
|
+
expect(renderedOffsetAtOrAfter(runs, 0)).toBe(0);
|
|
321
|
+
expect(renderedOffsetAtOrBefore(runs, 0)).toBe(0);
|
|
322
|
+
// nothing positioned beyond the text end
|
|
323
|
+
expect(renderedOffsetAtOrAfter(runs, 99)).toBeNull();
|
|
324
|
+
});
|
|
220
325
|
});
|
|
221
326
|
|
|
222
327
|
describe("span mode", () => {
|
package/src/view-plan.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
+
inlineRuns,
|
|
11
12
|
isAddressed,
|
|
12
13
|
lcsDiff,
|
|
13
14
|
parseBlocks,
|
|
@@ -141,7 +142,18 @@ function numberOrderedListItems(display: DisplayBlock[]): void {
|
|
|
141
142
|
|
|
142
143
|
// ── marks (annotation highlights + keyboard span) ──
|
|
143
144
|
|
|
144
|
-
export type RunRole =
|
|
145
|
+
export type RunRole =
|
|
146
|
+
| "plain"
|
|
147
|
+
| "ins"
|
|
148
|
+
| "del"
|
|
149
|
+
| "mark-comment"
|
|
150
|
+
| "kspan"
|
|
151
|
+
| "mark-focus"
|
|
152
|
+
| "strong"
|
|
153
|
+
| "em"
|
|
154
|
+
| "code"
|
|
155
|
+
| "strike"
|
|
156
|
+
| "link";
|
|
145
157
|
|
|
146
158
|
export interface StyleRun {
|
|
147
159
|
text: string;
|
|
@@ -149,6 +161,8 @@ export interface StyleRun {
|
|
|
149
161
|
/** Offset of this run in the block's working text; null for del runs. */
|
|
150
162
|
start: number | null;
|
|
151
163
|
annotationId?: string;
|
|
164
|
+
/** Link target for `link` runs. */
|
|
165
|
+
href?: string;
|
|
152
166
|
}
|
|
153
167
|
|
|
154
168
|
export interface Mark {
|
|
@@ -195,6 +209,7 @@ export function marksByDisplay(
|
|
|
195
209
|
|
|
196
210
|
/** Base runs for a display block: plain text, or word-diff for mod blocks. */
|
|
197
211
|
export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
|
|
212
|
+
const literal = block.kind === "code" || block.kind === "hr";
|
|
198
213
|
if (block.type === "mod" && markup) {
|
|
199
214
|
const changes = wordLevelChanges(block.base!.text, block.work!.text);
|
|
200
215
|
const runs: StyleRun[] = [];
|
|
@@ -202,18 +217,57 @@ export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
|
|
|
202
217
|
for (const change of changes) {
|
|
203
218
|
if (change.kind === "removed") {
|
|
204
219
|
runs.push({ text: change.text, role: "del", start: null });
|
|
205
|
-
|
|
206
|
-
runs.push({
|
|
207
|
-
text: change.text,
|
|
208
|
-
role: change.kind === "added" ? "ins" : "plain",
|
|
209
|
-
start: workOffset,
|
|
210
|
-
});
|
|
211
|
-
workOffset += change.text.length;
|
|
220
|
+
continue;
|
|
212
221
|
}
|
|
222
|
+
// A code/rule block stays literal; prose segments inline-tokenize so their
|
|
223
|
+
// markers conceal while their work offsets stay intact. Added text keeps
|
|
224
|
+
// the green diff role (it wins over emphasis); context text shows emphasis.
|
|
225
|
+
const segment = literal
|
|
226
|
+
? [{ text: change.text, role: "plain" as const, start: workOffset }]
|
|
227
|
+
: inlineStyleRuns(change.text, workOffset);
|
|
228
|
+
for (const run of segment) {
|
|
229
|
+
runs.push(change.kind === "added" ? { ...run, role: "ins" } : run);
|
|
230
|
+
}
|
|
231
|
+
workOffset += change.text.length;
|
|
213
232
|
}
|
|
214
233
|
return runs;
|
|
215
234
|
}
|
|
216
|
-
|
|
235
|
+
const text = displayText(block);
|
|
236
|
+
// Prose carries inline markup; code fences and rules are literal. Concealed
|
|
237
|
+
// markers are dropped, so every rendered cell still maps to a work offset.
|
|
238
|
+
if (markup && !literal) {
|
|
239
|
+
return inlineStyleRuns(text, 0);
|
|
240
|
+
}
|
|
241
|
+
return [{ text, role: "plain", start: 0 }];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** A link href safe to hand a terminal as an OSC 8 target, or undefined. Only
|
|
245
|
+
* http(s) and mailto are trusted; a scheme like javascript: is dropped. The
|
|
246
|
+
* target must stay printable ASCII: a control byte (ESC, BEL) in a reviewed
|
|
247
|
+
* document could otherwise splice into the terminal escape stream. */
|
|
248
|
+
export function safeLinkHref(href: string | undefined): string | undefined {
|
|
249
|
+
if (href === undefined) return undefined;
|
|
250
|
+
if (!/^(https?:|mailto:)/i.test(href)) return undefined;
|
|
251
|
+
return /^[\x21-\x7e]+$/.test(href) ? href : undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Map the schema inline tokens to positioned StyleRuns, dropping the concealed
|
|
256
|
+
* markers. `base` is the run's offset within the block's working text (0 for a
|
|
257
|
+
* whole prose block; a segment's start for a word-diff piece).
|
|
258
|
+
*/
|
|
259
|
+
export function inlineStyleRuns(text: string, base: number): StyleRun[] {
|
|
260
|
+
const runs: StyleRun[] = [];
|
|
261
|
+
for (const run of inlineRuns(text)) {
|
|
262
|
+
if (run.role === "marker" || run.start === null) continue;
|
|
263
|
+
runs.push({
|
|
264
|
+
text: run.text,
|
|
265
|
+
role: run.role === "text" ? "plain" : run.role,
|
|
266
|
+
start: base + run.start,
|
|
267
|
+
...(run.href !== undefined ? { href: run.href } : {}),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return runs;
|
|
217
271
|
}
|
|
218
272
|
|
|
219
273
|
/** Split runs at mark boundaries; marks only bind to runs with offsets. */
|
|
@@ -274,6 +328,39 @@ export function renderedOffsetFor(runs: StyleRun[], workOffset: number): number
|
|
|
274
328
|
return null;
|
|
275
329
|
}
|
|
276
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Rendered offset of the first positioned character at or after `workOffset`.
|
|
333
|
+
* A word span may start on a concealed marker (the `**` of an emphasized
|
|
334
|
+
* word), which has no rendered cell; the span visuals snap to the first
|
|
335
|
+
* character that does. Positioned runs appear in ascending work order.
|
|
336
|
+
*/
|
|
337
|
+
export function renderedOffsetAtOrAfter(runs: StyleRun[], workOffset: number): number | null {
|
|
338
|
+
let rendered = 0;
|
|
339
|
+
for (const run of runs) {
|
|
340
|
+
if (run.start !== null && run.start + run.text.length > workOffset) {
|
|
341
|
+
return rendered + Math.max(0, workOffset - run.start);
|
|
342
|
+
}
|
|
343
|
+
rendered += run.text.length;
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Rendered offset of the last positioned character at or before `workOffset` -
|
|
350
|
+
* the closing-marker counterpart of `renderedOffsetAtOrAfter`.
|
|
351
|
+
*/
|
|
352
|
+
export function renderedOffsetAtOrBefore(runs: StyleRun[], workOffset: number): number | null {
|
|
353
|
+
let rendered = 0;
|
|
354
|
+
let best: number | null = null;
|
|
355
|
+
for (const run of runs) {
|
|
356
|
+
if (run.start !== null && run.start <= workOffset) {
|
|
357
|
+
best = rendered + Math.min(workOffset - run.start, run.text.length - 1);
|
|
358
|
+
}
|
|
359
|
+
rendered += run.text.length;
|
|
360
|
+
}
|
|
361
|
+
return best;
|
|
362
|
+
}
|
|
363
|
+
|
|
277
364
|
/**
|
|
278
365
|
* Work-text range covered by a rendered-text selection [start, end). Del runs
|
|
279
366
|
* and other unpositioned text inside the selection contribute nothing; the
|