Variables as Chips, Plain Text Underneath: A Template Editor With Tiptap and Angular

René Kulik on 10.11.2026

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:

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:

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:

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:

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.