Your own Renderer

You can write a Renderer for any drawing library. The document stores data, never the drawing, so your Renderer draws every existing chart and the documents need no migration. This page shows a chart Renderer. For a diagram format, see A Diagram Renderer.
New tab

A chart Renderer

This Renderer draws bar charts as plain SVG, without a library:

import type { ChartRenderer, ChartRenderInput } from '@tiptap-pro/extension-diagrams'

const SVG = 'http://www.w3.org/2000/svg'

function escapeXml(text: string): string {
  return text
    .replace(/&/g, '&')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
}

function barsSvg({ data, spec, theme, size }: ChartRenderInput): string {
  const series = data.columns.flatMap((column, index) =>
    column.role === 'series' ? [{ ...column, index }] : [],
  )
  const colors = spec.palette ?? theme.series
  const values = data.rows
    .flatMap((row) => series.map((column) => row[column.index]))
    .filter((cell): cell is number => typeof cell === 'number')
  const max = Math.max(1, ...values)
  const band = size.width / Math.max(1, data.rows.length)
  const bar = band / (series.length + 1)
  const plot = size.height - 24

  const bars = data.rows.flatMap((row, rowIndex) => [
    ...series.map((column, seriesIndex) => {
      const value = typeof row[column.index] === 'number' ? (row[column.index] as number) : 0
      const height = (value / max) * plot
      const x = rowIndex * band + bar * (seriesIndex + 0.5)
      return `<rect x="${x}" y="${plot - height}" width="${bar}" height="${height}" fill="${colors[seriesIndex % colors.length]}"/>`
    }),
    `<text x="${rowIndex * band + band / 2}" y="${size.height - 6}" text-anchor="middle" fill="${theme.labelColor}" font-family="${escapeXml(theme.fontFamily)}" font-size="${theme.fontSize}">${escapeXml(String(row[0] ?? ''))}</text>`,
  ])

  return `<svg xmlns="${SVG}" width="${size.width}" height="${size.height}" viewBox="0 0 ${size.width} ${size.height}">${bars.join('')}</svg>`
}

export const plainBarsRenderer: ChartRenderer = {
  id: 'plain-bars',
  label: 'Plain bars',
  types: ['bar'],
  // Leaves stacked bars to the next Renderer that draws bar charts
  draws: ({ spec }) => !spec.stacked,
  mount(target, input) {
    const draw = (next: ChartRenderInput) => {
      const svg = new DOMParser().parseFromString(barsSvg(next), 'image/svg+xml').documentElement
      target.replaceChildren(target.ownerDocument.importNode(svg, true))
    }
    draw(input)
    return { update: draw, destroy: () => target.replaceChildren() }
  },
  async toSVG(input) {
    return barsSvg(input)
  },
}

Registering it

import { Diagrams } from '@tiptap-pro/extension-diagrams'
import { echartsRenderer } from '@tiptap-pro/extension-diagrams/echarts'

Diagrams.configure({
  renderers: [echartsRenderer(), plainBarsRenderer],
})

editor.commands.setChartRenderer({ renderer: 'plain-bars' })

A chart that names plain-bars is drawn by it. Every other chart keeps its Renderer. See Which Renderer draws a chart.

Members

MemberTypeDescription
idstringWhat a chart stores as its renderer
labelstringOptional. For a menu
typesreadonly ChartType[]The chart types it draws
draws({ data, spec })(chart) => booleanOptional. false for a chart of its types that it draws badly, so another Renderer takes it
rulesRendererRulesOptional. See Rules of your Renderer
mount(target, input)(target, input) => ChartRenderHandle | Promise<ChartRenderHandle>Draws into target, an element inside the editor, and returns { update, destroy }
toSVG(input)(input) => Promise<string>Returns a standalone SVG string with concrete colors, used by export
toImage(input)(input) => Promise<Blob>Returns a PNG at input.size. For a library that draws on a canvas, see A Renderer that draws on a canvas

A Renderer has either toSVG or toImage, and every exported file is made from what it returns. The frame, the attribution and the size are the same either way.

  • With toSVG, the type is VectorChartRenderer. The built-in Renderers are of this kind.
  • With toImage, the type is RasterChartRenderer. An SVG file then holds the PNG, not shapes, and export returns raster: true.

mount returns { update, destroy }. The extension calls update(input) with new input when the data, the spec, the theme or the size changes. destroy() removes what mount added.

What a Renderer receives

input is a ChartRenderInput:

FieldTypeDescription
dataChartDataThe Chart Data, with only the columns the spec selected
specChartSpecThe Chart Spec
themeChartThemeConcrete values to draw with, listed below
size{ width, height }In pixels
animatebooleantrue when the chart may move as it is drawn and changed. A file never gets it

theme holds the series colors, background, axisColor, gridColor, labelColor, titleColor, fontFamily, fontSize, titleSize, titleWeight, legendGap and legendMarkerSize. These are resolved values, also in a dark color scheme. Draw with them, not with var(…), so the chart matches the page and exports correctly. toSVG and toImage receive the light colors, unless the caller asked for scheme: 'dark'.

animate is true in the editor when the animation option is on and the user's system does not ask for reduced motion.

A Renderer never reads the document, a table or a Data Source. Typed rows, a table the chart follows and a snapshot of a URL all arrive as Chart Data in input.data. See Data.

