CB-3481 fix: editor tooltip position (#1678)

This commit is contained in:
Alexey
2023-05-22 22:29:17 +03:00
committed by GitHub
parent bf8dd74d98
commit c45e8d8933
6 changed files with 174 additions and 226 deletions
@@ -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<string, Extension> {
let extensions: Record<string, Extension> = {
tooltips: tooltips({
parent: document.body,
}),
highlightSpecialChars: highlightSpecialChars(),
highlightSelectionMatches: highlightSelectionMatches(),
syntaxHighlighting: syntaxHighlighting(classHighlighter),
@@ -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;
}
}
@@ -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);
}
}
@@ -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<typeof autocompletion>[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<IEditorRef>, ComponentStyle];
export function useEditorAutocompletion(config?: Parameters<typeof autocompletion>[0]): UseEditorAutocompletionResult {
const [editor, setEditor] = useState<IEditorRef | null>(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<HTMLDivElement>('.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,
];
}
@@ -44,8 +44,7 @@ export const SQLCodeEditorPanel: TabContainerPanelComponent<ISqlEditorModeProps>
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<ISqlEditorModeProps>
}],
});
return styled(useStyles(styles, autocompletionStyles))(
return styled(useStyles(styles))(
<box ref={dndBox.setRef}>
<SQLCodeEditorLoader
ref={combinedRef}
ref={setEditorRef}
getValue={() => data.value}
extensions={[ACTIVE_QUERY_EXTENSION, QUERY_STATUS_GUTTER_EXTENSION, autocompletion, sqlDialect]}
readonly={data.readonly}
@@ -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<CompletionConfig>(() => {
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<SqlCompletion>(proposal => ({
label: proposal.displayString,
apply: proposal.replacementString,
boost: proposal.score,
icon: proposal.icon,
})),
];
}
return [
...filteredProposals.map<SqlCompletion>(proposal => ({
label: proposal.displayString,
apply: proposal.replacementString,
boost: proposal.score,
icon: proposal.icon,
})),
];
}
async function completionSource(context: CompletionContext): Promise<CompletionResult | null> {
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<CompletionResult | null> {
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);
}