From de733187e35a7fc297b268977b4370103395c56b Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sun, 16 Nov 2025 09:32:51 -0500 Subject: [PATCH 1/7] in progress --- .../text-selection/TextSelectionMenu.tsx | 10 + .../text-selection/selectionUtils.js | 171 +++++++++++++++++- .../textSelectionBuilder.test.js | 112 ++++++++++++ 3 files changed, 285 insertions(+), 8 deletions(-) diff --git a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx index bc89a54ae..6fc1f69b2 100644 --- a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx @@ -219,6 +219,16 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle question: comment, context: panelData?.context || "", }); + + // Check if clipboard API is available + if (!navigator.clipboard || !navigator.clipboard.writeText) { + setNotification({ + type: "error", + message: "Clipboard API not available. Link: " + pageUrl, + }); + return; + } + try { await navigator.clipboard.writeText(pageUrl); setNotification({ type: "success", message: "Link is generated and copied to clipboard" }); diff --git a/znai-reactjs/src/doc-elements/text-selection/selectionUtils.js b/znai-reactjs/src/doc-elements/text-selection/selectionUtils.js index 36bf1ec42..02c4390b4 100644 --- a/znai-reactjs/src/doc-elements/text-selection/selectionUtils.js +++ b/znai-reactjs/src/doc-elements/text-selection/selectionUtils.js @@ -14,23 +14,145 @@ * limitations under the License. */ +/** + * Normalizes a range boundary point to always be a text node. + * When triple-clicking, browsers often create ranges with element nodes as containers. + * This function converts such boundaries to text node boundaries. + * + * @param {Node} node - The container node (text or element) + * @param {number} offset - The offset within the container + * @param {boolean} isEnd - Whether this is an end boundary (affects navigation for element nodes) + * @returns {{node: Node, offset: number}|null} Normalized boundary with text node, or null if no text node found + */ +function normalizeRangeBoundary(node, offset, isEnd = false) { + // If already a text node, return as-is + if (node.nodeType === Node.TEXT_NODE) { + console.debug("normalizeRangeBoundary: already text node", { isEnd }); + return { node, offset }; + } + + // Element node - the offset refers to child node index + if (node.nodeType === Node.ELEMENT_NODE) { + console.debug("normalizeRangeBoundary: element node", { + nodeName: node.nodeName, + offset, + isEnd, + childNodesLength: node.childNodes.length, + }); + + // For start boundary at offset 0, get first text node in the element + if (offset === 0 && !isEnd) { + const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT); + const firstTextNode = walker.nextNode(); + console.debug("normalizeRangeBoundary: start at 0", { found: !!firstTextNode }); + if (!firstTextNode) { + return null; + } + return { node: firstTextNode, offset: 0 }; + } + + // For end boundary at childNodes.length, get last text node in the element + if (isEnd && offset === node.childNodes.length) { + const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT); + let lastTextNode = null; + while (walker.nextNode()) { + lastTextNode = walker.currentNode; + } + console.debug("normalizeRangeBoundary: end at childNodes.length", { found: !!lastTextNode }); + if (!lastTextNode) { + return null; + } + return { node: lastTextNode, offset: lastTextNode.nodeValue.length }; + } + + // For other offsets, the offset refers to a position among direct children + // offset N means: for start, before child N; for end, after child N-1 + let targetChild; + if (isEnd) { + // For end boundary, we want the last text node before this offset + // offset N means after child N-1 + targetChild = node.childNodes[offset - 1]; + } else { + // For start boundary, we want the first text node at/after this offset + // offset N means before child N + targetChild = node.childNodes[offset]; + } + + console.debug("normalizeRangeBoundary: other offset", { + targetChild: targetChild?.nodeName, + targetChildType: targetChild?.nodeType, + }); + + if (!targetChild) { + console.debug("normalizeRangeBoundary: no target child found"); + return null; + } + + // If the target child is already a text node, use it directly + if (targetChild.nodeType === Node.TEXT_NODE) { + console.debug("normalizeRangeBoundary: target child is text node"); + if (isEnd) { + return { node: targetChild, offset: targetChild.nodeValue.length }; + } else { + return { node: targetChild, offset: 0 }; + } + } + + // Otherwise, find the appropriate text node within this child element + const walker = document.createTreeWalker(targetChild, NodeFilter.SHOW_TEXT); + + if (isEnd) { + // Get last text node in this child + let lastTextNode = null; + while (walker.nextNode()) { + lastTextNode = walker.currentNode; + } + console.debug("normalizeRangeBoundary: found last text in child", { found: !!lastTextNode }); + if (!lastTextNode) { + return null; + } + return { node: lastTextNode, offset: lastTextNode.nodeValue.length }; + } else { + // Get first text node in this child + const firstTextNode = walker.nextNode(); + console.debug("normalizeRangeBoundary: found first text in child", { found: !!firstTextNode }); + if (!firstTextNode) { + return null; + } + return { node: firstTextNode, offset: 0 }; + } + } + + // For other node types, return null (can't normalize) + console.debug("normalizeRangeBoundary: unknown node type", { nodeType: node.nodeType }); + 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 +272,42 @@ 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) { + console.warn("Normalization failed but startContainer is text node, using original"); + start = { node: range.startContainer, offset: range.startOffset }; + } + if (!end && range.endContainer.nodeType === Node.TEXT_NODE) { + console.warn("Normalization failed but endContainer is text node, using original"); + end = { node: range.endContainer, offset: range.endOffset }; + } + + // If we still can't get valid boundaries, return empty result + if (!start || !end) { + console.warn("Failed to normalize range boundaries", { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + startNormalized: start, + endNormalized: 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..b3689e3f6 100644 --- a/znai-reactjs/src/doc-elements/text-selection/textSelectionBuilder.test.js +++ b/znai-reactjs/src/doc-elements/text-selection/textSelectionBuilder.test.js @@ -128,5 +128,117 @@ describe("textSelectionBuilder", () => { expect(result.prefix).toBe(" "); expect(result.suffix).toBe(", b) {\n "); }); + + it("should handle triple-click selection with element node boundaries", () => { + const { container } = setupDOM(` +
+

First paragraph with some text.

+

Second paragraph to triple click.

+

Third paragraph with more text.

+
+ `); + + const targetP = document.getElementById("target"); + + // Triple-click typically selects with element node as container + // startContainer =

element, startOffset = 0 (before first child) + // endContainer =

element, endOffset = childNodes.length (after last child) + 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); + }); + + it("should handle triple-click with parent container selecting single child", () => { + const { container } = setupDOM(` +

+ +

Following paragraph that should not be included.

+
+ `); + + const parent = document.getElementById("parent"); + const ul = parent.querySelector("ul"); + const targetLi = document.getElementById("target-li"); + + // Find the actual child index of the target
  • in the