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,9 +7,11 @@ type Props = {
hasError?: boolean; hasError?: boolean;
}; };
export function FormulaInput({ value, onChange, hasError }: Props) { export const FormulaInput = forwardRef<HTMLTextAreaElement, Props>(
function FormulaInput({ value, onChange, hasError }, ref) {
return ( return (
<Textarea <Textarea
ref={ref}
autosize autosize
minRows={3} minRows={3}
maxRows={8} maxRows={8}
@@ -33,4 +36,5 @@ export function FormulaInput({ value, onChange, hasError }: Props) {
}} }}
/> />
); );
} },
);