Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 87 additions & 15 deletions packages/core/src/editor/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -40,25 +40,97 @@
font-size: 16px;
font-weight: normal;
font-family:
"Inter",
"Inter fallback",
"SF Pro Display",
-apple-system,
BlinkMacSystemFont,
"Open Sans",
"Segoe UI",
"Roboto",
"Oxygen",
"Ubuntu",
"Cantarell",
"Fira Sans",
"Droid Sans",
"Helvetica Neue",
sans-serif;
"Inter", "Inter fallback", "SF Pro Display", -apple-system,
BlinkMacSystemFont, "Open Sans", "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

/* Outside text editing, a hold lifts the block instead of selecting its text.
Scrolling and pinch zoom remain native; touch handlers cancel only after lift. */
.bn-editor.bn-touch-browse,
.bn-editor.bn-touch-browse * {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}

/* Native setDragImage needs an attached, painted, offscreen snapshot. */
.bn-drag-preview {
position: absolute;
top: -10000px;
left: 0;
margin: 0;
padding: 10px;
height: auto;
min-height: 0;
max-height: 75vh;
overflow: hidden;
background: transparent;
}

.bn-drag-preview > .bn-editor {
background: transparent;
}

.bn-touch-drag-source {
opacity: 0.35;
}

.bn-drag-preview.bn-touch-drag-preview {
/* Unitless fit limits can be overridden by applications. object-fit cannot
scale an arbitrary DOM subtree; a uniform transform preserves its layout. */
--bn-touch-drag-scale: min(
var(--bn-touch-drag-max-scale, 1.1),
calc(var(--bn-touch-drag-max-width, 242) / var(--bn-touch-drag-width)),
calc(var(--bn-touch-drag-max-height, 110) / var(--bn-touch-drag-height))
);
position: fixed;
top: 0;
left: 0;
max-height: none;
pointer-events: none;
transform-origin: top left;
z-index: 2147483647;
background: var(--bn-colors-editor-background);
box-shadow:
0 0 0 2px var(--bn-colors-border),
0 8px 24px #0004;
border-radius: 6px;
opacity: 0.9;
transform: translate(
calc(
var(--bn-touch-drag-x) - var(--bn-touch-drag-grab-x) *
var(--bn-touch-drag-scale)
),
calc(
var(--bn-touch-drag-y) - var(--bn-touch-drag-grab-y) *
var(--bn-touch-drag-scale)
)
)
scale(var(--bn-touch-drag-scale));
}

/* Animate the contents, not the positioned preview, around the grabbed point. */
@media (prefers-reduced-motion: no-preference) {
.bn-touch-drag-preview > .bn-editor {
animation: bn-touch-block-pickup 180ms ease-out;
transform-origin: var(--bn-touch-drag-grab-x) var(--bn-touch-drag-grab-y);
}
}

@keyframes bn-touch-block-pickup {
from {
opacity: 0.4;
transform: scale(0.94);
}
to {
opacity: 1;
transform: none;
}
}

