add experiment demos

This commit is contained in:
Philipp Kühn
2021-08-25 17:53:02 +02:00
parent 2498c24186
commit 6ab708b1a2
43 changed files with 3090 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

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

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

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

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

View 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: Doesnt work
return commands.wrapIn('details')
},
toggleDetails: () => ({ commands }) => {
// TODO: Doesnt work
return commands.toggleWrap('details')
},
unsetDetails: () => ({ commands }) => {
// TODO: Doesnt work
return commands.lift('details')
},
}
},
})

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

View 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 dont work</li>
<li>Fails to open nested details</li>
<li>Node cant be deleted (if its 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>Thats 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>

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

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

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

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

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

View 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="Whos 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>Thats 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>

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

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

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

View 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="Whos 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">thats 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">thats 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>

View 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`
}
}
}
},
},
},
}),
]
},
})

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

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

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

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

View File

@@ -0,0 +1,8 @@
import { Linter } from './Linter'
export * from './Linter'
export default Linter
export { BadWords } from './plugins/BadWords'
export { Punctuation } from './plugins/Punctuation'
export { HeadingLevel } from './plugins/HeadingLevel'

View File

@@ -0,0 +1,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
}
}

View File

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

View File

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

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

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

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

View File

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

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

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

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

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