open source pro extensions (develop) (#6466)

* open source pro extensions

* added changeset

* fix import for serializeForClipboard

* use serializeForClipboard from view

* improve type checking for validPosition
This commit is contained in:
bdbch 2025-06-20 11:03:54 +02:00 committed by GitHub
parent 5adeea1885
commit 2dad664fda
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
188 changed files with 41557 additions and 17 deletions

View File

@ -0,0 +1,34 @@
---
"@tiptap/extension-invisible-characters": minor
"@tiptap/extension-drag-handle": minor
"@tiptap/extension-drag-handle-react": minor
"@tiptap/extension-drag-handle-vue-2": minor
"@tiptap/extension-drag-handle-vue-3": minor
"@tiptap/extension-table-of-contents": minor
"@tiptap/extension-details-content": minor
"@tiptap/extension-details-summary": minor
"@tiptap/extension-details": minor
"@tiptap/extension-file-handler": minor
"@tiptap/extension-mathematics": minor
"@tiptap/extension-node-range": minor
"@tiptap/extension-unique-id": minor
"@tiptap/extension-emoji": minor
---
We open sourced our basic pro extensions
This release includes the following extensions that were previously only available in our Pro version:
- `@tiptap/extension-drag-handle`
- `@tiptap/extension-drag-handle-react`
- `@tiptap/extension-drag-handle-vue-2`
- `@tiptap/extension-drag-handle-vue-3`
- `@tiptap/extension-emoji`
- `@tiptap/extension-details-content`
- `@tiptap/extension-details-summary`
- `@tiptap/extension-details`
- `@tiptap/extension-file-handler`
- `@tiptap/extension-invisible-characters`
- `@tiptap/extension-mathematics`
- `@tiptap/extension-node-range`
- `@tiptap/extension-table-of-contents`
- `@tiptap/extension-unique-id`

View File

@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@hocuspocus/provider": "2.13.5", "@hocuspocus/provider": "2.13.5",
"@hocuspocus/transformer": "^2.13.7",
"@lexical/react": "^0.11.3", "@lexical/react": "^0.11.3",
"@shikijs/core": "1.10.3", "@shikijs/core": "1.10.3",
"d3": "^7.9.0", "d3": "^7.9.0",

View File

@ -1,6 +1,5 @@
import { Extension } from '@tiptap/core' import { Extension } from '@tiptap/core'
import { NodeSelection, Plugin } from '@tiptap/pm/state' import { NodeSelection, Plugin } from '@tiptap/pm/state'
import { __serializeForClipboard as serializeForClipboard } from '@tiptap/pm/view'
function removeNode(node) { function removeNode(node) {
node.parentNode.removeChild(node) node.parentNode.removeChild(node)
@ -63,7 +62,7 @@ export default Extension.create({
// from: view.nodeDOM(view.state.selection.from), // from: view.nodeDOM(view.state.selection.from),
// to: view.nodeDOM(view.state.selection.to), // to: view.nodeDOM(view.state.selection.to),
// }) // })
const { dom, text } = serializeForClipboard(view, slice) const { dom, text } = view.serializeForClipboard(view, slice)
e.dataTransfer.clearData() e.dataTransfer.clearData()
e.dataTransfer.setData('text/html', dom.innerHTML) e.dataTransfer.setData('text/html', dom.innerHTML)

View File

@ -0,0 +1,44 @@
import './styles.scss'
import DragHandle from '@tiptap/extension-drag-handle-react'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import React from 'react'
export default () => {
const editor = useEditor({
extensions: [
StarterKit,
],
content: `
<h1>
This is a very unique heading.
</h1>
<p>
This is a unique paragraph. Its so unique, it even has an ID attached to it.
</p>
<p>
And this one, too.
</p>
`,
})
const toggleEditable = () => {
editor.setEditable(!editor.isEditable)
editor.view.dispatch(editor.view.state.tr)
}
return (
<>
<div>
<button onClick={toggleEditable}>Toggle editable</button>
</div>
<DragHandle editor={editor}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 9h16.5m-16.5 6.75h16.5" />
</svg>
</DragHandle>
<EditorContent editor={editor} />
</>
)
}

View File

@ -0,0 +1,47 @@
.ProseMirror {
padding-inline: 4rem;
> * + * {
margin-top: 0.75em;
}
[data-id] {
border: 3px solid #0D0D0D;
border-radius: 0.5rem;
margin: 1rem 0;
position: relative;
margin-top: 1.5rem;
padding: 2rem 1rem 1rem;
&::before {
content: attr(data-id);
background-color: #0D0D0D;
font-size: 0.6rem;
letter-spacing: 1px;
font-weight: bold;
text-transform: uppercase;
color: #fff;
position: absolute;
top: 0;
padding: 0.25rem 0.75rem;
border-radius: 0 0 0.5rem 0.5rem;
}
}
}
.drag-handle {
align-items: center;
background: #f0f0f0;
border-radius: .25rem;
border: 1px solid rgba(0, 0, 0, 0.1);
cursor: grab;
display: flex;
height: 1.5rem;
justify-content: center;
width: 1.5rem;
svg {
width: 1.25rem;
height: 1.25rem;
}
}

View File

@ -0,0 +1,156 @@
<template>
<div v-if="editor">
<button @click="editor.chain().focus().toggleHeading({ level: 1 }).run()">
H1
</button>
<button @click="editor.chain().focus().toggleHeading({ level: 2 }).run()">
H2
</button>
<button @click="editor.chain().focus().toggleBold().run()">
Bold
</button>
<button @click="editor.chain().focus().toggleBulletList().run()" :class="{ 'is-active': editor.isActive('bulletList') }">
Bullet list
</button>
<button @click="editor.chain().focus().lockDragHandle().run()">
Lock drag handle
</button>
<button @click="editor.chain().focus().unlockDragHandle().run()">
Unlock drag handle
</button>
<button @click="editor.chain().focus().toggleDragHandle().run()">
Toggle drag handle
</button>
<button @click="editor.setEditable(!editor.isEditable)">
Toggle editable
</button>
<drag-handle :editor="editor">
<div class="custom-drag-handle" />
</drag-handle>
</div>
<editor-content :editor="editor" />
</template>
<script>
import { DragHandle } from '@tiptap/extension-drag-handle-vue-3'
import NodeRange from '@tiptap/extension-node-range'
import StarterKit from '@tiptap/starter-kit'
import { Editor, EditorContent } from '@tiptap/vue-3'
export default {
components: {
EditorContent,
DragHandle,
},
data() {
return {
editor: null,
}
},
mounted() {
this.editor = new Editor({
extensions: [
StarterKit,
NodeRange.configure({
// allow to select only on depth 0
// depth: 0,
key: null,
}),
],
content: `
<h1>This is a demo file for our Drag Handle extension experiement.</h1>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ipsum suspendisse ultrices gravida dictum fusce ut placerat. Viverra mauris in aliquam sem fringilla. Sit amet commodo nulla facilisi nullam. Viverra orci sagittis eu volutpat odio facilisis mauris sit. In hendrerit gravida rutrum quisque non tellus orci ac. Pellentesque adipiscing commodo elit at imperdiet. Pulvinar sapien et ligula ullamcorper malesuada proin. Odio pellentesque diam volutpat commodo. Pharetra diam sit amet nisl suscipit adipiscing bibendum est ultricies.</p>
<p>Odio eu feugiat pretium nibh ipsum consequat nisl. Velit euismod in pellentesque massa placerat. Vel quam elementum pulvinar etiam non quam. Sit amet purus gravida quis. Tincidunt eget nullam non nisi est sit. Eget nulla facilisi etiam dignissim diam. Magnis dis parturient montes nascetur ridiculus mus mauris vitae. Vitae congue eu consequat ac felis donec et odio pellentesque. Sit amet porttitor eget dolor morbi non arcu risus quis. Suspendisse ultrices gravida dictum fusce ut. Tortor vitae purus faucibus ornare. Faucibus ornare suspendisse sed nisi lacus sed. Tristique senectus et netus et.</p>
<p>Cursus euismod quis viverra nibh cras pulvinar mattis nunc. Sem viverra aliquet eget sit amet tellus. Nec ullamcorper sit amet risus nullam. Facilisis gravida neque convallis a cras semper auctor. Habitant morbi tristique senectus et netus et malesuada fames ac. Dui vivamus arcu felis bibendum. Velit laoreet id donec ultrices. Enim diam vulputate ut pharetra sit. Aenean pharetra magna ac placerat vestibulum lectus mauris. Mi eget mauris pharetra et ultrices. Lacus viverra vitae congue eu consequat ac felis donec.</p>
<h2></h2>
<p>Odio eu feugiat pretium nibh ipsum consequat nisl. Velit euismod in pellentesque massa placerat. Vel quam elementum pulvinar etiam non quam. Sit amet purus gravida quis. Tincidunt eget nullam non nisi est sit. Eget nulla facilisi etiam dignissim diam. Magnis dis parturient montes nascetur ridiculus mus mauris vitae. Vitae congue eu consequat ac felis donec et odio pellentesque. Sit amet porttitor eget dolor morbi non arcu risus quis. Suspendisse ultrices gravida dictum fusce ut. Tortor vitae purus faucibus ornare. Faucibus ornare suspendisse sed nisi lacus sed. Tristique senectus et netus et.</p>
<p>Cursus euismod quis viverra nibh cras pulvinar mattis nunc. Sem viverra aliquet eget sit amet tellus. Nec ullamcorper sit amet risus nullam. Facilisis gravida neque convallis a cras semper auctor. Habitant morbi tristique senectus et netus et malesuada fames ac. Dui vivamus arcu felis bibendum. Velit laoreet id donec ultrices. Enim diam vulputate ut pharetra sit. Aenean pharetra magna ac placerat vestibulum lectus mauris. Mi eget mauris pharetra et ultrices. Lacus viverra vitae congue eu consequat ac felis donec.</p>
<ul>
<li>Bullet Item 1</li>
<li>Bullet Item 2</li>
<li>Bullet Item 3</li>
</ul>
<h2>Lorem Ipsum</h2>
<p>Tincidunt ornare massa eget egestas. Neque convallis a cras semper auctor neque. Eget nulla facilisi etiam dignissim diam quis enim. Phasellus vestibulum lorem sed risus ultricies tristique nulla aliquet enim. At tempor commodo ullamcorper a lacus vestibulum sed arcu. Sed vulputate mi sit amet mauris commodo quis imperdiet. Eget gravida cum sociis natoque. Lacinia quis vel eros donec ac odio tempor orci dapibus. Integer vitae justo eget magna fermentum iaculis eu non. Sed odio morbi quis commodo. Neque sodales ut etiam sit amet. Ipsum nunc aliquet bibendum enim facilisis gravida neque convallis a. Tempus quam pellentesque nec nam aliquam sem et tortor consequat. Urna nec tincidunt praesent semper feugiat nibh sed pulvinar proin. Lacus sed turpis tincidunt id aliquet risus feugiat in. Et leo duis ut diam quam nulla. Ultrices eros in cursus turpis. Adipiscing elit ut aliquam purus sit amet luctus venenatis.</p>
<h3>Lorem Ipsum</h3>
<p>Sapien eget mi proin sed libero enim sed faucibus. Aliquam id diam maecenas ultricies mi eget mauris. Amet mattis vulputate enim nulla aliquet porttitor lacus. Pulvinar elementum integer enim neque volutpat ac. Libero volutpat sed cras ornare arcu dui vivamus arcu felis. Urna nunc id cursus metus aliquam eleifend mi in nulla. Justo laoreet sit amet cursus sit. In massa tempor nec feugiat nisl pretium fusce. Vel quam elementum pulvinar etiam non. Nisl nisi scelerisque eu ultrices vitae. Odio ut enim blandit volutpat maecenas volutpat blandit aliquam.</p>
`,
})
},
beforeUnmount() {
this.editor?.destroy()
},
}
</script>
<style lang="scss">
::selection {
background-color: #70CFF850;
}
.ProseMirror {
padding: 1rem 1rem 1rem 0;
* {
margin-top: 0.75em;
}
> * {
margin-left: 3rem;
}
.ProseMirror-widget * {
margin-top: auto
}
ul,
ol {
padding: 0 1rem;
}
}
.ProseMirror-noderangeselection {
*::selection {
background: transparent;
}
* {
caret-color: transparent;
}
}
.ProseMirror-selectednode,
.ProseMirror-selectednoderange {
position: relative;
&::before {
position: absolute;
pointer-events: none;
z-index: -1;
content: '';
top: -0.25rem;
left: -0.25rem;
right: -0.25rem;
bottom: -0.25rem;
background-color: #70CFF850;
border-radius: 0.2rem;
}
}
.custom-drag-handle {
&::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>

View File

@ -0,0 +1,3 @@
body {
color: red;
}

View File

@ -0,0 +1,63 @@
import { mergeAttributes, Node } from '@tiptap/core'
import { ReactNodeViewRenderer } from '@tiptap/react'
import { RecommendationView } from './views/index.jsx'
export const Recommendation = Node.create({
name: 'recommendation',
group: 'block',
draggable: true,
addOptions() {
return {
publicationId: '',
HTMLAttributes: {
class: `node-${this.name}`,
},
}
},
addAttributes() {
return {
id: {
default: undefined,
parseHTML: element => element.getAttribute('data-id'),
renderHTML: attributes => ({
'data-id': attributes.id,
}),
},
}
},
parseHTML() {
return [
{
tag: `div.node-${this.name}`,
},
]
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]
},
addCommands() {
return {
setRecommendation:
() => ({ chain }) => chain()
.focus()
.insertContent({
type: this.name,
})
.run(),
}
},
addNodeView() {
return ReactNodeViewRenderer(RecommendationView)
},
})
export default Recommendation

View File

@ -0,0 +1 @@
export * from './Recommendation.jsx'

View File

@ -0,0 +1,10 @@
import { NodeViewWrapper } from '@tiptap/react'
export const RecommendationView = ({ node }) => {
return (
<NodeViewWrapper data-drag-handle>
<div className="title">Recommendation {node.attrs.id}</div>
<p>Test</p>
</NodeViewWrapper>
)
}

View File

@ -0,0 +1 @@
export * from './RecommendationView.jsx'

View File

@ -0,0 +1,40 @@
import './styles.scss'
import DragHandle from '@tiptap/extension-drag-handle-react'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import React from 'react'
import { Recommendation } from './extensions/recommendation/index.jsx'
export default () => {
const editor = useEditor({
extensions: [
StarterKit,
Recommendation,
],
content: `
<h1>
This is a very unique heading.
</h1>
<p>
This is a unique paragraph. Its so unique, it even has an ID attached to it.
</p>
<div class="node-recommendation" data-id="123"></div>
<p>
And this one, too.
</p>
`,
})
return (
<>
<DragHandle editor={editor}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 9h16.5m-16.5 6.75h16.5" />
</svg>
</DragHandle>
<EditorContent editor={editor} />
</>
)
}

View File

@ -0,0 +1,62 @@
.ProseMirror {
padding-inline: 4rem;
> * + * {
margin-top: 0.75em;
}
[data-id] {
border: 3px solid #0D0D0D;
border-radius: 0.5rem;
margin: 1rem 0;
position: relative;
margin-top: 1.5rem;
padding: 2rem 1rem 1rem;
&::before {
content: attr(data-id);
background-color: #0D0D0D;
font-size: 0.6rem;
letter-spacing: 1px;
font-weight: bold;
text-transform: uppercase;
color: #fff;
position: absolute;
top: 0;
padding: 0.25rem 0.75rem;
border-radius: 0 0 0.5rem 0.5rem;
}
}
}
.drag-handle {
align-items: center;
background: #f0f0f0;
border-radius: .25rem;
border: 1px solid rgba(0, 0, 0, 0.1);
cursor: grab;
display: flex;
height: 1.5rem;
justify-content: center;
width: 1.5rem;
svg {
width: 1.25rem;
height: 1.25rem;
}
}
.node-recommendation {
padding: 0.5rem;
border-radius: 0.5rem;
border: 0.15rem solid #000;
.title {
font-size: .875rem;
color: #777;
}
p {
margin: 0;
}
}

View File

@ -0,0 +1,76 @@
import './styles.scss'
import Document from '@tiptap/extension-document'
import FileHandler from '@tiptap/extension-file-handler'
import Heading from '@tiptap/extension-heading'
import Image from '@tiptap/extension-image'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { EditorContent, useEditor } from '@tiptap/react'
import React from 'react'
export default () => {
const editor = useEditor({
extensions: [
Document,
Heading,
Paragraph,
Text,
Image,
FileHandler.configure({
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
onDrop: (currentEditor, files, pos) => {
files.forEach(file => {
const fileReader = new FileReader()
fileReader.readAsDataURL(file)
fileReader.onload = () => {
currentEditor.chain().insertContentAt(pos, {
type: 'image',
attrs: {
src: fileReader.result,
},
}).focus().run()
}
})
},
onPaste: (currentEditor, files, htmlContent) => {
files.forEach(file => {
if (htmlContent) {
// if there is htmlContent, stop manual insertion & let other extensions handle insertion via inputRule
// you could extract the pasted file from this url string and upload it to a server for example
console.log(htmlContent) // eslint-disable-line no-console
return false
}
const fileReader = new FileReader()
fileReader.readAsDataURL(file)
fileReader.onload = () => {
currentEditor.chain().insertContentAt(currentEditor.state.selection.anchor, {
type: 'image',
attrs: {
src: fileReader.result,
},
}).focus().run()
}
})
},
}),
],
content: `
<h1>
Try to paste or drop files into this editor
</h1>
<p></p>
<p></p>
<p></p>
<p></p>
<p></p>
`,
})
return (
<EditorContent editor={editor} />
)
}

View File

@ -0,0 +1,17 @@
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
img {
display: block;
height: auto;
margin: 1.5rem 0;
max-width: 100%;
&.ProseMirror-selectednode {
outline: 3px solid var(--purple);
}
}
}

View File

@ -0,0 +1,105 @@
<template>
<editor-content :editor="editor" />
</template>
<script>
import Document from '@tiptap/extension-document'
import FileHandler from '@tiptap/extension-file-handler'
import Heading from '@tiptap/extension-heading'
import Image from '@tiptap/extension-image'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { Editor, EditorContent } from '@tiptap/vue-3'
import { defineComponent } from 'vue'
export default defineComponent({
components: {
EditorContent,
},
data() {
return {
editor: null,
}
},
mounted() {
this.editor = new Editor({
extensions: [
Document,
Heading,
Paragraph,
Text,
Image,
FileHandler.configure({
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
onDrop: (currentEditor, files, pos) => {
files.forEach(file => {
const fileReader = new FileReader()
fileReader.readAsDataURL(file)
fileReader.onload = () => {
currentEditor.chain().insertContentAt(pos, {
type: 'image',
attrs: {
src: fileReader.result,
},
}).focus().run()
}
})
},
onPaste: (currentEditor, files) => {
files.forEach(file => {
const fileReader = new FileReader()
fileReader.readAsDataURL(file)
fileReader.onload = () => {
currentEditor.chain().insertContentAt(currentEditor.state.selection.anchor, {
type: 'image',
attrs: {
src: fileReader.result,
},
}).focus().run()
}
})
},
}),
],
content: `
<h1>
Try to paste or drop files into this editor
</h1>
<p></p>
<p></p>
<p></p>
<p></p>
<p></p>
`,
})
},
beforeUnmount() {
this.editor.destroy()
},
})
</script>
<style lang="scss">
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
img {
display: block;
height: auto;
margin: 1.5rem 0;
max-width: 100%;
&.ProseMirror-selectednode {
outline: 3px solid var(--purple);
}
}
}
</style>

View File

@ -0,0 +1,61 @@
import './styles.scss'
import Document from '@tiptap/extension-document'
import HardBreak from '@tiptap/extension-hard-break'
import Heading from '@tiptap/extension-heading'
import InvisibleCharacters from '@tiptap/extension-invisible-characters'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { EditorContent, useEditor } from '@tiptap/react'
import React from 'react'
export default () => {
const editor = useEditor({
extensions: [
Document,
Paragraph,
Heading,
Text,
InvisibleCharacters,
HardBreak,
],
content: `
<h1>
This is a heading.
</h1>
<p>
This<br>is<br>a<br>paragraph.
</p>
<p>
This is a paragraph, but without breaks.
</p>
`,
})
if (!editor) {
return false
}
return (
<div>
<div className="control-group">
<div className="button-group">
<button onClick={() => editor.commands.showInvisibleCharacters()}>Show invisible characters</button>
{/* Works as well */}
{/* <button onClick={() => editor.commands.showInvisibleCharacters(false)}>showInvisibleCharacters(false)</button> */}
<button onClick={() => editor.commands.hideInvisibleCharacters()}>Hide invisible characters</button>
<button onClick={() => editor.commands.toggleInvisibleCharacters()}>Toggle invisible characters</button>
</div>
<div>
<input type="checkbox" id="show-invisible-characters" checked={editor.storage.invisibleCharacters.visibility()} onChange={event => {
const value = event.currentTarget.checked
editor.commands.showInvisibleCharacters(value)
}} />
<label htmlFor="show-invisible-characters">Show invisibles</label>
</div>
</div>
<EditorContent editor={editor} />
</div>
)
}

View File

@ -0,0 +1,9 @@
context('/src/Extensions/InvisibleCharacters/React/', () => {
before(() => {
cy.visit('/src/Extensions/InvisibleCharacters/React/')
})
it('should have invisible characters', () => {
cy.get('[class*="Tiptap-invisible-character"]').should('exist')
})
})

View File

@ -0,0 +1,6 @@
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
}

View File

@ -0,0 +1,9 @@
context('/src/Extensions/InvisibleCharacters/Vue/', () => {
before(() => {
cy.visit('/src/Extensions/InvisibleCharacters/Vue/')
})
it('should have invisible characters', () => {
cy.get('[class*="Tiptap-invisible-character"]').should('exist')
})
})

View File

@ -0,0 +1,82 @@
<template>
<div v-if="editor" class="container">
<div class="control-group">
<div class="button-group">
<button @click="editor.commands.showInvisibleCharacters()">Show invisible characters</button>
<!-- Works as well -->
<!-- <button @click="editor.commands.showInvisibleCharacters(false)">showInvisibleCharacters(false)</button> -->
<button @click="editor.commands.hideInvisibleCharacters()">Hide invisible characters</button>
<button @click="editor.commands.toggleInvisibleCharacters()">Toggle invisible characters</button>
</div>
<div>
<input
type="checkbox"
id="show-invisible-characters"
:checked="editor.storage.invisibleCharacters.visibility()"
@change="event => editor.commands.showInvisibleCharacters(event.currentTarget.checked)"
>
<label for="show-invisible-characters">Show invisibles</label>
</div>
</div>
<editor-content :editor="editor" />
</div>
</template>
<script>
import Document from '@tiptap/extension-document'
import HardBreak from '@tiptap/extension-hard-break'
import Heading from '@tiptap/extension-heading'
import InvisibleCharacters from '@tiptap/extension-invisible-characters'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { Editor, EditorContent } from '@tiptap/vue-3'
export default {
components: {
EditorContent,
},
data() {
return {
editor: null,
}
},
mounted() {
this.editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Heading,
InvisibleCharacters,
HardBreak,
],
content: `
<h1>
This is a heading.
</h1>
<p>
This<br>is<br>a<br>paragraph.
</p>
<p>
This is a paragraph, but without breaks.
</p>
`,
})
},
beforeUnmount() {
this.editor.destroy()
},
}
</script>
<style lang="scss">
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
}
</style>

