@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.
Files changed (406) hide show
  1. package/README.md +24 -137
  2. package/lib/App.d.ts +2 -0
  3. package/lib/App.js +141 -0
  4. package/lib/Editor.d.ts +2 -0
  5. package/lib/Editor.js +111 -0
  6. package/lib/Settings.d.ts +2 -0
  7. package/lib/Settings.js +57 -0
  8. package/lib/appSettings.d.ts +36 -0
  9. package/lib/appSettings.js +44 -0
  10. package/lib/collab/loro/Bindings.d.ts +41 -0
  11. package/lib/collab/loro/Bindings.js +95 -0
  12. package/lib/collab/loro/Debug.d.ts +33 -0
  13. package/lib/collab/loro/Debug.js +448 -0
  14. package/lib/collab/loro/LexicalCollaborationContext.d.ts +19 -0
  15. package/lib/collab/loro/LexicalCollaborationContext.js +48 -0
  16. package/lib/collab/loro/LexicalCollaborationPlugin.d.ts +24 -0
  17. package/lib/collab/loro/LexicalCollaborationPlugin.js +83 -0
  18. package/lib/collab/loro/State.d.ts +53 -0
  19. package/lib/collab/loro/State.js +90 -0
  20. package/lib/collab/loro/components/LoroCollaborationUI.d.ts +13 -0
  21. package/lib/collab/loro/components/LoroCollaborationUI.js +9 -0
  22. package/lib/collab/loro/components/LoroCollaborators.d.ts +8 -0
  23. package/lib/collab/loro/components/LoroCollaborators.js +97 -0
  24. package/lib/collab/loro/components/index.d.ts +2 -0
  25. package/lib/collab/loro/components/index.js +2 -0
  26. package/lib/collab/loro/index.d.ts +6 -0
  27. package/lib/collab/loro/index.js +6 -0
  28. package/lib/collab/loro/integrators/BaseIntegrator.d.ts +14 -0
  29. package/lib/collab/loro/integrators/BaseIntegrator.js +1 -0
  30. package/lib/collab/loro/integrators/CounterIntegrator.d.ts +23 -0
  31. package/lib/collab/loro/integrators/CounterIntegrator.js +40 -0
  32. package/lib/collab/loro/integrators/ListIntegrator.d.ts +23 -0
  33. package/lib/collab/loro/integrators/ListIntegrator.js +49 -0
  34. package/lib/collab/loro/integrators/MapIntegrator.d.ts +24 -0
  35. package/lib/collab/loro/integrators/MapIntegrator.js +177 -0
  36. package/lib/collab/loro/integrators/TextIntegrator.d.ts +25 -0
  37. package/lib/collab/loro/integrators/TextIntegrator.js +51 -0
  38. package/lib/collab/loro/integrators/TreeIntegrator.d.ts +25 -0
  39. package/lib/collab/loro/integrators/TreeIntegrator.js +201 -0
  40. package/lib/collab/loro/nodes/NodeFactory.d.ts +8 -0
  41. package/lib/collab/loro/nodes/NodeFactory.js +105 -0
  42. package/lib/collab/loro/nodes/NodesMapper.d.ts +111 -0
  43. package/lib/collab/loro/nodes/NodesMapper.js +258 -0
  44. package/lib/collab/loro/propagators/DecoratorNodePropagator.d.ts +60 -0
  45. package/lib/collab/loro/propagators/DecoratorNodePropagator.js +302 -0
  46. package/lib/collab/loro/propagators/ElementNodePropagator.d.ts +62 -0
  47. package/lib/collab/loro/propagators/ElementNodePropagator.js +335 -0
  48. package/lib/collab/loro/propagators/LineBreakNodePropagator.d.ts +57 -0
  49. package/lib/collab/loro/propagators/LineBreakNodePropagator.js +196 -0
  50. package/lib/collab/loro/propagators/RootNodePropagator.d.ts +55 -0
  51. package/lib/collab/loro/propagators/RootNodePropagator.js +168 -0
  52. package/lib/collab/loro/propagators/TextNodePropagator.d.ts +60 -0
  53. package/lib/collab/loro/propagators/TextNodePropagator.js +434 -0
  54. package/lib/collab/loro/propagators/index.d.ts +49 -0
  55. package/lib/collab/loro/propagators/index.js +32 -0
  56. package/lib/collab/loro/provider/websocket.d.ts +116 -0
  57. package/lib/collab/loro/provider/websocket.js +907 -0
  58. package/lib/collab/loro/servers/index.d.ts +0 -0
  59. package/lib/collab/loro/servers/index.js +0 -0
  60. package/lib/collab/loro/servers/ws/callback.d.ts +5 -0
  61. package/lib/collab/loro/servers/ws/callback.js +85 -0
  62. package/lib/collab/loro/servers/ws/server.d.ts +2 -0
  63. package/lib/collab/loro/servers/ws/server.js +25 -0
  64. package/lib/collab/loro/servers/ws/utils.d.ts +40 -0
  65. package/lib/collab/loro/servers/ws/utils.js +513 -0
  66. package/lib/collab/loro/sync/SyncCursors.d.ts +32 -0
  67. package/lib/collab/loro/sync/SyncCursors.js +435 -0
  68. package/lib/collab/loro/sync/SyncLexicalToLoro.d.ts +4 -0
  69. package/lib/collab/loro/sync/SyncLexicalToLoro.js +80 -0
  70. package/lib/collab/loro/sync/SyncLoroToLexical.d.ts +5 -0
  71. package/lib/collab/loro/sync/SyncLoroToLexical.js +96 -0
  72. package/lib/collab/loro/types/LexicalNodeData.d.ts +32 -0
  73. package/lib/collab/loro/types/LexicalNodeData.js +71 -0
  74. package/lib/collab/loro/useCollaboration.d.ts +12 -0
  75. package/lib/collab/loro/useCollaboration.js +248 -0
  76. package/lib/collab/loro/utils/InitialContent.d.ts +64 -0
  77. package/lib/collab/loro/utils/InitialContent.js +109 -0
  78. package/lib/collab/loro/utils/LexicalToLoro.d.ts +18 -0
  79. package/lib/collab/loro/utils/LexicalToLoro.js +96 -0
  80. package/lib/collab/loro/utils/Utils.d.ts +44 -0
  81. package/lib/collab/loro/utils/Utils.js +153 -0
  82. package/lib/collab/loro/wsProvider.d.ts +8 -0
  83. package/lib/collab/loro/wsProvider.js +31 -0
  84. package/lib/collab/utils/invariant.d.ts +1 -0
  85. package/lib/collab/utils/invariant.js +11 -0
  86. package/lib/collab/utils/simpleDiffWithCursor.d.ts +5 -0
  87. package/lib/collab/utils/simpleDiffWithCursor.js +31 -0
  88. package/lib/collab/yjs/Bindings.d.ts +23 -0
  89. package/lib/collab/yjs/Bindings.js +26 -0
  90. package/lib/collab/yjs/Debug.d.ts +23 -0
  91. package/lib/collab/yjs/Debug.js +213 -0
  92. package/lib/collab/yjs/LexicalCollaborationContext.d.ts +10 -0
  93. package/lib/collab/yjs/LexicalCollaborationContext.js +37 -0
  94. package/lib/collab/yjs/LexicalCollaborationPlugin.d.ts +21 -0
  95. package/lib/collab/yjs/LexicalCollaborationPlugin.js +63 -0
  96. package/lib/collab/yjs/State.d.ts +51 -0
  97. package/lib/collab/yjs/State.js +35 -0
  98. package/lib/collab/yjs/nodes/AnyCollabNode.d.ts +5 -0
  99. package/lib/collab/yjs/nodes/AnyCollabNode.js +1 -0
  100. package/lib/collab/yjs/nodes/CollabDecoratorNode.d.ts +22 -0
  101. package/lib/collab/yjs/nodes/CollabDecoratorNode.js +64 -0
  102. package/lib/collab/yjs/nodes/CollabElementNode.d.ts +40 -0
  103. package/lib/collab/yjs/nodes/CollabElementNode.js +462 -0
  104. package/lib/collab/yjs/nodes/CollabLineBreakNode.d.ts +19 -0
  105. package/lib/collab/yjs/nodes/CollabLineBreakNode.js +44 -0
  106. package/lib/collab/yjs/nodes/CollabTextNode.d.ts +25 -0
  107. package/lib/collab/yjs/nodes/CollabTextNode.js +103 -0
  108. package/lib/collab/yjs/provider/websocket.d.ts +88 -0
  109. package/lib/collab/yjs/provider/websocket.js +415 -0
  110. package/lib/collab/yjs/servers/index.d.ts +0 -0
  111. package/lib/collab/yjs/servers/index.js +0 -0
  112. package/lib/collab/yjs/servers/ws/callback.d.ts +5 -0
  113. package/lib/collab/yjs/servers/ws/callback.js +72 -0
  114. package/lib/collab/yjs/servers/ws/server.d.ts +2 -0
  115. package/lib/collab/yjs/servers/ws/server.js +25 -0
  116. package/lib/collab/yjs/servers/ws/utils.d.ts +49 -0
  117. package/lib/collab/yjs/servers/ws/utils.js +284 -0
  118. package/lib/collab/yjs/sync/SyncCursors.d.ts +39 -0
  119. package/lib/collab/yjs/sync/SyncCursors.js +351 -0
  120. package/lib/collab/yjs/sync/SyncEditorStates.d.ts +10 -0
  121. package/lib/collab/yjs/sync/SyncEditorStates.js +200 -0
  122. package/lib/collab/yjs/useCollaboration.d.ts +12 -0
  123. package/lib/collab/yjs/useCollaboration.js +255 -0
  124. package/lib/collab/yjs/utils/Utils.d.ts +25 -0
  125. package/lib/collab/yjs/utils/Utils.js +402 -0
  126. package/lib/collab/yjs/wsProvider.d.ts +3 -0
  127. package/lib/collab/yjs/wsProvider.js +21 -0
  128. package/lib/commenting/index.d.ts +41 -0
  129. package/lib/commenting/index.js +324 -0
  130. package/lib/context/FlashMessageContext.d.ts +7 -0
  131. package/lib/context/FlashMessageContext.js +24 -0
  132. package/lib/context/SettingsContext.d.ts +12 -0
  133. package/lib/context/SettingsContext.js +38 -0
  134. package/lib/context/SharedHistoryContext.d.ts +11 -0
  135. package/lib/context/SharedHistoryContext.js +11 -0
  136. package/lib/context/ToolbarContext.d.ts +65 -0
  137. package/lib/context/ToolbarContext.js +84 -0
  138. package/lib/demo.d.ts +12 -0
  139. package/lib/demo.js +41 -0
  140. package/lib/hooks/useFlashMessage.d.ts +2 -0
  141. package/lib/hooks/useFlashMessage.js +4 -0
  142. package/lib/hooks/useModal.d.ts +5 -0
  143. package/lib/hooks/useModal.js +26 -0
  144. package/lib/hooks/useReport.d.ts +1 -0
  145. package/lib/hooks/useReport.js +46 -0
  146. package/lib/index.d.ts +1 -1
  147. package/lib/index.js +1 -5
  148. package/lib/nodes/AutocompleteNode.d.ts +27 -0
  149. package/lib/nodes/AutocompleteNode.js +56 -0
  150. package/lib/nodes/CounterComponent.d.ts +6 -0
  151. package/lib/nodes/CounterComponent.js +137 -0
  152. package/lib/nodes/CounterNode.d.ts +23 -0
  153. package/lib/nodes/CounterNode.js +47 -0
  154. package/lib/nodes/DateTimeNode/DateTimeComponent.d.ts +8 -0
  155. package/lib/nodes/DateTimeNode/DateTimeComponent.js +119 -0
  156. package/lib/nodes/DateTimeNode/DateTimeNode.d.ts +27 -0
  157. package/lib/nodes/DateTimeNode/DateTimeNode.js +82 -0
  158. package/lib/nodes/EmojiNode.d.ts +18 -0
  159. package/lib/nodes/EmojiNode.js +50 -0
  160. package/lib/nodes/EquationComponent.d.ts +9 -0
  161. package/lib/nodes/EquationComponent.js +75 -0
  162. package/lib/nodes/EquationNode.d.ts +26 -0
  163. package/lib/nodes/EquationNode.js +109 -0
  164. package/lib/nodes/ExcalidrawNode/ExcalidrawComponent.d.ts +8 -0
  165. package/lib/nodes/ExcalidrawNode/ExcalidrawComponent.js +110 -0
  166. package/lib/nodes/ExcalidrawNode/ExcalidrawImage.d.ts +50 -0
  167. package/lib/nodes/ExcalidrawNode/ExcalidrawImage.js +55 -0
  168. package/lib/nodes/ExcalidrawNode/index.d.ts +32 -0
  169. package/lib/nodes/ExcalidrawNode/index.js +117 -0
  170. package/lib/nodes/FigmaNode.d.ts +20 -0
  171. package/lib/nodes/FigmaNode.js +52 -0
  172. package/lib/nodes/ImageComponent.d.ts +16 -0
  173. package/lib/nodes/ImageComponent.js +272 -0
  174. package/lib/nodes/ImageNode.d.ts +50 -0
  175. package/lib/nodes/ImageNode.js +151 -0
  176. package/lib/nodes/InlineImageNode/InlineImageComponent.d.ts +26 -0
  177. package/lib/nodes/InlineImageNode/InlineImageComponent.js +161 -0
  178. package/lib/nodes/InlineImageNode/InlineImageNode.d.ts +59 -0
  179. package/lib/nodes/InlineImageNode/InlineImageNode.js +162 -0
  180. package/lib/nodes/KeywordNode.d.ts +14 -0
  181. package/lib/nodes/KeywordNode.js +33 -0
  182. package/lib/nodes/LayoutContainerNode.d.ts +24 -0
  183. package/lib/nodes/LayoutContainerNode.js +91 -0
  184. package/lib/nodes/LayoutItemNode.d.ts +16 -0
  185. package/lib/nodes/LayoutItemNode.js +65 -0
  186. package/lib/nodes/MentionNode.d.ts +20 -0
  187. package/lib/nodes/MentionNode.js +81 -0
  188. package/lib/nodes/PageBreakNode/index.d.ts +17 -0
  189. package/lib/nodes/PageBreakNode/index.js +83 -0
  190. package/lib/nodes/PlaygroundNodes.d.ts +3 -0
  191. package/lib/nodes/PlaygroundNodes.js +71 -0
  192. package/lib/nodes/PollComponent.d.ts +9 -0
  193. package/lib/nodes/PollComponent.js +85 -0
  194. package/lib/nodes/PollNode.d.ts +43 -0
  195. package/lib/nodes/PollNode.js +153 -0
  196. package/lib/nodes/SpecialTextNode.d.ts +24 -0
  197. package/lib/nodes/SpecialTextNode.js +50 -0
  198. package/lib/nodes/StickyComponent.d.ts +10 -0
  199. package/lib/nodes/StickyComponent.js +162 -0
  200. package/lib/nodes/StickyNode.d.ts +31 -0
  201. package/lib/nodes/StickyNode.js +76 -0
  202. package/lib/nodes/TweetNode.d.ts +21 -0
  203. package/lib/nodes/TweetNode.js +119 -0
  204. package/lib/nodes/YouTubeNode.d.ts +22 -0
  205. package/lib/nodes/YouTubeNode.js +84 -0
  206. package/lib/plugins/ActionsPlugin/index.d.ts +5 -0
  207. package/lib/plugins/ActionsPlugin/index.js +168 -0
  208. package/lib/plugins/AutoEmbedPlugin/index.d.ts +19 -0
  209. package/lib/plugins/AutoEmbedPlugin/index.js +158 -0
  210. package/lib/plugins/AutoLinkPlugin/index.d.ts +2 -0
  211. package/lib/plugins/AutoLinkPlugin/index.js +15 -0
  212. package/lib/plugins/AutocompletePlugin/index.d.ts +10 -0
  213. package/lib/plugins/AutocompletePlugin/index.js +2473 -0
  214. package/lib/plugins/CodeActionMenuPlugin/components/CopyButton/index.d.ts +7 -0
  215. package/lib/plugins/CodeActionMenuPlugin/components/CopyButton/index.js +42 -0
  216. package/lib/plugins/CodeActionMenuPlugin/components/PrettierButton/index.d.ts +17 -0
  217. package/lib/plugins/CodeActionMenuPlugin/components/PrettierButton/index.js +111 -0
  218. package/lib/plugins/CodeActionMenuPlugin/index.d.ts +5 -0
  219. package/lib/plugins/CodeActionMenuPlugin/index.js +104 -0
  220. package/lib/plugins/CodeActionMenuPlugin/utils.d.ts +1 -0
  221. package/lib/plugins/CodeActionMenuPlugin/utils.js +18 -0
  222. package/lib/plugins/CodeHighlightPrismPlugin/index.d.ts +2 -0
  223. package/lib/plugins/CodeHighlightPrismPlugin/index.js +10 -0
  224. package/lib/plugins/CodeHighlightShikiPlugin/index.d.ts +2 -0
  225. package/lib/plugins/CodeHighlightShikiPlugin/index.js +10 -0
  226. package/lib/plugins/CollapsiblePlugin/CollapsibleContainerNode.d.ts +25 -0
  227. package/lib/plugins/CollapsiblePlugin/CollapsibleContainerNode.js +131 -0
  228. package/lib/plugins/CollapsiblePlugin/CollapsibleContentNode.d.ts +16 -0
  229. package/lib/plugins/CollapsiblePlugin/CollapsibleContentNode.js +79 -0
  230. package/lib/plugins/CollapsiblePlugin/CollapsibleTitleNode.d.ts +16 -0
  231. package/lib/plugins/CollapsiblePlugin/CollapsibleTitleNode.js +81 -0
  232. package/lib/plugins/CollapsiblePlugin/CollapsibleUtils.d.ts +2 -0
  233. package/lib/plugins/CollapsiblePlugin/CollapsibleUtils.js +8 -0
  234. package/lib/plugins/CollapsiblePlugin/index.d.ts +3 -0
  235. package/lib/plugins/CollapsiblePlugin/index.js +128 -0
  236. package/lib/plugins/CommentPlugin/index.d.ts +9 -0
  237. package/lib/plugins/CommentPlugin/index.js +460 -0
  238. package/lib/plugins/ComponentPickerPlugin/index.d.ts +2 -0
  239. package/lib/plugins/ComponentPickerPlugin/index.js +276 -0
  240. package/lib/plugins/ContextMenuPlugin/index.d.ts +2 -0
  241. package/lib/plugins/ContextMenuPlugin/index.js +112 -0
  242. package/lib/plugins/CounterPlugin/index.d.ts +3 -0
  243. package/lib/plugins/CounterPlugin/index.js +20 -0
  244. package/lib/plugins/DatalayerPlugin/index.d.ts +2 -0
  245. package/lib/plugins/DatalayerPlugin/index.js +218 -0
  246. package/lib/plugins/DateTimePlugin/index.d.ts +8 -0
  247. package/lib/plugins/DateTimePlugin/index.js +24 -0
  248. package/lib/plugins/DocsPlugin/index.d.ts +2 -0
  249. package/lib/plugins/DocsPlugin/index.js +4 -0
  250. package/lib/plugins/DragDropPastePlugin/index.d.ts +1 -0
  251. package/lib/plugins/DragDropPastePlugin/index.js +33 -0
  252. package/lib/plugins/DraggableBlockPlugin/index.d.ts +12 -0
  253. package/lib/plugins/DraggableBlockPlugin/index.js +36 -0
  254. package/lib/plugins/EmojiPickerPlugin/index.d.ts +1 -0
  255. package/lib/plugins/EmojiPickerPlugin/index.js +80 -0
  256. package/lib/plugins/EmojisPlugin/index.d.ts +2 -0
  257. package/lib/plugins/EmojisPlugin/index.js +52 -0
  258. package/lib/plugins/EquationsPlugin/index.d.ts +14 -0
  259. package/lib/plugins/EquationsPlugin/index.js +34 -0
  260. package/lib/plugins/ExcalidrawPlugin/index.d.ts +5 -0
  261. package/lib/plugins/ExcalidrawPlugin/index.js +44 -0
  262. package/lib/plugins/FigmaPlugin/index.d.ts +4 -0
  263. package/lib/plugins/FigmaPlugin/index.js +20 -0
  264. package/lib/plugins/FloatingLinkEditorPlugin/index.d.ts +15 -0
  265. package/lib/plugins/FloatingLinkEditorPlugin/index.js +280 -0
  266. package/lib/plugins/FloatingTextFormatToolbarPlugin/index.d.ts +7 -0
  267. package/lib/plugins/FloatingTextFormatToolbarPlugin/index.js +219 -0
  268. package/lib/plugins/ImagesPlugin/index.d.ts +24 -0
  269. package/lib/plugins/ImagesPlugin/index.js +195 -0
  270. package/lib/plugins/InlineImagePlugin/index.d.ts +17 -0
  271. package/lib/plugins/InlineImagePlugin/index.js +180 -0
  272. package/lib/plugins/KeywordsPlugin/index.d.ts +2 -0
  273. package/lib/plugins/KeywordsPlugin/index.js +31 -0
  274. package/lib/plugins/LayoutPlugin/InsertLayoutDialog.d.ts +6 -0
  275. package/lib/plugins/LayoutPlugin/InsertLayoutDialog.js +21 -0
  276. package/lib/plugins/LayoutPlugin/LayoutPlugin.d.ts +7 -0
  277. package/lib/plugins/LayoutPlugin/LayoutPlugin.js +131 -0
  278. package/lib/plugins/LinkPlugin/index.d.ts +6 -0
  279. package/lib/plugins/LinkPlugin/index.js +11 -0
  280. package/lib/plugins/MarkdownShortcutPlugin/index.d.ts +2 -0
  281. package/lib/plugins/MarkdownShortcutPlugin/index.js +6 -0
  282. package/lib/plugins/MarkdownTransformers/index.d.ts +8 -0
  283. package/lib/plugins/MarkdownTransformers/index.js +234 -0
  284. package/lib/plugins/MaxLengthPlugin/index.d.ts +3 -0
  285. package/lib/plugins/MaxLengthPlugin/index.js +37 -0
  286. package/lib/plugins/MentionsPlugin/index.d.ts +2 -0
  287. package/lib/plugins/MentionsPlugin/index.js +564 -0
  288. package/lib/plugins/PageBreakPlugin/index.d.ts +4 -0
  289. package/lib/plugins/PageBreakPlugin/index.js +27 -0
  290. package/lib/plugins/PasteLogPlugin/index.d.ts +2 -0
  291. package/lib/plugins/PasteLogPlugin/index.js +27 -0
  292. package/lib/plugins/PollPlugin/index.d.ts +8 -0
  293. package/lib/plugins/PollPlugin/index.js +38 -0
  294. package/lib/plugins/ShortcutsPlugin/index.d.ts +6 -0
  295. package/lib/plugins/ShortcutsPlugin/index.js +112 -0
  296. package/lib/plugins/ShortcutsPlugin/shortcuts.d.ts +59 -0
  297. package/lib/plugins/ShortcutsPlugin/shortcuts.js +169 -0
  298. package/lib/plugins/SpecialTextPlugin/index.d.ts +2 -0
  299. package/lib/plugins/SpecialTextPlugin/index.js +46 -0
  300. package/lib/plugins/SpeechToTextPlugin/index.d.ts +5 -0
  301. package/lib/plugins/SpeechToTextPlugin/index.js +82 -0
  302. package/lib/plugins/StickyPlugin/index.d.ts +2 -0
  303. package/lib/plugins/StickyPlugin/index.js +12 -0
  304. package/lib/plugins/TabFocusPlugin/index.d.ts +1 -0
  305. package/lib/plugins/TabFocusPlugin/index.js +34 -0
  306. package/lib/plugins/TableActionMenuPlugin/index.d.ts +5 -0
  307. package/lib/plugins/TableActionMenuPlugin/index.js +492 -0
  308. package/lib/plugins/TableCellResizer/index.d.ts +3 -0
  309. package/lib/plugins/TableCellResizer/index.js +297 -0
  310. package/lib/plugins/TableHoverActionsPlugin/index.d.ts +4 -0
  311. package/lib/plugins/TableHoverActionsPlugin/index.js +188 -0
  312. package/lib/plugins/TableOfContentsPlugin/index.d.ts +2 -0
  313. package/lib/plugins/TableOfContentsPlugin/index.js +116 -0
  314. package/lib/plugins/TablePlugin.d.ts +31 -0
  315. package/lib/plugins/TablePlugin.js +63 -0
  316. package/lib/plugins/TestRecorderPlugin/index.d.ts +3 -0
  317. package/lib/plugins/TestRecorderPlugin/index.js +346 -0
  318. package/lib/plugins/ToolbarPlugin/fontSize.d.ts +9 -0
  319. package/lib/plugins/ToolbarPlugin/fontSize.js +80 -0
  320. package/lib/plugins/ToolbarPlugin/index.d.ts +9 -0
  321. package/lib/plugins/ToolbarPlugin/index.js +500 -0
  322. package/lib/plugins/ToolbarPlugin/utils.d.ts +26 -0
  323. package/lib/plugins/ToolbarPlugin/utils.js +243 -0
  324. package/lib/plugins/TreeViewPlugin/index.d.ts +2 -0
  325. package/lib/plugins/TreeViewPlugin/index.js +7 -0
  326. package/lib/plugins/TwitterPlugin/index.d.ts +4 -0
  327. package/lib/plugins/TwitterPlugin/index.js +20 -0
  328. package/lib/plugins/TypingPerfPlugin/index.d.ts +2 -0
  329. package/lib/plugins/TypingPerfPlugin/index.js +93 -0
  330. package/lib/plugins/YouTubePlugin/index.d.ts +4 -0
  331. package/lib/plugins/YouTubePlugin/index.js +20 -0
  332. package/lib/server/validation.d.ts +1 -0
  333. package/lib/server/validation.js +111 -0
  334. package/lib/setupEnv.d.ts +2 -0
  335. package/lib/setupEnv.js +25 -0
  336. package/lib/themes/CommentEditorTheme.d.ts +4 -0
  337. package/lib/themes/CommentEditorTheme.js +7 -0
  338. package/lib/themes/PlaygroundEditorTheme.d.ts +4 -0
  339. package/lib/themes/PlaygroundEditorTheme.js +120 -0
  340. package/lib/themes/StickyEditorTheme.d.ts +4 -0
  341. package/lib/themes/StickyEditorTheme.js +7 -0
  342. package/lib/tyes.dt.d.ts +12 -0
  343. package/lib/tyes.dt.js +0 -0
  344. package/lib/ui/Button.d.ts +12 -0
  345. package/lib/ui/Button.js +6 -0
  346. package/lib/ui/ColorPicker.d.ts +14 -0
  347. package/lib/ui/ColorPicker.js +219 -0
  348. package/lib/ui/ContentEditable.d.ts +9 -0
  349. package/lib/ui/ContentEditable.js +6 -0
  350. package/lib/ui/Dialog.d.ts +10 -0
  351. package/lib/ui/Dialog.js +8 -0
  352. package/lib/ui/DropDown.d.ts +18 -0
  353. package/lib/ui/DropDown.js +133 -0
  354. package/lib/ui/DropdownColorPicker.d.ts +13 -0
  355. package/lib/ui/DropdownColorPicker.js +6 -0
  356. package/lib/ui/EquationEditor.d.ts +8 -0
  357. package/lib/ui/EquationEditor.js +11 -0
  358. package/lib/ui/ExcalidrawModal.d.ts +42 -0
  359. package/lib/ui/ExcalidrawModal.js +103 -0
  360. package/lib/ui/FileInput.d.ts +10 -0
  361. package/lib/ui/FileInput.js +5 -0
  362. package/lib/ui/FlashMessage.d.ts +7 -0
  363. package/lib/ui/FlashMessage.js +6 -0
  364. package/lib/ui/ImageResizer.d.ts +17 -0
  365. package/lib/ui/ImageResizer.js +171 -0
  366. package/lib/ui/KatexEquationAlterer.d.ts +8 -0
  367. package/lib/ui/KatexEquationAlterer.js +23 -0
  368. package/lib/ui/KatexRenderer.d.ts +6 -0
  369. package/lib/ui/KatexRenderer.js +24 -0
  370. package/lib/ui/Modal.d.ts +9 -0
  371. package/lib/ui/Modal.js +48 -0
  372. package/lib/ui/Select.d.ts +8 -0
  373. package/lib/ui/Select.js +5 -0
  374. package/lib/ui/Switch.d.ts +8 -0
  375. package/lib/ui/Switch.js +6 -0
  376. package/lib/ui/TextInput.d.ts +13 -0
  377. package/lib/ui/TextInput.js +7 -0
  378. package/lib/utils/docSerialization.d.ts +3 -0
  379. package/lib/utils/docSerialization.js +56 -0
  380. package/lib/utils/emoji-list.d.ts +20 -0
  381. package/lib/utils/emoji-list.js +16605 -0
  382. package/lib/utils/getDOMRangeRect.d.ts +8 -0
  383. package/lib/utils/getDOMRangeRect.js +22 -0
  384. package/lib/utils/getSelectedNode.d.ts +2 -0
  385. package/lib/utils/getSelectedNode.js +24 -0
  386. package/lib/utils/getThemeSelector.d.ts +2 -0
  387. package/lib/utils/getThemeSelector.js +10 -0
  388. package/lib/utils/isMobileWidth.d.ts +7 -0
  389. package/lib/utils/isMobileWidth.js +7 -0
  390. package/lib/utils/joinClasses.d.ts +1 -0
  391. package/lib/utils/joinClasses.js +3 -0
  392. package/lib/utils/setFloatingElemPosition.d.ts +1 -0
  393. package/lib/utils/setFloatingElemPosition.js +55 -0
  394. package/lib/utils/setFloatingElemPositionForLinkEditor.d.ts +1 -0
  395. package/lib/utils/setFloatingElemPositionForLinkEditor.js +32 -0
  396. package/lib/utils/swipe.d.ts +4 -0
  397. package/lib/utils/swipe.js +90 -0
  398. package/lib/utils/url.d.ts +2 -0
  399. package/lib/utils/url.js +27 -0
  400. package/package.json +82 -51
  401. package/lib/DiffMerge.d.ts +0 -39
  402. package/lib/DiffMerge.js +0 -437
  403. package/lib/LoroCollaborativePlugin.d.ts +0 -62
  404. package/lib/LoroCollaborativePlugin.js +0 -2826
  405. package/lib/stableNodeState.d.ts +0 -8
  406. 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;