Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dashboard): variable management for all editor fields - DRAFT #7379

Draft
wants to merge 18 commits into
base: next
Choose a base branch
from
166 changes: 166 additions & 0 deletions apps/dashboard/src/components/primitives/field-editor/field-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { EditorView } from '@uiw/react-codemirror';
import { useMemo, useState, useRef, useCallback } from 'react';
import { Completion, CompletionContext } from '@codemirror/autocomplete';

import { Editor } from '@/components/primitives/editor';
import { completions } from '@/utils/liquid-autocomplete';
import { LiquidVariable } from '@/utils/parseStepVariablesToLiquidVariables';
import { autocompletion } from '@codemirror/autocomplete';
import { Popover, PopoverTrigger } from '@/components/primitives/popover';
import { VariablePopover } from './variable-popover';
import { createVariablePlugin } from './variable-plugin';
import { variablePillTheme } from './variable-theme';

type FieldEditorProps = {
value: string;
onChange: (value: string) => void;
variables: LiquidVariable[];
placeholder?: string;
autoFocus?: boolean;
size?: 'default' | 'lg';
fontFamily?: 'inherit';
id?: string;
};

export const FieldEditor = ({
value,
onChange,
variables,
placeholder,
autoFocus,
size = 'default',
fontFamily = 'inherit',
id,
}: FieldEditorProps) => {
const [selectedVariable, setSelectedVariable] = useState<{ value: string; from: number; to: number } | null>(null);
const viewRef = useRef<EditorView | null>(null);
const isUpdatingRef = useRef(false);
const lastCompletionRef = useRef<{ from: number; to: number } | null>(null);

const handleVariableSelect = useCallback((value: string, from: number, to: number) => {
if (isUpdatingRef.current) return;
requestAnimationFrame(() => {
setSelectedVariable({ value, from, to });
});
}, []);

const handleVariableUpdate = useCallback(
(newValue: string) => {
if (!selectedVariable || !viewRef.current || isUpdatingRef.current) {
return;
}

try {
isUpdatingRef.current = true;
const { from, to } = selectedVariable;
const view = viewRef.current;

const newVariableText = `{{${newValue}}}`;

const changes = {
from,
to,
insert: newVariableText,
};

view.dispatch({
changes,
selection: { anchor: from + newVariableText.length },
});

const updatedContent = view.state.doc.toString();

onChange(updatedContent);

setSelectedVariable((prev) => (prev ? { ...prev, value: newValue, to: from + newVariableText.length } : null));
} finally {
isUpdatingRef.current = false;
}
},
[selectedVariable, onChange]
);

const completionSource = useCallback(
(context: CompletionContext) => {
const word = context.matchBefore(/\{\{([^}]*)/);
if (!word) return null;

const options = completions(variables)(context);
if (!options) return null;

return {
...options,
apply: (view: EditorView, completion: Completion, from: number, to: number) => {
const text = completion.label;
lastCompletionRef.current = { from, to };

const content = view.state.doc.toString();
const before = content.slice(Math.max(0, from - 2), from);

if (before !== '{{') {
view.dispatch({
changes: { from, to, insert: `{{${text}}} ` },
});
} else {
view.dispatch({
changes: { from, to, insert: `${text}}} ` },
});
}
},
};
},
[variables]
);

const extensions = useMemo(
() => [
autocompletion({
override: [completionSource],
closeOnBlur: false,
defaultKeymap: true,
activateOnTyping: true,
}),
EditorView.lineWrapping,
variablePillTheme,
createVariablePlugin({ viewRef, lastCompletionRef, onSelect: handleVariableSelect }),
],
[variables, completionSource, handleVariableSelect]
);

return (
<div className="relative">
<Editor
size={size}
className="flex-1"
autoFocus={autoFocus}
fontFamily={fontFamily}
placeholder={placeholder}
id={id}
extensions={extensions}
value={value}
onChange={onChange}
/>
<Popover
open={!!selectedVariable}
onOpenChange={(open) => {
if (!open) {
setTimeout(() => {
setSelectedVariable(null);
}, 0);
}
}}
>
<PopoverTrigger asChild>
<div />
</PopoverTrigger>
{selectedVariable && (
<VariablePopover
variable={selectedVariable.value}
onClose={() => setSelectedVariable(null)}
onUpdate={handleVariableUpdate}
/>
)}
</Popover>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { FieldEditor } from './field-editor';
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { EditorView, ViewPlugin, Decoration, DecorationSet, WidgetType } from '@uiw/react-codemirror';
import { MutableRefObject } from 'react';

interface PluginState {
viewRef: MutableRefObject<EditorView | null>;
lastCompletionRef: MutableRefObject<{ from: number; to: number } | null>;
onSelect?: (value: string, from: number, to: number) => void;
}

class VariablePillWidget extends WidgetType {
constructor(
private variableName: string,
private fullVariableName: string,
private start: number,
private end: number,
private hasModifiers: boolean,
private onSelect?: (value: string, from: number, to: number) => void
) {
super();
}

toDOM() {
const span = document.createElement('span');
const pillClass = `cm-variable-pill ${document.documentElement.classList.contains('dark') ? 'cm-dark' : ''} ${this.hasModifiers ? 'has-modifiers' : ''}`;
span.className = pillClass;
span.setAttribute('data-variable', this.fullVariableName);
span.setAttribute('data-start', this.start.toString());
span.setAttribute('data-end', this.end.toString());
span.setAttribute('data-display', this.variableName);
span.textContent = this.variableName;

const handleClick = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();

setTimeout(() => {
this.onSelect?.(this.fullVariableName, this.start, this.end);
}, 0);
};

span.addEventListener('mousedown', handleClick);
(span as any)._variableClickHandler = handleClick;

return span;
}

eq(other: VariablePillWidget) {
return (
other.variableName === this.variableName &&
other.fullVariableName === this.fullVariableName &&
other.start === this.start &&
other.end === this.end &&
other.hasModifiers === this.hasModifiers
);
}

destroy(dom: HTMLElement) {
if ((dom as any)._variableClickHandler) {
dom.removeEventListener('mousedown', (dom as any)._variableClickHandler);
delete (dom as any)._variableClickHandler;
}
}

ignoreEvent() {
return false;
}
}

export function createVariablePlugin({ viewRef, lastCompletionRef, onSelect }: PluginState) {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
lastCursor: number = 0;

constructor(view: EditorView) {
this.decorations = this.createDecorations(view);
viewRef.current = view;
}

update(update: any) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.lastCursor = update.state.selection.main.head;

const content = update.state.doc.toString();
const pos = update.state.selection.main.head;

if (update.docChanged && content.slice(pos - 2, pos) === '}}') {
const start = content.lastIndexOf('{{', pos);
if (start !== -1) {
const variableContent = content.slice(start + 2, pos - 2).trim();
if (variableContent) {
this.lastCursor = -1;
if (pos === content.length || content[pos] !== ' ') {
requestAnimationFrame(() => {
update.view.dispatch({
changes: { from: pos, insert: ' ' },
selection: { anchor: pos + 1 },
});
});
}
}
}
}

this.decorations = this.createDecorations(update.view);
}
if (update.view) {
viewRef.current = update.view;
}
}

createDecorations(view: EditorView) {
const decorations: any[] = [];
const content = view.state.doc.toString();
const variableRegex = /{{([^{}]+)}}/g;
let match;

while ((match = variableRegex.exec(content)) !== null) {
const start = match.index;
const end = start + match[0].length;
const fullVariableName = match[1].trim();

const parts = fullVariableName.split('|').map((part) => part.trim());
const variableName = parts[0];
const hasModifiers = parts.length > 1;

if (variableName) {
decorations.push(
Decoration.replace({
widget: new VariablePillWidget(variableName, fullVariableName, start, end, hasModifiers, onSelect),
inclusive: true,
side: 1,
}).range(start, end)
);
}
}

lastCompletionRef.current = null;
return Decoration.set(decorations, true);
}
},
{
decorations: (v) => v.decorations,
provide: (plugin) =>
EditorView.atomicRanges.of((view) => {
return view.plugin(plugin)?.decorations || Decoration.none;
}),
}
);
}
Loading
Loading