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:
Philipp Kühn
2021-01-19 22:33:22 +01:00
46 changed files with 1207 additions and 66 deletions

View File

@@ -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>

View File

@@ -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, thisll 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>

View File

@@ -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>

View File

@@ -0,0 +1,10 @@
export class AnnotationItem {
public id!: number
public text!: string
constructor(id: number, text: string) {
this.id = id
this.text = text
}
}

View File

@@ -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
},
},
})

View File

@@ -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)
}
}

View File

@@ -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,
}
}

View File

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

View File

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

View 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 youre 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>

View File

@@ -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,
}
}

View File

@@ -0,0 +1,4 @@
import { CharacterLimit } from './CharacterLimit'
export * from './CharacterLimit'
export default CharacterLimit

View 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. Thats it. Its 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. Youll 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>

View File

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

View 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 youre 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>

View 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,
}
}

View 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
}
}

View 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'

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View 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>