From 837465e81febbd4a60cd88d29409f1b80de0d825 Mon Sep 17 00:00:00 2001 From: matt Date: Sun, 5 Jul 2026 13:05:41 -0400 Subject: [PATCH] Make Markdown typing shortcuts work everywhere in the editor Tiptap's built-in bold/italic input rules only fire after whitespace, so **bold** typed after punctuation (an em dash, an open paren, a colon) never converted, and [text](url) had no input rule at all. A small extension adds punctuation-tolerant bold/italic rules (using lookbehinds so the preceding character isn't swallowed by the rule's range deletion) and a link rule that fires on the closing parenthesis. Link URLs pass the same validator as navigation links, so javascript: URLs stay as plain text, and mid-word patterns like 2**3** are left alone. Co-Authored-By: Claude Fable 5 --- src/components/admin/RichTextEditor.tsx | 2 + src/components/admin/markdown-input-rules.ts | 69 ++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 src/components/admin/markdown-input-rules.ts diff --git a/src/components/admin/RichTextEditor.tsx b/src/components/admin/RichTextEditor.tsx index 0c1acbd..5057122 100644 --- a/src/components/admin/RichTextEditor.tsx +++ b/src/components/admin/RichTextEditor.tsx @@ -7,6 +7,7 @@ import type { Editor } from "@tiptap/react"; import { EditorContent, useEditor, useEditorState } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { useEffect, useId, useRef, useState } from "react"; +import { MarkdownInputRules } from "@/components/admin/markdown-input-rules"; import { ErrorText, Label, cx } from "@/components/ui"; import { renderMarkdown } from "@/lib/markdown"; import { uploadImageFile } from "@/lib/upload-client"; @@ -126,6 +127,7 @@ export function RichTextEditor({ Image.configure({ allowBase64: false }), TableKit.configure({ table: { resizable: false } }), Placeholder.configure({ placeholder: "Write your story…" }), + MarkdownInputRules, ], content: initialHTML, editorProps: { diff --git a/src/components/admin/markdown-input-rules.ts b/src/components/admin/markdown-input-rules.ts new file mode 100644 index 0000000..8e0a9ae --- /dev/null +++ b/src/components/admin/markdown-input-rules.ts @@ -0,0 +1,69 @@ +import { Extension, InputRule, markInputRule } from "@tiptap/core"; +import { isValidLinkUrl } from "@/lib/validation"; + +/** + * Extra Markdown typing shortcuts on top of tiptap's built-ins. + * + * Tiptap's own bold/italic input rules only fire when the opening + * marker sits at the start of a line or after whitespace, so typing + * `dash—**bold**` or `(**bold**)` never converts. These variants also + * accept punctuation before the marker. The lookbehind (instead of a + * consumed prefix) matters: `markInputRule` deletes everything in the + * match around the content group, so a consumed prefix character would + * be swallowed with the asterisks. + * + * `[text](url)` has no built-in rule at all; the custom rule below + * inserts a link mark when the closing parenthesis is typed. URLs are + * checked with the same validator used for navigation links, so + * `javascript:` and friends never become clickable (the text is simply + * left as typed). + * + * The word-ish guard `[^\w*_\x60]` (not a word character, backtick, or + * marker) keeps rules from firing mid-word: `2**3**` stays math. + */ +const BOLD_STAR = /(?<=^|[^\w*_`])(?:\*\*(?![\s*])([^*]+)\*\*)$/; +const BOLD_UNDERSCORE = /(?<=^|[^\w*_`])(?:__(?![\s_])([^_]+)__)$/; +const ITALIC_STAR = /(?<=^|[^\w*_`])(?:\*(?![\s*])([^*]+?)\*)$/; +const ITALIC_UNDERSCORE = /(?<=^|[^\w*_`])(?:_(?![\s_])([^_]+?)_)$/; +const LINK = /\[([^\]]+)\]\(([^()\s]+)\)$/; + +export const MarkdownInputRules = Extension.create({ + name: "markdownInputRules", + + addInputRules() { + const { bold, italic, link } = this.editor.schema.marks; + const rules: InputRule[] = []; + + if (bold) { + rules.push( + markInputRule({ find: BOLD_STAR, type: bold }), + markInputRule({ find: BOLD_UNDERSCORE, type: bold }), + ); + } + if (italic) { + rules.push( + markInputRule({ find: ITALIC_STAR, type: italic }), + markInputRule({ find: ITALIC_UNDERSCORE, type: italic }), + ); + } + if (link) { + rules.push( + new InputRule({ + find: LINK, + handler: ({ state, range, match }) => { + const [, text, href] = match; + if (!isValidLinkUrl(href)) return; + state.tr.replaceWith( + range.from, + range.to, + state.schema.text(text, [link.create({ href })]), + ); + // Don't keep writing inside the link after it's inserted. + state.tr.removeStoredMark(link); + }, + }), + ); + } + return rules; + }, +});