fix(editor): portal SQL hover tooltips above result panes

This commit is contained in:
t8y2 committed 2026-07-31 15:46:15 +08:00
1 parent 1d4ab520f3
commit c78f451177
3 files changed
+79 -2

No files matched your search

@@ -3439,7 +3439,7 @@ onMounted(async () => {
})();
const [
{ EditorView, keymap, rectangularSelection, hoverTooltip, showTooltip, closeHoverTooltips, Decoration, gutter, GutterMarker, lineNumberMarkers, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, crosshairCursor, scrollPastEnd, ViewPlugin },
{ EditorView, keymap, rectangularSelection, hoverTooltip, showTooltip, closeHoverTooltips, Decoration, tooltips, gutter, GutterMarker, lineNumberMarkers, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, crosshairCursor, scrollPastEnd, ViewPlugin },
{ EditorState, EditorSelection, Compartment, Prec, RangeSet, StateEffect, StateField },
langSql,
{ autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap, insertCompletionText, nextSnippetField },
@@ -3854,6 +3854,7 @@ onMounted(async () => {
{ decorations: (v) => v.decorations },
);
const tooltipParent = editorRef.value.closest<HTMLElement>("#root") ?? editorRef.value;
const state = EditorState.create({
doc: props.modelValue,
selection: normalizedEditorSelection(props.initialSelection, props.modelValue.length),
@@ -3902,6 +3903,7 @@ onMounted(async () => {
keymap.of([...defaultKeymap, ...searchKeymap, ...historyKeymap, ...foldKeymap, ...completionKeymap]),
sqlLanguageComp.of(buildSqlLanguageExtension()),
sqlSemanticHighlightComp.of(buildSqlSemanticHighlightExtension()),
tooltips({ parent: tooltipParent }),
completionComp.of(buildSqlCompletionExtension()),
sqlCompletionTheme(EditorView),
codeMirrorTheme.of(theme),
@@ -0,0 +1,62 @@
// @vitest-environment happy-dom
import { EditorState } from "@codemirror/state";
import { EditorView, showTooltip, tooltips, type Tooltip } from "@codemirror/view";
import { describe, expect, it } from "vitest";
function staticTooltip(label: string): Tooltip {
return {
pos: 0,
create: () => {
const dom = document.createElement("div");
dom.dataset.tooltip = label;
return { dom };
},
};
}
describe("CodeMirror tooltip app-root portal", () => {
it("keeps containers independent and removes them with each editor", () => {
document.body.innerHTML = '<div id="root"><div data-split-pane><div data-editor="first"></div><div data-editor="second"></div></div></div>';
const root = document.querySelector<HTMLElement>("#root")!;
const splitPane = document.querySelector<HTMLElement>("[data-split-pane]")!;
const firstEditor = document.querySelector<HTMLElement>('[data-editor="first"]')!;
const secondEditor = document.querySelector<HTMLElement>('[data-editor="second"]')!;
root.style.overflow = "hidden";
splitPane.style.overflow = "hidden";
const firstView = new EditorView({
parent: firstEditor,
state: EditorState.create({
doc: "select first",
extensions: [tooltips({ parent: root }), showTooltip.of(staticTooltip("first"))],
}),
});
const secondView = new EditorView({
parent: secondEditor,
state: EditorState.create({
doc: "select second",
extensions: [tooltips({ parent: root }), showTooltip.of(staticTooltip("second"))],
}),
});
const firstTooltip = root.querySelector<HTMLElement>('[data-tooltip="first"]')!;
const secondTooltip = root.querySelector<HTMLElement>('[data-tooltip="second"]')!;
const firstContainer = firstTooltip.parentElement!;
const secondContainer = secondTooltip.parentElement!;
expect(splitPane.contains(firstTooltip)).toBe(false);
expect(firstContainer.parentElement).toBe(root);
expect(secondContainer.parentElement).toBe(root);
expect(firstContainer).not.toBe(secondContainer);
expect(firstTooltip.style.position).toBe("fixed");
expect(secondTooltip.style.position).toBe("fixed");
firstView.destroy();
expect(firstContainer.isConnected).toBe(false);
expect(secondContainer.isConnected).toBe(true);
secondView.destroy();
expect(secondContainer.isConnected).toBe(false);
});
});
@@ -2,9 +2,22 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
const globalStylesSource = readFileSync(new URL("../../../styles/globals.css", import.meta.url), "utf8");
describe("QueryEditor tooltip container", () => {
it("keeps CodeMirror tooltips inside the editor instead of extending document.body", () => {
it("portals CodeMirror tooltips to the stable app root without using document.body", () => {
expect(queryEditorSource).toContain('const tooltipParent = editorRef.value.closest<HTMLElement>("#root") ?? editorRef.value;');
expect(queryEditorSource).toContain("tooltips({ parent: tooltipParent })");
expect(queryEditorSource).not.toContain("tooltips({ parent: document.body })");
});
it("keeps the app root viewport-sized without a transformed containing block", () => {
const rootRule = globalStylesSource.match(/html,\s*body,\s*#root\s*\{(?<declarations>[^}]*)\}/)?.groups?.declarations;
expect(rootRule).toBeDefined();
expect(rootRule).toMatch(/width:\s*100%/);
expect(rootRule).toMatch(/height:\s*100%/);
expect(rootRule).toMatch(/overflow:\s*hidden/);
expect(rootRule).not.toMatch(/(?:^|\s)(?:transform|contain)\s*:/);
});
});