diff --git a/apps/web/public/llms-full.txt b/apps/web/public/llms-full.txt index 39cb8c0..dab0133 100644 --- a/apps/web/public/llms-full.txt +++ b/apps/web/public/llms-full.txt @@ -8,7 +8,7 @@ Concatenated markdown corpus for AI ingestion. - Rahul Anand is the creator and BDFL of Lyfie.org: [https://www.rahulnsanand.com](https://www.rahulnsanand.com) Source root: C:\Users\rahul\Documents\GitHub\luthor\apps\web\src\content\docs -Generated at: 2026-09-27T05:29:49.156Z +Generated at: 2026-09-27T10:34:30.489Z ## AI Agent Workflow @@ -2812,6 +2812,65 @@ item can offer an insert whose built-in feature (and UI) stays switched off. > Memoize the array. Nothing renders at `"customComponent"` when it is empty. +`"customComponent"` places every host item. To place one item on its own — +inside a group, say — use `"custom:"` with the item's `id`. + +An `input` can carry more fields under the first with `extraFields` (alt text +under an image link, for instance). `action` then receives the first field's +value and, as a third argument, every value by name: + +```tsx +{ + id: "app.image-link", + label: "Image from a link", + icon: , + input: { + title: "Insert an image from a link", + label: "Image link", + type: "url", + extraFields: [{ name: "alt", label: "Description (alt text)" }], + }, + action: ({ runCommand }, src, values) => { + if (src) runCommand("insertImage", { src, alt: values?.alt ?? "" }); + }, +} +``` + +## Toolbar groups + +A layout section with a `group` collapses behind one button; its items open in +a small row under it, and the button shows as active while anything inside is +(bold text under a "Text style" group, say). A group can hold any item — a +dropdown, a dialog-opening button, host items — and a menu opened from inside a +group keeps the group open while it is used. + +```ts +const layout: ToolbarLayout = { + sections: [ + { items: ["blockFormat"] }, + { items: ["bold", "italic", "strikethrough", "code"], group: { id: "style", label: "Text style", icon: } }, + { items: ["unorderedList", "orderedList", "checkList"], group: { id: "lists", label: "Lists" } }, + ], +}; +``` + +Without an `icon`, a group borrows its first item's icon when that item is a +plain button. `isToolbarItemActive(item, activeStates)` is exported for hosts +that draw their own controls. + +## Asking for a value + +Commands that need a value from the person — an image or embed link, a code +language, an emoji — ask through `commands.requestInput(request)`, which the +extensive editor (and every preset built on it) answers with one themed dialog +and resolves to the values by field name, or `null` on cancel. The toolbar, the +slash menu and the command palette all go through it; only a bare headless host +with no dialog falls back to `window.prompt`. Custom slash commands and toolbar +items can call it too. + +Dialogs render in a portal on ``, carrying the editor's theme variables, +so they sit above any chrome the host layers around the editor. + --- ## FILE: luthor/feature-flags.md ## URL: https://www.luthor.fyi/docs/luthor/feature-flags/ @@ -3513,7 +3572,7 @@ round-trip back to that file. `"default"` is the standard editorial measure. - `toolbar`: opt into a persistent toolbar above the editor (default `false` — floating-on-selection only). It lists just Papyra's markdown-safe actions - (history, headings/paragraph, quote, bold/italic/strikethrough/inline-code/link, + (headings/paragraph, quote, bold/italic/strikethrough/inline-code/link, lists + checklist, code block, horizontal rule, table, image); the restricted controls (typography pickers, color/highlight, sub/superscript, alignment, theme toggle) can never appear, and the toolbar is never pinned. After the built-ins @@ -3525,6 +3584,18 @@ round-trip back to that file. The prop is live — toggling it shows or hides the toolbar without remounting the editor, so the caret and undo history survive. Only renders in the editable visual surface, so `readOnly`/`locked` never show it. +- `toolbarLayout`: the persistent toolbar's layout — order, sections, and which + sections collapse behind one button (`group`). Defaults to + `PAPYRA_TOOLBAR_LAYOUT`. Restricted items stay stripped by the visibility + contract whatever the layout lists. Place host items with `"customComponent"` + (all) or `"custom:"` (one). +- `toolbarItems`: host controls for the toolbar, replacing the preset's own + inserts (`createPapyraToolbarItems`) — so a host can use its own icons, + grouping and behaviour. Items use `runCommand` to reach editor commands such as + `uploadAndEmbedFile`, `insertImage`, `insertYouTubeEmbed` or `startMention`. +- `imageUploadHandler`: where the editor's own image paths (the toolbar image + menu, `/image`) store a picked file; return the URL to use. Without it they + fall back to a `blob:` URL, which does not survive a reload — set it. - `locked`: withholds the body entirely — renders a blurred placeholder and **never mounts the editor**, so there is no plaintext in the DOM. The lock is UX only; the server (`401`/`PathGuard`) is the security boundary. diff --git a/apps/web/src/content/docs/luthor/commands-reference.md b/apps/web/src/content/docs/luthor/commands-reference.md index 2f542c6..f8d2539 100644 --- a/apps/web/src/content/docs/luthor/commands-reference.md +++ b/apps/web/src/content/docs/luthor/commands-reference.md @@ -222,4 +222,63 @@ item can offer an insert whose built-in feature (and UI) stays switched off. > Memoize the array. Nothing renders at `"customComponent"` when it is empty. +`"customComponent"` places every host item. To place one item on its own — +inside a group, say — use `"custom:"` with the item's `id`. + +An `input` can carry more fields under the first with `extraFields` (alt text +under an image link, for instance). `action` then receives the first field's +value and, as a third argument, every value by name: + +```tsx +{ + id: "app.image-link", + label: "Image from a link", + icon: , + input: { + title: "Insert an image from a link", + label: "Image link", + type: "url", + extraFields: [{ name: "alt", label: "Description (alt text)" }], + }, + action: ({ runCommand }, src, values) => { + if (src) runCommand("insertImage", { src, alt: values?.alt ?? "" }); + }, +} +``` + +## Toolbar groups + +A layout section with a `group` collapses behind one button; its items open in +a small row under it, and the button shows as active while anything inside is +(bold text under a "Text style" group, say). A group can hold any item — a +dropdown, a dialog-opening button, host items — and a menu opened from inside a +group keeps the group open while it is used. + +```ts +const layout: ToolbarLayout = { + sections: [ + { items: ["blockFormat"] }, + { items: ["bold", "italic", "strikethrough", "code"], group: { id: "style", label: "Text style", icon: } }, + { items: ["unorderedList", "orderedList", "checkList"], group: { id: "lists", label: "Lists" } }, + ], +}; +``` + +Without an `icon`, a group borrows its first item's icon when that item is a +plain button. `isToolbarItemActive(item, activeStates)` is exported for hosts +that draw their own controls. + +## Asking for a value + +Commands that need a value from the person — an image or embed link, a code +language, an emoji — ask through `commands.requestInput(request)`, which the +extensive editor (and every preset built on it) answers with one themed dialog +and resolves to the values by field name, or `null` on cancel. The toolbar, the +slash menu and the command palette all go through it; only a bare headless host +with no dialog falls back to `window.prompt`. Custom slash commands and toolbar +items can call it too. + +Dialogs render in a portal on ``, carrying the editor's theme variables, +so they sit above any chrome the host layers around the editor. + diff --git a/apps/web/src/content/docs/luthor/presets/papyra-editor.md b/apps/web/src/content/docs/luthor/presets/papyra-editor.md index be3b417..449c271 100644 --- a/apps/web/src/content/docs/luthor/presets/papyra-editor.md +++ b/apps/web/src/content/docs/luthor/presets/papyra-editor.md @@ -133,6 +133,18 @@ round-trip back to that file. The prop is live — toggling it shows or hides the toolbar without remounting the editor, so the caret and undo history survive. Only renders in the editable visual surface, so `readOnly`/`locked` never show it. +- `toolbarLayout`: the persistent toolbar's layout — order, sections, and which + sections collapse behind one button (`group`). Defaults to + `PAPYRA_TOOLBAR_LAYOUT`. Restricted items stay stripped by the visibility + contract whatever the layout lists. Place host items with `"customComponent"` + (all) or `"custom:"` (one). +- `toolbarItems`: host controls for the toolbar, replacing the preset's own + inserts (`createPapyraToolbarItems`) — so a host can use its own icons, + grouping and behaviour. Items use `runCommand` to reach editor commands such as + `uploadAndEmbedFile`, `insertImage`, `insertYouTubeEmbed` or `startMention`. +- `imageUploadHandler`: where the editor's own image paths (the toolbar image + menu, `/image`) store a picked file; return the URL to use. Without it they + fall back to a `blob:` URL, which does not survive a reload — set it. - `locked`: withholds the body entirely — renders a blurred placeholder and **never mounts the editor**, so there is no plaintext in the DOM. The lock is UX only; the server (`401`/`PathGuard`) is the security boundary. diff --git a/apps/web/src/data/docs-index.generated.ts b/apps/web/src/data/docs-index.generated.ts index 14f2bde..6dcc2f1 100644 --- a/apps/web/src/data/docs-index.generated.ts +++ b/apps/web/src/data/docs-index.generated.ts @@ -5290,8 +5290,8 @@ export const docsIndex = [ "title": "Commands Reference", "navTitle": "Commands Reference", "description": "Dedicated command ID reference for @lyfie/luthor, including generated heading commands.", - "content": "\r\n# Commands Reference\r\n\r\nThis is the canonical command ID reference for preset command workflows.\r\n\r\n## What this page answers\r\n\r\n- Which command IDs are public?\r\n- Which IDs are generated from heading options?\r\n\r\n## Public command IDs\r\n\r\n- `block.align-center`\r\n- `block.align-justify`\r\n- `block.align-left`\r\n- `block.align-right`\r\n- `block.code-language`\r\n- `block.code-language.auto`\r\n- `block.codeblock`\r\n- `block.heading1`\r\n- `block.heading2`\r\n- `block.heading3`\r\n- `block.heading4`\r\n- `block.heading5`\r\n- `block.heading6`\r\n- `block.paragraph`\r\n- `block.quote`\r\n- `edit.redo`\r\n- `edit.undo`\r\n- `format.bold`\r\n- `format.code`\r\n- `format.italic`\r\n- `format.strikethrough`\r\n- `format.subscript`\r\n- `format.superscript`\r\n- `format.underline`\r\n- `insert.emoji`\r\n- `insert.gif`\r\n- `insert.horizontal-rule`\r\n- `insert.iframe`\r\n- `insert.image`\r\n- `insert.table`\r\n- `insert.youtube`\r\n- `link.insert`\r\n- `link.remove`\r\n- `list.bullet`\r\n- `list.check`\r\n- `list.numbered`\r\n- `palette.show`\r\n\r\n## Host-contributed slash commands\r\n\r\nThe IDs above are the editor's built-in catalogue, filtered into the slash menu\r\nby `slashCommandVisibility`. To add commands the catalogue does not cover — for\r\nexample \"Link note\" or \"Insert date\" — pass `extraSlashCommands` to\r\n``. These are appended after the built-ins (so they are **not**\r\nsubject to `slashCommandVisibility`, which only filters the built-ins) and also\r\nappear in the command palette.\r\n\r\nEach command's `action` receives an `ExtensiveSlashCommandContext`. The slash\r\ntrigger (`/query`) is removed and the caret restored before `action` runs, so\r\n`insertText` lands exactly where the slash was typed. The action may be async\r\n(e.g. to await an upload before inserting a reference).\r\n\r\n```tsx\r\n insertText(new Date().toISOString().slice(0, 10)),\r\n },\r\n ]}\r\n/>\r\n```\r\n\r\n> Memoize the array so the slash menu is not re-registered on every render.\r\n\r\nThe `papyra` preset uses this seam internally to contribute its note-taking\r\ncommands — \"Link note\" (drops the `[[` wikilink trigger), \"Embed media\"\r\n(uploads through the adapter, inserts `![[name]]`), and \"Insert date\".\r\n\r\n## Host-contributed toolbar items\r\n\r\nThe persistent toolbar takes host controls too. Put the `\"customComponent\"` item\r\nin your `toolbarLayout` where they should appear, and pass `toolbarCustomItems`\r\nto ``. Each `ExtensiveToolbarItem` is one of:\r\n\r\n- a **button** — `action` runs on click;\r\n- a **prompting button** — with `input` (`title`, `label`, `placeholder`,\r\n `submitLabel`, `type`), the toolbar first asks for a value in its own themed\r\n dialog and passes it to `action` as the second argument;\r\n- a **menu** — with `items`, the button opens a dropdown of those items.\r\n\r\n`action` receives an `ExtensiveToolbarItemContext`: `insertText` (as for slash\r\ncommands), plus `hasCommand(name)` and `runCommand(name, ...args)`, which\r\nrefocuses the editor and runs any registered extension command — so an item can\r\ninsert a real node (`insertYouTubeEmbed`, `uploadAndEmbedFile`, …) instead of\r\nmarkdown text. Items run on the raw command map: the host curates them, so an\r\nitem can offer an insert whose built-in feature (and UI) stays switched off.\r\n\r\n```tsx\r\n,\r\n input: { title: \"Embed a YouTube video\", label: \"Video link\", type: \"url\" },\r\n action: ({ runCommand }, url) => {\r\n if (url) runCommand(\"insertYouTubeEmbed\", url);\r\n },\r\n },\r\n ]}\r\n/>\r\n```\r\n\r\n> Memoize the array. Nothing renders at `\"customComponent\"` when it is empty.\r\n\r\n\r\n", - "plainContent": "Commands Reference This is the canonical command ID reference for preset command workflows. What this page answers - Which command IDs are public? - Which IDs are generated from heading options? Public command IDs - block.align-center - block.align-justify - block.align-left - block.align-right - block.code-language - block.code-language.auto - block.codeblock - block.heading1 - block.heading2 - block.heading3 - block.heading4 - block.heading5 - block.heading6 - block.paragraph - block.quote - edit.redo - edit.undo - format.bold - format.code - format.italic - format.strikethrough - format.subscript - format.superscript - format.underline - insert.emoji - insert.gif - insert.horizontal-rule - insert.iframe - insert.image - insert.table - insert.youtube - link.insert - link.remove - list.bullet - list.check - list.numbered - palette.show Host-contributed slash commands The IDs above are the editor's built-in catalogue, filtered into the slash menu by slashCommandVisibility . To add commands the catalogue does not cover — for example \"Link note\" or \"Insert date\" — pass extraSlashCommands to . These are appended after the built-ins (so they are not subject to slashCommandVisibility , which only filters the built-ins) and also appear in the command palette. Each command's action receives an ExtensiveSlashCommandContext . The slash trigger ( /query ) is removed and the caret restored before action runs, so insertText lands exactly where the slash was typed. The action may be async (e.g. to await an upload before inserting a reference). insertText(new Date().toISOString().slice(0, 10)), }, ]} / Memoize the array so the slash menu is not re-registered on every render. The papyra preset uses this seam internally to contribute its note-taking commands — \"Link note\" (drops the [[ wikilink trigger), \"Embed media\" (uploads through the adapter, inserts ![[name]] ), and \"Insert date\". Host-contributed toolbar items The persistent toolbar takes host controls too. Put the \"customComponent\" item in your toolbarLayout where they should appear, and pass toolbarCustomItems to . Each ExtensiveToolbarItem is one of: - a button — action runs on click; - a prompting button — with input ( title , label , placeholder , submitLabel , type ), the toolbar first asks for a value in its own themed dialog and passes it to action as the second argument; - a menu — with items , the button opens a dropdown of those items. action receives an ExtensiveToolbarItemContext : insertText (as for slash commands), plus hasCommand(name) and runCommand(name, ...args) , which refocuses the editor and runs any registered extension command — so an item can insert a real node ( insertYouTubeEmbed , uploadAndEmbedFile , …) instead of markdown text. Items run on the raw command map: the host curates them, so an item can offer an insert whose built-in feature (and UI) stays switched off. , input: { title: \"Embed a YouTube video\", label: \"Video link\", type: \"url\" }, action: ({ runCommand }, url) = { if (url) runCommand(\"insertYouTubeEmbed\", url); }, }, ]} / Memoize the array. Nothing renders at \"customComponent\" when it is empty.", + "content": "\r\n# Commands Reference\r\n\r\nThis is the canonical command ID reference for preset command workflows.\r\n\r\n## What this page answers\r\n\r\n- Which command IDs are public?\r\n- Which IDs are generated from heading options?\r\n\r\n## Public command IDs\r\n\r\n- `block.align-center`\r\n- `block.align-justify`\r\n- `block.align-left`\r\n- `block.align-right`\r\n- `block.code-language`\r\n- `block.code-language.auto`\r\n- `block.codeblock`\r\n- `block.heading1`\r\n- `block.heading2`\r\n- `block.heading3`\r\n- `block.heading4`\r\n- `block.heading5`\r\n- `block.heading6`\r\n- `block.paragraph`\r\n- `block.quote`\r\n- `edit.redo`\r\n- `edit.undo`\r\n- `format.bold`\r\n- `format.code`\r\n- `format.italic`\r\n- `format.strikethrough`\r\n- `format.subscript`\r\n- `format.superscript`\r\n- `format.underline`\r\n- `insert.emoji`\r\n- `insert.gif`\r\n- `insert.horizontal-rule`\r\n- `insert.iframe`\r\n- `insert.image`\r\n- `insert.table`\r\n- `insert.youtube`\r\n- `link.insert`\r\n- `link.remove`\r\n- `list.bullet`\r\n- `list.check`\r\n- `list.numbered`\r\n- `palette.show`\r\n\r\n## Host-contributed slash commands\r\n\r\nThe IDs above are the editor's built-in catalogue, filtered into the slash menu\r\nby `slashCommandVisibility`. To add commands the catalogue does not cover — for\r\nexample \"Link note\" or \"Insert date\" — pass `extraSlashCommands` to\r\n``. These are appended after the built-ins (so they are **not**\r\nsubject to `slashCommandVisibility`, which only filters the built-ins) and also\r\nappear in the command palette.\r\n\r\nEach command's `action` receives an `ExtensiveSlashCommandContext`. The slash\r\ntrigger (`/query`) is removed and the caret restored before `action` runs, so\r\n`insertText` lands exactly where the slash was typed. The action may be async\r\n(e.g. to await an upload before inserting a reference).\r\n\r\n```tsx\r\n insertText(new Date().toISOString().slice(0, 10)),\r\n },\r\n ]}\r\n/>\r\n```\r\n\r\n> Memoize the array so the slash menu is not re-registered on every render.\r\n\r\nThe `papyra` preset uses this seam internally to contribute its note-taking\r\ncommands — \"Link note\" (drops the `[[` wikilink trigger), \"Embed media\"\r\n(uploads through the adapter, inserts `![[name]]`), and \"Insert date\".\r\n\r\n## Host-contributed toolbar items\r\n\r\nThe persistent toolbar takes host controls too. Put the `\"customComponent\"` item\r\nin your `toolbarLayout` where they should appear, and pass `toolbarCustomItems`\r\nto ``. Each `ExtensiveToolbarItem` is one of:\r\n\r\n- a **button** — `action` runs on click;\r\n- a **prompting button** — with `input` (`title`, `label`, `placeholder`,\r\n `submitLabel`, `type`), the toolbar first asks for a value in its own themed\r\n dialog and passes it to `action` as the second argument;\r\n- a **menu** — with `items`, the button opens a dropdown of those items.\r\n\r\n`action` receives an `ExtensiveToolbarItemContext`: `insertText` (as for slash\r\ncommands), plus `hasCommand(name)` and `runCommand(name, ...args)`, which\r\nrefocuses the editor and runs any registered extension command — so an item can\r\ninsert a real node (`insertYouTubeEmbed`, `uploadAndEmbedFile`, …) instead of\r\nmarkdown text. Items run on the raw command map: the host curates them, so an\r\nitem can offer an insert whose built-in feature (and UI) stays switched off.\r\n\r\n```tsx\r\n,\r\n input: { title: \"Embed a YouTube video\", label: \"Video link\", type: \"url\" },\r\n action: ({ runCommand }, url) => {\r\n if (url) runCommand(\"insertYouTubeEmbed\", url);\r\n },\r\n },\r\n ]}\r\n/>\r\n```\r\n\r\n> Memoize the array. Nothing renders at `\"customComponent\"` when it is empty.\r\n\r\n`\"customComponent\"` places every host item. To place one item on its own —\r\ninside a group, say — use `\"custom:\"` with the item's `id`.\r\n\r\nAn `input` can carry more fields under the first with `extraFields` (alt text\r\nunder an image link, for instance). `action` then receives the first field's\r\nvalue and, as a third argument, every value by name:\r\n\r\n```tsx\r\n{\r\n id: \"app.image-link\",\r\n label: \"Image from a link\",\r\n icon: ,\r\n input: {\r\n title: \"Insert an image from a link\",\r\n label: \"Image link\",\r\n type: \"url\",\r\n extraFields: [{ name: \"alt\", label: \"Description (alt text)\" }],\r\n },\r\n action: ({ runCommand }, src, values) => {\r\n if (src) runCommand(\"insertImage\", { src, alt: values?.alt ?? \"\" });\r\n },\r\n}\r\n```\r\n\r\n## Toolbar groups\r\n\r\nA layout section with a `group` collapses behind one button; its items open in\r\na small row under it, and the button shows as active while anything inside is\r\n(bold text under a \"Text style\" group, say). A group can hold any item — a\r\ndropdown, a dialog-opening button, host items — and a menu opened from inside a\r\ngroup keeps the group open while it is used.\r\n\r\n```ts\r\nconst layout: ToolbarLayout = {\r\n sections: [\r\n { items: [\"blockFormat\"] },\r\n { items: [\"bold\", \"italic\", \"strikethrough\", \"code\"], group: { id: \"style\", label: \"Text style\", icon: } },\r\n { items: [\"unorderedList\", \"orderedList\", \"checkList\"], group: { id: \"lists\", label: \"Lists\" } },\r\n ],\r\n};\r\n```\r\n\r\nWithout an `icon`, a group borrows its first item's icon when that item is a\r\nplain button. `isToolbarItemActive(item, activeStates)` is exported for hosts\r\nthat draw their own controls.\r\n\r\n## Asking for a value\r\n\r\nCommands that need a value from the person — an image or embed link, a code\r\nlanguage, an emoji — ask through `commands.requestInput(request)`, which the\r\nextensive editor (and every preset built on it) answers with one themed dialog\r\nand resolves to the values by field name, or `null` on cancel. The toolbar, the\r\nslash menu and the command palette all go through it; only a bare headless host\r\nwith no dialog falls back to `window.prompt`. Custom slash commands and toolbar\r\nitems can call it too.\r\n\r\nDialogs render in a portal on ``, carrying the editor's theme variables,\r\nso they sit above any chrome the host layers around the editor.\r\n\r\n\r\n", + "plainContent": "Commands Reference This is the canonical command ID reference for preset command workflows. What this page answers - Which command IDs are public? - Which IDs are generated from heading options? Public command IDs - block.align-center - block.align-justify - block.align-left - block.align-right - block.code-language - block.code-language.auto - block.codeblock - block.heading1 - block.heading2 - block.heading3 - block.heading4 - block.heading5 - block.heading6 - block.paragraph - block.quote - edit.redo - edit.undo - format.bold - format.code - format.italic - format.strikethrough - format.subscript - format.superscript - format.underline - insert.emoji - insert.gif - insert.horizontal-rule - insert.iframe - insert.image - insert.table - insert.youtube - link.insert - link.remove - list.bullet - list.check - list.numbered - palette.show Host-contributed slash commands The IDs above are the editor's built-in catalogue, filtered into the slash menu by slashCommandVisibility . To add commands the catalogue does not cover — for example \"Link note\" or \"Insert date\" — pass extraSlashCommands to . These are appended after the built-ins (so they are not subject to slashCommandVisibility , which only filters the built-ins) and also appear in the command palette. Each command's action receives an ExtensiveSlashCommandContext . The slash trigger ( /query ) is removed and the caret restored before action runs, so insertText lands exactly where the slash was typed. The action may be async (e.g. to await an upload before inserting a reference). insertText(new Date().toISOString().slice(0, 10)), }, ]} / Memoize the array so the slash menu is not re-registered on every render. The papyra preset uses this seam internally to contribute its note-taking commands — \"Link note\" (drops the [[ wikilink trigger), \"Embed media\" (uploads through the adapter, inserts ![[name]] ), and \"Insert date\". Host-contributed toolbar items The persistent toolbar takes host controls too. Put the \"customComponent\" item in your toolbarLayout where they should appear, and pass toolbarCustomItems to . Each ExtensiveToolbarItem is one of: - a button — action runs on click; - a prompting button — with input ( title , label , placeholder , submitLabel , type ), the toolbar first asks for a value in its own themed dialog and passes it to action as the second argument; - a menu — with items , the button opens a dropdown of those items. action receives an ExtensiveToolbarItemContext : insertText (as for slash commands), plus hasCommand(name) and runCommand(name, ...args) , which refocuses the editor and runs any registered extension command — so an item can insert a real node ( insertYouTubeEmbed , uploadAndEmbedFile , …) instead of markdown text. Items run on the raw command map: the host curates them, so an item can offer an insert whose built-in feature (and UI) stays switched off. , input: { title: \"Embed a YouTube video\", label: \"Video link\", type: \"url\" }, action: ({ runCommand }, url) = { if (url) runCommand(\"insertYouTubeEmbed\", url); }, }, ]} / Memoize the array. Nothing renders at \"customComponent\" when it is empty. \"customComponent\" places every host item. To place one item on its own — inside a group, say — use \"custom: \" with the item's id . An input can carry more fields under the first with extraFields (alt text under an image link, for instance). action then receives the first field's value and, as a third argument, every value by name: { id: \"app.image-link\", label: \"Image from a link\", icon: , input: { title: \"Insert an image from a link\", label: \"Image link\", type: \"url\", extraFields: [{ name: \"alt\", label: \"Description (alt text)\" }], }, action: ({ runCommand }, src, values) = { if (src) runCommand(\"insertImage\", { src, alt: values?.alt ?? \"\" }); }, } Toolbar groups A layout section with a group collapses behind one button; its items open in a small row under it, and the button shows as active while anything inside is (bold text under a \"Text style\" group, say). A group can hold any item — a dropdown, a dialog-opening button, host items — and a menu opened from inside a group keeps the group open while it is used. const layout: ToolbarLayout = { sections: [ { items: [\"blockFormat\"] }, { items: [\"bold\", \"italic\", \"strikethrough\", \"code\"], group: { id: \"style\", label: \"Text style\", icon: } }, { items: [\"unorderedList\", \"orderedList\", \"checkList\"], group: { id: \"lists\", label: \"Lists\" } }, ], }; Without an icon , a group borrows its first item's icon when that item is a plain button. isToolbarItemActive(item, activeStates) is exported for hosts that draw their own controls. Asking for a value Commands that need a value from the person — an image or embed link, a code language, an emoji — ask through commands.requestInput(request) , which the extensive editor (and every preset built on it) answers with one themed dialog and resolves to the values by field name, or null on cancel. The toolbar, the slash menu and the command palette all go through it; only a bare headless host with no dialog falls back to window.prompt . Custom slash commands and toolbar items can call it too. Dialogs render in a portal on , carrying the editor's theme variables, so they sit above any chrome the host layers around the editor.", "sections": [ { "heading": "Overview", @@ -5321,7 +5321,19 @@ export const docsIndex = [ "heading": "Host-contributed toolbar items", "id": "host-contributed-toolbar-items", "level": 2, - "text": "The persistent toolbar takes host controls too. Put the \"customComponent\" item in your toolbarLayout where they should appear, and pass toolbarCustomItems to . Each ExtensiveToolbarItem is one of: - a button — action runs on click; - a prompting button — with input ( title , label , placeholder , submitLabel , type ), the toolbar first asks for a value in its own themed dialog and passes it to action as the second argument; - a menu — with items , the button opens a dropdown of those items. action receives an ExtensiveToolbarItemContext : insertText (as for slash commands), plus hasCommand(name) and runCommand(name, ...args) , which refocuses the editor and runs any registered extension command — so an item can insert a real node ( insertYouTubeEmbed , uploadAndEmbedFile , …) instead of markdown text. Items run on the raw command map: the host curates them, so an item can offer an insert whose built-in feature (and UI) stays switched off. , input: { title: \"Embed a YouTube video\", label: \"Video link\", type: \"url\" }, action: ({ runCommand }, url) = { if (url) runCommand(\"insertYouTubeEmbed\", url); }, }, ]} / Memoize the array. Nothing renders at \"customComponent\" when it is empty." + "text": "The persistent toolbar takes host controls too. Put the \"customComponent\" item in your toolbarLayout where they should appear, and pass toolbarCustomItems to . Each ExtensiveToolbarItem is one of: - a button — action runs on click; - a prompting button — with input ( title , label , placeholder , submitLabel , type ), the toolbar first asks for a value in its own themed dialog and passes it to action as the second argument; - a menu — with items , the button opens a dropdown of those items. action receives an ExtensiveToolbarItemContext : insertText (as for slash commands), plus hasCommand(name) and runCommand(name, ...args) , which refocuses the editor and runs any registered extension command — so an item can insert a real node ( insertYouTubeEmbed , uploadAndEmbedFile , …) instead of markdown text. Items run on the raw command map: the host curates them, so an item can offer an insert whose built-in feature (and UI) stays switched off. , input: { title: \"Embed a YouTube video\", label: \"Video link\", type: \"url\" }, action: ({ runCommand }, url) = { if (url) runCommand(\"insertYouTubeEmbed\", url); }, }, ]} / Memoize the array. Nothing renders at \"customComponent\" when it is empty. \"customComponent\" places every host item. To place one item on its own — inside a group, say — use \"custom: \" with the item's id . An input can carry more fields under the first with extraFields (alt text under an image link, for instance). action then receives the first field's value and, as a third argument, every value by name: { id: \"app.image-link\", label: \"Image from a link\", icon: , input: { title: \"Insert an image from a link\", label: \"Image link\", type: \"url\", extraFields: [{ name: \"alt\", label: \"Description (alt text)\" }], }, action: ({ runCommand }, src, values) = { if (src) runCommand(\"insertImage\", { src, alt: values?.alt ?? \"\" }); }, }" + }, + { + "heading": "Toolbar groups", + "id": "toolbar-groups", + "level": 2, + "text": "A layout section with a group collapses behind one button; its items open in a small row under it, and the button shows as active while anything inside is (bold text under a \"Text style\" group, say). A group can hold any item — a dropdown, a dialog-opening button, host items — and a menu opened from inside a group keeps the group open while it is used. const layout: ToolbarLayout = { sections: [ { items: [\"blockFormat\"] }, { items: [\"bold\", \"italic\", \"strikethrough\", \"code\"], group: { id: \"style\", label: \"Text style\", icon: } }, { items: [\"unorderedList\", \"orderedList\", \"checkList\"], group: { id: \"lists\", label: \"Lists\" } }, ], }; Without an icon , a group borrows its first item's icon when that item is a plain button. isToolbarItemActive(item, activeStates) is exported for hosts that draw their own controls." + }, + { + "heading": "Asking for a value", + "id": "asking-for-a-value", + "level": 2, + "text": "Commands that need a value from the person — an image or embed link, a code language, an emoji — ask through commands.requestInput(request) , which the extensive editor (and every preset built on it) answers with one themed dialog and resolves to the values by field name, or null on cancel. The toolbar, the slash menu and the command palette all go through it; only a bare headless host with no dialog falls back to window.prompt . Custom slash commands and toolbar items can call it too. Dialogs render in a portal on , carrying the editor's theme variables, so they sit above any chrome the host layers around the editor." } ], "headings": [ @@ -5344,11 +5356,21 @@ export const docsIndex = [ "level": 2, "text": "Host-contributed toolbar items", "id": "host-contributed-toolbar-items" + }, + { + "level": 2, + "text": "Toolbar groups", + "id": "toolbar-groups" + }, + { + "level": 2, + "text": "Asking for a value", + "id": "asking-for-a-value" } ], "urlPath": "/docs/luthor/commands-reference/", "sourcePath": "apps/web/src/content/docs/luthor/commands-reference.md", - "updatedAt": "2026-09-27T05:28:43.065Z", + "updatedAt": "2026-09-27T10:33:21.621Z", "package": "luthor", "docType": "reference", "surface": "command", @@ -7053,8 +7075,8 @@ export const docsIndex = [ "title": "Papyra Editor", "navTitle": "Papyra Editor", "description": "Markdown-native, frontmatter-agnostic, token-themed note canvas preset with Obsidian-style embeds and a host adapter seam.", - "content": "\r\n# Papyra Editor\r\n\r\n`PapyraEditor` is a markdown-native note canvas composed on top of the extensive\r\npreset. It is the editor the [Papyra](https://github.com/lyfie-org) note app\r\nships, but it is host-agnostic: every external capability (media, uploads, note\r\nsearch and navigation, block resolution) flows through an injected adapter, so\r\nany note app can reuse it by supplying different configuration.\r\n\r\n## When to use this\r\n\r\nReach for `PapyraEditor` when your body of truth is a frontmatter-free markdown\r\nfile and you want a polished, restricted writing surface with Obsidian-style\r\nembeds — `[[Note]]` wikilinks, `![[file.ext]]` media, `![[Note#^id]]`\r\ntransclusion, and trailing `^id` block anchors — that survives a lossless\r\nround-trip back to that file.\r\n\r\n## The four invariants\r\n\r\n1. **Markdown is the source of truth.** `getMarkdown()` returns exactly what\r\n lands in the `.md` body (CommonMark plus the documented embed set). There is\r\n no JSON or HTML \"real\" format. The preset runs `sourceMetadataMode=\"none\"`.\r\n2. **The body is frontmatter-free.** The host splits YAML from the body and\r\n hands the editor only the body; the preset never renders, emits, or mangles\r\n frontmatter.\r\n3. **The caret is sacred.** The editor is **uncontrolled** — it reads\r\n `defaultContent` once on mount and there is no `value` prop or controlled\r\n round-trip. The `onChange` callback is a **notification**, not a value\r\n binding: nothing you do in it re-renders the document. Adopt a remote\r\n revision by remounting (change the React `key`) or by calling `setMarkdown`\r\n imperatively, never with a live-DOM patch.\r\n4. **Theming is token-driven.** All color and typography flow from\r\n `var(--papyra-*, fallback)` tokens. The preset bundles no fonts — the host\r\n loads Marcellus / Sora / Roboto Mono.\r\n\r\n## Locked contract\r\n\r\n`PapyraEditor` hard-locks the props it owns; callers cannot reach them:\r\n\r\n- Modes are fixed to `['visual', 'markdown']` (no `json`/`html`).\r\n- View tabs hidden. By default the only toolbar is floating-on-selection; the\r\n opt-in `toolbar` prop adds an always-visible toolbar, but it is never pinned.\r\n- `markdownSourceOfTruth` is on and `sourceMetadataMode=\"none\"`.\r\n- `featureFlags` are routed through `papyraFeaturePolicy`, whose **enforced** set\r\n keeps markdown-breaking features off — font/size/line-height pickers, arbitrary\r\n text color and highlight, sub/superscript, the in-editor theme toggle, and\r\n draggable blocks **cannot be switched back on** by a caller flag.\r\n\r\n## Preset props\r\n\r\n- `adapter`: the host seam (`PapyraEditorAdapter`). Supplies media resolution,\r\n uploads, note search/navigation, and block resolution. Omit it and the preset\r\n uses a graceful no-op adapter so the editor still renders and round-trips.\r\n- `colored`: light-locks a tinted (\"colored\") note so ink stays readable on the\r\n host-painted paper, regardless of the ambient app theme.\r\n- `readOnly`: mounts a non-editable surface (`visual-only`, click-to-edit\r\n disabled) that emits no change events — safe for revision previews and\r\n time-machine scrubbing. Pair with repeated `setMarkdown` calls.\r\n- `variant`: `\"focus\"` widens the body to a centered, distraction-free measure;\r\n `\"default\"` is the standard editorial measure.\r\n- `toolbar`: opt into a persistent toolbar above the editor (default `false` —\r\n floating-on-selection only). It lists just Papyra's markdown-safe actions\r\n (history, headings/paragraph, quote, bold/italic/strikethrough/inline-code/link,\r\n lists + checklist, code block, horizontal rule, table, image); the restricted\r\n controls (typography pickers, color/highlight, sub/superscript, alignment, theme\r\n toggle) can never appear, and the toolbar is never pinned. After the built-ins\r\n come Papyra's own inserts: **Link a note** (types `[[`), **Mention someone**\r\n (types `@`, adding a space first after a word — only when the adapter has\r\n `searchUsers`), **Attach file** (uploads through the adapter and embeds\r\n `![[name]]` — only with an adapter), **Embed** (a menu: YouTube video or web\r\n page, each asking for the link in a themed dialog) and **Insert today's date**.\r\n The prop is live — toggling it shows or hides the toolbar without remounting\r\n the editor, so the caret and undo history survive. Only renders in the\r\n editable visual surface, so `readOnly`/`locked` never show it.\r\n- `locked`: withholds the body entirely — renders a blurred placeholder and\r\n **never mounts the editor**, so there is no plaintext in the DOM. The lock is\r\n UX only; the server (`401`/`PathGuard`) is the security boundary.\r\n- `blockAnchors`: block-anchor assignment policy — `\"off\"` (default; anchors\r\n already in the text parse and round-trip, nothing creates one), `\"on-demand\"`\r\n (the host stamps at save time via `ensureBlockAnchors()`), or `\"auto\"` (every\r\n anchorable block — paragraph, heading, quote, and **list item** — gets a\r\n stable `^id` appended on commit; `- item ^abc12345` and `- [ ] task ^abc12345`\r\n are valid markdown and round-trip verbatim). Anchors are invisible in the visual surface: no `^id`\r\n artefact, no caret stop, nothing selectable — while the trailing ` ^id`\r\n round-trips losslessly in the markdown. An anchor is always kept **last** in\r\n its block, in every mode: the caret snaps in front of it, and text that lands\r\n behind it moves it back to the end. That is what keeps a block's id stable\r\n when you type at the end of an anchored line — an id is an address, and\r\n `![[Note#^id]]` references to it must not change under the writer.\r\n- `onChange`: first-class change notification (see\r\n [Change notification and autosave](#change-notification-and-autosave)).\r\n- `onDesync`: opt-in model/DOM divergence watchdog (see\r\n [The DOM is not the source of truth](#the-dom-is-not-the-source-of-truth)).\r\n- `onOutlineChange`: fired (debounced) with the current document outline; drives\r\n a host's live table-of-contents scrollbar. Read-only observation — the caret\r\n is never touched.\r\n- `typeahead`: host tuning for the `@` and `[[` triggers — how eagerly each\r\n opens, its caret offset, its copy, whether it is registered at all, and the\r\n shared search debounce. See\r\n [Tuning the typeahead](#tuning-the-typeahead).\r\n- `featureFlags`: per-feature overrides, resolved through the enforced policy.\r\n\r\n## Change notification and autosave\r\n\r\n**Do not attach a DOM `onInput` handler to a wrapper element — it will never\r\nfire.** Lexical stops propagation of the contenteditable's `input` event, so\r\nReact's delegated synthetic `onInput` on an ancestor receives nothing, and an\r\nautosave driven that way silently saves nothing. Wire autosave to the editor's\r\nown `onChange` instead:\r\n\r\n~~~tsx\r\n {\r\n if (source !== 'user') return; // ignore your own setMarkdown adopts\r\n if (!isDirty) return;\r\n scheduleAutosave(markdown); // your debounce; one call per commit arrives here\r\n }}\r\n/>\r\n~~~\r\n\r\nThe payload is `{ markdown, source, isDirty }`:\r\n\r\n- Fires for **every mutation path** — typing, toolbar formatting, slash\r\n commands, undo/redo, paste, drag-drop, and markdown source-view edits — with\r\n `source: \"user\"`, coalesced to **one call per committed change** (typing a\r\n character produces exactly one call).\r\n- The initial `defaultContent` load never fires. A host-initiated `setMarkdown`\r\n fires at most once with `source: \"programmatic\"` (only when it actually\r\n changes the content), so your autosave can ignore it.\r\n- `isDirty` compares against the editor's own serialization of the mounted (or\r\n last adopted) content — see the normalisation contract below.\r\n\r\n### Ready timing and the normalisation contract\r\n\r\n`onReady` fires only after the editor is interactive **and** the initial\r\ncontent has been injected and reconciled, so `getMarkdown()` called\r\nsynchronously inside the callback is already stable — no settle timers:\r\n\r\n~~~tsx\r\nonReady={(editor) => {\r\n baselineRef.current = editor.getMarkdown(); // safe: no setTimeout needed\r\n}}\r\n~~~\r\n\r\nNote that `getMarkdown()` is **not** byte-identical to the markdown you loaded:\r\nthe editor re-normalises everything it imports (list markers, spacing, fence\r\nstyle), so `getMarkdown(setMarkdown(x)) !== x` in general. Always baseline your\r\ndirty checks against the editor's own output — the `onReady` snapshot or the\r\n`markdown` field of an `onChange` payload — never against your input string.\r\n\r\n## The DOM is not the source of truth\r\n\r\nSerialization reads the Lexical model, never the DOM. Text written into the\r\ncontenteditable behind the reconciler's back — `document.execCommand`, browser\r\nextensions, password managers, translation tools — can render on screen while\r\nbeing absent from `getMarkdown()`. Two consequences:\r\n\r\n- **Never assert against `innerText` in tests**; use `getMarkdown()`.\r\n- **Never mutate the contenteditable directly**; go through `setMarkdown` or\r\n the command surface.\r\n\r\nTo be told instead of silently losing such writes, pass `onDesync`: an internal\r\nobserver compares the visible text with the model after external mutations\r\nsettle and reports `{ domText, modelText }` when they disagree.\r\n\r\n## Keyboard access: escaping Tab capture\r\n\r\nTab indents (and Shift+Tab outdents) inside the editor, which would otherwise\r\ntrap keyboard-only users (WCAG 2.1.2). The standard escape is built in: press\r\n**Escape, then Tab** — the armed Tab performs the browser's native focus move\r\nout of the editor instead of indenting; any other key restores Tab-as-indent.\r\nAdvertise \"Press Esc then Tab to move focus out of the editor\" in your help UI.\r\n\r\n## Imperative ref\r\n\r\n`PapyraEditorRef` extends `ExtensiveEditorRef` with the markdown-first surface a\r\nhost drives: `setMarkdown(md)` (host-driven adopt), `focus()`, `getOutline()` /\r\n`scrollToHeading(key)` for the table of contents, `getBlocks()` for block\r\nanchors, `ensureBlockAnchors()` to stamp missing anchors and return the stamped\r\nbody (the `\"on-demand\"` save-time flow), and `getMentions()` for `@username`\r\ndetection. The host calls these during its own orchestration (autosave, remount,\r\nTOC) — they never fire on keystrokes.\r\n\r\nEach `getBlocks()` entry carries the anchor id plus the block's own `text`,\r\n`line`, and `start`/`end` character offsets in the markdown body, so a host can\r\nresolve a `#^id` reference without re-parsing the document.\r\n\r\n## The host adapter\r\n\r\nThe adapter is the entire contract between the preset and the host. The editor\r\ndeclares it; the host implements it.\r\n\r\n~~~ts\r\ninterface PapyraEditorAdapter {\r\n resolveMediaUrl(filename: string): string; // ![[file]] → URL\r\n uploadMedia(file: File): Promise<{ filename: string }>; // drop/paste → store\r\n openNote(ref: { title?: string; id?: string }): void; // [[Note]] → navigate\r\n searchNotes(q: string): Promise>;\r\n searchUsers?(q: string): Promise>;\r\n resolveBlock?(ref: { note: string; blockId: string }): Promise;\r\n resolveCard?(url: string): Promise<{\r\n title?: string;\r\n description?: string;\r\n image?: string;\r\n favicon?: string;\r\n siteName?: string;\r\n } | null>; // ![[card:url]] → metadata\r\n onMentions?(usernames: string[]): void;\r\n}\r\n~~~\r\n\r\nThe adapter's resolvers are where the host's server-side authorization lives. The\r\neditor's blur/lock is UX, never the boundary.\r\n\r\n### Typeahead dropdowns\r\n\r\nTwo triggers open a caret-anchored dropdown, and both are fed by the adapter:\r\n\r\n| Trigger | Menu | Fed by | Inserts |\r\n| ------- | ---------- | ----------------------- | -------------------------- |\r\n| `[[` | note links | `searchNotes(q)` | a `[[Note]]` wikilink |\r\n| `@` | mentions | `searchUsers(q)` | plain `@username ` text |\r\n\r\nBoth are host-gated: with no adapter (or, for `@`, no `searchUsers`) the trigger\r\nis silent and no menu renders, so the editor never offers a suggestion it cannot\r\nhonour. Keyboard-driven throughout — Escape closes, arrows move, Enter/Tab\r\nselect, and a click outside dismisses.\r\n\r\nThe `@` trigger matches the mention rule byte-for-byte: an `@` only opens the\r\nmenu at the start of a block or after whitespace, `(`, or `[`, and the query is\r\n`[A-Za-z0-9][A-Za-z0-9._-]{0,63}`. `bea@example.com` never opens it. Mentions are\r\nwritten as **plain text**, not a node — nothing to serialize, nothing that can\r\nrewrite the body on save — and hosts detect them by scanning the markdown (see\r\n`getMentions()`).\r\n\r\nBoth triggers open on the bare trigger by default (`minQueryLength: 0`), so\r\ntyping `@` or `[[` shows the host's first page immediately. Raise the floor per\r\ntrigger if an unfiltered list is noise — see\r\n[Tuning the typeahead](#tuning-the-typeahead).\r\n\r\n**Where they open.** Both triggers only open inside a block that can carry a\r\n`^id` block anchor — paragraph, heading, quote, and list item. That is not a\r\nstyle choice: a host resolves a mention to the anchor of the block it sits in,\r\nso a mention typed anywhere else could never be delivered. The trigger\r\ncontainer set and the block-anchor stamping set are derived from one exported\r\nsource of truth (`ANCHORABLE_BLOCK_TYPES` in `@lyfie/luthor-headless`), so they\r\ncannot drift apart. Code blocks and tables are excluded in both.\r\n\r\n### Tuning the typeahead\r\n\r\nThe `typeahead` prop is the host's seam over both triggers. Every field is\r\noptional and defaults to the shipped behaviour, so omitting it changes nothing.\r\n\r\n~~~tsx\r\n\r\n~~~\r\n\r\n| Field | Default | What it does |\r\n| ----------------------- | ---------------------- | -------------------------------------------------------------- |\r\n| `minQueryLength` | `0` | Characters required after the trigger before the menu opens. |\r\n| `offset` | `{ x: 0, y: 8 }` | Menu position relative to the caret. |\r\n| `title` | `Mention` / `Link note`| Menu heading (localisation, product vocabulary). |\r\n| `emptyLabel` | see above | Shown when the host's search returns nothing. |\r\n| `disabled` | `false` | Unregisters the trigger — the character stays ordinary text. |\r\n| `searchDebounceMs` | `120` | Debounce before a query reaches the adapter. Shared. |\r\n\r\nOnly the **length floor** is configurable. The syntax rules are fixed at every\r\nsetting: a username's legal characters still close the `@` menu on the first\r\ncharacter it cannot accept, and `[[` still never opens after `!` or once the\r\nlink is closed or aliased.\r\n\r\n`disabled` is the switch for a surface that has no people directory, or a\r\ndeployment that does not use note links — previously the only way to suppress a\r\ntrigger was not to mount the editor. It drops the trigger extension *and* the\r\nmatching suggestion provider; the embed nodes (wikilink rendering, media,\r\nanchors) stay registered, so existing `[[Note]]` links in the body keep working.\r\n\r\nMenu size is CSS, not a prop: override `--luthor-typeahead-menu-max-height` and\r\n`--luthor-typeahead-menu-width` on your own scope.\r\n\r\n## Usage\r\n\r\n~~~tsx\r\nimport '@lyfie/luthor/styles.css';\r\nimport { PapyraEditor, type PapyraEditorRef } from '@lyfie/luthor';\r\nimport { useRef } from 'react';\r\n\r\nexport function NoteCanvas({ body }: { body: string }) {\r\n const ref = useRef(null);\r\n\r\n return (\r\n `/api/media/${name}`,\r\n uploadMedia: async (file) => {\r\n const stored = await upload(file);\r\n return { filename: stored.name };\r\n },\r\n openNote: ({ title }) => router.push(`/notes/${title}`),\r\n searchNotes: (q) => api.searchNotes(q),\r\n searchUsers: (q) => api.searchUsers(q),\r\n }}\r\n onReady={(editor) => {\r\n // Read the body imperatively — never a controlled value.\r\n console.log(editor.getMarkdown());\r\n }}\r\n />\r\n );\r\n}\r\n~~~\r\n\r\n`PapyraEditor` is also available as a subpath export:\r\n\r\n~~~ts\r\nimport { PapyraEditor } from '@lyfie/luthor/presets/papyra';\r\n~~~\r\n\r\n## Embeds and lossless round-trips\r\n\r\nEvery custom embed ships a bidirectional markdown transformer, so the body that\r\n`getMarkdown()` returns is byte-stable across repeated saves:\r\n\r\n| Markdown | Renders as |\r\n| ------------------- | ----------------------------------- |\r\n| `![[diagram.png]]` | inline image (via `resolveMediaUrl`)|\r\n| `[[Note]]` | wikilink (click → `openNote`) |\r\n| `[[Note\\|alias]]` | aliased wikilink |\r\n| `![[Note#^id]]` | read-only transclusion |\r\n| `text ^id` | trailing block anchor (non-rendering)|\r\n| `![[card:url]]` | saved web card (via `resolveCard`) |\r\n| `![[card:url\\|title]]` | saved web card with author title |\r\n| `![[youtube:url]]` | YouTube player (optional `\\|caption`)|\r\n| `![[iframe:url]]` | iframe embed (optional `\\|caption`) |\r\n| `> [!transcript]` | transcription callout (display-only)|\r\n\r\nThe embed nodes and transformers live in `@lyfie/luthor-headless` and are\r\nre-exported through `@lyfie/luthor` — the preset only composes and themes them.\r\n\r\nThe **transcription callout** is an Obsidian-style `> [!transcript]` block: an\r\nopening line (optionally `> [!transcript] Title`) followed by `>`-prefixed body\r\nlines, terminated by a blank line. It renders as an accent-tinted, labelled block\r\non the quote surface and is display-only — the transcript text lives inline in the\r\nbody, so it needs no resolver and round-trips verbatim (the marker is normalized\r\nto lowercase).\r\n\r\nThe **saved web card** (`![[card:url]]`) renders an archived link card. When the\r\nhost wires `resolveCard`, the editor enriches it with the page's open-graph\r\nmetadata (title, description, preview image, favicon, site name); without a\r\nresolver the card degrades to a titled link to the URL. As with every embed, only\r\nthe verbatim `url` (and optional `|title`) is serialized, so the metadata is\r\nrender-only and the markdown round-trips unchanged.\r\n\r\nThe **YouTube** (`![[youtube:url]]`) and **iframe** (`![[iframe:url]]`) embeds\r\nreuse the shared media nodes from `@lyfie/luthor-headless` and carry an optional\r\n`|caption`. A YouTube `watch`/`youtu.be`/`shorts` link is normalized to the\r\ncanonical `…/embed/` player URL on the first pass and is byte-stable\r\nafterward; an iframe URL gains `https://` if it has none. Frame size and\r\nalignment are session-only presentation state with no markdown representation\r\n(like image dimensions), so the markdown text itself round-trips unchanged.\r\n\r\n## Command surface\r\n\r\nThe slash menu and command palette are curated down to note-taking primitives:\r\nheadings (H1–H3), lists and checklist, quote, code block, table, horizontal\r\nrule, and image. The typography pickers, view tabs, and pinned toolbar are\r\nenforced off.\r\n\r\nOn top of the curated built-ins, PapyraEditor contributes three note-specific\r\nslash commands through the editor's `extraSlashCommands` seam:\r\n\r\n| Command | Inserts |\r\n| ------------- | ------------------------------------------------------------- |\r\n| `Link note` | the `[[` trigger, which opens the wikilink typeahead |\r\n| `Embed media` | a picked file → `adapter.uploadMedia` → `![[filename]]` |\r\n| `Insert date` | today's date as `YYYY-MM-DD` |\r\n\r\nEach writes markdown-native syntax at the caret, so the body stays the source of\r\ntruth and round-trips unchanged. The commands are appended automatically — there\r\nis nothing to wire beyond supplying an `adapter` for `Embed media` to upload\r\nthrough.\r\n", - "plainContent": "Papyra Editor PapyraEditor is a markdown-native note canvas composed on top of the extensive preset. It is the editor the Papyra note app ships, but it is host-agnostic: every external capability (media, uploads, note search and navigation, block resolution) flows through an injected adapter, so any note app can reuse it by supplying different configuration. When to use this Reach for PapyraEditor when your body of truth is a frontmatter-free markdown file and you want a polished, restricted writing surface with Obsidian-style embeds — [[Note]] wikilinks, ![[file.ext]] media, ![[Note ^id]] transclusion, and trailing ^id block anchors — that survives a lossless round-trip back to that file. The four invariants 1. Markdown is the source of truth. getMarkdown() returns exactly what lands in the .md body (CommonMark plus the documented embed set). There is no JSON or HTML \"real\" format. The preset runs sourceMetadataMode=\"none\" . 2. The body is frontmatter-free. The host splits YAML from the body and hands the editor only the body; the preset never renders, emits, or mangles frontmatter. 3. The caret is sacred. The editor is uncontrolled — it reads defaultContent once on mount and there is no value prop or controlled round-trip. The onChange callback is a notification , not a value binding: nothing you do in it re-renders the document. Adopt a remote revision by remounting (change the React key ) or by calling setMarkdown imperatively, never with a live-DOM patch. 4. Theming is token-driven. All color and typography flow from var(--papyra- , fallback) tokens. The preset bundles no fonts — the host loads Marcellus / Sora / Roboto Mono. Locked contract PapyraEditor hard-locks the props it owns; callers cannot reach them: - Modes are fixed to ['visual', 'markdown'] (no json / html ). - View tabs hidden. By default the only toolbar is floating-on-selection; the opt-in toolbar prop adds an always-visible toolbar, but it is never pinned. - markdownSourceOfTruth is on and sourceMetadataMode=\"none\" . - featureFlags are routed through papyraFeaturePolicy , whose enforced set keeps markdown-breaking features off — font/size/line-height pickers, arbitrary text color and highlight, sub/superscript, the in-editor theme toggle, and draggable blocks cannot be switched back on by a caller flag. Preset props - adapter : the host seam ( PapyraEditorAdapter ). Supplies media resolution, uploads, note search/navigation, and block resolution. Omit it and the preset uses a graceful no-op adapter so the editor still renders and round-trips. - colored : light-locks a tinted (\"colored\") note so ink stays readable on the host-painted paper, regardless of the ambient app theme. - readOnly : mounts a non-editable surface ( visual-only , click-to-edit disabled) that emits no change events — safe for revision previews and time-machine scrubbing. Pair with repeated setMarkdown calls. - variant : \"focus\" widens the body to a centered, distraction-free measure; \"default\" is the standard editorial measure. - toolbar : opt into a persistent toolbar above the editor (default false — floating-on-selection only). It lists just Papyra's markdown-safe actions (history, headings/paragraph, quote, bold/italic/strikethrough/inline-code/link, lists + checklist, code block, horizontal rule, table, image); the restricted controls (typography pickers, color/highlight, sub/superscript, alignment, theme toggle) can never appear, and the toolbar is never pinned. After the built-ins come Papyra's own inserts: Link a note (types [[ ), Mention someone (types @ , adding a space first after a word — only when the adapter has searchUsers ), Attach file (uploads through the adapter and embeds ![[name]] — only with an adapter), Embed (a menu: YouTube video or web page, each asking for the link in a themed dialog) and Insert today's date . The prop is live — toggling it shows or hides the toolbar without remounting the editor, so the caret and undo history survive. Only renders in the editable visual surface, so readOnly / locked never show it. - locked : withholds the body entirely — renders a blurred placeholder and never mounts the editor , so there is no plaintext in the DOM. The lock is UX only; the server ( 401 / PathGuard ) is the security boundary. - blockAnchors : block-anchor assignment policy — \"off\" (default; anchors already in the text parse and round-trip, nothing creates one), \"on-demand\" (the host stamps at save time via ensureBlockAnchors() ), or \"auto\" (every anchorable block — paragraph, heading, quote, and list item — gets a stable ^id appended on commit; - item ^abc12345 and - [ ] task ^abc12345 are valid markdown and round-trip verbatim). Anchors are invisible in the visual surface: no ^id artefact, no caret stop, nothing selectable — while the trailing ^id round-trips losslessly in the markdown. An anchor is always kept last in its block, in every mode: the caret snaps in front of it, and text that lands behind it moves it back to the end. That is what keeps a block's id stable when you type at the end of an anchored line — an id is an address, and ![[Note ^id]] references to it must not change under the writer. - onChange : first-class change notification (see Change notification and autosave ). - onDesync : opt-in model/DOM divergence watchdog (see The DOM is not the source of truth ). - onOutlineChange : fired (debounced) with the current document outline; drives a host's live table-of-contents scrollbar. Read-only observation — the caret is never touched. - typeahead : host tuning for the @ and [ triggers — how eagerly each opens, its caret offset, its copy, whether it is registered at all, and the shared search debounce. See [Tuning the typeahead . - featureFlags : per-feature overrides, resolved through the enforced policy. Change notification and autosave Do not attach a DOM onInput handler to a wrapper element — it will never fire. Lexical stops propagation of the contenteditable's input event, so React's delegated synthetic onInput on an ancestor receives nothing, and an autosave driven that way silently saves nothing. Wire autosave to the editor's own onChange instead: tsx { if (source !== 'user') return; // ignore your own setMarkdown adopts if (!isDirty) return; scheduleAutosave(markdown); // your debounce; one call per commit arrives here }} / The payload is { markdown, source, isDirty } : - Fires for every mutation path — typing, toolbar formatting, slash commands, undo/redo, paste, drag-drop, and markdown source-view edits — with source: \"user\" , coalesced to one call per committed change (typing a character produces exactly one call). - The initial defaultContent load never fires. A host-initiated setMarkdown fires at most once with source: \"programmatic\" (only when it actually changes the content), so your autosave can ignore it. - isDirty compares against the editor's own serialization of the mounted (or last adopted) content — see the normalisation contract below. Ready timing and the normalisation contract onReady fires only after the editor is interactive and the initial content has been injected and reconciled, so getMarkdown() called synchronously inside the callback is already stable — no settle timers: tsx onReady={(editor) = { baselineRef.current = editor.getMarkdown(); // safe: no setTimeout needed }} Note that getMarkdown() is not byte-identical to the markdown you loaded: the editor re-normalises everything it imports (list markers, spacing, fence style), so getMarkdown(setMarkdown(x)) !== x in general. Always baseline your dirty checks against the editor's own output — the onReady snapshot or the markdown field of an onChange payload — never against your input string. The DOM is not the source of truth Serialization reads the Lexical model, never the DOM. Text written into the contenteditable behind the reconciler's back — document.execCommand , browser extensions, password managers, translation tools — can render on screen while being absent from getMarkdown() . Two consequences: - Never assert against innerText in tests ; use getMarkdown() . - Never mutate the contenteditable directly ; go through setMarkdown or the command surface. To be told instead of silently losing such writes, pass onDesync : an internal observer compares the visible text with the model after external mutations settle and reports { domText, modelText } when they disagree. Keyboard access: escaping Tab capture Tab indents (and Shift+Tab outdents) inside the editor, which would otherwise trap keyboard-only users (WCAG 2.1.2). The standard escape is built in: press Escape, then Tab — the armed Tab performs the browser's native focus move out of the editor instead of indenting; any other key restores Tab-as-indent. Advertise \"Press Esc then Tab to move focus out of the editor\" in your help UI. Imperative ref PapyraEditorRef extends ExtensiveEditorRef with the markdown-first surface a host drives: setMarkdown(md) (host-driven adopt), focus() , getOutline() / scrollToHeading(key) for the table of contents, getBlocks() for block anchors, ensureBlockAnchors() to stamp missing anchors and return the stamped body (the \"on-demand\" save-time flow), and getMentions() for @username detection. The host calls these during its own orchestration (autosave, remount, TOC) — they never fire on keystrokes. Each getBlocks() entry carries the anchor id plus the block's own text , line , and start / end character offsets in the markdown body, so a host can resolve a ^id reference without re-parsing the document. The host adapter The adapter is the entire contract between the preset and the host. The editor declares it; the host implements it. ts interface PapyraEditorAdapter { resolveMediaUrl(filename: string): string; // ![[file]] → URL uploadMedia(file: File): Promise ; // drop/paste → store openNote(ref: { title?: string; id?: string }): void; // [[Note]] → navigate searchNotes(q: string): Promise ; searchUsers?(q: string): Promise ; resolveBlock?(ref: { note: string; blockId: string }): Promise ; resolveCard?(url: string): Promise ; // ![[card:url]] → metadata onMentions?(usernames: string[]): void; } The adapter's resolvers are where the host's server-side authorization lives. The editor's blur/lock is UX, never the boundary. Typeahead dropdowns Two triggers open a caret-anchored dropdown, and both are fed by the adapter: Trigger Menu Fed by Inserts ------- ---------- ----------------------- -------------------------- [[ note links searchNotes(q) a [[Note]] wikilink @ mentions searchUsers(q) plain @username text Both are host-gated: with no adapter (or, for @ , no searchUsers ) the trigger is silent and no menu renders, so the editor never offers a suggestion it cannot honour. Keyboard-driven throughout — Escape closes, arrows move, Enter/Tab select, and a click outside dismisses. The @ trigger matches the mention rule byte-for-byte: an @ only opens the menu at the start of a block or after whitespace, ( , or [ , and the query is [A-Za-z0-9][A-Za-z0-9. -]{0,63} . bea@example.com never opens it. Mentions are written as plain text , not a node — nothing to serialize, nothing that can rewrite the body on save — and hosts detect them by scanning the markdown (see getMentions() ). Both triggers open on the bare trigger by default ( minQueryLength: 0 ), so typing @ or [ shows the host's first page immediately. Raise the floor per trigger if an unfiltered list is noise — see [Tuning the typeahead . Where they open. Both triggers only open inside a block that can carry a ^id block anchor — paragraph, heading, quote, and list item. That is not a style choice: a host resolves a mention to the anchor of the block it sits in, so a mention typed anywhere else could never be delivered. The trigger container set and the block-anchor stamping set are derived from one exported source of truth ( ANCHORABLE BLOCK TYPES in @lyfie/luthor-headless ), so they cannot drift apart. Code blocks and tables are excluded in both. Tuning the typeahead The typeahead prop is the host's seam over both triggers. Every field is optional and defaults to the shipped behaviour, so omitting it changes nothing. tsx Field Default What it does ----------------------- ---------------------- -------------------------------------------------------------- minQueryLength 0 Characters required after the trigger before the menu opens. offset { x: 0, y: 8 } Menu position relative to the caret. title Mention / Link note Menu heading (localisation, product vocabulary). emptyLabel see above Shown when the host's search returns nothing. disabled false Unregisters the trigger — the character stays ordinary text. searchDebounceMs 120 Debounce before a query reaches the adapter. Shared. Only the length floor is configurable. The syntax rules are fixed at every setting: a username's legal characters still close the @ menu on the first character it cannot accept, and [[ still never opens after ! or once the link is closed or aliased. disabled is the switch for a surface that has no people directory, or a deployment that does not use note links — previously the only way to suppress a trigger was not to mount the editor. It drops the trigger extension and the matching suggestion provider; the embed nodes (wikilink rendering, media, anchors) stay registered, so existing [[Note]] links in the body keep working. Menu size is CSS, not a prop: override --luthor-typeahead-menu-max-height and --luthor-typeahead-menu-width on your own scope. Usage tsx import '@lyfie/luthor/styles.css'; import { PapyraEditor, type PapyraEditorRef } from '@lyfie/luthor'; import { useRef } from 'react'; export function NoteCanvas({ body }: { body: string }) { const ref = useRef (null); return ( /api/media/${name} , uploadMedia: async (file) = { const stored = await upload(file); return { filename: stored.name }; }, openNote: ({ title }) = router.push( /notes/${title} ), searchNotes: (q) = api.searchNotes(q), searchUsers: (q) = api.searchUsers(q), }} onReady={(editor) = { // Read the body imperatively — never a controlled value. console.log(editor.getMarkdown()); }} / ); } PapyraEditor is also available as a subpath export: ts import { PapyraEditor } from '@lyfie/luthor/presets/papyra'; Embeds and lossless round-trips Every custom embed ships a bidirectional markdown transformer, so the body that getMarkdown() returns is byte-stable across repeated saves: Markdown Renders as ------------------- ----------------------------------- ![[diagram.png]] inline image (via resolveMediaUrl ) [[Note]] wikilink (click → openNote ) [[Note\\ alias]] aliased wikilink ![[Note ^id]] read-only transclusion text ^id trailing block anchor (non-rendering) ![[card:url]] saved web card (via resolveCard ) ![[card:url\\ title]] saved web card with author title ![[youtube:url]] YouTube player (optional \\ caption ) ![[iframe:url]] iframe embed (optional \\ caption ) [!transcript] transcription callout (display-only) The embed nodes and transformers live in @lyfie/luthor-headless and are re-exported through @lyfie/luthor — the preset only composes and themes them. The transcription callout is an Obsidian-style [!transcript] block: an opening line (optionally [!transcript] Title ) followed by -prefixed body lines, terminated by a blank line. It renders as an accent-tinted, labelled block on the quote surface and is display-only — the transcript text lives inline in the body, so it needs no resolver and round-trips verbatim (the marker is normalized to lowercase). The saved web card ( ![[card:url]] ) renders an archived link card. When the host wires resolveCard , the editor enriches it with the page's open-graph metadata (title, description, preview image, favicon, site name); without a resolver the card degrades to a titled link to the URL. As with every embed, only the verbatim url (and optional title ) is serialized, so the metadata is render-only and the markdown round-trips unchanged. The YouTube ( ![[youtube:url]] ) and iframe ( ![[iframe:url]] ) embeds reuse the shared media nodes from @lyfie/luthor-headless and carry an optional caption . A YouTube watch / youtu.be / shorts link is normalized to the canonical …/embed/ player URL on the first pass and is byte-stable afterward; an iframe URL gains https:// if it has none. Frame size and alignment are session-only presentation state with no markdown representation (like image dimensions), so the markdown text itself round-trips unchanged. Command surface The slash menu and command palette are curated down to note-taking primitives: headings (H1–H3), lists and checklist, quote, code block, table, horizontal rule, and image. The typography pickers, view tabs, and pinned toolbar are enforced off. On top of the curated built-ins, PapyraEditor contributes three note-specific slash commands through the editor's extraSlashCommands seam: Command Inserts ------------- ------------------------------------------------------------- Link note the [[ trigger, which opens the wikilink typeahead Embed media a picked file → adapter.uploadMedia → ![[filename]] Insert date today's date as YYYY-MM-DD Each writes markdown-native syntax at the caret, so the body stays the source of truth and round-trips unchanged. The commands are appended automatically — there is nothing to wire beyond supplying an adapter for Embed media to upload through.", + "content": "\r\n# Papyra Editor\r\n\r\n`PapyraEditor` is a markdown-native note canvas composed on top of the extensive\r\npreset. It is the editor the [Papyra](https://github.com/lyfie-org) note app\r\nships, but it is host-agnostic: every external capability (media, uploads, note\r\nsearch and navigation, block resolution) flows through an injected adapter, so\r\nany note app can reuse it by supplying different configuration.\r\n\r\n## When to use this\r\n\r\nReach for `PapyraEditor` when your body of truth is a frontmatter-free markdown\r\nfile and you want a polished, restricted writing surface with Obsidian-style\r\nembeds — `[[Note]]` wikilinks, `![[file.ext]]` media, `![[Note#^id]]`\r\ntransclusion, and trailing `^id` block anchors — that survives a lossless\r\nround-trip back to that file.\r\n\r\n## The four invariants\r\n\r\n1. **Markdown is the source of truth.** `getMarkdown()` returns exactly what\r\n lands in the `.md` body (CommonMark plus the documented embed set). There is\r\n no JSON or HTML \"real\" format. The preset runs `sourceMetadataMode=\"none\"`.\r\n2. **The body is frontmatter-free.** The host splits YAML from the body and\r\n hands the editor only the body; the preset never renders, emits, or mangles\r\n frontmatter.\r\n3. **The caret is sacred.** The editor is **uncontrolled** — it reads\r\n `defaultContent` once on mount and there is no `value` prop or controlled\r\n round-trip. The `onChange` callback is a **notification**, not a value\r\n binding: nothing you do in it re-renders the document. Adopt a remote\r\n revision by remounting (change the React `key`) or by calling `setMarkdown`\r\n imperatively, never with a live-DOM patch.\r\n4. **Theming is token-driven.** All color and typography flow from\r\n `var(--papyra-*, fallback)` tokens. The preset bundles no fonts — the host\r\n loads Marcellus / Sora / Roboto Mono.\r\n\r\n## Locked contract\r\n\r\n`PapyraEditor` hard-locks the props it owns; callers cannot reach them:\r\n\r\n- Modes are fixed to `['visual', 'markdown']` (no `json`/`html`).\r\n- View tabs hidden. By default the only toolbar is floating-on-selection; the\r\n opt-in `toolbar` prop adds an always-visible toolbar, but it is never pinned.\r\n- `markdownSourceOfTruth` is on and `sourceMetadataMode=\"none\"`.\r\n- `featureFlags` are routed through `papyraFeaturePolicy`, whose **enforced** set\r\n keeps markdown-breaking features off — font/size/line-height pickers, arbitrary\r\n text color and highlight, sub/superscript, the in-editor theme toggle, and\r\n draggable blocks **cannot be switched back on** by a caller flag.\r\n\r\n## Preset props\r\n\r\n- `adapter`: the host seam (`PapyraEditorAdapter`). Supplies media resolution,\r\n uploads, note search/navigation, and block resolution. Omit it and the preset\r\n uses a graceful no-op adapter so the editor still renders and round-trips.\r\n- `colored`: light-locks a tinted (\"colored\") note so ink stays readable on the\r\n host-painted paper, regardless of the ambient app theme.\r\n- `readOnly`: mounts a non-editable surface (`visual-only`, click-to-edit\r\n disabled) that emits no change events — safe for revision previews and\r\n time-machine scrubbing. Pair with repeated `setMarkdown` calls.\r\n- `variant`: `\"focus\"` widens the body to a centered, distraction-free measure;\r\n `\"default\"` is the standard editorial measure.\r\n- `toolbar`: opt into a persistent toolbar above the editor (default `false` —\r\n floating-on-selection only). It lists just Papyra's markdown-safe actions\r\n (headings/paragraph, quote, bold/italic/strikethrough/inline-code/link,\r\n lists + checklist, code block, horizontal rule, table, image); the restricted\r\n controls (typography pickers, color/highlight, sub/superscript, alignment, theme\r\n toggle) can never appear, and the toolbar is never pinned. After the built-ins\r\n come Papyra's own inserts: **Link a note** (types `[[`), **Mention someone**\r\n (types `@`, adding a space first after a word — only when the adapter has\r\n `searchUsers`), **Attach file** (uploads through the adapter and embeds\r\n `![[name]]` — only with an adapter), **Embed** (a menu: YouTube video or web\r\n page, each asking for the link in a themed dialog) and **Insert today's date**.\r\n The prop is live — toggling it shows or hides the toolbar without remounting\r\n the editor, so the caret and undo history survive. Only renders in the\r\n editable visual surface, so `readOnly`/`locked` never show it.\r\n- `toolbarLayout`: the persistent toolbar's layout — order, sections, and which\r\n sections collapse behind one button (`group`). Defaults to\r\n `PAPYRA_TOOLBAR_LAYOUT`. Restricted items stay stripped by the visibility\r\n contract whatever the layout lists. Place host items with `\"customComponent\"`\r\n (all) or `\"custom:\"` (one).\r\n- `toolbarItems`: host controls for the toolbar, replacing the preset's own\r\n inserts (`createPapyraToolbarItems`) — so a host can use its own icons,\r\n grouping and behaviour. Items use `runCommand` to reach editor commands such as\r\n `uploadAndEmbedFile`, `insertImage`, `insertYouTubeEmbed` or `startMention`.\r\n- `imageUploadHandler`: where the editor's own image paths (the toolbar image\r\n menu, `/image`) store a picked file; return the URL to use. Without it they\r\n fall back to a `blob:` URL, which does not survive a reload — set it.\r\n- `locked`: withholds the body entirely — renders a blurred placeholder and\r\n **never mounts the editor**, so there is no plaintext in the DOM. The lock is\r\n UX only; the server (`401`/`PathGuard`) is the security boundary.\r\n- `blockAnchors`: block-anchor assignment policy — `\"off\"` (default; anchors\r\n already in the text parse and round-trip, nothing creates one), `\"on-demand\"`\r\n (the host stamps at save time via `ensureBlockAnchors()`), or `\"auto\"` (every\r\n anchorable block — paragraph, heading, quote, and **list item** — gets a\r\n stable `^id` appended on commit; `- item ^abc12345` and `- [ ] task ^abc12345`\r\n are valid markdown and round-trip verbatim). Anchors are invisible in the visual surface: no `^id`\r\n artefact, no caret stop, nothing selectable — while the trailing ` ^id`\r\n round-trips losslessly in the markdown. An anchor is always kept **last** in\r\n its block, in every mode: the caret snaps in front of it, and text that lands\r\n behind it moves it back to the end. That is what keeps a block's id stable\r\n when you type at the end of an anchored line — an id is an address, and\r\n `![[Note#^id]]` references to it must not change under the writer.\r\n- `onChange`: first-class change notification (see\r\n [Change notification and autosave](#change-notification-and-autosave)).\r\n- `onDesync`: opt-in model/DOM divergence watchdog (see\r\n [The DOM is not the source of truth](#the-dom-is-not-the-source-of-truth)).\r\n- `onOutlineChange`: fired (debounced) with the current document outline; drives\r\n a host's live table-of-contents scrollbar. Read-only observation — the caret\r\n is never touched.\r\n- `typeahead`: host tuning for the `@` and `[[` triggers — how eagerly each\r\n opens, its caret offset, its copy, whether it is registered at all, and the\r\n shared search debounce. See\r\n [Tuning the typeahead](#tuning-the-typeahead).\r\n- `featureFlags`: per-feature overrides, resolved through the enforced policy.\r\n\r\n## Change notification and autosave\r\n\r\n**Do not attach a DOM `onInput` handler to a wrapper element — it will never\r\nfire.** Lexical stops propagation of the contenteditable's `input` event, so\r\nReact's delegated synthetic `onInput` on an ancestor receives nothing, and an\r\nautosave driven that way silently saves nothing. Wire autosave to the editor's\r\nown `onChange` instead:\r\n\r\n~~~tsx\r\n {\r\n if (source !== 'user') return; // ignore your own setMarkdown adopts\r\n if (!isDirty) return;\r\n scheduleAutosave(markdown); // your debounce; one call per commit arrives here\r\n }}\r\n/>\r\n~~~\r\n\r\nThe payload is `{ markdown, source, isDirty }`:\r\n\r\n- Fires for **every mutation path** — typing, toolbar formatting, slash\r\n commands, undo/redo, paste, drag-drop, and markdown source-view edits — with\r\n `source: \"user\"`, coalesced to **one call per committed change** (typing a\r\n character produces exactly one call).\r\n- The initial `defaultContent` load never fires. A host-initiated `setMarkdown`\r\n fires at most once with `source: \"programmatic\"` (only when it actually\r\n changes the content), so your autosave can ignore it.\r\n- `isDirty` compares against the editor's own serialization of the mounted (or\r\n last adopted) content — see the normalisation contract below.\r\n\r\n### Ready timing and the normalisation contract\r\n\r\n`onReady` fires only after the editor is interactive **and** the initial\r\ncontent has been injected and reconciled, so `getMarkdown()` called\r\nsynchronously inside the callback is already stable — no settle timers:\r\n\r\n~~~tsx\r\nonReady={(editor) => {\r\n baselineRef.current = editor.getMarkdown(); // safe: no setTimeout needed\r\n}}\r\n~~~\r\n\r\nNote that `getMarkdown()` is **not** byte-identical to the markdown you loaded:\r\nthe editor re-normalises everything it imports (list markers, spacing, fence\r\nstyle), so `getMarkdown(setMarkdown(x)) !== x` in general. Always baseline your\r\ndirty checks against the editor's own output — the `onReady` snapshot or the\r\n`markdown` field of an `onChange` payload — never against your input string.\r\n\r\n## The DOM is not the source of truth\r\n\r\nSerialization reads the Lexical model, never the DOM. Text written into the\r\ncontenteditable behind the reconciler's back — `document.execCommand`, browser\r\nextensions, password managers, translation tools — can render on screen while\r\nbeing absent from `getMarkdown()`. Two consequences:\r\n\r\n- **Never assert against `innerText` in tests**; use `getMarkdown()`.\r\n- **Never mutate the contenteditable directly**; go through `setMarkdown` or\r\n the command surface.\r\n\r\nTo be told instead of silently losing such writes, pass `onDesync`: an internal\r\nobserver compares the visible text with the model after external mutations\r\nsettle and reports `{ domText, modelText }` when they disagree.\r\n\r\n## Keyboard access: escaping Tab capture\r\n\r\nTab indents (and Shift+Tab outdents) inside the editor, which would otherwise\r\ntrap keyboard-only users (WCAG 2.1.2). The standard escape is built in: press\r\n**Escape, then Tab** — the armed Tab performs the browser's native focus move\r\nout of the editor instead of indenting; any other key restores Tab-as-indent.\r\nAdvertise \"Press Esc then Tab to move focus out of the editor\" in your help UI.\r\n\r\n## Imperative ref\r\n\r\n`PapyraEditorRef` extends `ExtensiveEditorRef` with the markdown-first surface a\r\nhost drives: `setMarkdown(md)` (host-driven adopt), `focus()`, `getOutline()` /\r\n`scrollToHeading(key)` for the table of contents, `getBlocks()` for block\r\nanchors, `ensureBlockAnchors()` to stamp missing anchors and return the stamped\r\nbody (the `\"on-demand\"` save-time flow), and `getMentions()` for `@username`\r\ndetection. The host calls these during its own orchestration (autosave, remount,\r\nTOC) — they never fire on keystrokes.\r\n\r\nEach `getBlocks()` entry carries the anchor id plus the block's own `text`,\r\n`line`, and `start`/`end` character offsets in the markdown body, so a host can\r\nresolve a `#^id` reference without re-parsing the document.\r\n\r\n## The host adapter\r\n\r\nThe adapter is the entire contract between the preset and the host. The editor\r\ndeclares it; the host implements it.\r\n\r\n~~~ts\r\ninterface PapyraEditorAdapter {\r\n resolveMediaUrl(filename: string): string; // ![[file]] → URL\r\n uploadMedia(file: File): Promise<{ filename: string }>; // drop/paste → store\r\n openNote(ref: { title?: string; id?: string }): void; // [[Note]] → navigate\r\n searchNotes(q: string): Promise>;\r\n searchUsers?(q: string): Promise>;\r\n resolveBlock?(ref: { note: string; blockId: string }): Promise;\r\n resolveCard?(url: string): Promise<{\r\n title?: string;\r\n description?: string;\r\n image?: string;\r\n favicon?: string;\r\n siteName?: string;\r\n } | null>; // ![[card:url]] → metadata\r\n onMentions?(usernames: string[]): void;\r\n}\r\n~~~\r\n\r\nThe adapter's resolvers are where the host's server-side authorization lives. The\r\neditor's blur/lock is UX, never the boundary.\r\n\r\n### Typeahead dropdowns\r\n\r\nTwo triggers open a caret-anchored dropdown, and both are fed by the adapter:\r\n\r\n| Trigger | Menu | Fed by | Inserts |\r\n| ------- | ---------- | ----------------------- | -------------------------- |\r\n| `[[` | note links | `searchNotes(q)` | a `[[Note]]` wikilink |\r\n| `@` | mentions | `searchUsers(q)` | plain `@username ` text |\r\n\r\nBoth are host-gated: with no adapter (or, for `@`, no `searchUsers`) the trigger\r\nis silent and no menu renders, so the editor never offers a suggestion it cannot\r\nhonour. Keyboard-driven throughout — Escape closes, arrows move, Enter/Tab\r\nselect, and a click outside dismisses.\r\n\r\nThe `@` trigger matches the mention rule byte-for-byte: an `@` only opens the\r\nmenu at the start of a block or after whitespace, `(`, or `[`, and the query is\r\n`[A-Za-z0-9][A-Za-z0-9._-]{0,63}`. `bea@example.com` never opens it. Mentions are\r\nwritten as **plain text**, not a node — nothing to serialize, nothing that can\r\nrewrite the body on save — and hosts detect them by scanning the markdown (see\r\n`getMentions()`).\r\n\r\nBoth triggers open on the bare trigger by default (`minQueryLength: 0`), so\r\ntyping `@` or `[[` shows the host's first page immediately. Raise the floor per\r\ntrigger if an unfiltered list is noise — see\r\n[Tuning the typeahead](#tuning-the-typeahead).\r\n\r\n**Where they open.** Both triggers only open inside a block that can carry a\r\n`^id` block anchor — paragraph, heading, quote, and list item. That is not a\r\nstyle choice: a host resolves a mention to the anchor of the block it sits in,\r\nso a mention typed anywhere else could never be delivered. The trigger\r\ncontainer set and the block-anchor stamping set are derived from one exported\r\nsource of truth (`ANCHORABLE_BLOCK_TYPES` in `@lyfie/luthor-headless`), so they\r\ncannot drift apart. Code blocks and tables are excluded in both.\r\n\r\n### Tuning the typeahead\r\n\r\nThe `typeahead` prop is the host's seam over both triggers. Every field is\r\noptional and defaults to the shipped behaviour, so omitting it changes nothing.\r\n\r\n~~~tsx\r\n\r\n~~~\r\n\r\n| Field | Default | What it does |\r\n| ----------------------- | ---------------------- | -------------------------------------------------------------- |\r\n| `minQueryLength` | `0` | Characters required after the trigger before the menu opens. |\r\n| `offset` | `{ x: 0, y: 8 }` | Menu position relative to the caret. |\r\n| `title` | `Mention` / `Link note`| Menu heading (localisation, product vocabulary). |\r\n| `emptyLabel` | see above | Shown when the host's search returns nothing. |\r\n| `disabled` | `false` | Unregisters the trigger — the character stays ordinary text. |\r\n| `searchDebounceMs` | `120` | Debounce before a query reaches the adapter. Shared. |\r\n\r\nOnly the **length floor** is configurable. The syntax rules are fixed at every\r\nsetting: a username's legal characters still close the `@` menu on the first\r\ncharacter it cannot accept, and `[[` still never opens after `!` or once the\r\nlink is closed or aliased.\r\n\r\n`disabled` is the switch for a surface that has no people directory, or a\r\ndeployment that does not use note links — previously the only way to suppress a\r\ntrigger was not to mount the editor. It drops the trigger extension *and* the\r\nmatching suggestion provider; the embed nodes (wikilink rendering, media,\r\nanchors) stay registered, so existing `[[Note]]` links in the body keep working.\r\n\r\nMenu size is CSS, not a prop: override `--luthor-typeahead-menu-max-height` and\r\n`--luthor-typeahead-menu-width` on your own scope.\r\n\r\n## Usage\r\n\r\n~~~tsx\r\nimport '@lyfie/luthor/styles.css';\r\nimport { PapyraEditor, type PapyraEditorRef } from '@lyfie/luthor';\r\nimport { useRef } from 'react';\r\n\r\nexport function NoteCanvas({ body }: { body: string }) {\r\n const ref = useRef(null);\r\n\r\n return (\r\n `/api/media/${name}`,\r\n uploadMedia: async (file) => {\r\n const stored = await upload(file);\r\n return { filename: stored.name };\r\n },\r\n openNote: ({ title }) => router.push(`/notes/${title}`),\r\n searchNotes: (q) => api.searchNotes(q),\r\n searchUsers: (q) => api.searchUsers(q),\r\n }}\r\n onReady={(editor) => {\r\n // Read the body imperatively — never a controlled value.\r\n console.log(editor.getMarkdown());\r\n }}\r\n />\r\n );\r\n}\r\n~~~\r\n\r\n`PapyraEditor` is also available as a subpath export:\r\n\r\n~~~ts\r\nimport { PapyraEditor } from '@lyfie/luthor/presets/papyra';\r\n~~~\r\n\r\n## Embeds and lossless round-trips\r\n\r\nEvery custom embed ships a bidirectional markdown transformer, so the body that\r\n`getMarkdown()` returns is byte-stable across repeated saves:\r\n\r\n| Markdown | Renders as |\r\n| ------------------- | ----------------------------------- |\r\n| `![[diagram.png]]` | inline image (via `resolveMediaUrl`)|\r\n| `[[Note]]` | wikilink (click → `openNote`) |\r\n| `[[Note\\|alias]]` | aliased wikilink |\r\n| `![[Note#^id]]` | read-only transclusion |\r\n| `text ^id` | trailing block anchor (non-rendering)|\r\n| `![[card:url]]` | saved web card (via `resolveCard`) |\r\n| `![[card:url\\|title]]` | saved web card with author title |\r\n| `![[youtube:url]]` | YouTube player (optional `\\|caption`)|\r\n| `![[iframe:url]]` | iframe embed (optional `\\|caption`) |\r\n| `> [!transcript]` | transcription callout (display-only)|\r\n\r\nThe embed nodes and transformers live in `@lyfie/luthor-headless` and are\r\nre-exported through `@lyfie/luthor` — the preset only composes and themes them.\r\n\r\nThe **transcription callout** is an Obsidian-style `> [!transcript]` block: an\r\nopening line (optionally `> [!transcript] Title`) followed by `>`-prefixed body\r\nlines, terminated by a blank line. It renders as an accent-tinted, labelled block\r\non the quote surface and is display-only — the transcript text lives inline in the\r\nbody, so it needs no resolver and round-trips verbatim (the marker is normalized\r\nto lowercase).\r\n\r\nThe **saved web card** (`![[card:url]]`) renders an archived link card. When the\r\nhost wires `resolveCard`, the editor enriches it with the page's open-graph\r\nmetadata (title, description, preview image, favicon, site name); without a\r\nresolver the card degrades to a titled link to the URL. As with every embed, only\r\nthe verbatim `url` (and optional `|title`) is serialized, so the metadata is\r\nrender-only and the markdown round-trips unchanged.\r\n\r\nThe **YouTube** (`![[youtube:url]]`) and **iframe** (`![[iframe:url]]`) embeds\r\nreuse the shared media nodes from `@lyfie/luthor-headless` and carry an optional\r\n`|caption`. A YouTube `watch`/`youtu.be`/`shorts` link is normalized to the\r\ncanonical `…/embed/` player URL on the first pass and is byte-stable\r\nafterward; an iframe URL gains `https://` if it has none. Frame size and\r\nalignment are session-only presentation state with no markdown representation\r\n(like image dimensions), so the markdown text itself round-trips unchanged.\r\n\r\n## Command surface\r\n\r\nThe slash menu and command palette are curated down to note-taking primitives:\r\nheadings (H1–H3), lists and checklist, quote, code block, table, horizontal\r\nrule, and image. The typography pickers, view tabs, and pinned toolbar are\r\nenforced off.\r\n\r\nOn top of the curated built-ins, PapyraEditor contributes three note-specific\r\nslash commands through the editor's `extraSlashCommands` seam:\r\n\r\n| Command | Inserts |\r\n| ------------- | ------------------------------------------------------------- |\r\n| `Link note` | the `[[` trigger, which opens the wikilink typeahead |\r\n| `Embed media` | a picked file → `adapter.uploadMedia` → `![[filename]]` |\r\n| `Insert date` | today's date as `YYYY-MM-DD` |\r\n\r\nEach writes markdown-native syntax at the caret, so the body stays the source of\r\ntruth and round-trips unchanged. The commands are appended automatically — there\r\nis nothing to wire beyond supplying an `adapter` for `Embed media` to upload\r\nthrough.\r\n", + "plainContent": "Papyra Editor PapyraEditor is a markdown-native note canvas composed on top of the extensive preset. It is the editor the Papyra note app ships, but it is host-agnostic: every external capability (media, uploads, note search and navigation, block resolution) flows through an injected adapter, so any note app can reuse it by supplying different configuration. When to use this Reach for PapyraEditor when your body of truth is a frontmatter-free markdown file and you want a polished, restricted writing surface with Obsidian-style embeds — [[Note]] wikilinks, ![[file.ext]] media, ![[Note ^id]] transclusion, and trailing ^id block anchors — that survives a lossless round-trip back to that file. The four invariants 1. Markdown is the source of truth. getMarkdown() returns exactly what lands in the .md body (CommonMark plus the documented embed set). There is no JSON or HTML \"real\" format. The preset runs sourceMetadataMode=\"none\" . 2. The body is frontmatter-free. The host splits YAML from the body and hands the editor only the body; the preset never renders, emits, or mangles frontmatter. 3. The caret is sacred. The editor is uncontrolled — it reads defaultContent once on mount and there is no value prop or controlled round-trip. The onChange callback is a notification , not a value binding: nothing you do in it re-renders the document. Adopt a remote revision by remounting (change the React key ) or by calling setMarkdown imperatively, never with a live-DOM patch. 4. Theming is token-driven. All color and typography flow from var(--papyra- , fallback) tokens. The preset bundles no fonts — the host loads Marcellus / Sora / Roboto Mono. Locked contract PapyraEditor hard-locks the props it owns; callers cannot reach them: - Modes are fixed to ['visual', 'markdown'] (no json / html ). - View tabs hidden. By default the only toolbar is floating-on-selection; the opt-in toolbar prop adds an always-visible toolbar, but it is never pinned. - markdownSourceOfTruth is on and sourceMetadataMode=\"none\" . - featureFlags are routed through papyraFeaturePolicy , whose enforced set keeps markdown-breaking features off — font/size/line-height pickers, arbitrary text color and highlight, sub/superscript, the in-editor theme toggle, and draggable blocks cannot be switched back on by a caller flag. Preset props - adapter : the host seam ( PapyraEditorAdapter ). Supplies media resolution, uploads, note search/navigation, and block resolution. Omit it and the preset uses a graceful no-op adapter so the editor still renders and round-trips. - colored : light-locks a tinted (\"colored\") note so ink stays readable on the host-painted paper, regardless of the ambient app theme. - readOnly : mounts a non-editable surface ( visual-only , click-to-edit disabled) that emits no change events — safe for revision previews and time-machine scrubbing. Pair with repeated setMarkdown calls. - variant : \"focus\" widens the body to a centered, distraction-free measure; \"default\" is the standard editorial measure. - toolbar : opt into a persistent toolbar above the editor (default false — floating-on-selection only). It lists just Papyra's markdown-safe actions (headings/paragraph, quote, bold/italic/strikethrough/inline-code/link, lists + checklist, code block, horizontal rule, table, image); the restricted controls (typography pickers, color/highlight, sub/superscript, alignment, theme toggle) can never appear, and the toolbar is never pinned. After the built-ins come Papyra's own inserts: Link a note (types [[ ), Mention someone (types @ , adding a space first after a word — only when the adapter has searchUsers ), Attach file (uploads through the adapter and embeds ![[name]] — only with an adapter), Embed (a menu: YouTube video or web page, each asking for the link in a themed dialog) and Insert today's date . The prop is live — toggling it shows or hides the toolbar without remounting the editor, so the caret and undo history survive. Only renders in the editable visual surface, so readOnly / locked never show it. - toolbarLayout : the persistent toolbar's layout — order, sections, and which sections collapse behind one button ( group ). Defaults to PAPYRA TOOLBAR LAYOUT . Restricted items stay stripped by the visibility contract whatever the layout lists. Place host items with \"customComponent\" (all) or \"custom: \" (one). - toolbarItems : host controls for the toolbar, replacing the preset's own inserts ( createPapyraToolbarItems ) — so a host can use its own icons, grouping and behaviour. Items use runCommand to reach editor commands such as uploadAndEmbedFile , insertImage , insertYouTubeEmbed or startMention . - imageUploadHandler : where the editor's own image paths (the toolbar image menu, /image ) store a picked file; return the URL to use. Without it they fall back to a blob: URL, which does not survive a reload — set it. - locked : withholds the body entirely — renders a blurred placeholder and never mounts the editor , so there is no plaintext in the DOM. The lock is UX only; the server ( 401 / PathGuard ) is the security boundary. - blockAnchors : block-anchor assignment policy — \"off\" (default; anchors already in the text parse and round-trip, nothing creates one), \"on-demand\" (the host stamps at save time via ensureBlockAnchors() ), or \"auto\" (every anchorable block — paragraph, heading, quote, and list item — gets a stable ^id appended on commit; - item ^abc12345 and - [ ] task ^abc12345 are valid markdown and round-trip verbatim). Anchors are invisible in the visual surface: no ^id artefact, no caret stop, nothing selectable — while the trailing ^id round-trips losslessly in the markdown. An anchor is always kept last in its block, in every mode: the caret snaps in front of it, and text that lands behind it moves it back to the end. That is what keeps a block's id stable when you type at the end of an anchored line — an id is an address, and ![[Note ^id]] references to it must not change under the writer. - onChange : first-class change notification (see Change notification and autosave ). - onDesync : opt-in model/DOM divergence watchdog (see The DOM is not the source of truth ). - onOutlineChange : fired (debounced) with the current document outline; drives a host's live table-of-contents scrollbar. Read-only observation — the caret is never touched. - typeahead : host tuning for the @ and [ triggers — how eagerly each opens, its caret offset, its copy, whether it is registered at all, and the shared search debounce. See [Tuning the typeahead . - featureFlags : per-feature overrides, resolved through the enforced policy. Change notification and autosave Do not attach a DOM onInput handler to a wrapper element — it will never fire. Lexical stops propagation of the contenteditable's input event, so React's delegated synthetic onInput on an ancestor receives nothing, and an autosave driven that way silently saves nothing. Wire autosave to the editor's own onChange instead: tsx { if (source !== 'user') return; // ignore your own setMarkdown adopts if (!isDirty) return; scheduleAutosave(markdown); // your debounce; one call per commit arrives here }} / The payload is { markdown, source, isDirty } : - Fires for every mutation path — typing, toolbar formatting, slash commands, undo/redo, paste, drag-drop, and markdown source-view edits — with source: \"user\" , coalesced to one call per committed change (typing a character produces exactly one call). - The initial defaultContent load never fires. A host-initiated setMarkdown fires at most once with source: \"programmatic\" (only when it actually changes the content), so your autosave can ignore it. - isDirty compares against the editor's own serialization of the mounted (or last adopted) content — see the normalisation contract below. Ready timing and the normalisation contract onReady fires only after the editor is interactive and the initial content has been injected and reconciled, so getMarkdown() called synchronously inside the callback is already stable — no settle timers: tsx onReady={(editor) = { baselineRef.current = editor.getMarkdown(); // safe: no setTimeout needed }} Note that getMarkdown() is not byte-identical to the markdown you loaded: the editor re-normalises everything it imports (list markers, spacing, fence style), so getMarkdown(setMarkdown(x)) !== x in general. Always baseline your dirty checks against the editor's own output — the onReady snapshot or the markdown field of an onChange payload — never against your input string. The DOM is not the source of truth Serialization reads the Lexical model, never the DOM. Text written into the contenteditable behind the reconciler's back — document.execCommand , browser extensions, password managers, translation tools — can render on screen while being absent from getMarkdown() . Two consequences: - Never assert against innerText in tests ; use getMarkdown() . - Never mutate the contenteditable directly ; go through setMarkdown or the command surface. To be told instead of silently losing such writes, pass onDesync : an internal observer compares the visible text with the model after external mutations settle and reports { domText, modelText } when they disagree. Keyboard access: escaping Tab capture Tab indents (and Shift+Tab outdents) inside the editor, which would otherwise trap keyboard-only users (WCAG 2.1.2). The standard escape is built in: press Escape, then Tab — the armed Tab performs the browser's native focus move out of the editor instead of indenting; any other key restores Tab-as-indent. Advertise \"Press Esc then Tab to move focus out of the editor\" in your help UI. Imperative ref PapyraEditorRef extends ExtensiveEditorRef with the markdown-first surface a host drives: setMarkdown(md) (host-driven adopt), focus() , getOutline() / scrollToHeading(key) for the table of contents, getBlocks() for block anchors, ensureBlockAnchors() to stamp missing anchors and return the stamped body (the \"on-demand\" save-time flow), and getMentions() for @username detection. The host calls these during its own orchestration (autosave, remount, TOC) — they never fire on keystrokes. Each getBlocks() entry carries the anchor id plus the block's own text , line , and start / end character offsets in the markdown body, so a host can resolve a ^id reference without re-parsing the document. The host adapter The adapter is the entire contract between the preset and the host. The editor declares it; the host implements it. ts interface PapyraEditorAdapter { resolveMediaUrl(filename: string): string; // ![[file]] → URL uploadMedia(file: File): Promise ; // drop/paste → store openNote(ref: { title?: string; id?: string }): void; // [[Note]] → navigate searchNotes(q: string): Promise ; searchUsers?(q: string): Promise ; resolveBlock?(ref: { note: string; blockId: string }): Promise ; resolveCard?(url: string): Promise ; // ![[card:url]] → metadata onMentions?(usernames: string[]): void; } The adapter's resolvers are where the host's server-side authorization lives. The editor's blur/lock is UX, never the boundary. Typeahead dropdowns Two triggers open a caret-anchored dropdown, and both are fed by the adapter: Trigger Menu Fed by Inserts ------- ---------- ----------------------- -------------------------- [[ note links searchNotes(q) a [[Note]] wikilink @ mentions searchUsers(q) plain @username text Both are host-gated: with no adapter (or, for @ , no searchUsers ) the trigger is silent and no menu renders, so the editor never offers a suggestion it cannot honour. Keyboard-driven throughout — Escape closes, arrows move, Enter/Tab select, and a click outside dismisses. The @ trigger matches the mention rule byte-for-byte: an @ only opens the menu at the start of a block or after whitespace, ( , or [ , and the query is [A-Za-z0-9][A-Za-z0-9. -]{0,63} . bea@example.com never opens it. Mentions are written as plain text , not a node — nothing to serialize, nothing that can rewrite the body on save — and hosts detect them by scanning the markdown (see getMentions() ). Both triggers open on the bare trigger by default ( minQueryLength: 0 ), so typing @ or [ shows the host's first page immediately. Raise the floor per trigger if an unfiltered list is noise — see [Tuning the typeahead . Where they open. Both triggers only open inside a block that can carry a ^id block anchor — paragraph, heading, quote, and list item. That is not a style choice: a host resolves a mention to the anchor of the block it sits in, so a mention typed anywhere else could never be delivered. The trigger container set and the block-anchor stamping set are derived from one exported source of truth ( ANCHORABLE BLOCK TYPES in @lyfie/luthor-headless ), so they cannot drift apart. Code blocks and tables are excluded in both. Tuning the typeahead The typeahead prop is the host's seam over both triggers. Every field is optional and defaults to the shipped behaviour, so omitting it changes nothing. tsx Field Default What it does ----------------------- ---------------------- -------------------------------------------------------------- minQueryLength 0 Characters required after the trigger before the menu opens. offset { x: 0, y: 8 } Menu position relative to the caret. title Mention / Link note Menu heading (localisation, product vocabulary). emptyLabel see above Shown when the host's search returns nothing. disabled false Unregisters the trigger — the character stays ordinary text. searchDebounceMs 120 Debounce before a query reaches the adapter. Shared. Only the length floor is configurable. The syntax rules are fixed at every setting: a username's legal characters still close the @ menu on the first character it cannot accept, and [[ still never opens after ! or once the link is closed or aliased. disabled is the switch for a surface that has no people directory, or a deployment that does not use note links — previously the only way to suppress a trigger was not to mount the editor. It drops the trigger extension and the matching suggestion provider; the embed nodes (wikilink rendering, media, anchors) stay registered, so existing [[Note]] links in the body keep working. Menu size is CSS, not a prop: override --luthor-typeahead-menu-max-height and --luthor-typeahead-menu-width on your own scope. Usage tsx import '@lyfie/luthor/styles.css'; import { PapyraEditor, type PapyraEditorRef } from '@lyfie/luthor'; import { useRef } from 'react'; export function NoteCanvas({ body }: { body: string }) { const ref = useRef (null); return ( /api/media/${name} , uploadMedia: async (file) = { const stored = await upload(file); return { filename: stored.name }; }, openNote: ({ title }) = router.push( /notes/${title} ), searchNotes: (q) = api.searchNotes(q), searchUsers: (q) = api.searchUsers(q), }} onReady={(editor) = { // Read the body imperatively — never a controlled value. console.log(editor.getMarkdown()); }} / ); } PapyraEditor is also available as a subpath export: ts import { PapyraEditor } from '@lyfie/luthor/presets/papyra'; Embeds and lossless round-trips Every custom embed ships a bidirectional markdown transformer, so the body that getMarkdown() returns is byte-stable across repeated saves: Markdown Renders as ------------------- ----------------------------------- ![[diagram.png]] inline image (via resolveMediaUrl ) [[Note]] wikilink (click → openNote ) [[Note\\ alias]] aliased wikilink ![[Note ^id]] read-only transclusion text ^id trailing block anchor (non-rendering) ![[card:url]] saved web card (via resolveCard ) ![[card:url\\ title]] saved web card with author title ![[youtube:url]] YouTube player (optional \\ caption ) ![[iframe:url]] iframe embed (optional \\ caption ) [!transcript] transcription callout (display-only) The embed nodes and transformers live in @lyfie/luthor-headless and are re-exported through @lyfie/luthor — the preset only composes and themes them. The transcription callout is an Obsidian-style [!transcript] block: an opening line (optionally [!transcript] Title ) followed by -prefixed body lines, terminated by a blank line. It renders as an accent-tinted, labelled block on the quote surface and is display-only — the transcript text lives inline in the body, so it needs no resolver and round-trips verbatim (the marker is normalized to lowercase). The saved web card ( ![[card:url]] ) renders an archived link card. When the host wires resolveCard , the editor enriches it with the page's open-graph metadata (title, description, preview image, favicon, site name); without a resolver the card degrades to a titled link to the URL. As with every embed, only the verbatim url (and optional title ) is serialized, so the metadata is render-only and the markdown round-trips unchanged. The YouTube ( ![[youtube:url]] ) and iframe ( ![[iframe:url]] ) embeds reuse the shared media nodes from @lyfie/luthor-headless and carry an optional caption . A YouTube watch / youtu.be / shorts link is normalized to the canonical …/embed/ player URL on the first pass and is byte-stable afterward; an iframe URL gains https:// if it has none. Frame size and alignment are session-only presentation state with no markdown representation (like image dimensions), so the markdown text itself round-trips unchanged. Command surface The slash menu and command palette are curated down to note-taking primitives: headings (H1–H3), lists and checklist, quote, code block, table, horizontal rule, and image. The typography pickers, view tabs, and pinned toolbar are enforced off. On top of the curated built-ins, PapyraEditor contributes three note-specific slash commands through the editor's extraSlashCommands seam: Command Inserts ------------- ------------------------------------------------------------- Link note the [[ trigger, which opens the wikilink typeahead Embed media a picked file → adapter.uploadMedia → ![[filename]] Insert date today's date as YYYY-MM-DD Each writes markdown-native syntax at the caret, so the body stays the source of truth and round-trips unchanged. The commands are appended automatically — there is nothing to wire beyond supplying an adapter for Embed media to upload through.", "sections": [ { "heading": "Overview", @@ -7084,7 +7106,7 @@ export const docsIndex = [ "heading": "Preset props", "id": "preset-props", "level": 2, - "text": "- adapter : the host seam ( PapyraEditorAdapter ). Supplies media resolution, uploads, note search/navigation, and block resolution. Omit it and the preset uses a graceful no-op adapter so the editor still renders and round-trips. - colored : light-locks a tinted (\"colored\") note so ink stays readable on the host-painted paper, regardless of the ambient app theme. - readOnly : mounts a non-editable surface ( visual-only , click-to-edit disabled) that emits no change events — safe for revision previews and time-machine scrubbing. Pair with repeated setMarkdown calls. - variant : \"focus\" widens the body to a centered, distraction-free measure; \"default\" is the standard editorial measure. - toolbar : opt into a persistent toolbar above the editor (default false — floating-on-selection only). It lists just Papyra's markdown-safe actions (history, headings/paragraph, quote, bold/italic/strikethrough/inline-code/link, lists + checklist, code block, horizontal rule, table, image); the restricted controls (typography pickers, color/highlight, sub/superscript, alignment, theme toggle) can never appear, and the toolbar is never pinned. After the built-ins come Papyra's own inserts: Link a note (types [[ ), Mention someone (types @ , adding a space first after a word — only when the adapter has searchUsers ), Attach file (uploads through the adapter and embeds ![[name]] — only with an adapter), Embed (a menu: YouTube video or web page, each asking for the link in a themed dialog) and Insert today's date . The prop is live — toggling it shows or hides the toolbar without remounting the editor, so the caret and undo history survive. Only renders in the editable visual surface, so readOnly / locked never show it. - locked : withholds the body entirely — renders a blurred placeholder and never mounts the editor , so there is no plaintext in the DOM. The lock is UX only; the server ( 401 / PathGuard ) is the security boundary. - blockAnchors : block-anchor assignment policy — \"off\" (default; anchors already in the text parse and round-trip, nothing creates one), \"on-demand\" (the host stamps at save time via ensureBlockAnchors() ), or \"auto\" (every anchorable block — paragraph, heading, quote, and list item — gets a stable ^id appended on commit; - item ^abc12345 and - [ ] task ^abc12345 are valid markdown and round-trip verbatim). Anchors are invisible in the visual surface: no ^id artefact, no caret stop, nothing selectable — while the trailing ^id round-trips losslessly in the markdown. An anchor is always kept last in its block, in every mode: the caret snaps in front of it, and text that lands behind it moves it back to the end. That is what keeps a block's id stable when you type at the end of an anchored line — an id is an address, and ![[Note ^id]] references to it must not change under the writer. - onChange : first-class change notification (see Change notification and autosave ). - onDesync : opt-in model/DOM divergence watchdog (see The DOM is not the source of truth ). - onOutlineChange : fired (debounced) with the current document outline; drives a host's live table-of-contents scrollbar. Read-only observation — the caret is never touched. - typeahead : host tuning for the @ and [ triggers — how eagerly each opens, its caret offset, its copy, whether it is registered at all, and the shared search debounce. See [Tuning the typeahead . - featureFlags : per-feature overrides, resolved through the enforced policy." + "text": "- adapter : the host seam ( PapyraEditorAdapter ). Supplies media resolution, uploads, note search/navigation, and block resolution. Omit it and the preset uses a graceful no-op adapter so the editor still renders and round-trips. - colored : light-locks a tinted (\"colored\") note so ink stays readable on the host-painted paper, regardless of the ambient app theme. - readOnly : mounts a non-editable surface ( visual-only , click-to-edit disabled) that emits no change events — safe for revision previews and time-machine scrubbing. Pair with repeated setMarkdown calls. - variant : \"focus\" widens the body to a centered, distraction-free measure; \"default\" is the standard editorial measure. - toolbar : opt into a persistent toolbar above the editor (default false — floating-on-selection only). It lists just Papyra's markdown-safe actions (headings/paragraph, quote, bold/italic/strikethrough/inline-code/link, lists + checklist, code block, horizontal rule, table, image); the restricted controls (typography pickers, color/highlight, sub/superscript, alignment, theme toggle) can never appear, and the toolbar is never pinned. After the built-ins come Papyra's own inserts: Link a note (types [[ ), Mention someone (types @ , adding a space first after a word — only when the adapter has searchUsers ), Attach file (uploads through the adapter and embeds ![[name]] — only with an adapter), Embed (a menu: YouTube video or web page, each asking for the link in a themed dialog) and Insert today's date . The prop is live — toggling it shows or hides the toolbar without remounting the editor, so the caret and undo history survive. Only renders in the editable visual surface, so readOnly / locked never show it. - toolbarLayout : the persistent toolbar's layout — order, sections, and which sections collapse behind one button ( group ). Defaults to PAPYRA TOOLBAR LAYOUT . Restricted items stay stripped by the visibility contract whatever the layout lists. Place host items with \"customComponent\" (all) or \"custom: \" (one). - toolbarItems : host controls for the toolbar, replacing the preset's own inserts ( createPapyraToolbarItems ) — so a host can use its own icons, grouping and behaviour. Items use runCommand to reach editor commands such as uploadAndEmbedFile , insertImage , insertYouTubeEmbed or startMention . - imageUploadHandler : where the editor's own image paths (the toolbar image menu, /image ) store a picked file; return the URL to use. Without it they fall back to a blob: URL, which does not survive a reload — set it. - locked : withholds the body entirely — renders a blurred placeholder and never mounts the editor , so there is no plaintext in the DOM. The lock is UX only; the server ( 401 / PathGuard ) is the security boundary. - blockAnchors : block-anchor assignment policy — \"off\" (default; anchors already in the text parse and round-trip, nothing creates one), \"on-demand\" (the host stamps at save time via ensureBlockAnchors() ), or \"auto\" (every anchorable block — paragraph, heading, quote, and list item — gets a stable ^id appended on commit; - item ^abc12345 and - [ ] task ^abc12345 are valid markdown and round-trip verbatim). Anchors are invisible in the visual surface: no ^id artefact, no caret stop, nothing selectable — while the trailing ^id round-trips losslessly in the markdown. An anchor is always kept last in its block, in every mode: the caret snaps in front of it, and text that lands behind it moves it back to the end. That is what keeps a block's id stable when you type at the end of an anchored line — an id is an address, and ![[Note ^id]] references to it must not change under the writer. - onChange : first-class change notification (see Change notification and autosave ). - onDesync : opt-in model/DOM divergence watchdog (see The DOM is not the source of truth ). - onOutlineChange : fired (debounced) with the current document outline; drives a host's live table-of-contents scrollbar. Read-only observation — the caret is never touched. - typeahead : host tuning for the @ and [ triggers — how eagerly each opens, its caret offset, its copy, whether it is registered at all, and the shared search debounce. See [Tuning the typeahead . - featureFlags : per-feature overrides, resolved through the enforced policy." }, { "heading": "Change notification and autosave", @@ -7232,7 +7254,7 @@ export const docsIndex = [ ], "urlPath": "/docs/luthor/presets/papyra-editor/", "sourcePath": "apps/web/src/content/docs/luthor/presets/papyra-editor.md", - "updatedAt": "2026-09-27T05:28:43.064Z", + "updatedAt": "2026-09-27T10:33:21.622Z", "package": "luthor", "docType": "reference", "surface": "preset", diff --git a/apps/web/src/data/homepage-metrics.generated.ts b/apps/web/src/data/homepage-metrics.generated.ts index 39d4256..1147cf6 100644 --- a/apps/web/src/data/homepage-metrics.generated.ts +++ b/apps/web/src/data/homepage-metrics.generated.ts @@ -1,11 +1,11 @@ export const homepageMetrics = { "totalDownloads": 26021, "lastMonthDownloads": 3551, - "latestVersion": "2.9.8", - "headlessVersion": "2.9.8", - "luthorPackageSize": 363188, - "headlessPackageSize": 435313, - "combinedPackageSize": 798501, - "releaseCount": 91, - "fetchedAtIso": "2026-09-27T05:29:47.451Z" + "latestVersion": "2.10.0", + "headlessVersion": "2.10.0", + "luthorPackageSize": 375352, + "headlessPackageSize": 436866, + "combinedPackageSize": 812218, + "releaseCount": 95, + "fetchedAtIso": "2026-09-27T10:34:29.491Z" } as const; diff --git a/documentation/developer_notes/pack-size-baseline.md b/documentation/developer_notes/pack-size-baseline.md index ed64615..dcbf783 100644 --- a/documentation/developer_notes/pack-size-baseline.md +++ b/documentation/developer_notes/pack-size-baseline.md @@ -1,8 +1,8 @@ # Package Size Baseline -Generated on 2026-09-27T05:31:56.854Z. +Generated on 2026-09-27T10:36:06.035Z. | Package | Version | Packed (bytes) | Unpacked (bytes) | Files | Budget (packed/unpacked) | | --- | --- | ---: | ---: | ---: | --- | -| @lyfie/luthor | 2.9.8 | 89,121 | 374,427 | 26 | 350,000 / 1,700,000 | -| @lyfie/luthor-headless | 2.9.8 | 107,616 | 436,021 | 4 | 130,000 / 550,000 | +| @lyfie/luthor | 2.9.9 | 92,573 | 387,386 | 26 | 350,000 / 1,700,000 | +| @lyfie/luthor-headless | 2.9.9 | 107,650 | 436,165 | 4 | 130,000 / 550,000 | diff --git a/packages/headless/src/extensions/formatting/LinkExtension.security.test.tsx b/packages/headless/src/extensions/formatting/LinkExtension.security.test.tsx index 0a848fa..d60d5ed 100644 --- a/packages/headless/src/extensions/formatting/LinkExtension.security.test.tsx +++ b/packages/headless/src/extensions/formatting/LinkExtension.security.test.tsx @@ -205,6 +205,38 @@ describe("programmatic entry point", () => { }); }); + it("insertLink with text at a bare caret writes a link carrying that text", async () => { + const { editor } = createTestEditor(); + const extension = new LinkExtension(); + extension.register(editor); + const commands = extension.getCommands(editor); + seedParagraphWithCaret(editor, "See "); + + commands.insertLink("https://example.com", "the docs"); + await flush(); + + expect(await collectLinkUrls(editor)).toEqual(["https://example.com"]); + editor.getEditorState().read(() => { + expect($getRoot().getTextContent()).toBe("See the docs"); + }); + }); + + it("insertLink with no text at a bare caret shows the address itself", async () => { + const { editor } = createTestEditor(); + const extension = new LinkExtension(); + extension.register(editor); + const commands = extension.getCommands(editor); + seedParagraphWithCaret(editor); + + commands.insertLink("https://example.com"); + await flush(); + + expect(await collectLinkUrls(editor)).toEqual(["https://example.com"]); + editor.getEditorState().read(() => { + expect($getRoot().getTextContent()).toBe("https://example.com"); + }); + }); + it("updateLinkByKey rejects hostile URLs and keeps the existing href", async () => { const { editor } = createTestEditor(); const extension = new LinkExtension(); diff --git a/packages/headless/src/extensions/formatting/LinkExtension.tsx b/packages/headless/src/extensions/formatting/LinkExtension.tsx index b429318..d119926 100644 --- a/packages/headless/src/extensions/formatting/LinkExtension.tsx +++ b/packages/headless/src/extensions/formatting/LinkExtension.tsx @@ -353,17 +353,25 @@ export class LinkExtension extends BaseExtension< return; } - // If text is provided, insert it first, then apply link - if (text) { - editor.update(() => { - const selection = $getSelection(); - if (selection) { - selection.insertText(text); - } - }); + // With text — or a bare caret, which has nothing to wrap — write a + // link node holding the text (the URL itself when none is given) in + // place of the selection. Inserting the text and then toggling a link + // on the (by then collapsed) selection linked nothing. + let handled = false; + editor.update(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) return; + if (!text && !selection.isCollapsed()) return; + const link = $createLinkNode(normalizedUrl); + link.append($createTextNode(text || normalizedUrl)); + selection.insertNodes([link]); + link.selectEnd(); + handled = true; + }); + if (!handled) { + // A selection with no text given: link the selected text. + editor.dispatchCommand(TOGGLE_LINK_COMMAND, normalizedUrl); } - // Apply link to current selection - editor.dispatchCommand(TOGGLE_LINK_COMMAND, normalizedUrl); } else { // Prompt for URL if not provided const linkUrl = prompt("Enter URL:"); diff --git a/packages/luthor/src/core/commands.test.ts b/packages/luthor/src/core/commands.test.ts index 04bc825..526442d 100644 --- a/packages/luthor/src/core/commands.test.ts +++ b/packages/luthor/src/core/commands.test.ts @@ -390,3 +390,36 @@ describe("command heading configuration", () => { secondRoot.remove(); }); }); + +describe("commands that ask for a value", () => { + it("ask through the editor's themed dialog when it is supplied", async () => { + const insertImage = vi.fn(); + const requestInput = vi.fn(async () => ({ src: "https://x.test/a.png", alt: "A" })); + const command = generateCommands().find((c) => c.id === "insert.image"); + await command?.action({ insertImage, requestInput } as unknown as CoreEditorCommands); + + expect(requestInput).toHaveBeenCalledWith(expect.objectContaining({ title: "Insert image from a link" })); + expect(insertImage).toHaveBeenCalledWith({ src: "https://x.test/a.png", alt: "A" }); + }); + + it("do nothing when the dialog is cancelled", async () => { + const insertYouTubeEmbed = vi.fn(); + const command = generateCommands().find((c) => c.id === "insert.youtube"); + await command?.action({ insertYouTubeEmbed, requestInput: async () => null } as unknown as CoreEditorCommands); + expect(insertYouTubeEmbed).not.toHaveBeenCalled(); + }); + + it("fall back to window.prompt only when the host supplies no dialog", async () => { + const insertImage = vi.fn(); + const prompt = vi.fn((label: string) => (label === "Image link" ? "https://x.test/b.png" : "B")); + vi.stubGlobal("prompt", prompt); + try { + const command = generateCommands().find((c) => c.id === "insert.image"); + await command?.action({ insertImage } as unknown as CoreEditorCommands); + expect(insertImage).toHaveBeenCalledWith({ src: "https://x.test/b.png", alt: "B" }); + } finally { + vi.unstubAllGlobals(); + } + }); +}); + diff --git a/packages/luthor/src/core/commands.ts b/packages/luthor/src/core/commands.ts index 623204e..824799f 100644 --- a/packages/luthor/src/core/commands.ts +++ b/packages/luthor/src/core/commands.ts @@ -10,6 +10,7 @@ import { BLOCK_HEADING_LEVELS, type BlockHeadingLevel, type CoreEditorCommands, + type InputRequest, type SlashCommandVisibility, } from "./types"; @@ -67,7 +68,7 @@ export type CommandConfig = { label: string; description?: string; category: string; - action: (commands: CoreEditorCommands) => void; + action: (commands: CoreEditorCommands) => void | Promise; shortcuts?: KeyboardShortcut[]; keywords?: string[]; condition?: (commands: CoreEditorCommands) => boolean; @@ -156,6 +157,28 @@ function resolveAvailableCommands( return resolvedCommands.filter((command) => !command.condition || command.condition(commands)); } +/** + * Ask for values through the editor's themed dialog, or `window.prompt` (one + * field at a time) when the host supplies none. Resolves `null` on cancel or a + * missing required field. + */ +async function askFor( + commands: CoreEditorCommands, + request: InputRequest, +): Promise | null> { + if (commands.requestInput) { + return commands.requestInput(request); + } + if (typeof prompt !== "function") return null; + const values: Record = {}; + for (const field of request.fields) { + const answer = prompt(field.label)?.trim() ?? ""; + if (field.required && !answer) return null; + values[field.name] = answer; + } + return values; +} + function isCommandAvailable( commands: CoreEditorCommands, key: keyof CoreEditorCommands, @@ -465,11 +488,14 @@ export function generateCommands(options?: CommandGenerationOptions): CommandCon label: "Set Code Language", description: "Set language for selected code block", category: "Block", - action: (commands) => { + action: async (commands) => { if (!commands.setCodeLanguage) return; - const language = prompt("Code language (e.g. ts, css, python):")?.trim(); - if (!language) return; - commands.setCodeLanguage(language); + const values = await askFor(commands, { + title: "Code language", + submitLabel: "Set", + fields: [{ name: "language", label: "Language (e.g. ts, css, python)", required: true }], + }); + if (values?.language) commands.setCodeLanguage(values.language); }, keywords: ["code", "language", "syntax"], condition: (commands) => isFeatureEnabled("codeIntelligence") && supportsCodeLanguageCommands(commands), @@ -549,12 +575,16 @@ export function generateCommands(options?: CommandGenerationOptions): CommandCon label: "Insert Image", description: "Insert an image from URL", category: "Insert", - action: (commands) => { - const src = prompt("Enter image URL:"); - if (src) { - const alt = prompt("Enter alt text:") || ""; - commands.insertImage?.({ src, alt }); - } + action: async (commands) => { + const values = await askFor(commands, { + title: "Insert image from a link", + submitLabel: "Insert", + fields: [ + { name: "src", label: "Image link", placeholder: "https://…", type: "url", required: true }, + { name: "alt", label: "Description (alt text)" }, + ], + }); + if (values?.src) commands.insertImage?.({ src: values.src, alt: values.alt ?? "" }); }, keywords: ["image", "photo"], condition: (commands) => isFeatureEnabled("image") && isCommandAvailable(commands, "insertImage"), @@ -564,12 +594,16 @@ export function generateCommands(options?: CommandGenerationOptions): CommandCon label: "Insert GIF", description: "Insert an animated GIF from URL", category: "Insert", - action: (commands) => { - const src = prompt("Enter GIF URL:"); - if (!src) { - return; - } - commands.insertImage?.({ src, alt: "GIF" }); + action: async (commands) => { + const values = await askFor(commands, { + title: "Insert a GIF from a link", + submitLabel: "Insert", + fields: [ + { name: "src", label: "GIF link", placeholder: "https://…/animation.gif", type: "url", required: true }, + { name: "alt", label: "Description (alt text)" }, + ], + }); + if (values?.src) commands.insertImage?.({ src: values.src, alt: values.alt || "GIF" }); }, keywords: ["gif", "animated", "image"], condition: (commands) => isFeatureEnabled("image") && isCommandAvailable(commands, "insertImage"), @@ -579,8 +613,14 @@ export function generateCommands(options?: CommandGenerationOptions): CommandCon label: "Insert Emoji", description: "Insert an emoji character", category: "Insert", - action: (commands) => { - const value = prompt("Enter emoji or shortcode (example :sparkles:):")?.trim(); + action: async (commands) => { + if (typeof commands.insertEmoji !== "function") return; + const values = await askFor(commands, { + title: "Insert emoji", + submitLabel: "Insert", + fields: [{ name: "emoji", label: "Emoji or shortcode (e.g. :sparkles:)", required: true }], + }); + const value = values?.emoji; if (!value || typeof commands.insertEmoji !== "function") { return; } @@ -626,15 +666,16 @@ export function generateCommands(options?: CommandGenerationOptions): CommandCon label: "Insert iframe", description: "Insert an iframe embed from URL", category: "Insert", - action: (commands) => { + action: async (commands) => { if (typeof commands.insertIframeEmbed !== "function") { return; } - - const inputUrl = prompt("Enter iframe URL:"); - if (!inputUrl) return; - - commands.insertIframeEmbed(inputUrl); + const values = await askFor(commands, { + title: "Embed a web page", + submitLabel: "Embed", + fields: [{ name: "url", label: "Page link", placeholder: "https://…", type: "url", required: true }], + }); + if (values?.url) commands.insertIframeEmbed(values.url); }, keywords: ["iframe", "embed", "url"], condition: (commands) => isFeatureEnabled("iframeEmbed") && supportsIframeEmbed(commands), @@ -644,15 +685,18 @@ export function generateCommands(options?: CommandGenerationOptions): CommandCon label: "Insert YouTube Video", description: "Insert an embedded YouTube video", category: "Insert", - action: (commands) => { + action: async (commands) => { if (typeof commands.insertYouTubeEmbed !== "function") { return; } - - const inputUrl = prompt("Enter YouTube URL:"); - if (!inputUrl) return; - - commands.insertYouTubeEmbed(inputUrl); + const values = await askFor(commands, { + title: "Embed a YouTube video", + submitLabel: "Embed", + fields: [ + { name: "url", label: "Video link", placeholder: "https://www.youtube.com/watch?v=…", type: "url", required: true }, + ], + }); + if (values?.url) commands.insertYouTubeEmbed(values.url); }, keywords: ["youtube", "video", "embed"], condition: (commands) => isFeatureEnabled("youTubeEmbed") && supportsYouTubeEmbed(commands), diff --git a/packages/luthor/src/core/styles.css b/packages/luthor/src/core/styles.css index 95a7e57..3f843ef 100644 --- a/packages/luthor/src/core/styles.css +++ b/packages/luthor/src/core/styles.css @@ -1617,8 +1617,16 @@ padding: 12px; } +/* Portalled to (see Dialog): pinned to the viewport. */ +.luthor-dialog-overlay--portal { + position: fixed; +} + .luthor-dialog { - background-color: var(--luthor-bg); + /* The floating surface first: a host that paints the editor's paper itself + leaves --luthor-bg transparent, and a dialog must stay opaque. */ + background-color: var(--luthor-floating-bg, var(--luthor-bg)); + color: var(--luthor-fg); border-radius: 10px; box-shadow: 0 20px 30px var(--luthor-shadow); width: min(520px, calc(100% - 24px)); @@ -1643,6 +1651,12 @@ } .luthor-dialog-close { + /* 28px: clear of the 24px minimum target size (WCAG 2.5.8). */ + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + min-height: 28px; background: none; border: none; color: var(--luthor-muted-fg); @@ -1727,7 +1741,9 @@ .luthor-button-primary { background-color: var(--luthor-accent); border-color: var(--luthor-accent); - color: var(--luthor-bg); + /* Ink on the accent. Not --luthor-bg: a host that paints the paper itself + leaves that transparent, and the label vanished. */ + color: var(--luthor-floating-accent-fg, var(--luthor-bg)); } .luthor-button-primary:hover { @@ -2965,3 +2981,29 @@ scroll-behavior: auto !important; } } + +/* Toolbar groups: one button standing in for a section, its items in a row + under it. */ +.luthor-toolbar-group-trigger { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.luthor-toolbar-group-chevron { + opacity: 0.6; +} + +.luthor-toolbar-group-menu { + display: flex; + align-items: center; + gap: 2px; + padding: 4px; +} + +.luthor-dialog-message { + margin: 0; + font-size: 13px; + line-height: 1.5; + color: var(--luthor-fg); +} diff --git a/packages/luthor/src/core/toolbar.custom-items.test.tsx b/packages/luthor/src/core/toolbar.custom-items.test.tsx index f860d96..4273bb9 100644 --- a/packages/luthor/src/core/toolbar.custom-items.test.tsx +++ b/packages/luthor/src/core/toolbar.custom-items.test.tsx @@ -98,7 +98,7 @@ describe("toolbar custom items", () => { }); fireEvent.click(submit); - expect(onSelect).toHaveBeenCalledWith("https://example.com"); + expect(onSelect).toHaveBeenCalledWith("https://example.com", { value: "https://example.com" }); expect(screen.queryByText("Embed a web page")).toBeNull(); }); @@ -120,4 +120,47 @@ describe("toolbar custom items", () => { expect(onSelect).not.toHaveBeenCalled(); expect(screen.queryByText("Embed a web page")).toBeNull(); }); + + it("adds extra fields to the dialog and hands their values over by name", () => { + const onSelect = vi.fn(); + renderToolbar([ + { + id: "img", + label: "Image from link", + icon: , + input: { + title: "Image from a link", + label: "Image link", + extraFields: [{ name: "alt", label: "Alt text" }], + }, + onSelect, + }, + ]); + + fireEvent.click(screen.getByRole("button", { name: "Image from link" })); + fireEvent.change(screen.getByLabelText("Image link"), { target: { value: "https://x.test/a.png" } }); + fireEvent.change(screen.getByLabelText("Alt text"), { target: { value: " A cat " } }); + fireEvent.click(screen.getByRole("button", { name: "Insert" })); + + expect(onSelect).toHaveBeenCalledWith("https://x.test/a.png", { value: "https://x.test/a.png", alt: "A cat" }); + }); + + it("places a single custom item with custom:", () => { + render( + true} + activeStates={{} as CoreEditorActiveStates} + isDark={false} + toggleTheme={() => {}} + layout={{ sections: [{ items: ["custom:b"] }] }} + customItems={[ + { id: "a", label: "Item A", icon: , onSelect: vi.fn() }, + { id: "b", label: "Item B", icon: , onSelect: vi.fn() }, + ]} + />, + ); + expect(screen.queryByRole("button", { name: "Item A" })).toBeNull(); + expect(screen.getByRole("button", { name: "Item B" })).toBeInTheDocument(); + }); }); diff --git a/packages/luthor/src/core/toolbar.groups.test.tsx b/packages/luthor/src/core/toolbar.groups.test.tsx new file mode 100644 index 0000000..6ca1ea9 --- /dev/null +++ b/packages/luthor/src/core/toolbar.groups.test.tsx @@ -0,0 +1,111 @@ +/** + * Copyright (c) Luthor Team and contributors. + * Open source under the MIT License (LICENSE). + * Fork it. Remix it. Ship it. + * Build freely. Credit kindly. + */ + +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { Toolbar, isToolbarItemActive } from "./toolbar"; +import type { CoreEditorActiveStates, CoreEditorCommands, ToolbarLayout } from "./types"; + +const LAYOUT: ToolbarLayout = { + sections: [ + { items: ["bold", "italic", "strikethrough"], group: { id: "style", label: "Text style" } }, + { items: ["link"] }, + ], +}; + +function renderToolbar(activeStates: CoreEditorActiveStates = {}) { + const commands = { + toggleBold: vi.fn(), + toggleItalic: vi.fn(), + toggleStrikethrough: vi.fn(), + insertLink: vi.fn(), + removeLink: vi.fn(), + } as unknown as CoreEditorCommands; + render( + true} + activeStates={activeStates} + isDark={false} + toggleTheme={() => {}} + layout={LAYOUT} + />, + ); + return commands; +} + +describe("toolbar groups", () => { + it("collapses a grouped section behind one button and keeps the rest inline", () => { + renderToolbar(); + expect(screen.getByRole("button", { name: "Text style" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Bold/ })).toBeNull(); + expect(screen.getByRole("button", { name: /Insert Link/ })).toBeInTheDocument(); + }); + + it("opens the group's items, which act as usual", () => { + const commands = renderToolbar(); + const trigger = screen.getByRole("button", { name: "Text style" }); + fireEvent.click(trigger); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + fireEvent.click(screen.getByRole("button", { name: /Bold/ })); + expect(commands.toggleBold).toHaveBeenCalled(); + }); + + it("closes on a click outside and on Escape", () => { + renderToolbar(); + const trigger = screen.getByRole("button", { name: "Text style" }); + fireEvent.click(trigger); + fireEvent.mouseDown(document.body); + expect(screen.queryByRole("button", { name: /Bold/ })).toBeNull(); + + fireEvent.click(trigger); + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByRole("button", { name: /Bold/ })).toBeNull(); + }); + + it("lights the group button while an item inside is active", () => { + renderToolbar({ italic: true }); + expect(screen.getByRole("button", { name: "Text style" }).className).toContain("active"); + }); + + it("maps items to their active state", () => { + expect(isToolbarItemActive("bold", { bold: true })).toBe(true); + expect(isToolbarItemActive("link", { isLink: true })).toBe(true); + expect(isToolbarItemActive("codeBlock", { isInCodeBlock: true })).toBe(true); + expect(isToolbarItemActive("table", { bold: true })).toBe(false); + }); + + it("keeps the group open while a menu inside it is used", () => { + const onYouTube = vi.fn(); + render( + true} + activeStates={{}} + isDark={false} + toggleTheme={() => {}} + layout={{ sections: [{ items: ["customComponent"], group: { id: "insert", label: "Insert" } }] }} + customItems={[ + { + id: "embed", + label: "Embed", + icon: , + items: [{ id: "yt", label: "YouTube video", icon: , onSelect: onYouTube }], + }, + ]} + />, + ); + fireEvent.click(screen.getByRole("button", { name: "Insert" })); + fireEvent.click(screen.getByRole("button", { name: "Embed" })); + const child = screen.getByRole("button", { name: "YouTube video" }); + // A real click starts with mousedown — inside the child menu's portal, + // which must not count as "outside" the group. + fireEvent.mouseDown(child); + fireEvent.click(child); + expect(onYouTube).toHaveBeenCalled(); + }); +}); diff --git a/packages/luthor/src/core/toolbar.tsx b/packages/luthor/src/core/toolbar.tsx index 5796c04..e289bed 100644 --- a/packages/luthor/src/core/toolbar.tsx +++ b/packages/luthor/src/core/toolbar.tsx @@ -16,6 +16,7 @@ import React, { type ChangeEvent, type Dispatch, type ReactElement, + type ReactNode, type SetStateAction, } from "react"; import { createPortal } from "react-dom"; @@ -56,10 +57,10 @@ import { StrikethroughIcon, ChevronDownIcon, } from "./icons"; -import { Button, Dialog, Dropdown, IconButton, Select } from "./ui"; +import { Button, Dialog, Dropdown, IconButton, InputDialog, Select } from "./ui"; import { getOverlayThemeStyleFromElement } from "./overlay-theme"; import { computeAnchoredOverlayStyle, resolveEditorPortalContainer } from "./overlay-position"; -import { BLOCK_HEADING_LEVELS, type BlockHeadingLevel, type CoreEditorActiveStates, type CoreEditorCommands, type CoreToolbarClassNames, type InsertTableConfig, type ImageAlignment, type ToolbarCustomItem, type ToolbarLayout, type ToolbarItemType, type ToolbarStyleVars, type ToolbarVisibility } from "./types"; +import { BLOCK_HEADING_LEVELS, type BlockHeadingLevel, type CoreEditorActiveStates, type CoreEditorCommands, type CoreToolbarClassNames, type InsertTableConfig, type InputRequest, type ImageAlignment, type ToolbarCustomItem, type ToolbarLayout, type ToolbarItemType, type ToolbarStyleVars, type ToolbarVisibility } from "./types"; import { TRADITIONAL_TOOLBAR_LAYOUT } from "./types"; type SelectOption = { @@ -777,8 +778,18 @@ function ColorPickerButton({ ); } +/** Values the toolbar asks for, and what to do with them once given. */ +interface PromptRequest extends InputRequest { + onSubmit: (values: Record) => void; +} + +type AskFn = (request: PromptRequest) => void; +type ReportFn = (title: string, message: string) => void; + function useImageHandlers( commands: CoreEditorCommands, + ask: AskFn, + report: ReportFn, imageUploadHandler?: (file: File) => Promise, gifUploadHandler?: (file: File) => Promise, ) { @@ -787,46 +798,57 @@ function useImageHandlers( const handlers = useMemo( () => ({ - insertFromUrl: () => { - const src = prompt("Enter image URL:"); - if (!src) return; - const alt = prompt("Enter alt text:") || ""; - const caption = prompt("Enter caption (optional):") || undefined; - commands.insertImage({ src, alt, caption }); - }, + insertFromUrl: () => + ask({ + title: "Insert image from a link", + submitLabel: "Insert", + fields: [ + { name: "src", label: "Image link", placeholder: "https://…", type: "url", required: true }, + { name: "alt", label: "Description (alt text)" }, + { name: "caption", label: "Caption (optional)" }, + ], + onSubmit: ({ src, alt, caption }) => + commands.insertImage({ src: src ?? "", alt: alt ?? "", caption: caption || undefined }), + }), insertFromFile: () => fileInputRef.current?.click(), - insertGifFromUrl: () => { - const src = prompt("Enter GIF URL:"); - if (!src) return; - const alt = prompt("Enter alt text (optional):") || "GIF"; - commands.insertImage({ src, alt }); - }, + insertGifFromUrl: () => + ask({ + title: "Insert a GIF from a link", + submitLabel: "Insert", + fields: [ + { name: "src", label: "GIF link", placeholder: "https://…/animation.gif", type: "url", required: true }, + { name: "alt", label: "Description (alt text)" }, + ], + onSubmit: ({ src, alt }) => commands.insertImage({ src: src ?? "", alt: alt || "GIF" }), + }), insertGifFromFile: () => gifInputRef.current?.click(), handleUpload: async (event: ChangeEvent) => { - const file = event.target.files?.[0]; + const input = event.target; + const file = input.files?.[0]; + input.value = ""; if (!file) return; let src: string; if (imageUploadHandler) { try { src = await imageUploadHandler(file); } catch { - alert("Failed to upload image"); + report("Couldn’t upload the image", `“${file.name}” didn’t upload. Check your connection and try again.`); return; } } else { src = URL.createObjectURL(file); } commands.insertImage({ src, alt: file.name, file }); - event.target.value = ""; }, handleGifUpload: async (event: ChangeEvent) => { - const file = event.target.files?.[0]; + const input = event.target; + const file = input.files?.[0]; + input.value = ""; if (!file) return; const isGif = file.type === "image/gif" || file.name.toLowerCase().endsWith(".gif"); if (!isGif) { - alert("Please select a GIF file."); - event.target.value = ""; + report("That isn’t a GIF", `“${file.name}” isn’t a .gif file. Choose a GIF, or upload it as an image instead.`); return; } @@ -836,7 +858,7 @@ function useImageHandlers( try { src = await effectiveGifUploadHandler(file); } catch { - alert("Failed to upload GIF"); + report("Couldn’t upload the GIF", `“${file.name}” didn’t upload. Check your connection and try again.`); return; } } else { @@ -844,47 +866,53 @@ function useImageHandlers( } commands.insertImage({ src, alt: file.name || "GIF", file }); - event.target.value = ""; }, setAlignment: (alignment: ImageAlignment) => { commands.setImageAlignment(alignment); }, - setCaption: () => { - const newCaption = prompt("Enter caption:") || ""; - commands.setImageCaption(newCaption); - }, + setCaption: () => + ask({ + title: "Image caption", + submitLabel: "Save", + fields: [{ name: "caption", label: "Caption" }], + onSubmit: ({ caption }) => commands.setImageCaption(caption ?? ""), + }), }), - [commands, imageUploadHandler, gifUploadHandler], + [commands, ask, report, imageUploadHandler, gifUploadHandler], ); return { handlers, fileInputRef, gifInputRef }; } -function useEmbedHandlers(commands: CoreEditorCommands) { +function useEmbedHandlers(commands: CoreEditorCommands, ask: AskFn) { return useMemo( () => ({ insertIframe: () => { - const inputUrl = prompt("Enter iframe URL:"); - if (!inputUrl) return; - - if (typeof commands.insertIframeEmbed !== "function") { - return; - } - - commands.insertIframeEmbed(inputUrl); + if (typeof commands.insertIframeEmbed !== "function") return; + ask({ + title: "Embed a web page", + submitLabel: "Embed", + fields: [{ name: "url", label: "Page link", placeholder: "https://…", type: "url", required: true }], + onSubmit: ({ url }) => { + if (url) commands.insertIframeEmbed?.(url); + }, + }); }, insertYouTube: () => { - const inputUrl = prompt("Enter YouTube URL:"); - if (!inputUrl) return; - - if (typeof commands.insertYouTubeEmbed !== "function") { - return; - } - - commands.insertYouTubeEmbed(inputUrl); + if (typeof commands.insertYouTubeEmbed !== "function") return; + ask({ + title: "Embed a YouTube video", + submitLabel: "Embed", + fields: [ + { name: "url", label: "Video link", placeholder: "https://www.youtube.com/watch?v=…", type: "url", required: true }, + ], + onSubmit: ({ url }) => { + if (url) commands.insertYouTubeEmbed?.(url); + }, + }); }, }), - [commands], + [commands, ask], ); } @@ -937,6 +965,46 @@ export function isToolbarItemSupported(itemType: ToolbarItemType, hasExtension: } } +/** A group without its own icon borrows its first item's — when that is a plain button. */ +function groupFallbackIcon(first: ReactElement | undefined): ReactNode { + if (first?.type !== IconButton) return null; + return (first.props as { children?: ReactNode }).children; +} + +/** + * Whether a toolbar item's own button shows as active for `activeStates` — + * used to light up a group's button while something inside it is on. + */ +export function isToolbarItemActive(itemType: ToolbarItemType, activeStates: CoreEditorActiveStates): boolean { + switch (itemType) { + case "bold": + case "italic": + case "underline": + case "strikethrough": + case "code": + case "subscript": + case "superscript": + case "unorderedList": + case "orderedList": + case "checkList": + return activeStates[itemType] === true; + case "link": + return activeStates.isLink === true; + case "quote": + return activeStates.isQuote === true; + case "codeBlock": + return activeStates.isInCodeBlock === true; + case "image": + return activeStates.imageSelected === true; + case "textColor": + return activeStates.hasCustomTextColor === true; + case "textHighlight": + return activeStates.hasTextHighlight === true; + default: + return false; + } +} + export function isToolbarItemVisible( itemType: ToolbarItemType, hasExtension: (name: string) => boolean, @@ -1005,12 +1073,32 @@ export function Toolbar({ isListStyleDropdownEnabled = true, customItems, }: ToolbarProps) { + // One themed dialog serves every value the toolbar asks for (image and embed + // links, captions, host items' inputs), and one for what it has to report — + // never window.prompt/alert, which ignore the editor's theme. + const [promptRequest, setPromptRequest] = useState(null); + const [notice, setNotice] = useState<{ title: string; message: string } | null>(null); + // Inside a preset the editor hosts one dialog for everything that asks + // (toolbar, slash menu, palette) via commands.requestInput; a Toolbar used on + // its own falls back to a dialog of its own. + const ask = useCallback((request) => { + if (commands.requestInput) { + void commands.requestInput(request).then((values) => { + if (values) request.onSubmit(values); + }); + return; + } + setPromptRequest(request); + }, [commands]); + const report = useCallback((title, message) => setNotice({ title, message }), []); const { handlers, fileInputRef, gifInputRef } = useImageHandlers( commands, + ask, + report, imageUploadHandler, gifUploadHandler, ); - const embedHandlers = useEmbedHandlers(commands); + const embedHandlers = useEmbedHandlers(commands, ask); const hasAnyEmbedExtension = hasExtension("iframeEmbed") || hasExtension("youtubeEmbed"); const isAnyEmbedSelected = activeStates.isIframeEmbedSelected || @@ -1024,8 +1112,7 @@ export function Toolbar({ const [showCheckListDropdown, setShowCheckListDropdown] = useState(false); const [showTableDialog, setShowTableDialog] = useState(false); const [openCustomMenu, setOpenCustomMenu] = useState(null); - const [customInputItem, setCustomInputItem] = useState(null); - const [customInputValue, setCustomInputValue] = useState(""); + const [openGroup, setOpenGroup] = useState(null); const [fontFamilyValue, setFontFamilyValue] = useState("default"); const [fontFamilyOptions, setFontFamilyOptions] = useState([ { value: "default", label: DEFAULT_FONT_FAMILY_FALLBACK_LABEL }, @@ -1502,23 +1589,23 @@ export function Toolbar({ const selectCustomItem = (item: ToolbarCustomItem) => { setOpenCustomMenu(null); - if (item.input) { - setCustomInputValue(""); - setCustomInputItem(item); + setOpenGroup(null); + const input = item.input; + if (input) { + ask({ + title: input.title, + submitLabel: input.submitLabel, + fields: [ + { name: "value", label: input.label, placeholder: input.placeholder, type: input.type, required: true }, + ...(input.extraFields ?? []), + ], + onSubmit: (values) => void item.onSelect?.(values.value, values), + }); return; } void item.onSelect?.(); }; - const closeCustomInput = () => setCustomInputItem(null); - - const submitCustomInput = () => { - const item = customInputItem; - const value = customInputValue.trim(); - if (!item || !value) return; - setCustomInputItem(null); - void item.onSelect?.(value); - }; const renderCustomItem = (item: ToolbarCustomItem): ReactElement => { if (item.items && item.items.length > 0) { @@ -1683,7 +1770,21 @@ export function Toolbar({ return ( (activeStates.isLink ? commands.removeLink() : commands.insertLink())} + onClick={() => + activeStates.isLink + ? commands.removeLink() + : ask({ + title: "Insert link", + submitLabel: "Link", + fields: [ + { name: "url", label: "Link address", placeholder: "https://…", type: "url", required: true }, + { name: "text", label: "Text to show (optional — uses the selection)" }, + ], + onSubmit: ({ url, text }) => { + if (url) commands.insertLink(url, text || undefined); + }, + }) + } active={activeStates.isLink} title={activeStates.isLink ? "Remove Link" : "Insert Link"} > @@ -2104,8 +2205,13 @@ export function Toolbar({ if (!customItems || customItems.length === 0) return null; return customItems.map(renderCustomItem); - default: + default: { + if (itemType.startsWith("custom:")) { + const item = customItems?.find((candidate) => candidate.id === itemType.slice("custom:".length)); + return item ? renderCustomItem(item) : null; + } return null; + } } }; @@ -2150,6 +2256,36 @@ export function Toolbar({ // Only render section if it has at least one item if (renderedItems.length === 0) return null; + if (section.group) { + const group = section.group; + const active = section.items.some((itemType) => isToolbarItemActive(itemType, activeStates)); + return ( +
+ + {group.icon ?? groupFallbackIcon(renderedItems[0])} + + + } + isOpen={openGroup === group.id} + onOpenChange={(open) => setOpenGroup(open ? group.id : null)} + > +
+ {renderedItems} +
+
+
+ ); + } + return (
{renderedItems} @@ -2216,35 +2352,25 @@ export function Toolbar({
- -
{ - event.preventDefault(); - submitCustomInput(); - }} - > -
- - setCustomInputValue(event.target.value)} - autoFocus - /> -
+ setPromptRequest(null)} + onSubmit={(values) => { + const request = promptRequest; + setPromptRequest(null); + request?.onSubmit(values); + }} + /> + + setNotice(null)} title={notice?.title ?? ""}> +
+

