add experiment demos
This commit is contained in:
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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,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,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { CollaborationAnnotation } from './collaboration-annotation'
|
||||||
|
|
||||||
|
export * from './collaboration-annotation'
|
||||||
|
|
||||||
|
export default CollaborationAnnotation
|
||||||
15
demos/src/Experiments/CollaborationAnnotation/Vue/index.html
Normal file
15
demos/src/Experiments/CollaborationAnnotation/Vue/index.html
Normal 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>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
context('/demos/Experiments/Annotation', () => {
|
||||||
|
before(() => {
|
||||||
|
cy.visit('/demos/Experiments/Annotation')
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO: Write tests
|
||||||
|
})
|
||||||
149
demos/src/Experiments/CollaborationAnnotation/Vue/index.vue
Normal file
149
demos/src/Experiments/CollaborationAnnotation/Vue/index.vue
Normal 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 you’re 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>
|
||||||
112
demos/src/Experiments/Commands/Vue/CommandsList.vue
Normal file
112
demos/src/Experiments/Commands/Vue/CommandsList.vue
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<div class="items">
|
||||||
|
<button
|
||||||
|
class="item"
|
||||||
|
:class="{ 'is-selected': index === selectedIndex }"
|
||||||
|
v-for="(item, index) in items"
|
||||||
|
:key="index"
|
||||||
|
@click="selectItem(index)"
|
||||||
|
>
|
||||||
|
{{ item.title }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
command: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selectedIndex: 0,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
items() {
|
||||||
|
this.selectedIndex = 0
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
onKeyDown({ event }) {
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
this.upHandler()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
this.downHandler()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
this.enterHandler()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
|
||||||
|
upHandler() {
|
||||||
|
this.selectedIndex = ((this.selectedIndex + this.items.length) - 1) % this.items.length
|
||||||
|
},
|
||||||
|
|
||||||
|
downHandler() {
|
||||||
|
this.selectedIndex = (this.selectedIndex + 1) % this.items.length
|
||||||
|
},
|
||||||
|
|
||||||
|
enterHandler() {
|
||||||
|
this.selectItem(this.selectedIndex)
|
||||||
|
},
|
||||||
|
|
||||||
|
selectItem(index) {
|
||||||
|
const item = this.items[index]
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
this.command(item)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.items {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: white;
|
||||||
|
color: rgba(black, 0.8);
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(0, 0, 0, 0.1),
|
||||||
|
0px 10px 20px rgba(0, 0, 0, 0.1),
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
|
||||||
|
&.is-selected,
|
||||||
|
&:hover {
|
||||||
|
color: #A975FF;
|
||||||
|
background: rgba(#A975FF, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
25
demos/src/Experiments/Commands/Vue/commands.js
Normal file
25
demos/src/Experiments/Commands/Vue/commands.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
import Suggestion from '@tiptap/suggestion'
|
||||||
|
|
||||||
|
export default Extension.create({
|
||||||
|
name: 'mention',
|
||||||
|
|
||||||
|
defaultOptions: {
|
||||||
|
suggestion: {
|
||||||
|
char: '/',
|
||||||
|
startOfLine: false,
|
||||||
|
command: ({ editor, range, props }) => {
|
||||||
|
props.command({ editor, range })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
Suggestion({
|
||||||
|
editor: this.editor,
|
||||||
|
...this.options.suggestion,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
15
demos/src/Experiments/Commands/Vue/index.html
Normal file
15
demos/src/Experiments/Commands/Vue/index.html
Normal 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/Commands', source)
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
155
demos/src/Experiments/Commands/Vue/index.vue
Normal file
155
demos/src/Experiments/Commands/Vue/index.vue
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="editor">
|
||||||
|
<editor-content :editor="editor" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import tippy from 'tippy.js'
|
||||||
|
import { Editor, EditorContent, VueRenderer } from '@tiptap/vue-3'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import Commands from './commands'
|
||||||
|
import CommandsList from './CommandsList.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
EditorContent,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
editor: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.editor = new Editor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Commands.configure({
|
||||||
|
suggestion: {
|
||||||
|
items: query => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'H1',
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setNode('heading', { level: 1 })
|
||||||
|
.run()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'H2',
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setNode('heading', { level: 2 })
|
||||||
|
.run()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'bold',
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setMark('bold')
|
||||||
|
.run()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'italic',
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setMark('italic')
|
||||||
|
.run()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
].filter(item => item.title.toLowerCase().startsWith(query.toLowerCase())).slice(0, 10)
|
||||||
|
},
|
||||||
|
render: () => {
|
||||||
|
let component
|
||||||
|
let popup
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart: props => {
|
||||||
|
component = new VueRenderer(CommandsList, {
|
||||||
|
// using vue 2:
|
||||||
|
// parent: this,
|
||||||
|
// propsData: props,
|
||||||
|
props,
|
||||||
|
editor: props.editor,
|
||||||
|
})
|
||||||
|
|
||||||
|
popup = tippy('body', {
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
appendTo: () => document.body,
|
||||||
|
content: component.element,
|
||||||
|
showOnCreate: true,
|
||||||
|
interactive: true,
|
||||||
|
trigger: 'manual',
|
||||||
|
placement: 'bottom-start',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onUpdate(props) {
|
||||||
|
component.updateProps(props)
|
||||||
|
|
||||||
|
popup[0].setProps({
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onKeyDown(props) {
|
||||||
|
if (props.event.key === 'Escape') {
|
||||||
|
popup[0].hide()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return component.ref?.onKeyDown(props)
|
||||||
|
},
|
||||||
|
onExit() {
|
||||||
|
popup[0].destroy()
|
||||||
|
component.destroy()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content: `
|
||||||
|
<p>Type a slash</p>
|
||||||
|
<p></p>
|
||||||
|
<p></p>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeDestroy() {
|
||||||
|
this.editor.destroy()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.ProseMirror {
|
||||||
|
> * + * {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention {
|
||||||
|
color: #A975FF;
|
||||||
|
background-color: rgba(#A975FF, 0.1);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
33
demos/src/Experiments/Details/Vue/details-summary.ts
Normal file
33
demos/src/Experiments/Details/Vue/details-summary.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Node } from '@tiptap/core'
|
||||||
|
|
||||||
|
export interface DetailsSummaryOptions {
|
||||||
|
HTMLAttributes: {
|
||||||
|
[key: string]: any
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Node.create<DetailsSummaryOptions>({
|
||||||
|
name: 'detailsSummary',
|
||||||
|
|
||||||
|
content: 'text*',
|
||||||
|
|
||||||
|
marks: '',
|
||||||
|
|
||||||
|
group: 'block',
|
||||||
|
|
||||||
|
isolating: true,
|
||||||
|
|
||||||
|
defaultOptions: {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{
|
||||||
|
tag: 'summary',
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML() {
|
||||||
|
return ['summary', 0]
|
||||||
|
},
|
||||||
|
})
|
||||||
107
demos/src/Experiments/Details/Vue/details.ts
Normal file
107
demos/src/Experiments/Details/Vue/details.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { Node, mergeAttributes } from '@tiptap/core'
|
||||||
|
|
||||||
|
export interface DetailsOptions {
|
||||||
|
HTMLAttributes: {
|
||||||
|
[key: string]: any
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
details: {
|
||||||
|
/**
|
||||||
|
* Set a details node
|
||||||
|
*/
|
||||||
|
setDetails: () => ReturnType,
|
||||||
|
/**
|
||||||
|
* Toggle a details node
|
||||||
|
*/
|
||||||
|
toggleDetails: () => ReturnType,
|
||||||
|
/**
|
||||||
|
* Unset a details node
|
||||||
|
*/
|
||||||
|
unsetDetails: () => ReturnType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Node.create<DetailsOptions>({
|
||||||
|
name: 'details',
|
||||||
|
|
||||||
|
content: 'detailsSummary block+',
|
||||||
|
|
||||||
|
group: 'block',
|
||||||
|
|
||||||
|
// defining: true,
|
||||||
|
|
||||||
|
defaultOptions: {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'details',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'div[data-type="details"]',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['details', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ({ HTMLAttributes }) => {
|
||||||
|
const item = document.createElement('div')
|
||||||
|
item.setAttribute('data-type', 'details')
|
||||||
|
|
||||||
|
const toggle = document.createElement('div')
|
||||||
|
toggle.setAttribute('data-type', 'detailsToggle')
|
||||||
|
item.append(toggle)
|
||||||
|
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.entries(HTMLAttributes).forEach(([key, value]) => {
|
||||||
|
item.setAttribute(key, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
dom: item,
|
||||||
|
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')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
15
demos/src/Experiments/Details/Vue/index.html
Normal file
15
demos/src/Experiments/Details/Vue/index.html
Normal 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/Details', source)
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
98
demos/src/Experiments/Details/Vue/index.vue
Normal file
98
demos/src/Experiments/Details/Vue/index.vue
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Editor, EditorContent } from '@tiptap/vue-3'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import Details from './details'
|
||||||
|
import DetailsSummary from './details-summary'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
EditorContent,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
editor: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.editor = new Editor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Details,
|
||||||
|
DetailsSummary,
|
||||||
|
],
|
||||||
|
content: `
|
||||||
|
<p>Here is a details list:</p>
|
||||||
|
<details>
|
||||||
|
<summary>An open details tag</summary>
|
||||||
|
<p>More info about the details.</p>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>A closed details tag</summary>
|
||||||
|
<p>More info about the details.</p>
|
||||||
|
</details>
|
||||||
|
<p>That’s it.</p>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeDestroy() {
|
||||||
|
this.editor.destroy()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.ProseMirror {
|
||||||
|
> * + * {
|
||||||
|
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>
|
||||||
78
demos/src/Experiments/Embeds/Vue/iframe.ts
Normal file
78
demos/src/Experiments/Embeds/Vue/iframe.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Node } from '@tiptap/core'
|
||||||
|
|
||||||
|
export interface IframeOptions {
|
||||||
|
allowFullscreen: boolean,
|
||||||
|
HTMLAttributes: {
|
||||||
|
[key: string]: any
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
iframe: {
|
||||||
|
/**
|
||||||
|
* Add an iframe
|
||||||
|
*/
|
||||||
|
setIframe: (options: { src: string }) => ReturnType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Node.create({
|
||||||
|
name: 'iframe',
|
||||||
|
|
||||||
|
group: 'block',
|
||||||
|
|
||||||
|
atom: true,
|
||||||
|
|
||||||
|
defaultOptions: <IframeOptions>{
|
||||||
|
allowFullscreen: true,
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'iframe-wrapper',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
frameborder: {
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
allowfullscreen: {
|
||||||
|
default: this.options.allowFullscreen,
|
||||||
|
parseHTML: () => {
|
||||||
|
return {
|
||||||
|
allowfullscreen: this.options.allowFullscreen,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{
|
||||||
|
tag: 'iframe',
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]]
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setIframe: (options: { src: string }) => ({ tr, dispatch }) => {
|
||||||
|
const { selection } = tr
|
||||||
|
const node = this.type.create(options)
|
||||||
|
|
||||||
|
if (dispatch) {
|
||||||
|
tr.replaceRangeWith(selection.from, selection.to, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
15
demos/src/Experiments/Embeds/Vue/index.html
Normal file
15
demos/src/Experiments/Embeds/Vue/index.html
Normal 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/Embeds', source)
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
84
demos/src/Experiments/Embeds/Vue/index.vue
Normal file
84
demos/src/Experiments/Embeds/Vue/index.vue
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="editor">
|
||||||
|
<button @click="addIframe">
|
||||||
|
add iframe
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<editor-content :editor="editor" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Editor, EditorContent } from '@tiptap/vue-3'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import Iframe from './iframe'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
EditorContent,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
editor: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.editor = new Editor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Iframe,
|
||||||
|
],
|
||||||
|
content: `
|
||||||
|
<p>Here is an exciting video:</p>
|
||||||
|
<iframe src="https://www.youtube.com/embed/XIMLoLxmTDw" frameborder="0" allowfullscreen></iframe>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
addIframe() {
|
||||||
|
const url = window.prompt('URL')
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
this.editor.chain().focus().setIframe({ src: url }).run()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeDestroy() {
|
||||||
|
this.editor.destroy()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use "sass:math";
|
||||||
|
.ProseMirror {
|
||||||
|
> * + * {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.iframe-wrapper {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: math.div(100,16)*9%;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
|
||||||
|
&.ProseMirror-selectednode {
|
||||||
|
outline: 3px solid #68CEF8;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
200
demos/src/Experiments/Figure/Vue/figure.ts
Normal file
200
demos/src/Experiments/Figure/Vue/figure.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import {
|
||||||
|
Node,
|
||||||
|
nodeInputRule,
|
||||||
|
mergeAttributes,
|
||||||
|
findChildrenInRange,
|
||||||
|
Tracker,
|
||||||
|
} from '@tiptap/core'
|
||||||
|
|
||||||
|
export interface FigureOptions {
|
||||||
|
HTMLAttributes: Record<string, any>,
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
figure: {
|
||||||
|
/**
|
||||||
|
* Add a figure element
|
||||||
|
*/
|
||||||
|
setFigure: (options: {
|
||||||
|
src: string,
|
||||||
|
alt?: string,
|
||||||
|
title?: string,
|
||||||
|
caption?: string,
|
||||||
|
}) => ReturnType,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an image to a figure
|
||||||
|
*/
|
||||||
|
imageToFigure: () => ReturnType,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a figure to an image
|
||||||
|
*/
|
||||||
|
figureToImage: () => ReturnType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inputRegex = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/
|
||||||
|
|
||||||
|
export const Figure = Node.create<FigureOptions>({
|
||||||
|
name: 'figure',
|
||||||
|
|
||||||
|
defaultOptions: {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
},
|
||||||
|
|
||||||
|
group: 'block',
|
||||||
|
|
||||||
|
content: 'inline*',
|
||||||
|
|
||||||
|
draggable: true,
|
||||||
|
|
||||||
|
isolating: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: element => {
|
||||||
|
return {
|
||||||
|
src: element.querySelector('img')?.getAttribute('src'),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
alt: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: element => {
|
||||||
|
return {
|
||||||
|
alt: element.querySelector('img')?.getAttribute('alt'),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
title: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: element => {
|
||||||
|
return {
|
||||||
|
title: element.querySelector('img')?.getAttribute('title'),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'figure',
|
||||||
|
contentElement: 'figcaption',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
'figure', this.options.HTMLAttributes,
|
||||||
|
['img', mergeAttributes(HTMLAttributes, { draggable: false, contenteditable: false })],
|
||||||
|
['figcaption', 0],
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setFigure: ({ caption, ...attrs }) => ({ chain }) => {
|
||||||
|
return chain()
|
||||||
|
.insertContent({
|
||||||
|
type: this.name,
|
||||||
|
attrs,
|
||||||
|
content: caption
|
||||||
|
? [{ type: 'text', text: caption }]
|
||||||
|
: [],
|
||||||
|
})
|
||||||
|
// set cursor at end of caption field
|
||||||
|
.command(({ tr, commands }) => {
|
||||||
|
const { doc, selection } = tr
|
||||||
|
const position = doc.resolve(selection.to - 2).end()
|
||||||
|
|
||||||
|
return commands.setTextSelection(position)
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
},
|
||||||
|
|
||||||
|
imageToFigure: () => ({ tr, commands }) => {
|
||||||
|
const { doc, selection } = tr
|
||||||
|
const { from, to } = selection
|
||||||
|
const images = findChildrenInRange(doc, { from, to }, node => node.type.name === 'image')
|
||||||
|
|
||||||
|
if (!images.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracker = new Tracker(tr)
|
||||||
|
|
||||||
|
return commands.forEach(images, ({ node, pos }) => {
|
||||||
|
const mapResult = tracker.map(pos)
|
||||||
|
|
||||||
|
if (mapResult.deleted) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = {
|
||||||
|
from: mapResult.position,
|
||||||
|
to: mapResult.position + node.nodeSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands.insertContentAt(range, {
|
||||||
|
type: this.name,
|
||||||
|
attrs: {
|
||||||
|
src: node.attrs.src,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
figureToImage: () => ({ tr, commands }) => {
|
||||||
|
const { doc, selection } = tr
|
||||||
|
const { from, to } = selection
|
||||||
|
const figures = findChildrenInRange(doc, { from, to }, node => node.type.name === this.name)
|
||||||
|
|
||||||
|
if (!figures.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracker = new Tracker(tr)
|
||||||
|
|
||||||
|
return commands.forEach(figures, ({ node, pos }) => {
|
||||||
|
const mapResult = tracker.map(pos)
|
||||||
|
|
||||||
|
if (mapResult.deleted) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = {
|
||||||
|
from: mapResult.position,
|
||||||
|
to: mapResult.position + node.nodeSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands.insertContentAt(range, {
|
||||||
|
type: 'image',
|
||||||
|
attrs: {
|
||||||
|
src: node.attrs.src,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addInputRules() {
|
||||||
|
return [
|
||||||
|
nodeInputRule(inputRegex, this.type, match => {
|
||||||
|
const [, alt, src, title] = match
|
||||||
|
|
||||||
|
return { src, alt, title }
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
15
demos/src/Experiments/Figure/Vue/index.html
Normal file
15
demos/src/Experiments/Figure/Vue/index.html
Normal 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/Figure', source)
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
115
demos/src/Experiments/Figure/Vue/index.vue
Normal file
115
demos/src/Experiments/Figure/Vue/index.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="editor">
|
||||||
|
<button @click="addFigure">
|
||||||
|
figure
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="editor.chain().focus().imageToFigure().run()"
|
||||||
|
:disabled="!editor.can().imageToFigure()"
|
||||||
|
>
|
||||||
|
image to figure
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="editor.chain().focus().figureToImage().run()"
|
||||||
|
:disabled="!editor.can().figureToImage()"
|
||||||
|
>
|
||||||
|
figure to image
|
||||||
|
</button>
|
||||||
|
<editor-content :editor="editor" />
|
||||||
|
|
||||||
|
<h2>HTML</h2>
|
||||||
|
{{ editor.getHTML() }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Editor, EditorContent } from '@tiptap/vue-3'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import Image from '@tiptap/extension-image'
|
||||||
|
import { Figure } from './figure'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
EditorContent,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
editor: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
addFigure() {
|
||||||
|
const url = window.prompt('URL')
|
||||||
|
const caption = window.prompt('caption')
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
this.editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.setFigure({ src: url, caption })
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.editor = new Editor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Figure,
|
||||||
|
Image,
|
||||||
|
],
|
||||||
|
content: `
|
||||||
|
<p>Figure + Figcaption</p>
|
||||||
|
<figure>
|
||||||
|
<img src="https://source.unsplash.com/8xznAGy4HcY/800x400" alt="Random photo of something" title="Who’s dat?">
|
||||||
|
<figcaption>
|
||||||
|
<p>Amazing caption</p>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
<img src="https://source.unsplash.com/K9QHL52rE2k/800x400">
|
||||||
|
<img src="https://source.unsplash.com/8xznAGy4HcY/800x400">
|
||||||
|
<img src="https://source.unsplash.com/K9QHL52rE2k/800x400">
|
||||||
|
<p>That’s it.</p>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeDestroy() {
|
||||||
|
this.editor.destroy()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.ProseMirror {
|
||||||
|
> * + * {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
figure {
|
||||||
|
max-width: 25rem;
|
||||||
|
border: 3px solid #0D0D0D;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
figcaption {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 2px dashed #0D0D0D20;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
max-width: min(100%, 25rem);
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
27
demos/src/Experiments/GenericFigure/Vue/figcaption.ts
Normal file
27
demos/src/Experiments/GenericFigure/Vue/figcaption.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Node, mergeAttributes } from '@tiptap/core'
|
||||||
|
|
||||||
|
export const Figcaption = Node.create({
|
||||||
|
name: 'figcaption',
|
||||||
|
|
||||||
|
defaultOptions: {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
},
|
||||||
|
|
||||||
|
content: 'inline*',
|
||||||
|
|
||||||
|
selectable: false,
|
||||||
|
|
||||||
|
draggable: false,
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'figcaption',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['figcaption', mergeAttributes(HTMLAttributes), 0]
|
||||||
|
},
|
||||||
|
})
|
||||||
56
demos/src/Experiments/GenericFigure/Vue/figure.ts
Normal file
56
demos/src/Experiments/GenericFigure/Vue/figure.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Node, mergeAttributes } from '@tiptap/core'
|
||||||
|
import { Plugin } from 'prosemirror-state'
|
||||||
|
|
||||||
|
export const Figure = Node.create({
|
||||||
|
name: 'figure',
|
||||||
|
|
||||||
|
defaultOptions: {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
},
|
||||||
|
|
||||||
|
group: 'block',
|
||||||
|
|
||||||
|
content: 'block figcaption',
|
||||||
|
|
||||||
|
draggable: true,
|
||||||
|
|
||||||
|
isolating: true,
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: `figure[data-type="${this.name}"]`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['figure', mergeAttributes(HTMLAttributes, { 'data-type': this.name }), 0]
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
props: {
|
||||||
|
handleDOMEvents: {
|
||||||
|
// prevent dragging nodes out of the figure
|
||||||
|
dragstart: (view, event) => {
|
||||||
|
if (!event.target) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = view.posAtDOM(event.target as HTMLElement, 0)
|
||||||
|
const $pos = view.state.doc.resolve(pos)
|
||||||
|
|
||||||
|
if ($pos.parent.type === this.type) {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
15
demos/src/Experiments/GenericFigure/Vue/index.html
Normal file
15
demos/src/Experiments/GenericFigure/Vue/index.html
Normal 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/GenericFigure', source)
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
329
demos/src/Experiments/GenericFigure/Vue/index.vue
Normal file
329
demos/src/Experiments/GenericFigure/Vue/index.vue
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="editor">
|
||||||
|
<button @click="addCapturedTable">
|
||||||
|
add table
|
||||||
|
</button>
|
||||||
|
<button @click="addCapturedImage">
|
||||||
|
add image
|
||||||
|
</button>
|
||||||
|
<button @click="removeCapturedTable">
|
||||||
|
remove table
|
||||||
|
</button>
|
||||||
|
<button @click="removeCapturedImage">
|
||||||
|
remove image
|
||||||
|
</button>
|
||||||
|
<editor-content :editor="editor" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Editor, EditorContent } from '@tiptap/vue-3'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import Image from '@tiptap/extension-image'
|
||||||
|
import Table from '@tiptap/extension-table'
|
||||||
|
import TableRow from '@tiptap/extension-table-row'
|
||||||
|
import TableCell from '@tiptap/extension-table-cell'
|
||||||
|
import TableHeader from '@tiptap/extension-table-header'
|
||||||
|
import { Figure } from './figure'
|
||||||
|
import { Figcaption } from './figcaption'
|
||||||
|
|
||||||
|
const ImageFigure = Figure.extend({
|
||||||
|
name: 'capturedImage',
|
||||||
|
content: 'figcaption image',
|
||||||
|
})
|
||||||
|
|
||||||
|
const TableFigure = Figure.extend({
|
||||||
|
name: 'capturedTable',
|
||||||
|
content: 'figcaption table',
|
||||||
|
})
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
EditorContent,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
editor: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
addCapturedImage() {
|
||||||
|
this.editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContent({
|
||||||
|
type: 'capturedImage',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'figcaption',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'image caption',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
attrs: {
|
||||||
|
src: 'https://source.unsplash.com/K9QHL52rE2k/800x400',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
},
|
||||||
|
|
||||||
|
addCapturedTable() {
|
||||||
|
this.editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContent({
|
||||||
|
type: 'capturedTable',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'figcaption',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'table caption',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'table',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tableRow',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tableCell',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'cell 1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tableCell',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'cell 2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
},
|
||||||
|
|
||||||
|
removeCapturedTable() {
|
||||||
|
this.editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteNode('capturedTable')
|
||||||
|
.run()
|
||||||
|
},
|
||||||
|
|
||||||
|
removeCapturedImage() {
|
||||||
|
this.editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteNode('capturedImage')
|
||||||
|
.run()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.editor = new Editor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Table,
|
||||||
|
TableRow,
|
||||||
|
TableHeader,
|
||||||
|
TableCell,
|
||||||
|
ImageFigure,
|
||||||
|
TableFigure,
|
||||||
|
Figcaption,
|
||||||
|
Image,
|
||||||
|
],
|
||||||
|
content: `
|
||||||
|
<p>Some text</p>
|
||||||
|
<figure data-type="capturedImage">
|
||||||
|
<figcaption>
|
||||||
|
Image caption
|
||||||
|
</figcaption>
|
||||||
|
<img src="https://source.unsplash.com/8xznAGy4HcY/800x400" alt="Random photo of something" title="Who’s dat?">
|
||||||
|
</figure>
|
||||||
|
<p>Some text</p>
|
||||||
|
<img src="https://source.unsplash.com/K9QHL52rE2k/800x400">
|
||||||
|
<p>Some text</p>
|
||||||
|
<figure data-type="capturedTable">
|
||||||
|
<figcaption>
|
||||||
|
Table caption
|
||||||
|
</figcaption>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th colspan="3">Description</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Cyndi Lauper</td>
|
||||||
|
<td>singer</td>
|
||||||
|
<td>songwriter</td>
|
||||||
|
<td>actress</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Philipp Kühn</td>
|
||||||
|
<td>designer</td>
|
||||||
|
<td>developer</td>
|
||||||
|
<td>maker</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Hans Pagel</td>
|
||||||
|
<td>wrote this</td>
|
||||||
|
<td colspan="2">that’s it</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</figure>
|
||||||
|
<p>Some text</p>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th colspan="3">Description</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Cyndi Lauper</td>
|
||||||
|
<td>singer</td>
|
||||||
|
<td>songwriter</td>
|
||||||
|
<td>actress</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Philipp Kühn</td>
|
||||||
|
<td>designer</td>
|
||||||
|
<td>developer</td>
|
||||||
|
<td>maker</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Hans Pagel</td>
|
||||||
|
<td>wrote this</td>
|
||||||
|
<td colspan="2">that’s it</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeDestroy() {
|
||||||
|
this.editor.destroy()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.ProseMirror {
|
||||||
|
> * + * {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
figure {
|
||||||
|
max-width: 25rem;
|
||||||
|
border: 3px solid #0D0D0D;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
figcaption {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 2px dashed #0D0D0D20;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
max-width: min(100%, 25rem);
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
min-width: 1em;
|
||||||
|
border: 2px solid #ced4da;
|
||||||
|
padding: 3px 5px;
|
||||||
|
vertical-align: top;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
background-color: #f1f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedCell:after {
|
||||||
|
z-index: 2;
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
left: 0; right: 0; top: 0; bottom: 0;
|
||||||
|
background: rgba(200, 200, 255, 0.4);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
right: -2px;
|
||||||
|
top: 0;
|
||||||
|
bottom: -2px;
|
||||||
|
width: 4px;
|
||||||
|
background-color: #adf;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
163
demos/src/Experiments/GlobalDragHandle/Vue/DragHandle.js
Normal file
163
demos/src/Experiments/GlobalDragHandle/Vue/DragHandle.js
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
import { NodeSelection, Plugin } from 'prosemirror-state'
|
||||||
|
import { serializeForClipboard } from 'prosemirror-view/src/clipboard'
|
||||||
|
|
||||||
|
function removeNode(node) {
|
||||||
|
node.parentNode.removeChild(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
function absoluteRect(node) {
|
||||||
|
const data = node.getBoundingClientRect()
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: data.top,
|
||||||
|
left: data.left,
|
||||||
|
width: data.width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Extension.create({
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
function blockPosAtCoords(coords, view) {
|
||||||
|
const pos = view.posAtCoords(coords)
|
||||||
|
let node = view.domAtPos(pos.pos)
|
||||||
|
|
||||||
|
node = node.node
|
||||||
|
|
||||||
|
while (node && node.parentNode) {
|
||||||
|
if (node.parentNode?.classList?.contains('ProseMirror')) { // todo
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
node = node.parentNode
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node && node.nodeType === 1) {
|
||||||
|
const desc = view.docView.nearestDesc(node, true)
|
||||||
|
|
||||||
|
if (!(!desc || desc === view.docView)) {
|
||||||
|
return desc.posBefore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragStart(e, view) {
|
||||||
|
view.composing = true
|
||||||
|
|
||||||
|
if (!e.dataTransfer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = { left: e.clientX + 50, top: e.clientY }
|
||||||
|
const pos = blockPosAtCoords(coords, view)
|
||||||
|
|
||||||
|
if (pos != null) {
|
||||||
|
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)))
|
||||||
|
|
||||||
|
const slice = view.state.selection.content()
|
||||||
|
|
||||||
|
// console.log({
|
||||||
|
// from: view.nodeDOM(view.state.selection.from),
|
||||||
|
// to: view.nodeDOM(view.state.selection.to),
|
||||||
|
// })
|
||||||
|
const { dom, text } = serializeForClipboard(view, slice)
|
||||||
|
|
||||||
|
e.dataTransfer.clearData()
|
||||||
|
e.dataTransfer.setData('text/html', dom.innerHTML)
|
||||||
|
e.dataTransfer.setData('text/plain', text)
|
||||||
|
|
||||||
|
const el = document.querySelector('.ProseMirror-selectednode')
|
||||||
|
e.dataTransfer?.setDragImage(el, 0, 0)
|
||||||
|
|
||||||
|
view.dragging = { slice, move: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dropElement
|
||||||
|
const WIDTH = 28
|
||||||
|
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
view(editorView) {
|
||||||
|
const element = document.createElement('div')
|
||||||
|
|
||||||
|
element.draggable = 'true'
|
||||||
|
element.classList.add('global-drag-handle')
|
||||||
|
element.addEventListener('dragstart', e => dragStart(e, editorView))
|
||||||
|
dropElement = element
|
||||||
|
document.body.appendChild(dropElement)
|
||||||
|
|
||||||
|
return {
|
||||||
|
// update(view, prevState) {
|
||||||
|
// },
|
||||||
|
destroy() {
|
||||||
|
removeNode(dropElement)
|
||||||
|
dropElement = null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
handleDrop(view, event, slice, moved) {
|
||||||
|
if (moved) {
|
||||||
|
// setTimeout(() => {
|
||||||
|
// console.log('remove selection')
|
||||||
|
// view.dispatch(view.state.tr.deleteSelection())
|
||||||
|
// }, 50)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// handlePaste() {
|
||||||
|
// alert(2)
|
||||||
|
// },
|
||||||
|
handleDOMEvents: {
|
||||||
|
// drop(view, event) {
|
||||||
|
// setTimeout(() => {
|
||||||
|
// const node = document.querySelector('.ProseMirror-hideselection')
|
||||||
|
// if (node) {
|
||||||
|
// node.classList.remove('ProseMirror-hideselection')
|
||||||
|
// }
|
||||||
|
// }, 50)
|
||||||
|
// },
|
||||||
|
mousemove(view, event) {
|
||||||
|
const coords = {
|
||||||
|
left: event.clientX + WIDTH + 50,
|
||||||
|
top: event.clientY,
|
||||||
|
}
|
||||||
|
const pos = view.posAtCoords(coords)
|
||||||
|
|
||||||
|
if (pos) {
|
||||||
|
let node = view.domAtPos(pos?.pos)
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
node = node.node
|
||||||
|
while (node && node.parentNode) {
|
||||||
|
if (node.parentNode?.classList?.contains('ProseMirror')) { // todo
|
||||||
|
break
|
||||||
|
}
|
||||||
|
node = node.parentNode
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node instanceof Element) {
|
||||||
|
const cstyle = window.getComputedStyle(node)
|
||||||
|
const lineHeight = parseInt(cstyle.lineHeight, 10)
|
||||||
|
// const top = parseInt(cstyle.marginTop, 10) + parseInt(cstyle.paddingTop, 10)
|
||||||
|
const top = 0
|
||||||
|
const rect = absoluteRect(node)
|
||||||
|
const win = node.ownerDocument.defaultView
|
||||||
|
|
||||||
|
rect.top += win.pageYOffset + ((lineHeight - 24) / 2) + top
|
||||||
|
rect.left += win.pageXOffset
|
||||||
|
rect.width = `${WIDTH}px`
|
||||||
|
|
||||||
|
dropElement.style.left = `${-WIDTH + rect.left}px`
|
||||||
|
dropElement.style.top = `${rect.top}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
15
demos/src/Experiments/GlobalDragHandle/Vue/index.html
Normal file
15
demos/src/Experiments/GlobalDragHandle/Vue/index.html
Normal 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/GlobalDragHandle', source)
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
133
demos/src/Experiments/GlobalDragHandle/Vue/index.vue
Normal file
133
demos/src/Experiments/GlobalDragHandle/Vue/index.vue
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="editor">
|
||||||
|
<editor-content :editor="editor" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Editor, EditorContent } from '@tiptap/vue-3'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import DragHandle from './DragHandle.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
EditorContent,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
editor: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.editor = new Editor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
DragHandle,
|
||||||
|
],
|
||||||
|
content: `
|
||||||
|
<p>paragraph 1</p>
|
||||||
|
<p>paragraph 2</p>
|
||||||
|
<p>paragraph 3</p>
|
||||||
|
<ul>
|
||||||
|
<li>list item 1</li>
|
||||||
|
<li>list item 2</li>
|
||||||
|
</ul>
|
||||||
|
<pre>code</pre>
|
||||||
|
`,
|
||||||
|
onUpdate: () => {
|
||||||
|
console.log(this.editor.getHTML())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeDestroy() {
|
||||||
|
this.editor.destroy()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.global-drag-handle {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
content: '⠿';
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: grab;
|
||||||
|
background:#0D0D0D10;
|
||||||
|
color: #0D0D0D50;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.ProseMirror {
|
||||||
|
padding: 0 1rem;
|
||||||
|
|
||||||
|
> * + * {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background-color: rgba(#616161, 0.1);
|
||||||
|
color: #616161;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: #0D0D0D;
|
||||||
|
color: #FFF;
|
||||||
|
font-family: 'JetBrainsMono', monospace;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
|
||||||
|
code {
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
padding-left: 1rem;
|
||||||
|
border-left: 2px solid rgba(#0D0D0D, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 2px solid rgba(#0D0D0D, 0.1);
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-selectednode {
|
||||||
|
outline: 2px solid #70CFF8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
103
demos/src/Experiments/Linter/Vue/extension/Linter.ts
Normal file
103
demos/src/Experiments/Linter/Vue/extension/Linter.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
import { Decoration, DecorationSet } from 'prosemirror-view'
|
||||||
|
import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'
|
||||||
|
import { Node as ProsemirrorNode } from 'prosemirror-model'
|
||||||
|
import LinterPlugin, { Result as Issue } from './LinterPlugin'
|
||||||
|
|
||||||
|
interface IconDivElement extends HTMLDivElement {
|
||||||
|
issue?: Issue
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIcon(issue: Issue) {
|
||||||
|
const icon: IconDivElement = document.createElement('div')
|
||||||
|
|
||||||
|
icon.className = 'lint-icon'
|
||||||
|
icon.title = issue.message
|
||||||
|
icon.issue = issue
|
||||||
|
|
||||||
|
return icon
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAllLinterPlugins(doc: ProsemirrorNode, plugins: Array<typeof LinterPlugin>) {
|
||||||
|
const decorations: [any?] = []
|
||||||
|
|
||||||
|
const results = plugins.map(RegisteredLinterPlugin => {
|
||||||
|
return new RegisteredLinterPlugin(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: Array<typeof LinterPlugin>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const target = (event.target as IconDivElement)
|
||||||
|
if (/lint-icon/.test(target.className) && target.issue) {
|
||||||
|
const { from, to } = target.issue
|
||||||
|
|
||||||
|
view.dispatch(
|
||||||
|
view.state.tr
|
||||||
|
.setSelection(TextSelection.create(view.state.doc, from, to))
|
||||||
|
.scrollIntoView(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
handleDoubleClick(view, _, event) {
|
||||||
|
const target = (event.target as IconDivElement)
|
||||||
|
if (/lint-icon/.test((event.target as HTMLElement).className) && target.issue) {
|
||||||
|
const prob = target.issue
|
||||||
|
|
||||||
|
if (prob.fix) {
|
||||||
|
prob.fix(view, prob)
|
||||||
|
view.focus()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
35
demos/src/Experiments/Linter/Vue/extension/LinterPlugin.ts
Normal file
35
demos/src/Experiments/Linter/Vue/extension/LinterPlugin.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Node as ProsemirrorNode } from 'prosemirror-model'
|
||||||
|
|
||||||
|
export interface Result {
|
||||||
|
message: string,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
fix?: Function
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class LinterPlugin {
|
||||||
|
protected doc
|
||||||
|
|
||||||
|
private results: Array<Result> = []
|
||||||
|
|
||||||
|
constructor(doc: ProsemirrorNode) {
|
||||||
|
this.doc = doc
|
||||||
|
}
|
||||||
|
|
||||||
|
record(message: string, from: number, to: number, fix?: Function) {
|
||||||
|
this.results.push({
|
||||||
|
message,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
fix,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
scan() {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
getResults() {
|
||||||
|
return this.results
|
||||||
|
}
|
||||||
|
}
|
||||||
8
demos/src/Experiments/Linter/Vue/extension/index.ts
Normal file
8
demos/src/Experiments/Linter/Vue/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,25 @@
|
|||||||
|
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: number) => {
|
||||||
|
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 @@
|
|||||||
|
import { EditorView } from 'prosemirror-view'
|
||||||
|
import LinterPlugin, { Result as Issue } from '../LinterPlugin'
|
||||||
|
|
||||||
|
export class HeadingLevel extends LinterPlugin {
|
||||||
|
fixHeader(level: number) {
|
||||||
|
return function ({ state, dispatch }: EditorView, issue: Issue) {
|
||||||
|
dispatch(state.tr.setNodeMarkup(issue.from - 1, undefined, { level }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scan() {
|
||||||
|
let lastHeadLevel: number | null = 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 @@
|
|||||||
|
import { EditorView } from 'prosemirror-view'
|
||||||
|
import LinterPlugin, { Result as Issue } from '../LinterPlugin'
|
||||||
|
|
||||||
|
export class Punctuation extends LinterPlugin {
|
||||||
|
public regex = / ([,.!?:]) ?/g
|
||||||
|
|
||||||
|
fix(replacement: any) {
|
||||||
|
return function ({ state, dispatch }: EditorView, issue: Issue) {
|
||||||
|
dispatch(
|
||||||
|
state.tr.replaceWith(
|
||||||
|
issue.from, issue.to,
|
||||||
|
state.schema.text(replacement),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scan() {
|
||||||
|
this.doc.descendants((node, position) => {
|
||||||
|
if (!node.isText) return
|
||||||
|
|
||||||
|
if (!node.text) 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
|
||||||
|
}
|
||||||
|
}
|
||||||
15
demos/src/Experiments/Linter/Vue/index.html
Normal file
15
demos/src/Experiments/Linter/Vue/index.html
Normal 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/Linter', source)
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
96
demos/src/Experiments/Linter/Vue/index.vue
Normal file
96
demos/src/Experiments/Linter/Vue/index.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<editor-content :editor="editor" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Editor, EditorContent } from '@tiptap/vue-3'
|
||||||
|
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>
|
||||||
15
demos/src/Experiments/MultipleEditors/Vue/index.html
Normal file
15
demos/src/Experiments/MultipleEditors/Vue/index.html
Normal 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/MultipleEditors', source)
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7
demos/src/Experiments/MultipleEditors/Vue/index.spec.js
Normal file
7
demos/src/Experiments/MultipleEditors/Vue/index.spec.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
context('/demos/Examples/MultipleEditors', () => {
|
||||||
|
before(() => {
|
||||||
|
cy.visit('/demos/Examples/MultipleEditors')
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO: Write tests
|
||||||
|
})
|
||||||
208
demos/src/Experiments/MultipleEditors/Vue/index.vue
Normal file
208
demos/src/Experiments/MultipleEditors/Vue/index.vue
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
<template>
|
||||||
|
<div class="form">
|
||||||
|
<div class="form__label">
|
||||||
|
Title
|
||||||
|
</div>
|
||||||
|
<div v-if="title" class="form__item form__item--title">
|
||||||
|
<editor-content :editor="title" />
|
||||||
|
</div>
|
||||||
|
<div class="form__label">
|
||||||
|
Tasks
|
||||||
|
</div>
|
||||||
|
<div v-if="tasks" class="form__item form__item--tasks">
|
||||||
|
<editor-content :editor="tasks" />
|
||||||
|
</div>
|
||||||
|
<div class="form__label">
|
||||||
|
Description
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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 Bold from '@tiptap/extension-bold'
|
||||||
|
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 { yDocToProsemirrorJSON } from 'y-prosemirror'
|
||||||
|
|
||||||
|
const ParagraphDocument = Document.extend({
|
||||||
|
content: 'paragraph',
|
||||||
|
})
|
||||||
|
|
||||||
|
const TaskListDocument = Document.extend({
|
||||||
|
content: 'taskList',
|
||||||
|
})
|
||||||
|
|
||||||
|
const CustomTaskItem = TaskItem.extend({
|
||||||
|
content: 'text*',
|
||||||
|
})
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
EditorContent,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
title: null,
|
||||||
|
tasks: null,
|
||||||
|
description: null,
|
||||||
|
ydoc: new Y.Doc(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.title = new Editor({
|
||||||
|
extensions: [
|
||||||
|
ParagraphDocument,
|
||||||
|
Paragraph,
|
||||||
|
Text,
|
||||||
|
Collaboration.configure({
|
||||||
|
document: this.ydoc,
|
||||||
|
field: 'title',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content: '<p>No matter what you do, this will be a single paragraph.',
|
||||||
|
})
|
||||||
|
|
||||||
|
this.tasks = new Editor({
|
||||||
|
extensions: [
|
||||||
|
TaskListDocument,
|
||||||
|
Paragraph,
|
||||||
|
Text,
|
||||||
|
TaskList,
|
||||||
|
CustomTaskItem,
|
||||||
|
Collaboration.configure({
|
||||||
|
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({
|
||||||
|
extensions: [
|
||||||
|
Document,
|
||||||
|
Paragraph,
|
||||||
|
Text,
|
||||||
|
Bold,
|
||||||
|
Collaboration.configure({
|
||||||
|
document: this.ydoc,
|
||||||
|
field: 'description',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content: `
|
||||||
|
<p>
|
||||||
|
<strong>Lengthy text</strong>
|
||||||
|
</p>
|
||||||
|
<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()
|
||||||
|
this.description.destroy()
|
||||||
|
this.provider.destroy()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.ProseMirror {
|
||||||
|
> * + * {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul[data-type="taskList"] {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
> label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form__label {
|
||||||
|
color: #868e96;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form__item {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
transition: .1s all ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #68CEF8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--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;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
15
demos/src/Experiments/TrailingNode/Vue/index.html
Normal file
15
demos/src/Experiments/TrailingNode/Vue/index.html
Normal 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/TrailingNode', source)
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
63
demos/src/Experiments/TrailingNode/Vue/index.vue
Normal file
63
demos/src/Experiments/TrailingNode/Vue/index.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="editor">
|
||||||
|
<editor-content :editor="editor" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Editor, EditorContent } from '@tiptap/vue-3'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import { TrailingNode } from './trailing-node'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
EditorContent,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
editor: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.editor = new Editor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
TrailingNode,
|
||||||
|
],
|
||||||
|
content: `
|
||||||
|
<p>Example text</p>
|
||||||
|
<pre><code>console.log('foo')</code></pre>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeDestroy() {
|
||||||
|
this.editor.destroy()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.ProseMirror {
|
||||||
|
> * + * {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: #0D0D0D;
|
||||||
|
color: #FFF;
|
||||||
|
font-family: 'JetBrainsMono', monospace;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
|
||||||
|
code {
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
68
demos/src/Experiments/TrailingNode/Vue/trailing-node.ts
Normal file
68
demos/src/Experiments/TrailingNode/Vue/trailing-node.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
import { PluginKey, Plugin } from 'prosemirror-state'
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
function nodeEqualsType({ types, node }) {
|
||||||
|
return (Array.isArray(types) && types.includes(node.type)) || node.type === types
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension based on:
|
||||||
|
* - https://github.com/ueberdosis/tiptap/blob/v1/packages/tiptap-extensions/src/extensions/TrailingNode.js
|
||||||
|
* - https://github.com/remirror/remirror/blob/e0f1bec4a1e8073ce8f5500d62193e52321155b9/packages/prosemirror-trailing-node/src/trailing-node-plugin.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TrailingNodeOptions {
|
||||||
|
node: string,
|
||||||
|
notAfter: string[],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TrailingNode = Extension.create<TrailingNodeOptions>({
|
||||||
|
name: 'trailingNode',
|
||||||
|
|
||||||
|
defaultOptions: {
|
||||||
|
node: 'paragraph',
|
||||||
|
notAfter: [
|
||||||
|
'paragraph',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
const plugin = new PluginKey(this.name)
|
||||||
|
const disabledNodes = Object.entries(this.editor.schema.nodes)
|
||||||
|
.map(([, value]) => value)
|
||||||
|
.filter(node => this.options.notAfter.includes(node.name))
|
||||||
|
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: plugin,
|
||||||
|
appendTransaction: (_, __, state) => {
|
||||||
|
const { doc, tr, schema } = state
|
||||||
|
const shouldInsertNodeAtEnd = plugin.getState(state)
|
||||||
|
const endPosition = doc.content.size
|
||||||
|
const type = schema.nodes[this.options.node]
|
||||||
|
|
||||||
|
if (!shouldInsertNodeAtEnd) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return tr.insert(endPosition, type.create())
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
init: (_, state) => {
|
||||||
|
const lastNode = state.tr.doc.lastChild
|
||||||
|
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
|
||||||
|
},
|
||||||
|
apply: (tr, value) => {
|
||||||
|
if (!tr.docChanged) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastNode = tr.doc.lastChild
|
||||||
|
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user