View File

@ -0,0 +1,74 @@
import 'katex/dist/katex.min.css'
import './styles.scss'
import { Mathematics } from '@tiptap/extension-mathematics'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import React, { useCallback } from 'react'
export default () => {
const editor = useEditor({
shouldRerenderOnTransaction: true,
extensions: [
StarterKit,
Mathematics,
],
content: `
<h1>
This editor supports $\\LaTeX$ math expressions.
</h1>
<p>
Did you know that $3 * 3 = 9$? Isn't that crazy? Also Pythagoras' theorem is $a^2 + b^2 = c^2$.<br />
Also the square root of 2 is $\\sqrt{2}$. If you want to know more about $\\LaTeX$ visit <a href="https://katex.org/docs/supported.html" target="_blank">katex.org</a>.
</p>
<code>
<pre>$\\LaTeX$</pre>
</code>
<p>
Do you want go deeper? Here is a list of all supported functions:
</p>
<ul>
<li>$\\sin(x)$</li>
<li>$\\cos(x)$</li>
<li>$\\tan(x)$</li>
<li>$\\log(x)$</li>
<li>$\\ln(x)$</li>
<li>$\\sqrt{x}$</li>
<li>$\\sum_{i=0}^n x_i$</li>
<li>$\\int_a^b x^2 dx$</li>
<li>$\\frac{1}{x}$</li>
<li>$\\binom{n}{k}$</li>
<li>$\\sqrt[n]{x}$</li>
<li>$\\left(\\frac{1}{x}\\right)$</li>
<li>$\\left\\{\\begin{matrix}x&\\text{if }x>0\\\\0&\\text{otherwise}\\end{matrix}\\right.$</li>
</ul>
`,
})
const toggleEditing = useCallback(e => {
if (!editor) {
return
}
const { checked } = e.target
editor.setEditable(!checked, true)
editor.view.dispatch(editor.view.state.tr.scrollIntoView())
}, [editor])
if (!editor) { return null }
return (
(
<>
<div className="control-group">
<label>
<input type="checkbox" checked={!editor.isEditable} onChange={toggleEditing} />
Readonly
</label>
</div>
<EditorContent editor={editor} />
</>
)
)
}

