From c45e8d893301f9d8843fc928c6f5f15f4e8a3e10 Mon Sep 17 00:00:00 2001 From: Alexey Date: Mon, 22 May 2023 23:29:17 +0400 Subject: [PATCH] CB-3481 fix: editor tooltip position (#1678) --- .../src/getDefaultExtensions.ts | 5 +- .../_base-code-editor-autocompletion.scss | 58 +++-- .../src/theme/_base-code-editor-tooltip.scss | 16 +- .../src/useEditorAutocompletion.ts | 105 ++------- .../SQLCodeEditorPanel/SQLCodeEditorPanel.tsx | 7 +- .../SQLEditor/useSqlDialectAutocompletion.ts | 209 +++++++++--------- 6 files changed, 174 insertions(+), 226 deletions(-) diff --git a/webapp/packages/plugin-codemirror6/src/getDefaultExtensions.ts b/webapp/packages/plugin-codemirror6/src/getDefaultExtensions.ts index b57ea9b7f2..c00d77f244 100644 --- a/webapp/packages/plugin-codemirror6/src/getDefaultExtensions.ts +++ b/webapp/packages/plugin-codemirror6/src/getDefaultExtensions.ts @@ -11,7 +11,7 @@ import { defaultKeymap, indentWithTab } from '@codemirror/commands'; import { foldGutter, indentOnInput, syntaxHighlighting, bracketMatching } from '@codemirror/language'; import { highlightSelectionMatches } from '@codemirror/search'; import type { Extension } from '@codemirror/state'; -import { lineNumbers, highlightSpecialChars, dropCursor, rectangularSelection, crosshairCursor, keymap, highlightActiveLineGutter, highlightActiveLine } from '@codemirror/view'; +import { tooltips, lineNumbers, highlightSpecialChars, dropCursor, rectangularSelection, crosshairCursor, keymap, highlightActiveLineGutter, highlightActiveLine } from '@codemirror/view'; import { classHighlighter } from '@lezer/highlight'; // @TODO allow to configure bindings outside of the component @@ -30,6 +30,9 @@ export interface IDefaultExtensions { /** Provides the necessary extensions to establish a basic editor */ export function getDefaultExtensions(options?: IDefaultExtensions): Record { let extensions: Record = { + tooltips: tooltips({ + parent: document.body, + }), highlightSpecialChars: highlightSpecialChars(), highlightSelectionMatches: highlightSelectionMatches(), syntaxHighlighting: syntaxHighlighting(classHighlighter), diff --git a/webapp/packages/plugin-codemirror6/src/theme/_base-code-editor-autocompletion.scss b/webapp/packages/plugin-codemirror6/src/theme/_base-code-editor-autocompletion.scss index a959dafa28..c166524be8 100644 --- a/webapp/packages/plugin-codemirror6/src/theme/_base-code-editor-autocompletion.scss +++ b/webapp/packages/plugin-codemirror6/src/theme/_base-code-editor-autocompletion.scss @@ -7,42 +7,40 @@ */ @mixin base-code-editor-autocompletion() { - .editor { - .cm-tooltip.cm-tooltip-autocomplete { - &>:global(ul) { - border-radius: var(--theme-form-element-radius); - } + .cm-tooltip.cm-tooltip-autocomplete { + &>:global(ul) { + border-radius: var(--theme-form-element-radius); + } - & :global(li) { - display: flex; - align-items: center; - } + & :global(li) { + display: flex; + align-items: center; + } - & .cm-completionIcon { - opacity: 1; - width: 16px; - height: 16px; - padding: 0; - margin-right: .6em; + & .cm-completionIcon { + opacity: 1; + width: 16px; + height: 16px; + padding: 0; + margin-right: .6em; - &:not(:global([src])) { - opacity: 0; - } - } - - & :global(li):hover { - @include mdc-theme-prop(background, sub-secondary, false); - @include mdc-theme-prop(color, on-secondary, false); - } - - & :global(li):global([aria-selected=true]) { - @include mdc-theme-prop(background, secondary, false); - @include mdc-theme-prop(color, text-primary-on-light, false); + &:not(:global([src])) { + opacity: 0; } } - .cm-tooltip.cm-completionInfo { - margin-left: 8px; + & :global(li):hover { + @include mdc-theme-prop(background, sub-secondary, false); + @include mdc-theme-prop(color, on-secondary, false); + } + + & :global(li):global([aria-selected=true]) { + @include mdc-theme-prop(background, secondary, false); + @include mdc-theme-prop(color, text-primary-on-light, false); } } + + .cm-tooltip.cm-completionInfo { + margin-left: 8px; + } } \ No newline at end of file diff --git a/webapp/packages/plugin-codemirror6/src/theme/_base-code-editor-tooltip.scss b/webapp/packages/plugin-codemirror6/src/theme/_base-code-editor-tooltip.scss index 6d8429de78..2d90004448 100644 --- a/webapp/packages/plugin-codemirror6/src/theme/_base-code-editor-tooltip.scss +++ b/webapp/packages/plugin-codemirror6/src/theme/_base-code-editor-tooltip.scss @@ -7,14 +7,12 @@ */ @mixin base-code-editor-tooltip() { - .editor { - .cm-tooltip { - @include mdc-theme-prop(background-color, surface, false); - @include mdc-theme-prop(color, on-surface, false); - @include mdc-elevation(3); - @include mdc-typography(body2); - border: none; - border-radius: var(--theme-form-element-radius); - } + .cm-tooltip { + @include mdc-theme-prop(background-color, surface, false); + @include mdc-theme-prop(color, on-surface, false); + @include mdc-elevation(3); + @include mdc-typography(body2); + border: none; + border-radius: var(--theme-form-element-radius); } } \ No newline at end of file diff --git a/webapp/packages/plugin-codemirror6/src/useEditorAutocompletion.ts b/webapp/packages/plugin-codemirror6/src/useEditorAutocompletion.ts index 28c3f4e85d..f2fc318830 100644 --- a/webapp/packages/plugin-codemirror6/src/useEditorAutocompletion.ts +++ b/webapp/packages/plugin-codemirror6/src/useEditorAutocompletion.ts @@ -1,91 +1,34 @@ -import type React from 'react'; -import { useLayoutEffect, useState } from 'react'; -import { css } from 'reshadow'; +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2023 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { useMemo, useState } from 'react'; -import type { ComponentStyle } from '@cloudbeaver/core-theming'; -import { clsx } from '@cloudbeaver/core-utils'; import { autocompletion, startCompletion } from '@codemirror/autocomplete'; -import type { Extension, EditorState } from '@codemirror/state'; +import type { Extension } from '@codemirror/state'; import { keymap } from '@codemirror/view'; -import type { IEditorRef } from './IEditorRef'; +export type CompletionConfig = Parameters[0]; -const styles = css` - :global(.cm-fix-position) { - margin-left: var(--cm-fix-position-left); - margin-top: var(--cm-fix-position-top); - z-index: 100; - } - :global(.cm-fixed-point) { - position: fixed; - top: 0; - left: 0; - width: 0; - height: 0; - } -`; +export function useEditorAutocompletion(config?: CompletionConfig): Extension[] { + const [autocompleteKeyMap] = useState(() => + keymap.of([ + { key: 'Alt-Space', run: startCompletion, preventDefault: true }, + { key: 'Shift-Ctrl-Space', run: startCompletion, preventDefault: true }, + ]) + ); -export type UseEditorAutocompletionResult = [Extension, React.ForwardedRef, ComponentStyle]; - -export function useEditorAutocompletion(config?: Parameters[0]): UseEditorAutocompletionResult { - const [editor, setEditor] = useState(null); - - useLayoutEffect(() => { - const container = editor?.container; - - if (!container) { - return; - } - - // we need this element to calculate the position of the autocompletion tooltip - // because codemirror fixed position is incorrect in case of translated container - let element = container.querySelector('.cm-fixed-point'); - - if (!element) { - element = document.createElement('div'); - element.className = 'cm-fixed-point'; - container.append(element); - } - - const resizeObserver = new ResizeObserver(() => { - if (!element) { - return; - } - - let top = 0; - let left = 0; - - // if (!isSafari) { - // seems like it's not working on iPad OS - const viewportOffset = element.getBoundingClientRect(); - top = Math.floor(viewportOffset.top); - left = Math.floor(viewportOffset.left); - // } - - container.style.setProperty('--cm-fix-position-top', `${-top}px`); - container.style.setProperty('--cm-fix-position-left', `${-left}px`); - }); - - resizeObserver.observe(container); - - return () => { - resizeObserver.disconnect(); - }; - }); + const autocompletionExtension = useMemo(() => autocompletion({ + ...config, + closeOnBlur: false, + }), [config]); return [ - [ - autocompletion({ - ...config, - tooltipClass: (state: EditorState) => clsx(config?.tooltipClass?.(state), 'cm-fix-position'), - closeOnBlur: false, - }), - keymap.of([ - { key: 'Alt-Space', run: startCompletion, preventDefault: true }, - { key: 'Shift-Ctrl-Space', run: startCompletion, preventDefault: true }, - ]), - ], - setEditor, - styles, + autocompletionExtension, + autocompleteKeyMap, ]; } diff --git a/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/SQLCodeEditorPanel.tsx b/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/SQLCodeEditorPanel.tsx index 9faeac5070..36b788db88 100644 --- a/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/SQLCodeEditorPanel.tsx +++ b/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/SQLCodeEditorPanel.tsx @@ -44,8 +44,7 @@ export const SQLCodeEditorPanel: TabContainerPanelComponent const editor = useSQLCodeEditor(editorRef); const panel = useSQLCodeEditorPanel(data, editor); - const [autocompletion, setEditor, autocompletionStyles] = useSqlDialectAutocompletion(data); - const combinedRef = useCombinedRef(setEditorRef, setEditor); + const autocompletion = useSqlDialectAutocompletion(data); const sqlDialect = useSqlDialectExtension(data.dialect); const dndBox = useDNDBox({ @@ -89,10 +88,10 @@ export const SQLCodeEditorPanel: TabContainerPanelComponent }], }); - return styled(useStyles(styles, autocompletionStyles))( + return styled(useStyles(styles))( data.value} extensions={[ACTIVE_QUERY_EXTENSION, QUERY_STATUS_GUTTER_EXTENSION, autocompletion, sqlDialect]} readonly={data.readonly} diff --git a/webapp/packages/plugin-sql-editor-new/src/SQLEditor/useSqlDialectAutocompletion.ts b/webapp/packages/plugin-sql-editor-new/src/SQLEditor/useSqlDialectAutocompletion.ts index 855fb3979c..3b0b820335 100644 --- a/webapp/packages/plugin-sql-editor-new/src/SQLEditor/useSqlDialectAutocompletion.ts +++ b/webapp/packages/plugin-sql-editor-new/src/SQLEditor/useSqlDialectAutocompletion.ts @@ -6,11 +6,13 @@ * you may not use this file except in compliance with the License. */ -import { createComplexLoader, useComplexLoader } from '@cloudbeaver/core-blocks'; +import { useState } from 'react'; + +import { createComplexLoader, useComplexLoader, useObjectRef } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; import { LocalizationService } from '@cloudbeaver/core-localization'; import { GlobalConstants } from '@cloudbeaver/core-utils'; -import type { Completion, CompletionContext, CompletionResult, UseEditorAutocompletionResult } from '@cloudbeaver/plugin-codemirror6'; +import type { Completion, CompletionConfig, CompletionContext, CompletionResult, Extension } from '@cloudbeaver/plugin-codemirror6'; import type { ISQLEditorData, SQLProposal } from '@cloudbeaver/plugin-sql-editor'; const codemirrorComplexLoader = createComplexLoader(() => import('@cloudbeaver/plugin-codemirror6')); @@ -22,117 +24,122 @@ type SqlCompletion = Completion & { const CLOSE_CHARACTERS = /[\s()[\]{};:>,=\\*]/; const COMPLETION_WORD = /[\w*]*/; -export function useSqlDialectAutocompletion(data: ISQLEditorData): UseEditorAutocompletionResult { +export function useSqlDialectAutocompletion(data: ISQLEditorData): Extension[] { const { closeCompletion, useEditorAutocompletion } = useComplexLoader(codemirrorComplexLoader); const localizationService = useService(LocalizationService); + const optionsRef = useObjectRef({ data }); - function getOptionsFromProposals(explicit: boolean, word: string, proposals: SQLProposal[]): SqlCompletion[] { - const wordLowerCase = word.toLocaleLowerCase(); - const hasSameName = proposals.some( - ({ displayString }) => displayString.toLocaleLowerCase() === wordLowerCase - ); - const filteredProposals = proposals.filter(({ displayString }) => ( - word === '*' - || ( - displayString.toLocaleLowerCase() !== wordLowerCase - && displayString.toLocaleLowerCase().startsWith(wordLowerCase) - ) - )) - .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); + const [config] = useState(() => { + function getOptionsFromProposals(explicit: boolean, word: string, proposals: SQLProposal[]): SqlCompletion[] { + const wordLowerCase = word.toLocaleLowerCase(); + const hasSameName = proposals.some( + ({ displayString }) => displayString.toLocaleLowerCase() === wordLowerCase + ); + const filteredProposals = proposals.filter(({ displayString }) => ( + word === '*' + || ( + displayString.toLocaleLowerCase() !== wordLowerCase + && displayString.toLocaleLowerCase().startsWith(wordLowerCase) + ) + )) + .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); - if (filteredProposals.length === 0 && !hasSameName && explicit) { - return [{ - apply: closeCompletion, - label: localizationService.translate('sql_editor_hint_empty'), - }]; + if (filteredProposals.length === 0 && !hasSameName && explicit) { + return [{ + apply: closeCompletion, + label: localizationService.translate('sql_editor_hint_empty'), + }]; + } + + return [ + ...filteredProposals.map(proposal => ({ + label: proposal.displayString, + apply: proposal.replacementString, + boost: proposal.score, + icon: proposal.icon, + })), + ]; } - return [ - ...filteredProposals.map(proposal => ({ - label: proposal.displayString, - apply: proposal.replacementString, - boost: proposal.score, - icon: proposal.icon, - })), - ]; - } - - async function completionSource(context: CompletionContext): Promise { - if (context.matchBefore(CLOSE_CHARACTERS) && !context.explicit) { - return null; - } - - const word = context.matchBefore(COMPLETION_WORD); - - if (word === null) { - return null; - } - - try { - const startPos = context.pos; - const proposals = await data.getHintProposals(startPos, !context.explicit); - const limitIsMet = data.hintsLimitIsMet; - - const result: CompletionResult = { - from: word.from, - options: getOptionsFromProposals(context.explicit, word.text, proposals), - update(current, from, to, context) { - if (startPos > context.pos) { - return null; - } - - if (current.options.some(option => option.apply === closeCompletion)) { - return null; - } - - if (limitIsMet) { - return null; - } - - if (context.matchBefore(CLOSE_CHARACTERS) && !context.explicit) { - return null; - } - - const word = context.matchBefore(COMPLETION_WORD); - - if (word === null) { - return null; - } - - return { - ...current, - options: getOptionsFromProposals(context.explicit, word.text, proposals), - }; - }, - filter: false, - }; - - if (result.options.length === 0) { + async function completionSource(context: CompletionContext): Promise { + if (context.matchBefore(CLOSE_CHARACTERS) && !context.explicit) { return null; } - return result; - } catch { - return null; - } - } + const word = context.matchBefore(COMPLETION_WORD); - return useEditorAutocompletion({ - override: [completionSource], - addToOptions: [{ - render(completion: SqlCompletion) { - const icon = document.createElement('img'); - icon.classList.add('cm-completionIcon'); - icon.setAttribute('aria-hidden', 'true'); + if (word === null) { + return null; + } - if (completion.icon) { - icon.setAttribute('src', GlobalConstants.absoluteUrl(completion.icon)); + try { + const startPos = context.pos; + const proposals = await optionsRef.data.getHintProposals(startPos, !context.explicit); + const limitIsMet = optionsRef.data.hintsLimitIsMet; + + const result: CompletionResult = { + from: word.from, + options: getOptionsFromProposals(context.explicit, word.text, proposals), + update(current, from, to, context) { + if (startPos > context.pos) { + return null; + } + + if (current.options.some(option => option.apply === closeCompletion)) { + return null; + } + + if (limitIsMet) { + return null; + } + + if (context.matchBefore(CLOSE_CHARACTERS) && !context.explicit) { + return null; + } + + const word = context.matchBefore(COMPLETION_WORD); + + if (word === null) { + return null; + } + + return { + ...current, + options: getOptionsFromProposals(context.explicit, word.text, proposals), + }; + }, + filter: false, + }; + + if (result.options.length === 0) { + return null; } - return icon; - }, - position: 20, - }], - icons: false, // disable native symbol based icons + return result; + } catch { + return null; + } + } + + return { + override: [completionSource], + addToOptions: [{ + render(completion: SqlCompletion) { + const icon = document.createElement('img'); + icon.classList.add('cm-completionIcon'); + icon.setAttribute('aria-hidden', 'true'); + + if (completion.icon) { + icon.setAttribute('src', GlobalConstants.absoluteUrl(completion.icon)); + } + + return icon; + }, + position: 20, + }], + icons: false, // disable native symbol based icons + }; }); + + return useEditorAutocompletion(config); } \ No newline at end of file