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",
|
||||
"remixicon": "^2.5.0",
|
||||
"simplify-js": "^1.2.4",
|
||||
"tippy.js": "^6.2.7",
|
||||
"vue-github-button": "^1.1.2",
|
||||
"vue-live": "^1.16.0",
|
||||
"y-indexeddb": "^9.0.6",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import { VueRenderer } from '@tiptap/vue'
|
||||
import { VueNodeViewRenderer } from '@tiptap/vue'
|
||||
import Component from './Component.vue'
|
||||
|
||||
export default Node.create({
|
||||
@@ -30,6 +30,6 @@ export default Node.create({
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return VueRenderer(Component)
|
||||
return VueNodeViewRenderer(Component)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import { VueRenderer } from '@tiptap/vue'
|
||||
import { VueNodeViewRenderer } from '@tiptap/vue'
|
||||
import Component from './Component.vue'
|
||||
|
||||
export default Node.create({
|
||||
@@ -24,6 +24,6 @@ export default Node.create({
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return VueRenderer(Component)
|
||||
return VueNodeViewRenderer(Component)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import { VueRenderer } from '@tiptap/vue'
|
||||
import { VueNodeViewRenderer } from '@tiptap/vue'
|
||||
import Component from './Component.vue'
|
||||
|
||||
export default Node.create({
|
||||
@@ -22,6 +22,6 @@ export default Node.create({
|
||||
},
|
||||
|
||||
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. |
|
||||
| .liftEmptyBlock() | Lift block if empty. |
|
||||
| .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. |
|
||||
| .selectParentNode() | Select the parent node. |
|
||||
| .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
|
||||
|
||||
:::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).
|
||||
:::
|
||||
## Installation
|
||||
```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
|
||||
import { Node } from '@tiptap/core'
|
||||
import { VueRenderer } from '@tiptap/vue'
|
||||
import { VueNodeViewRenderer } from '@tiptap/vue'
|
||||
import Component from './Component.vue'
|
||||
|
||||
export default Node.create({
|
||||
@@ -83,12 +83,12 @@ https://github.com/ueberdosis/tiptap-next/blob/main/packages/extension-task-item
|
||||
|
||||
```js
|
||||
import { Node } from '@tiptap/core'
|
||||
import { VueRenderer } from '@tiptap/vue'
|
||||
import { VueNodeViewRenderer } from '@tiptap/vue'
|
||||
import Component from './Component.vue'
|
||||
|
||||
export default Node.create({
|
||||
addNodeView() {
|
||||
return VueRenderer(Component)
|
||||
return VueNodeViewRenderer(Component)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
@@ -174,9 +174,6 @@
|
||||
link: /api/extensions/gapcursor
|
||||
- title: History
|
||||
link: /api/extensions/history
|
||||
- title: Suggestion
|
||||
link: /api/extensions/suggestion
|
||||
type: draft
|
||||
- title: TextAlign
|
||||
link: /api/extensions/text-align
|
||||
- title: Typography
|
||||
|
||||
@@ -29,7 +29,7 @@ export class Editor extends EventEmitter {
|
||||
|
||||
private commandManager!: CommandManager
|
||||
|
||||
private extensionManager!: ExtensionManager
|
||||
public extensionManager!: ExtensionManager
|
||||
|
||||
private css!: HTMLStyleElement
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Plugin, Transaction } from 'prosemirror-state'
|
||||
import { InputRule } from 'prosemirror-inputrules'
|
||||
import { Editor } from './Editor'
|
||||
import mergeDeep from './utilities/mergeDeep'
|
||||
import { GlobalAttributes } from './types'
|
||||
|
||||
export interface ExtensionConfig<Options = any, Commands = {}> {
|
||||
@@ -171,20 +172,14 @@ export class Extension<Options = any, Commands = any> {
|
||||
return new Extension<O, C>(config)
|
||||
}
|
||||
|
||||
configure(options?: Partial<Options>) {
|
||||
configure(options: Partial<Options> = {}) {
|
||||
return Extension
|
||||
.create<Options, Commands>(this.config as ExtensionConfig<Options, Commands>)
|
||||
.#configure({
|
||||
...this.config.defaultOptions,
|
||||
...options,
|
||||
})
|
||||
.#configure(options)
|
||||
}
|
||||
|
||||
#configure = (options: Partial<Options>) => {
|
||||
this.options = {
|
||||
...this.config.defaultOptions,
|
||||
...options,
|
||||
}
|
||||
this.options = mergeDeep(this.config.defaultOptions, options) as Options
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Editor } from './Editor'
|
||||
import { Extensions, NodeViewRenderer } from './types'
|
||||
import getSchema from './helpers/getSchema'
|
||||
import getSchemaTypeByName from './helpers/getSchemaTypeByName'
|
||||
import getNodeType from './helpers/getNodeType'
|
||||
import splitExtensions from './helpers/splitExtensions'
|
||||
import getAttributesFromExtensions from './helpers/getAttributesFromExtensions'
|
||||
import getRenderedAttributes from './helpers/getRenderedAttributes'
|
||||
@@ -145,11 +146,9 @@ export default class ExtensionManager {
|
||||
const context = {
|
||||
options: extension.options,
|
||||
editor,
|
||||
type: getSchemaTypeByName(extension.config.name, this.schema),
|
||||
type: getNodeType(extension.config.name, this.schema),
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const renderer = extension.config.addNodeView?.bind(context)?.() as NodeViewRenderer
|
||||
const renderer = extension.config.addNodeView?.call(context) as NodeViewRenderer
|
||||
|
||||
const nodeview = (
|
||||
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 { InputRule } from 'prosemirror-inputrules'
|
||||
import { ExtensionConfig } from './Extension'
|
||||
import mergeDeep from './utilities/mergeDeep'
|
||||
import { Attributes, Overwrite } from './types'
|
||||
import { Editor } from './Editor'
|
||||
|
||||
@@ -231,20 +232,14 @@ export class Mark<Options = any, Commands = {}> {
|
||||
return new Mark<O, C>(config)
|
||||
}
|
||||
|
||||
configure(options?: Partial<Options>) {
|
||||
configure(options: Partial<Options> = {}) {
|
||||
return Mark
|
||||
.create<Options, Commands>(this.config as MarkConfig<Options, Commands>)
|
||||
.#configure({
|
||||
...this.config.defaultOptions,
|
||||
...options,
|
||||
})
|
||||
.#configure(options)
|
||||
}
|
||||
|
||||
#configure = (options: Partial<Options>) => {
|
||||
this.options = {
|
||||
...this.config.defaultOptions,
|
||||
...options,
|
||||
}
|
||||
this.options = mergeDeep(this.config.defaultOptions, options) as Options
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import {
|
||||
DOMOutputSpec,
|
||||
NodeSpec,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
import { Plugin, Transaction } from 'prosemirror-state'
|
||||
import { InputRule } from 'prosemirror-inputrules'
|
||||
import { ExtensionConfig } from './Extension'
|
||||
import mergeDeep from './utilities/mergeDeep'
|
||||
import { Attributes, NodeViewRenderer, Overwrite } from './types'
|
||||
import { Editor } from './Editor'
|
||||
|
||||
@@ -88,6 +90,20 @@ export interface NodeConfig<Options = any, Commands = {}> extends Overwrite<Exte
|
||||
}
|
||||
) => DOMOutputSpec) | null,
|
||||
|
||||
/**
|
||||
* Render Text
|
||||
*/
|
||||
renderText?: ((
|
||||
this: {
|
||||
options: Options,
|
||||
editor: Editor,
|
||||
type: NodeType,
|
||||
},
|
||||
props: {
|
||||
node: ProseMirrorNode,
|
||||
}
|
||||
) => string) | null,
|
||||
|
||||
/**
|
||||
* Add Attributes
|
||||
*/
|
||||
@@ -257,6 +273,7 @@ export class Node<Options = any, Commands = {}> {
|
||||
isolating: null,
|
||||
parseHTML: () => null,
|
||||
renderHTML: null,
|
||||
renderText: null,
|
||||
addAttributes: () => ({}),
|
||||
addNodeView: null,
|
||||
onCreate: null,
|
||||
@@ -283,20 +300,14 @@ export class Node<Options = any, Commands = {}> {
|
||||
return new Node<O, C>(config)
|
||||
}
|
||||
|
||||
configure(options?: Partial<Options>) {
|
||||
configure(options: Partial<Options> = {}) {
|
||||
return Node
|
||||
.create<Options, Commands>(this.config as NodeConfig<Options, Commands>)
|
||||
.#configure({
|
||||
...this.config.defaultOptions,
|
||||
...options,
|
||||
})
|
||||
.#configure(options)
|
||||
}
|
||||
|
||||
#configure = (options: Partial<Options>) => {
|
||||
this.options = {
|
||||
...this.config.defaultOptions,
|
||||
...options,
|
||||
}
|
||||
this.options = mergeDeep(this.config.defaultOptions, options) as Options
|
||||
|
||||
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 liftListItem from '../commands/liftListItem'
|
||||
import * as newlineInCode from '../commands/newlineInCode'
|
||||
import * as replace from '../commands/replace'
|
||||
import * as resetNodeAttributes from '../commands/resetNodeAttributes'
|
||||
import * as scrollIntoView from '../commands/scrollIntoView'
|
||||
import * as selectAll from '../commands/selectAll'
|
||||
@@ -63,6 +64,7 @@ export const Commands = Extension.create({
|
||||
...liftEmptyBlock,
|
||||
...liftListItem,
|
||||
...newlineInCode,
|
||||
...replace,
|
||||
...resetNodeAttributes,
|
||||
...scrollIntoView,
|
||||
...selectAll,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { ClipboardTextSerializer } from './clipboardTextSerializer'
|
||||
export { Commands } from './commands'
|
||||
export { Editable } from './editable'
|
||||
export { FocusEvents } from './focusEvents'
|
||||
|
||||
@@ -130,3 +130,8 @@ export type ChainedCommands = {
|
||||
export type CanCommands = SingleCommands & { chain: () => ChainedCommands }
|
||||
|
||||
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 { VueConstructor } from 'vue/types/umd'
|
||||
|
||||
function getComponentFromElement(element: HTMLElement): Vue {
|
||||
// @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 {
|
||||
|
||||
export default class VueRenderer {
|
||||
vm!: Vue
|
||||
|
||||
editor: Editor
|
||||
constructor(component: Vue | VueConstructor, props: any) {
|
||||
const Component = Vue.extend(component)
|
||||
|
||||
extension!: Node
|
||||
|
||||
node!: ProseMirrorNode
|
||||
|
||||
decorations!: Decoration[]
|
||||
|
||||
id!: string
|
||||
|
||||
getPos!: any
|
||||
|
||||
isDragging = false
|
||||
|
||||
options: VueRendererOptions = {
|
||||
stopEvent: null,
|
||||
update: null,
|
||||
this.vm = new Component(props).$mount()
|
||||
}
|
||||
|
||||
constructor(component: Vue | VueConstructor, props: NodeViewRendererProps, options?: Partial<VueRendererOptions>) {
|
||||
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() {
|
||||
get element() {
|
||||
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 } = {}) {
|
||||
updateProps(props: { [key: string]: any } = {}) {
|
||||
if (!this.vm.$props) {
|
||||
return
|
||||
}
|
||||
@@ -277,7 +24,7 @@ class VueNodeView implements NodeView {
|
||||
Vue.config.silent = true
|
||||
|
||||
Object
|
||||
.entries(data)
|
||||
.entries(props)
|
||||
.forEach(([key, value]) => {
|
||||
this.vm.$props[key] = value
|
||||
})
|
||||
@@ -285,48 +32,7 @@ class VueNodeView implements NodeView {
|
||||
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 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
|
||||
destroy() {
|
||||
this.vm.$destroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from '@tiptap/core'
|
||||
export { default as VueRenderer } from './VueRenderer'
|
||||
export { default as VueNodeViewRenderer } from './VueNodeViewRenderer'
|
||||
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"
|
||||
"@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":
|
||||
version "5.2.2"
|
||||
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"
|
||||
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:
|
||||
version "0.0.33"
|
||||
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
|
||||
|
||||
Reference in New Issue
Block a user