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";
15import { insertFootnote } from "./insertFootnote";
16import { useEditorStates } from "src/state/useEditorState";
17export const inputrules = (
18 propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>,
19 repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>,
20 openMentionAutocomplete?: () => void,
21) =>
22 inputRules({
23 //Strikethrough
24 rules: [
25 new InputRule(/\~\~([^*]+)\~\~$/, (state, match, start, end) => {
26 const [fullMatch, content] = match;
27 const { tr } = state;
28 if (content) {
29 tr.replaceWith(start, end, state.schema.text(content))
30 .addMark(
31 start,
32 start + content.length,
33 schema.marks.strikethrough.create(),
34 )
35 .removeStoredMark(schema.marks.strikethrough);
36 return tr;
37 }
38 return null;
39 }),
40
41 //Highlight
42 new InputRule(/\=\=([^*]+)\=\=$/, (state, match, start, end) => {
43 const [fullMatch, content] = match;
44 const { tr } = state;
45 if (content) {
46 tr.replaceWith(start, end, state.schema.text(content))
47 .addMark(
48 start,
49 start + content.length,
50 schema.marks.highlight.create({
51 color: useUIState.getState().lastUsedHighlight || "1",
52 }),
53 )
54 .removeStoredMark(schema.marks.highlight);
55 return tr;
56 }
57 return null;
58 }),
59
60 //Bold
61 new InputRule(/\*\*([^*]+)\*\*$/, (state, match, start, end) => {
62 const [fullMatch, content] = match;
63 const { tr } = state;
64 if (content) {
65 tr.replaceWith(start, end, state.schema.text(content))
66 .addMark(
67 start,
68 start + content.length,
69 schema.marks.strong.create(),
70 )
71 .removeStoredMark(schema.marks.strong);
72 return tr;
73 }
74 return null;
75 }),
76
77 //Code
78 new InputRule(/\`([^`]+)\`$/, (state, match, start, end) => {
79 const [fullMatch, content] = match;
80 const { tr } = state;
81 if (content) {
82 const startIndex = start + fullMatch.indexOf("`");
83 tr.replaceWith(startIndex, end, state.schema.text(content))
84 .addMark(
85 startIndex,
86 startIndex + content.length,
87 schema.marks.code.create(),
88 )
89 .removeStoredMark(schema.marks.code);
90 return tr;
91 }
92 return null;
93 }),
94
95 //Italic
96 new InputRule(/(?:^|[^*])\*([^*]+)\*$/, (state, match, start, end) => {
97 const [fullMatch, content] = match;
98 const { tr } = state;
99 if (content) {
100 const startIndex = start + fullMatch.indexOf("*");
101 tr.replaceWith(startIndex, end, state.schema.text(content))
102 .addMark(
103 startIndex,
104 startIndex + content.length,
105 schema.marks.em.create(),
106 )
107 .removeStoredMark(schema.marks.em);
108 return tr;
109 }
110 return null;
111 }),
112
113 // Code Block
114 new InputRule(/^```\s$/, (state, match) => {
115 flushSync(() => {
116 repRef.current?.mutate.assertFact({
117 entity: propsRef.current.entityID,
118 attribute: "block/type",
119 data: { type: "block-type-union", value: "code" },
120 });
121 let lastLang = localStorage.getItem(LAST_USED_CODE_LANGUAGE_KEY);
122 if (lastLang) {
123 repRef.current?.mutate.assertFact({
124 entity: propsRef.current.entityID,
125 attribute: "block/code-language",
126 data: { type: "string", value: lastLang },
127 });
128 }
129 });
130 setTimeout(() => {
131 focusBlock({ ...propsRef.current, type: "code" }, { type: "start" });
132 }, 20);
133 return null;
134 }),
135
136 //Checklist
137 new InputRule(/^\-?\[(\ |x)?\]\s$/, (state, match) => {
138 if (!propsRef.current.listData)
139 repRef.current?.mutate.assertFact({
140 entity: propsRef.current.entityID,
141 attribute: "block/is-list",
142 data: { type: "boolean", value: true },
143 });
144 let tr = state.tr;
145 tr.delete(0, match[0].length);
146 repRef.current?.mutate.assertFact({
147 entity: propsRef.current.entityID,
148 attribute: "block/check-list",
149 data: { type: "boolean", value: match[1] === "x" ? true : false },
150 });
151 return tr;
152 }),
153
154 // Unordered List
155 new InputRule(/^([-+*])\s$/, (state) => {
156 if (propsRef.current.listData) return null;
157 let tr = state.tr;
158 tr.delete(0, 2);
159 repRef.current?.mutate.assertFact([
160 {
161 entity: propsRef.current.entityID,
162 attribute: "block/is-list",
163 data: { type: "boolean", value: true },
164 },
165 {
166 entity: propsRef.current.entityID,
167 attribute: "block/list-style",
168 data: { type: "list-style-union", value: "unordered" },
169 },
170 ]);
171 return tr;
172 }),
173
174 // Ordered List - respect the starting number typed (supports "1." or "1)")
175 new InputRule(/^(\d+)[.)]\s$/, (state, match) => {
176 if (propsRef.current.listData) return null;
177 let tr = state.tr;
178 tr.delete(0, match[0].length);
179 const startNumber = parseInt(match[1], 10);
180 repRef.current?.mutate.assertFact([
181 {
182 entity: propsRef.current.entityID,
183 attribute: "block/is-list",
184 data: { type: "boolean", value: true },
185 },
186 {
187 entity: propsRef.current.entityID,
188 attribute: "block/list-style",
189 data: { type: "list-style-union", value: "ordered" },
190 },
191 ]);
192 if (startNumber > 1) {
193 repRef.current?.mutate.assertFact({
194 entity: propsRef.current.entityID,
195 attribute: "block/list-number",
196 data: { type: "number", value: startNumber },
197 });
198 }
199 return tr;
200 }),
201
202 //Blockquote
203 new InputRule(/^([>]{1})\s$/, (state, match) => {
204 let tr = state.tr;
205 tr.delete(0, 2);
206 repRef.current?.mutate.assertFact({
207 entity: propsRef.current.entityID,
208 attribute: "block/type",
209 data: { type: "block-type-union", value: "blockquote" },
210 });
211 return tr;
212 }),
213
214 //Header
215 new InputRule(/^([#]{1,4})\s$/, (state, match) => {
216 let tr = state.tr;
217 tr.delete(0, match[0].length);
218 let headingLevel = match[1].length;
219 repRef.current?.mutate.assertFact({
220 entity: propsRef.current.entityID,
221 attribute: "block/type",
222 data: { type: "block-type-union", value: "heading" },
223 });
224 repRef.current?.mutate.assertFact({
225 entity: propsRef.current.entityID,
226 attribute: "block/heading-level",
227 data: { type: "number", value: headingLevel },
228 });
229 return tr;
230 }),
231
232 // Footnote - [^ triggers footnote insertion
233 new InputRule(/\[\^$/, (state, match, start, end) => {
234 let tr = state.tr.delete(start, end);
235 setTimeout(() => {
236 let view = useEditorStates.getState().editorStates[propsRef.current.entityID]?.view;
237 if (!view || !repRef.current) return;
238 insertFootnote(
239 view,
240 propsRef.current.entityID,
241 repRef.current,
242 propsRef.current.entity_set.set,
243 );
244 }, 0);
245 return tr;
246 }),
247
248 // Mention - @ at start of line, after space, or after hard break
249 new InputRule(/(?:^|\s)@$/, (state, match, start, end) => {
250 if (!openMentionAutocomplete) return null;
251 // Schedule opening the autocomplete after the transaction is applied
252 setTimeout(() => openMentionAutocomplete(), 0);
253 return null; // Let the @ be inserted normally
254 }),
255 // Mention - @ immediately after a hard break (hard breaks are nodes, not text)
256 new InputRule(/@$/, (state, match, start, end) => {
257 if (!openMentionAutocomplete) return null;
258 // Check if the character before @ is a hard break node
259 const $pos = state.doc.resolve(start);
260 const nodeBefore = $pos.nodeBefore;
261 if (nodeBefore && nodeBefore.type.name === "hard_break") {
262 setTimeout(() => openMentionAutocomplete(), 0);
263 }
264 return null; // Let the @ be inserted normally
265 }),
266 ],
267 });