quoin-editor 0.2.0 → 0.4.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
@@ -22,6 +22,10 @@ yarn add quoin-editor
22
22
 
23
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
24
 
25
+ **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.
26
+
27
+ 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.
28
+
25
29
  ## Quickstart
26
30
 
27
31
  ```tsx
@@ -70,8 +74,9 @@ import { useState } from 'react';
70
74
  import { BlockCanvas, useAutosave, type QuoinBlock } from 'quoin-editor';
71
75
  import 'quoin-editor/styles.css';
72
76
 
73
- async function saveDocument(blocks: QuoinBlock[]): Promise<void> {
74
- await fetch('/api/document', { method: 'PUT', body: JSON.stringify(blocks) });
77
+ async function saveDocument(blocks: QuoinBlock[]) {
78
+ const response = await fetch('/api/document', { method: 'PUT', body: JSON.stringify(blocks) });
79
+ return response.json() as Promise<{ revision: number }>;
75
80
  }
76
81
 
77
82
  export function AutosavingEditor({ initial }: { initial: QuoinBlock[] }) {
@@ -91,6 +96,8 @@ export function AutosavingEditor({ initial }: { initial: QuoinBlock[] }) {
91
96
  }
92
97
  ```
93
98
 
99
+ `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.
100
+
94
101
  ### Loading, undoing and discarding
95
102
 
96
103
  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.
@@ -264,13 +271,50 @@ A definition has these fields:
264
271
  | `type` | yes | The string stored on each block. It is how the registry finds the definition. |
265
272
  | `label` | yes | The name shown in menus and the slash palette. |
266
273
  | `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. |
267
- | `editor` | yes | The editing surface. It receives `data` and `onChange`. Call `onChange` with the whole next `data`, not a patch. |
268
- | `preview` | no | What the block shows when it is not being edited. Omit it for an edit-only block. |
274
+ | `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. |
275
+ | `preview` | one of the two | What the block shows when it is not being edited. Omit it for an edit-only block. |
269
276
  | `initialData` | no | Seeds `data` when a block of this type is inserted. It is checked against the editor's data type. |
270
277
  | `inSlashPalette` | no | Set to `false` to keep the type out of the slash palette and the inserter. Defaults to `true`. |
271
278
  | `alwaysEditing` | no | Renders the editor permanently instead of a preview you click to edit. |
272
279
 
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.
280
+ Supply `editor`, `preview` or both. Omitting both is a type error, because such a block could only ever draw its own label.
281
+
282
+ **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.
283
+
284
+ ```tsx
285
+ import { ShieldCheck } from 'lucide-react';
286
+ import { defineBlock } from 'quoin-editor';
287
+
288
+ const audit = defineBlock<{ approver: string }>({
289
+ type: 'AUDIT',
290
+ label: 'Audit trail',
291
+ icon: ShieldCheck,
292
+ preview: ({ data }) => <p>Signed off by {data.approver}</p>,
293
+ });
294
+ ```
295
+
296
+ **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.
297
+
298
+ ### What the core blocks store
299
+
300
+ 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.
301
+
302
+ | Type | `data` | Exported as |
303
+ | --- | --- | --- |
304
+ | `MARKDOWN` | `markdown: string` | `MarkdownBlockData` |
305
+ | `CALLOUT` | `tone: string`, `body: string` | `CalloutBlockData` |
306
+ | `CODE` | `language: string`, `code: string` | `CodeBlockData` |
307
+ | `IMAGE` | `storageKey: string`, `alt: string`, `caption?: string`, `width?: number`, `height?: number` | `ImageBlockData` |
308
+ | `FILE` | `storageKey: string`, `filename: string`, `size?: number` | `FileBlockData` |
309
+
310
+ `CoreBlockData` maps each `type` string to its shape, for a host writing one function over all five.
311
+
312
+ Two of these are worth saying out loud, because both have been guessed wrong:
313
+
314
+ - **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.
315
+ - **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.
316
+
317
+ 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.
274
318
 
275
319
  ### Reaching uploads from a custom block
276
320
 
@@ -422,6 +466,8 @@ export function Reader({ blocks }: { blocks: QuoinBlock[] }) {
422
466
 
423
467
  `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.
424
468
 
469
+ 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.
470
+
425
471
  ## Theming
426
472
 
427
473
  `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:
@@ -444,6 +490,9 @@ export function Reader({ blocks }: { blocks: QuoinBlock[] }) {
444
490
  --quoin-accent: oklch(0.97 0 0);
445
491
  --quoin-accent-foreground: oklch(0.205 0 0);
446
492
  --quoin-destructive: oklch(0.577 0.245 27.325);
493
+ --quoin-info: oklch(0.55 0.17 250);
494
+ --quoin-success: oklch(0.55 0.14 155);
495
+ --quoin-warning: oklch(0.65 0.16 65);
447
496
  --quoin-radius: 0.625rem;
448
497
  }
449
498
 
@@ -464,6 +513,9 @@ export function Reader({ blocks }: { blocks: QuoinBlock[] }) {
464
513
  --quoin-accent: oklch(0.269 0 0);
465
514
  --quoin-accent-foreground: oklch(0.985 0 0);
466
515
  --quoin-destructive: oklch(0.704 0.191 22.216);
516
+ --quoin-info: oklch(0.72 0.14 250);
517
+ --quoin-success: oklch(0.74 0.15 155);
518
+ --quoin-warning: oklch(0.79 0.15 65);
467
519
  --quoin-radius: 0.625rem;
468
520
  }
469
521
  ```
