a tool for shared writing and social publishing
at main 239 lines 7.7 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 if (node.nodeSize - 2 <= _pos) return; 84 85 // Check for marks at the clicked position 86 const nodeAt1 = node.nodeAt(_pos - 1); 87 const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); 88 89 // Check for link marks 90 let linkMark = nodeAt1?.marks.find((f) => f.type === schema.marks.link) || 91 nodeAt2?.marks.find((f) => f.type === schema.marks.link); 92 if (linkMark) { 93 window.open(linkMark.attrs.href, "_blank"); 94 return; 95 } 96 97 // Check for didMention inline nodes 98 if (nodeAt1?.type === schema.nodes.didMention) { 99 window.open(didToBlueskyUrl(nodeAt1.attrs.did), "_blank", "noopener,noreferrer"); 100 return; 101 } 102 if (nodeAt2?.type === schema.nodes.didMention) { 103 window.open(didToBlueskyUrl(nodeAt2.attrs.did), "_blank", "noopener,noreferrer"); 104 return; 105 } 106 107 // Check for atMention inline nodes 108 if (nodeAt1?.type === schema.nodes.atMention) { 109 const url = atUriToUrl(nodeAt1.attrs.atURI); 110 window.open(url, "_blank", "noopener,noreferrer"); 111 return; 112 } 113 if (nodeAt2?.type === schema.nodes.atMention) { 114 const url = atUriToUrl(nodeAt2.attrs.atURI); 115 window.open(url, "_blank", "noopener,noreferrer"); 116 return; 117 } 118 }, 119 dispatchTransaction, 120 }, 121 ); 122 123 const unsubscribe = useEditorStates.subscribe((s) => { 124 let editorState = s.editorStates[entityID]; 125 if (editorState?.initial) return; 126 if (editorState?.editor) 127 editorState.view?.updateState(editorState.editor); 128 }); 129 130 let editorState = useEditorStates.getState().editorStates[entityID]; 131 if (editorState?.editor && !editorState.initial) 132 editorState.view?.updateState(editorState.editor); 133 134 return () => { 135 unsubscribe(); 136 view.destroy(); 137 useEditorStates.setState((s) => ({ 138 ...s, 139 editorStates: { 140 ...s.editorStates, 141 [entityID]: undefined, 142 }, 143 })); 144 }; 145 146 function dispatchTransaction(this: EditorView, tr: any) { 147 useEditorStates.setState((s) => { 148 let oldEditorState = this.state; 149 let newState = this.state.apply(tr); 150 let addToHistory = tr.getMeta("addToHistory"); 151 let isBulkOp = tr.getMeta("bulkOp"); 152 let docHasChanges = tr.steps.length !== 0 || tr.docChanged; 153 154 // Handle undo/redo history with timeout-based grouping 155 if (addToHistory !== false && docHasChanges) { 156 if (actionTimeout.current) window.clearTimeout(actionTimeout.current); 157 else if (!isBulkOp) rep.undoManager.startGroup(); 158 159 if (!isBulkOp) { 160 actionTimeout.current = window.setTimeout(() => { 161 rep.undoManager.endGroup(); 162 actionTimeout.current = null; 163 }, 200); 164 } 165 166 let setState = (s: EditorState) => () => 167 useEditorStates.setState( 168 produce((draft) => { 169 let view = draft.editorStates[entityID]?.view; 170 if (!view?.hasFocus() && !isBulkOp) view?.focus(); 171 draft.editorStates[entityID]!.editor = s; 172 }), 173 ); 174 175 rep.undoManager.add({ 176 redo: setState(newState), 177 undo: setState(oldEditorState), 178 }); 179 } 180 181 return { 182 editorStates: { 183 ...s.editorStates, 184 [entityID]: { 185 editor: newState, 186 view: this as unknown as EditorView, 187 initial: false, 188 keymap: km, 189 }, 190 }, 191 }; 192 }); 193 } 194 }, [entityID, parent, value, handlePaste, rep]); 195 return { mountRef, actionTimeout }; 196} 197 198function useYJSValue(entityID: string) { 199 const [ydoc] = useState(new Y.Doc()); 200 const docStateFromReplicache = useEntity(entityID, "block/text"); 201 let rep = useReplicache(); 202 const [yText] = useState(ydoc.getXmlFragment("prosemirror")); 203 204 if (docStateFromReplicache) { 205 const update = base64.toByteArray(docStateFromReplicache.data.value); 206 Y.applyUpdate(ydoc, update); 207 } 208 209 useEffect(() => { 210 if (!rep.rep) return; 211 let timeout = null as null | number; 212 const updateReplicache = async () => { 213 const update = Y.encodeStateAsUpdate(ydoc); 214 await rep.rep?.mutate.assertFact({ 215 //These undos are handled above in the Prosemirror context 216 ignoreUndo: true, 217 entity: entityID, 218 attribute: "block/text", 219 data: { 220 value: base64.fromByteArray(update), 221 type: "text", 222 }, 223 }); 224 }; 225 const f = async (events: Y.YEvent<any>[], transaction: Y.Transaction) => { 226 if (!transaction.origin) return; 227 if (timeout) clearTimeout(timeout); 228 timeout = window.setTimeout(async () => { 229 updateReplicache(); 230 }, 300); 231 }; 232 233 yText.observeDeep(f); 234 return () => { 235 yText.unobserveDeep(f); 236 }; 237 }, [yText, entityID, rep, ydoc]); 238 return yText; 239}