add experiment demos

This commit is contained in:
Philipp Kühn
2021-08-25 17:53:02 +02:00
parent 2498c24186
commit 6ab708b1a2
43 changed files with 3090 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
export class AnnotationItem {
private decoration!: any
constructor(decoration: any) {
this.decoration = decoration
}
get id() {
return this.decoration.type.spec.id
}
get from() {
return this.decoration.from
}
get to() {
return this.decoration.to
}
get data() {
return this.decoration.type.spec.data
}
get HTMLAttributes() {
return this.decoration.type.attrs
}
toString() {
return JSON.stringify({
id: this.id,
data: this.data,
from: this.from,
to: this.to,
HTMLAttributes: this.HTMLAttributes,
})
}
}

View File

@@ -0,0 +1,50 @@
import * as Y from 'yjs'
import { Plugin, PluginKey } from 'prosemirror-state'
import { AnnotationState } from './AnnotationState'
export const AnnotationPluginKey = new PluginKey('annotation')
export interface AnnotationPluginOptions {
HTMLAttributes: {
[key: string]: any
},
onUpdate: (items: [any?]) => {},
map: Y.Map<any>,
instance: string,
}
export const AnnotationPlugin = (options: AnnotationPluginOptions) => new Plugin({
key: AnnotationPluginKey,
state: {
init() {
return new AnnotationState({
HTMLAttributes: options.HTMLAttributes,
map: options.map,
instance: options.instance,
})
},
apply(transaction, pluginState, oldState, newState) {
return pluginState.apply(transaction, newState)
},
},
props: {
decorations(state) {
const { decorations } = this.getState(state)
const { selection } = state
if (!selection.empty) {
return decorations
}
const annotations = this
.getState(state)
.annotationsAt(selection.from)
options.onUpdate(annotations)
return decorations
},
},
})

View File

@@ -0,0 +1,151 @@
import * as Y from 'yjs'
import { EditorState, Transaction } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view'
import { ySyncPluginKey, relativePositionToAbsolutePosition, absolutePositionToRelativePosition } from 'y-prosemirror'
import { AddAnnotationAction, DeleteAnnotationAction, UpdateAnnotationAction } from './collaboration-annotation'
import { AnnotationPluginKey } from './AnnotationPlugin'
import { AnnotationItem } from './AnnotationItem'
export interface AnnotationStateOptions {
HTMLAttributes: {
[key: string]: any
},
map: Y.Map<any>,
instance: string,
}
export class AnnotationState {
options: AnnotationStateOptions
decorations = DecorationSet.empty
constructor(options: AnnotationStateOptions) {
this.options = options
}
randomId() {
// TODO: That seems … to simple.
return Math.floor(Math.random() * 0xffffffff).toString()
}
findAnnotation(id: string) {
const current = this.decorations.find()
for (let i = 0; i < current.length; i += 1) {
if (current[i].spec.id === id) {
return current[i]
}
}
}
addAnnotation(action: AddAnnotationAction, state: EditorState) {
const ystate = ySyncPluginKey.getState(state)
const { type, binding } = ystate
const { map } = this.options
const { from, to, data } = action
const absoluteFrom = absolutePositionToRelativePosition(from, type, binding.mapping)
const absoluteTo = absolutePositionToRelativePosition(to, type, binding.mapping)
map.set(this.randomId(), {
from: absoluteFrom,
to: absoluteTo,
data,
})
}
updateAnnotation(action: UpdateAnnotationAction) {
const { map } = this.options
const annotation = map.get(action.id)
map.set(action.id, {
from: annotation.from,
to: annotation.to,
data: action.data,
})
}
deleteAnnotation(id: string) {
const { map } = this.options
map.delete(id)
}
annotationsAt(position: number) {
return this.decorations.find(position, position).map(decoration => {
return new AnnotationItem(decoration)
})
}
createDecorations(state: EditorState) {
const { map, HTMLAttributes } = this.options
const ystate = ySyncPluginKey.getState(state)
const { doc, type, binding } = ystate
const decorations: Decoration[] = []
map.forEach((annotation, id) => {
const from = relativePositionToAbsolutePosition(doc, type, annotation.from, binding.mapping)
const to = relativePositionToAbsolutePosition(doc, type, annotation.to, binding.mapping)
if (!from || !to) {
return
}
console.log(`[${this.options.instance}] Decoration.inline()`, from, to, HTMLAttributes, { id, data: annotation.data })
if (from === to) {
console.warn(`[${this.options.instance}] corrupt decoration `, annotation.from, from, annotation.to, to)
}
decorations.push(
Decoration.inline(from, to, HTMLAttributes, { id, data: annotation.data, inclusiveEnd: true }),
)
})
this.decorations = DecorationSet.create(state.doc, decorations)
}
apply(transaction: Transaction, state: EditorState) {
// Add/Remove annotations
const action = transaction.getMeta(AnnotationPluginKey) as AddAnnotationAction | UpdateAnnotationAction | DeleteAnnotationAction
if (action && action.type) {
console.log(`[${this.options.instance}] action: ${action.type}`)
if (action.type === 'addAnnotation') {
this.addAnnotation(action, state)
}
if (action.type === 'updateAnnotation') {
this.updateAnnotation(action)
}
if (action.type === 'deleteAnnotation') {
this.deleteAnnotation(action.id)
}
// @ts-ignore
if (action.type === 'createDecorations') {
this.createDecorations(state)
}
return this
}
// Use Y.js to update positions
const ystate = ySyncPluginKey.getState(state)
if (ystate.isChangeOrigin) {
console.log(`[${this.options.instance}] isChangeOrigin: true → createDecorations`)
this.createDecorations(state)
return this
}
// Use ProseMirror to update positions
console.log(`[${this.options.instance}] isChangeOrigin: false → ProseMirror mapping`)
this.decorations = this.decorations.map(transaction.mapping, transaction.doc)
return this
}
}

