lexical-vue
Version:
An extensible Vue 3 web text-editor based on Lexical.
214 lines (213 loc) • 9.32 kB
JavaScript
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 };