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