@@ -502,10 +554,15 @@ Point the prefixed names at your own in one block on `.quoin`. The canvas root c
502
554
  --quoin-accent: var(--accent);
503
555
  --quoin-accent-foreground: var(--accent-foreground);
504
556
  --quoin-destructive: var(--destructive);
557
+ --quoin-info: var(--info);
558
+ --quoin-success: var(--success);
559
+ --quoin-warning: var(--warning);
505
560
  --quoin-radius: var(--radius);
506
561
  }
507
562
  ```
508
563
 
564
+ `--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.
565
+
509
566
  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
567
 
511
568
  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.
@@ -545,6 +602,20 @@ All of Quoin's own rules are in cascade layers, so an **unlayered** global reset
545
602
 
546
603
  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.
547
604
 
605
+ ### Tailwind, or no Tailwind
606
+
607
+ `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.
608
+
609
+ If you do use Tailwind, both major versions work, and what you have to do differs:
610
+
611
+ | Your setup | What to do |
612
+ | --- | --- |
613
+ | **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. |
614
+ | **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. |
615
+ | **No Tailwind** | Nothing. Import the stylesheet and the editor is styled. |
616
+
617
+ **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.
618
+
548
619
  ### Cascade layers and import order
549
620
 
550
621
  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.
@@ -646,7 +717,7 @@ The main entry carries a `'use client'` banner, so a React Server Component has
646
717
 
647
718
  ## Exports
648
719
 
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).
720
+ 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`.
650
721
 
651
722
  From `quoin-editor/headless`: the transforms and the two hooks listed above, with no UI.
652
723
 
package/dist/index.cjs CHANGED
@@ -104,12 +104,12 @@ function blockByType(definitions, type) {
104
104
  return void 0;
105
105
  }
