fix: unify chat process and reply presentation

This commit is contained in:
2026-10-29 17:54:34 +08:00
parent 7621bf0f86
commit 9474788db6
46 changed files with 3160 additions and 500 deletions
+51
View File
@@ -0,0 +1,51 @@
import type { ClipboardEvent } from "react";
export function copySelectedText(event: ClipboardEvent<HTMLElement>) {
if (event.defaultPrevented) return;
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("input, textarea, [contenteditable]:not([contenteditable=false])")) return;
const selection = event.currentTarget.ownerDocument.defaultView?.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount !== 1) return;
const range = selection.getRangeAt(0);
const text = selection.toString();
const suffix = text.match(/\n+$/)?.[0].length ?? 0;
const sourceSuffix = range.toString().match(/\n+$/)?.[0].length ?? 0;
// Chromium includes the block boundary when selecting an entire paragraph.
// Keep native internal paragraphs, table tabs and code indentation, removing
// only the trailing boundary that is absent from the selected source.
if (suffix < 2 || suffix <= sourceSuffix) return;
event.clipboardData.setData("text/plain", text.slice(0, text.length - suffix + sourceSuffix));
event.preventDefault();
}
export function copySelectedParagraph(event: ClipboardEvent<HTMLElement>, paragraph: HTMLParagraphElement, hasNonSelectableControls = false) {
const selection = paragraph.ownerDocument.defaultView?.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount !== 1) return;
const selected = selection.getRangeAt(0);
if (!selected.intersectsNode(paragraph)) return;
const contents = paragraph.ownerDocument.createRange();
contents.selectNodeContents(paragraph);
const bounded = selected.cloneRange();
if (bounded.compareBoundaryPoints(Range.START_TO_START, contents) < 0) {
bounded.setStart(contents.startContainer, contents.startOffset);
}
if (bounded.compareBoundaryPoints(Range.END_TO_END, contents) > 0) {
bounded.setEnd(contents.endContainer, contents.endOffset);
}
// Native paragraph selection can extend to the next message and serialize
// the paragraph boundary as extra newlines. Range text preserves the actual
// selected source, including intentional whitespace inside the message.
const text = bounded.toString();
if (!text) return;
if (hasNonSelectableControls) {
// DOM ranges can include adjacent user-select:none toolbar labels, while
// the rendered selection contains only this paragraph and its boundary.
if (selection.toString() !== `${text}\n\n`) return;
} else if (selected.toString() !== text || selection.toString() === text) return;
event.clipboardData.setData("text/plain", text);
event.preventDefault();
}