diff --git a/packages/client/package.json b/packages/client/package.json index 3f9d8ef1..9a76045f 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -23,8 +23,6 @@ "@tiptap/extension-code": "^2.0.0-beta.26", "@tiptap/extension-code-block": "^2.0.0-beta.37", "@tiptap/extension-code-block-lowlight": "^2.0.0-beta.68", - "@tiptap/extension-collaboration": "^2.0.0-beta.33", - "@tiptap/extension-collaboration-cursor": "^2.0.0-beta.34", "@tiptap/extension-color": "^2.0.0-beta.9", "@tiptap/extension-document": "^2.0.0-beta.15", "@tiptap/extension-dropcursor": "^2.0.0-beta.25", @@ -60,6 +58,7 @@ "dompurify": "^2.3.5", "interactjs": "^1.10.11", "katex": "^0.15.2", + "lib0": "^0.2.47", "lowlight": "^2.5.0", "markdown-it": "^12.3.2", "markdown-it-anchor": "^8.4.1", @@ -78,7 +77,9 @@ "react-split-pane": "^0.1.92", "scroll-into-view-if-needed": "^2.2.29", "swr": "^1.2.0", - "tippy.js": "^6.3.7" + "tippy.js": "^6.3.7", + "y-prosemirror": "^1.0.14", + "yjs": "^13.5.24" }, "devDependencies": { "@types/node": "17.0.13", diff --git a/packages/client/src/components/tiptap/collaboration/collaboration.ts b/packages/client/src/components/tiptap/collaboration/collaboration.ts new file mode 100644 index 00000000..82a53504 --- /dev/null +++ b/packages/client/src/components/tiptap/collaboration/collaboration.ts @@ -0,0 +1,110 @@ +import { Extension } from '@tiptap/core'; +import { UndoManager } from 'yjs'; +import { redo, undo, ySyncPlugin, yUndoPlugin, yUndoPluginKey } from 'y-prosemirror'; + +declare module '@tiptap/core' { + interface Commands { + collaboration: { + /** + * Undo recent changes + */ + undo: () => ReturnType; + /** + * Reapply reverted changes + */ + redo: () => ReturnType; + }; + } +} + +export interface CollaborationOptions { + /** + * An initialized Y.js document. + */ + document: any; + /** + * Name of a Y.js fragment, can be changed to sync multiple fields with one Y.js document. + */ + field: string; + /** + * A raw Y.js fragment, can be used instead of `document` and `field`. + */ + fragment: any; +} + +export const Collaboration = Extension.create({ + name: 'collaboration', + + priority: 1000, + + addOptions() { + return { + document: null, + field: 'default', + fragment: null, + }; + }, + + onCreate() { + if (this.editor.extensionManager.extensions.find((extension) => extension.name === 'history')) { + console.warn( + '[tiptap warn]: "@tiptap/extension-collaboration" comes with its own history support and is not compatible with "@tiptap/extension-history".' + ); + } + }, + + addCommands() { + return { + undo: + () => + ({ tr, state, dispatch }) => { + tr.setMeta('preventDispatch', true); + + const undoManager: UndoManager = yUndoPluginKey.getState(state).undoManager; + + if (undoManager.undoStack.length === 0) { + return false; + } + + if (!dispatch) { + return true; + } + + return undo(state); + }, + redo: + () => + ({ tr, state, dispatch }) => { + tr.setMeta('preventDispatch', true); + + const undoManager: UndoManager = yUndoPluginKey.getState(state).undoManager; + + if (undoManager.redoStack.length === 0) { + return false; + } + + if (!dispatch) { + return true; + } + + return redo(state); + }, + }; + }, + + addKeyboardShortcuts() { + return { + 'Mod-z': () => this.editor.commands.undo(), + 'Mod-y': () => this.editor.commands.redo(), + 'Shift-Mod-z': () => this.editor.commands.redo(), + }; + }, + + addProseMirrorPlugins() { + const fragment = this.options.fragment + ? this.options.fragment + : this.options.document.getXmlFragment(this.options.field); + + return [ySyncPlugin(fragment), yUndoPlugin()]; + }, +}); diff --git a/packages/client/src/components/tiptap/collaboration/helpers/isChangeOrigin.ts b/packages/client/src/components/tiptap/collaboration/helpers/isChangeOrigin.ts new file mode 100644 index 00000000..1bec993d --- /dev/null +++ b/packages/client/src/components/tiptap/collaboration/helpers/isChangeOrigin.ts @@ -0,0 +1,6 @@ +import { ySyncPluginKey } from 'y-prosemirror'; +import { Transaction } from 'prosemirror-state'; + +export function isChangeOrigin(transaction: Transaction): boolean { + return !!transaction.getMeta(ySyncPluginKey); +} diff --git a/packages/client/src/components/tiptap/collaboration/index.ts b/packages/client/src/components/tiptap/collaboration/index.ts new file mode 100644 index 00000000..63d28153 --- /dev/null +++ b/packages/client/src/components/tiptap/collaboration/index.ts @@ -0,0 +1,2 @@ +export * from './collaboration'; +export * from './helpers/isChangeOrigin'; diff --git a/packages/client/src/components/tiptap/collaborationCursor/index.ts b/packages/client/src/components/tiptap/collaborationCursor/index.ts new file mode 100644 index 00000000..094ab3c2 --- /dev/null +++ b/packages/client/src/components/tiptap/collaborationCursor/index.ts @@ -0,0 +1,131 @@ +import { Extension } from '@tiptap/core'; +import { yCursorPlugin } from 'y-prosemirror'; + +type CollaborationCursorStorage = { + users: { clientId: number; [key: string]: any }[]; +}; + +export interface CollaborationCursorOptions { + provider: any; + user: Record; + render(user: Record): HTMLElement; + /** + * @deprecated The "onUpdate" option is deprecated. Please use `editor.storage.collaborationCursor.users` instead. Read more: https://tiptap.dev/api/extensions/collaboration-cursor + */ + onUpdate: (users: { clientId: number; [key: string]: any }[]) => null; +} + +declare module '@tiptap/core' { + interface Commands { + collaborationCursor: { + /** + * Update details of the current user + */ + updateUser: (attributes: Record) => ReturnType; + /** + * Update details of the current user + * + * @deprecated The "user" command is deprecated. Please use "updateUser" instead. Read more: https://tiptap.dev/api/extensions/collaboration-cursor + */ + user: (attributes: Record) => ReturnType; + }; + } +} + +const awarenessStatesToArray = (states: Map>) => { + return Array.from(states.entries()).map(([key, value]) => { + return { + clientId: key, + ...value.user, + }; + }); +}; + +const defaultOnUpdate = () => null; + +export const CollaborationCursor = Extension.create({ + name: 'collaborationCursor', + + addOptions() { + return { + provider: null, + user: { + name: null, + color: null, + }, + render: (user) => { + const cursor = document.createElement('span'); + + cursor.classList.add('collaboration-cursor__caret'); + cursor.setAttribute('style', `border-color: ${user.color}`); + + const label = document.createElement('div'); + + label.classList.add('collaboration-cursor__label'); + label.setAttribute('style', `background-color: ${user.color}`); + label.insertBefore(document.createTextNode(user.name), null); + cursor.insertBefore(label, null); + + return cursor; + }, + onUpdate: defaultOnUpdate, + }; + }, + + onCreate() { + if (this.options.onUpdate !== defaultOnUpdate) { + console.warn( + '[tiptap warn]: DEPRECATED: The "onUpdate" option is deprecated. Please use `editor.storage.collaborationCursor.users` instead. Read more: https://tiptap.dev/api/extensions/collaboration-cursor' + ); + } + }, + + addStorage() { + return { + users: [], + }; + }, + + addCommands() { + return { + updateUser: (attributes) => () => { + this.options.user = attributes; + + this.options.provider.awareness.setLocalStateField('user', this.options.user); + + return true; + }, + user: + (attributes) => + ({ editor }) => { + console.warn( + '[tiptap warn]: DEPRECATED: The "user" command is deprecated. Please use "updateUser" instead. Read more: https://tiptap.dev/api/extensions/collaboration-cursor' + ); + + return editor.commands.updateUser(attributes); + }, + }; + }, + + addProseMirrorPlugins() { + return [ + yCursorPlugin( + (() => { + this.options.provider.awareness.setLocalStateField('user', this.options.user); + + this.storage.users = awarenessStatesToArray(this.options.provider.awareness.states); + + this.options.provider.awareness.on('update', () => { + this.storage.users = awarenessStatesToArray(this.options.provider.awareness.states); + }); + + return this.options.provider.awareness; + })(), + // @ts-ignore + { + cursorBuilder: this.options.render, + } + ), + ]; + }, +}); diff --git a/packages/client/src/components/tiptap/collaborationCursor/yCursorPlugin.ts b/packages/client/src/components/tiptap/collaborationCursor/yCursorPlugin.ts new file mode 100644 index 00000000..2188d121 --- /dev/null +++ b/packages/client/src/components/tiptap/collaborationCursor/yCursorPlugin.ts @@ -0,0 +1,187 @@ +import * as Y from 'yjs'; +import { Decoration, DecorationSet } from 'prosemirror-view'; // eslint-disable-line +import { Plugin } from 'prosemirror-state'; // eslint-disable-line +import * as math from 'lib0/math'; +import { + absolutePositionToRelativePosition, + relativePositionToAbsolutePosition, + setMeta, + yCursorPluginKey, + ySyncPluginKey, +} from 'y-prosemirror'; + +/** + * Default generator for a cursor element + * + * @param {any} user user data + * @return HTMLElement + */ +export const defaultCursorBuilder = (user) => { + const cursor = document.createElement('span'); + cursor.classList.add('ProseMirror-yjs-cursor'); + cursor.setAttribute('style', `border-color: ${user.color}`); + const userDiv = document.createElement('div'); + userDiv.setAttribute('style', `background-color: ${user.color}`); + userDiv.insertBefore(document.createTextNode(user.name), null); + cursor.insertBefore(userDiv, null); + return cursor; +}; + +const rxValidColor = /^#[0-9a-fA-F]{6}$/; + +/** + * @param {any} state + * @param {Awareness} awareness + * @return {any} DecorationSet + */ +export const createDecorations = (state, awareness, createCursor) => { + const ystate = ySyncPluginKey.getState(state) || state['y-sync$']; + const y = ystate.doc; + const decorations = []; + if (ystate.snapshot != null || ystate.prevSnapshot != null || ystate.binding === null) { + // do not render cursors while snapshot is active + return DecorationSet.create(state.doc, []); + } + awareness.getStates().forEach((aw, clientId) => { + if (clientId === y.clientID) { + return; + } + if (aw.cursor != null) { + const user = aw.user || {}; + if (user.color == null) { + user.color = '#ffa500'; + } else if (!rxValidColor.test(user.color)) { + // We only support 6-digit RGB colors in y-prosemirror + console.warn('A user uses an unsupported color format', user); + } + if (user.name == null) { + user.name = `User: ${clientId}`; + } + let anchor = relativePositionToAbsolutePosition( + y, + ystate.type, + Y.createRelativePositionFromJSON(aw.cursor.anchor), + ystate.binding.mapping + ); + let head = relativePositionToAbsolutePosition( + y, + ystate.type, + Y.createRelativePositionFromJSON(aw.cursor.head), + ystate.binding.mapping + ); + if (anchor !== null && head !== null) { + const maxsize = math.max(state.doc.content.size - 1, 0); + anchor = math.min(anchor, maxsize); + head = math.min(head, maxsize); + decorations.push(Decoration.widget(head, () => createCursor(user), { key: clientId + '', side: 10 })); + const from = math.min(anchor, head); + const to = math.max(anchor, head); + decorations.push( + Decoration.inline( + from, + to, + { style: `background-color: ${user.color}70` }, + { inclusiveEnd: true, inclusiveStart: false } + ) + ); + } + } + }); + return DecorationSet.create(state.doc, decorations); +}; + +/** + * A prosemirror plugin that listens to awareness information on Yjs. + * This requires that a `prosemirrorPlugin` is also bound to the prosemirror. + * + * @public + * @param {Awareness} awareness + * @param {object} [opts] + * @param {function(any):HTMLElement} [opts.cursorBuilder] + * @param {function(any):any} [opts.getSelection] + * @param {string} [opts.cursorStateField] By default all editor bindings use the awareness 'cursor' field to propagate cursor information. + * @return {any} + */ +export const yCursorPlugin = ( + awareness, + { cursorBuilder = defaultCursorBuilder, getSelection = (state) => state.selection } = {}, + cursorStateField = 'cursor' +) => + new Plugin({ + key: yCursorPluginKey, + state: { + init(_, state) { + return createDecorations(state, awareness, cursorBuilder); + }, + apply(tr, prevState, oldState, newState) { + const ystate = ySyncPluginKey.getState(newState); + const yCursorState = tr.getMeta(yCursorPluginKey); + if ((ystate && ystate.isChangeOrigin) || (yCursorState && yCursorState.awarenessUpdated)) { + return createDecorations(newState, awareness, cursorBuilder); + } + return prevState.map(tr.mapping, tr.doc); + }, + }, + props: { + decorations: (state) => { + return yCursorPluginKey.getState(state); + }, + }, + view: (view) => { + const awarenessListener = () => { + // @ts-ignore + if (view.docView) { + setMeta(view, yCursorPluginKey, { awarenessUpdated: true }); + } + }; + const updateCursorInfo = () => { + const ystate = ySyncPluginKey.getState(view.state) || view.state['y-sync$']; + // @note We make implicit checks when checking for the cursor property + const current = awareness.getLocalState() || {}; + if (view.hasFocus() && ystate.binding !== null) { + const selection = getSelection(view.state); + /** + * @type {Y.RelativePosition} + */ + const anchor = absolutePositionToRelativePosition(selection.anchor, ystate.type, ystate.binding.mapping); + /** + * @type {Y.RelativePosition} + */ + const head = absolutePositionToRelativePosition(selection.head, ystate.type, ystate.binding.mapping); + if ( + current.cursor == null || + !Y.compareRelativePositions(Y.createRelativePositionFromJSON(current.cursor.anchor), anchor) || + !Y.compareRelativePositions(Y.createRelativePositionFromJSON(current.cursor.head), head) + ) { + awareness.setLocalStateField(cursorStateField, { + anchor, + head, + }); + } + } else if ( + current.cursor != null && + relativePositionToAbsolutePosition( + ystate.doc, + ystate.type, + Y.createRelativePositionFromJSON(current.cursor.anchor), + ystate.binding.mapping + ) !== null + ) { + // delete cursor information if current cursor information is owned by this editor binding + awareness.setLocalStateField(cursorStateField, null); + } + }; + awareness.on('change', awarenessListener); + view.dom.addEventListener('focusin', updateCursorInfo); + view.dom.addEventListener('focusout', updateCursorInfo); + return { + update: updateCursorInfo, + destroy: () => { + view.dom.removeEventListener('focusin', updateCursorInfo); + view.dom.removeEventListener('focusout', updateCursorInfo); + awareness.off('change', awarenessListener); + awareness.setLocalStateField(cursorStateField, null); + }, + }; + }, + }); diff --git a/packages/client/src/components/tiptap/index.ts b/packages/client/src/components/tiptap/index.ts index 9dc7cb3f..0201ff20 100644 --- a/packages/client/src/components/tiptap/index.ts +++ b/packages/client/src/components/tiptap/index.ts @@ -1,6 +1,6 @@ import { HocuspocusProvider } from '@hocuspocus/provider'; -import Collaboration from '@tiptap/extension-collaboration'; -import CollaborationCursor from '@tiptap/extension-collaboration-cursor'; +import { Collaboration } from './collaboration'; +import { CollaborationCursor } from './collaborationCursor'; import History from '@tiptap/extension-history'; import { getRandomColor } from 'helpers/color'; import { Document } from './extensions/document'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 281e9aed..54e7dd04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,8 +57,6 @@ importers: '@tiptap/extension-code': ^2.0.0-beta.26 '@tiptap/extension-code-block': ^2.0.0-beta.37 '@tiptap/extension-code-block-lowlight': ^2.0.0-beta.68 - '@tiptap/extension-collaboration': ^2.0.0-beta.33 - '@tiptap/extension-collaboration-cursor': ^2.0.0-beta.34 '@tiptap/extension-color': ^2.0.0-beta.9 '@tiptap/extension-document': ^2.0.0-beta.15 '@tiptap/extension-dropcursor': ^2.0.0-beta.25 @@ -96,6 +94,7 @@ importers: dompurify: ^2.3.5 interactjs: ^1.10.11 katex: ^0.15.2 + lib0: ^0.2.47 lowlight: ^2.5.0 markdown-it: ^12.3.2 markdown-it-anchor: ^8.4.1 @@ -117,6 +116,8 @@ importers: tippy.js: ^6.3.7 tsconfig-paths-webpack-plugin: ^3.5.2 typescript: 4.5.5 + y-prosemirror: ^1.0.14 + yjs: ^13.5.24 dependencies: '@douyinfe/semi-icons': 2.3.1_react@17.0.2 '@douyinfe/semi-next': 2.3.1 @@ -132,8 +133,6 @@ importers: '@tiptap/extension-code': 2.0.0-beta.26_@tiptap+core@2.0.0-beta.171 '@tiptap/extension-code-block': 2.0.0-beta.37_@tiptap+core@2.0.0-beta.171 '@tiptap/extension-code-block-lowlight': 2.0.0-beta.68_@tiptap+core@2.0.0-beta.171 - '@tiptap/extension-collaboration': 2.0.0-beta.33_24052c2d57881e244a8dbd08d556b4fc - '@tiptap/extension-collaboration-cursor': 2.0.0-beta.34_24052c2d57881e244a8dbd08d556b4fc '@tiptap/extension-color': 2.0.0-beta.9_e32f4205d0966701340db1456434a591 '@tiptap/extension-document': 2.0.0-beta.15_@tiptap+core@2.0.0-beta.171 '@tiptap/extension-dropcursor': 2.0.0-beta.25_@tiptap+core@2.0.0-beta.171 @@ -169,6 +168,7 @@ importers: dompurify: 2.3.5 interactjs: 1.10.11 katex: 0.15.2 + lib0: 0.2.47 lowlight: 2.5.0 markdown-it: 12.3.2 markdown-it-anchor: 8.4.1_markdown-it@12.3.2 @@ -188,6 +188,8 @@ importers: scroll-into-view-if-needed: 2.2.29 swr: 1.2.0_react@17.0.2 tippy.js: 6.3.7 + y-prosemirror: 1.0.14_0fedec857d2fb730ad5b02a71124bf2a + yjs: 13.5.24 devDependencies: '@types/node': 17.0.13 '@types/react': 17.0.38 @@ -917,7 +919,7 @@ packages: dependencies: '@hocuspocus/common': 1.0.0-alpha.4 '@lifeomic/attempt': 3.0.2 - lib0: 0.2.43 + lib0: 0.2.47 y-protocols: 1.0.5 yjs: 13.5.24 dev: false @@ -1650,36 +1652,6 @@ packages: '@tiptap/core': 2.0.0-beta.171 dev: false - /@tiptap/extension-collaboration-cursor/2.0.0-beta.34_24052c2d57881e244a8dbd08d556b4fc: - resolution: {integrity: sha512-6jxM8jxOXEwRHv7rkWsacC8Q3km83opEK/2udiMEC7a/yFeMKpYjOP+sAvzdGBposlIof/VgjXhbIUBRBqmqLw==} - peerDependencies: - '@tiptap/core': ^2.0.0-beta.1 - dependencies: - '@tiptap/core': 2.0.0-beta.171 - y-prosemirror: 1.0.14_prosemirror-view@1.23.6 - transitivePeerDependencies: - - prosemirror-model - - prosemirror-state - - prosemirror-view - - y-protocols - - yjs - dev: false - - /@tiptap/extension-collaboration/2.0.0-beta.33_24052c2d57881e244a8dbd08d556b4fc: - resolution: {integrity: sha512-KMHJxsS/FSVyum2ds3gfu0WFU1Zu7xQ5YmsSCLXyBxkhDtBGWBZTcpcSh9siWfs26+Fk1ZvcEssNyRYMEummpQ==} - peerDependencies: - '@tiptap/core': ^2.0.0-beta.1 - dependencies: - '@tiptap/core': 2.0.0-beta.171 - prosemirror-state: 1.3.4 - y-prosemirror: 1.0.14_fb8440a343abf572e136ee4e217d51fa - transitivePeerDependencies: - - prosemirror-model - - prosemirror-view - - y-protocols - - yjs - dev: false - /@tiptap/extension-color/2.0.0-beta.9_e32f4205d0966701340db1456434a591: resolution: {integrity: sha512-c8zcaNCdwUwbgrutfsG7LD9KH7ZvDVwKOZHbOL4gMSwdH9s+6r1ThRFLEbKgHIJlTa2jd96qoo+lVfj1Qwp7ww==} peerDependencies: @@ -5811,6 +5783,13 @@ packages: isomorphic.js: 0.2.4 dev: false + /lib0/0.2.47: + resolution: {integrity: sha512-RXprIyaflw7OmFNMpb8HmvDhuRVUFXYCXrmynQN8OGbGevgMx9u6tjQG/yB0dOoDcuB1XXgqFn8Oy3RlKF/Qhg==} + engines: {node: '>=12'} + dependencies: + isomorphic.js: 0.2.4 + dev: false + /libphonenumber-js/1.9.46: resolution: {integrity: sha512-QqTX4UVsGy24njtCgLRspiKpxfRniRBZE/P+d0vQXuYWQ+hwDS6X0ouo0O/SRyf7bhhMCE71b6vAvLMtY5PfEw==} dev: false @@ -8521,6 +8500,20 @@ packages: engines: {node: '>=0.4'} dev: false + /y-prosemirror/1.0.14_0fedec857d2fb730ad5b02a71124bf2a: + resolution: {integrity: sha512-fJQn/XT+z/gks9sd64eB+Mf1UQP0d5SHQ8RwbrzIwYFW+FgU/nx8/hJm+nrBAoO9azHOY5aDeoffeLmOFXLD9w==} + peerDependencies: + prosemirror-model: ^1.7.1 + prosemirror-state: ^1.2.3 + prosemirror-view: ^1.9.10 + y-protocols: ^1.0.1 + yjs: ^13.3.2 + dependencies: + lib0: 0.2.47 + prosemirror-view: 1.23.6 + yjs: 13.5.24 + dev: false + /y-prosemirror/1.0.14_8fd72c89aecefb95d86a797b5207d945: resolution: {integrity: sha512-fJQn/XT+z/gks9sd64eB+Mf1UQP0d5SHQ8RwbrzIwYFW+FgU/nx8/hJm+nrBAoO9azHOY5aDeoffeLmOFXLD9w==} peerDependencies: @@ -8535,37 +8528,10 @@ packages: yjs: 13.5.24 dev: false - /y-prosemirror/1.0.14_fb8440a343abf572e136ee4e217d51fa: - resolution: {integrity: sha512-fJQn/XT+z/gks9sd64eB+Mf1UQP0d5SHQ8RwbrzIwYFW+FgU/nx8/hJm+nrBAoO9azHOY5aDeoffeLmOFXLD9w==} - peerDependencies: - prosemirror-model: ^1.7.1 - prosemirror-state: ^1.2.3 - prosemirror-view: ^1.9.10 - y-protocols: ^1.0.1 - yjs: ^13.3.2 - dependencies: - lib0: 0.2.43 - prosemirror-state: 1.3.4 - prosemirror-view: 1.23.6 - dev: false - - /y-prosemirror/1.0.14_prosemirror-view@1.23.6: - resolution: {integrity: sha512-fJQn/XT+z/gks9sd64eB+Mf1UQP0d5SHQ8RwbrzIwYFW+FgU/nx8/hJm+nrBAoO9azHOY5aDeoffeLmOFXLD9w==} - peerDependencies: - prosemirror-model: ^1.7.1 - prosemirror-state: ^1.2.3 - prosemirror-view: ^1.9.10 - y-protocols: ^1.0.1 - yjs: ^13.3.2 - dependencies: - lib0: 0.2.43 - prosemirror-view: 1.23.6 - dev: false - /y-protocols/1.0.5: resolution: {integrity: sha512-Wil92b7cGk712lRHDqS4T90IczF6RkcvCwAD0A2OPg+adKmOe+nOiT/N2hvpQIWS3zfjmtL4CPaH5sIW1Hkm/A==} dependencies: - lib0: 0.2.43 + lib0: 0.2.47 dev: false /y18n/5.0.8: @@ -8626,7 +8592,7 @@ packages: resolution: {integrity: sha512-f6DqRfnhjihj4+iQv5zjhsYqOpkcM9SGroqluq6J6eEUTq7ipbgECKf+h5W4P+LU4fKawWFdQH8mxgJ7baZPJw==} requiresBuild: true dependencies: - lib0: 0.2.43 + lib0: 0.2.47 dev: false /yn/3.1.1: