Merge branch 'main' of https://github.com/ueberdosis/tiptap-next into feature/suggestions
# Conflicts: # docs/src/docPages/api/nodes/mention.md # docs/src/links.yaml
This commit is contained in:
@@ -18,7 +18,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
editor: null,
|
||||
provider: null,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -36,7 +35,6 @@ export default {
|
||||
|
||||
beforeDestroy() {
|
||||
this.editor.destroy()
|
||||
this.provider.destroy()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
<div v-if="description" class="form__item form__item--description">
|
||||
<editor-content :editor="description" />
|
||||
</div>
|
||||
<div class="form__label">
|
||||
JSON
|
||||
</div>
|
||||
<div class="form__item form__item--json">
|
||||
<code>{{ json }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -31,7 +37,7 @@ import TaskList from '@tiptap/extension-task-list'
|
||||
import TaskItem from '@tiptap/extension-task-item'
|
||||
import Collaboration from '@tiptap/extension-collaboration'
|
||||
import * as Y from 'yjs'
|
||||
import { WebsocketProvider } from 'y-websocket'
|
||||
import { yDocToProsemirrorJSON } from 'y-prosemirror'
|
||||
|
||||
const ParagraphDocument = Document.extend({
|
||||
content: 'paragraph',
|
||||
@@ -55,13 +61,12 @@ export default {
|
||||
title: null,
|
||||
tasks: null,
|
||||
description: null,
|
||||
ydoc: null,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const ydoc = new Y.Doc()
|
||||
|
||||
this.provider = new WebsocketProvider('wss://websocket.tiptap.dev', 'tiptap-multiple-editors-example', ydoc)
|
||||
this.ydoc = new Y.Doc()
|
||||
|
||||
this.title = new Editor({
|
||||
extensions: [
|
||||
@@ -69,10 +74,11 @@ export default {
|
||||
Paragraph,
|
||||
Text,
|
||||
Collaboration.configure({
|
||||
document: ydoc,
|
||||
document: this.ydoc,
|
||||
field: 'title',
|
||||
}),
|
||||
],
|
||||
content: '<p>No matter what you do, this’ll be a single paragraph.',
|
||||
})
|
||||
|
||||
this.tasks = new Editor({
|
||||
@@ -83,10 +89,17 @@ export default {
|
||||
TaskList,
|
||||
CustomTaskItem,
|
||||
Collaboration.configure({
|
||||
document: ydoc,
|
||||
document: this.ydoc,
|
||||
field: 'tasks',
|
||||
}),
|
||||
],
|
||||
content: `
|
||||
<ul data-type="taskList">
|
||||
<li data-type="taskItem" data-checked="true">And this</li>
|
||||
<li data-type="taskItem" data-checked="false">is a task list</li>
|
||||
<li data-type="taskItem" data-checked="false">and only a task list.</li>
|
||||
</ul>
|
||||
`,
|
||||
})
|
||||
|
||||
this.description = new Editor({
|
||||
@@ -95,13 +108,28 @@ export default {
|
||||
Paragraph,
|
||||
Text,
|
||||
Collaboration.configure({
|
||||
document: ydoc,
|
||||
document: this.ydoc,
|
||||
field: 'description',
|
||||
}),
|
||||
],
|
||||
content: `
|
||||
<p>
|
||||
This can be lengthy text.
|
||||
</p>
|
||||
`,
|
||||
})
|
||||
},
|
||||
|
||||
computed: {
|
||||
json() {
|
||||
return {
|
||||
title: yDocToProsemirrorJSON(this.ydoc, 'title'),
|
||||
tasks: yDocToProsemirrorJSON(this.ydoc, 'tasks'),
|
||||
description: yDocToProsemirrorJSON(this.ydoc, 'description'),
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.title.destroy()
|
||||
this.tasks.destroy()
|
||||
@@ -150,5 +178,23 @@ export default {
|
||||
&--title {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
&--json {
|
||||
background: #0D0D0D;
|
||||
color: #FFF;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: 'JetBrainsMono', monospace;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
code {
|
||||
color: inherit;
|
||||
background: none;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -43,8 +43,13 @@ export default {
|
||||
],
|
||||
content: `
|
||||
<ul data-type="taskList">
|
||||
<li data-type="taskItem" data-checked="true">A list item</li>
|
||||
<li data-type="taskItem" data-checked="false">And another one</li>
|
||||
<li data-type="taskItem" data-checked="true">flour
|
||||
<li data-type="taskItem" data-checked="false">baking powder</li>
|
||||
<li data-type="taskItem" data-checked="false">salt</li>
|
||||
<li data-type="taskItem" data-checked="false">sugar</li>
|
||||
<li data-type="taskItem" data-checked="false">milk</li>
|
||||
<li data-type="taskItem" data-checked="false">eggs</li>
|
||||
<li data-type="taskItem" data-checked="false">butter</li>
|
||||
</ul>
|
||||
`,
|
||||
})
|
||||
@@ -70,5 +75,9 @@ ul[data-type="taskList"] {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export class AnnotationItem {
|
||||
public id!: number
|
||||
|
||||
public text!: string
|
||||
|
||||
constructor(id: number, text: string) {
|
||||
this.id = id
|
||||
this.text = text
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Plugin, PluginKey } from 'prosemirror-state'
|
||||
import { AnnotationState } from './AnnotationState'
|
||||
|
||||
export const AnnotationPluginKey = new PluginKey('annotation')
|
||||
|
||||
export const AnnotationPlugin = (options: any) => new Plugin({
|
||||
key: AnnotationPluginKey,
|
||||
state: {
|
||||
init: AnnotationState.init,
|
||||
apply(transaction, oldState) {
|
||||
return oldState.apply(transaction)
|
||||
},
|
||||
},
|
||||
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
|
||||
},
|
||||
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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?]) => {},
|
||||
}
|
||||
|
||||
export const Annotation = Extension.create({
|
||||
name: 'annotation',
|
||||
|
||||
defaultOptions: <AnnotationOptions>{
|
||||
HTMLAttributes: {
|
||||
class: 'annotation',
|
||||
},
|
||||
onUpdate: decorations => decorations,
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
addAnnotation: (content: any): Command => ({ 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): Command => ({ dispatch, state }) => {
|
||||
if (dispatch) {
|
||||
dispatch(state.tr.setMeta(AnnotationPluginKey, { type: 'deleteAnnotation', id }))
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
AnnotationPlugin(this.options),
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface AllExtensions {
|
||||
Annotation: typeof Annotation,
|
||||
}
|
||||
}
|
||||
5
docs/src/demos/Experiments/Annotation/extension/index.ts
Normal file
5
docs/src/demos/Experiments/Annotation/extension/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Annotation } from './annotation'
|
||||
|
||||
export * from './annotation'
|
||||
|
||||
export default Annotation
|
||||
7
docs/src/demos/Experiments/Annotation/index.spec.js
Normal file
7
docs/src/demos/Experiments/Annotation/index.spec.js
Normal file
@@ -0,0 +1,7 @@
|
||||
context('/api/extensions/annotations', () => {
|
||||
before(() => {
|
||||
cy.visit('/api/extensions/annotations')
|
||||
})
|
||||
|
||||
// TODO: Write tests
|
||||
})
|
||||
87
docs/src/demos/Experiments/Annotation/index.vue
Normal file
87
docs/src/demos/Experiments/Annotation/index.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<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,64 @@
|
||||
// @ts-nocheck
|
||||
import { Extension } from '@tiptap/core'
|
||||
import {
|
||||
Plugin, PluginKey,
|
||||
} from 'prosemirror-state'
|
||||
|
||||
export interface CharacterLimitOptions {
|
||||
limit: number,
|
||||
}
|
||||
|
||||
export const CharacterLimit = Extension.create({
|
||||
name: 'characterLimit',
|
||||
|
||||
defaultOptions: <CharacterLimitOptions>{
|
||||
limit: 100,
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { options } = this
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
|
||||
key: new PluginKey('characterLimit'),
|
||||
|
||||
// state: {
|
||||
// init(_, config) {
|
||||
// // console.log(_, config)
|
||||
// // const length = config.doc.content.size
|
||||
|
||||
// // if (length > options.limit) {
|
||||
// // console.log('too long', options.limit, config)
|
||||
|
||||
// // const transaction = config.tr.insertText('', options.limit + 1, length)
|
||||
|
||||
// // return config.apply(transaction)
|
||||
// // }
|
||||
// },
|
||||
// apply() {
|
||||
// //
|
||||
// },
|
||||
// },
|
||||
|
||||
appendTransaction: (transactions, oldState, newState) => {
|
||||
const oldLength = oldState.doc.content.size
|
||||
const newLength = newState.doc.content.size
|
||||
|
||||
if (newLength > options.limit && newLength > oldLength) {
|
||||
const newTr = newState.tr
|
||||
newTr.insertText('', options.limit + 1, newLength)
|
||||
|
||||
return newTr
|
||||
}
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface AllExtensions {
|
||||
CharacterLimit: typeof CharacterLimit,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { CharacterLimit } from './CharacterLimit'
|
||||
|
||||
export * from './CharacterLimit'
|
||||
export default CharacterLimit
|
||||
73
docs/src/demos/Experiments/CharacterLimit/index.vue
Normal file
73
docs/src/demos/Experiments/CharacterLimit/index.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div>
|
||||
<editor-content :editor="editor" />
|
||||
<div>
|
||||
{{ characters }}/{{ limit }}
|
||||
</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 CharacterLimit from './extension'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EditorContent,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
editor: null,
|
||||
limit: 10,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.editor = new Editor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
CharacterLimit.configure({
|
||||
limit: this.limit,
|
||||
}),
|
||||
],
|
||||
content: `
|
||||
<p>
|
||||
This is a radically reduced version of tiptap. It has only support for a document, paragraphs and text. That’s it. It’s probably too much for real minimalists though.
|
||||
</p>
|
||||
<p>
|
||||
The paragraph extension is not really required, but you need at least one node. Sure, that node can be something different. You’ll mostly likely want to add a paragraph though.
|
||||
</p>
|
||||
`,
|
||||
})
|
||||
},
|
||||
|
||||
computed: {
|
||||
characters() {
|
||||
if (this.editor) {
|
||||
return this.editor.state.doc.content.size - 2
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.editor.destroy()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
/* Basic editor styles */
|
||||
.ProseMirror {
|
||||
> * + * {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
7
docs/src/demos/Experiments/Comments/index.spec.js
Normal file
7
docs/src/demos/Experiments/Comments/index.spec.js
Normal file
@@ -0,0 +1,7 @@
|
||||
context('/examples/annotations', () => {
|
||||
before(() => {
|
||||
cy.visit('/examples/annotations')
|
||||
})
|
||||
|
||||
// TODO: Write tests
|
||||
})
|
||||
145
docs/src/demos/Experiments/Comments/index.vue
Normal file
145
docs/src/demos/Experiments/Comments/index.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="editor">
|
||||
<h2>
|
||||
Original
|
||||
</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 }}
|
||||
|
||||
<button @click="deleteComment(comment.type.spec.data.id)">
|
||||
remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- <br>
|
||||
<h2>
|
||||
ProseMirror JSON from Y.js document
|
||||
</h2>
|
||||
{{ rawDocument }} -->
|
||||
|
||||
<br>
|
||||
<h2>
|
||||
Y.js document
|
||||
</h2>
|
||||
{{ json }}
|
||||
|
||||
<br>
|
||||
<h2>
|
||||
Mirror
|
||||
</h2>
|
||||
<editor-content :editor="anotherEditor" />
|
||||
</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 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'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EditorContent,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
editor: null,
|
||||
anotherEditor: null,
|
||||
comments: [],
|
||||
ydoc: null,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.ydoc = new Y.Doc()
|
||||
|
||||
this.editor = new Editor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Bold,
|
||||
Heading,
|
||||
Annotation.configure({
|
||||
onUpdate: items => { this.comments = items },
|
||||
}),
|
||||
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 you’re free to add more complex data, for example JSON from another tiptap instance. :-)
|
||||
</p>
|
||||
`,
|
||||
})
|
||||
|
||||
this.anotherEditor = new Editor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
// Annotation.configure({
|
||||
// onUpdate: items => { this.comments = items },
|
||||
// }),
|
||||
Collaboration.configure({
|
||||
document: this.ydoc,
|
||||
}),
|
||||
],
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
addComment() {
|
||||
const content = prompt('Comment', '')
|
||||
|
||||
this.editor.commands.addAnnotation(content)
|
||||
},
|
||||
deleteComment(id) {
|
||||
this.editor.commands.deleteAnnotation(id)
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
rawDocument() {
|
||||
return yDocToProsemirrorJSON(this.ydoc, 'default')
|
||||
},
|
||||
json() {
|
||||
return this.ydoc.toJSON()
|
||||
},
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.editor.destroy()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
/* Basic editor styles */
|
||||
.ProseMirror {
|
||||
> * + * {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
}
|
||||
|
||||
.annotation {
|
||||
background: #9DEF8F;
|
||||
}
|
||||
</style>
|
||||
98
docs/src/demos/Experiments/Linter/extension/Linter.ts
Normal file
98
docs/src/demos/Experiments/Linter/extension/Linter.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// @ts-nocheck
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Decoration, DecorationSet } from 'prosemirror-view'
|
||||
import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'
|
||||
|
||||
function renderIcon(issue) {
|
||||
const icon = document.createElement('div')
|
||||
|
||||
icon.className = 'lint-icon'
|
||||
icon.title = issue.message
|
||||
icon.issue = issue
|
||||
|
||||
return icon
|
||||
}
|
||||
|
||||
function runAllLinterPlugins(doc, plugins) {
|
||||
const decorations: [any?] = []
|
||||
|
||||
const results = plugins.map(LinterPlugin => {
|
||||
return new LinterPlugin(doc).scan().getResults()
|
||||
}).flat()
|
||||
|
||||
results.forEach(issue => {
|
||||
decorations.push(Decoration.inline(issue.from, issue.to, {
|
||||
class: 'problem',
|
||||
}),
|
||||
Decoration.widget(issue.from, renderIcon(issue)))
|
||||
})
|
||||
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
|
||||
export interface LinterOptions {
|
||||
plugins: [any],
|
||||
}
|
||||
|
||||
export const Linter = Extension.create({
|
||||
name: 'linter',
|
||||
|
||||
defaultOptions: <LinterOptions>{
|
||||
plugins: [],
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { plugins } = this.options
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('linter'),
|
||||
state: {
|
||||
init(_, { doc }) {
|
||||
return runAllLinterPlugins(doc, plugins)
|
||||
},
|
||||
apply(transaction, oldState) {
|
||||
return transaction.docChanged
|
||||
? runAllLinterPlugins(transaction.doc, plugins)
|
||||
: oldState
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state)
|
||||
},
|
||||
handleClick(view, _, event) {
|
||||
if (/lint-icon/.test(event.target.className)) {
|
||||
const { from, to } = event.target.issue
|
||||
|
||||
view.dispatch(
|
||||
view.state.tr
|
||||
.setSelection(TextSelection.create(view.state.doc, from, to))
|
||||
.scrollIntoView(),
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
},
|
||||
handleDoubleClick(view, _, event) {
|
||||
if (/lint-icon/.test(event.target.className)) {
|
||||
const prob = event.target.issue
|
||||
|
||||
if (prob.fix) {
|
||||
prob.fix(view)
|
||||
view.focus()
|
||||
return true
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface AllExtensions {
|
||||
Linter: typeof Linter,
|
||||
}
|
||||
}
|
||||
23
docs/src/demos/Experiments/Linter/extension/LinterPlugin.ts
Normal file
23
docs/src/demos/Experiments/Linter/extension/LinterPlugin.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// @ts-nocheck
|
||||
export default class LinterPlugin {
|
||||
protected doc
|
||||
|
||||
private results = []
|
||||
|
||||
constructor(doc: any) {
|
||||
this.doc = doc
|
||||
}
|
||||
|
||||
record(message: string, from: number, to: number, fix?: null) {
|
||||
this.results.push({
|
||||
message,
|
||||
from,
|
||||
to,
|
||||
fix,
|
||||
})
|
||||
}
|
||||
|
||||
getResults() {
|
||||
return this.results
|
||||
}
|
||||
}
|
||||
8
docs/src/demos/Experiments/Linter/extension/index.ts
Normal file
8
docs/src/demos/Experiments/Linter/extension/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Linter } from './Linter'
|
||||
|
||||
export * from './Linter'
|
||||
export default Linter
|
||||
|
||||
export { BadWords } from './plugins/BadWords'
|
||||
export { Punctuation } from './plugins/Punctuation'
|
||||
export { HeadingLevel } from './plugins/HeadingLevel'
|
||||
@@ -0,0 +1,26 @@
|
||||
// @ts-nocheck
|
||||
import LinterPlugin from '../LinterPlugin'
|
||||
|
||||
export class BadWords extends LinterPlugin {
|
||||
|
||||
public regex = /\b(obviously|clearly|evidently|simply)\b/ig
|
||||
|
||||
scan() {
|
||||
this.doc.descendants((node: any, position: any) => {
|
||||
if (!node.isText) {
|
||||
return
|
||||
}
|
||||
|
||||
const matches = this.regex.exec(node.text)
|
||||
|
||||
if (matches) {
|
||||
this.record(
|
||||
`Try not to say '${matches[0]}'`,
|
||||
position + matches.index, position + matches.index + matches[0].length,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// @ts-nocheck
|
||||
import LinterPlugin from '../LinterPlugin'
|
||||
|
||||
export class HeadingLevel extends LinterPlugin {
|
||||
fixHeader(level) {
|
||||
return function ({ state, dispatch }) {
|
||||
dispatch(state.tr.setNodeMarkup(this.from - 1, null, { level }))
|
||||
}
|
||||
}
|
||||
|
||||
scan() {
|
||||
let lastHeadLevel = null
|
||||
|
||||
this.doc.descendants((node, position) => {
|
||||
if (node.type.name === 'heading') {
|
||||
// Check whether heading levels fit under the current level
|
||||
const { level } = node.attrs
|
||||
|
||||
if (lastHeadLevel != null && level > lastHeadLevel + 1) {
|
||||
this.record(`Heading too small (${level} under ${lastHeadLevel})`,
|
||||
position + 1, position + 1 + node.content.size,
|
||||
this.fixHeader(lastHeadLevel + 1))
|
||||
}
|
||||
lastHeadLevel = level
|
||||
}
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// @ts-nocheck
|
||||
import LinterPlugin from '../LinterPlugin'
|
||||
|
||||
export class Punctuation extends LinterPlugin {
|
||||
public regex = / ([,.!?:]) ?/g
|
||||
|
||||
fix(replacement: any) {
|
||||
return function ({ state, dispatch }) {
|
||||
dispatch(
|
||||
state.tr.replaceWith(
|
||||
this.from, this.to,
|
||||
state.schema.text(replacement),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
scan() {
|
||||
this.doc.descendants((node, position) => {
|
||||
if (!node.isText) {
|
||||
return
|
||||
}
|
||||
|
||||
const matches = this.regex.exec(node.text)
|
||||
|
||||
if (matches) {
|
||||
this.record(
|
||||
'Suspicious spacing around punctuation',
|
||||
position + matches.index, position + matches.index + matches[0].length,
|
||||
this.fix(`${matches[1]} `),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
}
|
||||
96
docs/src/demos/Experiments/Linter/index.vue
Normal file
96
docs/src/demos/Experiments/Linter/index.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<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 Linter, { BadWords, Punctuation, HeadingLevel } from './extension'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EditorContent,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
editor: null,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.editor = new Editor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Heading,
|
||||
Text,
|
||||
Linter.configure({
|
||||
plugins: [
|
||||
BadWords,
|
||||
Punctuation,
|
||||
HeadingLevel,
|
||||
],
|
||||
}),
|
||||
],
|
||||
content: `
|
||||
<h1>
|
||||
Lint example
|
||||
</h1>
|
||||
<p>
|
||||
This is a sentence ,but the comma clearly isn't in the right place.
|
||||
</p>
|
||||
<h3>
|
||||
Too-minor header
|
||||
</h3>
|
||||
<p>
|
||||
You can hover over the icons on the right to see what the problem is, click them to select the relevant text, and, obviously, double-click them to automatically fix it (if supported).
|
||||
</ul>
|
||||
`,
|
||||
})
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.editor.destroy()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.problem {
|
||||
background: #fdd;
|
||||
border-bottom: 1px solid #f22;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.lint-icon {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
cursor: pointer;
|
||||
border-radius: 100px;
|
||||
background: #f22;
|
||||
color: white;
|
||||
font-family: times, georgia, serif;
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
width: 1.1em;
|
||||
height: 1.1em;
|
||||
text-align: center;
|
||||
padding-left: .5px;
|
||||
line-height: 1.1em
|
||||
}
|
||||
|
||||
.lint-icon:before {
|
||||
content: "!";
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
padding-right: 20px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user