quoin-editor 0.1.0 → 0.2.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/README.md CHANGED
@@ -37,18 +37,20 @@ export function Editor() {
37
37
 
38
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
39
 
40
+ 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).
41
+
40
42
  ## The controlled-component contract
41
43
 
42
44
  `BlockCanvas` behaves like a controlled `<input value onChange>`. Update `blocks` synchronously from `onChange`.
43
45
 
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.
46
+ 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
47
 
46
48
  ```tsx
47
49
  import { startTransition, useState } from 'react';
48
50
  import { BlockCanvas, type QuoinBlock } from 'quoin-editor';
49
51
  import 'quoin-editor/styles.css';
50
52
 
51
- export function BrokenEditor() {
53
+ export function TransitionEditor() {
52
54
  const [blocks, setBlocks] = useState<QuoinBlock[]>([]);
53
55
  return (
54
56
  <BlockCanvas
@@ -59,6 +61,8 @@ export function BrokenEditor() {
59
61
  }
60
62
  ```
61
63
 
64
+ 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.
65
+
62
66
  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
67
 
64
68
  ```tsx
@@ -122,6 +126,46 @@ export function EditorWithDiscard({ saved }: { saved: QuoinBlock[] }) {
122
126
 
123
127
  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
128
 
129
+ ### Undoing a deletion
130
+
131
+ `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.
132
+
133
+ 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`.
134
+
135
+ 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.
136
+
137
+ ```tsx
138
+ import { useState } from 'react';
139
+ import { BlockCanvas, type QuoinBlock } from 'quoin-editor';
140
+ import 'quoin-editor/styles.css';
141
+
142
+ export function EditorWithUndo({ saved }: { saved: QuoinBlock[] }) {
143
+ const [blocks, setBlocks] = useState<QuoinBlock[]>(saved);
144
+ const [removed, setRemoved] = useState<{ block: QuoinBlock; index: number } | null>(null);
145
+
146
+ function restore() {
147
+ if (!removed) return;
148
+ const next = [...blocks];
149
+ next.splice(removed.index, 0, removed.block);
150
+ setBlocks(next);
151
+ setRemoved(null);
152
+ }
153
+
154
+ return (
155
+ <>
156
+ <BlockCanvas blocks={blocks} onChange={setBlocks} onDelete={(block, index) => setRemoved({ block, index })} />
157
+ {removed && (
158
+ <button type="button" onClick={restore}>
159
+ Undo delete
160
+ </button>
161
+ )}
162
+ </>
163
+ );
164
+ }
165
+ ```
166
+
167
+ 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.
168
+
125
169
  ## Hoist `blockTypes` and your handlers
126
170
 
127
171
  `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.
@@ -228,6 +272,74 @@ A definition has these fields:
228
272
 
229
273
  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
274
 
275
+ ### Reaching uploads from a custom block
276
+
277
+ 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.
278
+
279
+ `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.
280
+
281
+ ```tsx
282
+ import { UploadField, type BlockEditorProps } from 'quoin-editor';
283
+
284
+ type SlidesData = { storageKey: string; filename: string };
285
+
286
+ export function SlidesEditor({ data, onChange }: BlockEditorProps<SlidesData>) {
287
+ return (
288
+ <div>
289
+ <p>{data.storageKey === '' ? 'No deck yet' : data.filename}</p>
290
+ <UploadField
291
+ category="slides"
292
+ label="Upload slides"
293
+ accept=".pdf"
294
+ onUploaded={(result) => onChange({ storageKey: result.storageKey, filename: result.filename })}
295
+ />
296
+ </div>
297
+ );
298
+ }
299
+ ```
300
+
301
+ 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`.
302
+
303
+ `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.
304
+
305
+ 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.
306
+
307
+ ```tsx
308
+ import { useQuoin, type BlockEditorProps } from 'quoin-editor';
309
+
310
+ type AttachmentData = { storageKey: string; filename: string };
311
+
312
+ export function AttachmentEditor({ data, onChange }: BlockEditorProps<AttachmentData>) {
313
+ const { upload, resolveAssetUrl, onError } = useQuoin();
314
+
315
+ async function store(file: File) {
316
+ if (!upload) return;
317
+ try {
318
+ const stored = await upload(file, 'attachment');
319
+ onChange({ storageKey: stored.storageKey, filename: stored.filename });
320
+ } catch (error) {
321
+ onError(error instanceof Error ? error : new Error('The upload failed.'));
322
+ }
323
+ }
324
+
325
+ return (
326
+ <div>
327
+ {data.storageKey !== '' && <a href={resolveAssetUrl(data.storageKey)}>{data.filename}</a>}
328
+ <input
329
+ type="file"
330
+ disabled={!upload}
331
+ onChange={(event) => {
332
+ const file = event.target.files?.[0];
333
+ if (file) void store(file);
334
+ }}
335
+ />
336
+ </div>
337
+ );
338
+ }
339
+ ```
340
+
341
+ `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`.
342
+
231
343
  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
344
 
233
345
  ```tsx
