Skip to content

Commit

Permalink
feat: add remirror yjs extension for rich-text real-time sync (#260)
Browse files Browse the repository at this point in the history
  • Loading branch information
lihebi authored May 3, 2023
1 parent f6d8777 commit 0b71e42
Show file tree
Hide file tree
Showing 3 changed files with 3,376 additions and 2,959 deletions.
10 changes: 9 additions & 1 deletion ui/src/components/nodes/Rich.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
SubExtension,
SupExtension,
TextHighlightExtension,
YjsExtension,
createMarkPositioner,
wysiwygPreset,
} from "remirror/extensions";
Expand Down Expand Up @@ -101,9 +102,11 @@ import { TableExtension } from "@remirror/extension-react-tables";
import { GenIcon, IconBase } from "@remirror/react-components";
import "remirror/styles/all.css";

import { htmlToProsemirrorNode } from "remirror";
import { ProsemirrorPlugin, htmlToProsemirrorNode } from "remirror";
import { styled } from "@mui/material";

import { MyYjsExtension } from "./YjsRemirror";

function useLinkShortcut() {
const [linkShortcut, setLinkShortcut] = useState<
ShortcutHandlerProps | undefined
Expand Down Expand Up @@ -386,6 +389,7 @@ const MyEditor = ({
initialContent?: string;
id: string;
}) => {
// FIXME this is re-rendered all the time.
const store = useContext(RepoContext);
if (!store) throw new Error("Missing BearContext.Provider in the tree");
const setPodContent = useStore(store, (state) => state.setPodContent);
Expand All @@ -395,6 +399,9 @@ const MyEditor = ({
const pod = getPod(id);
const isGuest = useStore(store, (state) => state.role === "GUEST");
const setPodFocus = useStore(store, (state) => state.setPodFocus);
// the Yjs extension for Remirror
const provider = useStore(store, (state) => state.provider)!;

const setPodBlur = useStore(store, (state) => state.setPodBlur);
const resetSelection = useStore(store, (state) => state.resetSelection);
const updateView = useStore(store, (state) => state.updateView);
Expand All @@ -413,6 +420,7 @@ const MyEditor = ({
new LinkExtension({ autoLink: true }),
new ImageExtension({ enableResizing: true }),
new DropCursorExtension(),
new MyYjsExtension({ getProvider: () => provider, id }),
// new CalloutExtension({ defaultType: "warn" }),
...wysiwygPreset(),
],
Expand Down
352 changes: 352 additions & 0 deletions ui/src/components/nodes/YjsRemirror.tsx
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;
}
Loading

0 comments on commit 0b71e42

Please sign in to comment.