-
Notifications
You must be signed in to change notification settings - Fork 3.9k
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 #7379
Changes from 39 commits
997c1f8
feaf04c
163ad78
b2111ec
65733c8
624383a
75d2c7d
faa410b
96acb70
80ed1f3
6ec81ab
25141c8
c32cd7e
2c3e6a9
bf9324a
fefc2cc
96ffc1b
ed3eaae
c6be4ab
ec924ed
e717bc8
e56a5c6
c83af5c
93c1d46
50a25c9
74b7ba4
e78ee36
b378aac
c0f0c3e
05842f0
48b448c
62c48c2
ecc8a8c
fb0901f
95f249b
92f915f
f97c433
44c678a
2bd08b0
a20ee80
7209172
64564ef
d00d441
36da57b
c10c935
d355467
a148965
71d6d08
d1d512a
17164ed
bda722a
cc3b33b
a8c1816
967be19
dc7d907
5957c02
c064171
481607c
b7d338e
9f389f2
132f5d2
6e7d175
c0e01ba
a1bad34
429e6a9
d480ffb
ebcb51e
a6ac187
18ee766
ae875e1
70f13eb
80f8ae4
8555101
8133c4f
d290268
5ca2247
73b7b5f
f3ebe5c
2e25fd4
4ce0cdc
93d7641
ba60a17
69c48b5
4525a00
d15d01a
78aba64
f153b42
ea753c2
707f7ba
e84db18
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,238 @@ | ||
import { EditorView } from '@uiw/react-codemirror'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How about calling this |
||
import { Completion, CompletionContext } from '@codemirror/autocomplete'; | ||
import { autocompletion } from '@codemirror/autocomplete'; | ||
|
||
import { Editor } from '@/components/primitives/editor'; | ||
import { completions } from '@/utils/liquid-autocomplete'; | ||
import { LiquidVariable } from '@/utils/parseStepVariablesToLiquidVariables'; | ||
import { Popover, PopoverTrigger } from '@/components/primitives/popover'; | ||
import { createVariablePlugin } from './variable-plugin'; | ||
import { VariablePopover } from './variable-popover'; | ||
import { variablePillTheme } from './variable-plugin/variable-theme'; | ||
import { useCallback, useMemo, useRef, useState, Dispatch, SetStateAction } from 'react'; | ||
|
||
type SelectedVariable = { | ||
value: string; | ||
from: number; | ||
to: number; | ||
} | null; | ||
|
||
type CompletionRange = { | ||
scopsy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
from: number; | ||
to: number; | ||
} | null; | ||
|
||
type FieldEditorProps = { | ||
value: string; | ||
onChange: (value: string) => void; | ||
variables: LiquidVariable[]; | ||
placeholder?: string; | ||
autoFocus?: boolean; | ||
size?: 'default' | 'lg'; | ||
fontFamily?: 'inherit'; | ||
id?: string; | ||
singleLine?: boolean; | ||
scopsy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
indentWithTab?: boolean; | ||
}; | ||
|
||
export function FieldEditor({ | ||
scopsy marked this conversation as resolved.
Show resolved
Hide resolved
scopsy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
value, | ||
onChange, | ||
variables, | ||
placeholder, | ||
autoFocus, | ||
size = 'default', | ||
fontFamily = 'inherit', | ||
id, | ||
singleLine, | ||
indentWithTab, | ||
}: FieldEditorProps) { | ||
const viewRef = useRef<EditorView | null>(null); | ||
const lastCompletionRef = useRef<CompletionRange>(null); | ||
|
||
const { selectedVariable, setSelectedVariable, handleVariableSelect, isUpdatingRef } = useVariableSelection(); | ||
|
||
const handleVariableUpdate = useVariableUpdate( | ||
scopsy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
selectedVariable, | ||
viewRef, | ||
isUpdatingRef, | ||
onChange, | ||
setSelectedVariable | ||
); | ||
scopsy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
const completionSource = useCompletionSource(variables, lastCompletionRef); | ||
scopsy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
const extensions = useMemo( | ||
() => [ | ||
autocompletion({ | ||
scopsy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
override: [completionSource], | ||
closeOnBlur: false, | ||
defaultKeymap: true, | ||
activateOnTyping: true, | ||
}), | ||
EditorView.lineWrapping, | ||
variablePillTheme, | ||
createVariablePlugin({ viewRef, lastCompletionRef, onSelect: handleVariableSelect }), | ||
], | ||
[variables, completionSource, handleVariableSelect] | ||
); | ||
|
||
return ( | ||
<div className="relative"> | ||
<Editor | ||
singleLine={singleLine} | ||
indentWithTab={indentWithTab} | ||
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 /> | ||
scopsy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
</PopoverTrigger> | ||
{selectedVariable && ( | ||
<VariablePopover | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the popover jumps, I feel it should be aligned with the start of the variable, but not sure if that is possible Screen.Recording.2024-12-31.at.10.02.25.movThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's a tricky one, i gave up on this. Will make another try with a clean head :) |
||
variable={selectedVariable.value} | ||
onClose={() => setSelectedVariable(null)} | ||
onUpdate={handleVariableUpdate} | ||
/> | ||
)} | ||
</Popover> | ||
</div> | ||
); | ||
} | ||
|
||
/** | ||
* Manages the state and selection of Liquid variables in the editor. | ||
* | ||
* This hook handles the complex state management needed when a user selects a variable in the editor: | ||
* 1. Tracks which variable is currently selected | ||
* 2. Prevents recursive updates when variables are being modified | ||
*/ | ||
function useVariableSelection() { | ||
const [selectedVariable, setSelectedVariable] = useState<SelectedVariable>(null); | ||
const isUpdatingRef = useRef(false); | ||
|
||
const handleVariableSelect = useCallback((value: string, from: number, to: number) => { | ||
if (isUpdatingRef.current) return; | ||
|
||
requestAnimationFrame(() => { | ||
scopsy marked this conversation as resolved.
Show resolved
Hide resolved
scopsy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
setSelectedVariable({ value, from, to }); | ||
}); | ||
}, []); | ||
|
||
return { | ||
selectedVariable, | ||
setSelectedVariable, | ||
handleVariableSelect, | ||
isUpdatingRef, | ||
}; | ||
} | ||
|
||
/** | ||
* Handles the logic of updating Liquid variables in the editor while maintaining proper syntax. | ||
* | ||
* This hook manages several critical aspects of variable editing: | ||
* 1. Ensures proper Liquid syntax ({{...}}) is maintained when updating variables | ||
* 2. Handles edge cases like existing closing brackets | ||
* 3. Maintains cursor position after updates | ||
* 4. Prevents recursive updates during the edit process | ||
* | ||
* The update process: | ||
* 1. Checks if the new value already has Liquid syntax | ||
* 2. Detects if there are existing closing brackets after the cursor | ||
* 3. Calculates the correct range to replace | ||
* 4. Updates the editor state while maintaining proper cursor position | ||
*/ | ||
function useVariableUpdate( | ||
scopsy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
selectedVariable: SelectedVariable, | ||
viewRef: React.RefObject<EditorView>, | ||
isUpdatingRef: React.MutableRefObject<boolean>, | ||
onChange: (value: string) => void, | ||
setSelectedVariable: Dispatch<SetStateAction<SelectedVariable>> | ||
) { | ||
return useCallback( | ||
(newValue: string) => { | ||
if (!selectedVariable || !viewRef.current || isUpdatingRef.current) return; | ||
|
||
try { | ||
isUpdatingRef.current = true; | ||
const { from, to } = selectedVariable; | ||
const view = viewRef.current; | ||
|
||
const hasLiquidSyntax = newValue.match(/^\{\{.*\}\}$/); | ||
const newVariableText = hasLiquidSyntax ? newValue : `{{${newValue}}}`; | ||
|
||
const currentContent = view.state.doc.toString(); | ||
const afterCursor = currentContent.slice(to).trim(); | ||
const hasClosingBrackets = afterCursor.startsWith('}}'); | ||
|
||
const changes = { | ||
from, | ||
to: hasClosingBrackets ? to + 2 : to, | ||
insert: newVariableText, | ||
}; | ||
|
||
view.dispatch({ | ||
changes, | ||
selection: { anchor: from + newVariableText.length }, | ||
}); | ||
|
||
onChange(view.state.doc.toString()); | ||
|
||
setSelectedVariable((prev: SelectedVariable) => | ||
prev ? { ...prev, value: newValue, to: from + newVariableText.length } : null | ||
); | ||
} finally { | ||
isUpdatingRef.current = false; | ||
} | ||
}, | ||
[selectedVariable, onChange, viewRef, isUpdatingRef, setSelectedVariable] | ||
); | ||
} | ||
|
||
function useCompletionSource(variables: LiquidVariable[], lastCompletionRef: React.MutableRefObject<CompletionRange>) { | ||
return useCallback( | ||
(context: CompletionContext) => { | ||
// Match text that starts with {{ and capture everything after it until the cursor position | ||
const word = context.matchBefore(/\{\{([^}]*)/); | ||
scopsy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 }; | ||
|
||
// Handle liquid variable syntax ({{variable}}) | ||
// If we're not already inside liquid brackets, wrap the completion with {{}} | ||
// If we're inside liquid brackets (detected by checking previous chars), just insert the variable name | ||
const content = view.state.doc.toString(); | ||
const before = content.slice(Math.max(0, from - 2), from); | ||
const insert = before !== '{{' ? `{{${text}}} ` : `${text}}} `; | ||
|
||
view.dispatch({ | ||
changes: { from, to, insert }, | ||
}); | ||
}, | ||
scopsy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}; | ||
}, | ||
[variables, lastCompletionRef] | ||
); | ||
} |
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,3 @@ | ||
export const VARIABLE_REGEX = /{{([^{}]+)}}/g; | ||
scopsy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
export const VARIABLE_PILL_CLASS = 'cm-variable-pill'; | ||
export const MODIFIERS_CLASS = 'has-modifiers'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
import { EditorView, ViewPlugin, Decoration } from '@uiw/react-codemirror'; | ||
import type { PluginState } from './types'; | ||
import { VariablePluginView } from './plugin-view'; | ||
|
||
export function createVariablePlugin({ viewRef, lastCompletionRef, onSelect }: PluginState) { | ||
scopsy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return ViewPlugin.fromClass( | ||
class { | ||
private view: VariablePluginView; | ||
|
||
constructor(view: EditorView) { | ||
this.view = new VariablePluginView(view, viewRef, lastCompletionRef, onSelect); | ||
} | ||
|
||
update(update: any) { | ||
this.view.update(update); | ||
} | ||
|
||
get decorations() { | ||
return this.view.decorations; | ||
} | ||
}, | ||
{ | ||
decorations: (v) => v.decorations, | ||
provide: (plugin) => | ||
EditorView.atomicRanges.of((view) => { | ||
return view.plugin(plugin)?.decorations || Decoration.none; | ||
}), | ||
} | ||
); | ||
} | ||
|
||
export * from './types'; | ||
export * from './constants'; | ||
scopsy marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
import { EditorView, Decoration, DecorationSet } from '@uiw/react-codemirror'; | ||
import { MutableRefObject } from 'react'; | ||
import { VARIABLE_REGEX } from './constants'; | ||
import { VariablePillWidget } from './variable-pill-widget'; | ||
import { handleVariableBackspace, handleVariableCompletion, isTypingVariable, parseVariable } from './utils'; | ||
|
||
export class VariablePluginView { | ||
decorations: DecorationSet; | ||
lastCursor: number = 0; | ||
isTypingVariable: boolean = false; | ||
|
||
constructor( | ||
view: EditorView, | ||
private viewRef: MutableRefObject<EditorView | null>, | ||
private lastCompletionRef: MutableRefObject<{ from: number; to: number } | null>, | ||
private onSelect?: (value: string, from: number, to: number) => void | ||
) { | ||
this.decorations = this.createDecorations(view); | ||
viewRef.current = view; | ||
} | ||
|
||
update(update: any) { | ||
scopsy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if (update.docChanged || update.viewportChanged || update.selectionSet) { | ||
const pos = update.state.selection.main.head; | ||
const content = update.state.doc.toString(); | ||
|
||
this.isTypingVariable = isTypingVariable(content, pos); | ||
|
||
// Handle backspace inside a variable | ||
if (update.docChanged && update.changes.desc === 'input.delete.backward') { | ||
if (handleVariableBackspace(update.view, pos, content)) { | ||
return; | ||
} | ||
} | ||
|
||
if (update.docChanged) { | ||
handleVariableCompletion(update.view, pos, content); | ||
} | ||
|
||
this.decorations = this.createDecorations(update.view); | ||
} | ||
|
||
if (update.view) { | ||
this.viewRef.current = update.view; | ||
} | ||
} | ||
|
||
createDecorations(view: EditorView) { | ||
const decorations: any[] = []; | ||
const content = view.state.doc.toString(); | ||
const pos = view.state.selection.main.head; | ||
let match; | ||
|
||
// Iterate through all variable matches in the content and add the pills | ||
while ((match = VARIABLE_REGEX.exec(content)) !== null) { | ||
const { fullVariableName, variableName, start, end, hasModifiers } = parseVariable(match); | ||
|
||
// Skip creating pills for variables that are currently being edited | ||
// This allows users to modify variables without the pill getting in the way | ||
if (this.isTypingVariable && pos > start && pos < end) { | ||
continue; | ||
} | ||
|
||
if (variableName) { | ||
decorations.push( | ||
Decoration.replace({ | ||
widget: new VariablePillWidget(variableName, fullVariableName, start, end, hasModifiers, this.onSelect), | ||
inclusive: false, | ||
side: -1, | ||
}).range(start, end) | ||
); | ||
} | ||
} | ||
|
||
this.lastCompletionRef.current = null; | ||
|
||
return Decoration.set(decorations, true); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Had to add it since I need to reference it from ::after in css