diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1b78160209..4f0f140cad 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -28,7 +28,6 @@ updates: - dependency-name: "@tiptap/extension-underline" # prosemirror packages - dependency-name: "prosemirror-changeset" - - dependency-name: "prosemirror-dropcursor" - dependency-name: "prosemirror-highlight" - dependency-name: "prosemirror-model" - dependency-name: "prosemirror-state" diff --git a/examples/03-ui-components/18-drag-n-drop/.bnexample.json b/examples/03-ui-components/18-drag-n-drop/.bnexample.json new file mode 100644 index 0000000000..c2c8b67c60 --- /dev/null +++ b/examples/03-ui-components/18-drag-n-drop/.bnexample.json @@ -0,0 +1,6 @@ +{ + "playground": true, + "docs": false, + "author": "nperez0111", + "tags": ["Intermediate", "UI Components", "Drag & Drop", "Customization"] +} diff --git a/examples/03-ui-components/18-drag-n-drop/README.md b/examples/03-ui-components/18-drag-n-drop/README.md new file mode 100644 index 0000000000..6746cf86a3 --- /dev/null +++ b/examples/03-ui-components/18-drag-n-drop/README.md @@ -0,0 +1,47 @@ +# Drag & Drop Exclusion + +This example demonstrates how to use the `DRAG_EXCLUSION_CLASSNAME` to create separate drag & drop areas that don't interfere with BlockNote's built-in block drag & drop functionality. + +## Features + +- **Drag Exclusion**: Elements with the `bn-drag-exclude` classname are treated as separate drag & drop operations +- **Independent Drag Areas**: Create custom drag & drop functionality alongside BlockNote's editor +- **No Interference**: Custom drag operations won't trigger BlockNote's block reordering +- **Side-by-side Demo**: Shows the editor and custom drag area working independently + +## How It Works + +By adding the `DRAG_EXCLUSION_CLASSNAME` (`bn-drag-exclude`) to an element, you tell BlockNote's drag & drop handlers to ignore all drag events within that element and its children. This allows you to implement your own custom drag & drop logic without conflicts. + +The exclusion check works by traversing up the DOM tree from the drag event target, checking if any ancestor has the exclusion classname. If found, BlockNote's handlers return early, leaving your custom handlers in full control. + +## Code Highlights + +### Import the constant: + +```tsx +import { DRAG_EXCLUSION_CLASSNAME } from "@blocknote/core"; +``` + +### Apply it to your custom drag area: + +```tsx +
+ {/* Your custom drag & drop UI */} +
+ Custom draggable items +
+
+``` + +## Use Cases + +- **Custom UI elements**: Add draggable components within or near the editor +- **File upload areas**: Create drag-and-drop file upload zones +- **Sortable lists**: Implement custom sortable lists alongside the editor +- **External integrations**: Integrate with third-party drag & drop libraries + +**Relevant Docs:** + +- [Side Menu (Drag Handle)](/docs/react/components/side-menu) +- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/03-ui-components/18-drag-n-drop/index.html b/examples/03-ui-components/18-drag-n-drop/index.html new file mode 100644 index 0000000000..a10c552621 --- /dev/null +++ b/examples/03-ui-components/18-drag-n-drop/index.html @@ -0,0 +1,14 @@ + + + + + Drag & Drop Exclusion + + + +
+ + + diff --git a/examples/03-ui-components/18-drag-n-drop/main.tsx b/examples/03-ui-components/18-drag-n-drop/main.tsx new file mode 100644 index 0000000000..677c7f7eed --- /dev/null +++ b/examples/03-ui-components/18-drag-n-drop/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./src/App.jsx"; + +const root = createRoot(document.getElementById("root")!); +root.render( + + + +); diff --git a/examples/03-ui-components/18-drag-n-drop/package.json b/examples/03-ui-components/18-drag-n-drop/package.json new file mode 100644 index 0000000000..354348292d --- /dev/null +++ b/examples/03-ui-components/18-drag-n-drop/package.json @@ -0,0 +1,31 @@ +{ + "name": "@blocknote/example-ui-components-drag-n-drop", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vite", + "dev": "vite", + "build:prod": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^8.3.11", + "@mantine/hooks": "^8.3.11", + "@mantine/utils": "^6.0.22", + "react": "^19.2.3", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^4.7.0", + "vite": "^5.4.20" + } +} \ No newline at end of file diff --git a/examples/03-ui-components/18-drag-n-drop/src/App.tsx b/examples/03-ui-components/18-drag-n-drop/src/App.tsx new file mode 100644 index 0000000000..e90596f427 --- /dev/null +++ b/examples/03-ui-components/18-drag-n-drop/src/App.tsx @@ -0,0 +1,133 @@ +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; +import { useState } from "react"; +import "./styles.css"; + +export default function App() { + // Creates a new editor instance. + const editor = useCreateBlockNote({ + initialContent: [ + { + type: "paragraph", + content: "Welcome to the Drag & Drop Exclusion demo!", + }, + { + type: "paragraph", + content: + "Try dragging the blocks in the editor - they will work as normal.", + }, + { + type: "paragraph", + content: + "Now try dragging the colored boxes on the right - they won't interfere with the editor's drag & drop!", + }, + ], + }); + + const [draggedItems, setDraggedItems] = useState([ + { id: "1", color: "#FF6B6B", label: "Red Item" }, + { id: "2", color: "#4ECDC4", label: "Teal Item" }, + { id: "3", color: "#45B7D1", label: "Blue Item" }, + { id: "4", color: "#FFA07A", label: "Orange Item" }, + ]); + + const [droppedItems, setDroppedItems] = useState([]); + + const handleDragStart = ( + e: React.DragEvent, + item: (typeof draggedItems)[0], + ) => { + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("custom-item", JSON.stringify(item)); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + const data = e.dataTransfer.getData("custom-item"); + if (data) { + const item = JSON.parse(data); + setDroppedItems((prev) => [...prev, item]); + setDraggedItems((prev) => prev.filter((i) => i.id !== item.id)); + } + }; + + const handleReset = () => { + setDraggedItems([ + { id: "1", color: "#FF6B6B", label: "Red Item" }, + { id: "2", color: "#4ECDC4", label: "Teal Item" }, + { id: "3", color: "#45B7D1", label: "Blue Item" }, + { id: "4", color: "#FFA07A", label: "Orange Item" }, + ]); + setDroppedItems([]); + }; + + return ( +
+
+

BlockNote Editor

+ +
+ +
+

Separate Drag & Drop Area

+

+ This area uses the bn-drag-exclude classname, so dragging + items here won't interfere with the editor. +

+ +
+
+

Draggable Items

+
+ {draggedItems.map((item) => ( +
handleDragStart(e, item)} + style={{ backgroundColor: item.color }} + > + {item.label} +
+ ))} +
+
+ +
+

Drop Zone

+
+ {droppedItems.length === 0 ? ( +

Drop items here

+ ) : ( + droppedItems.map((item) => ( +
+ {item.label} +
+ )) + )} +
+
+
+ + +
+
+ ); +} diff --git a/examples/03-ui-components/18-drag-n-drop/src/styles.css b/examples/03-ui-components/18-drag-n-drop/src/styles.css new file mode 100644 index 0000000000..4c8d78b9f4 --- /dev/null +++ b/examples/03-ui-components/18-drag-n-drop/src/styles.css @@ -0,0 +1,154 @@ +.app-container { + display: flex; + gap: 2rem; + padding: 2rem; + max-width: 1400px; + margin: 0 auto; + height: 100vh; +} + +.editor-section { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.editor-section h2 { + margin-top: 0; + margin-bottom: 1rem; + font-size: 1.5rem; + color: #333; +} + +.drag-demo-section { + flex: 0 0 400px; + display: flex; + flex-direction: column; + background: #f8f9fa; + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.drag-demo-section h2 { + margin-top: 0; + margin-bottom: 0.5rem; + font-size: 1.5rem; + color: #333; +} + +.info-text { + font-size: 0.875rem; + color: #666; + margin-bottom: 1.5rem; + line-height: 1.5; +} + +.info-text code { + background: #e9ecef; + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-family: monospace; + font-size: 0.85em; + color: #c7254e; +} + +.drag-columns { + display: flex; + gap: 1rem; + flex: 1; + min-height: 0; +} + +.drag-column { + flex: 1; + display: flex; + flex-direction: column; +} + +.drag-column h3 { + margin-top: 0; + margin-bottom: 0.75rem; + font-size: 1rem; + color: #495057; + font-weight: 600; +} + +.items-container { + flex: 1; + background: white; + border: 2px dashed #dee2e6; + border-radius: 6px; + padding: 0.75rem; + min-height: 200px; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.drop-zone .items-container { + border-color: #4ecdc4; + background: #f0fffe; +} + +.draggable-item { + padding: 0.75rem 1rem; + border-radius: 6px; + color: white; + font-weight: 500; + cursor: move; + user-select: none; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: + transform 0.2s, + box-shadow 0.2s; +} + +.draggable-item:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +.draggable-item:active { + cursor: grabbing; + transform: scale(0.95); +} + +.placeholder { + color: #adb5bd; + text-align: center; + margin: auto; + font-style: italic; +} + +.reset-button { + margin-top: 1rem; + padding: 0.625rem 1.25rem; + background: #495057; + color: white; + border: none; + border-radius: 6px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.reset-button:hover { + background: #343a40; +} + +.reset-button:active { + transform: scale(0.98); +} + +@media (max-width: 1024px) { + .app-container { + flex-direction: column; + height: auto; + } + + .drag-demo-section { + flex: 0 0 auto; + } +} diff --git a/examples/03-ui-components/18-drag-n-drop/tsconfig.json b/examples/03-ui-components/18-drag-n-drop/tsconfig.json new file mode 100644 index 0000000000..dbe3e6f62d --- /dev/null +++ b/examples/03-ui-components/18-drag-n-drop/tsconfig.json @@ -0,0 +1,36 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext" + ], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true + }, + "include": [ + "." + ], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} \ No newline at end of file diff --git a/examples/03-ui-components/18-drag-n-drop/vite.config.ts b/examples/03-ui-components/18-drag-n-drop/vite.config.ts new file mode 100644 index 0000000000..f62ab20bc2 --- /dev/null +++ b/examples/03-ui-components/18-drag-n-drop/vite.config.ts @@ -0,0 +1,32 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite"; +// import eslintPlugin from "vite-plugin-eslint"; +// https://vitejs.dev/config/ +export default defineConfig((conf) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/" + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/" + ), + } as any), + }, +})); diff --git a/package.json b/package.json index ae1dd2157d..7bd1aa8f36 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "clean": "nx run-many --target=clean", "deploy": "nx release --skip-publish", "gen": "nx run @blocknote/dev-scripts:gen", - "install-playwright": "cd tests && pnpx playwright install --with-deps", + "install-playwright": "cd tests && pnpm exec playwright install --with-deps", "e2e": "concurrently --success=first -r --kill-others \"pnpm run start -L\" \"wait-on http://localhost:3000 && cd tests && pnpm exec playwright test $PLAYWRIGHT_CONFIG\"", "e2e:updateSnaps": "concurrently --success=first -r --kill-others \"pnpm run start -L\" \"wait-on http://localhost:3000 && cd tests && pnpm run test:updateSnaps\"", "lint": "nx run-many --target=lint", diff --git a/packages/core/package.json b/packages/core/package.json index 6a2691eb22..cd1d63bfb2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -108,7 +108,6 @@ "emoji-mart": "^5.6.0", "fast-deep-equal": "^3.1.3", "hast-util-from-dom": "^5.0.1", - "prosemirror-dropcursor": "^1.8.2", "prosemirror-highlight": "^0.13.0", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 8a11e64493..2a6648f04b 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -5,7 +5,7 @@ import { getSchema, Editor as TiptapEditor, } from "@tiptap/core"; -import { type Command, type Plugin, type Transaction } from "@tiptap/pm/state"; +import { type Command, type Transaction } from "@tiptap/pm/state"; import { Node, Schema } from "prosemirror-model"; import type { BlocksChanged } from "../api/getBlocksChangedByTransaction.js"; import { blockToNode } from "../api/nodeConversions/blockToNode.js"; @@ -18,7 +18,10 @@ import { PartialBlock, } from "../blocks/index.js"; import type { CollaborationOptions } from "../extensions/Collaboration/Collaboration.js"; -import { BlockChangeExtension } from "../extensions/index.js"; +import { + BlockChangeExtension, + DropCursorOptions, +} from "../extensions/index.js"; import { UniqueID } from "../extensions/tiptap-extensions/UniqueID/UniqueID.js"; import type { Dictionary } from "../i18n/dictionary.js"; import { en } from "../i18n/locales/index.js"; @@ -118,19 +121,11 @@ export interface BlockNoteEditorOptions< domAttributes?: Partial; /** - * A replacement indicator to use when dragging and dropping blocks. Uses the [ProseMirror drop cursor](https://github.com/ProseMirror/prosemirror-dropcursor), or a modified version when [Column Blocks](https://www.blocknotejs.org/docs/document-structure#column-blocks) are enabled. - * @remarks `() => Plugin` + * Options for configuring the drop cursor behavior when dragging and dropping blocks. + * Allows customization of cursor appearance and drop position computation through hooks. + * @remarks `DropCursorOptions` */ - dropCursor?: (opts: { - editor: BlockNoteEditor< - NoInfer, - NoInfer, - NoInfer - >; - color?: string | false; - width?: number; - class?: string; - }) => Plugin; + dropCursor?: DropCursorOptions; /** * The content that should be in the editor when it's created, represented as an array of {@link PartialBlock} objects. diff --git a/packages/core/src/extensions/DropCursor/DropCursor.ts b/packages/core/src/extensions/DropCursor/DropCursor.ts index 784612951f..77d24cc114 100644 --- a/packages/core/src/extensions/DropCursor/DropCursor.ts +++ b/packages/core/src/extensions/DropCursor/DropCursor.ts @@ -1,26 +1,263 @@ -import { dropCursor } from "prosemirror-dropcursor"; +import { dropPoint } from "prosemirror-transform"; +import type { EditorView } from "prosemirror-view"; import { - createExtension, - ExtensionOptions, -} from "../../editor/BlockNoteExtension.js"; -import { BlockNoteEditorOptions } from "../../editor/BlockNoteEditor.js"; - -export const DropCursorExtension = createExtension( - ({ - editor, - options, - }: ExtensionOptions< - Pick, "dropCursor"> - >) => { - return { - key: "dropCursor", - prosemirrorPlugins: [ - (options.dropCursor ?? dropCursor)({ - width: 5, - color: "#ddeeff", - editor: editor, - }), - ], - } as const; - }, -); + applyOrientationClasses, + getBlockDropRect, + getInlineDropRect, + getParentOffsets, + hasExclusionClassname, + type DropCursorPosition, +} from "./utils.js"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; + +export const DRAG_EXCLUSION_CLASSNAME = "bn-drag-exclude"; + +/** + * Context passed to the computeDropPosition hook. + */ +export interface ComputeDropPositionContext { + editor: BlockNoteEditor; + event: DragEvent; + view: EditorView; + defaultPosition: DropCursorPosition | null; +} + +/** + * Hooks for customizing drop cursor behavior. + */ +export interface DropCursorHooks { + /** + * Compute cursor position and orientation. + * Return null to prevent dropping (no cursor shown). + */ + computeDropPosition?: ( + context: ComputeDropPositionContext, + ) => DropCursorPosition | null; +} + +/** + * Options for the DropCursor extension. + */ +export interface DropCursorOptions { + width?: number; // Cursor width in pixels (default: 5) + color?: string | false; // Cursor color (default: "#ddeeff") + exclude?: string; // CSS class for exclusion (default: "bn-drag-exclude") + hooks?: DropCursorHooks; // Optional behavior hooks +} + +/** + * Drop cursor visualization based on prosemirror-dropcursor: + * https://github.com/ProseMirror/prosemirror-dropcursor/blob/master/src/dropcursor.ts + * + * Refactored to use BlockNote extension pattern with mount callback and AbortSignal + * for lifecycle management instead of ProseMirror PluginView. + */ +export const DropCursorExtension = createExtension< + any, + { + dropCursor?: DropCursorOptions; + } +>(({ editor, options }) => { + // State + let cursorPos: DropCursorPosition | null = null; + let element: HTMLElement | null = null; + let timeout = -1; + let dragSourceElement: Element | null = null; + + const config = { + width: options.dropCursor?.width ?? 5, + color: options.dropCursor?.color ?? "#ddeeff", + exclude: options.dropCursor?.exclude ?? DRAG_EXCLUSION_CLASSNAME, + hooks: options.dropCursor?.hooks, + } as const; + + // Helper functions + const setCursor = (pos: DropCursorPosition | null) => { + if ( + pos?.pos === cursorPos?.pos && + pos?.orientation === cursorPos?.orientation + ) { + return; + } + cursorPos = pos; + + if (pos == null) { + if (element && element.parentNode) { + element.parentNode.removeChild(element); + } + element = null; + } else { + updateOverlay(); + } + }; + + const updateOverlay = () => { + if (!cursorPos) { + return; + } + + const view = editor.prosemirrorView; + const editorDOM = view.dom; + const editorRect = editorDOM.getBoundingClientRect(); + const scaleX = editorRect.width / editorDOM.offsetWidth; + const scaleY = editorRect.height / editorDOM.offsetHeight; + + const blockRect = getBlockDropRect( + view, + cursorPos, + config.width, + scaleX, + scaleY, + ); + const rect = + blockRect ?? getInlineDropRect(view, cursorPos, config.width, scaleX); + + const parent = view.dom.offsetParent as HTMLElement; + if (!element) { + element = parent.appendChild(document.createElement("div")); + element.style.cssText = + "position: absolute; z-index: 50; pointer-events: none;"; + if (config.color) { + element.style.backgroundColor = config.color; + } + } + + applyOrientationClasses(element, cursorPos.orientation); + + const { parentLeft, parentTop } = getParentOffsets(parent); + + element.style.left = (rect.left - parentLeft) / scaleX + "px"; + element.style.top = (rect.top - parentTop) / scaleY + "px"; + element.style.width = (rect.right - rect.left) / scaleX + "px"; + element.style.height = (rect.bottom - rect.top) / scaleY + "px"; + }; + + const scheduleRemoval = (ms: number) => { + clearTimeout(timeout); + timeout = window.setTimeout(() => setCursor(null), ms); + }; + + // Event handlers + const onDragStart = (event: Event) => { + const e = event as DragEvent; + dragSourceElement = e.target instanceof Element ? e.target : null; + }; + + const onDragOver = (event: Event) => { + const e = event as DragEvent; + + // Check if drag source has exclusion classname + if ( + dragSourceElement && + hasExclusionClassname(dragSourceElement, config.exclude) + ) { + return; + } + + // Check if drop target has exclusion classname + if ( + e.target instanceof Element && + hasExclusionClassname(e.target, config.exclude) + ) { + return; + } + + const view = editor.prosemirrorView; + if (!view.editable) { + return; + } + + const pos = view.posAtCoords({ + left: e.clientX, + top: e.clientY, + }); + + const node = pos && pos.inside >= 0 && view.state.doc.nodeAt(pos.inside); + const disableDropCursor = node && (node.type.spec as any).disableDropCursor; + const disabled = + typeof disableDropCursor === "function" + ? disableDropCursor(view, pos, e) + : disableDropCursor; + + if (pos && !disabled) { + let target = pos.pos; + if (view.dragging && view.dragging.slice) { + const point = dropPoint(view.state.doc, target, view.dragging.slice); + if (point != null) { + target = point; + } + } + + // Compute default position + const $pos = view.state.doc.resolve(target); + const isBlock = !$pos.parent.inlineContent; + const defaultPosition: DropCursorPosition = { + pos: target, + orientation: isBlock ? "block-horizontal" : "inline", + }; + + // Allow hook to override position + let finalPosition = defaultPosition; + if (config.hooks?.computeDropPosition) { + const hookResult = config.hooks.computeDropPosition({ + editor, + event: e, + view, + defaultPosition, + }); + if (hookResult === null) { + // Hook returned null - don't show cursor + setCursor(null); + return; + } + finalPosition = hookResult; + } + + setCursor(finalPosition); + scheduleRemoval(5000); + } + }; + + const onDragLeave = (event: Event) => { + const e = event as DragEvent; + if ( + !(e.relatedTarget instanceof Node) || + !editor.prosemirrorView.dom.contains(e.relatedTarget) + ) { + setCursor(null); + } + }; + + const onDrop = () => { + scheduleRemoval(20); + }; + + const onDragEnd = () => { + scheduleRemoval(20); + dragSourceElement = null; + }; + + return { + key: "dropCursor", + mount({ signal, dom, root }) { + // Track drag source at document level + root.addEventListener("dragstart", onDragStart, { + capture: true, + signal, + }); + + // Handle drag events on the editor + dom.addEventListener("dragover", onDragOver, { signal }); + dom.addEventListener("dragleave", onDragLeave, { signal }); + dom.addEventListener("drop", onDrop, { signal }); + dom.addEventListener("dragend", onDragEnd, { signal }); + + // Clean up on unmount + signal.addEventListener("abort", () => { + clearTimeout(timeout); + setCursor(null); + }); + }, + } as const; +}); diff --git a/packages/core/src/extensions/DropCursor/utils.ts b/packages/core/src/extensions/DropCursor/utils.ts new file mode 100644 index 0000000000..25e3985238 --- /dev/null +++ b/packages/core/src/extensions/DropCursor/utils.ts @@ -0,0 +1,195 @@ +import type { EditorView } from "prosemirror-view"; + +/** + * The orientation of the drop cursor. + */ +export type DropCursorOrientation = + | "inline" // Vertical line within text + | "block-horizontal" // Horizontal line between blocks + | "block-vertical-left" // Vertical line on left edge of block + | "block-vertical-right"; // Vertical line on right edge of block + +/** + * The position and orientation of the drop cursor. + */ +export type DropCursorPosition = { + pos: number; // Document position + orientation: DropCursorOrientation; +}; +/** + * Bounding rectangle in viewport coordinates (e.g. from getBoundingClientRect). + */ +export type Rect = { + left: number; + right: number; + top: number; + bottom: number; +}; + +/** + * Returns true if the element or any ancestor has the given CSS class. + * Used to skip drop cursor for elements marked with the exclusion class (e.g. drag handles). + */ +export function hasExclusionClassname( + element: Element | null, + exclude: string, +): boolean { + if (!element || !exclude) { + return false; + } + return !!element.closest(`.${exclude}`); +} + +/** + * Computes the viewport rect for a block-level drop cursor (horizontal line between blocks + * or vertical line on left/right edge). Returns null for inline positions or when no DOM node exists. + */ +export function getBlockDropRect( + view: EditorView, + cursorPos: DropCursorPosition, + width: number, + scaleX: number, + scaleY: number, +): Rect | null { + const $pos = view.state.doc.resolve(cursorPos.pos); + const isBlock = !$pos.parent.inlineContent; + + if (!isBlock || cursorPos.orientation === "inline") { + return null; + } + + const before = $pos.nodeBefore; + + const after = $pos.nodeAfter; + + if (!before && !after) { + return null; + } + + const isVertical = + cursorPos.orientation === "block-vertical-left" || + cursorPos.orientation === "block-vertical-right"; + // For vertical cursors, position is at the node position, for horizontal cursors, position is at the node before position + const nodePos = isVertical + ? cursorPos.pos + : cursorPos.pos - (before ? before.nodeSize : 0); + + const node = view.nodeDOM(nodePos) as HTMLElement | null; + if (!node) { + return null; + } + + const nodeRect = node.getBoundingClientRect(); + + if (isVertical) { + const halfWidth = (width / 2) * scaleX; + const left = + cursorPos.orientation === "block-vertical-left" + ? nodeRect.left + : nodeRect.right; + + return { + left: left - halfWidth, + right: left + halfWidth, + top: nodeRect.top, + bottom: nodeRect.bottom, + }; + } + + let top = before ? nodeRect.bottom : nodeRect.top; + if (before && after) { + top = + (top + + (view.nodeDOM(cursorPos.pos) as HTMLElement).getBoundingClientRect() + .top) / + 2; + } + const halfHeight = (width / 2) * scaleY; + + return { + left: nodeRect.left, + right: nodeRect.right, + top: top - halfHeight, + bottom: top + halfHeight, + }; +} + +/** + * Computes the viewport rect for an inline drop cursor (vertical line within text). + */ +export function getInlineDropRect( + view: EditorView, + cursorPos: DropCursorPosition, + width: number, + scaleX: number, +): Rect { + const coords = view.coordsAtPos(cursorPos.pos); + const halfWidth = (width / 2) * scaleX; + + return { + left: coords.left - halfWidth, + right: coords.left + halfWidth, + top: coords.top, + bottom: coords.bottom, + }; +} + +/** + * Applies orientation-specific CSS classes to the drop cursor element so it can be + * styled correctly (e.g. horizontal vs vertical line, inline vs block). + */ +export function applyOrientationClasses( + el: HTMLElement, + orientation: DropCursorOrientation, +) { + el.classList.toggle( + "prosemirror-dropcursor-inline", + orientation === "inline", + ); + el.classList.toggle( + "prosemirror-dropcursor-block-horizontal", + orientation === "block-horizontal", + ); + el.classList.toggle( + "prosemirror-dropcursor-block-vertical-left", + orientation === "block-vertical-left", + ); + el.classList.toggle( + "prosemirror-dropcursor-block-vertical-right", + orientation === "block-vertical-right", + ); + el.classList.toggle( + "prosemirror-dropcursor-block", + orientation === "block-horizontal", + ); + el.classList.toggle( + "prosemirror-dropcursor-vertical", + orientation === "block-vertical-left" || + orientation === "block-vertical-right", + ); +} + +/** + * Returns the offset of the parent element for converting viewport coordinates to + * parent-relative coordinates. Handles document.body and static positioning. + */ +export function getParentOffsets(parent: HTMLElement | null) { + if ( + !parent || + (parent === document.body && getComputedStyle(parent).position === "static") + ) { + return { + parentLeft: -window.pageXOffset, + parentTop: -window.pageYOffset, + }; + } + + const parentRect = parent.getBoundingClientRect(); + const parentScaleX = parentRect.width / parent.offsetWidth; + const parentScaleY = parentRect.height / parent.offsetHeight; + + return { + parentLeft: parentRect.left - parent.scrollLeft * parentScaleX, + parentTop: parentRect.top - parent.scrollTop * parentScaleY, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 88662e0970..e20a9f7ddf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,7 @@ export * from "./editor/BlockNoteExtension.js"; export * from "./editor/defaultColors.js"; export * from "./editor/selectionTypes.js"; export * from "./exporter/index.js"; +export * from "./extensions/index.js"; export * from "./extensions-shared/UiElementPosition.js"; export * from "./i18n/dictionary.js"; export * from "./schema/index.js"; diff --git a/packages/xl-multi-column/src/blocks/Columns/index.ts b/packages/xl-multi-column/src/blocks/Columns/index.ts index 8d9bcac3b4..2e49261ec6 100644 --- a/packages/xl-multi-column/src/blocks/Columns/index.ts +++ b/packages/xl-multi-column/src/blocks/Columns/index.ts @@ -1,3 +1,4 @@ +import { MultiColumnDropHandlerExtension } from "../../extensions/DropCursor/multiColumnHandleDropPlugin.js"; import { Column } from "../../pm-nodes/Column.js"; import { ColumnList } from "../../pm-nodes/ColumnList.js"; @@ -14,6 +15,7 @@ export const ColumnBlock = createBlockSpecFromTiptapNode( default: 1, }, }, + [MultiColumnDropHandlerExtension()], ); export const ColumnListBlock = createBlockSpecFromTiptapNode( diff --git a/packages/xl-multi-column/src/extensions/DropCursor/MultiColumnDropCursorPlugin.ts b/packages/xl-multi-column/src/extensions/DropCursor/MultiColumnDropCursorPlugin.ts deleted file mode 100644 index bc8a650bcf..0000000000 --- a/packages/xl-multi-column/src/extensions/DropCursor/MultiColumnDropCursorPlugin.ts +++ /dev/null @@ -1,508 +0,0 @@ -import type { BlockNoteEditor } from "@blocknote/core"; -import { - UniqueID, - getBlockInfo, - getNearestBlockPos, - nodeToBlock, -} from "@blocknote/core"; -import { EditorState, Plugin } from "prosemirror-state"; -import { dropPoint } from "prosemirror-transform"; -import { EditorView } from "prosemirror-view"; - -const PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP = 0.1; - -function eventCoords(event: MouseEvent) { - return { left: event.clientX, top: event.clientY }; -} - -interface DropCursorOptions { - /// The color of the cursor. Defaults to `black`. Use `false` to apply no color and rely only on class. - color?: string | false; - - /// The precise width of the cursor in pixels. Defaults to 1. - width?: number; - - /// A CSS class name to add to the cursor element. - class?: string; -} - -/// Create a plugin that, when added to a ProseMirror instance, -/// causes a decoration to show up at the drop position when something -/// is dragged over the editor. -/// -/// Nodes may add a `disableDropCursor` property to their spec to -/// control the showing of a drop cursor inside them. This may be a -/// boolean or a function, which will be called with a view and a -/// position, and should return a boolean. -export function multiColumnDropCursor( - options: DropCursorOptions & { - editor: BlockNoteEditor; - }, -): Plugin { - const editor = options.editor; - return new Plugin({ - view(editorView) { - return new DropCursorView(editorView, options); - }, - props: { - handleDrop(view, event, slice, _moved) { - const eventPos = view.posAtCoords(eventCoords(event)); - - if (!eventPos) { - throw new Error("Could not get event position"); - } - - const posInfo = getTargetPosInfo(view.state, eventPos); - const blockInfo = getBlockInfo(posInfo); - - const blockElement = view.nodeDOM(posInfo.posBeforeNode); - const blockRect = (blockElement as HTMLElement).getBoundingClientRect(); - let position: "regular" | "left" | "right" = "regular"; - if ( - event.clientX <= - blockRect.left + - blockRect.width * PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP - ) { - position = "left"; - } - if ( - event.clientX >= - blockRect.right - - blockRect.width * PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP - ) { - position = "right"; - } - - if (position === "regular") { - // handled by default prosemirror drop behaviour - return false; - } - - const draggedBlock = nodeToBlock( - slice.content.child(0), - editor.pmSchema, - // TODO: cache? - ); - - // const block = blockInfo.block(editor); - if (blockInfo.blockNoteType === "column") { - // insert new column in existing columnList - const parentBlock = view.state.doc - .resolve(blockInfo.bnBlock.beforePos) - .node(); - - const columnList = nodeToBlock( - parentBlock, - editor.pmSchema, - ); - - // In a `columnList`, we expect that the average width of each column - // is 1. However, there are cases in which this stops being true. For - // example, having one wider column and then removing it will cause - // the average width to go down. This isn't really an issue until the - // user tries to add a new column, which will, in this case, be wider - // than expected. Therefore, we normalize the column widths to an - // average of 1 here to avoid this issue. - let sumColumnWidthPercent = 0; - columnList.children.forEach((column) => { - sumColumnWidthPercent += column.props.width as number; - }); - const avgColumnWidthPercent = - sumColumnWidthPercent / columnList.children.length; - - // If the average column width is not 1, normalize it. We're dealing - // with floats so we need a small margin to account for precision - // errors. - if (avgColumnWidthPercent < 0.99 || avgColumnWidthPercent > 1.01) { - const scalingFactor = 1 / avgColumnWidthPercent; - - columnList.children.forEach((column) => { - column.props.width = - (column.props.width as number) * scalingFactor; - }); - } - - const index = columnList.children.findIndex( - (b) => b.id === blockInfo.bnBlock.node.attrs.id, - ); - - const newChildren = columnList.children - // If the dragged block is in one of the columns, remove it. - .map((column) => ({ - ...column, - children: column.children.filter( - (block) => block.id !== draggedBlock.id, - ), - })) - // Remove empty columns (can happen when dragged block is removed). - .filter((column) => column.children.length > 0) - // Insert the dragged block in the correct position. - .toSpliced(position === "left" ? index : index + 1, 0, { - type: "column", - children: [draggedBlock], - props: {}, - content: undefined, - id: UniqueID.options.generateID(), - }); - - if (editor.getBlock(draggedBlock.id)) { - editor.removeBlocks([draggedBlock]); - } - - editor.updateBlock(columnList, { - children: newChildren, - }); - } else { - // create new columnList with blocks as columns - const block = nodeToBlock(blockInfo.bnBlock.node, editor.pmSchema); - - // The user is dropping next to the original block being dragged - do - // nothing. - if (block.id === draggedBlock.id) { - return; - } - - const blocks = - position === "left" ? [draggedBlock, block] : [block, draggedBlock]; - - if (editor.getBlock(draggedBlock.id)) { - editor.removeBlocks([draggedBlock]); - } - - editor.replaceBlocks( - [block], - [ - { - type: "columnList", - children: blocks.map((b) => { - return { - type: "column", - children: [b], - }; - }), - }, - ], - ); - } - - return true; - }, - }, - }); -} - -class DropCursorView { - width: number; - color: string | undefined; - class: string | undefined; - cursorPos: - | { pos: number; position: "left" | "right" | "regular" } - | undefined = undefined; - element: HTMLElement | null = null; - timeout: ReturnType | undefined = undefined; - handlers: { name: string; handler: (event: Event) => void }[]; - - constructor( - readonly editorView: EditorView, - options: DropCursorOptions, - ) { - this.width = options.width ?? 1; - this.color = options.color === false ? undefined : options.color || "black"; - this.class = options.class; - - this.handlers = ["dragover", "dragend", "drop", "dragleave"].map((name) => { - const handler = (e: Event) => { - (this as any)[name](e); - }; - editorView.dom.addEventListener( - name, - handler, - // drop event captured in bubbling phase to make sure - // "cursorPos" is set to undefined before the "handleDrop" handler is called - // (otherwise an error could be thrown, see https://github.com/TypeCellOS/BlockNote/pull/1240) - name === "drop" ? true : undefined, - ); - return { name, handler }; - }); - } - - destroy() { - this.handlers.forEach(({ name, handler }) => - this.editorView.dom.removeEventListener( - name, - handler, - name === "drop" ? true : undefined, - ), - ); - } - - update(editorView: EditorView, prevState: EditorState) { - if (this.cursorPos != null && prevState.doc !== editorView.state.doc) { - if (this.cursorPos.pos > editorView.state.doc.content.size) { - this.setCursor(undefined); - } else { - // update overlay because document has changed - this.updateOverlay(); - } - } - } - - setCursor( - cursorPos: - | { pos: number; position: "left" | "right" | "regular" } - | undefined, - ) { - if ( - cursorPos === this.cursorPos || - (cursorPos?.pos === this.cursorPos?.pos && - cursorPos?.position === this.cursorPos?.position) - ) { - // no change - return; - } - this.cursorPos = cursorPos; - if (!cursorPos) { - this.element!.parentNode!.removeChild(this.element!); - this.element = null; - } else { - // update overlay because cursor has changed - this.updateOverlay(); - } - } - - updateOverlay() { - if (!this.cursorPos) { - throw new Error("updateOverlay called with no cursor position"); - } - const $pos = this.editorView.state.doc.resolve(this.cursorPos.pos); - const isBlock = !$pos.parent.inlineContent; - let rect; - const editorDOM = this.editorView.dom; - const editorRect = editorDOM.getBoundingClientRect(); - const scaleX = editorRect.width / editorDOM.offsetWidth; - const scaleY = editorRect.height / editorDOM.offsetHeight; - if (isBlock) { - const before = $pos.nodeBefore; - const after = $pos.nodeAfter; - if (before || after) { - if ( - this.cursorPos.position === "left" || - this.cursorPos.position === "right" - ) { - const block = this.editorView.nodeDOM(this.cursorPos.pos); - - if (!block) { - throw new Error("nodeDOM returned null in updateOverlay"); - } - - const blockRect = (block as HTMLElement).getBoundingClientRect(); - const halfWidth = (this.width / 2) * scaleY; - const left = - this.cursorPos.position === "left" - ? blockRect.left - : blockRect.right; - rect = { - left: left - halfWidth, - right: left + halfWidth, - top: blockRect.top, - bottom: blockRect.bottom, - // left: blockRect.left, - // right: blockRect.right, - }; - } else { - // regular logic - const node = this.editorView.nodeDOM( - this.cursorPos.pos - (before ? before.nodeSize : 0), - ); - if (node) { - const nodeRect = (node as HTMLElement).getBoundingClientRect(); - - let top = before ? nodeRect.bottom : nodeRect.top; - if (before && after) { - // find the middle between the node above and below - top = - (top + - ( - this.editorView.nodeDOM(this.cursorPos.pos) as HTMLElement - ).getBoundingClientRect().top) / - 2; - } - // console.log("node"); - const halfWidth = (this.width / 2) * scaleY; - - if (this.cursorPos.position === "regular") { - rect = { - left: nodeRect.left, - right: nodeRect.right, - top: top - halfWidth, - bottom: top + halfWidth, - }; - } - } - } - } - } - - if (!rect) { - // Cursor is an inline vertical dropcursor - const coords = this.editorView.coordsAtPos(this.cursorPos.pos); - const halfWidth = (this.width / 2) * scaleX; - rect = { - left: coords.left - halfWidth, - right: coords.left + halfWidth, - top: coords.top, - bottom: coords.bottom, - }; - } - - // Code below positions the cursor overlay based on the rect - const parent = this.editorView.dom.offsetParent as HTMLElement; - if (!this.element) { - this.element = parent.appendChild(document.createElement("div")); - if (this.class) { - this.element.className = this.class; - } - this.element.style.cssText = - "position: absolute; z-index: 50; pointer-events: none;"; - if (this.color) { - this.element.style.backgroundColor = this.color; - } - } - this.element.classList.toggle("prosemirror-dropcursor-block", isBlock); - this.element.classList.toggle( - "prosemirror-dropcursor-vertical", - this.cursorPos.position !== "regular", - ); - this.element.classList.toggle("prosemirror-dropcursor-inline", !isBlock); - let parentLeft, parentTop; - if ( - !parent || - (parent === document.body && - getComputedStyle(parent).position === "static") - ) { - parentLeft = -window.scrollX; - parentTop = -window.scrollY; - } else { - const rect = parent.getBoundingClientRect(); - const parentScaleX = rect.width / parent.offsetWidth; - const parentScaleY = rect.height / parent.offsetHeight; - parentLeft = rect.left - parent.scrollLeft * parentScaleX; - parentTop = rect.top - parent.scrollTop * parentScaleY; - } - this.element.style.left = (rect.left - parentLeft) / scaleX + "px"; - this.element.style.top = (rect.top - parentTop) / scaleY + "px"; - this.element.style.width = (rect.right - rect.left) / scaleX + "px"; - this.element.style.height = (rect.bottom - rect.top) / scaleY + "px"; - } - - scheduleRemoval(timeout: number) { - clearTimeout(this.timeout); - this.timeout = setTimeout(() => this.setCursor(undefined), timeout); - } - - // this gets executed on every mouse move when dragging (drag over) - dragover(event: DragEvent) { - if (!this.editorView.editable) { - return; - } - const pos = this.editorView.posAtCoords({ - left: event.clientX, - top: event.clientY, - }); - - // console.log("posatcoords", pos); - - const node = - pos && pos.inside >= 0 && this.editorView.state.doc.nodeAt(pos.inside); - const disableDropCursor = node && node.type.spec.disableDropCursor; - const disabled = - typeof disableDropCursor == "function" - ? disableDropCursor(this.editorView, pos, event) - : disableDropCursor; - - if (pos && !disabled) { - let position: "regular" | "left" | "right" = "regular"; - let target: number | null = pos.pos; - - const posInfo = getTargetPosInfo(this.editorView.state, pos); - - const block = this.editorView.nodeDOM(posInfo.posBeforeNode); - const blockRect = (block as HTMLElement).getBoundingClientRect(); - - if ( - event.clientX <= - blockRect.left + - blockRect.width * PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP - ) { - position = "left"; - target = posInfo.posBeforeNode; - } - if ( - event.clientX >= - blockRect.right - - blockRect.width * PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP - ) { - position = "right"; - target = posInfo.posBeforeNode; - } - - // "regular logic" - if ( - position === "regular" && - this.editorView.dragging && - this.editorView.dragging.slice - ) { - const point = dropPoint( - this.editorView.state.doc, - target, - this.editorView.dragging.slice, - ); - - if (point != null) { - target = point; - } - } - - this.setCursor({ pos: target, position }); - this.scheduleRemoval(5000); - } - } - - dragend() { - this.scheduleRemoval(20); - } - - drop() { - this.setCursor(undefined); - } - - dragleave(event: DragEvent) { - if ( - event.target === this.editorView.dom || - !this.editorView.dom.contains((event as any).relatedTarget) - ) { - this.setCursor(undefined); - } - } -} - -/** - * From a position inside the document get the block that should be the "drop target" block. - */ -function getTargetPosInfo( - state: EditorState, - eventPos: { pos: number; inside: number }, -) { - const blockPos = getNearestBlockPos(state.doc, eventPos.pos); - - // if we're at a block that's in a column, we want to compare the mouse position to the column, not the block inside it - // why? because we want to insert a new column in the columnList, instead of a new columnList inside of the column - let resolved = state.doc.resolve(blockPos.posBeforeNode); - if (resolved.parent.type.name === "column") { - resolved = state.doc.resolve(resolved.before()); - } - return { - posBeforeNode: resolved.pos, - node: resolved.nodeAfter!, - }; -} diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts new file mode 100644 index 0000000000..61defa7886 --- /dev/null +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts @@ -0,0 +1,112 @@ +import { type DropCursorHooks, getNearestBlockPos } from "@blocknote/core"; +import type { EditorState } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; + +const PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP = 0.1; + +export interface EdgeDropPosition { + position: "left" | "right" | "regular"; + posBeforeNode: number; + node: any; +} + +/** + * Detects if the drop event is near the left or right edge of a block. + * Shared utility used by both the drop cursor visualization and the drop handler. + * Returns null when the event position cannot be resolved (e.g. drop outside editor bounds). + */ +export function detectEdgePosition( + event: DragEvent, + view: EditorView, + state: EditorState, +): EdgeDropPosition | null { + const eventPos = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + + if (!eventPos) { + return null; + } + + const blockPos = getNearestBlockPos(state.doc, eventPos.pos); + + // If we're at a block that's in a column, we want to compare the mouse position to the column, not the block inside it + // Why? Because we want to insert a new column in the columnList, instead of a new columnList inside of the column + let resolved = state.doc.resolve(blockPos.posBeforeNode); + if (resolved.parent.type.name === "column") { + resolved = state.doc.resolve(resolved.before()); + } + + const posInfo = { + posBeforeNode: resolved.pos, + node: resolved.nodeAfter!, + }; + + const blockElement = view.nodeDOM(posInfo.posBeforeNode); + if (blockElement === null) { + return { + position: "regular", + posBeforeNode: posInfo.posBeforeNode, + node: posInfo.node, + }; + } + const blockRect = (blockElement as HTMLElement).getBoundingClientRect(); + + let position: "regular" | "left" | "right" = "regular"; + + if ( + event.clientX <= + blockRect.left + + blockRect.width * PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP + ) { + position = "left"; + } else if ( + event.clientX >= + blockRect.right - + blockRect.width * PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP + ) { + position = "right"; + } + + return { + position, + posBeforeNode: posInfo.posBeforeNode, + node: posInfo.node, + }; +} + +/** + * Creates the computeDropPosition hook for multi-column support. + * This hook detects edge drops and returns vertical cursor orientations. + */ +export const multiColumnDropCursor: { hooks: DropCursorHooks } = { + hooks: { + computeDropPosition: (context) => { + const edgePos = detectEdgePosition( + context.event, + context.view, + context.view.state, + ); + + // Fall back to default when position cannot be resolved + if (edgePos === null) { + return context.defaultPosition; + } + + // If it's a regular (non-edge) drop, use the default position + if (edgePos.position === "regular") { + return context.defaultPosition; + } + + // Edge drop - show vertical cursor + return { + pos: edgePos.posBeforeNode, + orientation: + edgePos.position === "left" + ? "block-vertical-left" + : "block-vertical-right", + }; + }, + }, +}; diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts new file mode 100644 index 0000000000..e93b266634 --- /dev/null +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts @@ -0,0 +1,162 @@ +import type { BlockNoteEditor } from "@blocknote/core"; +import { + UniqueID, + createExtension, + getBlockInfo, + nodeToBlock, +} from "@blocknote/core"; +import { Plugin } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; +import { detectEdgePosition } from "./multiColumnDropCursor.js"; + +/** + * Creates a ProseMirror plugin that handles drop events for multi-column layouts. + * When a block is dropped near the left or right edge of another block, it creates + * or modifies column layouts. + */ +export function createMultiColumnHandleDropPlugin( + editor: BlockNoteEditor, +): Plugin { + return new Plugin({ + props: { + handleDrop(view: EditorView, event: DragEvent, slice, _moved) { + const edgePos = detectEdgePosition(event, view, view.state); + if (edgePos === null) { + return false; // Let ProseMirror handle the drop (e.g. outside editor bounds) + } + + const blockInfo = getBlockInfo(edgePos); + + // Only handle edge drops (left/right) + if (edgePos.position === "regular") { + return false; // Let ProseMirror handle regular drops + } + + if (slice.content.childCount === 0) { + return false; // Let ProseMirror handle empty slice drops + } + + const draggedBlock = nodeToBlock( + slice.content.child(0), + editor.pmSchema, + ); + + if (blockInfo.blockNoteType === "column") { + // Insert new column in existing columnList + const parentBlock = view.state.doc + .resolve(blockInfo.bnBlock.beforePos) + .node(); + + const columnList = nodeToBlock( + parentBlock, + editor.pmSchema, + ); + + // Normalize column widths to average of 1 + // In a `columnList`, we expect that the average width of each column + // is 1. However, there are cases in which this stops being true. For + // example, having one wider column and then removing it will cause + // the average width to go down. This isn't really an issue until the + // user tries to add a new column, which will, in this case, be wider + // than expected. Therefore, we normalize the column widths to an + // average of 1 here to avoid this issue. + let sumColumnWidthPercent = 0; + columnList.children.forEach((column) => { + sumColumnWidthPercent += column.props.width as number; + }); + const avgColumnWidthPercent = + sumColumnWidthPercent / columnList.children.length; + + // If the average column width is not 1, normalize it. We're dealing + // with floats so we need a small margin to account for precision + // errors. + if (avgColumnWidthPercent < 0.99 || avgColumnWidthPercent > 1.01) { + const scalingFactor = 1 / avgColumnWidthPercent; + + columnList.children.forEach((column) => { + column.props.width = + (column.props.width as number) * scalingFactor; + }); + } + + const index = columnList.children.findIndex( + (b) => b.id === blockInfo.bnBlock.node.attrs.id, + ); + + const newChildren = columnList.children + // If the dragged block is in one of the columns, remove it. + .map((column) => ({ + ...column, + children: column.children.filter( + (block) => block.id !== draggedBlock.id, + ), + })) + // Remove empty columns (can happen when dragged block is removed). + .filter((column) => column.children.length > 0) + // Insert the dragged block in the correct position. + .toSpliced(edgePos.position === "left" ? index : index + 1, 0, { + type: "column", + children: [draggedBlock], + props: {}, + content: undefined, + id: UniqueID.options.generateID(), + }); + + if (editor.getBlock(draggedBlock.id)) { + editor.removeBlocks([draggedBlock]); + } + + editor.updateBlock(columnList, { + children: newChildren, + }); + } else { + // Create new columnList with blocks as columns + const block = nodeToBlock(blockInfo.bnBlock.node, editor.pmSchema); + + // The user is dropping next to the original block being dragged - do + // nothing. + if (block.id === draggedBlock.id) { + return true; + } + + const blocks = + edgePos.position === "left" + ? [draggedBlock, block] + : [block, draggedBlock]; + + if (editor.getBlock(draggedBlock.id)) { + editor.removeBlocks([draggedBlock]); + } + + editor.replaceBlocks( + [block], + [ + { + type: "columnList", + children: blocks.map((b) => { + return { + type: "column", + children: [b], + }; + }), + }, + ], + ); + } + + return true; // Prevent default ProseMirror drop behavior + }, + }, + }); +} + +/** + * BlockNote extension that adds the multi-column drop handler plugin. + * This should be added to the editor's extensions to enable column creation via drag-and-drop. + */ +export const MultiColumnDropHandlerExtension = createExtension( + ({ editor }) => ({ + key: "multiColumnDropHandler", + prosemirrorPlugins: [createMultiColumnHandleDropPlugin(editor)], + }), +); diff --git a/packages/xl-multi-column/src/index.ts b/packages/xl-multi-column/src/index.ts index 5676e55a7a..9c49753ebe 100644 --- a/packages/xl-multi-column/src/index.ts +++ b/packages/xl-multi-column/src/index.ts @@ -3,5 +3,5 @@ export { locales }; export * from "./i18n/dictionary.js"; export * from "./blocks/Columns/index.js"; export * from "./blocks/schema.js"; -export * from "./extensions/DropCursor/MultiColumnDropCursorPlugin.js"; +export * from "./extensions/DropCursor/multiColumnDropCursor.js"; export * from "./extensions/SuggestionMenu/getMultiColumnSlashMenuItems.js"; diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index d73534652c..aea664f288 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -810,6 +810,28 @@ "slug": "ui-components" }, "readme": "This example demonstrates advanced table features including automatic calculations. It shows how to create a table with calculated columns that automatically update when values change.\n\n## Features\n\n- **Automatic Calculations**: Quantity × Price = Total for each row\n- **Grand Total**: Automatically calculated sum of all totals\n- **Real-time Updates**: Calculations update immediately when you change quantity or price values\n- **Split cells**: Merge and split table cells\n- **Cell background color**: Color individual cells\n- **Cell text color**: Change text color in cells\n- **Table row and column headers**: Use headers for better organization\n\n## How It Works\n\nThe example uses the `onChange` event listener to detect when table content changes. When a table is updated, it automatically:\n\n1. Extracts quantity and price values from each data row\n2. Calculates the total (quantity × price) for each row\n3. Updates the total column with the calculated values\n4. Calculates and updates the grand total\n\n## Code Highlights\n\n```tsx\n {\n const changes = getChanges();\n\n changes.forEach((change) => {\n if (change.type === \"update\" && change.block.type === \"table\") {\n const updatedRows = calculateTableTotals(change.block);\n if (updatedRows) {\n editor.updateBlock(change.block, {\n type: \"table\",\n content: {\n ...change.block.content,\n rows: updatedRows as any,\n } as any,\n });\n }\n }\n });\n }}\n>\n```\n\n**Relevant Docs:**\n\n- [Tables](/docs/features/blocks/tables)\n- [Editor Setup](/docs/getting-started/editor-setup)\n- [Events](/docs/reference/editor/events)" + }, + { + "projectSlug": "drag-n-drop", + "fullSlug": "ui-components/drag-n-drop", + "pathFromRoot": "examples/03-ui-components/18-drag-n-drop", + "config": { + "playground": true, + "docs": false, + "author": "nperez0111", + "tags": [ + "Intermediate", + "UI Components", + "Drag & Drop", + "Customization" + ] + }, + "title": "Drag & Drop Exclusion", + "group": { + "pathFromRoot": "examples/03-ui-components", + "slug": "ui-components" + }, + "readme": "This example demonstrates how to use the `DRAG_EXCLUSION_CLASSNAME` to create separate drag & drop areas that don't interfere with BlockNote's built-in block drag & drop functionality.\n\n## Features\n\n- **Drag Exclusion**: Elements with the `bn-drag-exclude` classname are treated as separate drag & drop operations\n- **Independent Drag Areas**: Create custom drag & drop functionality alongside BlockNote's editor\n- **No Interference**: Custom drag operations won't trigger BlockNote's block reordering\n- **Side-by-side Demo**: Shows the editor and custom drag area working independently\n\n## How It Works\n\nBy adding the `DRAG_EXCLUSION_CLASSNAME` (`bn-drag-exclude`) to an element, you tell BlockNote's drag & drop handlers to ignore all drag events within that element and its children. This allows you to implement your own custom drag & drop logic without conflicts.\n\nThe exclusion check works by traversing up the DOM tree from the drag event target, checking if any ancestor has the exclusion classname. If found, BlockNote's handlers return early, leaving your custom handlers in full control.\n\n## Code Highlights\n\n### Import the constant:\n\n```tsx\nimport { DRAG_EXCLUSION_CLASSNAME } from \"@blocknote/core\";\n```\n\n### Apply it to your custom drag area:\n\n```tsx\n
\n {/* Your custom drag & drop UI */}\n
\n Custom draggable items\n
\n
\n```\n\n## Use Cases\n\n- **Custom UI elements**: Add draggable components within or near the editor\n- **File upload areas**: Create drag-and-drop file upload zones\n- **Sortable lists**: Implement custom sortable lists alongside the editor\n- **External integrations**: Integrate with third-party drag & drop libraries\n\n**Relevant Docs:**\n\n- [Side Menu (Drag Handle)](/docs/react/components/side-menu)\n- [Editor Setup](/docs/getting-started/editor-setup)" } ] }, diff --git a/playground/src/main.tsx b/playground/src/main.tsx index a4c3dbf847..43003c5ad2 100644 --- a/playground/src/main.tsx +++ b/playground/src/main.tsx @@ -1,5 +1,10 @@ import { usePrefersColorScheme } from "@blocknote/react"; -import { AppShell, MantineProvider, ScrollArea } from "@mantine/core"; +import { + AppShell, + MantineProvider, + ScrollArea, + TextInput, +} from "@mantine/core"; import React from "react"; import { createRoot } from "react-dom/client"; import { @@ -33,6 +38,7 @@ function Root() { // // "root:hover": { background: "blue" }, // }); + const [searchQuery, setSearchQuery] = React.useState(""); const themePreference = usePrefersColorScheme(); return ( @@ -59,9 +65,20 @@ function Root() { > {window.location.search.includes("hideMenu") ? undefined : ( + setSearchQuery(event.currentTarget.value)} + mb="md" + /> {Object.values(editors) .flatMap((g) => g.projects) + .filter((editor) => + editor.title + .toLowerCase() + .includes(searchQuery.toLowerCase()), + ) .map((editor, i) => (
{editor.title} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48398e977e..b4e5567591 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2140,6 +2140,52 @@ importers: specifier: ^5.4.20 version: 5.4.20(@types/node@25.3.3)(lightningcss@1.30.2)(terser@5.46.0) + examples/03-ui-components/18-drag-n-drop: + dependencies: + '@blocknote/ariakit': + specifier: latest + version: link:../../../packages/ariakit + '@blocknote/core': + specifier: latest + version: link:../../../packages/core + '@blocknote/mantine': + specifier: latest + version: link:../../../packages/mantine + '@blocknote/react': + specifier: latest + version: link:../../../packages/react + '@blocknote/shadcn': + specifier: latest + version: link:../../../packages/shadcn + '@mantine/core': + specifier: ^8.3.11 + version: 8.3.11(@mantine/hooks@8.3.11(react@19.2.3))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@mantine/hooks': + specifier: ^8.3.11 + version: 8.3.11(react@19.2.3) + '@mantine/utils': + specifier: ^6.0.22 + version: 6.0.22(react@19.2.3) + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + devDependencies: + '@types/react': + specifier: ^19.2.3 + version: 19.2.8 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.8) + '@vitejs/plugin-react': + specifier: ^4.7.0 + version: 4.7.0(vite@5.4.20(@types/node@25.3.3)(lightningcss@1.30.2)(terser@5.46.0)) + vite: + specifier: ^5.4.20 + version: 5.4.20(@types/node@25.3.3)(lightningcss@1.30.2)(terser@5.46.0) + examples/04-theming/01-theming-dom-attributes: dependencies: '@blocknote/ariakit': @@ -4627,9 +4673,6 @@ importers: hast-util-from-dom: specifier: ^5.0.1 version: 5.0.1 - prosemirror-dropcursor: - specifier: ^1.8.2 - version: 1.8.2 prosemirror-highlight: specifier: ^0.13.0 version: 0.13.0(@shikijs/types@3.19.0)(@types/hast@3.0.4)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-transform@1.10.5)(prosemirror-view@1.41.4)