mirror of
https://github.com/ueberdosis/tiptap.git
synced 2025-06-28 15:40:55 +08:00
open source pro extensions (main) (#6465)
* open source pro extensions * added changeset * fix import for serializeForClipboard * use serializeForClipboard from view * improve type checking for validPosition
This commit is contained in:
parent
0611ee766c
commit
f6f6158696
34
.changeset/good-onions-look.md
Normal file
34
.changeset/good-onions-look.md
Normal 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`
|
@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hocuspocus/provider": "2.13.5",
|
||||
"@hocuspocus/transformer": "^2.13.7",
|
||||
"@lexical/react": "^0.11.3",
|
||||
"@shikijs/core": "1.10.3",
|
||||
"d3": "^7.9.0",
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { NodeSelection, Plugin } from '@tiptap/pm/state'
|
||||
import { __serializeForClipboard as serializeForClipboard } from '@tiptap/pm/view'
|
||||
|
||||
function removeNode(node) {
|
||||
node.parentNode.removeChild(node)
|
||||
@ -63,7 +62,7 @@ export default Extension.create({
|
||||
// from: view.nodeDOM(view.state.selection.from),
|
||||
// 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.setData('text/html', dom.innerHTML)
|
||||
|
0
demos/src/Extensions/DragHandle/React/index.html
Normal file
0
demos/src/Extensions/DragHandle/React/index.html
Normal file
44
demos/src/Extensions/DragHandle/React/index.jsx
Normal file
44
demos/src/Extensions/DragHandle/React/index.jsx
Normal 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. It’s 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} />
|
||||
</>
|
||||
)
|
||||
}
|
47
demos/src/Extensions/DragHandle/React/styles.scss
Normal file
47
demos/src/Extensions/DragHandle/React/styles.scss
Normal 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;
|
||||
}
|
||||
}
|
0
demos/src/Extensions/DragHandle/Vue/index.html
Normal file
0
demos/src/Extensions/DragHandle/Vue/index.html
Normal file
156
demos/src/Extensions/DragHandle/Vue/index.vue
Normal file
156
demos/src/Extensions/DragHandle/Vue/index.vue
Normal 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>
|
3
demos/src/Extensions/DragHandle/Vue/style.css
Normal file
3
demos/src/Extensions/DragHandle/Vue/style.css
Normal file
@ -0,0 +1,3 @@
|
||||
body {
|
||||
color: red;
|
||||
}
|
@ -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
|
@ -0,0 +1 @@
|
||||
export * from './Recommendation.jsx'
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './RecommendationView.jsx'
|
40
demos/src/Extensions/DragHandleWithNodeViews/React/index.jsx
Normal file
40
demos/src/Extensions/DragHandleWithNodeViews/React/index.jsx
Normal 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. It’s 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} />
|
||||
</>
|
||||
)
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
0
demos/src/Extensions/FileHandler/React/index.html
Normal file
0
demos/src/Extensions/FileHandler/React/index.html
Normal file
76
demos/src/Extensions/FileHandler/React/index.jsx
Normal file
76
demos/src/Extensions/FileHandler/React/index.jsx
Normal 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} />
|
||||
)
|
||||
}
|
17
demos/src/Extensions/FileHandler/React/styles.scss
Normal file
17
demos/src/Extensions/FileHandler/React/styles.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
0
demos/src/Extensions/FileHandler/Vue/index.html
Normal file
0
demos/src/Extensions/FileHandler/Vue/index.html
Normal file
105
demos/src/Extensions/FileHandler/Vue/index.vue
Normal file
105
demos/src/Extensions/FileHandler/Vue/index.vue
Normal 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>
|
61
demos/src/Extensions/InvisibleCharacters/React/index.jsx
Normal file
61
demos/src/Extensions/InvisibleCharacters/React/index.jsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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')
|
||||
})
|
||||
})
|
@ -0,0 +1,6 @@
|
||||
/* Basic editor styles */
|
||||
.tiptap {
|
||||
:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
@ -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')
|
||||
})
|
||||
})
|
82
demos/src/Extensions/InvisibleCharacters/Vue/index.vue
Normal file
82
demos/src/Extensions/InvisibleCharacters/Vue/index.vue
Normal 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>
|
0
demos/src/Extensions/Mathematics/React/index.html
Normal file
0
demos/src/Extensions/Mathematics/React/index.html
Normal file
74
demos/src/Extensions/Mathematics/React/index.jsx
Normal file
74
demos/src/Extensions/Mathematics/React/index.jsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
}
|
21
demos/src/Extensions/Mathematics/React/index.spec.js
Normal file
21
demos/src/Extensions/Mathematics/React/index.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
33
demos/src/Extensions/Mathematics/React/styles.scss
Normal file
33
demos/src/Extensions/Mathematics/React/styles.scss
Normal 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;
|
||||
}
|
||||
}
|
0
demos/src/Extensions/Mathematics/Vue/index.html
Normal file
0
demos/src/Extensions/Mathematics/Vue/index.html
Normal file
7
demos/src/Extensions/Mathematics/Vue/index.spec.js
Normal file
7
demos/src/Extensions/Mathematics/Vue/index.spec.js
Normal file
@ -0,0 +1,7 @@
|
||||
context('/src/Extensions/Mathematics/Vue/', () => {
|
||||
before(() => {
|
||||
cy.visit('/src/Extensions/Mathematics/Vue/')
|
||||
})
|
||||
|
||||
// TODO: Write tests
|
||||
})
|
126
demos/src/Extensions/Mathematics/Vue/index.vue
Normal file
126
demos/src/Extensions/Mathematics/Vue/index.vue
Normal 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>
|
63
demos/src/Extensions/TableOfContents/React/ToC.jsx
Normal file
63
demos/src/Extensions/TableOfContents/React/ToC.jsx
Normal 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} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
48
demos/src/Extensions/TableOfContents/React/index.jsx
Normal file
48
demos/src/Extensions/TableOfContents/React/index.jsx
Normal 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>
|
||||
)
|
||||
}
|
100
demos/src/Extensions/TableOfContents/React/styles.scss
Normal file
100
demos/src/Extensions/TableOfContents/React/styles.scss
Normal 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)".";
|
||||
}
|
||||
}
|
||||
}
|
67
demos/src/Extensions/TableOfContents/Vue/ToC.vue
Normal file
67
demos/src/Extensions/TableOfContents/Vue/ToC.vue
Normal 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>
|
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<p>Start editing your document to see the outline.</p>
|
||||
</div>
|
||||
</template>
|
38
demos/src/Extensions/TableOfContents/Vue/ToCItem.vue
Normal file
38
demos/src/Extensions/TableOfContents/Vue/ToCItem.vue
Normal 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>
|
0
demos/src/Extensions/TableOfContents/Vue/index.html
Normal file
0
demos/src/Extensions/TableOfContents/Vue/index.html
Normal file
7
demos/src/Extensions/TableOfContents/Vue/index.spec.js
Normal file
7
demos/src/Extensions/TableOfContents/Vue/index.spec.js
Normal file
@ -0,0 +1,7 @@
|
||||
context('/src/Extensions/TableOfContents/Vue', () => {
|
||||
before(() => {
|
||||
cy.visit('/src/Extensions/TableOfContents/Vue')
|
||||
})
|
||||
|
||||
// TODO: Write tests
|
||||
})
|
163
demos/src/Extensions/TableOfContents/Vue/index.vue
Normal file
163
demos/src/Extensions/TableOfContents/Vue/index.vue
Normal 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>
|
27
demos/src/Extensions/TableOfContents/content.js
Normal file
27
demos/src/Extensions/TableOfContents/content.js
Normal 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 people—especially programmers—prefer 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>
|
||||
`
|
0
demos/src/Extensions/UniqueID/React/index.html
Normal file
0
demos/src/Extensions/UniqueID/React/index.html
Normal file
32
demos/src/Extensions/UniqueID/React/index.jsx
Normal file
32
demos/src/Extensions/UniqueID/React/index.jsx
Normal 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. It’s so unique, it even has an ID attached to it.
|
||||
</p>
|
||||
<p>
|
||||
And this one, too.
|
||||
</p>
|
||||
`,
|
||||
})
|
||||
|
||||
return (
|
||||
<EditorContent editor={editor} />
|
||||
)
|
||||
}
|
13
demos/src/Extensions/UniqueID/React/index.spec.js
Normal file
13
demos/src/Extensions/UniqueID/React/index.spec.js
Normal 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')
|
||||
})
|
||||
})
|
28
demos/src/Extensions/UniqueID/React/styles.scss
Normal file
28
demos/src/Extensions/UniqueID/React/styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
0
demos/src/Extensions/UniqueID/Vue/index.html
Normal file
0
demos/src/Extensions/UniqueID/Vue/index.html
Normal file
13
demos/src/Extensions/UniqueID/Vue/index.spec.js
Normal file
13
demos/src/Extensions/UniqueID/Vue/index.spec.js
Normal 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')
|
||||
})
|
||||
})
|
85
demos/src/Extensions/UniqueID/Vue/index.vue
Normal file
85
demos/src/Extensions/UniqueID/Vue/index.vue
Normal 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. It’s 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>
|
50
demos/src/Extensions/UniqueIDWithYdoc/React/index.jsx
Normal file
50
demos/src/Extensions/UniqueIDWithYdoc/React/index.jsx
Normal 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} />
|
||||
}
|
13
demos/src/Extensions/UniqueIDWithYdoc/React/index.spec.js
Normal file
13
demos/src/Extensions/UniqueIDWithYdoc/React/index.spec.js
Normal 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')
|
||||
})
|
||||
})
|
55
demos/src/Extensions/UniqueIDWithYdoc/React/styles.scss
Normal file
55
demos/src/Extensions/UniqueIDWithYdoc/React/styles.scss
Normal 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;
|
||||
}
|
||||
}
|
0
demos/src/Nodes/Details/React/index.html
Normal file
0
demos/src/Nodes/Details/React/index.html
Normal file
77
demos/src/Nodes/Details/React/index.jsx
Normal file
77
demos/src/Nodes/Details/React/index.jsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
62
demos/src/Nodes/Details/React/styles.scss
Normal file
62
demos/src/Nodes/Details/React/styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
0
demos/src/Nodes/Details/Vue/index.html
Normal file
0
demos/src/Nodes/Details/Vue/index.html
Normal file
71
demos/src/Nodes/Details/Vue/index.spec.js
Normal file
71
demos/src/Nodes/Details/Vue/index.spec.js
Normal 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')
|
||||
// })
|
||||
|
||||
})
|
146
demos/src/Nodes/Details/Vue/index.vue
Normal file
146
demos/src/Nodes/Details/Vue/index.vue
Normal 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>
|
75
demos/src/Nodes/Emoji/React/EmojiList.jsx
Normal file
75
demos/src/Nodes/Emoji/React/EmojiList.jsx
Normal 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>
|
||||
)
|
||||
})
|
36
demos/src/Nodes/Emoji/React/EmojiList.scss
Normal file
36
demos/src/Nodes/Emoji/React/EmojiList.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
0
demos/src/Nodes/Emoji/React/index.html
Normal file
0
demos/src/Nodes/Emoji/React/index.html
Normal file
63
demos/src/Nodes/Emoji/React/index.jsx
Normal file
63
demos/src/Nodes/Emoji/React/index.jsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
14
demos/src/Nodes/Emoji/React/styles.scss
Normal file
14
demos/src/Nodes/Emoji/React/styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
67
demos/src/Nodes/Emoji/React/suggestion.js
Normal file
67
demos/src/Nodes/Emoji/React/suggestion.js
Normal 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()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
129
demos/src/Nodes/Emoji/Vue/EmojiList.vue
Normal file
129
demos/src/Nodes/Emoji/Vue/EmojiList.vue
Normal 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>
|
0
demos/src/Nodes/Emoji/Vue/index.html
Normal file
0
demos/src/Nodes/Emoji/Vue/index.html
Normal file
92
demos/src/Nodes/Emoji/Vue/index.vue
Normal file
92
demos/src/Nodes/Emoji/Vue/index.vue
Normal 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>
|
65
demos/src/Nodes/Emoji/Vue/suggestion.js
Normal file
65
demos/src/Nodes/Emoji/Vue/suggestion.js
Normal 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()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
970
package-lock.json
generated
970
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
packages/extension-details-content/README.md
Normal file
14
packages/extension-details-content/README.md
Normal file
@ -0,0 +1,14 @@
|
||||
# @tiptap/extension-details-content
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-details-content)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-details-content)
|
||||
[](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).
|
48
packages/extension-details-content/package.json
Normal file
48
packages/extension-details-content/package.json
Normal 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"
|
||||
}
|
||||
}
|
5
packages/extension-details-content/rollup.config.js
Normal file
5
packages/extension-details-content/rollup.config.js
Normal 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 })
|
169
packages/extension-details-content/src/details-content.ts
Normal file
169
packages/extension-details-content/src/details-content.ts
Normal 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
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
5
packages/extension-details-content/src/index.ts
Normal file
5
packages/extension-details-content/src/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { DetailsContent } from './details-content.js'
|
||||
|
||||
export * from './details-content.js'
|
||||
|
||||
export default DetailsContent
|
14
packages/extension-details-summary/README.md
Normal file
14
packages/extension-details-summary/README.md
Normal file
@ -0,0 +1,14 @@
|
||||
# @tiptap/extension-details-summary
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-details-summary)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-details-summary)
|
||||
[](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).
|
48
packages/extension-details-summary/package.json
Normal file
48
packages/extension-details-summary/package.json
Normal 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"
|
||||
}
|
||||
}
|
5
packages/extension-details-summary/rollup.config.js
Normal file
5
packages/extension-details-summary/rollup.config.js
Normal 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 })
|
44
packages/extension-details-summary/src/details-summary.ts
Normal file
44
packages/extension-details-summary/src/details-summary.ts
Normal 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,
|
||||
]
|
||||
},
|
||||
})
|
5
packages/extension-details-summary/src/index.ts
Normal file
5
packages/extension-details-summary/src/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { DetailsSummary } from './details-summary.js'
|
||||
|
||||
export * from './details-summary.js'
|
||||
|
||||
export default DetailsSummary
|
14
packages/extension-details/README.md
Normal file
14
packages/extension-details/README.md
Normal file
@ -0,0 +1,14 @@
|
||||
# @tiptap/extension-details
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-details)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-details)
|
||||
[](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).
|
48
packages/extension-details/package.json
Normal file
48
packages/extension-details/package.json
Normal 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"
|
||||
}
|
||||
}
|
5
packages/extension-details/rollup.config.js
Normal file
5
packages/extension-details/rollup.config.js
Normal 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 })
|
451
packages/extension-details/src/details.ts
Normal file
451
packages/extension-details/src/details.ts
Normal 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 can’t handle hidden content, so we need to fix this.
|
||||
ArrowRight: ({ editor }) => {
|
||||
return setGapCursor(editor, 'right')
|
||||
},
|
||||
|
||||
// The default gapcursor implementation can’t 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
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
8
packages/extension-details/src/helpers/isNodeVisible.ts
Normal file
8
packages/extension-details/src/helpers/isNodeVisible.ts
Normal 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
|
||||
}
|
62
packages/extension-details/src/helpers/setGapCursor.ts
Normal file
62
packages/extension-details/src/helpers/setGapCursor.ts
Normal 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
|
||||
}
|
5
packages/extension-details/src/index.ts
Normal file
5
packages/extension-details/src/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Details } from './details.js'
|
||||
|
||||
export * from './details.js'
|
||||
|
||||
export default Details
|
14
packages/extension-drag-handle-react/README.md
Normal file
14
packages/extension-drag-handle-react/README.md
Normal file
@ -0,0 +1,14 @@
|
||||
# @tiptap/extension-drag-handle-react
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-drag-handle-react)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-drag-handle-react)
|
||||
[](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).
|
51
packages/extension-drag-handle-react/package.json
Normal file
51
packages/extension-drag-handle-react/package.json
Normal 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"
|
||||
}
|
||||
}
|
5
packages/extension-drag-handle-react/rollup.config.js
Normal file
5
packages/extension-drag-handle-react/rollup.config.js
Normal 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 })
|
69
packages/extension-drag-handle-react/src/DragHandle.tsx
Normal file
69
packages/extension-drag-handle-react/src/DragHandle.tsx
Normal 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>
|
||||
)
|
||||
}
|
5
packages/extension-drag-handle-react/src/index.ts
Normal file
5
packages/extension-drag-handle-react/src/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { DragHandle } from './DragHandle.js'
|
||||
|
||||
export * from './DragHandle.js'
|
||||
|
||||
export default DragHandle
|
14
packages/extension-drag-handle-vue-2/README.md
Normal file
14
packages/extension-drag-handle-vue-2/README.md
Normal file
@ -0,0 +1,14 @@
|
||||
# @tiptap/extension-drag-handle-vue-2
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-drag-handle-vue-2)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-drag-handle-vue-2)
|
||||
[](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).
|
48
packages/extension-drag-handle-vue-2/package.json
Normal file
48
packages/extension-drag-handle-vue-2/package.json
Normal 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"
|
||||
}
|
||||
}
|
5
packages/extension-drag-handle-vue-2/rollup.config.js
Normal file
5
packages/extension-drag-handle-vue-2/rollup.config.js
Normal 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
Loading…
Reference in New Issue
Block a user