···11+import {
22+ InputRule,
33+ inputRules,
44+ textblockTypeInputRule,
55+ wrappingInputRule,
66+} from "prosemirror-inputrules";
77+import type { MarkType, NodeType, Schema } from "prosemirror-model";
88+99+/**
1010+ * Build input rules for markdown-style shortcuts
1111+ */
1212+export function buildInputRules(schema: Schema) {
1313+ const rules: InputRule[] = [];
1414+1515+ // Bold: **text**
1616+ // Lookbehind ensures we don't match inside an existing bold sequence
1717+ // Pattern: not-star or line-start, then **, content, **
1818+ if (schema.marks.strong) {
1919+ rules.push(
2020+ markInputRule(/(?:^|[^*])(\*\*([^*]+)\*\*)$/, schema.marks.strong, 2),
2121+ );
2222+ }
2323+2424+ // Italic: *text*
2525+ // Must not be preceded by * (would be bold) or followed by * (incomplete bold)
2626+ if (schema.marks.em) {
2727+ rules.push(markInputRule(/(?:^|[^*])(\*([^*]+)\*)$/, schema.marks.em, 2));
2828+ }
2929+3030+ // Inline code: `text`
3131+ if (schema.marks.code) {
3232+ rules.push(markInputRule(/(?:^|[^`])(`([^`]+)`)$/, schema.marks.code, 2));
3333+ }
3434+3535+ // Code block: ``` at start of line
3636+ if (schema.nodes.code_block) {
3737+ rules.push(
3838+ textblockTypeInputRule(/^```$/, schema.nodes.code_block as NodeType),
3939+ );
4040+ }
4141+4242+ // Blockquote: > at start of line
4343+ if (schema.nodes.blockquote) {
4444+ rules.push(
4545+ wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote as NodeType),
4646+ );
4747+ }
4848+4949+ // Heading: # at start of line (levels 1-6)
5050+ if (schema.nodes.heading) {
5151+ for (let level = 1; level <= 6; level++) {
5252+ const pattern = new RegExp(`^(#{${level}})\\s$`);
5353+ rules.push(
5454+ textblockTypeInputRule(pattern, schema.nodes.heading as NodeType, {
5555+ level,
5656+ }),
5757+ );
5858+ }
5959+ }
6060+6161+ return inputRules({ rules });
6262+}
6363+6464+/**
6565+ * Create an input rule that applies a mark when a pattern matches.
6666+ *
6767+ * Based on the standard pattern from:
6868+ * https://discuss.prosemirror.net/t/input-rules-for-wrapping-marks/537
6969+ *
7070+ * @param pattern - Regex with capture groups: group 1 = full match to delete,
7171+ * group `textGroup` = the text to keep and mark
7272+ * @param markType - The mark type to apply
7373+ * @param textGroup - Which capture group contains the text (default 1)
7474+ */
7575+function markInputRule(
7676+ pattern: RegExp,
7777+ markType: MarkType,
7878+ textGroup = 1,
7979+): InputRule {
8080+ return new InputRule(pattern, (state, match, start, _end) => {
8181+ const fullMatch = match[1];
8282+ const text = match[textGroup];
8383+ if (!fullMatch || !text) return null;
8484+8585+ const tr = state.tr;
8686+8787+ // Calculate where the full match (including delimiters) starts
8888+ const matchStart = start + match[0].indexOf(fullMatch);
8989+ const matchEnd = matchStart + fullMatch.length;
9090+9191+ // Delete the matched text (including markers)
9292+ tr.delete(matchStart, matchEnd);
9393+9494+ // Insert the text without markers
9595+ tr.insertText(text, matchStart);
9696+9797+ // Apply the mark to the inserted text
9898+ tr.addMark(matchStart, matchStart + text.length, markType.create());
9999+100100+ // Remove stored mark so next typed text isn't marked
101101+ tr.removeStoredMark(markType);
102102+103103+ return tr;
104104+ });
105105+}
+19
src/components/richtext/plugins.ts
···11+import { Plugin, PluginKey } from "prosemirror-state";
22+import type { EditorView } from "prosemirror-view";
33+44+/**
55+ * Plugin that calls a callback on every state update.
66+ * Used to trigger React re-renders for toolbar state.
77+ */
88+export function createUpdatePlugin(onUpdate: (view: EditorView) => void) {
99+ return new Plugin({
1010+ key: new PluginKey("reactUpdate"),
1111+ view() {
1212+ return {
1313+ update(view) {
1414+ onUpdate(view);
1515+ },
1616+ };
1717+ },
1818+ });
1919+}