mirror of
https://github.com/dbeaver/cloudbeaver.git
synced 2026-09-24 16:04:36 +08:00
CB-3481 fix: editor tooltip position (#1678)
This commit is contained in:
@@ -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),
|
||||
|
||||
+28
-30
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
+3
-4
@@ -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}
|
||||
|
||||
+108
-101
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user