View File

@ -0,0 +1,21 @@
context('/src/Extensions/Mathematics/React/', () => {
before(() => {
cy.visit('/src/Extensions/Mathematics/React/')
})
// TODO: Write tests
it('should render latex tags when no focus', () => {
cy.get('.ProseMirror').then(() => {
// find latex tags by class .katex
cy.get('.katex').should('exist')
cy.get('.katex').should('have.length', 18)
cy.get('.katex').should('be.visible')
})
})
it('should not render latex tags in codeBlock', () => {
cy.get('.ProseMirror').then(() => {
cy.get('.ProseMirror pre code .katex').should('not.exist')
})
})
})

View File

@ -0,0 +1,33 @@
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
// Mathematics extension styles
.Tiptap-mathematics-editor {
background: #202020;
color: #fff;
font-family: monospace;
padding: 0.2rem 0.5rem;
}
.Tiptap-mathematics-render {
padding: 0 0.25rem;
&--editable {
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #eee;
}
}
}
.Tiptap-mathematics-editor,
.Tiptap-mathematics-render {
border-radius: 0.25rem;
display: inline-block;
}
}

View File

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

View File

@ -0,0 +1,126 @@
<template>
<div v-if="editor" class="container">
<div class="control-group">
<label>
<input
type="checkbox"
:checked="!isEditable"
@change="toggleEditing"
>
Readonly
</label>
</div>
<editor-content :editor="editor" />
</div>
</template>
<script>
import 'katex/dist/katex.min.css'
import { Mathematics } from '@tiptap/extension-mathematics'
import StarterKit from '@tiptap/starter-kit'
import { Editor, EditorContent } from '@tiptap/vue-3'
import { defineComponent } from 'vue'
export default defineComponent({
components: {
EditorContent,
},
data() {
return {
editor: null,
isEditable: true,
}
},
mounted() {
this.editor = new Editor({
extensions: [
StarterKit,
Mathematics,
],
content: `
<h1>
This editor supports $\\LaTeX$ math expressions.
</h1>
<p>
Did you know that $3 * 3 = 9$? Isn't that crazy? Also Pythagoras' theorem is $a^2 + b^2 = c^2$.<br />
Also the square root of 2 is $\\sqrt{2}$. If you want to know more about $\\LaTeX$ visit <a href="https://katex.org/docs/supported.html" target="_blank">katex.org</a>.
</p>
<code>
<pre>$\\LaTeX$</pre>
</code>
<p>
Do you want go deeper? Here is a list of all supported functions:
</p>
<ul>
<li>$\\sin(x)$</li>
<li>$\\cos(x)$</li>
<li>$\\tan(x)$</li>
<li>$\\log(x)$</li>
<li>$\\ln(x)$</li>
<li>$\\sqrt{x}$</li>
<li>$\\sum_{i=0}^n x_i$</li>
<li>$\\int_a^b x^2 dx$</li>
<li>$\\frac{1}{x}$</li>
<li>$\\binom{n}{k}$</li>
<li>$\\sqrt[n]{x}$</li>
<li>$\\left(\\frac{1}{x}\\right)$</li>
<li>$\\left\\{\\begin{matrix}x&\\text{if }x>0\\\\0&\\text{otherwise}\\end{matrix}\\right.$</li>
</ul>
`,
})
},
beforeUnmount() {
this.editor.destroy()
},
methods: {
toggleEditing() {
this.isEditable = !this.isEditable
if (this.editor) {
this.editor.setEditable(this.isEditable)
}
},
},
})
</script>
<style lang="scss">
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
// Mathematics extension styles
.Tiptap-mathematics-editor {
background: #202020;
color: #fff;
font-family: monospace;
padding: 0.2rem 0.5rem;
}
.Tiptap-mathematics-render {
padding: 0 0.25rem;
&--editable {
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #eee;
}
}
}
.Tiptap-mathematics-editor,
.Tiptap-mathematics-render {
border-radius: 0.25rem;
display: inline-block;
}
}
</style>

View File

@ -0,0 +1,63 @@
import { TextSelection } from '@tiptap/pm/state'
export const ToCItem = ({ item, onItemClick }) => {
return (
<div className={`${item.isActive && !item.isScrolledOver ? 'is-active' : ''} ${item.isScrolledOver ? 'is-scrolled-over' : ''}`} style={{
'--level': item.level,
}}>
<a href={`#${item.id}`} onClick={e => onItemClick(e, item.id)} data-item-index={item.itemIndex}>{item.textContent}</a>
</div>
)
}
export const ToCEmptyState = () => {
return (
<div className="empty-state">
<p>Start editing your document to see the outline.</p>
</div>
)
}
export const ToC = ({
items = [],
editor,
}) => {
if (items.length === 0) {
return <ToCEmptyState />
}
const onItemClick = (e, id) => {
e.preventDefault()
if (editor) {
const element = editor.view.dom.querySelector(`[data-toc-id="${id}"`)
const pos = editor.view.posAtDOM(element, 0)
// set focus
const tr = editor.view.state.tr
tr.setSelection(new TextSelection(tr.doc.resolve(pos)))
editor.view.dispatch(tr)
editor.view.focus()
if (history.pushState) { // eslint-disable-line
history.pushState(null, null, `#${id}`) // eslint-disable-line
}
window.scrollTo({
top: element.getBoundingClientRect().top + window.scrollY,
behavior: 'smooth',
})
}
}
return (
<>
{items.map((item, i) => (
<ToCItem onItemClick={onItemClick} key={item.id} item={item} index={i + 1} />
))}
</>
)
}

View File

@ -0,0 +1,48 @@
import './styles.scss'
import { getHierarchicalIndexes, TableOfContents } from '@tiptap/extension-table-of-contents'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import React, { useState } from 'react'
import { content as bookContent } from '../content.js'
import { ToC } from './ToC.jsx'
const MemorizedToC = React.memo(ToC)
export default () => {
const [items, setItems] = useState([])
const editor = useEditor({
extensions: [
StarterKit,
TableOfContents.configure({
getIndex: getHierarchicalIndexes,
onUpdate(content) {
setItems(content)
},
}),
],
content: bookContent,
})
if (!editor) {
return null
}
return (
<div className="col-group">
<div className="main">
<EditorContent editor={editor} />
</div>
<div className="sidebar">
<div className="sidebar-options">
<div className="label-large">Table of contents</div>
<div className="table-of-contents">
<MemorizedToC editor={editor} items={items} />
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,100 @@
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
}
.col-group {
display: flex;
flex-direction: row;
@media (max-width: 540px) {
flex-direction: column-reverse;
}
}
.main {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: auto;
}
.sidebar {
border-left: 1px solid var(--gray-3);
flex-grow: 0;
flex-shrink: 0;
padding: 1rem;
width: 15rem;
position: sticky;
height: 100vh;
top: 0;
@media (min-width: 800px) {
width: 20rem;
}
@media (max-width: 540px) {
border-bottom: 1px solid var(--gray-3);
border-left: unset;
width: 100%;
height: auto;
position: unset;
padding: 1.5rem;
}
}
.sidebar-options {
align-items: flex-start;
display: flex;
flex-direction: column;
height: 100%;
gap: 1rem;
position: sticky;
top: 1rem;
}
.table-of-contents {
display: flex;
flex-direction: column;
font-size: 0.875rem;
gap: 0.25rem;
overflow: auto;
text-decoration: none;
> div {
border-radius: 0.25rem;
padding-left: calc(0.875rem * (var(--level) - 1));
transition: all 0.2s cubic-bezier(0.65,0.05,0.36,1);
&:hover {
background-color: var(--gray-2);
}
}
.empty-state {
color: var(--gray-5);
user-select: none;
}
.is-active a {
color: var(--purple);
}
.is-scrolled-over a {
color: var(--gray-5);
}
a {
color: var(--black);
display: flex;
gap: 0.25rem;
text-decoration: none;
&::before {
content:attr(data-item-index)".";
}
}
}

View File

@ -0,0 +1,67 @@
<template>
<template v-if="items.length === 0">
<ToCEmptyState />
</template>
<template v-else>
<ToCItem
v-for="(item, i) in items"
:key="item.id"
:item="item"
:index="i + 1"
@item-click="onItemClick"
/>
</template>
</template>
<script>
import { TextSelection } from '@tiptap/pm/state'
import { defineComponent } from 'vue'
import ToCEmptyState from './ToCEmptyState.vue'
import ToCItem from './ToCItem.vue'
export default defineComponent({
components: {
ToCItem,
ToCEmptyState,
},
props: {
items: {
type: Array,
default: () => [],
},
editor: {
type: Object,
required: true,
},
},
methods: {
onItemClick(e, id) {
if (this.editor) {
const element = this.editor.view.dom.querySelector(`[data-toc-id="${id}"`)
const pos = this.editor.view.posAtDOM(element, 0)
// set focus
const tr = this.editor.view.state.tr
tr.setSelection(new TextSelection(tr.doc.resolve(pos)))
this.editor.view.dispatch(tr)
this.editor.view.focus()
if (history.pushState) { // eslint-disable-line
history.pushState(null, null, `#${id}`) // eslint-disable-line
}
window.scrollTo({
top: element.getBoundingClientRect().top + window.scrollY,
behavior: 'smooth',
})
}
},
},
})
</script>

View File

@ -0,0 +1,5 @@
<template>
<div class="empty-state">
<p>Start editing your document to see the outline.</p>
</div>
</template>

View File

@ -0,0 +1,38 @@
<template>
<div
:class="{
'is-active': item.isActive && !item.isScrolledOver,
'is-scrolled-over': item.isScrolledOver,
}"
:style="{ '--level': item.level }"
>
<a :href="'#' + item.id" @click.prevent="onItemClick" :data-item-index="item.itemIndex">
{{ item.textContent }}
</a>
</div>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
props: {
item: {
type: Object,
required: true,
},
index: {
type: Number,
required: true,
},
},
emits: ['item-click'],
methods: {
onItemClick(event) {
this.$emit('item-click', event, this.item.id)
},
},
})
</script>

