quoin-editor 0.4.2 → 1.0.0

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.
Files changed (231) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/MIGRATION.md +233 -0
  3. package/README.md +78 -716
  4. package/dist/ai.cjs +1107 -0
  5. package/dist/ai.cjs.map +1 -0
  6. package/dist/ai.d.cts +68 -0
  7. package/dist/ai.d.ts +68 -0
  8. package/dist/ai.js +1107 -0
  9. package/dist/ai.js.map +1 -0
  10. package/dist/catalogue.cjs +59 -0
  11. package/dist/catalogue.cjs.map +1 -0
  12. package/dist/catalogue.d.cts +39 -0
  13. package/dist/catalogue.d.ts +39 -0
  14. package/dist/catalogue.js +59 -0
  15. package/dist/catalogue.js.map +1 -0
  16. package/dist/chunk-2QCRZ7OL.cjs +319 -0
  17. package/dist/chunk-2QCRZ7OL.cjs.map +1 -0
  18. package/dist/chunk-2VDK27IF.cjs +6666 -0
  19. package/dist/chunk-2VDK27IF.cjs.map +1 -0
  20. package/dist/chunk-47MLKSI2.js +326 -0
  21. package/dist/chunk-47MLKSI2.js.map +1 -0
  22. package/dist/chunk-5DNCCN6X.cjs +326 -0
  23. package/dist/chunk-5DNCCN6X.cjs.map +1 -0
  24. package/dist/chunk-AEJXZBVR.cjs +1378 -0
  25. package/dist/chunk-AEJXZBVR.cjs.map +1 -0
  26. package/dist/chunk-CDELOHDY.cjs +239 -0
  27. package/dist/chunk-CDELOHDY.cjs.map +1 -0
  28. package/dist/chunk-E75EHRPA.cjs +175 -0
  29. package/dist/chunk-E75EHRPA.cjs.map +1 -0
  30. package/dist/chunk-EL2OEJQC.cjs +562 -0
  31. package/dist/chunk-EL2OEJQC.cjs.map +1 -0
  32. package/dist/chunk-GLLXHMH2.cjs +25 -0
  33. package/dist/chunk-GLLXHMH2.cjs.map +1 -0
  34. package/dist/chunk-GTTPVJSD.cjs +3286 -0
  35. package/dist/chunk-GTTPVJSD.cjs.map +1 -0
  36. package/dist/chunk-HELJTLNC.cjs +100 -0
  37. package/dist/chunk-HELJTLNC.cjs.map +1 -0
  38. package/dist/chunk-HJIQT3S5.js +4047 -0
  39. package/dist/chunk-HJIQT3S5.js.map +1 -0
  40. package/dist/chunk-HZSSEYJS.cjs +504 -0
  41. package/dist/chunk-HZSSEYJS.cjs.map +1 -0
  42. package/dist/chunk-JDPTZUBW.js +184 -0
  43. package/dist/chunk-JDPTZUBW.js.map +1 -0
  44. package/dist/chunk-JEIFJHEI.js +14 -0
  45. package/dist/chunk-JEIFJHEI.js.map +1 -0
  46. package/dist/chunk-K574XHMM.js +25 -0
  47. package/dist/chunk-K574XHMM.js.map +1 -0
  48. package/dist/chunk-KAVU6T6J.js +249 -0
  49. package/dist/chunk-KAVU6T6J.js.map +1 -0
  50. package/dist/chunk-KXOBZGQO.js +1378 -0
  51. package/dist/chunk-KXOBZGQO.js.map +1 -0
  52. package/dist/chunk-LCQSG4M6.js +3335 -0
  53. package/dist/chunk-LCQSG4M6.js.map +1 -0
  54. package/dist/chunk-MQ2XQ4MX.js +100 -0
  55. package/dist/chunk-MQ2XQ4MX.js.map +1 -0
  56. package/dist/chunk-ND5XJVK2.cjs +3335 -0
  57. package/dist/chunk-ND5XJVK2.cjs.map +1 -0
  58. package/dist/chunk-NNDYYEW5.js +504 -0
  59. package/dist/chunk-NNDYYEW5.js.map +1 -0
  60. package/dist/chunk-NOKWNK2O.js +319 -0
  61. package/dist/chunk-NOKWNK2O.js.map +1 -0
  62. package/dist/chunk-OMOBMXD5.cjs +57 -0
  63. package/dist/chunk-OMOBMXD5.cjs.map +1 -0
  64. package/dist/chunk-PMWCQSQX.js +239 -0
  65. package/dist/chunk-PMWCQSQX.js.map +1 -0
  66. package/dist/chunk-POBM7MGR.js +30 -0
  67. package/dist/chunk-POBM7MGR.js.map +1 -0
  68. package/dist/chunk-PWUEPDA3.cjs +598 -0
  69. package/dist/chunk-PWUEPDA3.cjs.map +1 -0
  70. package/dist/chunk-PZ4GT5JO.cjs +4047 -0
  71. package/dist/chunk-PZ4GT5JO.cjs.map +1 -0
  72. package/dist/chunk-QAW5NH6S.cjs +14 -0
  73. package/dist/chunk-QAW5NH6S.cjs.map +1 -0
  74. package/dist/chunk-RDG4VI6E.cjs +706 -0
  75. package/dist/chunk-RDG4VI6E.cjs.map +1 -0
  76. package/dist/chunk-RUDWMKYX.js +562 -0
  77. package/dist/chunk-RUDWMKYX.js.map +1 -0
  78. package/dist/chunk-SG37UBDO.js +706 -0
  79. package/dist/chunk-SG37UBDO.js.map +1 -0
  80. package/dist/chunk-SLVDAHHK.cjs +30 -0
  81. package/dist/chunk-SLVDAHHK.cjs.map +1 -0
  82. package/dist/chunk-T7SW6OWV.js +598 -0
  83. package/dist/chunk-T7SW6OWV.js.map +1 -0
  84. package/dist/chunk-TC4DARU2.js +175 -0
  85. package/dist/chunk-TC4DARU2.js.map +1 -0
  86. package/dist/chunk-WQHV3MRE.cjs +184 -0
  87. package/dist/chunk-WQHV3MRE.cjs.map +1 -0
  88. package/dist/chunk-WXMYDXPN.cjs +33 -0
  89. package/dist/chunk-WXMYDXPN.cjs.map +1 -0
  90. package/dist/chunk-XEBWC7ZL.js +57 -0
  91. package/dist/chunk-XEBWC7ZL.js.map +1 -0
  92. package/dist/chunk-ZC24WB7Y.cjs +249 -0
  93. package/dist/chunk-ZC24WB7Y.cjs.map +1 -0
  94. package/dist/chunk-ZGZEJCRV.js +6666 -0
  95. package/dist/chunk-ZGZEJCRV.js.map +1 -0
  96. package/dist/chunk-ZHOSSWUM.js +33 -0
  97. package/dist/chunk-ZHOSSWUM.js.map +1 -0
  98. package/dist/chunk-ZS2WA357.js +3286 -0
  99. package/dist/chunk-ZS2WA357.js.map +1 -0
  100. package/dist/comments.cjs +993 -0
  101. package/dist/comments.cjs.map +1 -0
  102. package/dist/comments.d.cts +88 -0
  103. package/dist/comments.d.ts +88 -0
  104. package/dist/comments.js +993 -0
  105. package/dist/comments.js.map +1 -0
  106. package/dist/diff.cjs +343 -0
  107. package/dist/diff.cjs.map +1 -0
  108. package/dist/diff.d.cts +67 -0
  109. package/dist/diff.d.ts +67 -0
  110. package/dist/diff.js +343 -0
  111. package/dist/diff.js.map +1 -0
  112. package/dist/docx.cjs +854 -0
  113. package/dist/docx.cjs.map +1 -0
  114. package/dist/docx.d.cts +27 -0
  115. package/dist/docx.d.ts +27 -0
  116. package/dist/docx.js +854 -0
  117. package/dist/docx.js.map +1 -0
  118. package/dist/embed-provider-jXwlRmnp.d.cts +19 -0
  119. package/dist/embed-provider-jXwlRmnp.d.ts +19 -0
  120. package/dist/embed.cjs +17 -0
  121. package/dist/embed.cjs.map +1 -0
  122. package/dist/embed.d.cts +56 -0
  123. package/dist/embed.d.ts +56 -0
  124. package/dist/embed.js +17 -0
  125. package/dist/embed.js.map +1 -0
  126. package/dist/emoji-data.cjs +1968 -0
  127. package/dist/emoji-data.cjs.map +1 -0
  128. package/dist/emoji-data.js +1968 -0
  129. package/dist/emoji-data.js.map +1 -0
  130. package/dist/emoji-extension-C12gDPm6.d.cts +46 -0
  131. package/dist/emoji-extension-DpQ43HjE.d.ts +46 -0
  132. package/dist/emoji.cjs +18 -0
  133. package/dist/emoji.cjs.map +1 -0
  134. package/dist/emoji.d.cts +31 -0
  135. package/dist/emoji.d.ts +31 -0
  136. package/dist/emoji.js +18 -0
  137. package/dist/emoji.js.map +1 -0
  138. package/dist/engine.cjs +119 -0
  139. package/dist/engine.cjs.map +1 -0
  140. package/dist/engine.d.cts +144 -0
  141. package/dist/engine.d.ts +144 -0
  142. package/dist/engine.js +119 -0
  143. package/dist/engine.js.map +1 -0
  144. package/dist/find-extension-DKqLLSuP.d.ts +41 -0
  145. package/dist/find-extension-_sbZEt3R.d.cts +41 -0
  146. package/dist/find.cjs +20 -0
  147. package/dist/find.cjs.map +1 -0
  148. package/dist/find.d.cts +44 -0
  149. package/dist/find.d.ts +44 -0
  150. package/dist/find.js +20 -0
  151. package/dist/find.js.map +1 -0
  152. package/dist/headless.cjs +151 -518
  153. package/dist/headless.cjs.map +1 -1
  154. package/dist/headless.d.cts +102 -69
  155. package/dist/headless.d.ts +102 -69
  156. package/dist/headless.js +149 -467
  157. package/dist/headless.js.map +1 -1
  158. package/dist/index-CgD72kHB.d.cts +65 -0
  159. package/dist/index-DNYbTK3w.d.ts +65 -0
  160. package/dist/index.cjs +183 -2753
  161. package/dist/index.cjs.map +1 -1
  162. package/dist/index.d.cts +345 -253
  163. package/dist/index.d.ts +345 -253
  164. package/dist/index.js +168 -2696
  165. package/dist/index.js.map +1 -1
  166. package/dist/math.cjs +18 -0
  167. package/dist/math.cjs.map +1 -0
  168. package/dist/math.d.cts +49 -0
  169. package/dist/math.d.ts +49 -0
  170. package/dist/math.js +18 -0
  171. package/dist/math.js.map +1 -0
  172. package/dist/mention.cjs +18 -0
  173. package/dist/mention.cjs.map +1 -0
  174. package/dist/mention.d.cts +50 -0
  175. package/dist/mention.d.ts +50 -0
  176. package/dist/mention.js +18 -0
  177. package/dist/mention.js.map +1 -0
  178. package/dist/presence.cjs +565 -0
  179. package/dist/presence.cjs.map +1 -0
  180. package/dist/presence.d.cts +30 -0
  181. package/dist/presence.d.ts +30 -0
  182. package/dist/presence.js +565 -0
  183. package/dist/presence.js.map +1 -0
  184. package/dist/registry-CWBOSAHm.d.cts +121 -0
  185. package/dist/registry-CWBOSAHm.d.ts +121 -0
  186. package/dist/stats-extension-Bly88uio.d.ts +40 -0
  187. package/dist/stats-extension-CJPijvUb.d.cts +40 -0
  188. package/dist/stats.cjs +19 -0
  189. package/dist/stats.cjs.map +1 -0
  190. package/dist/stats.d.cts +19 -0
  191. package/dist/stats.d.ts +19 -0
  192. package/dist/stats.js +19 -0
  193. package/dist/stats.js.map +1 -0
  194. package/dist/styles.css +1 -1
  195. package/dist/table-extension-BTLxFy2S.d.cts +57 -0
  196. package/dist/table-extension-Dzf4k6hI.d.ts +57 -0
  197. package/dist/table.cjs +50 -0
  198. package/dist/table.cjs.map +1 -0
  199. package/dist/table.d.cts +57 -0
  200. package/dist/table.d.ts +57 -0
  201. package/dist/table.js +50 -0
  202. package/dist/table.js.map +1 -0
  203. package/dist/to-html-C5t7xyaM.d.ts +21 -0
  204. package/dist/to-html-CNCITCln.d.cts +21 -0
  205. package/dist/toc-extension-B3-s6h2x.d.cts +25 -0
  206. package/dist/toc-extension-DWm5alVj.d.ts +25 -0
  207. package/dist/toc.cjs +18 -0
  208. package/dist/toc.cjs.map +1 -0
  209. package/dist/toc.d.cts +24 -0
  210. package/dist/toc.d.ts +24 -0
  211. package/dist/toc.js +18 -0
  212. package/dist/toc.js.map +1 -0
  213. package/dist/types-Cz7XJLaK.d.cts +366 -0
  214. package/dist/types-DULc8wwg.d.cts +120 -0
  215. package/dist/types-DULc8wwg.d.ts +120 -0
  216. package/dist/types-a1CRBlEh.d.ts +366 -0
  217. package/dist/view-controller-DSHw0ltQ.d.cts +118 -0
  218. package/dist/view-controller-UbpzblGl.d.ts +118 -0
  219. package/dist/view.cjs +23 -0
  220. package/dist/view.cjs.map +1 -0
  221. package/dist/view.d.cts +40 -0
  222. package/dist/view.d.ts +40 -0
  223. package/dist/view.js +23 -0
  224. package/dist/view.js.map +1 -0
  225. package/dist/yjs.cjs +1026 -0
  226. package/dist/yjs.cjs.map +1 -0
  227. package/dist/yjs.d.cts +22 -0
  228. package/dist/yjs.d.ts +22 -0
  229. package/dist/yjs.js +1026 -0
  230. package/dist/yjs.js.map +1 -0
  231. package/package.json +217 -18
