fix: prevent Notes autocomplete popup clipping (#3277)

* fix: keep note autocomplete popups outside clipped code blocks

* fix: address Codex review on PR #3277

* fix: constrain note completion width to its pane

* fix: retain owning pane colors on note tooltips

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
This commit is contained in:
陈大猫
2026-09-05 14:31:28 +08:00
committed by GitHub
parent 20b12a861c
commit 1151b31a42
4 changed files with 300 additions and 2 deletions
@@ -367,8 +367,15 @@ test("note editor registers a code block editor for pasted fenced code", () => {
source,
/codeBlockPlugin\([^)]*\),\s*codeMirrorPlugin\(\{\s*codeBlockLanguages:/s,
);
assert.match(source, /codeMirrorExtensions:\s*NOTE_CODE_MIRROR_EXTENSIONS/);
assert.match(source, /codeMirrorExtensions:\s*\[\s*\.\.\.NOTE_CODE_MIRROR_EXTENSIONS,/);
assert.match(source, /syntaxHighlighting\(noteCodeHighlightStyle\)/);
// Tooltips mount on body to escape clipped code blocks, so their placement
// must be bounded to this editor instead of the whole window.
assert.match(
source,
/createNoteCodeTooltipExtensions\(\s*typeof document === "undefined" \? undefined : document\.body,/,
);
assert.match(source, /\(\) => containerRef\.current,/);
});
test("note editor enables image plugin for remote markdown images", () => {
+11 -1
View File
@@ -63,6 +63,7 @@ import {
} from "./noteClipboardPaste";
import { annotateNoteImageSizes } from "./noteImageLayout";
import { renderNoteMathFormula } from "./noteMathRenderer";
import { createNoteCodeTooltipExtensions } from "./noteCodeTooltips";
import {
EMPTY_ACTIVE_FORMATS,
type ActiveTextFormats,
@@ -1203,7 +1204,16 @@ export const InlineMarkdownEditor = React.memo(
codeBlockPlugin({ defaultCodeBlockLanguage: "" }),
codeMirrorPlugin({
codeBlockLanguages: NOTE_CODE_BLOCK_LANGUAGES,
codeMirrorExtensions: NOTE_CODE_MIRROR_EXTENSIONS,
codeMirrorExtensions: [
...NOTE_CODE_MIRROR_EXTENSIONS,
...createNoteCodeTooltipExtensions(
typeof document === "undefined" ? undefined : document.body,
// Tooltips escape clipped code blocks via the body parent; bound
// their placement to this notes editor so a completion near a pane
// edge flips or shrinks instead of rendering over an adjacent pane.
() => containerRef.current,
),
],
}),
markdownShortcutPlugin(),
noteEditorDialogBridgePlugin({
+153
View File
@@ -0,0 +1,153 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import { EditorState } from "@codemirror/state";
import { EditorView, showTooltip } from "@codemirror/view";
import { syncNoteCodeTooltipStyle, createNoteCodeTooltipExtensions, getNoteTooltipSpace } from "./noteCodeTooltips";
const stubRect = (element: Element, top: number, left: number, right: number, bottom: number) => {
element.getBoundingClientRect = () => ({
top,
left,
right,
bottom,
width: right - left,
height: bottom - top,
x: left,
y: top,
toJSON: () => ({}),
} as DOMRect);
};
test("note tooltips escape clipped code blocks and disappear with their editor", () => {
const dom = new JSDOM('<div id="note" style="overflow:hidden;height:20px"></div>', {
pretendToBeVisual: true,
});
const keys = ["window", "document", "MutationObserver", "requestAnimationFrame", "cancelAnimationFrame"] as const;
const previous = keys.map((key) => Object.getOwnPropertyDescriptor(globalThis, key));
for (const key of keys) {
const value = dom.window[key];
Object.defineProperty(globalThis, key, {
configurable: true,
value: typeof value === "function" && key.includes("AnimationFrame") ? value.bind(dom.window) : value,
});
}
let view: EditorView | undefined;
try {
const parent = dom.window.document.querySelector("#note") as HTMLElement;
view = new EditorView({
parent,
state: EditorState.create({
doc: "con",
extensions: [
...createNoteCodeTooltipExtensions(dom.window.document.body),
showTooltip.of({
pos: 0,
create() {
const tooltip = dom.window.document.createElement("div");
tooltip.textContent = "const";
return { dom: tooltip };
},
}),
],
}),
});
const tooltip = dom.window.document.querySelector(".cm-tooltip")!;
assert.ok(tooltip);
assert.equal(parent.contains(tooltip), false);
assert.equal(tooltip.parentElement?.parentElement, dom.window.document.body);
for (const themeClass of view.themeClasses.split(" ")) {
assert.ok(tooltip.parentElement?.classList.contains(themeClass));
}
view.dom.style.setProperty("--popover", "120 50% 20%");
view.dom.style.setProperty("--accent", "120 70% 40%");
syncNoteCodeTooltipStyle(view, { top: 0, left: 100, right: 300, bottom: 400 });
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--popover"), "120 50% 20%");
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--accent"), "120 70% 40%");
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--note-tooltip-width"), "200px");
view.dom.style.setProperty("--popover", "240 50% 20%");
view.dom.style.removeProperty("--accent");
syncNoteCodeTooltipStyle(view, { top: 0, left: 100, right: 250, bottom: 400 });
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--popover"), "240 50% 20%");
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--accent"), "");
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--note-tooltip-width"), "150px");
view.destroy();
view = undefined;
assert.equal(dom.window.document.querySelector(".cm-tooltip"), null);
} finally {
view?.destroy();
keys.forEach((key, index) => {
const descriptor = previous[index];
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
});
dom.window.close();
}
});
test("note tooltip space is bounded by clipping pane ancestors", () => {
const dom = new JSDOM('<div id="pane" style="overflow:hidden"><div id="note"></div></div>');
const pane = dom.window.document.querySelector("#pane") as HTMLElement;
const note = dom.window.document.querySelector("#note") as HTMLElement;
stubRect(pane, 100, 200, 600, 500);
// The notes editor extends below its pane; a tooltip must not follow it out.
stubRect(note, 150, 250, 550, 700);
const realGetComputedStyle = dom.window.getComputedStyle.bind(dom.window);
dom.window.getComputedStyle = ((element: Element) => ({
...realGetComputedStyle(element),
overflowX: element === pane ? "hidden" : "visible",
overflowY: element === pane ? "hidden" : "visible",
})) as typeof dom.window.getComputedStyle;
try {
assert.deepEqual(getNoteTooltipSpace(note, dom.window.document), {
top: 150,
left: 250,
right: 550,
bottom: 500,
});
} finally {
dom.window.close();
}
});
test("note tooltip space honors paint containment without overflow clipping", () => {
const dom = new JSDOM(
'<style>#pane{contain:strict}</style><div id="pane"><div id="note"></div></div>',
);
const pane = dom.window.document.querySelector("#pane") as HTMLElement;
const note = dom.window.document.querySelector("#note") as HTMLElement;
stubRect(pane, 0, 0, 800, 600);
stubRect(note, 50, -50, 850, 900);
const realGetComputedStyle = dom.window.getComputedStyle.bind(dom.window);
dom.window.getComputedStyle = ((element: Element) => ({
...realGetComputedStyle(element),
overflowX: "visible",
overflowY: "visible",
contain: element === pane ? "strict" : "none",
})) as typeof dom.window.getComputedStyle;
try {
assert.deepEqual(getNoteTooltipSpace(note, dom.window.document), {
top: 50,
left: 0,
right: 800,
bottom: 600,
});
} finally {
dom.window.close();
}
});
test("note tooltip space falls back to window bounds when the editor is unmounted", () => {
const dom = new JSDOM("<div></div>");
const detached = dom.window.document.createElement("div");
try {
assert.deepEqual(getNoteTooltipSpace(detached, dom.window.document), {
top: 0,
left: 0,
right: dom.window.document.documentElement.clientWidth,
bottom: dom.window.document.documentElement.clientHeight,
});
} finally {
dom.window.close();
}
});
+128
View File
@@ -0,0 +1,128 @@
import { Prec } from "@codemirror/state";
import { EditorView, getTooltip, showTooltip, tooltips } from "@codemirror/view";
export interface NoteTooltipSpace {
top: number;
left: number;
right: number;
bottom: number;
}
/** True when the element clips horizontally overflowing descendants. */
const clipsHorizontally = (style: CSSStyleDeclaration): boolean =>
style.overflowX !== "visible" ||
/(^|\s)(strict|content|paint)(\s|$)/.test(style.contain || "");
/** True when the element clips vertically overflowing descendants. Paint
* containment (strict/content/paint) also clips and captures fixed
* descendants even when overflow is visible. */
const clipsVertically = (style: CSSStyleDeclaration): boolean =>
style.overflowY !== "visible" ||
/(^|\s)(strict|content|paint)(\s|$)/.test(style.contain || "");
/**
* Viewport-space bounds CodeMirror may place note tooltips in: the owning
* notes region (`boundsRoot`) shrunk by every clipping ancestor (terminal
* side-panel pane hosts, ScrollArea viewports). Tooltips are mounted on
* `document.body` so they escape clipped code blocks; bounding their space
* to the notes pane keeps a completion from rendering over an adjacent
* split pane. Without a mounted `boundsRoot`, falls back to the window
* bounds CodeMirror would use by default.
*/
export const getNoteTooltipSpace = (
boundsRoot: HTMLElement | null | undefined,
doc: Document,
): NoteTooltipSpace => {
if (!boundsRoot || !boundsRoot.isConnected) {
const docElt = doc.documentElement;
return { top: 0, left: 0, right: docElt.clientWidth, bottom: docElt.clientHeight };
}
const rootRect = boundsRoot.getBoundingClientRect();
let space: NoteTooltipSpace = {
top: rootRect.top,
left: rootRect.left,
right: rootRect.right,
bottom: rootRect.bottom,
};
const defaultView = boundsRoot.ownerDocument.defaultView;
if (!defaultView) return space;
for (let node: Element | null = boundsRoot; node; node = node.parentElement) {
const style = defaultView.getComputedStyle(node);
const rect = node.getBoundingClientRect();
if (clipsHorizontally(style)) {
space = {
...space,
left: Math.max(space.left, rect.left),
right: Math.min(space.right, rect.right),
};
}
if (clipsVertically(style)) {
space = {
...space,
top: Math.max(space.top, rect.top),
bottom: Math.min(space.bottom, rect.bottom),
};
}
}
return space;
};
export const syncNoteCodeTooltipStyle = (view: EditorView, space: NoteTooltipSpace): void => {
const width = `${Math.max(0, space.right - space.left)}px`;
const paneStyle = view.dom.ownerDocument.defaultView?.getComputedStyle(view.dom);
const tokens = ["--popover", "--popover-foreground", "--border", "--accent", "--accent-foreground"];
for (const tooltip of view.state.facet(showTooltip)) {
if (!tooltip) continue;
const dom = getTooltip(view, tooltip)?.dom;
if (!dom) continue;
dom.style.setProperty("--note-tooltip-width", width);
// Detached tooltips must retain terminal-side-panel theme overrides.
for (const token of tokens) {
const value = paneStyle?.getPropertyValue(token).trim();
if (value) dom.style.setProperty(token, value);
else dom.style.removeProperty(token);
}
}
};
export const createNoteCodeTooltipExtensions = (
parent: HTMLElement | undefined,
getBoundsRoot?: () => HTMLElement | null,
) => [
// Code blocks and their enclosing note panes can clip editor descendants.
Prec.highest(
tooltips({
parent,
// Tooltips escape those clips via the global parent, so constrain their
// placement to the owning notes pane instead of the whole window.
tooltipSpace: (view: EditorView) => {
const space = getNoteTooltipSpace(getBoundsRoot?.(), view.dom.ownerDocument);
// CodeMirror constrains coordinates and height, but not width. Set
// the available width; its ResizeObserver remeasures after resizing.
syncNoteCodeTooltipStyle(view, space);
return space;
},
}),
),
// CodeMirror carries this theme's scope onto its detached tooltip container.
// Keep these rules local to Notes and use the application's existing colors.
Prec.highest(EditorView.theme({
".cm-tooltip": {
backgroundColor: "hsl(var(--popover))",
color: "hsl(var(--popover-foreground))",
border: "1px solid hsl(var(--border))",
},
".cm-tooltip-autocomplete": {
maxWidth: "var(--note-tooltip-width, 95vw)",
boxSizing: "border-box",
},
".cm-tooltip.cm-tooltip-autocomplete > ul": {
minWidth: "min(250px, max(0px, calc(var(--note-tooltip-width, 95vw) - 2px)))",
maxWidth: "min(700px, max(0px, calc(var(--note-tooltip-width, 95vw) - 2px)))",
},
".cm-tooltip-autocomplete > ul > li[aria-selected]": {
backgroundColor: "hsl(var(--accent))",
color: "hsl(var(--accent-foreground))",
},
})),
];