View File

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

View File

@ -0,0 +1,163 @@
<template>
<div class="col-group">
<div class="main">
<editor-content :editor="editor" />
</div>
<div class="sidebar">
<div class="sidebar-options">
<div class="label-large">Table of contents</div>
<div class="table-of-contents">
<template v-if="editor">
<ToC :editor="editor" :items="items" />
</template>
</div>
</div>
</div>
</div>
</template>
<script>
import { getHierarchicalIndexes, TableOfContents } from '@tiptap/extension-table-of-contents'
import StarterKit from '@tiptap/starter-kit'
import { Editor, EditorContent } from '@tiptap/vue-3'
import { defineComponent } from 'vue'
import { content as bookContent } from '../content.js'
import ToC from './ToC.vue'
export default defineComponent({
components: {
EditorContent,
ToC,
},
data() {
return {
editor: null,
items: [],
}
},
mounted() {
this.editor = new Editor({
extensions: [
StarterKit,
TableOfContents.configure({
getIndex: getHierarchicalIndexes,
onUpdate: content => {
this.items = content
},
}),
],
content: bookContent,
})
},
beforeUnmount() {
this.editor.destroy()
},
})
</script>
<style lang="scss">
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
}
.col-group {
display: flex;
flex-direction: row;
@media (max-width: 540px) {
flex-direction: column-reverse;
}
}
.main {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: auto;
}
.sidebar {
border-left: 1px solid var(--gray-3);
flex-grow: 0;
flex-shrink: 0;
padding: 1rem;
width: 15rem;
position: sticky;
height: 100vh;
top: 0;
@media (min-width: 800px) {
width: 20rem;
}
@media (max-width: 540px) {
border-bottom: 1px solid var(--gray-3);
border-left: unset;
width: 100%;
height: auto;
position: unset;
padding: 1.5rem;
}
}
.sidebar-options {
align-items: flex-start;
display: flex;
flex-direction: column;
height: 100%;
gap: 1rem;
position: sticky;
top: 1rem;
}
.table-of-contents {
display: flex;
flex-direction: column;
font-size: 0.875rem;
gap: 0.25rem;
overflow: auto;
text-decoration: none;
> div {
border-radius: 0.25rem;
padding-left: calc(0.875rem * (var(--level) - 1));
transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1);
&:hover {
background-color: var(--gray-2);
}
}
.empty-state {
color: var(--gray-5);
user-select: none;
}
.is-active a {
color: var(--purple);
}
.is-scrolled-over a {
color: var(--gray-5);
}
a {
color: var(--black);
display: flex;
gap: 0.25rem;
text-decoration: none;
&::before {
content: attr(data-item-index) '.';
}
}
}
</style>

View File

@ -0,0 +1,27 @@
export const content = `
<h1>Text editor</h1>
<p>A text editor is a type of computer program that edits plain text. Such programs are sometimes known as "notepad" software (e.g. Windows Notepad). Text editors are provided with operating systems and software development packages, and can be used to change files such as configuration files, documentation files and programming language source code.</p>
<h2>Plain text and rich text</h2>
<p>There are important differences between plain text (created and edited by text editors) and rich text (such as that created by word processors or desktop publishing software).</p>
<p>Plain text exclusively consists of character representation. Each character is represented by a fixed-length sequence of one, two, or four bytes, or as a variable-length sequence of one to four bytes, in accordance to specific character encoding conventions, such as ASCII, ISO/IEC 2022, Shift JIS, UTF-8, or UTF-16. These conventions define many printable characters, but also non-printing characters that control the flow of the text, such as space, line break, and page break. Plain text contains no other information about the text itself, not even the character encoding convention employed. Plain text is stored in text files, although text files do not exclusively store plain text. Since the early days of computers, plain text was (once by necessity and now by convention) generally displayed using a monospace font, such that horizontal alignment and columnar formatting were sometimes done using whitespace characters.</p>
<p>Rich text, on the other hand, may contain metadata, character formatting data (e.g. typeface, size, weight and style), paragraph formatting data (e.g. indentation, alignment, letter and word distribution, and space between lines or other paragraphs), and page specification data (e.g. size, margin and reading direction). Rich text can be very complex. Rich text can be saved in binary format (e.g. DOC), text files adhering to a markup language (e.g. RTF or HTML), or in a hybrid form of both (e.g. Office Open XML).</p>
<p>Text editors are intended to open and save text files containing either plain text or anything that can be interpreted as plain text, including the markup for rich text or the markup for something else (e.g. SVG).</p>
<h2>History</h2>
<p>Before text editors existed, computer text was punched into cards with keypunch machines. Physical boxes of these thin cardboard cards were then inserted into a card reader. Magnetic tape, drum and disk card image files created from such card decks often had no line-separation characters at all, and assumed fixed-length 80- or 90-character records. An alternative to cards was Punched tape. It could be created by some teleprinters (such as the Teletype), which used special characters to indicate ends of records. Some early operating systems included batch text editors, either integrated with language processors or as separate utility programs; one early example was the ability to edit SQUOZE source files for SCAT in SHARE Operating System.</p>
<p>The first interactive text editors were "line editors" oriented to teleprinter- or typewriter-style terminals without displays. Commands (often a single keystroke) effected edits to a file at an imaginary insertion point called the "cursor". Edits were verified by typing a command to print a small section of the file, and periodically by printing the entire file. In some line editors, the cursor could be moved by commands that specified the line number in the file, text strings (context) for which to search, and eventually regular expressions. Line editors were major improvements over keypunching. Some line editors could be used by keypunch; editing commands could be taken from a deck of cards and applied to a specified file. Some common line editors supported a "verify" mode in which change commands displayed the altered lines.</p>
<h5>Weird h5 headline</h5>
<p>When computer terminals with video screens became available, screen-based text editors (sometimes called just "screen editors") became common. One of the earliest full-screen editors was O26, which was written for the operator console of the CDC 6000 series computers in 1967. Another early full-screen editor was vi. Written in the 1970s, it is still a standard editor on Unix and Linux operating systems. Also written in the 1970s was the UCSD Pascal Screen Oriented Editor, which was optimized both for indented source code and general text. Emacs, one of the first free and open-source software projects, is another early full-screen or real-time editor, one that was ported to many systems. A full-screen editor's ease-of-use and speed (compared to the line-based editors) motivated many early purchases of video terminals.</p>
<h2>Types of text editors</h2>
<h3>Simple text editors</h3>
<p>Some text editors are small and simple, while others offer broad and complex functions. For example, Unix and Unix-like operating systems have the pico editor (or a variant), but many also include the vi and Emacs editors. Microsoft Windows systems come with the simple Notepad, though many peopleespecially programmersprefer other editors with more features. Under Apple Macintosh's classic Mac OS there was the native TeachText later replaced by SimpleText in 1994, which was replaced in Mac OS X by TextEdit, which combines features of a text editor with those typical of a word processor such as rulers, margins and multiple font selection. These features are not available simultaneously, but must be switched by user command, or through the program automatically determining the file type.</p>
<h3>Word editors</h3>
<p>Most word processors can read and write files in plain text format, allowing them to open files saved from text editors. Saving these files from a word processor, however, requires ensuring the file is written in plain text format, and that any text encoding or BOM settings won't obscure the file for its intended use. Non-WYSIWYG word processors, such as WordStar, are more easily pressed into service as text editors, and in fact were commonly used as such during the 1980s. The default file format of these word processors often resembles a markup language, with the basic format being plain text and visual formatting achieved using non-printing control characters or escape sequences. Later word processors like Microsoft Word store their files in a binary format and are almost never used to edit plain text files.</p>
`

View File

@ -0,0 +1,32 @@
import './styles.scss'
import UniqueID from '@tiptap/extension-unique-id'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import React from 'react'
export default () => {
const editor = useEditor({
extensions: [
StarterKit,
UniqueID.configure({
types: ['heading', 'paragraph'],
}),
],
content: `
<h1>
This is a very unique heading.
</h1>
<p>
This is a unique paragraph. Its so unique, it even has an ID attached to it.
</p>
<p>
And this one, too.
</p>
`,
})
return (
<EditorContent editor={editor} />
)
}

View File

@ -0,0 +1,13 @@
context('/src/Extensions/UniqueID/React/', () => {
before(() => {
cy.visit('/src/Extensions/UniqueID/React/')
})
it('has a heading with an unique ID', () => {
cy.get('.ProseMirror h1').should('have.attr', 'data-id')
})
it('has a paragraph with an unique ID', () => {
cy.get('.ProseMirror p').should('have.attr', 'data-id')
})
})

View File

@ -0,0 +1,28 @@
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
/* Unique data id */
[data-id] {
border: 2px solid var(--black);
border-radius: 0.5rem;
padding: 2.5rem 1rem 1rem;
position: relative;
&::before {
background-color: var(--black);
border-radius: 0 0 0.5rem 0;
color: var(--white);
content: attr(data-id);
font-size: 0.75rem;
font-weight: bold;
left: 0;
line-height: 1.5;
padding: 0.25rem 0.5rem;
position: absolute;
top: 0;
}
}
}

View File

@ -0,0 +1,13 @@
context('/src/Extensions/UniqueID/Vue/', () => {
before(() => {
cy.visit('/src/Extensions/UniqueID/Vue/')
})
it('has a heading with an unique ID', () => {
cy.get('.ProseMirror h1').should('have.attr', 'data-id')
})
it('has a paragraph with an unique ID', () => {
cy.get('.ProseMirror p').should('have.attr', 'data-id')
})
})

View File

