a tool for shared writing and social publishing
at feature/footnotes 267 lines 9.1 kB view raw
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 });