feat(base): insert palette items at cursor and refocus editor

This commit is contained in:
Philipinho
2026-04-24 02:47:12 +01:00
parent 464bd701ba
commit 3bfdae7990
2 changed files with 68 additions and 33 deletions
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
Button, Button,
Divider, Divider,
@@ -45,6 +45,8 @@ export function FormulaEditor({
onCancel, onCancel,
}: Props) { }: Props) {
const [source, setSource] = useState(initialSource); const [source, setSource] = useState(initialSource);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pendingCursorRef = useRef<number | null>(null);
const parseState = useFormulaParser( const parseState = useFormulaParser(
source, source,
properties, properties,
@@ -52,8 +54,36 @@ export function FormulaEditor({
registry, registry,
); );
const canSave = parseState.state === "ok" && !disabled; const canSave = parseState.state === "ok" && !disabled;
const insertAtEnd = (snippet: string) =>
setSource((s) => `${s}${s ? " " : ""}${snippet}`); // After a palette insert mutates `source`, wait for React to flush the
// new value into the textarea, then focus + restore the cursor. Using
// useEffect (not RAF) guarantees the DOM update ran first.
useEffect(() => {
if (pendingCursorRef.current === null) return;
const pos = pendingCursorRef.current;
pendingCursorRef.current = null;
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
ta.setSelectionRange(pos, pos);
}, [source]);
const insertAtCursor = (snippet: string, cursorOffsetFromEnd = 0) => {
const ta = textareaRef.current;
const start = ta?.selectionStart ?? source.length;
const end = ta?.selectionEnd ?? source.length;
const before = source.slice(0, start);
const after = source.slice(end);
// Add a space separator when inserting after content that would
// otherwise mash against the snippet (e.g. `2` + `prop("A")`).
const prev = before.slice(-1);
const needsSpace = prev !== "" && !/[\s(,]/.test(prev);
const prefix = needsSpace ? " " : "";
const next = before + prefix + snippet + after;
pendingCursorRef.current =
before.length + prefix.length + snippet.length - cursorOffsetFromEnd;
setSource(next);
};
return ( return (
<Paper <Paper
@@ -97,6 +127,7 @@ export function FormulaEditor({
<Stack gap={6} px={14} pt={10} pb={8}> <Stack gap={6} px={14} pt={10} pb={8}>
<FormulaInput <FormulaInput
ref={textareaRef}
value={source} value={source}
onChange={setSource} onChange={setSource}
hasError={parseState.state === "error"} hasError={parseState.state === "error"}
@@ -130,7 +161,7 @@ export function FormulaEditor({
<Stack gap={8} px={14} pt={10} pb={10}> <Stack gap={8} px={14} pt={10} pb={10}>
<PropertyChipRow <PropertyChipRow
properties={properties.filter((p) => p.id !== editingPropertyId)} properties={properties.filter((p) => p.id !== editingPropertyId)}
onInsert={(name) => insertAtEnd(`prop("${name}")`)} onInsert={(name) => insertAtCursor(`prop("${name}")`)}
/> />
</Stack> </Stack>
@@ -142,7 +173,7 @@ export function FormulaEditor({
</Text> </Text>
<FunctionPalette <FunctionPalette
registry={registry} registry={registry}
onInsert={(name) => insertAtEnd(`${name}()`)} onInsert={(name) => insertAtCursor(`${name}()`, 1)}
/> />
</Stack> </Stack>
@@ -1,3 +1,4 @@
import { forwardRef } from "react";
import { Textarea } from "@mantine/core"; import { Textarea } from "@mantine/core";
type Props = { type Props = {
@@ -6,31 +7,34 @@ type Props = {
hasError?: boolean; hasError?: boolean;
}; };
export function FormulaInput({ value, onChange, hasError }: Props) { export const FormulaInput = forwardRef<HTMLTextAreaElement, Props>(
return ( function FormulaInput({ value, onChange, hasError }, ref) {
<Textarea return (
autosize <Textarea
minRows={3} ref={ref}
maxRows={8} autosize
value={value} minRows={3}
onChange={(e) => onChange(e.currentTarget.value)} maxRows={8}
placeholder='prop("Price") * prop("Qty")' value={value}
styles={{ onChange={(e) => onChange(e.currentTarget.value)}
input: { placeholder='prop("Price") * prop("Qty")'
fontFamily: styles={{
"ui-monospace, SFMono-Regular, Menlo, 'JetBrains Mono', monospace", input: {
fontSize: 13, fontFamily:
lineHeight: 1.65, "ui-monospace, SFMono-Regular, Menlo, 'JetBrains Mono', monospace",
backgroundColor: "var(--mantine-color-gray-0)", fontSize: 13,
borderColor: hasError lineHeight: 1.65,
? "var(--mantine-color-red-6)" backgroundColor: "var(--mantine-color-gray-0)",
: "var(--mantine-color-blue-6)", borderColor: hasError
borderWidth: 1.5, ? "var(--mantine-color-red-6)"
boxShadow: hasError : "var(--mantine-color-blue-6)",
? "0 0 0 3px var(--mantine-color-red-1)" borderWidth: 1.5,
: "0 0 0 3px var(--mantine-color-blue-1)", boxShadow: hasError
}, ? "0 0 0 3px var(--mantine-color-red-1)"
}} : "0 0 0 3px var(--mantine-color-blue-1)",
/> },
); }}
} />
);
},
);