quoin-editor 0.0.0-stage → 0.1.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/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,519 @@
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
+ 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.
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 TransitionEditor() {
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
+ What breaks is a store that rebuilds the document on the way through, so what comes back is a structural copy rather than the array the canvas emitted. Arriving behind the canvas's own state, that copy reads as a foreign document and replaces live text with an older snapshot. The damage is not a clean revert: the characters the snapshot predates are dropped and the ones typed after them are kept, so `ZZTYPED hello world` can land as `ZZYED hello wold`. A Redux or Zustand round trip that clones the document, and a server-synced store, are the usual sources.
63
+
64
+ 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.
65
+
66
+ ```tsx
67
+ import { useState } from 'react';
68
+ import { BlockCanvas, useAutosave, type QuoinBlock } from 'quoin-editor';
69
+ import 'quoin-editor/styles.css';
70
+
71
+ async function saveDocument(blocks: QuoinBlock[]): Promise<void> {
72
+ await fetch('/api/document', { method: 'PUT', body: JSON.stringify(blocks) });
73
+ }
74
+
75
+ export function AutosavingEditor({ initial }: { initial: QuoinBlock[] }) {
76
+ const [blocks, setBlocks] = useState<QuoinBlock[]>(initial);
77
+ const { status } = useAutosave({
78
+ deps: [blocks],
79
+ build: () => blocks,
80
+ save: saveDocument,
81
+ debounceMs: 1000,
82
+ });
83
+ return (
84
+ <>
85
+ <BlockCanvas blocks={blocks} onChange={setBlocks} />
86
+ <p role="status">{status}</p>
87
+ </>
88
+ );
89
+ }
90
+ ```
91
+
92
+ ### Loading, undoing and discarding
93
+
94
+ 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.
95
+
96
+ 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.
97
+
98
+ 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.
99
+
100
+ ```tsx
101
+ import { useState } from 'react';
102
+ import { BlockCanvas, type QuoinBlock } from 'quoin-editor';
103
+ import 'quoin-editor/styles.css';
104
+
105
+ export function EditorWithDiscard({ saved }: { saved: QuoinBlock[] }) {
106
+ const [blocks, setBlocks] = useState<QuoinBlock[]>(saved);
107
+ const [revision, setRevision] = useState(0);
108
+
109
+ function discard() {
110
+ setBlocks(saved);
111
+ setRevision((current) => current + 1);
112
+ }
113
+
114
+ return (
115
+ <>
116
+ <BlockCanvas key={revision} blocks={blocks} onChange={setBlocks} />
117
+ <button type="button" onClick={discard}>
118
+ Discard changes
119
+ </button>
120
+ </>
121
+ );
122
+ }
123
+ ```
124
+
125
+ 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.
126
+
127
+ ## Hoist `blockTypes` and your handlers
128
+
129
+ `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.
130
+
131
+ ```tsx
132
+ import { BlockCanvas, coreBlocks, type QuoinBlock } from 'quoin-editor';
133
+ import 'quoin-editor/styles.css';
134
+
135
+ export function SlowEditor({ blocks, onChange }: { blocks: QuoinBlock[]; onChange: (next: QuoinBlock[]) => void }) {
136
+ return (
137
+ <BlockCanvas
138
+ blocks={blocks}
139
+ onChange={onChange}
140
+ blockTypes={[...coreBlocks]}
141
+ onError={(error) => console.error(error)}
142
+ />
143
+ );
144
+ }
145
+ ```
146
+
147
+ Hoist them to module scope instead, or wrap them in `useMemo` and `useCallback` when they depend on props.
148
+
149
+ ```tsx
150
+ import { BlockCanvas, coreBlocks, type AnyBlockDefinition, type QuoinBlock } from 'quoin-editor';
151
+ import 'quoin-editor/styles.css';
152
+
153
+ const blockTypes: AnyBlockDefinition[] = [...coreBlocks];
154
+ const reportError = (error: Error) => console.error(error);
155
+
156
+ export function FastEditor({ blocks, onChange }: { blocks: QuoinBlock[]; onChange: (next: QuoinBlock[]) => void }) {
157
+ return <BlockCanvas blocks={blocks} onChange={onChange} blockTypes={blockTypes} onError={reportError} />;
158
+ }
159
+ ```
160
+
161
+ ## Custom blocks
162
+
163
+ 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.
164
+
165
+ ```tsx
166
+ import { Bookmark } from 'lucide-react';
167
+ import {
168
+ BlockCanvas,
169
+ coreBlocks,
170
+ defineBlock,
171
+ type AnyBlockDefinition,
172
+ type BlockEditorProps,
173
+ type BlockPreviewProps,
174
+ type QuoinBlock,
175
+ } from 'quoin-editor';
176
+ import 'quoin-editor/styles.css';
177
+
178
+ type BookmarkData = { url: string; title: string };
179
+
180
+ function BookmarkEditor({ data, onChange }: BlockEditorProps<BookmarkData>) {
181
+ return (
182
+ <div>
183
+ <input
184
+ aria-label="Title"
185
+ value={data.title}
186
+ onChange={(event) => onChange({ ...data, title: event.target.value })}
187
+ />
188
+ <input
189
+ aria-label="URL"
190
+ value={data.url}
191
+ onChange={(event) => onChange({ ...data, url: event.target.value })}
192
+ />
193
+ </div>
194
+ );
195
+ }
196
+
197
+ function BookmarkPreview({ data }: BlockPreviewProps<BookmarkData>) {
198
+ if (data.url === '') return <p>No bookmark yet</p>;
199
+ return <a href={data.url}>{data.title || data.url}</a>;
200
+ }
201
+
202
+ const bookmark = defineBlock({
203
+ type: 'BOOKMARK',
204
+ label: 'Bookmark',
205
+ icon: Bookmark,
206
+ editor: BookmarkEditor,
207
+ preview: BookmarkPreview,
208
+ initialData: () => ({ url: '', title: '' }),
209
+ });
210
+
211
+ const blockTypes: AnyBlockDefinition[] = [...coreBlocks, bookmark];
212
+
213
+ export function Editor({ blocks, onChange }: { blocks: QuoinBlock[]; onChange: (next: QuoinBlock[]) => void }) {
214
+ return <BlockCanvas blocks={blocks} onChange={onChange} blockTypes={blockTypes} />;
215
+ }
216
+ ```
217
+
218
+ A definition has these fields:
219
+
220
+ | Field | Required | Purpose |
221
+ | --- | --- | --- |
222
+ | `type` | yes | The string stored on each block. It is how the registry finds the definition. |
223
+ | `label` | yes | The name shown in menus and the slash palette. |
224
+ | `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. |
225
+ | `editor` | yes | The editing surface. It receives `data` and `onChange`. Call `onChange` with the whole next `data`, not a patch. |
226
+ | `preview` | no | What the block shows when it is not being edited. Omit it for an edit-only block. |
227
+ | `initialData` | no | Seeds `data` when a block of this type is inserted. It is checked against the editor's data type. |
228
+ | `inSlashPalette` | no | Set to `false` to keep the type out of the slash palette and the inserter. Defaults to `true`. |
229
+ | `alwaysEditing` | no | Renders the editor permanently instead of a preview you click to edit. |
230
+
231
+ 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.
232
+
233
+ ### Reaching uploads from a custom block
234
+
235
+ A block editor receives only `data` and `onChange`. The canvas configuration reaches it through `useQuoin()`, which returns `blockTypes`, `upload`, `resolveAssetUrl` and `onError`. It throws outside a `QuoinProvider`, so call it from a block the canvas is rendering.
236
+
237
+ ```tsx
238
+ import { useQuoin, type BlockEditorProps } from 'quoin-editor';
239
+
240
+ type AttachmentData = { storageKey: string; filename: string };
241
+
242
+ export function AttachmentEditor({ data, onChange }: BlockEditorProps<AttachmentData>) {
243
+ const { upload, resolveAssetUrl, onError } = useQuoin();
244
+
245
+ async function store(file: File) {
246
+ if (!upload) return;
247
+ try {
248
+ const stored = await upload(file, 'attachment');
249
+ onChange({ storageKey: stored.storageKey, filename: stored.filename });
250
+ } catch (error) {
251
+ onError(error instanceof Error ? error : new Error('The upload failed.'));
252
+ }
253
+ }
254
+
255
+ return (
256
+ <div>
257
+ {data.storageKey !== '' && <a href={resolveAssetUrl(data.storageKey)}>{data.filename}</a>}
258
+ <input
259
+ type="file"
260
+ disabled={!upload}
261
+ onChange={(event) => {
262
+ const file = event.target.files?.[0];
263
+ if (file) void store(file);
264
+ }}
265
+ />
266
+ </div>
267
+ );
268
+ }
269
+ ```
270
+
271
+ `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`.
272
+
273
+ The preview is also how you bring your own markdown renderer. Register a `MARKDOWN` definition last, reuse the core editor, and swap the preview:
274
+
275
+ ```tsx
276
+ import { blockByType, coreBlocks, defineBlock, type AnyBlockDefinition, type BlockPreviewProps } from 'quoin-editor';
277
+
278
+ function RichMarkdownPreview({ data }: BlockPreviewProps) {
279
+ return <article>{String(data.markdown ?? '')}</article>;
280
+ }
281
+
282
+ const core = blockByType(coreBlocks, 'MARKDOWN');
283
+
284
+ export const blockTypes: AnyBlockDefinition[] = core
285
+ ? [...coreBlocks, defineBlock({ ...core, preview: RichMarkdownPreview })]
286
+ : coreBlocks;
287
+ ```
288
+
289
+ Replace the `<article>` with whatever renderer you use.
290
+
291
+ ## Uploads
292
+
293
+ 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`.
294
+
295
+ ```tsx
296
+ import { BlockCanvas, type QuoinBlock, type UploadFn } from 'quoin-editor';
297
+ import 'quoin-editor/styles.css';
298
+
299
+ const upload: UploadFn = async (file, category) => {
300
+ let response: Response;
301
+ try {
302
+ response = await fetch(`/api/uploads?category=${encodeURIComponent(category)}`, {
303
+ method: 'POST',
304
+ body: file,
305
+ });
306
+ } catch {
307
+ throw new Error('Could not reach the server. Check your connection and try again.');
308
+ }
309
+ if (!response.ok) {
310
+ throw new Error(response.status === 413 ? 'That file is too large.' : 'The upload was refused.');
311
+ }
312
+ const stored = (await response.json()) as { key: string };
313
+ return { storageKey: stored.key, filename: file.name, size: file.size };
314
+ };
315
+
316
+ const resolveAssetUrl = (storageKey: string) => `/api/files/${encodeURIComponent(storageKey)}`;
317
+ const reportError = (error: Error) => console.error(error);
318
+
319
+ export function Editor({ blocks, onChange }: { blocks: QuoinBlock[]; onChange: (next: QuoinBlock[]) => void }) {
320
+ return (
321
+ <BlockCanvas
322
+ blocks={blocks}
323
+ onChange={onChange}
324
+ upload={upload}
325
+ resolveAssetUrl={resolveAssetUrl}
326
+ onError={reportError}
327
+ />
328
+ );
329
+ }
330
+ ```
331
+
332
+ - `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.
333
+ - `resolveAssetUrl` turns a `storageKey` back into a URL for display. It defaults to returning the key unchanged.
334
+ - **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.
335
+ - **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.
336
+ - The upload control is disabled while an upload is in flight.
337
+ - With no `upload` function, upload fields render disabled, with a note that uploads are not configured.
338
+ - `onError` receives errors Quoin cannot show inline, such as an empty markdown file picked for import. It defaults to `console.error`.
339
+
340
+ ## Read-only
341
+
342
+ ```tsx
343
+ import { BlockCanvas, type QuoinBlock } from 'quoin-editor';
344
+ import 'quoin-editor/styles.css';
345
+
346
+ const noop = () => {};
347
+
348
+ export function Reader({ blocks }: { blocks: QuoinBlock[] }) {
349
+ return <BlockCanvas blocks={blocks} onChange={noop} readOnly aria-label="Article" />;
350
+ }
351
+ ```
352
+
353
+ `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.
354
+
355
+ ## Theming
356
+
357
+ `quoin-editor/styles.css` is compiled Tailwind CSS v4 and defines these custom properties:
358
+
359
+ ```css
360
+ :root {
361
+ --background: oklch(1 0 0);
362
+ --foreground: oklch(0.145 0 0);
363
+ --muted: oklch(0.97 0 0);
364
+ --muted-foreground: oklch(0.556 0 0);
365
+ --popover: oklch(1 0 0);
366
+ --popover-foreground: oklch(0.145 0 0);
367
+ --border: oklch(0.922 0 0);
368
+ --input: oklch(0.922 0 0);
369
+ --ring: oklch(0.708 0 0);
370
+ --primary: oklch(0.205 0 0);
371
+ --primary-foreground: oklch(0.985 0 0);
372
+ --accent: oklch(0.97 0 0);
373
+ --accent-foreground: oklch(0.205 0 0);
374
+ --secondary: oklch(0.97 0 0);
375
+ --secondary-foreground: oklch(0.205 0 0);
376
+ --destructive: oklch(0.577 0.245 27.325);
377
+ --radius: 0.625rem;
378
+ }
379
+ ```
380
+
381
+ Override any of them from your own CSS. They are declared inside `@layer base`, so a plain `:root` rule of yours wins without `!important`:
382
+
383
+ ```css
384
+ :root {
385
+ --primary: oklch(0.55 0.2 260);
386
+ --radius: 0.5rem;
387
+ }
388
+ ```
389
+
390
+ A project that already defines shadcn-style tokens with these names inherits its theme for free, with nothing to configure.
391
+
392
+ ### When a token name collides
393
+
394
+ The same unprefixed names that make that inheritance free also let a collision happen by accident. A project that already declares one of them for something else hands Quoin a value nobody meant it to read, and Quoin cannot tell that apart from a theme deliberately shared with it.
395
+
396
+ The stock Vite React + TypeScript template is the case to know about. It declares `--accent` as a bright purple and `--border` on `:root` with meanings of its own, so a fresh `npm create vite` project that installs Quoin draws 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.
397
+
398
+ Declare the tokens you did not mean to share on `.quoin` instead. 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. It sits closer to the elements reading the token than your `:root` rule does, so it wins without `!important` and leaves your own `--accent` untouched.
399
+
400
+ ```css
401
+ .quoin {
402
+ --accent: oklch(0.97 0 0);
403
+ --accent-foreground: oklch(0.205 0 0);
404
+ --border: oklch(0.922 0 0);
405
+ }
406
+
407
+ .dark .quoin {
408
+ --accent: oklch(0.269 0 0);
409
+ --accent-foreground: oklch(0.985 0 0);
410
+ --border: oklch(1 0 0 / 10%);
411
+ }
412
+ ```
413
+
414
+ Both rules are needed once you take a token over: pinning `--accent` on `.quoin` alone would also beat Quoin's own `.dark` value and freeze that token in its light form.
415
+
416
+ Quoin keeps the unprefixed names rather than reading `--quoin-accent`. A prefix would stop the accident, but it would also cancel the free inheritance for every project that is already relying on it, and the package cannot tell a deliberate `--accent` from an accidental one.
417
+
418
+ 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.
419
+
420
+ 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.
421
+
422
+ 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`:
423
+
424
+ ```css
425
+ @layer reset, theme, base, components, utilities;
426
+
427
+ @layer reset {
428
+ * { margin: 0; padding: 0; border: 0; }
429
+ }
430
+ ```
431
+
432
+ ```css
433
+ *:where(:not(.quoin, .quoin *)) { margin: 0; padding: 0; border: 0; }
434
+ ```
435
+
436
+ 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.
437
+
438
+ ## Headless
439
+
440
+ `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.
441
+
442
+ | Export | What it does |
443
+ | --- | --- |
444
+ | `wrapSelection`, `linkPaste` | Bold, italic, inline code and link wrapping, and pasting a URL over a selection. |
445
+ | `toggleLinePrefix` | Heading, bullet and ordered list prefixes on the selected lines. |
446
+ | `continueList`, `indentList` | List continuation on Enter, climbing out on an empty item, and Tab indentation. |
447
+ | `insertCommand` | Inserts a slash-palette command at the caret. |
448
+ | `shouldOpenPalette`, `lineAt` | Slash trigger detection and line lookup. |
449
+ | `applyEdit` | Writes an `Edit` into a textarea through the native undo stack so Ctrl+Z keeps working. |
450
+ | `formatShortcut`, `caretRect` | A platform-aware shortcut label, and the caret's pixel position in a textarea. |
451
+ | `useMarkdownKeymap` | The keyboard and paste handlers that wire the above onto a textarea. |
452
+ | `useAutoGrow` | A ref that makes a textarea's height follow its content. |
453
+
454
+ The text transforms return an `Edit`, `{ value, selectionStart, selectionEnd }`, or `null` when they do not apply.
455
+
456
+ ```ts
457
+ import { wrapSelection } from 'quoin-editor/headless';
458
+
459
+ export function bold(text: string, start: number, end: number): string {
460
+ return wrapSelection('bold', text, start, end).value;
461
+ }
462
+ ```
463
+
464
+ `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.
465
+
466
+ ```tsx
467
+ 'use client';
468
+
469
+ import { useState } from 'react';
470
+ import { useAutoGrow, useMarkdownKeymap } from 'quoin-editor/headless';
471
+
472
+ export function Notes() {
473
+ const [text, setText] = useState('');
474
+ const [paletteOpen, setPaletteOpen] = useState(false);
475
+ const ref = useAutoGrow<HTMLTextAreaElement>(text);
476
+ const keymap = useMarkdownKeymap({
477
+ commit: setText,
478
+ onOpenPalette: () => setPaletteOpen(true),
479
+ });
480
+
481
+ return (
482
+ <>
483
+ <textarea
484
+ ref={ref}
485
+ value={text}
486
+ onChange={(event) => setText(event.target.value)}
487
+ {...keymap}
488
+ />
489
+ {paletteOpen && <p role="status">Open your own command palette here.</p>}
490
+ </>
491
+ );
492
+ }
493
+ ```
494
+
495
+ 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.
496
+
497
+ 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`.
498
+
499
+ ```ts
500
+ import { toggleLinePrefix } from 'quoin-editor';
501
+
502
+ export function heading(text: string, start: number, end: number): string {
503
+ return toggleLinePrefix('h2', text, start, end).value;
504
+ }
505
+ ```
506
+
507
+ 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`.
508
+
509
+ ## Exports
510
+
511
+ From `quoin-editor`: `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).
512
+
513
+ From `quoin-editor/headless`: the transforms and the two hooks listed above, with no UI.
514
+
515
+ From `quoin-editor/styles.css`: the stylesheet.
516
+
517
+ ## Licence
518
+
519
+ MIT. Copyright (c) 2026 Amine Sboui. See [LICENSE](LICENSE).