UNPKG

@liveblocks/node-lexical

Version:

A server-side utility that lets you modify lexical documents hosted in Liveblocks.

290 lines (285 loc) 7.93 kB
// src/index.ts import { createHeadlessEditor as createHeadlessEditor2 } from "@lexical/headless"; import { $convertToMarkdownString, TRANSFORMERS } from "@lexical/markdown"; import { createBinding as createBinding2 } from "@lexical/yjs"; import { detectDupes } from "@liveblocks/core"; import { $getRoot } from "lexical"; import { applyUpdate, Doc as Doc2, encodeStateAsUpdate, encodeStateVector } from "yjs"; // src/collab.ts import { createHeadlessEditor } from "@lexical/headless"; import { createBinding, syncLexicalUpdateToYjs, syncYjsChangesToLexical } from "@lexical/yjs"; import { Doc } from "yjs"; function registerCollaborationListeners(editor, provider, binding) { const unsubscribeUpdateListener = editor.registerUpdateListener( ({ dirtyElements, dirtyLeaves, editorState, normalizedNodes, prevEditorState, tags }) => { if (tags.has("skip-collab") === false) { syncLexicalUpdateToYjs( binding, provider, prevEditorState, editorState, dirtyElements, dirtyLeaves, normalizedNodes, tags ); } } ); const observer = (events, transaction) => { if (transaction.origin !== binding) { syncYjsChangesToLexical(binding, provider, events, false); } }; binding.root.getSharedType().observeDeep(observer); return () => { unsubscribeUpdateListener(); binding.root.getSharedType().unobserveDeep(observer); }; } function createNoOpProvider() { const emptyFunction = () => { }; return { awareness: { getLocalState: () => null, setLocalStateField: emptyFunction, getStates: () => /* @__PURE__ */ new Map(), off: emptyFunction, on: emptyFunction, setLocalState: emptyFunction }, connect: emptyFunction, disconnect: emptyFunction, off: emptyFunction, on: emptyFunction }; } // src/MentionNodeLite.ts import { $applyNodeReplacement, DecoratorNode } from "lexical"; var MENTION_CHARACTER = "@"; var MentionNode = class _MentionNode extends DecoratorNode { __id; __userId; constructor(id, userId, key) { super(key); this.__id = id; this.__userId = userId; } static getType() { return "lb-mention"; } static clone(node) { return new _MentionNode(node.__id, node.__userId); } static importJSON(serializedNode) { const node = new _MentionNode( serializedNode.value ?? serializedNode.id, serializedNode.userId ); return $applyNodeReplacement(node); } exportJSON() { return { id: this.getId(), userId: this.getUserId(), type: "lb-mention", version: 1 }; } getId() { const self = this.getLatest(); return self.__id; } getUserId() { const self = this.getLatest(); return self.__userId; } getTextContent() { const userId = this.getUserId(); if (userId) { return MENTION_CHARACTER + userId; } return this.getId(); } decorate() { return null; } }; // src/ThreadNodeLite.ts import { $applyNodeReplacement as $applyNodeReplacement2, $isRangeSelection, ElementNode } from "lexical"; var ThreadMarkNode = class _ThreadMarkNode extends ElementNode { /** @internal */ __ids; // The ids of the threads that this mark is associated with static getType() { return "lb-thread-mark"; } static clone(node) { return new _ThreadMarkNode(Array.from(node.__ids), node.__key); } static importJSON(serializedNode) { const node = $applyNodeReplacement2( new _ThreadMarkNode(serializedNode.ids) ); node.setFormat(serializedNode.format); node.setIndent(serializedNode.indent); node.setDirection(serializedNode.direction); return node; } exportJSON() { return { ...super.exportJSON(), ids: this.getIDs(), type: "lb-thread-mark", version: 1 }; } getIDs() { const self = this.getLatest(); return self instanceof _ThreadMarkNode ? self.__ids : []; } constructor(ids, key) { super(key); this.__ids = ids || []; } canInsertTextBefore() { return false; } canInsertTextAfter() { return false; } canBeEmpty() { return false; } isInline() { return true; } extractWithChild(_, selection, destination) { if (!$isRangeSelection(selection) || destination === "html") { return false; } const anchor = selection.anchor; const focus = selection.focus; const anchorNode = anchor.getNode(); const focusNode = focus.getNode(); const isBackward = selection.isBackward(); const selectionLength = isBackward ? anchor.offset - focus.offset : focus.offset - anchor.offset; return this.isParentOf(anchorNode) && this.isParentOf(focusNode) && this.getTextContent().length === selectionLength; } excludeFromCopy(destination) { return destination !== "clone"; } }; // src/version.ts var PKG_NAME = "@liveblocks/node-lexical"; var PKG_VERSION = "3.4.0"; var PKG_FORMAT = "esm"; // src/index.ts import { $createParagraphNode, $createTextNode, $getRoot as $getRoot2 } from "lexical"; detectDupes(PKG_NAME, PKG_VERSION, PKG_FORMAT); var LIVEBLOCKS_NODES = [ThreadMarkNode, MentionNode]; async function withLexicalDocument({ roomId, nodes, client }, callback) { const update = new Uint8Array( await client.getYjsDocumentAsBinaryUpdate(roomId) ); const editor = createHeadlessEditor2({ nodes: [...LIVEBLOCKS_NODES, ...nodes ?? []] }); const id = "root"; const doc = new Doc2(); const docMap = /* @__PURE__ */ new Map([[id, doc]]); const provider = createNoOpProvider(); const binding = createBinding2(editor, provider, id, doc, docMap); const unsubscribe = registerCollaborationListeners(editor, provider, binding); applyUpdate(binding.doc, update); editor.update(() => { }, { discrete: true }); const val = await callback({ /** * Fetches and resyncs the latest document with Liveblocks */ refresh: async () => { const latest = new Uint8Array( await client.getYjsDocumentAsBinaryUpdate(roomId) ); applyUpdate(binding.doc, latest); editor.update(() => { }, { discrete: true }); }, /** * Provide a callback to modify documetns with Lexical's standard api. All calls are discrete. */ update: async (modifyFn) => { editor.update(() => { }, { discrete: true }); const beforeVector = encodeStateVector(binding.doc); editor.update( () => { modifyFn(); }, { discrete: true } ); const diffUpdate = encodeStateAsUpdate(binding.doc, beforeVector); return client.sendYjsBinaryUpdate(roomId, diffUpdate); }, /** * Helper function to easily provide the text content from the root, i.e. `$getRoot().getTextContent()` */ getTextContent: () => { let content = ""; editor.getEditorState().read(() => { content = $getRoot().getTextContent(); }); return content; }, /** * Helper function to return editorState in JSON form */ toJSON: () => { return editor.getEditorState().toJSON(); }, /** * Helper function to return editor state as Markdown */ toMarkdown: () => { let markdown = ""; editor.getEditorState().read(() => { markdown = $convertToMarkdownString(TRANSFORMERS); }); return markdown; }, /** * Helper function to return the editor's current state */ getEditorState: () => { return editor.getEditorState(); }, /** * Helper function to return the current headless editor instance */ getLexicalEditor: () => { return editor; } }); unsubscribe(); return val; } export { $createParagraphNode, $createTextNode, $getRoot2 as $getRoot, withLexicalDocument }; //# sourceMappingURL=index.js.map