AnnotationExtension
Summary
This extension allows to annotate the content in your editor
Features
Annotate multiple text nodes
Annotation enrich parts of a document. For example, a user could annotate a sentence as "important" to find it back later on.
Overlapping annotations
Annotations can be partially or fully overlapping. For example, a user could annotate the "important" sentence as well with "to be reviewed" or a word in the sentence as "customer X".
The annotation extension provides logic to visualize such overlapping annotations by mixing colors. See storybook for an example.
Extendable data model
The extension defines only the minimal required fields: position where the annotation starts/ends and an ID. For convince, the annotation provides also the text covered by the annotation.
An app using the annotation-extension can extend the base data model. For example, it could add a label (like "important") or a color to each annotation. The annotation extension will pass these custom fields simply through to the app.
Collaborative editing (Yjs)
As stated above, Annotations are decorations - these are not part of the Prosemirror document model, they are part of the view. Whilst the model is syncronised between users, the view is not.
To enable collaboration on annotations, additional logic has been added in the Yjs extension, that modifies this extension's options to utilise a Yjs Map. This is a shared data structure, meaning annotations can also be collaborated on.
This proof of concept has a in-depth description of this approach.
Implementation
Annotations are rendered as decorations. In contrast to marks, they can span across multiple Prosemirror nodes.
Prosemirror stores all content in a flat sequence of nodes. For example, the text "bold italic bold" contains 3 different nodes (italic forces a split of the node).
Text | Marks |
---|---|
bold | bold |
italic | bold , italic |
bold | bold |
This means that modelling annotations as marks would lead to 3 different nodes, each with the same annotation mark. By rendering annotations as decorations, an annotation can cover multiple text nodes with a single decoration.
This is relevant for many use cases:
- Show a list of sentences marked as "important": Users expect each sentence to show once - not 3 separated items, one for each part of the annotation (in case of marks)
- Rename the "important" annotation to "very important": This would have to be done for all 3 nodes separately in case of marks.
- Brightening the background color of the annotion (styling) on mouse over: Users expect the complete annotation to lighten up - not only of the 3 parts where the mouse over happened. Note: This breaks if you use overlapping annotations.
References:
Usage
Installation
This extension is installed for you when you install the main remirror
package.
You can use the imports in the following way:
import { AnnotationExtension } from 'remirror/extensions';
The extension is provided by the @remirror/extension-annotation
package.
Examples
Source code
import 'remirror/styles/all.css';
import React, { FC, useEffect } from 'react';
import { AnnotationExtension, createCenteredAnnotationPositioner } from 'remirror/extensions';
import {
EditorComponent,
PositionerPortal,
Remirror,
ThemeProvider,
usePositioner,
useRemirror,
useRemirrorContext,
} from '@remirror/react';
const SAMPLE_TEXT = 'This is a sample text';
const Popup: FC = () => {
const { helpers, getState } = useRemirrorContext({ autoUpdate: true });
const positioner = usePositioner(
createCenteredAnnotationPositioner(helpers.getAnnotationsAt),
[],
);
if (!positioner.active) {
return null;
}
const sel = getState().selection;
const annotations = helpers.getAnnotationsAt(sel.from);
const label = annotations.map((annotation) => annotation.text).join('\n');
return (
<PositionerPortal>
<div
style={{
top: positioner.y + positioner.height,
left: positioner.x,
position: 'absolute',
border: '1px solid black',
whiteSpace: 'pre-line',
background: 'white',
}}
title='Floating annotation'
ref={positioner.ref}
>
{label}
</div>
</PositionerPortal>
);
};
const SmallEditor: FC = () => {
const { setContent, commands, helpers } = useRemirrorContext({
autoUpdate: true,
});
useEffect(() => {
setContent({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: `${SAMPLE_TEXT} `,
},
],
},
],
});
commands.setAnnotations([
{
id: 'a-1',
from: 1,
to: SAMPLE_TEXT.length + 1,
},
{
id: 'a-2',
from: 9,
to: SAMPLE_TEXT.length + 1,
},
{
id: 'a-3',
from: 11,
to: 17,
},
]);
}, [setContent, commands]);
return (
<div>
<EditorComponent />
<Popup />
<div>Annotations:</div>
<pre>{JSON.stringify(helpers.getAnnotations(), null, ' ')}</pre>
</div>
);
};
const Basic: FC = () => {
const { manager } = useRemirror({ extensions: () => [new AnnotationExtension()] });
return (
<ThemeProvider>
<Remirror manager={manager}>
<SmallEditor />
</Remirror>
</ThemeProvider>
);
};
export default Basic;