@ -0,0 +1,85 @@
<template>
<editor-content :editor="editor" />
</template>
<script>
import Document from '@tiptap/extension-document'
import Heading from '@tiptap/extension-heading'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import UniqueID from '@tiptap/extension-unique-id'
import { Editor, EditorContent } from '@tiptap/vue-3'
import { defineComponent } from 'vue'
export default defineComponent({
components: {
EditorContent,
},
data() {
return {
editor: null,
}
},
mounted() {
this.editor = new Editor({
extensions: [
Document,
Heading,
Paragraph,
Text,
UniqueID.configure({
types: ['heading', 'paragraph'],
}),
],
content: `
<h1>
This is a very unique heading.
</h1>
<p>
This is a unique paragraph. Its so unique, it even has an ID attached to it.
</p>
<p>
And this one, too.
</p>
`,
})
},
beforeUnmount() {
this.editor.destroy()
},
})
</script>
<style lang="scss">
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
/* Unique data id */
[data-id] {
border: 2px solid var(--black);
border-radius: 0.5rem;
padding: 2.5rem 1rem 1rem;
position: relative;
&::before {
background-color: var(--black);
border-radius: 0 0 0.5rem 0;
color: var(--white);
content: attr(data-id);
font-size: 0.75rem;
font-weight: bold;
left: 0;
line-height: 1.5;
padding: 0.25rem 0.5rem;
position: absolute;
top: 0;
}
}
}
</style>

View File

@ -0,0 +1,50 @@
import './styles.scss'
import { TiptapTransformer } from '@hocuspocus/transformer'
import Collaboration from '@tiptap/extension-collaboration'
import Document from '@tiptap/extension-document'
import Heading from '@tiptap/extension-heading'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import UniqueID from '@tiptap/extension-unique-id'
import { EditorContent, useEditor } from '@tiptap/react'
import React from 'react'
const doc = TiptapTransformer.toYdoc({
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'This is a predefined, collaborative ydoc' }],
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'Let\'s see how this works out.' }],
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'This should now generate unique IDs correctly' }],
},
],
})
export default () => {
const editor = useEditor({
extensions: [
Document,
Heading,
Paragraph,
Text,
Collaboration.configure({
document: doc,
}),
UniqueID.configure({
types: ['heading', 'paragraph'],
filterTransaction: () => true,
}),
],
})
return <EditorContent editor={editor} />
}

View File

@ -0,0 +1,13 @@
context('/src/Extensions/UniqueID/React/', () => {
beforeEach(() => {
cy.visit('/src/Extensions/UniqueID/React/')
})
it('has a heading with an unique ID', () => {
cy.get('.ProseMirror h1').should('have.attr', 'data-id')
})
it('has a paragraph with an unique ID', () => {
cy.get('.ProseMirror p').should('have.attr', 'data-id')
})
})

View File

@ -0,0 +1,55 @@
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
/* Unique data id */
[data-id] {
border: 2px solid var(--black);
border-radius: 0.5rem;
padding: 2.5rem 1rem 1rem;
position: relative;
&::before {
background-color: var(--black);
border-radius: 0 0 0.5rem 0;
color: var(--white);
content: attr(data-id);
font-size: 0.75rem;
font-weight: bold;
left: 0;
line-height: 1.5;
padding: 0.25rem 0.5rem;
position: absolute;
top: 0;
}
}
/* Give a remote user a caret */
.collaboration-carets__caret {
border-left: 1px solid #0d0d0d;
border-right: 1px solid #0d0d0d;
margin-left: -1px;
margin-right: -1px;
pointer-events: none;
position: relative;
word-break: normal;
}
/* Render the username above the caret */
.collaboration-carets__label {
border-radius: 3px 3px 3px 0;
color: #0d0d0d;
font-size: 12px;
font-style: normal;
font-weight: 600;
left: -1px;
line-height: normal;
padding: 0.1rem 0.3rem;
position: absolute;
top: -1.4em;
user-select: none;
white-space: nowrap;
}
}

View File

View File

@ -0,0 +1,77 @@
import './styles.scss'
import Details from '@tiptap/extension-details'
import DetailsContent from '@tiptap/extension-details-content'
import DetailsSummary from '@tiptap/extension-details-summary'
import Placeholder from '@tiptap/extension-placeholder'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import React from 'react'
export default () => {
const editor = useEditor({
extensions: [
StarterKit,
Details.configure({
persist: true,
HTMLAttributes: {
class: 'details',
},
}),
DetailsSummary,
DetailsContent,
Placeholder.configure({
includeChildren: true,
placeholder: ({ node }) => {
if (node.type.name === 'detailsSummary') {
return 'Summary'
}
return null
},
}),
],
content: `
<p>Look at these details</p>
<details>
<summary>This is a summary</summary>
<p>Surprise!</p>
</details>
<p>Nested details are also supported</p>
<details open>
<summary>This is another summary</summary>
<p>And there is even more.</p>
<details>
<summary>We need to go deeper</summary>
<p>Booya!</p>
</details>
</details>
`,
})
if (!editor) {
return null
}
return (
<>
<div className="control-group">
<div className="button-group">
<button onClick={() => editor.chain().focus().setDetails().run()} disabled={!editor.can().setDetails()}>
Set details
</button>
<button onClick={() => editor.chain().focus().unsetDetails().run()} disabled={!editor.can().unsetDetails()}>
Unset details
</button>
<button onClick={() => editor.chain().focus().command(({ tr }) => {
tr.setNodeAttribute(23, 'open', true)
return true
}).run()}>
Force open first details
</button>
</div>
</div>
<EditorContent editor={editor} />
</>
)
}

View File

@ -0,0 +1,62 @@
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
/* Details */
.details {
display: flex;
gap: 0.25rem;
margin: 1.5rem 0;
border: 1px solid var(--gray-3);
border-radius: 0.5rem;
padding: 0.5rem;
summary {
font-weight: 700;
}
> button {
align-items: center;
background: transparent;
border-radius: 4px;
display: flex;
font-size: 0.625rem;
height: 1.25rem;
justify-content: center;
line-height: 1;
margin-top: 0.1rem;
padding: 0;
width: 1.25rem;
&:hover {
background-color: var(--gray-3);
}
&::before {
content: '\25B6';
}
}
&.is-open > button::before {
transform: rotate(90deg);
}
> div {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
> [data-type="detailsContent"] > :last-child {
margin-bottom: 0.5rem;
}
}
.details {
margin: 0.5rem 0;
}
}
}

View File

View File

@ -0,0 +1,71 @@
context('/src/Nodes/Details/Vue/', () => {
before(() => {
cy.visit('/src/Nodes/Details/Vue/')
})
beforeEach(() => {
cy.get('.ProseMirror').then(([{ editor }]) => {
editor.commands.setContent('<p>Example Text</p>')
cy.get('.ProseMirror').type('{selectall}')
})
})
it('should parse details tags correctly', () => {
cy.get('.ProseMirror').then(([{ editor }]) => {
editor.commands.setContent('<details><summary>Summary</summary><p>Content</p></details>')
expect(editor.getHTML()).to.eq('<details class="details"><summary>Summary</summary><div data-type="detailsContent"><p>Content</p></div></details>')
})
})
it('should parse details tags without paragraphs correctly', () => {
cy.get('.ProseMirror').then(([{ editor }]) => {
editor.commands.setContent('<details><summary>Summary</summary>Content</details>')
expect(editor.getHTML()).to.eq('<details class="details"><summary>Summary</summary><div data-type="detailsContent"><p>Content</p></div></details>')
})
})
it('setDetails should make the selected line a details node', () => {
cy.get('.ProseMirror [data-type="details"]')
.should('not.exist')
cy.get('button:first')
.click()
cy.get('.ProseMirror')
.find('[data-type="details"] [data-type="detailsContent"]')
.should('contain', 'Example Text')
})
it('unsetDetails should make the selected line a paragraph node', () => {
cy.get('button:first')
.click()
cy.get('.ProseMirror [data-type="details"]')
.should('exist')
cy.get('button:nth-child(2)')
.click()
cy.get('.ProseMirror [data-type="details"]')
.should('not.exist')
})
// TODO: can use click on button because cypress is mutating the button which leads to a re-render in ProseMirror
// it('should toggle content', () => {
// cy.get('button:first')
// .click()
// cy.get('.ProseMirror [data-type="details"] [data-type="detailsContent"]')
// .should('not.be.visible')
// cy.get('.ProseMirror')
// .find('[data-type="details"] button').then($el => {
// console.log($el, Cypress.dom.isAttached($el))
// })
// .click()
// cy.get('.ProseMirror [data-type="details"] [data-type="detailsContent"]')
// .should('be.visible')
// })
})

View File

@ -0,0 +1,146 @@
<template>
<div v-if="editor" class="container">
<div class="control-group">
<div class="button-group">
<button @click="editor.chain().focus().setDetails().run()" :disabled="!editor.can().setDetails()">
Set details
</button>
<button @click="editor.chain().focus().unsetDetails().run()" :disabled="!editor.can().unsetDetails()">
Unset details
</button>
</div>
</div>
</div>
<editor-content :editor="editor" />
</template>
<script>
import Details from '@tiptap/extension-details'
import DetailsContent from '@tiptap/extension-details-content'
import DetailsSummary from '@tiptap/extension-details-summary'
import Placeholder from '@tiptap/extension-placeholder'
import StarterKit from '@tiptap/starter-kit'
import { Editor, EditorContent } from '@tiptap/vue-3'
export default {
components: {
EditorContent,
},
data() {
return {
editor: null,
}
},
mounted() {
this.editor = new Editor({
extensions: [
StarterKit,
Details.configure({
persist: true,
HTMLAttributes: {
class: 'details',
},
}),
DetailsSummary,
DetailsContent,
Placeholder.configure({
includeChildren: true,
placeholder: ({ node }) => {
if (node.type.name === 'detailsSummary') {
return 'Summary'
}
return null
},
}),
],
content: `
<p>Look at these details</p>
<details>
<summary>This is a summary</summary>
<p>Surprise!</p>
</details>
<p>Nested details are also supported</p>
<details open>
<summary>This is another summary</summary>
<p>And there is even more.</p>
<details>
<summary>We need to go deeper</summary>
<p>Booya!</p>
</details>
</details>
`,
})
},
beforeUnmount() {
this.editor.destroy()
},
}
</script>
<style lang="scss">/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
/* Details */
.details {
display: flex;
gap: 0.25rem;
margin: 1.5rem 0;
border: 1px solid var(--gray-3);
border-radius: 0.5rem;
padding: 0.5rem;
summary {
font-weight: 700;
}
> button {
align-items: center;
background: transparent;
border-radius: 4px;
display: flex;
font-size: 0.625rem;
height: 1.25rem;
justify-content: center;
line-height: 1;
margin-top: 0.1rem;
padding: 0;
width: 1.25rem;
&:hover {
background-color: var(--gray-3);
}
&::before {
content: '\25B6';
}
}
&.is-open > button::before {
transform: rotate(90deg);
}
> div {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
> [data-type="detailsContent"] > :last-child {
margin-bottom: 0.5rem;
}
}
.details {
margin: 0.5rem 0;
}
}
}
</style>

View File

