- Introduction
- The Goal
- The Tempting Shortcut
- A Node for Variables
- From Plain Text to Document and Back
- Typing and Pasting Tokens
- A Schema for Plain Text
- Connecting the Editor to Signal Forms
- Using the Editor
- Testing Without a Browser
- Conclusion
Introduction
Many applications need a small template language: an email that starts with Hi {{firstName}}, a notification that mentions {{orderNumber}}, a prompt with a few {{variables}} in it. Storing these templates as plain text is simple. Asking people to type the curly braces correctly is not. A friendlier editor shows each variable as a chip, lets you insert one with a click and still saves the same plain text in the end.
This post builds such an editor with Tiptap, a headless editor framework on top of ProseMirror, and ngx-tiptap, which connects Tiptap to Angular. It uses Tiptap 3.32, ngx-tiptap 15 and Angular 22 with signal forms.
Along the way, I will point out a few traps that are easy to fall into. Some of them only show up once real people use the editor: a cursor that jumps, undo that stops working or variables that silently disappear from the saved text.
The Goal
The editor should do the following:
- Show every
{{key}}token as a chip with a readable label, like “First name”. - Keep the form value as plain text, including line breaks and repeated spaces.
- Turn tokens into chips no matter how they get into the editor: loaded from the form, typed, pasted or inserted with a button.
- Keep unknown tokens. A typo like
{{fristName}}should be visible and flagged, not deleted. - Work as a regular form control, so validation, disabled state and touched state come from the form.
- Support a single-line mode for subject lines.
The form value for an email template looks like this, and that is all the backend ever sees:
{
"subject": "Welcome aboard, {{firstName}}!",
"body": "Hi {{firstName}},\n\nthanks for signing up on {{signupDate}}.\n\nCheers,\n{{senderName}}"
}
The Tempting Shortcut
The first idea is usually to let the editor work with HTML and replace tokens whenever the content changes:
onUpdate: ({ editor }) => {
const html = editor.getHTML();
const replaced = html.replaceAll('{{firstName}}', '<span data-variable="firstName"></span>');
if (replaced !== html) {
editor.commands.setContent(replaced);
}
},
It looks fine in a quick test, but it causes four problems:
- The cursor jumps.
setContentreplaces the whole document, so the selection is gone as soon as a token is complete. - Undo gets stuck. Pressing undo brings back the raw
{{firstName}}text. That is an update, so the handler turns it into a chip again. You cannot undo past that point. - Whitespace collapses. HTML parsing folds repeated spaces into one, unless every
setContentcall passespreserveWhitespace: 'full'. - HTML strings are fragile. As soon as a label or key is put into the markup with string interpolation, a quote or an angle bracket breaks it.
All four have the same cause: the document is rebuilt from a string after the fact instead of being changed in the right place at the right time. Tiptap has dedicated tools for that, and the rest of this post uses them.
A Node for Variables
The Token Lives on the Node
A variable is an inline node. It is atom, so the cursor treats it as a single character and its content cannot be edited:
export const VariableNode = Node.create<VariableNodeOptions>({
name: 'variable',
group: 'inline',
inline: true,
atom: true,
selectable: true,
addOptions: (): VariableNodeOptions => ({ injector: null }),
addAttributes: (): Attributes => ({
key: {
default: null,
parseHTML: (element: HTMLElement): string | null => element.getAttribute('data-variable'),
renderHTML: (attributes: Record<string, string>): Record<string, string> => ({
'data-variable': attributes['key'],
}),
},
}),
parseHTML: () => [{ tag: 'span[data-variable]' }],
renderHTML: (props): DOMOutputSpec => [
'span',
mergeAttributes(props.HTMLAttributes),
toToken(getKey(props.node)),
],
renderText: (props): string => toToken(getKey(props.node)),
});
Note how the attributes are declared. Each one is an object with a default and optional parseHTML and renderHTML functions. Returning a plain value like key: '' instead compiles if the types are loose enough, but Tiptap then spreads the string into the attribute config and the default is lost.
The most important line is renderText. Tiptap calls it when it turns the document into text with editor.getText(). The node only stores the key and builds the token from it, so the text value never depends on anything outside the node.
That sounds obvious, but the alternative is common: storing an ID on the node and looking up the token in a list of known variables. Then renderText has to decide what to return when the lookup fails, for example because the list has not loaded yet or because a variable was renamed. Any fallback like an empty string quietly removes the variable from the saved text. With the token on the node, unknown variables survive. The UI can still mark them as unknown.
toToken and getKey are small helpers:
const toToken = (key: string): string => `{{${key}}}`;
const extractKey = (token: string): string => token.slice(2, -2);
const getKey = (node: ProseMirrorNode): string => node.attrs['key'];
An Angular Component as Chip
Without a node view, Tiptap renders the node with renderHTML, which shows the raw token. For a chip with a label, a selection state and a warning for unknown variables, an Angular component is more convenient. ngx-tiptap provides AngularNodeViewRenderer for that:
addNodeView(): NodeViewRenderer | null {
const injector = this.options.injector;
return injector ? AngularNodeViewRenderer(VariableChip, { injector }) : null;
},
The renderer needs an Injector, so the node gets it as an option. Without one, addNodeView returns null and Tiptap falls back to renderHTML. That keeps the node usable in a headless editor, which comes in handy for tests.
The chip extends AngularNodeViewComponent, which provides the node, the selection state and more as signal inputs:
@Component({
selector: 'app-variable-chip',
template: `
<span
class="chip"
[class.chip--selected]="selected()"
[class.chip--unknown]="!variable()"
[attr.title]="title()"
>
{{ variable()?.label ?? token() }}
</span>
`,
styleUrl: './variable-chip.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VariableChip extends AngularNodeViewComponent {
protected readonly key: Signal<string> = computed(() => this.node().attrs['key']);
protected readonly token: Signal<string> = computed(() => toToken(this.key()));
protected readonly variable: Signal<Variable | undefined> = computed(() =>
findVariable(this.variables, this.key()),
);
protected readonly title: Signal<string> = computed(() =>
this.variable() ? this.token() : `Unknown variable ${this.token()}`,
);
private readonly variables = inject(TEMPLATE_VARIABLES);
}
The list of variables comes from dependency injection, not from the Tiptap extension:
export interface Variable {
key: string;
label: string;
sample: string;
}
export const TEMPLATE_VARIABLES = new InjectionToken<readonly Variable[]>('TEMPLATE_VARIABLES', {
factory: (): readonly Variable[] => [],
});
Because the chip is created with the editor component’s injector, it sees whatever the application provides for TEMPLATE_VARIABLES. The extension does not need to know the variables at all. It only knows keys. Labels, sample values and validation stay on the Angular side.
From Plain Text to Document and Back
The form holds text, and the editor holds a ProseMirror document. Going from text to document is a small parser: every line becomes a paragraph, and every token inside a line becomes a variable node.
const TOKEN_PATTERN = /\{\{[a-zA-Z]\w*\}\}/g;
const TOKEN_SPLIT_PATTERN = new RegExp(`(${TOKEN_PATTERN.source})`);
const toInlineNode = (part: string, index: number): JSONContent => {
const isToken = index % 2 === 1;
return isToken
? { type: 'variable', attrs: { key: extractKey(part) } }
: { type: 'text', text: part };
};
const isNotEmptyText = (node: JSONContent): boolean => node.type !== 'text' || node.text !== '';
const toParagraph = (line: string): JSONContent => {
const content = line.split(TOKEN_SPLIT_PATTERN).map(toInlineNode).filter(isNotEmptyText);
return content.length > 0 ? { type: 'paragraph', content } : { type: 'paragraph' };
};
export const parseTemplate = (text: string, options: ParseOptions): JSONContent => {
return { type: 'doc', content: splitLines(text, options).map(toParagraph) };
};
split with a capturing group keeps the separators, so the result alternates between text and tokens: 'Hi {{firstName}}!' becomes ['Hi ', '{{firstName}}', '!']. Every odd index is a token. ProseMirror does not allow empty text nodes, which is why empty strings are filtered out.
The parser returns JSON, not HTML. That solves the whitespace problem from above without any parse options: JSON text nodes are taken as they are, so repeated spaces and spaces at the end of a line survive.
The way back is a single call:
export const serializeTemplate = (editor: Editor): string => {
return editor.getText({ blockSeparator: '\n' });
};
getText joins paragraphs with the block separator and uses renderText for the variable nodes. With the default separator of two line breaks, every save would double the empty lines.
Pasting plain text goes through the same parser. ProseMirror lets you replace its default text parser with clipboardTextParser:
export const createClipboardSlice = (
text: string,
schema: Schema,
options: ParseOptions,
): Slice => {
const document = schema.nodeFromJSON(parseTemplate(text, options));
return Slice.maxOpen(Fragment.from(document.content));
};
Slice.maxOpen marks the outer paragraphs as open. Without it, pasting a single line into the middle of a paragraph would split it and insert a new paragraph instead of merging the text into the current one.
Typing and Pasting Tokens
Loading and plain-text pasting are covered. What remains is typing a token and pasting HTML, for example from another editor. For these cases, Tiptap has input rules and paste rules, which change exactly the matched range:
const TOKEN_AT_CURSOR_PATTERN = /\{\{[a-zA-Z]\w*\}\}$/;
const getAttributesFromMatch = (match: RegExpMatchArray): Record<string, string> => ({
key: extractKey(match[0]),
});
addInputRules(): InputRule[] {
return [
nodeInputRule({
find: TOKEN_AT_CURSOR_PATTERN,
type: this.type,
getAttributes: getAttributesFromMatch,
}),
];
},
addPasteRules(): PasteRule[] {
return [
nodePasteRule({
find: TOKEN_PATTERN,
type: this.type,
getAttributes: getAttributesFromMatch,
}),
];
},
The input rule runs whenever the user types a character. Its pattern ends with $, so it only matches a token that ends right at the cursor. Typing the second } of {{city}} replaces the token with a chip, and the cursor stays where it is. Since only the matched range changes, the conversion is a regular step in the undo history, and undo keeps working the way people expect. Pressing Backspace right after a conversion even turns the chip back into the typed text, because Tiptap tries undoInputRule first on Backspace.
There is one trap here, and it is the reason the patterns above have no capture group. A more natural pattern would be /\{\{(\w+)\}\}$/, which captures the key. But nodeInputRule treats a capture group as “the part to replace”: it puts the node only in place of the first group and keeps the rest of the match as text. The result is {{, then the chip, then }}. Without a group, the whole match is replaced, and getAttributesFromMatch takes the key from match[0] instead.
Inserting a variable with a button needs a command. Inserting JSON instead of an HTML string means the content is never parsed as markup, so labels and keys cannot break anything:
declare module '@tiptap/core' {
interface Commands<ReturnType> {
variable: {
insertVariable: (key: string) => ReturnType;
};
}
}
addCommands(): Partial<RawCommands> {
return {
insertVariable:
(key: string) =>
(props: CommandProps): boolean => {
return props.commands.insertContent({ type: this.name, attrs: { key } });
},
};
},
A Schema for Plain Text
Text is the storage format, so the editor must not offer anything that text cannot hold. StarterKit bundles a lot, and almost all of it has to go:
// The schema allows exactly one paragraph, so Enter and multi-paragraph pastes have nowhere to go.
const SingleLineDocument = Node.create({
name: 'doc',
topNode: true,
content: 'paragraph',
});
export const createTemplateExtensions = (options: TemplateExtensionsOptions): Extensions => {
const extensions: Extensions = [
StarterKit.configure({
document: options.multiline ? undefined : false,
blockquote: false,
bold: false,
bulletList: false,
code: false,
codeBlock: false,
// A hard break would serialize to "\n" but parse back as a new paragraph.
hardBreak: false,
heading: false,
horizontalRule: false,
italic: false,
link: false,
listItem: false,
listKeymap: false,
orderedList: false,
strike: false,
trailingNode: false,
underline: false,
}),
VariableNode.configure({ injector: options.injector }),
Placeholder.configure({ placeholder: options.placeholder }),
];
return options.multiline ? extensions : [...extensions, SingleLineDocument];
};
What stays is the document, paragraphs, text, undo and redo, and the cursor helpers. Two of the disabled extensions are worth a closer look:
hardBreakwould insert a line break inside a paragraph on Shift+Enter. In text, that is a\n, which the parser turns into a new paragraph on the next load. The document would change shape on every round trip, so it is easier to only have paragraphs.StarterKitalso includeslink,underline,listKeymapandtrailingNode. A plain-text editor has to disable them as well, or links suddenly turn up in your text editor.
For single-line fields like a subject, the schema does the work. A document whose content is exactly one paragraph cannot be split, so Enter does nothing and a multi-paragraph paste has nowhere to go. No keyboard handler is needed. The parser joins the lines of pasted text with a space in single-line mode, so pasting still works.
Placeholder comes from the @tiptap/extensions package. Its placeholder option accepts a function, which lets the placeholder text come from a signal input.
Connecting the Editor to Signal Forms
With reactive forms, an editor like this would implement ControlValueAccessor, with writeValue, registerOnChange and friends. Signal forms make that much shorter. A component becomes a form control by implementing FormValueControl, which is mostly a value model and a few optional inputs:
@Component({
selector: 'app-template-editor',
imports: [TiptapEditorDirective],
template: `
@if (editor(); as editor) {
<tiptap-editor [editor]="editor" />
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TemplateEditor implements FormValueControl<string>, OnInit {
public readonly value = model('');
public readonly disabled = input(false);
public readonly invalid = input(false);
public readonly touched = input(false);
public readonly touch = output<void>();
public readonly multiline = input(true);
public readonly placeholder = input('');
protected readonly editor = signal<Editor | undefined>(undefined);
private readonly injector = inject(Injector);
public constructor() {
inject(DestroyRef).onDestroy(() => this.editor()?.destroy());
effect(() => this.syncValueToEditor(this.value()));
effect(() => this.editor()?.setEditable(!this.disabled(), false));
}
public ngOnInit(): void {
this.editor.set(this.createEditor());
}
private createEditor(): Editor {
const parseOptions: ParseOptions = { multiline: this.multiline() };
return new Editor({
extensions: createTemplateExtensions({
multiline: this.multiline(),
injector: this.injector,
placeholder: () => this.placeholder(),
}),
content: parseTemplate(this.value(), parseOptions),
editable: !this.disabled(),
editorProps: {
clipboardTextParser: (text, context) =>
createClipboardSlice(text, context.doc.type.schema, parseOptions),
},
onUpdate: (props) => this.value.set(serializeTemplate(props.editor)),
onBlur: () => this.touch.emit(),
});
}
private syncValueToEditor(value: string): void {
const editor = this.editor();
if (!editor || value === serializeTemplate(editor)) {
return;
}
editor.commands.setContent(parseTemplate(value, { multiline: this.multiline() }), {
emitUpdate: false,
});
}
}
A few details here are easy to get wrong:
- The editor is created in
ngOnInit, not in the constructor. Whether the document is single- or multi-line is part of the schema, and the schema is fixed once the editor exists. Inputs are only available fromngOnIniton. Keeping the editor in a signal lets the template and the effects react once it exists. - The value flows in both directions without a loop. Typing calls
onUpdate, which setsvalue. That triggers the effect, which compares the new value with the editor’s own text. They are equal, so nothing happens. Only a change from outside, like a reset of the form, replaces the content. This comparison is what keeps the cursor and the undo history intact while typing. emitUpdate: falsewhen the value comes from outside. The form already has that value, so there is no reason to send it back.- The editor is destroyed with the component. A Tiptap editor holds a ProseMirror view and, with Angular node views, one component per chip. Nothing cleans them up automatically. Registering
destroyonDestroyRefright where the editor is created makes it hard to forget.
disabled, invalid and touched are filled in by the form automatically. touch is an output that tells the form when the field was left. The full component also sets role="textbox", aria-multiline, aria-invalid and aria-describedby on the editable element, so screen readers announce it like a regular text field with its errors. An effect passes those attributes to editor.setOptions whenever they change.
Finally, a small method for the buttons that insert variables:
public insertVariable(key: string): void {
const editor = this.editor();
if (!editor?.isEditable) {
return;
}
editor.chain().focus().insertVariable(key).run();
}
Using the Editor
With signal forms, the form is built from a signal and a schema function. Validation for unknown variables fits in a few lines:
export class App {
protected readonly variables = inject(TEMPLATE_VARIABLES);
protected readonly locked = signal(false);
protected readonly template = signal<EmailTemplate>(INITIAL_TEMPLATE);
protected readonly templateForm = form(this.template, (path) => {
required(path.subject, { message: 'Please enter a subject.' });
maxLength(path.subject, SUBJECT_MAX_LENGTH, {
message: `Keep the subject under ${SUBJECT_MAX_LENGTH} characters.`,
});
required(path.body, { message: 'Please write a message.' });
validate(path.body, (context): ValidationError | null => {
const unknownKeys = findUnknownKeys(context.value(), this.variables);
return unknownKeys.length > 0
? { kind: 'unknownVariable', message: `Unknown variables: ${unknownKeys.join(', ')}` }
: null;
});
disabled(path, () => this.locked());
});
}
Because the value is plain text, validators do not need to know anything about the editor. findUnknownKeys runs the token pattern over the text and checks each key against the list of variables.
In the template, [formField] binds the editor like a native input:
<button
type="button"
(mousedown)="$event.preventDefault()"
(click)="insertVariable(variable)"
>
{{ variable.label }}
</button>
<app-template-editor
#subjectEditor
placeholder="Write a subject line…"
[multiline]="false"
[formField]="templateForm.subject"
(focusin)="setActiveEditor(subjectEditor)"
/>
<app-template-editor
#bodyEditor
placeholder="Write your message…"
[formField]="templateForm.body"
(focusin)="setActiveEditor(bodyEditor)"
/>
Two small things make the variable buttons pleasant to use. preventDefault on mousedown keeps the focus, and with it the cursor, in the editor while the button is clicked. And remembering the last focused editor lets one row of buttons serve both the subject and the body.
The preview renders the same text with sample values. It needs a single replace and nothing from Tiptap:
export const renderTemplate = (
template: string,
values: Readonly<Record<string, string>>,
): string => {
return template.replace(TOKEN_PATTERN, (token) => values[extractKey(token)] ?? token);
};
Testing Without a Browser
Tiptap runs without a DOM element, which makes the round trip easy to test. With the injector set to null, the node falls back to renderHTML, so no Angular component is involved:
const createHeadlessEditor = (text: string, multiline: boolean): Editor => {
return new Editor({
extensions: createTemplateExtensions({ multiline, injector: null, placeholder: () => '' }),
content: parseTemplate(text, { multiline }),
});
};
it.each([
['plain text', 'Hello world'],
['variables', 'Hi {{firstName}} {{lastName}}!'],
['adjacent variables', '{{firstName}}{{lastName}}'],
['unknown variables', 'Hi {{doesNotExist}}'],
['repeated whitespace', 'Hi {{firstName}}, welcome'],
['empty lines', 'Hi {{firstName}},\n\n\nbye'],
['incomplete tokens', 'Hi {{firstName} and {{ city }}'],
['empty text', ''],
])('keeps %s unchanged', (_description: string, text: string) => {
const editor = createHeadlessEditor(text, true);
expect(serializeTemplate(editor)).toBe(text);
editor.destroy();
});
Each case stands for a way an editor like this can lose or change data without anyone noticing. Testing them together as “text in equals text out” is the most valuable test in the project.
Input and paste rules can be tested headless as well, with one detail: jsdom has no ClipboardEvent, so view.pasteHTML needs a plain event:
editor.view.pasteHTML(
'<p>Greetings from {{city}}, {{firstName}}</p>',
new Event('paste') as ClipboardEvent,
);
expect(serializeTemplate(editor)).toBe('Greetings from {{city}}, {{firstName}}');
Conclusion
The editor itself is not much code: one node, a parser of about 40 lines and a component that connects both to the form. Most of the work is in decisions that are not visible in a quick test. Where does the token live? Which format goes into the editor? When does the editor update itself, and when does it leave the content alone?
The answers I ended up with all point in the same direction: change as little as possible at a time. Input and paste rules change only the matched range instead of rebuilding the document. The node stores its own token instead of depending on a list that might not be loaded. The editor only replaces its content when the value actually differs. Each of these keeps something intact that the user would otherwise lose: the cursor, the undo history or the variables themselves.
Signal forms make the Angular side pleasantly small. A value model, a few inputs and an output are enough to turn a Tiptap editor into a form control that validation, disabled state and touched state work with out of the box.