.bn-table-drop-cursor {
position: absolute;
z-index: 20;
Expand Down
66 changes: 48 additions & 18 deletions packages/core/src/extensions/DropCursor/DropCursor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Plugin, PluginKey, Selection } from "prosemirror-state";
import { Plugin, PluginKey } from "prosemirror-state";
import { dropPoint } from "prosemirror-transform";
import { Decoration, DecorationSet, type EditorView } from "prosemirror-view";
import {
Expand All @@ -12,6 +12,9 @@ import {
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { createExtension } from "../../editor/BlockNoteExtension.js";
import { getDropIntoChildren } from "./dropIntoChildren.js";
import { MultipleNodeSelection } from "../SideMenu/MultipleNodeSelection.js";
import { getNodeById } from "../../api/nodeUtil.js";
import { readDragSelection } from "../SideMenu/dragSelection.js";

export const DRAG_EXCLUSION_CLASSNAME = "bn-drag-exclude";

Expand Down Expand Up @@ -73,7 +76,9 @@ export const DropCursorExtension = createExtension<
let timeout = -1;
let dragSourceElement: Element | null = null;
// Set when blocks are dropped in this editor, until the drag ends.
let blocksDropped = false;
let pendingDrop:
| { selection: ReturnType<typeof readDragSelection>; touch: boolean }
| undefined;
let focusTimeout: ReturnType<typeof setTimeout> | undefined;

const config = {
Expand Down Expand Up @@ -165,7 +170,7 @@ export const DropCursorExtension = createExtension<
dragSourceElement = e.target instanceof Element ? e.target : null;
// A drop from another document ends the drag there, without a `dragend`
// in this one.
blocksDropped = false;
pendingDrop = undefined;
clearTimeout(focusTimeout);
};

Expand Down Expand Up @@ -277,14 +282,19 @@ export const DropCursorExtension = createExtension<
const onDrop = (event: Event) => {
scheduleRemoval(20);
// Only block drags carry `blocknote/html`, not text drags.
blocksDropped =
const transfer = (event as DragEvent).dataTransfer;
pendingDrop =
editor.prosemirrorView.editable &&
((event as DragEvent).dataTransfer?.types.includes("blocknote/html") ??
false);
transfer?.types.includes("blocknote/html")
? {
selection: readDragSelection(transfer),
touch: transfer.getData("blocknote/touch-drag") === "true",
}
: undefined;
// A drop from another document or shadow root has no local dragend.
// Finish it after drop handlers run, unless dragend already did so.
clearTimeout(focusTimeout);
if (blocksDropped) {
if (pendingDrop) {
focusTimeout = setTimeout(onRootDragEnd, 0);
}
};
Expand All @@ -295,27 +305,36 @@ export const DropCursorExtension = createExtension<
};

/**
* Leaves the editor ready for the keyboard after blocks were dropped in it.
* Restores the selection category and appropriate focus after a block drop.
* Listens on the root, as `dragend` fires on the drag source, e.g. the side
* menu's handle, which is outside the editor.
*/
function onRootDragEnd() {
// Sources outside the editable DOM (handles and touch lifts) do not reach
// its dragend listener. Cancellation must clear feedback even without drop.
onDragEnd();
clearTimeout(focusTimeout);
if (!blocksDropped) {
const dropped = pendingDrop;
pendingDrop = undefined;
if (!dropped) {
return;
}
blocksDropped = false;
const view = editor.prosemirrorView;
// Firefox shows no text cursor after a drop until the editor is focused
// again (#2128).
view.dom.blur();
// A text cursor at the end of the dropped blocks, so the keyboard works on
// the editor right away, e.g. to undo the drop (#3165). The drop leaves the
// blocks selected, which would show the formatting toolbar.
// Focus is needed for undo (#3165), not a reason to change selection type.
const selection = view.state.selection;
view.dispatch(
view.state.tr.setSelection(Selection.near(view.state.selection.$to, -1)),
view.state.tr.setSelection(
dropped.selection.restore(view.state.doc, selection),
),
);
view.focus();
// Focusing contenteditable opens the software keyboard even for a block
// selection. A touch block drop is manipulation, not entry into text editing.
if (dropped.selection.type !== "block" || !dropped.touch) {
view.focus();
}
}

// Drops the blocks where the cursor shows them: as the first children of
Expand Down Expand Up @@ -386,8 +405,20 @@ export const DropCursorExtension = createExtension<
} else {
editor.updateBlock(target, { children: draggedBlocks });
}
// Custom drops must also leave the cursor at the dropped blocks.
editor.setTextCursorPosition(draggedBlocks.at(-1), "end");
// Mark the inserted block range after the content changes, in the
// same undo operation. Drag completion restores the bookmark once;
// restoring here too would lose descendant selections on completion.
editor.transact((tr) => {
const first = getNodeById(draggedBlocks[0].id, tr.doc)!;
const last = getNodeById(draggedBlocks.at(-1).id, tr.doc)!;
tr.setSelection(
MultipleNodeSelection.create(
tr.doc,
first.posBeforeNode,
last.posBeforeNode + last.node.nodeSize,
),
);
});
});
return true;
},
Expand All @@ -412,7 +443,6 @@ export const DropCursorExtension = createExtension<
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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,20 @@ export const FormattingToolbarExtension = createExtension(({ editor }) => {
(_editor, { focused }) => {
if (!focused) {
store.setState(false);
// On touch, leaving the editor also ends the selection. A phone
// On touch, leaving the editor also ends a text selection. A phone
// can hand focus back without the keyboard (see the window blur
// listener below): the old selection then showed the desktop
// toolbar, and without the keyboard no tap cleared it. A tablet
// with a mouse or hardware keyboard loses the selection on blur
// too, unlike a desktop.
if (isTouchDevice()) {
editor.transact((tr) => {
if (!tr.selection.empty) {
// Block selections survive manipulation without opening the
// software keyboard, including a touch drag-and-drop.
if (
tr.selection.toJSON().type === "text" &&
!tr.selection.empty
) {
tr.setSelection(Selection.near(tr.selection.$head));
}
});
Expand Down
47 changes: 45 additions & 2 deletions packages/core/src/extensions/SideMenu/SideMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import {
} from "../../schema/index.js";
import { registerBlockDropHandlers } from "./blockDropHandling.js";
import { dragStart, unsetDragImage } from "./dragging.js";
import {
createTouchBlockDraggingPlugin,
registerTouchBlockDragging,
} from "./touchBlockDragging.js";
import {
HoveredBlock,
isHeadingToMenu,
Expand Down Expand Up @@ -41,6 +45,8 @@ export type SideMenuState<
* padding, where the menu sits) always counts.
*/
export type SideMenuOptions = {
/** Allow holding a block's surface to lift it outside text editing. @default true */
touchBlockDragging?: boolean;
/**
* How far left or right of the editor element the pointer still shows the
* side menu, for the block at the pointer's height. Above or below the
Expand Down Expand Up @@ -108,6 +114,7 @@ export const SideMenuExtension = createExtension(
let menuElement: HTMLElement | undefined;
// The last pointer position over this document.
let pointer: Point | undefined;
let touchInput = false;
// The last pointer position over the active block (or the menu).
let lastOnActive: Point | undefined;
// Set while the pointer is heading from the active block to the menu.
Expand Down Expand Up @@ -228,8 +235,8 @@ export const SideMenuExtension = createExtension(
}
}

function endBlockDrag() {
unsetDragImage(editor.prosemirrorView.root);
function endBlockDrag(root = editor.prosemirrorView.root) {
unsetDragImage(root);
isDragOrigin = false;
}

Expand Down Expand Up @@ -278,6 +285,7 @@ export const SideMenuExtension = createExtension(
key: "sideMenu",
store,
prosemirrorPlugins: [
createTouchBlockDraggingPlugin(() => editor.isEditable),
new Plugin({
key: sideMenuPluginKey,
appendTransaction: (transactions, _oldState, newState) => {
Expand Down Expand Up @@ -305,6 +313,40 @@ export const SideMenuExtension = createExtension(
options?.sideMenu?.dropMargin ?? 250,
signal,
);
if (options?.sideMenu?.touchBlockDragging !== false) {
registerTouchBlockDragging({
view: editor.prosemirrorView,
isEditable: () => editor.isEditable,
canDrag: (id) => {
const block = editor.getBlock(id);
return !!block && isDraggable(block.type);
},
startDrag: (event, id) => {
const block = editor.getBlock(id);
if (!block) {
return;
}
isDragOrigin = true;
dragStart(event, block, editor);
},
endDrag: () => {
// The mount signal can abort after TipTap has removed its view.
// Use the captured root, not editor.prosemirrorView, for cleanup.
endBlockDrag(root);
hideMenu();
},
signal,
});
}
// Native drag events do not expose their pointer type. Remember the
// initiating input only to avoid opening the keyboard on a touch drop.
root.addEventListener(
"pointerdown",
(event) => {
touchInput = (event as PointerEvent).pointerType === "touch";
},
{ signal, capture: true, passive: true },
);

// Shows or updates the menu whenever the pointer moves, unless frozen.
root.addEventListener(
Expand Down Expand Up @@ -410,6 +452,7 @@ export const SideMenuExtension = createExtension(
) {
isDragOrigin = true;
dragStart(event, block, editor);
event.dataTransfer?.setData("blocknote/touch-drag", String(touchInput));
},

/**
Expand Down
11 changes: 9 additions & 2 deletions packages/core/src/extensions/SideMenu/blockDropHandling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ function findDropEditor(
? [target]
: Array.from(view.root.querySelectorAll(".bn-editor")).filter(
(editor) =>
// The floating preview clones editor markup, but owns no EditorView.
!editor.closest(".bn-drag-preview") &&
distanceToRect(point, editor.getBoundingClientRect()) <= dropMargin,
);
const coords = event;
Expand Down Expand Up @@ -121,14 +123,19 @@ export function registerBlockDropHandlers(
const dropPointBoundingBox = (
view.dom.firstChild as HTMLElement
).getBoundingClientRect();
// Firefox can resolve the exact right/bottom edge to the document's end.
// Preserve the left/top boundary: it distinguishes dropping beside a layout
// from dropping inside its first column.
const insetX = Math.min(1, dropPointBoundingBox.width / 2);
const insetY = Math.min(1, dropPointBoundingBox.height / 2);
const evt = Object.assign(new Event(event.type, event), {
clientX: Math.min(
Math.max(event.clientX, dropPointBoundingBox.left),
dropPointBoundingBox.left + dropPointBoundingBox.width,
dropPointBoundingBox.right - insetX,
),
clientY: Math.min(
Math.max(event.clientY, dropPointBoundingBox.top),
dropPointBoundingBox.top + dropPointBoundingBox.height,
dropPointBoundingBox.bottom - insetY,
),
dataTransfer: event.dataTransfer,
preventDefault: () => event.preventDefault(),
Expand Down
Loading
Loading