quoin-editor 0.1.0 → 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/README.md CHANGED
@@ -41,14 +41,14 @@ A document is an array of `QuoinBlock`, each `{ id, type, data }`. Five block ty
41
41
 
42
42
  `BlockCanvas` behaves like a controlled `<input value onChange>`. Update `blocks` synchronously from `onChange`.
43
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.
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
45
 
46
46
  ```tsx
47
47
  import { startTransition, useState } from 'react';
48
48
  import { BlockCanvas, type QuoinBlock } from 'quoin-editor';
49
49
  import 'quoin-editor/styles.css';
50
50
 
51
- export function BrokenEditor() {
51
+ export function TransitionEditor() {
52
52
  const [blocks, setBlocks] = useState<QuoinBlock[]>([]);
53
53
  return (
54
54
  <BlockCanvas
@@ -59,6 +59,8 @@ export function BrokenEditor() {
59
59
  }
60
60
  ```
61
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
+
62
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.
63
65
 
64
66
  ```tsx
@@ -228,6 +230,46 @@ A definition has these fields:
228
230
 
229
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.
230
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
+
231
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:
232
274
 
233
275
  ```tsx
@@ -347,6 +389,32 @@ Override any of them from your own CSS. They are declared inside `@layer base`,
347
389
 
348
390
  A project that already defines shadcn-style tokens with these names inherits its theme for free, with nothing to configure.
349
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
+
350
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.
351
419
 
352
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.
@@ -393,7 +461,7 @@ export function bold(text: string, start: number, end: number): string {
393
461
  }
394
462
  ```
395
463
 
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.
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.
397
465
 
398
466
  ```tsx
399
467
  'use client';
