Merge branch 'main' of github.com:ueberdosis/tiptap-next into main
This commit is contained in:
@@ -25,6 +25,7 @@
|
|||||||
"remark-toc": "^7.0.0",
|
"remark-toc": "^7.0.0",
|
||||||
"remixicon": "^2.5.0",
|
"remixicon": "^2.5.0",
|
||||||
"simplify-js": "^1.2.4",
|
"simplify-js": "^1.2.4",
|
||||||
|
"tippy.js": "^6.2.7",
|
||||||
"vue-github-button": "^1.1.2",
|
"vue-github-button": "^1.1.2",
|
||||||
"vue-live": "^1.16.0",
|
"vue-live": "^1.16.0",
|
||||||
"y-indexeddb": "^9.0.6",
|
"y-indexeddb": "^9.0.6",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Node, mergeAttributes } from '@tiptap/core'
|
import { Node, mergeAttributes } from '@tiptap/core'
|
||||||
import { VueRenderer } from '@tiptap/vue'
|
import { VueNodeViewRenderer } from '@tiptap/vue'
|
||||||
import Component from './Component.vue'
|
import Component from './Component.vue'
|
||||||
|
|
||||||
export default Node.create({
|
export default Node.create({
|
||||||
@@ -30,6 +30,6 @@ export default Node.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
return VueRenderer(Component)
|
return VueNodeViewRenderer(Component)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Node, mergeAttributes } from '@tiptap/core'
|
import { Node, mergeAttributes } from '@tiptap/core'
|
||||||
import { VueRenderer } from '@tiptap/vue'
|
import { VueNodeViewRenderer } from '@tiptap/vue'
|
||||||
import Component from './Component.vue'
|
import Component from './Component.vue'
|
||||||
|
|
||||||
export default Node.create({
|
export default Node.create({
|
||||||
@@ -24,6 +24,6 @@ export default Node.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
return VueRenderer(Component)
|
return VueNodeViewRenderer(Component)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Node, mergeAttributes } from '@tiptap/core'
|
import { Node, mergeAttributes } from '@tiptap/core'
|
||||||
import { VueRenderer } from '@tiptap/vue'
|
import { VueNodeViewRenderer } from '@tiptap/vue'
|
||||||
import Component from './Component.vue'
|
import Component from './Component.vue'
|
||||||
|
|
||||||
export default Node.create({
|
export default Node.create({
|
||||||
@@ -22,6 +22,6 @@ export default Node.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
return VueRenderer(Component)
|
return VueNodeViewRenderer(Component)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
112
docs/src/demos/Nodes/Mention/MentionList.vue
Normal file
112
docs/src/demos/Nodes/Mention/MentionList.vue
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<div class="items">
|
||||||
|
<button
|
||||||
|
class="item"
|
||||||
|
:class="{ 'is-selected': index === selectedIndex }"
|
||||||
|
v-for="(item, index) in items"
|
||||||
|
:key="index"
|
||||||
|
@click="selectItem(index)"
|
||||||
|
>
|
||||||
|
{{ item }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
command: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selectedIndex: 0,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
items() {
|
||||||
|
this.selectedIndex = 0
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
onKeyDown({ event }) {
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
this.upHandler()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
this.downHandler()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
this.enterHandler()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
|
||||||
|
upHandler() {
|
||||||
|
this.selectedIndex = ((this.selectedIndex + this.items.length) - 1) % this.items.length
|
||||||
|
},
|
||||||
|
|
||||||
|
downHandler() {
|
||||||
|
this.selectedIndex = (this.selectedIndex + 1) % this.items.length
|
||||||
|
},
|
||||||
|
|
||||||
|
enterHandler() {
|
||||||
|
this.selectItem(this.selectedIndex)
|
||||||
|
},
|
||||||
|
|
||||||
|
selectItem(index) {
|
||||||
|
const item = this.items[index]
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
this.command({ id: item })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.items {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: white;
|
||||||
|
color: rgba(black, 0.8);
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(0, 0, 0, 0.1),
|
||||||
|
0px 10px 20px rgba(0, 0, 0, 0.1),
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
|
||||||
|
&.is-selected,
|
||||||
|
&:hover {
|
||||||
|
color: #A975FF;
|
||||||
|
background: rgba(#A975FF, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
5
docs/src/demos/Nodes/Mention/index.spec.js
Normal file
5
docs/src/demos/Nodes/Mention/index.spec.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
context('/api/nodes/mention', () => {
|
||||||
|
before(() => {
|
||||||
|
cy.visit('/api/nodes/mention')
|
||||||
|
})
|
||||||
|
})
|
||||||
100
docs/src/demos/Nodes/Mention/index.vue
Normal file
100
docs/src/demos/Nodes/Mention/index.vue
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="editor">
|
||||||
|
<editor-content :editor="editor" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import tippy from 'tippy.js'
|
||||||
|
import { Editor, EditorContent, VueRenderer } from '@tiptap/vue'
|
||||||
|
import Document from '@tiptap/extension-document'
|
||||||
|
import Paragraph from '@tiptap/extension-paragraph'
|
||||||
|
import Text from '@tiptap/extension-text'
|
||||||
|
import Mention from '@tiptap/extension-mention'
|
||||||
|
import MentionList from './MentionList'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
EditorContent,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
editor: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.editor = new Editor({
|
||||||
|
extensions: [
|
||||||
|
Document,
|
||||||
|
Paragraph,
|
||||||
|
Text,
|
||||||
|
Mention.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'mention',
|
||||||
|
},
|
||||||
|
suggestionOptions: {
|
||||||
|
items: query => {
|
||||||
|
return ['Hans', 'Philipp', 'Kris'].filter(item => item.startsWith(query))
|
||||||
|
},
|
||||||
|
render: () => {
|
||||||
|
let component
|
||||||
|
let popup
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart: props => {
|
||||||
|
component = new VueRenderer(MentionList, {
|
||||||
|
parent: this,
|
||||||
|
propsData: props,
|
||||||
|
})
|
||||||
|
|
||||||
|
popup = tippy('body', {
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
appendTo: () => document.body,
|
||||||
|
content: component.element,
|
||||||
|
showOnCreate: true,
|
||||||
|
interactive: true,
|
||||||
|
trigger: 'manual',
|
||||||
|
placement: 'top-start',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onUpdate(props) {
|
||||||
|
component.updateProps(props)
|
||||||
|
|
||||||
|
popup[0].setProps({
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onKeyDown(props) {
|
||||||
|
return component.vm.onKeyDown(props)
|
||||||
|
},
|
||||||
|
onExit() {
|
||||||
|
popup[0].destroy()
|
||||||
|
component.destroy()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content: `
|
||||||
|
<p>Hello <span data-mention="Hans"></span> and <span data-mention="Philipp"></span> and <span data-mention="Kris"></span>!</p>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeDestroy() {
|
||||||
|
this.editor.destroy()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.mention {
|
||||||
|
color: #A975FF;
|
||||||
|
background-color: rgba(#A975FF, 0.1);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -147,6 +147,7 @@ Have a look at all of the core commands listed below. They should give you a goo
|
|||||||
| .lift() | Removes an existing wrap. |
|
| .lift() | Removes an existing wrap. |
|
||||||
| .liftEmptyBlock() | Lift block if empty. |
|
| .liftEmptyBlock() | Lift block if empty. |
|
||||||
| .newlineInCode() | Add a newline character in code. |
|
| .newlineInCode() | Add a newline character in code. |
|
||||||
|
| .replace() | Replaces text with a node within a range. |
|
||||||
| .resetNodeAttributes() | Resets all node attributes to the default value. |
|
| .resetNodeAttributes() | Resets all node attributes to the default value. |
|
||||||
| .selectParentNode() | Select the parent node. |
|
| .selectParentNode() | Select the parent node. |
|
||||||
| .setMark() | Add a mark with new attributes. |
|
| .setMark() | Add a mark with new attributes. |
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
# Suggestion
|
|
||||||
|
|
||||||
:::pro Fund the development 💖
|
|
||||||
We need your support to maintain, update, support and develop tiptap 2. If you’re waiting for this extension, [become a sponsor and fund open source](/sponsor).
|
|
||||||
:::
|
|
||||||
|
|
||||||
TODO
|
|
||||||
|
|
||||||
- mentions (as text or as/with user ID)
|
|
||||||
- hashtags (as text)
|
|
||||||
- emojis (input rule with an autocomplete popup)
|
|
||||||
- commands (can only be triggered at the beginning of a line, should trigger custom commands)
|
|
||||||
- snippets (should be able to insert multiple paragraphs)
|
|
||||||
- variables (should replaced when copied to external sources)
|
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
# Mention
|
# Mention
|
||||||
|
|
||||||
:::pro Fund the development 💖
|
## Installation
|
||||||
We need your support to maintain, update, support and develop tiptap 2. If you’re waiting for this extension, [become a sponsor and fund open source](/sponsor).
|
```bash
|
||||||
:::
|
# with npm
|
||||||
|
npm install @tiptap/extension-mention
|
||||||
|
|
||||||
TODO
|
# with Yarn
|
||||||
|
yarn add @tiptap/extension-mention
|
||||||
|
```
|
||||||
|
|
||||||
|
## Source code
|
||||||
|
[packages/extension-mention/](https://github.com/ueberdosis/tiptap-next/blob/main/packages/extension-mention/)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
<demo name="Nodes/Mention" />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Node views are the best thing since sliced bread, at least if you’re a fan of
|
|||||||
|
|
||||||
<!-- ```js
|
<!-- ```js
|
||||||
import { Node } from '@tiptap/core'
|
import { Node } from '@tiptap/core'
|
||||||
import { VueRenderer } from '@tiptap/vue'
|
import { VueNodeViewRenderer } from '@tiptap/vue'
|
||||||
import Component from './Component.vue'
|
import Component from './Component.vue'
|
||||||
|
|
||||||
export default Node.create({
|
export default Node.create({
|
||||||
@@ -83,12 +83,12 @@ https://github.com/ueberdosis/tiptap-next/blob/main/packages/extension-task-item
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
import { Node } from '@tiptap/core'
|
import { Node } from '@tiptap/core'
|
||||||
import { VueRenderer } from '@tiptap/vue'
|
import { VueNodeViewRenderer } from '@tiptap/vue'
|
||||||
import Component from './Component.vue'
|
import Component from './Component.vue'
|
||||||
|
|
||||||
export default Node.create({
|
export default Node.create({
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
return VueRenderer(Component)
|
return VueNodeViewRenderer(Component)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -174,9 +174,6 @@
|
|||||||
link: /api/extensions/gapcursor
|
link: /api/extensions/gapcursor
|
||||||
- title: History
|
- title: History
|
||||||
link: /api/extensions/history
|
link: /api/extensions/history
|
||||||
- title: Suggestion
|
|
||||||
link: /api/extensions/suggestion
|
|
||||||
type: draft
|
|
||||||
- title: TextAlign
|
- title: TextAlign
|
||||||
link: /api/extensions/text-align
|
link: /api/extensions/text-align
|
||||||
- title: Typography
|
- title: Typography
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export class Editor extends EventEmitter {
|
|||||||
|
|
||||||
private commandManager!: CommandManager
|
private commandManager!: CommandManager
|
||||||
|
|
||||||
private extensionManager!: ExtensionManager
|
public extensionManager!: ExtensionManager
|
||||||
|
|
||||||
private css!: HTMLStyleElement
|
private css!: HTMLStyleElement
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Plugin, Transaction } from 'prosemirror-state'
|
import { Plugin, Transaction } from 'prosemirror-state'
|
||||||
import { InputRule } from 'prosemirror-inputrules'
|
import { InputRule } from 'prosemirror-inputrules'
|
||||||
import { Editor } from './Editor'
|
import { Editor } from './Editor'
|
||||||
|
import mergeDeep from './utilities/mergeDeep'
|
||||||
import { GlobalAttributes } from './types'
|
import { GlobalAttributes } from './types'
|
||||||
|
|
||||||
export interface ExtensionConfig<Options = any, Commands = {}> {
|
export interface ExtensionConfig<Options = any, Commands = {}> {
|
||||||
@@ -171,20 +172,14 @@ export class Extension<Options = any, Commands = any> {
|
|||||||
return new Extension<O, C>(config)
|
return new Extension<O, C>(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
configure(options?: Partial<Options>) {
|
configure(options: Partial<Options> = {}) {
|
||||||
return Extension
|
return Extension
|
||||||
.create<Options, Commands>(this.config as ExtensionConfig<Options, Commands>)
|
.create<Options, Commands>(this.config as ExtensionConfig<Options, Commands>)
|
||||||
.#configure({
|
.#configure(options)
|
||||||
...this.config.defaultOptions,
|
|
||||||
...options,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#configure = (options: Partial<Options>) => {
|
#configure = (options: Partial<Options>) => {
|
||||||
this.options = {
|
this.options = mergeDeep(this.config.defaultOptions, options) as Options
|
||||||
...this.config.defaultOptions,
|
|
||||||
...options,
|
|
||||||
}
|
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Editor } from './Editor'
|
|||||||
import { Extensions, NodeViewRenderer } from './types'
|
import { Extensions, NodeViewRenderer } from './types'
|
||||||
import getSchema from './helpers/getSchema'
|
import getSchema from './helpers/getSchema'
|
||||||
import getSchemaTypeByName from './helpers/getSchemaTypeByName'
|
import getSchemaTypeByName from './helpers/getSchemaTypeByName'
|
||||||
|
import getNodeType from './helpers/getNodeType'
|
||||||
import splitExtensions from './helpers/splitExtensions'
|
import splitExtensions from './helpers/splitExtensions'
|
||||||
import getAttributesFromExtensions from './helpers/getAttributesFromExtensions'
|
import getAttributesFromExtensions from './helpers/getAttributesFromExtensions'
|
||||||
import getRenderedAttributes from './helpers/getRenderedAttributes'
|
import getRenderedAttributes from './helpers/getRenderedAttributes'
|
||||||
@@ -145,11 +146,9 @@ export default class ExtensionManager {
|
|||||||
const context = {
|
const context = {
|
||||||
options: extension.options,
|
options: extension.options,
|
||||||
editor,
|
editor,
|
||||||
type: getSchemaTypeByName(extension.config.name, this.schema),
|
type: getNodeType(extension.config.name, this.schema),
|
||||||
}
|
}
|
||||||
|
const renderer = extension.config.addNodeView?.call(context) as NodeViewRenderer
|
||||||
// @ts-ignore
|
|
||||||
const renderer = extension.config.addNodeView?.bind(context)?.() as NodeViewRenderer
|
|
||||||
|
|
||||||
const nodeview = (
|
const nodeview = (
|
||||||
node: ProsemirrorNode,
|
node: ProsemirrorNode,
|
||||||
@@ -173,4 +172,23 @@ export default class ExtensionManager {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get textSerializers() {
|
||||||
|
const { editor } = this
|
||||||
|
const { nodeExtensions } = splitExtensions(this.extensions)
|
||||||
|
|
||||||
|
return Object.fromEntries(nodeExtensions
|
||||||
|
.filter(extension => !!extension.config.renderText)
|
||||||
|
.map(extension => {
|
||||||
|
const context = {
|
||||||
|
options: extension.options,
|
||||||
|
editor,
|
||||||
|
type: getNodeType(extension.config.name, this.schema),
|
||||||
|
}
|
||||||
|
|
||||||
|
const textSerializer = (props: { node: ProsemirrorNode }) => extension.config.renderText?.call(context, props)
|
||||||
|
|
||||||
|
return [extension.config.name, textSerializer]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { Plugin, Transaction } from 'prosemirror-state'
|
import { Plugin, Transaction } from 'prosemirror-state'
|
||||||
import { InputRule } from 'prosemirror-inputrules'
|
import { InputRule } from 'prosemirror-inputrules'
|
||||||
import { ExtensionConfig } from './Extension'
|
import { ExtensionConfig } from './Extension'
|
||||||
|
import mergeDeep from './utilities/mergeDeep'
|
||||||
import { Attributes, Overwrite } from './types'
|
import { Attributes, Overwrite } from './types'
|
||||||
import { Editor } from './Editor'
|
import { Editor } from './Editor'
|
||||||
|
|
||||||
@@ -231,20 +232,14 @@ export class Mark<Options = any, Commands = {}> {
|
|||||||
return new Mark<O, C>(config)
|
return new Mark<O, C>(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
configure(options?: Partial<Options>) {
|
configure(options: Partial<Options> = {}) {
|
||||||
return Mark
|
return Mark
|
||||||
.create<Options, Commands>(this.config as MarkConfig<Options, Commands>)
|
.create<Options, Commands>(this.config as MarkConfig<Options, Commands>)
|
||||||
.#configure({
|
.#configure(options)
|
||||||
...this.config.defaultOptions,
|
|
||||||
...options,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#configure = (options: Partial<Options>) => {
|
#configure = (options: Partial<Options>) => {
|
||||||
this.options = {
|
this.options = mergeDeep(this.config.defaultOptions, options) as Options
|
||||||
...this.config.defaultOptions,
|
|
||||||
...options,
|
|
||||||
}
|
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// @ts-nocheck
|
||||||
import {
|
import {
|
||||||
DOMOutputSpec,
|
DOMOutputSpec,
|
||||||
NodeSpec,
|
NodeSpec,
|
||||||
@@ -7,6 +8,7 @@ import {
|
|||||||
import { Plugin, Transaction } from 'prosemirror-state'
|
import { Plugin, Transaction } from 'prosemirror-state'
|
||||||
import { InputRule } from 'prosemirror-inputrules'
|
import { InputRule } from 'prosemirror-inputrules'
|
||||||
import { ExtensionConfig } from './Extension'
|
import { ExtensionConfig } from './Extension'
|
||||||
|
import mergeDeep from './utilities/mergeDeep'
|
||||||
import { Attributes, NodeViewRenderer, Overwrite } from './types'
|
import { Attributes, NodeViewRenderer, Overwrite } from './types'
|
||||||
import { Editor } from './Editor'
|
import { Editor } from './Editor'
|
||||||
|
|
||||||
@@ -88,6 +90,20 @@ export interface NodeConfig<Options = any, Commands = {}> extends Overwrite<Exte
|
|||||||
}
|
}
|
||||||
) => DOMOutputSpec) | null,
|
) => DOMOutputSpec) | null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render Text
|
||||||
|
*/
|
||||||
|
renderText?: ((
|
||||||
|
this: {
|
||||||
|
options: Options,
|
||||||
|
editor: Editor,
|
||||||
|
type: NodeType,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
node: ProseMirrorNode,
|
||||||
|
}
|
||||||
|
) => string) | null,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add Attributes
|
* Add Attributes
|
||||||
*/
|
*/
|
||||||
@@ -257,6 +273,7 @@ export class Node<Options = any, Commands = {}> {
|
|||||||
isolating: null,
|
isolating: null,
|
||||||
parseHTML: () => null,
|
parseHTML: () => null,
|
||||||
renderHTML: null,
|
renderHTML: null,
|
||||||
|
renderText: null,
|
||||||
addAttributes: () => ({}),
|
addAttributes: () => ({}),
|
||||||
addNodeView: null,
|
addNodeView: null,
|
||||||
onCreate: null,
|
onCreate: null,
|
||||||
@@ -283,20 +300,14 @@ export class Node<Options = any, Commands = {}> {
|
|||||||
return new Node<O, C>(config)
|
return new Node<O, C>(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
configure(options?: Partial<Options>) {
|
configure(options: Partial<Options> = {}) {
|
||||||
return Node
|
return Node
|
||||||
.create<Options, Commands>(this.config as NodeConfig<Options, Commands>)
|
.create<Options, Commands>(this.config as NodeConfig<Options, Commands>)
|
||||||
.#configure({
|
.#configure(options)
|
||||||
...this.config.defaultOptions,
|
|
||||||
...options,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#configure = (options: Partial<Options>) => {
|
#configure = (options: Partial<Options>) => {
|
||||||
this.options = {
|
this.options = mergeDeep(this.config.defaultOptions, options) as Options
|
||||||
...this.config.defaultOptions,
|
|
||||||
...options,
|
|
||||||
}
|
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|||||||
24
packages/core/src/commands/replace.ts
Normal file
24
packages/core/src/commands/replace.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { NodeType } from 'prosemirror-model'
|
||||||
|
import getNodeType from '../helpers/getNodeType'
|
||||||
|
import { Command, Range, AnyObject } from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces text with a node within a range.
|
||||||
|
*/
|
||||||
|
export const replace = (range: Range | null = null, typeOrName: string | NodeType, attrs: AnyObject = {}): Command => ({ tr, state, dispatch }) => {
|
||||||
|
const type = getNodeType(typeOrName, state.schema)
|
||||||
|
const { $from, $to } = state.selection
|
||||||
|
const index = $from.index()
|
||||||
|
const from = range ? range.from : $from.pos
|
||||||
|
const to = range ? range.to : $to.pos
|
||||||
|
|
||||||
|
if (!$from.parent.canReplaceWith(index, index, type)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dispatch) {
|
||||||
|
tr.replaceWith(from, to, type.create(attrs))
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
60
packages/core/src/extensions/clipboardTextSerializer.ts
Normal file
60
packages/core/src/extensions/clipboardTextSerializer.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Editor } from '@tiptap/core'
|
||||||
|
import { Plugin, PluginKey } from 'prosemirror-state'
|
||||||
|
import { Extension } from '../Extension'
|
||||||
|
|
||||||
|
const textBetween = (
|
||||||
|
editor: Editor,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
blockSeparator?: string,
|
||||||
|
leafText?: string,
|
||||||
|
): string => {
|
||||||
|
let text = ''
|
||||||
|
let separated = true
|
||||||
|
|
||||||
|
editor.state.doc.nodesBetween(from, to, (node, pos) => {
|
||||||
|
const textSerializer = editor.extensionManager.textSerializers[node.type.name]
|
||||||
|
|
||||||
|
if (textSerializer) {
|
||||||
|
text += textSerializer({ node })
|
||||||
|
separated = !blockSeparator
|
||||||
|
} else if (node.isText) {
|
||||||
|
text += node?.text?.slice(Math.max(from, pos) - pos, to - pos)
|
||||||
|
separated = !blockSeparator
|
||||||
|
} else if (node.isLeaf && leafText) {
|
||||||
|
text += leafText
|
||||||
|
separated = !blockSeparator
|
||||||
|
} else if (!separated && node.isBlock) {
|
||||||
|
text += blockSeparator
|
||||||
|
separated = true
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClipboardTextSerializer = Extension.create({
|
||||||
|
name: 'editable',
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: new PluginKey('clipboardTextSerializer'),
|
||||||
|
props: {
|
||||||
|
clipboardTextSerializer: () => {
|
||||||
|
const { editor } = this
|
||||||
|
const { from, to } = editor.state.selection
|
||||||
|
|
||||||
|
return textBetween(editor, from, to, '\n')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface AllExtensions {
|
||||||
|
ClipboardTextSerializer: typeof ClipboardTextSerializer,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import * as lift from '../commands/lift'
|
|||||||
import * as liftEmptyBlock from '../commands/liftEmptyBlock'
|
import * as liftEmptyBlock from '../commands/liftEmptyBlock'
|
||||||
import * as liftListItem from '../commands/liftListItem'
|
import * as liftListItem from '../commands/liftListItem'
|
||||||
import * as newlineInCode from '../commands/newlineInCode'
|
import * as newlineInCode from '../commands/newlineInCode'
|
||||||
|
import * as replace from '../commands/replace'
|
||||||
import * as resetNodeAttributes from '../commands/resetNodeAttributes'
|
import * as resetNodeAttributes from '../commands/resetNodeAttributes'
|
||||||
import * as scrollIntoView from '../commands/scrollIntoView'
|
import * as scrollIntoView from '../commands/scrollIntoView'
|
||||||
import * as selectAll from '../commands/selectAll'
|
import * as selectAll from '../commands/selectAll'
|
||||||
@@ -63,6 +64,7 @@ export const Commands = Extension.create({
|
|||||||
...liftEmptyBlock,
|
...liftEmptyBlock,
|
||||||
...liftListItem,
|
...liftListItem,
|
||||||
...newlineInCode,
|
...newlineInCode,
|
||||||
|
...replace,
|
||||||
...resetNodeAttributes,
|
...resetNodeAttributes,
|
||||||
...scrollIntoView,
|
...scrollIntoView,
|
||||||
...selectAll,
|
...selectAll,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { ClipboardTextSerializer } from './clipboardTextSerializer'
|
||||||
export { Commands } from './commands'
|
export { Commands } from './commands'
|
||||||
export { Editable } from './editable'
|
export { Editable } from './editable'
|
||||||
export { FocusEvents } from './focusEvents'
|
export { FocusEvents } from './focusEvents'
|
||||||
|
|||||||
@@ -130,3 +130,8 @@ export type ChainedCommands = {
|
|||||||
export type CanCommands = SingleCommands & { chain: () => ChainedCommands }
|
export type CanCommands = SingleCommands & { chain: () => ChainedCommands }
|
||||||
|
|
||||||
export type FocusPosition = 'start' | 'end' | number | boolean | null
|
export type FocusPosition = 'start' | 'end' | number | boolean | null
|
||||||
|
|
||||||
|
export type Range = {
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
}
|
||||||
|
|||||||
3
packages/core/src/utilities/isObject.ts
Normal file
3
packages/core/src/utilities/isObject.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function isObject(item: any): boolean {
|
||||||
|
return (item && typeof item === 'object' && !Array.isArray(item))
|
||||||
|
}
|
||||||
22
packages/core/src/utilities/mergeDeep.ts
Normal file
22
packages/core/src/utilities/mergeDeep.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { AnyObject } from '../types'
|
||||||
|
import isObject from './isObject'
|
||||||
|
|
||||||
|
export default function mergeDeep(target: AnyObject, source: AnyObject) {
|
||||||
|
const output = { ...target }
|
||||||
|
|
||||||
|
if (isObject(target) && isObject(source)) {
|
||||||
|
Object.keys(source).forEach(key => {
|
||||||
|
if (isObject(source[key])) {
|
||||||
|
if (!(key in target)) {
|
||||||
|
Object.assign(output, { [key]: source[key] })
|
||||||
|
} else {
|
||||||
|
output[key] = mergeDeep(target[key], source[key])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Object.assign(output, { [key]: source[key] })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return output
|
||||||
|
}
|
||||||
14
packages/extension-mention/README.md
Normal file
14
packages/extension-mention/README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# @tiptap/extension-mention
|
||||||
|
[](https://www.npmjs.com/package/@tiptap/extension-mention)
|
||||||
|
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||||
|
[](https://www.npmjs.com/package/@tiptap/extension-mention)
|
||||||
|
[](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*.
|
||||||
|
|
||||||
|
## Offical 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-next/blob/main/LICENSE.md).
|
||||||
30
packages/extension-mention/package.json
Normal file
30
packages/extension-mention/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@tiptap/extension-mention",
|
||||||
|
"description": "mention extension for tiptap",
|
||||||
|
"version": "2.0.0-alpha.0",
|
||||||
|
"homepage": "https://tiptap.dev",
|
||||||
|
"keywords": [
|
||||||
|
"tiptap",
|
||||||
|
"tiptap extension"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"main": "dist/tiptap-extension-mention.cjs.js",
|
||||||
|
"umd": "dist/tiptap-extension-mention.umd.js",
|
||||||
|
"module": "dist/tiptap-extension-mention.esm.js",
|
||||||
|
"unpkg": "dist/tiptap-extension-mention.bundle.umd.min.js",
|
||||||
|
"types": "dist/packages/extension-mention/src/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.0.0-alpha.6"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tiptap/suggestion": "^2.0.0-alpha.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
packages/extension-mention/src/index.ts
Normal file
5
packages/extension-mention/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Mention } from './mention'
|
||||||
|
|
||||||
|
export * from './mention'
|
||||||
|
|
||||||
|
export default Mention
|
||||||
89
packages/extension-mention/src/mention.ts
Normal file
89
packages/extension-mention/src/mention.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Node, mergeAttributes } from '@tiptap/core'
|
||||||
|
import Suggestion, { SuggestionOptions } from '@tiptap/suggestion'
|
||||||
|
|
||||||
|
export type MentionOptions = {
|
||||||
|
HTMLAttributes: {
|
||||||
|
[key: string]: any,
|
||||||
|
},
|
||||||
|
suggestionOptions: Omit<SuggestionOptions, 'editor'>,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Mention = Node.create({
|
||||||
|
name: 'mention',
|
||||||
|
|
||||||
|
defaultOptions: <MentionOptions>{
|
||||||
|
HTMLAttributes: {},
|
||||||
|
suggestionOptions: {
|
||||||
|
char: '@',
|
||||||
|
command: ({ editor, range, attributes }) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.replace(range, 'mention', attributes)
|
||||||
|
.insertText(' ')
|
||||||
|
.run()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
group: 'inline',
|
||||||
|
|
||||||
|
inline: true,
|
||||||
|
|
||||||
|
selectable: false,
|
||||||
|
|
||||||
|
atom: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
id: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: element => {
|
||||||
|
return {
|
||||||
|
id: element.getAttribute('data-mention'),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderHTML: attributes => {
|
||||||
|
if (!attributes.id) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'data-mention': attributes.id,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'span[data-mention]',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ node, HTMLAttributes }) {
|
||||||
|
return ['span', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), `@${node.attrs.id}`]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderText({ node }) {
|
||||||
|
return `@${node.attrs.id}`
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
Suggestion({
|
||||||
|
editor: this.editor,
|
||||||
|
...this.options.suggestionOptions,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface AllExtensions {
|
||||||
|
Mention: typeof Mention,
|
||||||
|
}
|
||||||
|
}
|
||||||
14
packages/suggestion/README.md
Normal file
14
packages/suggestion/README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# @tiptap/suggestions
|
||||||
|
[](https://www.npmjs.com/package/@tiptap/suggestions)
|
||||||
|
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||||
|
[](https://www.npmjs.com/package/@tiptap/suggestions)
|
||||||
|
[](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*.
|
||||||
|
|
||||||
|
## Offical 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-next/blob/main/LICENSE.md).
|
||||||
30
packages/suggestion/package.json
Normal file
30
packages/suggestion/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@tiptap/suggestion",
|
||||||
|
"description": "suggestion plugin for tiptap",
|
||||||
|
"version": "2.0.0-alpha.0",
|
||||||
|
"homepage": "https://tiptap.dev",
|
||||||
|
"keywords": [
|
||||||
|
"tiptap",
|
||||||
|
"tiptap utility"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"main": "dist/tiptap-suggestion.cjs.js",
|
||||||
|
"umd": "dist/tiptap-suggestion.umd.js",
|
||||||
|
"module": "dist/tiptap-suggestion.esm.js",
|
||||||
|
"unpkg": "dist/tiptap-suggestion.bundle.umd.min.js",
|
||||||
|
"types": "dist/packages/suggestion/src/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@tiptap/core": "^2.0.0-alpha.7",
|
||||||
|
"prosemirror-state": "^1.3.3",
|
||||||
|
"prosemirror-view": "^1.16.3",
|
||||||
|
"prosemirror-model": "^1.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
83
packages/suggestion/src/findSuggestionMatch.ts
Normal file
83
packages/suggestion/src/findSuggestionMatch.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Range } from '@tiptap/core'
|
||||||
|
import { ResolvedPos } from 'prosemirror-model'
|
||||||
|
|
||||||
|
export interface Trigger {
|
||||||
|
char: string,
|
||||||
|
allowSpaces: boolean,
|
||||||
|
startOfLine: boolean,
|
||||||
|
$position: ResolvedPos,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SuggestionMatch = {
|
||||||
|
range: Range,
|
||||||
|
query: string,
|
||||||
|
text: string,
|
||||||
|
} | null
|
||||||
|
|
||||||
|
export function findSuggestionMatch(config: Trigger): SuggestionMatch {
|
||||||
|
const {
|
||||||
|
char,
|
||||||
|
allowSpaces,
|
||||||
|
startOfLine,
|
||||||
|
$position,
|
||||||
|
} = config
|
||||||
|
|
||||||
|
// cancel if top level node
|
||||||
|
if ($position.depth <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matching expressions used for later
|
||||||
|
const escapedChar = `\\${char}`
|
||||||
|
const suffix = new RegExp(`\\s${escapedChar}$`)
|
||||||
|
const prefix = startOfLine ? '^' : ''
|
||||||
|
const regexp = allowSpaces
|
||||||
|
? new RegExp(`${prefix}${escapedChar}.*?(?=\\s${escapedChar}|$)`, 'gm')
|
||||||
|
: new RegExp(`${prefix}(?:^)?${escapedChar}[^\\s${escapedChar}]*`, 'gm')
|
||||||
|
|
||||||
|
// Lookup the boundaries of the current node
|
||||||
|
const textFrom = $position.before()
|
||||||
|
|
||||||
|
// Only look up to the cursor, old behavior: textTo = $position.end()
|
||||||
|
const textTo = $position.pos
|
||||||
|
|
||||||
|
const text = $position.doc.textBetween(textFrom, textTo, '\0', '\0')
|
||||||
|
|
||||||
|
let match = regexp.exec(text)
|
||||||
|
let position = null
|
||||||
|
|
||||||
|
while (match !== null) {
|
||||||
|
// JavaScript doesn't have lookbehinds; this hacks a check that first character is " "
|
||||||
|
// or the line beginning
|
||||||
|
const matchPrefix = match.input.slice(Math.max(0, match.index - 1), match.index)
|
||||||
|
|
||||||
|
if (/^[\s\0]?$/.test(matchPrefix)) {
|
||||||
|
// The absolute position of the match in the document
|
||||||
|
const from = match.index + $position.start()
|
||||||
|
let to = from + match[0].length
|
||||||
|
|
||||||
|
// Edge case handling; if spaces are allowed and we're directly in between
|
||||||
|
// two triggers
|
||||||
|
if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) {
|
||||||
|
match[0] += ' '
|
||||||
|
to += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the $position is located within the matched substring, return that range
|
||||||
|
if (from < $position.pos && to >= $position.pos) {
|
||||||
|
position = {
|
||||||
|
range: {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
},
|
||||||
|
query: match[0].slice(char.length),
|
||||||
|
text: match[0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match = regexp.exec(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
return position
|
||||||
|
}
|
||||||
6
packages/suggestion/src/index.ts
Normal file
6
packages/suggestion/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { Suggestion } from './suggestion'
|
||||||
|
|
||||||
|
export * from './findSuggestionMatch'
|
||||||
|
export * from './suggestion'
|
||||||
|
|
||||||
|
export default Suggestion
|
||||||
207
packages/suggestion/src/suggestion.ts
Normal file
207
packages/suggestion/src/suggestion.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { Editor, Range, AnyObject } from '@tiptap/core'
|
||||||
|
import { Plugin, PluginKey } from 'prosemirror-state'
|
||||||
|
import { Decoration, DecorationSet, EditorView } from 'prosemirror-view'
|
||||||
|
import { findSuggestionMatch } from './findSuggestionMatch'
|
||||||
|
|
||||||
|
export interface SuggestionOptions {
|
||||||
|
editor: Editor,
|
||||||
|
char?: string,
|
||||||
|
allowSpaces?: boolean,
|
||||||
|
startOfLine?: boolean,
|
||||||
|
decorationTag?: string,
|
||||||
|
decorationClass?: string,
|
||||||
|
command?: (props: {
|
||||||
|
editor: Editor,
|
||||||
|
range: Range,
|
||||||
|
attributes: AnyObject
|
||||||
|
}) => void,
|
||||||
|
items?: (query: string) => any[],
|
||||||
|
render?: () => {
|
||||||
|
onStart?: (props: SuggestionProps) => void,
|
||||||
|
onUpdate?: (props: SuggestionProps) => void,
|
||||||
|
onExit?: (props: SuggestionProps) => void,
|
||||||
|
onKeyDown?: (props: SuggestionKeyDownProps) => boolean,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuggestionProps {
|
||||||
|
editor: Editor,
|
||||||
|
range: Range,
|
||||||
|
query: string,
|
||||||
|
text: string,
|
||||||
|
items: any[],
|
||||||
|
command: (attributes: AnyObject) => void,
|
||||||
|
decorationNode: Element | null,
|
||||||
|
clientRect: () => (DOMRect | null),
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuggestionKeyDownProps {
|
||||||
|
view: EditorView,
|
||||||
|
event: KeyboardEvent,
|
||||||
|
range: Range,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Suggestion({
|
||||||
|
editor,
|
||||||
|
char = '@',
|
||||||
|
allowSpaces = false,
|
||||||
|
startOfLine = false,
|
||||||
|
decorationTag = 'span',
|
||||||
|
decorationClass = 'suggestion',
|
||||||
|
command = () => null,
|
||||||
|
items = () => [],
|
||||||
|
render = () => ({}),
|
||||||
|
}: SuggestionOptions) {
|
||||||
|
|
||||||
|
const renderer = render?.()
|
||||||
|
|
||||||
|
return new Plugin({
|
||||||
|
key: new PluginKey('suggestion'),
|
||||||
|
|
||||||
|
view() {
|
||||||
|
return {
|
||||||
|
update: async (view, prevState) => {
|
||||||
|
const prev = this.key?.getState(prevState)
|
||||||
|
const next = this.key?.getState(view.state)
|
||||||
|
|
||||||
|
// See how the state changed
|
||||||
|
const moved = prev.active && next.active && prev.range.from !== next.range.from
|
||||||
|
const started = !prev.active && next.active
|
||||||
|
const stopped = prev.active && !next.active
|
||||||
|
const changed = !started && !stopped && prev.query !== next.query
|
||||||
|
const handleStart = started || moved
|
||||||
|
const handleChange = changed && !moved
|
||||||
|
const handleExit = stopped || moved
|
||||||
|
|
||||||
|
// Cancel when suggestion isn't active
|
||||||
|
if (!handleStart && !handleChange && !handleExit) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = handleExit ? prev : next
|
||||||
|
const decorationNode = document.querySelector(`[data-decoration-id="${state.decorationId}"]`)
|
||||||
|
const props: SuggestionProps = {
|
||||||
|
editor,
|
||||||
|
range: state.range,
|
||||||
|
query: state.query,
|
||||||
|
text: state.text,
|
||||||
|
items: (handleChange || handleStart)
|
||||||
|
? await items(state.query)
|
||||||
|
: [],
|
||||||
|
command: attributes => {
|
||||||
|
command({
|
||||||
|
editor,
|
||||||
|
range: state.range,
|
||||||
|
attributes,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
decorationNode,
|
||||||
|
// virtual node for popper.js or tippy.js
|
||||||
|
// this can be used for building popups without a DOM node
|
||||||
|
clientRect: () => decorationNode?.getBoundingClientRect() || null,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handleStart) {
|
||||||
|
renderer?.onStart?.(props)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handleChange) {
|
||||||
|
renderer?.onUpdate?.(props)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handleExit) {
|
||||||
|
renderer?.onExit?.(props)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
state: {
|
||||||
|
// Initialize the plugin's internal state.
|
||||||
|
init() {
|
||||||
|
return {
|
||||||
|
active: false,
|
||||||
|
range: {},
|
||||||
|
query: null,
|
||||||
|
text: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Apply changes to the plugin state from a view transaction.
|
||||||
|
apply(transaction, prev) {
|
||||||
|
const { selection } = transaction
|
||||||
|
const next = { ...prev }
|
||||||
|
|
||||||
|
// We can only be suggesting if there is no selection
|
||||||
|
if (selection.from === selection.to) {
|
||||||
|
// Reset active state if we just left the previous suggestion range
|
||||||
|
if (selection.from < prev.range.from || selection.from > prev.range.to) {
|
||||||
|
next.active = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to match against where our cursor currently is
|
||||||
|
const match = findSuggestionMatch({
|
||||||
|
char,
|
||||||
|
allowSpaces,
|
||||||
|
startOfLine,
|
||||||
|
$position: selection.$from,
|
||||||
|
})
|
||||||
|
const decorationId = `id_${Math.floor(Math.random() * 0xFFFFFFFF)}`
|
||||||
|
|
||||||
|
// If we found a match, update the current state to show it
|
||||||
|
if (match) {
|
||||||
|
next.active = true
|
||||||
|
next.decorationId = prev.decorationId ? prev.decorationId : decorationId
|
||||||
|
next.range = match.range
|
||||||
|
next.query = match.query
|
||||||
|
next.text = match.text
|
||||||
|
} else {
|
||||||
|
next.active = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next.active = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure to empty the range if suggestion is inactive
|
||||||
|
if (!next.active) {
|
||||||
|
next.decorationId = null
|
||||||
|
next.range = {}
|
||||||
|
next.query = null
|
||||||
|
next.text = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
// Call the keydown hook if suggestion is active.
|
||||||
|
handleKeyDown(view, event) {
|
||||||
|
const { active, range } = this.getState(view.state)
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderer?.onKeyDown?.({ view, event, range }) || false
|
||||||
|
},
|
||||||
|
|
||||||
|
// Setup decorator on the currently active suggestion.
|
||||||
|
decorations(state) {
|
||||||
|
const { active, range, decorationId } = this.getState(state)
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return DecorationSet.create(state.doc, [
|
||||||
|
Decoration.inline(range.from, range.to, {
|
||||||
|
nodeName: decorationTag,
|
||||||
|
class: decorationClass,
|
||||||
|
'data-decoration-id': decorationId,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
332
packages/vue/src/VueNodeViewRenderer.ts
Normal file
332
packages/vue/src/VueNodeViewRenderer.ts
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import {
|
||||||
|
Editor,
|
||||||
|
Node,
|
||||||
|
NodeViewRenderer,
|
||||||
|
NodeViewRendererProps,
|
||||||
|
} from '@tiptap/core'
|
||||||
|
import { Decoration, NodeView } from 'prosemirror-view'
|
||||||
|
import { NodeSelection } from 'prosemirror-state'
|
||||||
|
import { Node as ProseMirrorNode } from 'prosemirror-model'
|
||||||
|
import Vue from 'vue'
|
||||||
|
import { VueConstructor } from 'vue/types/umd'
|
||||||
|
|
||||||
|
function getComponentFromElement(element: HTMLElement): Vue {
|
||||||
|
// @ts-ignore
|
||||||
|
// eslint-disable-next-line
|
||||||
|
return element.__vue__
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VueNodeViewRendererOptions {
|
||||||
|
stopEvent: ((event: Event) => boolean) | null,
|
||||||
|
update: ((node: ProseMirrorNode, decorations: Decoration[]) => boolean) | null,
|
||||||
|
}
|
||||||
|
|
||||||
|
class VueNodeView implements NodeView {
|
||||||
|
|
||||||
|
vm!: Vue
|
||||||
|
|
||||||
|
editor: Editor
|
||||||
|
|
||||||
|
extension!: Node
|
||||||
|
|
||||||
|
node!: ProseMirrorNode
|
||||||
|
|
||||||
|
decorations!: Decoration[]
|
||||||
|
|
||||||
|
id!: string
|
||||||
|
|
||||||
|
getPos!: any
|
||||||
|
|
||||||
|
isDragging = false
|
||||||
|
|
||||||
|
options: VueNodeViewRendererOptions = {
|
||||||
|
stopEvent: null,
|
||||||
|
update: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(component: Vue | VueConstructor, props: NodeViewRendererProps, options?: Partial<VueNodeViewRendererOptions>) {
|
||||||
|
this.options = { ...this.options, ...options }
|
||||||
|
this.editor = props.editor
|
||||||
|
this.extension = props.extension
|
||||||
|
this.node = props.node
|
||||||
|
this.getPos = props.getPos
|
||||||
|
this.createUniqueId()
|
||||||
|
this.mount(component)
|
||||||
|
}
|
||||||
|
|
||||||
|
createUniqueId() {
|
||||||
|
this.id = `id_${Math.floor(Math.random() * 0xFFFFFFFF)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
createNodeViewWrapper() {
|
||||||
|
const { handleDragStart } = this
|
||||||
|
const dragstart = handleDragStart.bind(this)
|
||||||
|
|
||||||
|
return Vue.extend({
|
||||||
|
props: {
|
||||||
|
as: {
|
||||||
|
type: String,
|
||||||
|
default: 'div',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render(createElement) {
|
||||||
|
return createElement(
|
||||||
|
this.as, {
|
||||||
|
style: {
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
},
|
||||||
|
on: {
|
||||||
|
dragstart,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
this.$slots.default,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDragStart(event: DragEvent) {
|
||||||
|
const { view } = this.editor
|
||||||
|
const target = (event.target as HTMLElement)
|
||||||
|
|
||||||
|
if (this.contentDOM?.contains(target)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// sometimes `event.target` is not the `dom` element
|
||||||
|
event.dataTransfer?.setDragImage(this.dom, 0, 0)
|
||||||
|
|
||||||
|
const selection = NodeSelection.create(view.state.doc, this.getPos())
|
||||||
|
const transaction = view.state.tr.setSelection(selection)
|
||||||
|
|
||||||
|
view.dispatch(transaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
createNodeViewContent() {
|
||||||
|
const { id } = this
|
||||||
|
const { isEditable } = this.editor
|
||||||
|
|
||||||
|
return Vue.extend({
|
||||||
|
inheritAttrs: false,
|
||||||
|
props: {
|
||||||
|
as: {
|
||||||
|
type: String,
|
||||||
|
default: 'div',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render(createElement) {
|
||||||
|
return createElement(
|
||||||
|
this.as, {
|
||||||
|
style: {
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
},
|
||||||
|
domProps: {
|
||||||
|
id,
|
||||||
|
contenteditable: isEditable,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
mount(component: Vue | VueConstructor) {
|
||||||
|
const NodeViewWrapper = this.createNodeViewWrapper()
|
||||||
|
const NodeViewContent = this.createNodeViewContent()
|
||||||
|
|
||||||
|
const Component = Vue
|
||||||
|
.extend(component)
|
||||||
|
.extend({
|
||||||
|
components: {
|
||||||
|
NodeViewWrapper,
|
||||||
|
NodeViewContent,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const propsData = {
|
||||||
|
NodeViewWrapper,
|
||||||
|
NodeViewContent,
|
||||||
|
editor: this.editor,
|
||||||
|
node: this.node,
|
||||||
|
decorations: this.decorations,
|
||||||
|
selected: false,
|
||||||
|
extension: this.extension,
|
||||||
|
getPos: () => this.getPos(),
|
||||||
|
updateAttributes: (attributes = {}) => this.updateAttributes(attributes),
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = this.editor.view.dom.parentElement
|
||||||
|
? getComponentFromElement(this.editor.view.dom.parentElement)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
this.vm = new Component({
|
||||||
|
parent,
|
||||||
|
propsData,
|
||||||
|
}).$mount()
|
||||||
|
}
|
||||||
|
|
||||||
|
get dom() {
|
||||||
|
return this.vm.$el
|
||||||
|
}
|
||||||
|
|
||||||
|
get contentDOM() {
|
||||||
|
if (this.vm.$el.id === this.id) {
|
||||||
|
return this.vm.$el
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.vm.$el.querySelector(`#${this.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopEvent(event: Event) {
|
||||||
|
if (typeof this.options.stopEvent === 'function') {
|
||||||
|
return this.options.stopEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = (event.target as HTMLElement)
|
||||||
|
const isInElement = this.dom.contains(target) && !this.contentDOM?.contains(target)
|
||||||
|
|
||||||
|
// ignore all events from child nodes
|
||||||
|
if (!isInElement) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isEditable } = this.editor
|
||||||
|
const isDraggable = this.node.type.spec.draggable
|
||||||
|
const isCopyEvent = event.type === 'copy'
|
||||||
|
const isPasteEvent = event.type === 'paste'
|
||||||
|
const isCutEvent = event.type === 'cut'
|
||||||
|
const isDragEvent = event.type.startsWith('drag') || event.type === 'drop'
|
||||||
|
|
||||||
|
if (isDraggable && isDragEvent && !this.isDragging) {
|
||||||
|
event.preventDefault()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// we have to store that dragging started
|
||||||
|
if (isDraggable && isEditable && !this.isDragging && event.type === 'mousedown') {
|
||||||
|
const dragHandle = target.closest('[data-drag-handle]')
|
||||||
|
const isValidDragHandle = dragHandle
|
||||||
|
&& (this.dom === dragHandle || (this.dom.contains(dragHandle)))
|
||||||
|
|
||||||
|
if (isValidDragHandle) {
|
||||||
|
this.isDragging = true
|
||||||
|
document.addEventListener('dragend', () => {
|
||||||
|
this.isDragging = false
|
||||||
|
}, { once: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// these events are handled by prosemirror
|
||||||
|
if (this.isDragging || isCopyEvent || isPasteEvent || isCutEvent) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreMutation(mutation: MutationRecord | { type: 'selection'; target: Element }) {
|
||||||
|
if (mutation.type === 'selection') {
|
||||||
|
if (this.node.isLeaf) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.contentDOM) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentDOMHasChanged = !this.contentDOM.contains(mutation.target)
|
||||||
|
|| this.contentDOM === mutation.target
|
||||||
|
|
||||||
|
return contentDOMHasChanged
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.vm.$destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
update(node: ProseMirrorNode, decorations: Decoration[]) {
|
||||||
|
if (typeof this.options.update === 'function') {
|
||||||
|
return this.options.update(node, decorations)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type !== this.node.type) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node === this.node && this.decorations === decorations) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
this.node = node
|
||||||
|
this.decorations = decorations
|
||||||
|
this.updateComponentProps({ node, decorations })
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
updateComponentProps(data: { [key: string]: any } = {}) {
|
||||||
|
if (!this.vm.$props) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// prevents `Avoid mutating a prop directly` error message
|
||||||
|
const originalSilent = Vue.config.silent
|
||||||
|
Vue.config.silent = true
|
||||||
|
|
||||||
|
Object
|
||||||
|
.entries(data)
|
||||||
|
.forEach(([key, value]) => {
|
||||||
|
this.vm.$props[key] = value
|
||||||
|
})
|
||||||
|
|
||||||
|
Vue.config.silent = originalSilent
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAttributes(attributes: {}) {
|
||||||
|
if (!this.editor.view.editable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { state } = this.editor.view
|
||||||
|
const pos = this.getPos()
|
||||||
|
const transaction = state.tr.setNodeMarkup(pos, undefined, {
|
||||||
|
...this.node.attrs,
|
||||||
|
...attributes,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.editor.view.dispatch(transaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
selectNode() {
|
||||||
|
this.updateComponentProps({
|
||||||
|
selected: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
deselectNode() {
|
||||||
|
this.updateComponentProps({
|
||||||
|
selected: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VueNodeViewRenderer(component: Vue | VueConstructor, options?: Partial<VueNodeViewRendererOptions>): NodeViewRenderer {
|
||||||
|
return (props: NodeViewRendererProps) => {
|
||||||
|
// try to get the parent component
|
||||||
|
// this is important for vue devtools to show the component hierarchy correctly
|
||||||
|
// maybe it’s `undefined` because <editor-content> isn’t rendered yet
|
||||||
|
const parent = props.editor.view.dom.parentElement
|
||||||
|
? getComponentFromElement(props.editor.view.dom.parentElement)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (!parent) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new VueNodeView(component, props, options) as NodeView
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,273 +1,20 @@
|
|||||||
import {
|
|
||||||
Editor,
|
|
||||||
Node,
|
|
||||||
NodeViewRenderer,
|
|
||||||
NodeViewRendererProps,
|
|
||||||
} from '@tiptap/core'
|
|
||||||
import { Decoration, NodeView } from 'prosemirror-view'
|
|
||||||
import { NodeSelection } from 'prosemirror-state'
|
|
||||||
import { Node as ProseMirrorNode } from 'prosemirror-model'
|
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import { VueConstructor } from 'vue/types/umd'
|
import { VueConstructor } from 'vue/types/umd'
|
||||||
|
|
||||||
function getComponentFromElement(element: HTMLElement): Vue {
|
export default class VueRenderer {
|
||||||
// @ts-ignore
|
|
||||||
// eslint-disable-next-line
|
|
||||||
return element.__vue__
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VueRendererOptions {
|
|
||||||
stopEvent: ((event: Event) => boolean) | null,
|
|
||||||
update: ((node: ProseMirrorNode, decorations: Decoration[]) => boolean) | null,
|
|
||||||
}
|
|
||||||
|
|
||||||
class VueNodeView implements NodeView {
|
|
||||||
|
|
||||||
vm!: Vue
|
vm!: Vue
|
||||||
|
|
||||||
editor: Editor
|
constructor(component: Vue | VueConstructor, props: any) {
|
||||||
|
const Component = Vue.extend(component)
|
||||||
|
|
||||||
extension!: Node
|
this.vm = new Component(props).$mount()
|
||||||
|
|
||||||
node!: ProseMirrorNode
|
|
||||||
|
|
||||||
decorations!: Decoration[]
|
|
||||||
|
|
||||||
id!: string
|
|
||||||
|
|
||||||
getPos!: any
|
|
||||||
|
|
||||||
isDragging = false
|
|
||||||
|
|
||||||
options: VueRendererOptions = {
|
|
||||||
stopEvent: null,
|
|
||||||
update: null,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(component: Vue | VueConstructor, props: NodeViewRendererProps, options?: Partial<VueRendererOptions>) {
|
get element() {
|
||||||
this.options = { ...this.options, ...options }
|
|
||||||
this.editor = props.editor
|
|
||||||
this.extension = props.extension
|
|
||||||
this.node = props.node
|
|
||||||
this.getPos = props.getPos
|
|
||||||
this.createUniqueId()
|
|
||||||
this.mount(component)
|
|
||||||
}
|
|
||||||
|
|
||||||
createUniqueId() {
|
|
||||||
this.id = `id_${Math.floor(Math.random() * 0xFFFFFFFF)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
createNodeViewWrapper() {
|
|
||||||
const { handleDragStart } = this
|
|
||||||
const dragstart = handleDragStart.bind(this)
|
|
||||||
|
|
||||||
return Vue.extend({
|
|
||||||
props: {
|
|
||||||
as: {
|
|
||||||
type: String,
|
|
||||||
default: 'div',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render(createElement) {
|
|
||||||
return createElement(
|
|
||||||
this.as, {
|
|
||||||
style: {
|
|
||||||
whiteSpace: 'normal',
|
|
||||||
},
|
|
||||||
on: {
|
|
||||||
dragstart,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
this.$slots.default,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDragStart(event: DragEvent) {
|
|
||||||
const { view } = this.editor
|
|
||||||
const target = (event.target as HTMLElement)
|
|
||||||
|
|
||||||
if (this.contentDOM?.contains(target)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// sometimes `event.target` is not the `dom` element
|
|
||||||
event.dataTransfer?.setDragImage(this.dom, 0, 0)
|
|
||||||
|
|
||||||
const selection = NodeSelection.create(view.state.doc, this.getPos())
|
|
||||||
const transaction = view.state.tr.setSelection(selection)
|
|
||||||
|
|
||||||
view.dispatch(transaction)
|
|
||||||
}
|
|
||||||
|
|
||||||
createNodeViewContent() {
|
|
||||||
const { id } = this
|
|
||||||
const { isEditable } = this.editor
|
|
||||||
|
|
||||||
return Vue.extend({
|
|
||||||
inheritAttrs: false,
|
|
||||||
props: {
|
|
||||||
as: {
|
|
||||||
type: String,
|
|
||||||
default: 'div',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render(createElement) {
|
|
||||||
return createElement(
|
|
||||||
this.as, {
|
|
||||||
style: {
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
},
|
|
||||||
domProps: {
|
|
||||||
id,
|
|
||||||
contenteditable: isEditable,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
mount(component: Vue | VueConstructor) {
|
|
||||||
const NodeViewWrapper = this.createNodeViewWrapper()
|
|
||||||
const NodeViewContent = this.createNodeViewContent()
|
|
||||||
|
|
||||||
const Component = Vue
|
|
||||||
.extend(component)
|
|
||||||
.extend({
|
|
||||||
components: {
|
|
||||||
NodeViewWrapper,
|
|
||||||
NodeViewContent,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const propsData = {
|
|
||||||
NodeViewWrapper,
|
|
||||||
NodeViewContent,
|
|
||||||
editor: this.editor,
|
|
||||||
node: this.node,
|
|
||||||
decorations: this.decorations,
|
|
||||||
selected: false,
|
|
||||||
extension: this.extension,
|
|
||||||
getPos: () => this.getPos(),
|
|
||||||
updateAttributes: (attributes = {}) => this.updateAttributes(attributes),
|
|
||||||
}
|
|
||||||
|
|
||||||
const parent = this.editor.view.dom.parentElement
|
|
||||||
? getComponentFromElement(this.editor.view.dom.parentElement)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
this.vm = new Component({
|
|
||||||
parent,
|
|
||||||
propsData,
|
|
||||||
}).$mount()
|
|
||||||
}
|
|
||||||
|
|
||||||
get dom() {
|
|
||||||
return this.vm.$el
|
return this.vm.$el
|
||||||
}
|
}
|
||||||
|
|
||||||
get contentDOM() {
|
updateProps(props: { [key: string]: any } = {}) {
|
||||||
if (this.vm.$el.id === this.id) {
|
|
||||||
return this.vm.$el
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.vm.$el.querySelector(`#${this.id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
stopEvent(event: Event) {
|
|
||||||
if (typeof this.options.stopEvent === 'function') {
|
|
||||||
return this.options.stopEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = (event.target as HTMLElement)
|
|
||||||
const isInElement = this.dom.contains(target) && !this.contentDOM?.contains(target)
|
|
||||||
|
|
||||||
// ignore all events from child nodes
|
|
||||||
if (!isInElement) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const { isEditable } = this.editor
|
|
||||||
const isDraggable = this.node.type.spec.draggable
|
|
||||||
const isCopyEvent = event.type === 'copy'
|
|
||||||
const isPasteEvent = event.type === 'paste'
|
|
||||||
const isCutEvent = event.type === 'cut'
|
|
||||||
const isDragEvent = event.type.startsWith('drag') || event.type === 'drop'
|
|
||||||
|
|
||||||
if (isDraggable && isDragEvent && !this.isDragging) {
|
|
||||||
event.preventDefault()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// we have to store that dragging started
|
|
||||||
if (isDraggable && isEditable && !this.isDragging && event.type === 'mousedown') {
|
|
||||||
const dragHandle = target.closest('[data-drag-handle]')
|
|
||||||
const isValidDragHandle = dragHandle
|
|
||||||
&& (this.dom === dragHandle || (this.dom.contains(dragHandle)))
|
|
||||||
|
|
||||||
if (isValidDragHandle) {
|
|
||||||
this.isDragging = true
|
|
||||||
document.addEventListener('dragend', () => {
|
|
||||||
this.isDragging = false
|
|
||||||
}, { once: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// these events are handled by prosemirror
|
|
||||||
if (this.isDragging || isCopyEvent || isPasteEvent || isCutEvent) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
ignoreMutation(mutation: MutationRecord | { type: 'selection'; target: Element }) {
|
|
||||||
if (mutation.type === 'selection') {
|
|
||||||
if (this.node.isLeaf) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.contentDOM) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentDOMHasChanged = !this.contentDOM.contains(mutation.target)
|
|
||||||
|| this.contentDOM === mutation.target
|
|
||||||
|
|
||||||
return contentDOMHasChanged
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.vm.$destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
update(node: ProseMirrorNode, decorations: Decoration[]) {
|
|
||||||
if (typeof this.options.update === 'function') {
|
|
||||||
return this.options.update(node, decorations)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type !== this.node.type) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node === this.node && this.decorations === decorations) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
this.node = node
|
|
||||||
this.decorations = decorations
|
|
||||||
this.updateComponentProps({ node, decorations })
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
updateComponentProps(data: { [key: string]: any } = {}) {
|
|
||||||
if (!this.vm.$props) {
|
if (!this.vm.$props) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -277,7 +24,7 @@ class VueNodeView implements NodeView {
|
|||||||
Vue.config.silent = true
|
Vue.config.silent = true
|
||||||
|
|
||||||
Object
|
Object
|
||||||
.entries(data)
|
.entries(props)
|
||||||
.forEach(([key, value]) => {
|
.forEach(([key, value]) => {
|
||||||
this.vm.$props[key] = value
|
this.vm.$props[key] = value
|
||||||
})
|
})
|
||||||
@@ -285,48 +32,7 @@ class VueNodeView implements NodeView {
|
|||||||
Vue.config.silent = originalSilent
|
Vue.config.silent = originalSilent
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAttributes(attributes: {}) {
|
destroy() {
|
||||||
if (!this.editor.view.editable) {
|
this.vm.$destroy()
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { state } = this.editor.view
|
|
||||||
const pos = this.getPos()
|
|
||||||
const transaction = state.tr.setNodeMarkup(pos, undefined, {
|
|
||||||
...this.node.attrs,
|
|
||||||
...attributes,
|
|
||||||
})
|
|
||||||
|
|
||||||
this.editor.view.dispatch(transaction)
|
|
||||||
}
|
|
||||||
|
|
||||||
selectNode() {
|
|
||||||
this.updateComponentProps({
|
|
||||||
selected: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
deselectNode() {
|
|
||||||
this.updateComponentProps({
|
|
||||||
selected: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VueRenderer(component: Vue | VueConstructor, options?: Partial<VueRendererOptions>): NodeViewRenderer {
|
|
||||||
return (props: NodeViewRendererProps) => {
|
|
||||||
// try to get the parent component
|
|
||||||
// this is important for vue devtools to show the component hierarchy correctly
|
|
||||||
// maybe it’s `undefined` because <editor-content> isn’t rendered yet
|
|
||||||
const parent = props.editor.view.dom.parentElement
|
|
||||||
? getComponentFromElement(props.editor.view.dom.parentElement)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
if (!parent) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new VueNodeView(component, props, options) as NodeView
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from '@tiptap/core'
|
export * from '@tiptap/core'
|
||||||
export { default as VueRenderer } from './VueRenderer'
|
export { default as VueRenderer } from './VueRenderer'
|
||||||
|
export { default as VueNodeViewRenderer } from './VueNodeViewRenderer'
|
||||||
export { default as EditorContent } from './components/EditorContent'
|
export { default as EditorContent } from './components/EditorContent'
|
||||||
|
|||||||
68
tests/cypress/integration/core/mergeDeep.spec.ts
Normal file
68
tests/cypress/integration/core/mergeDeep.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/// <reference types="cypress" />
|
||||||
|
|
||||||
|
import mergeDeep from '@tiptap/core/src/utilities/mergeDeep'
|
||||||
|
|
||||||
|
describe('mergeDeep', () => {
|
||||||
|
it('should merge', () => {
|
||||||
|
const one = {
|
||||||
|
a: 1,
|
||||||
|
}
|
||||||
|
const two = {
|
||||||
|
b: 1,
|
||||||
|
}
|
||||||
|
const result = {
|
||||||
|
a: 1,
|
||||||
|
b: 1,
|
||||||
|
}
|
||||||
|
const merged = mergeDeep(one, two)
|
||||||
|
|
||||||
|
expect(merged).to.deep.eq(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not merge array', () => {
|
||||||
|
const one = {
|
||||||
|
a: [1],
|
||||||
|
}
|
||||||
|
const two = {
|
||||||
|
a: [2],
|
||||||
|
}
|
||||||
|
const result = {
|
||||||
|
a: [2],
|
||||||
|
}
|
||||||
|
const merged = mergeDeep(one, two)
|
||||||
|
|
||||||
|
expect(merged).to.deep.eq(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should merge deep', () => {
|
||||||
|
const one = {
|
||||||
|
a: 1,
|
||||||
|
b: {
|
||||||
|
c: true,
|
||||||
|
},
|
||||||
|
d: {
|
||||||
|
e: true,
|
||||||
|
f: [1],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const two = {
|
||||||
|
b: 1,
|
||||||
|
d: {
|
||||||
|
f: [2],
|
||||||
|
g: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const result = {
|
||||||
|
a: 1,
|
||||||
|
b: 1,
|
||||||
|
d: {
|
||||||
|
e: true,
|
||||||
|
f: [2],
|
||||||
|
g: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const merged = mergeDeep(one, two)
|
||||||
|
|
||||||
|
expect(merged).to.deep.eq(result)
|
||||||
|
})
|
||||||
|
})
|
||||||
12
yarn.lock
12
yarn.lock
@@ -2231,6 +2231,11 @@
|
|||||||
"@octokit/openapi-types" "^1.2.0"
|
"@octokit/openapi-types" "^1.2.0"
|
||||||
"@types/node" ">= 8"
|
"@types/node" ">= 8"
|
||||||
|
|
||||||
|
"@popperjs/core@^2.4.4":
|
||||||
|
version "2.6.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.6.0.tgz#f022195afdfc942e088ee2101285a1d31c7d727f"
|
||||||
|
integrity sha512-cPqjjzuFWNK3BSKLm0abspP0sp/IGOli4p5I5fKFAzdS8fvjdOwDCfZqAaIiXd9lPkOWi3SUUfZof3hEb7J/uw==
|
||||||
|
|
||||||
"@rollup/plugin-babel@^5.2.1":
|
"@rollup/plugin-babel@^5.2.1":
|
||||||
version "5.2.2"
|
version "5.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.2.2.tgz#e5623a01dd8e37e004ba87f2de218c611727d9b2"
|
resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.2.2.tgz#e5623a01dd8e37e004ba87f2de218c611727d9b2"
|
||||||
@@ -14120,6 +14125,13 @@ tiny-emitter@^2.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423"
|
resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423"
|
||||||
integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==
|
integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==
|
||||||
|
|
||||||
|
tippy.js@^6.2.7:
|
||||||
|
version "6.2.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-6.2.7.tgz#62fb34eda23f7d78151ddca922b62818c1ab9869"
|
||||||
|
integrity sha512-k+kWF9AJz5xLQHBi3K/XlmJiyu+p9gsCyc5qZhxxGaJWIW8SMjw1R+C7saUnP33IM8gUhDA2xX//ejRSwqR0tA==
|
||||||
|
dependencies:
|
||||||
|
"@popperjs/core" "^2.4.4"
|
||||||
|
|
||||||
tmp@^0.0.33:
|
tmp@^0.0.33:
|
||||||
version "0.0.33"
|
version "0.0.33"
|
||||||
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
|
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
|
||||||
|
|||||||
Reference in New Issue
Block a user