@ -0,0 +1,75 @@
import './EmojiList.scss'
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from 'react'
export const EmojiList = forwardRef((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0)
const selectItem = index => {
const item = props.items[index]
if (item) {
props.command({ name: item.name })
}
}
const upHandler = () => {
setSelectedIndex(((selectedIndex + props.items.length) - 1) % props.items.length)
}
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length)
}
const enterHandler = () => {
selectItem(selectedIndex)
}
useEffect(() => setSelectedIndex(0), [props.items])
useImperativeHandle(ref, () => {
return {
onKeyDown: x => {
if (x.event.key === 'ArrowUp') {
upHandler()
return true
}
if (x.event.key === 'ArrowDown') {
downHandler()
return true
}
if (x.event.key === 'Enter') {
enterHandler()
return true
}
return false
},
}
}, [upHandler, downHandler, enterHandler])
return (
<div className="dropdown-menu">
{props.items.map((item, index) => (
<button
className={index === selectedIndex ? 'is-selected' : ''}
key={index}
onClick={() => selectItem(index)}
>
{ item.fallbackImage
? <img src={item.fallbackImage} align="absmiddle" />
: item.emoji
}
:{item.name}:
</button>
))}
</div>
)
})

View File

@ -0,0 +1,36 @@
/* Dropdown menu */
.dropdown-menu {
background: var(--white);
border: 1px solid var(--gray-1);
border-radius: 0.7rem;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 0.1rem;
overflow: auto;
padding: 0.4rem;
position: relative;
button {
align-items: center;
background-color: transparent;
display: flex;
gap: 0.25rem;
text-align: left;
width: 100%;
&:hover,
&:hover.is-selected {
background-color: var(--gray-3);
}
&.is-selected {
background-color: var(--gray-2);
}
img {
height: 1em;
width: 1em;
}
}
}

View File

View File

@ -0,0 +1,63 @@
import './styles.scss'
import Emoji, { gitHubEmojis } from '@tiptap/extension-emoji'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import React from 'react'
import suggestion from './suggestion.js'
export default () => {
const editor = useEditor({
extensions: [
StarterKit,
Emoji.configure({
emojis: gitHubEmojis,
enableEmoticons: true,
suggestion,
}),
],
content: `
<p>
These <span data-type="emoji" data-name="smiley"></span>
are <span data-type="emoji" data-name="fire"></span>
some <span data-type="emoji" data-name="smiley_cat"></span>
emojis <span data-type="emoji" data-name="exploding_head"></span>
rendered <span data-type="emoji" data-name="ghost"></span>
as <span data-type="emoji" data-name="massage"></span>
inline <span data-type="emoji" data-name="v"></span>
nodes.
</p>
<p>
Type <code>:</code> to open the autocomplete.
</p>
<p>
Even <span data-type="emoji" data-name="octocat"></span>
custom <span data-type="emoji" data-name="trollface"></span>
emojis <span data-type="emoji" data-name="neckbeard"></span>
are <span data-type="emoji" data-name="rage1"></span>
supported.
</p>
<p>
And unsupported emojis (without a fallback image) are rendered as just the shortcode <span data-type="emoji" data-name="this_does_not_exist"></span>.
</p>
<pre><code>In code blocks all emojis are rendered as plain text. 👩💻👨💻</code></pre>
<p>
There is also support for emoticons. Try typing <code><3</code>.
</p>
`,
})
return (
<>
<div className="control-group">
<div className="button-group">
<button onClick={() => editor.chain().focus().setEmoji('zap').run()}>
Insert
</button>
</div>
</div>
<EditorContent editor={editor} />
</>
)
}

View File

@ -0,0 +1,14 @@
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
// Emoji extension styles
[data-type="emoji"] {
img {
height: 1em;
width: 1em;
}
}
}

View File

@ -0,0 +1,67 @@
import { ReactRenderer } from '@tiptap/react'
import tippy from 'tippy.js'
import { EmojiList } from './EmojiList.jsx'
export default {
items: ({ editor, query }) => {
return editor.storage.emoji.emojis
.filter(({ shortcodes, tags }) => {
return (
shortcodes.find(shortcode => shortcode.startsWith(query.toLowerCase()))
|| tags.find(tag => tag.startsWith(query.toLowerCase()))
)
})
.slice(0, 5)
},
allowSpaces: false,
render: () => {
let component
let popup
return {
onStart: props => {
component = new ReactRenderer(EmojiList, {
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()
component.destroy()
return true
}
return component.ref?.onKeyDown(props)
},
onExit() {
popup[0].destroy()
component.destroy()
},
}
},
}

View File

@ -0,0 +1,129 @@
<template>
<div class="dropdown-menu">
<button
:class="{ 'is-selected': index === selectedIndex }"
v-for="(item, index) in items"
:key="index"
@click="selectItem(index)"
>
<img v-if="item.fallbackImage" :src="item.fallbackImage" align="absmiddle">
<template v-else>
{{ item.emoji }}
</template>
:{{ item.name }}:
</button>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
required: true,
},
command: {
type: Function,
required: true,
},
editor: {
type: Object,
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({ name: item.name })
}
},
},
}
</script>
<style lang="scss">
/* Dropdown menu */
.dropdown-menu {
background: var(--white);
border: 1px solid var(--gray-1);
border-radius: 0.7rem;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 0.1rem;
overflow: auto;
padding: 0.4rem;
position: relative;
button {
align-items: center;
background-color: transparent;
display: flex;
gap: 0.25rem;
text-align: left;
width: 100%;
&:hover,
&:hover.is-selected {
background-color: var(--gray-3);
}
&.is-selected {
background-color: var(--gray-2);
}
img {
height: 1em;
width: 1em;
}
}
}
</style>

View File

View File

@ -0,0 +1,92 @@
<template>
<div class="control-group">
<div class="button-group">
<button @click="editor.chain().focus().setEmoji('zap').run()">Insert </button>
</div>
</div>
<editor-content :editor="editor" />
</template>
<script>
import Emoji, { gitHubEmojis } from '@tiptap/extension-emoji'
import StarterKit from '@tiptap/starter-kit'
import { Editor, EditorContent } from '@tiptap/vue-3'
import { defineComponent } from 'vue'
import suggestion from './suggestion.js'
export default defineComponent({
components: {
EditorContent,
},
data() {
return {
editor: null,
}
},
mounted() {
this.editor = new Editor({
extensions: [
StarterKit,
Emoji.configure({
emojis: gitHubEmojis,
enableEmoticons: true,
suggestion,
}),
],
content: `
<p>
These <span data-type="emoji" data-name="smiley"></span>
are <span data-type="emoji" data-name="fire"></span>
some <span data-type="emoji" data-name="smiley_cat"></span>
emojis <span data-type="emoji" data-name="exploding_head"></span>
rendered <span data-type="emoji" data-name="ghost"></span>
as <span data-type="emoji" data-name="massage"></span>
inline <span data-type="emoji" data-name="v"></span>
nodes.
</p>
<p>
Type <code>:</code> to open the autocomplete.
</p>
<p>
Even <span data-type="emoji" data-name="octocat"></span>
custom <span data-type="emoji" data-name="trollface"></span>
emojis <span data-type="emoji" data-name="neckbeard"></span>
are <span data-type="emoji" data-name="rage1"></span>
supported.
</p>
<p>
And unsupported emojis (without a fallback image) are rendered as just the shortcode <span data-type="emoji" data-name="this_does_not_exist"></span>.
</p>
<pre><code>In code blocks all emojis are rendered as plain text. 👩💻👨💻</code></pre>
<p>
There is also support for emoticons. Try typing <code><3</code>.
</p>
`,
})
},
beforeUnmount() {
this.editor.destroy()
},
})
</script>
<style lang="scss">
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
// Emoji extension styles
[data-type="emoji"] {
img {
height: 1em;
width: 1em;
}
}
}
</style>

View File

@ -0,0 +1,65 @@
import { VueRenderer } from '@tiptap/vue-3'
import tippy from 'tippy.js'
import EmojiList from './EmojiList.vue'
export default {
items: ({ editor, query }) => {
return editor.storage.emoji.emojis
.filter(({ shortcodes, tags }) => {
return (
shortcodes.find(shortcode => shortcode.startsWith(query.toLowerCase()))
|| tags.find(tag => tag.startsWith(query.toLowerCase()))
)
})
.slice(0, 5)
},
render: () => {
let component
let popup
return {
onStart: props => {
component = new VueRenderer(EmojiList, {
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()
component.destroy()
return true
}
return component.ref?.onKeyDown(props)
},
onExit() {
popup[0].destroy()
component.destroy()
},
}
},
}

1758
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,14 @@
# @tiptap/extension-details-content
[![Version](https://img.shields.io/npm/v/@tiptap/extension-details-content.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-details-content)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-details-content.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-details-content.svg)](https://www.npmjs.com/package/@tiptap/extension-details-content)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as *New York Times*, *The Guardian* or *Atlassian*.
## Official Documentation
Documentation can be found on the [Tiptap website](https://tiptap.dev).
## License
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).

View File

@ -0,0 +1,48 @@
{
"name": "@tiptap/extension-details-content",
"description": "details content extension for tiptap",
"version": "2.14.0",
"homepage": "https://tiptap.dev/api/nodes/details-content",
"keywords": [
"tiptap",
"tiptap extension"
],
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"main": "dist/index.cjs",
"module": "dist/index.js",
"umd": "dist/index.umd.js",
"types": "dist/index.d.ts",
"files": [
"src",
"dist"
],
"devDependencies": {
"@tiptap/core": "^2.14.0",
"@tiptap/extension-text-style": "^2.14.0"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/extension-text-style": "^2.7.0"
},
"repository": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap",
"directory": "packages/extension-details-content"
},
"scripts": {
"clean": "rm -rf dist",
"build": "npm run clean && rollup -c"
}
}

View File

@ -0,0 +1,5 @@
import { baseConfig } from '@tiptap-shared/rollup-config'
import pkg from './package.json' assert { type: 'json' }
export default baseConfig({ input: 'src/index.ts', pkg })

View File

@ -0,0 +1,169 @@
import {
defaultBlockAt,
findParentNode,
mergeAttributes,
Node,
} from '@tiptap/core'
import { Selection } from '@tiptap/pm/state'
import type { ViewMutationRecord } from '@tiptap/pm/view'
export interface DetailsContentOptions {
/**
* Custom HTML attributes that should be added to the rendered HTML tag.
*/
HTMLAttributes: {
[key: string]: any
},
}
export const DetailsContent = Node.create<DetailsContentOptions>({
name: 'detailsContent',
content: 'block+',
defining: true,
selectable: false,
addOptions() {
return {
HTMLAttributes: {},
}
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
]
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 'data-type': this.name }),
0,
]
},
addNodeView() {
return ({ HTMLAttributes }) => {
const dom = document.createElement('div')
const attributes = mergeAttributes(
this.options.HTMLAttributes,
HTMLAttributes,
{
'data-type': this.name,
hidden: 'hidden',
},
)
Object.entries(attributes).forEach(([key, value]) => dom.setAttribute(key, value))
dom.addEventListener('toggleDetailsContent', () => {
dom.toggleAttribute('hidden')
})
return {
dom,
contentDOM: dom,
ignoreMutation(mutation: ViewMutationRecord) {
if (mutation.type === 'selection') {
return false
}
return !dom.contains(mutation.target) || dom === mutation.target
},
update: updatedNode => {
if (updatedNode.type !== this.type) {
return false
}
return true
},
}
}
},
addKeyboardShortcuts() {
return {
// Escape node on double enter
Enter: ({ editor }) => {
const { state, view } = editor
const { selection } = state
const { $from, empty } = selection
const detailsContent = findParentNode(node => node.type === this.type)(selection)
if (!empty || !detailsContent || !detailsContent.node.childCount) {
return false
}
const fromIndex = $from.index(detailsContent.depth)
const { childCount } = detailsContent.node
const isAtEnd = childCount === fromIndex + 1
if (!isAtEnd) {
return false
}
const defaultChildType = detailsContent.node.type.contentMatch.defaultType
const defaultChildNode = defaultChildType?.createAndFill()
if (!defaultChildNode) {
return false
}
const $childPos = state.doc.resolve(detailsContent.pos + 1)
const lastChildIndex = childCount - 1
const lastChildNode = detailsContent.node.child(lastChildIndex)
const lastChildPos = $childPos.posAtIndex(lastChildIndex, detailsContent.depth)
const lastChildNodeIsEmpty = lastChildNode.eq(defaultChildNode)
if (!lastChildNodeIsEmpty) {
return false
}
// get parent of details node
const above = $from.node(-3)
if (!above) {
return false
}
// get default node type after details node
const after = $from.indexAfter(-3)
const type = defaultBlockAt(above.contentMatchAt(after))
if (!type || !above.canReplaceWith(after, after, type)) {
return false
}
const node = type.createAndFill()
if (!node) {
return false
}
const { tr } = state
const pos = $from.after(-2)
tr.replaceWith(pos, pos, node)
const $pos = tr.doc.resolve(pos)
const newSelection = Selection.near($pos, 1)
tr.setSelection(newSelection)
const deleteFrom = lastChildPos
const deleteTo = lastChildPos + lastChildNode.nodeSize
tr.delete(deleteFrom, deleteTo)
tr.scrollIntoView()
view.dispatch(tr)
return true
},
}
},
})