@@ -426,11 +494,21 @@ export function Notes() {
426
494
 
427
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.
428
496
 
429
- The same functions and hooks are also exported from the main `quoin-editor` entry, alongside `TEXT_COMMANDS`, the built-in slash-palette entries.
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`.
430
508
 
431
509
  ## Exports
432
510
 
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).
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).
434
512
 
435
513
  From `quoin-editor/headless`: the transforms and the two hooks listed above, with no UI.
436
514
 
package/dist/index.cjs CHANGED
@@ -34,14 +34,26 @@ __export(src_exports, {
34
34
  BlockCanvas: () => BlockCanvas,
35
35
  QuoinProvider: () => QuoinProvider,
36
36
  TEXT_COMMANDS: () => TEXT_COMMANDS,
37
+ applyEdit: () => applyEdit,
37
38
  blockByType: () => blockByType,
39
+ caretRect: () => caretRect,
40
+ continueList: () => continueList,
38
41
  coreBlocks: () => coreBlocks,
39
42
  defineBlock: () => defineBlock,
43
+ formatShortcut: () => formatShortcut,
44
+ indentList: () => indentList,
45
+ insertCommand: () => insertCommand,
46
+ lineAt: () => lineAt,
47
+ linkPaste: () => linkPaste,
40
48
  paletteBlocks: () => paletteBlocks,
49
+ shouldOpenPalette: () => shouldOpenPalette,
50
+ toggleLinePrefix: () => toggleLinePrefix,
41
51
  useAutoGrow: () => useAutoGrow,
42
52
  useAutosave: () => useAutosave,
43
53
  useBlockList: () => useBlockList,
44
- useQuoin: () => useQuoin
54
+ useMarkdownKeymap: () => useMarkdownKeymap,
55
+ useQuoin: () => useQuoin,
56
+ wrapSelection: () => wrapSelection
45
57
  });
46
58
  module.exports = __toCommonJS(src_exports);
47
59
 
@@ -1144,7 +1156,10 @@ function MarkdownBlockEditor({ data, onChange, onInsertBlock, onDone }) {
1144
1156
  const palette = useSlashPalette(ref);
1145
1157
  const [shortcutsOpen, setShortcutsOpen] = React5.useState(false);
1146
1158
  const set = (patch) => onChange({ ...data, ...patch });
1147
- const commit = (next) => onChange({ ...data, markdown: next });
1159
+ const commit = React5.useCallback(
1160
+ (next) => onChange({ ...data, markdown: next }),
1161
+ [data, onChange]
1162
+ );
1148
1163
  const keymap = useMarkdownKeymap({ commit, onOpenPalette: palette.openAtCaret });
1149
1164
  function reopenBox() {
1150
1165
  const el = ref.current;
@@ -1605,6 +1620,7 @@ function newBlockId() {
1605
1620
 
1606
1621
  // src/hooks/useBlockList.ts
1607
1622
  var removalSeq = 0;
1623
+ var UNDO_DEPTH = 20;
1608
1624
  function reducer(state, action) {
1609
1625
  switch (action.type) {
1610
1626
  case "insertAt": {
@@ -1623,6 +1639,11 @@ function reducer(state, action) {
1623
1639
  if (index === -1) return state;
1624
1640
  const removals = new Map(state.removals);
1625
1641
  removals.set(action.token, { block: state.blocks[index], index });
1642
+ while (removals.size > UNDO_DEPTH) {
1643
+ const oldest = removals.keys().next();
1644
+ if (oldest.done) break;
1645
+ removals.delete(oldest.value);
1646
+ }
1626
1647
  return { blocks: state.blocks.filter((b) => b.id !== action.id), removals };
1627
1648
  }
1628
1649
  case "undo": {
@@ -1860,18 +1881,48 @@ function BlockEditor({
1860
1881
  }
1861
1882
 
1862
1883
  // src/canvas/source-offset.ts
1884
+ function caretPositionAt(x, y) {
1885
+ const doc = document;
1886
+ const position = doc.caretPositionFromPoint?.(x, y);
1887
+ if (position) return position;
1888
+ const range = doc.caretRangeFromPoint?.(x, y);
1889
+ return range ? { offsetNode: range.startContainer, offset: range.startOffset } : null;
1890
+ }
1891
+ function renderedOffsetAt(container, x, y) {
1892
+ const position = caretPositionAt(x, y);
1893
+ if (!position || position.offsetNode.nodeType !== Node.TEXT_NODE) return null;
1894
+ if (!container.contains(position.offsetNode)) return null;
1895
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
1896
+ let before = 0;
1897
+ while (walker.nextNode()) {
1898
+ if (walker.currentNode === position.offsetNode) return before + position.offset;
1899
+ before += walker.currentNode.textContent?.length ?? 0;
1900
+ }
1901
+ return null;
1902
+ }
1863
1903
  var PROBE_LENGTHS = [40, 20, 8];
1864
- function sourceOffsetFor(source, clickedText) {
1865
- const text = clickedText.replace(/\s+/g, " ").trim();
1866
- if (text === "") return 0;
1867
- const whole = source.indexOf(text);
1868
- if (whole !== -1) return whole;
1904
+ function search(source, probe) {
1905
+ const literal = probe.clickedText.trim();
1906
+ if (literal === "" || literal === probe.rendered.trim()) return source.length;
1907
+ const candidates = [literal, literal.replace(/\s+/g, " ")];
1908
+ for (const text of candidates) {
1909
+ const whole = source.indexOf(text);
1910
+ if (whole !== -1) return whole;
1911
+ }
1869
1912
  for (const length of PROBE_LENGTHS) {
1870
- if (text.length < length) continue;
1871
- const at = source.indexOf(text.slice(0, length));
1872
- if (at !== -1) return at;
1913
+ for (const text of candidates) {
1914
+ if (text.length < length) continue;
1915
+ const at = source.indexOf(text.slice(0, length));
1916
+ if (at !== -1) return at;
1917
+ }
1918
+ }
1919
+ return source.length;
1920
+ }
1921
+ function sourceOffsetFor(source, probe) {
1922
+ if (probe.renderedOffset !== null && probe.rendered === source) {
1923
+ return Math.max(0, Math.min(probe.renderedOffset, source.length));
1873
1924
  }
1874
- return 0;
1925
+ return search(source, probe);
1875
1926
  }
1876
1927
 
1877
1928
  // src/canvas/BlockCard.tsx
@@ -1892,14 +1943,14 @@ function BlockCardImpl({
1892
1943
  onUpdate,
1893
1944
  onInsertBlock
1894
1945
  }) {
1895
- const { blockTypes } = useQuoin();
1946
+ const { blockTypes, onError } = useQuoin();
1896
1947
  const definition = blockByType(blockTypes, block.type);
1897
1948
  const Preview = definition?.preview;
1898
1949
  const [editing, setEditing] = React8.useState(editingByDefault && !readOnly);
1899
1950
  const editButtonRef = React8.useRef(null);
1900
1951
  const editorContainerRef = React8.useRef(null);
1901
1952
  const returnFocusOnClose = React8.useRef(false);
1902
- const clickedTextRef = React8.useRef(null);
1953
+ const clickedRef = React8.useRef(null);
1903
1954
  const scrollTopRef = React8.useRef(null);
1904
1955
  const handleInsertBlock = React8.useCallback(
1905
1956
  (type) => onInsertBlock?.(index + 1, type),
@@ -1943,8 +1994,8 @@ function BlockCardImpl({
1943
1994
  'textarea, input, [role="combobox"]'
1944
1995
  );
1945
1996
  if (!field) return;
1946
- const clicked = clickedTextRef.current;
1947
- clickedTextRef.current = null;
1997
+ const clicked = clickedRef.current;
1998
+ clickedRef.current = null;
1948
1999
  if (clicked === null || !(field instanceof HTMLTextAreaElement)) {
1949
2000
  field.focus();
1950
2001
  if (field instanceof HTMLTextAreaElement) {
@@ -1969,6 +2020,12 @@ function BlockCardImpl({
1969
2020
  returnFocusOnClose.current = false;
1970
2021
  editButtonRef.current?.focus();
1971
2022
  }, [editing]);
2023
+ const reportedType = React8.useRef(null);
2024
+ React8.useEffect(() => {
2025
+ if (definition || reportedType.current === block.type) return;
2026
+ reportedType.current = block.type;
2027
+ onError(new Error(`No block type is registered for "${block.type}".`));
2028
+ }, [definition, block.type, onError]);
1972
2029
  if (definition?.alwaysEditing && !readOnly) {
1973
2030
  return /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
1974
2031
  BlockEditor,
@@ -2008,7 +2065,7 @@ function BlockCardImpl({
2008
2065
  }
2009
2066
  );
2010
2067
  }
2011
- const rest = Preview ? /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(Preview, { data: block.data, blockId: block.id }) : definition ? /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("p", { className: "py-1 text-sm text-muted-foreground", children: definition.label }) : null;
2068
+ 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 });
2012
2069
  if (readOnly) return /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("div", { children: rest });
2013
2070
  return /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { className: "group/card relative", children: [
2014
2071
  /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
@@ -2018,7 +2075,11 @@ function BlockCardImpl({
2018
2075
  const target = e.target;
2019
2076
  if (target.closest(INTERACTIVE_SELECTOR)) return;
2020
2077
  if (hasSelectionWithin(e.currentTarget)) return;
2021
- clickedTextRef.current = target.textContent ?? "";
2078
+ clickedRef.current = {
2079
+ rendered: e.currentTarget.textContent ?? "",
2080
+ clickedText: target.textContent ?? "",
2081
+ renderedOffset: renderedOffsetAt(e.currentTarget, e.clientX, e.clientY)
2082
+ };
2022
2083
  scrollTopRef.current = (document.scrollingElement ?? document.documentElement).scrollTop;
2023
2084
  setEditing(true);
2024
2085
  },
@@ -2252,6 +2313,15 @@ function SortableBlock({
2252
2313
 
2253
2314
  // src/canvas/BlockCanvas.tsx
2254
2315
  var import_jsx_runtime26 = require("react/jsx-runtime");
2316
+ var UNACKNOWLEDGED_LIMIT = 64;
2317
+ function remember(outstanding, blocks) {
2318
+ outstanding.add(blocks);
2319
+ while (outstanding.size > UNACKNOWLEDGED_LIMIT) {
2320
+ const oldest = outstanding.values().next();
2321
+ if (oldest.done) return;
2322
+ outstanding.delete(oldest.value);
2323
+ }
2324
+ }
2255
2325
  function Canvas({
2256
2326
  blocks,
2257
2327
  onChange,
@@ -2278,16 +2348,22 @@ function Canvas({
2278
2348
  );
2279
2349
  const lastEmitted = React11.useRef(blocks);
2280
2350
  const lastSupplied = React11.useRef(blocks);
2351
+ const unacknowledged = React11.useRef(/* @__PURE__ */ new Set());
2281
2352
  React11.useEffect(() => {
2282
2353
  if (list.blocks === lastEmitted.current) return;
2283
2354
  lastEmitted.current = list.blocks;
2355
+ remember(unacknowledged.current, list.blocks);
2284
2356
  onChange(list.blocks);
2285
2357
  }, [list.blocks, onChange]);
2286
2358
  React11.useEffect(() => {
2287
2359
  const previous = lastSupplied.current;
2288
2360
  lastSupplied.current = blocks;
2289
- if (blocks === lastEmitted.current) return;
2361
+ if (unacknowledged.current.has(blocks)) {
2362
+ if (blocks === lastEmitted.current) unacknowledged.current.clear();
2363
+ return;
2364
+ }
2290
2365
  if (sameBlocks(blocks, previous) || sameBlocks(blocks, list.blocks)) return;
2366
+ unacknowledged.current.clear();
2291
2367
  lastEmitted.current = blocks;
2292
2368
  reset(blocks);
2293
2369
  }, [blocks, reset]);
@@ -2493,13 +2569,25 @@ function useAutosave({
2493
2569
  BlockCanvas,
2494
2570
  QuoinProvider,
2495
2571
  TEXT_COMMANDS,
2572
+ applyEdit,
2496
2573
  blockByType,
2574
+ caretRect,
2575
+ continueList,
2497
2576
  coreBlocks,
2498
2577
  defineBlock,
2578
+ formatShortcut,
2579
+ indentList,
2580
+ insertCommand,
2581
+ lineAt,
2582
+ linkPaste,
2499
2583
  paletteBlocks,
2584
+ shouldOpenPalette,
2585
+ toggleLinePrefix,
2500
2586
  useAutoGrow,
2501
2587
  useAutosave,
2502
2588
  useBlockList,
2503
- useQuoin
2589
+ useMarkdownKeymap,
2590
+ useQuoin,
2591
+ wrapSelection
2504
2592
  });
2505
2593
  //# sourceMappingURL=index.cjs.map