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