@@ -312,49 +424,115 @@ export function Reader({ blocks }: { blocks: QuoinBlock[] }) {
312
424
 
313
425
  ## Theming
314
426
 
315
- `quoin-editor/styles.css` is compiled Tailwind CSS v4 and defines these custom properties:
427
+ `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:
316
428
 
317
429
  ```css
318
430
  :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;
431
+ --quoin-background: oklch(1 0 0);
432
+ --quoin-foreground: oklch(0.145 0 0);
433
+ --quoin-muted: oklch(0.97 0 0);
434
+ --quoin-muted-foreground: oklch(0.556 0 0);
435
+ --quoin-secondary: oklch(0.97 0 0);
436
+ --quoin-secondary-foreground: oklch(0.205 0 0);
437
+ --quoin-popover: oklch(1 0 0);
438
+ --quoin-popover-foreground: oklch(0.145 0 0);
439
+ --quoin-border: oklch(0.922 0 0);
440
+ --quoin-input: oklch(0.922 0 0);
441
+ --quoin-ring: oklch(0.708 0 0);
442
+ --quoin-primary: oklch(0.205 0 0);
443
+ --quoin-primary-foreground: oklch(0.985 0 0);
444
+ --quoin-accent: oklch(0.97 0 0);
445
+ --quoin-accent-foreground: oklch(0.205 0 0);
446
+ --quoin-destructive: oklch(0.577 0.245 27.325);
447
+ --quoin-radius: 0.625rem;
448
+ }
449
+
450
+ .dark {
451
+ --quoin-background: oklch(0.145 0 0);
452
+ --quoin-foreground: oklch(0.985 0 0);
453
+ --quoin-muted: oklch(0.269 0 0);
454
+ --quoin-muted-foreground: oklch(0.708 0 0);
455
+ --quoin-secondary: oklch(0.269 0 0);
456
+ --quoin-secondary-foreground: oklch(0.985 0 0);
457
+ --quoin-popover: oklch(0.205 0 0);
458
+ --quoin-popover-foreground: oklch(0.985 0 0);
459
+ --quoin-border: oklch(1 0 0 / 10%);
460
+ --quoin-input: oklch(1 0 0 / 15%);
461
+ --quoin-ring: oklch(0.556 0 0);
462
+ --quoin-primary: oklch(0.922 0 0);
463
+ --quoin-primary-foreground: oklch(0.205 0 0);
464
+ --quoin-accent: oklch(0.269 0 0);
465
+ --quoin-accent-foreground: oklch(0.985 0 0);
466
+ --quoin-destructive: oklch(0.704 0.191 22.216);
467
+ --quoin-radius: 0.625rem;
468
+ }
469
+ ```
470
+
471
+ Out of the box that is a neutral grey editor that works in both modes with nothing configured.
472
+
473
+ 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.
474
+
475
+ ### The prefix arrived in 0.2.0
476
+
477
+ 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.
478
+
479
+ 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.
480
+
481
+ 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.
482
+
483
+ ### Mapping your own tokens
484
+
485
+ 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.
486
+
487
+ ```css
488
+ .quoin {
489
+ --quoin-background: var(--background);
490
+ --quoin-foreground: var(--foreground);
491
+ --quoin-muted: var(--muted);
492
+ --quoin-muted-foreground: var(--muted-foreground);
493
+ --quoin-secondary: var(--secondary);
494
+ --quoin-secondary-foreground: var(--secondary-foreground);
495
+ --quoin-popover: var(--popover);
496
+ --quoin-popover-foreground: var(--popover-foreground);
497
+ --quoin-border: var(--border);
498
+ --quoin-input: var(--input);
499
+ --quoin-ring: var(--ring);
500
+ --quoin-primary: var(--primary);
501
+ --quoin-primary-foreground: var(--primary-foreground);
502
+ --quoin-accent: var(--accent);
503
+ --quoin-accent-foreground: var(--accent-foreground);
504
+ --quoin-destructive: var(--destructive);
505
+ --quoin-radius: var(--radius);
336
506
  }
