diff --git a/.changeset/lexical-markdown-shortcuts.md b/.changeset/lexical-markdown-shortcuts.md new file mode 100644 index 0000000..b18a3b4 --- /dev/null +++ b/.changeset/lexical-markdown-shortcuts.md @@ -0,0 +1,5 @@ +--- +"@opral/zettel-lexical": minor +--- + +Add opt-in Markdown shortcuts: `registerZettelLexicalPlugin(editor, { markdownShortcuts: true })`. They format `**bold**`/`__bold__`, `*italic*`/`_italic_`, `` `code` `` and `~~strike~~` as the closing marker is typed. `- `/`* `, `1. ` and `> ` at the start of a paragraph start a list or a quote. Bare http(s) URLs are linked on space, on Return and on paste, and pasting a URL over selected text links it. Undo reverts a conversion in one step. `onLinkShortcut` handles Cmd/Ctrl+K on selected text or a link, with the link UI supplied by the application. diff --git a/examples/playground/src.js b/examples/playground/src.js index 2c982ae..46a5bd9 100644 --- a/examples/playground/src.js +++ b/examples/playground/src.js @@ -18,6 +18,7 @@ const editor = createZettelEditor({ }); editor.setRootElement($("editor")); registerZettelLexicalPlugin(editor, { + markdownShortcuts: true, onPasteDiagnostics(diagnostics) { $("diagnostics").textContent = diagnostics .map((item) => item?.message ?? String(item)) diff --git a/examples/playground/test-browser.mjs b/examples/playground/test-browser.mjs index e2de2c7..cd54a52 100644 --- a/examples/playground/test-browser.mjs +++ b/examples/playground/test-browser.mjs @@ -230,6 +230,62 @@ try { "underlined text renders underlined", ); checks.push("Cmd+U underlines and round-trips as the underline mark"); + await applyMarkdown("Start\n"); + await caret("#editor p", 5); + await page.keyboard.type(" **bold** after *it* `code` https://example.com/x. done"); + await page.waitForTimeout(80); + assert.deepEqual( + (await doc()).blocks[0].children.map((child) => [child.text, child.marks.map((mark) => (["strong", "em", "code"].includes(mark) ? mark : "link"))]), + [ + ["Start ", []], + ["bold", ["strong"]], + [" after ", []], + ["it", ["em"]], + [" ", []], + ["code", ["code"]], + [" ", []], + ["https://example.com/x", ["link"]], + [". done", []], + ], + "Markdown shortcuts format typed text and typing continues unformatted", + ); + assert.equal((await doc()).blocks[0].markDefs[0]?.href, "https://example.com/x"); + await page.keyboard.press("ControlOrMeta+z"); + await page.waitForTimeout(80); + assert.equal( + (await doc()).blocks[0].children.map((child) => child.text).join(""), + "Start bold after it code https://example.com/x. ", + "Undo first reverts the typing after the last conversion", + ); + await page.keyboard.press("ControlOrMeta+z"); + await page.waitForTimeout(80); + edited = await doc(); + assert.equal(edited.blocks[0].children.map((child) => child.text).join(""), "Start bold after it code https://example.com/x. "); + assert.deepEqual(edited.blocks[0].markDefs, [], "The next undo reverts only the autolink"); + await applyMarkdown("Start\n"); + await caret("#editor p", 5); + await page.keyboard.type(" **b**"); + await page.waitForTimeout(80); + await page.keyboard.press("ControlOrMeta+z"); + await page.waitForTimeout(80); + assert.deepEqual( + (await doc()).blocks[0].children.map((child) => [child.text, child.marks]), + [["Start **b**", []]], + "Undo reverts a shortcut conversion in one step", + ); + await applyMarkdown("Start\n"); + await caret("#editor p", 5); + await page.keyboard.press("Enter"); + await page.keyboard.type("- one"); + await page.keyboard.press("Enter"); + await page.keyboard.type("two"); + await page.waitForTimeout(80); + assert.deepEqual( + (await doc()).blocks.map((block) => block._type === "zettel_list" ? [block.kind, block.items.map((item) => item.blocks[0].children.map((child) => child.text).join(""))] : block.children.map((child) => child.text).join("")), + ["Start", ["bullet", ["one", "two"]]], + "- at the start of a paragraph starts a bullet list", + ); + checks.push("Markdown shortcuts format, list and link typed text; undo reverts a conversion"); for (const inputType of ["insertReplacementText", "insertFromDrop"]) { await applyMarkdown("Hello wrold again.\n"); await page.locator("#editor p").evaluate((el) => { diff --git a/packages/zettel-lexical/README.md b/packages/zettel-lexical/README.md index bd1a67d..9fb665e 100644 --- a/packages/zettel-lexical/README.md +++ b/packages/zettel-lexical/README.md @@ -45,3 +45,29 @@ and unknown extension nodes stay read-only source atoms. Pasting a document at a text caret inserts its inline content at that caret; multi-block pastes use Lexical's block insertion behavior. The adapter delegates HTML sanitization and rendering to `@opral/zettel-html`. + +### Markdown shortcuts + +Markdown-style input rules are opt-in: + +```ts +registerZettelLexicalPlugin(editor, { + markdownShortcuts: true, + // Optional: Cmd+K / Ctrl+K on selected text or in a link. + onLinkShortcut: ({ text, href }) => window.prompt(`Link for "${text}"`, href ?? "https://"), +}); +``` + +With `markdownShortcuts`, `**bold**`, `__bold__`, `*italic*`, `_italic_`, +`` `code` `` and `~~strike~~` are formatted when the closing marker is typed; +`- `, `* `, `1. ` and `> ` at the start of a paragraph start a bullet list, a +numbered list or a quote; and a bare http(s) URL becomes a link when it is +followed by a space or Return, or when it is pasted. Pasting a URL over +selected text links the text. Each conversion is its own undo step, so Undo +brings back the typed Markdown. The shortcuts only create marks and blocks +the Zettel profile stores. + +`onLinkShortcut` receives the selected text and the current link, and returns +an href, `null` to remove the link, or `undefined` to leave it unchanged (a +promise of these works too). The editor has no link UI of its own; without the +callback, Cmd+K is left to the browser. diff --git a/packages/zettel-lexical/src/markdown-shortcuts.test.ts b/packages/zettel-lexical/src/markdown-shortcuts.test.ts new file mode 100644 index 0000000..c22fcac --- /dev/null +++ b/packages/zettel-lexical/src/markdown-shortcuts.test.ts @@ -0,0 +1,164 @@ +// @vitest-environment jsdom +import { afterEach, expect, it } from 'vitest'; +import { $getRoot, $getSelection, $isRangeSelection, CONTROLLED_TEXT_INSERTION_COMMAND, KEY_DOWN_COMMAND, KEY_ENTER_COMMAND, PASTE_COMMAND, UNDO_COMMAND, type LexicalEditor } from 'lexical'; +import { createZettelEditor, registerZettelLexicalPlugin, loadDocument, exportDocument, type ZettelLexicalPluginOptions } from './index.js'; + +const cleanups: Array<() => void> = []; +afterEach(() => { while (cleanups.length) cleanups.pop()!(); }); +// Not mounted: jsdom's selectionchange timestamps cannot match Lexical's, so a +// mounted editor would reset the caret format from the DOM. The Chromium +// playground covers the mounted editor. +function setup(text = '', options: ZettelLexicalPluginOptions = { markdownShortcuts: true }): LexicalEditor { + const editor = createZettelEditor({ onError: error => { throw error; } }); + const unregister = registerZettelLexicalPlugin(editor, options); + cleanups.push(unregister); + loadDocument(editor, { _type: 'zettel_doc', blocks: [{ _type: 'zettel_block', _key: 'p', style: 'normal', markDefs: [], children: text ? [{ _type: 'zettel_span', _key: 's', text, marks: [] }] : [] }] }); + editor.update(() => { $getRoot().getFirstChildOrThrow().selectEnd(); }, { discrete: true }); + return editor; +} +const flush = () => new Promise(resolve => setTimeout(resolve, 0)); +async function type(editor: LexicalEditor, text: string) { + for (const character of text) { + editor.update(() => { editor.dispatchCommand(CONTROLLED_TEXT_INSERTION_COMMAND, character); }, { discrete: true }); + await flush(); + } +} +async function enter(editor: LexicalEditor) { + editor.update(() => { editor.dispatchCommand(KEY_ENTER_COMMAND, new KeyboardEvent('keydown', { key: 'Enter' })); }, { discrete: true }); + await flush(); +} +async function undo(editor: LexicalEditor) { + editor.update(() => { editor.dispatchCommand(UNDO_COMMAND, undefined); }, { discrete: true }); + await flush(); +} +async function paste(editor: LexicalEditor, text: string) { + const event = new Event('paste', { cancelable: true }) as ClipboardEvent; + Object.defineProperty(event, 'clipboardData', { value: { types: ['text/plain'], getData: (type: string) => type === 'text/plain' ? text : '' } }); + editor.update(() => { editor.dispatchCommand(PASTE_COMMAND, event); }, { discrete: true }); + await flush(); +} +// Text blocks as [text, marks] pairs; links show as `link:`. +const spans = (block: any) => block.children.map((child: any) => [child.text, child.marks.map((mark: string) => block.markDefs.find((definition: any) => definition._key === mark) ? `link:${block.markDefs.find((definition: any) => definition._key === mark).href}` : mark)]); +const first = (editor: LexicalEditor) => exportDocument(editor).blocks[0] as any; +const plain = (block: any) => block.children.map((child: any) => child.text).join(''); + +it('turns **x**, __x__, *x*, _x_, `x` and ~~x~~ into marks when the closing marker is typed', async () => { + for (const [source, mark] of [['**bold**', 'strong'], ['__bold__', 'strong'], ['*it*', 'em'], ['_it_', 'em'], ['`code`', 'code'], ['~~gone~~', 'strike-through']] as const) { + const editor = setup('say '); + await type(editor, source); + const text = source.replace(/[*_`~]/g, ''); + expect(spans(first(editor)), source).toEqual([['say ', []], [text, [mark]]]); + // Typing continues without the new mark. + await type(editor, ' next'); + expect(spans(first(editor)).at(-1), source).toEqual([' next', []]); + exportDocument(editor); + } +}); + +it('leaves unmatched and intra-word markers as typed', async () => { + const editor = setup(); + await type(editor, 'a * b * c snake_case_name 2*3*4 ** x** '); + expect(spans(first(editor))).toEqual([['a * b * c snake_case_name 2*3*4 ** x** ', []]]); +}); + +it('does not apply inline shortcuts inside inline code', async () => { + const editor = setup(); + await type(editor, '`a c`'); + expect(spans(first(editor))).toEqual([['a c', ['code']]]); + // Put the caret inside the code text, with the code format a click would give it. + editor.update(() => { const code = $getRoot().getAllTextNodes()[0]!; code.select(2, 2).format = code.getFormat(); }, { discrete: true }); + await type(editor, '*b*'); + expect(spans(first(editor))).toEqual([['a *b* c', ['code']]]); +}); + +it('undo reverts a conversion in one step and keeps the typed Markdown', async () => { + const editor = setup(); + await type(editor, '**bold**'); + expect(spans(first(editor))).toEqual([['bold', ['strong']]]); + await undo(editor); + expect(spans(first(editor))).toEqual([['**bold**', []]]); + const list = setup(); + await type(list, '- '); + expect(exportDocument(list).blocks[0]._type).toBe('zettel_list'); + await undo(list); + expect(exportDocument(list).blocks.map(block => block._type)).toEqual(['zettel_block']); + expect(plain(first(list))).toBe('- '); +}); + +it('turns "- ", "* ", "1. " and "> " at the start of a paragraph into lists and quotes', async () => { + for (const [marker, expected] of [['- ', { _type: 'zettel_list', kind: 'bullet' }], ['* ', { _type: 'zettel_list', kind: 'bullet' }], ['1. ', { _type: 'zettel_list', kind: 'number', start: 1 }], ['3) ', { _type: 'zettel_list', kind: 'number', start: 3 }], ['> ', { _type: 'zettel_quote' }]] as const) { + const editor = setup(); + await type(editor, `${marker}item`); + const [block] = exportDocument(editor).blocks as any[]; + expect(block, marker).toMatchObject(expected); + const inner = block._type === 'zettel_list' ? block.items[0].blocks[0] : block.blocks[0]; + expect(plain(inner), marker).toBe('item'); + } +}); + +it('continues a list with Enter and joins a new list to the list right before it', async () => { + const editor = setup(); + await type(editor, '- one'); + await enter(editor); + await type(editor, 'two'); + expect((exportDocument(editor).blocks[0] as any).items.map((item: any) => plain(item.blocks[0]))).toEqual(['one', 'two']); + // A paragraph right after the list. + loadDocument(editor, { _type: 'zettel_doc', blocks: [{ ...(exportDocument(editor).blocks[0] as any) }, { _type: 'zettel_block', _key: 'after', style: 'normal', markDefs: [], children: [] }] } as any); + editor.update(() => { $getRoot().getLastChildOrThrow().selectStart(); }, { discrete: true }); + await type(editor, '- three'); + const blocks = exportDocument(editor).blocks as any[]; + expect(blocks.map(block => block._type)).toEqual(['zettel_list']); + expect(blocks[0].items.map((item: any) => plain(item.blocks[0]))).toEqual(['one', 'two', 'three']); +}); + +it('does not start a list mid-paragraph or in a heading', async () => { + const editor = setup('text '); + await type(editor, '- x'); + expect(exportDocument(editor).blocks[0]._type).toBe('zettel_block'); +}); + +it('links a typed bare URL on space and on Enter, without the trailing punctuation', async () => { + const editor = setup(); + await type(editor, 'see https://example.com/a?b=1. ok'); + expect(spans(first(editor))).toEqual([['see ', []], ['https://example.com/a?b=1', ['link:https://example.com/a?b=1']], ['. ok', []]]); + await type(editor, ' http://x.test'); + await enter(editor); + const blocks = exportDocument(editor).blocks as any[]; + expect(spans(blocks[0]).at(-1)).toEqual(['http://x.test', ['link:http://x.test']]); + expect(blocks).toHaveLength(2); + // Undo removes the link and keeps the URL text and the new line. + await undo(editor); + const undone = exportDocument(editor).blocks as any[]; + expect(undone).toHaveLength(2); + expect(spans(undone[0]).at(-1)).toEqual(['. ok http://x.test', []]); +}); + +it('links a pasted bare URL, and pasting a URL over selected text links the text', async () => { + const editor = setup('go '); + await paste(editor, 'https://example.com/path'); + expect(spans(first(editor))).toEqual([['go ', []], ['https://example.com/path', ['link:https://example.com/path']]]); + const selected = setup('read the docs'); + selected.update(() => { const text = $getRoot().getAllTextNodes()[0]!; text.select(9, 13); }, { discrete: true }); + await paste(selected, 'https://docs.test'); + expect(spans(first(selected))).toEqual([['read the ', []], ['docs', ['link:https://docs.test']]]); +}); + +it('Mod+K asks the application for a link and applies it', async () => { + const requests: unknown[] = []; + const editor = setup('read the docs', { onLinkShortcut: (link) => { requests.push(link); return 'https://docs.test'; } }); + editor.update(() => { $getRoot().getAllTextNodes()[0]!.select(9, 13); }, { discrete: true }); + const shortcut = (init: KeyboardEventInit) => { const event = new KeyboardEvent('keydown', { key: 'k', cancelable: true, ...init }); editor.update(() => { editor.dispatchCommand(KEY_DOWN_COMMAND, event); }, { discrete: true }); return event; }; + const event = shortcut(/Mac/.test(navigator.platform) ? { metaKey: true } : { ctrlKey: true }); + await flush(); + expect(event.defaultPrevented).toBe(true); + expect(requests).toEqual([{ text: 'docs' }]); + expect(spans(first(editor))).toEqual([['read the ', []], ['docs', ['link:https://docs.test']]]); +}); + +it('is off unless the plugin is registered with markdownShortcuts', async () => { + const editor = setup('', {}); + await type(editor, '**bold** - x https://example.com '); + expect(spans(first(editor))).toEqual([['**bold** - x https://example.com ', []]]); + const selection = editor.getEditorState().read(() => { const current = $getSelection(); return $isRangeSelection(current) ? current.anchor.offset : -1; }); + expect(selection).toBe(33); +}); diff --git a/packages/zettel-lexical/src/markdown-shortcuts.ts b/packages/zettel-lexical/src/markdown-shortcuts.ts new file mode 100644 index 0000000..b5ec352 --- /dev/null +++ b/packages/zettel-lexical/src/markdown-shortcuts.ts @@ -0,0 +1,304 @@ +import { + $getNodeByKey, + $getSelection, + $isRangeSelection, + $isRootNode, + COMMAND_PRIORITY_LOW, + PASTE_COMMAND, + type EditorState, + type LexicalEditor, + type LexicalNode, +} from "lexical"; +import { mergeRegister } from "@lexical/utils"; +import { + ZettelCodeNode, + ZettelListItemNode, + ZettelListNode, + ZettelQuoteNode, + ZettelSpanNode, + ZettelTableCellNode, + ZettelTextBlockNode, +} from "./nodes/index.js"; +import { generateKey, type List } from "./types.js"; + +/** + * Markdown-style input rules for a Zettel Lexical editor. They only create + * nodes the Zettel profile stores: the strong, em, strike-through and code + * decorators, links, bullet and numbered lists, and quotes. + * + * - `**x**` / `__x__` bold, `*x*` / `_x_` italic, `` `x` `` inline code and + * `~~x~~` strike-through, applied when the closing marker is typed. + * - `- ` / `* ` bullet list, `1. ` / `1) ` numbered list and `> ` quote, typed + * at the start of a paragraph. + * - A bare http(s) URL becomes a link when it is followed by a space or + * Return, or when it is pasted. Pasting a URL over selected text links it. + * + * Every conversion is its own undo step: Undo restores the typed Markdown. + */ +export function registerZettelMarkdownShortcuts(editor: LexicalEditor, linkSelection: (href: string) => boolean): () => void { + return mergeRegister( + editor.registerUpdateListener(({ tags, dirtyLeaves, editorState, prevEditorState }) => { + if (tags.has("historic") || tags.has("collaboration") || tags.has(MARKDOWN_SHORTCUT_TAG) || editor.isComposing()) return; + const typed = typedCharacter(editorState, prevEditorState, dirtyLeaves); + if (typed) { + editor.update(() => { + const node = $getNodeByKey(typed.key); + if (!(node instanceof ZettelSpanNode) || !node.isAttached()) return; + $applyTypedShortcut(node, typed.offset, linkSelection); + }, { tag: [MARKDOWN_SHORTCUT_TAG, "history-push"] }); + return; + } + const split = splitBlock(editorState, prevEditorState); + if (split) { + editor.update(() => { + const block = $getNodeByKey(split); + if (block instanceof ZettelTextBlockNode) $autolinkBlockEnd(block, linkSelection); + }, { tag: [MARKDOWN_SHORTCUT_TAG, "history-push"] }); + } + }), + editor.registerCommand(PASTE_COMMAND, (event) => { + const data = event && "clipboardData" in event ? (event as ClipboardEvent).clipboardData : null; + const url = data?.getData("text/plain").trim() ?? ""; + if (!BARE_URL.test(url)) return false; + const selection = $getSelection(); + if (!$isRangeSelection(selection)) return false; + const anchorBlock = inlineContainer(selection.anchor.getNode()); + if (!anchorBlock || anchorBlock !== inlineContainer(selection.focus.getNode())) return false; + if (selection.isCollapsed()) { + selection.insertText(url); + const after = $getSelection(); + if (!$isRangeSelection(after) || after.anchor.type !== "text") return true; + const node = after.anchor.getNode(); + const end = after.anchor.offset; + if (node instanceof ZettelSpanNode && end >= url.length) { + node.select(end - url.length, end); + linkSelection(url); + collapseToEnd(); + } + } else if (!linkSelection(url)) { + return false; + } + (event as ClipboardEvent).preventDefault(); + return true; + }, COMMAND_PRIORITY_LOW), + ); +} + +const MARKDOWN_SHORTCUT_TAG = "zettel-markdown-shortcut"; +const BARE_URL = /^https?:\/\/[^\s<>"]+$/i; +const TRAILING_URL = /(^|\s)(https?:\/\/[^\s<>"]+)$/i; + +/** The caret moved one character right in the node that changed: a character was typed. */ +function typedCharacter(editorState: EditorState, prevEditorState: EditorState, dirtyLeaves: Set): { key: string; offset: number } | null { + const selection = editorState._selection; + const previous = prevEditorState._selection; + if (!$isRangeSelection(selection) || !$isRangeSelection(previous) || !selection.isCollapsed() || !previous.isCollapsed()) return null; + if (selection.anchor.type !== "text" || !dirtyLeaves.has(selection.anchor.key)) return null; + const offset = selection.anchor.offset; + const sameNode = previous.anchor.key === selection.anchor.key && previous.anchor.offset + 1 === offset; + if (!sameNode && offset !== 1) return null; + const node = editorState._nodeMap.get(selection.anchor.key); + if (!(node instanceof ZettelSpanNode)) return null; + // Only a one-character insertion, not a paste or a replacement. + const before = prevEditorState._nodeMap.get(selection.anchor.key); + const length = editorState.read(() => node.getTextContentSize()); + const previousLength = before instanceof ZettelSpanNode ? prevEditorState.read(() => before.getTextContentSize()) : 0; + if (length !== previousLength + 1) return null; + return { key: selection.anchor.key, offset }; +} + +/** Return created a new text block and put the caret at its start. */ +function splitBlock(editorState: EditorState, prevEditorState: EditorState): string | null { + const selection = editorState._selection; + if (!$isRangeSelection(selection) || !selection.isCollapsed() || selection.anchor.offset !== 0) return null; + return editorState.read(() => { + const block = nearestTextBlock(selection.anchor.getNode()); + if (!block || prevEditorState._nodeMap.has(block.getKey())) return null; + let previous: LexicalNode | null = block.getPreviousSibling(); + const item = block.getParent(); + const previousItem = item instanceof ZettelListItemNode ? item.getPreviousSibling() : null; + if (!previous && previousItem instanceof ZettelListItemNode) previous = previousItem.getLastChild(); + return previous instanceof ZettelTextBlockNode && prevEditorState._nodeMap.has(previous.getKey()) ? previous.getKey() : null; + }); +} + +function nearestTextBlock(node: LexicalNode): ZettelTextBlockNode | null { + for (let current: LexicalNode | null = node; current; current = current.getParent()) { + if (current instanceof ZettelTextBlockNode) return current; + } + return null; +} + +function inlineContainer(node: LexicalNode): ZettelTextBlockNode | ZettelTableCellNode | null { + for (let current: LexicalNode | null = node; current; current = current.getParent()) { + if (current instanceof ZettelTextBlockNode || current instanceof ZettelTableCellNode) return current; + } + return null; +} + +function inCode(node: LexicalNode): boolean { + for (let current: LexicalNode | null = node; current; current = current.getParent()) { + if (current instanceof ZettelCodeNode) return true; + } + return false; +} + +function collapseToEnd(): void { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) return; + const end = selection.isBackward() ? selection.anchor : selection.focus; + selection.anchor.set(end.key, end.offset, end.type); + selection.focus.set(end.key, end.offset, end.type); +} + +interface InlineRule { + marker: string; + mark: "strong" | "em" | "code" | "strike-through"; + pattern: RegExp; +} + +// Longer markers first: `**bold*` must not become italic before the second `*`. +// The content may not start or end with a space, and a marker only opens +// after a non-word character, so snake_case_names and 2*3*4 stay as typed. +const INLINE_RULES: InlineRule[] = [ + { marker: "**", mark: "strong", pattern: /(?:^|[^*\w\\])\*\*([^*\s](?:[^*]*[^*\s])?)\*\*$/ }, + { marker: "__", mark: "strong", pattern: /(?:^|[^_\w\\])__([^_\s](?:[^_]*[^_\s])?)__$/ }, + { marker: "~~", mark: "strike-through", pattern: /(?:^|[^~\w\\])~~([^~\s](?:[^~]*[^~\s])?)~~$/ }, + { marker: "*", mark: "em", pattern: /(?:^|[^*\w\\])\*([^*\s](?:[^*]*[^*\s])?)\*$/ }, + { marker: "_", mark: "em", pattern: /(?:^|[^_\w\\])_([^_\s](?:[^_]*[^_\s])?)_$/ }, + { marker: "`", mark: "code", pattern: /(?:^|[^`\\])`([^`]+)`$/ }, +]; + +const BLOCK_RULE = /^(?:([-*])|(\d{1,9})[.)]|(>)) $/; + +function $applyTypedShortcut(node: ZettelSpanNode, offset: number, linkSelection: (href: string) => boolean): void { + if (inCode(node) || node.toZettel().marks.includes("code")) return; + const text = node.getTextContent(); + const typed = text[offset - 1]; + const before = text.slice(0, offset); + if (typed === " " && $applyBlockRule(node, before)) return; + if (typed === " " || typed === "\t") { + $autolinkBefore(node, offset - 1, linkSelection); + return; + } + if (!typed || !"*_~`".includes(typed)) return; + for (const rule of INLINE_RULES) { + const match = rule.pattern.exec(before); + if (!match) continue; + const content = match[1]!; + const closeStart = offset - rule.marker.length; + const openStart = closeStart - content.length - rule.marker.length; + $formatRange(node, openStart, closeStart, rule.marker.length, rule.mark); + return; + } +} + +/** Remove the markers around text[openStart + marker, closeStart) and mark that text. */ +function $formatRange(node: ZettelSpanNode, openStart: number, closeStart: number, markerLength: number, mark: InlineRule["mark"]): void { + const format = node.getFormat(); + const text = node.getTextContent(); + const content = text.slice(openStart + markerLength, closeStart); + const next = text.slice(0, openStart) + content + text.slice(closeStart + markerLength); + node.setTextContent(next); + const parts = node.splitText(openStart, openStart + content.length); + const target = parts[openStart === 0 ? 0 : 1] as ZettelSpanNode; + const marks = target.toZettel().marks; + if (!marks.includes(mark)) target.setMarks([...marks, mark]); + // Continue typing after the formatted text, without its new mark. + const after = target.getNextSibling(); + if (after instanceof ZettelSpanNode && !after.toZettel().marks.includes(mark)) after.select(0, 0); + else { + const selection = target.select(content.length, content.length); + selection.format = format; + } +} + +function $applyBlockRule(node: ZettelSpanNode, before: string): boolean { + const match = BLOCK_RULE.exec(before); + if (!match) return false; + const block = node.getParent(); + if (!(block instanceof ZettelTextBlockNode) || block.style !== "normal" || !node.is(block.getFirstChild())) return false; + const container = block.getParent(); + if (!$isRootNode(container) && !(container instanceof ZettelQuoteNode)) return false; + const rest = node.getTextContent().slice(before.length); + if (rest) { + node.setTextContent(rest); + node.select(0, 0); + } else { + node.remove(); + block.selectStart(); + } + if (match[3]) { + const quote = new ZettelQuoteNode({ _type: "zettel_quote", _key: generateKey(), blocks: [] }); + block.insertBefore(quote); + quote.append(block); + return true; + } + const kind: List["kind"] = match[2] ? "number" : "bullet"; + const list = new ZettelListNode({ _type: "zettel_list", _key: generateKey(), kind, spread: false, ...(kind === "number" ? { start: Number(match[2]) } : {}) }); + const item = new ZettelListItemNode({ _type: "zettel_list_item", _key: generateKey(), spread: false }); + block.insertBefore(list); + item.append(block); + list.append(item); + // Adjacent lists of one kind are one list in Markdown; keep them one here. + const previous = list.getPreviousSibling(); + if (previous instanceof ZettelListNode && previous.kind === kind) { + previous.append(...list.getChildren()); + list.remove(); + } + return true; +} + +/** Link a bare URL that ends at `end` in the span (the caret is right after it). */ +function $autolinkBefore(node: ZettelSpanNode, end: number, linkSelection: (href: string) => boolean): void { + if (hasLink(node)) return; + const text = node.getTextContent(); + const match = TRAILING_URL.exec(text.slice(0, end)); + if (!match) return; + const url = trimUrl(match[2]!); + if (!url) return; + const start = end - match[2]!.length; + const caret = $getSelection(); + const caretOffset = $isRangeSelection(caret) ? caret.anchor.offset : end + 1; + node.select(start, start + url.length); + if (!linkSelection(url)) return; + // Put the caret back after the typed character, outside the link. + const linked = $getSelection(); + if (!$isRangeSelection(linked)) return; + const tail = (linked.isBackward() ? linked.anchor : linked.focus).getNode().getNextSibling(); + if (tail instanceof ZettelSpanNode) tail.select(caretOffset - start - url.length, caretOffset - start - url.length); +} + +/** Return after a bare URL: link it in the block the caret just left. */ +function $autolinkBlockEnd(block: ZettelTextBlockNode, linkSelection: (href: string) => boolean): void { + const last = block.getLastChild(); + if (!(last instanceof ZettelSpanNode) || hasLink(last) || last.toZettel().marks.includes("code")) return; + const selection = $getSelection(); + const caret = $isRangeSelection(selection) ? { key: selection.anchor.key, offset: selection.anchor.offset, type: selection.anchor.type } : null; + const text = last.getTextContent(); + const match = TRAILING_URL.exec(text); + if (!match) return; + const url = trimUrl(match[2]!); + if (!url) return; + const start = text.length - match[2]!.length; + last.select(start, start + url.length); + linkSelection(url); + const restored = $getSelection(); + if (caret && $isRangeSelection(restored)) { + restored.anchor.set(caret.key, caret.offset, caret.type); + restored.focus.set(caret.key, caret.offset, caret.type); + } +} + +function hasLink(node: ZettelSpanNode): boolean { + const container = inlineContainer(node); + const marks = node.toZettel().marks; + return Boolean(container?.markDefs.some((definition) => marks.includes(definition._key))); +} + +/** GFM autolinks leave trailing punctuation out of the link. */ +function trimUrl(url: string): string { + let result = url.replace(/[.,:;!?"'*_~]+$/, ""); + while (result.endsWith(")") && (result.match(/\(/g)?.length ?? 0) < (result.match(/\)/g)?.length ?? 0)) result = result.slice(0, -1); + return /^https?:\/\/[^/?#]+/i.test(result) ? result : ""; +} diff --git a/packages/zettel-lexical/src/plugin.ts b/packages/zettel-lexical/src/plugin.ts index cb90b5e..859e4e8 100644 --- a/packages/zettel-lexical/src/plugin.ts +++ b/packages/zettel-lexical/src/plugin.ts @@ -3,6 +3,7 @@ import { createCommand, $isRangeSelection, COMMAND_PRIORITY_EDITOR, + COMMAND_PRIORITY_LOW, COPY_COMMAND, CONTROLLED_TEXT_INSERTION_COMMAND, CUT_COMMAND, @@ -12,6 +13,7 @@ import { FORMAT_TEXT_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, + KEY_DOWN_COMMAND, KEY_ENTER_COMMAND, LexicalEditor, PASTE_COMMAND, @@ -25,6 +27,7 @@ import { $isTextNode, $selectAll, ElementNode, + $setSelection, type LexicalNode, type PointType, type RangeSelection, @@ -34,6 +37,7 @@ import { createEmptyHistoryState, registerHistory } from "@lexical/history"; import { mergeRegister } from "@lexical/utils"; import { copyDocumentToClipboard, pasteClipboardData } from "./clipboard.js"; import { exportDocument } from "./lexical-state.js"; +import { registerZettelMarkdownShortcuts } from "./markdown-shortcuts.js"; import { $createZettelBreakNode, $createZettelTextBlockNode, @@ -97,6 +101,23 @@ export function $setZettelLink(href: string | null): boolean { export interface ZettelLexicalPluginOptions { onPasteDiagnostics?: (diagnostics: unknown[]) => void; + /** + * Opt in to Markdown-style input rules: `**bold**`, `__bold__`, `*italic*`, + * `_italic_`, `` `code` `` and `~~strike~~` when the closing marker is + * typed; `- `, `* `, `1. ` and `> ` at the start of a paragraph for lists + * and quotes; and links for bare http(s) URLs followed by a space or + * Return, or pasted (over selected text, a pasted URL links the text). + * Undo reverts a conversion and leaves the typed Markdown. + */ + markdownShortcuts?: boolean; + /** + * Handle Mod+K (Cmd+K on macOS, Ctrl+K elsewhere) on selected text or in a + * link. Receive the selected text and the current href; return an href to + * link, `null` to remove the link, or `undefined` to leave it unchanged. + * The editor has no link UI of its own, so Mod+K is left to the browser + * when this is not set. + */ + onLinkShortcut?: (link: { text: string; href?: string }) => string | null | undefined | Promise; } /** Register normal editor commands plus Zettel clipboard integration. */ @@ -128,6 +149,30 @@ export function registerZettelLexicalPlugin(editor: LexicalEditor, options: Zett editor.dispatchCommand(CONTROLLED_TEXT_INSERTION_COMMAND, input.data); } return mergeRegister( + options.markdownShortcuts ? registerZettelMarkdownShortcuts(editor, $setZettelLink) : () => {}, + // Above COMMAND_PRIORITY_EDITOR: from Lexical 0.50, core's own keydown + // dispatch is a KEY_DOWN_COMMAND listener at that priority and handles + // every key, so a listener registered after it there never runs. + options.onLinkShortcut ? editor.registerCommand(KEY_DOWN_COMMAND, (event) => { + if (!isLinkShortcut(event)) return false; + const selection = $getSelection(); + if (!$isRangeSelection(selection)) return false; + const href = currentLinkHref(selection); + if (selection.isCollapsed() && href === undefined) return false; + event.preventDefault(); + const saved = selection.clone(); + const apply = (result: string | null | undefined) => { + if (result === undefined) return; + editor.update(() => { + if (saved.getNodes().every((node) => node.isAttached())) $setSelection(saved.clone()); + $setZettelLink(result); + }); + }; + const result = options.onLinkShortcut!({ text: selection.getTextContent(), ...(href === undefined ? {} : { href }) }); + if (result instanceof Promise) void result.then(apply, () => {}); + else apply(result); + return true; + }, COMMAND_PRIORITY_LOW) : () => {}, unregisterRoot, editor.registerCommand(SET_ZETTEL_LINK_COMMAND, $setZettelLink, COMMAND_PRIORITY_EDITOR), registerHistory(editor, createEmptyHistoryState(), 300), @@ -557,6 +602,22 @@ function $liftAtStart(selection: RangeSelection): boolean { return false; } +function isLinkShortcut(event: KeyboardEvent): boolean { + const mac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform); + return event.key.toLowerCase() === "k" && !event.shiftKey && !event.altKey && (mac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey); +} + +function currentLinkHref(selection: RangeSelection): string | undefined { + for (const node of selection.getNodes()) { + const parent = node.getParent(); + if (!(node instanceof ZettelSpanNode) || !(parent instanceof ZettelTextBlockNode || parent instanceof ZettelTableCellNode)) continue; + const marks = node.toZettel().marks; + const link = parent.markDefs.find((definition) => marks.includes(definition._key)); + if (link) return link.href; + } + return undefined; +} + function onChecklistChange(event: Event): void { const target = event.target; if (!(target instanceof HTMLInputElement) || target.type !== "checkbox") return;