Skip to content

Commit

Permalink
feat: add key option and shouldShow option to menus (fix #1480, fix #…
Browse files Browse the repository at this point in the history
…1043, fix #1268, fix #1503)

* add key option to bubble menu

* ignore react for now

* add shouldShow option to bubble menu extension

* improve types

* remove BubbleMenuPluginKey

* add key and shouldShow option to floating menu extension

* fix: don’t show floating menu within code block

* docs: add new menu options
  • Loading branch information
philippkuehn authored Aug 11, 2021
1 parent bcc1309 commit 9ba61c1
Show file tree
Hide file tree
Showing 12 changed files with 312 additions and 60 deletions.
64 changes: 60 additions & 4 deletions docs/src/docPages/api/extensions/bubble-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand All @@ -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'),
}),
],
})
```
64 changes: 60 additions & 4 deletions docs/src/docPages/api/extensions/floating-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand All @@ -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'),
}),
],
})
```
58 changes: 46 additions & 12 deletions packages/extension-bubble-menu/src/bubble-menu-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Props>,
shouldShow: ((props: {
editor: Editor,
view: EditorView,
state: EditorState,
oldState?: EditorState,
from: number,
to: number,
}) => boolean) | null,
}

export type BubbleMenuViewProps = BubbleMenuPluginProps & {
Expand All @@ -29,15 +38,38 @@ export class BubbleMenuView {

public tippy!: Instance

public shouldShow: Exclude<BubbleMenuPluginProps['shouldShow'], null> = ({ 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)
Expand Down Expand Up @@ -98,27 +130,29 @@ 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
}

this.tippy.setProps({
getReferenceClientRect: () => {
if (isNodeSelection(view.state.selection)) {
if (isNodeSelection(state.selection)) {
const node = view.nodeDOM(from) as HTMLElement

if (node) {
Expand Down Expand Up @@ -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 }),
})
}
4 changes: 4 additions & 0 deletions packages/extension-bubble-menu/src/bubble-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const BubbleMenu = Extension.create<BubbleMenuOptions>({
defaultOptions: {
element: null,
tippyOptions: {},
key: 'bubbleMenu',
shouldShow: null,
},

addProseMirrorPlugins() {
Expand All @@ -20,9 +22,11 @@ export const BubbleMenu = Extension.create<BubbleMenuOptions>({

return [
BubbleMenuPlugin({
key: this.options.key,
editor: this.editor,
element: this.options.element,
tippyOptions: this.options.tippyOptions,
shouldShow: this.options.shouldShow,
}),
]
},
Expand Down
52 changes: 39 additions & 13 deletions packages/extension-floating-menu/src/floating-menu-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Props>,
shouldShow: ((props: {
editor: Editor,
view: EditorView,
state: EditorState,
oldState?: EditorState,
}) => boolean) | null,
}

export type FloatingMenuViewProps = FloatingMenuPluginProps & {
Expand All @@ -24,15 +31,36 @@ export class FloatingMenuView {

public tippy!: Instance

public shouldShow: Exclude<FloatingMenuPluginProps['shouldShow'], null> = ({ 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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }),
})
}
Loading

0 comments on commit 9ba61c1

Please sign in to comment.