Custom extensions

Write your own extension with a toolbar button, or extend a built-in one.

An Echo Editor extension is a Tiptap extension whose options include a button function. The toolbar calls it to render the control.

A toolbar button for a custom command

extensions/Timestamp.ts
import { Extension } from '@tiptap/core'
import { ActionButton } from 'vue-echo-editor'
import type { GeneralOptions } from 'vue-echo-editor'

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    timestamp: { insertTimestamp: () => ReturnType }
  }
}

export interface TimestampOptions extends GeneralOptions<TimestampOptions> {
  format: Intl.DateTimeFormatOptions
}

export const Timestamp = Extension.create<TimestampOptions>({
  name: 'timestamp',

  addOptions() {
    return {
      ...(this.parent?.() as TimestampOptions),
      format: { dateStyle: 'medium', timeStyle: 'short' },
      button: ({ editor, extension, t }) => ({
        component: ActionButton,
        componentProps: {
          icon: 'Hash',
          tooltip: 'Insert timestamp',
          action: () => editor.chain().focus().insertTimestamp().run(),
          disabled: !editor.isEditable,
        },
      }),
    }
  },

  addCommands() {
    return {
      insertTimestamp:
        () =>
        ({ commands }) =>
          commands.insertContent(new Intl.DateTimeFormat(undefined, this.options.format).format(new Date())),
    }
  },

  addKeyboardShortcuts() {
    return { 'Mod-Shift-t': () => this.editor.commands.insertTimestamp() }
  },
})

The button contract

button({ editor, extension, t }) returns one item (or an array of items):

interface ButtonViewReturn {
  component: Component          // usually ActionButton
  componentProps: {
    action?: (value?: any) => void
    isActive?: () => boolean
    icon?: IconName             // name from the built-in Lucide icon map
    tooltip?: string
    shortcutKeys?: string[]     // e.g. ['mod', 'shift', 'T']
    disabled?: boolean
    [key: string]: any          // forwarded to your component
  }
  componentSlots?: Record<string, () => any>
}

The function runs at most once per animation frame, so reading editor.isActive() / editor.can() inside it is cheap.

Extending a built-in extension

import { Heading } from 'vue-echo-editor'

export const CompactHeading = Heading.extend({
  addOptions() {
    return { ...this.parent?.(), levels: [2, 3] }
  },
  addKeyboardShortcuts() {
    return {
      ...this.parent?.(),
      'Mod-Alt-0': () => this.editor.commands.setParagraph(),
    }
  },
})

Node views with Vue

Use Tiptap's VueNodeViewRenderer for interactive blocks. Remember that in Tiptap 3 getPos() can return undefined:

<script setup lang="ts">
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'

const props = defineProps(nodeViewProps)

function select() {
  const pos = props.getPos()
  if (typeof pos === 'number') props.editor.commands.setNodeSelection(pos)
}
</script>

<template>
  <NodeViewWrapper @click="select">…</NodeViewWrapper>
</template>

To package extensions, buttons, slash commands and translations together, use definePlugin.