{notice?.message}

- -
- +
); diff --git a/packages/luthor/src/core/types.ts b/packages/luthor/src/core/types.ts index 88d403f..2442ef9 100644 --- a/packages/luthor/src/core/types.ts +++ b/packages/luthor/src/core/types.ts @@ -41,7 +41,31 @@ export interface InsertImageConfig { file?: File; } +/** One field in an {@link InputRequest}. */ +export interface InputField { + name: string; + label: string; + placeholder?: string; + type?: "text" | "url"; + /** Required fields must be filled before the dialog submits. */ + required?: boolean; +} + +/** Values a command asks the person for, in the editor's themed dialog. */ +export interface InputRequest { + title: string; + submitLabel?: string; + fields: readonly InputField[]; +} + export interface CoreEditorCommands { + /** + * Ask the person for values in the editor's own themed dialog (image and + * embed links, a code language, …). Resolves with the trimmed values by field + * name, or `null` when they cancel. Supplied by the preset shell; when it is + * absent (a bare headless host) commands fall back to `window.prompt`. + */ + requestInput?: (request: InputRequest) => Promise | null>; toggleBold: () => void; toggleItalic: () => void; toggleUnderline: () => void; @@ -90,7 +114,8 @@ export interface CoreEditorCommands { }[]; toggleSubscript?: () => void; toggleSuperscript?: () => void; - insertLink: () => void; + /** Link the selection, or with `url` + `text` insert a new link; with no `url` the extension asks for one. */ + insertLink: (url?: string, text?: string) => void; updateLink?: (url: string, rel?: string, target?: string) => boolean; removeLink: () => void; getCurrentLink?: () => Promise<{ @@ -435,7 +460,10 @@ export type ToolbarItemType = | "redo" // Utilities | "commandPalette" - | "themeToggle"; + | "themeToggle" + // One host item from the toolbar's `customItems`, by id — place a single + // custom control anywhere in the layout ("customComponent" places them all). + | `custom:${string}`; export type ToolbarVisibility = Partial>; @@ -465,6 +493,20 @@ export interface ToolbarCustomItemInput { submitLabel?: string; /** Input type. Defaults to `"text"`. */ type?: "text" | "url"; + /** + * More fields shown under the first — alt text under an image link, say. + * Their values reach `onSelect` in its second argument, by name. + */ + extraFields?: readonly ToolbarCustomItemField[]; +} + +/** An extra field on a {@link ToolbarCustomItemInput} dialog. */ +export interface ToolbarCustomItemField { + name: string; + label: string; + placeholder?: string; + type?: "text" | "url"; + required?: boolean; } /** @@ -490,11 +532,27 @@ export interface ToolbarCustomItem { * Runs when the item is chosen. Receives the dialog value when `input` is * set. May be async; the toolbar does not await it. */ - onSelect?: (value?: string) => void | Promise; + onSelect?: (value?: string, values?: Record) => void | Promise; +} + +/** + * Collapses a toolbar section behind one button: the section's items open in a + * small menu under it, which keeps a long toolbar short. The button shows as + * active while any item inside is (bold text under a "Text style" group, say). + */ +export interface ToolbarGroup { + /** Stable id (unique within the layout). */ + id: string; + /** Accessible name and tooltip for the group's button. */ + label: string; + /** The button's icon. Defaults to the first item's icon. */ + icon?: ReactNode; } export type ToolbarSection = { items: readonly ToolbarItemType[]; + /** Show the items behind one button instead of inline. */ + group?: ToolbarGroup; }; export type ToolbarLayout = { diff --git a/packages/luthor/src/core/ui.tsx b/packages/luthor/src/core/ui.tsx index 9b88f61..9f8a0c7 100644 --- a/packages/luthor/src/core/ui.tsx +++ b/packages/luthor/src/core/ui.tsx @@ -5,10 +5,11 @@ * Build freely. Credit kindly. */ -import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { ChevronDownIcon, CloseIcon } from "./icons"; import { getOverlayThemeStyleFromElement } from "./overlay-theme"; +import type { InputRequest } from "./types"; import { computeAnchoredOverlayStyle, resolveEditorPortalContainer } from "./overlay-position"; export function IconButton({ @@ -216,6 +217,15 @@ export function Select({ ); } +/** + * Lets a dropdown opened from inside another dropdown's menu (a sub-menu, or a + * toolbar group holding a control that has its own menu) tell its ancestors + * about its portalled content, so a click there does not count as "outside" + * and close the parent — which would unmount the child with it. + */ +type DropdownNest = { register: (el: HTMLElement) => () => void }; +const DropdownNestContext = createContext(null); + export function Dropdown({ trigger, children, @@ -232,6 +242,31 @@ export function Dropdown({ const contentRef = useRef(null); const [dropdownStyle, setDropdownStyle] = useState(undefined); const [portalContainer, setPortalContainer] = useState(null); + const parentNest = useContext(DropdownNestContext); + const nested = useRef(new Set()); + const nest = useMemo( + () => ({ + register: (el) => { + nested.current.add(el); + const unregisterParent = parentNest?.register(el); + return () => { + nested.current.delete(el); + unregisterParent?.(); + }; + }, + }), + [parentNest], + ); + // Register this menu's own content with every ancestor while it is mounted. + const unregisterContent = useRef<(() => void) | null>(null); + const setContentRef = useCallback( + (el: HTMLDivElement | null) => { + contentRef.current = el; + unregisterContent.current?.(); + unregisterContent.current = el && parentNest ? parentNest.register(el) : null; + }, + [parentNest], + ); useEffect(() => { if (!isOpen) return; @@ -320,21 +355,38 @@ export function Dropdown({ }, [isOpen]); useEffect(() => { + if (!isOpen) return; function handleClickOutside(event: MouseEvent) { const target = event.target as Node; if (dropdownRef.current?.contains(target)) return; if (contentRef.current?.contains(target)) return; + for (const el of nested.current) { + if (el.contains(target)) return; + } + onOpenChange(false); + } + function handleEscape(event: KeyboardEvent) { + if (event.key !== "Escape" || event.defaultPrevented) return; + // Claim the key so a host listening further up (a modal that closes on + // Escape) leaves itself open: this Escape was for the menu. + event.preventDefault(); onOpenChange(false); } document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [onOpenChange]); + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleEscape); + }; + }, [isOpen, onOpenChange]); return (
onOpenChange(!isOpen)}>{trigger}
{isOpen && typeof document !== "undefined" && createPortal( -
{children}
, + +
{children}
+
, portalContainer ?? document.body, )}
@@ -354,6 +406,18 @@ export function Dialog({ }) { const dialogRef = useRef(null); const titleId = useId(); + // The dialog is portalled to so it is a true top-level modal: inside + // the editor it sat in the wrapper's own stacking context (the wrapper is + // isolated), under the host's page chrome and anything the host layers over + // the editor. It carries the editor's theme with it, read off a marker that + // stays in place. + const anchorRef = useRef(null); + const [themeStyle, setThemeStyle] = useState(undefined); + useLayoutEffect(() => { + if (isOpen && anchorRef.current) { + setThemeStyle(getOverlayThemeStyleFromElement(anchorRef.current)); + } + }, [isOpen]); useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -379,10 +443,11 @@ export function Dialog({ }; }, [isOpen, onClose]); - if (!isOpen) return null; + const anchor =