diff --git a/skills/htmlit/client/annotate.js b/skills/htmlit/client/annotate.js
index aafd553..f16eb2f 100644
--- a/skills/htmlit/client/annotate.js
+++ b/skills/htmlit/client/annotate.js
@@ -213,7 +213,9 @@ export function wrapRange(range, id, cls, attr, title) {
if (!root) return;
var segs = [];
if (range.startContainer === range.endContainer && range.startContainer.nodeType === 3) {
- segs.push({ node: range.startContainer, from: range.startOffset, to: range.endOffset });
+ if (/\S/.test(range.startContainer.nodeValue.slice(range.startOffset, range.endOffset))) {
+ segs.push({ node: range.startContainer, from: range.startOffset, to: range.endOffset });
+ }
} else {
var w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
var n;
@@ -224,7 +226,7 @@ export function wrapRange(range, id, cls, attr, title) {
if (!intersects) continue;
var from = (n === range.startContainer) ? range.startOffset : 0;
var to = (n === range.endContainer) ? range.endOffset : n.nodeValue.length;
- if (to > from) segs.push({ node: n, from: from, to: to });
+ if (to > from && /\S/.test(n.nodeValue.slice(from, to))) segs.push({ node: n, from: from, to: to });
}
}
// wrap last-to-first so earlier segments' offsets stay valid as nodes split
diff --git a/tests/client/fixtures/selector-collision.html b/tests/client/fixtures/selector-collision.html
index 98eb20d..42a5422 100644
--- a/tests/client/fixtures/selector-collision.html
+++ b/tests/client/fixtures/selector-collision.html
@@ -3,6 +3,10 @@
Selector collision
+
diff --git a/tests/client/selector-anchor.test.mjs b/tests/client/selector-anchor.test.mjs
index c12270c..1f46742 100644
--- a/tests/client/selector-anchor.test.mjs
+++ b/tests/client/selector-anchor.test.mjs
@@ -95,3 +95,58 @@ test("selecting a nested inline element opens the annotation menu despite a deco
if (review) await review.stop();
}
});
+
+test("comment anchors do not turn structural whitespace into grid children", async (t) => {
+ if (state.unavailable) return t.skip(state.unavailable);
+ let review, page;
+ try {
+ review = await startReview(FIXTURE);
+ page = await state.browser.newPage();
+ await page.setViewport({ width: 1440, height: 1000 });
+ await page.goto(review.url, { waitUntil: "domcontentloaded" });
+
+ const range = await page.evaluate(() => {
+ const grid = document.querySelector(".grid");
+ const card = grid.querySelector(".card");
+ const selected = card.textContent;
+ const start = grid.textContent.indexOf(selected);
+ return {
+ container: ".grid",
+ start,
+ end: start + selected.length,
+ text: selected.replace(/\s+/g, " ").trim(),
+ };
+ });
+ assert.notEqual(range.start, -1);
+
+ await fetch(`${review.base}/api/${review.key}/highlights`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ highlights: [{
+ id: "cm-grid", kind: "comment", prompt: "Explain this card", comments: ["Explain this card"],
+ text: "", range,
+ }] }),
+ });
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await page.waitForFunction(() => document.querySelector('mark.htmlit-cm[data-htmlit-cm="cm-grid"]'), { timeout: 20000 });
+
+ const result = await page.evaluate(() => {
+ const grid = document.querySelector(".grid");
+ const marks = [...document.querySelectorAll('mark.htmlit-cm[data-htmlit-cm="cm-grid"]')];
+ return {
+ markCount: marks.length,
+ whitespaceMarks: marks.filter((mark) => !mark.textContent.trim()).length,
+ directGridMarks: grid.querySelectorAll(':scope > mark.htmlit-cm[data-htmlit-cm="cm-grid"]').length,
+ gridChildren: grid.children.length,
+ };
+ });
+
+ assert.ok(result.markCount > 0, "the selected text should still have comment anchors");
+ assert.equal(result.whitespaceMarks, 0, "structural whitespace should not be wrapped in marks");
+ assert.equal(result.directGridMarks, 0, "the grid should not gain a synthetic mark child");
+ assert.equal(result.gridChildren, 1, "the grid should keep only its artifact card");
+ } finally {
+ if (page) await page.close();
+ if (review) await review.stop();
+ }
+});