337
507
  ```
338
508
 
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`:
509
+ 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.
510
+
511
+ 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.
340
512
 
341
513
  ```css
342
514
  :root {
343
- --primary: oklch(0.55 0.2 260);
344
- --radius: 0.5rem;
515
+ --quoin-primary: oklch(0.55 0.2 260);
516
+ --quoin-radius: 0.5rem;
517
+ }
518
+
519
+ .dark {
520
+ --quoin-primary: oklch(0.72 0.17 260);
345
521
  }
346
522
  ```
347
523
 
348
- A project that already defines shadcn-style tokens with these names inherits its theme for free, with nothing to configure.
524
+ ### Dark mode
525
+
526
+ 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.
349
527
 
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.
528
+ ### Resets
351
529
 
352
530
  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
531
 
354
532
  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
533
 
356
534
  ```css
357
- @layer reset, theme, base, components, utilities;
535
+ @layer reset, theme, base, components, quoin, utilities;
358
536
 
359
537
  @layer reset {
360
538
  * { margin: 0; padding: 0; border: 0; }
@@ -367,6 +545,34 @@ All of Quoin's own rules are in cascade layers, so an **unlayered** global reset
367
545
 
368
546
  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
547
 
548
+ ### Cascade layers and import order
549
+
550
+ 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.
551
+
552
+ 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.
553
+
554
+ 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:
555
+
556
+ ```css
557
+ @layer theme, base, components, quoin, utilities;
558
+ @import "quoin-editor/styles.css" layer(quoin);
559
+ ```
560
+
561
+ With `quoin` named before `utilities`, your own utilities outrank Quoin's, including the responsive ones, while Quoin still outranks your `components`.
562
+
563
+ Naming it first instead puts every layer of yours above the package:
564
+
565
+ ```css
566
+ @layer quoin, theme, base, components, utilities;
567
+ @import "quoin-editor/styles.css" layer(quoin);
568
+ ```
569
+
570
+ 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.
571
+
572
+ **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.
573
+
574
+ 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.
575
+
370
576
  ## Headless
371
577
 
372
578
  `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.
@@ -393,7 +599,7 @@ export function bold(text: string, start: number, end: number): string {
393
599
  }
394
600
  ```
395
601
 
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.
602
+ `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.
397
603
 
398
604
  ```tsx
399
605
  'use client';
@@ -426,11 +632,21 @@ export function Notes() {
426
632
 
427
633
  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
634
 
429
- The same functions and hooks are also exported from the main `quoin-editor` entry, alongside `TEXT_COMMANDS`, the built-in slash-palette entries.
635
+ 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`.
636
+
637
+ ```ts
638
+ import { toggleLinePrefix } from 'quoin-editor';
639
+
640
+ export function heading(text: string, start: number, end: number): string {
641
+ return toggleLinePrefix('h2', text, start, end).value;
642
+ }
643
+ ```
644
+
645
+ 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`.
430
646
 
431
647
  ## Exports
432
648
 
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).
649
+ 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).
434
650
 
435
651
  From `quoin-editor/headless`: the transforms and the two hooks listed above, with no UI.
436
652