Merge branch 'main' of https://github.com/ueberdosis/tiptap-next into feature/remove-inferred-commands

# Conflicts:
#	docs/src/demos/Experiments/Annotation/extension/annotation.ts
#	docs/src/demos/Experiments/Color/extension/Color.ts
#	docs/src/demos/Experiments/Details/details.ts
This commit is contained in:
Philipp Kühn
2021-02-16 18:08:12 +01:00
57 changed files with 813 additions and 694 deletions

View File

@@ -67,6 +67,8 @@ export default {
this.status = event.status
})
window.ydoc = ydoc
this.indexdb = new IndexeddbPersistence('tiptap-collaboration-example', ydoc)
this.editor = new Editor({
@@ -141,7 +143,7 @@ export default {
.editor {
display: flex;
flex-direction: column;
max-height: 90vh;
max-height: 400px;
color: #0D0D0D;
background-color: $colorWhite;
border: 3px solid #0D0D0D;

View File

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

View File

@@ -1,95 +0,0 @@
import { Decoration, DecorationSet } from 'prosemirror-view'
import { ySyncPluginKey } from 'y-prosemirror'
import { AnnotationPluginKey } from './AnnotationPlugin'
export class AnnotationState {
private decorations: any
constructor(decorations: any) {
this.decorations = decorations
}
findAnnotation(id: number) {
const current = this.decorations.find()
for (let i = 0; i < current.length; i += 1) {
if (current[i].spec.data.id === id) {
return current[i]
}
}
}
annotationsAt(position: number) {
return this.decorations.find(position, position)
}
apply(transaction: any) {
console.log('transaction', transaction.meta, transaction.docChanged, transaction)
const yjsTransaction = transaction.getMeta(ySyncPluginKey)
if (yjsTransaction) {
// TODO: Map positions
// absolutePositionToRelativePosition(state.selection.anchor, pmbinding.type, pmbinding.mapping)
console.log('map positions', transaction, yjsTransaction)
return this
// const { binding } = yjsTransaction
// console.log({ binding }, { transaction }, transaction.docChanged)
// console.log('yjsTransaction.isChangeOrigin', yjsTransaction.isChangeOrigin)
// console.log('yjs mapping', yjsTransaction.binding?.mapping)
// console.log('all decorations', this.decorations.find())
// console.log('original prosemirror mapping', this.decorations.map(transaction.mapping, transaction.doc))
// console.log('difference between ProseMirror & Y.js', transaction.mapping, yjsTransaction.binding?.mapping)
// Code to sync the selection:
// export const getRelativeSelection = (pmbinding, state) => ({
// anchor: absolutePositionToRelativePosition(state.selection.anchor, pmbinding.type, pmbinding.mapping),
// head: absolutePositionToRelativePosition(state.selection.head, pmbinding.type, pmbinding.mapping)
// })
// console.log(yjsTransaction.binding.mapping, transaction.curSelection.anchor)
}
if (transaction.docChanged) {
// TODO: Fixes the initial load (complete replace of the document)
// return this
// TODO: Fixes later changes (typing before the annotation)
const decorations = this.decorations.map(transaction.mapping, transaction.doc)
return new AnnotationState(decorations)
}
const action = transaction.getMeta(AnnotationPluginKey)
const actionType = action && action.type
if (action) {
let { decorations } = this
if (actionType === 'addAnnotation') {
decorations = decorations.add(transaction.doc, [
Decoration.inline(action.from, action.to, { class: 'annotation' }, { data: action.data }),
])
} else if (actionType === 'deleteAnnotation') {
decorations = decorations.remove([
this.findAnnotation(action.id),
])
}
return new AnnotationState(decorations)
}
return this
}
static init(config: any, state: any) {
// TODO: Load initial decorations from Y.js?
const decorations = DecorationSet.create(state.doc, [
Decoration.inline(105, 190, { class: 'annotation' }, { data: { id: 123, content: 'foobar' } }),
])
return new AnnotationState(decorations)
}
}

View File

@@ -1,73 +0,0 @@
import { Extension, Command } from '@tiptap/core'
import { AnnotationItem } from './AnnotationItem'
import { AnnotationPlugin, AnnotationPluginKey } from './AnnotationPlugin'
function randomId() {
return Math.floor(Math.random() * 0xffffffff)
}
export interface AnnotationOptions {
HTMLAttributes: {
[key: string]: any
},
onUpdate: (items: [any?]) => {},
}
declare module '@tiptap/core' {
interface AllCommands {
annotation: {
addAnnotation: (content: any) => Command,
deleteAnnotation: (id: number) => Command,
}
}
}
export const Annotation = Extension.create({
name: 'annotation',
defaultOptions: <AnnotationOptions>{
HTMLAttributes: {
class: 'annotation',
},
onUpdate: decorations => decorations,
},
addCommands() {
return {
addAnnotation: (content: any) => ({ dispatch, state }) => {
const { selection } = state
if (selection.empty) {
return false
}
if (dispatch && content) {
dispatch(state.tr.setMeta(AnnotationPluginKey, {
type: 'addAnnotation',
from: selection.from,
to: selection.to,
data: new AnnotationItem(
randomId(),
content,
),
}))
}
return true
},
deleteAnnotation: (id: number) => ({ dispatch, state }) => {
if (dispatch) {
dispatch(state.tr.setMeta(AnnotationPluginKey, { type: 'deleteAnnotation', id }))
}
return true
},
}
},
addProseMirrorPlugins() {
return [
AnnotationPlugin(this.options),
]
},
})

View File

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

View File

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

View File

@@ -1,87 +0,0 @@
<template>
<div>
<div v-if="editor">
<button @click="addAnnotation" :disabled="!editor.can().addAnnotation()">
add annotation
</button>
<editor-content :editor="editor" />
<div v-for="comment in comments" :key="comment.type.spec.data.id">
{{ comment.type.spec.data }}
<button @click="deleteAnnotation(comment.type.spec.data.id)">
remove
</button>
</div>
</div>
</div>
</template>
<script>
import { Editor, EditorContent } from '@tiptap/vue-starter-kit'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import Annotation from './extension'
export default {
components: {
EditorContent,
},
data() {
return {
editor: null,
comments: [],
}
},
mounted() {
this.editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Annotation.configure({
onUpdate: items => { this.comments = items },
}),
],
content: `
<p>
Annotations can be used to add additional information to the content, for example comments. They live on a different level than the actual editor content.
</p>
<p>
This example allows you to add plain text, but youre free to add more complex data, for example JSON from another tiptap instance. :-)
</p>
`,
})
},
methods: {
addAnnotation() {
const content = prompt('Annotation', '')
this.editor.commands.addAnnotation(content)
},
deleteAnnotation(id) {
this.editor.commands.deleteAnnotation(id)
},
},
beforeDestroy() {
this.editor.destroy()
},
}
</script>
<style lang="scss">
/* Basic editor styles */
.ProseMirror {
> * + * {
margin-top: 0.75em;
}
}
.annotation {
background: #9DEF8F;
}
</style>

View File

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

@@ -1,16 +1,34 @@
import * as Y from 'yjs'
import { Plugin, PluginKey } from 'prosemirror-state'
import { AnnotationState } from './AnnotationState'
export const AnnotationPluginKey = new PluginKey('annotation')
export const AnnotationPlugin = (options: any) => new Plugin({
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: AnnotationState.init,
apply(transaction, oldState) {
return oldState.apply(transaction)
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)
@@ -28,6 +46,5 @@ export const AnnotationPlugin = (options: any) => new Plugin({
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,144 @@
import * as Y from 'yjs'
import { Extension, Command } 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 AllCommands {
annotation: {
addAnnotation: (data: any) => Command,
updateAnnotation: (id: string, data: any) => Command,
deleteAnnotation: (id: string) => Command,
}
}
}
export const CollaborationAnnotation = Extension.create({
name: 'annotation',
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,7 @@
context('/demos/Experiments/Annotation', () => {
before(() => {
cy.visit('/demos/Experiments/Annotation')
})
// TODO: Write tests
})

View File

@@ -2,36 +2,30 @@
<div>
<div v-if="editor">
<h2>
Original
Original Editor
</h2>
<button @click="addComment" :disabled="!editor.can().addAnnotation()">
comment
</button>
<editor-content :editor="editor" />
<div v-for="comment in comments" :key="comment.type.spec.data.id">
{{ comment.type.spec.data }}
<div v-for="comment in comments" :key="comment.id">
{{ comment }}
<button @click="deleteComment(comment.type.spec.data.id)">
<button @click="updateComment(comment.id)">
update
</button>
<button @click="deleteComment(comment.id)">
remove
</button>
</div>
<!-- <br>
<h2>
ProseMirror JSON from Y.js document
</h2>
{{ rawDocument }} -->
<br>
<h2>
Y.js document
</h2>
{{ json }}
<br>
<h2>
Mirror
Another Editor
</h2>
<button @click="addAnotherComment" :disabled="!anotherEditor.can().addAnnotation()">
comment
</button>
<editor-content :editor="anotherEditor" />
</div>
</div>
@@ -46,8 +40,7 @@ import Collaboration from '@tiptap/extension-collaboration'
import Bold from '@tiptap/extension-bold'
import Heading from '@tiptap/extension-heading'
import * as Y from 'yjs'
import { yDocToProsemirrorJSON } from 'y-prosemirror'
import Annotation from '../Annotation/extension'
import CollaborationAnnotation from './extension'
export default {
components: {
@@ -73,8 +66,10 @@ export default {
Text,
Bold,
Heading,
Annotation.configure({
CollaborationAnnotation.configure({
document: this.ydoc,
onUpdate: items => { this.comments = items },
instance: 'editor1',
}),
Collaboration.configure({
document: this.ydoc,
@@ -95,9 +90,12 @@ export default {
Document,
Paragraph,
Text,
// Annotation.configure({
// onUpdate: items => { this.comments = items },
// }),
Bold,
Heading,
CollaborationAnnotation.configure({
document: this.ydoc,
instance: 'editor2',
}),
Collaboration.configure({
document: this.ydoc,
}),
@@ -107,26 +105,32 @@ export default {
methods: {
addComment() {
const content = prompt('Comment', '')
const data = prompt('Comment', '')
this.editor.commands.addAnnotation(content)
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', '')
computed: {
rawDocument() {
return yDocToProsemirrorJSON(this.ydoc, 'default')
},
json() {
return this.ydoc.toJSON()
this.anotherEditor.commands.addAnnotation(data)
},
},
beforeDestroy() {
this.editor.destroy()
this.anotherEditor.destroy()
},
}
</script>

View File

@@ -1,62 +0,0 @@
// @ts-nocheck
import { Extension } from '@tiptap/core'
import { Decoration, DecorationSet } from 'prosemirror-view'
import { Plugin } from 'prosemirror-state'
function detectColors(doc) {
const hexColor = /(#[0-9a-f]{3,6})\b/ig
const results = []
const decorations: [any?] = []
doc.descendants((node: any, position: any) => {
if (!node.isText) {
return
}
let matches
// eslint-disable-next-line
while (matches = hexColor.exec(node.text)) {
results.push({
color: matches[0],
from: position + matches.index,
to: position + matches.index + matches[0].length,
})
}
})
results.forEach(issue => {
decorations.push(Decoration.inline(issue.from, issue.to, {
class: 'color',
style: `--color: ${issue.color}`,
}))
})
return DecorationSet.create(doc, decorations)
}
export const Color = Extension.create({
name: 'color',
addProseMirrorPlugins() {
return [
new Plugin({
state: {
init(_, { doc }) {
return detectColors(doc)
},
apply(transaction, oldState) {
return transaction.docChanged
? detectColors(transaction.doc)
: oldState
},
},
props: {
decorations(state) {
return this.getState(state)
},
},
}),
]
},
})

View File

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

View File

@@ -1,76 +0,0 @@
<template>
<div>
<editor-content :editor="editor" />
</div>
</template>
<script>
import { Editor, EditorContent } from '@tiptap/vue-starter-kit'
import Document from '@tiptap/extension-document'
import Text from '@tiptap/extension-text'
import Paragraph from '@tiptap/extension-paragraph'
import Heading from '@tiptap/extension-heading'
import Color from './extension'
export default {
components: {
EditorContent,
},
data() {
return {
editor: null,
}
},
mounted() {
this.editor = new Editor({
extensions: [
Document,
Paragraph,
Heading,
Text,
Color,
],
content: `
<p>
For triplets with repeated values, you can eliminate the repetition by writing in shorthand, for instance, #00FFFF becomes #0FF. This system is easy for computers to understand, and it pretty short to write, which makes it useful for quick copy paste and designation in programming. If youre going to work with colors in a more involved way, though, HSL is a little bit more human-readable.
</p>
<p>
A few more examples: #FFF, #0D0D0D, #616161, #A975FF, #FB5151, #FD9170, #FFCB6B, #68CEF8, #80cbc4, #9DEF8F
</p>
`,
})
},
beforeDestroy() {
this.editor.destroy()
},
}
</script>
<style lang="scss">
/* Basic editor styles */
.ProseMirror {
> * + * {
margin-top: 0.75em;
}
}
.color {
white-space: nowrap;
&::before {
content: ' ';
display: inline-block;
width: 1em;
height: 1em;
border: 1px solid rgba(128, 128, 128, 0.3);
vertical-align: middle;
margin-right: 0.1em;
margin-bottom: 0.15em;
border-radius: 2px;
background-color: var(--color);
}
}
</style>

View File

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

View File

@@ -9,9 +9,13 @@ export interface DetailsSummaryOptions {
export default Node.create<DetailsSummaryOptions>({
name: 'detailsSummary',
content: 'inline*',
content: 'text*',
// group: 'block',
marks: '',
group: 'block',
isolating: true,
defaultOptions: {
HTMLAttributes: {},

View File

@@ -1,4 +1,4 @@
import { Node, mergeAttributes } from '@tiptap/core'
import { Node, mergeAttributes, Command } from '@tiptap/core'
export interface DetailsOptions {
HTMLAttributes: {
@@ -6,6 +6,25 @@ export interface DetailsOptions {
},
}
declare module '@tiptap/core' {
interface AllCommands {
details: {
/**
* Set a details node
*/
setDetails: () => Command,
/**
* Toggle a details node
*/
toggleDetails: () => Command,
/**
* Unset a details node
*/
unsetDetails: () => Command,
}
}
}
export default Node.create<DetailsOptions>({
name: 'details',
@@ -13,36 +32,21 @@ export default Node.create<DetailsOptions>({
group: 'block',
// defining: true,
defaultOptions: {
HTMLAttributes: {},
},
addAttributes() {
return {
open: {
default: true,
parseHTML: element => {
return {
open: element.hasAttribute('open'),
}
},
renderHTML: attributes => {
if (!attributes.open) {
return null
}
return {
open: 'open',
}
},
},
}
},
parseHTML() {
return [{
tag: 'details',
}]
return [
{
tag: 'details',
},
{
tag: 'div[data-type="details"]',
},
]
},
renderHTML({ HTMLAttributes }) {
@@ -50,54 +54,54 @@ export default Node.create<DetailsOptions>({
},
addNodeView() {
return ({
node,
HTMLAttributes,
getPos,
editor,
}) => {
const { view } = editor
const item = document.createElement('details')
return ({ HTMLAttributes }) => {
const item = document.createElement('div')
item.setAttribute('data-type', 'details')
item.addEventListener('click', event => {
// @ts-ignore
const { open } = event.target.parentElement as HTMLElement
// @ts-ignore
const { localName } = event.target
const toggle = document.createElement('div')
toggle.setAttribute('data-type', 'detailsToggle')
item.append(toggle)
if (typeof getPos === 'function' && localName === 'summary') {
view.dispatch(view.state.tr.setNodeMarkup(getPos(), undefined, {
open: !open,
}))
editor.commands.focus()
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')
}
})
if (node.attrs.open) {
item.setAttribute('open', 'open')
}
Object.entries(HTMLAttributes).forEach(([key, value]) => {
item.setAttribute(key, value)
})
return {
dom: item,
contentDOM: item,
update: updatedNode => {
if (updatedNode.type !== this.type) {
return false
}
if (updatedNode.attrs.open) {
item.setAttribute('open', 'open')
} else {
item.removeAttribute('open')
}
return true
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

@@ -1,6 +1,20 @@
<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>
@@ -31,7 +45,7 @@ export default {
],
content: `
<p>Here is a details list:</p>
<details open>
<details>
<summary>An open details tag</summary>
<p>More info about the details.</p>
</details>
@@ -39,6 +53,7 @@ export default {
<summary>A closed details tag</summary>
<p>More info about the details.</p>
</details>
<p>Thats it.</p>
`,
})
},
@@ -54,5 +69,31 @@ export default {
> * + * {
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>