a tool for shared writing and social publishing

extract out prosemirror mounting logic from textblock

+223 -201
+18 -196
components/Blocks/TextBlock/index.tsx
··· 1 - import { useRef, useEffect, useState, useLayoutEffect } from "react"; 1 + import { useRef, useEffect, useState } from "react"; 2 2 import { elementId } from "src/utils/elementId"; 3 - import { baseKeymap } from "prosemirror-commands"; 4 - import { keymap } from "prosemirror-keymap"; 5 - import * as Y from "yjs"; 6 - import * as base64 from "base64-js"; 7 - import { useReplicache, useEntity, ReplicacheMutators } from "src/replicache"; 3 + import { useReplicache, useEntity } from "src/replicache"; 8 4 import { isVisible } from "src/utils/isVisible"; 9 - 10 5 import { EditorState, TextSelection } from "prosemirror-state"; 11 - import { EditorView } from "prosemirror-view"; 12 - 13 - import { ySyncPlugin } from "y-prosemirror"; 14 - import { Replicache } from "replicache"; 15 6 import { RenderYJSFragment } from "./RenderYJSFragment"; 16 7 import { useInitialPageLoad } from "components/InitialPageLoadProvider"; 17 8 import { BlockProps } from "../Block"; 18 9 import { focusBlock } from "src/utils/focusBlock"; 19 - import { TextBlockKeymap } from "./keymap"; 20 - import { multiBlockSchema, schema } from "./schema"; 21 10 import { useUIState } from "src/useUIState"; 22 11 import { addBlueskyPostBlock, addLinkBlock } from "src/utils/addLinkBlock"; 23 12 import { BlockCommandBar } from "components/Blocks/BlockCommandBar"; 24 13 import { useEditorStates } from "src/state/useEditorState"; 25 14 import { useEntitySetContext } from "components/EntitySetProvider"; 26 - import { useHandlePaste } from "./useHandlePaste"; 27 - import { highlightSelectionPlugin } from "./plugins"; 28 - import { inputrules } from "./inputRules"; 29 - import { autolink } from "./autolink-plugin"; 30 15 import { TooltipButton } from "components/Buttons"; 31 16 import { blockCommands } from "../BlockCommands"; 32 17 import { betterIsUrl } from "src/utils/isURL"; ··· 37 22 import { isIOS } from "src/utils/isDevice"; 38 23 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 39 24 import { DotLoader } from "components/utils/DotLoader"; 25 + import { useMountProsemirror } from "./mountProsemirror"; 40 26 41 27 const HeadingStyle = { 42 28 1: "text-xl font-bold", ··· 177 163 } 178 164 179 165 export function BaseTextBlock(props: BlockProps & { className?: string }) { 180 - let mountRef = useRef<HTMLPreElement | null>(null); 181 - let actionTimeout = useRef<number | null>(null); 182 - let repRef = useRef<null | Replicache<ReplicacheMutators>>(null); 183 166 let headingLevel = useEntity(props.entityID, "block/heading-level"); 184 - let entity_set = useEntitySetContext(); 185 167 let alignment = 186 168 useEntity(props.entityID, "block/text-alignment")?.data.value || "left"; 187 - let propsRef = useRef({ ...props, entity_set, alignment }); 188 - useEffect(() => { 189 - propsRef.current = { ...props, entity_set, alignment }; 190 - }, [props, entity_set, alignment]); 169 + 191 170 let rep = useReplicache(); 192 - useEffect(() => { 193 - repRef.current = rep.rep; 194 - }, [rep?.rep]); 195 171 196 172 let selected = useUIState( 197 173 (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID), ··· 204 180 justify: "text-justify", 205 181 }[alignment]; 206 182 207 - let value = useYJSValue(props.entityID); 208 - 209 183 let editorState = useEditorStates( 210 184 (s) => s.editorStates[props.entityID], 211 185 )?.editor; 212 - let handlePaste = useHandlePaste(props.entityID, propsRef); 213 - useLayoutEffect(() => { 214 - if (!mountRef.current) return; 215 - let km = TextBlockKeymap(propsRef, repRef, rep.undoManager); 216 - let editor = EditorState.create({ 217 - schema: schema, 218 - plugins: [ 219 - ySyncPlugin(value), 220 - keymap(km), 221 - inputrules(propsRef, repRef), 222 - keymap(baseKeymap), 223 - highlightSelectionPlugin, 224 - autolink({ 225 - type: schema.marks.link, 226 - shouldAutoLink: () => true, 227 - defaultProtocol: "https", 228 - }), 229 - ], 230 - }); 231 186 232 - let unsubscribe = useEditorStates.subscribe((s) => { 233 - let editorState = s.editorStates[props.entityID]; 234 - if (editorState?.initial) return; 235 - if (editorState?.editor) 236 - editorState.view?.updateState(editorState.editor); 237 - }); 238 - let view = new EditorView( 239 - { mount: mountRef.current }, 240 - { 241 - state: editor, 242 - handlePaste, 243 - handleClickOn: (view, _pos, node, _nodePos, _event, direct) => { 244 - if (!direct) return; 245 - if (node.nodeSize - 2 <= _pos) return; 246 - let mark = 247 - node 248 - .nodeAt(_pos - 1) 249 - ?.marks.find((f) => f.type === schema.marks.link) || 250 - node 251 - .nodeAt(Math.max(_pos - 2, 0)) 252 - ?.marks.find((f) => f.type === schema.marks.link); 253 - if (mark) { 254 - window.open(mark.attrs.href, "_blank"); 255 - } 256 - }, 257 - dispatchTransaction(tr) { 258 - useEditorStates.setState((s) => { 259 - let oldEditorState = this.state; 260 - let newState = this.state.apply(tr); 261 - let addToHistory = tr.getMeta("addToHistory"); 262 - let isBulkOp = tr.getMeta("bulkOp"); 263 - let docHasChanges = tr.steps.length !== 0 || tr.docChanged; 264 - if (addToHistory !== false && docHasChanges) { 265 - if (actionTimeout.current) { 266 - window.clearTimeout(actionTimeout.current); 267 - } else { 268 - if (!isBulkOp) rep.undoManager.startGroup(); 269 - } 270 - 271 - if (!isBulkOp) 272 - actionTimeout.current = window.setTimeout(() => { 273 - rep.undoManager.endGroup(); 274 - actionTimeout.current = null; 275 - }, 200); 276 - rep.undoManager.add({ 277 - redo: () => { 278 - useEditorStates.setState((oldState) => { 279 - let view = oldState.editorStates[props.entityID]?.view; 280 - if (!view?.hasFocus() && !isBulkOp) view?.focus(); 281 - return { 282 - editorStates: { 283 - ...oldState.editorStates, 284 - [props.entityID]: { 285 - ...oldState.editorStates[props.entityID]!, 286 - editor: newState, 287 - }, 288 - }, 289 - }; 290 - }); 291 - }, 292 - undo: () => { 293 - useEditorStates.setState((oldState) => { 294 - let view = oldState.editorStates[props.entityID]?.view; 295 - if (!view?.hasFocus() && !isBulkOp) view?.focus(); 296 - return { 297 - editorStates: { 298 - ...oldState.editorStates, 299 - [props.entityID]: { 300 - ...oldState.editorStates[props.entityID]!, 301 - editor: oldEditorState, 302 - }, 303 - }, 304 - }; 305 - }); 306 - }, 307 - }); 308 - } 309 - 310 - return { 311 - editorStates: { 312 - ...s.editorStates, 313 - [props.entityID]: { 314 - editor: newState, 315 - view: this as unknown as EditorView, 316 - initial: false, 317 - keymap: km, 318 - }, 319 - }, 320 - }; 321 - }); 322 - }, 323 - }, 324 - ); 325 - return () => { 326 - unsubscribe(); 327 - view.destroy(); 328 - useEditorStates.setState((s) => ({ 329 - ...s, 330 - editorStates: { 331 - ...s.editorStates, 332 - [props.entityID]: undefined, 333 - }, 334 - })); 335 - }; 336 - }, [props.entityID, props.parent, value, handlePaste, rep]); 187 + let { mountRef, actionTimeout } = useMountProsemirror({ 188 + props, 189 + }); 337 190 338 191 return ( 339 192 <> ··· 586 439 ); 587 440 }; 588 441 589 - function useYJSValue(entityID: string) { 590 - const [ydoc] = useState(new Y.Doc()); 591 - const docStateFromReplicache = useEntity(entityID, "block/text"); 592 - let rep = useReplicache(); 593 - const [yText] = useState(ydoc.getXmlFragment("prosemirror")); 594 - 595 - if (docStateFromReplicache) { 596 - const update = base64.toByteArray(docStateFromReplicache.data.value); 597 - Y.applyUpdate(ydoc, update); 598 - } 599 - 600 - useEffect(() => { 601 - if (!rep.rep) return; 602 - let timeout = null as null | number; 603 - const updateReplicache = async () => { 604 - const update = Y.encodeStateAsUpdate(ydoc); 605 - await rep.rep?.mutate.assertFact({ 606 - //These undos are handled above in the Prosemirror context 607 - ignoreUndo: true, 608 - entity: entityID, 609 - attribute: "block/text", 610 - data: { 611 - value: base64.fromByteArray(update), 612 - type: "text", 613 - }, 614 - }); 615 - }; 616 - const f = async (events: Y.YEvent<any>[], transaction: Y.Transaction) => { 617 - if (!transaction.origin) return; 618 - if (timeout) clearTimeout(timeout); 619 - timeout = window.setTimeout(async () => { 620 - updateReplicache(); 621 - }, 300); 622 - }; 623 - 624 - yText.observeDeep(f); 625 - return () => { 626 - yText.unobserveDeep(f); 627 - }; 628 - }, [yText, entityID, rep, ydoc]); 629 - return yText; 630 - } 442 + const useMentionState = () => { 443 + const [editorState, setEditorState] = useState<EditorState | null>(null); 444 + const [mentionState, setMentionState] = useState<{ 445 + active: boolean; 446 + range: { from: number; to: number } | null; 447 + selectedMention: { handle: string; did: string } | null; 448 + }>({ active: false, range: null, selectedMention: null }); 449 + const mentionStateRef = useRef(mentionState); 450 + mentionStateRef.current = mentionState; 451 + return { mentionStateRef }; 452 + };
+199
components/Blocks/TextBlock/mountProsemirror.ts
··· 1 + import { useLayoutEffect, useRef, useEffect, useState } from "react"; 2 + import { EditorState } from "prosemirror-state"; 3 + import { EditorView } from "prosemirror-view"; 4 + import { baseKeymap } from "prosemirror-commands"; 5 + import { keymap } from "prosemirror-keymap"; 6 + import { ySyncPlugin } from "y-prosemirror"; 7 + import * as Y from "yjs"; 8 + import * as base64 from "base64-js"; 9 + import { Replicache } from "replicache"; 10 + import { produce } from "immer"; 11 + 12 + import { schema } from "./schema"; 13 + import { TextBlockKeymap } from "./keymap"; 14 + import { inputrules } from "./inputRules"; 15 + import { highlightSelectionPlugin } from "./plugins"; 16 + import { autolink } from "./autolink-plugin"; 17 + import { useEditorStates } from "src/state/useEditorState"; 18 + import { 19 + useEntity, 20 + useReplicache, 21 + type ReplicacheMutators, 22 + } from "src/replicache"; 23 + import { useHandlePaste } from "./useHandlePaste"; 24 + import { BlockProps } from "../Block"; 25 + import { useEntitySetContext } from "components/EntitySetProvider"; 26 + 27 + export 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 + return () => { 95 + unsubscribe(); 96 + view.destroy(); 97 + useEditorStates.setState((s) => ({ 98 + ...s, 99 + editorStates: { 100 + ...s.editorStates, 101 + [entityID]: undefined, 102 + }, 103 + })); 104 + }; 105 + 106 + function dispatchTransaction(this: EditorView, tr: any) { 107 + useEditorStates.setState((s) => { 108 + let oldEditorState = this.state; 109 + let newState = this.state.apply(tr); 110 + let addToHistory = tr.getMeta("addToHistory"); 111 + let isBulkOp = tr.getMeta("bulkOp"); 112 + let docHasChanges = tr.steps.length !== 0 || tr.docChanged; 113 + 114 + // Handle undo/redo history with timeout-based grouping 115 + if (addToHistory !== false && docHasChanges) { 116 + if (actionTimeout.current) window.clearTimeout(actionTimeout.current); 117 + else if (!isBulkOp) rep.undoManager.startGroup(); 118 + 119 + if (!isBulkOp) { 120 + actionTimeout.current = window.setTimeout(() => { 121 + rep.undoManager.endGroup(); 122 + actionTimeout.current = null; 123 + }, 200); 124 + } 125 + 126 + let setState = (s: EditorState) => () => 127 + useEditorStates.setState( 128 + produce((draft) => { 129 + let view = draft.editorStates[entityID]?.view; 130 + if (!view?.hasFocus() && !isBulkOp) view?.focus(); 131 + draft.editorStates[entityID]!.editor = s; 132 + }), 133 + ); 134 + 135 + rep.undoManager.add({ 136 + redo: setState(newState), 137 + undo: setState(oldEditorState), 138 + }); 139 + } 140 + 141 + return { 142 + editorStates: { 143 + ...s.editorStates, 144 + [entityID]: { 145 + editor: newState, 146 + view: this as unknown as EditorView, 147 + initial: false, 148 + keymap: km, 149 + }, 150 + }, 151 + }; 152 + }); 153 + } 154 + }, [entityID, parent, value, handlePaste, rep]); 155 + return { mountRef, actionTimeout }; 156 + } 157 + 158 + function useYJSValue(entityID: string) { 159 + const [ydoc] = useState(new Y.Doc()); 160 + const docStateFromReplicache = useEntity(entityID, "block/text"); 161 + let rep = useReplicache(); 162 + const [yText] = useState(ydoc.getXmlFragment("prosemirror")); 163 + 164 + if (docStateFromReplicache) { 165 + const update = base64.toByteArray(docStateFromReplicache.data.value); 166 + Y.applyUpdate(ydoc, update); 167 + } 168 + 169 + useEffect(() => { 170 + if (!rep.rep) return; 171 + let timeout = null as null | number; 172 + const updateReplicache = async () => { 173 + const update = Y.encodeStateAsUpdate(ydoc); 174 + await rep.rep?.mutate.assertFact({ 175 + //These undos are handled above in the Prosemirror context 176 + ignoreUndo: true, 177 + entity: entityID, 178 + attribute: "block/text", 179 + data: { 180 + value: base64.fromByteArray(update), 181 + type: "text", 182 + }, 183 + }); 184 + }; 185 + const f = async (events: Y.YEvent<any>[], transaction: Y.Transaction) => { 186 + if (!transaction.origin) return; 187 + if (timeout) clearTimeout(timeout); 188 + timeout = window.setTimeout(async () => { 189 + updateReplicache(); 190 + }, 300); 191 + }; 192 + 193 + yText.observeDeep(f); 194 + return () => { 195 + yText.unobserveDeep(f); 196 + }; 197 + }, [yText, entityID, rep, ydoc]); 198 + return yText; 199 + }
+5 -5
package-lock.json
··· 44 44 "feed": "^5.1.0", 45 45 "fractional-indexing": "^3.2.0", 46 46 "hono": "^4.7.11", 47 + "immer": "^10.2.0", 47 48 "inngest": "^3.40.1", 48 49 "ioredis": "^5.6.1", 49 50 "katex": "^0.16.22", ··· 10877 10878 } 10878 10879 }, 10879 10880 "node_modules/immer": { 10880 - "version": "10.1.1", 10881 - "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", 10882 - "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", 10883 - "optional": true, 10884 - "peer": true, 10881 + "version": "10.2.0", 10882 + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", 10883 + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", 10884 + "license": "MIT", 10885 10885 "funding": { 10886 10886 "type": "opencollective", 10887 10887 "url": "https://opencollective.com/immer"
+1
package.json
··· 54 54 "feed": "^5.1.0", 55 55 "fractional-indexing": "^3.2.0", 56 56 "hono": "^4.7.11", 57 + "immer": "^10.2.0", 57 58 "inngest": "^3.40.1", 58 59 "ioredis": "^5.6.1", 59 60 "katex": "^0.16.22",