Migrating Quoin
Two upgrades are written up here. 0.4 to 1.0 is the rewrite; 1.x to 2.0 takes seven names the view layer only ever called itself out of the public entries.
Migrating from 0.4 to 1.0
Quoin 1.0 keeps the 0.4 document format and the BlockCanvas contract, and replaces everything under them: the card and textarea of each block give way to an editor of Quoin's own that types into the page in place. Most hosts upgrade by changing the version and reading the list below; the checklist at the end sums it up. Every change is listed in the changelog.
npm install quoin-editor@^1React 18.2 or 19 and TypeScript 5.4 or later, as in 0.4.
Data
No migration is needed
A document is still a QuoinBlock[] of { id, type, data }, and every key of the five 0.4 block types keeps its name and meaning (MarkdownBlockData, CalloutBlockData, CodeBlockData, ImageBlockData and FileBlockData). Store and load the same arrays. 1.0 adds optional keys, listed below. Blocks nobody edits are handed back by onChange as the very objects you supplied, so a stored 0.4 document is never rewritten by being opened. The one exception is a block whose id repeats another's: it gets a fresh id.
Multi-paragraph markdown blocks split on their first edit
A 0.4 MARKDOWN block could hold a whole page: several paragraphs, a list, a table. A 1.0 text block holds one line: a paragraph, a heading, a list item, a task or a quote. A 0.4 block of several lines is drawn line by line and kept as it is until someone edits it. On that first edit it is split: the first line keeps the block's id, the others take ids derived from it (<id>-2, <id>-3 and on, skipping any id the document already has), a table becomes a TABLE block and an image alone on its line an IMAGE block. Each piece is written back as the canonical markdown of its line, so a host that diffs stored strings sees those blocks change once.
import type { QuoinBlock } from 'quoin-editor';
// Before the first edit, exactly as stored in 0.4.
export const stored: QuoinBlock[] = [{ id: 'a', type: 'MARKDOWN', data: { markdown: '# Notes\n\nFirst point\n\n- one\n - nested' } }];
// What onChange reports once the heading has been edited.
export const edited: QuoinBlock[] = [
{ id: 'a', type: 'MARKDOWN', data: { markdown: '# Notes!' } },
{ id: 'a-2', type: 'MARKDOWN', data: { markdown: 'First point' } },
{ id: 'a-3', type: 'MARKDOWN', data: { markdown: '- one' } },
{ id: 'a-4', type: 'MARKDOWN', data: { markdown: '- nested' }, indent: 1 },
];The indent field
QuoinBlock gains an optional indent, the nesting depth shared by every block type: a nested list item, or a block inside a toggle. It is written only when it is above 0. Code that copies, validates or stores blocks should keep it.
New optional keys
1.0 writes keys that 0.4 never wrote, each only once a writer uses what it holds:
| Type | New keys in data |
|---|---|
MARKDOWN | align ('left', 'center', 'right' or 'justify'), color and background (palette colour names) |
CALLOUT | icon, the emoji the writer picked |
IMAGE | url (an image embedded by address), align and widthPercent |
FILE | mime, the type the uploaded file declares |
The block itself may carry indent, and documents may hold the new block types DIVIDER, TOGGLE, TABLE, EMBED, BOOKMARK, MATH and TOC. A host whose schema rejects unknown keys or types would reject a paragraph that was aligned, or an image that was resized. Add the keys above as optional, or let unknown keys through: in zod, .passthrough() (zod 3) or .loose() (zod 4) on the data schema; in JSON Schema, "additionalProperties": true.
Captions and callout bodies are inline markdown
An image's caption and a callout's body were plain text in 0.4. In 1.0 they are typed in place and stored as inline markdown, like a paragraph. A 0.4 caption such as A **sleepy** cat now shows "sleepy" in bold, and & shows as &. To keep a character literal in stored data, escape it with a backslash (\*\*); text typed in the canvas is escaped for you.
Named HTML entities
Numeric character references (é, é) are always decoded. Of the named entities, only the 23 most common are: &, <, >, ", ', , ©, ®, ™, …, —, –, ‘, ’, “, ”, «, », ×, ÷, °, · and •. Any other, such as é, stays literal text, and is written back escaped (\é) once its block is edited. The full HTML5 table would add about 10 KB gzip to the engine. Workaround: replace other named entities with the character itself or its numeric reference before you import the markdown.
Inline images in imported markdown
An image alone on its line becomes an IMAGE block. An image inside a paragraph, such as see  below, stays literal text, since 1.0 has no inline image. Workaround: put each image on a line of its own before you import the markdown, or write an extension that adds an inline image atom.
API
BlockCanvas
Every 0.4 prop keeps its name, and its meaning except as the changes below say: blocks, onChange, onDelete, blockTypes, upload, resolveAssetUrl, onError, readOnly, className, id and aria-label. New ones are optional:
| Prop | What it does |
|---|---|
extensions | Opt-in features and extensions of your own, read when the canvas mounts. Remount with a new key to change them. |
highlight | Colours code blocks with your highlighter. Keep it stable. |
autoFocus | Focuses the canvas on mount. Off by default. |
placeholder | The text of an empty paragraph that holds the caret. |
labels | Every string the canvas shows or speaks, English by default. Keep the object stable. |
blockLink | The address "Copy link to block" copies, by default the page's address with #block-<id>. |
ref | A QuoinEditorHandle with the editor, the controller, focus() and toMarkdown(). |
Two behaviours changed:
onDeletereports more. Besides the block menu's Delete, it reports whole blocks removed by typing over a selected block or by pasting over selected blocks. A line that Backspace or Delete merges into its neighbour is still not reported. To restore a block, splice it back intoblocksonce they no longer hold it, as on a later click of an Undo toast, not inside the callback.- A collaborative canvas is uncontrolled. A canvas given
collaborationExtensionfromquoin-editor/yjstakes its content from the shared document. Pass the starting blocks once and keep echoingonChange; while the shared document is connected, any other array handed toblocksis ignored, with a warning in development.
defineBlock
defineBlock takes the same definition. icon now accepts any component that takes a className and aria-hidden, not only a lucide one. A custom block shows its preview at rest, and its editor opens on a double click, its Edit button or Enter on the selected block; Escape closes it.
A definition of a type the canvas types into in place, MARKDOWN, CALLOUT or TOGGLE, now draws only on a read-only canvas, and an editable canvas warns in development. CODE, IMAGE and FILE are edited in their preview rather than through an editor, so a definition that replaces the preview and brings no editor of its own leaves a block an editable canvas draws and nobody can write in; that warns in development too. To draw one of those three your own way on a read-only canvas and keep Quoin's editing where the canvas is editable, register your definition only while readOnly is set. To change how one of them looks while it is edited, give an extension a renderer for the type in ui.renderers:
import { BlockCanvas, EditableRegion, type BlockRenderer, type ExtensionUI, type QuoinBlock } from 'quoin-editor';
import { defineExtension } from 'quoin-editor/engine';
const Callout: BlockRenderer = ({ block }) => (
<div className="my-callout" data-tone={String(block.attrs.tone)}>
<EditableRegion block={block.id} region="body" label="Callout" />
</div>
);
const ui: ExtensionUI = { renderers: { CALLOUT: Callout } };
const extensions = [defineExtension({ name: 'my-callout', ui })];
export function Editor({ blocks, onChange }: { blocks: QuoinBlock[]; onChange: (next: QuoinBlock[]) => void }) {
return <BlockCanvas blocks={blocks} onChange={onChange} extensions={extensions} />;
}useQuoin
QuoinConfig, which useQuoin() returns to a custom block, gains highlight and the required controller and labels. QuoinProvider fills them in, so only a QuoinConfig built by hand, such as a test double for a custom block, needs controller: null and labels: englishLabels added.
Entries
quoin-editor/headlessnow exports the view and the engine (useQuoinEditor,EditorView,createEditorand the rest), and none of the 0.4 helpers. The main entry re-exports all of it.formatShortcutis exported fromquoin-editoronly. Import it from there instead ofquoin-editor/headless.quoin-editor/engineis the engine alone, with no React and no'use client'banner: importtoMarkdown,toHTMLand the other pure functions from it in a server component.
Removed helpers
The 0.4 helpers worked on the text of a textarea. In 1.0 each is an engine command, run through the canvas's ref (handle.current?.editor.commands.<name>(...)) or a headless editor, or is no longer needed.
| 0.4 | 1.0 | Notes |
|---|---|---|
applyEdit, Edit | insertText | The editor applies every edit itself; insert text at the selection with the command. |
lineAt | Nothing | Each text block holds one line. |
toggleLinePrefix, LinePrefix | setBlockType | setBlockType('MARKDOWN', { kind: 'heading', level: 2 }), { kind: 'bullet' }, { kind: 'ordered' } and so on. |
continueList | splitBlock | Enter continues a list on its own. |
indentList | indent, outdent | Tab and Shift+Tab call them. |
wrapSelection, WrapKind | toggleMark, setLink | toggleMark('bold'), 'italic' or 'code'; setLink for 'link'. |
linkPaste | setLink, pasteLinkOverSelection | Pasting an address over a selection links it on its own. |
insertCommand, InsertableCommand | insertBlockAfter, setBlockType | setBlockType for a heading or a list, insertBlockAfter for a code block or a divider; a table needs quoin-editor/table. |
shouldOpenPalette | Nothing | The slash menu opens from the text typed, virtual keyboards included. |
caretRect, CaretRect | Nothing | The caret is the browser's own: read window.getSelection(). |
useMarkdownKeymap | defineExtension | Give an extension a keymap of commands. |
TEXT_COMMANDS, TextCommand | defineExtension | Add slash items through an extension's ui.slashItems. |
useBlockList, BlockList, UndoToken | Nothing | The host holds the array itself and hands it to blocks; the rows below map each member. |
BlockList.insertAt | insertBlockAfter | Inserts after the selected block. |
BlockList.update | setAttrs | Patches the selected block; to replace whole blocks, hand blocks a new array. |
BlockList.remove | deleteBlocks | Deletes the selected blocks. |
BlockList.undo | undo | The editor's history undoes a deletion, with no token. |
BlockList.move | moveBlocksUp, moveBlocksDown | |
BlockList.reorder | moveBlocksTo | moveBlocksTo(ids, toIndex, indent). |
BlockList.reset | Nothing | Hand blocks a new array, or call editor.setBlocks(blocks). |
useAutoGrow | Nothing | Nothing is drawn in a textarea any more. |
import { useRef } from 'react';
import { BlockCanvas, type QuoinBlock, type QuoinEditorHandle } from 'quoin-editor';
export function Editor({ blocks, onChange }: { blocks: QuoinBlock[]; onChange: (next: QuoinBlock[]) => void }) {
const handle = useRef<QuoinEditorHandle>(null);
const heading = () => handle.current?.editor.commands.setBlockType('MARKDOWN', { kind: 'heading', level: 2 });
return (
<>
<button type="button" onClick={heading}>
Heading
</button>
<BlockCanvas ref={handle} blocks={blocks} onChange={onChange} />
</>
);
}Command names of the design spec
The 1.0 design spec named mergeBackward, mergeForward and insertBlocks as commands. They are not commands: deleteBackward at the start of a block joins it to the one before, deleteForward at its end joins the next one to it, and insertBlockAfter and insertSlice insert blocks. Use those names.
Labels
Strings come from labels. Two keys are functions rather than strings: blockDeleted(n) and undoHint(shortcut). English callout tone names are written in lower case in the tone chip and its menu; give calloutTones to write them otherwise:
import { BlockCanvas, type QuoinBlock, type QuoinLabels } from 'quoin-editor';
const labels: Partial<QuoinLabels> = {
calloutTones: { info: 'Info', tip: 'Tip', success: 'Success', warning: 'Warning', danger: 'Danger' },
blockDeleted: (n) => (n === 1 ? 'Block removed' : `${n} blocks removed`),
undoHint: (shortcut) => `${shortcut} brings it back`,
};
export function Editor({ blocks, onChange }: { blocks: QuoinBlock[]; onChange: (next: QuoinBlock[]) => void }) {
return <BlockCanvas blocks={blocks} onChange={onChange} labels={labels} />;
}Features that now need an import
In 0.4 the slash palette's Table command wrote a markdown table into the block. In 1.0 a table is a TABLE block, drawn and edited by quoin-editor/table, which a page loads only when it imports it. Without it, a table, including one split out of a 0.4 block, is drawn as an unsupported block and its data is kept as it was. The one-line change is to pass the extension:
import { BlockCanvas, type QuoinBlock } from 'quoin-editor';
import { tableExtension } from 'quoin-editor/table';
const extensions = [tableExtension()];
export function Editor({ blocks, onChange }: { blocks: QuoinBlock[]; onChange: (next: QuoinBlock[]) => void }) {
return <BlockCanvas blocks={blocks} onChange={onChange} extensions={extensions} />;
}catalogue() from quoin-editor/catalogue adds every opt-in feature at once: tables, embeds, math, a table of contents, mentions, emoji, find and replace and stats.
Styling
- Every 0.4
--quoin-token keeps its name, and the stylesheet still sits in the onequoincascade layer. - New tokens:
--quoin-selection, the writer's text and background colours--quoin-color-*and--quoin-bg-*,--quoin-comment,--quoin-comment-activeand--quoin-comment-edge,--quoin-diff-insertedand--quoin-diff-deleted, and--quoin-link. Each has a light and a dark default; a host that maps its own tokens onto Quoin's can map these too. color-schemeis declared for light and dark.- The DOM and the class names inside the canvas are new. CSS that targeted 0.4 internals, such as a block card or its textarea, no longer matches; style through the tokens.
React 18
React 18.2 and 19 are both supported, as 0.4.1 made them. CI typechecks the packed package against both majors and renders the server entries in plain Node on each, and BlockCanvas forwards its ref, so a QuoinEditorHandle reaches you on React 18 too.
The package
The tarball ships ESM and CommonJS builds of every entry, with their sourcemaps. The sourcemaps leave out sourcesContent: a debugger maps to the original file names and lines, but shows their text only where it can read the sources, such as from the repository at the release tag.
Checklist
- Install
quoin-editor@^1. - Keep
indentand the new optional keys when you copy, validate or store blocks, and let a strict schema accept them. - Expect a 0.4 block of several lines to be split into one block per line, with derived ids, on its first edit.
- Check captions and callout bodies that hold
*,_,`,[or&, which now read as inline markdown. - Replace named entities other than the 23 listed, and move inline images onto lines of their own, in markdown you import.
- Replace each removed helper with the command in the table above.
- Import
formatShortcutfromquoin-editor. - Move a
MARKDOWN,CALLOUTorTOGGLEdefinition used on an editable canvas to an extension'sui.renderers. - Wait until
blocksno longer hold a block before restoring it fromonDelete. - Pass
tableExtension()(orcatalogue()) if your documents hold tables. - Map the new tokens if you map Quoin's tokens to your own, and drop CSS that targeted 0.4 internals.
- Treat a canvas with a collaboration extension as uncontrolled.
Migrating from 1.x to 2.0
Quoin 2.0 keeps the document format, the props of every component and every command of 1.x. What changes is the published surface: seven names that only the view layer ever called itself stop being exported from quoin-editor/view, and so from quoin-editor/headless and quoin-editor with it. Five of them have carried a deprecation notice since 1.8.0, shown by an editor on hover and on the headless reference; the other two are the prop types of the two components, and go with them. Nothing else is removed and nothing behaves differently, so a host that imports none of the seven upgrades by changing the version.
npm install quoin-editor@^2React 18.2 or 19 and TypeScript 5.4 or later, as in 1.x.
Exports that are gone
| 1.x | 2.0 | Notes |
|---|---|---|
defaultRenderers | EditableRegion | The one removal with a cost, below. |
BlockShell, BlockShellProps | Nothing | EditorView draws a shell for every block already. |
SelectionSink, SelectionSinkProps | Nothing | EditorView draws one already. |
VoidBlockFallback | Nothing | A block shell falls back to it on its own. |
isCrossSelection | Selection | Four lines over anchor and head. |
BlockRenderProps and BlockRenderer stay public, so a renderer table still types.
defaultRenderers
A paragraph, a heading, a bullet, a numbered item, a task and a quote are all MARKDOWN blocks drawn by one renderer. A host that wanted its own quote therefore had to name MARKDOWN in its table, and could hand every other kind back to the default:
import { defaultRenderers, EditableRegion } from 'quoin-editor';
const Markdown = defaultRenderers.MARKDOWN;
export const renderers = {
MARKDOWN: (props) =>
props.block.attrs.kind === 'quote' ? (
<figure className="pull-quote">
<EditableRegion block={props.block.id} region="text" label="Quote" />
</figure>
) : (
<Markdown {...props} />
),
};In 2.0 that renderer owns every kind, and draws each one's text with EditableRegion, which is what the default renderer does inside:
import { EditableRegion, type BlockRenderer } from 'quoin-editor';
const LABELS: Record<string, string> = {
heading: 'Heading',
bullet: 'Bullet list item',
ordered: 'Numbered list item',
task: 'Task',
quote: 'Quote',
};
export const renderers: Record<string, BlockRenderer> = {
MARKDOWN: ({ block }) => {
const kind = typeof block.attrs.kind === 'string' ? block.attrs.kind : '';
const text = <EditableRegion block={block.id} region="text" label={LABELS[kind] ?? 'Paragraph'} />;
if (kind === 'quote') return <figure className="pull-quote">{text}</figure>;
if (kind === 'heading') {
const level = typeof block.attrs.level === 'number' ? block.attrs.level : 1;
return <div role="heading" aria-level={level} className="quoin-heading">{text}</div>;
}
if (kind === 'bullet') {
return (
<div className="quoin-list-item" data-list="bullet">
<span className="quoin-marker" aria-hidden="true">{'•'}</span>
{text}
</div>
);
}
return text;
},
};EditableRegion takes the block's id, the name of the region to draw (text for every markdown kind) and an accessible name, and is the same editable host the default renderer mounts, so the caret, the selection and the decorations keep working.
Two kinds ask for more than markup, and the sample above leaves both drawn as plain text. A numbered item shows its running number, which counts the ordered blocks above it at the same indent; a task shows a checkbox that runs the toggleTask command against its own block. Neither the counter nor the checkbox is public, so a host that overrides MARKDOWN and still wants those two drawn the way Quoin draws them writes them itself. A host that only wants to restyle a kind has a cheaper way out: leave MARKDOWN out of the renderer table and style the markup the default renderer already writes, which carries data-kind on the block and the quoin-heading, quoin-list-item, quoin-task and quoin-quote class names.
BlockShell and SelectionSink
Neither was ever something to render. EditorView draws one SelectionSink and one BlockShell per block, and each registers itself with the ViewController on mount (controller.registerSink, controller.registerBlockElement). A second copy takes that registration from the first, so a host that rendered either lost the selection or the block it meant to help. Drop the import; the editor already draws both. BlockShellProps and SelectionSinkProps named their props and go with them.
VoidBlockFallback
A block shell falls back to it already when no renderer in the table draws a block's type, so a table that named it only restated the default. Drop the entry and the import.
isCrossSelection
It asked whether a text selection spans blocks or regions, which is what painting and the arrow keys ask. It reads anchor and head of the public Selection, so a host that wants the answer writes it:
import type { Selection } from 'quoin-editor/engine';
export const spansBlocks = (selection: Selection | null): boolean =>
selection?.kind === 'text' &&
(selection.anchor.block !== selection.head.block || selection.anchor.region !== selection.head.region);2.0 checklist
- Install
quoin-editor@^2. - Drop any
BlockShellorSelectionSinkyou render, and theBlockShellPropsandSelectionSinkPropsthat typed them. - Drop any
VoidBlockFallbackentry from a renderer table. - Replace
isCrossSelectionwith the predicate above. - Draw every
MARKDOWNkind yourself if your table namesMARKDOWN, or leaveMARKDOWNout and style the default markup instead.