Skip to content

Commit

Permalink
feat(extension-yjs): add ability to collaborate on annonations (#956)
Browse files Browse the repository at this point in the history
  • Loading branch information
whawker committed Jun 11, 2021
1 parent 4966eed commit 6bba178
Show file tree
Hide file tree
Showing 10 changed files with 197 additions and 59 deletions.
6 changes: 6 additions & 0 deletions .changeset/dull-impalas-bathe.md
@@ -0,0 +1,6 @@
---
'@remirror/extension-annotation': minor
'@remirror/extension-yjs': minor
---

Add the ability to share annotations via YJS if both extensions are present in the editor
Expand Up @@ -7,6 +7,8 @@ import {
getTextSelection,
Helper,
helper,
isEmptyObject,
OnSetOptionsProps,
PlainExtension,
PrimitiveSelection,
within,
Expand Down Expand Up @@ -39,6 +41,9 @@ function defaultGetStyle<A extends Annotation>(annotations: Array<OmitText<A>>)
defaultOptions: {
getStyle: defaultGetStyle,
blockSeparator: undefined,
getMap: () => new Map(),
transformPosition: (pos) => pos,
transformPositionBeforeRender: (pos) => pos,
},
defaultPriority: ExtensionPriority.Low,
})
Expand All @@ -49,12 +54,30 @@ export class AnnotationExtension<Type extends Annotation = Annotation> extends P
return 'annotation' as const;
}

protected onSetOptions(props: OnSetOptionsProps<AnnotationOptions<Type>>): void {
const { pickChanged } = props;
const changedPluginOptions = pickChanged([
'getMap',
'transformPosition',
'transformPositionBeforeRender',
]);

if (!isEmptyObject(changedPluginOptions)) {
this.store.updateExtensionPlugins(this);
}
}

