a tool for shared writing and social publishing

share editor logic between footnote and textblock

+69 -76
+45 -28
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"; ··· 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"; ··· 184 useEditorStates.setState((s) => { 185 let oldEditorState = this.state; 186 let newState = this.state.apply(tr); 187 - let addToHistory = tr.getMeta("addToHistory"); 188 - let isBulkOp = tr.getMeta("bulkOp"); 189 let docHasChanges = tr.steps.length !== 0 || tr.docChanged; 190 191 // Diff for removed/added footnote nodes ··· 212 } 213 214 // Handle undo/redo history with timeout-based grouping 215 - if (addToHistory !== false && docHasChanges) { 216 - if (actionTimeout.current) window.clearTimeout(actionTimeout.current); 217 - else if (!isBulkOp) rep.undoManager.startGroup(); 218 219 - if (!isBulkOp) { 220 - actionTimeout.current = window.setTimeout(() => { 221 - rep.undoManager.endGroup(); 222 - actionTimeout.current = null; 223 - }, 200); 224 - } 225 - 226 - let setState = (s: EditorState) => () => 227 - useEditorStates.setState( 228 - produce((draft) => { 229 - let view = draft.editorStates[entityID]?.view; 230 - if (!view?.hasFocus() && !isBulkOp) view?.focus(); 231 - draft.editorStates[entityID]!.editor = s; 232 - }), 233 - ); 234 - 235 - rep.undoManager.add({ 236 - redo: setState(newState), 237 - undo: setState(oldEditorState), 238 - }); 239 - } 240 241 return { 242 editorStates: { ··· 255 return { mountRef, actionTimeout }; 256 } 257 258 - function useYJSValue(entityID: string) { 259 const [ydoc] = useState(new Y.Doc()); 260 const docStateFromReplicache = useEntity(entityID, "block/text"); 261 let rep = useReplicache();
··· 1 import { useLayoutEffect, useRef, useEffect, useState } from "react"; 2 + import { EditorState, Transaction } from "prosemirror-state"; 3 import { EditorView } from "prosemirror-view"; 4 import { baseKeymap } from "prosemirror-commands"; 5 import { keymap } from "prosemirror-keymap"; ··· 10 import { produce } from "immer"; 11 12 import { schema } from "./schema"; 13 + import { UndoManager } from "src/undoManager"; 14 import { TextBlockKeymap } from "./keymap"; 15 import { inputrules } from "./inputRules"; 16 import { highlightSelectionPlugin } from "./plugins"; ··· 185 useEditorStates.setState((s) => { 186 let oldEditorState = this.state; 187 let newState = this.state.apply(tr); 188 let docHasChanges = tr.steps.length !== 0 || tr.docChanged; 189 190 // Diff for removed/added footnote nodes ··· 211 } 212 213 // Handle undo/redo history with timeout-based grouping 214 + let isBulkOp = tr.getMeta("bulkOp"); 215 + let setState = (s: EditorState) => () => 216 + useEditorStates.setState( 217 + produce((draft) => { 218 + let view = draft.editorStates[entityID]?.view; 219 + if (!view?.hasFocus() && !isBulkOp) view?.focus(); 220 + draft.editorStates[entityID]!.editor = s; 221 + }), 222 + ); 223 224 + trackUndoRedo( 225 + tr, 226 + rep.undoManager, 227 + actionTimeout, 228 + setState(oldEditorState), 229 + setState(newState), 230 + ); 231 232 return { 233 editorStates: { ··· 246 return { mountRef, actionTimeout }; 247 } 248 249 + export function trackUndoRedo( 250 + tr: Transaction, 251 + undoManager: UndoManager, 252 + actionTimeout: { current: number | null }, 253 + undo: () => void, 254 + redo: () => void, 255 + ) { 256 + let addToHistory = tr.getMeta("addToHistory"); 257 + let isBulkOp = tr.getMeta("bulkOp"); 258 + let docHasChanges = tr.steps.length !== 0 || tr.docChanged; 259 + 260 + if (addToHistory !== false && docHasChanges) { 261 + if (actionTimeout.current) window.clearTimeout(actionTimeout.current); 262 + else if (!isBulkOp) undoManager.startGroup(); 263 + 264 + if (!isBulkOp) { 265 + actionTimeout.current = window.setTimeout(() => { 266 + undoManager.endGroup(); 267 + actionTimeout.current = null; 268 + }, 200); 269 + } 270 + 271 + undoManager.add({ undo, redo }); 272 + } 273 + } 274 + 275 + export function useYJSValue(entityID: string) { 276 const [ydoc] = useState(new Y.Doc()); 277 const docStateFromReplicache = useEntity(entityID, "block/text"); 278 let rep = useReplicache();
+24 -48
components/Footnotes/FootnoteEditor.tsx
··· 1 - import { useLayoutEffect, useRef, useState, useEffect } from "react"; 2 import { EditorState } from "prosemirror-state"; 3 import { EditorView } from "prosemirror-view"; 4 import { baseKeymap, toggleMark } 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 { schema } from "components/Blocks/TextBlock/schema"; 10 - import { useEntity, useReplicache } from "src/replicache"; 11 import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 12 import { CloseTiny } from "components/Icons/CloseTiny"; 13 14 export function FootnoteEditor(props: { ··· 20 }) { 21 let mountRef = useRef<HTMLDivElement | null>(null); 22 let rep = useReplicache(); 23 - let value = useFootnoteYJS(props.footnoteEntityID); 24 25 useLayoutEffect(() => { 26 if (!mountRef.current || !value) return; ··· 63 state, 64 editable: () => props.editable, 65 dispatchTransaction(this: EditorView, tr) { 66 let newState = this.state.apply(tr); 67 this.updateState(newState); 68 }, 69 }, 70 ); ··· 76 return () => { 77 view.destroy(); 78 }; 79 - }, [props.footnoteEntityID, value, props.editable, props.autoFocus]); 80 81 return ( 82 <div className="footnote-editor flex items-start gap-2 text-xs group/footnote" data-footnote-editor={props.footnoteEntityID}> ··· 112 ); 113 } 114 115 - function useFootnoteYJS(footnoteEntityID: string) { 116 - const [ydoc] = useState(new Y.Doc()); 117 - const docState = useEntity(footnoteEntityID, "block/text"); 118 - let rep = useReplicache(); 119 - const [yText] = useState(ydoc.getXmlFragment("prosemirror")); 120 - 121 - if (docState) { 122 - const update = base64.toByteArray(docState.data.value); 123 - Y.applyUpdate(ydoc, update); 124 - } 125 - 126 - useEffect(() => { 127 - if (!rep.rep) return; 128 - let timeout = null as null | number; 129 - const updateReplicache = async () => { 130 - const update = Y.encodeStateAsUpdate(ydoc); 131 - await rep.rep?.mutate.assertFact({ 132 - ignoreUndo: true, 133 - entity: footnoteEntityID, 134 - attribute: "block/text", 135 - data: { 136 - value: base64.fromByteArray(update), 137 - type: "text", 138 - }, 139 - }); 140 - }; 141 - const f = async (_events: Y.YEvent<any>[], transaction: Y.Transaction) => { 142 - if (!transaction.origin) return; 143 - if (timeout) clearTimeout(timeout); 144 - timeout = window.setTimeout(async () => { 145 - updateReplicache(); 146 - }, 300); 147 - }; 148 - 149 - yText.observeDeep(f); 150 - return () => { 151 - yText.unobserveDeep(f); 152 - }; 153 - }, [yText, footnoteEntityID, rep, ydoc]); 154 - 155 - return yText; 156 - }
··· 1 + import { useLayoutEffect, useRef } from "react"; 2 import { EditorState } from "prosemirror-state"; 3 import { EditorView } from "prosemirror-view"; 4 import { baseKeymap, toggleMark } from "prosemirror-commands"; 5 import { keymap } from "prosemirror-keymap"; 6 import { ySyncPlugin } from "y-prosemirror"; 7 import { schema } from "components/Blocks/TextBlock/schema"; 8 + import { useReplicache } from "src/replicache"; 9 import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 10 + import { 11 + useYJSValue, 12 + trackUndoRedo, 13 + } from "components/Blocks/TextBlock/mountProsemirror"; 14 import { CloseTiny } from "components/Icons/CloseTiny"; 15 16 export function FootnoteEditor(props: { ··· 22 }) { 23 let mountRef = useRef<HTMLDivElement | null>(null); 24 let rep = useReplicache(); 25 + let value = useYJSValue(props.footnoteEntityID); 26 + let actionTimeout = useRef<number | null>(null); 27 28 useLayoutEffect(() => { 29 if (!mountRef.current || !value) return; ··· 66 state, 67 editable: () => props.editable, 68 dispatchTransaction(this: EditorView, tr) { 69 + let oldState = this.state; 70 let newState = this.state.apply(tr); 71 this.updateState(newState); 72 + 73 + trackUndoRedo( 74 + tr, 75 + rep.undoManager, 76 + actionTimeout, 77 + () => { 78 + this.focus(); 79 + this.updateState(oldState); 80 + }, 81 + () => { 82 + this.focus(); 83 + this.updateState(newState); 84 + }, 85 + ); 86 }, 87 }, 88 ); ··· 94 return () => { 95 view.destroy(); 96 }; 97 + }, [props.footnoteEntityID, value, props.editable, props.autoFocus, rep.undoManager]); 98 99 return ( 100 <div className="footnote-editor flex items-start gap-2 text-xs group/footnote" data-footnote-editor={props.footnoteEntityID}> ··· 130 ); 131 } 132