Merge branch 'main' of github.com:ueberdosis/tiptap-next into main

This commit is contained in:
Hans Pagel
2021-01-20 11:41:10 +01:00
38 changed files with 1309 additions and 365 deletions

View File

@@ -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",

View File

@@ -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)
}, },
}) })

View File

@@ -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)
}, },
}) })

View File

@@ -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)
}, },
}) })

View 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>

View File

@@ -0,0 +1,5 @@
context('/api/nodes/mention', () => {
before(() => {
cy.visit('/api/nodes/mention')
})
})

View 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>

View File

@@ -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. |

View File

@@ -1,14 +0,0 @@
# Suggestion
:::pro Fund the development 💖
We need your support to maintain, update, support and develop tiptap 2. If youre 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)

View File

@@ -1,7 +1,16 @@
# Mention # Mention
:::pro Fund the development 💖 ## Installation
We need your support to maintain, update, support and develop tiptap 2. If youre 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" />

View File

@@ -7,7 +7,7 @@ Node views are the best thing since sliced bread, at least if youre 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)
}, },
}) })
``` ```

View File

@@ -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

View File

@@ -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

View File

@@ -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
} }

View File

@@ -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]
}))
}
} }

View File

@@ -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
} }

View File

@@ -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
} }

View 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
}

View 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,
}
}

View File

@@ -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,

View File

@@ -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'

View File

@@ -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,
}

View File

@@ -0,0 +1,3 @@
export default function isObject(item: any): boolean {
return (item && typeof item === 'object' && !Array.isArray(item))
}

View 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
}

View File

@@ -0,0 +1,14 @@
# @tiptap/extension-mention
[![Version](https://img.shields.io/npm/v/@tiptap/extension-mention.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-mention)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-mention.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-mention.svg)](https://www.npmjs.com/package/@tiptap/extension-mention)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as *New York Times*, *The Guardian* or *Atlassian*.
## 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).

View 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"
}
}

View File

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

View 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,
}
}

View File

@@ -0,0 +1,14 @@
# @tiptap/suggestions
[![Version](https://img.shields.io/npm/v/@tiptap/suggestions.svg?label=version)](https://www.npmjs.com/package/@tiptap/suggestions)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/suggestions.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/suggestions.svg)](https://www.npmjs.com/package/@tiptap/suggestions)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as *New York Times*, *The Guardian* or *Atlassian*.
## 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).

View 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"
}
}

View 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
}

View File

@@ -0,0 +1,6 @@
import { Suggestion } from './suggestion'
export * from './findSuggestionMatch'
export * from './suggestion'
export default Suggestion

View 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,
}),
])
},
},
})
}

View 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 its `undefined` because <editor-content> isnt 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
}
}

View File

@@ -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 its `undefined` because <editor-content> isnt 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
} }
} }

View File

@@ -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'

View 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)
})
})

View File

@@ -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"