129 lines
4.9 KiB
TypeScript
129 lines
4.9 KiB
TypeScript
|
|
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))",
|
||
|
|
},
|
||
|
|
})),
|
||
|
|
];
|