|
| 1 | +// Wrapper of original milkdown-plugin-slash |
| 2 | +/* Copyright 2021, Milkdown by Mirone. */ |
| 3 | +import { findParentNode, posToDOMRect } from '@milkdown/prose' |
| 4 | +import type { EditorState } from '@milkdown/prose/state' |
| 5 | +import type { Node } from '@milkdown/prose/model' |
| 6 | +import { TextSelection } from '@milkdown/prose/state' |
| 7 | +import type { EditorView } from '@milkdown/prose/view' |
| 8 | +import debounce from 'lodash.debounce' |
| 9 | +import type { Instance, Props } from 'tippy.js' |
| 10 | +import tippy from 'tippy.js' |
| 11 | + |
| 12 | +/// Options for slash provider. |
| 13 | +export type SlashProviderOptions = { |
| 14 | + /// The slash content. |
| 15 | + content: HTMLElement |
| 16 | + /// The options for creating [tippy.js](https://atomiks.github.io/tippyjs/) instance. |
| 17 | + tippyOptions?: Partial<Props> |
| 18 | + /// The debounce time for updating slash, 200ms by default. |
| 19 | + debounce?: number |
| 20 | + /// The function to determine whether the tooltip should be shown. |
| 21 | + shouldShow?: (view: EditorView, prevState?: EditorState) => boolean |
| 22 | + |
| 23 | + prefix?: string[] |
| 24 | + |
| 25 | + marks?: { prefix: string; links: { name: string; id: string }[] }[] |
| 26 | + |
| 27 | + markActive?: { prefix: string; links: { name: string; id: string }[] } |
| 28 | +} |
| 29 | + |
| 30 | +/// A provider for creating slash. |
| 31 | +export class SlashProvider { |
| 32 | + /// The root element of the slash. |
| 33 | + element: HTMLElement |
| 34 | + |
| 35 | + /// @internal |
| 36 | + #tippy: Instance | undefined |
| 37 | + |
| 38 | + /// @internal |
| 39 | + #tippyOptions: Partial<Props> |
| 40 | + |
| 41 | + /// @internal |
| 42 | + #debounce: number |
| 43 | + |
| 44 | + /// @internal |
| 45 | + #marks: { prefix: string; links: { name: string; id: string }[] }[] |
| 46 | + |
| 47 | + /// @internal |
| 48 | + #markActive?: { prefix: string; links: { name: string; id: string }[] } |
| 49 | + |
| 50 | + /// @internal |
| 51 | + #shouldShow: (view: EditorView, prevState?: EditorState) => boolean |
| 52 | + |
| 53 | + constructor(options: SlashProviderOptions) { |
| 54 | + this.element = options.content |
| 55 | + this.#tippyOptions = options.tippyOptions ?? {} |
| 56 | + this.#debounce = options.debounce ?? 100 |
| 57 | + this.#shouldShow = options.shouldShow ?? this.#_shouldShow |
| 58 | + this.#marks = options.marks ?? [] |
| 59 | + this.#markActive = options.marks ? options.marks[0] : undefined |
| 60 | + } |
| 61 | + |
| 62 | + /// @internal |
| 63 | + #onUpdate = (view: EditorView, prevState?: EditorState): void => { |
| 64 | + const { state, composing } = view |
| 65 | + const { selection, doc } = state |
| 66 | + const { ranges } = selection |
| 67 | + const from = Math.min(...ranges.map((range) => range.$from.pos)) |
| 68 | + const to = Math.max(...ranges.map((range) => range.$to.pos)) |
| 69 | + const isSame = |
| 70 | + prevState && prevState.doc.eq(doc) && prevState.selection.eq(selection) |
| 71 | + |
| 72 | + this.#tippy ??= tippy(view.dom, { |
| 73 | + trigger: 'manual', |
| 74 | + placement: 'bottom-start', |
| 75 | + interactive: true, |
| 76 | + ...this.#tippyOptions, |
| 77 | + content: this.element, |
| 78 | + }) |
| 79 | + |
| 80 | + if (composing || isSame) return |
| 81 | + |
| 82 | + if (!this.#shouldShow(view, prevState)) { |
| 83 | + this.hide() |
| 84 | + return |
| 85 | + } |
| 86 | + |
| 87 | + this.#tippy.setProps({ |
| 88 | + getReferenceClientRect: () => posToDOMRect(view, from, to), |
| 89 | + }) |
| 90 | + |
| 91 | + this.show() |
| 92 | + } |
| 93 | + |
| 94 | + /// @internal |
| 95 | + #_shouldShow(view: EditorView): boolean { |
| 96 | + const currentTextBlockContent = this.getContent(view) |
| 97 | + |
| 98 | + if (!currentTextBlockContent) return false |
| 99 | + |
| 100 | + const mark = this.#marks.find( |
| 101 | + (item) => currentTextBlockContent.at(-1) === item.prefix |
| 102 | + ) |
| 103 | + |
| 104 | + if (mark) { |
| 105 | + this.#markActive = this.#marks.find((mark) => mark.prefix === mark.prefix) |
| 106 | + } |
| 107 | + |
| 108 | + return !!mark |
| 109 | + } |
| 110 | + |
| 111 | + /// Update provider state by editor view. |
| 112 | + update = (view: EditorView, prevState?: EditorState): void => { |
| 113 | + const updater = debounce(this.#onUpdate, this.#debounce) |
| 114 | + |
| 115 | + updater(view, prevState) |
| 116 | + } |
| 117 | + |
| 118 | + /// Get the content of the current text block. |
| 119 | + /// Pass the `matchNode` function to determine whether the current node should be matched, by default, it will match the paragraph node. |
| 120 | + getContent = ( |
| 121 | + view: EditorView, |
| 122 | + matchNode: (node: Node) => boolean = (node) => |
| 123 | + node.type.name === 'paragraph' |
| 124 | + ): string | undefined => { |
| 125 | + const { selection } = view.state |
| 126 | + const { empty } = selection |
| 127 | + const isTextBlock = view.state.selection instanceof TextSelection |
| 128 | + |
| 129 | + const isSlashChildren = this.element.contains(document.activeElement) |
| 130 | + |
| 131 | + const notHasFocus = !view.hasFocus() && !isSlashChildren |
| 132 | + |
| 133 | + const isReadonly = !view.editable |
| 134 | + |
| 135 | + const paragraph = findParentNode(matchNode)(view.state.selection) |
| 136 | + |
| 137 | + const isNotInParagraph = !paragraph |
| 138 | + |
| 139 | + if (notHasFocus || isReadonly || !empty || !isTextBlock || isNotInParagraph) |
| 140 | + return |
| 141 | + |
| 142 | + return paragraph.node.textContent |
| 143 | + } |
| 144 | + |
| 145 | + /// Destroy the slash. |
| 146 | + destroy = () => { |
| 147 | + this.#tippy?.destroy() |
| 148 | + } |
| 149 | + |
| 150 | + /// Show the slash. |
| 151 | + show = () => { |
| 152 | + this.element.innerHTML = |
| 153 | + this.#markActive?.links.reduce((acc, item) => { |
| 154 | + return (acc += `<div id=\"${ |
| 155 | + item.id |
| 156 | + }\" class=\"flex flex-col gap-2 bg-theme-background-3 p-2\">${ |
| 157 | + this.#markActive?.prefix ?? '' |
| 158 | + } ${item.name}</div>`) |
| 159 | + }, '<div class="bg-theme-background-2 wb-text w-full p-2 cursor-pointer min-w-20">') ?? |
| 160 | + '<div>' |
| 161 | + this.element.innerHTML = this.element.innerHTML += '</div>' |
| 162 | + this.element.onclick = (e) => { |
| 163 | + this.hide() |
| 164 | + } |
| 165 | + |
| 166 | + this.#tippy?.show() |
| 167 | + } |
| 168 | + |
| 169 | + /// Hide the slash. |
| 170 | + hide = () => { |
| 171 | + this.#tippy?.hide() |
| 172 | + } |
| 173 | + |
| 174 | + /// Get the [tippy.js](https://atomiks.github.io/tippyjs/) instance. |
| 175 | + getInstance = () => this.#tippy |
| 176 | +} |
0 commit comments