Compare commits

...
3 changed files with 90 additions and 4 deletions

No files matched your search

@@ -1,3 +1,4 @@
import { stringCellWidth } from "../autocomplete/terminalStringCellWidth";
import { FitAddon } from "@xterm/addon-fit";
import { ImageAddon } from "@xterm/addon-image";
import { SearchAddon } from "@xterm/addon-search";
@@ -1065,7 +1066,43 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
overlay.style.fontSize = `${currentTerminalFontSize()}px`;
overlay.style.fontFamily = String(term.options.fontFamily ?? fontFamily);
overlay.style.lineHeight = String(term.options.lineHeight ?? lineHeight);
overlay.textContent = previewRows.map((row) => row.text).join("\n");
// Native font advances differ from xterm's device-pixel-rounded cells.
// Fit each row to its terminal cell width, including double-width glyphs,
// rather than applying a single-cell correction once per Unicode glyph.
overlay.style.fontWeight = String(term.options.fontWeight ?? "normal");
overlay.style.fontKerning = "none";
overlay.style.fontVariantLigatures = "none";
const screenRect = term.element?.querySelector(".xterm-screen")?.getBoundingClientRect();
if (screenRect && screenRect.width > 0 && screenRect.height > 0
&& term.cols > 0 && term.rows > 0) {
overlay.style.lineHeight = `${screenRect.height / term.rows}px`;
const rowElements = previewRows.map((row) => {
const span = document.createElement("span");
span.style.display = "inline-block";
span.style.verticalAlign = "top";
span.style.transformOrigin = "left top";
span.textContent = row.text;
return span;
});
const fragment = document.createDocumentFragment();
rowElements.forEach((span, index) => {
if (index > 0) fragment.appendChild(document.createTextNode("\n"));
fragment.appendChild(span);
});
overlay.replaceChildren(fragment);
// Batch all measurements before writing transforms to avoid one forced
// layout per row. Text nodes and explicit newlines stay unchanged for copy.
const nativeWidths = rowElements.map((span) => span.getBoundingClientRect().width);
rowElements.forEach((span, index) => {
const nativeWidth = nativeWidths[index];
const cells = stringCellWidth(previewRows[index].text, term);
if (nativeWidth > 0 && cells > 0) {
span.style.transform = `scaleX(${cells * screenRect.width / term.cols / nativeWidth})`;
}
});
} else {
overlay.textContent = previewRows.map((row) => row.text).join("\n");
}
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, encodeHistoryPreviewWrapFlags(previewRows));
return true;
};
@@ -17,10 +17,30 @@ import {
isHistoryPreviewDismissClick,
joinHistoryPreviewSelectionText,
nextHistoryPreviewTop,
selectHistoryPreviewAll,
shouldHideHistoryPreviewOnMouseDown,
shouldKeepHistoryPreviewOnKey,
} from "./terminalHistoryScrollOverride.ts";
test("select all includes every preview row and preserves soft-wrap copying", async () => {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM("<pre><span>中文abc</span>\n<span>def</span>\n<span>last</span></pre>");
try {
const overlay = dom.window.document.querySelector("pre")!;
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, "010");
assert.equal(selectHistoryPreviewAll(overlay), true);
assert.equal(dom.window.getSelection()!.toString(), "中文abc\ndef\nlast");
assert.equal(getHistoryPreviewSelectionText(overlay, dom.window.getSelection()), "中文abcdef\nlast");
overlay.textContent = "plain\ntext";
assert.equal(selectHistoryPreviewAll(overlay), true);
assert.equal(dom.window.getSelection()!.toString(), "plain\ntext");
overlay.textContent = "";
assert.equal(selectHistoryPreviewAll(overlay), false);
} finally {
dom.window.close();
}
});
const wheel = (
over: Partial<Parameters<typeof forcedHistoryScrollLinesForWheel>[0]> = {},
) => ({
@@ -352,3 +372,23 @@ test("history preview right-click is recognized as an app-menu target", () => {
false,
);
});
test("nested preview rows retain soft-wrap copy and partial selections", async () => {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM("<pre><span>中文abc</span>\n<span>def</span></pre>");
const overlay = dom.window.document.querySelector("pre")!;
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, "01");
const first = overlay.firstChild!.firstChild!;
const last = overlay.lastChild!.firstChild!;
const selection = {
rangeCount: 1, anchorNode: first, focusNode: last,
anchorOffset: 1, focusOffset: 2, toString: () => "文abc\ndef",
};
assert.equal(getHistoryPreviewSelectionText(overlay, selection), "文abcde");
assert.equal(getHistoryPreviewSelectionText(overlay, {
...selection, anchorNode: overlay, focusNode: overlay,
anchorOffset: 0, focusOffset: overlay.childNodes.length,
}), "中文abcdef");
dom.window.close();
});
@@ -147,6 +147,7 @@ export type HistoryPreviewSelectionLike = {
};
export type HistoryPreviewNodeLike = {
ownerDocument?: Pick<Document, "createRange">;
contains(node: { nodeType?: number } | null): boolean;
firstChild?: { nodeType?: number } | null;
getAttribute?(name: string): string | null;
@@ -271,6 +272,16 @@ const resolveOverlayTextOffset = (
node: { nodeType?: number } | null,
offset: number,
): number | null => {
if (overlay.ownerDocument && node) {
try {
const prefix = overlay.ownerDocument.createRange();
prefix.selectNodeContents(overlay as unknown as Node);
prefix.setEnd(node as Node, offset);
return prefix.toString().length;
} catch {
return null;
}
}
if (node === textNode) return offset;
if (node === overlay) return offset <= 0 ? 0 : overlay.textContent?.length ?? 0;
return null;
@@ -347,9 +358,7 @@ export const selectHistoryPreviewAll = (overlay: HTMLElement | null | undefined)
const selection = overlay.ownerDocument.getSelection();
if (!selection) return false;
const range = overlay.ownerDocument.createRange();
const textNode = overlay.firstChild;
if (textNode) range.selectNodeContents(textNode);
else range.selectNodeContents(overlay);
range.selectNodeContents(overlay);
selection.removeAllRanges();
selection.addRange(range);
return !selection.isCollapsed;