View File

@@ -0,0 +1,146 @@
import * as Y from 'yjs'
import { Extension } from '@tiptap/core'
import { AnnotationPlugin, AnnotationPluginKey } from './AnnotationPlugin'
export interface AddAnnotationAction {
type: 'addAnnotation',
data: any,
from: number,
to: number,
}
export interface UpdateAnnotationAction {
type: 'updateAnnotation',
id: string,
data: any,
}
export interface DeleteAnnotationAction {
type: 'deleteAnnotation',
id: string,
}
export interface AnnotationOptions {
HTMLAttributes: {
[key: string]: any
},
/**
* An event listener which receives annotations for the current selection.
*/
onUpdate: (items: [any?]) => {},
/**
* An initialized Y.js document.
*/
document: Y.Doc | null,
/**
* Name of a Y.js map, can be changed to sync multiple fields with one Y.js document.
*/
field: string,
/**
* A raw Y.js map, can be used instead of `document` and `field`.
*/
map: Y.Map<any> | null,
instance: string,
}
function getMapFromOptions(options: AnnotationOptions): Y.Map<any> {
return options.map
? options.map
: options.document?.getMap(options.field) as Y.Map<any>
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
annotation: {
addAnnotation: (data: any) => ReturnType,
updateAnnotation: (id: string, data: any) => ReturnType,
deleteAnnotation: (id: string) => ReturnType,
}
}
}
export const CollaborationAnnotation = Extension.create({
name: 'annotation',
priority: 1000,
defaultOptions: <AnnotationOptions>{
HTMLAttributes: {
class: 'annotation',
},
onUpdate: decorations => decorations,
document: null,
field: 'annotations',
map: null,
instance: '',
},
onCreate() {
const map = getMapFromOptions(this.options)
map.observe(() => {
console.log(`[${this.options.instance}] map updated → createDecorations`)
const transaction = this.editor.state.tr.setMeta(AnnotationPluginKey, {
type: 'createDecorations',
})
this.editor.view.dispatch(transaction)
})
},
addCommands() {
return {
addAnnotation: (data: any) => ({ dispatch, state }) => {
const { selection } = state
if (selection.empty) {
return false
}
if (dispatch && data) {
state.tr.setMeta(AnnotationPluginKey, <AddAnnotationAction>{
type: 'addAnnotation',
from: selection.from,
to: selection.to,
data,
})
}
return true
},
updateAnnotation: (id: string, data: any) => ({ dispatch, state }) => {
if (dispatch) {
state.tr.setMeta(AnnotationPluginKey, <UpdateAnnotationAction>{
type: 'updateAnnotation',
id,
data,
})
}
return true
},
deleteAnnotation: id => ({ dispatch, state }) => {
if (dispatch) {
state.tr.setMeta(AnnotationPluginKey, <DeleteAnnotationAction>{
type: 'deleteAnnotation',
id,
})
}
return true
},
}
},
addProseMirrorPlugins() {
return [
AnnotationPlugin({
HTMLAttributes: this.options.HTMLAttributes,
onUpdate: this.options.onUpdate,
map: getMapFromOptions(this.options),
instance: this.options.instance,
}),
]
},
})

