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 1 import { useLayoutEffect, useRef, useEffect, useState } from "react"; 2 - import { EditorState } from "prosemirror-state"; 2 + import { EditorState, Transaction } from "prosemirror-state"; 3 3 import { EditorView } from "prosemirror-view"; 4 4 import { baseKeymap } from "prosemirror-commands"; 5 5 import { keymap } from "prosemirror-keymap"; ··· 10 10 import { produce } from "immer"; 11 11 12 12 import { schema } from "./schema"; 13 + import { UndoManager } from "src/undoManager"; 13 14 import { TextBlockKeymap } from "./keymap"; 14 15 import { inputrules } from "./inputRules"; 15 16 import { highlightSelectionPlugin } from "./plugins"; ··· 184 185 useEditorStates.setState((s) => { 185 186 let oldEditorState = this.state; 186 187 let newState = this.state.apply(tr); 187 - let addToHistory = tr.getMeta("addToHistory"); 188 - let isBulkOp = tr.getMeta("bulkOp"); 189 188 let docHasChanges = tr.steps.length !== 0 || tr.docChanged; 190 189 191 190 // Diff for removed/added footnote nodes ··· 212 211 } 213 212 214 213 // 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(); 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 + ); 218 223 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 - } 224 + trackUndoRedo( 225 + tr, 226 + rep.undoManager, 227 + actionTimeout, 228 + setState(oldEditorState), 229 + setState(newState), 230 + ); 240 231 241 232 return { 242 233 editorStates: { ··· 255 246 return { mountRef, actionTimeout }; 256 247 } 257 248 258 - function useYJSValue(entityID: string) { 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) { 259 276 const [ydoc] = useState(new Y.Doc()); 260 277 const docStateFromReplicache = useEntity(entityID, "block/text"); 261 278 let rep = useReplicache();
+24 -48
components/Footnotes/FootnoteEditor.tsx
··· 1 - import { useLayoutEffect, useRef, useState, useEffect } from "react"; 1 + import { useLayoutEffect, useRef } from "react"; 2 2 import { EditorState } from "prosemirror-state"; 3 3 import { EditorView } from "prosemirror-view"; 4 4 import { baseKeymap, toggleMark } from "prosemirror-commands"; 5 5 import { keymap } from "prosemirror-keymap"; 6 6 import { ySyncPlugin } from "y-prosemirror"; 7 - import * as Y from "yjs"; 8 - import * as base64 from "base64-js"; 9 7 import { schema } from "components/Blocks/TextBlock/schema"; 10 - import { useEntity, useReplicache } from "src/replicache"; 8 + import { useReplicache } from "src/replicache"; 11 9 import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 10 + import { 11 + useYJSValue, 12 + trackUndoRedo, 13 + } from "components/Blocks/TextBlock/mountProsemirror"; 12 14 import { CloseTiny } from "components/Icons/CloseTiny"; 13 15 14 16 export function FootnoteEditor(props: { ··· 20 22 }) { 21 23 let mountRef = useRef<HTMLDivElement | null>(null); 22 24 let rep = useReplicache(); 23 - let value = useFootnoteYJS(props.footnoteEntityID); 25 + let value = useYJSValue(props.footnoteEntityID); 26 + let actionTimeout = useRef<number | null>(null); 24 27 25 28 useLayoutEffect(() => { 26 29 if (!mountRef.current || !value) return; ··· 63 66 state, 64 67 editable: () => props.editable, 65 68 dispatchTransaction(this: EditorView, tr) { 69 + let oldState = this.state; 66 70 let newState = this.state.apply(tr); 67 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 + ); 68 86 }, 69 87 }, 70 88 ); ··· 76 94 return () => { 77 95 view.destroy(); 78 96 }; 79 - }, [props.footnoteEntityID, value, props.editable, props.autoFocus]); 97 + }, [props.footnoteEntityID, value, props.editable, props.autoFocus, rep.undoManager]); 80 98 81 99 return ( 82 100 <div className="footnote-editor flex items-start gap-2 text-xs group/footnote" data-footnote-editor={props.footnoteEntityID}> ··· 112 130 ); 113 131 } 114 132 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 - }