a tool for shared writing and social publishing
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"; 26 27export function useMountProsemirror({ props }: { props: BlockProps }) { 28 let { entityID, parent } = props; 29 let rep = useReplicache(); 30 let mountRef = useRef<HTMLPreElement | null>(null); 31 const repRef = useRef<Replicache<ReplicacheMutators> | null>(null); 32 let value = useYJSValue(entityID); 33 let entity_set = useEntitySetContext(); 34 let alignment = 35 useEntity(entityID, "block/text-alignment")?.data.value || "left"; 36 let propsRef = useRef({ ...props, entity_set, alignment }); 37 let handlePaste = useHandlePaste(entityID, propsRef); 38 39 const actionTimeout = useRef<number | null>(null); 40 41 propsRef.current = { ...props, entity_set, alignment }; 42 repRef.current = rep.rep; 43 44 useLayoutEffect(() => { 45 if (!mountRef.current) return; 46 47 const km = TextBlockKeymap(propsRef, repRef, rep.undoManager); 48 const editor = EditorState.create({ 49 schema: schema, 50 plugins: [ 51 ySyncPlugin(value), 52 keymap(km), 53 inputrules(propsRef, repRef), 54 keymap(baseKeymap), 55 highlightSelectionPlugin, 56 autolink({ 57 type: schema.marks.link, 58 shouldAutoLink: () => true, 59 defaultProtocol: "https", 60 }), 61 ], 62 }); 63 64 const view = new EditorView( 65 { mount: mountRef.current }, 66 { 67 state: editor, 68 handlePaste, 69 handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { 70 if (!direct) return; 71 if (node.nodeSize - 2 <= _pos) return; 72 let mark = 73 node 74 .nodeAt(_pos - 1) 75 ?.marks.find((f) => f.type === schema.marks.link) || 76 node 77 .nodeAt(Math.max(_pos - 2, 0)) 78 ?.marks.find((f) => f.type === schema.marks.link); 79 if (mark) { 80 window.open(mark.attrs.href, "_blank"); 81 } 82 }, 83 dispatchTransaction, 84 }, 85 ); 86 87 const unsubscribe = useEditorStates.subscribe((s) => { 88 let editorState = s.editorStates[entityID]; 89 if (editorState?.initial) return; 90 if (editorState?.editor) 91 editorState.view?.updateState(editorState.editor); 92 }); 93 94 let editorState = useEditorStates.getState().editorStates[entityID]; 95 if (editorState?.editor && !editorState.initial) 96 editorState.view?.updateState(editorState.editor); 97 98 return () => { 99 unsubscribe(); 100 view.destroy(); 101 useEditorStates.setState((s) => ({ 102 ...s, 103 editorStates: { 104 ...s.editorStates, 105 [entityID]: undefined, 106 }, 107 })); 108 }; 109 110 function dispatchTransaction(this: EditorView, tr: any) { 111 useEditorStates.setState((s) => { 112 let oldEditorState = this.state; 113 let newState = this.state.apply(tr); 114 let addToHistory = tr.getMeta("addToHistory"); 115 let isBulkOp = tr.getMeta("bulkOp"); 116 let docHasChanges = tr.steps.length !== 0 || tr.docChanged; 117 118 // Handle undo/redo history with timeout-based grouping 119 if (addToHistory !== false && docHasChanges) { 120 if (actionTimeout.current) window.clearTimeout(actionTimeout.current); 121 else if (!isBulkOp) rep.undoManager.startGroup(); 122 123 if (!isBulkOp) { 124 actionTimeout.current = window.setTimeout(() => { 125 rep.undoManager.endGroup(); 126 actionTimeout.current = null; 127 }, 200); 128 } 129 130 let setState = (s: EditorState) => () => 131 useEditorStates.setState( 132 produce((draft) => { 133 let view = draft.editorStates[entityID]?.view; 134 if (!view?.hasFocus() && !isBulkOp) view?.focus(); 135 draft.editorStates[entityID]!.editor = s; 136 }), 137 ); 138 139 rep.undoManager.add({ 140 redo: setState(newState), 141 undo: setState(oldEditorState), 142 }); 143 } 144 145 return { 146 editorStates: { 147 ...s.editorStates, 148 [entityID]: { 149 editor: newState, 150 view: this as unknown as EditorView, 151 initial: false, 152 keymap: km, 153 }, 154 }, 155 }; 156 }); 157 } 158 }, [entityID, parent, value, handlePaste, rep]); 159 return { mountRef, actionTimeout }; 160} 161 162function useYJSValue(entityID: string) { 163 const [ydoc] = useState(new Y.Doc()); 164 const docStateFromReplicache = useEntity(entityID, "block/text"); 165 let rep = useReplicache(); 166 const [yText] = useState(ydoc.getXmlFragment("prosemirror")); 167 168 if (docStateFromReplicache) { 169 const update = base64.toByteArray(docStateFromReplicache.data.value); 170 Y.applyUpdate(ydoc, update); 171 } 172 173 useEffect(() => { 174 if (!rep.rep) return; 175 let timeout = null as null | number; 176 const updateReplicache = async () => { 177 const update = Y.encodeStateAsUpdate(ydoc); 178 await rep.rep?.mutate.assertFact({ 179 //These undos are handled above in the Prosemirror context 180 ignoreUndo: true, 181 entity: entityID, 182 attribute: "block/text", 183 data: { 184 value: base64.fromByteArray(update), 185 type: "text", 186 }, 187 }); 188 }; 189 const f = async (events: Y.YEvent<any>[], transaction: Y.Transaction) => { 190 if (!transaction.origin) return; 191 if (timeout) clearTimeout(timeout); 192 timeout = window.setTimeout(async () => { 193 updateReplicache(); 194 }, 300); 195 }; 196 197 yText.observeDeep(f); 198 return () => { 199 yText.unobserveDeep(f); 200 }; 201 }, [yText, entityID, rep, ydoc]); 202 return yText; 203}