's text nodes
+ *
+ * Strategy:
+ * Start boundaries: search forward from offset for first text node
+ * End boundaries: search backward from offset for last text node
+ */
+function normalizeRangeBoundary(node, offset, isEnd = false) {
+ // Already a text node - return as-is
+ if (node.nodeType === Node.TEXT_NODE) {
+ return { node, offset };
+ }
+
+ // Only handle element nodes
+ if (node.nodeType !== Node.ELEMENT_NODE) {
+ return null;
+ }
+
+ try {
+ if (isEnd) {
+ // End boundary: find the last text node before position 'offset'
+ // This means looking backward from offset
+ return findTextNodeBackward(node, offset);
+ } else {
+ // Start boundary: find the first text node at or after position 'offset'
+ // This means looking forward from offset
+ return findTextNodeForward(node, offset);
+ }
+ } catch (e) {
+ return null;
+ }
+}
+
+/**
+ * Finds the first text node at or after the given offset position in an element.
+ * Used for start boundaries.
+ */
+function findTextNodeForward(element, offset) {
+ // First, try to find text within children starting from offset
+ for (let i = offset; i < element.childNodes.length; i++) {
+ const child = element.childNodes[i];
+
+ if (child.nodeType === Node.TEXT_NODE) {
+ return { node: child, offset: 0 };
+ }
+
+ if (child.nodeType === Node.ELEMENT_NODE) {
+ const textNode = findFirstTextNode(child);
+ if (textNode) return textNode;
+ }
+ }
+
+ // No text node found among this element's children
+ return null;
+}
+
+/**
+ * Finds the last text node before the given offset position in an element.
+ * Used for end boundaries.
+ */
+function findTextNodeBackward(element, offset) {
+ // If offset is 0, need to look before this element entirely
+ if (offset === 0) {
+ return findPrecedingTextNode(element);
+ }
+
+ // Search backward through children before offset
+ for (let i = offset - 1; i >= 0; i--) {
+ const child = element.childNodes[i];
+
+ if (child.nodeType === Node.TEXT_NODE) {
+ return { node: child, offset: child.nodeValue.length };
+ }
+
+ if (child.nodeType === Node.ELEMENT_NODE) {
+ const textNode = findLastTextNode(child);
+ if (textNode) return textNode;
+ }
+ }
+
+ // No text node found among this element's children
+ return null;
+}
+
+/**
+ * Finds the first text node within an element.
+ */
+function findFirstTextNode(element) {
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
+ const first = walker.nextNode();
+ return first ? { node: first, offset: 0 } : null;
+}
+
+/**
+ * Finds the last text node within an element.
+ */
+function findLastTextNode(element) {
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
+ let last = null;
+ while (walker.nextNode()) {
+ last = walker.currentNode;
+ }
+ return last ? { node: last, offset: last.nodeValue.length } : null;
+}
+
+/**
+ * Finds the text node that precedes a given element in document order.
+ * Used for end boundaries that point to the start of an element.
+ */
+function findPrecedingTextNode(element) {
+ // Check previous sibling and its descendants
+ let sibling = element.previousSibling;
+ while (sibling) {
+ if (sibling.nodeType === Node.TEXT_NODE && sibling.nodeValue.length > 0) {
+ return { node: sibling, offset: sibling.nodeValue.length };
+ }
+ if (sibling.nodeType === Node.ELEMENT_NODE) {
+ const textNode = findLastTextNode(sibling);
+ if (textNode) return textNode;
+ }
+ sibling = sibling.previousSibling;
+ }
+
+ // Check parent's previous siblings and ancestors
+ let parent = element.parentNode;
+ while (parent && parent.nodeType === Node.ELEMENT_NODE) {
+ let parentSibling = parent.previousSibling;
+ while (parentSibling) {
+ if (parentSibling.nodeType === Node.TEXT_NODE && parentSibling.nodeValue.length > 0) {
+ return { node: parentSibling, offset: parentSibling.nodeValue.length };
+ }
+ if (parentSibling.nodeType === Node.ELEMENT_NODE) {
+ const textNode = findLastTextNode(parentSibling);
+ if (textNode) return textNode;
+ }
+ parentSibling = parentSibling.previousSibling;
+ }
+ parent = parent.parentNode;
+ }
+
+ return null;
+}
+
export function getSelectionText(range) {
+ const start = normalizeRangeBoundary(range.startContainer, range.startOffset, false);
+ const end = normalizeRangeBoundary(range.endContainer, range.endOffset, true);
+
+ // If we can't normalize the boundaries, fall back to empty string
+ if (!start || !end) {
+ return "";
+ }
+
const walker = document.createTreeWalker(range.commonAncestorContainer, NodeFilter.SHOW_TEXT);
- walker.currentNode = range.startContainer;
+ walker.currentNode = start.node;
let text = "";
- if (range.startContainer === range.endContainer) {
- return range.startContainer.nodeValue.substring(range.startOffset, range.endOffset);
+ if (start.node === end.node) {
+ return start.node.nodeValue.substring(start.offset, end.offset);
}
- text += range.startContainer.nodeValue.substring(range.startOffset);
+ text += start.node.nodeValue.substring(start.offset);
while (walker.nextNode()) {
const node = walker.currentNode;
- if (node === range.endContainer) {
- text += node.nodeValue.substring(0, range.endOffset);
+ if (node === end.node) {
+ text += node.nodeValue.substring(0, end.offset);
break;
}
text += node.nodeValue;
@@ -150,9 +312,32 @@ export function createSelectionExpander(container) {
const range = selection.getRangeAt(0);
+ // Normalize range boundaries to handle triple-click selections with element nodes
+ let start = normalizeRangeBoundary(range.startContainer, range.startOffset, false);
+ let end = normalizeRangeBoundary(range.endContainer, range.endOffset, true);
+
+ // Fallback: if normalization fails but the original boundaries are text nodes, use them directly
+ if (!start && range.startContainer.nodeType === Node.TEXT_NODE) {
+ start = { node: range.startContainer, offset: range.startOffset };
+ }
+ if (!end && range.endContainer.nodeType === Node.TEXT_NODE) {
+ end = { node: range.endContainer, offset: range.endOffset };
+ }
+
+ // If we still can't get valid boundaries, return empty result
+ if (!start || !end) {
+ return function () {
+ return {
+ prefix: "",
+ selection: "",
+ suffix: "",
+ };
+ };
+ }
+
const selectionText = getSelectionText(range);
- const prefixExpander = new TextExpander(range.startContainer, range.startOffset, false, container);
- const suffixExpander = new TextExpander(range.endContainer, range.endOffset, true, container);
+ const prefixExpander = new TextExpander(start.node, start.offset, false, container);
+ const suffixExpander = new TextExpander(end.node, end.offset, true, container);
return function () {
prefixExpander.expand(10);
diff --git a/znai-reactjs/src/doc-elements/text-selection/textSelectionBuilder.test.js b/znai-reactjs/src/doc-elements/text-selection/textSelectionBuilder.test.js
index 92304b7b7..19f48bf6b 100644
--- a/znai-reactjs/src/doc-elements/text-selection/textSelectionBuilder.test.js
+++ b/znai-reactjs/src/doc-elements/text-selection/textSelectionBuilder.test.js
@@ -20,113 +20,245 @@ import { selectText, setupDOM } from "./selectionTestUtils.js";
describe("textSelectionBuilder", () => {
describe("findPrefixSuffixAndMatch", () => {
- it("should handle unique text with minimal context when multiple entries are present", () => {
- const { container } = setupDOM(`
-
-
This is a sample text with multiple words.
-
This text contains repeated phrases.
-
The sample text is useful for testing.
-
- `);
+ describe("basic text node selections", () => {
+ it("should handle unique text with minimal context when multiple entries are present", () => {
+ const { container } = setupDOM(`
+
+
This is a sample text with multiple words.
+
This text contains repeated phrases.
+
The sample text is useful for testing.
+
+ `);
- const lastP = document.getElementById("content");
- const textNode = lastP.firstChild;
+ const textNode = document.getElementById("content").firstChild;
+ selectText(textNode, 4, textNode, 10);
- selectText(textNode, 4, textNode, 10);
+ const result = findPrefixSuffixAndMatch(container);
- const result = findPrefixSuffixAndMatch(container);
+ expect(result.selection).toBe("sample");
+ expect(result.prefix).toBe(" The ");
+ expect(result.suffix).toBe(" text is u");
+ });
- expect(result.selection).toBe("sample");
- expect(result.prefix).toBe(" The ");
- expect(result.suffix).toBe(" text is u");
- });
+ it("should handle selection across multiple nodes", () => {
+ const { container } = setupDOM(`
+
+
This is a sample text with multiple words.
+
This text contains repeated phrases.
+
The sample text is useful for testing.
+
+ `);
- it("should handle selection across multiple nodes", () => {
- const { container } = setupDOM(`
-
-
This is a sample text with multiple words.
-
This text contains repeated phrases.
-
The sample text is useful for testing.
-
- `);
+ const paragraphs = container.querySelectorAll("p");
+ selectText(paragraphs[0].firstChild, 37, paragraphs[1].firstChild, 4);
- const paragraphs = container.querySelectorAll("p");
- const firstTextNode = paragraphs[0].firstChild;
- const secondTextNode = paragraphs[1].firstChild;
+ const result = findPrefixSuffixAndMatch(container);
- selectText(firstTextNode, 37, secondTextNode, 4);
+ expect(result.selection).toBe("ords.\n This");
+ expect(result.prefix).toBe("multiple w");
+ expect(result.suffix).toBe(" text cont");
+ });
- const result = findPrefixSuffixAndMatch(container);
+ it("should handle selection at the beginning of container", () => {
+ const { container } = setupDOM(`
+ This is a sample text with multiple words.
+
This text contains repeated phrases.
+
The sample text is useful for testing.
+ `);
- expect(result.selection).toBe("ords.\n This");
- expect(result.prefix).toBe("multiple w");
- expect(result.suffix).toBe(" text cont");
- });
+ const boundaryContainer = document.getElementById("boundary");
+ const textNode = container.querySelector("p").firstChild;
+ selectText(textNode, 0, textNode, 7);
+
+ const result = findPrefixSuffixAndMatch(boundaryContainer);
+
+ expect(result.selection).toBe("This is");
+ expect(result.prefix).toBe("");
+ expect(result.suffix).toBe(" a sample ");
+ });
+
+ it("should handle selection at the end of container", () => {
+ const { container } = setupDOM(`
+
+
This is a sample text with multiple words.
+
This text contains repeated phrases.
+
The sample text is useful for testing.
+ `);
+
+ const boundaryContainer = document.getElementById("boundary");
+ const textNode = container.querySelectorAll("p")[2].firstChild;
+ const text = textNode.textContent;
+ selectText(textNode, text.length - 8, textNode, text.length);
- it("should handle selection at the beginning of container", () => {
- const { container } = setupDOM(`
- This is a sample text with multiple words.
-
This text contains repeated phrases.
-
The sample text is useful for testing.
- `);
+ const result = findPrefixSuffixAndMatch(boundaryContainer);
- const boundaryContainer = document.getElementById("boundary");
- const firstP = container.querySelector("p");
- const textNode = firstP.firstChild;
+ expect(result.selection).toBe("testing.");
+ expect(result.prefix).toBe("seful for ");
+ expect(result.suffix).toBe("");
+ });
- selectText(textNode, 0, textNode, 7);
+ it("should handle selection across code snippet span elements", () => {
+ const { container } = setupDOM(`
+
+
+ function calculateSum(a, b) {
+ return a + b;
+ }
+ const result = calculateSum(5, 3);
+
+
+ `);
- const result = findPrefixSuffixAndMatch(boundaryContainer);
+ const functionSpan = container.querySelector(".keyword");
+ const parameterSpan = container.querySelector(".parameter");
+ selectText(functionSpan.firstChild, 0, parameterSpan.firstChild, 1);
- expect(result.selection).toBe("This is");
- expect(result.prefix).toBe("");
- expect(result.suffix).toBe(" a sample ");
+ const result = findPrefixSuffixAndMatch(container);
+
+ expect(result.selection).toBe("function calculateSum(a");
+ expect(result.prefix).toBe(" ");
+ expect(result.suffix).toBe(", b) {\n ");
+ });
});
- it("should handle selection at the end of container", () => {
- const { container } = setupDOM(`
-
-
This is a sample text with multiple words.
-
This text contains repeated phrases.
-
The sample text is useful for testing.
- `);
+ describe("triple-click selections with element node boundaries", () => {
+ it("should handle triple-click on middle paragraph", () => {
+ const { container } = setupDOM(`
+
+
First paragraph with some text.
+
Second paragraph to triple click.
+
Third paragraph with more text.
+
+ `);
+
+ const targetP = document.getElementById("target");
+ selectText(targetP, 0, targetP, targetP.childNodes.length);
+
+ const result = findPrefixSuffixAndMatch(container);
+
+ expect(result.selection).toBe("Second paragraph to triple click.");
+ expect(result.prefix.length).toBeGreaterThan(0);
+ expect(result.suffix.length).toBeGreaterThan(0);
+ });
+
+ const bulletTestCases = [
+ { position: "first", id: "first-li", text: "First bullet point item.", expectPrefix: false },
+ { position: "second", id: "second-li", text: "Second bullet point item.", expectPrefix: true },
+ { position: "last", id: "last-li", text: "Third bullet point item.", expectPrefix: true },
+ ];
- const boundaryContainer = document.getElementById("boundary");
- const lastP = container.querySelectorAll("p")[2];
- const textNode = lastP.firstChild;
- const text = textNode.textContent;
+ bulletTestCases.forEach(({ position, id, text, expectPrefix }) => {
+ it(`should handle triple-click on ${position} bullet point`, () => {
+ const { container } = setupDOM(`
+
+
+ - First bullet point item.
+ - Second bullet point item.
+ - Third bullet point item.
+
+
+ `);
- selectText(textNode, text.length - 8, textNode, text.length);
+ const ul = document.getElementById("list");
+ const targetLi = document.getElementById(id);
+ const childIndex = Array.from(ul.childNodes).indexOf(targetLi);
+ selectText(ul, childIndex, ul, childIndex + 1);
- const result = findPrefixSuffixAndMatch(boundaryContainer);
+ const result = findPrefixSuffixAndMatch(container);
- expect(result.selection).toBe("testing.");
- expect(result.prefix).toBe("seful for ");
- expect(result.suffix).toBe("");
+ expect(result.selection).toBe(text);
+ if (expectPrefix) {
+ expect(result.prefix.length).toBeGreaterThan(0);
+ }
+ expect(result.suffix.length).toBeGreaterThan(0);
+ });
+ });
+
+ it("should not include following paragraphs when selecting a bullet point", () => {
+ const { container } = setupDOM(`
+
+
+ - First bullet point item.
+ - Second bullet point item.
+ - Third bullet point item.
+
+
Following paragraph that should not be included.
+
+ `);
+
+ const ul = container.querySelector("ul");
+ const targetLi = document.getElementById("target-li");
+ const childIndex = Array.from(ul.childNodes).indexOf(targetLi);
+ selectText(ul, childIndex, ul, childIndex + 1);
+
+ const result = findPrefixSuffixAndMatch(container);
+
+ expect(result.selection).toBe("Second bullet point item.");
+ expect(result.selection).not.toContain("Following paragraph");
+ });
});
- it("should handle selection across code snippet span elements", () => {
- const { container } = setupDOM(`
-
-
- function calculateSum(a, b) {
- return a + b;
- }
- const result = calculateSum(5, 3);
-
-
- `);
+ describe("edge cases with complex boundary scenarios", () => {
+ it("should handle end boundary outside the list", () => {
+ const { container } = setupDOM(`
+
+
+ - First bullet point item.
+ - Second bullet point item.
+ - Third bullet point item.
+
+
+ `);
+
+ const parent = document.getElementById("parent");
+ const ul = document.getElementById("list");
+ const lastLi = document.getElementById("last-li");
+ const ulChildIndex = Array.from(parent.childNodes).indexOf(ul);
+ selectText(lastLi, 0, parent, ulChildIndex + 1);
+
+ const result = findPrefixSuffixAndMatch(container);
+
+ expect(result.selection.trim()).toBe("Third bullet point item.");
+ expect(result.prefix.length).toBeGreaterThan(0);
+ });
+
+ it("should handle end boundary at offset 0 of next element", () => {
+ const { container } = setupDOM(`
+
+
+ - Last bullet point item.
+
+
Next paragraph.
+
+ `);
+
+ const lastLi = document.getElementById("last-li");
+ const nextBlock = document.getElementById("next-block");
+ selectText(lastLi.firstChild, 0, nextBlock, 0);
+
+ const result = findPrefixSuffixAndMatch(container);
+
+ expect(result.selection.trim()).toBe("Last bullet point item.");
+ expect(result.suffix.length).toBeGreaterThan(0);
+ });
- const functionSpan = container.querySelector(".keyword");
- const parameterSpan = container.querySelector(".parameter");
+ it("should handle triple-click on paragraph where end is next paragraph at offset 0", () => {
+ const { container } = setupDOM(`
+
+
First paragraph of text.
+
Second paragraph of text.
+
+ `);
- selectText(functionSpan.firstChild, 0, parameterSpan.firstChild, 1);
+ const firstPara = document.getElementById("first-para");
+ const secondPara = document.getElementById("second-para");
+ selectText(firstPara.firstChild, 0, secondPara, 0);
- const result = findPrefixSuffixAndMatch(container);
+ const result = findPrefixSuffixAndMatch(container);
- expect(result.selection).toBe("function calculateSum(a");
- expect(result.prefix).toBe(" ");
- expect(result.suffix).toBe(", b) {\n ");
+ expect(result.selection.trim()).toBe("First paragraph of text.");
+ expect(result.suffix.length).toBeGreaterThan(0);
+ });
});
});
});
diff --git a/znai-tests/src/test/groovy/scenarios/textSelection.groovy b/znai-tests/src/test/groovy/scenarios/textSelection.groovy
new file mode 100644
index 000000000..90b3dd56f
--- /dev/null
+++ b/znai-tests/src/test/groovy/scenarios/textSelection.groovy
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2025 znai maintainers
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package scenarios
+
+import docgen.ScaffoldUtils
+import org.openqa.selenium.WebElement
+import org.openqa.selenium.interactions.Actions
+import org.testingisdocumenting.webtau.WebTauGroovyDsl
+
+import static org.testingisdocumenting.webtau.WebTauGroovyDsl.*
+import static pages.Pages.*
+
+def scaffoldServerUrl = cache.value('text-selection-scaffold-url')
+
+scenario('scaffold docs for text selection test') {
+ scaffoldServerUrl.set(
+ ScaffoldUtils.scaffoldAndServe('text-selection-scaffold').baseUrl)
+}
+
+scenario('open docs in browser') {
+ browser.open(scaffoldServerUrl.get() + '/my-product')
+}
+
+scenario('triple-click bullet point and generate link') {
+ standardView.gettingStartedTocItem.click()
+ standardView.pageTitle.waitTo == "Getting Started"
+
+ def bulletElement = $("ul li").get(3)
+ bulletElement.waitTo visible
+
+ Actions actions = new Actions(browser.driver);
+
+ // TODO expose triple click and selenium actions via webtau
+ def webElement = bulletElement.findElement()
+ actions.click(webElement).click(webElement).click(webElement).perform()
+
+ def menu = $(".znai-text-selection-menu")
+ menu.waitTo visible
+
+ def generateLinkItem = $(".znai-text-selection-menu-item").get("Generate Link")
+ generateLinkItem.waitTo visible
+ generateLinkItem.click()
+
+ def questionInput = $(".znai-text-selection-question-input")
+ questionInput.waitTo visible
+
+ questionInput.sendKeys("test")
+
+ def sendButton = $(".znai-text-selection-send-button")
+ sendButton.click()
+
+ // TODO expose this via webtau
+ def highlightUrl = browser.driver.executeAsyncScript("""
+ var callback = arguments[arguments.length - 1];
+ navigator.clipboard.readText().then(function(text) {
+ callback(text);
+ }).catch(function(err) {
+ callback("Error reading clipboard: " + err);
+ });
+ """)
+
+ browser.reopen(highlightUrl)
+
+ def highlightedElement = $(".znai-highlight")
+ highlightedElement.waitTo contain("Page Sections")
+}