a tool for shared writing and social publishing
1import {
2 InputRule,
3 inputRules,
4 wrappingInputRule,
5} from "prosemirror-inputrules";
6import { MutableRefObject } from "react";
7import { Replicache } from "replicache";
8import type { ReplicacheMutators } from "src/replicache";
9import { BlockProps } from "../Block";
10import { focusBlock } from "src/utils/focusBlock";
11import { schema } from "./schema";
12import { useUIState } from "src/useUIState";
13import { flushSync } from "react-dom";
14import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage";
15export const inputrules = (
16 propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>,
17 repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>,
18 openMentionAutocomplete?: () => void,
19) =>
20 inputRules({
21 //Strikethrough
22 rules: [
23 new InputRule(/\~\~([^*]+)\~\~$/, (state, match, start, end) => {
24 const [fullMatch, content] = match;
25 const { tr } = state;
26 if (content) {
27 tr.replaceWith(start, end, state.schema.text(content))
28 .addMark(
29 start,
30 start + content.length,
31 schema.marks.strikethrough.create(),
32 )
33 .removeStoredMark(schema.marks.strikethrough);
34 return tr;
35 }
36 return null;
37 }),
38
39 //Highlight
40 new InputRule(/\=\=([^*]+)\=\=$/, (state, match, start, end) => {
41 const [fullMatch, content] = match;
42 const { tr } = state;
43 if (content) {
44 tr.replaceWith(start, end, state.schema.text(content))
45 .addMark(
46 start,
47 start + content.length,
48 schema.marks.highlight.create({
49 color: useUIState.getState().lastUsedHighlight || "1",
50 }),
51 )
52 .removeStoredMark(schema.marks.highlight);
53 return tr;
54 }
55 return null;
56 }),
57
58 //Bold
59 new InputRule(/\*\*([^*]+)\*\*$/, (state, match, start, end) => {
60 const [fullMatch, content] = match;
61 const { tr } = state;
62 if (content) {
63 tr.replaceWith(start, end, state.schema.text(content))
64 .addMark(
65 start,
66 start + content.length,
67 schema.marks.strong.create(),
68 )
69 .removeStoredMark(schema.marks.strong);
70 return tr;
71 }
72 return null;
73 }),
74
75 //Code
76 new InputRule(/\`([^`]+)\`$/, (state, match, start, end) => {
77 const [fullMatch, content] = match;
78 const { tr } = state;
79 if (content) {
80 const startIndex = start + fullMatch.indexOf("`");
81 tr.replaceWith(startIndex, end, state.schema.text(content))
82 .addMark(
83 startIndex,
84 startIndex + content.length,
85 schema.marks.code.create(),
86 )
87 .removeStoredMark(schema.marks.code);
88 return tr;
89 }
90 return null;
91 }),
92
93 //Italic
94 new InputRule(/(?:^|[^*])\*([^*]+)\*$/, (state, match, start, end) => {
95 const [fullMatch, content] = match;
96 const { tr } = state;
97 if (content) {
98 const startIndex = start + fullMatch.indexOf("*");
99 tr.replaceWith(startIndex, end, state.schema.text(content))
100 .addMark(
101 startIndex,
102 startIndex + content.length,
103 schema.marks.em.create(),
104 )
105 .removeStoredMark(schema.marks.em);
106 return tr;
107 }
108 return null;
109 }),
110
111 // Code Block
112 new InputRule(/^```\s$/, (state, match) => {
113 flushSync(() => {
114 repRef.current?.mutate.assertFact({
115 entity: propsRef.current.entityID,
116 attribute: "block/type",
117 data: { type: "block-type-union", value: "code" },
118 });
119 let lastLang = localStorage.getItem(LAST_USED_CODE_LANGUAGE_KEY);
120 if (lastLang) {
121 repRef.current?.mutate.assertFact({
122 entity: propsRef.current.entityID,
123 attribute: "block/code-language",
124 data: { type: "string", value: lastLang },
125 });
126 }
127 });
128 setTimeout(() => {
129 focusBlock({ ...propsRef.current, type: "code" }, { type: "start" });
130 }, 20);
131 return null;
132 }),
133
134 //Checklist
135 new InputRule(/^\-?\[(\ |x)?\]\s$/, (state, match) => {
136 if (!propsRef.current.listData)
137 repRef.current?.mutate.assertFact({
138 entity: propsRef.current.entityID,
139 attribute: "block/is-list",
140 data: { type: "boolean", value: true },
141 });
142 let tr = state.tr;
143 tr.delete(0, match[0].length);
144 repRef.current?.mutate.assertFact({
145 entity: propsRef.current.entityID,
146 attribute: "block/check-list",
147 data: { type: "boolean", value: match[1] === "x" ? true : false },
148 });
149 return tr;
150 }),
151
152 // Unordered List
153 new InputRule(/^([-+*])\s$/, (state) => {
154 if (propsRef.current.listData) return null;
155 let tr = state.tr;
156 tr.delete(0, 2);
157 repRef.current?.mutate.assertFact({
158 entity: propsRef.current.entityID,
159 attribute: "block/is-list",
160 data: { type: "boolean", value: true },
161 });
162 return tr;
163 }),
164
165 //Blockquote
166 new InputRule(/^([>]{1})\s$/, (state, match) => {
167 let tr = state.tr;
168 tr.delete(0, 2);
169 repRef.current?.mutate.assertFact({
170 entity: propsRef.current.entityID,
171 attribute: "block/type",
172 data: { type: "block-type-union", value: "blockquote" },
173 });
174 return tr;
175 }),
176
177 //Header
178 new InputRule(/^([#]{1,3})\s$/, (state, match) => {
179 let tr = state.tr;
180 tr.delete(0, match[0].length);
181 let headingLevel = match[1].length;
182 repRef.current?.mutate.assertFact({
183 entity: propsRef.current.entityID,
184 attribute: "block/type",
185 data: { type: "block-type-union", value: "heading" },
186 });
187 repRef.current?.mutate.assertFact({
188 entity: propsRef.current.entityID,
189 attribute: "block/heading-level",
190 data: { type: "number", value: headingLevel },
191 });
192 return tr;
193 }),
194
195 // Mention - @ at start of line, after space, or after hard break
196 new InputRule(/(?:^|\s)@$/, (state, match, start, end) => {
197 if (!openMentionAutocomplete) return null;
198 // Schedule opening the autocomplete after the transaction is applied
199 setTimeout(() => openMentionAutocomplete(), 0);
200 return null; // Let the @ be inserted normally
201 }),
202 // Mention - @ immediately after a hard break (hard breaks are nodes, not text)
203 new InputRule(/@$/, (state, match, start, end) => {
204 if (!openMentionAutocomplete) return null;
205 // Check if the character before @ is a hard break node
206 const $pos = state.doc.resolve(start);
207 const nodeBefore = $pos.nodeBefore;
208 if (nodeBefore && nodeBefore.type.name === "hard_break") {
209 setTimeout(() => openMentionAutocomplete(), 0);
210 }
211 return null; // Let the @ be inserted normally
212 }),
213 ],
214 });