package/README.md CHANGED
@@ -1,15 +1,20 @@
1
1
  # Quoin
2
2
 
3
- [![npm](https://img.shields.io/npm/v/quoin-editor)](https://www.npmjs.com/package/quoin-editor)
4
- [![CI](https://github.com/AmineSeboui/quoin-editor/actions/workflows/ci.yml/badge.svg)](https://github.com/AmineSeboui/quoin-editor/actions/workflows/ci.yml)
3
+ **Blocks on screen. Markdown on disk.**
5
4
 
6
- A React block editor: typed blocks, drag to reorder, a markdown surface with a slash palette, and an open block type registry.
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
- Quoin edits markdown. It does not render it. A markdown block is edited in a plain textarea with shortcuts, list continuation and a slash palette, and it is drawn at rest as plain text. There is no remark or rehype pipeline in the package. A host that wants rich rendering registers its own `preview` on a block definition (see [Custom blocks](#custom-blocks)), so Quoin never forces a markdown renderer on you.
7
+ [![npm](https://img.shields.io/npm/v/quoin-editor)](https://www.npmjs.com/package/quoin-editor) [![CI](https://github.com/AmineSeboui/quoin-editor/actions/workflows/ci.yml/badge.svg)](https://github.com/AmineSeboui/quoin-editor/actions/workflows/ci.yml) [![licence](https://img.shields.io/npm/l/quoin-editor)](LICENSE)
9
8
 
10
- Documentation: [amineseboui.github.io/quoin-editor](https://amineseboui.github.io/quoin-editor). Package: [`quoin-editor` on npm](https://www.npmjs.com/package/quoin-editor).
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
- Quoin is published on npm as `quoin-editor`, because npm rejects the bare name `quoin` as too similar to an existing package. Everywhere below, `quoin-editor` is the package you install and import.
11
+ ![A heading typed with a hash and a space, a bulleted list, two words made bold from the toolbar, a callout inserted from the slash menu and dragged into place, while the panel beside shows the markdown onChange hands back](demo/public/quoin-demo.gif)
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
- ```bash
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
- A document is an array of `QuoinBlock`, each `{ id, type, data }`. Five block types ship by default: `MARKDOWN`, `CALLOUT`, `CODE`, `IMAGE` and `FILE`. The main entry point is marked `'use client'`, so render `BlockCanvas` from a client component.
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
- When you echo `onChange` back into `blocks`, as every sample above does, the last array you supplied is the edited document, so `setBlocks(saved)` differs from it and resets the canvas with nothing more to do.
107
-
108
- The case that needs care is a host that does not echo edits back. There, the last array you supplied is still the original, and an array structurally equal to it is ignored: a "discard changes" button that re-supplies the original document does nothing. The same goes for restoring a copy of a state from the last 64 edits, which reads as a late echo of the canvas's own output. To force a reset, remount the canvas with a React `key`. The sample below uses a `key` so it works either way.
109
-
110
- ```tsx
111
- import { useState } from 'react';
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
- `upload` is absent when the host wired none, which is why the control above disables itself rather than failing on the pick. `resolveAssetUrl` defaults to returning the key unchanged, and `onError` to `console.error`.
50
+ ## What ships
387
51
 
388
- The preview is also how you bring your own markdown renderer. Register a `MARKDOWN` definition last, reuse the core editor, and swap the preview:
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
- | **Tailwind v4** | Name the layer order and import Quoin into it, as [below](#cascade-layers-and-import-order). Without it Quoin's layer sorts last and its utilities beat yours. |
617
- | **Tailwind v3** | Nothing. v3 emits its own utilities unlayered, and an unlayered rule beats a layered one whatever the order, so yours win over Quoin's by construction. |
618
- | **No Tailwind** | Nothing. Import the stylesheet and the editor is styled. |
619
-
620
- **On Tailwind v3, 0.2.0 is the first version that builds at all.** 0.1.1 and earlier emitted bare `@layer base` and `@layer utilities`, which the v3 PostCSS plugin refuses two different ways: importing the stylesheet from a component raises `'@layer base' is used but no matching '@tailwind base' directive is present`, and `@import`ing it from a stylesheet that does carry the `@tailwind` directives raises `TypeError: layerRule.nodes is not iterable`. Nesting everything under `quoin` in 0.2.0 removed both. If you worked around this by copying the stylesheet into `public/`, you can stop.
621
-
622
- ### Cascade layers and import order
623
-
624
- Everything the stylesheet paints is inside one top-level cascade layer called `quoin`, with nested layers for its own theme, base and utilities inside it. Nothing it ships lands in a bare `theme`, `base`, `components` or `utilities` layer. It carries only the utilities Quoin's own components use, so the set of class names it can collide with at all is small and fixed.
625
-
626
- That changed in 0.2.0, and it had to. Until then the package imported Tailwind's utilities into the bare `utilities` layer, so in a Tailwind host they appended into the host's own `utilities` layer, after the host's rules, and beat them on source order. One bare `.hidden { display: none }` from the package was enough to keep every element carrying `hidden md:block` hidden at every width, across the whole application.
627
-
628
- The package cannot settle this on its own, because a browser orders layers by first appearance. A host whose own Tailwind is parsed first registers `theme`, `base`, `components` and `utilities` before `quoin` appears, so `quoin` sorts last and still wins. Name the order yourself, as the first thing in your stylesheet, and import Quoin into it:
629
-
630
- ```css
631
- @layer theme, base, components, quoin, utilities;
632
- @import "quoin-editor/styles.css" layer(quoin);
633
- ```
634
-
635
- With `quoin` named before `utilities`, your own utilities outrank Quoin's, including the responsive ones, while Quoin still outranks your `components`.
636
-
637
- Naming it first instead puts every layer of yours above the package:
638
-
639
- ```css
640
- @layer quoin, theme, base, components, utilities;
641
- @import "quoin-editor/styles.css" layer(quoin);
642
- ```
643
-
644
- Layer position settles more than which utility wins. It also settles who owns a custom property both of you declare on `:root`, which is why it is worth deciding rather than inheriting. Quoin no longer declares any name Tailwind owns, because it imports Tailwind's theme by reference, but a package that does, including Quoin before 0.2.0, resizes every `p-4` on the page from whichever layer sorts last.
645
-
646
- **Upgrading alone does not fix this.** Import 0.2.0 the way 0.1.1 was imported, with no layer statement of your own, and a host's `hidden md:block` still computes to `display: none`, exactly as before. The layer the package ships is what makes the fix available to you; the two lines above are what apply it.
647
-
648
- Importing the stylesheet from a root layout, or with a plain `import 'quoin-editor/styles.css'` in JavaScript, gives you no say in where the layer lands. The bundler decides, and in a Tailwind host that usually means Quoin's layer sorts last. That is fine for an application with no Tailwind of its own, and it is what the samples in this README do for brevity. A Tailwind host should use the two lines above and drop the JavaScript import.
649
-
650
- ## Headless
651
-
652
- `quoin-editor/headless` is the text-editing core with no UI in it: pure transforms over a string and a selection, plus two hooks. It has no component or UI library dependency, and its bundle carries no `'use client'` banner, so the pure functions can be imported from a React Server Component.
653
-
654
- | Export | What it does |
655
- | --- | --- |
656
- | `wrapSelection`, `linkPaste` | Bold, italic, inline code and link wrapping, and pasting a URL over a selection. |
657
- | `toggleLinePrefix` | Heading, bullet and ordered list prefixes on the selected lines. |
658
- | `continueList`, `indentList` | List continuation on Enter, climbing out on an empty item, and Tab indentation. |
659
- | `insertCommand` | Inserts a slash-palette command at the caret. |
660
- | `shouldOpenPalette`, `lineAt` | Slash trigger detection and line lookup. |
661
- | `applyEdit` | Writes an `Edit` into a textarea through the native undo stack so Ctrl+Z keeps working. |
662
- | `formatShortcut`, `caretRect` | A platform-aware shortcut label, and the caret's pixel position in a textarea. |
663
- | `useMarkdownKeymap` | The keyboard and paste handlers that wire the above onto a textarea. |
664
- | `useAutoGrow` | A ref that makes a textarea's height follow its content. |
665
-
666
- The text transforms return an `Edit`, `{ value, selectionStart, selectionEnd }`, or `null` when they do not apply.
667
-
668
- ```ts
669
- import { wrapSelection } from 'quoin-editor/headless';
670
-
671
- export function bold(text: string, start: number, end: number): string {
672
- return wrapSelection('bold', text, start, end).value;
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