106
106
  function paletteBlocks(definitions) {
107
- const seen = /* @__PURE__ */ new Set();
107
+ const seen2 = /* @__PURE__ */ new Set();
108
108
  const out = [];
109
109
  for (let i = definitions.length - 1; i >= 0; i -= 1) {
110
110
  const d = definitions[i];
111
- if (seen.has(d.type)) continue;
112
- seen.add(d.type);
111
+ if (seen2.has(d.type)) continue;
112
+ seen2.add(d.type);
113
113
  if (d.inSlashPalette === false) continue;
114
114
  out.push(d);
115
115
  }
@@ -1519,12 +1519,12 @@ function FileBlockEditor({ data, onChange }) {
1519
1519
  // src/blocks/previews.tsx
1520
1520
  var import_jsx_runtime18 = require("react/jsx-runtime");
1521
1521
  var HINT = "text-sm text-muted-foreground";
1522
- var TONE_BORDER = {
1523
- info: "border-primary",
1524
- tip: "border-accent-foreground",
1525
- warning: "border-muted-foreground",
1526
- success: "border-secondary-foreground",
1527
- danger: "border-destructive"
1522
+ var TONE = {
1523
+ info: "border-info bg-info/15",
1524
+ tip: "border-primary bg-primary/15",
1525
+ warning: "border-warning bg-warning/15",
1526
+ success: "border-success bg-success/15",
1527
+ danger: "border-destructive bg-destructive/15"
1528
1528
  };
1529
1529
  function MarkdownPreview({ data }) {
1530
1530
  const markdown = str(data.markdown);
@@ -1551,8 +1551,8 @@ function CalloutPreview({ data }) {
1551
1551
  const body = str(data.body);
1552
1552
  if (body.trim() === "") return /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("p", { className: HINT, children: "Empty callout" });
1553
1553
  const tone = str(data.tone) || "info";
1554
- const border = TONE_BORDER[tone] ?? "border-border";
1555
- return /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { "data-tone": tone, className: `rounded-md border-l-4 ${border} bg-muted/50 p-3 text-sm`, children: body });
1554
+ const paint = TONE[tone] ?? "border-border bg-muted/50";
1555
+ return /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { "data-tone": tone, className: `rounded-md border-l-4 ${paint} p-3 text-sm`, children: body });
1556
1556
  }
1557
1557
 
1558
1558
  // src/blocks/core.ts
@@ -1866,9 +1866,8 @@ function BlockEditor({
1866
1866
  onDone
1867
1867
  }) {
1868
1868
  const { blockTypes } = useQuoin();
1869
- const definition = blockByType(blockTypes, type);
1870
- if (!definition) return null;
1871
- const Editor = definition.editor;
1869
+ const Editor = blockByType(blockTypes, type)?.editor;
1870
+ if (!Editor) return null;
1872
1871
  return /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(Editor, { data, onChange, onInsertBlock, onDone });
1873
1872
  }
1874
1873
 
@@ -1917,6 +1916,15 @@ function sourceOffsetFor(source, probe) {
1917
1916
  return search(source, probe);
1918
1917
  }
1919
1918
 
1919
+ // src/dev-warn.ts
1920
+ var seen = /* @__PURE__ */ new Set();
1921
+ function devWarn(message) {
1922
+ if (typeof process !== "undefined" && process?.env?.NODE_ENV === "production") return;
1923
+ if (seen.has(message)) return;
1924
+ seen.add(message);
1925
+ console.warn(`Quoin: ${message}`);
1926
+ }
1927
+
1920
1928
  // src/canvas/BlockCard.tsx
1921
1929
  var import_jsx_runtime22 = require("react/jsx-runtime");
1922
1930
  var INTERACTIVE_SELECTOR = 'a, button, input, textarea, select, [role="button"], [tabindex], iframe';
@@ -1938,7 +1946,8 @@ function BlockCardImpl({
1938
1946
  const { blockTypes, onError } = useQuoin();
1939
1947
  const definition = blockByType(blockTypes, block.type);
1940
1948
  const Preview = definition?.preview;
1941
- const [editing, setEditing] = React8.useState(editingByDefault && !readOnly);
1949
+ const editable = Boolean(definition?.editor) && !readOnly;
1950
+ const [editing, setEditing] = React8.useState(editingByDefault && editable);
1942
1951
  const editButtonRef = React8.useRef(null);
1943
1952
  const editorContainerRef = React8.useRef(null);
1944
1953
  const returnFocusOnClose = React8.useRef(false);
@@ -2018,7 +2027,13 @@ function BlockCardImpl({
2018
2027
  reportedType.current = block.type;
2019
2028
  onError(new Error(`No block type is registered for "${block.type}".`));
2020
2029
  }, [definition, block.type, onError]);
2021
- if (definition?.alwaysEditing && !readOnly) {
2030
+ React8.useEffect(() => {
2031
+ if (!readOnly || !definition || definition.preview) return;
2032
+ devWarn(
2033
+ `the block type "${block.type}" has no \`preview\`, so a read-only canvas can draw only its label. Give its definition a \`preview\` to render the block itself.`
2034
+ );
2035
+ }, [readOnly, definition, block.type]);
2036
+ if (definition?.alwaysEditing && editable) {
2022
2037
  return /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
2023
2038
  BlockEditor,
2024
2039
  {
@@ -2058,7 +2073,7 @@ function BlockCardImpl({
2058
2073
  );
2059
2074
  }
2060
2075
  const rest = Preview ? /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(Preview, { data: block.data, blockId: block.id }) : /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("p", { className: "py-1 text-sm text-muted-foreground", children: definition?.label ?? block.type });
2061
- if (readOnly) return /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("div", { children: rest });
2076
+ if (!editable) return /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("div", { children: rest });
2062
2077
  return /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { className: "group/card relative", children: [
2063
2078
  /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
2064
2079
  "div",
@@ -2514,6 +2529,9 @@ function BlockCanvas({
2514
2529
 
2515
2530
  // src/hooks/useAutosave.ts
2516
2531
  var React12 = __toESM(require("react"), 1);
2532
+ function reportedFailure(result) {
2533
+ return typeof result === "object" && result !== null && "ok" in result && result.ok === false;
2534
+ }
2517
2535
  function useAutosave({
2518
2536
  deps,
2519
2537
  build,
@@ -2537,7 +2555,7 @@ function useAutosave({
2537
2555
  setStatus("saving");
2538
2556
  try {
2539
2557
  const res = await saveRef.current(buildRef.current());
2540
- if (res && res.ok === false) throw new Error("save rejected");
2558
+ if (reportedFailure(res)) throw new Error("save rejected");
2541
2559
  retryRef.current = 0;
2542
2560
  dirtyRef.current = false;
2543
2561
  setStatus("saved");
@@ -2553,10 +2571,10 @@ function useAutosave({
2553
2571
  });
2554
2572
  const seenRef = React12.useRef(null);
2555
2573
  React12.useEffect(() => {
2556
- const seen = seenRef.current;
2574
+ const seen2 = seenRef.current;
2557
2575
  seenRef.current = deps;
2558
- if (seen === null) return;
2559
- const unchanged = seen.length === deps.length && seen.every((v, i) => v === deps[i]);
2576
+ if (seen2 === null) return;
2577
+ const unchanged = seen2.length === deps.length && seen2.every((v, i) => v === deps[i]);
2560
2578
  if (!unchanged) dirtyRef.current = true;
2561
2579
  }, deps);
2562
2580
  React12.useEffect(() => {