diff --git a/docs/src/docPages/api/extensions/bubble-menu.md b/docs/src/docPages/api/extensions/bubble-menu.md index 0a5c7798e4a..6c1822c7fe5 100644 --- a/docs/src/docPages/api/extensions/bubble-menu.md +++ b/docs/src/docPages/api/extensions/bubble-menu.md @@ -15,10 +15,12 @@ yarn add @tiptap/extension-bubble-menu ``` ## Settings -| Option | Type | Default | Description | -| ------------ | ------------- | ------- | ----------------------------------------------------------------------- | -| element | `HTMLElement` | `null` | The DOM element that contains your menu. | -| tippyOptions | `Object` | `{}` | [Options for tippy.js](https://atomiks.github.io/tippyjs/v6/all-props/) | +| Option | Type | Default | Description | +| ------------ | -------------------- | -------------- | ----------------------------------------------------------------------- | +| element | `HTMLElement` | `null` | The DOM element that contains your menu. | +| tippyOptions | `Object` | `{}` | [Options for tippy.js](https://atomiks.github.io/tippyjs/v6/all-props/) | +| key | `string | PluginKey` | `'bubbleMenu'` | The key for the underlying ProseMirror plugin. | +| shouldShow | `(props) => boolean` | | Controls whether the menu should be shown or not. | ## Source code [packages/extension-bubble-menu/](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-bubble-menu/) @@ -44,3 +46,57 @@ new Editor({ Vue: 'Extensions/BubbleMenu/Vue', React: 'Extensions/BubbleMenu/React', }" /> + +### Custom logic +Customize the logic for showing the menu with the `shouldShow` option. For components, `shouldShow` can be passed as a prop. + +```js +BubbleMenu.configure({ + shouldShow: ({ editor, view, state, oldState, from, to }) => { + // only show the bubble menu for images and links + return editor.isActive('image') || editor.isActive('link') + }, +}) +``` + +### Multiple menus +Use multiple menus by setting an unique `key`. + +```js +import { Editor } from '@tiptap/core' +import BubbleMenu from '@tiptap/extension-bubble-menu' + +new Editor({ + extensions: [ + BubbleMenu.configure({ + key: 'bubbleMenuOne', + element: document.querySelector('.menu-one'), + }), + BubbleMenu.configure({ + key: 'bubbleMenuTwo', + element: document.querySelector('.menu-two'), + }), + ], +}) +``` + +Alternatively you can pass a ProseMirror `PluginKey`. + +```js +import { Editor } from '@tiptap/core' +import BubbleMenu from '@tiptap/extension-bubble-menu' +import { PluginKey } from 'prosemirror-state' + +new Editor({ + extensions: [ + BubbleMenu.configure({ + key: new PluginKey('bubbleMenuOne'), + element: document.querySelector('.menu-one'), + }), + BubbleMenu.configure({ + key: new PluginKey('bubbleMenuTwo'), + element: document.querySelector('.menu-two'), + }), + ], +}) +``` diff --git a/docs/src/docPages/api/extensions/floating-menu.md b/docs/src/docPages/api/extensions/floating-menu.md index adccdc0f4b5..838ffe925d6 100644 --- a/docs/src/docPages/api/extensions/floating-menu.md +++ b/docs/src/docPages/api/extensions/floating-menu.md @@ -13,10 +13,12 @@ yarn add @tiptap/extension-floating-menu ``` ## Settings -| Option | Type | Default | Description | -| ------------ | ------------- | ------- | ----------------------------------------------------------------------- | -| element | `HTMLElement` | `null` | The DOM element of your menu. | -| tippyOptions | `Object` | `{}` | [Options for tippy.js](https://atomiks.github.io/tippyjs/v6/all-props/) | +| Option | Type | Default | Description | +| ------------ | -------------------- | ---------------- | ----------------------------------------------------------------------- | +| element | `HTMLElement` | `null` | The DOM element of your menu. | +| tippyOptions | `Object` | `{}` | [Options for tippy.js](https://atomiks.github.io/tippyjs/v6/all-props/) | +| key | `string | PluginKey` | `'floatingMenu'` | The key for the underlying ProseMirror plugin. | +| shouldShow | `(props) => boolean` | | Controls whether the menu should be shown or not. | ## Source code [packages/extension-floating-menu/](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-floating-menu/) @@ -40,3 +42,57 @@ new Editor({ Vue: 'Extensions/FloatingMenu/Vue', React: 'Extensions/FloatingMenu/React', }" /> + +### Custom logic +Customize the logic for showing the menu with the `shouldShow` option. For components, `shouldShow` can be passed as a prop. + +```js +FloatingMenu.configure({ + shouldShow: ({ editor, view, state, oldState }) => { + // show the floating within any paragraph + return editor.isActive('paragraph') + }, +}) +``` + +### Multiple menus +Use multiple menus by setting an unique `key`. + +```js +import { Editor } from '@tiptap/core' +import FloatingMenu from '@tiptap/extension-floating-menu' + +new Editor({ + extensions: [ + FloatingMenu.configure({ + key: 'floatingMenuOne', + element: document.querySelector('.menu-one'), + }), + FloatingMenu.configure({ + key: 'floatingMenuTwo', + element: document.querySelector('.menu-two'), + }), + ], +}) +``` + +Alternatively you can pass a ProseMirror `PluginKey`. + +```js +import { Editor } from '@tiptap/core' +import FloatingMenu from '@tiptap/extension-floating-menu' +import { PluginKey } from 'prosemirror-state' + +new Editor({ + extensions: [ + FloatingMenu.configure({ + key: new PluginKey('floatingMenuOne'), + element: document.querySelector('.menu-one'), + }), + FloatingMenu.configure({ + key: new PluginKey('floatingMenuOne'), + element: document.querySelector('.menu-two'), + }), + ], +}) +``` diff --git a/packages/extension-bubble-menu/src/bubble-menu-plugin.ts b/packages/extension-bubble-menu/src/bubble-menu-plugin.ts index 71cee1c284b..2ffb4072c2a 100644 --- a/packages/extension-bubble-menu/src/bubble-menu-plugin.ts +++ b/packages/extension-bubble-menu/src/bubble-menu-plugin.ts @@ -9,9 +9,18 @@ import { EditorView } from 'prosemirror-view' import tippy, { Instance, Props } from 'tippy.js' export interface BubbleMenuPluginProps { + key: PluginKey | string, editor: Editor, element: HTMLElement, tippyOptions?: Partial, + shouldShow: ((props: { + editor: Editor, + view: EditorView, + state: EditorState, + oldState?: EditorState, + from: number, + to: number, + }) => boolean) | null, } export type BubbleMenuViewProps = BubbleMenuPluginProps & { @@ -29,15 +38,38 @@ export class BubbleMenuView { public tippy!: Instance + public shouldShow: Exclude = ({ state, from, to }) => { + const { doc, selection } = state + const { empty } = selection + + // Sometime check for `empty` is not enough. + // Doubleclick an empty paragraph returns a node size of 2. + // So we check also for an empty text size. + const isEmptyTextBlock = !doc.textBetween(from, to).length + && isTextSelection(state.selection) + + if (empty || isEmptyTextBlock) { + return false + } + + return true + } + constructor({ editor, element, view, tippyOptions, + shouldShow, }: BubbleMenuViewProps) { this.editor = editor this.element = element this.view = view + + if (shouldShow) { + this.shouldShow = shouldShow + } + this.element.addEventListener('mousedown', this.mousedownHandler, { capture: true }) this.view.dom.addEventListener('dragstart', this.dragstartHandler) this.editor.on('focus', this.focusHandler) @@ -98,19 +130,21 @@ export class BubbleMenuView { return } - const { empty, ranges } = selection - // support for CellSelections + const { ranges } = selection const from = Math.min(...ranges.map(range => range.$from.pos)) const to = Math.max(...ranges.map(range => range.$to.pos)) - // Sometime check for `empty` is not enough. - // Doubleclick an empty paragraph returns a node size of 2. - // So we check also for an empty text size. - const isEmptyTextBlock = !doc.textBetween(from, to).length - && isTextSelection(view.state.selection) + const shouldShow = this.shouldShow({ + editor: this.editor, + view, + state, + oldState, + from, + to, + }) - if (empty || isEmptyTextBlock) { + if (!shouldShow) { this.hide() return @@ -118,7 +152,7 @@ export class BubbleMenuView { this.tippy.setProps({ getReferenceClientRect: () => { - if (isNodeSelection(view.state.selection)) { + if (isNodeSelection(state.selection)) { const node = view.nodeDOM(from) as HTMLElement if (node) { @@ -150,11 +184,11 @@ export class BubbleMenuView { } } -export const BubbleMenuPluginKey = new PluginKey('menuBubble') - export const BubbleMenuPlugin = (options: BubbleMenuPluginProps) => { return new Plugin({ - key: BubbleMenuPluginKey, + key: typeof options.key === 'string' + ? new PluginKey(options.key) + : options.key, view: view => new BubbleMenuView({ view, ...options }), }) } diff --git a/packages/extension-bubble-menu/src/bubble-menu.ts b/packages/extension-bubble-menu/src/bubble-menu.ts index 87c9c51da68..cb745864539 100644 --- a/packages/extension-bubble-menu/src/bubble-menu.ts +++ b/packages/extension-bubble-menu/src/bubble-menu.ts @@ -11,6 +11,8 @@ export const BubbleMenu = Extension.create({ defaultOptions: { element: null, tippyOptions: {}, + key: 'bubbleMenu', + shouldShow: null, }, addProseMirrorPlugins() { @@ -20,9 +22,11 @@ export const BubbleMenu = Extension.create({ return [ BubbleMenuPlugin({ + key: this.options.key, editor: this.editor, element: this.options.element, tippyOptions: this.options.tippyOptions, + shouldShow: this.options.shouldShow, }), ] }, diff --git a/packages/extension-floating-menu/src/floating-menu-plugin.ts b/packages/extension-floating-menu/src/floating-menu-plugin.ts index 3331b5f6d07..4916c0e32e3 100644 --- a/packages/extension-floating-menu/src/floating-menu-plugin.ts +++ b/packages/extension-floating-menu/src/floating-menu-plugin.ts @@ -4,9 +4,16 @@ import { EditorView } from 'prosemirror-view' import tippy, { Instance, Props } from 'tippy.js' export interface FloatingMenuPluginProps { + key: PluginKey | string, editor: Editor, element: HTMLElement, tippyOptions?: Partial, + shouldShow: ((props: { + editor: Editor, + view: EditorView, + state: EditorState, + oldState?: EditorState, + }) => boolean) | null, } export type FloatingMenuViewProps = FloatingMenuPluginProps & { @@ -24,15 +31,36 @@ export class FloatingMenuView { public tippy!: Instance + public shouldShow: Exclude = ({ state }) => { + const { selection } = state + const { $anchor, empty } = selection + const isRootDepth = $anchor.depth === 1 + const isEmptyTextBlock = $anchor.parent.isTextblock + && !$anchor.parent.type.spec.code + && !$anchor.parent.textContent + + if (!empty || !isRootDepth || !isEmptyTextBlock) { + return false + } + + return true + } + constructor({ editor, element, view, tippyOptions, + shouldShow, }: FloatingMenuViewProps) { this.editor = editor this.element = element this.view = view + + if (shouldShow) { + this.shouldShow = shouldShow + } + this.element.addEventListener('mousedown', this.mousedownHandler, { capture: true }) this.editor.on('focus', this.focusHandler) this.editor.on('blur', this.blurHandler) @@ -82,23 +110,21 @@ export class FloatingMenuView { update(view: EditorView, oldState?: EditorState) { const { state, composing } = view const { doc, selection } = state + const { from, to } = selection const isSame = oldState && oldState.doc.eq(doc) && oldState.selection.eq(selection) if (composing || isSame) { return } - const { - $anchor, - empty, - from, - to, - } = selection - const isRootDepth = $anchor.depth === 1 - const isNodeEmpty = !selection.$anchor.parent.isLeaf && !selection.$anchor.parent.textContent - const isActive = isRootDepth && isNodeEmpty + const shouldShow = this.shouldShow({ + editor: this.editor, + view, + state, + oldState, + }) - if (!empty || !isActive) { + if (!shouldShow) { this.hide() return @@ -127,11 +153,11 @@ export class FloatingMenuView { } } -export const FloatingMenuPluginKey = new PluginKey('menuFloating') - export const FloatingMenuPlugin = (options: FloatingMenuPluginProps) => { return new Plugin({ - key: FloatingMenuPluginKey, + key: typeof options.key === 'string' + ? new PluginKey(options.key) + : options.key, view: view => new FloatingMenuView({ view, ...options }), }) } diff --git a/packages/extension-floating-menu/src/floating-menu.ts b/packages/extension-floating-menu/src/floating-menu.ts index 5f216cae68d..4e0141708e7 100644 --- a/packages/extension-floating-menu/src/floating-menu.ts +++ b/packages/extension-floating-menu/src/floating-menu.ts @@ -11,6 +11,8 @@ export const FloatingMenu = Extension.create({ defaultOptions: { element: null, tippyOptions: {}, + key: 'floatingMenu', + shouldShow: null, }, addProseMirrorPlugins() { @@ -20,9 +22,11 @@ export const FloatingMenu = Extension.create({ return [ FloatingMenuPlugin({ + key: this.options.key, editor: this.editor, element: this.options.element, tippyOptions: this.options.tippyOptions, + shouldShow: this.options.shouldShow, }), ] }, diff --git a/packages/react/src/BubbleMenu.tsx b/packages/react/src/BubbleMenu.tsx index 3f48a2da966..415bb7023ff 100644 --- a/packages/react/src/BubbleMenu.tsx +++ b/packages/react/src/BubbleMenu.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from 'react' -import { BubbleMenuPlugin, BubbleMenuPluginKey, BubbleMenuPluginProps } from '@tiptap/extension-bubble-menu' +import { BubbleMenuPlugin, BubbleMenuPluginProps } from '@tiptap/extension-bubble-menu' export type BubbleMenuProps = Omit & { className?: string, @@ -9,16 +9,23 @@ export const BubbleMenu: React.FC = props => { const element = useRef(null) useEffect(() => { - const { editor, tippyOptions } = props + const { + key, + editor, + tippyOptions, + shouldShow, + } = props editor.registerPlugin(BubbleMenuPlugin({ + key, editor, element: element.current as HTMLElement, tippyOptions, + shouldShow, })) return () => { - editor.unregisterPlugin(BubbleMenuPluginKey) + editor.unregisterPlugin(key) } }, []) diff --git a/packages/react/src/FloatingMenu.tsx b/packages/react/src/FloatingMenu.tsx index e33a06363b8..d1b5d34c5fb 100644 --- a/packages/react/src/FloatingMenu.tsx +++ b/packages/react/src/FloatingMenu.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from 'react' -import { FloatingMenuPlugin, FloatingMenuPluginKey, FloatingMenuPluginProps } from '@tiptap/extension-floating-menu' +import { FloatingMenuPlugin, FloatingMenuPluginProps } from '@tiptap/extension-floating-menu' export type FloatingMenuProps = Omit & { className?: string, @@ -9,16 +9,23 @@ export const FloatingMenu: React.FC = props => { const element = useRef(null) useEffect(() => { - const { editor, tippyOptions } = props + const { + key, + editor, + tippyOptions, + shouldShow, + } = props editor.registerPlugin(FloatingMenuPlugin({ + key, editor, element: element.current as HTMLElement, tippyOptions, + shouldShow, })) return () => { - editor.unregisterPlugin(FloatingMenuPluginKey) + editor.unregisterPlugin(key) } }, []) diff --git a/packages/vue-2/src/BubbleMenu.ts b/packages/vue-2/src/BubbleMenu.ts index 5b176feb431..cc71785bad0 100644 --- a/packages/vue-2/src/BubbleMenu.ts +++ b/packages/vue-2/src/BubbleMenu.ts @@ -1,15 +1,22 @@ import Vue, { Component, PropType } from 'vue' -import { BubbleMenuPlugin, BubbleMenuPluginKey, BubbleMenuPluginProps } from '@tiptap/extension-bubble-menu' +import { BubbleMenuPlugin, BubbleMenuPluginProps } from '@tiptap/extension-bubble-menu' export interface BubbleMenuInterface extends Vue { - tippyOptions: BubbleMenuPluginProps['tippyOptions'], + pluginKey: BubbleMenuPluginProps['key'], editor: BubbleMenuPluginProps['editor'], + tippyOptions: BubbleMenuPluginProps['tippyOptions'], + shouldShow: BubbleMenuPluginProps['shouldShow'], } export const BubbleMenu: Component = { name: 'BubbleMenu', props: { + pluginKey: { + type: [String, Object as PropType>], + default: 'bubbleMenu', + }, + editor: { type: Object as PropType, required: true, @@ -19,6 +26,11 @@ export const BubbleMenu: Component = { type: Object as PropType, default: () => ({}), }, + + shouldShow: { + type: Function as PropType>, + default: null, + }, }, watch: { @@ -31,9 +43,11 @@ export const BubbleMenu: Component = { this.$nextTick(() => { editor.registerPlugin(BubbleMenuPlugin({ + key: this.pluginKey, editor, element: this.$el as HTMLElement, tippyOptions: this.tippyOptions, + shouldShow: this.shouldShow, })) }) }, @@ -45,6 +59,6 @@ export const BubbleMenu: Component = { }, beforeDestroy(this: BubbleMenuInterface) { - this.editor.unregisterPlugin(BubbleMenuPluginKey) + this.editor.unregisterPlugin(this.pluginKey) }, } diff --git a/packages/vue-2/src/FloatingMenu.ts b/packages/vue-2/src/FloatingMenu.ts index 40e38073193..78868dcfcce 100644 --- a/packages/vue-2/src/FloatingMenu.ts +++ b/packages/vue-2/src/FloatingMenu.ts @@ -1,15 +1,22 @@ import Vue, { Component, PropType } from 'vue' -import { FloatingMenuPlugin, FloatingMenuPluginKey, FloatingMenuPluginProps } from '@tiptap/extension-floating-menu' +import { FloatingMenuPlugin, FloatingMenuPluginProps } from '@tiptap/extension-floating-menu' export interface FloatingMenuInterface extends Vue { + pluginKey: FloatingMenuPluginProps['key'], tippyOptions: FloatingMenuPluginProps['tippyOptions'], editor: FloatingMenuPluginProps['editor'], + shouldShow: FloatingMenuPluginProps['shouldShow'], } export const FloatingMenu: Component = { name: 'FloatingMenu', props: { + pluginKey: { + type: [String, Object as PropType>], + default: 'floatingMenu', + }, + editor: { type: Object as PropType, required: true, @@ -19,6 +26,11 @@ export const FloatingMenu: Component = { type: Object as PropType, default: () => ({}), }, + + shouldShow: { + type: Function as PropType>, + default: null, + }, }, watch: { @@ -31,9 +43,11 @@ export const FloatingMenu: Component = { this.$nextTick(() => { editor.registerPlugin(FloatingMenuPlugin({ + key: this.pluginKey, editor, element: this.$el as HTMLElement, tippyOptions: this.tippyOptions, + shouldShow: this.shouldShow, })) }) }, @@ -45,6 +59,6 @@ export const FloatingMenu: Component = { }, beforeDestroy(this: FloatingMenuInterface) { - this.editor.unregisterPlugin(FloatingMenuPluginKey) + this.editor.unregisterPlugin(this.pluginKey) }, } diff --git a/packages/vue-3/src/BubbleMenu.ts b/packages/vue-3/src/BubbleMenu.ts index c741b8537c7..419fe150f15 100644 --- a/packages/vue-3/src/BubbleMenu.ts +++ b/packages/vue-3/src/BubbleMenu.ts @@ -6,16 +6,19 @@ import { onBeforeUnmount, defineComponent, } from 'vue' -import { - BubbleMenuPlugin, - BubbleMenuPluginKey, - BubbleMenuPluginProps, -} from '@tiptap/extension-bubble-menu' +import { BubbleMenuPlugin, BubbleMenuPluginProps } from '@tiptap/extension-bubble-menu' export const BubbleMenu = defineComponent({ name: 'BubbleMenu', props: { + pluginKey: { + // TODO: TypeScript breaks :( + // type: [String, Object as PropType>], + type: [String, Object], + default: 'bubbleMenu', + }, + editor: { type: Object as PropType, required: true, @@ -25,25 +28,37 @@ export const BubbleMenu = defineComponent({ type: Object as PropType, default: () => ({}), }, + + shouldShow: { + type: Function as PropType>, + default: null, + }, }, setup(props, { slots }) { const root = ref(null) onMounted(() => { - const { editor, tippyOptions } = props + const { + pluginKey, + editor, + tippyOptions, + shouldShow, + } = props editor.registerPlugin(BubbleMenuPlugin({ + key: pluginKey, editor, element: root.value as HTMLElement, tippyOptions, + shouldShow, })) }) onBeforeUnmount(() => { - const { editor } = props + const { pluginKey, editor } = props - editor.unregisterPlugin(BubbleMenuPluginKey) + editor.unregisterPlugin(pluginKey) }) return () => h('div', { ref: root }, slots.default?.()) diff --git a/packages/vue-3/src/FloatingMenu.ts b/packages/vue-3/src/FloatingMenu.ts index 31a53a4b066..13874c35bda 100644 --- a/packages/vue-3/src/FloatingMenu.ts +++ b/packages/vue-3/src/FloatingMenu.ts @@ -6,16 +6,19 @@ import { onBeforeUnmount, defineComponent, } from 'vue' -import { - FloatingMenuPlugin, - FloatingMenuPluginKey, - FloatingMenuPluginProps, -} from '@tiptap/extension-floating-menu' +import { FloatingMenuPlugin, FloatingMenuPluginProps } from '@tiptap/extension-floating-menu' export const FloatingMenu = defineComponent({ name: 'FloatingMenu', props: { + pluginKey: { + // TODO: TypeScript breaks :( + // type: [String, Object as PropType>], + type: [String, Object], + default: 'floatingMenu', + }, + editor: { type: Object as PropType, required: true, @@ -25,25 +28,37 @@ export const FloatingMenu = defineComponent({ type: Object as PropType, default: () => ({}), }, + + shouldShow: { + type: Function as PropType>, + default: null, + }, }, setup(props, { slots }) { const root = ref(null) onMounted(() => { - const { editor, tippyOptions } = props + const { + pluginKey, + editor, + tippyOptions, + shouldShow, + } = props editor.registerPlugin(FloatingMenuPlugin({ + key: pluginKey, editor, element: root.value as HTMLElement, tippyOptions, + shouldShow, })) }) onBeforeUnmount(() => { - const { editor } = props + const { pluginKey, editor } = props - editor.unregisterPlugin(FloatingMenuPluginKey) + editor.unregisterPlugin(pluginKey) }) return () => h('div', { ref: root }, slots.default?.())