Merge feature/markdown-input-rules: Markdown typing shortcuts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
matt 2026-07-05 13:05:41 -04:00
commit f552be3dac
2 changed files with 71 additions and 0 deletions

View file

@ -7,6 +7,7 @@ import type { Editor } from "@tiptap/react";
import { EditorContent, useEditor, useEditorState } from "@tiptap/react"; import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import { useEffect, useId, useRef, useState } from "react"; import { useEffect, useId, useRef, useState } from "react";
import { MarkdownInputRules } from "@/components/admin/markdown-input-rules";
import { ErrorText, Label, cx } from "@/components/ui"; import { ErrorText, Label, cx } from "@/components/ui";
import { renderMarkdown } from "@/lib/markdown"; import { renderMarkdown } from "@/lib/markdown";
import { uploadImageFile } from "@/lib/upload-client"; import { uploadImageFile } from "@/lib/upload-client";
@ -126,6 +127,7 @@ export function RichTextEditor({
Image.configure({ allowBase64: false }), Image.configure({ allowBase64: false }),
TableKit.configure({ table: { resizable: false } }), TableKit.configure({ table: { resizable: false } }),
Placeholder.configure({ placeholder: "Write your story…" }), Placeholder.configure({ placeholder: "Write your story…" }),
MarkdownInputRules,
], ],
content: initialHTML, content: initialHTML,
editorProps: { editorProps: {

View file

@ -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;
},
});