@datalayer/lexical-loro 0.0.7 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -137
- package/lib/App.d.ts +2 -0
- package/lib/App.js +141 -0
- package/lib/Editor.d.ts +2 -0
- package/lib/Editor.js +111 -0
- package/lib/Settings.d.ts +2 -0
- package/lib/Settings.js +57 -0
- package/lib/appSettings.d.ts +36 -0
- package/lib/appSettings.js +44 -0
- package/lib/collab/loro/Bindings.d.ts +41 -0
- package/lib/collab/loro/Bindings.js +95 -0
- package/lib/collab/loro/Debug.d.ts +33 -0
- package/lib/collab/loro/Debug.js +448 -0
- package/lib/collab/loro/LexicalCollaborationContext.d.ts +19 -0
- package/lib/collab/loro/LexicalCollaborationContext.js +48 -0
- package/lib/collab/loro/LexicalCollaborationPlugin.d.ts +24 -0
- package/lib/collab/loro/LexicalCollaborationPlugin.js +83 -0
- package/lib/collab/loro/State.d.ts +53 -0
- package/lib/collab/loro/State.js +90 -0
- package/lib/collab/loro/components/LoroCollaborationUI.d.ts +13 -0
- package/lib/collab/loro/components/LoroCollaborationUI.js +9 -0
- package/lib/collab/loro/components/LoroCollaborators.d.ts +8 -0
- package/lib/collab/loro/components/LoroCollaborators.js +97 -0
- package/lib/collab/loro/components/index.d.ts +2 -0
- package/lib/collab/loro/components/index.js +2 -0
- package/lib/collab/loro/index.d.ts +6 -0
- package/lib/collab/loro/index.js +6 -0
- package/lib/collab/loro/integrators/BaseIntegrator.d.ts +14 -0
- package/lib/collab/loro/integrators/BaseIntegrator.js +1 -0
- package/lib/collab/loro/integrators/CounterIntegrator.d.ts +23 -0
- package/lib/collab/loro/integrators/CounterIntegrator.js +40 -0
- package/lib/collab/loro/integrators/ListIntegrator.d.ts +23 -0
- package/lib/collab/loro/integrators/ListIntegrator.js +49 -0
- package/lib/collab/loro/integrators/MapIntegrator.d.ts +24 -0
- package/lib/collab/loro/integrators/MapIntegrator.js +177 -0
- package/lib/collab/loro/integrators/TextIntegrator.d.ts +25 -0
- package/lib/collab/loro/integrators/TextIntegrator.js +51 -0
- package/lib/collab/loro/integrators/TreeIntegrator.d.ts +25 -0
- package/lib/collab/loro/integrators/TreeIntegrator.js +201 -0
- package/lib/collab/loro/nodes/NodeFactory.d.ts +8 -0
- package/lib/collab/loro/nodes/NodeFactory.js +105 -0
- package/lib/collab/loro/nodes/NodesMapper.d.ts +111 -0
- package/lib/collab/loro/nodes/NodesMapper.js +258 -0
- package/lib/collab/loro/propagators/DecoratorNodePropagator.d.ts +60 -0
- package/lib/collab/loro/propagators/DecoratorNodePropagator.js +302 -0
- package/lib/collab/loro/propagators/ElementNodePropagator.d.ts +62 -0
- package/lib/collab/loro/propagators/ElementNodePropagator.js +335 -0
- package/lib/collab/loro/propagators/LineBreakNodePropagator.d.ts +57 -0
- package/lib/collab/loro/propagators/LineBreakNodePropagator.js +196 -0
- package/lib/collab/loro/propagators/RootNodePropagator.d.ts +55 -0
- package/lib/collab/loro/propagators/RootNodePropagator.js +168 -0
- package/lib/collab/loro/propagators/TextNodePropagator.d.ts +60 -0
- package/lib/collab/loro/propagators/TextNodePropagator.js +434 -0
- package/lib/collab/loro/propagators/index.d.ts +49 -0
- package/lib/collab/loro/propagators/index.js +32 -0
- package/lib/collab/loro/provider/websocket.d.ts +116 -0
- package/lib/collab/loro/provider/websocket.js +907 -0
- package/lib/collab/loro/servers/index.d.ts +0 -0
- package/lib/collab/loro/servers/index.js +0 -0
- package/lib/collab/loro/servers/ws/callback.d.ts +5 -0
- package/lib/collab/loro/servers/ws/callback.js +85 -0
- package/lib/collab/loro/servers/ws/server.d.ts +2 -0
- package/lib/collab/loro/servers/ws/server.js +25 -0
- package/lib/collab/loro/servers/ws/utils.d.ts +40 -0
- package/lib/collab/loro/servers/ws/utils.js +513 -0
- package/lib/collab/loro/sync/SyncCursors.d.ts +32 -0
- package/lib/collab/loro/sync/SyncCursors.js +435 -0
- package/lib/collab/loro/sync/SyncLexicalToLoro.d.ts +4 -0
- package/lib/collab/loro/sync/SyncLexicalToLoro.js +80 -0
- package/lib/collab/loro/sync/SyncLoroToLexical.d.ts +5 -0
- package/lib/collab/loro/sync/SyncLoroToLexical.js +96 -0
- package/lib/collab/loro/types/LexicalNodeData.d.ts +32 -0
- package/lib/collab/loro/types/LexicalNodeData.js +71 -0
- package/lib/collab/loro/useCollaboration.d.ts +12 -0
- package/lib/collab/loro/useCollaboration.js +248 -0
- package/lib/collab/loro/utils/InitialContent.d.ts +64 -0
- package/lib/collab/loro/utils/InitialContent.js +109 -0
- package/lib/collab/loro/utils/LexicalToLoro.d.ts +18 -0
- package/lib/collab/loro/utils/LexicalToLoro.js +96 -0
- package/lib/collab/loro/utils/Utils.d.ts +44 -0
- package/lib/collab/loro/utils/Utils.js +153 -0
- package/lib/collab/loro/wsProvider.d.ts +8 -0
- package/lib/collab/loro/wsProvider.js +31 -0
- package/lib/collab/utils/invariant.d.ts +1 -0
- package/lib/collab/utils/invariant.js +11 -0
- package/lib/collab/utils/simpleDiffWithCursor.d.ts +5 -0
- package/lib/collab/utils/simpleDiffWithCursor.js +31 -0
- package/lib/collab/yjs/Bindings.d.ts +23 -0
- package/lib/collab/yjs/Bindings.js +26 -0
- package/lib/collab/yjs/Debug.d.ts +23 -0
- package/lib/collab/yjs/Debug.js +213 -0
- package/lib/collab/yjs/LexicalCollaborationContext.d.ts +10 -0
- package/lib/collab/yjs/LexicalCollaborationContext.js +37 -0
- package/lib/collab/yjs/LexicalCollaborationPlugin.d.ts +21 -0
- package/lib/collab/yjs/LexicalCollaborationPlugin.js +63 -0
- package/lib/collab/yjs/State.d.ts +51 -0
- package/lib/collab/yjs/State.js +35 -0
- package/lib/collab/yjs/nodes/AnyCollabNode.d.ts +5 -0
- package/lib/collab/yjs/nodes/AnyCollabNode.js +1 -0
- package/lib/collab/yjs/nodes/CollabDecoratorNode.d.ts +22 -0
- package/lib/collab/yjs/nodes/CollabDecoratorNode.js +64 -0
- package/lib/collab/yjs/nodes/CollabElementNode.d.ts +40 -0
- package/lib/collab/yjs/nodes/CollabElementNode.js +462 -0
- package/lib/collab/yjs/nodes/CollabLineBreakNode.d.ts +19 -0
- package/lib/collab/yjs/nodes/CollabLineBreakNode.js +44 -0
- package/lib/collab/yjs/nodes/CollabTextNode.d.ts +25 -0
- package/lib/collab/yjs/nodes/CollabTextNode.js +103 -0
- package/lib/collab/yjs/provider/websocket.d.ts +88 -0
- package/lib/collab/yjs/provider/websocket.js +415 -0
- package/lib/collab/yjs/servers/index.d.ts +0 -0
- package/lib/collab/yjs/servers/index.js +0 -0
- package/lib/collab/yjs/servers/ws/callback.d.ts +5 -0
- package/lib/collab/yjs/servers/ws/callback.js +72 -0
- package/lib/collab/yjs/servers/ws/server.d.ts +2 -0
- package/lib/collab/yjs/servers/ws/server.js +25 -0
- package/lib/collab/yjs/servers/ws/utils.d.ts +49 -0
- package/lib/collab/yjs/servers/ws/utils.js +284 -0
- package/lib/collab/yjs/sync/SyncCursors.d.ts +39 -0
- package/lib/collab/yjs/sync/SyncCursors.js +351 -0
- package/lib/collab/yjs/sync/SyncEditorStates.d.ts +10 -0
- package/lib/collab/yjs/sync/SyncEditorStates.js +200 -0
- package/lib/collab/yjs/useCollaboration.d.ts +12 -0
- package/lib/collab/yjs/useCollaboration.js +255 -0
- package/lib/collab/yjs/utils/Utils.d.ts +25 -0
- package/lib/collab/yjs/utils/Utils.js +402 -0
- package/lib/collab/yjs/wsProvider.d.ts +3 -0
- package/lib/collab/yjs/wsProvider.js +21 -0
- package/lib/commenting/index.d.ts +41 -0
- package/lib/commenting/index.js +324 -0
- package/lib/context/FlashMessageContext.d.ts +7 -0
- package/lib/context/FlashMessageContext.js +24 -0
- package/lib/context/SettingsContext.d.ts +12 -0
- package/lib/context/SettingsContext.js +38 -0
- package/lib/context/SharedHistoryContext.d.ts +11 -0
- package/lib/context/SharedHistoryContext.js +11 -0
- package/lib/context/ToolbarContext.d.ts +65 -0
- package/lib/context/ToolbarContext.js +84 -0
- package/lib/demo.d.ts +12 -0
- package/lib/demo.js +41 -0
- package/lib/hooks/useFlashMessage.d.ts +2 -0
- package/lib/hooks/useFlashMessage.js +4 -0
- package/lib/hooks/useModal.d.ts +5 -0
- package/lib/hooks/useModal.js +26 -0
- package/lib/hooks/useReport.d.ts +1 -0
- package/lib/hooks/useReport.js +46 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -5
- package/lib/nodes/AutocompleteNode.d.ts +27 -0
- package/lib/nodes/AutocompleteNode.js +56 -0
- package/lib/nodes/CounterComponent.d.ts +6 -0
- package/lib/nodes/CounterComponent.js +137 -0
- package/lib/nodes/CounterNode.d.ts +23 -0
- package/lib/nodes/CounterNode.js +47 -0
- package/lib/nodes/DateTimeNode/DateTimeComponent.d.ts +8 -0
- package/lib/nodes/DateTimeNode/DateTimeComponent.js +119 -0
- package/lib/nodes/DateTimeNode/DateTimeNode.d.ts +27 -0
- package/lib/nodes/DateTimeNode/DateTimeNode.js +82 -0
- package/lib/nodes/EmojiNode.d.ts +18 -0
- package/lib/nodes/EmojiNode.js +50 -0
- package/lib/nodes/EquationComponent.d.ts +9 -0
- package/lib/nodes/EquationComponent.js +75 -0
- package/lib/nodes/EquationNode.d.ts +26 -0
- package/lib/nodes/EquationNode.js +109 -0
- package/lib/nodes/ExcalidrawNode/ExcalidrawComponent.d.ts +8 -0
- package/lib/nodes/ExcalidrawNode/ExcalidrawComponent.js +110 -0
- package/lib/nodes/ExcalidrawNode/ExcalidrawImage.d.ts +50 -0
- package/lib/nodes/ExcalidrawNode/ExcalidrawImage.js +55 -0
- package/lib/nodes/ExcalidrawNode/index.d.ts +32 -0
- package/lib/nodes/ExcalidrawNode/index.js +117 -0
- package/lib/nodes/FigmaNode.d.ts +20 -0
- package/lib/nodes/FigmaNode.js +52 -0
- package/lib/nodes/ImageComponent.d.ts +16 -0
- package/lib/nodes/ImageComponent.js +272 -0
- package/lib/nodes/ImageNode.d.ts +50 -0
- package/lib/nodes/ImageNode.js +151 -0
- package/lib/nodes/InlineImageNode/InlineImageComponent.d.ts +26 -0
- package/lib/nodes/InlineImageNode/InlineImageComponent.js +161 -0
- package/lib/nodes/InlineImageNode/InlineImageNode.d.ts +59 -0
- package/lib/nodes/InlineImageNode/InlineImageNode.js +162 -0
- package/lib/nodes/KeywordNode.d.ts +14 -0
- package/lib/nodes/KeywordNode.js +33 -0
- package/lib/nodes/LayoutContainerNode.d.ts +24 -0
- package/lib/nodes/LayoutContainerNode.js +91 -0
- package/lib/nodes/LayoutItemNode.d.ts +16 -0
- package/lib/nodes/LayoutItemNode.js +65 -0
- package/lib/nodes/MentionNode.d.ts +20 -0
- package/lib/nodes/MentionNode.js +81 -0
- package/lib/nodes/PageBreakNode/index.d.ts +17 -0
- package/lib/nodes/PageBreakNode/index.js +83 -0
- package/lib/nodes/PlaygroundNodes.d.ts +3 -0
- package/lib/nodes/PlaygroundNodes.js +71 -0
- package/lib/nodes/PollComponent.d.ts +9 -0
- package/lib/nodes/PollComponent.js +85 -0
- package/lib/nodes/PollNode.d.ts +43 -0
- package/lib/nodes/PollNode.js +153 -0
- package/lib/nodes/SpecialTextNode.d.ts +24 -0
- package/lib/nodes/SpecialTextNode.js +50 -0
- package/lib/nodes/StickyComponent.d.ts +10 -0
- package/lib/nodes/StickyComponent.js +162 -0
- package/lib/nodes/StickyNode.d.ts +31 -0
- package/lib/nodes/StickyNode.js +76 -0
- package/lib/nodes/TweetNode.d.ts +21 -0
- package/lib/nodes/TweetNode.js +119 -0
- package/lib/nodes/YouTubeNode.d.ts +22 -0
- package/lib/nodes/YouTubeNode.js +84 -0
- package/lib/plugins/ActionsPlugin/index.d.ts +5 -0
- package/lib/plugins/ActionsPlugin/index.js +168 -0
- package/lib/plugins/AutoEmbedPlugin/index.d.ts +19 -0
- package/lib/plugins/AutoEmbedPlugin/index.js +158 -0
- package/lib/plugins/AutoLinkPlugin/index.d.ts +2 -0
- package/lib/plugins/AutoLinkPlugin/index.js +15 -0
- package/lib/plugins/AutocompletePlugin/index.d.ts +10 -0
- package/lib/plugins/AutocompletePlugin/index.js +2473 -0
- package/lib/plugins/CodeActionMenuPlugin/components/CopyButton/index.d.ts +7 -0
- package/lib/plugins/CodeActionMenuPlugin/components/CopyButton/index.js +42 -0
- package/lib/plugins/CodeActionMenuPlugin/components/PrettierButton/index.d.ts +17 -0
- package/lib/plugins/CodeActionMenuPlugin/components/PrettierButton/index.js +111 -0
- package/lib/plugins/CodeActionMenuPlugin/index.d.ts +5 -0
- package/lib/plugins/CodeActionMenuPlugin/index.js +104 -0
- package/lib/plugins/CodeActionMenuPlugin/utils.d.ts +1 -0
- package/lib/plugins/CodeActionMenuPlugin/utils.js +18 -0
- package/lib/plugins/CodeHighlightPrismPlugin/index.d.ts +2 -0
- package/lib/plugins/CodeHighlightPrismPlugin/index.js +10 -0
- package/lib/plugins/CodeHighlightShikiPlugin/index.d.ts +2 -0
- package/lib/plugins/CodeHighlightShikiPlugin/index.js +10 -0
- package/lib/plugins/CollapsiblePlugin/CollapsibleContainerNode.d.ts +25 -0
- package/lib/plugins/CollapsiblePlugin/CollapsibleContainerNode.js +131 -0
- package/lib/plugins/CollapsiblePlugin/CollapsibleContentNode.d.ts +16 -0
- package/lib/plugins/CollapsiblePlugin/CollapsibleContentNode.js +79 -0
- package/lib/plugins/CollapsiblePlugin/CollapsibleTitleNode.d.ts +16 -0
- package/lib/plugins/CollapsiblePlugin/CollapsibleTitleNode.js +81 -0
- package/lib/plugins/CollapsiblePlugin/CollapsibleUtils.d.ts +2 -0
- package/lib/plugins/CollapsiblePlugin/CollapsibleUtils.js +8 -0
- package/lib/plugins/CollapsiblePlugin/index.d.ts +3 -0
- package/lib/plugins/CollapsiblePlugin/index.js +128 -0
- package/lib/plugins/CommentPlugin/index.d.ts +9 -0
- package/lib/plugins/CommentPlugin/index.js +460 -0
- package/lib/plugins/ComponentPickerPlugin/index.d.ts +2 -0
- package/lib/plugins/ComponentPickerPlugin/index.js +276 -0
- package/lib/plugins/ContextMenuPlugin/index.d.ts +2 -0
- package/lib/plugins/ContextMenuPlugin/index.js +112 -0
- package/lib/plugins/CounterPlugin/index.d.ts +3 -0
- package/lib/plugins/CounterPlugin/index.js +20 -0
- package/lib/plugins/DatalayerPlugin/index.d.ts +2 -0
- package/lib/plugins/DatalayerPlugin/index.js +218 -0
- package/lib/plugins/DateTimePlugin/index.d.ts +8 -0
- package/lib/plugins/DateTimePlugin/index.js +24 -0
- package/lib/plugins/DocsPlugin/index.d.ts +2 -0
- package/lib/plugins/DocsPlugin/index.js +4 -0
- package/lib/plugins/DragDropPastePlugin/index.d.ts +1 -0
- package/lib/plugins/DragDropPastePlugin/index.js +33 -0
- package/lib/plugins/DraggableBlockPlugin/index.d.ts +12 -0
- package/lib/plugins/DraggableBlockPlugin/index.js +36 -0
- package/lib/plugins/EmojiPickerPlugin/index.d.ts +1 -0
- package/lib/plugins/EmojiPickerPlugin/index.js +80 -0
- package/lib/plugins/EmojisPlugin/index.d.ts +2 -0
- package/lib/plugins/EmojisPlugin/index.js +52 -0
- package/lib/plugins/EquationsPlugin/index.d.ts +14 -0
- package/lib/plugins/EquationsPlugin/index.js +34 -0
- package/lib/plugins/ExcalidrawPlugin/index.d.ts +5 -0
- package/lib/plugins/ExcalidrawPlugin/index.js +44 -0
- package/lib/plugins/FigmaPlugin/index.d.ts +4 -0
- package/lib/plugins/FigmaPlugin/index.js +20 -0
- package/lib/plugins/FloatingLinkEditorPlugin/index.d.ts +15 -0
- package/lib/plugins/FloatingLinkEditorPlugin/index.js +280 -0
- package/lib/plugins/FloatingTextFormatToolbarPlugin/index.d.ts +7 -0
- package/lib/plugins/FloatingTextFormatToolbarPlugin/index.js +219 -0
- package/lib/plugins/ImagesPlugin/index.d.ts +24 -0
- package/lib/plugins/ImagesPlugin/index.js +195 -0
- package/lib/plugins/InlineImagePlugin/index.d.ts +17 -0
- package/lib/plugins/InlineImagePlugin/index.js +180 -0
- package/lib/plugins/KeywordsPlugin/index.d.ts +2 -0
- package/lib/plugins/KeywordsPlugin/index.js +31 -0
- package/lib/plugins/LayoutPlugin/InsertLayoutDialog.d.ts +6 -0
- package/lib/plugins/LayoutPlugin/InsertLayoutDialog.js +21 -0
- package/lib/plugins/LayoutPlugin/LayoutPlugin.d.ts +7 -0
- package/lib/plugins/LayoutPlugin/LayoutPlugin.js +131 -0
- package/lib/plugins/LinkPlugin/index.d.ts +6 -0
- package/lib/plugins/LinkPlugin/index.js +11 -0
- package/lib/plugins/MarkdownShortcutPlugin/index.d.ts +2 -0
- package/lib/plugins/MarkdownShortcutPlugin/index.js +6 -0
- package/lib/plugins/MarkdownTransformers/index.d.ts +8 -0
- package/lib/plugins/MarkdownTransformers/index.js +234 -0
- package/lib/plugins/MaxLengthPlugin/index.d.ts +3 -0
- package/lib/plugins/MaxLengthPlugin/index.js +37 -0
- package/lib/plugins/MentionsPlugin/index.d.ts +2 -0
- package/lib/plugins/MentionsPlugin/index.js +564 -0
- package/lib/plugins/PageBreakPlugin/index.d.ts +4 -0
- package/lib/plugins/PageBreakPlugin/index.js +27 -0
- package/lib/plugins/PasteLogPlugin/index.d.ts +2 -0
- package/lib/plugins/PasteLogPlugin/index.js +27 -0
- package/lib/plugins/PollPlugin/index.d.ts +8 -0
- package/lib/plugins/PollPlugin/index.js +38 -0
- package/lib/plugins/ShortcutsPlugin/index.d.ts +6 -0
- package/lib/plugins/ShortcutsPlugin/index.js +112 -0
- package/lib/plugins/ShortcutsPlugin/shortcuts.d.ts +59 -0
- package/lib/plugins/ShortcutsPlugin/shortcuts.js +169 -0
- package/lib/plugins/SpecialTextPlugin/index.d.ts +2 -0
- package/lib/plugins/SpecialTextPlugin/index.js +46 -0
- package/lib/plugins/SpeechToTextPlugin/index.d.ts +5 -0
- package/lib/plugins/SpeechToTextPlugin/index.js +82 -0
- package/lib/plugins/StickyPlugin/index.d.ts +2 -0
- package/lib/plugins/StickyPlugin/index.js +12 -0
- package/lib/plugins/TabFocusPlugin/index.d.ts +1 -0
- package/lib/plugins/TabFocusPlugin/index.js +34 -0
- package/lib/plugins/TableActionMenuPlugin/index.d.ts +5 -0
- package/lib/plugins/TableActionMenuPlugin/index.js +492 -0
- package/lib/plugins/TableCellResizer/index.d.ts +3 -0
- package/lib/plugins/TableCellResizer/index.js +297 -0
- package/lib/plugins/TableHoverActionsPlugin/index.d.ts +4 -0
- package/lib/plugins/TableHoverActionsPlugin/index.js +188 -0
- package/lib/plugins/TableOfContentsPlugin/index.d.ts +2 -0
- package/lib/plugins/TableOfContentsPlugin/index.js +116 -0
- package/lib/plugins/TablePlugin.d.ts +31 -0
- package/lib/plugins/TablePlugin.js +63 -0
- package/lib/plugins/TestRecorderPlugin/index.d.ts +3 -0
- package/lib/plugins/TestRecorderPlugin/index.js +346 -0
- package/lib/plugins/ToolbarPlugin/fontSize.d.ts +9 -0
- package/lib/plugins/ToolbarPlugin/fontSize.js +80 -0
- package/lib/plugins/ToolbarPlugin/index.d.ts +9 -0
- package/lib/plugins/ToolbarPlugin/index.js +500 -0
- package/lib/plugins/ToolbarPlugin/utils.d.ts +26 -0
- package/lib/plugins/ToolbarPlugin/utils.js +243 -0
- package/lib/plugins/TreeViewPlugin/index.d.ts +2 -0
- package/lib/plugins/TreeViewPlugin/index.js +7 -0
- package/lib/plugins/TwitterPlugin/index.d.ts +4 -0
- package/lib/plugins/TwitterPlugin/index.js +20 -0
- package/lib/plugins/TypingPerfPlugin/index.d.ts +2 -0
- package/lib/plugins/TypingPerfPlugin/index.js +93 -0
- package/lib/plugins/YouTubePlugin/index.d.ts +4 -0
- package/lib/plugins/YouTubePlugin/index.js +20 -0
- package/lib/server/validation.d.ts +1 -0
- package/lib/server/validation.js +111 -0
- package/lib/setupEnv.d.ts +2 -0
- package/lib/setupEnv.js +25 -0
- package/lib/themes/CommentEditorTheme.d.ts +4 -0
- package/lib/themes/CommentEditorTheme.js +7 -0
- package/lib/themes/PlaygroundEditorTheme.d.ts +4 -0
- package/lib/themes/PlaygroundEditorTheme.js +120 -0
- package/lib/themes/StickyEditorTheme.d.ts +4 -0
- package/lib/themes/StickyEditorTheme.js +7 -0
- package/lib/tyes.dt.d.ts +12 -0
- package/lib/tyes.dt.js +0 -0
- package/lib/ui/Button.d.ts +12 -0
- package/lib/ui/Button.js +6 -0
- package/lib/ui/ColorPicker.d.ts +14 -0
- package/lib/ui/ColorPicker.js +219 -0
- package/lib/ui/ContentEditable.d.ts +9 -0
- package/lib/ui/ContentEditable.js +6 -0
- package/lib/ui/Dialog.d.ts +10 -0
- package/lib/ui/Dialog.js +8 -0
- package/lib/ui/DropDown.d.ts +18 -0
- package/lib/ui/DropDown.js +133 -0
- package/lib/ui/DropdownColorPicker.d.ts +13 -0
- package/lib/ui/DropdownColorPicker.js +6 -0
- package/lib/ui/EquationEditor.d.ts +8 -0
- package/lib/ui/EquationEditor.js +11 -0
- package/lib/ui/ExcalidrawModal.d.ts +42 -0
- package/lib/ui/ExcalidrawModal.js +103 -0
- package/lib/ui/FileInput.d.ts +10 -0
- package/lib/ui/FileInput.js +5 -0
- package/lib/ui/FlashMessage.d.ts +7 -0
- package/lib/ui/FlashMessage.js +6 -0
- package/lib/ui/ImageResizer.d.ts +17 -0
- package/lib/ui/ImageResizer.js +171 -0
- package/lib/ui/KatexEquationAlterer.d.ts +8 -0
- package/lib/ui/KatexEquationAlterer.js +23 -0
- package/lib/ui/KatexRenderer.d.ts +6 -0
- package/lib/ui/KatexRenderer.js +24 -0
- package/lib/ui/Modal.d.ts +9 -0
- package/lib/ui/Modal.js +48 -0
- package/lib/ui/Select.d.ts +8 -0
- package/lib/ui/Select.js +5 -0
- package/lib/ui/Switch.d.ts +8 -0
- package/lib/ui/Switch.js +6 -0
- package/lib/ui/TextInput.d.ts +13 -0
- package/lib/ui/TextInput.js +7 -0
- package/lib/utils/docSerialization.d.ts +3 -0
- package/lib/utils/docSerialization.js +56 -0
- package/lib/utils/emoji-list.d.ts +20 -0
- package/lib/utils/emoji-list.js +16605 -0
- package/lib/utils/getDOMRangeRect.d.ts +8 -0
- package/lib/utils/getDOMRangeRect.js +22 -0
- package/lib/utils/getSelectedNode.d.ts +2 -0
- package/lib/utils/getSelectedNode.js +24 -0
- package/lib/utils/getThemeSelector.d.ts +2 -0
- package/lib/utils/getThemeSelector.js +10 -0
- package/lib/utils/isMobileWidth.d.ts +7 -0
- package/lib/utils/isMobileWidth.js +7 -0
- package/lib/utils/joinClasses.d.ts +1 -0
- package/lib/utils/joinClasses.js +3 -0
- package/lib/utils/setFloatingElemPosition.d.ts +1 -0
- package/lib/utils/setFloatingElemPosition.js +55 -0
- package/lib/utils/setFloatingElemPositionForLinkEditor.d.ts +1 -0
- package/lib/utils/setFloatingElemPositionForLinkEditor.js +32 -0
- package/lib/utils/swipe.d.ts +4 -0
- package/lib/utils/swipe.js +90 -0
- package/lib/utils/url.d.ts +2 -0
- package/lib/utils/url.js +27 -0
- package/package.json +82 -51
- package/lib/DiffMerge.d.ts +0 -39
- package/lib/DiffMerge.js +0 -437
- package/lib/LoroCollaborativePlugin.d.ts +0 -62
- package/lib/LoroCollaborativePlugin.js +0 -2826
- package/lib/stableNodeState.d.ts +0 -8
- package/lib/stableNodeState.js +0 -15
|
@@ -1,2826 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
/*
|
|
3
|
-
* Copyright (c) 2023-2025 Datalayer, Inc.
|
|
4
|
-
* Distributed under the terms of the MIT License.
|
|
5
|
-
*/
|
|
6
|
-
import React, { useEffect, useRef, useCallback, useState, useImperativeHandle } from 'react';
|
|
7
|
-
import { createPortal } from 'react-dom';
|
|
8
|
-
import { $createParagraphNode, $getRoot, $getSelection, $isRangeSelection, $getNodeByKey, $isTextNode, $isElementNode, $isLineBreakNode, $createTextNode, $getState, $setState } from 'lexical';
|
|
9
|
-
import { createDOMRange, createRectsFromDOMRange } from '@lexical/selection';
|
|
10
|
-
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
11
|
-
import { LoroDoc, LoroText, Cursor, EphemeralStore } from 'loro-crdt';
|
|
12
|
-
import { applyDifferentialUpdate } from './DiffMerge';
|
|
13
|
-
import { stableNodeIdState } from './stableNodeState';
|
|
14
|
-
// ============================================================================
|
|
15
|
-
// DIFFERENTIAL UPDATE CONFIGURATION
|
|
16
|
-
// ============================================================================
|
|
17
|
-
/**
|
|
18
|
-
* Control flag for differential updates to prevent decorator node reloading.
|
|
19
|
-
* When true, uses sophisticated differential merging instead of wholesale setEditorState.
|
|
20
|
-
* This prevents YouTube/Counter decorator nodes from reloading during collaborative editing.
|
|
21
|
-
*/
|
|
22
|
-
const USE_DIFFERENTIAL_UPDATE = true;
|
|
23
|
-
// ============================================================================
|
|
24
|
-
// STABLE NODE UUID SYSTEM using Lexical NodeState
|
|
25
|
-
// ============================================================================
|
|
26
|
-
/**
|
|
27
|
-
* Generate a stable UUID for nodes
|
|
28
|
-
*/
|
|
29
|
-
function generateStableNodeId() {
|
|
30
|
-
return `node_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* Get or create a stable UUID for a Lexical node using NodeState
|
|
34
|
-
*/
|
|
35
|
-
function $getStableNodeId(node) {
|
|
36
|
-
let stableId = $getState(node, stableNodeIdState);
|
|
37
|
-
if (!stableId) {
|
|
38
|
-
stableId = generateStableNodeId();
|
|
39
|
-
$setState(node, stableNodeIdState, stableId);
|
|
40
|
-
}
|
|
41
|
-
return stableId;
|
|
42
|
-
}
|
|
43
|
-
// ============================================================================
|
|
44
|
-
// STABLE CURSOR POSITION FUNCTIONS - UUID Based (No Performance Issues)
|
|
45
|
-
// ============================================================================
|
|
46
|
-
/**
|
|
47
|
-
* Create stable position data from Lexical selection point using UUID
|
|
48
|
-
* This replaces NodeKey-based approach with stable UUIDs
|
|
49
|
-
* Must be called within editor.getEditorState().read() or editor.update()
|
|
50
|
-
*/
|
|
51
|
-
function $createStablePositionFromPoint(point) {
|
|
52
|
-
const node = $getNodeByKey(point.key);
|
|
53
|
-
if (!node) {
|
|
54
|
-
console.warn('❌ Node not found for key:', point.key);
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
57
|
-
// Get or create stable UUID for this node
|
|
58
|
-
const stableNodeId = $getStableNodeId(node);
|
|
59
|
-
return {
|
|
60
|
-
stableNodeId,
|
|
61
|
-
offset: point.offset,
|
|
62
|
-
type: $isTextNode(node) ? 'text' : 'element'
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
/**
|
|
66
|
-
* Find a node by its stable UUID (traverses the document tree)
|
|
67
|
-
* This is the reverse operation - finding node by stable ID
|
|
68
|
-
*/
|
|
69
|
-
function $findNodeByStableId(stableNodeId) {
|
|
70
|
-
const root = $getRoot();
|
|
71
|
-
// Traverse the document tree to find node with matching stable ID
|
|
72
|
-
function traverse(node) {
|
|
73
|
-
// Check if this node has the stable ID we're looking for
|
|
74
|
-
const nodeStableId = $getState(node, stableNodeIdState);
|
|
75
|
-
if (nodeStableId === stableNodeId) {
|
|
76
|
-
return node;
|
|
77
|
-
}
|
|
78
|
-
// If this is an element node, traverse its children
|
|
79
|
-
if ($isElementNode(node)) {
|
|
80
|
-
const children = node.getChildren();
|
|
81
|
-
for (const child of children) {
|
|
82
|
-
const found = traverse(child);
|
|
83
|
-
if (found)
|
|
84
|
-
return found;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
|
-
return traverse(root);
|
|
90
|
-
}
|
|
91
|
-
/**
|
|
92
|
-
* Convert stable position back to NodeKey and offset for Lexical operations
|
|
93
|
-
* This allows compatibility with existing cursor positioning code
|
|
94
|
-
*/
|
|
95
|
-
function $resolveStablePosition(stablePos) {
|
|
96
|
-
const node = $findNodeByStableId(stablePos.stableNodeId);
|
|
97
|
-
if (!node) {
|
|
98
|
-
console.warn('❌ Could not find node for stable ID:', stablePos.stableNodeId, '- using document end fallback');
|
|
99
|
-
// ROBUST FALLBACK: When stable UUID can't be resolved (node doesn't exist yet),
|
|
100
|
-
// position cursor at end of document instead of failing
|
|
101
|
-
const root = $getRoot();
|
|
102
|
-
const children = root.getChildren();
|
|
103
|
-
// Find the last text node in the document
|
|
104
|
-
for (let i = children.length - 1; i >= 0; i--) {
|
|
105
|
-
const child = children[i];
|
|
106
|
-
if ($isElementNode(child)) {
|
|
107
|
-
const textChildren = child.getChildren().filter($isTextNode);
|
|
108
|
-
if (textChildren.length > 0) {
|
|
109
|
-
const lastText = textChildren[textChildren.length - 1];
|
|
110
|
-
console.log('✅ Fallback: Using end of last text node:', {
|
|
111
|
-
nodeKey: lastText.getKey(),
|
|
112
|
-
textLength: lastText.getTextContentSize(),
|
|
113
|
-
stableIdThatFailed: stablePos.stableNodeId
|
|
114
|
-
});
|
|
115
|
-
return {
|
|
116
|
-
key: lastText.getKey(),
|
|
117
|
-
offset: lastText.getTextContentSize()
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
// If no text nodes found, use root
|
|
123
|
-
console.log('✅ Fallback: Using root node (no text nodes found)');
|
|
124
|
-
return {
|
|
125
|
-
key: root.getKey(),
|
|
126
|
-
offset: 0
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
return {
|
|
130
|
-
key: node.getKey(),
|
|
131
|
-
offset: stablePos.offset
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
/**
|
|
135
|
-
* Ensure all nodes in the document have stable UUIDs
|
|
136
|
-
* This should be called after document updates to maintain stability
|
|
137
|
-
*/
|
|
138
|
-
function $ensureAllNodesHaveStableIds() {
|
|
139
|
-
const root = $getRoot();
|
|
140
|
-
function traverse(node) {
|
|
141
|
-
// Ensure this node has a stable ID
|
|
142
|
-
$getStableNodeId(node);
|
|
143
|
-
// If this is an element node, traverse its children
|
|
144
|
-
if ($isElementNode(node)) {
|
|
145
|
-
const children = node.getChildren();
|
|
146
|
-
for (const child of children) {
|
|
147
|
-
traverse(child);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
traverse(root);
|
|
152
|
-
}
|
|
153
|
-
const CursorComponent = ({ peerId, position, color, name, isCurrentUser, selection }) => {
|
|
154
|
-
const displayName = `${name} (peer:${peerId})`;
|
|
155
|
-
return (_jsxs(_Fragment, { children: [selection && selection.rects.map((rect, index) => (_jsx("span", { style: {
|
|
156
|
-
position: 'fixed',
|
|
157
|
-
top: `${rect.top}px`,
|
|
158
|
-
left: `${rect.left}px`,
|
|
159
|
-
width: `${rect.width}px`,
|
|
160
|
-
height: `${rect.height}px`,
|
|
161
|
-
backgroundColor: color,
|
|
162
|
-
opacity: 0.2,
|
|
163
|
-
pointerEvents: 'none',
|
|
164
|
-
zIndex: 1, // Behind cursor
|
|
165
|
-
} }, `selection-${peerId}-${index}`))), _jsxs("span", { style: {
|
|
166
|
-
position: 'fixed',
|
|
167
|
-
top: `${position.top}px`,
|
|
168
|
-
left: `${position.left}px`,
|
|
169
|
-
height: '20px', // Standard text line height
|
|
170
|
-
width: '0px',
|
|
171
|
-
pointerEvents: 'none',
|
|
172
|
-
zIndex: 5,
|
|
173
|
-
opacity: isCurrentUser ? 0.6 : 1.0,
|
|
174
|
-
}, children: [_jsx("span", { style: {
|
|
175
|
-
position: 'absolute',
|
|
176
|
-
left: '0',
|
|
177
|
-
top: '0',
|
|
178
|
-
backgroundColor: color,
|
|
179
|
-
opacity: 0.3,
|
|
180
|
-
height: '20px',
|
|
181
|
-
width: '2px',
|
|
182
|
-
pointerEvents: 'none',
|
|
183
|
-
zIndex: 5,
|
|
184
|
-
} }), _jsx("span", { style: {
|
|
185
|
-
position: 'absolute',
|
|
186
|
-
top: '0',
|
|
187
|
-
bottom: '0',
|
|
188
|
-
right: '-1px',
|
|
189
|
-
width: '1px',
|
|
190
|
-
backgroundColor: color,
|
|
191
|
-
zIndex: 10,
|
|
192
|
-
pointerEvents: 'none',
|
|
193
|
-
}, children: _jsx("span", { style: {
|
|
194
|
-
position: 'absolute',
|
|
195
|
-
left: '-2px',
|
|
196
|
-
top: '-16px',
|
|
197
|
-
backgroundColor: color,
|
|
198
|
-
color: '#fff',
|
|
199
|
-
lineHeight: '12px',
|
|
200
|
-
fontSize: '12px',
|
|
201
|
-
padding: '2px',
|
|
202
|
-
fontFamily: 'Arial',
|
|
203
|
-
fontWeight: 'bold',
|
|
204
|
-
whiteSpace: 'nowrap',
|
|
205
|
-
borderRadius: '2px',
|
|
206
|
-
maxWidth: '200px',
|
|
207
|
-
overflow: 'hidden',
|
|
208
|
-
textOverflow: 'ellipsis',
|
|
209
|
-
}, children: displayName }) })] })] }));
|
|
210
|
-
};
|
|
211
|
-
const CursorsContainer = React.forwardRef(({ remoteCursors, getPositionFromLexicalPosition, clientId, editor }, ref) => {
|
|
212
|
-
const [portalContainer, setPortalContainer] = useState(null);
|
|
213
|
-
// Keep last known good positions to avoid snapping to x=0 when mapping fails
|
|
214
|
-
const lastCursorStateRef = useRef({});
|
|
215
|
-
// Internal state to hold the current cursor data
|
|
216
|
-
const [internalCursors, setInternalCursors] = useState(remoteCursors);
|
|
217
|
-
// Expose update method through ref
|
|
218
|
-
useImperativeHandle(ref, () => ({
|
|
219
|
-
update: (cursors) => {
|
|
220
|
-
setInternalCursors(cursors);
|
|
221
|
-
}
|
|
222
|
-
}), []);
|
|
223
|
-
// Use internal cursors instead of props for rendering
|
|
224
|
-
const cursorsToRender = internalCursors;
|
|
225
|
-
useEffect(() => {
|
|
226
|
-
// Create or get the cursor overlay container
|
|
227
|
-
let container = document.getElementById('loro-cursor-overlay');
|
|
228
|
-
if (!container) {
|
|
229
|
-
container = document.createElement('div');
|
|
230
|
-
container.id = 'loro-cursor-overlay';
|
|
231
|
-
container.style.cssText = `
|
|
232
|
-
position: fixed;
|
|
233
|
-
top: 0;
|
|
234
|
-
left: 0;
|
|
235
|
-
width: 100vw;
|
|
236
|
-
height: 100vh;
|
|
237
|
-
pointer-events: none;
|
|
238
|
-
z-index: 999999;
|
|
239
|
-
overflow: visible;
|
|
240
|
-
`;
|
|
241
|
-
document.body.appendChild(container);
|
|
242
|
-
console.log('🎭 Created React portal cursor overlay container');
|
|
243
|
-
}
|
|
244
|
-
setPortalContainer(container);
|
|
245
|
-
return () => {
|
|
246
|
-
// Clean up container on unmount
|
|
247
|
-
const existingContainer = document.getElementById('loro-cursor-overlay');
|
|
248
|
-
if (existingContainer && existingContainer.parentNode) {
|
|
249
|
-
existingContainer.parentNode.removeChild(existingContainer);
|
|
250
|
-
console.log('🧹 Cleaned up cursor overlay container');
|
|
251
|
-
}
|
|
252
|
-
};
|
|
253
|
-
}, []);
|
|
254
|
-
if (!portalContainer) {
|
|
255
|
-
return null;
|
|
256
|
-
}
|
|
257
|
-
console.log('🎯 Rendering cursors via React portal:', {
|
|
258
|
-
remoteCursorsCount: Object.keys(cursorsToRender).length,
|
|
259
|
-
clientId
|
|
260
|
-
});
|
|
261
|
-
const cursors = Object.values(cursorsToRender)
|
|
262
|
-
.map(remoteCursor => {
|
|
263
|
-
const { peerId, anchor, focus, user } = remoteCursor;
|
|
264
|
-
if (!anchor) {
|
|
265
|
-
console.log('⚠️ No anchor for peer:', peerId);
|
|
266
|
-
return null;
|
|
267
|
-
}
|
|
268
|
-
try {
|
|
269
|
-
// Get cursor position using standard positioning
|
|
270
|
-
let position = getPositionFromLexicalPosition(anchor.key, anchor.offset);
|
|
271
|
-
const lastState = lastCursorStateRef.current[peerId];
|
|
272
|
-
// Basic position validation
|
|
273
|
-
const isPositionValid = (pos) => {
|
|
274
|
-
if (!pos)
|
|
275
|
-
return false;
|
|
276
|
-
// Check for NaN values
|
|
277
|
-
if (isNaN(pos.top) || isNaN(pos.left))
|
|
278
|
-
return false;
|
|
279
|
-
// Check for negative positions (usually indicates positioning error)
|
|
280
|
-
if (pos.top < 0 || pos.left < 0)
|
|
281
|
-
return false;
|
|
282
|
-
// Check for unreasonably large positions (likely positioning error)
|
|
283
|
-
if (pos.top > window.innerHeight * 3 || pos.left > window.innerWidth * 3)
|
|
284
|
-
return false;
|
|
285
|
-
return true;
|
|
286
|
-
};
|
|
287
|
-
// If position seems invalid, try to recalculate
|
|
288
|
-
if (!isPositionValid(position)) {
|
|
289
|
-
console.log('⚠️ Position validation failed, recalculating...', position);
|
|
290
|
-
// Try again to get position
|
|
291
|
-
position = getPositionFromLexicalPosition(anchor.key, anchor.offset);
|
|
292
|
-
console.log('🔄 Recalculated position:', position);
|
|
293
|
-
}
|
|
294
|
-
// Heuristic: if mapping still invalid, or we detect a suspicious jump to line start,
|
|
295
|
-
// keep the last known good position to avoid snapping to x=0.
|
|
296
|
-
const looksLikeLineStartFallback = () => {
|
|
297
|
-
if (!position || !lastState)
|
|
298
|
-
return false;
|
|
299
|
-
// Consider a suspicious leftward jump on the same line while offset increased or stayed
|
|
300
|
-
const leftwardJump = position.left < (lastState.position.left - 20); // >20px jump left
|
|
301
|
-
const roughlySameLine = Math.abs(position.top - lastState.position.top) < 30; // within same line height
|
|
302
|
-
const offsetDidNotDecrease = anchor.offset >= (lastState.offset || 0);
|
|
303
|
-
return leftwardJump && roughlySameLine && offsetDidNotDecrease;
|
|
304
|
-
};
|
|
305
|
-
if (!isPositionValid(position) || looksLikeLineStartFallback()) {
|
|
306
|
-
if (!isPositionValid(position)) {
|
|
307
|
-
console.log('⚠️ Final position invalid; using last known good position for peer:', peerId, { last: lastState?.position });
|
|
308
|
-
}
|
|
309
|
-
else {
|
|
310
|
-
console.log('⚠️ Suspicious leftward jump detected; keeping last position for peer:', peerId, {
|
|
311
|
-
current: position,
|
|
312
|
-
last: lastState?.position,
|
|
313
|
-
anchorOffset: anchor.offset,
|
|
314
|
-
lastOffset: lastState?.offset
|
|
315
|
-
});
|
|
316
|
-
}
|
|
317
|
-
if (lastState && isPositionValid(lastState.position)) {
|
|
318
|
-
position = lastState.position;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
if (!isPositionValid(position)) {
|
|
322
|
-
console.log('⚠️ No valid position available for peer after fallback:', peerId, position);
|
|
323
|
-
return null;
|
|
324
|
-
}
|
|
325
|
-
// Position is now guaranteed to be valid due to isPositionValid check above
|
|
326
|
-
const color = user?.color || '#007acc';
|
|
327
|
-
const displayName = user?.name || peerId.slice(-8);
|
|
328
|
-
const isCurrentUser = peerId === clientId;
|
|
329
|
-
// Calculate selection rectangles if there's a focus position different from anchor
|
|
330
|
-
let selection;
|
|
331
|
-
if (focus && (focus.key !== anchor.key || focus.offset !== anchor.offset)) {
|
|
332
|
-
// There's a selection, calculate the selection rectangles
|
|
333
|
-
console.log('� Calculating selection for peer:', peerId, { anchor, focus });
|
|
334
|
-
try {
|
|
335
|
-
// Use the provided editor instance to create a range from anchor to focus
|
|
336
|
-
if (editor) {
|
|
337
|
-
const rects = editor.getEditorState().read(() => {
|
|
338
|
-
const anchorNode = $getNodeByKey(anchor.key);
|
|
339
|
-
const focusNode = $getNodeByKey(focus.key);
|
|
340
|
-
if (!anchorNode || !focusNode) {
|
|
341
|
-
console.log('⚠️ Selection nodes not found:', { anchorNode: !!anchorNode, focusNode: !!focusNode });
|
|
342
|
-
return [];
|
|
343
|
-
}
|
|
344
|
-
try {
|
|
345
|
-
// Create a DOM range from anchor to focus
|
|
346
|
-
const range = createDOMRange(editor, anchorNode, anchor.offset, focusNode, focus.offset);
|
|
347
|
-
if (range) {
|
|
348
|
-
const rectList = createRectsFromDOMRange(editor, range);
|
|
349
|
-
console.log('📐 Selection rects calculated:', rectList.length);
|
|
350
|
-
return rectList.map(rect => ({
|
|
351
|
-
top: rect.top,
|
|
352
|
-
left: rect.left,
|
|
353
|
-
width: rect.width,
|
|
354
|
-
height: rect.height
|
|
355
|
-
}));
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
catch (rangeError) {
|
|
359
|
-
console.warn('Error creating selection range:', rangeError);
|
|
360
|
-
}
|
|
361
|
-
return [];
|
|
362
|
-
});
|
|
363
|
-
if (rects.length > 0) {
|
|
364
|
-
selection = { rects };
|
|
365
|
-
console.log('✅ Selection calculated successfully for peer:', peerId, selection);
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
catch (selectionError) {
|
|
370
|
-
console.warn('Error calculating selection for peer:', peerId, selectionError);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
console.log('�🟢 Rendering cursor for peer:', peerId, {
|
|
374
|
-
position,
|
|
375
|
-
color,
|
|
376
|
-
displayName,
|
|
377
|
-
isCurrentUser,
|
|
378
|
-
hasSelection: !!selection
|
|
379
|
-
});
|
|
380
|
-
// Store last known good position and offset for future fallbacks
|
|
381
|
-
lastCursorStateRef.current[peerId] = {
|
|
382
|
-
position: { top: position.top, left: position.left },
|
|
383
|
-
offset: anchor.offset
|
|
384
|
-
};
|
|
385
|
-
return (_jsx(CursorComponent, { peerId: peerId, position: {
|
|
386
|
-
top: Math.max(position.top, 20),
|
|
387
|
-
left: Math.max(position.left, 20)
|
|
388
|
-
}, color: color, name: displayName, isCurrentUser: isCurrentUser, selection: selection }, peerId));
|
|
389
|
-
}
|
|
390
|
-
catch (error) {
|
|
391
|
-
console.warn('Error creating cursor for peer:', peerId, error);
|
|
392
|
-
return null;
|
|
393
|
-
}
|
|
394
|
-
})
|
|
395
|
-
.filter(Boolean);
|
|
396
|
-
return createPortal(_jsx(_Fragment, { children: cursors }), portalContainer);
|
|
397
|
-
});
|
|
398
|
-
CursorsContainer.displayName = 'CursorsContainer';
|
|
399
|
-
class CursorAwareness {
|
|
400
|
-
ephemeralStore;
|
|
401
|
-
peerId;
|
|
402
|
-
listeners = [];
|
|
403
|
-
loroDoc; // Add reference to Loro document for proper cursor operations
|
|
404
|
-
constructor(peer, loroDoc, timeout = 300_000) {
|
|
405
|
-
this.ephemeralStore = new EphemeralStore(timeout);
|
|
406
|
-
this.peerId = peer.toString();
|
|
407
|
-
this.loroDoc = loroDoc; // Store document reference for stable cursor operations
|
|
408
|
-
// Subscribe to EphemeralStore events with proper event handling
|
|
409
|
-
this.ephemeralStore.subscribe((event) => {
|
|
410
|
-
console.log('🔔 EphemeralStore event received:', {
|
|
411
|
-
by: event.by,
|
|
412
|
-
added: event.added,
|
|
413
|
-
updated: event.updated,
|
|
414
|
-
removed: event.removed
|
|
415
|
-
});
|
|
416
|
-
// Notify all listeners about changes with event details
|
|
417
|
-
this.notifyListeners(event);
|
|
418
|
-
return true; // Continue subscription
|
|
419
|
-
});
|
|
420
|
-
}
|
|
421
|
-
getAll() {
|
|
422
|
-
const ans = {};
|
|
423
|
-
const allStates = this.ephemeralStore.getAllStates();
|
|
424
|
-
for (const [peer, state] of Object.entries(allStates)) {
|
|
425
|
-
const stateData = state;
|
|
426
|
-
try {
|
|
427
|
-
const decodedAnchor = stateData.anchor ? Cursor.decode(stateData.anchor) : undefined;
|
|
428
|
-
const decodedFocus = stateData.focus ? Cursor.decode(stateData.focus) : undefined;
|
|
429
|
-
ans[peer] = {
|
|
430
|
-
anchor: decodedAnchor,
|
|
431
|
-
focus: decodedFocus,
|
|
432
|
-
user: stateData.user ? stateData.user : undefined,
|
|
433
|
-
};
|
|
434
|
-
}
|
|
435
|
-
catch (error) {
|
|
436
|
-
console.warn('Error decoding cursor for peer', peer, error);
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
return ans;
|
|
440
|
-
}
|
|
441
|
-
setLocal(state) {
|
|
442
|
-
this.ephemeralStore.set(this.peerId, {
|
|
443
|
-
anchor: state.anchor?.encode() || null,
|
|
444
|
-
focus: state.focus?.encode() || null,
|
|
445
|
-
user: state.user || null,
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
|
-
getLocal() {
|
|
449
|
-
const state = this.ephemeralStore.get(this.peerId);
|
|
450
|
-
if (!state) {
|
|
451
|
-
return undefined;
|
|
452
|
-
}
|
|
453
|
-
const stateData = state;
|
|
454
|
-
try {
|
|
455
|
-
return {
|
|
456
|
-
anchor: stateData.anchor && Cursor.decode(stateData.anchor),
|
|
457
|
-
focus: stateData.focus && Cursor.decode(stateData.focus),
|
|
458
|
-
user: stateData.user,
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
catch (error) {
|
|
462
|
-
console.warn('Error decoding local cursor:', error);
|
|
463
|
-
return undefined;
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
getLocalState() {
|
|
467
|
-
const state = this.ephemeralStore.get(this.peerId);
|
|
468
|
-
if (!state)
|
|
469
|
-
return null;
|
|
470
|
-
const stateData = state;
|
|
471
|
-
return {
|
|
472
|
-
anchor: stateData.anchor || null,
|
|
473
|
-
focus: stateData.focus || null,
|
|
474
|
-
user: stateData.user || null,
|
|
475
|
-
};
|
|
476
|
-
}
|
|
477
|
-
setRemoteState(peerId, state) {
|
|
478
|
-
console.log('Setting remote state for peer:', peerId, state);
|
|
479
|
-
try {
|
|
480
|
-
if (state === null || (state.anchor === null && state.focus === null)) {
|
|
481
|
-
this.ephemeralStore.delete(peerId.toString());
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
// Store the raw state in EphemeralStore
|
|
485
|
-
this.ephemeralStore.set(peerId.toString(), {
|
|
486
|
-
anchor: state.anchor,
|
|
487
|
-
focus: state.focus,
|
|
488
|
-
user: state.user
|
|
489
|
-
});
|
|
490
|
-
// Validate and decode cursor data safely for callback
|
|
491
|
-
let anchor;
|
|
492
|
-
let focus;
|
|
493
|
-
if (state.anchor && state.anchor.length > 0) {
|
|
494
|
-
try {
|
|
495
|
-
anchor = Cursor.decode(state.anchor);
|
|
496
|
-
}
|
|
497
|
-
catch (error) {
|
|
498
|
-
console.warn('Failed to decode anchor cursor:', error);
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
if (state.focus && state.focus.length > 0) {
|
|
502
|
-
try {
|
|
503
|
-
focus = Cursor.decode(state.focus);
|
|
504
|
-
}
|
|
505
|
-
catch (error) {
|
|
506
|
-
console.warn('Failed to decode focus cursor:', error);
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
if (anchor || focus) {
|
|
510
|
-
// The awareness callback will handle the cursor conversion
|
|
511
|
-
// Just trigger a notification that this peer's cursor has changed
|
|
512
|
-
setTimeout(() => {
|
|
513
|
-
// Force the awareness callback to run by notifying listeners
|
|
514
|
-
this.notifyListeners();
|
|
515
|
-
}, 0);
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
catch (error) {
|
|
519
|
-
console.error('Error processing remote state:', error);
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
// Add methods for compatibility with existing code
|
|
523
|
-
addListener(callback) {
|
|
524
|
-
this.listeners.push(callback);
|
|
525
|
-
}
|
|
526
|
-
removeListener(callback) {
|
|
527
|
-
const index = this.listeners.indexOf(callback);
|
|
528
|
-
if (index > -1) {
|
|
529
|
-
this.listeners.splice(index, 1);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
notifyListeners(event) {
|
|
533
|
-
const states = new Map();
|
|
534
|
-
const allStates = this.ephemeralStore.getAllStates();
|
|
535
|
-
for (const [peer, state] of Object.entries(allStates)) {
|
|
536
|
-
states.set(peer, state);
|
|
537
|
-
}
|
|
538
|
-
this.listeners.forEach(listener => listener(states, event));
|
|
539
|
-
}
|
|
540
|
-
// Get encoded data for network transmission
|
|
541
|
-
encode() {
|
|
542
|
-
return this.ephemeralStore.encodeAll();
|
|
543
|
-
}
|
|
544
|
-
// Apply received encoded data
|
|
545
|
-
apply(data) {
|
|
546
|
-
this.ephemeralStore.apply(data);
|
|
547
|
-
// Trigger listeners after applying external data
|
|
548
|
-
this.notifyListeners();
|
|
549
|
-
}
|
|
550
|
-
setRemoteCursorCallback(callback) {
|
|
551
|
-
this._onRemoteCursorUpdate = callback;
|
|
552
|
-
}
|
|
553
|
-
// Simplified cursor creation from Lexical point (inspired by YJS createRelativePosition)
|
|
554
|
-
// Loro Cursor = container ID + character ID, much simpler than YJS RelativePosition
|
|
555
|
-
createLoroPosition(nodeKey, offset, textContainer) {
|
|
556
|
-
try {
|
|
557
|
-
if (!this.loroDoc || !textContainer) {
|
|
558
|
-
console.warn('❌ No Loro document or text container available');
|
|
559
|
-
return null;
|
|
560
|
-
}
|
|
561
|
-
// SIMPLIFIED APPROACH: For Loro, we just need the global text position
|
|
562
|
-
// Loro will handle the container ID + character ID mapping internally
|
|
563
|
-
const globalPosition = this.calculateSimpleGlobalPosition(nodeKey, offset);
|
|
564
|
-
// Let Loro create the cursor with its internal container+character structure
|
|
565
|
-
const cursor = textContainer.getCursor(globalPosition);
|
|
566
|
-
console.log('🎯 Created Loro cursor:', {
|
|
567
|
-
nodeKey,
|
|
568
|
-
offset,
|
|
569
|
-
globalPosition,
|
|
570
|
-
cursorCreated: !!cursor
|
|
571
|
-
});
|
|
572
|
-
return cursor || null;
|
|
573
|
-
}
|
|
574
|
-
catch (error) {
|
|
575
|
-
console.warn('❌ Failed to create Loro position:', error);
|
|
576
|
-
return null;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
// Simplified position calculation (much simpler than YJS approach)
|
|
580
|
-
calculateSimpleGlobalPosition(nodeKey, offset) {
|
|
581
|
-
// For Loro, we don't need complex CollabNode mapping like YJS
|
|
582
|
-
// Just calculate the simple global text position
|
|
583
|
-
// This is much simpler because Loro handles container+character mapping internally
|
|
584
|
-
// TODO: Implement simple document traversal
|
|
585
|
-
// For now, return a basic position - this would be implemented with:
|
|
586
|
-
// 1. Find the text node in the document
|
|
587
|
-
// 2. Calculate its start position
|
|
588
|
-
// 3. Add the offset within that node
|
|
589
|
-
console.log('🔄 Calculating simple position for Loro cursor:', { nodeKey, offset });
|
|
590
|
-
return 0; // Placeholder for simplified implementation
|
|
591
|
-
}
|
|
592
|
-
// Debug method to access raw ephemeral store data
|
|
593
|
-
getRawStates() {
|
|
594
|
-
return this.ephemeralStore.getAllStates();
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
/**
|
|
598
|
-
* LoroCollaborativePlugin - Enhanced Cursor Management
|
|
599
|
-
*
|
|
600
|
-
* IMPROVEMENTS IMPLEMENTED based on Loro Cursor documentation and YJS SyncCursors patterns:
|
|
601
|
-
*
|
|
602
|
-
* 1. Enhanced CursorAwareness class with Loro document reference
|
|
603
|
-
* - Added loroDoc parameter for proper cursor operations
|
|
604
|
-
* - Provides framework for stable cursor positioning
|
|
605
|
-
*
|
|
606
|
-
* 2. Added createCursorFromLexicalPoint method
|
|
607
|
-
* - Inspired by YJS SyncCursors createRelativePosition pattern
|
|
608
|
-
* - Creates stable Loro cursors from Lexical selection points
|
|
609
|
-
* - Replaces approximation with proper cursor positioning
|
|
610
|
-
*
|
|
611
|
-
* 3. Added getStableCursorPosition method
|
|
612
|
-
* - Inspired by YJS SyncCursors createAbsolutePosition pattern
|
|
613
|
-
* - Converts Loro cursors back to stable positions
|
|
614
|
-
* - Provides better positioning than current approximations
|
|
615
|
-
*
|
|
616
|
-
* 4. Enhanced cursor side information support
|
|
617
|
-
* - Added anchorSide and focusSide to stable cursor data
|
|
618
|
-
* - Follows Loro Cursor documentation patterns for precise positioning
|
|
619
|
-
* - Equivalent to YJS RelativePosition side information
|
|
620
|
-
*
|
|
621
|
-
* 5. Improved cursor creation with framework for better methods
|
|
622
|
-
* - Added TODO comments showing enhanced cursor creation approach
|
|
623
|
-
* - Framework ready for using createCursorFromLexicalPoint
|
|
624
|
-
* - Maintains backward compatibility while providing upgrade path
|
|
625
|
-
*
|
|
626
|
-
* 6. Enhanced remote cursor processing
|
|
627
|
-
* - Added support for cursor side information in stable cursor data
|
|
628
|
-
* - Provides framework for direct Loro cursor conversion
|
|
629
|
-
* - Better handling of cursor position stability across edits
|
|
630
|
-
*
|
|
631
|
-
* TECHNICAL APPROACH:
|
|
632
|
-
* - Loro Cursor type is equivalent to YJS RelativePosition (as documented)
|
|
633
|
-
* - Stable positions survive document edits (like YJS RelativePosition)
|
|
634
|
-
* - Cursor side information provides precise positioning
|
|
635
|
-
* - Framework supports proper createRelativePosition/createAbsolutePosition patterns
|
|
636
|
-
*
|
|
637
|
-
* NEXT STEPS for full implementation:
|
|
638
|
-
* - Implement calculateGlobalPosition method with proper document traversal
|
|
639
|
-
* - Add convertGlobalPositionToLexical helper function
|
|
640
|
-
* - Enable the enhanced cursor creation methods by uncommenting TODO sections
|
|
641
|
-
* - Complete the direct Loro cursor conversion path
|
|
642
|
-
*/
|
|
643
|
-
export function LoroCollaborativePlugin({ websocketUrl, docId, onConnectionChange, onDisconnectReady, onPeerIdChange, onAwarenessChange, onInitialization, onSendMessageReady }) {
|
|
644
|
-
const [editor] = useLexicalComposerContext();
|
|
645
|
-
const wsRef = useRef(null);
|
|
646
|
-
const loroDocRef = useRef(new LoroDoc());
|
|
647
|
-
const loroTextRef = useRef(null);
|
|
648
|
-
const isLocalChange = useRef(false);
|
|
649
|
-
const hasReceivedInitialSnapshot = useRef(false);
|
|
650
|
-
// Cursor awareness system
|
|
651
|
-
const awarenessRef = useRef(null);
|
|
652
|
-
// Use a ref instead of state to avoid triggering full plugin re-renders
|
|
653
|
-
const remoteCursorsRef = useRef({});
|
|
654
|
-
// Cursor overlay manager - handles rendering without triggering full plugin re-renders
|
|
655
|
-
const cursorOverlayRef = useRef(null);
|
|
656
|
-
// Update remote cursors and trigger only overlay re-render
|
|
657
|
-
const updateRemoteCursors = useCallback((newCursors) => {
|
|
658
|
-
remoteCursorsRef.current = newCursors;
|
|
659
|
-
cursorOverlayRef.current?.update(newCursors);
|
|
660
|
-
}, []);
|
|
661
|
-
const [clientId, setClientId] = useState('');
|
|
662
|
-
const [clientColor, setClientColor] = useState('');
|
|
663
|
-
const peerIdRef = useRef(''); // Changed from numericPeerIdRef to handle string IDs
|
|
664
|
-
// Incremental update error state
|
|
665
|
-
const [incrementalUpdateError, setIncrementalUpdateError] = useState(null);
|
|
666
|
-
// Version vector state for optimized updates
|
|
667
|
-
const [lastSentVersionVector, setLastSentVersionVector] = useState(null);
|
|
668
|
-
const isConnectingRef = useRef(false);
|
|
669
|
-
// Remove forceUpdate state - no longer needed
|
|
670
|
-
const cursorTimestamps = useRef({});
|
|
671
|
-
const updateLoroFromLexical = useCallback((editorState) => {
|
|
672
|
-
if (!loroTextRef.current) {
|
|
673
|
-
console.warn('🚨 updateLoroFromLexical called but loroTextRef.current is null');
|
|
674
|
-
return;
|
|
675
|
-
}
|
|
676
|
-
let editorStateJson = '';
|
|
677
|
-
editorState.read(() => {
|
|
678
|
-
// Store the raw Lexical EditorState JSON instead of HTML
|
|
679
|
-
const serialized = editorState.toJSON();
|
|
680
|
-
editorStateJson = JSON.stringify(serialized);
|
|
681
|
-
});
|
|
682
|
-
const currentLoroText = loroTextRef.current.toString();
|
|
683
|
-
console.log('🔄 updateLoroFromLexical triggered:', {
|
|
684
|
-
currentLength: currentLoroText.length,
|
|
685
|
-
newLength: editorStateJson.length,
|
|
686
|
-
hasChanged: currentLoroText !== editorStateJson,
|
|
687
|
-
isLocalChange: isLocalChange.current
|
|
688
|
-
});
|
|
689
|
-
if (currentLoroText === editorStateJson) {
|
|
690
|
-
console.log('🔄 No changes detected, skipping update');
|
|
691
|
-
return;
|
|
692
|
-
}
|
|
693
|
-
// Mark this as a local change
|
|
694
|
-
isLocalChange.current = true;
|
|
695
|
-
// FIXED: Use incremental text operations instead of wholesale replacement
|
|
696
|
-
// This prevents massive changes that can cause connection issues
|
|
697
|
-
try {
|
|
698
|
-
// Calculate the difference between current and new content
|
|
699
|
-
const oldContent = currentLoroText;
|
|
700
|
-
const newContent = editorStateJson;
|
|
701
|
-
console.log('🔄 Incremental update starting:', {
|
|
702
|
-
oldLength: oldContent.length,
|
|
703
|
-
newLength: newContent.length,
|
|
704
|
-
oldStart: oldContent.substring(0, 100),
|
|
705
|
-
newStart: newContent.substring(0, 100)
|
|
706
|
-
});
|
|
707
|
-
// Find common prefix and suffix to minimize changes
|
|
708
|
-
let prefixEnd = 0;
|
|
709
|
-
const minLength = Math.min(oldContent.length, newContent.length);
|
|
710
|
-
// Find common prefix
|
|
711
|
-
while (prefixEnd < minLength && oldContent[prefixEnd] === newContent[prefixEnd]) {
|
|
712
|
-
prefixEnd++;
|
|
713
|
-
}
|
|
714
|
-
// Find common suffix
|
|
715
|
-
let suffixStart = oldContent.length;
|
|
716
|
-
let newSuffixStart = newContent.length;
|
|
717
|
-
while (suffixStart > prefixEnd && newSuffixStart > prefixEnd &&
|
|
718
|
-
oldContent[suffixStart - 1] === newContent[newSuffixStart - 1]) {
|
|
719
|
-
suffixStart--;
|
|
720
|
-
newSuffixStart--;
|
|
721
|
-
}
|
|
722
|
-
console.log('🔄 Diff calculation:', {
|
|
723
|
-
prefixEnd,
|
|
724
|
-
suffixStart,
|
|
725
|
-
newSuffixStart,
|
|
726
|
-
deleteLength: suffixStart - prefixEnd,
|
|
727
|
-
insertLength: newSuffixStart - prefixEnd,
|
|
728
|
-
deleteText: oldContent.substring(prefixEnd, suffixStart),
|
|
729
|
-
insertText: newContent.substring(prefixEnd, newSuffixStart)
|
|
730
|
-
});
|
|
731
|
-
// Apply incremental changes
|
|
732
|
-
if (prefixEnd < suffixStart) {
|
|
733
|
-
// Delete the changed portion
|
|
734
|
-
const deleteLength = suffixStart - prefixEnd;
|
|
735
|
-
if (deleteLength > 0) {
|
|
736
|
-
console.log('🗑️ Deleting:', { position: prefixEnd, length: deleteLength });
|
|
737
|
-
loroTextRef.current.delete(prefixEnd, deleteLength);
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
if (prefixEnd < newSuffixStart) {
|
|
741
|
-
// Insert the new content
|
|
742
|
-
const insertText = newContent.substring(prefixEnd, newSuffixStart);
|
|
743
|
-
if (insertText.length > 0) {
|
|
744
|
-
console.log('➕ Inserting:', { position: prefixEnd, text: insertText.substring(0, 100) });
|
|
745
|
-
loroTextRef.current.insert(prefixEnd, insertText);
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
console.log('✅ Incremental update completed successfully');
|
|
749
|
-
}
|
|
750
|
-
catch (error) {
|
|
751
|
-
console.warn('🚨 Error with incremental update, falling back to full replacement:', error);
|
|
752
|
-
console.warn('🚨 Error details:', {
|
|
753
|
-
errorMessage: error instanceof Error ? error.message : String(error),
|
|
754
|
-
errorStack: error instanceof Error ? error.stack : 'No stack trace',
|
|
755
|
-
currentLoroTextLength: currentLoroText.length,
|
|
756
|
-
newContentLength: editorStateJson.length
|
|
757
|
-
});
|
|
758
|
-
// Check if the CRDT text container is still valid
|
|
759
|
-
if (!loroTextRef.current) {
|
|
760
|
-
console.error('🚨 CRDT text container is null during error recovery!');
|
|
761
|
-
return;
|
|
762
|
-
}
|
|
763
|
-
// REMOVED: Wholesale delete/insert fallback to ensure pure incremental updates
|
|
764
|
-
// Instead, log the error and continue with incremental-only approach
|
|
765
|
-
console.error('🚨 Incremental update failed - skipping wholesale fallback to maintain CRDT consistency');
|
|
766
|
-
console.error('🚨 This maintains collaborative editing integrity by avoiding destructive operations');
|
|
767
|
-
// Set error state to show red banner
|
|
768
|
-
setIncrementalUpdateError(`Incremental update failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
769
|
-
// Clear error after 5 seconds
|
|
770
|
-
setTimeout(() => {
|
|
771
|
-
setIncrementalUpdateError(null);
|
|
772
|
-
}, 5000);
|
|
773
|
-
return;
|
|
774
|
-
}
|
|
775
|
-
// Send update to WebSocket server
|
|
776
|
-
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
|
777
|
-
// Use the new export method with version vector optimization
|
|
778
|
-
const currentVersion = loroDocRef.current.version();
|
|
779
|
-
const update = loroDocRef.current.export({
|
|
780
|
-
mode: "update",
|
|
781
|
-
from: lastSentVersionVector || undefined
|
|
782
|
-
});
|
|
783
|
-
// Update the last sent version vector
|
|
784
|
-
setLastSentVersionVector(currentVersion);
|
|
785
|
-
wsRef.current.send(JSON.stringify({
|
|
786
|
-
type: 'loro-update',
|
|
787
|
-
update: Array.from(update),
|
|
788
|
-
docId: docId
|
|
789
|
-
}));
|
|
790
|
-
// NOTE: Removed snapshot sending to ensure pure incremental updates
|
|
791
|
-
// The system now relies entirely on loro-update messages for collaboration
|
|
792
|
-
// Initial snapshots are still sent by the server for new client connections
|
|
793
|
-
}
|
|
794
|
-
// Reset the flag after a delay to prevent infinite loops
|
|
795
|
-
setTimeout(() => {
|
|
796
|
-
isLocalChange.current = false;
|
|
797
|
-
}, 50);
|
|
798
|
-
}, [docId, lastSentVersionVector, setLastSentVersionVector]);
|
|
799
|
-
const updateLexicalFromLoro = useCallback((editor, incoming) => {
|
|
800
|
-
if (isLocalChange.current)
|
|
801
|
-
return; // Don't update if this is a local change
|
|
802
|
-
isLocalChange.current = true;
|
|
803
|
-
let applied = false;
|
|
804
|
-
editor.update(() => {
|
|
805
|
-
const root = $getRoot();
|
|
806
|
-
// Avoid unnecessary updates when the incoming JSON exactly matches current state
|
|
807
|
-
try {
|
|
808
|
-
const currentStateJson = JSON.stringify(editor.getEditorState().toJSON());
|
|
809
|
-
if (incoming === currentStateJson) {
|
|
810
|
-
isLocalChange.current = false;
|
|
811
|
-
return;
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
catch {
|
|
815
|
-
// ignore JSON stringify/compare failure; not critical for update gating
|
|
816
|
-
}
|
|
817
|
-
try {
|
|
818
|
-
if (incoming && incoming.trim().length > 0) {
|
|
819
|
-
// DEBUG: Log the incoming content to see what's causing JSON parsing to fail
|
|
820
|
-
console.log('🔍 updateLexicalFromLoro incoming content length:', incoming.length);
|
|
821
|
-
console.log('🔍 updateLexicalFromLoro incoming preview:', incoming.slice(0, 200) + '...');
|
|
822
|
-
// Try to parse as Lexical EditorState JSON first
|
|
823
|
-
try {
|
|
824
|
-
console.log('🔍 About to parse JSON - final length check:', incoming.length);
|
|
825
|
-
console.log('🔍 Content ending before parse:', '...' + incoming.slice(-200));
|
|
826
|
-
console.log('🔍 Content character codes near end:', incoming.slice(-10).split('').map(c => c.charCodeAt(0)));
|
|
827
|
-
const parsed = JSON.parse(incoming);
|
|
828
|
-
console.log('✅ JSON parsing successful, parsed type:', typeof parsed);
|
|
829
|
-
console.log('✅ Parsed structure:', {
|
|
830
|
-
hasRoot: !!parsed.root,
|
|
831
|
-
hasEditorState: !!parsed.editorState,
|
|
832
|
-
rootType: parsed.root?.type,
|
|
833
|
-
children: parsed.root?.children?.length
|
|
834
|
-
});
|
|
835
|
-
// Only support direct Lexical EditorState format: {"root": {...}}
|
|
836
|
-
// This standardizes the format and prevents confusion between wrapped/unwrapped formats
|
|
837
|
-
const stateLike = parsed; // Always use the parsed object directly
|
|
838
|
-
if (stateLike && typeof stateLike === 'object' && stateLike.root && stateLike.root.type === 'root') {
|
|
839
|
-
// Use differential updates to prevent YouTube nodes from reloading
|
|
840
|
-
if (USE_DIFFERENTIAL_UPDATE) {
|
|
841
|
-
const success = applyDifferentialUpdate(editor, stateLike, 'WebSocket update');
|
|
842
|
-
if (success) {
|
|
843
|
-
applied = true;
|
|
844
|
-
console.log('✅ Successfully applied JSON as differential Lexical state update');
|
|
845
|
-
}
|
|
846
|
-
else {
|
|
847
|
-
console.log('❌ Differential update failed, falling back to setEditorState');
|
|
848
|
-
const newEditorState = editor.parseEditorState(stateLike);
|
|
849
|
-
editor.setEditorState(newEditorState);
|
|
850
|
-
applied = true;
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
else {
|
|
854
|
-
// Fallback to wholesale setEditorState when differential updates disabled
|
|
855
|
-
const newEditorState = editor.parseEditorState(stateLike);
|
|
856
|
-
editor.setEditorState(newEditorState);
|
|
857
|
-
applied = true;
|
|
858
|
-
console.log('✅ Applied JSON using setEditorState (differential updates disabled)');
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
else {
|
|
862
|
-
console.log('❌ JSON structure invalid for Lexical:', {
|
|
863
|
-
stateLike: typeof stateLike,
|
|
864
|
-
hasRoot: !!stateLike?.root,
|
|
865
|
-
rootType: stateLike?.root?.type
|
|
866
|
-
});
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
catch (parseError) {
|
|
870
|
-
console.log('❌ JSON parsing failed:', parseError);
|
|
871
|
-
console.log('❌ Content that failed to parse:', incoming.slice(0, 500));
|
|
872
|
-
// Extract error position for detailed analysis
|
|
873
|
-
const errorMessage = parseError instanceof Error ? parseError.message : String(parseError);
|
|
874
|
-
const errorMatch = errorMessage.match(/position (\d+)/);
|
|
875
|
-
if (errorMatch) {
|
|
876
|
-
const errorPos = parseInt(errorMatch[1]);
|
|
877
|
-
console.log('❌ Error position analysis:', {
|
|
878
|
-
errorPosition: errorPos,
|
|
879
|
-
totalLength: incoming.length,
|
|
880
|
-
characterAtError: incoming[errorPos] || 'undefined',
|
|
881
|
-
charCodeAtError: incoming.charCodeAt(errorPos) || 'undefined',
|
|
882
|
-
contextBefore: incoming.slice(Math.max(0, errorPos - 50), errorPos),
|
|
883
|
-
contextAfter: incoming.slice(errorPos, errorPos + 50)
|
|
884
|
-
});
|
|
885
|
-
}
|
|
886
|
-
// Check if this looks like JSON concatenation
|
|
887
|
-
if (incoming.includes('}{')) {
|
|
888
|
-
console.log('🚨 FOUND JSON CONCATENATION! Multiple JSON objects detected');
|
|
889
|
-
const jsonObjects = incoming.split('}{');
|
|
890
|
-
console.log('🚨 Number of concatenated objects:', jsonObjects.length);
|
|
891
|
-
// Try to parse each JSON object and use the most complete/recent one
|
|
892
|
-
let bestParsedObject = null;
|
|
893
|
-
let bestObjectIndex = -1;
|
|
894
|
-
for (let i = 0; i < jsonObjects.length; i++) {
|
|
895
|
-
try {
|
|
896
|
-
let objectToTry;
|
|
897
|
-
if (i === 0) {
|
|
898
|
-
// First object: add closing brace
|
|
899
|
-
objectToTry = jsonObjects[i] + '}';
|
|
900
|
-
}
|
|
901
|
-
else if (i === jsonObjects.length - 1) {
|
|
902
|
-
// Last object: add opening brace
|
|
903
|
-
objectToTry = '{' + jsonObjects[i];
|
|
904
|
-
}
|
|
905
|
-
else {
|
|
906
|
-
// Middle objects: add both braces
|
|
907
|
-
objectToTry = '{' + jsonObjects[i] + '}';
|
|
908
|
-
}
|
|
909
|
-
console.log(`🔧 Attempting to parse JSON object ${i}:`, objectToTry.slice(0, 100) + '...');
|
|
910
|
-
const parsed = JSON.parse(objectToTry);
|
|
911
|
-
// Check if this looks like a valid Lexical state
|
|
912
|
-
const stateLike = (parsed && typeof parsed === 'object' && parsed.editorState)
|
|
913
|
-
? parsed.editorState
|
|
914
|
-
: parsed;
|
|
915
|
-
if (stateLike && typeof stateLike === 'object' && stateLike.root && stateLike.root.type === 'root') {
|
|
916
|
-
console.log(`✅ Object ${i} has valid Lexical structure with ${stateLike.root.children?.length || 0} children`);
|
|
917
|
-
// Prefer objects with more content (more children nodes)
|
|
918
|
-
const childrenCount = stateLike.root.children?.length || 0;
|
|
919
|
-
const previousBestCount = bestParsedObject?.root?.children?.length || 0;
|
|
920
|
-
if (!bestParsedObject || childrenCount >= previousBestCount) {
|
|
921
|
-
bestParsedObject = stateLike;
|
|
922
|
-
bestObjectIndex = i;
|
|
923
|
-
console.log(`🎯 Object ${i} is now the best candidate (${childrenCount} children vs ${previousBestCount})`);
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
else {
|
|
927
|
-
console.log(`❌ Object ${i} structure invalid for Lexical:`, {
|
|
928
|
-
stateLike: typeof stateLike,
|
|
929
|
-
hasRoot: !!stateLike?.root,
|
|
930
|
-
rootType: stateLike?.root?.type
|
|
931
|
-
});
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
catch (objectError) {
|
|
935
|
-
console.log(`❌ Failed to parse JSON object ${i}:`, objectError);
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
if (bestParsedObject) {
|
|
939
|
-
try {
|
|
940
|
-
// Use differential updates for concatenated JSON recovery too
|
|
941
|
-
if (USE_DIFFERENTIAL_UPDATE) {
|
|
942
|
-
const success = applyDifferentialUpdate(editor, bestParsedObject, 'JSON recovery');
|
|
943
|
-
if (success) {
|
|
944
|
-
applied = true;
|
|
945
|
-
console.log(`✅ Successfully applied JSON object ${bestObjectIndex} as differential update (most complete)`);
|
|
946
|
-
}
|
|
947
|
-
else {
|
|
948
|
-
console.log('❌ Differential update failed in JSON recovery, falling back to setEditorState');
|
|
949
|
-
const newEditorState = editor.parseEditorState(bestParsedObject);
|
|
950
|
-
editor.setEditorState(newEditorState);
|
|
951
|
-
applied = true;
|
|
952
|
-
}
|
|
953
|
-
}
|
|
954
|
-
else {
|
|
955
|
-
// Fallback to wholesale setEditorState when differential updates disabled
|
|
956
|
-
const newEditorState = editor.parseEditorState(bestParsedObject);
|
|
957
|
-
editor.setEditorState(newEditorState);
|
|
958
|
-
applied = true;
|
|
959
|
-
console.log(`✅ Applied JSON object ${bestObjectIndex} using setEditorState (differential updates disabled)`);
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
catch (applyError) {
|
|
963
|
-
console.log('❌ Failed to apply best JSON object to editor:', applyError);
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
else {
|
|
967
|
-
console.log('❌ No valid Lexical state found in any concatenated JSON object');
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
if (!applied) {
|
|
971
|
-
// Not JSON; will treat as plain text below
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
if (!applied) {
|
|
975
|
-
// Treat incoming as plain text (e.g., from Python server)
|
|
976
|
-
root.clear();
|
|
977
|
-
const lines = incoming.split(/\r?\n/);
|
|
978
|
-
if (lines.length === 0) {
|
|
979
|
-
const p = $createParagraphNode();
|
|
980
|
-
root.append(p);
|
|
981
|
-
}
|
|
982
|
-
else {
|
|
983
|
-
for (const line of lines) {
|
|
984
|
-
const p = $createParagraphNode();
|
|
985
|
-
if (line.length > 0) {
|
|
986
|
-
p.append($createTextNode(line));
|
|
987
|
-
}
|
|
988
|
-
root.append(p);
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
applied = true;
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
else {
|
|
995
|
-
// Empty content -> ensure there's one empty paragraph
|
|
996
|
-
root.clear();
|
|
997
|
-
const paragraph = $createParagraphNode();
|
|
998
|
-
root.append(paragraph);
|
|
999
|
-
applied = true;
|
|
1000
|
-
}
|
|
1001
|
-
// Defer UUID assignment to a follow-up update to avoid frozen node map mutations
|
|
1002
|
-
}
|
|
1003
|
-
catch (error) {
|
|
1004
|
-
console.error('Error applying incoming content to Lexical editor:', error);
|
|
1005
|
-
// Fallback: create a single empty paragraph
|
|
1006
|
-
root.clear();
|
|
1007
|
-
const paragraph = $createParagraphNode();
|
|
1008
|
-
root.append(paragraph);
|
|
1009
|
-
}
|
|
1010
|
-
}, { tag: 'collaboration' });
|
|
1011
|
-
if (applied) {
|
|
1012
|
-
// Ensure the previous update is committed before assigning UUIDs
|
|
1013
|
-
setTimeout(() => {
|
|
1014
|
-
editor.update(() => {
|
|
1015
|
-
try {
|
|
1016
|
-
$ensureAllNodesHaveStableIds();
|
|
1017
|
-
console.log('🆔 Assigned stable UUIDs after applying incoming content');
|
|
1018
|
-
}
|
|
1019
|
-
catch (e) {
|
|
1020
|
-
console.warn('⚠️ Failed to assign stable UUIDs in deferred update:', e);
|
|
1021
|
-
}
|
|
1022
|
-
}, { tag: 'uuid-assignment' });
|
|
1023
|
-
}, 0);
|
|
1024
|
-
}
|
|
1025
|
-
// Reset the flag after a short delay
|
|
1026
|
-
setTimeout(() => {
|
|
1027
|
-
isLocalChange.current = false;
|
|
1028
|
-
}, 50);
|
|
1029
|
-
}, []);
|
|
1030
|
-
// Send cursor position using Awareness
|
|
1031
|
-
const updateCursorAwareness = useCallback(() => {
|
|
1032
|
-
if (!awarenessRef.current || !loroTextRef.current)
|
|
1033
|
-
return;
|
|
1034
|
-
editor.getEditorState().read(() => {
|
|
1035
|
-
const selection = $getSelection();
|
|
1036
|
-
if ($isRangeSelection(selection)) {
|
|
1037
|
-
try {
|
|
1038
|
-
// =================================================================
|
|
1039
|
-
// NEW STABLE UUID APPROACH - Replace unstable NodeKeys
|
|
1040
|
-
// =================================================================
|
|
1041
|
-
// Create stable positions using UUIDs instead of NodeKeys
|
|
1042
|
-
const anchorStablePos = $createStablePositionFromPoint({
|
|
1043
|
-
key: selection.anchor.key,
|
|
1044
|
-
offset: selection.anchor.offset
|
|
1045
|
-
});
|
|
1046
|
-
const focusStablePos = $createStablePositionFromPoint({
|
|
1047
|
-
key: selection.focus.key,
|
|
1048
|
-
offset: selection.focus.offset
|
|
1049
|
-
});
|
|
1050
|
-
if (!anchorStablePos || !focusStablePos) {
|
|
1051
|
-
console.warn('❌ Failed to create stable positions');
|
|
1052
|
-
return;
|
|
1053
|
-
}
|
|
1054
|
-
console.log('🎯 Created stable UUID-based positions:', {
|
|
1055
|
-
anchor: anchorStablePos,
|
|
1056
|
-
focus: focusStablePos
|
|
1057
|
-
});
|
|
1058
|
-
// LEGACY APPROACH for Loro cursor creation (still needed for now)
|
|
1059
|
-
// Create Loro cursors using the resolved NodeKeys
|
|
1060
|
-
const anchorKey = selection.anchor.key;
|
|
1061
|
-
const anchorOffset = selection.anchor.offset;
|
|
1062
|
-
const focusKey = selection.focus.key;
|
|
1063
|
-
const focusOffset = selection.focus.offset;
|
|
1064
|
-
const anchor = awarenessRef.current.createLoroPosition(anchorKey, anchorOffset, loroTextRef.current);
|
|
1065
|
-
const focus = awarenessRef.current.createLoroPosition(focusKey, focusOffset, loroTextRef.current);
|
|
1066
|
-
if (!anchor || !focus) {
|
|
1067
|
-
console.warn('❌ Failed to create Loro cursors');
|
|
1068
|
-
return;
|
|
1069
|
-
}
|
|
1070
|
-
console.log('🎯 Created Loro cursors with stable position data:', {
|
|
1071
|
-
anchorStableId: anchorStablePos.stableNodeId,
|
|
1072
|
-
focusStableId: focusStablePos.stableNodeId,
|
|
1073
|
-
anchorCreated: !!anchor,
|
|
1074
|
-
focusCreated: !!focus
|
|
1075
|
-
});
|
|
1076
|
-
// Extract meaningful part from client ID
|
|
1077
|
-
const extractedId = clientId.includes('_') ?
|
|
1078
|
-
clientId.split('_').find(part => /^\d{13}$/.test(part)) || clientId.slice(-8) :
|
|
1079
|
-
clientId.slice(-8);
|
|
1080
|
-
// ENHANCED: Store stable UUID-based cursor data instead of NodeKeys
|
|
1081
|
-
const userWithCursorData = {
|
|
1082
|
-
name: extractedId,
|
|
1083
|
-
color: clientColor || '#007acc',
|
|
1084
|
-
// NEW: Use stable UUIDs that survive document edits
|
|
1085
|
-
stableCursor: {
|
|
1086
|
-
// Store stable UUIDs instead of unstable NodeKeys
|
|
1087
|
-
anchorStableId: anchorStablePos.stableNodeId,
|
|
1088
|
-
anchorOffset: anchorStablePos.offset,
|
|
1089
|
-
anchorType: anchorStablePos.type,
|
|
1090
|
-
focusStableId: focusStablePos.stableNodeId,
|
|
1091
|
-
focusOffset: focusStablePos.offset,
|
|
1092
|
-
focusType: focusStablePos.type,
|
|
1093
|
-
timestamp: Date.now()
|
|
1094
|
-
}
|
|
1095
|
-
};
|
|
1096
|
-
awarenessRef.current.setLocal({
|
|
1097
|
-
anchor,
|
|
1098
|
-
focus,
|
|
1099
|
-
user: userWithCursorData
|
|
1100
|
-
});
|
|
1101
|
-
console.log('🎯 Set awareness with stable cursor data:', { userWithCursorData, clientId });
|
|
1102
|
-
// Send ephemeral update to other clients via WebSocket
|
|
1103
|
-
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && awarenessRef.current) {
|
|
1104
|
-
try {
|
|
1105
|
-
const ephemeralData = awarenessRef.current.encode();
|
|
1106
|
-
// Validate ephemeral data before sending
|
|
1107
|
-
if (!ephemeralData || ephemeralData.length === 0) {
|
|
1108
|
-
console.warn('⚠️ Empty ephemeral data, skipping send');
|
|
1109
|
-
return;
|
|
1110
|
-
}
|
|
1111
|
-
const hexData = Array.from(ephemeralData).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
1112
|
-
// Validate hex data
|
|
1113
|
-
if (!hexData || hexData.length === 0) {
|
|
1114
|
-
console.warn('⚠️ Empty hex data, skipping send');
|
|
1115
|
-
return;
|
|
1116
|
-
}
|
|
1117
|
-
wsRef.current.send(JSON.stringify({
|
|
1118
|
-
type: 'ephemeral-update',
|
|
1119
|
-
docId: docId,
|
|
1120
|
-
data: hexData // Convert to hex string
|
|
1121
|
-
}));
|
|
1122
|
-
console.log('📤 Sent ephemeral update:', { docId, dataLength: hexData.length });
|
|
1123
|
-
}
|
|
1124
|
-
catch (error) {
|
|
1125
|
-
console.error('❌ Error encoding/sending ephemeral data:', error);
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
catch (error) {
|
|
1130
|
-
console.warn('Error creating cursor:', error);
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
});
|
|
1134
|
-
}, [editor, clientId, clientColor, docId]);
|
|
1135
|
-
useEffect(() => {
|
|
1136
|
-
// Initialize Loro document and text object - always use "content" container
|
|
1137
|
-
loroTextRef.current = loroDocRef.current.getText("content");
|
|
1138
|
-
// Only initialize awareness if it doesn't exist yet
|
|
1139
|
-
if (!awarenessRef.current) {
|
|
1140
|
-
// Initialize cursor awareness with a temporary numeric ID
|
|
1141
|
-
// We'll update this with the actual client ID when we receive the welcome message
|
|
1142
|
-
const tempNumericId = Date.now(); // Temporary ID until we get the real client ID
|
|
1143
|
-
peerIdRef.current = tempNumericId.toString();
|
|
1144
|
-
awarenessRef.current = new CursorAwareness(tempNumericId.toString(), loroDocRef.current);
|
|
1145
|
-
console.log('🎯 Initializing awareness with temporary numeric ID:', tempNumericId, '(will be updated with client ID)');
|
|
1146
|
-
}
|
|
1147
|
-
else {
|
|
1148
|
-
console.log('🎯 Awareness already exists, skipping initialization');
|
|
1149
|
-
}
|
|
1150
|
-
// Subscribe to awareness changes with event-aware callback
|
|
1151
|
-
const awarenessCallback = (_states, event) => {
|
|
1152
|
-
console.log('🚨 AWARENESS CALLBACK TRIGGERED!', {
|
|
1153
|
-
event: event ? {
|
|
1154
|
-
by: event.by,
|
|
1155
|
-
added: event.added,
|
|
1156
|
-
updated: event.updated,
|
|
1157
|
-
removed: event.removed
|
|
1158
|
-
} : 'no event',
|
|
1159
|
-
statesSize: _states?.size,
|
|
1160
|
-
timestamp: Date.now()
|
|
1161
|
-
});
|
|
1162
|
-
if (awarenessRef.current) {
|
|
1163
|
-
const allCursors = awarenessRef.current.getAll();
|
|
1164
|
-
const remoteCursorsData = {};
|
|
1165
|
-
const currentPeerId = peerIdRef.current || clientId;
|
|
1166
|
-
console.log('👁️ Awareness callback - all cursors:', allCursors);
|
|
1167
|
-
console.log('👁️ Current peer ID:', currentPeerId);
|
|
1168
|
-
console.log('👁️ All cursor peer IDs:', Object.keys(allCursors));
|
|
1169
|
-
// Debug: Check raw ephemeral store data
|
|
1170
|
-
const rawStates = awarenessRef.current.getRawStates();
|
|
1171
|
-
console.log('👁️ Raw ephemeral store states:', rawStates);
|
|
1172
|
-
console.log('👁️ Awareness callback triggered:', {
|
|
1173
|
-
event: event ? {
|
|
1174
|
-
by: event.by,
|
|
1175
|
-
added: event.added,
|
|
1176
|
-
updated: event.updated,
|
|
1177
|
-
removed: event.removed
|
|
1178
|
-
} : 'no event',
|
|
1179
|
-
allCursorsKeys: Object.keys(allCursors),
|
|
1180
|
-
allCursorsDetail: allCursors,
|
|
1181
|
-
currentPeerId: currentPeerId,
|
|
1182
|
-
clientId: clientId
|
|
1183
|
-
});
|
|
1184
|
-
// CRITICAL DEBUG: Check if we have remote cursors before processing
|
|
1185
|
-
const remoteCursorsBefore = Object.keys(allCursors).filter(peerId => peerId !== currentPeerId);
|
|
1186
|
-
console.log('🔍 Remote cursors BEFORE processing:', remoteCursorsBefore);
|
|
1187
|
-
console.log('🔍 ALL CURSORS DATA:', allCursors);
|
|
1188
|
-
console.log('🔍 TOTAL CURSORS COUNT:', Object.keys(allCursors).length);
|
|
1189
|
-
// Use event information to optimize cursor processing
|
|
1190
|
-
let peersToProcess = [];
|
|
1191
|
-
if (event) {
|
|
1192
|
-
console.log('🔍 DETAILED EVENT ANALYSIS:', {
|
|
1193
|
-
eventBy: event.by,
|
|
1194
|
-
isImportEvent: event.by === 'import',
|
|
1195
|
-
isImportEventCaseInsensitive: event.by?.toLowerCase() === 'import',
|
|
1196
|
-
removedCount: event.removed?.length || 0,
|
|
1197
|
-
removedPeers: event.removed || [],
|
|
1198
|
-
addedCount: event.added?.length || 0,
|
|
1199
|
-
updatedCount: event.updated?.length || 0
|
|
1200
|
-
});
|
|
1201
|
-
// Check if this is a local event (our own cursor update)
|
|
1202
|
-
const isLocalEvent = event.by === 'local' || event.by === currentPeerId;
|
|
1203
|
-
const isImportEvent = event.by === 'import' || event.by?.toLowerCase() === 'import';
|
|
1204
|
-
if (isLocalEvent) {
|
|
1205
|
-
console.log('👁️ Local event detected - processing all cursors to ensure remote cursors remain visible');
|
|
1206
|
-
// For local events, process all cursors to maintain remote cursor visibility
|
|
1207
|
-
peersToProcess = Object.keys(allCursors);
|
|
1208
|
-
}
|
|
1209
|
-
else if (isImportEvent) {
|
|
1210
|
-
console.log('👁️ Import event detected - processing all current cursors to maintain visibility');
|
|
1211
|
-
// For import events, process all current cursors to maintain remote cursor visibility
|
|
1212
|
-
// Import events often have misleading added/updated arrays
|
|
1213
|
-
peersToProcess = Object.keys(allCursors);
|
|
1214
|
-
}
|
|
1215
|
-
else {
|
|
1216
|
-
console.log('👁️ Remote event detected - processing only changed peers');
|
|
1217
|
-
// For other remote events, process only the peers that changed
|
|
1218
|
-
peersToProcess = [...event.added, ...event.updated];
|
|
1219
|
-
}
|
|
1220
|
-
console.log('👁️ Event-driven processing - peers to process:', peersToProcess);
|
|
1221
|
-
// CRITICAL FIX: Be much more conservative about removals
|
|
1222
|
-
// Only remove cursors if they're not in the current allCursors AND
|
|
1223
|
-
// this is not an "import" event (which often has false removals)
|
|
1224
|
-
if (event.removed && event.removed.length > 0) {
|
|
1225
|
-
console.log('🔍 REMOVAL EVENT ANALYSIS:', {
|
|
1226
|
-
eventBy: event.by,
|
|
1227
|
-
isImport: event.by?.toLowerCase() === 'import',
|
|
1228
|
-
isImportLowercase: event.by?.toLowerCase() === 'import',
|
|
1229
|
-
removedPeers: event.removed,
|
|
1230
|
-
shouldIgnore: event.by?.toLowerCase() === 'import'
|
|
1231
|
-
});
|
|
1232
|
-
if (event.by?.toLowerCase() === 'import') {
|
|
1233
|
-
console.log('👁️ 🚫 IGNORING import-based removal events (often false positives):', event.removed);
|
|
1234
|
-
// Don't process removals for import events - they're usually false positives
|
|
1235
|
-
}
|
|
1236
|
-
else {
|
|
1237
|
-
console.log('👁️ Processing potential removals for peers (non-import event):', event.removed);
|
|
1238
|
-
const currentAllCursors = awarenessRef.current.getAll();
|
|
1239
|
-
const currentPeerIds = Object.keys(currentAllCursors);
|
|
1240
|
-
console.log('🔍 REMOVAL VALIDATION:', {
|
|
1241
|
-
removedPeers: event.removed,
|
|
1242
|
-
currentPeerIds: currentPeerIds,
|
|
1243
|
-
peerStillExists: event.removed.map(peerId => ({
|
|
1244
|
-
peerId,
|
|
1245
|
-
stillExists: currentPeerIds.includes(peerId)
|
|
1246
|
-
}))
|
|
1247
|
-
});
|
|
1248
|
-
event.removed.forEach(peerId => {
|
|
1249
|
-
// Only remove if the peer is truly no longer in the awareness state
|
|
1250
|
-
if (!currentPeerIds.includes(peerId)) {
|
|
1251
|
-
console.log('👁️ ✅ Confirmed removal - peer not in current state:', peerId);
|
|
1252
|
-
const updated = { ...remoteCursorsRef.current };
|
|
1253
|
-
delete updated[peerId];
|
|
1254
|
-
updateRemoteCursors(updated);
|
|
1255
|
-
console.log('👁️ Removed peer from remote cursors:', peerId);
|
|
1256
|
-
// Clear cursor timestamps
|
|
1257
|
-
delete cursorTimestamps.current[peerId];
|
|
1258
|
-
}
|
|
1259
|
-
else {
|
|
1260
|
-
console.log('👁️ ❌ Ignoring removal - peer still in current state:', peerId);
|
|
1261
|
-
}
|
|
1262
|
-
});
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
// Don't force reprocessing of all peers, just continue with the event-driven processing
|
|
1266
|
-
}
|
|
1267
|
-
else {
|
|
1268
|
-
// No event info, process all cursors
|
|
1269
|
-
peersToProcess = Object.keys(allCursors);
|
|
1270
|
-
console.log('👁️ Full processing - all peers:', peersToProcess);
|
|
1271
|
-
}
|
|
1272
|
-
// Process the relevant peers
|
|
1273
|
-
console.log('🔍 PEER PROCESSING START:', {
|
|
1274
|
-
peersToProcess,
|
|
1275
|
-
totalPeersInAllCursors: Object.keys(allCursors).length,
|
|
1276
|
-
currentPeerId
|
|
1277
|
-
});
|
|
1278
|
-
peersToProcess.forEach(peerId => {
|
|
1279
|
-
const cursorData = allCursors[peerId];
|
|
1280
|
-
console.log('🔍 Processing peer:', peerId, {
|
|
1281
|
-
hasData: !!cursorData,
|
|
1282
|
-
isCurrentUser: peerId === currentPeerId,
|
|
1283
|
-
cursorData: cursorData ? {
|
|
1284
|
-
hasAnchor: !!cursorData.anchor,
|
|
1285
|
-
hasFocus: !!cursorData.focus,
|
|
1286
|
-
hasUser: !!cursorData.user
|
|
1287
|
-
} : 'NO DATA'
|
|
1288
|
-
});
|
|
1289
|
-
if (!cursorData) {
|
|
1290
|
-
console.log('⚠️ No cursor data for peer:', peerId);
|
|
1291
|
-
return;
|
|
1292
|
-
}
|
|
1293
|
-
// Only exclude our own cursor (using current peer ID)
|
|
1294
|
-
if (peerId !== currentPeerId) {
|
|
1295
|
-
console.log('👁️ Processing remote cursor for peer:', peerId, {
|
|
1296
|
-
hasAnchor: !!cursorData.anchor,
|
|
1297
|
-
hasFocus: !!cursorData.focus,
|
|
1298
|
-
hasUser: !!cursorData.user,
|
|
1299
|
-
hasStableCursor: !!cursorData.user?.stableCursor,
|
|
1300
|
-
user: cursorData.user
|
|
1301
|
-
});
|
|
1302
|
-
let anchorPos;
|
|
1303
|
-
let focusPos;
|
|
1304
|
-
// Check if we have stable cursor data in user metadata (preferred)
|
|
1305
|
-
const stableCursor = cursorData.user?.stableCursor;
|
|
1306
|
-
// =================================================================
|
|
1307
|
-
// NEW STABLE UUID RESOLUTION - Replace NodeKey validation
|
|
1308
|
-
// =================================================================
|
|
1309
|
-
if (stableCursor && stableCursor.anchorStableId && stableCursor.focusStableId) {
|
|
1310
|
-
console.log('👁️ Using NEW stable UUID-based cursor data:', stableCursor);
|
|
1311
|
-
// Use stable UUIDs to resolve positions
|
|
1312
|
-
const anchorResolved = editor.getEditorState().read(() => {
|
|
1313
|
-
return $resolveStablePosition({
|
|
1314
|
-
stableNodeId: stableCursor.anchorStableId,
|
|
1315
|
-
offset: stableCursor.anchorOffset,
|
|
1316
|
-
type: stableCursor.anchorType || 'text'
|
|
1317
|
-
});
|
|
1318
|
-
});
|
|
1319
|
-
const focusResolved = editor.getEditorState().read(() => {
|
|
1320
|
-
return $resolveStablePosition({
|
|
1321
|
-
stableNodeId: stableCursor.focusStableId,
|
|
1322
|
-
offset: stableCursor.focusOffset,
|
|
1323
|
-
type: stableCursor.focusType || 'text'
|
|
1324
|
-
});
|
|
1325
|
-
});
|
|
1326
|
-
if (anchorResolved && focusResolved) {
|
|
1327
|
-
console.log('✅ Successfully resolved stable UUID positions:', {
|
|
1328
|
-
anchorStableId: stableCursor.anchorStableId,
|
|
1329
|
-
focusStableId: stableCursor.focusStableId,
|
|
1330
|
-
anchorNodeKey: anchorResolved.key,
|
|
1331
|
-
focusNodeKey: focusResolved.key
|
|
1332
|
-
});
|
|
1333
|
-
anchorPos = {
|
|
1334
|
-
key: anchorResolved.key,
|
|
1335
|
-
offset: anchorResolved.offset,
|
|
1336
|
-
type: 'text'
|
|
1337
|
-
};
|
|
1338
|
-
focusPos = {
|
|
1339
|
-
key: focusResolved.key,
|
|
1340
|
-
offset: focusResolved.offset,
|
|
1341
|
-
type: 'text'
|
|
1342
|
-
};
|
|
1343
|
-
}
|
|
1344
|
-
else {
|
|
1345
|
-
console.log('🔄 STABLE UUID RESOLUTION FAILED - positions will use document fallback:', {
|
|
1346
|
-
anchorStableId: stableCursor.anchorStableId,
|
|
1347
|
-
focusStableId: stableCursor.focusStableId,
|
|
1348
|
-
anchorResolved: !!anchorResolved,
|
|
1349
|
-
focusResolved: !!focusResolved,
|
|
1350
|
-
note: 'Fallback positioning will be used - this prevents (0,0) cursor jumps'
|
|
1351
|
-
});
|
|
1352
|
-
// anchorPos and focusPos will remain undefined, triggering legacy fallback
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
1355
|
-
// FALLBACK: Legacy NodeKey-based approach (for backwards compatibility)
|
|
1356
|
-
else if (stableCursor && stableCursor.anchorKey && typeof stableCursor.anchorOffset === 'number') {
|
|
1357
|
-
console.log('👁️ Fallback to legacy NodeKey-based cursor data:', stableCursor);
|
|
1358
|
-
// ENHANCEMENT: Use cursor side information for better positioning
|
|
1359
|
-
// The stableCursor now includes anchorSide and focusSide following Loro Cursor patterns
|
|
1360
|
-
const hasPositioningSides = stableCursor.anchorSide && stableCursor.focusSide;
|
|
1361
|
-
if (hasPositioningSides) {
|
|
1362
|
-
console.log('🎯 Enhanced positioning with cursor side information:', {
|
|
1363
|
-
anchorSide: stableCursor.anchorSide,
|
|
1364
|
-
focusSide: stableCursor.focusSide
|
|
1365
|
-
});
|
|
1366
|
-
}
|
|
1367
|
-
// Validate that the node keys still exist in the current editor state
|
|
1368
|
-
const validAnchor = editor.getEditorState().read(() => {
|
|
1369
|
-
const anchorNode = $getNodeByKey(stableCursor.anchorKey);
|
|
1370
|
-
const isValid = !!anchorNode;
|
|
1371
|
-
console.log('🔍 Anchor node validation:', {
|
|
1372
|
-
key: stableCursor.anchorKey,
|
|
1373
|
-
found: isValid,
|
|
1374
|
-
nodeType: anchorNode?.getType?.() || 'null'
|
|
1375
|
-
});
|
|
1376
|
-
return isValid;
|
|
1377
|
-
});
|
|
1378
|
-
const validFocus = editor.getEditorState().read(() => {
|
|
1379
|
-
const focusNode = $getNodeByKey(stableCursor.focusKey);
|
|
1380
|
-
const isValid = !!focusNode;
|
|
1381
|
-
console.log('🔍 Focus node validation:', {
|
|
1382
|
-
key: stableCursor.focusKey,
|
|
1383
|
-
found: isValid,
|
|
1384
|
-
nodeType: focusNode?.getType?.() || 'null'
|
|
1385
|
-
});
|
|
1386
|
-
return isValid;
|
|
1387
|
-
});
|
|
1388
|
-
if (validAnchor && validFocus) {
|
|
1389
|
-
console.log('✅ Using stable cursor data - nodes are valid');
|
|
1390
|
-
anchorPos = {
|
|
1391
|
-
key: stableCursor.anchorKey,
|
|
1392
|
-
offset: stableCursor.anchorOffset,
|
|
1393
|
-
type: 'text'
|
|
1394
|
-
};
|
|
1395
|
-
focusPos = {
|
|
1396
|
-
key: stableCursor.focusKey,
|
|
1397
|
-
offset: stableCursor.focusOffset,
|
|
1398
|
-
type: 'text'
|
|
1399
|
-
};
|
|
1400
|
-
console.log('👁️ Successfully used stable cursor data:', { anchorPos, focusPos });
|
|
1401
|
-
}
|
|
1402
|
-
else {
|
|
1403
|
-
console.log('👁️ Node keys invalid, using line-aware stable fallback');
|
|
1404
|
-
// LINE-AWARE MINIMAL FALLBACK: Try to preserve which line the cursor was on
|
|
1405
|
-
const lineAwarePosition = editor.getEditorState().read(() => {
|
|
1406
|
-
const root = $getRoot();
|
|
1407
|
-
const children = root.getChildren();
|
|
1408
|
-
// Build a simple map of text nodes (representing lines/paragraphs)
|
|
1409
|
-
const textNodesList = [];
|
|
1410
|
-
let lineIndex = 0;
|
|
1411
|
-
for (const child of children) {
|
|
1412
|
-
if ($isElementNode(child)) {
|
|
1413
|
-
const textChildren = child.getChildren().filter($isTextNode);
|
|
1414
|
-
for (const textNode of textChildren) {
|
|
1415
|
-
textNodesList.push({ node: textNode, lineIndex });
|
|
1416
|
-
}
|
|
1417
|
-
// Each element (paragraph/div) represents a new line
|
|
1418
|
-
if (textChildren.length > 0) {
|
|
1419
|
-
lineIndex++;
|
|
1420
|
-
}
|
|
1421
|
-
}
|
|
1422
|
-
}
|
|
1423
|
-
console.log('👁️ Document structure for line-aware fallback:', {
|
|
1424
|
-
totalLines: lineIndex,
|
|
1425
|
-
totalTextNodes: textNodesList.length,
|
|
1426
|
-
originalOffset: stableCursor.anchorOffset
|
|
1427
|
-
});
|
|
1428
|
-
if (textNodesList.length === 0) {
|
|
1429
|
-
// No text nodes, use root
|
|
1430
|
-
return {
|
|
1431
|
-
key: root.getKey(),
|
|
1432
|
-
offset: 0,
|
|
1433
|
-
type: 'text'
|
|
1434
|
-
};
|
|
1435
|
-
}
|
|
1436
|
-
// SMART ESTIMATION: Use the original offset to guess which line
|
|
1437
|
-
const originalOffset = stableCursor.anchorOffset;
|
|
1438
|
-
let targetLineIndex = 0;
|
|
1439
|
-
if (textNodesList.length > 1) {
|
|
1440
|
-
// Multiple lines available - estimate which line based on offset
|
|
1441
|
-
if (originalOffset <= 10) {
|
|
1442
|
-
targetLineIndex = 0; // Small offset = first line
|
|
1443
|
-
}
|
|
1444
|
-
else if (originalOffset <= 30) {
|
|
1445
|
-
targetLineIndex = Math.min(1, textNodesList.length - 1); // Medium offset = second line
|
|
1446
|
-
}
|
|
1447
|
-
else {
|
|
1448
|
-
// Large offset = later line (proportional)
|
|
1449
|
-
targetLineIndex = Math.min(Math.floor(originalOffset / 25), // Assume ~25 chars per line average
|
|
1450
|
-
textNodesList.length - 1);
|
|
1451
|
-
}
|
|
1452
|
-
}
|
|
1453
|
-
// Find text node for the target line
|
|
1454
|
-
const targetTextNodeInfo = textNodesList.find(info => info.lineIndex === targetLineIndex) || textNodesList[0];
|
|
1455
|
-
const targetTextNode = targetTextNodeInfo.node;
|
|
1456
|
-
// Use a small, safe offset within that line
|
|
1457
|
-
const safeOffset = Math.min(1, targetTextNode.getTextContentSize());
|
|
1458
|
-
console.log('👁️ Line-aware positioning:', {
|
|
1459
|
-
originalOffset,
|
|
1460
|
-
estimatedLine: targetLineIndex,
|
|
1461
|
-
selectedLine: targetTextNodeInfo.lineIndex,
|
|
1462
|
-
nodeKey: targetTextNode.getKey(),
|
|
1463
|
-
safeOffset,
|
|
1464
|
-
nodeText: targetTextNode.getTextContent().substring(0, 15)
|
|
1465
|
-
});
|
|
1466
|
-
return {
|
|
1467
|
-
key: targetTextNode.getKey(),
|
|
1468
|
-
offset: safeOffset,
|
|
1469
|
-
type: 'text'
|
|
1470
|
-
};
|
|
1471
|
-
});
|
|
1472
|
-
anchorPos = lineAwarePosition;
|
|
1473
|
-
focusPos = lineAwarePosition;
|
|
1474
|
-
console.log('👁️ Applied line-aware stable fallback:', { anchorPos, focusPos });
|
|
1475
|
-
}
|
|
1476
|
-
}
|
|
1477
|
-
else {
|
|
1478
|
-
console.log('👁️ No stable cursor data available, creating smart fallback positions');
|
|
1479
|
-
// Instead of trying LORO cursor conversion (which we skip), create immediate fallback
|
|
1480
|
-
const smartFallbackPosition = editor.getEditorState().read(() => {
|
|
1481
|
-
const root = $getRoot();
|
|
1482
|
-
const children = root.getChildren();
|
|
1483
|
-
// Find the first available text node
|
|
1484
|
-
for (const child of children) {
|
|
1485
|
-
if ($isElementNode(child)) {
|
|
1486
|
-
const grandChildren = child.getChildren();
|
|
1487
|
-
for (const grandChild of grandChildren) {
|
|
1488
|
-
if ($isTextNode(grandChild)) {
|
|
1489
|
-
console.log('👁️ Using first available text node for cursor:', {
|
|
1490
|
-
nodeKey: grandChild.getKey(),
|
|
1491
|
-
textContent: grandChild.getTextContent().substring(0, 30)
|
|
1492
|
-
});
|
|
1493
|
-
return {
|
|
1494
|
-
key: grandChild.getKey(),
|
|
1495
|
-
offset: Math.min(5, grandChild.getTextContent().length), // Small offset from start
|
|
1496
|
-
type: 'text'
|
|
1497
|
-
};
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
}
|
|
1501
|
-
}
|
|
1502
|
-
// Fallback to root if no text nodes found
|
|
1503
|
-
console.log('👁️ No text nodes found, using root as fallback');
|
|
1504
|
-
return {
|
|
1505
|
-
key: root.getKey(),
|
|
1506
|
-
offset: 0,
|
|
1507
|
-
type: 'text'
|
|
1508
|
-
};
|
|
1509
|
-
});
|
|
1510
|
-
anchorPos = smartFallbackPosition;
|
|
1511
|
-
focusPos = smartFallbackPosition;
|
|
1512
|
-
console.log('👁️ Applied smart fallback for no stable cursor data:', { anchorPos, focusPos });
|
|
1513
|
-
}
|
|
1514
|
-
// ENHANCEMENT: Direct Loro cursor conversion path
|
|
1515
|
-
// When stable cursor data is not available, we could use the improved
|
|
1516
|
-
// CursorAwareness methods to convert Loro cursors to Lexical positions:
|
|
1517
|
-
//
|
|
1518
|
-
// if (cursorData.anchor && awarenessRef.current) {
|
|
1519
|
-
// const stableAnchorPos = awarenessRef.current.getStableCursorPosition(cursorData.anchor);
|
|
1520
|
-
// if (stableAnchorPos !== null) {
|
|
1521
|
-
// // Convert stable position to Lexical node position using document traversal
|
|
1522
|
-
// anchorPos = convertGlobalPositionToLexical(stableAnchorPos);
|
|
1523
|
-
// }
|
|
1524
|
-
// }
|
|
1525
|
-
//
|
|
1526
|
-
// This would provide better cursor positioning than approximations
|
|
1527
|
-
console.log('👁️ Note: Enhanced Loro cursor conversion framework available for implementation');
|
|
1528
|
-
console.log('👁️ Converted positions for peer:', peerId, {
|
|
1529
|
-
anchorPos,
|
|
1530
|
-
focusPos
|
|
1531
|
-
});
|
|
1532
|
-
// CRITICAL: Ensure we always have valid anchor and focus positions
|
|
1533
|
-
if (!anchorPos || !focusPos) {
|
|
1534
|
-
console.log('🚨 Missing anchor or focus position, creating smart fallback for peer:', peerId);
|
|
1535
|
-
// Try to use the stored stable cursor as reference for finding a similar position
|
|
1536
|
-
let referencePosition = null;
|
|
1537
|
-
if (stableCursor && stableCursor.anchorKey && typeof stableCursor.anchorOffset === 'number') {
|
|
1538
|
-
referencePosition = {
|
|
1539
|
-
anchorKey: stableCursor.anchorKey,
|
|
1540
|
-
anchorOffset: stableCursor.anchorOffset
|
|
1541
|
-
};
|
|
1542
|
-
}
|
|
1543
|
-
const smartPosition = editor.getEditorState().read(() => {
|
|
1544
|
-
const root = $getRoot();
|
|
1545
|
-
// If we have a reference position, calculate the global document position
|
|
1546
|
-
// and try to find a position that maintains the same relative location
|
|
1547
|
-
if (referencePosition) {
|
|
1548
|
-
console.log('🔄 Using reference position for smart fallback:', referencePosition);
|
|
1549
|
-
// First, try to find the exact same node (it might still exist)
|
|
1550
|
-
let targetNode = null;
|
|
1551
|
-
const findExactNode = (node) => {
|
|
1552
|
-
if (node.getKey() === referencePosition.anchorKey) {
|
|
1553
|
-
targetNode = node;
|
|
1554
|
-
return true;
|
|
1555
|
-
}
|
|
1556
|
-
if ($isElementNode(node)) {
|
|
1557
|
-
const nodeChildren = node.getChildren();
|
|
1558
|
-
for (const child of nodeChildren) {
|
|
1559
|
-
if (findExactNode(child)) {
|
|
1560
|
-
return true;
|
|
1561
|
-
}
|
|
1562
|
-
}
|
|
1563
|
-
}
|
|
1564
|
-
return false;
|
|
1565
|
-
};
|
|
1566
|
-
findExactNode(root);
|
|
1567
|
-
if (targetNode && $isTextNode(targetNode)) {
|
|
1568
|
-
const textNode = targetNode;
|
|
1569
|
-
const textLength = textNode.getTextContent().length;
|
|
1570
|
-
const safeOffset = Math.min(referencePosition.anchorOffset, textLength);
|
|
1571
|
-
console.log('🔄 Found exact node still exists:', {
|
|
1572
|
-
nodeKey: textNode.getKey(),
|
|
1573
|
-
offset: safeOffset
|
|
1574
|
-
});
|
|
1575
|
-
return {
|
|
1576
|
-
key: textNode.getKey(),
|
|
1577
|
-
offset: safeOffset,
|
|
1578
|
-
type: 'text'
|
|
1579
|
-
};
|
|
1580
|
-
}
|
|
1581
|
-
// If exact node not found, we need to calculate the global position
|
|
1582
|
-
// that this cursor was at and find the equivalent position in the new tree
|
|
1583
|
-
console.log('🔄 Exact node not found, calculating global position equivalent');
|
|
1584
|
-
// Instead of guessing, let's calculate where this cursor should be
|
|
1585
|
-
// based on the current document structure
|
|
1586
|
-
const fullDocumentText = root.getTextContent();
|
|
1587
|
-
console.log('🔄 Full document text for position calculation:', {
|
|
1588
|
-
text: JSON.stringify(fullDocumentText),
|
|
1589
|
-
length: fullDocumentText.length
|
|
1590
|
-
});
|
|
1591
|
-
// Calculate a reasonable position: try to maintain the same relative position
|
|
1592
|
-
// For a simple approach, let's use the offset as a ratio of the original node length
|
|
1593
|
-
// and apply that ratio to a reasonable position in the current document
|
|
1594
|
-
// Find a good text node to place the cursor in
|
|
1595
|
-
let bestFallbackNode = null;
|
|
1596
|
-
let bestFallbackOffset = 0;
|
|
1597
|
-
const findBestFallbackPosition = (node) => {
|
|
1598
|
-
if ($isTextNode(node)) {
|
|
1599
|
-
const textContent = node.getTextContent();
|
|
1600
|
-
const textLength = textContent.length;
|
|
1601
|
-
if (textLength > 0 && !bestFallbackNode) {
|
|
1602
|
-
bestFallbackNode = node;
|
|
1603
|
-
// Use a reasonable offset: if original offset was small, use small offset
|
|
1604
|
-
// if original offset was large relative to typical text, use larger offset
|
|
1605
|
-
const originalOffset = referencePosition.anchorOffset;
|
|
1606
|
-
if (originalOffset <= 5) {
|
|
1607
|
-
// Original was near start, place near start
|
|
1608
|
-
bestFallbackOffset = Math.min(originalOffset, textLength);
|
|
1609
|
-
}
|
|
1610
|
-
else {
|
|
1611
|
-
// Original was further in, place proportionally
|
|
1612
|
-
bestFallbackOffset = Math.min(Math.floor(textLength * 0.3), textLength);
|
|
1613
|
-
}
|
|
1614
|
-
}
|
|
1615
|
-
}
|
|
1616
|
-
else if ($isElementNode(node)) {
|
|
1617
|
-
const nodeChildren = node.getChildren();
|
|
1618
|
-
for (const child of nodeChildren) {
|
|
1619
|
-
findBestFallbackPosition(child);
|
|
1620
|
-
}
|
|
1621
|
-
}
|
|
1622
|
-
};
|
|
1623
|
-
findBestFallbackPosition(root);
|
|
1624
|
-
if (bestFallbackNode && $isTextNode(bestFallbackNode)) {
|
|
1625
|
-
const textNode = bestFallbackNode;
|
|
1626
|
-
const textLength = textNode.getTextContent().length;
|
|
1627
|
-
const safeOffset = Math.min(bestFallbackOffset, textLength);
|
|
1628
|
-
console.log('🔄 Found proportional fallback position:', {
|
|
1629
|
-
nodeKey: textNode.getKey(),
|
|
1630
|
-
offset: safeOffset,
|
|
1631
|
-
originalOffset: referencePosition.anchorOffset,
|
|
1632
|
-
nodeLength: textLength
|
|
1633
|
-
});
|
|
1634
|
-
return {
|
|
1635
|
-
key: textNode.getKey(),
|
|
1636
|
-
offset: safeOffset,
|
|
1637
|
-
type: 'text'
|
|
1638
|
-
};
|
|
1639
|
-
}
|
|
1640
|
-
}
|
|
1641
|
-
// If no reference position or position calculation failed,
|
|
1642
|
-
// fallback to a reasonable default position (not beginning of document)
|
|
1643
|
-
console.log('🔄 No reference position or calculation failed, using safe fallback');
|
|
1644
|
-
// Find the first text node with some content
|
|
1645
|
-
const findFirstTextNode = (node) => {
|
|
1646
|
-
if ($isTextNode(node) && node.getTextContent().length > 0) {
|
|
1647
|
-
return node;
|
|
1648
|
-
}
|
|
1649
|
-
if ($isElementNode(node)) {
|
|
1650
|
-
const nodeChildren = node.getChildren();
|
|
1651
|
-
for (const child of nodeChildren) {
|
|
1652
|
-
const result = findFirstTextNode(child);
|
|
1653
|
-
if (result)
|
|
1654
|
-
return result;
|
|
1655
|
-
}
|
|
1656
|
-
}
|
|
1657
|
-
return null;
|
|
1658
|
-
};
|
|
1659
|
-
const firstTextNode = findFirstTextNode(root);
|
|
1660
|
-
if (firstTextNode && $isTextNode(firstTextNode)) {
|
|
1661
|
-
const textNode = firstTextNode;
|
|
1662
|
-
// Place cursor at a reasonable position, not at the very beginning
|
|
1663
|
-
const textLength = textNode.getTextContent().length;
|
|
1664
|
-
const offset = Math.min(1, textLength); // Position 1 or end if shorter
|
|
1665
|
-
console.log('🔄 Using reasonable position in first text node as fallback:', {
|
|
1666
|
-
nodeKey: textNode.getKey(),
|
|
1667
|
-
offset: offset,
|
|
1668
|
-
textLength: textLength
|
|
1669
|
-
});
|
|
1670
|
-
return {
|
|
1671
|
-
key: textNode.getKey(),
|
|
1672
|
-
offset: offset,
|
|
1673
|
-
type: 'text'
|
|
1674
|
-
};
|
|
1675
|
-
}
|
|
1676
|
-
// Ultimate fallback to root
|
|
1677
|
-
console.log('🔄 Ultimate emergency fallback to root');
|
|
1678
|
-
return {
|
|
1679
|
-
key: root.getKey(),
|
|
1680
|
-
offset: 0,
|
|
1681
|
-
type: 'text'
|
|
1682
|
-
};
|
|
1683
|
-
});
|
|
1684
|
-
anchorPos = anchorPos || smartPosition;
|
|
1685
|
-
focusPos = focusPos || smartPosition;
|
|
1686
|
-
console.log('🔄 Applied smart fallback positions:', { anchorPos, focusPos });
|
|
1687
|
-
}
|
|
1688
|
-
remoteCursorsData[peerId] = {
|
|
1689
|
-
peerId: peerId,
|
|
1690
|
-
anchor: anchorPos,
|
|
1691
|
-
focus: focusPos,
|
|
1692
|
-
user: cursorData.user
|
|
1693
|
-
};
|
|
1694
|
-
}
|
|
1695
|
-
else {
|
|
1696
|
-
console.log('👁️ Skipping own cursor for peer:', peerId);
|
|
1697
|
-
}
|
|
1698
|
-
});
|
|
1699
|
-
console.log('🔍 PEER PROCESSING END:', {
|
|
1700
|
-
remoteCursorsDataKeys: Object.keys(remoteCursorsData),
|
|
1701
|
-
remoteCursorsDataCount: Object.keys(remoteCursorsData).length,
|
|
1702
|
-
originalAllCursorsKeys: Object.keys(allCursors),
|
|
1703
|
-
currentPeerId,
|
|
1704
|
-
peersProcessed: peersToProcess
|
|
1705
|
-
});
|
|
1706
|
-
console.log('🎯 Setting remote cursors:', remoteCursorsData);
|
|
1707
|
-
console.log('🔢 Remote cursors count after processing:', Object.keys(remoteCursorsData).length);
|
|
1708
|
-
if (Object.keys(remoteCursorsData).length === 0) {
|
|
1709
|
-
console.log('💡 No remote cursors to display. Open another browser tab to see collaborative cursors!');
|
|
1710
|
-
}
|
|
1711
|
-
// Update cursor timestamps for activity tracking
|
|
1712
|
-
const now = Date.now();
|
|
1713
|
-
Object.keys(remoteCursorsData).forEach(peerId => {
|
|
1714
|
-
cursorTimestamps.current[peerId] = now;
|
|
1715
|
-
});
|
|
1716
|
-
updateRemoteCursors(remoteCursorsData);
|
|
1717
|
-
// Call awareness change callback for UI display (include ALL users, including self)
|
|
1718
|
-
if (stableOnAwarenessChange.current) {
|
|
1719
|
-
const awarenessData = Object.keys(allCursors).map(peerId => {
|
|
1720
|
-
// Extract meaningful part from peer ID
|
|
1721
|
-
const extractedId = peerId.includes('_') ?
|
|
1722
|
-
peerId.split('_').find(part => /^\d{13}$/.test(part)) || peerId.slice(-8) :
|
|
1723
|
-
peerId.slice(-8);
|
|
1724
|
-
const isCurrentUser = peerId === currentPeerId;
|
|
1725
|
-
return {
|
|
1726
|
-
peerId: peerId,
|
|
1727
|
-
userName: allCursors[peerId]?.user?.name || extractedId,
|
|
1728
|
-
isCurrentUser: isCurrentUser
|
|
1729
|
-
};
|
|
1730
|
-
});
|
|
1731
|
-
stableOnAwarenessChange.current(awarenessData);
|
|
1732
|
-
}
|
|
1733
|
-
// No more setForceUpdate - overlay handles its own re-rendering
|
|
1734
|
-
}
|
|
1735
|
-
};
|
|
1736
|
-
// Only add the listener if this is a new awareness instance
|
|
1737
|
-
const currentAwareness = awarenessRef.current;
|
|
1738
|
-
if (currentAwareness) {
|
|
1739
|
-
// Remove any existing listeners first to prevent duplicates
|
|
1740
|
-
currentAwareness.removeListener(awarenessCallback);
|
|
1741
|
-
// Add the new listener
|
|
1742
|
-
currentAwareness.addListener(awarenessCallback);
|
|
1743
|
-
console.log('🎯 Added awareness callback listener');
|
|
1744
|
-
}
|
|
1745
|
-
// Set up the remote cursor callback
|
|
1746
|
-
awarenessRef.current.setRemoteCursorCallback((peerId, cursor) => {
|
|
1747
|
-
console.log('🎯 Remote cursor callback triggered:', peerId, cursor);
|
|
1748
|
-
const updated = {
|
|
1749
|
-
...remoteCursorsRef.current,
|
|
1750
|
-
[peerId]: cursor
|
|
1751
|
-
};
|
|
1752
|
-
console.log('🎯 Updated remote cursors state:', updated);
|
|
1753
|
-
updateRemoteCursors(updated);
|
|
1754
|
-
});
|
|
1755
|
-
// Subscribe to Loro document changes
|
|
1756
|
-
const unsubscribe = loroDocRef.current.subscribe(() => {
|
|
1757
|
-
if (!isLocalChange.current) {
|
|
1758
|
-
// This is a remote change, update Lexical editor
|
|
1759
|
-
const currentText = loroTextRef.current?.toString() || '';
|
|
1760
|
-
console.log('🔍📥 CRDT subscription triggered - content length:', currentText.length);
|
|
1761
|
-
console.log('🔍📥 CRDT content preview:', currentText.slice(0, 200) + '...');
|
|
1762
|
-
console.log('🔍📥 CRDT content ending:', '...' + currentText.slice(-200));
|
|
1763
|
-
// Check if content is truncated (ends abruptly)
|
|
1764
|
-
if (currentText.length > 100 && !currentText.endsWith('}')) {
|
|
1765
|
-
console.error('🚨 CRDT content appears truncated - does not end with }');
|
|
1766
|
-
console.error('🚨 Last 100 characters:', currentText.slice(-100));
|
|
1767
|
-
}
|
|
1768
|
-
updateLexicalFromLoro(editor, currentText);
|
|
1769
|
-
}
|
|
1770
|
-
});
|
|
1771
|
-
// Subscribe to Lexical editor changes with debouncing
|
|
1772
|
-
let updateTimeout = null;
|
|
1773
|
-
const removeEditorListener = editor.registerUpdateListener(({ editorState, tags }) => {
|
|
1774
|
-
// Skip if this is a local change from our plugin
|
|
1775
|
-
if (isLocalChange.current || tags.has('collaboration'))
|
|
1776
|
-
return;
|
|
1777
|
-
// =================================================================
|
|
1778
|
-
// CRITICAL: Assign stable UUIDs to new nodes on local changes
|
|
1779
|
-
// =================================================================
|
|
1780
|
-
editor.update(() => {
|
|
1781
|
-
$ensureAllNodesHaveStableIds();
|
|
1782
|
-
}, { tag: 'uuid-assignment' });
|
|
1783
|
-
// Clear previous timeout
|
|
1784
|
-
if (updateTimeout) {
|
|
1785
|
-
clearTimeout(updateTimeout);
|
|
1786
|
-
}
|
|
1787
|
-
// Debounce updates to prevent rapid firing
|
|
1788
|
-
updateTimeout = setTimeout(() => {
|
|
1789
|
-
if (!isLocalChange.current) {
|
|
1790
|
-
updateLoroFromLexical(editorState);
|
|
1791
|
-
}
|
|
1792
|
-
}, 25); // 25ms debounce for better responsiveness
|
|
1793
|
-
});
|
|
1794
|
-
return () => {
|
|
1795
|
-
if (updateTimeout) {
|
|
1796
|
-
clearTimeout(updateTimeout);
|
|
1797
|
-
}
|
|
1798
|
-
if (awarenessRef.current) {
|
|
1799
|
-
awarenessRef.current.removeListener(awarenessCallback);
|
|
1800
|
-
console.log('🎯 Removed awareness callback listener');
|
|
1801
|
-
}
|
|
1802
|
-
unsubscribe();
|
|
1803
|
-
removeEditorListener();
|
|
1804
|
-
};
|
|
1805
|
-
}, [editor, docId, updateLoroFromLexical, updateLexicalFromLoro, clientId, updateRemoteCursors]);
|
|
1806
|
-
// Connection retry state
|
|
1807
|
-
const retryTimeoutRef = useRef(null);
|
|
1808
|
-
const retryCountRef = useRef(0);
|
|
1809
|
-
const maxRetries = 5;
|
|
1810
|
-
// Create stable refs for callbacks to avoid dependency issues
|
|
1811
|
-
const stableOnAwarenessChange = useRef(onAwarenessChange);
|
|
1812
|
-
stableOnAwarenessChange.current = onAwarenessChange;
|
|
1813
|
-
// WebSocket connection management with stable dependencies
|
|
1814
|
-
const stableOnConnectionChange = useRef(onConnectionChange);
|
|
1815
|
-
const stableOnDisconnectReady = useRef(onDisconnectReady);
|
|
1816
|
-
const stableOnSendMessageReady = useRef(onSendMessageReady);
|
|
1817
|
-
// Update refs when props change without triggering effect
|
|
1818
|
-
useEffect(() => {
|
|
1819
|
-
stableOnConnectionChange.current = onConnectionChange;
|
|
1820
|
-
stableOnDisconnectReady.current = onDisconnectReady;
|
|
1821
|
-
stableOnSendMessageReady.current = onSendMessageReady;
|
|
1822
|
-
});
|
|
1823
|
-
useEffect(() => {
|
|
1824
|
-
// Close any existing connection before creating a new one
|
|
1825
|
-
if (wsRef.current) {
|
|
1826
|
-
wsRef.current.close();
|
|
1827
|
-
wsRef.current = null;
|
|
1828
|
-
}
|
|
1829
|
-
const connectWebSocket = () => {
|
|
1830
|
-
// Prevent multiple connections
|
|
1831
|
-
if (isConnectingRef.current || (wsRef.current && wsRef.current.readyState === WebSocket.OPEN)) {
|
|
1832
|
-
return;
|
|
1833
|
-
}
|
|
1834
|
-
try {
|
|
1835
|
-
isConnectingRef.current = true;
|
|
1836
|
-
const ws = new WebSocket(websocketUrl);
|
|
1837
|
-
wsRef.current = ws;
|
|
1838
|
-
// Wrap send to log all outgoing messages with a clear, visible marker
|
|
1839
|
-
try {
|
|
1840
|
-
const originalSend = ws.send.bind(ws);
|
|
1841
|
-
ws.send = (data) => {
|
|
1842
|
-
try {
|
|
1843
|
-
if (typeof data === 'string') {
|
|
1844
|
-
const len = data.length;
|
|
1845
|
-
let parsed = null;
|
|
1846
|
-
try {
|
|
1847
|
-
parsed = JSON.parse(data);
|
|
1848
|
-
}
|
|
1849
|
-
catch { /* ignore parse errors for preview */ }
|
|
1850
|
-
const preview = data.slice(0, 300) + (len > 300 ? '…' : '');
|
|
1851
|
-
console.log('🛰️📤 WS SEND →', {
|
|
1852
|
-
type: parsed?.type,
|
|
1853
|
-
docId: parsed?.docId,
|
|
1854
|
-
length: len,
|
|
1855
|
-
keys: parsed ? Object.keys(parsed) : ['<unparsed>'],
|
|
1856
|
-
preview
|
|
1857
|
-
});
|
|
1858
|
-
}
|
|
1859
|
-
else {
|
|
1860
|
-
console.log('🛰️📤 WS SEND → (non-string payload)', { kind: typeof data });
|
|
1861
|
-
}
|
|
1862
|
-
}
|
|
1863
|
-
catch (logErr) {
|
|
1864
|
-
console.warn('WS send log failed:', logErr);
|
|
1865
|
-
}
|
|
1866
|
-
return originalSend(data);
|
|
1867
|
-
};
|
|
1868
|
-
}
|
|
1869
|
-
catch (wrapErr) {
|
|
1870
|
-
console.warn('Failed to wrap WebSocket.send for logging:', wrapErr);
|
|
1871
|
-
}
|
|
1872
|
-
ws.onopen = () => {
|
|
1873
|
-
isConnectingRef.current = false;
|
|
1874
|
-
retryCountRef.current = 0; // Reset retry count on successful connection
|
|
1875
|
-
console.log('🔗 Lexical editor connected to WebSocket server');
|
|
1876
|
-
stableOnConnectionChange.current?.(true);
|
|
1877
|
-
// Initialize version vector for optimized updates
|
|
1878
|
-
setLastSentVersionVector(loroDocRef.current.version());
|
|
1879
|
-
// Provide disconnect function to parent component
|
|
1880
|
-
const disconnectFn = () => {
|
|
1881
|
-
if (wsRef.current) {
|
|
1882
|
-
wsRef.current.close();
|
|
1883
|
-
stableOnConnectionChange.current?.(false);
|
|
1884
|
-
}
|
|
1885
|
-
};
|
|
1886
|
-
stableOnDisconnectReady.current?.(disconnectFn);
|
|
1887
|
-
// Provide sendMessage function to parent component
|
|
1888
|
-
const sendMessageFn = (message) => {
|
|
1889
|
-
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
|
1890
|
-
wsRef.current.send(JSON.stringify(message));
|
|
1891
|
-
}
|
|
1892
|
-
};
|
|
1893
|
-
stableOnSendMessageReady.current?.(sendMessageFn);
|
|
1894
|
-
// Request initial snapshot immediately after connection to ensure proper initialization
|
|
1895
|
-
// This ensures the editor is ready for programmatic operations even before user types
|
|
1896
|
-
setTimeout(() => {
|
|
1897
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
1898
|
-
ws.send(JSON.stringify({
|
|
1899
|
-
type: 'request-snapshot',
|
|
1900
|
-
docId: docId
|
|
1901
|
-
}));
|
|
1902
|
-
console.log('📞 Lexical editor requested initial snapshot on connection');
|
|
1903
|
-
}
|
|
1904
|
-
}, 100); // Small delay to ensure connection is fully established
|
|
1905
|
-
};
|
|
1906
|
-
ws.onmessage = (event) => {
|
|
1907
|
-
try {
|
|
1908
|
-
// VERY PROMINENT LOGGING - This should appear for EVERY message received
|
|
1909
|
-
console.log('🟢🟢🟢 WEBSOCKET MESSAGE RECEIVED 🟢🟢🟢');
|
|
1910
|
-
console.log('Raw event.data:', event.data);
|
|
1911
|
-
console.log('Event type:', typeof event.data);
|
|
1912
|
-
console.log('Event length:', event.data?.length || 'N/A');
|
|
1913
|
-
const data = JSON.parse(event.data);
|
|
1914
|
-
console.log('🟢🟢🟢 PARSED MESSAGE DATA 🟢🟢🟢');
|
|
1915
|
-
console.log('Message type:', data.type);
|
|
1916
|
-
console.log('Document ID:', data.docId);
|
|
1917
|
-
console.log('Full parsed data:', data);
|
|
1918
|
-
// Prominent log for ALL incoming messages with safe preview
|
|
1919
|
-
const preview = typeof event.data === 'string' ? event.data.slice(0, 300) + (event.data.length > 300 ? '…' : '') : '';
|
|
1920
|
-
console.log('🛰️📥 WS RECV ←', {
|
|
1921
|
-
type: data.type,
|
|
1922
|
-
docId: data.docId,
|
|
1923
|
-
hasData: !!data.data,
|
|
1924
|
-
hasEvent: !!data.event,
|
|
1925
|
-
clientId: data.clientId,
|
|
1926
|
-
length: typeof event.data === 'string' ? event.data.length : undefined,
|
|
1927
|
-
preview
|
|
1928
|
-
});
|
|
1929
|
-
if ((data.type === 'loro-update' || data.type === 'snapshot') && data.docId === docId) {
|
|
1930
|
-
// Apply remote update or snapshot to local document
|
|
1931
|
-
const update = new Uint8Array(data.update || data.snapshot);
|
|
1932
|
-
console.log(`🔍📥 Processing ${data.type}:`, {
|
|
1933
|
-
updateSize: update.length,
|
|
1934
|
-
docId: data.docId,
|
|
1935
|
-
hasUpdate: !!(data.update || data.snapshot),
|
|
1936
|
-
messageType: data.type
|
|
1937
|
-
});
|
|
1938
|
-
// Check CRDT content BEFORE import
|
|
1939
|
-
const contentBefore = loroTextRef.current?.toString() || '';
|
|
1940
|
-
console.log('🔍📥 CRDT content BEFORE import:', {
|
|
1941
|
-
length: contentBefore.length,
|
|
1942
|
-
preview: contentBefore.slice(0, 100) + '...',
|
|
1943
|
-
ending: '...' + contentBefore.slice(-100)
|
|
1944
|
-
});
|
|
1945
|
-
loroDocRef.current.import(update);
|
|
1946
|
-
// Check CRDT content AFTER import
|
|
1947
|
-
const contentAfter = loroTextRef.current?.toString() || '';
|
|
1948
|
-
console.log('🔍📥 CRDT content AFTER import:', {
|
|
1949
|
-
length: contentAfter.length,
|
|
1950
|
-
preview: contentAfter.slice(0, 100) + '...',
|
|
1951
|
-
ending: '...' + contentAfter.slice(-100),
|
|
1952
|
-
lengthChanged: contentBefore.length !== contentAfter.length,
|
|
1953
|
-
contentChanged: contentBefore !== contentAfter
|
|
1954
|
-
});
|
|
1955
|
-
// Check for truncation
|
|
1956
|
-
if (contentAfter.length > 100 && !contentAfter.endsWith('}')) {
|
|
1957
|
-
console.error('🚨 CRDT content appears truncated after import - does not end with }');
|
|
1958
|
-
console.error('🚨 Last 200 characters:', contentAfter.slice(-200));
|
|
1959
|
-
}
|
|
1960
|
-
// Sync imported changes to Lexical editor
|
|
1961
|
-
if (contentAfter && contentAfter.trim().length > 0 && contentBefore !== contentAfter) {
|
|
1962
|
-
try {
|
|
1963
|
-
updateLexicalFromLoro(editor, contentAfter);
|
|
1964
|
-
console.log(`✅ Successfully updated Lexical editor from ${data.type}`);
|
|
1965
|
-
}
|
|
1966
|
-
catch (e) {
|
|
1967
|
-
console.warn(`⚠️ Could not update Lexical editor from ${data.type}:`, e);
|
|
1968
|
-
}
|
|
1969
|
-
}
|
|
1970
|
-
else {
|
|
1971
|
-
console.log('📝 No content change detected, skipping Lexical update');
|
|
1972
|
-
}
|
|
1973
|
-
}
|
|
1974
|
-
else if (data.type === 'initial-snapshot' && data.docId === docId) {
|
|
1975
|
-
// Handle initial snapshot from server
|
|
1976
|
-
hasReceivedInitialSnapshot.current = true;
|
|
1977
|
-
console.log('📄 Lexical editor received initial snapshot response');
|
|
1978
|
-
// Check if there's actual snapshot data
|
|
1979
|
-
if (data.snapshot && data.snapshot.length > 0) {
|
|
1980
|
-
// Apply snapshot with actual data
|
|
1981
|
-
const snapshot = new Uint8Array(data.snapshot);
|
|
1982
|
-
loroDocRef.current.import(snapshot);
|
|
1983
|
-
console.log('📄 Applied non-empty initial snapshot');
|
|
1984
|
-
// Immediately reflect the current Loro content into the editor after import
|
|
1985
|
-
try {
|
|
1986
|
-
// Always use 'content' container for structured JSON (single container architecture)
|
|
1987
|
-
const currentContent = loroDocRef.current.getText('content').toString();
|
|
1988
|
-
console.log('📋 Got structured content from "content" container:', currentContent.slice(0, 100) + '...');
|
|
1989
|
-
if (currentContent && currentContent.trim().length > 0) {
|
|
1990
|
-
updateLexicalFromLoro(editor, currentContent);
|
|
1991
|
-
console.log('✅ Successfully updated Lexical editor from snapshot');
|
|
1992
|
-
}
|
|
1993
|
-
}
|
|
1994
|
-
catch (e) {
|
|
1995
|
-
console.warn('⚠️ Could not immediately reflect snapshot to editor:', e);
|
|
1996
|
-
}
|
|
1997
|
-
}
|
|
1998
|
-
else {
|
|
1999
|
-
// No snapshot data - initialize with empty document
|
|
2000
|
-
console.log('📄 No snapshot data available, initializing with empty document');
|
|
2001
|
-
// Initialize the CRDT document with a basic empty structure
|
|
2002
|
-
try {
|
|
2003
|
-
const emptyContent = JSON.stringify({
|
|
2004
|
-
root: {
|
|
2005
|
-
children: [],
|
|
2006
|
-
direction: null,
|
|
2007
|
-
format: "",
|
|
2008
|
-
indent: 0,
|
|
2009
|
-
type: "root",
|
|
2010
|
-
version: 1
|
|
2011
|
-
}
|
|
2012
|
-
});
|
|
2013
|
-
// Set the content in the Loro document to establish baseline
|
|
2014
|
-
loroDocRef.current.getText('content').insert(0, emptyContent);
|
|
2015
|
-
console.log('📄 Initialized Loro document with empty structure');
|
|
2016
|
-
}
|
|
2017
|
-
catch (e) {
|
|
2018
|
-
console.warn('⚠️ Could not initialize empty document structure:', e);
|
|
2019
|
-
}
|
|
2020
|
-
}
|
|
2021
|
-
// Notify parent component about successful initialization (even if empty)
|
|
2022
|
-
if (onInitialization) {
|
|
2023
|
-
onInitialization(true);
|
|
2024
|
-
}
|
|
2025
|
-
}
|
|
2026
|
-
else if (data.type === 'document-update' && data.docId === docId) {
|
|
2027
|
-
// Handle document update broadcasts (e.g., from append-paragraph operations)
|
|
2028
|
-
console.log('📄 Lexical editor received document-update broadcast');
|
|
2029
|
-
// Check if there's snapshot data
|
|
2030
|
-
if (data.snapshot && data.snapshot.length > 0) {
|
|
2031
|
-
try {
|
|
2032
|
-
let snapshotBytes;
|
|
2033
|
-
// Handle different snapshot formats
|
|
2034
|
-
if (typeof data.snapshot === 'string') {
|
|
2035
|
-
// Base64 encoded string (from document-update)
|
|
2036
|
-
const base64Decoded = atob(data.snapshot);
|
|
2037
|
-
snapshotBytes = new Uint8Array(base64Decoded.split('').map(char => char.charCodeAt(0)));
|
|
2038
|
-
}
|
|
2039
|
-
else {
|
|
2040
|
-
// Already a Uint8Array (from initial-snapshot)
|
|
2041
|
-
snapshotBytes = new Uint8Array(data.snapshot);
|
|
2042
|
-
}
|
|
2043
|
-
console.log('📄 Applying document-update snapshot:', {
|
|
2044
|
-
originalLength: data.snapshot.length,
|
|
2045
|
-
decodedLength: snapshotBytes.length
|
|
2046
|
-
});
|
|
2047
|
-
// Apply snapshot to local document
|
|
2048
|
-
loroDocRef.current.import(snapshotBytes);
|
|
2049
|
-
// Update the Lexical editor with the new content
|
|
2050
|
-
const updatedContent = loroDocRef.current.getText('content').toString();
|
|
2051
|
-
console.log('📋 Got updated content from document-update:', updatedContent.slice(0, 100) + '...');
|
|
2052
|
-
if (updatedContent && updatedContent.trim().length > 0) {
|
|
2053
|
-
updateLexicalFromLoro(editor, updatedContent);
|
|
2054
|
-
console.log('✅ Successfully updated Lexical editor from document-update');
|
|
2055
|
-
}
|
|
2056
|
-
}
|
|
2057
|
-
catch (e) {
|
|
2058
|
-
console.warn('⚠️ Could not apply document-update snapshot:', e);
|
|
2059
|
-
}
|
|
2060
|
-
}
|
|
2061
|
-
else {
|
|
2062
|
-
console.log('📄 No snapshot data in document-update message');
|
|
2063
|
-
}
|
|
2064
|
-
}
|
|
2065
|
-
else if (data.type === 'ephemeral-update' || data.type === 'ephemeral-event') {
|
|
2066
|
-
// Handle ephemeral updates from other clients using EphemeralStore
|
|
2067
|
-
if (data.docId === docId && data.data) {
|
|
2068
|
-
try {
|
|
2069
|
-
console.log('📡 Received ephemeral update:', {
|
|
2070
|
-
type: data.type,
|
|
2071
|
-
event: data.event || 'legacy',
|
|
2072
|
-
hasEventInfo: !!data.event,
|
|
2073
|
-
eventDetails: data.event
|
|
2074
|
-
});
|
|
2075
|
-
// Convert hex string back to Uint8Array
|
|
2076
|
-
const ephemeralBytes = new Uint8Array(data.data.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) || []);
|
|
2077
|
-
if (awarenessRef.current && ephemeralBytes.length > 0) {
|
|
2078
|
-
console.log('🎯 About to apply ephemeral data, current state before apply:');
|
|
2079
|
-
console.log('🎯 Current awareness data before apply:', awarenessRef.current.getAll());
|
|
2080
|
-
// Apply the ephemeral data to our local store
|
|
2081
|
-
// This will now automatically trigger the awareness callback
|
|
2082
|
-
awarenessRef.current.apply(ephemeralBytes);
|
|
2083
|
-
console.log('🎯 Current awareness data after apply:', awarenessRef.current.getAll());
|
|
2084
|
-
// Process ephemeral event - the awareness callback handles cursor updates
|
|
2085
|
-
console.log('🎯 Processing ephemeral event with details:', {
|
|
2086
|
-
by: data.event?.by,
|
|
2087
|
-
added: data.event?.added,
|
|
2088
|
-
updated: data.event?.updated,
|
|
2089
|
-
removed: data.event?.removed
|
|
2090
|
-
});
|
|
2091
|
-
// CRITICAL FIX: Don't immediately remove cursors on ephemeral events
|
|
2092
|
-
// The typing action often triggers false "removal" events
|
|
2093
|
-
// Let the awareness callback handle cursor state properly
|
|
2094
|
-
if (data.event?.removed && data.event.removed.length > 0) {
|
|
2095
|
-
console.log('�️ Note: Ephemeral event indicates removals:', data.event.removed, '(will be validated by awareness callback)');
|
|
2096
|
-
// Don't immediately remove cursors - let the awareness callback validate
|
|
2097
|
-
}
|
|
2098
|
-
console.log('👁️ Applied ephemeral update from remote clients');
|
|
2099
|
-
}
|
|
2100
|
-
}
|
|
2101
|
-
catch (error) {
|
|
2102
|
-
console.warn('Error applying ephemeral update:', error);
|
|
2103
|
-
}
|
|
2104
|
-
}
|
|
2105
|
-
}
|
|
2106
|
-
else if (data.type === 'welcome') {
|
|
2107
|
-
console.log('👋 Lexical editor welcome message received', {
|
|
2108
|
-
clientId: data.clientId,
|
|
2109
|
-
color: data.color
|
|
2110
|
-
});
|
|
2111
|
-
// Set client ID and color for cursor tracking
|
|
2112
|
-
setClientId(data.clientId || '');
|
|
2113
|
-
setClientColor(data.color || '');
|
|
2114
|
-
// FIXED: Preserve existing awareness state when updating peer ID
|
|
2115
|
-
if (data.clientId && awarenessRef.current) {
|
|
2116
|
-
// Store the client ID as the peer ID
|
|
2117
|
-
peerIdRef.current = data.clientId;
|
|
2118
|
-
console.log('🎯 Updating awareness to use client ID as peer ID:', data.clientId);
|
|
2119
|
-
// Save current local state before creating new instance
|
|
2120
|
-
const currentState = awarenessRef.current.getLocal();
|
|
2121
|
-
console.log('💾 Saving current awareness state:', currentState);
|
|
2122
|
-
// Create a new CursorAwareness instance with the client ID as peer ID
|
|
2123
|
-
// This is necessary because the peer ID is set in the constructor
|
|
2124
|
-
awarenessRef.current = new CursorAwareness(data.clientId, loroDocRef.current);
|
|
2125
|
-
// Extract meaningful part from client ID
|
|
2126
|
-
const extractedId = data.clientId.includes('_') ?
|
|
2127
|
-
data.clientId.split('_').find(part => /^\d{13}$/.test(part)) || data.clientId.slice(-8) :
|
|
2128
|
-
data.clientId.slice(-8);
|
|
2129
|
-
// Update awareness with client info using the client ID
|
|
2130
|
-
awarenessRef.current.setLocal({
|
|
2131
|
-
user: { name: extractedId, color: data.color || '#007acc' }
|
|
2132
|
-
});
|
|
2133
|
-
console.log('🎯 Updated awareness with WebSocket client ID user data:', {
|
|
2134
|
-
name: extractedId,
|
|
2135
|
-
color: data.color || '#007acc',
|
|
2136
|
-
clientId: data.clientId
|
|
2137
|
-
});
|
|
2138
|
-
// NOTE: The awareness callback listeners will be re-added by the useEffect
|
|
2139
|
-
// that monitors changes to awarenessRef.current
|
|
2140
|
-
console.log('🎯 Awareness instance updated - listeners will be re-attached by useEffect');
|
|
2141
|
-
} // Notify parent component of the peerId
|
|
2142
|
-
if (onPeerIdChange && data.clientId) {
|
|
2143
|
-
onPeerIdChange(data.clientId);
|
|
2144
|
-
}
|
|
2145
|
-
// Request current snapshot from server after a small delay
|
|
2146
|
-
setTimeout(() => {
|
|
2147
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
2148
|
-
ws.send(JSON.stringify({
|
|
2149
|
-
type: 'request-snapshot',
|
|
2150
|
-
docId: docId
|
|
2151
|
-
}));
|
|
2152
|
-
console.log('📞 Lexical editor requested current snapshot from server');
|
|
2153
|
-
}
|
|
2154
|
-
}, 150); // Slightly different delay than text editor
|
|
2155
|
-
}
|
|
2156
|
-
else if (data.type === 'snapshot-request' && data.docId === docId) {
|
|
2157
|
-
// REMOVED: Snapshot response to maintain pure incremental updates
|
|
2158
|
-
// Instead, let the requesting client get updates through normal loro-update flow
|
|
2159
|
-
console.log('📄 Lexical editor received snapshot request - ignoring to maintain incremental-only updates');
|
|
2160
|
-
console.log('📄 Requesting client will receive updates through normal loro-update messages');
|
|
2161
|
-
}
|
|
2162
|
-
else if (data.type === 'client-disconnect') {
|
|
2163
|
-
// Handle explicit client disconnect notifications
|
|
2164
|
-
console.log('📢 Received client disconnect notification:', data);
|
|
2165
|
-
const disconnectedClientId = data.clientId;
|
|
2166
|
-
if (disconnectedClientId && awarenessRef.current) {
|
|
2167
|
-
console.log('🧹 Forcing cleanup of disconnected client:', disconnectedClientId);
|
|
2168
|
-
// Remove from remote cursors immediately
|
|
2169
|
-
const updated = { ...remoteCursorsRef.current };
|
|
2170
|
-
console.log('🧹 Current remote cursors before cleanup:', remoteCursorsRef.current);
|
|
2171
|
-
delete updated[disconnectedClientId];
|
|
2172
|
-
console.log('🧹 Removed disconnected client from remote cursors, new state:', updated);
|
|
2173
|
-
updateRemoteCursors(updated);
|
|
2174
|
-
// Clear from timestamps
|
|
2175
|
-
delete cursorTimestamps.current[disconnectedClientId];
|
|
2176
|
-
console.log('🧹 Completed immediate cleanup for disconnected client');
|
|
2177
|
-
}
|
|
2178
|
-
else {
|
|
2179
|
-
console.warn('🧹 Cannot cleanup - missing client ID or awareness ref');
|
|
2180
|
-
}
|
|
2181
|
-
}
|
|
2182
|
-
else if (data.type === 'paragraph-added') {
|
|
2183
|
-
// Handle server broadcast when a new paragraph was added
|
|
2184
|
-
console.log('➕ Received paragraph-added broadcast:', {
|
|
2185
|
-
docId: data.docId,
|
|
2186
|
-
message: data.message,
|
|
2187
|
-
addedBy: data.addedBy
|
|
2188
|
-
});
|
|
2189
|
-
// Trigger a sync from Loro to Lexical to reflect the new paragraph
|
|
2190
|
-
if (data.docId === docId) {
|
|
2191
|
-
try {
|
|
2192
|
-
// Request fresh snapshot to get the updated content
|
|
2193
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
2194
|
-
ws.send(JSON.stringify({
|
|
2195
|
-
type: 'request-snapshot',
|
|
2196
|
-
docId: docId
|
|
2197
|
-
}));
|
|
2198
|
-
console.log('📞 Requested fresh snapshot after paragraph addition');
|
|
2199
|
-
}
|
|
2200
|
-
}
|
|
2201
|
-
catch (error) {
|
|
2202
|
-
console.warn('Error handling paragraph-added message:', error);
|
|
2203
|
-
}
|
|
2204
|
-
}
|
|
2205
|
-
}
|
|
2206
|
-
}
|
|
2207
|
-
catch (err) {
|
|
2208
|
-
console.error('Error processing WebSocket message in Lexical plugin:', err);
|
|
2209
|
-
// Notify parent component about failed initialization
|
|
2210
|
-
if (onInitialization) {
|
|
2211
|
-
onInitialization(false);
|
|
2212
|
-
}
|
|
2213
|
-
}
|
|
2214
|
-
};
|
|
2215
|
-
ws.onclose = () => {
|
|
2216
|
-
isConnectingRef.current = false;
|
|
2217
|
-
console.log('📴 Lexical editor disconnected from WebSocket server');
|
|
2218
|
-
stableOnConnectionChange.current?.(false);
|
|
2219
|
-
// Clear any existing retry timeout
|
|
2220
|
-
if (retryTimeoutRef.current) {
|
|
2221
|
-
clearTimeout(retryTimeoutRef.current);
|
|
2222
|
-
}
|
|
2223
|
-
// Only retry if we haven't exceeded max retries
|
|
2224
|
-
if (retryCountRef.current < maxRetries) {
|
|
2225
|
-
const retryDelay = Math.min(1000 * Math.pow(2, retryCountRef.current), 10000); // Exponential backoff, max 10s
|
|
2226
|
-
retryCountRef.current++;
|
|
2227
|
-
console.log(`🔄 Retrying connection in ${retryDelay}ms (attempt ${retryCountRef.current}/${maxRetries})`);
|
|
2228
|
-
retryTimeoutRef.current = setTimeout(connectWebSocket, retryDelay);
|
|
2229
|
-
}
|
|
2230
|
-
else {
|
|
2231
|
-
console.log('❌ Max connection retries exceeded, giving up');
|
|
2232
|
-
}
|
|
2233
|
-
};
|
|
2234
|
-
ws.onerror = (err) => {
|
|
2235
|
-
isConnectingRef.current = false;
|
|
2236
|
-
console.error('WebSocket error in Lexical plugin:', err);
|
|
2237
|
-
// Notify initialization failure if we haven't received initial content yet
|
|
2238
|
-
if (!hasReceivedInitialSnapshot.current && onInitialization) {
|
|
2239
|
-
onInitialization(false);
|
|
2240
|
-
}
|
|
2241
|
-
};
|
|
2242
|
-
}
|
|
2243
|
-
catch (err) {
|
|
2244
|
-
isConnectingRef.current = false;
|
|
2245
|
-
console.error('Failed to connect to WebSocket server in Lexical plugin:', err);
|
|
2246
|
-
}
|
|
2247
|
-
};
|
|
2248
|
-
connectWebSocket();
|
|
2249
|
-
return () => {
|
|
2250
|
-
// Clear retry timeout
|
|
2251
|
-
if (retryTimeoutRef.current) {
|
|
2252
|
-
clearTimeout(retryTimeoutRef.current);
|
|
2253
|
-
}
|
|
2254
|
-
if (wsRef.current) {
|
|
2255
|
-
wsRef.current.close();
|
|
2256
|
-
}
|
|
2257
|
-
};
|
|
2258
|
-
}, [websocketUrl, docId, editor, onPeerIdChange, onInitialization, updateLexicalFromLoro, updateRemoteCursors]); // Include all dependencies
|
|
2259
|
-
// Cleanup stale cursors periodically
|
|
2260
|
-
useEffect(() => {
|
|
2261
|
-
const cleanupInterval = setInterval(() => {
|
|
2262
|
-
const now = Date.now();
|
|
2263
|
-
const staleThreshold = 10000; // 10 seconds
|
|
2264
|
-
const updated = { ...remoteCursorsRef.current };
|
|
2265
|
-
let hasChanges = false;
|
|
2266
|
-
Object.keys(updated).forEach(peerId => {
|
|
2267
|
-
const lastSeen = cursorTimestamps.current[peerId] || 0;
|
|
2268
|
-
if (now - lastSeen > staleThreshold) {
|
|
2269
|
-
console.log('🧹 Removing stale cursor for peer:', peerId, 'last seen:', now - lastSeen, 'ms ago');
|
|
2270
|
-
delete updated[peerId];
|
|
2271
|
-
delete cursorTimestamps.current[peerId];
|
|
2272
|
-
hasChanges = true;
|
|
2273
|
-
}
|
|
2274
|
-
});
|
|
2275
|
-
if (hasChanges) {
|
|
2276
|
-
updateRemoteCursors(updated);
|
|
2277
|
-
}
|
|
2278
|
-
}, 2000); // Check every 2 seconds
|
|
2279
|
-
return () => clearInterval(cleanupInterval);
|
|
2280
|
-
}, [updateRemoteCursors]);
|
|
2281
|
-
// Track selection changes for collaborative cursors using Awareness
|
|
2282
|
-
useEffect(() => {
|
|
2283
|
-
// Listen to both content changes AND selection changes
|
|
2284
|
-
const removeUpdateListener = editor.registerUpdateListener(({ editorState }) => {
|
|
2285
|
-
// Always update cursor awareness on any state change (content or selection)
|
|
2286
|
-
editorState.read(() => {
|
|
2287
|
-
updateCursorAwareness();
|
|
2288
|
-
});
|
|
2289
|
-
});
|
|
2290
|
-
// Add DOM event listeners to track cursor movements
|
|
2291
|
-
const editorElement = editor.getElementByKey('root');
|
|
2292
|
-
const editorContainer = editorElement?.closest('[contenteditable]');
|
|
2293
|
-
if (editorContainer) {
|
|
2294
|
-
// Listen for mouse clicks that change cursor position
|
|
2295
|
-
const handleClick = () => {
|
|
2296
|
-
// Small delay to ensure selection has updated
|
|
2297
|
-
setTimeout(() => {
|
|
2298
|
-
updateCursorAwareness();
|
|
2299
|
-
}, 10);
|
|
2300
|
-
};
|
|
2301
|
-
// Listen for keyboard events that change cursor position
|
|
2302
|
-
const handleKeyboard = (event) => {
|
|
2303
|
-
// Check for cursor movement keys OR typing keys
|
|
2304
|
-
const cursorKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'];
|
|
2305
|
-
const isTyping = event.key.length === 1 || event.key === 'Backspace' || event.key === 'Delete';
|
|
2306
|
-
if (cursorKeys.includes(event.key) || isTyping) {
|
|
2307
|
-
// Small delay to ensure selection has updated
|
|
2308
|
-
setTimeout(() => {
|
|
2309
|
-
updateCursorAwareness();
|
|
2310
|
-
}, 10);
|
|
2311
|
-
}
|
|
2312
|
-
};
|
|
2313
|
-
// Listen for global selection changes
|
|
2314
|
-
const handleSelectionChange = () => {
|
|
2315
|
-
// Check if the current selection is within our editor
|
|
2316
|
-
const selection = window.getSelection();
|
|
2317
|
-
if (selection && selection.rangeCount > 0) {
|
|
2318
|
-
const range = selection.getRangeAt(0);
|
|
2319
|
-
if (editorContainer.contains(range.commonAncestorContainer)) {
|
|
2320
|
-
updateCursorAwareness();
|
|
2321
|
-
}
|
|
2322
|
-
}
|
|
2323
|
-
};
|
|
2324
|
-
editorContainer.addEventListener('click', handleClick);
|
|
2325
|
-
editorContainer.addEventListener('keyup', handleKeyboard);
|
|
2326
|
-
document.addEventListener('selectionchange', handleSelectionChange);
|
|
2327
|
-
// Periodic cursor refresh to keep cursor alive in EphemeralStore
|
|
2328
|
-
// Send cursor update every 60 seconds to prevent timeout
|
|
2329
|
-
const cursorRefreshInterval = setInterval(() => {
|
|
2330
|
-
updateCursorAwareness();
|
|
2331
|
-
}, 60_000); // Every 60 seconds
|
|
2332
|
-
return () => {
|
|
2333
|
-
clearInterval(cursorRefreshInterval);
|
|
2334
|
-
removeUpdateListener();
|
|
2335
|
-
editorContainer.removeEventListener('click', handleClick);
|
|
2336
|
-
editorContainer.removeEventListener('keyup', handleKeyboard);
|
|
2337
|
-
document.removeEventListener('selectionchange', handleSelectionChange);
|
|
2338
|
-
};
|
|
2339
|
-
}
|
|
2340
|
-
return removeUpdateListener;
|
|
2341
|
-
}, [editor, updateCursorAwareness]);
|
|
2342
|
-
// Get the Lexical editor element and its parent for overlay positioning
|
|
2343
|
-
const getEditorElement = useCallback(() => {
|
|
2344
|
-
const editorContainer = editor.getElementByKey('root');
|
|
2345
|
-
return editorContainer?.closest('[contenteditable]');
|
|
2346
|
-
}, [editor]);
|
|
2347
|
-
// Calculate DOM position from Lexical node position (using Lexical's approach)
|
|
2348
|
-
const getPositionFromLexicalPosition = useCallback((nodeKey, offset) => {
|
|
2349
|
-
const editorElement = getEditorElement();
|
|
2350
|
-
if (!editorElement) {
|
|
2351
|
-
console.warn('🚨 No editor element for cursor positioning');
|
|
2352
|
-
// Return position far off-screen so validation will skip it
|
|
2353
|
-
return { top: -1000, left: -1000 };
|
|
2354
|
-
}
|
|
2355
|
-
try {
|
|
2356
|
-
return editor.getEditorState().read(() => {
|
|
2357
|
-
const node = $getNodeByKey(nodeKey);
|
|
2358
|
-
if (!node) {
|
|
2359
|
-
console.warn('🚨 Node not found for key:', nodeKey);
|
|
2360
|
-
return { top: -1000, left: -1000 };
|
|
2361
|
-
}
|
|
2362
|
-
console.log('🎯 Calculating position for node:', {
|
|
2363
|
-
nodeKey,
|
|
2364
|
-
offset,
|
|
2365
|
-
nodeType: node.getType(),
|
|
2366
|
-
isTextNode: $isTextNode(node),
|
|
2367
|
-
isElementNode: $isElementNode(node),
|
|
2368
|
-
isLineBreakNode: $isLineBreakNode(node),
|
|
2369
|
-
textContent: $isTextNode(node) ? node.getTextContent() : 'N/A'
|
|
2370
|
-
});
|
|
2371
|
-
// Handle line break nodes specially (like Lexical does)
|
|
2372
|
-
if ($isLineBreakNode(node)) {
|
|
2373
|
-
const brElement = editor.getElementByKey(nodeKey);
|
|
2374
|
-
if (brElement) {
|
|
2375
|
-
const brRect = brElement.getBoundingClientRect();
|
|
2376
|
-
console.log('📏 Line break node position:', { top: brRect.top, left: brRect.left });
|
|
2377
|
-
return {
|
|
2378
|
-
top: brRect.top,
|
|
2379
|
-
left: brRect.left
|
|
2380
|
-
};
|
|
2381
|
-
}
|
|
2382
|
-
}
|
|
2383
|
-
// For element nodes (like root, paragraph), we need to find the text position within
|
|
2384
|
-
if ($isElementNode(node)) {
|
|
2385
|
-
console.log('🏗️ Element node, finding text position at offset:', offset);
|
|
2386
|
-
// Get all children and find the text position
|
|
2387
|
-
const children = node.getChildren();
|
|
2388
|
-
let currentOffset = 0;
|
|
2389
|
-
let targetNode = null;
|
|
2390
|
-
let targetOffset = 0;
|
|
2391
|
-
for (let i = 0; i < children.length; i++) {
|
|
2392
|
-
const child = children[i];
|
|
2393
|
-
if ($isTextNode(child)) {
|
|
2394
|
-
const textLength = child.getTextContentSize();
|
|
2395
|
-
console.log('📝 Found text node:', {
|
|
2396
|
-
key: child.getKey(),
|
|
2397
|
-
textLength,
|
|
2398
|
-
currentOffset,
|
|
2399
|
-
targetOffset: offset
|
|
2400
|
-
});
|
|
2401
|
-
if (currentOffset + textLength >= offset) {
|
|
2402
|
-
// Found the target text node
|
|
2403
|
-
targetNode = child;
|
|
2404
|
-
targetOffset = offset - currentOffset;
|
|
2405
|
-
console.log('🎯 Target found in text node:', {
|
|
2406
|
-
targetNodeKey: targetNode.getKey(),
|
|
2407
|
-
targetOffset
|
|
2408
|
-
});
|
|
2409
|
-
break;
|
|
2410
|
-
}
|
|
2411
|
-
currentOffset += textLength;
|
|
2412
|
-
}
|
|
2413
|
-
else if ($isElementNode(child)) {
|
|
2414
|
-
// For element children, count as 1 position
|
|
2415
|
-
console.log('🏗️ Found element node:', {
|
|
2416
|
-
key: child.getKey(),
|
|
2417
|
-
currentOffset,
|
|
2418
|
-
targetOffset: offset
|
|
2419
|
-
});
|
|
2420
|
-
if (currentOffset + 1 > offset) {
|
|
2421
|
-
targetNode = child;
|
|
2422
|
-
targetOffset = 0;
|
|
2423
|
-
console.log('🎯 Target found at element node:', {
|
|
2424
|
-
targetNodeKey: targetNode.getKey(),
|
|
2425
|
-
targetOffset
|
|
2426
|
-
});
|
|
2427
|
-
break;
|
|
2428
|
-
}
|
|
2429
|
-
currentOffset += 1;
|
|
2430
|
-
}
|
|
2431
|
-
else {
|
|
2432
|
-
// Other node types (decorators, etc.)
|
|
2433
|
-
if (currentOffset + 1 > offset) {
|
|
2434
|
-
targetNode = child;
|
|
2435
|
-
targetOffset = 0;
|
|
2436
|
-
break;
|
|
2437
|
-
}
|
|
2438
|
-
currentOffset += 1;
|
|
2439
|
-
}
|
|
2440
|
-
}
|
|
2441
|
-
// If we didn't find a specific target, use the last available position
|
|
2442
|
-
if (!targetNode && children.length > 0) {
|
|
2443
|
-
const lastChild = children[children.length - 1];
|
|
2444
|
-
if ($isTextNode(lastChild)) {
|
|
2445
|
-
targetNode = lastChild;
|
|
2446
|
-
targetOffset = lastChild.getTextContentSize();
|
|
2447
|
-
console.log('🔚 Using last text node position:', {
|
|
2448
|
-
targetNodeKey: targetNode.getKey(),
|
|
2449
|
-
targetOffset
|
|
2450
|
-
});
|
|
2451
|
-
}
|
|
2452
|
-
else {
|
|
2453
|
-
targetNode = lastChild;
|
|
2454
|
-
targetOffset = 0;
|
|
2455
|
-
console.log('🔚 Using last element node:', {
|
|
2456
|
-
targetNodeKey: targetNode.getKey(),
|
|
2457
|
-
targetOffset
|
|
2458
|
-
});
|
|
2459
|
-
}
|
|
2460
|
-
}
|
|
2461
|
-
// If we found a target node, use it for positioning
|
|
2462
|
-
if (targetNode) {
|
|
2463
|
-
console.log('🎯 Processing target node:', {
|
|
2464
|
-
targetNodeKey: targetNode.getKey(),
|
|
2465
|
-
targetOffset,
|
|
2466
|
-
isTextNode: $isTextNode(targetNode),
|
|
2467
|
-
isElementNode: $isElementNode(targetNode)
|
|
2468
|
-
});
|
|
2469
|
-
// If target is a text node, use it directly
|
|
2470
|
-
if ($isTextNode(targetNode)) {
|
|
2471
|
-
try {
|
|
2472
|
-
// Create DOM range for position calculation
|
|
2473
|
-
const range = createDOMRange(editor, targetNode, targetOffset, targetNode, targetOffset);
|
|
2474
|
-
if (range !== null) {
|
|
2475
|
-
// Use createRectsFromDOMRange for accurate positioning
|
|
2476
|
-
const rects = createRectsFromDOMRange(editor, range);
|
|
2477
|
-
if (rects.length > 0) {
|
|
2478
|
-
const rect = rects[0];
|
|
2479
|
-
// Ensure the rect has valid dimensions
|
|
2480
|
-
if (rect.height > 0 && rect.width >= 0) {
|
|
2481
|
-
console.log('📐 Valid text node range position:', {
|
|
2482
|
-
top: rect.top,
|
|
2483
|
-
left: rect.left,
|
|
2484
|
-
height: rect.height,
|
|
2485
|
-
width: rect.width,
|
|
2486
|
-
targetNodeKey: targetNode.getKey(),
|
|
2487
|
-
targetOffset
|
|
2488
|
-
});
|
|
2489
|
-
return {
|
|
2490
|
-
top: rect.top,
|
|
2491
|
-
left: rect.left
|
|
2492
|
-
};
|
|
2493
|
-
}
|
|
2494
|
-
else {
|
|
2495
|
-
console.warn('🚨 Invalid rect dimensions, trying fallback approach:', rect);
|
|
2496
|
-
}
|
|
2497
|
-
}
|
|
2498
|
-
// Fallback: Use native DOM range if Lexical rects fail
|
|
2499
|
-
const rangeBounds = range.getBoundingClientRect();
|
|
2500
|
-
if (rangeBounds && rangeBounds.height > 0) {
|
|
2501
|
-
console.log('📐 Fallback DOM range position:', {
|
|
2502
|
-
top: rangeBounds.top,
|
|
2503
|
-
left: rangeBounds.left,
|
|
2504
|
-
height: rangeBounds.height,
|
|
2505
|
-
width: rangeBounds.width
|
|
2506
|
-
});
|
|
2507
|
-
return {
|
|
2508
|
-
top: rangeBounds.top,
|
|
2509
|
-
left: rangeBounds.left
|
|
2510
|
-
};
|
|
2511
|
-
}
|
|
2512
|
-
}
|
|
2513
|
-
// Ultimate fallback: Use direct DOM element positioning
|
|
2514
|
-
const domElement = editor.getElementByKey(targetNode.getKey());
|
|
2515
|
-
if (domElement) {
|
|
2516
|
-
const elementRect = domElement.getBoundingClientRect();
|
|
2517
|
-
console.log('📐 Ultimate fallback - DOM element position:', {
|
|
2518
|
-
top: elementRect.top,
|
|
2519
|
-
left: elementRect.left,
|
|
2520
|
-
height: elementRect.height,
|
|
2521
|
-
width: elementRect.width
|
|
2522
|
-
});
|
|
2523
|
-
// For text nodes, try to calculate character position within the element
|
|
2524
|
-
if (targetOffset > 0 && domElement.textContent) {
|
|
2525
|
-
// Create a temporary range to measure character offset
|
|
2526
|
-
const tempRange = document.createRange();
|
|
2527
|
-
const textNode = domElement.firstChild;
|
|
2528
|
-
if (textNode && textNode.nodeType === Node.TEXT_NODE && textNode.textContent) {
|
|
2529
|
-
const safeOffset = Math.min(targetOffset, textNode.textContent.length);
|
|
2530
|
-
tempRange.setStart(textNode, safeOffset);
|
|
2531
|
-
tempRange.setEnd(textNode, safeOffset);
|
|
2532
|
-
const tempRect = tempRange.getBoundingClientRect();
|
|
2533
|
-
if (tempRect && tempRect.height > 0) {
|
|
2534
|
-
console.log('📐 Character-precise position:', {
|
|
2535
|
-
top: tempRect.top,
|
|
2536
|
-
left: tempRect.left,
|
|
2537
|
-
offset: safeOffset
|
|
2538
|
-
});
|
|
2539
|
-
return {
|
|
2540
|
-
top: tempRect.top,
|
|
2541
|
-
left: tempRect.left
|
|
2542
|
-
};
|
|
2543
|
-
}
|
|
2544
|
-
}
|
|
2545
|
-
}
|
|
2546
|
-
return {
|
|
2547
|
-
top: elementRect.top,
|
|
2548
|
-
left: elementRect.left
|
|
2549
|
-
};
|
|
2550
|
-
}
|
|
2551
|
-
}
|
|
2552
|
-
catch (error) {
|
|
2553
|
-
console.warn('🚨 Error creating range for target text node:', error);
|
|
2554
|
-
}
|
|
2555
|
-
}
|
|
2556
|
-
// If target is an element node, try to find first text node within it
|
|
2557
|
-
if ($isElementNode(targetNode)) {
|
|
2558
|
-
console.log('🏗️ Target is element, looking for text within it');
|
|
2559
|
-
const targetChildren = targetNode.getChildren();
|
|
2560
|
-
let firstTextNode = null;
|
|
2561
|
-
for (const child of targetChildren) {
|
|
2562
|
-
if ($isTextNode(child)) {
|
|
2563
|
-
firstTextNode = child;
|
|
2564
|
-
console.log('📝 Found first text node in target element:', child.getKey());
|
|
2565
|
-
break;
|
|
2566
|
-
}
|
|
2567
|
-
}
|
|
2568
|
-
if (firstTextNode) {
|
|
2569
|
-
try {
|
|
2570
|
-
// Improved range creation for element nodes
|
|
2571
|
-
const range = createDOMRange(editor, firstTextNode, 0, // Start of first text node
|
|
2572
|
-
firstTextNode, 0);
|
|
2573
|
-
if (range !== null) {
|
|
2574
|
-
const rects = createRectsFromDOMRange(editor, range);
|
|
2575
|
-
if (rects.length > 0) {
|
|
2576
|
-
const rect = rects[0];
|
|
2577
|
-
// Ensure rect has valid height
|
|
2578
|
-
if (rect.height > 0) {
|
|
2579
|
-
console.log('📐 Valid element->text range position:', {
|
|
2580
|
-
top: rect.top,
|
|
2581
|
-
left: rect.left,
|
|
2582
|
-
height: rect.height,
|
|
2583
|
-
targetNodeKey: targetNode.getKey(),
|
|
2584
|
-
firstTextNodeKey: firstTextNode.getKey()
|
|
2585
|
-
});
|
|
2586
|
-
return {
|
|
2587
|
-
top: rect.top,
|
|
2588
|
-
left: rect.left
|
|
2589
|
-
};
|
|
2590
|
-
}
|
|
2591
|
-
else {
|
|
2592
|
-
console.warn('🚨 Invalid element rect height, using fallback');
|
|
2593
|
-
}
|
|
2594
|
-
}
|
|
2595
|
-
// Try native DOM range fallback
|
|
2596
|
-
const rangeBounds = range.getBoundingClientRect();
|
|
2597
|
-
if (rangeBounds && rangeBounds.height > 0) {
|
|
2598
|
-
console.log('📐 Element fallback DOM range position:', {
|
|
2599
|
-
top: rangeBounds.top,
|
|
2600
|
-
left: rangeBounds.left,
|
|
2601
|
-
height: rangeBounds.height
|
|
2602
|
-
});
|
|
2603
|
-
return {
|
|
2604
|
-
top: rangeBounds.top,
|
|
2605
|
-
left: rangeBounds.left
|
|
2606
|
-
};
|
|
2607
|
-
}
|
|
2608
|
-
}
|
|
2609
|
-
}
|
|
2610
|
-
catch (error) {
|
|
2611
|
-
console.warn('🚨 Error creating range for text within target element:', error);
|
|
2612
|
-
}
|
|
2613
|
-
}
|
|
2614
|
-
else {
|
|
2615
|
-
// No text nodes in element, use element position directly
|
|
2616
|
-
console.log('📦 No text in target element, using element position');
|
|
2617
|
-
const domElement = editor.getElementByKey(targetNode.getKey());
|
|
2618
|
-
if (domElement) {
|
|
2619
|
-
const elementRect = domElement.getBoundingClientRect();
|
|
2620
|
-
console.log('📐 Direct element position:', {
|
|
2621
|
-
top: elementRect.top,
|
|
2622
|
-
left: elementRect.left,
|
|
2623
|
-
height: elementRect.height,
|
|
2624
|
-
width: elementRect.width
|
|
2625
|
-
});
|
|
2626
|
-
return {
|
|
2627
|
-
top: elementRect.top,
|
|
2628
|
-
left: elementRect.left
|
|
2629
|
-
};
|
|
2630
|
-
}
|
|
2631
|
-
}
|
|
2632
|
-
}
|
|
2633
|
-
}
|
|
2634
|
-
// Fallback to element position if we can't create a range
|
|
2635
|
-
console.log('⚠️ Falling back to element position for:', nodeKey);
|
|
2636
|
-
const domElement = editor.getElementByKey(nodeKey);
|
|
2637
|
-
if (domElement) {
|
|
2638
|
-
const elementRect = domElement.getBoundingClientRect();
|
|
2639
|
-
return {
|
|
2640
|
-
top: elementRect.top,
|
|
2641
|
-
left: elementRect.left
|
|
2642
|
-
};
|
|
2643
|
-
}
|
|
2644
|
-
}
|
|
2645
|
-
// For text nodes, use Lexical's createDOMRange directly
|
|
2646
|
-
if ($isTextNode(node)) {
|
|
2647
|
-
console.log('📝 Text node, creating range at offset:', offset);
|
|
2648
|
-
try {
|
|
2649
|
-
// Enhanced range creation for text nodes
|
|
2650
|
-
const range = createDOMRange(editor, node, offset, node, offset);
|
|
2651
|
-
if (range !== null) {
|
|
2652
|
-
const rects = createRectsFromDOMRange(editor, range);
|
|
2653
|
-
if (rects.length > 0) {
|
|
2654
|
-
const rect = rects[0];
|
|
2655
|
-
// Validate rect dimensions
|
|
2656
|
-
if (rect.height > 0 && !isNaN(rect.top) && !isNaN(rect.left)) {
|
|
2657
|
-
console.log('📐 Valid text range position:', {
|
|
2658
|
-
top: rect.top,
|
|
2659
|
-
left: rect.left,
|
|
2660
|
-
width: rect.width,
|
|
2661
|
-
height: rect.height,
|
|
2662
|
-
nodeKey,
|
|
2663
|
-
offset
|
|
2664
|
-
});
|
|
2665
|
-
return {
|
|
2666
|
-
top: rect.top,
|
|
2667
|
-
left: rect.left
|
|
2668
|
-
};
|
|
2669
|
-
}
|
|
2670
|
-
else {
|
|
2671
|
-
console.warn('🚨 Invalid text rect, trying DOM range fallback:', rect);
|
|
2672
|
-
// Use native DOM range fallback
|
|
2673
|
-
const rangeBounds = range.getBoundingClientRect();
|
|
2674
|
-
if (rangeBounds && rangeBounds.height > 0) {
|
|
2675
|
-
console.log('📐 Text DOM range fallback position:', {
|
|
2676
|
-
top: rangeBounds.top,
|
|
2677
|
-
left: rangeBounds.left,
|
|
2678
|
-
height: rangeBounds.height
|
|
2679
|
-
});
|
|
2680
|
-
return {
|
|
2681
|
-
top: rangeBounds.top,
|
|
2682
|
-
left: rangeBounds.left
|
|
2683
|
-
};
|
|
2684
|
-
}
|
|
2685
|
-
}
|
|
2686
|
-
}
|
|
2687
|
-
// Additional fallback: Use range getBoundingClientRect directly
|
|
2688
|
-
const directRect = range.getBoundingClientRect();
|
|
2689
|
-
if (directRect && directRect.height > 0) {
|
|
2690
|
-
console.log('📐 Direct range rect position:', {
|
|
2691
|
-
top: directRect.top,
|
|
2692
|
-
left: directRect.left,
|
|
2693
|
-
height: directRect.height
|
|
2694
|
-
});
|
|
2695
|
-
return {
|
|
2696
|
-
top: directRect.top,
|
|
2697
|
-
left: directRect.left
|
|
2698
|
-
};
|
|
2699
|
-
}
|
|
2700
|
-
}
|
|
2701
|
-
}
|
|
2702
|
-
catch (error) {
|
|
2703
|
-
console.warn('🚨 Error creating range for text node:', error);
|
|
2704
|
-
}
|
|
2705
|
-
}
|
|
2706
|
-
// Get the DOM element for this node
|
|
2707
|
-
const domElement = editor.getElementByKey(nodeKey);
|
|
2708
|
-
if (!domElement) {
|
|
2709
|
-
console.warn('� DOM element not found for node key:', nodeKey);
|
|
2710
|
-
return { top: -1000, left: -1000 };
|
|
2711
|
-
}
|
|
2712
|
-
if (domElement) {
|
|
2713
|
-
try {
|
|
2714
|
-
// Create a range at the specified offset within the node
|
|
2715
|
-
const range = document.createRange();
|
|
2716
|
-
if (domElement.nodeType === Node.TEXT_NODE) {
|
|
2717
|
-
// For text nodes, set range at the offset
|
|
2718
|
-
range.setStart(domElement, Math.min(offset, domElement?.textContent?.length || 0));
|
|
2719
|
-
range.collapse(true);
|
|
2720
|
-
}
|
|
2721
|
-
else {
|
|
2722
|
-
// For element nodes, find the text content and position
|
|
2723
|
-
const walker = document.createTreeWalker(domElement, NodeFilter.SHOW_TEXT, null);
|
|
2724
|
-
let currentOffset = 0;
|
|
2725
|
-
let textNode = walker.nextNode();
|
|
2726
|
-
while (textNode && currentOffset + textNode.textContent.length < offset) {
|
|
2727
|
-
currentOffset += textNode.textContent.length;
|
|
2728
|
-
textNode = walker.nextNode();
|
|
2729
|
-
}
|
|
2730
|
-
if (textNode) {
|
|
2731
|
-
range.setStart(textNode, Math.min(offset - currentOffset, textNode.textContent.length));
|
|
2732
|
-
range.collapse(true);
|
|
2733
|
-
}
|
|
2734
|
-
else {
|
|
2735
|
-
// Fallback to end of element
|
|
2736
|
-
range.selectNodeContents(domElement);
|
|
2737
|
-
range.collapse(false);
|
|
2738
|
-
}
|
|
2739
|
-
}
|
|
2740
|
-
const rect = range.getBoundingClientRect();
|
|
2741
|
-
if (rect.width > 0 || rect.height > 0) {
|
|
2742
|
-
return {
|
|
2743
|
-
top: rect.top,
|
|
2744
|
-
left: rect.left
|
|
2745
|
-
};
|
|
2746
|
-
}
|
|
2747
|
-
}
|
|
2748
|
-
catch (rangeError) {
|
|
2749
|
-
console.warn('Range error:', rangeError);
|
|
2750
|
-
}
|
|
2751
|
-
// Fallback to element position
|
|
2752
|
-
const elementRect = domElement.getBoundingClientRect();
|
|
2753
|
-
return {
|
|
2754
|
-
top: elementRect.top,
|
|
2755
|
-
left: elementRect.left
|
|
2756
|
-
};
|
|
2757
|
-
}
|
|
2758
|
-
else {
|
|
2759
|
-
// No DOM element found
|
|
2760
|
-
return { top: -1000, left: -1000 };
|
|
2761
|
-
}
|
|
2762
|
-
});
|
|
2763
|
-
}
|
|
2764
|
-
catch (error) {
|
|
2765
|
-
console.warn('Error calculating cursor position:', error);
|
|
2766
|
-
const editorRect = editorElement.getBoundingClientRect();
|
|
2767
|
-
return {
|
|
2768
|
-
top: editorRect.top + 20,
|
|
2769
|
-
left: editorRect.left + 20
|
|
2770
|
-
};
|
|
2771
|
-
}
|
|
2772
|
-
}, [getEditorElement, editor]);
|
|
2773
|
-
// Add scroll listener to update cursor positions when page scrolls
|
|
2774
|
-
useEffect(() => {
|
|
2775
|
-
const handleScroll = () => {
|
|
2776
|
-
console.log('🔄 Scroll detected, forcing cursor re-render');
|
|
2777
|
-
// Cursor overlay will handle its own re-rendering through the ref
|
|
2778
|
-
};
|
|
2779
|
-
// Listen to scroll events on window and any scrollable containers
|
|
2780
|
-
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
2781
|
-
document.addEventListener('scroll', handleScroll, { passive: true });
|
|
2782
|
-
// Also listen to editor container scroll if it exists
|
|
2783
|
-
const editorElement = getEditorElement();
|
|
2784
|
-
if (editorElement) {
|
|
2785
|
-
const editorContainer = editorElement.closest('.editor-container, .lexical-editor, [data-lexical-editor]');
|
|
2786
|
-
if (editorContainer) {
|
|
2787
|
-
editorContainer.addEventListener('scroll', handleScroll, { passive: true });
|
|
2788
|
-
}
|
|
2789
|
-
}
|
|
2790
|
-
return () => {
|
|
2791
|
-
window.removeEventListener('scroll', handleScroll);
|
|
2792
|
-
document.removeEventListener('scroll', handleScroll);
|
|
2793
|
-
const editorElement = getEditorElement();
|
|
2794
|
-
if (editorElement) {
|
|
2795
|
-
const editorContainer = editorElement.closest('.editor-container, .lexical-editor, [data-lexical-editor]');
|
|
2796
|
-
if (editorContainer) {
|
|
2797
|
-
editorContainer.removeEventListener('scroll', handleScroll);
|
|
2798
|
-
}
|
|
2799
|
-
}
|
|
2800
|
-
};
|
|
2801
|
-
}, [getEditorElement]);
|
|
2802
|
-
console.log('🎬 LoroCollaborativePlugin component render called', {
|
|
2803
|
-
remoteCursorsCount: Object.keys(remoteCursorsRef.current).length,
|
|
2804
|
-
remoteCursorsPeerIds: Object.keys(remoteCursorsRef.current),
|
|
2805
|
-
clientId: clientId,
|
|
2806
|
-
peerIdRef: peerIdRef.current,
|
|
2807
|
-
editorElementExists: !!getEditorElement()
|
|
2808
|
-
});
|
|
2809
|
-
// Use React portal for cursor rendering
|
|
2810
|
-
return (_jsxs(_Fragment, { children: [incrementalUpdateError && (_jsxs("div", { style: {
|
|
2811
|
-
position: 'fixed',
|
|
2812
|
-
top: 0,
|
|
2813
|
-
left: 0,
|
|
2814
|
-
right: 0,
|
|
2815
|
-
backgroundColor: '#dc3545',
|
|
2816
|
-
color: 'white',
|
|
2817
|
-
padding: '8px 16px',
|
|
2818
|
-
fontSize: '14px',
|
|
2819
|
-
fontWeight: 'bold',
|
|
2820
|
-
textAlign: 'center',
|
|
2821
|
-
zIndex: 9999,
|
|
2822
|
-
borderBottom: '2px solid #a02834',
|
|
2823
|
-
boxShadow: '0 2px 4px rgba(0,0,0,0.2)'
|
|
2824
|
-
}, children: ["\u26A0\uFE0F Incremental Update Failed: ", incrementalUpdateError] })), _jsx(CursorsContainer, { ref: cursorOverlayRef, remoteCursors: remoteCursorsRef.current, getPositionFromLexicalPosition: getPositionFromLexicalPosition, clientId: clientId, editor: editor })] }));
|
|
2825
|
-
}
|
|
2826
|
-
export default LoroCollaborativePlugin;
|