fix(editor): prevent tooltip portal from covering app

This commit is contained in:
t8y2 2026-07-31 17:46:11 +08:00
parent 12373868ec
commit 4f9ea3f5cc
No known key found for this signature in database
4 changed files with 28 additions and 10 deletions

View File

@ -2613,6 +2613,7 @@ onUnmounted(() => {
@saved="onQueryEditorObjectSourceSaved"
/>
</TooltipProvider>
<div id="dbx-query-editor-tooltip-root" class="fixed left-0 top-0 z-[70] h-0 w-0 overflow-visible" />
</div>
</template>

View File

@ -3857,7 +3857,9 @@ onMounted(async () => {
{ decorations: (v) => v.decorations },
);
const tooltipParent = editorRef.value.closest<HTMLElement>("#root") ?? editorRef.value;
const editorElement = editorRef.value;
if (!editorElement) return;
const tooltipParent = editorElement.closest<HTMLElement>("#root")?.querySelector<HTMLElement>("#dbx-query-editor-tooltip-root") ?? editorElement;
const state = EditorState.create({
doc: props.modelValue,
selection: normalizedEditorSelection(props.initialSelection, props.modelValue.length),
@ -4189,7 +4191,7 @@ onMounted(async () => {
],
});
view.value = new EditorView({ state, parent: editorRef.value });
view.value = new EditorView({ state, parent: editorElement });
registerEditorScrollbarPointerGuard(view.value);
view.value.scrollDOM.addEventListener("scroll", scheduleEditorViewportEmit, {
passive: true,

View File

@ -3,6 +3,7 @@
import { EditorState } from "@codemirror/state";
import { EditorView, showTooltip, tooltips, type Tooltip } from "@codemirror/view";
import { describe, expect, it } from "vitest";
import { editorFontTheme } from "@/lib/editor/editorThemes";
function staticTooltip(label: string): Tooltip {
return {
@ -15,28 +16,31 @@ function staticTooltip(label: string): Tooltip {
};
}
describe("CodeMirror tooltip app-root portal", () => {
describe("CodeMirror tooltip host", () => {
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>';
document.body.innerHTML = '<div id="root"><div data-app><div data-split-pane><div data-editor="first"></div><div data-editor="second"></div></div><div id="dbx-query-editor-tooltip-root" style="position: fixed; width: 0; height: 0; overflow: visible"></div></div></div>';
const root = document.querySelector<HTMLElement>("#root")!;
const splitPane = document.querySelector<HTMLElement>("[data-split-pane]")!;
const tooltipHost = document.querySelector<HTMLElement>("#dbx-query-editor-tooltip-root")!;
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 editorTheme = EditorView.theme({ "&": { backgroundColor: "rgb(255, 255, 255)" } });
const editorExtensions = [editorTheme, editorFontTheme(EditorView, 13, "monospace", { fixedHeight: true })];
const firstView = new EditorView({
parent: firstEditor,
state: EditorState.create({
doc: "select first",
extensions: [tooltips({ parent: root }), showTooltip.of(staticTooltip("first"))],
extensions: [...editorExtensions, tooltips({ parent: tooltipHost }), showTooltip.of(staticTooltip("first"))],
}),
});
const secondView = new EditorView({
parent: secondEditor,
state: EditorState.create({
doc: "select second",
extensions: [tooltips({ parent: root }), showTooltip.of(staticTooltip("second"))],
extensions: [...editorExtensions, tooltips({ parent: tooltipHost }), showTooltip.of(staticTooltip("second"))],
}),
});
@ -46,8 +50,13 @@ describe("CodeMirror tooltip app-root portal", () => {
const secondContainer = secondTooltip.parentElement!;
expect(splitPane.contains(firstTooltip)).toBe(false);
expect(firstContainer.parentElement).toBe(root);
expect(secondContainer.parentElement).toBe(root);
expect(firstContainer.parentElement).toBe(tooltipHost);
expect(secondContainer.parentElement).toBe(tooltipHost);
expect(root.children).toHaveLength(1);
expect(tooltipHost.style.width).toBe("0px");
expect(tooltipHost.style.height).toBe("0px");
expect(getComputedStyle(firstContainer).height).toBe("100%");
expect(getComputedStyle(firstContainer).backgroundColor).toBe("rgb(255, 255, 255)");
expect(firstContainer).not.toBe(secondContainer);
expect(firstTooltip.style.position).toBe("fixed");
expect(secondTooltip.style.position).toBe("fixed");

View File

@ -2,13 +2,19 @@ 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 appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
const globalStylesSource = readFileSync(new URL("../../../styles/globals.css", import.meta.url), "utf8");
describe("QueryEditor tooltip container", () => {
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;');
it("portals CodeMirror tooltips to a zero-sized app host", () => {
expect(queryEditorSource).toContain("const editorElement = editorRef.value;");
expect(queryEditorSource).toContain("if (!editorElement) return;");
expect(queryEditorSource).toContain('querySelector<HTMLElement>("#dbx-query-editor-tooltip-root") ?? editorElement');
expect(queryEditorSource).toContain("tooltips({ parent: tooltipParent })");
expect(queryEditorSource).toContain("new EditorView({ state, parent: editorElement })");
expect(queryEditorSource).not.toContain("tooltips({ parent: document.body })");
expect(appSource).toContain('id="dbx-query-editor-tooltip-root"');
expect(appSource).toContain('class="fixed left-0 top-0 z-[70] h-0 w-0 overflow-visible"');
});
it("keeps the app root viewport-sized without a transformed containing block", () => {