a tool for shared writing and social publishing
at feature/recommend 235 lines 7.4 kB view raw
1import { useLayoutEffect, useRef, useEffect, useState } from "react"; 2import { EditorState } from "prosemirror-state"; 3import { EditorView } from "prosemirror-view"; 4import { baseKeymap } from "prosemirror-commands"; 5import { keymap } from "prosemirror-keymap"; 6import { ySyncPlugin } from "y-prosemirror"; 7import * as Y from "yjs"; 8import * as base64 from "base64-js"; 9import { Replicache } from "replicache"; 10import { produce } from "immer"; 11 12import { schema } from "./schema"; 13import { TextBlockKeymap } from "./keymap"; 14import { inputrules } from "./inputRules"; 15import { highlightSelectionPlugin } from "./plugins"; 16import { autolink } from "./autolink-plugin"; 17import { useEditorStates } from "src/state/useEditorState"; 18import { 19 useEntity, 20 useReplicache, 21 type ReplicacheMutators, 22} from "src/replicache"; 23import { useHandlePaste } from "./useHandlePaste"; 24import { BlockProps } from "../Block"; 25import { useEntitySetContext } from "components/EntitySetProvider"; 26import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 27 28export function useMountProsemirror({ 29 props, 30 openMentionAutocomplete, 31}: { 32 props: BlockProps; 33 openMentionAutocomplete: () => void; 34}) { 35 let { entityID, parent } = props; 36 let rep = useReplicache(); 37 let mountRef = useRef<HTMLPreElement | null>(null); 38 const repRef = useRef<Replicache<ReplicacheMutators> | null>(null); 39 let value = useYJSValue(entityID); 40 let entity_set = useEntitySetContext(); 41 let alignment = 42 useEntity(entityID, "block/text-alignment")?.data.value || "left"; 43 let propsRef = useRef({ ...props, entity_set, alignment }); 44 let handlePaste = useHandlePaste(entityID, propsRef); 45 46 const actionTimeout = useRef<number | null>(null); 47 48 propsRef.current = { ...props, entity_set, alignment }; 49 repRef.current = rep.rep; 50 51 useLayoutEffect(() => { 52 if (!mountRef.current) return; 53 54 const km = TextBlockKeymap( 55 propsRef, 56 repRef, 57 rep.undoManager, 58 openMentionAutocomplete, 59 ); 60 const editor = EditorState.create({ 61 schema: schema, 62 plugins: [ 63 ySyncPlugin(value), 64 keymap(km), 65 inputrules(propsRef, repRef, openMentionAutocomplete), 66 keymap(baseKeymap), 67 highlightSelectionPlugin, 68 autolink({ 69 type: schema.marks.link, 70 shouldAutoLink: () => true, 71 defaultProtocol: "https", 72 }), 73 ], 74 }); 75 76 const view = new EditorView( 77 { mount: mountRef.current }, 78 { 79 state: editor, 80 handlePaste, 81 handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { 82 if (!direct) return; 83 84 // Check for didMention inline nodes 85 if (node?.type === schema.nodes.didMention) { 86 window.open( 87 didToBlueskyUrl(node.attrs.did), 88 "_blank", 89 "noopener,noreferrer", 90 ); 91 return; 92 } 93 94 // Check for atMention inline nodes 95 if (node?.type === schema.nodes.atMention) { 96 const url = atUriToUrl(node.attrs.atURI); 97 window.open(url, "_blank", "noopener,noreferrer"); 98 return; 99 } 100 if (node.nodeSize - 2 <= _pos) return; 101 102 // Check for marks at the clicked position 103 const nodeAt1 = node.nodeAt(_pos - 1); 104 const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); 105 106 // Check for link marks 107 let linkMark = 108 nodeAt1?.marks.find((f) => f.type === schema.marks.link) || 109 nodeAt2?.marks.find((f) => f.type === schema.marks.link); 110 if (linkMark) { 111 window.open(linkMark.attrs.href, "_blank"); 112 return; 113 } 114 }, 115 dispatchTransaction, 116 }, 117 ); 118 119 const unsubscribe = useEditorStates.subscribe((s) => { 120 let editorState = s.editorStates[entityID]; 121 if (editorState?.initial) return; 122 if (editorState?.editor) 123 editorState.view?.updateState(editorState.editor); 124 }); 125 126 let editorState = useEditorStates.getState().editorStates[entityID]; 127 if (editorState?.editor && !editorState.initial) 128 editorState.view?.updateState(editorState.editor); 129 130 return () => { 131 unsubscribe(); 132 view.destroy(); 133 useEditorStates.setState((s) => ({ 134 ...s, 135 editorStates: { 136 ...s.editorStates, 137 [entityID]: undefined, 138 }, 139 })); 140 }; 141 142 function dispatchTransaction(this: EditorView, tr: any) { 143 useEditorStates.setState((s) => { 144 let oldEditorState = this.state; 145 let newState = this.state.apply(tr); 146 let addToHistory = tr.getMeta("addToHistory"); 147 let isBulkOp = tr.getMeta("bulkOp"); 148 let docHasChanges = tr.steps.length !== 0 || tr.docChanged; 149 150 // Handle undo/redo history with timeout-based grouping 151 if (addToHistory !== false && docHasChanges) { 152 if (actionTimeout.current) window.clearTimeout(actionTimeout.current); 153 else if (!isBulkOp) rep.undoManager.startGroup(); 154 155 if (!isBulkOp) { 156 actionTimeout.current = window.setTimeout(() => { 157 rep.undoManager.endGroup(); 158 actionTimeout.current = null; 159 }, 200); 160 } 161 162 let setState = (s: EditorState) => () => 163 useEditorStates.setState( 164 produce((draft) => { 165 let view = draft.editorStates[entityID]?.view; 166 if (!view?.hasFocus() && !isBulkOp) view?.focus(); 167 draft.editorStates[entityID]!.editor = s; 168 }), 169 ); 170 171 rep.undoManager.add({ 172 redo: setState(newState), 173 undo: setState(oldEditorState), 174 }); 175 } 176 177 return { 178 editorStates: { 179 ...s.editorStates, 180 [entityID]: { 181 editor: newState, 182 view: this as unknown as EditorView, 183 initial: false, 184 keymap: km, 185 }, 186 }, 187 }; 188 }); 189 } 190 }, [entityID, parent, value, handlePaste, rep]); 191 return { mountRef, actionTimeout }; 192} 193 194function useYJSValue(entityID: string) { 195 const [ydoc] = useState(new Y.Doc()); 196 const docStateFromReplicache = useEntity(entityID, "block/text"); 197 let rep = useReplicache(); 198 const [yText] = useState(ydoc.getXmlFragment("prosemirror")); 199 200 if (docStateFromReplicache) { 201 const update = base64.toByteArray(docStateFromReplicache.data.value); 202 Y.applyUpdate(ydoc, update); 203 } 204 205 useEffect(() => { 206 if (!rep.rep) return; 207 let timeout = null as null | number; 208 const updateReplicache = async () => { 209 const update = Y.encodeStateAsUpdate(ydoc); 210 await rep.rep?.mutate.assertFact({ 211 //These undos are handled above in the Prosemirror context 212 ignoreUndo: true, 213 entity: entityID, 214 attribute: "block/text", 215 data: { 216 value: base64.fromByteArray(update), 217 type: "text", 218 }, 219 }); 220 }; 221 const f = async (events: Y.YEvent<any>[], transaction: Y.Transaction) => { 222 if (!transaction.origin) return; 223 if (timeout) clearTimeout(timeout); 224 timeout = window.setTimeout(async () => { 225 updateReplicache(); 226 }, 300); 227 }; 228 229 yText.observeDeep(f); 230 return () => { 231 yText.unobserveDeep(f); 232 }; 233 }, [yText, entityID, rep, ydoc]); 234 return yText; 235}