UNPKG

@milkdown/plugin-block

Version:

The block plugin of [milkdown](https://milkdown.dev/).

287 lines (238 loc) 7.18 kB
import type { Ctx } from '@milkdown/ctx' import type { Selection } from '@milkdown/prose/state' import type { EditorView } from '@milkdown/prose/view' import { editorViewCtx } from '@milkdown/core' import { browser } from '@milkdown/prose' import { NodeSelection } from '@milkdown/prose/state' import { throttle } from 'lodash-es' import type { FilterNodes } from './block-config' import type { ActiveNode } from './types' import { selectRootNodeByDom } from './__internal__/select-node-by-dom' import { blockConfig } from './block-config' const brokenClipboardAPI = (browser.ie && <number>browser.ie_version < 15) || (browser.ios && browser.webkit_version < 604) const buffer = 20 /// @internal export type BlockServiceMessageType = | { type: 'hide' } | { type: 'show' active: ActiveNode } /// @internal export type BlockServiceMessage = (message: BlockServiceMessageType) => void /// @internal /// The block service, provide events and methods for block plugin. /// Generally you don't need to use this class directly. export class BlockService { /// @internal #ctx?: Ctx /// @internal #createSelection: () => null | Selection = () => { if (!this.#active) return null const result = this.#active const view = this.#view if (view && NodeSelection.isSelectable(result.node)) { const nodeSelection = NodeSelection.create( view.state.doc, result.$pos.pos ) view.dispatch(view.state.tr.setSelection(nodeSelection)) view.focus() this.#activeSelection = nodeSelection return nodeSelection } return null } /// @internal #activeSelection: null | Selection = null /// @internal #active: null | ActiveNode = null /// @internal #activeDOMRect: undefined | DOMRect = undefined /// @internal #dragging = false /// @internal get #filterNodes(): FilterNodes | undefined { try { return this.#ctx?.get(blockConfig.key).filterNodes } catch { return undefined } } /// @internal get #view() { return this.#ctx?.get(editorViewCtx) } /// @internal #notify?: BlockServiceMessage /// @internal #hide = () => { this.#notify?.({ type: 'hide' }) this.#active = null } /// @internal #show = (active: ActiveNode) => { this.#active = active this.#notify?.({ type: 'show', active }) } /// Bind editor context and notify function to the service. bind = (ctx: Ctx, notify: BlockServiceMessage) => { this.#ctx = ctx this.#notify = notify } /// Add mouse event to the dom. addEvent = (dom: HTMLElement) => { dom.addEventListener('mousedown', this.#handleMouseDown) dom.addEventListener('mouseup', this.#handleMouseUp) dom.addEventListener('dragstart', this.#handleDragStart) } /// Remove mouse event to the dom. removeEvent = (dom: HTMLElement) => { dom.removeEventListener('mousedown', this.#handleMouseDown) dom.removeEventListener('mouseup', this.#handleMouseUp) dom.removeEventListener('dragstart', this.#handleDragStart) } /// Unbind the notify function. unBind = () => { this.#notify = undefined } /// @internal #handleMouseDown = () => { this.#activeDOMRect = this.#active?.el.getBoundingClientRect() this.#createSelection() } /// @internal #handleMouseUp = () => { if (!this.#dragging) { requestAnimationFrame(() => { if (!this.#activeDOMRect) return this.#view?.focus() }) return } this.#dragging = false this.#activeSelection = null } /// @internal #handleDragStart = (event: DragEvent) => { this.#dragging = true const view = this.#view if (!view) return view.dom.dataset.dragging = 'true' const selection = this.#activeSelection if (event.dataTransfer && selection) { const slice = selection.content() event.dataTransfer.effectAllowed = 'copyMove' const { dom, text } = view.serializeForClipboard(slice) event.dataTransfer.clearData() event.dataTransfer.setData( brokenClipboardAPI ? 'Text' : 'text/html', dom.innerHTML ) if (!brokenClipboardAPI) event.dataTransfer.setData('text/plain', text) const activeEl = this.#active?.el if (activeEl) event.dataTransfer.setDragImage(activeEl, 0, 0) view.dragging = { slice, move: true, } } } /// @internal keydownCallback = (view: EditorView) => { this.#hide() this.#dragging = false view.dom.dataset.dragging = 'false' return false } /// @internal #mousemoveCallback = throttle((view: EditorView, event: MouseEvent) => { if (!view.editable) return const rect = view.dom.getBoundingClientRect() const x = rect.left + rect.width / 2 const dom = view.root.elementFromPoint(x, event.clientY) if (!(dom instanceof Element)) { this.#hide() return } const filterNodes = this.#filterNodes if (!filterNodes) return const result = selectRootNodeByDom( view, { x, y: event.clientY }, filterNodes ) if (!result) { this.#hide() return } this.#show(result) }, 200) /// @internal mousemoveCallback = (view: EditorView, event: MouseEvent) => { if (view.composing || !view.editable) return false this.#mousemoveCallback(view, event) return false } /// @internal dragoverCallback = (view: EditorView, event: DragEvent) => { if (this.#dragging) { const root = this.#view?.dom.parentElement if (!root) return false const hasHorizontalScrollbar = root.scrollHeight > root.clientHeight const rootRect = root.getBoundingClientRect() if (hasHorizontalScrollbar) { if (root.scrollTop > 0 && Math.abs(event.y - rootRect.y) < buffer) { const top = root.scrollTop > 10 ? root.scrollTop - 10 : 0 root.scrollTop = top return false } const totalHeight = Math.round(view.dom.getBoundingClientRect().height) const scrollBottom = Math.round(root.scrollTop + rootRect.height) if ( scrollBottom < totalHeight && Math.abs(event.y - (rootRect.height + rootRect.y)) < buffer ) { const top = root.scrollTop + 10 root.scrollTop = top return false } } } return false } /// @internal dragenterCallback = (view: EditorView) => { if (!view.dragging) return this.#dragging = true view.dom.dataset.dragging = 'true' } /// @internal dragleaveCallback = (view: EditorView, event: DragEvent) => { const x = event.clientX const y = event.clientY // if cursor out of the editor if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) { this.#active = null this.#dragEnd(view) } } /// @internal dropCallback = (view: EditorView) => { this.#dragEnd(view) return false } /// @internal dragendCallback = (view: EditorView) => { this.#dragEnd(view) } /// @internal #dragEnd = (view: EditorView) => { this.#dragging = false view.dom.dataset.dragging = 'false' } }