What a Renderer must not do

  • Run code that comes from the document.
  • Insert markup that comes from the document.
  • Set text such as labels and titles as HTML. Text is data, so set it as text.

What to honor in the spec

Spec fieldWhat to draw
typeOnly the types in types. Return false from draws for a variant you cannot draw
paletteUse it instead of theme.series
stackedStack the series of a bar or area chart
orientation'horizontal': lay the bars of a bar chart on their side
axes, referencesGrid and axis lines, ticks at every axes.y.step, and lines and bands at values. Decline through draws if you cannot
range{ min, max } of a gauge
legend, titleShow or hide the legend and the title
seriesLine style, width, marker and smoothing per series of a line or area chart. Decline through draws if you cannot draw them
renderer[id]Options a chart stores for your Renderer only, under your id

These functions from @tiptap-pro/extension-diagrams/core work out the axes as the built-in Renderers draw them:

FunctionReturns
hasAxisLines(type)true for line, bar, area, scatter and combo, the types whose axis lines spec.axes sets
hasRadarLines(type)true for radar, whose rings (y.grid) and spokes (x.line) spec.axes sets
valueExtent(series, stacked)The lowest and highest value the value axis must reach, 0 included
valueAxisScale(spec, extent){ min, max, ticks, fixed } of the value axis, with the step and the reference lines
resolveAxisStrokes(spec, numericCategories?)The grid and axis lines as drawn, every field decided

Text in the chart

A user can double click a text in a chart, such as its title, and rename it there. See Editing in the chart. The ECharts and D3 Renderers support this. The Mermaid Renderer does not, so a double click opens nothing there.

Your Renderer supports it when the handle that mount returns has onTextDoubleClick. That function takes a listener and returns a function that removes it. Call the listener on a double click, with a ChartTextHit:

FieldTypeDescription
targetChartTextTargetWhich text was hit, see below
textstringThe text as drawn
boxChartTextBoxx, y, width, height in the page's pixels, from getBoundingClientRect
TargetText
{ kind: 'title' }The title
{ kind: 'axis', axis: 'x' | 'y' }An axis title. x is the category axis, also on its side
{ kind: 'series', column }A series name, by the column id in input.data
{ kind: 'category', row }A category label or a pie part, by the row in input.data
mount(target, input) {
  const svg = draw(input)
  target.append(svg)
  const listeners = new Set<(hit: ChartTextHit) => void>()
  svg.addEventListener('dblclick', event => {
    const title = event.target instanceof Element ? event.target.closest('.title') : null
    if (!title) return
    const { x, y, width, height } = title.getBoundingClientRect()
    const hit: ChartTextHit = {
      target: { kind: 'title' },
      text: title.textContent ?? '',
      box: { x, y, width, height },
    }
    listeners.forEach(listener => listener(hit))
  })
  return {
    update: next => {},
    destroy: () => svg.remove(),
    onTextDoubleClick: listener => {
      listeners.add(listener)
      return () => listeners.delete(listener)
    },
  }
}

The editor opens no text field in a read-only editor, and none for a series or a category of read-only data.

Rules of your Renderer

A library's license may ask for a visible credit, or limit the file formats you export to. A Renderer declares this in rules, and the extension applies them wherever its charts or diagrams appear. A Diagram Renderer takes the same rules, as the BPMN Renderer does.

export const creditedRenderer: ChartRenderer = {
  ...plainBarsRenderer,
  id: 'credited-bars',
  rules: {
    attribution: {
      label: 'Drawn with Example',
      href: 'https://example.com',
      logo: '<svg viewBox="0 0 16 16">…</svg>',
    },
    exports: ['svg', 'png', 'docx'],
  },
}
RuleTypeDescription
attribution{ label: string, href: string, logo?: string }A credit shown with every chart or diagram, described below
exportsreadonly ('svg' | 'png' | 'webp' | 'jpeg' | 'docx')[]The formats export may produce. Without the list, every format

What attribution does

  • In the editor, it is a link below the chart or diagram (tiptap-chart-attribution, tiptap-flow-diagram-attribution).
  • In every exported SVG, PNG, WebP, JPEG and Word image, its label is drawn into the lower right corner.
  • logo is standalone SVG markup, shown 16 pixels high.

What exports does

  • A format that is not in the list makes export throw.
  • Without docx, exportDocx leaves a diagram out with a note, and writes a chart as a native Word chart.

Adding rules

Rules you add can only make a Renderer stricter. withRendererRules(renderer, rules) returns the Renderer with rules added. The rendererRules option adds rules by Renderer id.

import { Diagrams, withRendererRules } from '@tiptap-pro/extension-diagrams'

Diagrams.configure({
  renderers: [withRendererRules(plainBarsRenderer, { exports: ['svg', 'png'] })],
  rendererRules: {
    drawio: { attribution: { label: 'draw.io', href: 'https://www.drawio.com' } },
  },
})
  • An attribution the Renderer declares stays. You cannot remove it.
  • An attribution you add applies only where the Renderer declares none.
  • The allowed formats are those both lists allow.
  • editor.storage.diagrams.rulesFor(renderer) returns the rules in effect.
  • The built-in Renderers for ECharts, D3, Mermaid and draw.io declare no attribution and allow every format.