diff --git a/src-web/components/RequestPane.tsx b/src-web/components/RequestPane.tsx index 5d3792bd..7f4235a9 100644 --- a/src-web/components/RequestPane.tsx +++ b/src-web/components/RequestPane.tsx @@ -158,7 +158,6 @@ export const RequestPane = memo(function RequestPane({ { value: TAB_DESCRIPTION, label: 'Info', - rightSlot: activeRequest.description ? : null, }, { value: TAB_BODY, @@ -272,7 +271,6 @@ export const RequestPane = memo(function RequestPane({ activeRequest.authentication, activeRequest.authenticationType, activeRequest.bodyType, - activeRequest.description, activeRequest.headers, activeRequest.method, activeRequestId, diff --git a/src-web/components/SidebarItem.tsx b/src-web/components/SidebarItem.tsx index 95eb03e9..4a148e91 100644 --- a/src-web/components/SidebarItem.tsx +++ b/src-web/components/SidebarItem.tsx @@ -1,7 +1,7 @@ import type { AnyModel, GrpcConnection, HttpResponse } from '@yaakapp-internal/models'; import classNames from 'classnames'; import { atom, useAtomValue } from 'jotai'; -import type { ReactNode } from 'react'; +import type { ReactElement } from 'react'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { XYCoord } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd'; @@ -31,12 +31,11 @@ export type SidebarItemProps = { className?: string; itemId: string; itemName: string; - itemFallbackName: string; itemModel: AnyModel['model']; onMove: (id: string, side: 'above' | 'below') => void; onEnd: (id: string) => void; onDragStart: (id: string) => void; - children?: ReactNode; + children: ReactElement | null; child: SidebarTreeNode; latestHttpResponse: HttpResponse | null; latestGrpcConnection: GrpcConnection | null; @@ -57,7 +56,6 @@ export const SidebarItem = memo(function SidebarItem({ onDragStart, onSelect, className, - itemFallbackName, latestHttpResponse, latestGrpcConnection, children, @@ -210,7 +208,7 @@ export const SidebarItem = memo(function SidebarItem({ return null; } }); - }, [itemId, itemModel]) + }, [itemId, itemModel]); const item = useAtomValue(itemAtom); @@ -271,7 +269,7 @@ export const SidebarItem = memo(function SidebarItem({ onKeyDown={handleInputKeyDown} /> ) : ( - {itemName || itemFallbackName} + {itemName} )} {latestGrpcConnection ? ( diff --git a/src-web/components/SidebarItems.tsx b/src-web/components/SidebarItems.tsx index 73ab5ee4..86930c78 100644 --- a/src-web/components/SidebarItems.tsx +++ b/src-web/components/SidebarItems.tsx @@ -54,19 +54,16 @@ export const SidebarItems = memo(function SidebarItems({ r.requestId === child.id) ?? null} - latestGrpcConnection={ - grpcConnections.find((c) => c.requestId === child.id) ?? null - } + latestGrpcConnection={grpcConnections.find((c) => c.requestId === child.id) ?? null} onMove={handleMove} onEnd={handleEnd} onSelect={onSelect} onDragStart={handleDragStart} child={child} > - {child.model === 'folder' && draggingId !== child.id && ( + {child.model === 'folder' && draggingId !== child.id ? ( - )} + ) : null} ); })} - {hoveredIndex === tree.children.length && hoveredTree?.id === tree.id && ( - - )} + {hoveredIndex === tree.children.length && hoveredTree?.id === tree.id && } ); }); diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index 4cb73050..1eea239f 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -24,7 +24,10 @@ import { useDialog } from '../../../hooks/useDialog'; import { parseTemplate } from '../../../hooks/useParseTemplate'; import { useRequestEditor } from '../../../hooks/useRequestEditor'; import { useSettings } from '../../../hooks/useSettings'; -import { useTemplateFunctions } from '../../../hooks/useTemplateFunctions'; +import { + useTemplateFunctions, + useTwigCompletionOptions, +} from '../../../hooks/useTemplateFunctions'; import { TemplateFunctionDialog } from '../../TemplateFunctionDialog'; import { TemplateVariableDialog } from '../../TemplateVariableDialog'; import { IconButton } from '../IconButton'; @@ -160,22 +163,28 @@ export const Editor = forwardRef(function E // Update placeholder const placeholderCompartment = useRef(new Compartment()); - useEffect(() => { - if (cm.current === null) return; - const effect = placeholderCompartment.current.reconfigure( - placeholderExt(placeholderElFromText(placeholder ?? '')), - ); - cm.current?.view.dispatch({ effects: effect }); - }, [placeholder]); + useEffect( + function configurePlaceholder() { + if (cm.current === null) return; + const effect = placeholderCompartment.current.reconfigure( + placeholderExt(placeholderElFromText(placeholder ?? '')), + ); + cm.current?.view.dispatch({ effects: effect }); + }, + [placeholder], + ); // Update wrap lines const wrapLinesCompartment = useRef(new Compartment()); - useEffect(() => { - if (cm.current === null) return; - const ext = wrapLines ? [EditorView.lineWrapping] : []; - const effect = wrapLinesCompartment.current.reconfigure(ext); - cm.current?.view.dispatch({ effects: effect }); - }, [wrapLines]); + useEffect( + function configureWrapLines() { + if (cm.current === null) return; + const ext = wrapLines ? [EditorView.lineWrapping] : []; + const effect = wrapLinesCompartment.current.reconfigure(ext); + cm.current?.view.dispatch({ effects: effect }); + }, + [wrapLines], + ); const dialog = useDialog(); const onClickFunction = useCallback( @@ -257,6 +266,8 @@ export const Editor = forwardRef(function E [focusParamValue], ); + const completionOptions = useTwigCompletionOptions(onClickFunction); + // Update the language extension when the language changes useEffect(() => { if (cm.current === null) return; @@ -266,8 +277,7 @@ export const Editor = forwardRef(function E environmentVariables, useTemplating, autocomplete, - templateFunctions, - onClickFunction, + completionOptions, onClickVariable, onClickMissingVariable, onClickPathParameter, @@ -283,11 +293,12 @@ export const Editor = forwardRef(function E onClickVariable, onClickMissingVariable, onClickPathParameter, + completionOptions, ]); // Initialize the editor when ref mounts const initEditorRef = useCallback( - (container: HTMLDivElement | null) => { + function initEditorRef(container: HTMLDivElement | null) { if (container === null) { cm.current?.view.destroy(); cm.current = null; @@ -299,11 +310,10 @@ export const Editor = forwardRef(function E const langExt = getLanguageExtension({ language, useTemplating, + completionOptions, autocomplete, environmentVariables, - templateFunctions, onClickVariable, - onClickFunction, onClickMissingVariable, onClickPathParameter, }); @@ -362,31 +372,34 @@ export const Editor = forwardRef(function E ); // For read-only mode, update content when `defaultValue` changes - useEffect(() => { - if (!readOnly || cm.current?.view == null || defaultValue == null) return; + useEffect( + function updateReadOnlyEditor() { + if (!readOnly || cm.current?.view == null || defaultValue == null) return; - // Replace codemirror contents - const currentDoc = cm.current.view.state.doc.toString(); - if (defaultValue.startsWith(currentDoc)) { - // If we're just appending, append only the changes. This preserves - // things like scroll position. - cm.current.view.dispatch({ - changes: cm.current.view.state.changes({ - from: currentDoc.length, - insert: defaultValue.slice(currentDoc.length), - }), - }); - } else { - // If we're replacing everything, reset the entire content - cm.current.view.dispatch({ - changes: cm.current.view.state.changes({ - from: 0, - to: currentDoc.length, - insert: defaultValue, - }), - }); - } - }, [defaultValue, readOnly]); + // Replace codemirror contents + const currentDoc = cm.current.view.state.doc.toString(); + if (defaultValue.startsWith(currentDoc)) { + // If we're just appending, append only the changes. This preserves + // things like scroll position. + cm.current.view.dispatch({ + changes: cm.current.view.state.changes({ + from: currentDoc.length, + insert: defaultValue.slice(currentDoc.length), + }), + }); + } else { + // If we're replacing everything, reset the entire content + cm.current.view.dispatch({ + changes: cm.current.view.state.changes({ + from: 0, + to: currentDoc.length, + insert: defaultValue, + }), + }); + } + }, + [defaultValue, readOnly], + ); // Add bg classes to actions, so they appear over the text const decoratedActions = useMemo(() => { @@ -557,7 +570,7 @@ function saveCachedEditorState(stateKey: string | null, state: EditorState | nul function getCachedEditorState(doc: string, stateKey: string | null) { if (stateKey == null) return; - const stateStr = sessionStorage.getItem(stateKey) + const stateStr = sessionStorage.getItem(stateKey); if (stateStr == null) return null; try { diff --git a/src-web/components/core/Editor/extensions.ts b/src-web/components/core/Editor/extensions.ts index 9df08c2a..22d5eb30 100644 --- a/src-web/components/core/Editor/extensions.ts +++ b/src-web/components/core/Editor/extensions.ts @@ -34,14 +34,15 @@ import { } from '@codemirror/view'; import { tags as t } from '@lezer/highlight'; import type { EnvironmentVariable } from '@yaakapp-internal/models'; -import type { TemplateFunction } from '@yaakapp-internal/plugin'; import { graphql } from 'cm6-graphql'; import { EditorView } from 'codemirror'; import { pluralizeCount } from '../../../lib/pluralize'; import type { EditorProps } from './Editor'; import { pairs } from './pairs/extension'; import { text } from './text/extension'; +import type { TwigCompletionOption } from './twig/completion'; import { twig } from './twig/extension'; +import { pathParametersPlugin } from './twig/pathParameters'; import { url } from './url/extension'; export const syntaxHighlightStyle = HighlightStyle.define([ @@ -89,18 +90,16 @@ export function getLanguageExtension({ useTemplating = false, environmentVariables, autocomplete, - templateFunctions, onClickVariable, - onClickFunction, onClickMissingVariable, onClickPathParameter, + completionOptions, }: { environmentVariables: EnvironmentVariable[]; - templateFunctions: TemplateFunction[]; - onClickFunction: (option: TemplateFunction, tagValue: string, startPos: number) => void; onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void; onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void; onClickPathParameter: (name: string) => void; + completionOptions: TwigCompletionOption[]; } & Pick) { if (language === 'graphql') { return graphql(); @@ -111,15 +110,17 @@ export function getLanguageExtension({ return base; } + const extraExtensions = language === 'url' ? [pathParametersPlugin(onClickPathParameter)] : []; + return twig({ base, environmentVariables, - templateFunctions, + completionOptions, autocomplete, - onClickFunction, onClickVariable, onClickMissingVariable, onClickPathParameter, + extraExtensions, }); } diff --git a/src-web/components/core/Editor/twig/extension.ts b/src-web/components/core/Editor/twig/extension.ts index 7c706f9a..3d4775d1 100644 --- a/src-web/components/core/Editor/twig/extension.ts +++ b/src-web/components/core/Editor/twig/extension.ts @@ -1,8 +1,8 @@ import type { LanguageSupport } from '@codemirror/language'; import { LRLanguage } from '@codemirror/language'; +import type { Extension } from '@codemirror/state'; import { parseMixed } from '@lezer/common'; import type { EnvironmentVariable } from '@yaakapp-internal/models'; -import type { TemplateFunction } from '@yaakapp-internal/plugin'; import type { GenericCompletionConfig } from '../genericCompletion'; import { genericCompletion } from '../genericCompletion'; import { textLanguageName } from '../text/extension'; @@ -14,21 +14,20 @@ import { parser as twigParser } from './twig'; export function twig({ base, environmentVariables, - templateFunctions, + completionOptions, autocomplete, - onClickFunction, onClickVariable, onClickMissingVariable, - onClickPathParameter, + extraExtensions, }: { base: LanguageSupport; environmentVariables: EnvironmentVariable[]; - templateFunctions: TemplateFunction[]; + completionOptions: TwigCompletionOption[]; autocomplete?: GenericCompletionConfig; - onClickFunction: (option: TemplateFunction, tagValue: string, startPos: number) => void; onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void; onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void; onClickPathParameter: (name: string) => void; + extraExtensions: Extension[]; }) { const language = mixLanguage(base); @@ -40,28 +39,7 @@ export function twig({ onClick: (rawTag: string, startPos: number) => onClickVariable(v, rawTag, startPos), })) ?? []; - const functionOptions: TwigCompletionOption[] = - templateFunctions.map((fn) => { - const NUM_ARGS = 2; - const shortArgs = - fn.args - .slice(0, NUM_ARGS) - .map((a) => a.name) - .join(', ') + (fn.args.length > NUM_ARGS ? ', …' : ''); - return { - name: fn.name, - aliases: fn.aliases, - type: 'function', - description: fn.description, - args: fn.args.map((a) => ({ name: a.name })), - value: null, - label: `${fn.name}(${shortArgs})`, - onClick: (rawTag: string, startPos: number) => onClickFunction(fn, rawTag, startPos), - }; - }) ?? []; - - const options = [...variableOptions, ...functionOptions]; - + const options = [...variableOptions, ...completionOptions]; const completions = twigCompletion({ options }); return [ @@ -71,11 +49,20 @@ export function twig({ base.language.data.of({ autocomplete: completions }), language.data.of({ autocomplete: genericCompletion(autocomplete) }), base.language.data.of({ autocomplete: genericCompletion(autocomplete) }), - templateTagsPlugin(options, onClickMissingVariable, onClickPathParameter), + templateTagsPlugin(options, onClickMissingVariable), + ...extraExtensions, ]; } +const mixedLanguagesCache: Record = {}; + function mixLanguage(base: LanguageSupport): LRLanguage { + // It can be slow to mix languages when there are hundreds of editors, so we'll cache them to speed it up + const cached = mixedLanguagesCache[base.language.name]; + if (cached != null) { + return cached; + } + const name = 'twig'; const parser = twigParser.configure({ @@ -92,5 +79,7 @@ function mixLanguage(base: LanguageSupport): LRLanguage { }), }); - return LRLanguage.define({ name, parser }); + const language = LRLanguage.define({ name, parser }); + mixedLanguagesCache[base.language.name] = language; + return language; } diff --git a/src-web/components/core/Editor/twig/pathParameters.ts b/src-web/components/core/Editor/twig/pathParameters.ts new file mode 100644 index 00000000..4cd64393 --- /dev/null +++ b/src-web/components/core/Editor/twig/pathParameters.ts @@ -0,0 +1,109 @@ +import { syntaxTree } from '@codemirror/language'; +import type { Range } from '@codemirror/state'; +import type { DecorationSet, ViewUpdate } from '@codemirror/view'; +import { Decoration, ViewPlugin, WidgetType } from '@codemirror/view'; +import { EditorView } from 'codemirror'; + +class PathPlaceholderWidget extends WidgetType { + readonly #clickListenerCallback: () => void; + + constructor( + readonly rawText: string, + readonly startPos: number, + readonly onClick: () => void, + ) { + super(); + this.#clickListenerCallback = () => { + this.onClick?.(); + }; + } + + eq(other: PathPlaceholderWidget) { + return this.startPos === other.startPos && this.rawText === other.rawText; + } + + toDOM() { + const elt = document.createElement('span'); + elt.className = `x-theme-templateTag x-theme-templateTag--secondary template-tag`; + elt.textContent = this.rawText; + elt.addEventListener('click', this.#clickListenerCallback); + return elt; + } + + destroy(dom: HTMLElement) { + dom.removeEventListener('click', this.#clickListenerCallback); + super.destroy(dom); + } + + ignoreEvent() { + return false; + } +} + +function pathParameters( + view: EditorView, + onClickPathParameter: (name: string) => void, +): DecorationSet { + const widgets: Range[] = []; + const tree = syntaxTree(view.state); + for (const { from, to } of view.visibleRanges) { + tree.iterate({ + from, + to, + enter(node) { + if (node.name === 'Text') { + // Find the `url` node and then jump into it to find the placeholders + for (let i = node.from; i < node.to; i++) { + const innerTree = syntaxTree(view.state).resolveInner(i); + if (innerTree.node.name === 'url') { + innerTree.toTree().iterate({ + enter(node) { + if (node.name !== 'Placeholder') return; + const globalFrom = innerTree.node.from + node.from; + const globalTo = innerTree.node.from + node.to; + const rawText = view.state.doc.sliceString(globalFrom, globalTo); + const onClick = () => onClickPathParameter(rawText); + const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick); + const deco = Decoration.replace({ widget, inclusive: false }); + widgets.push(deco.range(globalFrom, globalTo)); + }, + }); + break; + } + } + } + }, + }); + } + + // Widgets must be sorted start to end + widgets.sort((a, b) => a.from - b.from); + + return Decoration.set(widgets); +} + +export function pathParametersPlugin(onClickPathParameter: (name: string) => void) { + return ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = pathParameters(view, onClickPathParameter); + } + + update(update: ViewUpdate) { + this.decorations = pathParameters(update.view, onClickPathParameter); + } + }, + { + decorations(v) { + return v.decorations; + }, + provide(plugin) { + return EditorView.atomicRanges.of((view) => { + return view.plugin(plugin)?.decorations || Decoration.none; + }); + }, + }, + ); +} diff --git a/src-web/components/core/Editor/twig/templateTags.ts b/src-web/components/core/Editor/twig/templateTags.ts index 6c5871cf..9b7650cb 100644 --- a/src-web/components/core/Editor/twig/templateTags.ts +++ b/src-web/components/core/Editor/twig/templateTags.ts @@ -6,42 +6,6 @@ import type { SyntaxNodeRef } from '@lezer/common'; import { EditorView } from 'codemirror'; import type { TwigCompletionOption } from './completion'; -class PathPlaceholderWidget extends WidgetType { - readonly #clickListenerCallback: () => void; - - constructor( - readonly rawText: string, - readonly startPos: number, - readonly onClick: () => void, - ) { - super(); - this.#clickListenerCallback = () => { - this.onClick?.(); - }; - } - - eq(other: PathPlaceholderWidget) { - return this.startPos === other.startPos && this.rawText === other.rawText; - } - - toDOM() { - const elt = document.createElement('span'); - elt.className = `x-theme-templateTag x-theme-templateTag--secondary template-tag`; - elt.textContent = this.rawText; - elt.addEventListener('click', this.#clickListenerCallback); - return elt; - } - - destroy(dom: HTMLElement) { - dom.removeEventListener('click', this.#clickListenerCallback); - super.destroy(dom); - } - - ignoreEvent() { - return false; - } -} - class TemplateTagWidget extends WidgetType { readonly #clickListenerCallback: () => void; @@ -99,38 +63,15 @@ function templateTags( view: EditorView, options: TwigCompletionOption[], onClickMissingVariable: (name: string, rawTag: string, startPos: number) => void, - onClickPathParameter: (name: string) => void, ): DecorationSet { const widgets: Range[] = []; + const tree = syntaxTree(view.state); for (const { from, to } of view.visibleRanges) { - const tree = syntaxTree(view.state); tree.iterate({ from, to, enter(node) { - if (node.name === 'Text') { - // Find the `url` node and then jump into it to find the placeholders - for (let i = node.from; i < node.to; i++) { - const innerTree = syntaxTree(view.state).resolveInner(i); - if (innerTree.node.name === 'url') { - innerTree.toTree().iterate({ - enter(node) { - if (node.name !== 'Placeholder') return; - if (isSelectionInsideNode(view, node)) return; - - const globalFrom = innerTree.node.from + node.from; - const globalTo = innerTree.node.from + node.to; - const rawText = view.state.doc.sliceString(globalFrom, globalTo); - const onClick = () => onClickPathParameter(rawText); - const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick); - const deco = Decoration.replace({ widget, inclusive: false }); - widgets.push(deco.range(globalFrom, globalTo)); - }, - }); - break; - } - } - } else if (node.name === 'Tag') { + if (node.name === 'Tag') { // Don't decorate if the cursor is inside the match if (isSelectionInsideNode(view, node)) return; @@ -177,28 +118,17 @@ function templateTags( export function templateTagsPlugin( options: TwigCompletionOption[], onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void, - onClickPathParameter: (name: string) => void, ) { return ViewPlugin.fromClass( class { decorations: DecorationSet; constructor(view: EditorView) { - this.decorations = templateTags( - view, - options, - onClickMissingVariable, - onClickPathParameter, - ); + this.decorations = templateTags(view, options, onClickMissingVariable); } update(update: ViewUpdate) { - this.decorations = templateTags( - update.view, - options, - onClickMissingVariable, - onClickPathParameter, - ); + this.decorations = templateTags(update.view, options, onClickMissingVariable); } }, { diff --git a/src-web/hooks/useSyncModelStores.ts b/src-web/hooks/useSyncModelStores.ts index d3157fe3..9692744c 100644 --- a/src-web/hooks/useSyncModelStores.ts +++ b/src-web/hooks/useSyncModelStores.ts @@ -1,3 +1,4 @@ +import deepEqual from '@gilbarbara/deep-equal'; import { useQueryClient } from '@tanstack/react-query'; import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; import type { AnyModel, KeyValue } from '@yaakapp-internal/models'; @@ -121,7 +122,11 @@ export function updateModelList(model: T) { return (current: T[] | undefined): T[] => { const index = current?.findIndex((v) => modelsEq(v, model)) ?? -1; - if (index >= 0) { + const existingModel = current?.[index]; + if (existingModel && deepEqual(existingModel, model)) { + // We already have the exact model, so do nothing + return current; + } else if (existingModel) { return [...(current ?? []).slice(0, index), model, ...(current ?? []).slice(index + 1)]; } else { return pushToFront ? [model, ...(current ?? [])] : [...(current ?? []), model]; @@ -130,12 +135,21 @@ export function updateModelList(model: T) { } export function removeModelById(model: T) { - return (entries: T[] | undefined) => entries?.filter((e) => e.id !== model.id) ?? []; + return (prevEntries: T[] | undefined) => { + const entries = prevEntries?.filter((e) => e.id !== model.id) ?? []; + + // Don't trigger an update if we didn't actually remove anything + if (entries.length === (prevEntries ?? []).length) { + return prevEntries ?? []; + } + + return entries; + }; } export function removeModelByKeyValue(model: KeyValue) { - return (entries: KeyValue[] | undefined) => - entries?.filter( + return (prevEntries: KeyValue[] | undefined) => + prevEntries?.filter( (e) => !( e.namespace === model.namespace && diff --git a/src-web/hooks/useTemplateFunctions.ts b/src-web/hooks/useTemplateFunctions.ts index 6e0b0b4d..3067da50 100644 --- a/src-web/hooks/useTemplateFunctions.ts +++ b/src-web/hooks/useTemplateFunctions.ts @@ -2,12 +2,41 @@ import { useQuery } from '@tanstack/react-query'; import type { GetTemplateFunctionsResponse, TemplateFunction } from '@yaakapp-internal/plugin'; import { atom, useAtomValue } from 'jotai'; import { useSetAtom } from 'jotai/index'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; +import type {TwigCompletionOption} from "../components/core/Editor/twig/completion"; import { invokeCmd } from '../lib/tauri'; import { usePluginsKey } from './usePlugins'; const templateFunctionsAtom = atom([]); +export function useTwigCompletionOptions( + onClick: (fn: TemplateFunction, ragTag: string, pos: number) => void, +) { + const templateFunctions = useTemplateFunctions(); + return useMemo(() => { + return ( + templateFunctions.map((fn) => { + const NUM_ARGS = 2; + const shortArgs = + fn.args + .slice(0, NUM_ARGS) + .map((a) => a.name) + .join(', ') + (fn.args.length > NUM_ARGS ? ', …' : ''); + return { + name: fn.name, + aliases: fn.aliases, + type: 'function', + description: fn.description, + args: fn.args.map((a) => ({ name: a.name })), + value: null, + label: `${fn.name}(${shortArgs})`, + onClick: (rawTag: string, startPos: number) => onClick(fn, rawTag, startPos), + }; + }) ?? [] + ); + }, [onClick, templateFunctions]); +} + export function useTemplateFunctions() { return useAtomValue(templateFunctionsAtom); }