/**
* Create the custom code block plugin which handles the delete key amongst
* other things.
*/
createPlugin(): CreateExtensionPlugin<AnnotationState<Type>> {
const pluginState = new AnnotationState<Type>(this.options.getStyle);
const pluginState = new AnnotationState<Type>(
this.options.getStyle,
this.options.getMap(),
this.options.transformPosition,
this.options.transformPositionBeforeRender,
);

return {
state: {
Expand Down
160 changes: 103 additions & 57 deletions packages/remirror__extension-annotation/src/annotation-plugin.ts
@@ -1,4 +1,4 @@
import { assertGet, TransactionProps } from '@remirror/core';
import { assert, Transaction, TransactionProps } from '@remirror/core';
import { Decoration, DecorationSet } from '@remirror/pm/view';

import {
Expand All @@ -9,7 +9,7 @@ import {
UpdateAnnotationAction,
} from './annotation-actions';
import { toSegments } from './annotation-segments';
import type { Annotation, GetStyle, OmitText } from './annotation-types';
import type { Annotation, GetStyle, MapLike, OmitText } from './annotation-types';

interface ApplyProps extends TransactionProps {
action: any;
Expand All @@ -24,7 +24,92 @@ export class AnnotationState<Type extends Annotation = Annotation> {
*/
decorationSet = DecorationSet.empty;

constructor(private readonly getStyle: GetStyle<Type>) {}
constructor(
private readonly getStyle: GetStyle<Type>,
private readonly map: MapLike<string, OmitText<Type>>,
private readonly transformPosition: (pos: number) => number,
private readonly transformPositionBeforeRender: (pos: number) => number | null,
) {}

addAnnotation(addAction: AddAnnotationAction<Type>): void {
const { id } = addAction.annotationData;
this.map.set(id, {
...addAction.annotationData,
from: this.transformPosition(addAction.from),
to: this.transformPosition(addAction.to),
} as OmitText<Type>);
}

updateAnnotation(updateAction: UpdateAnnotationAction<Type>): void {
assert(this.map.has(updateAction.annotationId));

this.map.set(updateAction.annotationId, {
...this.map.get(updateAction.annotationId),
...updateAction.annotationData,
} as OmitText<Type>);
}

removeAnnotations(removeAction: RemoveAnnotationsAction): void {
removeAction.annotationIds.forEach((id) => {
this.map.delete(id);
});
}

setAnnotations(setAction: SetAnnotationsAction<Type>): void {
// YJS maps don't support clear
this.map.clear?.();
// eslint-disable-next-line prefer-arrow-callback
this.map.forEach(function (_, id, map) {
map.delete(id);
});

setAction.annotations.forEach((annotation) => {
const { id, from, to } = annotation;
this.map.set(id, {
...annotation,
from: this.transformPosition(from),
to: this.transformPosition(to),
} as OmitText<Type>);
});
}

formatAnnotations(): Array<OmitText<Type>> {
const annotations: Array<OmitText<Type>> = [];

this.map.forEach((annotation, id, map) => {
const from = this.transformPositionBeforeRender(annotation.from);
const to = this.transformPositionBeforeRender(annotation.to);

if (!from || !to) {
map.delete(id);
}

annotations.push({
...annotation,
from,
to,
});
});

return annotations;
}

createDecorations(tr: Transaction, annotations: Array<OmitText<Type>> = []): DecorationSet {
// Recalculate decorations when annotations changed
const decos = toSegments(annotations).map((segment) => {
const classNames = segment.annotations
.map((a) => a.className)
.filter((className) => className);
const style = this.getStyle(segment.annotations);

return Decoration.inline(segment.from, segment.to, {
class: classNames.length > 0 ? classNames.join(' ') : undefined,
style,
});
});

return DecorationSet.create(tr.doc, decos);
}

apply({ tr, action }: ApplyProps): this {
const actionType = action?.type;
Expand All @@ -46,64 +131,25 @@ export class AnnotationState<Type extends Annotation = Annotation> {
// Remove annotations for which all containing content was deleted
.filter((annotation) => annotation.to !== annotation.from);

let newAnnotations: Array<OmitText<Type>> | undefined;
if (actionType !== undefined) {
if (actionType === ActionType.ADD_ANNOTATION) {
this.addAnnotation(action as AddAnnotationAction<Type>);
}

if (actionType === ActionType.ADD_ANNOTATION) {
const addAction = action as AddAnnotationAction<Type>;
const newAnnotation = {
...addAction.annotationData,
from: addAction.from,
to: addAction.to,
} as OmitText<Type>;
newAnnotations = [...this.annotations, newAnnotation];
}
if (actionType === ActionType.UPDATE_ANNOTATION) {
this.updateAnnotation(action as UpdateAnnotationAction<Type>);
}

if (actionType === ActionType.UPDATE_ANNOTATION) {
const updateAction = action as UpdateAnnotationAction<Type>;
const annotationIndex = this.annotations.findIndex(
(annotation) => annotation.id === updateAction.annotationId,
);
const updatedAnnotation = {
...assertGet(this.annotations, annotationIndex),
...updateAction.annotationData,
};
newAnnotations = [
...this.annotations.slice(0, annotationIndex),
updatedAnnotation,
...this.annotations.slice(annotationIndex + 1),
];
}
if (actionType === ActionType.REMOVE_ANNOTATIONS) {
this.removeAnnotations(action as RemoveAnnotationsAction);
}

if (actionType === ActionType.REMOVE_ANNOTATIONS) {
const removeAction = action as RemoveAnnotationsAction;
newAnnotations = this.annotations.filter((a) => !removeAction.annotationIds.includes(a.id));
}

if (actionType === ActionType.SET_ANNOTATIONS) {
const setAction = action as SetAnnotationsAction<Type>;
newAnnotations = setAction.annotations;
}

if (actionType === ActionType.REDRAW_ANNOTATIONS) {
newAnnotations = this.annotations;
}

if (newAnnotations) {
// Recalculate decorations when annotations changed
const decos = toSegments(newAnnotations).map((segment) => {
const classNames = segment.annotations
.map((a) => a.className)
.filter((className) => className);
const style = this.getStyle(segment.annotations);

return Decoration.inline(segment.from, segment.to, {
class: classNames.length > 0 ? classNames.join(' ') : undefined,
style,
});
});
if (actionType === ActionType.SET_ANNOTATIONS) {
this.setAnnotations(action as SetAnnotationsAction<Type>);
}

this.annotations = newAnnotations;
this.decorationSet = DecorationSet.create(tr.doc, decos);
this.annotations = this.formatAnnotations();
this.decorationSet = this.createDecorations(tr, this.annotations);
} else {
// Performance optimization: Adjust decoration positions based on changes
// in the editor, e.g. if new text was added before the decoration
Expand Down
17 changes: 17 additions & 0 deletions packages/remirror__extension-annotation/src/annotation-types.ts
Expand Up @@ -3,6 +3,17 @@ import type { AcceptUndefined } from '@remirror/core';
export type GetStyle<Type extends Annotation> = (
annotations: Array<OmitText<Type>>,
) => string | undefined;

export interface MapLike<K extends string, V> {
clear?: () => void;
delete: (key: K) => any;
forEach: (callbackfn: (value: V, key: K, map: MapLike<K, V>) => void, thisArg?: any) => void;
get: (key: K) => V | undefined;
has: (key: K) => boolean;
set: (key: K, value: V) => any;
readonly size: number;
}

export interface AnnotationOptions<Type extends Annotation = Annotation> {
/**
* Method to calculate styles for a segment with one or more annotations
Expand All @@ -23,6 +34,12 @@ export interface AnnotationOptions<Type extends Annotation = Annotation> {
* @see ProsemirrorNode.textBetween
*/
blockSeparator?: AcceptUndefined<string>;

getMap?: () => MapLike<string, OmitText<Type>>;

transformPosition?: (pos: number) => number;

transformPositionBeforeRender?: (pos: number) => number | null;
}

export interface Annotation {
Expand Down
3 changes: 3 additions & 0 deletions packages/remirror__extension-yjs/__tests__/tsconfig.json
Expand Up @@ -34,6 +34,9 @@
{
"path": "../../remirror__core/src"
},
{
"path": "../../remirror__extension-annotation/src"
},
{
"path": "../../remirror__messages/src"
}
Expand Down
1 change: 1 addition & 0 deletions packages/remirror__extension-yjs/package.json
Expand Up @@ -41,6 +41,7 @@
"dependencies": {
"@babel/runtime": "^7.13.10",
"@remirror/core": "1.0.0-next.60",
"@remirror/extension-annotation": "1.0.0-next.60",
"@remirror/messages": "0.0.0",
"y-prosemirror": "^1.0.6",
"y-protocols": "^1.0.4"
Expand Down
3 changes: 3 additions & 0 deletions packages/remirror__extension-yjs/src/tsconfig.json
Expand Up @@ -19,6 +19,9 @@
{
"path": "../../remirror__core/src"
},
{
"path": "../../remirror__extension-annotation/src"
},
{
"path": "../../remirror__messages/src"
}
Expand Down
31 changes: 31 additions & 0 deletions packages/remirror__extension-yjs/src/yjs-extension.ts
@@ -1,9 +1,12 @@
import {
absolutePositionToRelativePosition,
defaultCursorBuilder,
redo,
relativePositionToAbsolutePosition,
undo,
yCursorPlugin,
ySyncPlugin,
ySyncPluginKey,
yUndoPlugin,
yUndoPluginKey,
} from 'y-prosemirror';
Expand All @@ -30,6 +33,7 @@ import {
Selection,
Shape,
} from '@remirror/core';
import { AnnotationExtension } from '@remirror/extension-annotation';
import { ExtensionHistoryMessages as Messages } from '@remirror/messages';

export interface ColorDef {
Expand Down Expand Up @@ -142,6 +146,21 @@ export class YjsExtension extends PlainExtension<YjsOptions> {
return (this._provider ??= getLazyValue(getProvider));
}

onView(): void {
try {
this.store.manager.getExtension(AnnotationExtension).setOptions({
getMap: () => this.provider.doc.getMap('annotations'),
transformPosition: this.transformPosition.bind(this),
transformPositionBeforeRender: this.transformPositionBeforeRender.bind(this),
});
this.provider.doc.on('update', () => {
this.store.commands.redrawAnnotations?.();
});
} catch {
// AnnotationExtension isn't present in editor
}
}

/**
* Create the yjs plugins.
*/
Expand Down Expand Up @@ -288,6 +307,18 @@ export class YjsExtension extends PlainExtension<YjsOptions> {
redoShortcut(props: KeyBindingProps): boolean {
return this.yRedo()(props);
}

private transformPosition(pos: number): number {
const state = this.store.getState();
const { type, binding } = ySyncPluginKey.getState(state);
return absolutePositionToRelativePosition(pos, type, binding.mapping);
}

private transformPositionBeforeRender(pos: number): number | null {
const state = this.store.getState();
const { type, binding } = ySyncPluginKey.getState(state);
return relativePositionToAbsolutePosition(this.provider.doc, type, pos, binding.mapping);
}
}

/**
Expand Down
2 changes: 2 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion support/root/.size-limit.json
Expand Up @@ -642,7 +642,13 @@
"name": "@remirror/extension-yjs",
"limit": "115 KB",
"path": "packages/remirror__extension-yjs/dist/remirror-extension-yjs.browser.esm.js",
"ignore": ["@remirror/pm", "yjs", "@remirror/core", "@remirror/messages"],
"ignore": [
"@remirror/pm",
"yjs",
"@remirror/core",
"@remirror/extension-annotation",
"@remirror/messages"
],
"running": false,
"gzip": true
},
Expand Down

0 comments on commit 6bba178

Please sign in to comment.