View File

@ -0,0 +1,5 @@
import { DetailsContent } from './details-content.js'
export * from './details-content.js'
export default DetailsContent

View File

@ -0,0 +1,14 @@
# @tiptap/extension-details-summary
[![Version](https://img.shields.io/npm/v/@tiptap/extension-details-summary.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-details-summary)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-details-summary.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-details-summary.svg)](https://www.npmjs.com/package/@tiptap/extension-details-summary)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as *New York Times*, *The Guardian* or *Atlassian*.
## Official Documentation
Documentation can be found on the [Tiptap website](https://tiptap.dev).
## License
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).

View File

@ -0,0 +1,48 @@
{
"name": "@tiptap/extension-details-summary",
"description": "details summary extension for tiptap",
"version": "2.14.0",
"homepage": "https://tiptap.dev/api/nodes/details-summary",
"keywords": [
"tiptap",
"tiptap extension"
],
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"main": "dist/index.cjs",
"module": "dist/index.js",
"umd": "dist/index.umd.js",
"types": "dist/index.d.ts",
"files": [
"src",
"dist"
],
"devDependencies": {
"@tiptap/core": "^2.14.0",
"@tiptap/extension-text-style": "^2.14.0"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/extension-text-style": "^2.7.0"
},
"repository": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap",
"directory": "packages/extension-details-summary"
},
"scripts": {
"clean": "rm -rf dist",
"build": "npm run clean && rollup -c"
}
}

View File

@ -0,0 +1,5 @@
import { baseConfig } from '@tiptap-shared/rollup-config'
import pkg from './package.json' assert { type: 'json' }
export default baseConfig({ input: 'src/index.ts', pkg })

View File

@ -0,0 +1,44 @@
import { mergeAttributes, Node } from '@tiptap/core'
export interface DetailsSummaryOptions {
/**
* Custom HTML attributes that should be added to the rendered HTML tag.
*/
HTMLAttributes: {
[key: string]: any
},
}
export const DetailsSummary = Node.create<DetailsSummaryOptions>({
name: 'detailsSummary',
content: 'text*',
defining: true,
selectable: false,
isolating: true,
addOptions() {
return {
HTMLAttributes: {},
}
},
parseHTML() {
return [
{
tag: 'summary',
},
]
},
renderHTML({ HTMLAttributes }) {
return [
'summary',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
]
},
})

View File

@ -0,0 +1,5 @@
import { DetailsSummary } from './details-summary.js'
export * from './details-summary.js'
export default DetailsSummary

View File

@ -0,0 +1,14 @@
# @tiptap/extension-details
[![Version](https://img.shields.io/npm/v/@tiptap/extension-details.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-details)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-details.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-details.svg)](https://www.npmjs.com/package/@tiptap/extension-details)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as *New York Times*, *The Guardian* or *Atlassian*.
## Official Documentation
Documentation can be found on the [Tiptap website](https://tiptap.dev).
## License
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).

View File

@ -0,0 +1,48 @@
{
"name": "@tiptap/extension-details",
"description": "details extension for tiptap",
"version": "2.14.0",
"homepage": "https://tiptap.dev/api/nodes/details",
"keywords": [
"tiptap",
"tiptap extension"
],
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"main": "dist/index.cjs",
"module": "dist/index.js",
"umd": "dist/index.umd.js",
"types": "dist/index.d.ts",
"files": [
"src",
"dist"
],
"devDependencies": {
"@tiptap/core": "^2.14.0",
"@tiptap/extension-text-style": "^2.14.0"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/extension-text-style": "^2.7.0"
},
"repository": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap",
"directory": "packages/extension-details"
},
"scripts": {
"clean": "rm -rf dist",
"build": "npm run clean && rollup -c"
}
}

View File

@ -0,0 +1,5 @@
import { baseConfig } from '@tiptap-shared/rollup-config'
import pkg from './package.json' assert { type: 'json' }
export default baseConfig({ input: 'src/index.ts', pkg })

View File

@ -0,0 +1,451 @@
import {
defaultBlockAt,
findChildren,
findParentNode,
isActive,
mergeAttributes,
Node,
} from '@tiptap/core'
import {
Plugin,
PluginKey,
Selection,
TextSelection,
} from '@tiptap/pm/state'
import type { ViewMutationRecord } from '@tiptap/pm/view'
import { findClosestVisibleNode } from './helpers/findClosestVisibleNode.js'
import { isNodeVisible } from './helpers/isNodeVisible.js'
import { setGapCursor } from './helpers/setGapCursor.js'
export interface DetailsOptions {
/**
* Specify if the open status should be saved in the document. Defaults to `false`.
*/
persist: boolean,
/**
* Specifies a CSS class that is set when toggling the content. Defaults to `is-open`.
*/
openClassName: string,
/**
* Custom HTML attributes that should be added to the rendered HTML tag.
*/
HTMLAttributes: {
[key: string]: any
},
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
details: {
/**
* Set a details node
*/
setDetails: () => ReturnType,
/**
* Unset a details node
*/
unsetDetails: () => ReturnType,
}
}
}
export const Details = Node.create<DetailsOptions>({
name: 'details',
content: 'detailsSummary detailsContent',
group: 'block',
defining: true,
isolating: true,
allowGapCursor: false,
addOptions() {
return {
persist: false,
openClassName: 'is-open',
HTMLAttributes: {},
}
},
addAttributes() {
if (!this.options.persist) {
return []
}
return {
open: {
default: false,
parseHTML: element => element.hasAttribute('open'),
renderHTML: ({ open }) => {
if (!open) {
return {}
}
return { open: '' }
},
},
}
},
parseHTML() {
return [
{
tag: 'details',
},
]
},
renderHTML({ HTMLAttributes }) {
return [
'details',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
]
},
addNodeView() {
return ({
editor,
getPos,
node,
HTMLAttributes,
}) => {
const dom = document.createElement('div')
const attributes = mergeAttributes(
this.options.HTMLAttributes,
HTMLAttributes,
{
'data-type': this.name,
},
)
Object.entries(attributes).forEach(([key, value]) => dom.setAttribute(key, value))
const toggle = document.createElement('button')
toggle.type = 'button'
dom.append(toggle)
const content = document.createElement('div')
dom.append(content)
const toggleDetailsContent = (setToValue?: boolean) => {
if (setToValue !== undefined) {
if (setToValue) {
if (dom.classList.contains(this.options.openClassName)) {
return
}
dom.classList.add(this.options.openClassName)
} else {
if (!dom.classList.contains(this.options.openClassName)) {
return
}
dom.classList.remove(this.options.openClassName)
}
} else {
dom.classList.toggle(this.options.openClassName)
}
const event = new Event('toggleDetailsContent')
const detailsContent = content.querySelector(':scope > div[data-type="detailsContent"]')
detailsContent?.dispatchEvent(event)
}
if (node.attrs.open) {
setTimeout(() => toggleDetailsContent())
}
toggle.addEventListener('click', () => {
toggleDetailsContent()
if (!this.options.persist) {
editor.commands
.focus(undefined, { scrollIntoView: false })
return
}
if (editor.isEditable && typeof getPos === 'function') {
const { from, to } = editor.state.selection
editor
.chain()
.command(({ tr }) => {
const pos = getPos()
const currentNode = tr.doc.nodeAt(pos)
if (currentNode?.type !== this.type) {
return false
}
tr.setNodeMarkup(pos, undefined, {
open: !currentNode.attrs.open,
})
return true
})
.setTextSelection({
from,
to,
})
.focus(undefined, { scrollIntoView: false })
.run()
}
})
return {
dom,
contentDOM: content,
ignoreMutation(mutation: ViewMutationRecord) {
if (mutation.type === 'selection') {
return false
}
return !dom.contains(mutation.target) || dom === mutation.target
},
update: updatedNode => {
if (updatedNode.type !== this.type) {
return false
}
// Only update the open state if set
if (updatedNode.attrs.open !== undefined) {
toggleDetailsContent(updatedNode.attrs.open)
}
return true
},
}
}
},
addCommands() {
return {
setDetails: () => ({ state, chain }) => {
const { schema, selection } = state
const { $from, $to } = selection
const range = $from.blockRange($to)
if (!range) {
return false
}
const slice = state.doc.slice(range.start, range.end)
const match = schema.nodes.detailsContent.contentMatch.matchFragment(slice.content)
if (!match) {
return false
}
const content = slice.toJSON()?.content || []
return chain()
.insertContentAt({ from: range.start, to: range.end }, {
type: this.name,
content: [
{
type: 'detailsSummary',
},
{
type: 'detailsContent',
content,
},
],
})
.setTextSelection(range.start + 2)
.run()
},
unsetDetails: () => ({ state, chain }) => {
const { selection, schema } = state
const details = findParentNode(node => node.type === this.type)(selection)
if (!details) {
return false
}
const detailsSummaries = findChildren(details.node, node => node.type === schema.nodes.detailsSummary)
const detailsContents = findChildren(details.node, node => node.type === schema.nodes.detailsContent)
if (!detailsSummaries.length || !detailsContents.length) {
return false
}
const detailsSummary = detailsSummaries[0]
const detailsContent = detailsContents[0]
const from = details.pos
const $from = state.doc.resolve(from)
const to = from + details.node.nodeSize
const range = { from, to }
const content = detailsContent.node.content.toJSON() as [] || []
const defaultTypeForSummary = $from.parent.type.contentMatch.defaultType
// TODO: this may break for some custom schemas
const summaryContent = defaultTypeForSummary?.create(null, detailsSummary.node.content).toJSON()
const mergedContent = [
summaryContent,
...content,
]
return chain()
.insertContentAt(range, mergedContent)
.setTextSelection(from + 1)
.run()
},
}
},
addKeyboardShortcuts() {
return {
Backspace: () => {
const { schema, selection } = this.editor.state
const { empty, $anchor } = selection
if (!empty || $anchor.parent.type !== schema.nodes.detailsSummary) {
return false
}
// for some reason safari removes the whole text content within a `<summary>`tag on backspace
// so we have to remove the text manually
// see: https://discuss.prosemirror.net/t/safari-backspace-bug-with-details-tag/4223
if ($anchor.parentOffset !== 0) {
return this.editor.commands.command(({ tr }) => {
const from = $anchor.pos - 1
const to = $anchor.pos
tr.delete(from, to)
return true
})
}
return this.editor.commands.unsetDetails()
},
// Creates a new node below it if it is closed.
// Otherwise inside `DetailsContent`.
Enter: ({ editor }) => {
const { state, view } = editor
const { schema, selection } = state
const { $head } = selection
if ($head.parent.type !== schema.nodes.detailsSummary) {
return false
}
const isVisible = isNodeVisible($head.after() + 1, editor)
const above = isVisible
? state.doc.nodeAt($head.after())
: $head.node(-2)
if (!above) {
return false
}
const after = isVisible
? 0
: $head.indexAfter(-1)
const type = defaultBlockAt(above.contentMatchAt(after))
if (!type || !above.canReplaceWith(after, after, type)) {
return false
}
const node = type.createAndFill()
if (!node) {
return false
}
const pos = isVisible
? $head.after() + 1
: $head.after(-1)
const tr = state.tr.replaceWith(pos, pos, node)
const $pos = tr.doc.resolve(pos)
const newSelection = Selection.near($pos, 1)
tr.setSelection(newSelection)
tr.scrollIntoView()
view.dispatch(tr)
return true
},
// The default gapcursor implementation cant handle hidden content, so we need to fix this.
ArrowRight: ({ editor }) => {
return setGapCursor(editor, 'right')
},
// The default gapcursor implementation cant handle hidden content, so we need to fix this.
ArrowDown: ({ editor }) => {
return setGapCursor(editor, 'down')
},
}
},
addProseMirrorPlugins() {
return [
// This plugin prevents text selections within the hidden content in `DetailsContent`.
// The cursor is moved to the next visible position.
new Plugin({
key: new PluginKey('detailsSelection'),
appendTransaction: (transactions, oldState, newState) => {
const { editor, type } = this
const selectionSet = transactions.some(transaction => transaction.selectionSet)
if (
!selectionSet
|| !oldState.selection.empty
|| !newState.selection.empty
) {
return
}
const detailsIsActive = isActive(newState, type.name)
if (!detailsIsActive) {
return
}
const { $from } = newState.selection
const isVisible = isNodeVisible($from.pos, editor)
if (isVisible) {
return
}
const details = findClosestVisibleNode($from, node => node.type === type, editor)
if (!details) {
return
}
const detailsSummaries = findChildren(details.node, node => node.type === newState.schema.nodes.detailsSummary)
if (!detailsSummaries.length) {
return
}
const detailsSummary = detailsSummaries[0]
const selectionDirection = oldState.selection.from < newState.selection.from
? 'forward'
: 'backward'
const correctedPosition = selectionDirection === 'forward'
? details.start + detailsSummary.pos
: details.pos + detailsSummary.pos + detailsSummary.node.nodeSize
const selection = TextSelection.create(newState.doc, correctedPosition)
const transaction = newState.tr.setSelection(selection)
return transaction
},
}),
]
},
})

View File

@ -0,0 +1,26 @@
import { Editor, Predicate } from '@tiptap/core'
import { Node as ProseMirrorNode, ResolvedPos } from '@tiptap/pm/model'
import { isNodeVisible } from './isNodeVisible.js'
export const findClosestVisibleNode = ($pos: ResolvedPos, predicate: Predicate, editor: Editor): ({
pos: number,
start: number,
depth: number,
node: ProseMirrorNode,
} | undefined) => {
for (let i = $pos.depth; i > 0; i -= 1) {
const node = $pos.node(i)
const match = predicate(node)
const isVisible = isNodeVisible($pos.start(i), editor)
if (match && isVisible) {
return {
pos: i > 0 ? $pos.before(i) : 0,
start: $pos.start(i),
depth: i,
node,
}
}
}
}

View File

@ -0,0 +1,8 @@
import { Editor } from '@tiptap/core'
export const isNodeVisible = (position: number, editor: Editor): boolean => {
const node = editor.view.domAtPos(position).node as HTMLElement
const isOpen = node.offsetParent !== null
return isOpen
}

View File

@ -0,0 +1,62 @@
import { Editor, findChildren, findParentNode } from '@tiptap/core'
import { GapCursor } from '@tiptap/pm/gapcursor'
import { ResolvedPos } from '@tiptap/pm/model'
import { Selection } from '@tiptap/pm/state'
import { isNodeVisible } from './isNodeVisible.js'
export const setGapCursor = (editor: Editor, direction: 'down' | 'right') => {
const { state, view, extensionManager } = editor
const { schema, selection } = state
const { empty, $anchor } = selection
const hasGapCursorExtension = !!extensionManager.extensions.find(extension => extension.name === 'gapCursor')
if (
!empty
|| $anchor.parent.type !== schema.nodes.detailsSummary
|| !hasGapCursorExtension
) {
return false
}
if (
direction === 'right'
&& $anchor.parentOffset !== ($anchor.parent.nodeSize - 2)
) {
return false
}
const details = findParentNode(node => node.type === schema.nodes.details)(selection)
if (!details) {
return false
}
const detailsContent = findChildren(details.node, node => node.type === schema.nodes.detailsContent)
if (!detailsContent.length) {
return false
}
const isOpen = isNodeVisible(details.start + detailsContent[0].pos + 1, editor)
if (isOpen) {
return false
}
const $position = state.doc.resolve(details.pos + details.node.nodeSize)
const $validPosition = GapCursor.findFrom($position, 1, false) as unknown as (null | ResolvedPos)
if (!$validPosition) {
return false
}
const { tr } = state
const gapCursorSelection = new GapCursor($validPosition) as Selection
tr.setSelection(gapCursorSelection)
tr.scrollIntoView()
view.dispatch(tr)
return true
}

View File

@ -0,0 +1,5 @@
import { Details } from './details.js'
export * from './details.js'
export default Details

View File

@ -0,0 +1,14 @@
# @tiptap/extension-drag-handle-react
[![Version](https://img.shields.io/npm/v/@tiptap/extension-drag-handle-react.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-drag-handle-react)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-drag-handle-react.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-drag-handle-react.svg)](https://www.npmjs.com/package/@tiptap/extension-drag-handle-react)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as *New York Times*, *The Guardian* or *Atlassian*.
## Official Documentation
Documentation can be found on the [Tiptap website](https://tiptap.dev).
## License
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).

View File

@ -0,0 +1,51 @@
{
"name": "@tiptap/extension-drag-handle-react",
"description": "drag handle extension for tiptap with react",
"version": "2.14.0",
"homepage": "https://tiptap.dev",
"keywords": [
"tiptap",
"tiptap extension"
],
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"main": "dist/index.cjs",
"module": "dist/index.js",
"umd": "dist/index.umd.js",
"types": "dist/index.d.ts",
"files": [
"src",
"dist"
],
"peerDependencies": {
"@tiptap/extension-drag-handle": "^2.14.0",
"@tiptap/pm": "^2.7.0",
"@tiptap/react": "^2.7.0",
"react": "^16.8 || ^17 || ^18 || ^19",
"react-dom": "^16.8 || ^17 || ^18 || ^19"
},
"devDependencies": {
"@tiptap/extension-drag-handle": "^2.14.0",
"@tiptap/pm": "^2.14.0",
"@tiptap/react": "^2.14.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"scripts": {
"clean": "rm -rf dist",
"build": "npm run clean && rollup -c"
}
}

View File

@ -0,0 +1,5 @@
import { baseConfig } from '@tiptap-shared/rollup-config'
import pkg from './package.json' assert { type: 'json' }
export default baseConfig({ input: 'src/index.ts', pkg })

View File

@ -0,0 +1,69 @@
import {
DragHandlePlugin,
dragHandlePluginDefaultKey,
DragHandlePluginProps,
} from '@tiptap/extension-drag-handle'
import { Node } from '@tiptap/pm/model'
import { Plugin } from '@tiptap/pm/state'
import { Editor } from '@tiptap/react'
import React, {
ReactNode, useEffect, useRef, useState,
} from 'react'
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
export type DragHandleProps = Omit<Optional<DragHandlePluginProps, 'pluginKey'>, 'element'> & {
className?: string;
onNodeChange?: (data: { node: Node | null; editor: Editor; pos: number }) => void;
children: ReactNode;
};
export const DragHandle = (props: DragHandleProps) => {
const {
className = 'drag-handle',
children,
editor,
pluginKey = dragHandlePluginDefaultKey,
onNodeChange,
tippyOptions = {},
} = props
const [element, setElement] = useState<HTMLDivElement | null>(null)
const plugin = useRef<Plugin | null>(null)
useEffect(() => {
if (!element) {
return () => {
plugin.current = null
}
}
if (editor.isDestroyed) {
return () => {
plugin.current = null
}
}
if (!plugin.current) {
plugin.current = DragHandlePlugin({
editor,
element,
pluginKey,
tippyOptions,
onNodeChange,
})
editor.registerPlugin(plugin.current)
}
return () => {
editor.unregisterPlugin(pluginKey)
plugin.current = null
}
}, [element, editor, onNodeChange, pluginKey, tippyOptions])
return (
<div className={className} ref={setElement}>
{children}
</div>
)
}

View File

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

View File

@ -0,0 +1,14 @@
# @tiptap/extension-drag-handle-vue-2
[![Version](https://img.shields.io/npm/v/@tiptap/extension-drag-handle-vue-2.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-drag-handle-vue-2)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-drag-handle-vue-2.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-drag-handle-vue-2.svg)](https://www.npmjs.com/package/@tiptap/extension-drag-handle-vue-2)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as *New York Times*, *The Guardian* or *Atlassian*.
## Official Documentation
Documentation can be found on the [Tiptap website](https://tiptap.dev).
## License
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).

View File

@ -0,0 +1,48 @@
{
"name": "@tiptap/extension-drag-handle-vue-2",
"description": "drag handle extension for tiptap with vue 2",
"version": "2.14.0",
"homepage": "https://tiptap.dev",
"keywords": [
"tiptap",
"tiptap extension"
],
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"main": "dist/index.cjs",
"module": "dist/index.js",
"umd": "dist/index.umd.js",
"types": "dist/index.d.ts",
"files": [
"src",
"dist"
],
"peerDependencies": {
"@tiptap/extension-drag-handle": "^2.14.0",
"@tiptap/pm": "^2.7.0",
"@tiptap/vue-2": "^2.7.0",
"vue": "^2.0.0"
},
"devDependencies": {
"@tiptap/extension-drag-handle": "^2.14.0",
"@tiptap/pm": "^2.14.0",
"@tiptap/vue-2": "^2.14.0",
"vue": "^2.0.0",
"vue-ts-types": "1.6.2"
},
"scripts": {
"clean": "rm -rf dist",
"build": "npm run clean && rollup -c"
}
}

View File

@ -0,0 +1,5 @@
import { baseConfig } from '@tiptap-shared/rollup-config'
import pkg from './package.json' assert { type: 'json' }
export default baseConfig({ input: 'src/index.ts', pkg })

Some files were not shown because too many files have changed in this diff Show More