Merge branch 'main' of https://github.com/ueberdosis/tiptap-next into feature/remove-inferred-commands
# Conflicts: # docs/src/demos/Experiments/Annotation/extension/annotation.ts # docs/src/demos/Experiments/Color/extension/Color.ts # docs/src/demos/Experiments/Details/details.ts
This commit is contained in:
@@ -67,6 +67,8 @@ export default {
|
||||
this.status = event.status
|
||||
})
|
||||
|
||||
window.ydoc = ydoc
|
||||
|
||||
this.indexdb = new IndexeddbPersistence('tiptap-collaboration-example', ydoc)
|
||||
|
||||
this.editor = new Editor({
|
||||
@@ -141,7 +143,7 @@ export default {
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 90vh;
|
||||
max-height: 400px;
|
||||
color: #0D0D0D;
|
||||
background-color: $colorWhite;
|
||||
border: 3px solid #0D0D0D;
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export class AnnotationItem {
|
||||
public id!: number
|
||||
|
||||
public text!: string
|
||||
|
||||
constructor(id: number, text: string) {
|
||||
this.id = id
|
||||
this.text = text
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Decoration, DecorationSet } from 'prosemirror-view'
|
||||
import { ySyncPluginKey } from 'y-prosemirror'
|
||||
import { AnnotationPluginKey } from './AnnotationPlugin'
|
||||
|
||||
export class AnnotationState {
|
||||
private decorations: any
|
||||
|
||||
constructor(decorations: any) {
|
||||
this.decorations = decorations
|
||||
}
|
||||
|
||||
findAnnotation(id: number) {
|
||||
const current = this.decorations.find()
|
||||
|
||||
for (let i = 0; i < current.length; i += 1) {
|
||||
if (current[i].spec.data.id === id) {
|
||||
return current[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
annotationsAt(position: number) {
|
||||
return this.decorations.find(position, position)
|
||||
}
|
||||
|
||||
apply(transaction: any) {
|
||||
console.log('transaction', transaction.meta, transaction.docChanged, transaction)
|
||||
|
||||
const yjsTransaction = transaction.getMeta(ySyncPluginKey)
|
||||
if (yjsTransaction) {
|
||||
// TODO: Map positions
|
||||
// absolutePositionToRelativePosition(state.selection.anchor, pmbinding.type, pmbinding.mapping)
|
||||
console.log('map positions', transaction, yjsTransaction)
|
||||
|
||||
return this
|
||||
|
||||
// const { binding } = yjsTransaction
|
||||
// console.log({ binding }, { transaction }, transaction.docChanged)
|
||||
// console.log('yjsTransaction.isChangeOrigin', yjsTransaction.isChangeOrigin)
|
||||
|
||||
// console.log('yjs mapping', yjsTransaction.binding?.mapping)
|
||||
// console.log('all decorations', this.decorations.find())
|
||||
// console.log('original prosemirror mapping', this.decorations.map(transaction.mapping, transaction.doc))
|
||||
// console.log('difference between ProseMirror & Y.js', transaction.mapping, yjsTransaction.binding?.mapping)
|
||||
|
||||
// Code to sync the selection:
|
||||
// export const getRelativeSelection = (pmbinding, state) => ({
|
||||
// anchor: absolutePositionToRelativePosition(state.selection.anchor, pmbinding.type, pmbinding.mapping),
|
||||
// head: absolutePositionToRelativePosition(state.selection.head, pmbinding.type, pmbinding.mapping)
|
||||
// })
|
||||
|
||||
// console.log(yjsTransaction.binding.mapping, transaction.curSelection.anchor)
|
||||
}
|
||||
|
||||
if (transaction.docChanged) {
|
||||
// TODO: Fixes the initial load (complete replace of the document)
|
||||
// return this
|
||||
|
||||
// TODO: Fixes later changes (typing before the annotation)
|
||||
const decorations = this.decorations.map(transaction.mapping, transaction.doc)
|
||||
|
||||
return new AnnotationState(decorations)
|
||||
}
|
||||
|
||||
const action = transaction.getMeta(AnnotationPluginKey)
|
||||
const actionType = action && action.type
|
||||
|
||||
if (action) {
|
||||
let { decorations } = this
|
||||
|
||||
if (actionType === 'addAnnotation') {
|
||||
decorations = decorations.add(transaction.doc, [
|
||||
Decoration.inline(action.from, action.to, { class: 'annotation' }, { data: action.data }),
|
||||
])
|
||||
} else if (actionType === 'deleteAnnotation') {
|
||||
decorations = decorations.remove([
|
||||
this.findAnnotation(action.id),
|
||||
])
|
||||
}
|
||||
|
||||
return new AnnotationState(decorations)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
static init(config: any, state: any) {
|
||||
// TODO: Load initial decorations from Y.js?
|
||||
const decorations = DecorationSet.create(state.doc, [
|
||||
Decoration.inline(105, 190, { class: 'annotation' }, { data: { id: 123, content: 'foobar' } }),
|
||||
])
|
||||
|
||||
return new AnnotationState(decorations)
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Extension, Command } from '@tiptap/core'
|
||||
import { AnnotationItem } from './AnnotationItem'
|
||||
import { AnnotationPlugin, AnnotationPluginKey } from './AnnotationPlugin'
|
||||
|
||||
function randomId() {
|
||||
return Math.floor(Math.random() * 0xffffffff)
|
||||
}
|
||||
|
||||
export interface AnnotationOptions {
|
||||
HTMLAttributes: {
|
||||
[key: string]: any
|
||||
},
|
||||
onUpdate: (items: [any?]) => {},
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface AllCommands {
|
||||
annotation: {
|
||||
addAnnotation: (content: any) => Command,
|
||||
deleteAnnotation: (id: number) => Command,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const Annotation = Extension.create({
|
||||
name: 'annotation',
|
||||
|
||||
defaultOptions: <AnnotationOptions>{
|
||||
HTMLAttributes: {
|
||||
class: 'annotation',
|
||||
},
|
||||
onUpdate: decorations => decorations,
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
addAnnotation: (content: any) => ({ dispatch, state }) => {
|
||||
const { selection } = state
|
||||
|
||||
if (selection.empty) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (dispatch && content) {
|
||||
dispatch(state.tr.setMeta(AnnotationPluginKey, {
|
||||
type: 'addAnnotation',
|
||||
from: selection.from,
|
||||
to: selection.to,
|
||||
data: new AnnotationItem(
|
||||
randomId(),
|
||||
content,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
deleteAnnotation: (id: number) => ({ dispatch, state }) => {
|
||||
if (dispatch) {
|
||||
dispatch(state.tr.setMeta(AnnotationPluginKey, { type: 'deleteAnnotation', id }))
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
AnnotationPlugin(this.options),
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Annotation } from './annotation'
|
||||
|
||||
export * from './annotation'
|
||||
|
||||
export default Annotation
|
||||
@@ -1,7 +0,0 @@
|
||||
context('/demos/Extensions/Annotations', () => {
|
||||
before(() => {
|
||||
cy.visit('/demos/Extensions/Annotations')
|
||||
})
|
||||
|
||||
// TODO: Write tests
|
||||
})
|
||||
@@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="editor">
|
||||
<button @click="addAnnotation" :disabled="!editor.can().addAnnotation()">
|
||||
add annotation
|
||||
</button>
|
||||
<editor-content :editor="editor" />
|
||||
<div v-for="comment in comments" :key="comment.type.spec.data.id">
|
||||
{{ comment.type.spec.data }}
|
||||
|
||||
<button @click="deleteAnnotation(comment.type.spec.data.id)">
|
||||
remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Editor, EditorContent } from '@tiptap/vue-starter-kit'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import Annotation from './extension'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EditorContent,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
editor: null,
|
||||
comments: [],
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.editor = new Editor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Annotation.configure({
|
||||
onUpdate: items => { this.comments = items },
|
||||
}),
|
||||
],
|
||||
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 you’re free to add more complex data, for example JSON from another tiptap instance. :-)
|
||||
</p>
|
||||
`,
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
addAnnotation() {
|
||||
const content = prompt('Annotation', '')
|
||||
|
||||
this.editor.commands.addAnnotation(content)
|
||||
},
|
||||
deleteAnnotation(id) {
|
||||
this.editor.commands.deleteAnnotation(id)
|
||||
},
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.editor.destroy()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
/* Basic editor styles */
|
||||
.ProseMirror {
|
||||
> * + * {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
}
|
||||
|
||||
.annotation {
|
||||
background: #9DEF8F;
|
||||
}
|
||||
</style>
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,34 @@
|
||||
import * as Y from 'yjs'
|
||||
import { Plugin, PluginKey } from 'prosemirror-state'
|
||||
import { AnnotationState } from './AnnotationState'
|
||||
|
||||
export const AnnotationPluginKey = new PluginKey('annotation')
|
||||
|
||||
export const AnnotationPlugin = (options: any) => new Plugin({
|
||||
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: AnnotationState.init,
|
||||
apply(transaction, oldState) {
|
||||
return oldState.apply(transaction)
|
||||
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)
|
||||
@@ -28,6 +46,5 @@ export const AnnotationPlugin = (options: any) => new Plugin({
|
||||
|
||||
return decorations
|
||||
},
|
||||
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import * as Y from 'yjs'
|
||||
import { Extension, Command } 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 AllCommands {
|
||||
annotation: {
|
||||
addAnnotation: (data: any) => Command,
|
||||
updateAnnotation: (id: string, data: any) => Command,
|
||||
deleteAnnotation: (id: string) => Command,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CollaborationAnnotation = Extension.create({
|
||||
name: 'annotation',
|
||||
|
||||
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,
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CollaborationAnnotation } from './collaboration-annotation'
|
||||
|
||||
export * from './collaboration-annotation'
|
||||
|
||||
export default CollaborationAnnotation
|
||||
@@ -0,0 +1,7 @@
|
||||
context('/demos/Experiments/Annotation', () => {
|
||||
before(() => {
|
||||
cy.visit('/demos/Experiments/Annotation')
|
||||
})
|
||||
|
||||
// TODO: Write tests
|
||||
})
|
||||
@@ -2,36 +2,30 @@
|
||||
<div>
|
||||
<div v-if="editor">
|
||||
<h2>
|
||||
Original
|
||||
Original Editor
|
||||
</h2>
|
||||
<button @click="addComment" :disabled="!editor.can().addAnnotation()">
|
||||
comment
|
||||
</button>
|
||||
<editor-content :editor="editor" />
|
||||
<div v-for="comment in comments" :key="comment.type.spec.data.id">
|
||||
{{ comment.type.spec.data }}
|
||||
<div v-for="comment in comments" :key="comment.id">
|
||||
{{ comment }}
|
||||
|
||||
<button @click="deleteComment(comment.type.spec.data.id)">
|
||||
<button @click="updateComment(comment.id)">
|
||||
update
|
||||
</button>
|
||||
|
||||
<button @click="deleteComment(comment.id)">
|
||||
remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- <br>
|
||||
<h2>
|
||||
ProseMirror JSON from Y.js document
|
||||
</h2>
|
||||
{{ rawDocument }} -->
|
||||
|
||||
<br>
|
||||
<h2>
|
||||
Y.js document
|
||||
</h2>
|
||||
{{ json }}
|
||||
|
||||
<br>
|
||||
<h2>
|
||||
Mirror
|
||||
Another Editor
|
||||
</h2>
|
||||
<button @click="addAnotherComment" :disabled="!anotherEditor.can().addAnnotation()">
|
||||
comment
|
||||
</button>
|
||||
<editor-content :editor="anotherEditor" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,8 +40,7 @@ import Collaboration from '@tiptap/extension-collaboration'
|
||||
import Bold from '@tiptap/extension-bold'
|
||||
import Heading from '@tiptap/extension-heading'
|
||||
import * as Y from 'yjs'
|
||||
import { yDocToProsemirrorJSON } from 'y-prosemirror'
|
||||
import Annotation from '../Annotation/extension'
|
||||
import CollaborationAnnotation from './extension'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -73,8 +66,10 @@ export default {
|
||||
Text,
|
||||
Bold,
|
||||
Heading,
|
||||
Annotation.configure({
|
||||
CollaborationAnnotation.configure({
|
||||
document: this.ydoc,
|
||||
onUpdate: items => { this.comments = items },
|
||||
instance: 'editor1',
|
||||
}),
|
||||
Collaboration.configure({
|
||||
document: this.ydoc,
|
||||
@@ -95,9 +90,12 @@ export default {
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
// Annotation.configure({
|
||||
// onUpdate: items => { this.comments = items },
|
||||
// }),
|
||||
Bold,
|
||||
Heading,
|
||||
CollaborationAnnotation.configure({
|
||||
document: this.ydoc,
|
||||
instance: 'editor2',
|
||||
}),
|
||||
Collaboration.configure({
|
||||
document: this.ydoc,
|
||||
}),
|
||||
@@ -107,26 +105,32 @@ export default {
|
||||
|
||||
methods: {
|
||||
addComment() {
|
||||
const content = prompt('Comment', '')
|
||||
const data = prompt('Comment', '')
|
||||
|
||||
this.editor.commands.addAnnotation(content)
|
||||
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', '')
|
||||
|
||||
computed: {
|
||||
rawDocument() {
|
||||
return yDocToProsemirrorJSON(this.ydoc, 'default')
|
||||
},
|
||||
json() {
|
||||
return this.ydoc.toJSON()
|
||||
this.anotherEditor.commands.addAnnotation(data)
|
||||
},
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.editor.destroy()
|
||||
this.anotherEditor.destroy()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -1,62 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Decoration, DecorationSet } from 'prosemirror-view'
|
||||
import { Plugin } from 'prosemirror-state'
|
||||
|
||||
function detectColors(doc) {
|
||||
const hexColor = /(#[0-9a-f]{3,6})\b/ig
|
||||
const results = []
|
||||
const decorations: [any?] = []
|
||||
|
||||
doc.descendants((node: any, position: any) => {
|
||||
if (!node.isText) {
|
||||
return
|
||||
}
|
||||
|
||||
let matches
|
||||
|
||||
// eslint-disable-next-line
|
||||
while (matches = hexColor.exec(node.text)) {
|
||||
results.push({
|
||||
color: matches[0],
|
||||
from: position + matches.index,
|
||||
to: position + matches.index + matches[0].length,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
results.forEach(issue => {
|
||||
decorations.push(Decoration.inline(issue.from, issue.to, {
|
||||
class: 'color',
|
||||
style: `--color: ${issue.color}`,
|
||||
}))
|
||||
})
|
||||
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
|
||||
export const Color = Extension.create({
|
||||
name: 'color',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
state: {
|
||||
init(_, { doc }) {
|
||||
return detectColors(doc)
|
||||
},
|
||||
apply(transaction, oldState) {
|
||||
return transaction.docChanged
|
||||
? detectColors(transaction.doc)
|
||||
: oldState
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state)
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -1,4 +0,0 @@
|
||||
import { Color } from './Color'
|
||||
|
||||
export * from './Color'
|
||||
export default Color
|
||||
@@ -1,76 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<editor-content :editor="editor" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Editor, EditorContent } from '@tiptap/vue-starter-kit'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Heading from '@tiptap/extension-heading'
|
||||
import Color from './extension'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EditorContent,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
editor: null,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.editor = new Editor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Heading,
|
||||
Text,
|
||||
Color,
|
||||
],
|
||||
content: `
|
||||
<p>
|
||||
For triplets with repeated values, you can eliminate the repetition by writing in shorthand, for instance, #00FFFF becomes #0FF. This system is easy for computers to understand, and it pretty short to write, which makes it useful for quick copy paste and designation in programming. If you’re going to work with colors in a more involved way, though, HSL is a little bit more human-readable.
|
||||
</p>
|
||||
<p>
|
||||
A few more examples: #FFF, #0D0D0D, #616161, #A975FF, #FB5151, #FD9170, #FFCB6B, #68CEF8, #80cbc4, #9DEF8F
|
||||
</p>
|
||||
`,
|
||||
})
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.editor.destroy()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
/* Basic editor styles */
|
||||
.ProseMirror {
|
||||
> * + * {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
}
|
||||
|
||||
.color {
|
||||
white-space: nowrap;
|
||||
|
||||
&::before {
|
||||
content: ' ';
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border: 1px solid rgba(128, 128, 128, 0.3);
|
||||
vertical-align: middle;
|
||||
margin-right: 0.1em;
|
||||
margin-bottom: 0.15em;
|
||||
border-radius: 2px;
|
||||
background-color: var(--color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +0,0 @@
|
||||
context('/demos/Examples/Annotations', () => {
|
||||
before(() => {
|
||||
cy.visit('/demos/Examples/Annotations')
|
||||
})
|
||||
|
||||
// TODO: Write tests
|
||||
})
|
||||
@@ -9,9 +9,13 @@ export interface DetailsSummaryOptions {
|
||||
export default Node.create<DetailsSummaryOptions>({
|
||||
name: 'detailsSummary',
|
||||
|
||||
content: 'inline*',
|
||||
content: 'text*',
|
||||
|
||||
// group: 'block',
|
||||
marks: '',
|
||||
|
||||
group: 'block',
|
||||
|
||||
isolating: true,
|
||||
|
||||
defaultOptions: {
|
||||
HTMLAttributes: {},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import { Node, mergeAttributes, Command } from '@tiptap/core'
|
||||
|
||||
export interface DetailsOptions {
|
||||
HTMLAttributes: {
|
||||
@@ -6,6 +6,25 @@ export interface DetailsOptions {
|
||||
},
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface AllCommands {
|
||||
details: {
|
||||
/**
|
||||
* Set a details node
|
||||
*/
|
||||
setDetails: () => Command,
|
||||
/**
|
||||
* Toggle a details node
|
||||
*/
|
||||
toggleDetails: () => Command,
|
||||
/**
|
||||
* Unset a details node
|
||||
*/
|
||||
unsetDetails: () => Command,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Node.create<DetailsOptions>({
|
||||
name: 'details',
|
||||
|
||||
@@ -13,36 +32,21 @@ export default Node.create<DetailsOptions>({
|
||||
|
||||
group: 'block',
|
||||
|
||||
// defining: true,
|
||||
|
||||
defaultOptions: {
|
||||
HTMLAttributes: {},
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
open: {
|
||||
default: true,
|
||||
parseHTML: element => {
|
||||
return {
|
||||
open: element.hasAttribute('open'),
|
||||
}
|
||||
},
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.open) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
open: 'open',
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{
|
||||
tag: 'details',
|
||||
}]
|
||||
return [
|
||||
{
|
||||
tag: 'details',
|
||||
},
|
||||
{
|
||||
tag: 'div[data-type="details"]',
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
@@ -50,54 +54,54 @@ export default Node.create<DetailsOptions>({
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({
|
||||
node,
|
||||
HTMLAttributes,
|
||||
getPos,
|
||||
editor,
|
||||
}) => {
|
||||
const { view } = editor
|
||||
const item = document.createElement('details')
|
||||
return ({ HTMLAttributes }) => {
|
||||
const item = document.createElement('div')
|
||||
item.setAttribute('data-type', 'details')
|
||||
|
||||
item.addEventListener('click', event => {
|
||||
// @ts-ignore
|
||||
const { open } = event.target.parentElement as HTMLElement
|
||||
// @ts-ignore
|
||||
const { localName } = event.target
|
||||
const toggle = document.createElement('div')
|
||||
toggle.setAttribute('data-type', 'detailsToggle')
|
||||
item.append(toggle)
|
||||
|
||||
if (typeof getPos === 'function' && localName === 'summary') {
|
||||
view.dispatch(view.state.tr.setNodeMarkup(getPos(), undefined, {
|
||||
open: !open,
|
||||
}))
|
||||
editor.commands.focus()
|
||||
const content = document.createElement('div')
|
||||
content.setAttribute('data-type', 'detailsContent')
|
||||
item.append(content)
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
if (item.hasAttribute('open')) {
|
||||
item.removeAttribute('open')
|
||||
} else {
|
||||
item.setAttribute('open', 'open')
|
||||
}
|
||||
})
|
||||
|
||||
if (node.attrs.open) {
|
||||
item.setAttribute('open', 'open')
|
||||
}
|
||||
|
||||
Object.entries(HTMLAttributes).forEach(([key, value]) => {
|
||||
item.setAttribute(key, value)
|
||||
})
|
||||
|
||||
return {
|
||||
dom: item,
|
||||
contentDOM: item,
|
||||
update: updatedNode => {
|
||||
if (updatedNode.type !== this.type) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (updatedNode.attrs.open) {
|
||||
item.setAttribute('open', 'open')
|
||||
} else {
|
||||
item.removeAttribute('open')
|
||||
}
|
||||
|
||||
return true
|
||||
contentDOM: content,
|
||||
ignoreMutation: (mutation: MutationRecord) => {
|
||||
return !item.contains(mutation.target) || item === mutation.target
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setDetails: () => ({ commands }) => {
|
||||
// TODO: Doesn’t work
|
||||
return commands.wrapIn('details')
|
||||
},
|
||||
toggleDetails: () => ({ commands }) => {
|
||||
// TODO: Doesn’t work
|
||||
return commands.toggleWrap('details')
|
||||
},
|
||||
unsetDetails: () => ({ commands }) => {
|
||||
// TODO: Doesn’t work
|
||||
return commands.lift('details')
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
<template>
|
||||
<div v-if="editor">
|
||||
<button @click="editor.chain().focus().toggleDetails().run()" :class="{ 'is-active': editor.isActive('details') }">
|
||||
details
|
||||
</button>
|
||||
|
||||
<editor-content :editor="editor" />
|
||||
|
||||
<h2>HTML</h2>
|
||||
{{ editor.getHTML() }}
|
||||
|
||||
<h2>Issues</h2>
|
||||
<ul>
|
||||
<li>Commands don’t work</li>
|
||||
<li>Fails to open nested details</li>
|
||||
<li>Node can’t be deleted (if it’s the last node)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -31,7 +45,7 @@ export default {
|
||||
],
|
||||
content: `
|
||||
<p>Here is a details list:</p>
|
||||
<details open>
|
||||
<details>
|
||||
<summary>An open details tag</summary>
|
||||
<p>More info about the details.</p>
|
||||
</details>
|
||||
@@ -39,6 +53,7 @@ export default {
|
||||
<summary>A closed details tag</summary>
|
||||
<p>More info about the details.</p>
|
||||
</details>
|
||||
<p>That’s it.</p>
|
||||
`,
|
||||
})
|
||||
},
|
||||
@@ -54,5 +69,31 @@ export default {
|
||||
> * + * {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
|
||||
details,
|
||||
[data-type="details"] {
|
||||
display: flex;
|
||||
|
||||
[data-type="detailsContent"] > *:not(summary) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-type="detailsToggle"]::before {
|
||||
cursor: pointer;
|
||||
content: '▸';
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
&[open] {
|
||||
[data-type="detailsContent"] > *:not(summary) {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
[data-type="detailsToggle"]::before {
|
||||
content: '▾';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user