add basic floating menu
This commit is contained in:
43
docs/src/demos/Extensions/FloatingMenu/React/index.jsx
Normal file
43
docs/src/demos/Extensions/FloatingMenu/React/index.jsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useEditor, EditorContent, BubbleMenu } from '@tiptap/react'
|
||||||
|
import { defaultExtensions } from '@tiptap/starter-kit'
|
||||||
|
import './styles.scss'
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
...defaultExtensions(),
|
||||||
|
],
|
||||||
|
content: `
|
||||||
|
<p>
|
||||||
|
Hey, try to select some text here. There will popup a menu for selecting some inline styles. Remember: you have full control about content and styling of this menu.
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
{editor && <BubbleMenu editor={editor}>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
|
className={editor.isActive('bold') ? 'is-active' : ''}
|
||||||
|
>
|
||||||
|
bold
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
className={editor.isActive('italic') ? 'is-active' : ''}
|
||||||
|
>
|
||||||
|
italic
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||||
|
className={editor.isActive('code') ? 'is-active' : ''}
|
||||||
|
>
|
||||||
|
code
|
||||||
|
</button>
|
||||||
|
</BubbleMenu>}
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
docs/src/demos/Extensions/FloatingMenu/React/styles.scss
Normal file
5
docs/src/demos/Extensions/FloatingMenu/React/styles.scss
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.ProseMirror {
|
||||||
|
> * + * {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
docs/src/demos/Extensions/FloatingMenu/Vue/index.vue
Normal file
73
docs/src/demos/Extensions/FloatingMenu/Vue/index.vue
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<div style="position: relative">
|
||||||
|
<floating-menu :editor="editor" v-if="editor">
|
||||||
|
<button @click="editor.chain().focus().toggleHeading({ level: 1 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }">
|
||||||
|
h1
|
||||||
|
</button>
|
||||||
|
<button @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }">
|
||||||
|
h2
|
||||||
|
</button>
|
||||||
|
<button @click="editor.chain().focus().toggleBulletList().run()" :class="{ 'is-active': editor.isActive('bulletList') }">
|
||||||
|
bullet list
|
||||||
|
</button>
|
||||||
|
<button @click="editor.chain().focus().toggleBlockquote().run()" :class="{ 'is-active': editor.isActive('blockquote') }">
|
||||||
|
blockquote
|
||||||
|
</button>
|
||||||
|
</floating-menu>
|
||||||
|
<editor-content :editor="editor" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Editor, EditorContent, FloatingMenu } from '@tiptap/vue-2'
|
||||||
|
import { defaultExtensions } from '@tiptap/starter-kit'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
EditorContent,
|
||||||
|
FloatingMenu,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
editor: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.editor = new Editor({
|
||||||
|
extensions: [
|
||||||
|
...defaultExtensions(),
|
||||||
|
],
|
||||||
|
content: `
|
||||||
|
<p>
|
||||||
|
Hey, try to select some text here. There will popup a menu for selecting some inline styles. Remember: you have full control about content and styling of this menu.
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeDestroy() {
|
||||||
|
this.editor.destroy()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
/* Basic editor styles */
|
||||||
|
.ProseMirror {
|
||||||
|
> * + * {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
padding-left: 1rem;
|
||||||
|
border-left: 2px solid rgba(#0D0D0D, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
14
packages/extension-floating-menu/README.md
Normal file
14
packages/extension-floating-menu/README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# @tiptap/extension-floating-menu
|
||||||
|
[](https://www.npmjs.com/package/@tiptap/extension-floating-menu)
|
||||||
|
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||||
|
[](https://www.npmjs.com/package/@tiptap/extension-floating-menu)
|
||||||
|
[](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).
|
||||||
31
packages/extension-floating-menu/package.json
Normal file
31
packages/extension-floating-menu/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@tiptap/extension-floating-menu",
|
||||||
|
"description": "floating-menu extension for tiptap",
|
||||||
|
"version": "2.0.0-beta.0",
|
||||||
|
"homepage": "https://tiptap.dev",
|
||||||
|
"keywords": [
|
||||||
|
"tiptap",
|
||||||
|
"tiptap extension"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"main": "dist/tiptap-extension-floating-menu.cjs.js",
|
||||||
|
"umd": "dist/tiptap-extension-floating-menu.umd.js",
|
||||||
|
"module": "dist/tiptap-extension-floating-menu.esm.js",
|
||||||
|
"unpkg": "dist/tiptap-extension-floating-menu.bundle.umd.min.js",
|
||||||
|
"types": "dist/packages/extension-floating-menu/src/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.0.0-beta.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.3.4",
|
||||||
|
"prosemirror-view": "^1.18.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
131
packages/extension-floating-menu/src/floating-menu-plugin.ts
Normal file
131
packages/extension-floating-menu/src/floating-menu-plugin.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { Editor } from '@tiptap/core'
|
||||||
|
import { EditorState, Plugin, PluginKey } from 'prosemirror-state'
|
||||||
|
import { EditorView } from 'prosemirror-view'
|
||||||
|
|
||||||
|
export interface FloatingMenuPluginProps {
|
||||||
|
editor: Editor,
|
||||||
|
element: HTMLElement,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FloatingMenuViewProps = FloatingMenuPluginProps & {
|
||||||
|
view: EditorView,
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FloatingMenuView {
|
||||||
|
public editor: Editor
|
||||||
|
|
||||||
|
public element: HTMLElement
|
||||||
|
|
||||||
|
public view: EditorView
|
||||||
|
|
||||||
|
public isActive = false
|
||||||
|
|
||||||
|
public top = 0
|
||||||
|
|
||||||
|
public preventHide = false
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
editor,
|
||||||
|
element,
|
||||||
|
view,
|
||||||
|
}: FloatingMenuViewProps) {
|
||||||
|
this.editor = editor
|
||||||
|
this.element = element
|
||||||
|
this.view = view
|
||||||
|
this.element.addEventListener('mousedown', this.mousedownHandler, { capture: true })
|
||||||
|
this.editor.on('focus', this.focusHandler)
|
||||||
|
this.editor.on('blur', this.blurHandler)
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
mousedownHandler = () => {
|
||||||
|
this.preventHide = true
|
||||||
|
}
|
||||||
|
|
||||||
|
focusHandler = () => {
|
||||||
|
// we use `setTimeout` to make sure `selection` is already updated
|
||||||
|
setTimeout(() => this.update(this.editor.view))
|
||||||
|
}
|
||||||
|
|
||||||
|
blurHandler = ({ event }: { event: FocusEvent }) => {
|
||||||
|
if (this.preventHide) {
|
||||||
|
this.preventHide = false
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event?.relatedTarget
|
||||||
|
&& this.element.parentNode?.contains(event.relatedTarget as Node)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
update(view: EditorView, oldState?: EditorState) {
|
||||||
|
const { state, composing } = view
|
||||||
|
const { doc, selection } = state
|
||||||
|
const isSame = oldState && oldState.doc.eq(doc) && oldState.selection.eq(selection)
|
||||||
|
|
||||||
|
if (composing || isSame) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { anchor, empty } = selection
|
||||||
|
const parent = this.element.offsetParent
|
||||||
|
const currentDom = view.domAtPos(anchor)
|
||||||
|
const currentElement = currentDom.node as Element
|
||||||
|
const isActive = currentElement.innerHTML === '<br>'
|
||||||
|
&& currentElement.tagName === 'P'
|
||||||
|
&& currentElement.parentNode === view.dom
|
||||||
|
|
||||||
|
if (!empty || !parent || !isActive) {
|
||||||
|
this.hide()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentBox = parent.getBoundingClientRect()
|
||||||
|
const cursorCoords = view.coordsAtPos(anchor)
|
||||||
|
const top = cursorCoords.top - parentBox.top
|
||||||
|
|
||||||
|
this.isActive = true
|
||||||
|
this.top = top
|
||||||
|
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
Object.assign(this.element.style, {
|
||||||
|
position: 'absolute',
|
||||||
|
zIndex: 1,
|
||||||
|
visibility: this.isActive ? 'visible' : 'hidden',
|
||||||
|
opacity: this.isActive ? 1 : 0,
|
||||||
|
// left: `${this.left}px`,
|
||||||
|
top: `${this.top}px`,
|
||||||
|
// bottom: `${this.bottom}px`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
this.isActive = false
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.element.removeEventListener('mousedown', this.mousedownHandler)
|
||||||
|
this.editor.off('focus', this.focusHandler)
|
||||||
|
this.editor.off('blur', this.blurHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FloatingMenuPluginKey = new PluginKey('menuFloating')
|
||||||
|
|
||||||
|
export const FloatingMenuPlugin = (options: FloatingMenuPluginProps) => {
|
||||||
|
return new Plugin({
|
||||||
|
key: FloatingMenuPluginKey,
|
||||||
|
view: view => new FloatingMenuView({ view, ...options }),
|
||||||
|
})
|
||||||
|
}
|
||||||
27
packages/extension-floating-menu/src/floating-menu.ts
Normal file
27
packages/extension-floating-menu/src/floating-menu.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
import { FloatingMenuPlugin, FloatingMenuPluginProps } from './floating-menu-plugin'
|
||||||
|
|
||||||
|
export type FloatingMenuOptions = Omit<FloatingMenuPluginProps, 'editor' | 'element'> & {
|
||||||
|
element: HTMLElement | null,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FloatingMenu = Extension.create<FloatingMenuOptions>({
|
||||||
|
name: 'bubbleMenu',
|
||||||
|
|
||||||
|
defaultOptions: {
|
||||||
|
element: null,
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
if (!this.options.element) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
FloatingMenuPlugin({
|
||||||
|
editor: this.editor,
|
||||||
|
element: this.options.element,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
6
packages/extension-floating-menu/src/index.ts
Normal file
6
packages/extension-floating-menu/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { FloatingMenu } from './floating-menu'
|
||||||
|
|
||||||
|
export * from './floating-menu'
|
||||||
|
export * from './floating-menu-plugin'
|
||||||
|
|
||||||
|
export default FloatingMenu
|
||||||
39
packages/vue-2/src/FloatingMenu.ts
Normal file
39
packages/vue-2/src/FloatingMenu.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import Vue, { PropType } from 'vue'
|
||||||
|
import { FloatingMenuPlugin, FloatingMenuPluginKey, FloatingMenuPluginProps } from '@tiptap/extension-floating-menu'
|
||||||
|
|
||||||
|
export const FloatingMenu = Vue.extend({
|
||||||
|
name: 'FloatingMenu',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
editor: {
|
||||||
|
type: Object as PropType<FloatingMenuPluginProps['editor']>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
editor: {
|
||||||
|
immediate: true,
|
||||||
|
handler(editor: FloatingMenuPluginProps['editor']) {
|
||||||
|
if (!editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
editor.registerPlugin(FloatingMenuPlugin({
|
||||||
|
editor,
|
||||||
|
element: this.$el as HTMLElement,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
render(createElement) {
|
||||||
|
return createElement('div', {}, this.$slots.default)
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeDestroy() {
|
||||||
|
this.editor.unregisterPlugin(FloatingMenuPluginKey)
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ export * from '@tiptap/core'
|
|||||||
export * from './BubbleMenu'
|
export * from './BubbleMenu'
|
||||||
export { Editor } from './Editor'
|
export { Editor } from './Editor'
|
||||||
export * from './EditorContent'
|
export * from './EditorContent'
|
||||||
|
export * from './FloatingMenu'
|
||||||
export * from './VueRenderer'
|
export * from './VueRenderer'
|
||||||
export * from './VueNodeViewRenderer'
|
export * from './VueNodeViewRenderer'
|
||||||
export * from './NodeViewWrapper'
|
export * from './NodeViewWrapper'
|
||||||
|
|||||||
Reference in New Issue
Block a user