View File

@@ -0,0 +1,5 @@
import { CollaborationAnnotation } from './collaboration-annotation'
export * from './collaboration-annotation'
export default CollaborationAnnotation

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
</head>
<body>
<div id="app"></div>
<script type="module">
import setup from '../../../../setup/vue.ts'
import source from '@source'
setup('Experiments/CollaborationAnnotation', source)
</script>
</body>
</html>

View File

@@ -0,0 +1,7 @@
context('/demos/Experiments/Annotation', () => {
before(() => {
cy.visit('/demos/Experiments/Annotation')
})
// TODO: Write tests
})

View File

@@ -0,0 +1,149 @@
<template>
<div>
<div v-if="editor">
<h2>
Original Editor
</h2>
<button @click="addComment" :disabled="!editor.can().addAnnotation()">
comment
</button>
<editor-content :editor="editor" />
<div v-for="comment in comments" :key="comment.id">
{{ comment }}
<button @click="updateComment(comment.id)">
update
</button>
<button @click="deleteComment(comment.id)">
remove
</button>
</div>
<h2>
Another Editor
</h2>
<button @click="addAnotherComment" :disabled="!anotherEditor.can().addAnnotation()">
comment
</button>
<editor-content :editor="anotherEditor" />
</div>
</div>
</template>
<script>
import { Editor, EditorContent } from '@tiptap/vue-3'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import Collaboration from '@tiptap/extension-collaboration'
import Bold from '@tiptap/extension-bold'
import Heading from '@tiptap/extension-heading'
import * as Y from 'yjs'
import CollaborationAnnotation from './extension'
export default {
components: {
EditorContent,
},
data() {
return {
editor: null,
anotherEditor: null,
comments: [],
ydoc: new Y.Doc(),
}
},
mounted() {
this.ydoc = new Y.Doc()
this.editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Bold,
Heading,
CollaborationAnnotation.configure({
document: this.ydoc,
onUpdate: items => { this.comments = items },
instance: 'editor1',
}),
Collaboration.configure({
document: this.ydoc,
}),
],
content: `
<p>
Annotations can be used to add additional information to the content, for example comments. They live on a different level than the actual editor content.
</p>
<p>
This example allows you to add plain text, but youre free to add more complex data, for example JSON from another tiptap instance. :-)
</p>
`,
})
this.anotherEditor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Bold,
Heading,
CollaborationAnnotation.configure({
document: this.ydoc,
instance: 'editor2',
}),
Collaboration.configure({
document: this.ydoc,
}),
],
})
},
methods: {
addComment() {
const data = prompt('Comment', '')
this.editor.commands.addAnnotation(data)
},
updateComment(id) {
const comment = this.comments.find(item => {
return id === item.id
})
const data = prompt('Comment', comment.data)
this.editor.commands.updateAnnotation(id, data)
},
deleteComment(id) {
this.editor.commands.deleteAnnotation(id)
},
addAnotherComment() {
const data = prompt('Comment', '')
this.anotherEditor.commands.addAnnotation(data)
},
},
beforeDestroy() {
this.editor.destroy()
this.anotherEditor.destroy()
},
}
</script>
<style lang="scss">
/* Basic editor styles */
.ProseMirror {
> * + * {
margin-top: 0.75em;
}
}
.annotation {
background: #9DEF8F;
}
</style>