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 <noreply@anthropic.com>
This commit is contained in:
parent
f7906f99f7
commit
837465e81f
|
|
@ -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: {
|
||||||
|
|
|
||||||
69
src/components/admin/markdown-input-rules.ts
Normal file
69
src/components/admin/markdown-input-rules.ts
Normal 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;
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue