mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-17 23:14:03 +01:00
Template Tag Function Editor (#67)

This commit is contained in:
@@ -1,18 +1,19 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import type { Model } from '@yaakapp/api';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import { useEnsureActiveCookieJar, useMigrateActiveCookieJarId } from '../hooks/useActiveCookieJar';
|
||||
import { useActiveWorkspaceChangedToast } from '../hooks/useActiveWorkspaceChangedToast';
|
||||
import { cookieJarsQueryKey } from '../hooks/useCookieJars';
|
||||
import { useCopy } from '../hooks/useCopy';
|
||||
import { environmentsQueryKey } from '../hooks/useEnvironments';
|
||||
import { environmentsAtom } from '../hooks/useEnvironments';
|
||||
import { foldersQueryKey } from '../hooks/useFolders';
|
||||
import { grpcConnectionsQueryKey } from '../hooks/useGrpcConnections';
|
||||
import { grpcEventsQueryKey } from '../hooks/useGrpcEvents';
|
||||
import { grpcRequestsQueryKey } from '../hooks/useGrpcRequests';
|
||||
import { grpcRequestsAtom } from '../hooks/useGrpcRequests';
|
||||
import { useHotKey } from '../hooks/useHotKey';
|
||||
import { httpRequestsQueryKey } from '../hooks/useHttpRequests';
|
||||
import { httpRequestsAtom } from '../hooks/useHttpRequests';
|
||||
import { httpResponsesQueryKey } from '../hooks/useHttpResponses';
|
||||
import { keyValueQueryKey } from '../hooks/useKeyValue';
|
||||
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
|
||||
@@ -25,7 +26,7 @@ import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
||||
import { settingsQueryKey, useSettings } from '../hooks/useSettings';
|
||||
import { useSyncThemeToDocument } from '../hooks/useSyncThemeToDocument';
|
||||
import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette';
|
||||
import { workspacesQueryKey } from '../hooks/useWorkspaces';
|
||||
import { workspacesAtom } from '../hooks/useWorkspaces';
|
||||
import { useZoom } from '../hooks/useZoom';
|
||||
import { extractKeyValue } from '../lib/keyValueStore';
|
||||
import { modelsEq } from '../lib/models';
|
||||
@@ -64,25 +65,22 @@ export function GlobalHooks() {
|
||||
windowLabel: string;
|
||||
}
|
||||
|
||||
const setWorkspaces = useSetAtom(workspacesAtom);
|
||||
const setHttpRequests = useSetAtom(httpRequestsAtom);
|
||||
const setGrpcRequests = useSetAtom(grpcRequestsAtom);
|
||||
const setEnvironments = useSetAtom(environmentsAtom);
|
||||
|
||||
useListenToTauriEvent<ModelPayload>('upserted_model', ({ payload }) => {
|
||||
const { model, windowLabel } = payload;
|
||||
const queryKey =
|
||||
model.model === 'http_request'
|
||||
? httpRequestsQueryKey(model)
|
||||
: model.model === 'http_response'
|
||||
model.model === 'http_response'
|
||||
? httpResponsesQueryKey(model)
|
||||
: model.model === 'folder'
|
||||
? foldersQueryKey(model)
|
||||
: model.model === 'environment'
|
||||
? environmentsQueryKey(model)
|
||||
: model.model === 'grpc_connection'
|
||||
? grpcConnectionsQueryKey(model)
|
||||
: model.model === 'grpc_event'
|
||||
? grpcEventsQueryKey(model)
|
||||
: model.model === 'grpc_request'
|
||||
? grpcRequestsQueryKey(model)
|
||||
: model.model === 'workspace'
|
||||
? workspacesQueryKey(model)
|
||||
: model.model === 'key_value'
|
||||
? keyValueQueryKey(model)
|
||||
: model.model === 'cookie_jar'
|
||||
@@ -91,11 +89,6 @@ export function GlobalHooks() {
|
||||
? settingsQueryKey()
|
||||
: null;
|
||||
|
||||
if (queryKey === null) {
|
||||
console.log('Unrecognized updated model:', model);
|
||||
return;
|
||||
}
|
||||
|
||||
if (model.model === 'http_request' && windowLabel !== getCurrentWebviewWindow().label) {
|
||||
wasUpdatedExternally(model.id);
|
||||
}
|
||||
@@ -106,21 +99,27 @@ export function GlobalHooks() {
|
||||
|
||||
if (shouldIgnoreModel(model, windowLabel)) return;
|
||||
|
||||
queryClient.setQueryData(queryKey, (current: unknown) => {
|
||||
if (model.model === 'key_value') {
|
||||
// Special-case for KeyValue
|
||||
return extractKeyValue(model);
|
||||
}
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
const index = current.findIndex((v) => modelsEq(v, model)) ?? -1;
|
||||
if (index >= 0) {
|
||||
return [...current.slice(0, index), model, ...current.slice(index + 1)];
|
||||
} else {
|
||||
return pushToFront ? [model, ...(current ?? [])] : [...(current ?? []), model];
|
||||
if (model.model === 'workspace') {
|
||||
setWorkspaces(updateModelList(model, pushToFront));
|
||||
} else if (model.model === 'http_request') {
|
||||
setHttpRequests(updateModelList(model, pushToFront));
|
||||
} else if (model.model === 'grpc_request') {
|
||||
setGrpcRequests(updateModelList(model, pushToFront));
|
||||
} else if (model.model === 'environment') {
|
||||
setEnvironments(updateModelList(model, pushToFront));
|
||||
} else if (queryKey != null) {
|
||||
// TODO: Convert all models to use Jotai
|
||||
queryClient.setQueryData(queryKey, (current: unknown) => {
|
||||
if (model.model === 'key_value') {
|
||||
// Special-case for KeyValue
|
||||
return extractKeyValue(model);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
return updateModelList(model, pushToFront)(current);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useListenToTauriEvent<ModelPayload>('deleted_model', ({ payload }) => {
|
||||
@@ -128,17 +127,17 @@ export function GlobalHooks() {
|
||||
if (shouldIgnoreModel(model, windowLabel)) return;
|
||||
|
||||
if (model.model === 'workspace') {
|
||||
queryClient.setQueryData(workspacesQueryKey(), removeById(model));
|
||||
setWorkspaces(removeById(model));
|
||||
} else if (model.model === 'http_request') {
|
||||
queryClient.setQueryData(httpRequestsQueryKey(model), removeById(model));
|
||||
setHttpRequests(removeById(model));
|
||||
} else if (model.model === 'http_response') {
|
||||
queryClient.setQueryData(httpResponsesQueryKey(model), removeById(model));
|
||||
} else if (model.model === 'folder') {
|
||||
queryClient.setQueryData(foldersQueryKey(model), removeById(model));
|
||||
} else if (model.model === 'environment') {
|
||||
queryClient.setQueryData(environmentsQueryKey(model), removeById(model));
|
||||
setEnvironments(removeById(model));
|
||||
} else if (model.model === 'grpc_request') {
|
||||
queryClient.setQueryData(grpcRequestsQueryKey(model), removeById(model));
|
||||
setGrpcRequests(removeById(model));
|
||||
} else if (model.model === 'grpc_connection') {
|
||||
queryClient.setQueryData(grpcConnectionsQueryKey(model), removeById(model));
|
||||
} else if (model.model === 'grpc_event') {
|
||||
@@ -192,8 +191,19 @@ export function GlobalHooks() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function updateModelList<T extends Model>(model: T, pushToFront: boolean) {
|
||||
return (current: T[]): T[] => {
|
||||
const index = current.findIndex((v) => modelsEq(v, model)) ?? -1;
|
||||
if (index >= 0) {
|
||||
return [...current.slice(0, index), model, ...current.slice(index + 1)];
|
||||
} else {
|
||||
return pushToFront ? [model, ...(current ?? [])] : [...(current ?? []), model];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function removeById<T extends { id: string }>(model: T) {
|
||||
return (entries: T[] | undefined) => entries?.filter((e) => e.id !== model.id);
|
||||
return (entries: T[] | undefined) => entries?.filter((e) => e.id !== model.id) ?? [];
|
||||
}
|
||||
|
||||
const shouldIgnoreModel = (payload: Model, windowLabel: string) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ interface Props {
|
||||
|
||||
export function IsDev({ children }: Props) {
|
||||
const appInfo = useAppInfo();
|
||||
if (!appInfo?.isDev) {
|
||||
if (!appInfo.isDev) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { GrpcRequest, HttpRequest } from '@yaakapp/api';
|
||||
import React, { useState } from 'react';
|
||||
import { useAppRoutes } from '../hooks/useAppRoutes';
|
||||
import { grpcRequestsQueryKey } from '../hooks/useGrpcRequests';
|
||||
import { httpRequestsQueryKey } from '../hooks/useHttpRequests';
|
||||
import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest';
|
||||
import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
|
||||
import { useWorkspaces } from '../hooks/useWorkspaces';
|
||||
import { fallbackRequestName } from '../lib/fallbackRequestName';
|
||||
import type { GrpcRequest, HttpRequest } from '@yaakapp/api';
|
||||
import { Button } from './core/Button';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { Select } from './core/Select';
|
||||
@@ -22,7 +19,6 @@ interface Props {
|
||||
|
||||
export function MoveToWorkspaceDialog({ onDone, request, activeWorkspaceId }: Props) {
|
||||
const workspaces = useWorkspaces();
|
||||
const queryClient = useQueryClient();
|
||||
const updateHttpRequest = useUpdateAnyHttpRequest();
|
||||
const updateGrpcRequest = useUpdateAnyGrpcRequest();
|
||||
const toast = useToast();
|
||||
@@ -52,14 +48,8 @@ export function MoveToWorkspaceDialog({ onDone, request, activeWorkspaceId }: Pr
|
||||
|
||||
if (request.model === 'http_request') {
|
||||
await updateHttpRequest.mutateAsync(args);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: httpRequestsQueryKey({ workspaceId: activeWorkspaceId }),
|
||||
});
|
||||
} else if (request.model === 'grpc_request') {
|
||||
await updateGrpcRequest.mutateAsync(args);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: grpcRequestsQueryKey({ workspaceId: activeWorkspaceId }),
|
||||
});
|
||||
}
|
||||
|
||||
// Hide after a moment, to give time for request to disappear
|
||||
|
||||
@@ -117,9 +117,9 @@ export function SettingsGeneral() {
|
||||
|
||||
<Heading size={2}>App Info</Heading>
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Version" value={appInfo?.version} />
|
||||
<KeyValueRow label="Data Directory" value={appInfo?.appDataDir} />
|
||||
<KeyValueRow label="Logs Directory" value={appInfo?.appLogDir} />
|
||||
<KeyValueRow label="Version" value={appInfo.version} />
|
||||
<KeyValueRow label="Data Directory" value={appInfo.appDataDir} />
|
||||
<KeyValueRow label="Logs Directory" value={appInfo.appLogDir} />
|
||||
</KeyValueRows>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
@@ -73,7 +73,7 @@ export function SettingsDropdown() {
|
||||
leftSlot: <Icon icon="folderOutput" />,
|
||||
onSelect: () => exportData.mutate(),
|
||||
},
|
||||
{ type: 'separator', label: `Yaak v${appInfo?.version}` },
|
||||
{ type: 'separator', label: `Yaak v${appInfo.version}` },
|
||||
{
|
||||
key: 'update-check',
|
||||
label: 'Check for Updates',
|
||||
@@ -92,7 +92,7 @@ export function SettingsDropdown() {
|
||||
label: 'Changelog',
|
||||
leftSlot: <Icon icon="cake" />,
|
||||
rightSlot: <Icon icon="externalLink" />,
|
||||
onSelect: () => open(`https://yaak.app/changelog/${appInfo?.version}`),
|
||||
onSelect: () => open(`https://yaak.app/changelog/${appInfo.version}`),
|
||||
},
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Folder, GrpcRequest, HttpRequest, Workspace } from '@yaakapp/api';
|
||||
import type { Folder, GrpcRequest, HttpRequest, Model, Workspace } from '@yaakapp/api';
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { Fragment, useCallback, useMemo, useRef, useState } from 'react';
|
||||
@@ -576,7 +576,7 @@ type SidebarItemProps = {
|
||||
itemId: string;
|
||||
itemName: string;
|
||||
itemFallbackName: string;
|
||||
itemModel: string;
|
||||
itemModel: Model['model'];
|
||||
itemPrefix: ReactNode;
|
||||
useProminentStyles?: boolean;
|
||||
selected: boolean;
|
||||
@@ -658,8 +658,10 @@ function SidebarItem({
|
||||
const sendRequest = useSendAnyHttpRequest();
|
||||
const moveToWorkspace = useMoveToWorkspace(itemId);
|
||||
const sendManyRequests = useSendManyRequests();
|
||||
const latestHttpResponse = useLatestHttpResponse(itemId);
|
||||
const latestGrpcConnection = useLatestGrpcConnection(itemId);
|
||||
const latestHttpResponse = useLatestHttpResponse(itemModel === 'http_request' ? itemId : null);
|
||||
const latestGrpcConnection = useLatestGrpcConnection(
|
||||
itemModel === 'grpc_request' ? itemId : null,
|
||||
);
|
||||
const updateHttpRequest = useUpdateAnyHttpRequest();
|
||||
const workspaces = useWorkspaces();
|
||||
const updateGrpcRequest = useUpdateAnyGrpcRequest();
|
||||
|
||||
211
src-web/components/TemplateFunctionDialog.tsx
Normal file
211
src-web/components/TemplateFunctionDialog.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { FnArg } from '../gen/FnArg';
|
||||
import type { Tokens } from '../gen/Tokens';
|
||||
import { useHttpRequests } from '../hooks/useHttpRequests';
|
||||
import { useRenderTemplate } from '../hooks/useRenderTemplate';
|
||||
import type {
|
||||
TemplateFunction,
|
||||
TemplateFunctionArg,
|
||||
TemplateFunctionHttpRequestArg,
|
||||
TemplateFunctionSelectArg,
|
||||
TemplateFunctionTextArg,
|
||||
} from '../hooks/useTemplateFunctions';
|
||||
import { useTemplateTokensToString } from '../hooks/useTemplateTokensToString';
|
||||
import { fallbackRequestName } from '../lib/fallbackRequestName';
|
||||
import { Button } from './core/Button';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { PlainInput } from './core/PlainInput';
|
||||
import { Select } from './core/Select';
|
||||
import { VStack } from './core/Stacks';
|
||||
|
||||
const NULL_ARG = '__NULL__';
|
||||
|
||||
interface Props {
|
||||
templateFunction: TemplateFunction;
|
||||
initialTokens: Tokens;
|
||||
hide: () => void;
|
||||
onChange: (insert: string) => void;
|
||||
}
|
||||
|
||||
export function TemplateFunctionDialog({ templateFunction, hide, initialTokens, onChange }: Props) {
|
||||
const [argValues, setArgValues] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
const initialArgs =
|
||||
initialTokens.tokens[0]?.type === 'tag' && initialTokens.tokens[0]?.val.type === 'fn'
|
||||
? initialTokens.tokens[0]?.val.args
|
||||
: [];
|
||||
for (const arg of templateFunction.args) {
|
||||
const initialArg = initialArgs.find((a) => a.name === arg.name);
|
||||
const initialArgValue =
|
||||
initialArg?.value.type === 'str'
|
||||
? initialArg?.value.text
|
||||
: // TODO: Implement variable-based args
|
||||
'__NULL__';
|
||||
initial[arg.name] = initialArgValue ?? NULL_ARG;
|
||||
}
|
||||
|
||||
return initial;
|
||||
});
|
||||
|
||||
const setArgValue = useCallback((name: string, value: string) => {
|
||||
setArgValues((v) => ({ ...v, [name]: value }));
|
||||
}, []);
|
||||
|
||||
const tokens: Tokens = useMemo(() => {
|
||||
const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({
|
||||
name,
|
||||
value:
|
||||
argValues[name] === NULL_ARG
|
||||
? { type: 'null' }
|
||||
: {
|
||||
type: 'str',
|
||||
text: argValues[name] ?? '',
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
tokens: [
|
||||
{
|
||||
type: 'tag',
|
||||
val: {
|
||||
type: 'fn',
|
||||
name: templateFunction.name,
|
||||
args: argTokens,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [argValues, templateFunction.name]);
|
||||
|
||||
const tagText = useTemplateTokensToString(tokens);
|
||||
|
||||
const handleDone = () => {
|
||||
if (tagText.data) {
|
||||
onChange(tagText.data);
|
||||
}
|
||||
hide();
|
||||
};
|
||||
|
||||
const rendered = useRenderTemplate(tagText.data ?? '');
|
||||
|
||||
return (
|
||||
<VStack className="pb-3" space={4}>
|
||||
<VStack space={2}>
|
||||
{templateFunction.args.map((a: TemplateFunctionArg, i: number) => {
|
||||
switch (a.type) {
|
||||
case 'select':
|
||||
return (
|
||||
<SelectArg
|
||||
key={i}
|
||||
arg={a}
|
||||
onChange={(v) => setArgValue(a.name, v)}
|
||||
value={argValues[a.name] ?? '__ERROR__'}
|
||||
/>
|
||||
);
|
||||
case 'text':
|
||||
return (
|
||||
<TextArg
|
||||
key={i}
|
||||
arg={a}
|
||||
onChange={(v) => setArgValue(a.name, v)}
|
||||
value={argValues[a.name] ?? '__ERROR__'}
|
||||
/>
|
||||
);
|
||||
case 'http_request':
|
||||
return (
|
||||
<HttpRequestArg
|
||||
key={i}
|
||||
arg={a}
|
||||
onChange={(v) => setArgValue(a.name, v)}
|
||||
value={argValues[a.name] ?? '__ERROR__'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</VStack>
|
||||
<InlineCode className="select-text cursor-text">{rendered.data}</InlineCode>
|
||||
<Button color="primary" onClick={handleDone}>
|
||||
Done
|
||||
</Button>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function TextArg({
|
||||
arg,
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
arg: TemplateFunctionTextArg;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
onChange(value === '' ? NULL_ARG : value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<PlainInput
|
||||
name={arg.name}
|
||||
onChange={handleChange}
|
||||
defaultValue={value === NULL_ARG ? '' : value}
|
||||
label={arg.label ?? arg.name}
|
||||
hideLabel={arg.label == null}
|
||||
placeholder={arg.placeholder ?? arg.defaultValue ?? ''}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectArg({
|
||||
arg,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
arg: TemplateFunctionSelectArg;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
label={arg.label ?? arg.name}
|
||||
name={arg.name}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
options={[
|
||||
...arg.options.map((a) => ({
|
||||
label: a.name + (arg.defaultValue === a.value ? ' (default)' : ''),
|
||||
value: a.value === arg.defaultValue ? NULL_ARG : a.value,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HttpRequestArg({
|
||||
arg,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
arg: TemplateFunctionHttpRequestArg;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const httpRequests = useHttpRequests();
|
||||
return (
|
||||
<Select
|
||||
label={arg.label ?? arg.name}
|
||||
name={arg.name}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
options={[
|
||||
...httpRequests.map((r) => ({
|
||||
label: fallbackRequestName(r),
|
||||
value: r.id,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
69
src-web/components/TemplateVariableDialog.tsx
Normal file
69
src-web/components/TemplateVariableDialog.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { EnvironmentVariable } from '@yaakapp/api';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { Tokens } from '../gen/Tokens';
|
||||
import { useActiveEnvironmentVariables } from '../hooks/useActiveEnvironmentVariables';
|
||||
import { useRenderTemplate } from '../hooks/useRenderTemplate';
|
||||
import { useTemplateTokensToString } from '../hooks/useTemplateTokensToString';
|
||||
import { Button } from './core/Button';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { Select } from './core/Select';
|
||||
import { VStack } from './core/Stacks';
|
||||
|
||||
interface Props {
|
||||
definition: EnvironmentVariable;
|
||||
initialTokens: Tokens;
|
||||
hide: () => void;
|
||||
onChange: (rawTag: string) => void;
|
||||
}
|
||||
|
||||
export function TemplateVariableDialog({ hide, onChange, initialTokens }: Props) {
|
||||
const variables = useActiveEnvironmentVariables();
|
||||
const [selectedVariableName, setSelectedVariableName] = useState<string>(() => {
|
||||
return initialTokens.tokens[0]?.type === 'tag' && initialTokens.tokens[0]?.val.type === 'var'
|
||||
? initialTokens.tokens[0]?.val.name
|
||||
: ''; // Should never happen
|
||||
});
|
||||
|
||||
const tokens: Tokens = useMemo(() => {
|
||||
const selectedVariable = variables.find((v) => v.name === selectedVariableName);
|
||||
return {
|
||||
tokens: [
|
||||
{
|
||||
type: 'tag',
|
||||
val: {
|
||||
type: 'var',
|
||||
name: selectedVariable?.name ?? '',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [selectedVariableName, variables]);
|
||||
|
||||
const tagText = useTemplateTokensToString(tokens);
|
||||
const handleDone = useCallback(async () => {
|
||||
if (tagText.data != null) {
|
||||
onChange(tagText.data);
|
||||
}
|
||||
hide();
|
||||
}, [hide, onChange, tagText.data]);
|
||||
|
||||
const rendered = useRenderTemplate(tagText.data ?? '');
|
||||
|
||||
return (
|
||||
<VStack className="pb-3" space={4}>
|
||||
<VStack space={2}>
|
||||
<Select
|
||||
name="variable"
|
||||
label="Variable"
|
||||
value={selectedVariableName}
|
||||
options={variables.map((v) => ({ label: v.name, value: v.name }))}
|
||||
onChange={setSelectedVariableName}
|
||||
/>
|
||||
</VStack>
|
||||
<InlineCode className="select-text cursor-text">{rendered.data}</InlineCode>
|
||||
<Button color="primary" onClick={handleDone}>
|
||||
Done
|
||||
</Button>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
}
|
||||
|
||||
.cm-placeholder {
|
||||
@apply text-text-subtlest;
|
||||
@apply text-placeholder;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
@@ -60,12 +60,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
.template-tag {
|
||||
/* Colors */
|
||||
@apply bg-surface text-text-subtle border-border-subtle;
|
||||
@apply hover:border-border-subtle hover:text-text hover:bg-surface-highlight;
|
||||
|
||||
@apply border px-1 mx-[0.5px] rounded cursor-default dark:shadow;
|
||||
@apply inline border px-1 mx-[0.5px] rounded cursor-default dark:shadow;
|
||||
|
||||
-webkit-text-security: none;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defaultKeymap } from '@codemirror/commands';
|
||||
import { Compartment, EditorState, type Extension } from '@codemirror/state';
|
||||
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
|
||||
import type { EnvironmentVariable } from '@yaakapp/api';
|
||||
import classNames from 'classnames';
|
||||
import { EditorView } from 'codemirror';
|
||||
import type { MutableRefObject, ReactNode } from 'react';
|
||||
@@ -15,9 +16,13 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useActiveEnvironment } from '../../../hooks/useActiveEnvironment';
|
||||
import { useActiveWorkspace } from '../../../hooks/useActiveWorkspace';
|
||||
import { useActiveEnvironmentVariables } from '../../../hooks/useActiveEnvironmentVariables';
|
||||
import { parseTemplate } from '../../../hooks/useParseTemplate';
|
||||
import { useSettings } from '../../../hooks/useSettings';
|
||||
import { type TemplateFunction, useTemplateFunctions } from '../../../hooks/useTemplateFunctions';
|
||||
import { useDialog } from '../../DialogContext';
|
||||
import { TemplateFunctionDialog } from '../../TemplateFunctionDialog';
|
||||
import { TemplateVariableDialog } from '../../TemplateVariableDialog';
|
||||
import { IconButton } from '../IconButton';
|
||||
import { HStack } from '../Stacks';
|
||||
import './Editor.css';
|
||||
@@ -58,6 +63,8 @@ export interface EditorProps {
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
const emptyVariables: EnvironmentVariable[] = [];
|
||||
|
||||
export const Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
|
||||
{
|
||||
readOnly,
|
||||
@@ -87,10 +94,9 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
ref,
|
||||
) {
|
||||
const s = useSettings();
|
||||
const [e] = useActiveEnvironment();
|
||||
const w = useActiveWorkspace();
|
||||
const environment = autocompleteVariables ? e : null;
|
||||
const workspace = autocompleteVariables ? w : null;
|
||||
const templateFunctions = useTemplateFunctions();
|
||||
const allEnvironmentVariables = useActiveEnvironmentVariables();
|
||||
const environmentVariables = autocompleteVariables ? allEnvironmentVariables : emptyVariables;
|
||||
|
||||
if (s && wrapLines === undefined) {
|
||||
wrapLines = s.editorSoftWrap;
|
||||
@@ -148,19 +154,78 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
cm.current?.view.dispatch({ effects: effect });
|
||||
}, [wrapLines]);
|
||||
|
||||
const dialog = useDialog();
|
||||
const onClickFunction = useCallback(
|
||||
async (fn: TemplateFunction, tagValue: string, startPos: number) => {
|
||||
const initialTokens = await parseTemplate(tagValue);
|
||||
dialog.show({
|
||||
id: 'template-function',
|
||||
size: 'sm',
|
||||
title: 'Configure Function',
|
||||
render: ({ hide }) => (
|
||||
<TemplateFunctionDialog
|
||||
templateFunction={fn}
|
||||
hide={hide}
|
||||
initialTokens={initialTokens}
|
||||
onChange={(insert) => {
|
||||
cm.current?.view.dispatch({
|
||||
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
[dialog],
|
||||
);
|
||||
|
||||
const onClickVariable = useCallback(
|
||||
async (v: EnvironmentVariable, tagValue: string, startPos: number) => {
|
||||
const initialTokens = await parseTemplate(tagValue);
|
||||
dialog.show({
|
||||
size: 'dynamic',
|
||||
id: 'template-variable',
|
||||
title: 'Configure Variable',
|
||||
render: ({ hide }) => (
|
||||
<TemplateVariableDialog
|
||||
definition={v}
|
||||
hide={hide}
|
||||
initialTokens={initialTokens}
|
||||
onChange={(insert) => {
|
||||
cm.current?.view.dispatch({
|
||||
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
[dialog],
|
||||
);
|
||||
|
||||
// Update language extension when contentType changes
|
||||
useEffect(() => {
|
||||
if (cm.current === null) return;
|
||||
const { view, languageCompartment } = cm.current;
|
||||
const ext = getLanguageExtension({
|
||||
contentType,
|
||||
environment,
|
||||
workspace,
|
||||
environmentVariables,
|
||||
useTemplating,
|
||||
autocomplete,
|
||||
templateFunctions,
|
||||
onClickFunction,
|
||||
onClickVariable,
|
||||
});
|
||||
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
|
||||
}, [contentType, autocomplete, useTemplating, environment, workspace]);
|
||||
}, [
|
||||
contentType,
|
||||
autocomplete,
|
||||
useTemplating,
|
||||
environmentVariables,
|
||||
templateFunctions,
|
||||
onClickFunction,
|
||||
onClickVariable,
|
||||
]);
|
||||
|
||||
// Initialize the editor when ref mounts
|
||||
const initEditorRef = useCallback(
|
||||
@@ -178,8 +243,10 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
contentType,
|
||||
useTemplating,
|
||||
autocomplete,
|
||||
environment,
|
||||
workspace,
|
||||
environmentVariables,
|
||||
templateFunctions,
|
||||
onClickVariable,
|
||||
onClickFunction,
|
||||
});
|
||||
|
||||
const state = EditorState.create({
|
||||
|
||||
@@ -31,9 +31,10 @@ import {
|
||||
rectangularSelection,
|
||||
} from '@codemirror/view';
|
||||
import { tags as t } from '@lezer/highlight';
|
||||
import type { EnvironmentVariable } from '@yaakapp/api';
|
||||
import { graphql, graphqlLanguageSupport } from 'cm6-graphql';
|
||||
import { EditorView } from 'codemirror';
|
||||
import type { Environment, Workspace } from '@yaakapp/api';
|
||||
import type { TemplateFunction } from '../../../hooks/useTemplateFunctions';
|
||||
import type { EditorProps } from './index';
|
||||
import { pairs } from './pairs/extension';
|
||||
import { text } from './text/extension';
|
||||
@@ -78,13 +79,17 @@ const syntaxExtensions: Record<string, LanguageSupport> = {
|
||||
export function getLanguageExtension({
|
||||
contentType,
|
||||
useTemplating = false,
|
||||
environment,
|
||||
workspace,
|
||||
environmentVariables,
|
||||
autocomplete,
|
||||
}: { environment: Environment | null; workspace: Workspace | null } & Pick<
|
||||
EditorProps,
|
||||
'contentType' | 'useTemplating' | 'autocomplete'
|
||||
>) {
|
||||
templateFunctions,
|
||||
onClickVariable,
|
||||
onClickFunction,
|
||||
}: {
|
||||
environmentVariables: EnvironmentVariable[];
|
||||
templateFunctions: TemplateFunction[];
|
||||
onClickFunction: (option: TemplateFunction, tagValue: string, startPos: number) => void;
|
||||
onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void;
|
||||
} & Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete'>) {
|
||||
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
|
||||
if (justContentType === 'application/graphql') {
|
||||
return graphql();
|
||||
@@ -94,7 +99,14 @@ export function getLanguageExtension({
|
||||
return base;
|
||||
}
|
||||
|
||||
return twig(base, environment, workspace, autocomplete);
|
||||
return twig({
|
||||
base,
|
||||
environmentVariables,
|
||||
templateFunctions,
|
||||
autocomplete,
|
||||
onClickFunction,
|
||||
onClickVariable,
|
||||
});
|
||||
}
|
||||
|
||||
export const baseExtensions = [
|
||||
|
||||
@@ -75,21 +75,21 @@ const decorator = function () {
|
||||
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
placeholders: DecorationSet;
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.placeholders = placeholderMatcher.createDeco(view);
|
||||
this.decorations = placeholderMatcher.createDeco(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
|
||||
this.decorations = placeholderMatcher.updateDeco(update, this.decorations);
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (instance) => instance.placeholders,
|
||||
decorations: (instance) => instance.decorations,
|
||||
provide: (plugin) =>
|
||||
EditorView.bidiIsolatedRanges.of((view) => {
|
||||
return view.plugin(plugin)?.placeholders || Decoration.none;
|
||||
return view.plugin(plugin)?.decorations || Decoration.none;
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,8 +3,12 @@ import type { CompletionContext } from '@codemirror/autocomplete';
|
||||
const openTag = '${[ ';
|
||||
const closeTag = ' ]}';
|
||||
|
||||
interface TwigCompletionOption {
|
||||
export interface TwigCompletionOption {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'function' | 'variable' | 'unknown';
|
||||
value: string | null;
|
||||
onClick?: (rawTag: string, startPos: number) => void;
|
||||
}
|
||||
|
||||
export interface TwigCompletionConfig {
|
||||
@@ -41,12 +45,16 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
|
||||
from: toMatch.from,
|
||||
options: options
|
||||
.filter((v) => v.name.trim())
|
||||
.map((v) => ({
|
||||
label: toStartOfVariable ? `${openTag}${v.name}${closeTag}` : v.name,
|
||||
apply: `${openTag}${v.name}${closeTag}`,
|
||||
type: 'variable',
|
||||
matchLen: matchLen,
|
||||
}))
|
||||
.map((v) => {
|
||||
const innerLabel = v.type === 'function' ? `${v.name}()` : v.name;
|
||||
const tagSyntax = openTag + innerLabel + closeTag;
|
||||
return {
|
||||
label: innerLabel,
|
||||
apply: tagSyntax,
|
||||
type: v.type === 'variable' ? 'variable' : 'function',
|
||||
matchLen: matchLen,
|
||||
};
|
||||
})
|
||||
// Filter out exact matches
|
||||
.filter((o) => o.label !== toMatch.text),
|
||||
};
|
||||
|
||||
@@ -1,29 +1,57 @@
|
||||
import type { LanguageSupport } from '@codemirror/language';
|
||||
import { LRLanguage } from '@codemirror/language';
|
||||
import { parseMixed } from '@lezer/common';
|
||||
import type { Environment, Workspace } from '@yaakapp/api';
|
||||
import type { EnvironmentVariable } from '@yaakapp/api';
|
||||
import type { TemplateFunction } from '../../../../hooks/useTemplateFunctions';
|
||||
import type { GenericCompletionConfig } from '../genericCompletion';
|
||||
import { genericCompletion } from '../genericCompletion';
|
||||
import { textLanguageName } from '../text/extension';
|
||||
import type { TwigCompletionOption } from './completion';
|
||||
import { twigCompletion } from './completion';
|
||||
import { placeholders } from './placeholder';
|
||||
import { templateTags } from './templateTags';
|
||||
import { parser as twigParser } from './twig';
|
||||
|
||||
export function twig(
|
||||
base: LanguageSupport,
|
||||
environment: Environment | null,
|
||||
workspace: Workspace | null,
|
||||
autocomplete?: GenericCompletionConfig,
|
||||
) {
|
||||
export function twig({
|
||||
base,
|
||||
environmentVariables,
|
||||
templateFunctions,
|
||||
autocomplete,
|
||||
onClickFunction,
|
||||
onClickVariable,
|
||||
}: {
|
||||
base: LanguageSupport;
|
||||
environmentVariables: EnvironmentVariable[];
|
||||
templateFunctions: TemplateFunction[];
|
||||
autocomplete?: GenericCompletionConfig;
|
||||
onClickFunction: (option: TemplateFunction, tagValue: string, startPos: number) => void;
|
||||
onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void;
|
||||
}) {
|
||||
const language = mixLanguage(base);
|
||||
const allVariables = [...(workspace?.variables ?? []), ...(environment?.variables ?? [])];
|
||||
const variables = allVariables.filter((v) => v.enabled) ?? [];
|
||||
const completions = twigCompletion({ options: variables });
|
||||
|
||||
const variableOptions: TwigCompletionOption[] =
|
||||
environmentVariables.map((v) => ({
|
||||
...v,
|
||||
type: 'variable',
|
||||
label: v.name,
|
||||
onClick: (rawTag: string, startPos: number) => onClickVariable(v, rawTag, startPos),
|
||||
})) ?? [];
|
||||
const functionOptions: TwigCompletionOption[] =
|
||||
templateFunctions.map((fn) => ({
|
||||
name: fn.name,
|
||||
type: 'function',
|
||||
value: null,
|
||||
label: fn.name + '(' + fn.args.length + ')',
|
||||
onClick: (rawTag: string, startPos: number) => onClickFunction(fn, rawTag, startPos),
|
||||
})) ?? [];
|
||||
|
||||
const options = [...variableOptions, ...functionOptions];
|
||||
|
||||
const completions = twigCompletion({ options });
|
||||
|
||||
return [
|
||||
language,
|
||||
base.support,
|
||||
placeholders(variables),
|
||||
templateTags(options),
|
||||
language.data.of({ autocomplete: completions }),
|
||||
base.language.data.of({ autocomplete: completions }),
|
||||
language.data.of({ autocomplete: genericCompletion(autocomplete) }),
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
|
||||
import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
|
||||
import { BetterMatchDecorator } from '../BetterMatchDecorator';
|
||||
|
||||
class PlaceholderWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly name: string,
|
||||
readonly value: string,
|
||||
readonly exists: boolean,
|
||||
readonly type: 'function' | 'variable' = 'variable',
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: PlaceholderWidget) {
|
||||
return this.name == other.name && this.exists == other.exists;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const elt = document.createElement('span');
|
||||
elt.className = `x-theme-placeholder placeholder ${
|
||||
!this.exists
|
||||
? 'x-theme-placeholder--danger'
|
||||
: this.type === 'variable'
|
||||
? 'x-theme-placeholder--primary'
|
||||
: 'x-theme-placeholder--info'
|
||||
}`;
|
||||
elt.title = !this.exists ? 'Variable not found in active environment' : this.value ?? '';
|
||||
elt.textContent = this.name;
|
||||
return elt;
|
||||
}
|
||||
|
||||
ignoreEvent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const placeholders = function (variables: { name: string; value?: string }[]) {
|
||||
const placeholderMatcher = new BetterMatchDecorator({
|
||||
regexp: /\$\{\[\s*([^\]\s]+)\s*]}/g,
|
||||
decoration(match, view, matchStartPos) {
|
||||
const matchEndPos = matchStartPos + match[0].length - 1;
|
||||
|
||||
// Don't decorate if the cursor is inside the match
|
||||
for (const r of view.state.selection.ranges) {
|
||||
if (r.from > matchStartPos && r.to <= matchEndPos) {
|
||||
return Decoration.replace({});
|
||||
}
|
||||
}
|
||||
|
||||
const groupMatch = match[1];
|
||||
if (groupMatch == null) {
|
||||
// Should never happen, but make TS happy
|
||||
console.warn('Group match was empty', match);
|
||||
return Decoration.replace({});
|
||||
}
|
||||
|
||||
const isFunction = groupMatch.includes('(');
|
||||
return Decoration.replace({
|
||||
inclusive: true,
|
||||
widget: new PlaceholderWidget(
|
||||
groupMatch,
|
||||
variables.find((v) => v.name === groupMatch)?.value ?? '',
|
||||
isFunction ? true : variables.some((v) => v.name === groupMatch),
|
||||
isFunction ? 'function' : 'variable',
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
placeholders: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.placeholders = placeholderMatcher.createDeco(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (instance) => instance.placeholders,
|
||||
provide: (plugin) =>
|
||||
EditorView.atomicRanges.of((view) => {
|
||||
return view.plugin(plugin)?.placeholders || Decoration.none;
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
115
src-web/components/core/Editor/twig/templateTags.ts
Normal file
115
src-web/components/core/Editor/twig/templateTags.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
|
||||
import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
|
||||
import { truncate } from '../../../../lib/truncate';
|
||||
import { BetterMatchDecorator } from '../BetterMatchDecorator';
|
||||
import type { TwigCompletionOption } from './completion';
|
||||
|
||||
const TAG_TRUNCATE_LEN = 30;
|
||||
|
||||
class TemplateTagWidget extends WidgetType {
|
||||
readonly #clickListenerCallback: () => void;
|
||||
|
||||
constructor(
|
||||
readonly option: TwigCompletionOption,
|
||||
readonly rawTag: string,
|
||||
readonly startPos: number,
|
||||
) {
|
||||
super();
|
||||
this.#clickListenerCallback = () => {
|
||||
this.option.onClick?.(this.rawTag, this.startPos);
|
||||
};
|
||||
}
|
||||
|
||||
eq(other: TemplateTagWidget) {
|
||||
return (
|
||||
this.option.name === other.option.name &&
|
||||
this.option.type === other.option.type &&
|
||||
this.option.value === other.option.value &&
|
||||
this.rawTag === other.rawTag
|
||||
);
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const elt = document.createElement('span');
|
||||
elt.className = `x-theme-templateTag template-tag ${
|
||||
this.option.type === 'unknown'
|
||||
? 'x-theme-templateTag--danger'
|
||||
: this.option.type === 'variable'
|
||||
? 'x-theme-templateTag--primary'
|
||||
: 'x-theme-templateTag--info'
|
||||
}`;
|
||||
elt.title = this.option.type === 'unknown' ? '__NOT_FOUND__' : this.option.value ?? '';
|
||||
elt.textContent = truncate(
|
||||
this.rawTag.replace('${[', '').replace(']}', '').trim(),
|
||||
TAG_TRUNCATE_LEN,
|
||||
);
|
||||
elt.addEventListener('click', this.#clickListenerCallback);
|
||||
return elt;
|
||||
}
|
||||
|
||||
destroy(dom: HTMLElement) {
|
||||
dom.removeEventListener('click', this.#clickListenerCallback);
|
||||
super.destroy(dom);
|
||||
}
|
||||
|
||||
ignoreEvent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function templateTags(options: TwigCompletionOption[]) {
|
||||
const templateTagMatcher = new BetterMatchDecorator({
|
||||
regexp: /\$\{\[\s*([^\]]+)\s*]}/g,
|
||||
decoration(match, view, matchStartPos) {
|
||||
const matchEndPos = matchStartPos + match[0].length - 1;
|
||||
|
||||
// Don't decorate if the cursor is inside the match
|
||||
for (const r of view.state.selection.ranges) {
|
||||
if (r.from > matchStartPos && r.to <= matchEndPos) {
|
||||
return Decoration.replace({});
|
||||
}
|
||||
}
|
||||
|
||||
const innerTagMatch = match[1];
|
||||
if (innerTagMatch == null) {
|
||||
// Should never happen, but make TS happy
|
||||
console.warn('Group match was empty', match);
|
||||
return Decoration.replace({});
|
||||
}
|
||||
|
||||
// TODO: Replace this hacky match with a proper template parser
|
||||
const name = innerTagMatch.match(/\s*(\w+)[(\s]*/)?.[1] ?? innerTagMatch;
|
||||
|
||||
let option = options.find((v) => v.name === name);
|
||||
if (option == null) {
|
||||
option = { type: 'unknown', name: innerTagMatch, value: null, label: innerTagMatch };
|
||||
}
|
||||
|
||||
return Decoration.replace({
|
||||
inclusive: true,
|
||||
widget: new TemplateTagWidget(option, match[0], matchStartPos),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = templateTagMatcher.createDeco(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
this.decorations = templateTagMatcher.updateDeco(update, this.decorations);
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (instance) => instance.decorations,
|
||||
provide: (plugin) =>
|
||||
EditorView.atomicRanges.of((view) => {
|
||||
return view.plugin(plugin)?.decorations || Decoration.none;
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export function Select<T extends string>({
|
||||
{options.map((o) => {
|
||||
if (o.type === 'separator') return null;
|
||||
return (
|
||||
<option key={o.label} value={o.value}>
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user