quoin-editor 0.4.2 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +251 -0
- package/MIGRATION.md +233 -0
- package/README.md +78 -716
- package/dist/ai.cjs +1148 -0
- package/dist/ai.cjs.map +1 -0
- package/dist/ai.d.cts +68 -0
- package/dist/ai.d.ts +68 -0
- package/dist/ai.js +1148 -0
- package/dist/ai.js.map +1 -0
- package/dist/catalogue.cjs +59 -0
- package/dist/catalogue.cjs.map +1 -0
- package/dist/catalogue.d.cts +39 -0
- package/dist/catalogue.d.ts +39 -0
- package/dist/catalogue.js +59 -0
- package/dist/catalogue.js.map +1 -0
- package/dist/chunk-26Q4LSIU.js +100 -0
- package/dist/chunk-26Q4LSIU.js.map +1 -0
- package/dist/chunk-56C7GCKO.cjs +1385 -0
- package/dist/chunk-56C7GCKO.cjs.map +1 -0
- package/dist/chunk-5M4QG3ZC.cjs +3303 -0
- package/dist/chunk-5M4QG3ZC.cjs.map +1 -0
- package/dist/chunk-5R3ISX4A.cjs +6643 -0
- package/dist/chunk-5R3ISX4A.cjs.map +1 -0
- package/dist/chunk-62WKUAQY.js +3335 -0
- package/dist/chunk-62WKUAQY.js.map +1 -0
- package/dist/chunk-6LMS5J3Y.js +320 -0
- package/dist/chunk-6LMS5J3Y.js.map +1 -0
- package/dist/chunk-6PKVJZQV.cjs +100 -0
- package/dist/chunk-6PKVJZQV.cjs.map +1 -0
- package/dist/chunk-6YSGUAVW.js +376 -0
- package/dist/chunk-6YSGUAVW.js.map +1 -0
- package/dist/chunk-75C6PX2L.cjs +239 -0
- package/dist/chunk-75C6PX2L.cjs.map +1 -0
- package/dist/chunk-A4IJSJKL.js +598 -0
- package/dist/chunk-A4IJSJKL.js.map +1 -0
- package/dist/chunk-BTBHHBVR.cjs +3335 -0
- package/dist/chunk-BTBHHBVR.cjs.map +1 -0
- package/dist/chunk-CLTHD5GO.cjs +34 -0
- package/dist/chunk-CLTHD5GO.cjs.map +1 -0
- package/dist/chunk-EH5KXQXY.cjs +62 -0
- package/dist/chunk-EH5KXQXY.cjs.map +1 -0
- package/dist/chunk-ESMAW4NK.cjs +184 -0
- package/dist/chunk-ESMAW4NK.cjs.map +1 -0
- package/dist/chunk-EYFCOV3T.cjs +30 -0
- package/dist/chunk-EYFCOV3T.cjs.map +1 -0
- package/dist/chunk-F5V3IEEE.cjs +326 -0
- package/dist/chunk-F5V3IEEE.cjs.map +1 -0
- package/dist/chunk-GPIDU3GN.js +239 -0
- package/dist/chunk-GPIDU3GN.js.map +1 -0
- package/dist/chunk-HOIDJEVI.cjs +4063 -0
- package/dist/chunk-HOIDJEVI.cjs.map +1 -0
- package/dist/chunk-JJ3RASQS.cjs +706 -0
- package/dist/chunk-JJ3RASQS.cjs.map +1 -0
- package/dist/chunk-LE3F7WIP.js +6643 -0
- package/dist/chunk-LE3F7WIP.js.map +1 -0
- package/dist/chunk-LE5XMIFJ.cjs +14 -0
- package/dist/chunk-LE5XMIFJ.cjs.map +1 -0
- package/dist/chunk-LMAEPFTX.js +30 -0
- package/dist/chunk-LMAEPFTX.js.map +1 -0
- package/dist/chunk-LV6EYPJO.js +34 -0
- package/dist/chunk-LV6EYPJO.js.map +1 -0
- package/dist/chunk-MLRAVUFG.cjs +376 -0
- package/dist/chunk-MLRAVUFG.cjs.map +1 -0
- package/dist/chunk-MQUCCSWG.js +14 -0
- package/dist/chunk-MQUCCSWG.js.map +1 -0
- package/dist/chunk-O4Z2WMFF.js +62 -0
- package/dist/chunk-O4Z2WMFF.js.map +1 -0
- package/dist/chunk-OCCKQHCY.js +25 -0
- package/dist/chunk-OCCKQHCY.js.map +1 -0
- package/dist/chunk-PGSS4IOS.js +706 -0
- package/dist/chunk-PGSS4IOS.js.map +1 -0
- package/dist/chunk-PIWRSX7U.js +562 -0
- package/dist/chunk-PIWRSX7U.js.map +1 -0
- package/dist/chunk-PX6LZMH4.cjs +598 -0
- package/dist/chunk-PX6LZMH4.cjs.map +1 -0
- package/dist/chunk-QXG3HWDS.js +184 -0
- package/dist/chunk-QXG3HWDS.js.map +1 -0
- package/dist/chunk-RQW3P3GH.js +504 -0
- package/dist/chunk-RQW3P3GH.js.map +1 -0
- package/dist/chunk-SSIRYUCK.js +3303 -0
- package/dist/chunk-SSIRYUCK.js.map +1 -0
- package/dist/chunk-UBNG5YCG.js +326 -0
- package/dist/chunk-UBNG5YCG.js.map +1 -0
- package/dist/chunk-UCXLRRWY.js +1385 -0
- package/dist/chunk-UCXLRRWY.js.map +1 -0
- package/dist/chunk-UDAVVEFC.cjs +562 -0
- package/dist/chunk-UDAVVEFC.cjs.map +1 -0
- package/dist/chunk-UPSLDTLT.js +4063 -0
- package/dist/chunk-UPSLDTLT.js.map +1 -0
- package/dist/chunk-UWNZG2SD.cjs +25 -0
- package/dist/chunk-UWNZG2SD.cjs.map +1 -0
- package/dist/chunk-V3KLMSRO.cjs +504 -0
- package/dist/chunk-V3KLMSRO.cjs.map +1 -0
- package/dist/chunk-VACTAZWS.cjs +249 -0
- package/dist/chunk-VACTAZWS.cjs.map +1 -0
- package/dist/chunk-WMN6566P.js +249 -0
- package/dist/chunk-WMN6566P.js.map +1 -0
- package/dist/chunk-XX5FTPJR.cjs +320 -0
- package/dist/chunk-XX5FTPJR.cjs.map +1 -0
- package/dist/comments.cjs +1008 -0
- package/dist/comments.cjs.map +1 -0
- package/dist/comments.d.cts +93 -0
- package/dist/comments.d.ts +93 -0
- package/dist/comments.js +1008 -0
- package/dist/comments.js.map +1 -0
- package/dist/diff.cjs +346 -0
- package/dist/diff.cjs.map +1 -0
- package/dist/diff.d.cts +67 -0
- package/dist/diff.d.ts +67 -0
- package/dist/diff.js +346 -0
- package/dist/diff.js.map +1 -0
- package/dist/docx.cjs +874 -0
- package/dist/docx.cjs.map +1 -0
- package/dist/docx.d.cts +33 -0
- package/dist/docx.d.ts +33 -0
- package/dist/docx.js +874 -0
- package/dist/docx.js.map +1 -0
- package/dist/embed-provider-jXwlRmnp.d.cts +19 -0
- package/dist/embed-provider-jXwlRmnp.d.ts +19 -0
- package/dist/embed.cjs +17 -0
- package/dist/embed.cjs.map +1 -0
- package/dist/embed.d.cts +56 -0
- package/dist/embed.d.ts +56 -0
- package/dist/embed.js +17 -0
- package/dist/embed.js.map +1 -0
- package/dist/emoji-data.cjs +1968 -0
- package/dist/emoji-data.cjs.map +1 -0
- package/dist/emoji-data.js +1968 -0
- package/dist/emoji-data.js.map +1 -0
- package/dist/emoji-extension-CmPlRssm.d.ts +46 -0
- package/dist/emoji-extension-zJ6F7hW8.d.cts +46 -0
- package/dist/emoji.cjs +18 -0
- package/dist/emoji.cjs.map +1 -0
- package/dist/emoji.d.cts +31 -0
- package/dist/emoji.d.ts +31 -0
- package/dist/emoji.js +18 -0
- package/dist/emoji.js.map +1 -0
- package/dist/engine.cjs +119 -0
- package/dist/engine.cjs.map +1 -0
- package/dist/engine.d.cts +144 -0
- package/dist/engine.d.ts +144 -0
- package/dist/engine.js +119 -0
- package/dist/engine.js.map +1 -0
- package/dist/find-extension-BcRyE9K4.d.cts +41 -0
- package/dist/find-extension-CmFehyul.d.ts +41 -0
- package/dist/find.cjs +20 -0
- package/dist/find.cjs.map +1 -0
- package/dist/find.d.cts +44 -0
- package/dist/find.d.ts +44 -0
- package/dist/find.js +20 -0
- package/dist/find.js.map +1 -0
- package/dist/headless.cjs +151 -518
- package/dist/headless.cjs.map +1 -1
- package/dist/headless.d.cts +102 -69
- package/dist/headless.d.ts +102 -69
- package/dist/headless.js +149 -467
- package/dist/headless.js.map +1 -1
- package/dist/index-CFPmCee4.d.cts +68 -0
- package/dist/index-DiCGV60d.d.ts +68 -0
- package/dist/index.cjs +183 -2753
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +345 -253
- package/dist/index.d.ts +345 -253
- package/dist/index.js +168 -2696
- package/dist/index.js.map +1 -1
- package/dist/math.cjs +18 -0
- package/dist/math.cjs.map +1 -0
- package/dist/math.d.cts +49 -0
- package/dist/math.d.ts +49 -0
- package/dist/math.js +18 -0
- package/dist/math.js.map +1 -0
- package/dist/mention.cjs +18 -0
- package/dist/mention.cjs.map +1 -0
- package/dist/mention.d.cts +50 -0
- package/dist/mention.d.ts +50 -0
- package/dist/mention.js +18 -0
- package/dist/mention.js.map +1 -0
- package/dist/presence.cjs +562 -0
- package/dist/presence.cjs.map +1 -0
- package/dist/presence.d.cts +30 -0
- package/dist/presence.d.ts +30 -0
- package/dist/presence.js +562 -0
- package/dist/presence.js.map +1 -0
- package/dist/registry-CWBOSAHm.d.cts +121 -0
- package/dist/registry-CWBOSAHm.d.ts +121 -0
- package/dist/stats-extension-DisOEbbI.d.ts +40 -0
- package/dist/stats-extension-Dt1lgP3m.d.cts +40 -0
- package/dist/stats.cjs +19 -0
- package/dist/stats.cjs.map +1 -0
- package/dist/stats.d.cts +19 -0
- package/dist/stats.d.ts +19 -0
- package/dist/stats.js +19 -0
- package/dist/stats.js.map +1 -0
- package/dist/styles.css +1 -1
- package/dist/table-extension-BTMJnT7J.d.ts +57 -0
- package/dist/table-extension-DxZz3jqp.d.cts +57 -0
- package/dist/table.cjs +50 -0
- package/dist/table.cjs.map +1 -0
- package/dist/table.d.cts +57 -0
- package/dist/table.d.ts +57 -0
- package/dist/table.js +50 -0
- package/dist/table.js.map +1 -0
- package/dist/to-html-C5t7xyaM.d.ts +21 -0
- package/dist/to-html-CNCITCln.d.cts +21 -0
- package/dist/toc-extension-CR9U-l6j.d.ts +25 -0
- package/dist/toc-extension-DGHywOBv.d.cts +25 -0
- package/dist/toc.cjs +18 -0
- package/dist/toc.cjs.map +1 -0
- package/dist/toc.d.cts +24 -0
- package/dist/toc.d.ts +24 -0
- package/dist/toc.js +18 -0
- package/dist/toc.js.map +1 -0
- package/dist/types-C6_QkXzy.d.ts +358 -0
- package/dist/types-CJ7Gq3GX.d.cts +358 -0
- package/dist/types-DULc8wwg.d.cts +120 -0
- package/dist/types-DULc8wwg.d.ts +120 -0
- package/dist/view-controller-BD1KwYfE.d.cts +105 -0
- package/dist/view-controller-y4cb60ue.d.ts +105 -0
- package/dist/view.cjs +23 -0
- package/dist/view.cjs.map +1 -0
- package/dist/view.d.cts +40 -0
- package/dist/view.d.ts +40 -0
- package/dist/view.js +23 -0
- package/dist/view.js.map +1 -0
- package/dist/yjs.cjs +864 -0
- package/dist/yjs.cjs.map +1 -0
- package/dist/yjs.d.cts +22 -0
- package/dist/yjs.d.ts +22 -0
- package/dist/yjs.js +864 -0
- package/dist/yjs.js.map +1 -0
- package/package.json +217 -18
package/README.md
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
# Quoin
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[](https://github.com/AmineSeboui/quoin-editor/actions/workflows/ci.yml)
|
|
3
|
+
**Blocks on screen. Markdown on disk.**
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
Quoin is a block editor for React that keeps every text block as readable markdown inside a typed array you hold in your own state; images, files, code, dividers, tables and the catalogue blocks are typed data beside them. Writers get live formatting, a slash menu and drag handles; you get a document you can save, diff in git or hand to an LLM without a conversion step.
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
[](https://www.npmjs.com/package/quoin-editor) [](https://github.com/AmineSeboui/quoin-editor/actions/workflows/ci.yml) [](LICENSE)
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
[Live demo](https://amineseboui.github.io/quoin-editor/) | [Docs](https://amineseboui.github.io/quoin-editor/docs/getting-started/) | [Changelog](CHANGELOG.md)
|
|
11
10
|
|
|
12
|
-
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
- **Live formatting, markdown on disk.** Type `**bold**` or `# ` and see bold text or a heading; the block stores the markdown. Blocks you never edit are never rewritten (`src/engine/blocks/codec.test.ts`).
|
|
14
|
+
- **Typed blocks you control like an input.** A document is `QuoinBlock[]`, plain JSON, passed as `blocks` and returned through `onChange`, so saving, versioning and validation stay in your code.
|
|
15
|
+
- **Notion-style UI included, MIT.** Slash menu, bubble toolbar, block menu, drag handles, a mobile toolbar and a shortcuts dialog, with no account and no paid tier.
|
|
16
|
+
- **Custom blocks as plain React.** `defineBlock` takes an editor, a preview, or both, as React components; no schema language to learn.
|
|
17
|
+
- **Accessible, with tested keyboard and screen reader support.** In CI, axe runs over the canvas and its menus in light and dark mode (`e2e/tests/canvas/a11y.spec.ts`), a document is written with the keyboard alone (`keyboard-only.spec.ts`), and moves and deletions are announced (`shortcuts.spec.ts`). [What is not checked yet](https://amineseboui.github.io/quoin-editor/docs/accessibility/#what-is-not-checked-yet).
|
|
13
18
|
|
|
14
19
|
## Install
|
|
15
20
|
|
|
@@ -17,15 +22,7 @@ Quoin is published on npm as `quoin-editor`, because npm rejects the bare name `
|
|
|
17
22
|
npm install quoin-editor
|
|
18
23
|
```
|
|
19
24
|
|
|
20
|
-
|
|
21
|
-
yarn add quoin-editor
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
Quoin needs React 18.2 or later (React 19 is supported) and TypeScript 5.4 or later if you use TypeScript. The block registry relies on the built-in `NoInfer` utility type, which arrived in 5.4.
|
|
25
|
-
|
|
26
|
-
**Tailwind is not required, and either major version works.** See [Tailwind, or no Tailwind](#tailwind-or-no-tailwind) for the one thing a v4 host has to do.
|
|
27
|
-
|
|
28
|
-
The package is around 200 kB packed. It depends on `@dnd-kit` for dragging, five Radix primitives, `cmdk` for the slash palette, `lucide-react` for the icons, and `clsx`, `tailwind-merge` and `class-variance-authority` for class composition. That is the price of the canvas: the cards, the drag gutter, the inserters, the palette and the dialogs. If you want the text editing and none of the UI, [`quoin-editor/headless`](#headless) has no component or UI library dependency at all.
|
|
25
|
+
React 18.2 or 19, TypeScript 5.4 or later. Tailwind is optional, v3 and v4 both work. The npm tarball is about 1 MB packed, under 5 MB unpacked: ESM and CommonJS builds, with sourcemaps that leave out the source text. A page loads only the entries it imports.
|
|
29
26
|
|
|
30
27
|
## Quickstart
|
|
31
28
|
|
|
@@ -40,712 +37,77 @@ export function Editor() {
|
|
|
40
37
|
}
|
|
41
38
|
```
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
If your application has Tailwind of its own, import the stylesheet from your CSS into a layer you have named instead of from JavaScript, so you decide which of the two wins. See [Cascade layers and import order](#cascade-layers-and-import-order).
|
|
46
|
-
|
|
47
|
-
## The controlled-component contract
|
|
48
|
-
|
|
49
|
-
`BlockCanvas` behaves like a controlled `<input value onChange>`. Update `blocks` synchronously from `onChange`.
|
|
50
|
-
|
|
51
|
-
Applying the edit late is tolerated, as long as what you eventually hand back is one of the arrays the canvas gave you. It recognises its own output even when the echo is several keystrokes behind, so `startTransition` and a debounced `setBlocks` both keep typing intact.
|
|
52
|
-
|
|
53
|
-
```tsx
|
|
54
|
-
import { startTransition, useState } from 'react';
|
|
55
|
-
import { BlockCanvas, type QuoinBlock } from 'quoin-editor';
|
|
56
|
-
import 'quoin-editor/styles.css';
|
|
57
|
-
|
|
58
|
-
export function TransitionEditor() {
|
|
59
|
-
const [blocks, setBlocks] = useState<QuoinBlock[]>([]);
|
|
60
|
-
return (
|
|
61
|
-
<BlockCanvas
|
|
62
|
-
blocks={blocks}
|
|
63
|
-
onChange={(next) => startTransition(() => setBlocks(next))}
|
|
64
|
-
/>
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
Since 0.4.1, stores that clone the document on the way through, such as a Redux or Zustand round trip or a server-synced store, are supported too. The canvas recognises an array it emitted however late it comes back, and a structural copy of one as long as it is within the last 64 edits.
|
|
70
|
-
|
|
71
|
-
Debounce the side effect, not the state. `useAutosave` does exactly that: it tracks the values you give it, waits for the edits to settle, then calls your `save`, and it retries a failed save with exponential backoff.
|
|
72
|
-
|
|
73
|
-
```tsx
|
|
74
|
-
import { useState } from 'react';
|
|
75
|
-
import { BlockCanvas, useAutosave, type QuoinBlock } from 'quoin-editor';
|
|
76
|
-
import 'quoin-editor/styles.css';
|
|
77
|
-
|
|
78
|
-
async function saveDocument(blocks: QuoinBlock[]) {
|
|
79
|
-
const response = await fetch('/api/document', { method: 'PUT', body: JSON.stringify(blocks) });
|
|
80
|
-
return response.json() as Promise<{ revision: number }>;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function AutosavingEditor({ initial }: { initial: QuoinBlock[] }) {
|
|
84
|
-
const [blocks, setBlocks] = useState<QuoinBlock[]>(initial);
|
|
85
|
-
const { status } = useAutosave({
|
|
86
|
-
deps: [blocks],
|
|
87
|
-
build: () => blocks,
|
|
88
|
-
save: saveDocument,
|
|
89
|
-
debounceMs: 1000,
|
|
90
|
-
});
|
|
91
|
-
return (
|
|
92
|
-
<>
|
|
93
|
-
<BlockCanvas blocks={blocks} onChange={setBlocks} />
|
|
94
|
-
<p role="status">{status}</p>
|
|
95
|
-
</>
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
`save` may return anything, so a mutation, an axios call or a `fetch` that resolves to a body goes straight in with no wrapper to discard the value. A save fails by rejecting, or by resolving to an explicit `{ ok: false }`; everything else, `undefined` included, is a success. `status` moves `idle` to `saving` to `saved`, or to `retrying` while it backs off, and `retry()` tries again at once. Since 0.4.1 one save runs at a time, with the newest payload queued behind it, and a pending edit is saved at once when the component unmounts or the page is hidden (`flush()` does the same on demand). Retries stop after `maxRetries` (default 8) or when `isRetryable(error)` returns false; `status` is then `failed` and `error` holds the reason.
|
|
101
|
-
|
|
102
|
-
### Loading, undoing and discarding
|
|
103
|
-
|
|
104
|
-
You can hand the canvas a new `blocks` array at any time to load, undo or merge a document. A new array replaces what the canvas shows when it differs from the last array you supplied.
|
|
40
|
+
What `onChange` hands you, and what you store:
|
|
105
41
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
import { BlockCanvas, type QuoinBlock } from 'quoin-editor';
|
|
113
|
-
import 'quoin-editor/styles.css';
|
|
114
|
-
|
|
115
|
-
export function EditorWithDiscard({ saved }: { saved: QuoinBlock[] }) {
|
|
116
|
-
const [blocks, setBlocks] = useState<QuoinBlock[]>(saved);
|
|
117
|
-
const [revision, setRevision] = useState(0);
|
|
118
|
-
|
|
119
|
-
function discard() {
|
|
120
|
-
setBlocks(saved);
|
|
121
|
-
setRevision((current) => current + 1);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return (
|
|
125
|
-
<>
|
|
126
|
-
<BlockCanvas key={revision} blocks={blocks} onChange={setBlocks} />
|
|
127
|
-
<button type="button" onClick={discard}>
|
|
128
|
-
Discard changes
|
|
129
|
-
</button>
|
|
130
|
-
</>
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
The structural comparison is shallow over each block's `data`. A host whose block `data` contains nested objects rebuilt on every render, and which does not echo `onChange` back into `blocks`, can still lose an edit. All five core block types have flat data, so this only affects custom blocks. Keep `data` flat, or keep it referentially stable.
|
|
136
|
-
|
|
137
|
-
### Undoing a deletion
|
|
138
|
-
|
|
139
|
-
`onChange` reports a new array and nothing about what changed, so a host that wants a "Block deleted / Undo" toast would have to diff two arrays to find out. `onDelete` tells it instead: it fires when a block is removed through its actions menu, with the block and the index it held.
|
|
140
|
-
|
|
141
|
-
It runs just after the `onChange` that reports the document without that block, so by the time your handler runs the new array has already been reported. Restoring is an ordinary document load: splice the block back at its index and supply the result as `blocks`.
|
|
142
|
-
|
|
143
|
-
Do that on a later tick or a later interaction, such as the click of an Undo toast, which is what the sample below does. A restore applied synchronously inside `onDelete` is batched together with the echo of the deletion, so what reaches the canvas is structurally equal to the document it last had from you, which it reads as an unrelated re-render and ignores. Nothing throws; the deletion simply stands.
|
|
144
|
-
|
|
145
|
-
```tsx
|
|
146
|
-
import { useState } from 'react';
|
|
147
|
-
import { BlockCanvas, type QuoinBlock } from 'quoin-editor';
|
|
148
|
-
import 'quoin-editor/styles.css';
|
|
149
|
-
|
|
150
|
-
export function EditorWithUndo({ saved }: { saved: QuoinBlock[] }) {
|
|
151
|
-
const [blocks, setBlocks] = useState<QuoinBlock[]>(saved);
|
|
152
|
-
const [removed, setRemoved] = useState<{ block: QuoinBlock; index: number } | null>(null);
|
|
153
|
-
|
|
154
|
-
function restore() {
|
|
155
|
-
if (!removed) return;
|
|
156
|
-
const next = [...blocks];
|
|
157
|
-
next.splice(removed.index, 0, removed.block);
|
|
158
|
-
setBlocks(next);
|
|
159
|
-
setRemoved(null);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
return (
|
|
163
|
-
<>
|
|
164
|
-
<BlockCanvas blocks={blocks} onChange={setBlocks} onDelete={(block, index) => setRemoved({ block, index })} />
|
|
165
|
-
{removed && (
|
|
166
|
-
<button type="button" onClick={restore}>
|
|
167
|
-
Undo delete
|
|
168
|
-
</button>
|
|
169
|
-
)}
|
|
170
|
-
</>
|
|
171
|
-
);
|
|
172
|
-
}
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
A restored block comes back at rest, even one that was inserted moments before it was deleted, so the undo does not pull the caret out of whatever is being written.
|
|
176
|
-
|
|
177
|
-
## Hoist `blockTypes` and your handlers
|
|
178
|
-
|
|
179
|
-
`BlockCanvas` builds its configuration from `blockTypes`, `upload`, `resolveAssetUrl` and `onError`, and memoises it on their identity. Pass a new array or a new function on every render and the memo is busted, which re-renders every block editor on the page.
|
|
180
|
-
|
|
181
|
-
```tsx
|
|
182
|
-
import { BlockCanvas, coreBlocks, type QuoinBlock } from 'quoin-editor';
|
|
183
|
-
import 'quoin-editor/styles.css';
|
|
184
|
-
|
|
185
|
-
export function SlowEditor({ blocks, onChange }: { blocks: QuoinBlock[]; onChange: (next: QuoinBlock[]) => void }) {
|
|
186
|
-
return (
|
|
187
|
-
<BlockCanvas
|
|
188
|
-
blocks={blocks}
|
|
189
|
-
onChange={onChange}
|
|
190
|
-
blockTypes={[...coreBlocks]}
|
|
191
|
-
onError={(error) => console.error(error)}
|
|
192
|
-
/>
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
Hoist them to module scope instead, or wrap them in `useMemo` and `useCallback` when they depend on props.
|
|
198
|
-
|
|
199
|
-
```tsx
|
|
200
|
-
import { BlockCanvas, coreBlocks, type AnyBlockDefinition, type QuoinBlock } from 'quoin-editor';
|
|
201
|
-
import 'quoin-editor/styles.css';
|
|
202
|
-
|
|
203
|
-
const blockTypes: AnyBlockDefinition[] = [...coreBlocks];
|
|
204
|
-
const reportError = (error: Error) => console.error(error);
|
|
205
|
-
|
|
206
|
-
export function FastEditor({ blocks, onChange }: { blocks: QuoinBlock[]; onChange: (next: QuoinBlock[]) => void }) {
|
|
207
|
-
return <BlockCanvas blocks={blocks} onChange={onChange} blockTypes={blockTypes} onError={reportError} />;
|
|
208
|
-
}
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
## Custom blocks
|
|
212
|
-
|
|
213
|
-
A block type is a definition built with `defineBlock`. `blockTypes` defaults to `coreBlocks`, so extend it by spreading them and adding your own. When two definitions share a `type`, the last one wins, which is also how you override a core block.
|
|
214
|
-
|
|
215
|
-
```tsx
|
|
216
|
-
import { Bookmark } from 'lucide-react';
|
|
217
|
-
import {
|
|
218
|
-
BlockCanvas,
|
|
219
|
-
coreBlocks,
|
|
220
|
-
defineBlock,
|
|
221
|
-
type AnyBlockDefinition,
|
|
222
|
-
type BlockEditorProps,
|
|
223
|
-
type BlockPreviewProps,
|
|
224
|
-
type QuoinBlock,
|
|
225
|
-
} from 'quoin-editor';
|
|
226
|
-
import 'quoin-editor/styles.css';
|
|
227
|
-
|
|
228
|
-
type BookmarkData = { url: string; title: string };
|
|
229
|
-
|
|
230
|
-
function BookmarkEditor({ data, onChange }: BlockEditorProps<BookmarkData>) {
|
|
231
|
-
return (
|
|
232
|
-
<div>
|
|
233
|
-
<input
|
|
234
|
-
aria-label="Title"
|
|
235
|
-
value={data.title}
|
|
236
|
-
onChange={(event) => onChange({ ...data, title: event.target.value })}
|
|
237
|
-
/>
|
|
238
|
-
<input
|
|
239
|
-
aria-label="URL"
|
|
240
|
-
value={data.url}
|
|
241
|
-
onChange={(event) => onChange({ ...data, url: event.target.value })}
|
|
242
|
-
/>
|
|
243
|
-
</div>
|
|
244
|
-
);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function BookmarkPreview({ data }: BlockPreviewProps<BookmarkData>) {
|
|
248
|
-
if (data.url === '') return <p>No bookmark yet</p>;
|
|
249
|
-
return <a href={data.url}>{data.title || data.url}</a>;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const bookmark = defineBlock({
|
|
253
|
-
type: 'BOOKMARK',
|
|
254
|
-
label: 'Bookmark',
|
|
255
|
-
icon: Bookmark,
|
|
256
|
-
editor: BookmarkEditor,
|
|
257
|
-
preview: BookmarkPreview,
|
|
258
|
-
initialData: () => ({ url: '', title: '' }),
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
const blockTypes: AnyBlockDefinition[] = [...coreBlocks, bookmark];
|
|
262
|
-
|
|
263
|
-
export function Editor({ blocks, onChange }: { blocks: QuoinBlock[]; onChange: (next: QuoinBlock[]) => void }) {
|
|
264
|
-
return <BlockCanvas blocks={blocks} onChange={onChange} blockTypes={blockTypes} />;
|
|
265
|
-
}
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
A definition has these fields:
|
|
269
|
-
|
|
270
|
-
| Field | Required | Purpose |
|
|
271
|
-
| --- | --- | --- |
|
|
272
|
-
| `type` | yes | The string stored on each block. It is how the registry finds the definition. |
|
|
273
|
-
| `label` | yes | The name shown in menus and the slash palette. |
|
|
274
|
-
| `icon` | yes | A [lucide](https://lucide.dev) icon component. Quoin depends on `lucide-react@^1`; add that same major to your own dependencies to import icons, so you do not end up with a second copy and mismatched icon types. |
|
|
275
|
-
| `editor` | one of the two | The editing surface. It receives `data` and `onChange`. Call `onChange` with the whole next `data`, not a patch. Omit it for a block that is never editable here. |
|
|
276
|
-
| `preview` | one of the two | What the block shows when it is not being edited. Omit it for an edit-only block. |
|
|
277
|
-
| `initialData` | no | Seeds `data` when a block of this type is inserted. It is checked against the editor's data type. |
|
|
278
|
-
| `inSlashPalette` | no | Set to `false` to keep the type out of the slash palette and the inserter. Defaults to `true`. |
|
|
279
|
-
| `alwaysEditing` | no | Renders the editor permanently instead of a preview you click to edit. |
|
|
280
|
-
|
|
281
|
-
Supply `editor`, `preview` or both. Omitting both is a type error, because such a block could only ever draw its own label.
|
|
282
|
-
|
|
283
|
-
**Omit `editor` for a block that is not yours to edit** here: content that belongs to a server-side record, a generated summary, an audit trail. The card then offers no way in, with no edit button and no click target, and `preview` becomes required because it is the only thing left that can draw the block.
|
|
284
|
-
|
|
285
|
-
```tsx
|
|
286
|
-
import { ShieldCheck } from 'lucide-react';
|
|
287
|
-
import { defineBlock } from 'quoin-editor';
|
|
288
|
-
|
|
289
|
-
const audit = defineBlock<{ approver: string }>({
|
|
290
|
-
type: 'AUDIT',
|
|
291
|
-
label: 'Audit trail',
|
|
292
|
-
icon: ShieldCheck,
|
|
293
|
-
preview: ({ data }) => <p>Signed off by {data.approver}</p>,
|
|
294
|
-
});
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
**Omit `preview` only for a block you never show read-only.** A `readOnly` canvas draws previews and never editors, so such a block shows nothing there but its own label. Quoin warns once per type in the console when that happens, naming the type. The same applies to `alwaysEditing`, which is also an editor and is also not drawn on a read-only canvas.
|
|
298
|
-
|
|
299
|
-
### What the core blocks store
|
|
300
|
-
|
|
301
|
-
Each core block keeps its content under its own keys. A host that persists blocks reads and writes these, so they are exported as types: import them rather than retyping the shapes.
|
|
302
|
-
|
|
303
|
-
| Type | `data` | Exported as |
|
|
304
|
-
| --- | --- | --- |
|
|
305
|
-
| `MARKDOWN` | `markdown: string` | `MarkdownBlockData` |
|
|
306
|
-
| `CALLOUT` | `tone: string`, `body: string` | `CalloutBlockData` |
|
|
307
|
-
| `CODE` | `language: string`, `code: string` | `CodeBlockData` |
|
|
308
|
-
| `IMAGE` | `storageKey: string`, `alt: string`, `caption?: string`, `width?: number`, `height?: number` | `ImageBlockData` |
|
|
309
|
-
| `FILE` | `storageKey: string`, `filename: string`, `size?: number` | `FileBlockData` |
|
|
310
|
-
|
|
311
|
-
`CoreBlockData` maps each `type` string to its shape, for a host writing one function over all five.
|
|
312
|
-
|
|
313
|
-
Two of these are worth saying out loud, because both have been guessed wrong:
|
|
314
|
-
|
|
315
|
-
- **The callout's prose is `body`, not `text`.** The label over the field reads "Callout text", which is what the author sees; `body` is what the block stores. `tone` is one of `info`, `tip`, `warning`, `success` or `danger`, each drawn in its own colour; anything else falls back to the neutral border and fill.
|
|
316
|
-
- **Markdown blocks store markdown source, not rendered HTML.** Quoin edits and stores the source and renders it as plain text; supply a `preview` if you want it rendered.
|
|
317
|
-
|
|
318
|
-
The optional fields are written after a block's first save: an image's `caption` when the author types one, its `width` and `height` once the uploaded bytes have been measured, a file's `size` when the upload reports it. `src/blocks/data.test.ts` reads the keys back out of each editor's source and fails if one drifts from the table above.
|
|
319
|
-
|
|
320
|
-
### Reaching uploads from a custom block
|
|
321
|
-
|
|
322
|
-
A block editor receives only `data` and `onChange`, but the canvas configuration reaches it through context, so an upload control inside a custom block needs no wiring from you.
|
|
323
|
-
|
|
324
|
-
`UploadField` is the control the image and file blocks use, and it is exported for yours. It reads `upload` from the canvas it is rendered in, disables itself while a file is in flight, announces the progress to assistive technology, renders a rejection's message beside the button, and says that uploads are not configured when the host wired none.
|
|
325
|
-
|
|
326
|
-
```tsx
|
|
327
|
-
import { UploadField, type BlockEditorProps } from 'quoin-editor';
|
|
328
|
-
|
|
329
|
-
type SlidesData = { storageKey: string; filename: string };
|
|
330
|
-
|
|
331
|
-
export function SlidesEditor({ data, onChange }: BlockEditorProps<SlidesData>) {
|
|
332
|
-
return (
|
|
333
|
-
<div>
|
|
334
|
-
<p>{data.storageKey === '' ? 'No deck yet' : data.filename}</p>
|
|
335
|
-
<UploadField
|
|
336
|
-
category="slides"
|
|
337
|
-
label="Upload slides"
|
|
338
|
-
accept=".pdf"
|
|
339
|
-
onUploaded={(result) => onChange({ storageKey: result.storageKey, filename: result.filename })}
|
|
340
|
-
/>
|
|
341
|
-
</div>
|
|
342
|
-
);
|
|
343
|
-
}
|
|
344
|
-
```
|
|
345
|
-
|
|
346
|
-
Register that editor with `defineBlock` as above, and the canvas's `upload` function receives `'slides'` as its category. `UploadField` takes `category`, `label`, an optional `accept` and `onUploaded`.
|
|
347
|
-
|
|
348
|
-
`FilePickerButton` is the picker underneath it: a labelled button laid over a transparent file input, which hands you the chosen `File` and uploads nothing itself. Take that one when you want your own busy and error presentation.
|
|
349
|
-
|
|
350
|
-
For anything neither control covers, `useQuoin()` returns `blockTypes`, `upload`, `resolveAssetUrl` and `onError`. It throws outside a `QuoinProvider`, so call it from a block the canvas is rendering.
|
|
351
|
-
|
|
352
|
-
```tsx
|
|
353
|
-
import { useQuoin, type BlockEditorProps } from 'quoin-editor';
|
|
354
|
-
|
|
355
|
-
type AttachmentData = { storageKey: string; filename: string };
|
|
356
|
-
|
|
357
|
-
export function AttachmentEditor({ data, onChange }: BlockEditorProps<AttachmentData>) {
|
|
358
|
-
const { upload, resolveAssetUrl, onError } = useQuoin();
|
|
359
|
-
|
|
360
|
-
async function store(file: File) {
|
|
361
|
-
if (!upload) return;
|
|
362
|
-
try {
|
|
363
|
-
const stored = await upload(file, 'attachment');
|
|
364
|
-
onChange({ storageKey: stored.storageKey, filename: stored.filename });
|
|
365
|
-
} catch (error) {
|
|
366
|
-
onError(error instanceof Error ? error : new Error('The upload failed.'));
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
return (
|
|
371
|
-
<div>
|
|
372
|
-
{data.storageKey !== '' && <a href={resolveAssetUrl(data.storageKey)}>{data.filename}</a>}
|
|
373
|
-
<input
|
|
374
|
-
type="file"
|
|
375
|
-
disabled={!upload}
|
|
376
|
-
onChange={(event) => {
|
|
377
|
-
const file = event.target.files?.[0];
|
|
378
|
-
if (file) void store(file);
|
|
379
|
-
}}
|
|
380
|
-
/>
|
|
381
|
-
</div>
|
|
382
|
-
);
|
|
383
|
-
}
|
|
42
|
+
```json
|
|
43
|
+
[
|
|
44
|
+
{ "id": "a1", "type": "MARKDOWN", "data": { "markdown": "# Release notes" } },
|
|
45
|
+
{ "id": "b2", "type": "MARKDOWN", "data": { "markdown": "- Faster **paste**" } },
|
|
46
|
+
{ "id": "c3", "type": "CALLOUT", "data": { "tone": "tip", "body": "Back up first." } }
|
|
47
|
+
]
|
|
384
48
|
```
|
|
385
49
|
|
|
386
|
-
|
|
50
|
+
## What ships
|
|
387
51
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
```tsx
|
|
391
|
-
import { blockByType, coreBlocks, defineBlock, type AnyBlockDefinition, type BlockPreviewProps } from 'quoin-editor';
|
|
392
|
-
|
|
393
|
-
function RichMarkdownPreview({ data }: BlockPreviewProps) {
|
|
394
|
-
return <article>{String(data.markdown ?? '')}</article>;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
const core = blockByType(coreBlocks, 'MARKDOWN');
|
|
398
|
-
|
|
399
|
-
export const blockTypes: AnyBlockDefinition[] = core
|
|
400
|
-
? [...coreBlocks, defineBlock({ ...core, preview: RichMarkdownPreview })]
|
|
401
|
-
: coreBlocks;
|
|
402
|
-
```
|
|
403
|
-
|
|
404
|
-
Replace the `<article>` with whatever renderer you use.
|
|
405
|
-
|
|
406
|
-
## Uploads
|
|
407
|
-
|
|
408
|
-
Quoin never performs a request. You hand it an `upload` function, and the image and file blocks call it with the chosen `File` and a `category` that names what the file is for (the core blocks use `'image'` and `'file'`). It resolves with an `UploadResult`.
|
|
409
|
-
|
|
410
|
-
```tsx
|
|
411
|
-
import { BlockCanvas, type QuoinBlock, type UploadFn } from 'quoin-editor';
|
|
412
|
-
import 'quoin-editor/styles.css';
|
|
413
|
-
|
|
414
|
-
const upload: UploadFn = async (file, category) => {
|
|
415
|
-
let response: Response;
|
|
416
|
-
try {
|
|
417
|
-
response = await fetch(`/api/uploads?category=${encodeURIComponent(category)}`, {
|
|
418
|
-
method: 'POST',
|
|
419
|
-
body: file,
|
|
420
|
-
});
|
|
421
|
-
} catch {
|
|
422
|
-
throw new Error('Could not reach the server. Check your connection and try again.');
|
|
423
|
-
}
|
|
424
|
-
if (!response.ok) {
|
|
425
|
-
throw new Error(response.status === 413 ? 'That file is too large.' : 'The upload was refused.');
|
|
426
|
-
}
|
|
427
|
-
const stored = (await response.json()) as { key: string };
|
|
428
|
-
return { storageKey: stored.key, filename: file.name, size: file.size };
|
|
429
|
-
};
|
|
430
|
-
|
|
431
|
-
const resolveAssetUrl = (storageKey: string) => `/api/files/${encodeURIComponent(storageKey)}`;
|
|
432
|
-
const reportError = (error: Error) => console.error(error);
|
|
433
|
-
|
|
434
|
-
export function Editor({ blocks, onChange }: { blocks: QuoinBlock[]; onChange: (next: QuoinBlock[]) => void }) {
|
|
435
|
-
return (
|
|
436
|
-
<BlockCanvas
|
|
437
|
-
blocks={blocks}
|
|
438
|
-
onChange={onChange}
|
|
439
|
-
upload={upload}
|
|
440
|
-
resolveAssetUrl={resolveAssetUrl}
|
|
441
|
-
onError={reportError}
|
|
442
|
-
/>
|
|
443
|
-
);
|
|
444
|
-
}
|
|
445
|
-
```
|
|
446
|
-
|
|
447
|
-
- `UploadResult` is `{ storageKey, filename, size, assetId? }`. `storageKey` is an opaque key your storage can find the file by. It is what Quoin stores in the document.
|
|
448
|
-
- `resolveAssetUrl` turns a `storageKey` back into a URL for display. It defaults to returning the key unchanged.
|
|
449
|
-
- **Reject with an `Error` whose `message` is safe to show.** It is rendered verbatim beside the upload button. A rejection that is not an `Error`, or an `Error` with an empty message, shows a generic "Upload failed." instead.
|
|
450
|
-
- **Catch network failures and rethrow with your own wording.** A raw `fetch` rejection such as "Failed to fetch" would otherwise be shown to the user verbatim.
|
|
451
|
-
- The upload control is disabled while an upload is in flight.
|
|
452
|
-
- With no `upload` function, upload fields render disabled, with a note that uploads are not configured.
|
|
453
|
-
- `onError` receives errors Quoin cannot show inline, such as an empty markdown file picked for import. It defaults to `console.error`.
|
|
454
|
-
|
|
455
|
-
## Read-only
|
|
456
|
-
|
|
457
|
-
```tsx
|
|
458
|
-
import { BlockCanvas, type QuoinBlock } from 'quoin-editor';
|
|
459
|
-
import 'quoin-editor/styles.css';
|
|
460
|
-
|
|
461
|
-
const noop = () => {};
|
|
462
|
-
|
|
463
|
-
export function Reader({ blocks }: { blocks: QuoinBlock[] }) {
|
|
464
|
-
return <BlockCanvas blocks={blocks} onChange={noop} readOnly aria-label="Article" />;
|
|
465
|
-
}
|
|
466
|
-
```
|
|
467
|
-
|
|
468
|
-
`readOnly` renders each block's `preview` with no inserters, menus, drag handles or editors. `onChange` is still required by the type, and is never called. `aria-label` names the document for assistive technology, and `className` and `id` go on the root element.
|
|
469
|
-
|
|
470
|
-
Because it draws previews and never editors, a block type registered without a `preview` has nothing to show here but its own label. Quoin warns once per type in the console when that happens, so give every type you render read-only a `preview`. It is not an error channel: `onError` is for the document, this is for the registration, so it goes to the developer and not to the reader.
|
|
471
|
-
|
|
472
|
-
## Theming
|
|
473
|
-
|
|
474
|
-
`quoin-editor/styles.css` is compiled Tailwind CSS v4. Every colour and every radius it draws with comes from a custom property under a `--quoin-` prefix. It ships a default for all of them, on `:root` for light and on `.dark` for dark:
|
|
475
|
-
|
|
476
|
-
```css
|
|
477
|
-
:root {
|
|
478
|
-
--quoin-background: oklch(1 0 0);
|
|
479
|
-
--quoin-foreground: oklch(0.145 0 0);
|
|
480
|
-
--quoin-muted: oklch(0.97 0 0);
|
|
481
|
-
--quoin-muted-foreground: oklch(0.54 0 0);
|
|
482
|
-
--quoin-secondary: oklch(0.97 0 0);
|
|
483
|
-
--quoin-secondary-foreground: oklch(0.205 0 0);
|
|
484
|
-
--quoin-popover: oklch(1 0 0);
|
|
485
|
-
--quoin-popover-foreground: oklch(0.145 0 0);
|
|
486
|
-
--quoin-border: oklch(0.922 0 0);
|
|
487
|
-
--quoin-input: oklch(0.922 0 0);
|
|
488
|
-
--quoin-ring: oklch(0.45 0 0);
|
|
489
|
-
--quoin-primary: oklch(0.205 0 0);
|
|
490
|
-
--quoin-primary-foreground: oklch(0.985 0 0);
|
|
491
|
-
--quoin-accent: oklch(0.97 0 0);
|
|
492
|
-
--quoin-accent-foreground: oklch(0.205 0 0);
|
|
493
|
-
--quoin-destructive: oklch(0.55 0.245 27.325);
|
|
494
|
-
--quoin-info: oklch(0.55 0.17 250);
|
|
495
|
-
--quoin-success: oklch(0.55 0.14 155);
|
|
496
|
-
--quoin-warning: oklch(0.65 0.16 65);
|
|
497
|
-
--quoin-radius: 0.625rem;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
.dark {
|
|
501
|
-
--quoin-background: oklch(0.145 0 0);
|
|
502
|
-
--quoin-foreground: oklch(0.985 0 0);
|
|
503
|
-
--quoin-muted: oklch(0.269 0 0);
|
|
504
|
-
--quoin-muted-foreground: oklch(0.708 0 0);
|
|
505
|
-
--quoin-secondary: oklch(0.269 0 0);
|
|
506
|
-
--quoin-secondary-foreground: oklch(0.985 0 0);
|
|
507
|
-
--quoin-popover: oklch(0.205 0 0);
|
|
508
|
-
--quoin-popover-foreground: oklch(0.985 0 0);
|
|
509
|
-
--quoin-border: oklch(1 0 0 / 10%);
|
|
510
|
-
--quoin-input: oklch(1 0 0 / 15%);
|
|
511
|
-
--quoin-ring: oklch(0.708 0 0);
|
|
512
|
-
--quoin-primary: oklch(0.922 0 0);
|
|
513
|
-
--quoin-primary-foreground: oklch(0.205 0 0);
|
|
514
|
-
--quoin-accent: oklch(0.269 0 0);
|
|
515
|
-
--quoin-accent-foreground: oklch(0.985 0 0);
|
|
516
|
-
--quoin-destructive: oklch(0.704 0.191 22.216);
|
|
517
|
-
--quoin-info: oklch(0.72 0.14 250);
|
|
518
|
-
--quoin-success: oklch(0.74 0.15 155);
|
|
519
|
-
--quoin-warning: oklch(0.79 0.15 65);
|
|
520
|
-
--quoin-radius: 0.625rem;
|
|
521
|
-
}
|
|
522
|
-
```
|
|
523
|
-
|
|
524
|
-
Out of the box that is a neutral grey editor that works in both modes with nothing configured.
|
|
525
|
-
|
|
526
|
-
Those are the only custom properties the stylesheet declares. Apart from them it reads `--tw-*`, which belong to the Tailwind runtime it is compiled with, and the `--radix-*` values Radix writes on its own portalled elements while they are open. Tailwind's theme tokens are imported by reference, so the package resolves `--spacing`, `--text-base`, `--container-md` and the rest at build time and declares none of them on your page.
|
|
527
|
-
|
|
528
|
-
### The prefix arrived in 0.2.0
|
|
529
|
-
|
|
530
|
-
Until 0.2.0 these names were unprefixed: `--background`, `--accent`, `--border` and the rest. That was sold as free theme inheritance, and for a shadcn-style host it was. It also meant Quoin read whatever a host happened to keep under those names.
|
|
531
|
-
|
|
532
|
-
The stock Vite React + TypeScript template is the case that forced the change. It declares `--accent` as a bright purple and `--border` on `:root` with meanings of its own, so a fresh `npm create vite` project that installed Quoin drew the selected slash-palette row on that purple while keeping Quoin's own near-black `--accent-foreground`. The pair measures around 3.2:1, under the 4.5:1 that WCAG AA asks for, with nothing done wrong by the consumer.
|
|
533
|
-
|
|
534
|
-
A fallback form such as `var(--quoin-accent, var(--accent))` does not fix that, because the colliding value is exactly what gets read when only the unprefixed name is set. So the prefix is unconditional, and the inheritance is no longer free. If your theme stopped reaching Quoin when you upgraded, this is why, and the next section is how to hand it over again.
|
|
535
|
-
|
|
536
|
-
### Mapping your own tokens
|
|
537
|
-
|
|
538
|
-
Point the prefixed names at your own in one block on `.quoin`. The canvas root carries that class, and so do the menus, palette, select and dialog Quoin renders into `document.body`, so one rule covers all of them.
|
|
539
|
-
|
|
540
|
-
```css
|
|
541
|
-
.quoin {
|
|
542
|
-
--quoin-background: var(--background);
|
|
543
|
-
--quoin-foreground: var(--foreground);
|
|
544
|
-
--quoin-muted: var(--muted);
|
|
545
|
-
--quoin-muted-foreground: var(--muted-foreground);
|
|
546
|
-
--quoin-secondary: var(--secondary);
|
|
547
|
-
--quoin-secondary-foreground: var(--secondary-foreground);
|
|
548
|
-
--quoin-popover: var(--popover);
|
|
549
|
-
--quoin-popover-foreground: var(--popover-foreground);
|
|
550
|
-
--quoin-border: var(--border);
|
|
551
|
-
--quoin-input: var(--input);
|
|
552
|
-
--quoin-ring: var(--ring);
|
|
553
|
-
--quoin-primary: var(--primary);
|
|
554
|
-
--quoin-primary-foreground: var(--primary-foreground);
|
|
555
|
-
--quoin-accent: var(--accent);
|
|
556
|
-
--quoin-accent-foreground: var(--accent-foreground);
|
|
557
|
-
--quoin-destructive: var(--destructive);
|
|
558
|
-
--quoin-info: var(--info);
|
|
559
|
-
--quoin-success: var(--success);
|
|
560
|
-
--quoin-warning: var(--warning);
|
|
561
|
-
--quoin-radius: var(--radius);
|
|
562
|
-
}
|
|
563
|
-
```
|
|
564
|
-
|
|
565
|
-
`--quoin-info`, `--quoin-success` and `--quoin-warning` arrived in 0.4.0 and are read only by the callout block, for its rule and the wash behind it, never for type. If your own theme has no equivalent, leave those three out of the block and the defaults above stand; the rest of the editor does not touch them.
|
|
566
|
-
|
|
567
|
-
`--quoin-ring` colours the keyboard focus outline, which is solid and drawn at full strength, so it should reach 3:1 against both `--quoin-background` and `--quoin-popover`. A pale value, such as the grey many shadcn themes keep for `--ring`, fails that, so give `--quoin-ring` a value of its own that does.
|
|
568
|
-
|
|
569
|
-
That one block covers both modes: the tokens on the right already flip with your own dark class, and each mapping beats Quoin's `:root` and `.dark` defaults alike. Write it unlayered, or in a layer that sorts after `quoin`, so it outranks those defaults.
|
|
570
|
-
|
|
571
|
-
To change one token rather than adopt a whole theme, declare it on `:root`, and pair it with a `.dark` rule if you theme dark at all. Pinning a token on `:root` alone also beats Quoin's `.dark` value for it, which would freeze that one token in its light form.
|
|
572
|
-
|
|
573
|
-
```css
|
|
574
|
-
:root {
|
|
575
|
-
--quoin-primary: oklch(0.55 0.2 260);
|
|
576
|
-
--quoin-radius: 0.5rem;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
.dark {
|
|
580
|
-
--quoin-primary: oklch(0.72 0.17 260);
|
|
581
|
-
}
|
|
582
|
-
```
|
|
583
|
-
|
|
584
|
-
### Dark mode
|
|
585
|
-
|
|
586
|
-
Dark mode keys on a `.dark` class on an ancestor (for example `<html class="dark">`), not on `prefers-color-scheme`. Quoin redefines its own tokens under `.dark`, and its `dark:` variants follow the same class, so the two cannot disagree when a visitor's OS theme differs from your app's theme.
|
|
587
|
-
|
|
588
|
-
### Resets
|
|
589
|
-
|
|
590
|
-
Quoin does not reset your page. The stylesheet contains no `html`, `body`, heading, list or other element rules, and no universal `*` reset. Its own reset (box sizing, zero margins, unstyled lists and form controls) applies only inside a `.quoin` element: the canvas root, and the menus, palette, select and dialog that Quoin renders into `document.body`, which carry the class themselves. Your headings, paragraphs, lists and buttons keep whatever styles you gave them, or the browser's.
|
|
591
|
-
|
|
592
|
-
All of Quoin's own rules are in cascade layers, so an **unlayered** global reset of yours beats them. A host that ships one (Bootstrap's reboot, or a hand-rolled `* { margin: 0; padding: 0; border: 0 }`) will see Quoin's padding and borders vanish. Tailwind 4's preflight is itself layered and is fine. Either declare your reset in a layer that comes before Quoin's, or scope it away from `.quoin`:
|
|
593
|
-
|
|
594
|
-
```css
|
|
595
|
-
@layer reset, theme, base, components, quoin, utilities;
|
|
596
|
-
|
|
597
|
-
@layer reset {
|
|
598
|
-
* { margin: 0; padding: 0; border: 0; }
|
|
599
|
-
}
|
|
600
|
-
```
|
|
601
|
-
|
|
602
|
-
```css
|
|
603
|
-
*:where(:not(.quoin, .quoin *)) { margin: 0; padding: 0; border: 0; }
|
|
604
|
-
```
|
|
605
|
-
|
|
606
|
-
The layer statement has to come before Quoin's stylesheet is parsed, so put it first in your own CSS or in an inline `<style>` in the document head.
|
|
607
|
-
|
|
608
|
-
### Tailwind, or no Tailwind
|
|
609
|
-
|
|
610
|
-
`quoin-editor/styles.css` is compiled output. Every value in it is already resolved, so it is a plain stylesheet, not a Tailwind plugin, and a project with no Tailwind at all can import it and be done.
|
|
611
|
-
|
|
612
|
-
If you do use Tailwind, both major versions work, and what you have to do differs:
|
|
613
|
-
|
|
614
|
-
| Your setup | What to do |
|
|
52
|
+
| Entry | What it adds |
|
|
615
53
|
| --- | --- |
|
|
616
|
-
|
|
|
617
|
-
|
|
|
618
|
-
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
}
|
|
674
|
-
```
|
|
675
|
-
|
|
676
|
-
`useMarkdownKeymap` and `useAutoGrow` are importable from a React Server Component, because the module that exports them carries no `'use client'` banner. Being hooks, they are callable only from a client component.
|
|
677
|
-
|
|
678
|
-
```tsx
|
|
679
|
-
'use client';
|
|
680
|
-
|
|
681
|
-
import { useState } from 'react';
|
|
682
|
-
import { useAutoGrow, useMarkdownKeymap } from 'quoin-editor/headless';
|
|
683
|
-
|
|
684
|
-
export function Notes() {
|
|
685
|
-
const [text, setText] = useState('');
|
|
686
|
-
const [paletteOpen, setPaletteOpen] = useState(false);
|
|
687
|
-
const ref = useAutoGrow<HTMLTextAreaElement>(text);
|
|
688
|
-
const keymap = useMarkdownKeymap({
|
|
689
|
-
commit: setText,
|
|
690
|
-
onOpenPalette: () => setPaletteOpen(true),
|
|
691
|
-
});
|
|
692
|
-
|
|
693
|
-
return (
|
|
694
|
-
<>
|
|
695
|
-
<textarea
|
|
696
|
-
ref={ref}
|
|
697
|
-
value={text}
|
|
698
|
-
onChange={(event) => setText(event.target.value)}
|
|
699
|
-
{...keymap}
|
|
700
|
-
/>
|
|
701
|
-
{paletteOpen && <p role="status">Open your own command palette here.</p>}
|
|
702
|
-
</>
|
|
703
|
-
);
|
|
704
|
-
}
|
|
705
|
-
```
|
|
706
|
-
|
|
707
|
-
The keymap swallows a `/` typed at the start of a word (at the start of the text, or after a newline, space or tab, outside a code fence) and calls `onOpenPalette` instead, so open a palette of your own there, or insert the slash yourself.
|
|
708
|
-
|
|
709
|
-
Everything in the table is re-exported from the main `quoin-editor` entry too, alongside `TEXT_COMMANDS`, the built-in slash-palette entries. Import from `quoin-editor/headless` when you want the transforms without the components behind them, and from `quoin-editor` when you are already importing `BlockCanvas`.
|
|
710
|
-
|
|
711
|
-
```ts
|
|
712
|
-
import { toggleLinePrefix } from 'quoin-editor';
|
|
713
|
-
|
|
714
|
-
export function heading(text: string, start: number, end: number): string {
|
|
715
|
-
return toggleLinePrefix('h2', text, start, end).value;
|
|
716
|
-
}
|
|
717
|
-
```
|
|
718
|
-
|
|
719
|
-
The main entry carries a `'use client'` banner, so a React Server Component has to import from `quoin-editor/headless` rather than from `quoin-editor`.
|
|
720
|
-
|
|
721
|
-
## Exports
|
|
722
|
-
|
|
723
|
-
From `quoin-editor`: `BlockCanvas`, `QuoinProvider`, `useQuoin`, `defineBlock`, `blockByType`, `paletteBlocks`, `coreBlocks`, `UploadField`, `FilePickerButton`, `TEXT_COMMANDS`, `useBlockList`, `useAutosave`, `useAutoGrow`, and the headless transforms and hooks, with their types (`QuoinBlock`, `UploadFn`, `UploadResult`, `BlockDefinition`, `AnyBlockDefinition`, `BlockEditorProps`, `BlockPreviewProps`, `BlockCanvasProps`, `UploadFieldProps`, `FilePickerButtonProps` and others), and the [core block data shapes](#what-the-core-blocks-store): `MarkdownBlockData`, `CalloutBlockData`, `CodeBlockData`, `ImageBlockData`, `FileBlockData` and `CoreBlockData`.
|
|
724
|
-
|
|
725
|
-
From `quoin-editor/headless`: the transforms and the two hooks listed above, with no UI.
|
|
726
|
-
|
|
727
|
-
From `quoin-editor/styles.css`: the stylesheet.
|
|
728
|
-
|
|
729
|
-
## Accessibility
|
|
730
|
-
|
|
731
|
-
What the test suite checks:
|
|
732
|
-
|
|
733
|
-
- axe runs over the canvas, the block cards, the inserter, the slash palette and the shortcuts reference.
|
|
734
|
-
- Controls carry accessible names, and the icons in the canvas, the block options menu, the inserter and the slash palette are hidden from assistive technology.
|
|
735
|
-
- Blocks reorder by keyboard, with announcements that name each block by label and position.
|
|
736
|
-
- Escape leaves an open block and returns focus to its Edit button.
|
|
737
|
-
- The default light and dark tokens keep the keyboard focus outline at 3:1 or more and highlighted-row text at 4.5:1 or more, and focusable controls draw the focus as an outline, which Windows forced colours mode keeps.
|
|
738
|
-
|
|
739
|
-
The checks are automated. A manual pass with NVDA and VoiceOver is not recorded yet.
|
|
740
|
-
|
|
741
|
-
Known gaps in 0.4:
|
|
742
|
-
|
|
743
|
-
- A markdown block is read as source. At rest it is plain text, so a screen reader reads the markdown characters as written.
|
|
744
|
-
- Keyboard drag with Space and the arrow keys can drop a block in place, and its announcement can name the wrong position. Use Move up and Move down in the block's options menu to reorder by keyboard.
|
|
745
|
-
|
|
746
|
-
## Stability
|
|
747
|
-
|
|
748
|
-
Quoin is 0.x. A minor version may contain breaking changes, and each one is listed in the [changelog](CHANGELOG.md). If you need stability, pin a minor with `~0.4.0`, which takes patch releases only.
|
|
54
|
+
| `quoin-editor` | The canvas: text, lists, tasks, quotes, callouts, toggles, dividers, code, images and files, with every menu and toolbar |
|
|
55
|
+
| `quoin-editor/table`, `/embed`, `/math`, `/toc`, `/mention`, `/emoji`, `/find`, `/stats` | Opt-in features, each its own entry; `quoin-editor/catalogue` adds them all |
|
|
56
|
+
| `quoin-editor/yjs`, `/presence` | Real-time co-editing through your own Yjs provider, and collaborators' carets |
|
|
57
|
+
| `quoin-editor/comments` | Comment threads anchored to text, kept in your store |
|
|
58
|
+
| `quoin-editor/ai` | Your model's answers streamed into the document; Quoin never calls a model |
|
|
59
|
+
| `quoin-editor/view`, `/diff`, `/docx` | Server rendering with no hooks, a diff of two versions, a Word export |
|
|
60
|
+
| `quoin-editor/headless`, `/engine` | The editor without the canvas UI, and the engine alone with no React |
|
|
61
|
+
|
|
62
|
+
## Why Quoin
|
|
63
|
+
|
|
64
|
+
Each competitor cell links to the page that shows it, and Quoin's cite the files listed below the table; the Tiptap UI cell also draws on the review in [#73](https://github.com/AmineSeboui/quoin-editor/issues/73) (2026-10-12).
|
|
65
|
+
|
|
66
|
+
| Feature | Quoin 1.0 | Tiptap 3 | BlockNote | Editor.js |
|
|
67
|
+
| --- | --- | --- | --- | --- |
|
|
68
|
+
| Stored document | Block array, markdown per text block | [JSON, or HTML][tt-store] | [Block JSON][bn-formats] | [JSON][ej-repo] |
|
|
69
|
+
| Markdown | The storage format | [`@tiptap/markdown`, an early release][tt-md] | [Export and import, documented as lossy][bn-formats] | [Community plugin][ej-awesome] |
|
|
70
|
+
| Notion-style UI | Included, MIT | [MIT menus and drag handle][tt-review]; [Notion-like template on a paid plan][tt-notion] | [Included][bn-home] | [Toolbox and inline toolbar][ej-home] |
|
|
71
|
+
| Licence | MIT | [MIT core, paid Pro extensions][tt-repo] | [MPL-2.0, `xl-*` packages GPL-3.0 or commercial][bn-repo] | [Apache-2.0][ej-repo] |
|
|
72
|
+
| Real-time collaboration | Yjs, opt-in entry | [Yjs][tt-collab] | [Yjs][bn-collab] | [On its roadmap][ej-repo] |
|
|
73
|
+
| Comments | Opt-in, your store | [Paid plan][tt-comments] | [Built in, on Yjs or a thread store][bn-comments] | [Community plugin][ej-awesome] |
|
|
74
|
+
| Framework | React | [React, Vue, Svelte, plain JavaScript and more][tt-install] | [React][bn-start] | [Plain JavaScript][ej-start] |
|
|
75
|
+
|
|
76
|
+
Sources, checked on 2026-10-23: Tiptap [persistence][tt-store], [markdown][tt-md], [Notion-like template][tt-notion], [repository][tt-repo], [collaboration][tt-collab], [comments][tt-comments], [install][tt-install]; BlockNote [home][bn-home], [formats][bn-formats], [repository][bn-repo], [collaboration][bn-collab], [comments][bn-comments], [getting started][bn-start]; Editor.js [home][ej-home], [repository][ej-repo], [getting started][ej-start], [awesome-editorjs][ej-awesome]. Quoin: storage in `src/engine/blocks/markdown-spec.ts`, untouched blocks in `src/engine/blocks/codec.test.ts`, collaboration in `e2e/tests/canvas/presence.spec.ts`, comments in `e2e/tests/canvas/comments.spec.ts`.
|
|
77
|
+
|
|
78
|
+
[tt-store]: https://tiptap.dev/docs/editor/core-concepts/persistence
|
|
79
|
+
[tt-md]: https://tiptap.dev/docs/editor/markdown
|
|
80
|
+
[tt-review]: https://github.com/AmineSeboui/quoin-editor/issues/73
|
|
81
|
+
[tt-notion]: https://tiptap.dev/docs/ui-components/templates/notion-like-editor
|
|
82
|
+
[tt-repo]: https://github.com/ueberdosis/tiptap
|
|
83
|
+
[tt-collab]: https://tiptap.dev/docs/editor/extensions/functionality/collaboration
|
|
84
|
+
[tt-comments]: https://tiptap.dev/docs/comments/getting-started/overview
|
|
85
|
+
[tt-install]: https://tiptap.dev/docs/editor/getting-started/install
|
|
86
|
+
[bn-home]: https://www.blocknotejs.org/
|
|
87
|
+
[bn-formats]: https://www.blocknotejs.org/docs/foundations/supported-formats
|
|
88
|
+
[bn-repo]: https://github.com/TypeCellOS/BlockNote
|
|
89
|
+
[bn-collab]: https://www.blocknotejs.org/docs/features/collaboration
|
|
90
|
+
[bn-comments]: https://www.blocknotejs.org/docs/features/collaboration/comments
|
|
91
|
+
[bn-start]: https://www.blocknotejs.org/docs/getting-started
|
|
92
|
+
[ej-home]: https://editorjs.io/
|
|
93
|
+
[ej-repo]: https://github.com/codex-team/editor.js
|
|
94
|
+
[ej-start]: https://editorjs.io/getting-started
|
|
95
|
+
[ej-awesome]: https://github.com/editor-js/awesome-editorjs
|
|
96
|
+
|
|
97
|
+
## Learn more
|
|
98
|
+
|
|
99
|
+
- [The controlled contract](https://amineseboui.github.io/quoin-editor/docs/contract/): loading, autosave, discarding, undoing a deletion
|
|
100
|
+
- [Theming](https://amineseboui.github.io/quoin-editor/docs/theming/): `--quoin-` tokens, dark mode, cascade layers, Tailwind
|
|
101
|
+
- [Custom blocks](https://amineseboui.github.io/quoin-editor/docs/custom-blocks/) and [uploads](https://amineseboui.github.io/quoin-editor/docs/uploads/)
|
|
102
|
+
- [Extensions](https://amineseboui.github.io/quoin-editor/docs/extensions/): every opt-in feature, and writing your own
|
|
103
|
+
- [Collaboration](https://amineseboui.github.io/quoin-editor/docs/collaboration/), [comments](https://amineseboui.github.io/quoin-editor/docs/comments/) and [AI](https://amineseboui.github.io/quoin-editor/docs/ai/)
|
|
104
|
+
- [Server rendering](https://amineseboui.github.io/quoin-editor/docs/server-rendering/): read-only, `QuoinView`, `QuoinDiff`, `toDocx`
|
|
105
|
+
- [Accessibility](https://amineseboui.github.io/quoin-editor/docs/accessibility/): what each test suite checks, and what it does not yet
|
|
106
|
+
- [FAQ](https://amineseboui.github.io/quoin-editor/docs/faq/): the package name, stability, React versions, and [upgrading from 0.4](MIGRATION.md)
|
|
107
|
+
|
|
108
|
+
## The name
|
|
109
|
+
|
|
110
|
+
A quoin is the wedge a letterpress printer tightens to lock blocks of type into the frame, so nothing moves while the page is printed. Quoin does the same for blocks of markdown.
|
|
749
111
|
|
|
750
112
|
## Licence
|
|
751
113
|
|