UNPKG

lexical-vue

Version:

An extensible Vue 3 web text-editor based on Lexical.

214 lines (213 loc) 9.32 kB
import { mergeRegister } from "@lexical/utils"; import { CONNECTED_COMMAND, TOGGLE_CONNECT_COMMAND, createUndoManager, initLocalState, setLocalStateFocus, syncCursorPositions, syncLexicalUpdateToYjs, syncYjsChangesToLexical } from "@lexical/yjs"; import { $createParagraphNode, $getRoot, $getSelection, BLUR_COMMAND, CAN_REDO_COMMAND, CAN_UNDO_COMMAND, COMMAND_PRIORITY_EDITOR, FOCUS_COMMAND, HISTORY_MERGE_TAG, REDO_COMMAND, SKIP_COLLAB_TAG, UNDO_COMMAND } from "lexical"; import { Teleport, computed, h, ref, toValue, watchEffect } from "vue"; import { UndoManager } from "yjs"; function useYjsCollaboration(editor, id, provider, docMap, name, color, shouldBootstrap, binding, doc, cursorsContainerRef, initialEditorState, awarenessData, syncCursorPositionsFn = syncCursorPositions) { const isReloadingDoc = ref(false); const onBootstrap = ()=>{ const { root } = toValue(binding); if (shouldBootstrap && root.isEmpty() && 0 === root._xmlText._length) initializeEditor(editor, toValue(initialEditorState)); }; watchEffect((onInvalidate)=>{ const { root } = toValue(binding); const onYjsTreeChanges = (events, transaction)=>{ const origin = transaction.origin; if (origin !== binding) { const isFromUndoManger = origin instanceof UndoManager; syncYjsChangesToLexical(toValue(binding), provider.value, events, isFromUndoManger, syncCursorPositionsFn); } }; root.getSharedType().observeDeep(onYjsTreeChanges); const removeListener = editor.registerUpdateListener(({ prevEditorState, editorState, dirtyLeaves, dirtyElements, normalizedNodes, tags })=>{ if (!tags.has(SKIP_COLLAB_TAG)) syncLexicalUpdateToYjs(toValue(binding), provider.value, prevEditorState, editorState, dirtyElements, dirtyLeaves, normalizedNodes, tags); }); onInvalidate(()=>{ root.getSharedType().unobserveDeep(onYjsTreeChanges); removeListener(); }); }); watchEffect((onInvalidate)=>{ const onProviderDocReload = (ydoc)=>{ clearEditorSkipCollab(editor, toValue(binding)); doc.value = ydoc; toValue(docMap).set(toValue(id), ydoc); isReloadingDoc.value = true; }; const onSync = ()=>{ isReloadingDoc.value = false; }; provider.value.on('reload', onProviderDocReload); provider.value.on('sync', onSync); onInvalidate(()=>{ provider.value.off('reload', onProviderDocReload); provider.value.off('sync', onSync); }); }); useProvider(editor, provider, name, color, isReloadingDoc, awarenessData, onBootstrap); return useYjsCursors(binding, cursorsContainerRef); } function useProvider(editor, provider, name, color, isReloadingDoc, awarenessData, onBootstrap) { const connect = ()=>provider.value.connect(); const disconnect = ()=>{ try { provider.value.disconnect(); } catch {} }; watchEffect((onInvalidate)=>{ const onStatus = ({ status })=>{ editor.dispatchCommand(CONNECTED_COMMAND, 'connected' === status); }; const onSync = (isSynced)=>{ if (isSynced && false === isReloadingDoc.value && onBootstrap) onBootstrap(); }; initLocalState(provider.value, toValue(name), toValue(color), document.activeElement === editor.getRootElement(), awarenessData || {}); provider.value.on('status', onStatus); provider.value.on('sync', onSync); const connectionPromise = connect(); onInvalidate(()=>{ if (false === isReloadingDoc.value) if (connectionPromise) connectionPromise.then(disconnect); else disconnect(); provider.value.off('sync', onSync); provider.value.off('status', onStatus); }); }); watchEffect((onInvalidate)=>{ const unregister = editor.registerCommand(TOGGLE_CONNECT_COMMAND, (payload)=>{ const shouldConnect = payload; if (shouldConnect) { console.log('Collaboration connected!'); connect(); } else { console.log('Collaboration disconnected!'); disconnect(); } return true; }, COMMAND_PRIORITY_EDITOR); onInvalidate(unregister); }); } function useYjsCursors(binding, cursorsContainerRef) { return computed(()=>{ const target = toValue(cursorsContainerRef) || document.body; return h(Teleport, { to: target }, h('div', { ref: (element)=>{ toValue(binding).cursorsContainer = element; } })); }); } function useYjsFocusTracking(editor, provider, name, color, awarenessData) { watchEffect((onInvalidate)=>{ const unregister = mergeRegister(editor.registerCommand(FOCUS_COMMAND, ()=>{ setLocalStateFocus(provider.value, toValue(name), toValue(color), true, awarenessData || {}); return false; }, COMMAND_PRIORITY_EDITOR), editor.registerCommand(BLUR_COMMAND, ()=>{ setLocalStateFocus(provider.value, toValue(name), toValue(color), false, awarenessData || {}); return false; }, COMMAND_PRIORITY_EDITOR)); onInvalidate(unregister); }); } function useYjsHistory(editor, binding) { const undoManager = computed(()=>createUndoManager(toValue(binding), toValue(binding).root.getSharedType())); return useYjsUndoManager(editor, undoManager); } function useYjsUndoManager(editor, undoManager) { watchEffect((onInvalidate)=>{ const undo = ()=>{ undoManager.value.undo(); }; const redo = ()=>{ undoManager.value.redo(); }; const unregister = mergeRegister(editor.registerCommand(UNDO_COMMAND, ()=>{ undo(); return true; }, COMMAND_PRIORITY_EDITOR), editor.registerCommand(REDO_COMMAND, ()=>{ redo(); return true; }, COMMAND_PRIORITY_EDITOR)); onInvalidate(unregister); }); const clearHistory = ()=>{ undoManager.value.clear(); }; watchEffect((onInvalidate)=>{ const updateUndoRedoStates = ()=>{ editor.dispatchCommand(CAN_UNDO_COMMAND, undoManager.value.undoStack.length > 0); editor.dispatchCommand(CAN_REDO_COMMAND, undoManager.value.redoStack.length > 0); }; undoManager.value.on('stack-item-added', updateUndoRedoStates); undoManager.value.on('stack-item-popped', updateUndoRedoStates); undoManager.value.on('stack-cleared', updateUndoRedoStates); onInvalidate(()=>{ undoManager.value.off('stack-item-added', updateUndoRedoStates); undoManager.value.off('stack-item-popped', updateUndoRedoStates); undoManager.value.off('stack-cleared', updateUndoRedoStates); }); }); return clearHistory; } function initializeEditor(editor, initialEditorState) { editor.update(()=>{ const root = $getRoot(); if (root.isEmpty()) if (initialEditorState) switch(typeof initialEditorState){ case 'string': { const parsedEditorState = editor.parseEditorState(initialEditorState); editor.setEditorState(parsedEditorState, { tag: HISTORY_MERGE_TAG }); break; } case 'object': editor.setEditorState(initialEditorState, { tag: HISTORY_MERGE_TAG }); break; case 'function': editor.update(()=>{ const root1 = $getRoot(); if (root1.isEmpty()) initialEditorState(editor); }, { tag: HISTORY_MERGE_TAG }); break; } else { const paragraph = $createParagraphNode(); root.append(paragraph); const { activeElement } = document; if (null !== $getSelection() || null !== activeElement && activeElement === editor.getRootElement()) paragraph.select(); } }, { tag: HISTORY_MERGE_TAG }); } function clearEditorSkipCollab(editor, binding) { editor.update(()=>{ const root = $getRoot(); root.clear(); root.select(); }, { tag: SKIP_COLLAB_TAG }); if (null == binding.cursors) return; const cursors = binding.cursors; if (null == cursors) return; const cursorsContainer = binding.cursorsContainer; if (null == cursorsContainer) return; const cursorsArr = Array.from(cursors.values()); for(let i = 0; i < cursorsArr.length; i++){ const cursor = cursorsArr[i]; const selection = cursor.selection; if (selection && null !== selection.selections) { const selections = selection.selections; for(let j = 0; j < selections.length; j++)cursorsContainer.removeChild(selections[i]); } } } export { useProvider, useYjsCollaboration, useYjsCursors, useYjsFocusTracking, useYjsHistory, useYjsUndoManager };