a tool for shared writing and social publishing
at feature/fonts 249 lines 8.5 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"; 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 { 159 entity: propsRef.current.entityID, 160 attribute: "block/is-list", 161 data: { type: "boolean", value: true }, 162 }, 163 { 164 entity: propsRef.current.entityID, 165 attribute: "block/list-style", 166 data: { type: "list-style-union", value: "unordered" }, 167 }, 168 ]); 169 return tr; 170 }), 171 172 // Ordered List - respect the starting number typed (supports "1." or "1)") 173 new InputRule(/^(\d+)[.)]\s$/, (state, match) => { 174 if (propsRef.current.listData) return null; 175 let tr = state.tr; 176 tr.delete(0, match[0].length); 177 const startNumber = parseInt(match[1], 10); 178 repRef.current?.mutate.assertFact([ 179 { 180 entity: propsRef.current.entityID, 181 attribute: "block/is-list", 182 data: { type: "boolean", value: true }, 183 }, 184 { 185 entity: propsRef.current.entityID, 186 attribute: "block/list-style", 187 data: { type: "list-style-union", value: "ordered" }, 188 }, 189 ]); 190 if (startNumber > 1) { 191 repRef.current?.mutate.assertFact({ 192 entity: propsRef.current.entityID, 193 attribute: "block/list-number", 194 data: { type: "number", value: startNumber }, 195 }); 196 } 197 return tr; 198 }), 199 200 //Blockquote 201 new InputRule(/^([>]{1})\s$/, (state, match) => { 202 let tr = state.tr; 203 tr.delete(0, 2); 204 repRef.current?.mutate.assertFact({ 205 entity: propsRef.current.entityID, 206 attribute: "block/type", 207 data: { type: "block-type-union", value: "blockquote" }, 208 }); 209 return tr; 210 }), 211 212 //Header 213 new InputRule(/^([#]{1,4})\s$/, (state, match) => { 214 let tr = state.tr; 215 tr.delete(0, match[0].length); 216 let headingLevel = match[1].length; 217 repRef.current?.mutate.assertFact({ 218 entity: propsRef.current.entityID, 219 attribute: "block/type", 220 data: { type: "block-type-union", value: "heading" }, 221 }); 222 repRef.current?.mutate.assertFact({ 223 entity: propsRef.current.entityID, 224 attribute: "block/heading-level", 225 data: { type: "number", value: headingLevel }, 226 }); 227 return tr; 228 }), 229 230 // Mention - @ at start of line, after space, or after hard break 231 new InputRule(/(?:^|\s)@$/, (state, match, start, end) => { 232 if (!openMentionAutocomplete) return null; 233 // Schedule opening the autocomplete after the transaction is applied 234 setTimeout(() => openMentionAutocomplete(), 0); 235 return null; // Let the @ be inserted normally 236 }), 237 // Mention - @ immediately after a hard break (hard breaks are nodes, not text) 238 new InputRule(/@$/, (state, match, start, end) => { 239 if (!openMentionAutocomplete) return null; 240 // Check if the character before @ is a hard break node 241 const $pos = state.doc.resolve(start); 242 const nodeBefore = $pos.nodeBefore; 243 if (nodeBefore && nodeBefore.type.name === "hard_break") { 244 setTimeout(() => openMentionAutocomplete(), 0); 245 } 246 return null; // Let the @ be inserted normally 247 }), 248 ], 249 });