mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-21 08:01:30 +00:00
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:
@@ -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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user