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, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
}
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
| Member | Type | Description |
|---|---|---|
id | string | What a chart stores as its renderer |
label | string | Optional. For a menu |
types | readonly ChartType[] | The chart types it draws |
draws({ data, spec }) | (chart) => boolean | Optional. false for a chart of its types that it draws badly, so another Renderer takes it |
rules | RendererRules | Optional. 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 isVectorChartRenderer. The built-in Renderers are of this kind. - With
toImage, the type isRasterChartRenderer. An SVG file then holds the PNG, not shapes, andexportreturnsraster: 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:
| Field | Type | Description |
|---|---|---|
data | ChartData | The Chart Data, with only the columns the spec selected |
spec | ChartSpec | The Chart Spec |
theme | ChartTheme | Concrete values to draw with, listed below |
size | { width, height } | In pixels |
animate | boolean | true 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 field | What to draw |
|---|---|
type | Only the types in types. Return false from draws for a variant you cannot draw |
palette | Use it instead of theme.series |
stacked | Stack the series of a bar or area chart |
orientation | 'horizontal': lay the bars of a bar chart on their side |
axes, references | Grid 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, title | Show or hide the legend and the title |
series | Line 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:
| Function | Returns |
|---|---|
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:
| Field | Type | Description |
|---|---|---|
target | ChartTextTarget | Which text was hit, see below |
text | string | The text as drawn |
box | ChartTextBox | x, y, width, height in the page's pixels, from getBoundingClientRect |
| Target | Text |
|---|---|
{ 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'],
},
}| Rule | Type | Description |
|---|---|---|
attribution | { label: string, href: string, logo?: string } | A credit shown with every chart or diagram, described below |
exports | readonly ('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.
logois standalone SVG markup, shown 16 pixels high.
What exports does
- A format that is not in the list makes
exportthrow. - Without
docx,exportDocxleaves 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.