Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
7.4 kB · 235 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236import { useLayoutEffect, useRef, useEffect, useState } from "react";import { EditorState } from "prosemirror-state";import { EditorView } from "prosemirror-view";import { baseKeymap } from "prosemirror-commands";import { keymap } from "prosemirror-keymap";import { ySyncPlugin } from "y-prosemirror";import * as Y from "yjs";import * as base64 from "base64-js";import { Replicache } from "replicache";import { produce } from "immer";
import { schema } from "./schema";import { TextBlockKeymap } from "./keymap";import { inputrules } from "./inputRules";import { highlightSelectionPlugin } from "./plugins";import { autolink } from "./autolink-plugin";import { useEditorStates } from "src/state/useEditorState";import { useEntity, useReplicache, type ReplicacheMutators,} from "src/replicache";import { useHandlePaste } from "./useHandlePaste";import { BlockProps } from "../Block";import { useEntitySetContext } from "components/EntitySetProvider";import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils";
export function useMountProsemirror({ props, openMentionAutocomplete,}: { props: BlockProps; openMentionAutocomplete: () => void;}) { let { entityID, parent } = props; let rep = useReplicache(); let mountRef = useRef<HTMLPreElement | null>(null); const repRef = useRef<Replicache<ReplicacheMutators> | null>(null); let value = useYJSValue(entityID); let entity_set = useEntitySetContext(); let alignment = useEntity(entityID, "block/text-alignment")?.data.value || "left"; let propsRef = useRef({ ...props, entity_set, alignment }); let handlePaste = useHandlePaste(entityID, propsRef);
const actionTimeout = useRef<number | null>(null);
propsRef.current = { ...props, entity_set, alignment }; repRef.current = rep.rep;
useLayoutEffect(() => { if (!mountRef.current) return;
const km = TextBlockKeymap( propsRef, repRef, rep.undoManager, openMentionAutocomplete, ); const editor = EditorState.create({ schema: schema, plugins: [ ySyncPlugin(value), keymap(km), inputrules(propsRef, repRef, openMentionAutocomplete), keymap(baseKeymap), highlightSelectionPlugin, autolink({ type: schema.marks.link, shouldAutoLink: () => true, defaultProtocol: "https", }), ], });
const view = new EditorView( { mount: mountRef.current }, { state: editor, handlePaste, handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { if (!direct) return;
// Check for didMention inline nodes if (node?.type === schema.nodes.didMention) { window.open( didToBlueskyUrl(node.attrs.did), "_blank", "noopener,noreferrer", ); return; }
// Check for atMention inline nodes if (node?.type === schema.nodes.atMention) { const url = atUriToUrl(node.attrs.atURI); window.open(url, "_blank", "noopener,noreferrer"); return; } if (node.nodeSize - 2 <= _pos) return;
// Check for marks at the clicked position const nodeAt1 = node.nodeAt(_pos - 1); const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0));
// Check for link marks let linkMark = nodeAt1?.marks.find((f) => f.type === schema.marks.link) || nodeAt2?.marks.find((f) => f.type === schema.marks.link); if (linkMark) { window.open(linkMark.attrs.href, "_blank"); return; } }, dispatchTransaction, }, );
const unsubscribe = useEditorStates.subscribe((s) => { let editorState = s.editorStates[entityID]; if (editorState?.initial) return; if (editorState?.editor) editorState.view?.updateState(editorState.editor); });
let editorState = useEditorStates.getState().editorStates[entityID]; if (editorState?.editor && !editorState.initial) editorState.view?.updateState(editorState.editor);
return () => { unsubscribe(); view.destroy(); useEditorStates.setState((s) => ({ ...s, editorStates: { ...s.editorStates, [entityID]: undefined, }, })); };
function dispatchTransaction(this: EditorView, tr: any) { useEditorStates.setState((s) => { let oldEditorState = this.state; let newState = this.state.apply(tr); let addToHistory = tr.getMeta("addToHistory"); let isBulkOp = tr.getMeta("bulkOp"); let docHasChanges = tr.steps.length !== 0 || tr.docChanged;
// Handle undo/redo history with timeout-based grouping if (addToHistory !== false && docHasChanges) { if (actionTimeout.current) window.clearTimeout(actionTimeout.current); else if (!isBulkOp) rep.undoManager.startGroup();
if (!isBulkOp) { actionTimeout.current = window.setTimeout(() => { rep.undoManager.endGroup(); actionTimeout.current = null; }, 200); }
let setState = (s: EditorState) => () => useEditorStates.setState( produce((draft) => { let view = draft.editorStates[entityID]?.view; if (!view?.hasFocus() && !isBulkOp) view?.focus(); draft.editorStates[entityID]!.editor = s; }), );
rep.undoManager.add({ redo: setState(newState), undo: setState(oldEditorState), }); }
return { editorStates: { ...s.editorStates, [entityID]: { editor: newState, view: this as unknown as EditorView, initial: false, keymap: km, }, }, }; }); } }, [entityID, parent, value, handlePaste, rep]); return { mountRef, actionTimeout };}
function useYJSValue(entityID: string) { const [ydoc] = useState(new Y.Doc()); const docStateFromReplicache = useEntity(entityID, "block/text"); let rep = useReplicache(); const [yText] = useState(ydoc.getXmlFragment("prosemirror"));
if (docStateFromReplicache) { const update = base64.toByteArray(docStateFromReplicache.data.value); Y.applyUpdate(ydoc, update); }
useEffect(() => { if (!rep.rep) return; let timeout = null as null | number; const updateReplicache = async () => { const update = Y.encodeStateAsUpdate(ydoc); await rep.rep?.mutate.assertFact({ //These undos are handled above in the Prosemirror context ignoreUndo: true, entity: entityID, attribute: "block/text", data: { value: base64.fromByteArray(update), type: "text", }, }); }; const f = async (events: Y.YEvent<any>[], transaction: Y.Transaction) => { if (!transaction.origin) return; if (timeout) clearTimeout(timeout); timeout = window.setTimeout(async () => { updateReplicache(); }, 300); };
yText.observeDeep(f); return () => { yText.unobserveDeep(f); }; }, [yText, entityID, rep, ydoc]); return yText;}