Skip to content

Add opt-in Markdown shortcuts to the Lexical plugin - #21

Merged
samuelstroschein merged 4 commits into
mainfrom
feat/lexical-markdown-shortcuts
Sep 24, 2026
Merged

samuelstroschein merged 4 commits into
mainfrom
feat/lexical-markdown-shortcuts

Conversation

@samuelstroschein

Copy link
Copy Markdown
Member

What

Opt-in Markdown-style input rules for @opral/zettel-lexical:

registerZettelLexicalPlugin(editor, {
  markdownShortcuts: true,
  onLinkShortcut: ({ text, href }) => askForUrl(text, href), // optional, Cmd/Ctrl+K
});
  • Inline: **x** / __x__ → strong, *x* / _x_ → em, `x` → code, ~~x~~ → strike-through. The rule applies when the closing marker is typed, and typing continues without the new mark. A marker opens only after a non-word character and the content cannot start or end with a space, so snake_case_name, 2*3*4 and a * b stay as typed. Nothing is converted inside inline code or code blocks.
  • Blocks: - / * → bullet list, 1. / 1) → numbered list (the typed number is the start), > → quote. These apply at the start of a normal paragraph at the root or in a quote. A new list joins a list of the same kind directly before it, because Markdown would read the two as one list anyway.
  • Autolinks: a bare http(s):// URL becomes a link when it is followed by a space or Return, or when it is pasted. Trailing punctuation is left out of the link, as in GFM. Pasting a URL over selected text links the text.
  • Cmd/Ctrl+K: calls onLinkShortcut({ text, href }) on selected text or inside a link. The callback returns an href, null to unlink, or undefined to cancel, and may return a promise. It applies through the existing SET_ZETTEL_LINK_COMMAND logic. The editor has no link UI, so without the callback Cmd+K is left to the browser.
  • Undo: each conversion runs in its own update tagged history-push, after the update that typed the triggering character. One Undo brings back the literal Markdown, such as **b** or - .

The rules only create what the Zettel profile stores: the strong/em/code/strike-through decorators, zettel_link mark definitions, zettel_list (bullet/number) and zettel_quote. There are no heading, code-block or task-list rules, since those were not needed for the comment composer. They can be added the same way.

Why opt-in

This goes beyond "bindings for the Zettel format": it changes what typing does. Some apps want literal * and _, for example code-heavy comments. It is therefore behind markdownShortcuts: true and off by default, and the tests check that nothing changes without it. The playground turns it on.

Implementation

src/markdown-shortcuts.ts registers an update listener that detects a single typed character: the caret moved one step in the text node that grew by one character. It is skipped for historic, collaboration and composition updates. The listener then runs the matching rule in a follow-up update. Return is detected as a new text block with the caret at its start, and a URL at the end of the previous block is linked. Pasted URLs are handled by a PASTE_COMMAND listener with a higher priority than the plugin's clipboard handler.

Tests

  • markdown-shortcuts.test.ts (11 tests, committed before the implementation): every inline rule, and typing continuing unformatted after it; markers that must stay literal; no rules inside inline code; undo of an inline rule and of a list rule; every block rule including 3) → start: 3; list continuation and joining; no list mid-paragraph; autolinking on space and Return with trailing punctuation and undo; URL paste and URL-over-selection paste; Cmd/Ctrl+K; nothing changing without the option.
  • A new Chromium playground check with the real keyboard. It types **bold** after *it*code https://example.com/x. done and checks the spans and the link. Cmd+Z undoes the typing and then only the autolink, and a separate case checks that Cmd+Z reverts **b** in one step. - one, Return, two gives a bullet list. This covers the caret format after a conversion at the end of a block. In jsdom that cannot be checked because a mounted editor re-reads the format from the DOM, so the unit tests use an unmounted editor.

Local run: pnpm build, package tests (ast 11, html 17, markdown 19, lexical 46) and examples/playground pnpm test (27 checks) all pass.

🤖 Generated with Claude Code

samuelstroschein and others added 2 commits September 23, 2026 13:13
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
registerZettelLexicalPlugin(editor, { markdownShortcuts: true }) enables
input rules for bold, italic, inline code and strike-through markers, for
bullet/numbered lists and quotes at the start of a paragraph, and autolinks
bare http(s) URLs on space, Return and paste (a URL pasted over selected
text links it). Conversions run in their own update tagged history-push, so
Undo restores the typed Markdown in one step. onLinkShortcut handles Mod+K
with an application-provided link UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-23T20:18:07.622519Z 9b34ba7 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9b34ba71f2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +61 to +62
const url = data?.getData("text/plain").trim() ?? "";
if (!BARE_URL.test(url)) return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve rich payloads when linkifying a pasted URL

At a collapsed caret, copying a formatted URL supplies text/zettel, HTML, and plain-text representations, but this handler decides solely from the plain-text value and runs before the normal clipboard handler. Consequently, pasting a bold URL or other rich URL-only content bypasses the established text/zettel/HTML priority and reconstructs it as a plain linked span, losing formatting and extension data. Only apply this shortcut when no richer clipboard representation is available.

Useful? React with 👍 / 👎.

Comment on lines +154 to +155
if (saved.getNodes().every((node) => node.isAttached())) $setSelection(saved.clone());
$setZettelLink(result);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Abort async link updates after the saved selection detaches

When onLinkShortcut returns a promise and the original selected nodes are deleted or the document is reloaded before it resolves, the attachment check skips restoring the saved selection but the next line still calls $setZettelLink against whatever selection is currently active. The returned URL can therefore link unrelated text, potentially in a newly loaded document; abort the update when the saved selection is no longer valid.

Useful? React with 👍 / 👎.

From Lexical 0.50, core dispatches keys from a KEY_DOWN_COMMAND listener at
COMMAND_PRIORITY_EDITOR that handles every key, so a Mod+K listener
registered after it at the same priority never runs. Register it at
COMMAND_PRIORITY_LOW, which also works with Lexical 0.30.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@samuelstroschein
samuelstroschein merged commit 9e75f06 into main Sep 24, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants