diff --git a/components/Blocks/TextBlock/index.tsx b/components/Blocks/TextBlock/index.tsx index a2b9d3c6..e143ac84 100644 --- a/components/Blocks/TextBlock/index.tsx +++ b/components/Blocks/TextBlock/index.tsx @@ -1,32 +1,17 @@ -import { useRef, useEffect, useState, useLayoutEffect } from "react"; +import { useRef, useEffect, useState } from "react"; import { elementId } from "src/utils/elementId"; -import { baseKeymap } from "prosemirror-commands"; -import { keymap } from "prosemirror-keymap"; -import * as Y from "yjs"; -import * as base64 from "base64-js"; -import { useReplicache, useEntity, ReplicacheMutators } from "src/replicache"; +import { useReplicache, useEntity } from "src/replicache"; import { isVisible } from "src/utils/isVisible"; - import { EditorState, TextSelection } from "prosemirror-state"; -import { EditorView } from "prosemirror-view"; - -import { ySyncPlugin } from "y-prosemirror"; -import { Replicache } from "replicache"; import { RenderYJSFragment } from "./RenderYJSFragment"; import { useInitialPageLoad } from "components/InitialPageLoadProvider"; import { BlockProps } from "../Block"; import { focusBlock } from "src/utils/focusBlock"; -import { TextBlockKeymap } from "./keymap"; -import { multiBlockSchema, schema } from "./schema"; import { useUIState } from "src/useUIState"; import { addBlueskyPostBlock, addLinkBlock } from "src/utils/addLinkBlock"; import { BlockCommandBar } from "components/Blocks/BlockCommandBar"; import { useEditorStates } from "src/state/useEditorState"; import { useEntitySetContext } from "components/EntitySetProvider"; -import { useHandlePaste } from "./useHandlePaste"; -import { highlightSelectionPlugin } from "./plugins"; -import { inputrules } from "./inputRules"; -import { autolink } from "./autolink-plugin"; import { TooltipButton } from "components/Buttons"; import { blockCommands } from "../BlockCommands"; import { betterIsUrl } from "src/utils/isURL"; @@ -37,6 +22,7 @@ import { BlockImageSmall } from "components/Icons/BlockImageSmall"; import { isIOS } from "src/utils/isDevice"; import { useLeafletPublicationData } from "components/PageSWRDataProvider"; import { DotLoader } from "components/utils/DotLoader"; +import { useMountProsemirror } from "./mountProsemirror"; const HeadingStyle = { 1: "text-xl font-bold", @@ -177,21 +163,11 @@ export function RenderedTextBlock(props: { } export function BaseTextBlock(props: BlockProps & { className?: string }) { - let mountRef = useRef(null); - let actionTimeout = useRef(null); - let repRef = useRef>(null); let headingLevel = useEntity(props.entityID, "block/heading-level"); - let entity_set = useEntitySetContext(); let alignment = useEntity(props.entityID, "block/text-alignment")?.data.value || "left"; - let propsRef = useRef({ ...props, entity_set, alignment }); - useEffect(() => { - propsRef.current = { ...props, entity_set, alignment }; - }, [props, entity_set, alignment]); + let rep = useReplicache(); - useEffect(() => { - repRef.current = rep.rep; - }, [rep?.rep]); let selected = useUIState( (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID), @@ -204,136 +180,13 @@ export function BaseTextBlock(props: BlockProps & { className?: string }) { justify: "text-justify", }[alignment]; - let value = useYJSValue(props.entityID); - let editorState = useEditorStates( (s) => s.editorStates[props.entityID], )?.editor; - let handlePaste = useHandlePaste(props.entityID, propsRef); - useLayoutEffect(() => { - if (!mountRef.current) return; - let km = TextBlockKeymap(propsRef, repRef, rep.undoManager); - let editor = EditorState.create({ - schema: schema, - plugins: [ - ySyncPlugin(value), - keymap(km), - inputrules(propsRef, repRef), - keymap(baseKeymap), - highlightSelectionPlugin, - autolink({ - type: schema.marks.link, - shouldAutoLink: () => true, - defaultProtocol: "https", - }), - ], - }); - - let unsubscribe = useEditorStates.subscribe((s) => { - let editorState = s.editorStates[props.entityID]; - if (editorState?.initial) return; - if (editorState?.editor) - editorState.view?.updateState(editorState.editor); - }); - let view = new EditorView( - { mount: mountRef.current }, - { - state: editor, - handlePaste, - handleClickOn: (view, _pos, node, _nodePos, _event, direct) => { - if (!direct) return; - if (node.nodeSize - 2 <= _pos) return; - let mark = - node - .nodeAt(_pos - 1) - ?.marks.find((f) => f.type === schema.marks.link) || - node - .nodeAt(Math.max(_pos - 2, 0)) - ?.marks.find((f) => f.type === schema.marks.link); - if (mark) { - window.open(mark.attrs.href, "_blank"); - } - }, - dispatchTransaction(tr) { - 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; - 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); - rep.undoManager.add({ - redo: () => { - useEditorStates.setState((oldState) => { - let view = oldState.editorStates[props.entityID]?.view; - if (!view?.hasFocus() && !isBulkOp) view?.focus(); - return { - editorStates: { - ...oldState.editorStates, - [props.entityID]: { - ...oldState.editorStates[props.entityID]!, - editor: newState, - }, - }, - }; - }); - }, - undo: () => { - useEditorStates.setState((oldState) => { - let view = oldState.editorStates[props.entityID]?.view; - if (!view?.hasFocus() && !isBulkOp) view?.focus(); - return { - editorStates: { - ...oldState.editorStates, - [props.entityID]: { - ...oldState.editorStates[props.entityID]!, - editor: oldEditorState, - }, - }, - }; - }); - }, - }); - } - return { - editorStates: { - ...s.editorStates, - [props.entityID]: { - editor: newState, - view: this as unknown as EditorView, - initial: false, - keymap: km, - }, - }, - }; - }); - }, - }, - ); - return () => { - unsubscribe(); - view.destroy(); - useEditorStates.setState((s) => ({ - ...s, - editorStates: { - ...s.editorStates, - [props.entityID]: undefined, - }, - })); - }; - }, [props.entityID, props.parent, value, handlePaste, rep]); + let { mountRef, actionTimeout } = useMountProsemirror({ + props, + }); return ( <> @@ -586,45 +439,14 @@ const CommandOptions = (props: BlockProps & { className?: string }) => { ); }; -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[], 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; -} +const useMentionState = () => { + const [editorState, setEditorState] = useState(null); + const [mentionState, setMentionState] = useState<{ + active: boolean; + range: { from: number; to: number } | null; + selectedMention: { handle: string; did: string } | null; + }>({ active: false, range: null, selectedMention: null }); + const mentionStateRef = useRef(mentionState); + mentionStateRef.current = mentionState; + return { mentionStateRef }; +}; diff --git a/components/Blocks/TextBlock/mountProsemirror.ts b/components/Blocks/TextBlock/mountProsemirror.ts new file mode 100644 index 00000000..e77b36bd --- /dev/null +++ b/components/Blocks/TextBlock/mountProsemirror.ts @@ -0,0 +1,199 @@ +import { 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"; + +export function useMountProsemirror({ props }: { props: BlockProps }) { + let { entityID, parent } = props; + let rep = useReplicache(); + let mountRef = useRef(null); + const repRef = useRef | 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(null); + + propsRef.current = { ...props, entity_set, alignment }; + repRef.current = rep.rep; + + useLayoutEffect(() => { + if (!mountRef.current) return; + + const km = TextBlockKeymap(propsRef, repRef, rep.undoManager); + const editor = EditorState.create({ + schema: schema, + plugins: [ + ySyncPlugin(value), + keymap(km), + inputrules(propsRef, repRef), + 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; + if (node.nodeSize - 2 <= _pos) return; + let mark = + node + .nodeAt(_pos - 1) + ?.marks.find((f) => f.type === schema.marks.link) || + node + .nodeAt(Math.max(_pos - 2, 0)) + ?.marks.find((f) => f.type === schema.marks.link); + if (mark) { + window.open(mark.attrs.href, "_blank"); + } + }, + dispatchTransaction, + }, + ); + + const unsubscribe = useEditorStates.subscribe((s) => { + let editorState = s.editorStates[entityID]; + if (editorState?.initial) return; + if (editorState?.editor) + 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[], 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; +} diff --git a/package-lock.json b/package-lock.json index 0d806d73..48d6531c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "feed": "^5.1.0", "fractional-indexing": "^3.2.0", "hono": "^4.7.11", + "immer": "^10.2.0", "inngest": "^3.40.1", "ioredis": "^5.6.1", "katex": "^0.16.22", @@ -10877,11 +10878,10 @@ } }, "node_modules/immer": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", - "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", - "optional": true, - "peer": true, + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" diff --git a/package.json b/package.json index 19bffcf9..74fbb5ee 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "feed": "^5.1.0", "fractional-indexing": "^3.2.0", "hono": "^4.7.11", + "immer": "^10.2.0", "inngest": "^3.40.1", "ioredis": "^5.6.1", "katex": "^0.16.22",