UNPKG

@milkdown/plugin-block

Version:

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

443 lines (442 loc) 12.9 kB
import { findParent, browser } from "@milkdown/prose"; import { $ctx, $prose } from "@milkdown/utils"; import { NodeSelection, PluginKey, Plugin } from "@milkdown/prose/state"; import { editorViewCtx } from "@milkdown/core"; import { throttle } from "lodash-es"; import { flip, offset, computePosition } from "@floating-ui/dom"; function withMeta(plugin, meta) { Object.assign(plugin, { meta: { package: "@milkdown/plugin-block", ...meta } }); return plugin; } const defaultNodeFilter = (pos) => { const table = findParent((node) => node.type.name === "table")(pos); if (table) return false; return true; }; const blockConfig = $ctx( { filterNodes: defaultNodeFilter }, "blockConfig" ); withMeta(blockConfig, { displayName: "Ctx<blockConfig>" }); function selectRootNodeByDom(view, coords, filterNodes) { const root = view.dom.parentElement; if (!root) return null; try { const pos = view.posAtCoords({ left: coords.x, top: coords.y })?.inside; if (pos == null || pos < 0) return null; let $pos = view.state.doc.resolve(pos); let node = view.state.doc.nodeAt(pos); let element = view.nodeDOM(pos); const filter = (needLookup) => { const checkDepth = $pos.depth >= 1 && $pos.index($pos.depth) === 0; const shouldLookUp = needLookup || checkDepth; if (!shouldLookUp) return; const ancestorPos = $pos.before($pos.depth); node = view.state.doc.nodeAt(ancestorPos); element = view.nodeDOM(ancestorPos); $pos = view.state.doc.resolve(ancestorPos); if (!filterNodes($pos, node)) filter(true); }; const filterResult = filterNodes($pos, node); filter(!filterResult); if (!element || !node) return null; return { node, $pos, el: element }; } catch { return null; } } const brokenClipboardAPI = browser.ie && browser.ie_version < 15 || browser.ios && browser.webkit_version < 604; const buffer = 20; class BlockService { constructor() { this.#createSelection = () => { 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; }; this.#activeSelection = null; this.#active = null; this.#activeDOMRect = void 0; this.#dragging = false; this.#hide = () => { this.#notify?.({ type: "hide" }); this.#active = null; }; this.#show = (active) => { this.#active = active; this.#notify?.({ type: "show", active }); }; this.bind = (ctx, notify) => { this.#ctx = ctx; this.#notify = notify; }; this.addEvent = (dom) => { dom.addEventListener("mousedown", this.#handleMouseDown); dom.addEventListener("mouseup", this.#handleMouseUp); dom.addEventListener("dragstart", this.#handleDragStart); }; this.removeEvent = (dom) => { dom.removeEventListener("mousedown", this.#handleMouseDown); dom.removeEventListener("mouseup", this.#handleMouseUp); dom.removeEventListener("dragstart", this.#handleDragStart); }; this.unBind = () => { this.#notify = void 0; }; this.#handleMouseDown = () => { this.#activeDOMRect = this.#active?.el.getBoundingClientRect(); this.#createSelection(); }; this.#handleMouseUp = () => { if (!this.#dragging) { requestAnimationFrame(() => { if (!this.#activeDOMRect) return; this.#view?.focus(); }); return; } this.#dragging = false; this.#activeSelection = null; }; this.#handleDragStart = (event) => { 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 }; } }; this.keydownCallback = (view) => { this.#hide(); this.#dragging = false; view.dom.dataset.dragging = "false"; return false; }; this.#mousemoveCallback = throttle((view, event) => { 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); this.mousemoveCallback = (view, event) => { if (view.composing || !view.editable) return false; this.#mousemoveCallback(view, event); return false; }; this.dragoverCallback = (view, event) => { 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; }; this.dragenterCallback = (view) => { if (!view.dragging) return; this.#dragging = true; view.dom.dataset.dragging = "true"; }; this.dragleaveCallback = (view, event) => { const x = event.clientX; const y = event.clientY; if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) { this.#active = null; this.#dragEnd(view); } }; this.dropCallback = (view) => { this.#dragEnd(view); return false; }; this.dragendCallback = (view) => { this.#dragEnd(view); }; this.#dragEnd = (view) => { this.#dragging = false; view.dom.dataset.dragging = "false"; }; } /// @internal #ctx; #createSelection; #activeSelection; #active; #activeDOMRect; #dragging; /// @internal get #filterNodes() { try { return this.#ctx?.get(blockConfig.key).filterNodes; } catch { return void 0; } } /// @internal get #view() { return this.#ctx?.get(editorViewCtx); } /// @internal #notify; #hide; #show; #handleMouseDown; #handleMouseUp; #handleDragStart; #mousemoveCallback; #dragEnd; } const blockService = $ctx(() => new BlockService(), "blockService"); const blockServiceInstance = $ctx( {}, "blockServiceInstance" ); withMeta(blockService, { displayName: "Ctx<blockService>" }); withMeta(blockServiceInstance, { displayName: "Ctx<blockServiceInstance>" }); const blockSpec = $ctx({}, "blockSpec"); withMeta(blockSpec, { displayName: "Ctx<blockSpec>" }); const blockPlugin = $prose((ctx) => { const milkdownPluginBlockKey = new PluginKey("MILKDOWN_BLOCK"); const getService = ctx.get(blockService.key); const service = getService(); ctx.set(blockServiceInstance.key, service); const spec = ctx.get(blockSpec.key); return new Plugin({ key: milkdownPluginBlockKey, ...spec, props: { ...spec.props, handleDOMEvents: { drop: (view) => { return service.dropCallback(view); }, pointermove: (view, event) => { return service.mousemoveCallback(view, event); }, keydown: (view) => { return service.keydownCallback(view); }, dragover: (view, event) => { return service.dragoverCallback(view, event); }, dragleave: (view, event) => { return service.dragleaveCallback(view, event); }, dragenter: (view) => { return service.dragenterCallback(view); }, dragend: (view) => { return service.dragendCallback(view); } } } }); }); withMeta(blockPlugin, { displayName: "Prose<block>" }); class BlockProvider { constructor(options) { this.#activeNode = null; this.#initialized = false; this.update = () => { requestAnimationFrame(() => { if (!this.#initialized) { try { this.#init(); this.#initialized = true; } catch { } } }); }; this.destroy = () => { this.#service?.unBind(); this.#service?.removeEvent(this.#element); this.#element.remove(); }; this.show = (active) => { const dom = active.el; const editorDom = this.#ctx.get(editorViewCtx).dom; const deriveContext = { ctx: this.#ctx, active, editorDom, blockDom: this.#element }; const virtualEl = { contextElement: dom, getBoundingClientRect: () => { if (this.#getPosition) return this.#getPosition(deriveContext); return dom.getBoundingClientRect(); } }; const middleware = [flip()]; if (this.#getOffset) { const offsetOption = this.#getOffset(deriveContext); const offsetExt = offset(offsetOption); middleware.push(offsetExt); } computePosition(virtualEl, this.#element, { placement: this.#getPlacement ? this.#getPlacement(deriveContext) : "left", middleware: [...middleware, ...this.#middleware], ...this.#floatingUIOptions }).then(({ x, y }) => { Object.assign(this.#element.style, { left: `${x}px`, top: `${y}px` }); this.#element.dataset.show = "true"; }).catch(console.error); }; this.hide = () => { this.#element.dataset.show = "false"; }; this.#ctx = options.ctx; this.#element = options.content; this.#getOffset = options.getOffset; this.#getPosition = options.getPosition; this.#getPlacement = options.getPlacement; this.#middleware = options.middleware ?? []; this.#floatingUIOptions = options.floatingUIOptions ?? {}; this.#root = options.root; this.hide(); } /// @internal #element; /// @internal #ctx; /// @internal #service; #activeNode; /// @internal #root; #initialized; /// @internal #middleware; /// @internal #floatingUIOptions; /// @internal #getOffset; /// @internal #getPosition; /// @internal #getPlacement; /// The context of current active node. get active() { return this.#activeNode; } /// @internal #init() { const view = this.#ctx.get(editorViewCtx); const root = this.#root ?? view.dom.parentElement ?? document.body; root.appendChild(this.#element); const service = this.#ctx.get(blockServiceInstance.key); service.bind(this.#ctx, (message) => { if (message.type === "hide") { this.hide(); this.#activeNode = null; } else if (message.type === "show") { this.show(message.active); this.#activeNode = message.active; } }); this.#service = service; this.#service.addEvent(this.#element); this.#element.draggable = true; } } const block = [ blockSpec, blockConfig, blockService, blockServiceInstance, blockPlugin ]; block.key = blockSpec.key; block.pluginKey = blockPlugin.key; export { BlockProvider, BlockService, block, blockConfig, blockPlugin, blockService, blockServiceInstance, blockSpec, defaultNodeFilter }; //# sourceMappingURL=index.js.map