diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index d0a131782e..cb7f4e673a 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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("#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), diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorTooltipParent.dom.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorTooltipParent.dom.spec.ts new file mode 100644 index 0000000000..e2054bcbca --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorTooltipParent.dom.spec.ts @@ -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 = '
'; + const root = document.querySelector("#root")!; + const splitPane = document.querySelector("[data-split-pane]")!; + const firstEditor = document.querySelector('[data-editor="first"]')!; + const secondEditor = document.querySelector('[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('[data-tooltip="first"]')!; + const secondTooltip = root.querySelector('[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); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorTooltipParent.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorTooltipParent.spec.ts index 0b3deb3793..15fa10ee08 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorTooltipParent.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorTooltipParent.spec.ts @@ -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("#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*\{(?[^}]*)\}/)?.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*:/); + }); });