a tool for shared writing and social publishing
at feature/footnotes 319 lines 10 kB view raw
1import { useLayoutEffect, useRef, useEffect, useState } from "react"; 2import { EditorState, Transaction } 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 { UndoManager } from "src/undoManager"; 14import { TextBlockKeymap } from "./keymap"; 15import { inputrules } from "./inputRules"; 16import { highlightSelectionPlugin } from "./plugins"; 17import { autolink } from "./autolink-plugin"; 18import { useEditorStates } from "src/state/useEditorState"; 19import { 20 useEntity, 21 useReplicache, 22 type ReplicacheMutators, 23} from "src/replicache"; 24import { useHandlePaste } from "./useHandlePaste"; 25import { BlockProps } from "../Block"; 26import { useEntitySetContext } from "components/EntitySetProvider"; 27import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 28import { useFootnotePopoverStore } from "components/Footnotes/FootnotePopover"; 29 30export function useMountProsemirror({ 31 props, 32 openMentionAutocomplete, 33}: { 34 props: BlockProps; 35 openMentionAutocomplete: () => void; 36}) { 37 let { entityID, parent } = props; 38 let rep = useReplicache(); 39 let mountRef = useRef<HTMLPreElement | null>(null); 40 const repRef = useRef<Replicache<ReplicacheMutators> | null>(null); 41 let value = useYJSValue(entityID); 42 let entity_set = useEntitySetContext(); 43 let alignment = 44 useEntity(entityID, "block/text-alignment")?.data.value || "left"; 45 let propsRef = useRef({ ...props, entity_set, alignment }); 46 let handlePaste = useHandlePaste(entityID, propsRef); 47 48 const actionTimeout = useRef<number | null>(null); 49 50 propsRef.current = { ...props, entity_set, alignment }; 51 repRef.current = rep.rep; 52 53 useLayoutEffect(() => { 54 if (!mountRef.current) return; 55 56 const km = TextBlockKeymap( 57 propsRef, 58 repRef, 59 rep.undoManager, 60 openMentionAutocomplete, 61 ); 62 const editor = EditorState.create({ 63 schema: schema, 64 plugins: [ 65 ySyncPlugin(value), 66 keymap(km), 67 inputrules(propsRef, repRef, openMentionAutocomplete), 68 keymap(baseKeymap), 69 highlightSelectionPlugin, 70 autolink({ 71 type: schema.marks.link, 72 shouldAutoLink: () => true, 73 defaultProtocol: "https", 74 }), 75 ], 76 }); 77 78 const view = new EditorView( 79 { mount: mountRef.current }, 80 { 81 state: editor, 82 handlePaste, 83 handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { 84 if (!direct) return; 85 86 // Check for footnote inline nodes 87 if (node?.type === schema.nodes.footnote) { 88 let footnoteID = node.attrs.footnoteEntityID; 89 let supEl = _event.target as HTMLElement; 90 let sup = supEl.closest(".footnote-ref") as HTMLElement | null; 91 if (!sup) return; 92 93 // On mobile/tablet or canvas, show popover 94 let isDesktop = window.matchMedia("(min-width: 1280px)").matches; 95 let isCanvas = propsRef.current.pageType === "canvas"; 96 if (!isDesktop || isCanvas) { 97 let store = useFootnotePopoverStore.getState(); 98 if (store.activeFootnoteID === footnoteID) { 99 store.close(); 100 } else { 101 store.open(footnoteID, sup); 102 } 103 return; 104 } 105 106 // On desktop, prefer the side column editor if visible 107 let sideColumn = document.querySelector(".footnote-side-column"); 108 let editor = sideColumn?.querySelector( 109 `[data-footnote-editor="${footnoteID}"]`, 110 ) as HTMLElement | null; 111 // Fall back to the bottom section 112 if (!editor) { 113 editor = document.querySelector( 114 `[data-footnote-editor="${footnoteID}"]`, 115 ) as HTMLElement | null; 116 } 117 if (editor) { 118 editor.scrollIntoView({ behavior: "smooth", block: "nearest" }); 119 let pm = editor.querySelector( 120 ".ProseMirror", 121 ) as HTMLElement | null; 122 if (pm) { 123 setTimeout(() => pm!.focus(), 100); 124 } 125 } 126 return; 127 } 128 129 // Check for didMention inline nodes 130 if (node?.type === schema.nodes.didMention) { 131 window.open( 132 didToBlueskyUrl(node.attrs.did), 133 "_blank", 134 "noopener,noreferrer", 135 ); 136 return; 137 } 138 139 // Check for atMention inline nodes 140 if (node?.type === schema.nodes.atMention) { 141 const url = atUriToUrl(node.attrs.atURI); 142 window.open(url, "_blank", "noopener,noreferrer"); 143 return; 144 } 145 if (node.nodeSize - 2 <= _pos) return; 146 147 // Check for marks at the clicked position 148 const nodeAt1 = node.nodeAt(_pos - 1); 149 const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); 150 151 // Check for link marks 152 let linkMark = 153 nodeAt1?.marks.find((f) => f.type === schema.marks.link) || 154 nodeAt2?.marks.find((f) => f.type === schema.marks.link); 155 if (linkMark) { 156 window.open(linkMark.attrs.href, "_blank"); 157 return; 158 } 159 }, 160 dispatchTransaction, 161 }, 162 ); 163 164 const unsubscribe = useEditorStates.subscribe((s) => { 165 let editorState = s.editorStates[entityID]; 166 if (editorState?.initial) return; 167 if (editorState?.editor) 168 editorState.view?.updateState(editorState.editor); 169 }); 170 171 let editorState = useEditorStates.getState().editorStates[entityID]; 172 if (editorState?.editor && !editorState.initial) 173 editorState.view?.updateState(editorState.editor); 174 175 return () => { 176 unsubscribe(); 177 view.destroy(); 178 useEditorStates.setState((s) => ({ 179 ...s, 180 editorStates: { 181 ...s.editorStates, 182 [entityID]: undefined, 183 }, 184 })); 185 }; 186 187 function dispatchTransaction(this: EditorView, tr: any) { 188 useEditorStates.setState((s) => { 189 let oldEditorState = this.state; 190 let newState = this.state.apply(tr); 191 let docHasChanges = tr.steps.length !== 0 || tr.docChanged; 192 193 // Diff for removed/added footnote nodes 194 if (docHasChanges) { 195 let oldFootnotes = new Set<string>(); 196 let newFootnotes = new Set<string>(); 197 oldEditorState.doc.descendants((n) => { 198 if (n.type.name === "footnote") 199 oldFootnotes.add(n.attrs.footnoteEntityID); 200 }); 201 newState.doc.descendants((n) => { 202 if (n.type.name === "footnote") 203 newFootnotes.add(n.attrs.footnoteEntityID); 204 }); 205 // Removed footnotes 206 for (let id of oldFootnotes) { 207 if (!newFootnotes.has(id)) { 208 repRef.current?.mutate.deleteFootnote({ 209 footnoteEntityID: id, 210 blockID: entityID, 211 }); 212 } 213 } 214 } 215 216 // Handle undo/redo history with timeout-based grouping 217 let isBulkOp = tr.getMeta("bulkOp"); 218 let setState = (s: EditorState) => () => 219 useEditorStates.setState( 220 produce((draft) => { 221 let view = draft.editorStates[entityID]?.view; 222 if (!view?.hasFocus() && !isBulkOp) view?.focus(); 223 draft.editorStates[entityID]!.editor = s; 224 }), 225 ); 226 227 trackUndoRedo( 228 tr, 229 rep.undoManager, 230 actionTimeout, 231 setState(oldEditorState), 232 setState(newState), 233 ); 234 235 return { 236 editorStates: { 237 ...s.editorStates, 238 [entityID]: { 239 editor: newState, 240 view: this as unknown as EditorView, 241 initial: false, 242 keymap: km, 243 }, 244 }, 245 }; 246 }); 247 } 248 }, [entityID, parent, value, handlePaste, rep]); 249 return { mountRef, actionTimeout }; 250} 251 252export function trackUndoRedo( 253 tr: Transaction, 254 undoManager: UndoManager, 255 actionTimeout: { current: number | null }, 256 undo: () => void, 257 redo: () => void, 258) { 259 let addToHistory = tr.getMeta("addToHistory"); 260 let isBulkOp = tr.getMeta("bulkOp"); 261 let docHasChanges = tr.steps.length !== 0 || tr.docChanged; 262 263 if (addToHistory !== false && docHasChanges) { 264 if (actionTimeout.current) window.clearTimeout(actionTimeout.current); 265 else if (!isBulkOp) undoManager.startGroup(); 266 267 if (!isBulkOp) { 268 actionTimeout.current = window.setTimeout(() => { 269 undoManager.endGroup(); 270 actionTimeout.current = null; 271 }, 200); 272 } 273 274 undoManager.add({ undo, redo }); 275 } 276} 277 278export function useYJSValue(entityID: string) { 279 const [ydoc] = useState(new Y.Doc()); 280 const docStateFromReplicache = useEntity(entityID, "block/text"); 281 let rep = useReplicache(); 282 const [yText] = useState(ydoc.getXmlFragment("prosemirror")); 283 284 if (docStateFromReplicache) { 285 const update = base64.toByteArray(docStateFromReplicache.data.value); 286 Y.applyUpdate(ydoc, update); 287 } 288 289 useEffect(() => { 290 if (!rep.rep) return; 291 let timeout = null as null | number; 292 const updateReplicache = async () => { 293 const update = Y.encodeStateAsUpdate(ydoc); 294 await rep.rep?.mutate.assertFact({ 295 //These undos are handled above in the Prosemirror context 296 ignoreUndo: true, 297 entity: entityID, 298 attribute: "block/text", 299 data: { 300 value: base64.fromByteArray(update), 301 type: "text", 302 }, 303 }); 304 }; 305 const f = async (events: Y.YEvent<any>[], transaction: Y.Transaction) => { 306 if (!transaction.origin) return; 307 if (timeout) clearTimeout(timeout); 308 timeout = window.setTimeout(async () => { 309 updateReplicache(); 310 }, 300); 311 }; 312 313 yText.observeDeep(f); 314 return () => { 315 yText.unobserveDeep(f); 316 }; 317 }, [yText, entityID, rep, ydoc]); 318 return yText; 319}