diff --git a/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/QueryEditor.tsx b/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/QueryEditor.tsx index a2a2c7a42..2a4290264 100644 --- a/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/QueryEditor.tsx +++ b/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/QueryEditor.tsx @@ -9,6 +9,7 @@ import { partialWarning, seriesFetchedWarning } from "./warningText"; import { AutocompleteOptions } from "../../Main/Autocomplete/Autocomplete"; import useDeviceDetect from "../../../hooks/useDeviceDetect"; import { useQueryState } from "../../../state/query/QueryStateContext"; +import debounce from "lodash.debounce"; export interface QueryEditorProps { onChange: (query: string) => void; @@ -40,9 +41,12 @@ const QueryEditor: FC = ({ const { isMobile } = useDeviceDetect(); const [openAutocomplete, setOpenAutocomplete] = useState(false); - const [caretPosition, setCaretPosition] = useState([0, 0]); + const [caretPosition, setCaretPosition] = useState<[number, number]>([0, 0]); const autocompleteAnchorEl = useRef(null); + const [showAutocomplete, setShowAutocomplete] = useState(autocomplete); + const debouncedSetShowAutocomplete = useRef(debounce(setShowAutocomplete, 500)).current; + const warning = [ { show: stats?.seriesFetched === "0" && !stats.resultLength, @@ -58,8 +62,9 @@ const QueryEditor: FC = ({ label = `${label} (${stats.executionTimeMsec || 0}ms)`; } - const handleSelect = (val: string) => { + const handleSelect = (val: string, caretPosition: number) => { onChange(val); + setCaretPosition([caretPosition, caretPosition]); }; const handleKeyDown = (e: KeyboardEvent) => { @@ -100,14 +105,19 @@ const QueryEditor: FC = ({ setOpenAutocomplete(!!val.length); }; - const handleChangeCaret = (val: number[]) => { - setCaretPosition(val); + const handleChangeCaret = (val: [number, number]) => { + setCaretPosition(prev => prev[0] === val[0] && prev[1] === val[1] ? prev : val); }; useEffect(() => { setOpenAutocomplete(autocomplete); }, [autocompleteQuick]); + useEffect(() => { + setShowAutocomplete(false); + debouncedSetShowAutocomplete(true); + }, [caretPosition]); + return (
= ({ onChangeCaret={handleChangeCaret} disabled={disabled} inputmode={"search"} + caretPosition={caretPosition} /> - {autocomplete && ( + {showAutocomplete && autocomplete && ( diff --git a/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/QueryEditorAutocomplete.tsx b/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/QueryEditorAutocomplete.tsx index edbd85d35..c19934895 100644 --- a/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/QueryEditorAutocomplete.tsx +++ b/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/QueryEditorAutocomplete.tsx @@ -1,7 +1,6 @@ -import React, { FC, Ref, useState, useEffect, useMemo } from "preact/compat"; +import React, { FC, Ref, useState, useEffect, useMemo, useCallback } from "preact/compat"; import Autocomplete, { AutocompleteOptions } from "../../Main/Autocomplete/Autocomplete"; import { useFetchQueryOptions } from "../../../hooks/useFetchQueryOptions"; -import { getTextWidth } from "../../../utils/uplot"; import { escapeRegexp, hasUnclosedQuotes } from "../../../utils/regexp"; import useGetMetricsQL from "../../../hooks/useGetMetricsQL"; import { QueryContextType } from "../../../types"; @@ -10,8 +9,9 @@ import { AUTOCOMPLETE_LIMITS } from "../../../constants/queryAutocomplete"; interface QueryEditorAutocompleteProps { value: string; anchorEl: Ref; - caretPosition: number[]; - onSelect: (val: string) => void; + caretPosition: [number, number]; // [start, end] + hasHelperText: boolean; + onSelect: (val: string, caretPosition: number) => void; onFoundOptions: (val: AutocompleteOptions[]) => void; } @@ -19,16 +19,24 @@ const QueryEditorAutocomplete: FC = ({ value, anchorEl, caretPosition, + hasHelperText, onSelect, onFoundOptions }) => { - const [leftOffset, setLeftOffset] = useState(0); + const [offsetPos, setOffsetPos] = useState({ top: 0, left: 0 }); const metricsqlFunctions = useGetMetricsQL(); + const values = useMemo(() => { + if (caretPosition[0] !== caretPosition[1]) return { beforeCursor: value, afterCursor: "" }; + const beforeCursor = value.substring(0, caretPosition[0]); + const afterCursor = value.substring(caretPosition[1]); + return { beforeCursor, afterCursor }; + }, [value, caretPosition]); + const exprLastPart = useMemo(() => { - const parts = value.split("}"); + const parts = values.beforeCursor.split("}"); return parts[parts.length - 1]; - }, [value]); + }, [values]); const metric = useMemo(() => { const regexp = /\b[^{}(),\s]+(?={|$)/g; @@ -43,7 +51,7 @@ const QueryEditorAutocomplete: FC = ({ }, [exprLastPart]); const shouldSuppressAutoSuggestion = (value: string) => { - const pattern = /([(),+\-*/^]|\b(?:or|and|unless|default|ifnot|if|group_left|group_right)\b)/; + const pattern = /([{(),+\-*/^]|\b(?:or|and|unless|default|ifnot|if|group_left|group_right)\b)/; const parts = value.split(/\s+/); const partsCount = parts.length; const lastPart = parts[partsCount - 1]; @@ -55,7 +63,7 @@ const QueryEditorAutocomplete: FC = ({ }; const context = useMemo(() => { - if (!value || value.endsWith("}") || shouldSuppressAutoSuggestion(value)) { + if (!values.beforeCursor || values.beforeCursor.endsWith("}") || shouldSuppressAutoSuggestion(values.beforeCursor)) { return QueryContextType.empty; } @@ -63,19 +71,19 @@ const QueryEditorAutocomplete: FC = ({ const labelValueRegexp = new RegExp(`(${escapeRegexp(metric)})?{?.+${escapeRegexp(label)}(=|!=|=~|!~)"?([^"]*)$`, "g"); switch (true) { - case labelValueRegexp.test(value): + case labelValueRegexp.test(values.beforeCursor): return QueryContextType.labelValue; - case labelRegexp.test(value): + case labelRegexp.test(values.beforeCursor): return QueryContextType.label; default: return QueryContextType.metricsql; } - }, [value, metric, label]); + }, [values, metric, label]); const valueByContext = useMemo(() => { - const wordMatch = value.match(/([\w_\-.:/]+(?![},]))$/); + const wordMatch = values.beforeCursor.match(/([\w_\-.:/]+(?![},]))$/); return wordMatch ? wordMatch[0] : ""; - }, [value]); + }, [values.beforeCursor]); const { metrics, labels, labelValues, loading } = useFetchQueryOptions({ valueByContext, @@ -97,8 +105,10 @@ const QueryEditorAutocomplete: FC = ({ } }, [context, metrics, labels, labelValues]); - const handleSelect = (insert: string) => { + const handleSelect = useCallback((insert: string) => { // Find the start and end of valueByContext in the query string + const value = values.beforeCursor; + let valueAfterCursor = values.afterCursor; const startIndexOfValueByContext = value.lastIndexOf(valueByContext, caretPosition[0]); const endIndexOfValueByContext = startIndexOfValueByContext + valueByContext.length; @@ -110,26 +120,59 @@ const QueryEditorAutocomplete: FC = ({ if (context === QueryContextType.labelValue) { const quote = "\""; const needsQuote = /(?:=|!=|=~|!~)$/.test(beforeValueByContext); + valueAfterCursor = valueAfterCursor.replace(/^[^\s"|},]*/, ""); insert = `${needsQuote ? quote : ""}${insert}`; } + if (context === QueryContextType.label) { + valueAfterCursor = valueAfterCursor.replace(/^[^\s=!,{}()"|+\-/*^]*/, ""); + } + + if (context === QueryContextType.metricsql) { + valueAfterCursor = valueAfterCursor.replace(/^[^\s[\]{}()"|+\-/*^]*/, ""); + } // Assemble the new value with the inserted text - const newVal = `${beforeValueByContext}${insert}${afterValueByContext}`; - onSelect(newVal); - }; + const newVal = `${beforeValueByContext}${insert}${afterValueByContext}${valueAfterCursor}`; + onSelect(newVal, beforeValueByContext.length + insert.length); + }, [values]); useEffect(() => { if (!anchorEl.current) { - setLeftOffset(0); + setOffsetPos({ top: 0, left: 0 }); return; } - const style = window.getComputedStyle(anchorEl.current); + const element = anchorEl.current.querySelector("textarea") || anchorEl.current; + const style = window.getComputedStyle(element); const fontSize = `${style.getPropertyValue("font-size")}`; const fontFamily = `${style.getPropertyValue("font-family")}`; - const offset = getTextWidth(value, `${fontSize} ${fontFamily}`); - setLeftOffset(offset); - }, [anchorEl, caretPosition]); + const lineHeight = parseInt(`${style.getPropertyValue("line-height")}`); + + const span = document.createElement("div"); + span.style.font = `${fontSize} ${fontFamily}`; + span.style.padding = style.getPropertyValue("padding"); + span.style.lineHeight = `${lineHeight}px`; + span.style.width = `${element.offsetWidth}px`; + span.style.maxWidth = `${element.offsetWidth}px`; + span.style.whiteSpace = style.getPropertyValue("white-space"); + span.style.overflowWrap = style.getPropertyValue("overflow-wrap"); + + const marker = document.createElement("span"); + span.appendChild(document.createTextNode(values.beforeCursor)); + span.appendChild(marker); + span.appendChild(document.createTextNode(values.afterCursor)); + document.body.appendChild(span); + + const spanRect = span.getBoundingClientRect(); + const markerRect = marker.getBoundingClientRect(); + + const leftOffset = markerRect.left - spanRect.left; + const topOffset = markerRect.bottom - spanRect.bottom - (hasHelperText ? lineHeight : 0); + setOffsetPos({ top: topOffset, left: leftOffset }); + + span.remove(); + marker.remove(); + }, [anchorEl, caretPosition, hasHelperText]); return ( <> @@ -140,7 +183,7 @@ const QueryEditorAutocomplete: FC = ({ options={options} anchor={anchorEl} minLength={0} - offset={{ top: 0, left: leftOffset }} + offset={offsetPos} onSelect={handleSelect} onFoundOptions={onFoundOptions} maxDisplayResults={{ diff --git a/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/style.scss b/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/style.scss index db48a9cf1..b012403ae 100644 --- a/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/style.scss +++ b/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/style.scss @@ -2,4 +2,13 @@ .vm-query-editor { position: relative; + + .marker-detection { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + z-index: -9999; + visibility: hidden; + } } diff --git a/app/vmui/packages/vmui/src/components/Main/Autocomplete/Autocomplete.tsx b/app/vmui/packages/vmui/src/components/Main/Autocomplete/Autocomplete.tsx index ab83c2518..bbb15c76f 100644 --- a/app/vmui/packages/vmui/src/components/Main/Autocomplete/Autocomplete.tsx +++ b/app/vmui/packages/vmui/src/components/Main/Autocomplete/Autocomplete.tsx @@ -120,7 +120,7 @@ const Autocomplete: FC = ({ const handleKeyDown = useCallback((e: KeyboardEvent) => { const { key, ctrlKey, metaKey, shiftKey } = e; const modifiers = ctrlKey || metaKey || shiftKey; - const hasOptions = foundOptions.length; + const hasOptions = foundOptions.length && !hideFoundedOptions; if (key === "ArrowUp" && !modifiers && hasOptions) { e.preventDefault(); @@ -148,7 +148,7 @@ const Autocomplete: FC = ({ if (key === "Escape") { handleCloseAutocomplete(); } - }, [focusOption, foundOptions, handleCloseAutocomplete, onSelect, selected]); + }, [focusOption, foundOptions, hideFoundedOptions, handleCloseAutocomplete, onSelect, selected]); useEffect(() => { setOpenAutocomplete(value.length >= minLength); diff --git a/app/vmui/packages/vmui/src/components/Main/TextField/TextField.tsx b/app/vmui/packages/vmui/src/components/Main/TextField/TextField.tsx index f95b4194e..a2a938885 100644 --- a/app/vmui/packages/vmui/src/components/Main/TextField/TextField.tsx +++ b/app/vmui/packages/vmui/src/components/Main/TextField/TextField.tsx @@ -1,6 +1,7 @@ import React, { FC, useEffect, + useState, useRef, useMemo, FormEvent, @@ -28,12 +29,13 @@ interface TextFieldProps { autofocus?: boolean helperText?: string inputmode?: "search" | "text" | "email" | "tel" | "url" | "none" | "numeric" | "decimal" + caretPosition?: [number, number] onChange?: (value: string) => void onEnter?: () => void onKeyDown?: (e: KeyboardEvent) => void onFocus?: () => void onBlur?: () => void - onChangeCaret?: (position: number[]) => void + onChangeCaret?: (position: [number, number]) => void } const TextField: FC = ({ @@ -49,6 +51,7 @@ const TextField: FC = ({ disabled = false, autofocus = false, inputmode = "text", + caretPosition, onChange, onEnter, onKeyDown, @@ -62,6 +65,7 @@ const TextField: FC = ({ const inputRef = useRef(null); const textareaRef = useRef(null); const fieldRef = useMemo(() => type === "textarea" ? textareaRef : inputRef, [type]); + const [selectionPos, setSelectionPos] = useState<[start: number, end: number]>([0, 0]); const inputClasses = classNames({ "vm-text-field__input": true, @@ -74,7 +78,7 @@ const TextField: FC = ({ const updateCaretPosition = (target: HTMLInputElement | HTMLTextAreaElement) => { const { selectionStart, selectionEnd } = target; - onChangeCaret && onChangeCaret([selectionStart || 0, selectionEnd || 0]); + setSelectionPos([selectionStart || 0, selectionEnd || 0]); }; const handleMouseUp = (e: MouseEvent) => { @@ -102,11 +106,6 @@ const TextField: FC = ({ updateCaretPosition(e.currentTarget); }; - useEffect(() => { - if (!autofocus || isMobile) return; - fieldRef?.current?.focus && fieldRef.current.focus(); - }, [fieldRef, autofocus]); - const handleFocus = () => { onFocus && onFocus(); }; @@ -115,6 +114,31 @@ const TextField: FC = ({ onBlur && onBlur(); }; + const setSelectionRange = (range: [number, number]) => { + try { + fieldRef.current && fieldRef.current.setSelectionRange(range[0], range[1]); + } catch (e) { + return e; + } + }; + + useEffect(() => { + if (!autofocus || isMobile) return; + fieldRef?.current?.focus && fieldRef.current.focus(); + }, [fieldRef, autofocus]); + + useEffect(() => { + onChangeCaret && onChangeCaret(selectionPos); + }, [selectionPos]); + + useEffect(() => { + setSelectionRange(selectionPos); + }, [value]); + + useEffect(() => { + caretPosition && setSelectionRange(caretPosition); + }, [caretPosition]); + return