-
Notifications
You must be signed in to change notification settings - Fork 15
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add remirror yjs extension for rich-text real-time sync (#260)
- Loading branch information
Showing
3 changed files
with
3,376 additions
and
2,959 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,352 @@ | ||
// Adapted from https://github.com/remirror/remirror/blob/main/packages/remirror__extension-yjs/src/yjs-extension.ts | ||
// Added node ID to bind a different room for each rich-text instance. | ||
|
||
import { | ||
defaultCursorBuilder, | ||
defaultDeleteFilter, | ||
defaultSelectionBuilder, | ||
redo, | ||
undo, | ||
yCursorPlugin, | ||
ySyncPlugin, | ||
ySyncPluginKey, | ||
yUndoPlugin, | ||
yUndoPluginKey, | ||
} from "y-prosemirror"; | ||
import type { Doc } from "yjs"; | ||
import { UndoManager } from "yjs"; | ||
import { | ||
AcceptUndefined, | ||
command, | ||
convertCommand, | ||
EditorState, | ||
ErrorConstant, | ||
extension, | ||
ExtensionPriority, | ||
invariant, | ||
isEmptyObject, | ||
isFunction, | ||
keyBinding, | ||
KeyBindingProps, | ||
NamedShortcut, | ||
nonChainable, | ||
NonChainableCommandFunction, | ||
OnSetOptionsProps, | ||
PlainExtension, | ||
ProsemirrorPlugin, | ||
Selection, | ||
Shape, | ||
Static, | ||
} from "@remirror/core"; | ||
import { ExtensionHistoryMessages as Messages } from "@remirror/messages"; | ||
import { DecorationAttrs } from "@remirror/pm/view"; | ||
|
||
export interface ColorDef { | ||
light: string; | ||
dark: string; | ||
} | ||
|
||
export interface YSyncOpts { | ||
colors?: ColorDef[]; | ||
colorMapping?: Map<string, ColorDef>; | ||
permanentUserData?: any | null; | ||
} | ||
|
||
/** | ||
* yjs typings are very rough; so we define here the interface that we require | ||
* (y-webrtc and y-websocket providers are both compatible with this interface; | ||
* no other providers have been checked). | ||
*/ | ||
interface YjsRealtimeProvider { | ||
doc: Doc; | ||
awareness: any; | ||
destroy: () => void; | ||
disconnect: () => void; | ||
} | ||
|
||
export interface YjsOptions< | ||
Provider extends YjsRealtimeProvider = YjsRealtimeProvider | ||
> { | ||
id: string; | ||
/** | ||
* Get the provider for this extension. | ||
*/ | ||
getProvider: Provider | (() => Provider); | ||
|
||
/** | ||
* Remove the active provider. This should only be set at initial construction | ||
* of the editor. | ||
*/ | ||
destroyProvider?: (provider: Provider) => void; | ||
|
||
/** | ||
* The options which are passed through to the Yjs sync plugin. | ||
*/ | ||
syncPluginOptions?: AcceptUndefined<YSyncOpts>; | ||
|
||
/** | ||
* Take the user data and transform it into a html element which is used for | ||
* the cursor. This is passed into the cursor builder. | ||
* | ||
* See https://github.com/yjs/y-prosemirror#remote-cursors | ||
*/ | ||
cursorBuilder?: (user: Shape) => HTMLElement; | ||
|
||
/** | ||
* Generator for the selection attributes | ||
*/ | ||
selectionBuilder?: (user: Shape) => DecorationAttrs; | ||
|
||
/** | ||
* By default all editor bindings use the awareness 'cursor' field to | ||
* propagate cursor information. | ||
* | ||
* @defaultValue 'cursor' | ||
*/ | ||
cursorStateField?: string; | ||
|
||
/** | ||
* Get the current editor selection. | ||
* | ||
* @defaultValue `(state) => state.selection` | ||
*/ | ||
getSelection?: (state: EditorState) => Selection; | ||
|
||
disableUndo?: Static<boolean>; | ||
|
||
/** | ||
* Names of nodes in the editor which should be protected. | ||
* | ||
* @defaultValue `new Set('paragraph')` | ||
*/ | ||
protectedNodes?: Static<Set<string>>; | ||
trackedOrigins?: Static<any[]>; | ||
} | ||
|
||
/** | ||
* The YJS extension is the recommended extension for creating a collaborative | ||
* editor. | ||
*/ | ||
@extension<YjsOptions>({ | ||
defaultOptions: { | ||
id: "defaultId", | ||
getProvider: (): never => { | ||
invariant(false, { | ||
code: ErrorConstant.EXTENSION, | ||
message: "You must provide a YJS Provider to the `YjsExtension`.", | ||
}); | ||
}, | ||
destroyProvider: defaultDestroyProvider, | ||
syncPluginOptions: undefined, | ||
cursorBuilder: defaultCursorBuilder, | ||
selectionBuilder: defaultSelectionBuilder, | ||
cursorStateField: "cursor", | ||
getSelection: (state) => state.selection, | ||
disableUndo: false, | ||
protectedNodes: new Set("paragraph"), | ||
trackedOrigins: [], | ||
}, | ||
staticKeys: ["disableUndo", "protectedNodes", "trackedOrigins"], | ||
defaultPriority: ExtensionPriority.High, | ||
}) | ||
export class MyYjsExtension extends PlainExtension<YjsOptions> { | ||
get name() { | ||
return "yjs" as const; | ||
} | ||
|
||
private _provider?: YjsRealtimeProvider; | ||
|
||
/** | ||
* The provider that is being used for the editor. | ||
*/ | ||
get provider(): YjsRealtimeProvider { | ||
const { getProvider } = this.options; | ||
|
||
return (this._provider ??= getLazyValue(getProvider)); | ||
} | ||
|
||
getBinding(): { mapping: Map<any, any> } | undefined { | ||
const state = this.store.getState(); | ||
const { binding } = ySyncPluginKey.getState(state); | ||
return binding; | ||
} | ||
|
||
/** | ||
* Create the yjs plugins. | ||
*/ | ||
createExternalPlugins(): ProsemirrorPlugin[] { | ||
const { | ||
syncPluginOptions, | ||
cursorBuilder, | ||
getSelection, | ||
cursorStateField, | ||
disableUndo, | ||
protectedNodes, | ||
trackedOrigins, | ||
selectionBuilder, | ||
} = this.options; | ||
|
||
const yDoc = this.provider.doc; | ||
const id = this.options.id; | ||
const type = yDoc.getXmlFragment("rich-" + id); | ||
|
||
const plugins = [ | ||
ySyncPlugin(type, syncPluginOptions), | ||
yCursorPlugin( | ||
this.provider.awareness, | ||
{ cursorBuilder, getSelection, selectionBuilder }, | ||
cursorStateField | ||
), | ||
]; | ||
|
||
if (!disableUndo) { | ||
const undoManager = new UndoManager(type, { | ||
trackedOrigins: new Set([ySyncPluginKey, ...trackedOrigins]), | ||
deleteFilter: (item) => defaultDeleteFilter(item, protectedNodes), | ||
}); | ||
plugins.push(yUndoPlugin({ undoManager })); | ||
} | ||
|
||
return plugins; | ||
} | ||
|
||
/** | ||
* This managers the updates of the collaboration provider. | ||
*/ | ||
onSetOptions(props: OnSetOptionsProps<YjsOptions>): void { | ||
const { changes, pickChanged } = props; | ||
const changedPluginOptions = pickChanged([ | ||
"cursorBuilder", | ||
"cursorStateField", | ||
"getProvider", | ||
"getSelection", | ||
"syncPluginOptions", | ||
]); | ||
|
||
if (changes.getProvider.changed) { | ||
this._provider = undefined; | ||
const previousProvider = getLazyValue(changes.getProvider.previousValue); | ||
|
||
// Check whether the values have changed. | ||
if (changes.destroyProvider.changed) { | ||
changes.destroyProvider.previousValue?.(previousProvider); | ||
} else { | ||
this.options.destroyProvider(previousProvider); | ||
} | ||
} | ||
|
||
if (!isEmptyObject(changedPluginOptions)) { | ||
this.store.updateExtensionPlugins(this); | ||
} | ||
} | ||
|
||
/** | ||
* Remove the provider from the manager. | ||
*/ | ||
onDestroy(): void { | ||
if (!this._provider) { | ||
return; | ||
} | ||
|
||
this.options.destroyProvider(this._provider); | ||
this._provider = undefined; | ||
} | ||
|
||
/** | ||
* Undo that last Yjs transaction(s) | ||
* | ||
* This command does **not** support chaining. | ||
* This command is a no-op and always returns `false` when the `disableUndo` option is set. | ||
*/ | ||
@command({ | ||
disableChaining: true, | ||
description: ({ t }) => t(Messages.UNDO_DESCRIPTION), | ||
label: ({ t }) => t(Messages.UNDO_LABEL), | ||
icon: "arrowGoBackFill", | ||
}) | ||
yUndo(): NonChainableCommandFunction { | ||
return nonChainable((props) => { | ||
if (this.options.disableUndo) { | ||
return false; | ||
} | ||
|
||
const { state, dispatch } = props; | ||
const undoManager: UndoManager = | ||
yUndoPluginKey.getState(state).undoManager; | ||
|
||
if (undoManager.undoStack.length === 0) { | ||
return false; | ||
} | ||
|
||
if (!dispatch) { | ||
return true; | ||
} | ||
|
||
return convertCommand(undo)(props); | ||
}); | ||
} | ||
|
||
/** | ||
* Redo the last transaction undone with a previous `yUndo` command. | ||
* | ||
* This command does **not** support chaining. | ||
* This command is a no-op and always returns `false` when the `disableUndo` option is set. | ||
*/ | ||
@command({ | ||
disableChaining: true, | ||
description: ({ t }) => t(Messages.REDO_DESCRIPTION), | ||
label: ({ t }) => t(Messages.REDO_LABEL), | ||
icon: "arrowGoForwardFill", | ||
}) | ||
yRedo(): NonChainableCommandFunction { | ||
return nonChainable((props) => { | ||
if (this.options.disableUndo) { | ||
return false; | ||
} | ||
|
||
const { state, dispatch } = props; | ||
const undoManager: UndoManager = | ||
yUndoPluginKey.getState(state).undoManager; | ||
|
||
if (undoManager.redoStack.length === 0) { | ||
return false; | ||
} | ||
|
||
if (!dispatch) { | ||
return true; | ||
} | ||
|
||
return convertCommand(redo)(props); | ||
}); | ||
} | ||
|
||
/** | ||
* Handle the undo keybinding. | ||
*/ | ||
@keyBinding({ shortcut: NamedShortcut.Undo, command: "yUndo" }) | ||
undoShortcut(props: KeyBindingProps): boolean { | ||
return this.yUndo()(props); | ||
} | ||
|
||
/** | ||
* Handle the redo keybinding for the editor. | ||
*/ | ||
@keyBinding({ shortcut: NamedShortcut.Redo, command: "yRedo" }) | ||
redoShortcut(props: KeyBindingProps): boolean { | ||
return this.yRedo()(props); | ||
} | ||
} | ||
|
||
/** | ||
* The default destroy provider method. | ||
*/ | ||
export function defaultDestroyProvider(provider: YjsRealtimeProvider): void { | ||
const { doc } = provider; | ||
provider.disconnect(); | ||
provider.destroy(); | ||
doc.destroy(); | ||
} | ||
|
||
function getLazyValue<Type>(lazyValue: Type | (() => Type)): Type { | ||
return isFunction(lazyValue) ? lazyValue() : lazyValue; | ||
} |
Oops, something went wrong.