feat: add atomic TipTap variable, AI-variable, and conditional chip nodes with literal-token serialization for the campaign email editor

This commit is contained in:
Matthew Meszaros
2026-07-22 17:04:51 +02:00
parent bb6cb330b6
commit 71df2c8a6d
5 changed files with 1660 additions and 0 deletions
@@ -0,0 +1,423 @@
// AIVariableNode — an atomic inline "AI block" that generates unique copy for
// each recipient at send time. The full config rides inline in the editor HTML
// (base64 in a data attribute); the control-plane resolver reads it, renders the
// prompt per contact through the platform humanizer, and swaps the whole span for
// the result (see @/lib/aiVariables). The chip shows the prompt; clicking it opens
// a centered config modal (the app Dialog). The modal auto-opens ONCE right after
// insertion (via the justInserted registry), never again on Edit/Preview remounts.
//
// The instruction is the SAME rich editor used everywhere else, in `minimal` mode:
// {{.Field}} merge tokens render as chips, the {{ type-ahead works, and if/else
// conditionals are available — just small. The dialog runs non-modal so the
// editor's own body-portaled popovers (variable menu, type-ahead, condition
// builder) stay interactive inside it.
import React from "react";
import { createPortal } from "react-dom";
import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
import { AnimatePresence, motion } from "framer-motion";
import { SparklesIcon, GlobeIcon, TrashIcon } from "lucide-react";
import toast from "react-hot-toast";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import useGenerateAIVariable from "@/lib/api/hooks/app/generation/useGenerateAIVariable";
import useTypewriter from "@/components/app/ai/useTypewriter";
import formatUsage from "@/components/app/ai/usage";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { WRITE_TONES } from "@/lib/api/models/app/generation/Write";
import { VARIABLES } from "@/lib/templateVars";
import RichTextEditor from "@/components/app/campaigns/sequences/RichTextEditor";
import { htmlToPlain, promptToHtml, renderPreview, SAMPLE } from "@/components/app/campaigns/sequences/emailPreview";
import { markJustInserted, consumeJustInserted } from "./justInserted";
import {
type AIVariableConfig,
DEFAULT_AI_CONFIG,
aiToken,
newAIVariableId,
encodeConfig,
decodeConfig,
} from "@/lib/aiVariables";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
aiVariable: {
// Insert a fresh AI block; opens focused for immediate configuration.
insertAIVariable: (config?: Partial<AIVariableConfig>) => ReturnType;
};
}
}
export const AIVariableNode = TiptapNode.create({
name: "aiVariable",
inline: true,
group: "inline",
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
id: {
default: "",
parseHTML: (el) => (el as HTMLElement).getAttribute("data-ai-var") || "",
renderHTML: (attrs) => ({ "data-ai-var": attrs.id }),
},
config: {
default: DEFAULT_AI_CONFIG,
parseHTML: (el) => decodeConfig((el as HTMLElement).getAttribute("data-ai-config") || ""),
renderHTML: (attrs) => ({ "data-ai-config": encodeConfig(attrs.config as AIVariableConfig) }),
},
};
},
parseHTML() {
return [{ tag: "span[data-ai-var]" }];
},
renderHTML({ node, HTMLAttributes }) {
return ["span", mergeAttributes(HTMLAttributes), aiToken(node.attrs.id)];
},
renderText({ node }) {
return aiToken(node.attrs.id);
},
addCommands() {
return {
insertAIVariable:
(config?: Partial<AIVariableConfig>) =>
({ chain }) => {
// Mint the id here so it can be flagged as freshly inserted; the
// node view opens its modal once for this id and never again.
const id = newAIVariableId();
markJustInserted(id);
return chain()
.insertContent({
type: this.name,
attrs: { id, config: { ...DEFAULT_AI_CONFIG, ...config } },
})
.run();
},
};
},
addNodeView() {
return ReactNodeViewRenderer(AIVariableChip);
},
});
function truncate(s: string, n: number): string {
const t = s.trim().replace(/\s+/g, " ");
return t.length > n ? t.slice(0, n - 1) + "…" : t;
}
// Sky-tinted chip that shows the prompt; opens the config modal on click. It
// auto-opens only for a just-inserted block (consumeJustInserted), so toggling
// the Edit/Preview tabs — which remounts every node view — never reopens it.
function AIVariableChip({ node, updateAttributes, deleteNode, selected, editor }: NodeViewProps) {
const config: AIVariableConfig = node.attrs.config || DEFAULT_AI_CONFIG;
const [open, setOpen] = React.useState(() => consumeJustInserted(node.attrs.id));
// The email text on both sides of this block, so generation writes a fragment
// that flows with the sentence it lands in. Other AI blocks blank to a neutral
// placeholder; captured lazily (read when the config modal opens).
const getContext = React.useCallback(() => {
try {
const plain = htmlToPlain(editor.getHTML());
const idx = plain.indexOf(aiToken(node.attrs.id));
if (idx < 0) return { before: "", after: "" };
const clean = (s: string) => s.replace(/\[\[ai:[^\]]*\]\]/g, "…").trim();
return { before: clean(plain.slice(0, idx)), after: clean(plain.slice(idx + aiToken(node.attrs.id).length)) };
} catch {
return { before: "", after: "" };
}
}, [editor, node.attrs.id]);
const label = config.name?.trim() || (config.prompt ? truncate(config.prompt, 44) : "") || "Set up AI block";
return (
<NodeViewWrapper as="span" className="tpl-ai-wrap">
<motion.button
type="button"
initial={{ scale: 0.92, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
whileTap={{ scale: 0.96 }}
transition={{ type: "spring", stiffness: 640, damping: 30 }}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setOpen(true)}
title={config.prompt ? `AI: ${config.prompt}` : "Configure this AI block"}
className={`tpl-ai ${selected || open ? "tpl-ai-active" : ""} ${config.prompt ? "" : "tpl-ai-empty"}`}
>
<SparklesIcon className="h-2.5 w-2.5 shrink-0" />
<span className="max-w-[16rem] truncate">{label}</span>
</motion.button>
{/* Non-modal so the embedded editor's body-portaled popovers (variable menu,
{{ type-ahead, condition builder) stay clickable and focusable; the
onInteractOutside guard keeps a click on one of those from closing it. */}
<Dialog open={open} onOpenChange={setOpen} modal={false}>
{/* Non-modal Radix renders no overlay, so paint our own dimming backdrop.
Clicking it lands outside the content and dismisses via Radix. */}
{open &&
createPortal(
<div className="fixed inset-0 z-40 bg-black/50 duration-200 animate-in fade-in-0" aria-hidden />,
document.body,
)}
<DialogContent
showCloseButton={false}
className="gap-0 overflow-hidden p-0 sm:max-w-[640px]"
onOpenAutoFocus={(e) => e.preventDefault()}
onInteractOutside={(e) => {
const target = e.detail.originalEvent.target as HTMLElement | null;
if (target?.closest("[data-floating]")) e.preventDefault();
}}
>
<DialogTitle className="sr-only">Configure AI block</DialogTitle>
{open && (
<AIVariableConfigBody
config={config}
getContext={getContext}
onChange={(next) => updateAttributes({ config: next })}
onRemove={() => {
deleteNode();
setOpen(false);
}}
onClose={() => setOpen(false)}
/>
)}
</DialogContent>
</Dialog>
</NodeViewWrapper>
);
}
function AIVariableConfigBody({
config,
getContext,
onChange,
onRemove,
onClose,
}: {
config: AIVariableConfig;
getContext: () => { before: string; after: string };
onChange: (next: AIVariableConfig) => void;
onRemove: () => void;
onClose: () => void;
}) {
const gen = useGenerateAIVariable();
const typewriter = useTypewriter();
const [draft, setDraft] = React.useState<AIVariableConfig>(config);
const [preview, setPreview] = React.useState<string>("");
const [usage, setUsage] = React.useState<{ charged: number; tokens: number } | null>(null);
// Surrounding email, captured when the modal opens (the body isn't edited while open).
const [ctx] = React.useState(getContext);
// Compute the editor's starting HTML once (the body remounts each time the
// dialog opens), so the editor is effectively uncontrolled after mount and we
// only read plain text back out; feeding derived HTML back would reset the
// caret on every keystroke.
const [initialHtml] = React.useState(() => promptToHtml(config.prompt));
const patch = React.useCallback(
(p: Partial<AIVariableConfig>) => {
setDraft((d) => {
const next = { ...d, ...p };
onChange(next);
return next;
});
},
[onChange],
);
const runPreview = () => {
if (!draft.prompt.trim() || gen.isPending) return;
setPreview("");
// mode is pinned "instant"; the removed research mode no longer exists.
gen.mutate(
{
mode: "instant",
prompt: draft.prompt,
tone: draft.tone || undefined,
web_search: draft.web_search,
context_before: ctx.before,
context_after: ctx.after,
},
{
onSuccess: (res) => {
setUsage({ charged: res.credits_charged ?? 0, tokens: res.tokens_used ?? 0 });
typewriter.run(res.text, (partial) => setPreview(partial));
},
onError: (e) => {
const err = e as unknown as AppError;
if (err?.status === 402) {
toast.error("You're out of AI credits. Upgrade or purchase more to preview AI blocks.");
} else {
toast.error(buildError(err));
}
},
},
);
};
return (
<div className="flex max-h-[calc(100dvh-4rem)] min-h-[340px]">
{/* LEFT — the instruction, in the same editor used everywhere else */}
<div className="flex min-w-0 flex-1 flex-col">
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 pb-4 pt-5">
<div>
{/* opening clause — bare sparkle, no badge */}
<div className="mb-2 flex items-center gap-1.5 leading-none">
<SparklesIcon className="h-3.5 w-3.5 shrink-0 text-sky-500" />
<span className="text-[13px] text-slate-500">For each recipient, write</span>
</div>
<RichTextEditor
minimal
html={initialHtml}
onChange={(html) => patch({ prompt: htmlToPlain(html) })}
variables={VARIABLES}
placeholder="a warm one-line opener for {{.FirstName}} at {{.Company}}"
/>
</div>
{/* tone — quiet chips, all visible */}
<div className="flex flex-wrap items-center gap-1.5">
<span className="mr-1 text-[11px] text-slate-500">Tone</span>
{WRITE_TONES.map((t) => {
const active = (draft.tone || "") === t.value;
return (
<button
key={t.value || "default"}
type="button"
onClick={() => patch({ tone: t.value })}
className={`inline-flex h-6 items-center rounded-full border px-2.5 text-[11.5px] transition-colors ${
active
? "border-sky-300 bg-sky-50 text-sky-700"
: "border-slate-200 text-slate-500 hover:bg-slate-50"
}`}
>
{t.label}
</button>
);
})}
</div>
{/* the ONE add-on — a switch (globe left, switch on the right) */}
<button
type="button"
role="switch"
aria-checked={draft.web_search}
onClick={() => patch({ web_search: !draft.web_search })}
className="flex w-full items-center gap-2.5 text-left"
>
<GlobeIcon className="h-4 w-4 shrink-0 text-slate-400" />
<span className="min-w-0 flex-1">
<span className="block text-[12.5px] font-medium text-slate-700">Web search</span>
<span className="block text-[11px] leading-snug text-slate-400">
Look the contact up on the web before writing.
</span>
</span>
<span
className={`relative h-[18px] w-8 shrink-0 rounded-full transition-colors ${
draft.web_search ? "bg-sky-500" : "bg-slate-200"
}`}
>
<motion.span
layout
transition={{ type: "spring", stiffness: 560, damping: 34 }}
className={`absolute top-0.5 size-3.5 rounded-full bg-white shadow ${
draft.web_search ? "right-0.5" : "left-0.5"
}`}
/>
</span>
</button>
{/* cost — honest: it's metered by usage, not a flat number */}
<p className="text-[11px] leading-snug text-slate-400">
Billed by usage the tokens each snippet uses{draft.web_search ? ", plus the web search" : ""}.
Preview to see a real example.
</p>
</div>
{/* actions — scoped to the config pane */}
<div className="flex items-center justify-between border-t border-slate-200 px-5 py-3">
<button
type="button"
onClick={onRemove}
className="inline-flex items-center gap-1.5 text-[12px] text-slate-500 transition-colors hover:text-rose-600"
>
<TrashIcon className="h-3.5 w-3.5" /> Remove
</button>
<button
type="button"
onClick={onClose}
className="h-7 rounded-md bg-slate-900 px-4 text-[12.5px] font-medium text-white transition-colors hover:bg-slate-700"
>
Done
</button>
</div>
</div>
{/* RIGHT — the live sample */}
<div className="flex w-[300px] shrink-0 flex-col border-l border-slate-200 bg-slate-50/60 p-4">
<span className="text-[10px] font-medium uppercase tracking-[0.14em] text-slate-400">
Sample for Alex Rivera at Acme
</span>
<div className="mt-3 min-h-0 flex-1 overflow-y-auto">
<AnimatePresence mode="wait">
{gen.isPending && !preview ? (
<motion.div
key="busy"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex items-center gap-2"
>
<SparklesIcon className="h-3.5 w-3.5 shrink-0 animate-pulse text-sky-500" />
<span className="ai-shimmer-text text-[12px] font-medium">Writing a sample</span>
</motion.div>
) : preview ? (
// Show the WHOLE message with the generated fragment in place, so
// you can see it actually fits. Surrounding text renders with the
// sample contact's values; the AI part is highlighted.
<motion.p
key="text"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="whitespace-pre-wrap text-[13px] leading-relaxed text-slate-700"
>
{ctx.before || ctx.after ? (
<>
{renderPreview(ctx.before, SAMPLE)}
{ctx.before ? " " : ""}
<mark className="rounded bg-sky-100 px-0.5 text-slate-900">{preview}</mark>
{ctx.after ? " " : ""}
{renderPreview(ctx.after, SAMPLE)}
</>
) : (
preview
)}
</motion.p>
) : (
<p className="text-[12.5px] leading-relaxed text-slate-400">Your snippet appears here.</p>
)}
</AnimatePresence>
{usage && preview && formatUsage(usage.charged, usage.tokens) && (
<p className="mt-2 text-[10px] text-slate-400">{formatUsage(usage.charged, usage.tokens)}</p>
)}
</div>
<button
type="button"
onClick={runPreview}
disabled={!draft.prompt.trim() || gen.isPending}
className="mt-3 inline-flex h-7 items-center gap-1.5 self-start rounded-md bg-sky-600 px-3 text-[12px] font-medium text-white transition-colors hover:bg-sky-700 disabled:opacity-50"
>
<SparklesIcon className="h-3 w-3" />
{gen.isPending ? "Writing…" : preview ? "Try again" : "Preview"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,580 @@
// ConditionalNode — an atomic inline chip for a Go-template conditional
// {{if EXPR}}THEN{{else}}ELSE{{end}}. The whole construct (condition + bodies)
// is edited in a builder popover, so the editor never carries fragile, orphanable
// half-tokens. It serializes as the literal template text (the span's content),
// so the send-time Go renderer resolves it unchanged and htmlToPlain keeps it.
// A raw-expression field is the escape hatch, so any condition is possible.
import React from "react";
import { createPortal } from "react-dom";
import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
import { AnimatePresence, motion } from "framer-motion";
import { GitBranchIcon, XIcon, ChevronDownIcon, PlusIcon } from "lucide-react";
import useCustomFieldKeys from "@/lib/api/hooks/app/contacts/useCustomFieldKeys";
import { useAnchoredFloating } from "@/hooks/useAnchoredFloating";
import { STANDARD_VARS, buildToken, cleanFieldName, isStandardKey } from "@/lib/templateVars";
import { markJustInserted, consumeJustInserted, freshId } from "./justInserted";
interface ConditionalAttrs {
expr: string; // the condition after `{{if `, e.g. `.Company` or `eq .Industry "SaaS"`
thenText: string;
elseText: string;
uid: string; // transient, not serialized — flags a just-inserted chip to open once
}
const DEFAULT_ATTRS: ConditionalAttrs = { expr: ".Company", thenText: "", elseText: "", uid: "" };
declare module "@tiptap/core" {
interface Commands<ReturnType> {
conditional: {
insertConditional: (attrs?: Partial<ConditionalAttrs>) => ReturnType;
};
}
}
// buildConditionalToken assembles the literal template text from the attrs.
function buildConditionalToken(a: ConditionalAttrs): string {
const expr = a.expr.trim() || ".Company";
const then = a.thenText ?? "";
if (a.elseText && a.elseText.trim()) {
return `{{if ${expr}}}${then}{{else}}${a.elseText}{{end}}`;
}
return `{{if ${expr}}}${then}{{end}}`;
}
// parseConditionalToken reverses buildConditionalToken so a saved/pasted
// conditional round-trips back into the builder.
function parseConditionalToken(token: string): ConditionalAttrs | null {
const m = token.match(/^\{\{\s*if\s+([\s\S]*?)\s*\}\}([\s\S]*?)(?:\{\{\s*else\s*\}\}([\s\S]*?))?\{\{\s*end\s*\}\}$/);
if (!m) return null;
return { expr: m[1].trim(), thenText: m[2] ?? "", elseText: m[3] ?? "" };
}
export const ConditionalNode = TiptapNode.create({
name: "conditional",
inline: true,
group: "inline",
atom: true,
selectable: true,
addAttributes() {
return {
expr: {
default: DEFAULT_ATTRS.expr,
parseHTML: (el) => parseConditionalToken((el as HTMLElement).textContent || "")?.expr ?? DEFAULT_ATTRS.expr,
renderHTML: () => ({}),
},
thenText: {
default: "",
parseHTML: (el) => parseConditionalToken((el as HTMLElement).textContent || "")?.thenText ?? "",
renderHTML: () => ({}),
},
elseText: {
default: "",
parseHTML: (el) => parseConditionalToken((el as HTMLElement).textContent || "")?.elseText ?? "",
renderHTML: () => ({}),
},
// Transient: never serialized (regenerates as "" on load), so only a
// fresh toolbar insert carries a uid the chip opens its builder for.
uid: {
default: "",
parseHTML: () => "",
renderHTML: () => ({}),
},
};
},
parseHTML() {
return [
{
tag: "span[data-if]",
getAttrs: (el) => {
const parsed = parseConditionalToken(((el as HTMLElement).textContent || "").trim());
return parsed ? parsed : false;
},
},
];
},
renderHTML({ node, HTMLAttributes }) {
return ["span", mergeAttributes(HTMLAttributes, { "data-if": "" }), buildConditionalToken(node.attrs as ConditionalAttrs)];
},
renderText({ node }) {
return buildConditionalToken(node.attrs as ConditionalAttrs);
},
addCommands() {
return {
insertConditional:
(attrs?: Partial<ConditionalAttrs>) =>
({ chain }) => {
// Flag this insertion so the chip opens its builder exactly once.
const uid = freshId();
markJustInserted(uid);
return chain().insertContent({ type: this.name, attrs: { ...DEFAULT_ATTRS, ...attrs, uid } }).run();
},
};
},
addNodeView() {
return ReactNodeViewRenderer(ConditionalChip);
},
});
function clip(s: string, n: number): string {
const t = s.trim().replace(/\s+/g, " ");
return t.length > n ? t.slice(0, n - 1) + "…" : t;
}
// Friendly one-line label for the condition itself.
function condLabel(expr: string): string {
const e = expr.trim();
let m = e.match(/^\.([A-Za-z0-9_ -]+)$/);
if (m) return `If ${m[1]} is set`;
m = e.match(/^not\s+\.([A-Za-z0-9_ -]+)$/);
if (m) return `If ${m[1]} is empty`;
m = e.match(/^eq\s+\.([A-Za-z0-9_ -]+)\s+"([^"]*)"$/);
if (m) return `If ${m[1]} = ${m[2]}`;
m = e.match(/^ne\s+\.([A-Za-z0-9_ -]+)\s+"([^"]*)"$/);
if (m) return `If ${m[1]}${m[2]}`;
return "Condition";
}
// Chip label shows the condition AND a short preview of each branch, so the
// if/else structure reads clearly without opening the builder.
function summarize(a: ConditionalAttrs): string {
const then = a.thenText.trim();
const els = a.elseText.trim();
// A brand-new, unconfigured condition reads clearly as a call to action rather
// than a bare "If Company is set" with no visible effect.
if (!then && !els) return "Set up condition";
let s = condLabel(a.expr);
if (then) s += `${clip(then, 18)}`;
if (els) s += ` / else ${clip(els, 14)}`;
return s;
}
function ConditionalChip({ node, updateAttributes, deleteNode, selected, editor, getPos }: NodeViewProps) {
const attrs = node.attrs as ConditionalAttrs;
// Open the builder once, right after insertion — not on every Edit/Preview remount.
const [open, setOpen] = React.useState(() => consumeJustInserted(attrs.uid));
const { setReference, setFloating, floatingStyle } = useAnchoredFloating(open, {
placement: "bottom-start",
gap: 6,
maxHeight: true,
});
// Free raw edit of the whole {{if}}...{{end}} construct. If it re-parses into
// the builder shape, keep it a chip; otherwise replace the node with the raw
// literal so any hand-written Go-template conditional is possible.
const onRaw = React.useCallback(
(rawToken: string) => {
const t = rawToken.trim();
setOpen(false);
if (!t) return;
const parsed = parseConditionalToken(t);
if (parsed) {
updateAttributes(parsed);
return;
}
const pos = typeof getPos === "function" ? getPos() : null;
if (typeof pos === "number") {
editor.chain().focus().insertContentAt({ from: pos, to: pos + node.nodeSize }, t).run();
}
},
[editor, getPos, node, updateAttributes],
);
return (
<NodeViewWrapper as="span" className="tpl-if-wrap">
<motion.button
ref={(el) => setReference(el)}
type="button"
initial={{ scale: 0.92, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
whileTap={{ scale: 0.96 }}
transition={{ type: "spring", stiffness: 640, damping: 30 }}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setOpen((o) => !o)}
title={buildConditionalToken(attrs)}
className={`tpl-if ${selected || open ? "tpl-if-active" : ""}`}
>
<GitBranchIcon className="h-2.5 w-2.5 shrink-0 opacity-70" />
{summarize(attrs)}
</motion.button>
{typeof document !== "undefined" &&
createPortal(
<AnimatePresence>
{open && (
<ConditionalBuilder
setFloating={setFloating}
floatingStyle={floatingStyle}
attrs={attrs}
onChange={(next) => updateAttributes(next)}
onRaw={onRaw}
onRemove={() => {
deleteNode();
setOpen(false);
}}
onClose={() => setOpen(false)}
/>
)}
</AnimatePresence>,
document.body,
)}
</NodeViewWrapper>
);
}
type Op = "set" | "empty" | "eq" | "ne" | "raw";
function exprToParts(expr: string): { field: string; op: Op; value: string } {
const e = expr.trim();
let m = e.match(/^\.([A-Za-z0-9_ -]+)$/);
if (m) return { field: m[1], op: "set", value: "" };
m = e.match(/^not\s+\.([A-Za-z0-9_ -]+)$/);
if (m) return { field: m[1], op: "empty", value: "" };
m = e.match(/^eq\s+\.([A-Za-z0-9_ -]+)\s+"([^"]*)"$/);
if (m) return { field: m[1], op: "eq", value: m[2] };
m = e.match(/^ne\s+\.([A-Za-z0-9_ -]+)\s+"([^"]*)"$/);
if (m) return { field: m[1], op: "ne", value: m[2] };
return { field: "", op: "raw", value: "" };
}
function partsToExpr(field: string, op: Op, value: string, raw: string): string {
const f = cleanFieldName(field) || "Company";
const v = value.replace(/"/g, "'");
switch (op) {
case "set":
return `.${f}`;
case "empty":
return `not .${f}`;
case "eq":
return `eq .${f} "${v}"`;
case "ne":
return `ne .${f} "${v}"`;
default:
return raw.trim();
}
}
const OPS: { value: Op; label: string }[] = [
{ value: "set", label: "is set" },
{ value: "empty", label: "is empty" },
{ value: "eq", label: "equals" },
{ value: "ne", label: "does not equal" },
{ value: "raw", label: "advanced…" },
];
function ConditionalBuilder({
setFloating,
floatingStyle,
attrs,
onChange,
onRaw,
onRemove,
onClose,
}: {
setFloating: (el: HTMLElement | null) => void;
floatingStyle: React.CSSProperties;
attrs: ConditionalAttrs;
onChange: (next: Partial<ConditionalAttrs>) => void;
onRaw: (rawToken: string) => void;
onRemove: () => void;
onClose: () => void;
}) {
const { data: customKeys = [] } = useCustomFieldKeys();
const localRef = React.useRef<HTMLDivElement | null>(null);
const initial = exprToParts(attrs.expr);
const [field, setField] = React.useState(initial.field || "Company");
const [op, setOp] = React.useState<Op>(initial.op);
const [value, setValue] = React.useState(initial.value);
const [raw, setRaw] = React.useState(op === "raw" ? attrs.expr : "");
const [thenText, setThenText] = React.useState(attrs.thenText);
const [elseText, setElseText] = React.useState(attrs.elseText);
const [showElse, setShowElse] = React.useState(!!attrs.elseText);
// Full-construct raw mode: edit the entire {{if}}...{{end}} literal freely.
const [rawMode, setRawMode] = React.useState(false);
const [rawFull, setRawFull] = React.useState("");
const enterRaw = () => {
setRawFull(buildConditionalToken({ expr: partsToExpr(field, op, value, raw), thenText, elseText: showElse ? elseText : "" }));
setRawMode(true);
};
const setRefs = React.useCallback(
(el: HTMLDivElement | null) => {
localRef.current = el;
setFloating(el);
},
[setFloating],
);
// Push changes up as the builder is edited (skipped in raw mode, which
// commits through onRaw instead).
React.useEffect(() => {
if (rawMode) return;
onChange({ expr: partsToExpr(field, op, value, raw), thenText, elseText: showElse ? elseText : "" });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [field, op, value, raw, thenText, elseText, showElse, rawMode]);
React.useEffect(() => {
const onDown = (e: MouseEvent | TouchEvent) => {
if (!localRef.current?.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
};
document.addEventListener("mousedown", onDown, true);
document.addEventListener("touchstart", onDown, true);
document.addEventListener("keydown", onKey, true);
return () => {
document.removeEventListener("mousedown", onDown, true);
document.removeEventListener("touchstart", onDown, true);
document.removeEventListener("keydown", onKey, true);
};
}, [onClose]);
const fields = [
...STANDARD_VARS.map((v) => v.key),
...customKeys.filter((k) => !isStandardKey(k)).map((k) => cleanFieldName(k)),
];
const insertVarInto = (setter: React.Dispatch<React.SetStateAction<string>>) => (token: string) => {
setter((cur) => cur + token);
};
return (
<motion.div
ref={setRefs}
data-floating=""
style={floatingStyle}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="z-[60] w-[320px] overflow-hidden rounded-xl border border-slate-200 bg-white text-left shadow-[0_16px_40px_-14px_rgba(15,23,42,0.28)]"
>
<div className="flex items-center gap-2 border-b border-slate-100 px-3 py-2">
<span className="flex size-6 items-center justify-center rounded-md bg-slate-100 text-slate-600">
<GitBranchIcon className="h-3.5 w-3.5" />
</span>
<span className="flex-1 text-[12.5px] font-medium text-slate-800">Condition</span>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => (rawMode ? setRawMode(false) : enterRaw())}
className="rounded px-1.5 py-0.5 font-mono text-[10px] text-slate-400 transition-colors hover:text-sky-600"
title={rawMode ? "Back to the builder" : "Edit the raw template freely"}
>
{rawMode ? "builder" : "{ } raw"}
</button>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={onClose}
className="flex size-6 items-center justify-center rounded text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
title="Done"
>
<XIcon className="h-3.5 w-3.5" />
</button>
</div>
<div className="space-y-2.5 p-3">
{rawMode ? (
<div>
<div className="text-[10px] font-medium uppercase tracking-[0.14em] text-slate-400">
Raw template
</div>
<textarea
value={rawFull}
onChange={(e) => setRawFull(e.target.value)}
rows={5}
spellCheck={false}
className="mt-1 w-full resize-y rounded-md border border-slate-200 px-2 py-1.5 font-mono text-[11.5px] leading-relaxed text-slate-800 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
/>
<p className="mt-1 text-[10px] leading-snug text-slate-400">
The full {"{{if …}}…{{end}}"} construct. Any valid Go-template conditional works; press Apply.
</p>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onRaw(rawFull)}
className="mt-1.5 h-7 rounded-md bg-slate-900 px-2.5 text-[11.5px] font-medium text-white transition-colors hover:bg-slate-800"
>
Apply raw
</button>
</div>
) : (
<>
{/* Condition */}
<div className="space-y-1.5">
<div className="text-[10px] font-medium uppercase tracking-[0.14em] text-slate-400">Show when</div>
{op !== "raw" ? (
<div className="flex items-center gap-1.5">
<Select value={field} onChange={setField} options={fields.map((f) => ({ value: f, label: f }))} />
<Select
value={op}
onChange={(v) => {
const nv = v as Op;
// Carry the current condition into the raw box so "advanced" starts
// from what you already had rather than blank.
if (nv === "raw" && !raw.trim()) setRaw(partsToExpr(field, op, value, ""));
setOp(nv);
}}
options={OPS}
/>
</div>
) : (
<div className="flex items-center gap-1.5">
<span className="font-mono text-[11px] text-slate-400">{"{{if"}</span>
<input
value={raw}
onChange={(e) => setRaw(e.target.value)}
placeholder='eq .Industry "SaaS"'
className="h-7 min-w-0 flex-1 rounded-md border border-slate-200 px-2 font-mono text-[11.5px] text-slate-800 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
/>
<span className="font-mono text-[11px] text-slate-400">{"}}"}</span>
<button
type="button"
onClick={() => setOp("set")}
className="text-[11px] text-slate-400 hover:text-slate-600"
title="Back to simple"
>
simple
</button>
</div>
)}
{(op === "eq" || op === "ne") && (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="value to match"
className="h-7 w-full rounded-md border border-slate-200 px-2 text-[12px] text-slate-800 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
/>
)}
</div>
{/* Then */}
<div className="space-y-1">
<div className="text-[10px] font-medium uppercase tracking-[0.14em] text-slate-400">Then show</div>
<textarea
value={thenText}
onChange={(e) => setThenText(e.target.value)}
rows={2}
placeholder="Text shown when the condition is true"
className="w-full resize-y rounded-md border border-slate-200 px-2 py-1.5 text-[12.5px] leading-relaxed text-slate-800 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
/>
<VarRow fields={fields} onPick={insertVarInto(setThenText)} />
</div>
{/* Otherwise */}
{showElse ? (
<div className="space-y-1">
<div className="flex items-center justify-between">
<div className="text-[10px] font-medium uppercase tracking-[0.14em] text-slate-400">Otherwise</div>
<button
type="button"
onClick={() => {
setShowElse(false);
setElseText("");
}}
className="text-[10.5px] text-slate-400 hover:text-rose-600"
>
remove
</button>
</div>
<textarea
value={elseText}
onChange={(e) => setElseText(e.target.value)}
rows={2}
placeholder="Text shown otherwise"
className="w-full resize-y rounded-md border border-slate-200 px-2 py-1.5 text-[12.5px] leading-relaxed text-slate-800 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
/>
<VarRow fields={fields} onPick={insertVarInto(setElseText)} />
</div>
) : (
<button
type="button"
onClick={() => setShowElse(true)}
className="inline-flex items-center gap-1 text-[11.5px] text-slate-500 transition-colors hover:text-slate-800"
>
<PlusIcon className="h-3 w-3" /> Add an otherwise
</button>
)}
</>
)}
</div>
<div className="flex items-center justify-between border-t border-slate-100 px-3 py-2">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={onRemove}
className="inline-flex items-center gap-1.5 rounded px-1.5 py-1 text-[11.5px] text-slate-500 transition-colors hover:bg-rose-50 hover:text-rose-600"
>
<XIcon className="h-3 w-3" /> Remove
</button>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => (rawMode ? onRaw(rawFull) : onClose())}
className="rounded-md bg-slate-900 px-2.5 py-1 text-[11.5px] font-medium text-white transition-colors hover:bg-slate-800"
>
Done
</button>
</div>
</motion.div>
);
}
function VarRow({ fields, onPick }: { fields: string[]; onPick: (token: string) => void }) {
return (
<div className="flex flex-wrap gap-1">
{fields.slice(0, 6).map((f) => (
<button
key={f}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onPick(buildToken(f))}
title={`Insert ${buildToken(f)}`}
className="rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 font-mono text-[10px] text-slate-500 transition-colors hover:border-sky-300 hover:bg-sky-50 hover:text-sky-700"
>
{f}
</button>
))}
</div>
);
}
// Small native-free select styled to the theme.
function Select<T extends string>({
value,
onChange,
options,
}: {
value: T;
onChange: (v: T) => void;
options: { value: T; label: string }[];
}) {
return (
<div className="relative min-w-0 flex-1">
<select
value={value}
onChange={(e) => onChange(e.target.value as T)}
className="h-7 w-full appearance-none rounded-md border border-slate-200 bg-white pl-2 pr-6 text-[12px] text-slate-800 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<ChevronDownIcon className="pointer-events-none absolute right-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-slate-400" />
</div>
);
}
@@ -0,0 +1,273 @@
// EditorSuggest — a caret type-ahead for the body/instruction editors. Typing
// `{{` opens a filtered inserter at the caret with three groups: merge Fields
// (insert a variable chip), Logic (an if/else condition chip), and Functions
// (Go-template helpers like default/title/upper). Anchored to a floating-ui
// virtual caret so it follows the caret and stays glued through scroll. Driven
// per editor (not a global plugin) so several editors can share a page.
import React from "react";
import { createPortal } from "react-dom";
import type { Editor } from "@tiptap/react";
import { AnimatePresence, motion } from "framer-motion";
import { BracesIcon, GitBranchIcon, FunctionSquareIcon } from "lucide-react";
import useCustomFieldKeys from "@/lib/api/hooks/app/contacts/useCustomFieldKeys";
import { STANDARD_VARS, buildToken, cleanFieldName, isStandardKey } from "@/lib/templateVars";
import { useAnchoredFloating, caretReference } from "@/hooks/useAnchoredFloating";
type Group = "Fields" | "Logic" | "Functions";
// How picking an item mutates the doc, after the typed `{{…` trigger is removed.
type Insert =
| { type: "chip"; token: string } // a {{.Field}} (optionally with a helper) chip
| { type: "conditional" } // an {{if}}/{{else}} condition chip
| { type: "text"; text: string }; // a raw template snippet (advanced functions)
interface Item {
id: string;
group: Group;
label: string;
hint: string; // shown as muted code on the right
search: string; // lowercased haystack for filtering
insert: Insert;
}
const GROUP_ICON: Record<Group, typeof BracesIcon> = {
Fields: BracesIcon,
Logic: GitBranchIcon,
Functions: FunctionSquareIcon,
};
// The fixed Logic + Functions helpers. Function snippets carry a representative
// field the user can edit; `default` stays a chip because the variable chip
// already models a fallback.
const HELPERS: Item[] = [
{
id: "if",
group: "Logic",
label: "If / else condition",
hint: "{{if}}",
search: "if else condition when show only",
insert: { type: "conditional" },
},
{
id: "default",
group: "Functions",
label: "Fallback if empty",
hint: '| default',
search: "default fallback empty missing",
insert: { type: "chip", token: '{{.FirstName | default "there"}}' },
},
{
id: "title",
group: "Functions",
label: "Title case",
hint: "| title",
search: "title case capitalize proper",
insert: { type: "text", text: "{{.FirstName | title}}" },
},
{
id: "upper",
group: "Functions",
label: "Uppercase",
hint: "| upper",
search: "upper uppercase caps",
insert: { type: "text", text: "{{.Company | upper}}" },
},
{
id: "lower",
group: "Functions",
label: "Lowercase",
hint: "| lower",
search: "lower lowercase",
insert: { type: "text", text: "{{.Email | lower}}" },
},
{
id: "trim",
group: "Functions",
label: "Trim spaces",
hint: "| trim",
search: "trim whitespace spaces clean",
insert: { type: "text", text: "{{.Company | trim}}" },
},
];
export default function EditorSuggest({ editor }: { editor: Editor }) {
const { data: customKeys = [] } = useCustomFieldKeys();
const [trigger, setTrigger] = React.useState<{ from: number; query: string } | null>(null);
const [active, setActive] = React.useState(0);
const candidates = React.useMemo<Item[]>(() => {
const fields: Item[] = [
...STANDARD_VARS.map((v) => ({
id: `f:${v.key}`,
group: "Fields" as const,
label: v.label,
hint: v.token,
search: `${v.key} ${v.label}`.toLowerCase(),
insert: { type: "chip" as const, token: v.token },
})),
...customKeys
.filter((k) => !isStandardKey(k))
.map((k) => ({
id: `f:${k}`,
group: "Fields" as const,
label: k,
hint: buildToken(k),
search: `${cleanFieldName(k)} ${k}`.toLowerCase(),
insert: { type: "chip" as const, token: buildToken(k) },
})),
];
return [...fields, ...HELPERS];
}, [customKeys]);
const items = React.useMemo<Item[]>(() => {
if (!trigger) return [];
const q = trigger.query.trim().toLowerCase();
const filtered = q ? candidates.filter((c) => c.search.includes(q)) : candidates;
return filtered.slice(0, 10);
}, [trigger, candidates]);
const open = !!trigger && items.length > 0;
const { setReference, setFloating, floatingStyle } = useAnchoredFloating(open, {
placement: "bottom-start",
gap: 6,
maxHeight: true,
});
// Recompute the trigger from the caret on every edit/selection change.
React.useEffect(() => {
const recompute = () => {
const sel = editor.state.selection;
if (!sel.empty) {
setTrigger(null);
return;
}
const $from = sel.$from;
const before = $from.parent.textBetween(0, $from.parentOffset, "", "");
const m = before.match(/\{\{\s*\.?([A-Za-z0-9_ ]*)$/);
if (!m) {
setTrigger(null);
return;
}
setActive(0);
setTrigger({ from: $from.start() + (m.index ?? 0), query: m[1] ?? "" });
};
editor.on("update", recompute);
editor.on("selectionUpdate", recompute);
return () => {
editor.off("update", recompute);
editor.off("selectionUpdate", recompute);
};
}, [editor]);
// Point floating-ui at a virtual caret element; refresh it whenever the caret
// moves (a new object identity re-runs the positioning effect).
React.useEffect(() => {
if (!trigger) {
setReference(null);
return;
}
setReference(
caretReference(() => {
try {
const c = editor.view.coordsAtPos(editor.state.selection.from);
return new DOMRect(c.left, c.top, 0, c.bottom - c.top);
} catch {
return null;
}
}, editor.view.dom),
);
}, [trigger, editor, setReference]);
const select = React.useCallback(
(item: Item) => {
if (!trigger) return;
const chain = editor
.chain()
.focus()
.deleteRange({ from: trigger.from, to: editor.state.selection.from });
if (item.insert.type === "chip") {
chain.insertVariable(item.insert.token).run();
} else if (item.insert.type === "conditional") {
chain.insertConditional().run();
} else {
chain.insertContent(item.insert.text).run();
}
setTrigger(null);
},
[editor, trigger],
);
// Keyboard nav while open, in capture so the editor doesn't also act on it.
React.useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((a) => (a + 1) % items.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => (a - 1 + items.length) % items.length);
} else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
e.stopPropagation();
select(items[Math.min(active, items.length - 1)]);
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setTrigger(null);
}
};
document.addEventListener("keydown", onKey, true);
return () => document.removeEventListener("keydown", onKey, true);
}, [open, items, active, select]);
if (typeof document === "undefined") return null;
return createPortal(
<AnimatePresence>
{open && (
<motion.div
ref={setFloating}
data-floating=""
style={floatingStyle}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="z-[60] w-64 overflow-y-auto rounded-lg border border-slate-200 bg-white py-1 shadow-[0_10px_30px_-10px_rgba(15,23,42,0.25)]"
>
{items.map((item, i) => {
const Icon = GROUP_ICON[item.group];
const showHeader = i === 0 || items[i - 1].group !== item.group;
return (
<React.Fragment key={item.id}>
{showHeader && (
<div className="px-2.5 pb-0.5 pt-1.5 text-[10px] font-medium uppercase tracking-[0.14em] text-slate-400">
{item.group}
</div>
)}
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
select(item);
}}
onMouseEnter={() => setActive(i)}
className={`flex w-full items-center gap-2 px-2.5 py-1.5 text-left transition-colors ${
i === active ? "bg-sky-50" : "hover:bg-slate-50"
}`}
>
<Icon className={`h-3 w-3 shrink-0 ${i === active ? "text-sky-500" : "text-slate-400"}`} />
<span className="min-w-0 flex-1 truncate text-[12.5px] text-slate-700">{item.label}</span>
<code className="shrink-0 font-mono text-[10px] text-slate-400">{item.hint}</code>
</button>
</React.Fragment>
);
})}
</motion.div>
)}
</AnimatePresence>,
document.body,
);
}
@@ -0,0 +1,353 @@
// VariableNode — an atomic inline chip for a merge tag like {{.Company}}. The
// node keeps the FULL literal token as the span's text content, so:
// - editor.getHTML() -> `<span data-var>{{.Company}}</span>`, which the Go
// send-time renderer (internal/tasks/template.go) resolves unchanged, and
// - htmlToPlain()'s naive tag-strip still yields the literal token for
// body_plain.
// parseHTML matches span[data-var]; upgradeVariableTokens (@/lib/templateVars)
// turns previously-saved plain {{.X}} text into chips on load. Click-to-edit is a
// floating-ui-anchored popover portaled to <body> (the node view lives inside the
// ProseMirror editable, where an inline <input> fights contentEditable).
import React from "react";
import { createPortal } from "react-dom";
import { Node as TiptapNode, mergeAttributes, nodeInputRule } from "@tiptap/core";
import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
import { AnimatePresence, motion } from "framer-motion";
import { BracesIcon, XIcon, CheckIcon } from "lucide-react";
import useCustomFieldKeys from "@/lib/api/hooks/app/contacts/useCustomFieldKeys";
import { useAnchoredFloating } from "@/hooks/useAnchoredFloating";
import {
STANDARD_VARS,
buildToken,
parseToken,
tokenLabel,
isStandardKey,
cleanFieldName,
} from "@/lib/templateVars";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
variable: {
// Insert a merge-tag chip carrying the given literal token.
insertVariable: (token: string) => ReturnType;
};
}
}
export const VariableNode = TiptapNode.create({
name: "variable",
inline: true,
group: "inline",
atom: true,
selectable: true,
addAttributes() {
return {
token: {
default: "",
parseHTML: (el) => (el as HTMLElement).textContent || "",
renderHTML: () => ({}),
},
};
},
parseHTML() {
return [
{
tag: "span[data-var]",
getAttrs: (el) => {
const token = ((el as HTMLElement).textContent || "").trim();
return token ? { token } : false;
},
},
];
},
renderHTML({ node, HTMLAttributes }) {
return ["span", mergeAttributes(HTMLAttributes, { "data-var": "" }), node.attrs.token];
},
renderText({ node }) {
return node.attrs.token;
},
addCommands() {
return {
insertVariable:
(token: string) =>
({ chain }) =>
chain().insertContent({ type: this.name, attrs: { token } }).run(),
};
},
addInputRules() {
return [
nodeInputRule({
find: /\{\{\s*\.([A-Za-z0-9_ -]+?)\s*\}\}$/,
type: this.type,
getAttributes: (match) => ({ token: buildToken(match[1]) }),
}),
];
},
addNodeView() {
return ReactNodeViewRenderer(VariableChip);
},
});
// Compact chip with a floating-ui-anchored click-to-edit popover (swap field, set
// a fallback, remove). floating-ui keeps it glued to the chip through scroll.
function VariableChip({ node, updateAttributes, deleteNode, selected, editor, getPos }: NodeViewProps) {
const token: string = node.attrs.token || "";
const parsed = parseToken(token);
const [open, setOpen] = React.useState(false);
const { setReference, setFloating, floatingStyle } = useAnchoredFloating(open, {
placement: "bottom-start",
gap: 6,
maxHeight: true,
});
// Free raw edit: if the raw text is a plain field token, keep it a chip;
// otherwise replace the atom node with the raw literal so any expression is
// possible ("edit what is inside the {{}} freely").
const onRaw = React.useCallback(
(raw: string) => {
const t = raw.trim();
setOpen(false);
if (!t) return;
if (parseToken(t)) {
updateAttributes({ token: t });
return;
}
const pos = typeof getPos === "function" ? getPos() : null;
if (typeof pos === "number") {
editor.chain().focus().insertContentAt({ from: pos, to: pos + node.nodeSize }, t).run();
}
},
[editor, getPos, node, updateAttributes],
);
return (
<NodeViewWrapper as="span" className="tpl-var-wrap">
<motion.button
ref={(el) => setReference(el)}
type="button"
initial={{ scale: 0.92, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
whileTap={{ scale: 0.96 }}
transition={{ type: "spring", stiffness: 640, damping: 30 }}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setOpen((o) => !o)}
title={token}
className={`tpl-var ${selected || open ? "tpl-var-active" : ""}`}
>
<BracesIcon className="h-2.5 w-2.5 shrink-0 opacity-70" />
{tokenLabel(token)}
</motion.button>
{typeof document !== "undefined" &&
createPortal(
<AnimatePresence>
{open && (
<VariableChipEditor
setFloating={setFloating}
floatingStyle={floatingStyle}
token={token}
currentKey={parsed?.key ?? cleanFieldName(token)}
fallback={parsed?.fallback ?? ""}
onChange={(next) => {
updateAttributes({ token: next });
setOpen(false);
}}
onRaw={onRaw}
onRemove={() => {
deleteNode();
setOpen(false);
}}
onClose={() => setOpen(false)}
/>
)}
</AnimatePresence>,
document.body,
)}
</NodeViewWrapper>
);
}
function VariableChipEditor({
setFloating,
floatingStyle,
token,
currentKey,
fallback,
onChange,
onRaw,
onRemove,
onClose,
}: {
setFloating: (el: HTMLElement | null) => void;
floatingStyle: React.CSSProperties;
token: string;
currentKey: string;
fallback: string;
onChange: (next: string) => void;
onRaw: (raw: string) => void;
onRemove: () => void;
onClose: () => void;
}) {
const { data: customKeys = [] } = useCustomFieldKeys();
const [fb, setFb] = React.useState(fallback);
const localRef = React.useRef<HTMLDivElement | null>(null);
const parseable = parseToken(token) !== null;
// Raw mode exposes the literal {{...}} for free editing; auto-on for tokens
// the structured UI can't model (helpers, complex expressions).
const [showRaw, setShowRaw] = React.useState(!parseable);
const [raw, setRaw] = React.useState(token);
const setRefs = React.useCallback(
(el: HTMLDivElement | null) => {
localRef.current = el;
setFloating(el);
},
[setFloating],
);
React.useEffect(() => {
const onDown = (e: MouseEvent | TouchEvent) => {
if (!localRef.current?.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
};
document.addEventListener("mousedown", onDown, true);
document.addEventListener("touchstart", onDown, true);
document.addEventListener("keydown", onKey, true);
return () => {
document.removeEventListener("mousedown", onDown, true);
document.removeEventListener("touchstart", onDown, true);
document.removeEventListener("keydown", onKey, true);
};
}, [onClose]);
const options = [
...STANDARD_VARS.map((v) => ({ key: v.key, label: v.label })),
...customKeys.filter((k) => !isStandardKey(k)).map((k) => ({ key: k, label: k })),
];
return (
<motion.div
ref={setRefs}
data-floating=""
style={floatingStyle}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="z-[60] w-64 overflow-hidden rounded-lg border border-slate-200 bg-white p-2 text-left shadow-[0_10px_30px_-10px_rgba(15,23,42,0.25)]"
>
<div className="flex items-center justify-between px-0.5 pb-1">
<span className="text-[10px] font-medium uppercase tracking-[0.14em] text-slate-400">
{showRaw ? "Raw token" : "Field"}
</span>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setShowRaw((v) => !v)}
className="rounded px-1 font-mono text-[10px] text-slate-400 transition-colors hover:text-sky-600"
title={showRaw ? "Use the field picker" : "Edit the raw token freely"}
>
{showRaw ? "field picker" : "{ } edit raw"}
</button>
</div>
{showRaw ? (
<div>
<textarea
value={raw}
onChange={(e) => setRaw(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onRaw(raw);
}
}}
rows={2}
spellCheck={false}
className="w-full resize-y rounded-md border border-slate-200 px-2 py-1.5 font-mono text-[11.5px] text-slate-800 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
/>
<div className="mt-1 flex items-center justify-between">
<span className="text-[10px] text-slate-400">Any {"{{…}}"} expression</span>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onRaw(raw)}
className="h-7 rounded-md bg-slate-900 px-2.5 text-[11.5px] font-medium text-white transition-colors hover:bg-slate-800"
>
Apply
</button>
</div>
</div>
) : (
<>
<div className="max-h-44 space-y-0.5 overflow-y-auto">
{options.map((o) => {
const active =
cleanFieldName(o.key).toLowerCase() === cleanFieldName(currentKey).toLowerCase();
return (
<button
key={o.key}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onChange(buildToken(o.key, fb))}
className={`flex w-full items-center justify-between gap-2 rounded px-2 py-1 text-left text-[12px] transition-colors ${
active ? "bg-sky-50 text-sky-700" : "text-slate-700 hover:bg-slate-100"
}`}
>
<span className="truncate">{o.label}</span>
{active && <CheckIcon className="h-3 w-3 shrink-0" />}
</button>
);
})}
</div>
<div className="mt-1.5 border-t border-slate-100 pt-1.5">
<div className="px-0.5 pb-1 text-[10px] font-medium uppercase tracking-[0.14em] text-slate-400">
Fallback if blank
</div>
<div className="flex items-center gap-1.5">
<input
value={fb}
onChange={(e) => setFb(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onChange(buildToken(currentKey, fb));
}
}}
placeholder='e.g. "there"'
className="h-7 min-w-0 flex-1 rounded-md border border-slate-200 bg-white px-2 text-[12px] text-slate-900 placeholder:text-slate-400 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
/>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onChange(buildToken(currentKey, fb))}
className="h-7 shrink-0 rounded-md bg-slate-900 px-2 text-[11.5px] font-medium text-white transition-colors hover:bg-slate-800"
>
Apply
</button>
</div>
</div>
</>
)}
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={onRemove}
className="mt-1.5 flex w-full items-center gap-1.5 rounded px-2 py-1 text-[12px] text-slate-500 transition-colors hover:bg-rose-50 hover:text-rose-600"
>
<XIcon className="h-3 w-3" /> Remove
</button>
</motion.div>
);
}
@@ -0,0 +1,31 @@
// Tracks the nodes created THIS session by a toolbar/insert command, so a chip's
// config popover opens exactly once — right after it's inserted — and never again
// on the remounts caused by toggling the editor's Edit/Preview tabs (which fully
// unmount and rebuild the TipTap view, re-running each node view's initial state).
//
// The insert command marks the fresh node's id; the node view consumes it on mount.
// Loaded/pasted content never marks anything, so existing blocks stay closed.
const pending = new Set<string>();
// markJustInserted flags a node id as freshly inserted (call it from the command).
export function markJustInserted(id: string): void {
if (id) pending.add(id);
}
// consumeJustInserted reports whether this id was just inserted, clearing the flag
// so a later remount won't reopen the popover.
export function consumeJustInserted(id: string): boolean {
if (id && pending.has(id)) {
pending.delete(id);
return true;
}
return false;
}
// freshId mints a transient id for nodes (like the conditional) that don't already
// carry a stable serialized id.
export function freshId(): string {
if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();
return `id-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
}