mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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))",
|
||||
},
|
||||
})),
|
||||
];
|
||||
Reference in New Issue
Block a user