Skip to content

Configurable drag'n'drop preview container in SideMenuPlugin #1685

Open

Description

@kruszczynski

Is your feature request related to a problem? Please describe.

My Custom Blocks rely on CSS variables from their parent nodes. That means that when I use the drag handle to drag and drop them I lose styling properties and drag and drop preview doesn't look good.

Describe the solution you'd like
A configurable option to specify the selector for the parent of the drag and drop preview. The SideMenuController seems like a good place for it. I could take a stab at implementing it.

Describe alternatives you've considered
An alternative is for me to rewrite my app in a way that does not rely on CSS variables set up the DOM tree. I'd rather not do that.

Additional context
As you can see in the video, button's text color and background are part of the theme specified on the Editor's parent. The dragged button loses it's styles.

Screen.Recording.2025-05-14.at.12.33.38.mov

Bonus
[ ] I'm a sponsor and would appreciate if you could look into this sooner than later 💖

Activity

  1. YousefED commented on Jun 20, 2025

    @YousefED
    Collaborator

    @nperez0111 I'm starting to think using a library like html2canvas, snapdom, or htmltoimage might be the most robust approach here, even though it feels like overkill. Thoughts?

  2. kruszczynski commented on Jun 21, 2025

    @kruszczynski
    Author

    I ended up going mostly with inline styles for my blocks (for lossyHTML export portability) and that solved big chunk of that issue for me.

    The snap approach does feel like an overkill for me, maybe some more configurability like renderForDrag in block specs would allow me to wrap the component in a div setting all the css vars I need. And if someone wants to use snap2image they can too.

  3. nperez0111 commented on Jun 25, 2025

    @nperez0111
    Contributor

    While I would agree that it likely is overkill, it does look like htmltoimage is a pretty small lib ~5kb and I found the core function of it here: https://github.com/bubkoo/html-to-image/blob/master/src/util.ts#L221 Maybe can just pull in that approach and cut out the rest

    Seems like snapdom uses the same approach: https://github.com/zumerlab/snapdom/blob/main/src/core/capture.js

  4. added 2 commits that reference this issue on Aug 14, 2026
    41d57fa
    e07c2e5
  5. batnieluyo commented on Oct 13, 2026

    @batnieluyo

    ¡Hi there!

    I put together a workaround for the oversized image preview when dragging blocks. Hope this helps, @YousefED!

    Image

    IMPORTANT
    This workaround relies on BlockNote's internal DOM classes and data-id attributes. Recheck those selectors and the drag behavior after upgrading BlockNote. Content persistence and block ordering remain handled by the existing editor and autosave logic.

    The idea is to let BlockNote start the drag normally, then resize its preview using the dimensions of the blocks actually displayed in the editor. That way, images don't suddenly expand in the drag preview, and BlockNote still handles the drop and cleanup. Here's the code:

    // my-side-menu.tsx
    import { SideMenuExtension } from '@blocknote/core/extensions';
    import {
        SideMenu,
        type SideMenuProps,
        useBlockNoteEditor,
        useComponentsContext,
        useExtensionState,
        useExtension,
    } from '@blocknote/react';
    import { GripVertical, Trash2 } from 'lucide-react';
    import { constrainKnowledgeDragPreview } from './knowledge-drag-preview';
    
    export function RemoveBlockButton() {
        const editor = useBlockNoteEditor();
        const components = useComponentsContext();
        const block = useExtensionState(SideMenuExtension, {
            editor,
            selector: (state) => state?.block,
        });
        if (!components || !block || !editor.isEditable) return null;
    
        return (
            <components.SideMenu.Button
                label="Remove block"
                icon={<Trash2 size={24} />}
                onClick={() => editor.removeBlocks([block])}
            />
        );
    }
    
    export function DragHandleButton() {
        const editor = useBlockNoteEditor();
        const components = useComponentsContext();
        const sideMenu = useExtension(SideMenuExtension, { editor });
        const block = useExtensionState(SideMenuExtension, {
            editor,
            selector: (state) => state?.block,
        });
        if (!components || !block || !editor.isEditable) return null;
    
        return (
            <components.SideMenu.Button
                label="Drag handle"
                className="bn-button"
                icon={<GripVertical size={24} />}
                draggable
                onDragStart={(event) => {
                    sideMenu.blockDragStart(event, block);
                    constrainKnowledgeDragPreview(editor.domElement, event);
                }}
                onDragEnd={sideMenu.blockDragEnd}
            />
        );
    }
    
    export function KnowledgeSideMenu(props: SideMenuProps) {
        return (
            <SideMenu {...props}>
                <RemoveBlockButton />
                <DragHandleButton />
            </SideMenu>
        );
    }

    The important part is the order in onDragStart: call blockDragStart first so BlockNote creates the preview, then apply the sizing workaround.

    // my-drag-preview.ts
    type BlockDragEvent = { dataTransfer: DataTransfer | null; clientY: number };
    
    export function constrainKnowledgeDragPreview(
        editorElement: HTMLElement | undefined,
        event: BlockDragEvent,
    ): void {
        if (!editorElement || !event.dataTransfer) return;
        const preview =
            editorElement.ownerDocument.querySelector<HTMLElement>(
                '.bn-drag-preview',
            );
        if (!preview) return;
        const width = editorElement.getBoundingClientRect().width;
        if (width <= 0) return;
    
        preview.classList.add('knowledge-drag-preview');
        preview.style.width = `${width}px`;
        preview.style.maxWidth = `${width}px`;
        preview.style.maxHeight = '240px';
        preview.style.overflow = 'hidden';
        preview.style.boxSizing = 'border-box';
    
        const sourceBlocks = new Map(
            Array.from(
                editorElement.querySelectorAll<HTMLElement>('[data-id]'),
            ).map((element) => [element.dataset.id, element]),
        );
        for (const clonedBlock of preview.querySelectorAll<HTMLElement>(
            '[data-id]',
        )) {
            const sourceBlock = sourceBlocks.get(clonedBlock.dataset.id);
            if (!sourceBlock) continue;
            const selector =
                '.bn-file-block-content-wrapper, .bn-visual-media-wrapper, img, video';
            const originals = sourceBlock.querySelectorAll<HTMLElement>(selector);
            const clones = clonedBlock.querySelectorAll<HTMLElement>(selector);
            clones.forEach((clone, index) => {
                const bounds = originals[index]?.getBoundingClientRect();
                if (!bounds || bounds.width <= 0 || bounds.height <= 0) return;
                clone.style.width = `${bounds.width}px`;
                clone.style.height = `${bounds.height}px`;
                clone.style.maxWidth = '100%';
                clone.style.objectFit = 'contain';
            });
        }
    
        const container = editorElement.closest<HTMLElement>('.bn-container');
        if (container) {
            preview.classList.add('bn-container');
            const styles =
                editorElement.ownerDocument.defaultView?.getComputedStyle(
                    container,
                );
            for (const name of [
                '--bn-colors-editor-background',
                '--bn-colors-editor-text',
                '--bn-font-family',
            ]) {
                const value = styles?.getPropertyValue(name);
                if (value) preview.style.setProperty(name, value);
            }
        }
        event.dataTransfer.setDragImage(preview, 12, 12);
    }

    I also added a little styling for the preview:

    .knowledge-drag-preview {
        border-radius: 8px;
        background: var(--bn-colors-editor-background, var(--background));
        color: var(--bn-colors-editor-text, var(--foreground));
        box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
    }

    To use the custom side menu, disable the default one and connect the controller inside existing editor view:

    import { SideMenuController } from '@blocknote/react';
    import { KnowledgeSideMenu } from './knowledge-side-menu';
    
    <BlockNoteView editor={editor} sideMenu={false}>
        <SideMenuController sideMenu={KnowledgeSideMenu} />
    </BlockNoteView>

    NOTE: When you use a custom schema, pass it to useBlockNoteEditor(yourSchema) in both buttons.

    The preview keeps the editor's width, copies the rendered media dimensions, and clips its height at 240px (you can adjust that limit or remove it if you'd rather show the whole block). This only changes the drag preview.

    One caveat:

    This workaround relies on BlockNote's internal DOM classes and data-id attributes. Recheck those selectors and the drag behavior after upgrading BlockNote. Content persistence and block ordering remain handled by the existing editor and autosave logic.

    Hopefully this gives us a useful starting point for a fix!

  6. nperez0111 commented on Oct 27, 2026

    @nperez0111
    Contributor

    Can you guys try out #3235 to let us know whether you think this solution is sufficient for your needs?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bug:P4Low: Minor issues with little user impact.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions