@milkdown/plugin-block
Version:
The block plugin of [milkdown](https://milkdown.dev/).
443 lines (442 loc) • 12.9 kB
JavaScript
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