quoin-editor 0.0.0-stage → 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Amine Sboui
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,441 @@
1
- # Temporary Holding Version
1
+ # Quoin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![CI](https://github.com/AmineSeboui/quoin/actions/workflows/ci.yml/badge.svg)](https://github.com/AmineSeboui/quoin/actions/workflows/ci.yml)
4
+
5
+ A React block editor: typed blocks, drag to reorder, a markdown surface with a slash palette, and an open block type registry.
6
+
7
+ 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.
8
+
9
+ Documentation: [amineseboui.github.io/quoin](https://amineseboui.github.io/quoin)
10
+
11
+ 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.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install quoin-editor
17
+ ```
18
+
19
+ ```bash
20
+ yarn add quoin-editor
21
+ ```
22
+
23
+ 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.
24
+
25
+ ## Quickstart
26
+
27
+ ```tsx
28
+ import { useState } from 'react';
29
+ import { BlockCanvas, type QuoinBlock } from 'quoin-editor';
30
+ import 'quoin-editor/styles.css';
31
+
32
+ export function Editor() {
33
+ const [blocks, setBlocks] = useState<QuoinBlock[]>([]);
34
+ return <BlockCanvas blocks={blocks} onChange={setBlocks} />;
35
+ }
36
+ ```
37
+
38
+ 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.
39
+
40
+ ## The controlled-component contract
41
+
42
+ `BlockCanvas` behaves like a controlled `<input value onChange>`. Update `blocks` synchronously from `onChange`.
43
+
44
+ If you apply the edit late, typing reverts. A debounce, a `startTransition`, or an async store all break the contract for the same reason a debounced parent breaks a controlled input: the canvas renders the keystroke, your state has not caught up, and the next render hands the old document back.
45
+
46
+ ```tsx
47
+ import { startTransition, useState } from 'react';
48
+ import { BlockCanvas, type QuoinBlock } from 'quoin-editor';
49
+ import 'quoin-editor/styles.css';
50
+
51
+ export function BrokenEditor() {
52
+ const [blocks, setBlocks] = useState<QuoinBlock[]>([]);
53
+ return (
54
+ <BlockCanvas
55
+ blocks={blocks}
56
+ onChange={(next) => startTransition(() => setBlocks(next))}
57
+ />
58
+ );
59
+ }
60
+ ```
61
+
62
+ 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.
63
+
64
+ ```tsx
65
+ import { useState } from 'react';
66
+ import { BlockCanvas, useAutosave, type QuoinBlock } from 'quoin-editor';
67
+ import 'quoin-editor/styles.css';
68
+
69
+ async function saveDocument(blocks: QuoinBlock[]): Promise<void> {
70
+ await fetch('/api/document', { method: 'PUT', body: JSON.stringify(blocks) });
71
+ }
72
+
73
+ export function AutosavingEditor({ initial }: { initial: QuoinBlock[] }) {
74
+ const [blocks, setBlocks] = useState<QuoinBlock[]>(initial);
75
+ const { status } = useAutosave({
76
+ deps: [blocks],
77
+ build: () => blocks,
78
+ save: saveDocument,
79
+ debounceMs: 1000,
80
+ });
81
+ return (
82
+ <>
83
+ <BlockCanvas blocks={blocks} onChange={setBlocks} />
84
+ <p role="status">{status}</p>
85
+ </>
86
+ );
87
+ }
88
+ ```
89
+
90
+ ### Loading, undoing and discarding
91
+
92
+ 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.
93
+
94
+ 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.
95
+
96
+ 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. To force a reset, remount the canvas with a React `key`. The sample below uses a `key` so it works either way.
97
+
98
+ ```tsx
99
+ import { useState } from 'react';
100
+ import { BlockCanvas, type QuoinBlock } from 'quoin-editor';
101
+ import 'quoin-editor/styles.css';
102
+
103
+ export function EditorWithDiscard({ saved }: { saved: QuoinBlock[] }) {
104
+ const [blocks, setBlocks] = useState<QuoinBlock[]>(saved);
105
+ const [revision, setRevision] = useState(0);
106
+
107
+ function discard() {
108
+ setBlocks(saved);
109
+ setRevision((current) => current + 1);
110
+ }
111
+
112
+ return (
113
+ <>
114
+ <BlockCanvas key={revision} blocks={blocks} onChange={setBlocks} />
115
+ <button type="button" onClick={discard}>
116
+ Discard changes
117
+ </button>
118
+ </>
119
+ );
120
+ }
121
+ ```
122
+
123
+ 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.
124
+
125
+ ## Hoist `blockTypes` and your handlers
126
+
127
+ `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.
128
+
129
+ ```tsx
130
+ import { BlockCanvas, coreBlocks, type QuoinBlock } from 'quoin-editor';
131
+ import 'quoin-editor/styles.css';
132
+
133
+ export function SlowEditor({ blocks, onChange }: { blocks: QuoinBlock[]; onChange: (next: QuoinBlock[]) => void }) {
134
+ return (
135
+ <BlockCanvas
136
+ blocks={blocks}
137
+ onChange={onChange}
138
+ blockTypes={[...coreBlocks]}
139
+ onError={(error) => console.error(error)}
140
+ />
141
+ );
142
+ }
143
+ ```
144
+
145
+ Hoist them to module scope instead, or wrap them in `useMemo` and `useCallback` when they depend on props.
146
+
147
+ ```tsx
148
+ import { BlockCanvas, coreBlocks, type AnyBlockDefinition, type QuoinBlock } from 'quoin-editor';
149
+ import 'quoin-editor/styles.css';
150
+
151
+ const blockTypes: AnyBlockDefinition[] = [...coreBlocks];
152
+ const reportError = (error: Error) => console.error(error);
153
+
154
+ export function FastEditor({ blocks, onChange }: { blocks: QuoinBlock[]; onChange: (next: QuoinBlock[]) => void }) {
155
+ return <BlockCanvas blocks={blocks} onChange={onChange} blockTypes={blockTypes} onError={reportError} />;
156
+ }
157
+ ```
158
+
159
+ ## Custom blocks
160
+
161
+ 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.
162
+
163
+ ```tsx
164
+ import { Bookmark } from 'lucide-react';
165
+ import {
166
+ BlockCanvas,
167
+ coreBlocks,
168
+ defineBlock,
169
+ type AnyBlockDefinition,
170
+ type BlockEditorProps,
171
+ type BlockPreviewProps,
172
+ type QuoinBlock,
173
+ } from 'quoin-editor';
174
+ import 'quoin-editor/styles.css';
175
+
176
+ type BookmarkData = { url: string; title: string };
177
+
178
+ function BookmarkEditor({ data, onChange }: BlockEditorProps<BookmarkData>) {
179
+ return (
180
+ <div>
181
+ <input
182
+ aria-label="Title"
183
+ value={data.title}
184
+ onChange={(event) => onChange({ ...data, title: event.target.value })}
185
+ />
186
+ <input
187
+ aria-label="URL"
188
+ value={data.url}
189
+ onChange={(event) => onChange({ ...data, url: event.target.value })}
190
+ />
191
+ </div>
192
+ );
193
+ }
194
+
195
+ function BookmarkPreview({ data }: BlockPreviewProps<BookmarkData>) {
196
+ if (data.url === '') return <p>No bookmark yet</p>;
197
+ return <a href={data.url}>{data.title || data.url}</a>;
198
+ }
199
+
200
+ const bookmark = defineBlock({
201
+ type: 'BOOKMARK',
202
+ label: 'Bookmark',
203
+ icon: Bookmark,
204
+ editor: BookmarkEditor,
205
+ preview: BookmarkPreview,
206
+ initialData: () => ({ url: '', title: '' }),
207
+ });
208
+
209
+ const blockTypes: AnyBlockDefinition[] = [...coreBlocks, bookmark];
210
+
211
+ export function Editor({ blocks, onChange }: { blocks: QuoinBlock[]; onChange: (next: QuoinBlock[]) => void }) {
212
+ return <BlockCanvas blocks={blocks} onChange={onChange} blockTypes={blockTypes} />;
213
+ }
214
+ ```
215
+
216
+ A definition has these fields:
217
+
218
+ | Field | Required | Purpose |
219
+ | --- | --- | --- |
220
+ | `type` | yes | The string stored on each block. It is how the registry finds the definition. |
221
+ | `label` | yes | The name shown in menus and the slash palette. |
222
+ | `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. |
223
+ | `editor` | yes | The editing surface. It receives `data` and `onChange`. Call `onChange` with the whole next `data`, not a patch. |
224
+ | `preview` | no | What the block shows when it is not being edited. Omit it for an edit-only block. |
225
+ | `initialData` | no | Seeds `data` when a block of this type is inserted. It is checked against the editor's data type. |
226
+ | `inSlashPalette` | no | Set to `false` to keep the type out of the slash palette and the inserter. Defaults to `true`. |
227
+ | `alwaysEditing` | no | Renders the editor permanently instead of a preview you click to edit. |
228
+
229
+ A definition that sets `alwaysEditing` should also supply a `preview`. A `readOnly` canvas draws the preview and never the editor, so without a preview the block's content is invisible there.
230
+
231
+ The preview is also how you bring your own markdown renderer. Register a `MARKDOWN` definition last, reuse the core editor, and swap the preview:
232
+
233
+ ```tsx
234
+ import { blockByType, coreBlocks, defineBlock, type AnyBlockDefinition, type BlockPreviewProps } from 'quoin-editor';
235
+
236
+ function RichMarkdownPreview({ data }: BlockPreviewProps) {
237
+ return <article>{String(data.markdown ?? '')}</article>;
238
+ }
239
+
240
+ const core = blockByType(coreBlocks, 'MARKDOWN');
241
+
242
+ export const blockTypes: AnyBlockDefinition[] = core
243
+ ? [...coreBlocks, defineBlock({ ...core, preview: RichMarkdownPreview })]
244
+ : coreBlocks;
245
+ ```
246
+
247
+ Replace the `<article>` with whatever renderer you use.
248
+
249
+ ## Uploads
250
+
251
+ 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`.
252
+
253
+ ```tsx
254
+ import { BlockCanvas, type QuoinBlock, type UploadFn } from 'quoin-editor';
255
+ import 'quoin-editor/styles.css';
256
+
257
+ const upload: UploadFn = async (file, category) => {
258
+ let response: Response;
259
+ try {
260
+ response = await fetch(`/api/uploads?category=${encodeURIComponent(category)}`, {
261
+ method: 'POST',
262
+ body: file,
263
+ });
264
+ } catch {
265
+ throw new Error('Could not reach the server. Check your connection and try again.');
266
+ }
267
+ if (!response.ok) {
268
+ throw new Error(response.status === 413 ? 'That file is too large.' : 'The upload was refused.');
269
+ }
270
+ const stored = (await response.json()) as { key: string };
271
+ return { storageKey: stored.key, filename: file.name, size: file.size };
272
+ };
273
+
274
+ const resolveAssetUrl = (storageKey: string) => `/api/files/${encodeURIComponent(storageKey)}`;
275
+ const reportError = (error: Error) => console.error(error);
276
+
277
+ export function Editor({ blocks, onChange }: { blocks: QuoinBlock[]; onChange: (next: QuoinBlock[]) => void }) {
278
+ return (
279
+ <BlockCanvas
280
+ blocks={blocks}
281
+ onChange={onChange}
282
+ upload={upload}
283
+ resolveAssetUrl={resolveAssetUrl}
284
+ onError={reportError}
285
+ />
286
+ );
287
+ }
288
+ ```
289
+
290
+ - `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.
291
+ - `resolveAssetUrl` turns a `storageKey` back into a URL for display. It defaults to returning the key unchanged.
292
+ - **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.
293
+ - **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.
294
+ - The upload control is disabled while an upload is in flight.
295
+ - With no `upload` function, upload fields render disabled, with a note that uploads are not configured.
296
+ - `onError` receives errors Quoin cannot show inline, such as an empty markdown file picked for import. It defaults to `console.error`.
297
+
298
+ ## Read-only
299
+
300
+ ```tsx
301
+ import { BlockCanvas, type QuoinBlock } from 'quoin-editor';
302
+ import 'quoin-editor/styles.css';
303
+
304
+ const noop = () => {};
305
+
306
+ export function Reader({ blocks }: { blocks: QuoinBlock[] }) {
307
+ return <BlockCanvas blocks={blocks} onChange={noop} readOnly aria-label="Article" />;
308
+ }
309
+ ```
310
+
311
+ `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.
312
+
313
+ ## Theming
314
+
315
+ `quoin-editor/styles.css` is compiled Tailwind CSS v4 and defines these custom properties:
316
+
317
+ ```css
318
+ :root {
319
+ --background: oklch(1 0 0);
320
+ --foreground: oklch(0.145 0 0);
321
+ --muted: oklch(0.97 0 0);
322
+ --muted-foreground: oklch(0.556 0 0);
323
+ --popover: oklch(1 0 0);
324
+ --popover-foreground: oklch(0.145 0 0);
325
+ --border: oklch(0.922 0 0);
326
+ --input: oklch(0.922 0 0);
327
+ --ring: oklch(0.708 0 0);
328
+ --primary: oklch(0.205 0 0);
329
+ --primary-foreground: oklch(0.985 0 0);
330
+ --accent: oklch(0.97 0 0);
331
+ --accent-foreground: oklch(0.205 0 0);
332
+ --secondary: oklch(0.97 0 0);
333
+ --secondary-foreground: oklch(0.205 0 0);
334
+ --destructive: oklch(0.577 0.245 27.325);
335
+ --radius: 0.625rem;
336
+ }
337
+ ```
338
+
339
+ Override any of them from your own CSS. They are declared inside `@layer base`, so a plain `:root` rule of yours wins without `!important`:
340
+
341
+ ```css
342
+ :root {
343
+ --primary: oklch(0.55 0.2 260);
344
+ --radius: 0.5rem;
345
+ }
346
+ ```
347
+
348
+ A project that already defines shadcn-style tokens with these names inherits its theme for free, with nothing to configure.
349
+
350
+ Dark mode keys on a `.dark` class on an ancestor (for example `<html class="dark">`), not on `prefers-color-scheme`. Quoin redefines the same variables 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.
351
+
352
+ 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.
353
+
354
+ 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`:
355
+
356
+ ```css
357
+ @layer reset, theme, base, components, utilities;
358
+
359
+ @layer reset {
360
+ * { margin: 0; padding: 0; border: 0; }
361
+ }
362
+ ```
363
+
364
+ ```css
365
+ *:where(:not(.quoin, .quoin *)) { margin: 0; padding: 0; border: 0; }
366
+ ```
367
+
368
+ 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.
369
+
370
+ ## Headless
371
+
372
+ `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.
373
+
374
+ | Export | What it does |
375
+ | --- | --- |
376
+ | `wrapSelection`, `linkPaste` | Bold, italic, inline code and link wrapping, and pasting a URL over a selection. |
377
+ | `toggleLinePrefix` | Heading, bullet and ordered list prefixes on the selected lines. |
378
+ | `continueList`, `indentList` | List continuation on Enter, climbing out on an empty item, and Tab indentation. |
379
+ | `insertCommand` | Inserts a slash-palette command at the caret. |
380
+ | `shouldOpenPalette`, `lineAt` | Slash trigger detection and line lookup. |
381
+ | `applyEdit` | Writes an `Edit` into a textarea through the native undo stack so Ctrl+Z keeps working. |
382
+ | `formatShortcut`, `caretRect` | A platform-aware shortcut label, and the caret's pixel position in a textarea. |
383
+ | `useMarkdownKeymap` | The keyboard and paste handlers that wire the above onto a textarea. |
384
+ | `useAutoGrow` | A ref that makes a textarea's height follow its content. |
385
+
386
+ The text transforms return an `Edit`, `{ value, selectionStart, selectionEnd }`, or `null` when they do not apply.
387
+
388
+ ```ts
389
+ import { wrapSelection } from 'quoin-editor/headless';
390
+
391
+ export function bold(text: string, start: number, end: number): string {
392
+ return wrapSelection('bold', text, start, end).value;
393
+ }
394
+ ```
395
+
396
+ `useMarkdownKeymap` and `useAutoGrow` are hooks, so they can only be called from a client component, even though the module that exports them is safe to import anywhere.
397
+
398
+ ```tsx
399
+ 'use client';
400
+
401
+ import { useState } from 'react';
402
+ import { useAutoGrow, useMarkdownKeymap } from 'quoin-editor/headless';
403
+
404
+ export function Notes() {
405
+ const [text, setText] = useState('');
406
+ const [paletteOpen, setPaletteOpen] = useState(false);
407
+ const ref = useAutoGrow<HTMLTextAreaElement>(text);
408
+ const keymap = useMarkdownKeymap({
409
+ commit: setText,
410
+ onOpenPalette: () => setPaletteOpen(true),
411
+ });
412
+
413
+ return (
414
+ <>
415
+ <textarea
416
+ ref={ref}
417
+ value={text}
418
+ onChange={(event) => setText(event.target.value)}
419
+ {...keymap}
420
+ />
421
+ {paletteOpen && <p role="status">Open your own command palette here.</p>}
422
+ </>
423
+ );
424
+ }
425
+ ```
426
+
427
+ 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.
428
+
429
+ The same functions and hooks are also exported from the main `quoin-editor` entry, alongside `TEXT_COMMANDS`, the built-in slash-palette entries.
430
+
431
+ ## Exports
432
+
433
+ From `quoin`: `BlockCanvas`, `QuoinProvider`, `useQuoin`, `defineBlock`, `blockByType`, `paletteBlocks`, `coreBlocks`, `TEXT_COMMANDS`, `useBlockList`, `useAutosave`, `useAutoGrow`, and the headless transforms and hooks, with their types (`QuoinBlock`, `UploadFn`, `UploadResult`, `BlockDefinition`, `AnyBlockDefinition`, `BlockEditorProps`, `BlockPreviewProps`, `BlockCanvasProps` and others).
434
+
435
+ From `quoin-editor/headless`: the transforms and the two hooks listed above, with no UI.
436
+
437
+ From `quoin-editor/styles.css`: the stylesheet.
438
+
439
+ ## Licence
440
+
441
+ MIT. Copyright (c) 2026 Amine Sboui. See [LICENSE](LICENSE).