mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 23:44:41 +00:00
* fix(app): group native assistant prose into selectable runs * fix(app): render selectable native prose with enriched markdown * docs: explain native prose selection boundaries and tradeoffs * fix(app): preserve markdown semantics and render limits in prose runs * docs: record native prose compatibility fixes and heading limits * fix(app): allow additive task lists and admonitions in native prose * fix(app): preserve inline image formatting and reference scope * fix(app): preserve markdown source and file actions in native prose Prepare original source blocks with source-position edits from the assistant parser. Keep reference scope and live/history projection consistent, and retain link text when dispatching workspace file actions. * fix(app): preserve native prose entities and links while bounding preparation cost * fix(app): preserve provisional syntax at cached reveal boundaries * fix(app): bound prose append work and preserve markdown edge cases * fix(app): preserve home paths and normalized prose links * fix(app): preserve parser-owned links and streaming tilde tails * fix(app): keep growing markdown on the streaming block renderer * fix(app): preserve bracketed IPv6 links in native prose * fix(app): skip prose classification for blocks over the render cap * fix(app): share selectable native prose across Markdown surfaces * fix(app): preserve Markdown containers before grouping prose * revert(app): remove shared prose-run renderer changes Reverts ab4737bb2 and ac47e1063 before replacing the Markdown engine across all platforms. * feat(markdown): use enriched renderer across all platforms * test: verify library image controls and isolate website config * fix(markdown): preserve file links and image revisions * fix(markdown): restore diagrams and preserve link origin * fix(markdown): retain diagram identity and reference scope * fix(markdown): avoid empty web history measurements * fix(markdown): preserve authored empty link titles * test(markdown): exercise find across whole-message history * fix(markdown): preserve encoded image paths after rebase Keep the shared renderer image normalization and file-read regression coverage that originally lived in the main integration merge. * fix(markdown): derive table and task list colours from the theme * fix(markdown): restore selection highlight on web * fix(markdown): restore file link hover tooltips on web * fix(markdown): normalize inline HTML before rendering * fix(markdown): create renderer types when applying package patch The published nightly omits both MarkdownRenderers type files. Represent them as new files so patch-package succeeds on a fresh npm install, matching the already patched development package. * fix(markdown): keep the renderer tree stable while images resolve * fix(markdown): size inline images to the line and collapse failed loads * fix(markdown): normalize only parsed HTML nodes * fix(markdown): keep the first image reference definition * fix(markdown): preserve complete assistant messages on native * fix(markdown): classify web links explicitly and complete renderer types * fix(markdown): preserve spacing between HTML elements * fix(markdown): normalize HTML following code elements * fix(markdown): preserve literal image fallback labels * fix(markdown): retry transient image decode failures * docs(markdown): record native selection and image limitations * fix(markdown): preserve first definitions across Mermaid segments * fix(markdown): reserve pending images and harden web rendering * test(markdown): cover state retention during image arrival * fix(markdown): preserve reader position while responses grow Keep the same message component boundary when a live row becomes retained history, preserving Mermaid runtimes and selection nodes. Observe paced child text commits at the web viewport so sticky-bottom follows before the next frame. * test(markdown): require growth while holding a streaming paragraph * test(markdown): isolate image arrival from reload positioning Mounted row totals change as history virtualizes, so observe the new response and tool directly. Hold the real PNG response after reload until the reading position settles, then verify its reserved height and viewport instead of attributing initial bottom-follow positioning to image decoding. * fix(markdown): preserve adjacency around inline HTML * fix(markdown): keep summary block boundaries outside bold text * perf(markdown): scan HTML block boundaries incrementally * test(markdown): gate delivery while establishing a held reader * test(markdown): name shared renderer setup and file actions * test(markdown): assert linear HTML boundary work without timing * test(markdown): check large HTML table output without observing built-ins * fix(markdown): restore accentBright link colour * fix(markdown): require letters and known extensions in bare path recognition * fix(markdown): match main's blockquote, bullet and h6 colours * fix(android): keep inverted chat still during text selection React Native decomposes Android list inversion into a 180-degree rotation with positive scaleY, so the existing visibility-scroll guard never recognized the inverted viewport. Inspect the composed matrix and reproduce the actual list transform through the native view manager in regression tests. * fix(markdown): linked inline code keeps inline code size * fix(markdown): reserve a placeholder box with an image glyph while images load * fix(markdown): bare prose paths need an extension, line suffix or depth Slash-separated English pairs were recognized as file links in prose. Require stronger path intent there while preserving the existing inline-code recognition rule. * fix(markdown): horizontal blocks own their drags on compact layouts * perf(markdown): keep native streaming updates in the unfinished tail Native markdown reparses each updated view in full. Keep parser-closed blocks in a memoized head during paced reveal, then reunite completed prose for cross-paragraph selection. Preserve trailing spacing and source indentation at the split. * perf(markdown): reuse Android measurements across view mounts * test(markdown): bare Fabel/model and daemon/client stay plain text when copieda502cfd0dstopped linking bare two-segment prose paths; the clipboard expectation still carried the links from the earlier loose pattern and failed on CI (playwright shard 1/4 on60b4e2d05). main expects the plain text, so restore that. * perf(app): limit native chat mount batches Whole-message Markdown rows measure synchronously during Fabric layout. Render fewer history rows per batch and retain a smaller offscreen window so opening a chat does not measure twelve history rows at once. * fix(app): keep populated markdown in sync with native layout Fabric can collapse streaming blocks before asynchronous rendering replaces the old text, producing a blank frame. Commit populated-view updates synchronously while retaining worker preparation for first mounts to avoid moving large history parsing onto the UI thread. * perf(app): preserve unchanged sidebar workspace projections Reopening a retained sidebar rebuilt its entry collection and invalidated every project row despite unchanged workspace data. Preserve collection identity as well as row identity, while keeping membership and ordering changes observable. * Revert "perf(app): preserve unchanged sidebar workspace projections" This reverts commita3919faed4.
687 lines
24 KiB
TypeScript
687 lines
24 KiB
TypeScript
import TurndownService from "turndown";
|
|
import { gfm } from "turndown-plugin-gfm";
|
|
import {
|
|
createCodeClipboardContent,
|
|
createMarkdownClipboardContent,
|
|
type MarkdownClipboardContent,
|
|
} from "@/utils/rich-clipboard";
|
|
import {
|
|
MARKDOWN_COPY_IMAGE_SOURCES_ATTRIBUTE,
|
|
MARKDOWN_COPY_ALIGN_ATTRIBUTE,
|
|
MARKDOWN_COPY_ALT_ATTRIBUTE,
|
|
MARKDOWN_COPY_IGNORE_ATTRIBUTE,
|
|
MARKDOWN_COPY_LANGUAGE_ATTRIBUTE,
|
|
MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE,
|
|
MARKDOWN_COPY_LIST_START_ATTRIBUTE,
|
|
MARKDOWN_COPY_SRC_ATTRIBUTE,
|
|
MARKDOWN_COPY_TAG_ATTRIBUTE,
|
|
MARKDOWN_COPY_UNWRAP_ATTRIBUTE,
|
|
TRAILING_CODE_LINE_BREAKS,
|
|
} from "./markup";
|
|
|
|
const ASSISTANT_MESSAGE_SELECTOR = '[data-testid="assistant-message"]';
|
|
const MESSAGE_ROW_SELECTOR = "[data-message-id]";
|
|
const CHAT_SCROLL_SELECTOR = '[data-testid="agent-chat-scroll"]';
|
|
const messageRowSelector = (messageId: string) => `[data-message-id="${CSS.escape(messageId)}"]`;
|
|
const CODE_BLOCK_SELECTOR = `pre, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="pre"]`;
|
|
const CODE_REGION_SELECTOR = `${CODE_BLOCK_SELECTOR}, code, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="code"]`;
|
|
const IMAGE_FRAME_SELECTOR = `img, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="img"]`;
|
|
|
|
const SEMANTIC_TAGS =
|
|
"p,blockquote,br,code,pre,h1,h2,h3,h4,h5,h6,hr,img,li,ol,ul,strong,em,s,del,table,thead,tbody,tr,th,td";
|
|
function semanticTag(element: Element): string | null {
|
|
return (
|
|
element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) ??
|
|
(element.matches(SEMANTIC_TAGS) ? element.tagName.toLowerCase() : null)
|
|
);
|
|
}
|
|
function copySemanticAttributes(original: Element, copy: Element): void {
|
|
const tag = semanticTag(original);
|
|
if (!tag || original.hasAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE)) return;
|
|
copy.setAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE, tag);
|
|
if (tag === "ol")
|
|
copy.setAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE, original.getAttribute("start") ?? "1");
|
|
if (tag === "img") {
|
|
const url = original.getAttribute("src") ?? "";
|
|
const encodedSources = original
|
|
.closest(`[${MARKDOWN_COPY_IMAGE_SOURCES_ATTRIBUTE}]`)
|
|
?.getAttribute(MARKDOWN_COPY_IMAGE_SOURCES_ATTRIBUTE);
|
|
const sources: Record<string, string> = encodedSources ? JSON.parse(encodedSources) : {};
|
|
copy.setAttribute(MARKDOWN_COPY_SRC_ATTRIBUTE, sources[url] ?? url);
|
|
copy.setAttribute(MARKDOWN_COPY_ALT_ATTRIBUTE, original.getAttribute("alt") ?? "");
|
|
}
|
|
if (tag === "td" || tag === "th") {
|
|
const align = (original as HTMLElement).style.textAlign;
|
|
if (align) copy.setAttribute(MARKDOWN_COPY_ALIGN_ATTRIBUTE, align);
|
|
}
|
|
}
|
|
|
|
const turndown = new TurndownService({
|
|
bulletListMarker: "-",
|
|
codeBlockStyle: "fenced",
|
|
emDelimiter: "_",
|
|
strongDelimiter: "**",
|
|
});
|
|
turndown.use(gfm);
|
|
turndown.addRule("escapedGfmTableCell", {
|
|
filter: ["th", "td"],
|
|
replacement: (content, node) => {
|
|
const index = node.parentNode ? Array.from(node.parentNode.childNodes).indexOf(node) : 0;
|
|
const prefix = index === 0 ? "| " : " ";
|
|
return `${prefix}${content.replace(/\|/g, "\\|")} |`;
|
|
},
|
|
});
|
|
turndown.addRule("gfmStrikethrough", {
|
|
filter: ["del", "s"],
|
|
replacement: (content) => `~~${content}~~`,
|
|
});
|
|
turndown.addRule("compactListItem", {
|
|
filter: "li",
|
|
replacement: (content, node, options) => {
|
|
const item = content
|
|
.replace(/^\n+/, "")
|
|
.replace(/\n+$/, "\n")
|
|
.replace(/\n(?=\S)/g, "\n ");
|
|
const parent = node.parentElement;
|
|
if (parent?.nodeName !== "OL") {
|
|
return `${options.bulletListMarker} ${item}\n`;
|
|
}
|
|
return `${node.getAttribute("value")}. ${item}\n`;
|
|
},
|
|
});
|
|
|
|
export function createAssistantSelectionClipboardContent(
|
|
selection: Selection | null,
|
|
): MarkdownClipboardContent | null {
|
|
if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) {
|
|
return null;
|
|
}
|
|
|
|
const range = startAfterImageFrame(selection.getRangeAt(0));
|
|
const parts = selectedMessageParts(range)?.filter((part) => selectsContent(part.range));
|
|
if (!parts?.length) {
|
|
return null;
|
|
}
|
|
|
|
if (parts.length === 1) {
|
|
const partialCode = createPartialCodeContent(range, parts[0]!.message);
|
|
if (partialCode) {
|
|
return partialCode;
|
|
}
|
|
}
|
|
|
|
const container = document.createElement("div");
|
|
for (const part of parts) {
|
|
container.append(cloneMarkdownSelection(part.range, part.message));
|
|
}
|
|
restoreMarkdownElements(container);
|
|
|
|
const markdown = turndown.turndown(container.innerHTML).trim();
|
|
if (!markdown) {
|
|
return null;
|
|
}
|
|
const content = createMarkdownClipboardContent(markdown);
|
|
return { ...content, html: flattenClipboardListMarkup(content.html) };
|
|
}
|
|
|
|
/**
|
|
* A drag that starts in the gap below an image anchors at the start of the image's
|
|
* rendered internals, ahead of the hidden `img`, while only the text after it is
|
|
* highlighted. Pressing on the image itself opens it instead of starting a selection,
|
|
* so a selection that starts inside an image, before any of its text, and ends past it
|
|
* starts after the image. A failed image shows its error as text, and a selection
|
|
* starting in that text keeps the image.
|
|
*/
|
|
function startAfterImageFrame(range: Range): Range {
|
|
const start =
|
|
range.startContainer instanceof Element
|
|
? range.startContainer
|
|
: range.startContainer.parentElement;
|
|
const frame = start?.closest(IMAGE_FRAME_SELECTOR);
|
|
if (!frame || frame.contains(range.endContainer)) {
|
|
return range;
|
|
}
|
|
const insideFrame = range.cloneRange();
|
|
insideFrame.setEnd(frame, frame.childNodes.length);
|
|
if (insideFrame.toString()) {
|
|
return range;
|
|
}
|
|
const afterFrame = range.cloneRange();
|
|
afterFrame.setStartAfter(frame);
|
|
return afterFrame;
|
|
}
|
|
|
|
/**
|
|
* Partly-selected code copies as the code itself, never through Turndown.
|
|
*
|
|
* `removeUnselectedSemantics` strips the `pre`/`code` tags from a partial selection,
|
|
* which is right for the Markdown syntax but leaves the code being serialized as
|
|
* prose: Turndown collapses the indentation and the line breaks, and escapes
|
|
* Markdown-significant characters inside it. Two selected lines arrive as one.
|
|
*
|
|
* A selection contained inside code always copies as code, even when it contains every
|
|
* character. A selection that crosses the code boundary stays on the Markdown path so
|
|
* a complete block retains its fence. Crossing means selecting content outside the code:
|
|
* a drag that starts or ends in the gap beside a block anchors at the edge of the
|
|
* neighbouring block, which selects nothing there.
|
|
*/
|
|
function createPartialCodeContent(range: Range, message: Element): MarkdownClipboardContent | null {
|
|
const region = selectedCodeRegion(range, message);
|
|
if (!region) {
|
|
return null;
|
|
}
|
|
|
|
const code = selectedCodeText(range);
|
|
if (!code) {
|
|
return null;
|
|
}
|
|
|
|
// `closest` stops at the inner `code` span, but the fence language lives on the
|
|
// outer `pre` wrapper.
|
|
const fence = region.closest(CODE_BLOCK_SELECTOR);
|
|
|
|
// Only multi-line code earns markup in the html half, and only because HTML would
|
|
// otherwise collapse the newlines. A single line stays undecorated, like every
|
|
// other partial selection.
|
|
const block = Boolean(fence) && code.includes("\n");
|
|
return createCodeClipboardContent(code, {
|
|
language: fence?.getAttribute(MARKDOWN_COPY_LANGUAGE_ATTRIBUTE),
|
|
block,
|
|
});
|
|
}
|
|
|
|
function selectedCodeRegion(range: Range, message: Element): Element | null {
|
|
const edgeRegions = [range.startContainer, range.endContainer].map((node) =>
|
|
closestCodeRegion(node, message),
|
|
);
|
|
return (
|
|
edgeRegions.find(
|
|
// Measure against the whole block, so its own hover Copy button is not outside it.
|
|
(edge) => edge && selectsNothingOutside(range, edge.closest(CODE_BLOCK_SELECTOR) ?? edge),
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
function closestCodeRegion(node: Node, message: Element): Element | null {
|
|
const element = node instanceof Element ? node : node.parentElement;
|
|
const region = element?.closest(CODE_REGION_SELECTOR) ?? null;
|
|
if (!region || !message.contains(region)) {
|
|
return null;
|
|
}
|
|
// A selection sitting entirely inside the block's own hover Copy button is not
|
|
// code. `cloneContents` of a single text node drops the ignore marker with the
|
|
// element that carried it, so this has to be asked of the live DOM.
|
|
return element?.closest(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`) ? null : region;
|
|
}
|
|
|
|
function selectedCodeText(range: Range): string {
|
|
const fragment = range.cloneContents();
|
|
// The hover Copy button lives inside the `pre`, so a selection that reaches the
|
|
// end of the block sweeps it up too.
|
|
for (const ignored of fragment.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) {
|
|
ignored.remove();
|
|
}
|
|
// Deliberately untrimmed — leading indentation is part of the code. Trailing line
|
|
// breaks are the exception: newlines are their own text nodes between rendered
|
|
// lines, so a selection that overshoots the end of a line sweeps one in.
|
|
return (fragment.textContent ?? "").replace(TRAILING_CODE_LINE_BREAKS, "");
|
|
}
|
|
|
|
function flattenClipboardListMarkup(html: string): string {
|
|
const container = document.createElement("div");
|
|
container.innerHTML = html;
|
|
|
|
const lists = Array.from(container.querySelectorAll("ul, ol")).toReversed();
|
|
for (const list of lists) {
|
|
const replacement = document.createDocumentFragment();
|
|
const items = Array.from(list.children).filter((child) => child.tagName === "LI");
|
|
const start = list.tagName === "OL" ? Number(list.getAttribute("start") ?? 1) : null;
|
|
|
|
items.forEach((item, index) => {
|
|
const line = document.createElement("div");
|
|
line.append(start === null ? "- " : `${start + index}. `);
|
|
const nestedLines: HTMLDivElement[] = [];
|
|
let hasParagraph = false;
|
|
for (const child of Array.from(item.childNodes)) {
|
|
if (child instanceof HTMLDivElement) {
|
|
nestedLines.push(child);
|
|
} else if (child instanceof HTMLParagraphElement) {
|
|
if (hasParagraph) {
|
|
line.append(document.createElement("br"), document.createElement("br"));
|
|
}
|
|
line.append(...child.childNodes);
|
|
hasParagraph = true;
|
|
} else {
|
|
line.append(child);
|
|
}
|
|
}
|
|
replacement.append(line, ...nestedLines);
|
|
});
|
|
list.replaceWith(replacement);
|
|
}
|
|
|
|
return container.innerHTML;
|
|
}
|
|
|
|
function cloneMarkdownSelection(range: Range, message: Element): Node {
|
|
const clonedMessage = message.cloneNode(true) as Element;
|
|
const clonedRange = cloneRangeInto(range, message, clonedMessage);
|
|
removeUnselectedSemantics(range, message, clonedMessage);
|
|
|
|
let selected: Node = clonedRange.cloneContents();
|
|
let ancestor =
|
|
range.commonAncestorContainer instanceof Element
|
|
? range.commonAncestorContainer
|
|
: range.commonAncestorContainer.parentElement;
|
|
|
|
while (ancestor && ancestor !== message) {
|
|
if (shouldPreserveSemanticElement(range, ancestor)) {
|
|
const wrapper = ancestor.cloneNode(false) as Element;
|
|
copySemanticAttributes(ancestor, wrapper);
|
|
normalizeOrderedListStart(wrapper, ancestor, range);
|
|
wrapper.append(selected);
|
|
selected = wrapper;
|
|
}
|
|
ancestor = ancestor.parentElement;
|
|
}
|
|
|
|
return selected;
|
|
}
|
|
|
|
function cloneRangeInto(range: Range, source: Element, clone: Element): Range {
|
|
const clonedRange = document.createRange();
|
|
clonedRange.setStart(
|
|
nodeAtPath(clone, nodePath(source, range.startContainer)),
|
|
range.startOffset,
|
|
);
|
|
clonedRange.setEnd(nodeAtPath(clone, nodePath(source, range.endContainer)), range.endOffset);
|
|
return clonedRange;
|
|
}
|
|
|
|
function nodePath(root: Node, node: Node): number[] {
|
|
const path: number[] = [];
|
|
let current = node;
|
|
while (current !== root) {
|
|
const parent = current.parentNode;
|
|
if (!parent) {
|
|
throw new Error("Selection boundary is outside its assistant message");
|
|
}
|
|
path.unshift(Array.from(parent.childNodes).findIndex((child) => child === current));
|
|
current = parent;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
function nodeAtPath(root: Node, path: number[]): Node {
|
|
let current = root;
|
|
for (const index of path) {
|
|
const child = current.childNodes[index];
|
|
if (!child) {
|
|
throw new Error("Cloned assistant message does not match the selection source");
|
|
}
|
|
current = child;
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function removeUnselectedSemantics(range: Range, source: Element, clone: Element): void {
|
|
const selector = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}], a, ${SEMANTIC_TAGS}`;
|
|
const originals = Array.from(source.querySelectorAll(selector));
|
|
const copies = Array.from(clone.querySelectorAll(selector));
|
|
|
|
originals.forEach((original, index) => {
|
|
const copy = copies[index];
|
|
if (copy) copySemanticAttributes(original, copy);
|
|
if (!copy || shouldPreserveSemanticElement(range, original)) {
|
|
if (copy) {
|
|
normalizeOrderedListStart(copy, original, range);
|
|
}
|
|
return;
|
|
}
|
|
const tag = semanticTag(original);
|
|
if (tag && isBlockBoundary(tag)) {
|
|
copy.setAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE, "p");
|
|
return;
|
|
}
|
|
copy.removeAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
|
|
if (copy.matches(SEMANTIC_TAGS) || copy.tagName === "A") {
|
|
copy.setAttribute(MARKDOWN_COPY_UNWRAP_ATTRIBUTE, "true");
|
|
}
|
|
});
|
|
}
|
|
|
|
function shouldPreserveSemanticElement(range: Range, element: Element): boolean {
|
|
if (!range.intersectsNode(element)) {
|
|
return false;
|
|
}
|
|
const tag = semanticTag(element);
|
|
// An image has no part to select, so touching it selects all of it.
|
|
if (tag === "p" || tag === "img" || isTableStructure(tag)) {
|
|
return true;
|
|
}
|
|
const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul";
|
|
if (
|
|
(isSelectableSemantic || element.tagName === "A") &&
|
|
selectionStaysInsideElement(range, element)
|
|
) {
|
|
return false;
|
|
}
|
|
if (tag === "ol" || tag === "ul") {
|
|
const selectedItems = selectedListItems(element, range);
|
|
return selectedItems.some(
|
|
(item) => hasSelectedListMarker(range, item) || hasSelectedAllContents(range, item, true),
|
|
);
|
|
}
|
|
if (tag === "li") {
|
|
if (hasSelectedListMarker(range, element)) return true;
|
|
// CSS list markers are outside text nodes. Starting inside a semantic item
|
|
// excludes its marker even when all of the label's characters are selected.
|
|
if (
|
|
element.tagName === "LI" &&
|
|
range.startContainer !== element &&
|
|
element.contains(range.startContainer)
|
|
)
|
|
return false;
|
|
}
|
|
return hasSelectedAllContents(range, element, tag === "li");
|
|
}
|
|
|
|
function selectionStaysInsideElement(range: Range, element: Element): boolean {
|
|
return (
|
|
range.startContainer !== element &&
|
|
range.endContainer !== element &&
|
|
element.contains(range.startContainer) &&
|
|
element.contains(range.endContainer)
|
|
);
|
|
}
|
|
|
|
function selectedListItems(list: Element, range: Range): Element[] {
|
|
return Array.from(list.children).filter(
|
|
(child) =>
|
|
semanticTag(child) === "li" &&
|
|
(child.contains(range.startContainer) ||
|
|
child.contains(range.endContainer) ||
|
|
hasSelectedAllContents(range, child, true)),
|
|
);
|
|
}
|
|
|
|
function hasSelectedListMarker(range: Range, item: Element): boolean {
|
|
const marker = item.querySelector(`:scope > [${MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE}]`);
|
|
if (!marker) {
|
|
return item.tagName === "LI" && range.startContainer === item && range.startOffset === 0;
|
|
}
|
|
|
|
const markerContents = document.createRange();
|
|
markerContents.selectNodeContents(marker);
|
|
const selectionEndsBeforeMarker =
|
|
range.compareBoundaryPoints(Range.END_TO_START, markerContents) >= 0;
|
|
const selectionStartsAfterMarker =
|
|
range.compareBoundaryPoints(Range.START_TO_END, markerContents) <= 0;
|
|
if (selectionEndsBeforeMarker || selectionStartsAfterMarker) {
|
|
return false;
|
|
}
|
|
|
|
if (range.compareBoundaryPoints(Range.START_TO_START, markerContents) > 0) {
|
|
markerContents.setStart(range.startContainer, range.startOffset);
|
|
}
|
|
if (range.compareBoundaryPoints(Range.END_TO_END, markerContents) < 0) {
|
|
markerContents.setEnd(range.endContainer, range.endOffset);
|
|
}
|
|
return hasMarkdownContent(markerContents.cloneContents(), true);
|
|
}
|
|
|
|
function normalizeOrderedListStart(copy: Element, original: Element, range: Range): void {
|
|
if (semanticTag(original) !== "ol") {
|
|
return;
|
|
}
|
|
const firstSelectedItem = selectedListItems(original, range)[0];
|
|
if (!firstSelectedItem) {
|
|
return;
|
|
}
|
|
const items = Array.from(original.children).filter((child) => semanticTag(child) === "li");
|
|
const omittedItems = items.indexOf(firstSelectedItem);
|
|
const originalStart = Number(
|
|
original.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE) ??
|
|
original.getAttribute("start") ??
|
|
1,
|
|
);
|
|
copy.setAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE, String(originalStart + omittedItems));
|
|
}
|
|
|
|
function hasSelectedAllContents(range: Range, element: Element, includeIgnored = false): boolean {
|
|
const contents = document.createRange();
|
|
contents.selectNodeContents(element);
|
|
|
|
if (range.compareBoundaryPoints(Range.START_TO_START, contents) > 0) {
|
|
const before = contents.cloneRange();
|
|
before.setEnd(range.startContainer, range.startOffset);
|
|
if (hasMarkdownContent(before.cloneContents(), includeIgnored)) {
|
|
return false;
|
|
}
|
|
}
|
|
if (range.compareBoundaryPoints(Range.END_TO_END, contents) < 0) {
|
|
const after = contents.cloneRange();
|
|
after.setStart(range.endContainer, range.endOffset);
|
|
if (hasMarkdownContent(after.cloneContents(), includeIgnored)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function selectsContent(range: Range): boolean {
|
|
return hasMarkdownContent(range.cloneContents(), true);
|
|
}
|
|
|
|
function selectsNothingOutside(range: Range, element: Element): boolean {
|
|
const contents = document.createRange();
|
|
contents.selectNodeContents(element);
|
|
|
|
if (range.compareBoundaryPoints(Range.START_TO_START, contents) < 0) {
|
|
const before = range.cloneRange();
|
|
before.setEnd(contents.startContainer, contents.startOffset);
|
|
if (selectsContent(before)) {
|
|
return false;
|
|
}
|
|
}
|
|
if (range.compareBoundaryPoints(Range.END_TO_END, contents) > 0) {
|
|
const after = range.cloneRange();
|
|
after.setStart(contents.endContainer, contents.endOffset);
|
|
if (selectsContent(after)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean): boolean {
|
|
if (!includeIgnored) {
|
|
for (const ignored of fragment.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) {
|
|
ignored.remove();
|
|
}
|
|
}
|
|
if (fragment.textContent) {
|
|
return true;
|
|
}
|
|
const visibleVoidSelector = ["br", "hr", "img"]
|
|
.map((tag) => `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="${tag}"]`)
|
|
.concat("img")
|
|
.join(",");
|
|
return Boolean(fragment.querySelector(visibleVoidSelector));
|
|
}
|
|
|
|
function isBlockBoundary(tag: string): boolean {
|
|
return tag === "li" || tag === "blockquote" || tag === "pre" || /^h[1-6]$/.test(tag);
|
|
}
|
|
|
|
function isTableStructure(tag: string | null): boolean {
|
|
return ["table", "thead", "tbody", "tr", "th", "td"].includes(tag ?? "");
|
|
}
|
|
|
|
function closestAssistantMessage(node: Node): Element | null {
|
|
const element = node instanceof Element ? node : node.parentElement;
|
|
return element?.closest(ASSISTANT_MESSAGE_SELECTOR) ?? null;
|
|
}
|
|
|
|
interface SelectedMessagePart {
|
|
message: Element;
|
|
range: Range;
|
|
}
|
|
|
|
function messageIdOf(node: Element): string | undefined {
|
|
return node.closest<HTMLElement>(MESSAGE_ROW_SELECTOR)?.dataset.messageId;
|
|
}
|
|
|
|
/**
|
|
* A retained inactive panel keeps rendering its chat, so the same agent — and the same
|
|
* message id — can exist twice in the document. The selection's own transcript is the
|
|
* only one that can answer for it.
|
|
*/
|
|
function assistantMessagesOfGroup(node: Element, messageId: string): Element[] {
|
|
const transcript = node.closest(CHAT_SCROLL_SELECTOR);
|
|
if (!transcript) {
|
|
return [];
|
|
}
|
|
const rows = transcript.querySelectorAll<HTMLElement>(messageRowSelector(messageId));
|
|
return Array.from(rows).flatMap((row) => {
|
|
const message = row.querySelector(ASSISTANT_MESSAGE_SELECTOR);
|
|
return message ? [message] : [];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The selection cut into one range per assistant message element it covers.
|
|
*
|
|
* An assistant message renders one row per Markdown block, so a selection that runs
|
|
* past a paragraph ends in a different element of the same message. Each block is
|
|
* serialized against its own element, exactly as a selection inside one block is, and
|
|
* the results are concatenated — Turndown never sees the row wrappers between them.
|
|
* A selection reaching a second message, or anything that is not assistant Markdown,
|
|
* copies as plain text.
|
|
*/
|
|
function selectedMessageParts(range: Range): SelectedMessagePart[] | null {
|
|
const start = closestAssistantMessage(range.startContainer);
|
|
const end = closestAssistantMessage(range.endContainer);
|
|
if (!start || !end) {
|
|
return null;
|
|
}
|
|
if (start === end) {
|
|
return [{ message: start, range }];
|
|
}
|
|
const messageId = messageIdOf(start);
|
|
if (!messageId || messageId !== messageIdOf(end)) {
|
|
return null;
|
|
}
|
|
const messages = assistantMessagesOfGroup(start, messageId);
|
|
const first = messages.indexOf(start);
|
|
const last = messages.indexOf(end);
|
|
if (first < 0 || last < first) {
|
|
return null;
|
|
}
|
|
return messages.slice(first, last + 1).map((message) => {
|
|
const part = message.ownerDocument.createRange();
|
|
part.selectNodeContents(message);
|
|
if (message === start) part.setStart(range.startContainer, range.startOffset);
|
|
if (message === end) part.setEnd(range.endContainer, range.endOffset);
|
|
return { message, range: part };
|
|
});
|
|
}
|
|
|
|
function restoreMarkdownElements(container: HTMLElement): void {
|
|
for (const ignored of container.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) {
|
|
ignored.remove();
|
|
}
|
|
|
|
const marked = Array.from(container.querySelectorAll(`[${MARKDOWN_COPY_TAG_ATTRIBUTE}]`));
|
|
for (const element of marked.toReversed()) {
|
|
const tagName = element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
|
|
if (!tagName) {
|
|
continue;
|
|
}
|
|
const semanticElement = document.createElement(tagName);
|
|
if (tagName !== "br" && tagName !== "img") {
|
|
semanticElement.append(...element.childNodes);
|
|
}
|
|
if (tagName === "img") {
|
|
semanticElement.setAttribute("src", element.getAttribute(MARKDOWN_COPY_SRC_ATTRIBUTE) ?? "");
|
|
semanticElement.setAttribute("alt", element.getAttribute(MARKDOWN_COPY_ALT_ATTRIBUTE) ?? "");
|
|
}
|
|
if (tagName === "ol") {
|
|
const start = element.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE);
|
|
if (start) {
|
|
semanticElement.setAttribute("start", start);
|
|
}
|
|
numberOrderedListItems(semanticElement, Number(start ?? 1));
|
|
}
|
|
if (tagName === "pre") {
|
|
const language = element.getAttribute(MARKDOWN_COPY_LANGUAGE_ATTRIBUTE);
|
|
const code = semanticElement.querySelector(":scope > code");
|
|
if (language && code) {
|
|
code.className = `language-${language}`;
|
|
}
|
|
}
|
|
if (tagName === "th" || tagName === "td") {
|
|
const alignment = element.getAttribute(MARKDOWN_COPY_ALIGN_ATTRIBUTE);
|
|
if (alignment) {
|
|
semanticElement.setAttribute("align", alignment);
|
|
}
|
|
}
|
|
element.replaceWith(semanticElement);
|
|
}
|
|
|
|
unwrapIncompleteTables(container);
|
|
|
|
const generatedLinks = Array.from(
|
|
container.querySelectorAll(`[${MARKDOWN_COPY_UNWRAP_ATTRIBUTE}]`),
|
|
);
|
|
for (const element of generatedLinks.toReversed()) {
|
|
element.replaceWith(...element.childNodes);
|
|
}
|
|
|
|
const presentational = Array.from(container.querySelectorAll("div, span"));
|
|
for (const element of presentational.toReversed()) {
|
|
element.replaceWith(...element.childNodes);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Each item carries its number before Turndown re-parses the HTML. Until then every
|
|
* child of the list is one item, including a partly selected item demoted to a `p`.
|
|
* The parser splits a `p` that holds a code block into several siblings, so counting
|
|
* siblings afterwards numbers the next item too high.
|
|
*/
|
|
function numberOrderedListItems(list: Element, start: number): void {
|
|
Array.from(list.children).forEach((child, index) => {
|
|
if (child.tagName === "LI") {
|
|
child.setAttribute("value", String(start + index));
|
|
}
|
|
});
|
|
}
|
|
|
|
function unwrapIncompleteTables(container: HTMLElement): void {
|
|
for (const table of container.querySelectorAll("table")) {
|
|
if (hasUsableTableColumnContract(table)) {
|
|
continue;
|
|
}
|
|
const cells = Array.from(table.querySelectorAll("th, td"));
|
|
const selectedContent = document.createDocumentFragment();
|
|
cells.forEach((cell, index) => {
|
|
if (index > 0) {
|
|
selectedContent.append("\n");
|
|
}
|
|
selectedContent.append(...cell.childNodes);
|
|
});
|
|
table.replaceWith(selectedContent);
|
|
}
|
|
}
|
|
|
|
function hasUsableTableColumnContract(table: Element): boolean {
|
|
const headerWidth = table.querySelectorAll("thead th").length;
|
|
return (
|
|
headerWidth > 0 &&
|
|
Array.from(table.querySelectorAll("tbody tr")).every(
|
|
(row) => row.children.length <= headerWidth,
|
|
)
|
|
);
|
|
}
|