diff --git a/packages/editor/src/extensions/drag-drop/index.ts b/packages/editor/src/extensions/drag-drop/index.ts
new file mode 100644
index 000000000..cb6213834
--- /dev/null
+++ b/packages/editor/src/extensions/drag-drop/index.ts
@@ -0,0 +1,512 @@
+/*
+This file is part of the Notesnook project (https://notesnook.com/)
+
+Copyright (C) 2023 Streetwriters (Private) Limited
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+import { Editor, Extension } from "@tiptap/core";
+import { NodeSelection, Plugin, PluginKey } from "prosemirror-state";
+import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
+import { isAndroid, isiOS } from "../../utils/platform.js";
+
+/**
+ * Drags a node by its `[data-drag-handle]` with pointer events instead of
+ * the browser's HTML5 drag & drop, which on iOS and Android loses the
+ * gesture to text selection and in Firefox never starts inside
+ * `contenteditable` at all. The drop target is a real gap in the document,
+ * so the content moves apart the way it will once the node is dropped.
+ *
+ * NOTE: only task list items use this so far, see their component.
+ */
+export const DragDrop = Extension.create({
+ name: "dragDrop",
+ addProseMirrorPlugins: () => [dropGapPlugin()]
+});
+
+const DROP_GAP_CLASS = "drop-gap";
+// how far the pointer has to move before this is a drag and not a tap, or
+// how long it has to stay down without moving (touch only)
+const DRAG_THRESHOLD = 4;
+const HOLD_DELAY = 150;
+// how far to the right the pointer travels to nest the item, and by how
+// much the gap is indented to show it
+const NEST_THRESHOLD = 40;
+const NEST_INDENT = 24;
+// distance from the edge of the scroller at which auto scrolling starts
+const SCROLL_ZONE = 60;
+const SCROLL_SPEED = 12;
+// how far below a list the pointer can be and still drop into its last slot
+const LIST_SLOP = 24;
+
+type DropGap = { pos: number; height: number; indent: number };
+const gapKey = new PluginKey("drop-gap");
+
+function dropGapPlugin() {
+ return new Plugin({
+ key: gapKey,
+ state: {
+ init: () => null,
+ apply: (tr, value) => {
+ const meta = tr.getMeta(gapKey);
+ return meta === undefined ? value : meta;
+ }
+ },
+ props: {
+ decorations(state) {
+ const gap = gapKey.getState(state);
+ if (!gap || gap.pos > state.doc.content.size) return null;
+ return DecorationSet.create(state.doc, [
+ Decoration.widget(gap.pos, () => createGap(gap), {
+ side: -1,
+ ignoreSelection: true,
+ key: `drop-gap-${gap.pos}`
+ })
+ ]);
+ }
+ }
+ });
+}
+
+function createGap({ height, indent }: DropGap) {
+ // a task list is made of list items, so the gap is one too
+ const element = document.createElement("li");
+ element.className = DROP_GAP_CLASS;
+ element.contentEditable = "false";
+ element.style.marginInlineStart = `${indent}px`;
+ element.style.height = "0px";
+ requestAnimationFrame(() => (element.style.height = `${height}px`));
+ return element;
+}
+
+function setGap(view: EditorView, gap: DropGap | null) {
+ const current = gapKey.getState(view.state);
+ if (current === gap) return;
+ if (current && gap && current.pos === gap.pos) {
+ const element = view.dom.querySelector(`.${DROP_GAP_CLASS}`);
+ if (element) {
+ element.style.marginInlineStart = `${gap.indent}px`;
+ current.indent = gap.indent;
+ return;
+ }
+ }
+ view.dispatch(view.state.tr.setMeta(gapKey, gap));
+}
+
+type Drag = {
+ item: HTMLElement;
+ pos: number;
+ end: number;
+ /** where the item's top edge is, relative to the pointer */
+ offsetY: number;
+ /** measured before the item is hidden, when it still has a size */
+ height: number;
+ startX: number;
+ /** the list reads right to left, so nesting is a drag to the left */
+ rtl: boolean;
+ gap?: DropGap;
+ preview: HTMLElement;
+ /** what the preview is currently as wide as */
+ previewWidth: number;
+ scroller: HTMLElement | null;
+ frame?: number;
+};
+
+/**
+ * Picks up the task item at `getPos` and moves it wherever it is dropped.
+ */
+export function startItemDrag(
+ editor: Editor,
+ getPos: () => number,
+ event: PointerEvent
+) {
+ const handle = event.currentTarget as HTMLElement;
+ const item = handle.closest("li");
+ if (!editor.isEditable || event.button !== 0 || !item) return;
+
+ event.stopPropagation();
+ // the handle has no tap action of its own, so cancelling the default is
+ // safe — and on touch it is what stops the WebView from starting a text
+ // selection instead of the drag
+ if (event.cancelable) event.preventDefault();
+
+ const { view } = editor;
+ let drag: Drag | undefined;
+ let hold: number | undefined;
+
+ const start = () => {
+ clearTimeout(hold);
+ if (drag) return drag;
+
+ const pos = getPos();
+ const node = pos >= 0 && view.state.doc.nodeAt(pos);
+ if (!node) return undefined;
+
+ view.dispatch(
+ view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos))
+ );
+ if (isAndroid || isiOS) setTimeout(() => editor.commands.blur());
+
+ const box = item.getBoundingClientRect();
+
+ const rtl = getComputedStyle(item).direction === "rtl";
+ const { preview, row } = createPreview(view, item, box);
+ preview.style.direction = rtl ? "rtl" : "ltr";
+ drag = {
+ item,
+ pos,
+ end: pos + node.nodeSize,
+ offsetY: box.top - event.clientY,
+ height: row.getBoundingClientRect().height,
+ startX: event.clientX,
+ rtl,
+ preview,
+ previewWidth: box.width,
+ scroller: getScroller(view.dom)
+ };
+
+ item.style.display = "none";
+ document.body.style.setProperty("user-select", "none");
+ return drag;
+ };
+
+ const move = (e: PointerEvent) => {
+ const state =
+ drag ??
+ (Math.hypot(e.clientX - event.clientX, e.clientY - event.clientY) <
+ DRAG_THRESHOLD
+ ? undefined
+ : start());
+ if (!state) return;
+ e.preventDefault();
+ const top = e.clientY + state.offsetY;
+ state.preview.style.transform = `translate3d(0, ${top}px, 0)`;
+
+ // undefined means "leave the gap as it is"; null clears it, so a drop
+ // outside any task list has no target and is cancelled
+ const target = findGap(view, state, e.clientX, e.clientY, top);
+ if (target !== undefined) {
+ state.gap = target ?? undefined;
+ setGap(view, target);
+ }
+ fitPreview(view, state);
+ autoScroll(state, e.clientY);
+ };
+
+ const end = () => {
+ const dropped = drag;
+ cleanup();
+ if (!dropped?.gap) return;
+
+ const at = moveItem(view, dropped.pos, dropped.gap.pos);
+ if (at !== null && dropped.gap.indent) nestItem(editor, at);
+ };
+
+ const cleanup = () => {
+ clearTimeout(hold);
+ // NOTE: on `window`, not the handle. The handle is re-rendered whenever
+ // the gap moves (a decoration change re-renders the node views), and
+ // listeners on the old element would be lost — the drag would freeze.
+ window.removeEventListener("pointermove", move);
+ window.removeEventListener("pointerup", end);
+ window.removeEventListener("pointercancel", cleanup);
+ if (!drag) return;
+ cancelAnimationFrame(drag.frame ?? 0);
+ drag.preview.remove();
+ drag.item.style.removeProperty("display");
+ document.body.style.removeProperty("user-select");
+ setGap(view, null);
+ drag = undefined;
+ };
+
+ window.addEventListener("pointermove", move, { passive: false });
+ window.addEventListener("pointerup", end);
+ window.addEventListener("pointercancel", cleanup);
+ if (event.pointerType !== "mouse")
+ hold = setTimeout(start, HOLD_DELAY) as unknown as number;
+}
+
+/**
+ * A copy of the item that follows the pointer, with its nested items left
+ * out so that tall items stay easy to place.
+ */
+function createPreview(view: EditorView, item: HTMLElement, box: DOMRect) {
+ const preview = document.createElement("div");
+ preview.className = "drag-preview";
+ preview.style.left = `${box.left}px`;
+ preview.style.width = `${box.width}px`;
+
+ const context = document.createElement("div");
+ context.className = view.dom.className;
+ // `.ProseMirror:first-child` adds a top margin to the editor content; the
+ // wrapper is not that, so drop it or the card gains a top gap
+ context.style.margin = "0";
+ preview.appendChild(context);
+
+ const list = (item.parentElement ?? document.createElement("ul")).cloneNode(
+ false
+ ) as HTMLElement;
+ list.style.margin = list.style.padding = "0";
+ context.appendChild(list);
+
+ const clone = item.cloneNode(true) as HTMLElement;
+ clone.style.margin = "0";
+ // the handle is what is being held, not part of the item, so leave it out
+ // of the copy — otherwise it takes up an empty slot on the start side
+ clone.querySelector("[data-drag-handle]")?.remove();
+ let children = 0;
+ clone.querySelectorAll("ul, ol").forEach((nested) => {
+ children += nested.querySelectorAll("li").length;
+ (nested.closest("[class$='-view-content-wrap']") ?? nested).remove();
+ });
+ if (children) {
+ const badge = document.createElement("span");
+ badge.className = "drag-preview-badge";
+ badge.textContent = `+${children}`;
+ clone.appendChild(badge);
+ }
+ list.appendChild(clone);
+
+ (view.dom.parentElement ?? document.body).appendChild(preview);
+ return { preview, row: clone };
+}
+
+/**
+ * Where the item would land: the sibling top edge nearest to the top edge
+ * of the item being dragged, within the task list under the pointer. The
+ * gap counts as one of those edges, which is what keeps it in place while
+ * the item is over it — moving it would move everything below it, putting a
+ * different edge under the item, and it would flicker between the two.
+ *
+ * Returns `undefined` to leave the gap where it is (the pointer is over the
+ * item itself, or off the document for a frame), and `null` to clear it —
+ * a task item only drops into a task list, so anywhere else is cancelled.
+ *
+ * The list is found under the pointer (`pointerY`), but the slot within it
+ * from the item's own top edge (`top`). The item's top rises above the list
+ * before the pointer does, so hit testing with the pointer is what lets the
+ * item reach the very first slot.
+ */
+function findGap(
+ view: EditorView,
+ drag: Drag,
+ x: number,
+ pointerY: number,
+ top: number
+): DropGap | null | undefined {
+ const hx = Math.max(x, view.dom.getBoundingClientRect().left + 1);
+
+ // The list under the pointer, or the item's top (the handle is grabbed
+ // near the top, so they are close).
+ let list = listAt(view, hx, pointerY) ?? listAt(view, hx, top);
+
+ // ...but a list ending just above the point wins if it is deeper. This is
+ // how the last slot is reached: past the last row the point is over the
+ // parent, yet dropping there should land the item after the nested list's
+ // last row, not after the whole parent.
+ const above = listAbove(view, hx, Math.max(pointerY, top));
+ if (above && (!list || list.contains(above))) list = above;
+
+ if (!list) {
+ // off the document for a frame (keep the gap) vs. genuinely elsewhere
+ const element = document.elementFromPoint(hx, pointerY);
+ return !element || !view.dom.contains(element) ? undefined : null;
+ }
+
+ let closest: number | null = null;
+ let distance = Infinity;
+ const consider = (edge: number, pos: number) => {
+ if (Math.abs(edge - top) >= distance) return;
+ distance = Math.abs(edge - top);
+ closest = pos;
+ };
+
+ const children = Array.from(list.children) as HTMLElement[];
+ for (const child of children) {
+ const box = child.getBoundingClientRect();
+ if (!box.height) continue;
+ if (child.classList.contains(DROP_GAP_CLASS)) {
+ if (drag.gap) consider(box.top, drag.gap.pos);
+ continue;
+ }
+
+ const pos = posOf(view, child);
+ if (pos === null) continue;
+ consider(box.top, pos.before);
+ if (child === children.at(-1)) consider(box.bottom, pos.after);
+ }
+
+ if (closest === null) return null;
+ // dropping the item into itself is a no-op: leave the gap alone
+ if (closest > drag.pos && closest < drag.end) return undefined;
+
+ const toEnd = drag.rtl ? drag.startX - x : x - drag.startX;
+ const nest = toEnd > NEST_THRESHOLD && canNest(view, closest, drag);
+ return { pos: closest, height: drag.height, indent: nest ? NEST_INDENT : 0 };
+}
+
+/**
+ * The task list at the given point, if any. When the point is on a list's
+ * header (the tools bar sits above the first item, outside the `ul`) it
+ * still resolves to that list, so the item can be dropped into its first
+ * slot.
+ */
+function listAt(view: EditorView, x: number, y: number) {
+ const element = document.elementFromPoint(x, y);
+ if (!element || !view.dom.contains(element)) return null;
+
+ const list =
+ element.closest("ul.tasklist-content-wrapper") ||
+ element
+ .closest(".taskList-view-content-wrap")
+ ?.querySelector("ul.tasklist-content-wrapper");
+ return list && view.dom.contains(list) ? list : null;
+}
+
+/**
+ * The task list whose bottom edge is just above `y` (within a slop) —
+ * nothing is under the point past the last row, so this is what makes the
+ * last slot reachable there. The deepest such list wins, so the last slot
+ * of a nested list is preferred to its parent's.
+ *
+ * `x` is not used to pick the list: the handle sits at the far left, well
+ * left of an indented nested list, and a note is a single column anyway —
+ * only that the point is not off to the right of the list.
+ */
+function listAbove(view: EditorView, x: number, y: number) {
+ let match: HTMLElement | null = null;
+ let matchTop = -Infinity;
+ const lists = view.dom.querySelectorAll(
+ "ul.tasklist-content-wrapper"
+ );
+ for (const list of lists) {
+ const r = list.getBoundingClientRect();
+ if (x > r.right) continue;
+ if (y < r.bottom || y > r.bottom + LIST_SLOP) continue;
+ // the deepest (lowest starting) list wins
+ if (r.top > matchTop) {
+ matchTop = r.top;
+ match = list;
+ }
+ }
+ return match;
+}
+
+/** the item is as wide as the gap it will land in, and as indented */
+function fitPreview(view: EditorView, drag: Drag) {
+ const box = view.dom
+ .querySelector(`.${DROP_GAP_CLASS}`)
+ ?.getBoundingClientRect();
+ if (!box?.width || box.width === drag.previewWidth) return;
+
+ drag.previewWidth = box.width;
+ drag.preview.style.left = `${box.left}px`;
+ drag.preview.style.width = `${box.width}px`;
+}
+
+/** the positions around the node `element` renders */
+function posOf(view: EditorView, element: HTMLElement) {
+ try {
+ const $pos = view.state.doc.resolve(view.posAtDOM(element, 0));
+ for (let depth = $pos.depth; depth > 0; depth--)
+ if (view.nodeDOM($pos.before(depth)) === element)
+ return { before: $pos.before(depth), after: $pos.after(depth) };
+ } catch (e) {
+ // the element is not part of the document (yet)
+ }
+ return null;
+}
+
+/** an item can only nest under a sibling it will still have once moved */
+function canNest(view: EditorView, pos: number, drag: Drag) {
+ const $pos = view.state.doc.resolve(pos);
+ let at = $pos.start();
+ for (let index = 0; index < $pos.index(); index++) {
+ if (at !== drag.pos) return true;
+ at += $pos.parent.child(index).nodeSize;
+ }
+ return false;
+}
+
+/** moves the item at `from` to `to`, returning where it ended up */
+function moveItem(view: EditorView, from: number, to: number) {
+ const { state } = view;
+ const item = state.doc.nodeAt(from);
+ if (!item) return null;
+
+ if (to === from || to === from + item.nodeSize) return from;
+
+ // NOTE: `deleteRange`, not `delete`: taking the only child out of a
+ // nested list leaves the list empty, and an empty list is not valid
+ // content, so it would be filled with a blank item. This takes the list
+ // itself away instead.
+ const tr = state.tr.deleteRange(from, from + item.nodeSize);
+ const at = Math.min(tr.mapping.map(to), tr.doc.content.size);
+
+ // the target is always a task list, but guard anyway: dropping the item
+ // where it does not fit would put it somewhere unexpected
+ const $at = tr.doc.resolve(at);
+ if (!$at.parent.canReplaceWith($at.index(), $at.index(), item.type))
+ return null;
+
+ const steps = tr.steps.length;
+ tr.replaceRangeWith(at, at, item);
+
+ if (tr.steps.length === steps) return null;
+
+ // select the item, but only if it really landed where we think it did:
+ // NodeSelection throws if there is no node right after `at`
+ const node = tr.doc.resolve(at).nodeAfter;
+ if (node?.type === item.type) {
+ tr.setSelection(NodeSelection.create(tr.doc, at));
+ }
+
+ view.dispatch(tr.setMeta("uiEvent", "drop"));
+ return at;
+}
+
+function nestItem(editor: Editor, pos: number) {
+ const node = editor.state.doc.nodeAt(pos);
+ if (node)
+ editor
+ .chain()
+ .setTextSelection(pos + 1)
+ .sinkListItem(node.type.name)
+ .run();
+}
+
+/** dragging past the edge of the note scrolls it */
+function autoScroll(drag: Drag, y: number) {
+ const box = drag.scroller?.getBoundingClientRect();
+ const top = (box?.top ?? 0) + SCROLL_ZONE;
+ const bottom = (box?.bottom ?? window.innerHeight) - SCROLL_ZONE;
+ const speed = y < top ? -SCROLL_SPEED : y > bottom ? SCROLL_SPEED : 0;
+
+ cancelAnimationFrame(drag.frame ?? 0);
+ if (!speed) return;
+ const step = () => {
+ (drag.scroller ?? window).scrollBy(0, speed);
+ drag.frame = requestAnimationFrame(step);
+ };
+ drag.frame = requestAnimationFrame(step);
+}
+
+function getScroller(element: HTMLElement): HTMLElement | null {
+ for (let node = element.parentElement; node; node = node.parentElement) {
+ if (node.scrollHeight <= node.clientHeight) continue;
+ if (/auto|scroll/.test(getComputedStyle(node).overflowY)) return node;
+ }
+ return null;
+}
diff --git a/packages/editor/src/extensions/task-item/component.tsx b/packages/editor/src/extensions/task-item/component.tsx
index 89bd9baac..17a19cccf 100644
--- a/packages/editor/src/extensions/task-item/component.tsx
+++ b/packages/editor/src/extensions/task-item/component.tsx
@@ -26,6 +26,7 @@ import { useCallback } from "react";
import type { TaskItemAttributes } from "./task-item.js";
import { useIsMobile } from "../../toolbar/stores/toolbar-store.js";
import { isiOS } from "../../utils/platform.js";
+import { startItemDrag } from "../drag-drop/index.js";
import { DesktopOnly } from "../../components/responsive/index.js";
import TaskItem from "@tiptap/extension-task-item";
import { strings } from "@notesnook/intl";
@@ -73,18 +74,16 @@ export function TaskItemComponent(
style={{
flexDirection: "row",
alignItems: "center",
- maxWidth: "95%",
+ maxWidth: "100%",
flexGrow: 1
}}
>
{editor.isEditable && (
startItemDrag(editor, getPos, e.nativeEvent)}
path={Icons.dragHandle}
sx={{
opacity: [1, 1, 0],
@@ -93,7 +92,25 @@ export function TaskItemComponent(
cursor: "grab",
mr: "0.2rem",
fontFamily: "inherit",
- marginTop: "calc((1lh - 18px) / 2)"
+ marginTop: "calc((1lh - 18px) / 2)",
+ // the browser must not take this gesture for scrolling, text
+ // selection or the long press callout
+ touchAction: "none",
+ userSelect: "none",
+ WebkitUserSelect: "none",
+ WebkitTouchCallout: "none",
+ svg: { pointerEvents: "none" },
+ // hit slop: an invisible box larger than the icon, so a finger
+ // landing near the handle still starts the drag instead of the
+ // browser selecting the text next to it.
+ position: "relative",
+ "::before": {
+ content: '""',
+ position: "absolute",
+ insetBlock: "-12px",
+ insetInlineStart: "-16px",
+ insetInlineEnd: "-2px"
+ }
}}
size={isMobile ? "2.46ch" : "2.22ch"}
/>
@@ -154,7 +171,9 @@ export function TaskItemComponent(
sx={{
bg: "background",
opacity: 0,
- alignSelf: "flex-start",
+ position: "absolute",
+ insetInlineEnd: 0,
+ top: 0,
marginTop: "calc((1lh - 14px) / 2)"
}}
>
diff --git a/packages/editor/src/extensions/task-list/component.tsx b/packages/editor/src/extensions/task-list/component.tsx
index 5264c5c1e..8483079ff 100644
--- a/packages/editor/src/extensions/task-list/component.tsx
+++ b/packages/editor/src/extensions/task-list/component.tsx
@@ -236,12 +236,12 @@ export function TaskListComponent(
if (readonly) e.preventDefault();
}}
sx={{
- ul: {
+ "ul.tasklist-content-wrapper": {
display: "block",
paddingInlineStart: 0,
marginBlockStart: isNested ? 10 : 0,
marginBlockEnd: 0,
- marginLeft: isNested ? (editor.isEditable ? -35 : -10) : 0,
+ marginInlineStart: isNested ? (editor.isEditable ? -35 : -10) : 0,
padding: 0
},
li: {
diff --git a/packages/editor/src/extensions/text-direction/__tests__/text-direction.test.ts b/packages/editor/src/extensions/text-direction/__tests__/text-direction.test.ts
new file mode 100644
index 000000000..d0de5a1b0
--- /dev/null
+++ b/packages/editor/src/extensions/text-direction/__tests__/text-direction.test.ts
@@ -0,0 +1,96 @@
+/*
+This file is part of the Notesnook project (https://notesnook.com/)
+
+Copyright (C) 2023 Streetwriters (Private) Limited
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+import { describe, expect, test } from "vitest";
+import { createEditor } from "../../../../test-utils/index.js";
+import { TaskListNode } from "../../task-list/task-list.js";
+import { TaskItemNode } from "../../task-item/task-item.js";
+import { BulletList } from "../../bullet-list/bullet-list.js";
+import { ListItem } from "../../list-item/list-item.js";
+import { Paragraph } from "../../paragraph/paragraph.js";
+import { TextDirection } from "../text-direction.js";
+
+function directions(editor: {
+ state: { doc: { descendants: (fn: (node: any) => void) => void } };
+}) {
+ const found: Record = {};
+ editor.state.doc.descendants((node) => {
+ const dir = node.attrs.textDirection;
+ if (dir !== undefined) (found[node.type.name] ??= []).push(dir);
+ });
+ return found;
+}
+
+/** cursor into the first paragraph of the document */
+function cursorInFirstParagraph(editor: any) {
+ let pos = -1;
+ editor.state.doc.descendants((node: any, at: number) => {
+ if (pos === -1 && node.type.name === "paragraph") pos = at + 1;
+ });
+ editor.commands.setTextSelection(pos);
+}
+
+describe("text direction on lists", () => {
+ const cases = [
+ {
+ name: "task list",
+ extensions: {
+ taskList: TaskListNode,
+ taskListItem: TaskItemNode.configure({ nested: true }),
+ paragraph: Paragraph
+ },
+ content: ``
+ },
+ {
+ name: "bullet list",
+ extensions: {
+ bulletList: BulletList,
+ listItem: ListItem,
+ paragraph: Paragraph
+ },
+ content: ``
+ }
+ ];
+
+ for (const { name, extensions, content } of cases) {
+ test(`switching a ${name} to ltr clears the direction of every item`, () => {
+ const { editor } = createEditor({
+ initialContent: content,
+ extensions: {
+ ...extensions,
+ textDirection: TextDirection.configure({
+ types: ["paragraph", "taskList", "bulletList"]
+ })
+ }
+ });
+
+ // every paragraph starts rtl, matching the list
+ const before = directions(editor);
+ expect(Object.values(before).flat()).toContain("rtl");
+
+ cursorInFirstParagraph(editor);
+ editor.commands.setTextDirection(undefined);
+
+ // ...and nothing is left rtl — not the list, not any item, cursor or
+ // not, so the checkboxes/markers and the text no longer disagree
+ const after = directions(editor);
+ expect(Object.values(after).flat()).not.toContain("rtl");
+ });
+ }
+});
diff --git a/packages/editor/src/extensions/text-direction/text-direction.ts b/packages/editor/src/extensions/text-direction/text-direction.ts
index bbc67a1eb..2fda1f74d 100644
--- a/packages/editor/src/extensions/text-direction/text-direction.ts
+++ b/packages/editor/src/extensions/text-direction/text-direction.ts
@@ -114,10 +114,40 @@ export const TextDirection = Extension.create({
return {
setTextDirection:
(direction) =>
- ({ commands }) => {
- return this.options.types.every((type) =>
- commands.updateAttributes(type, { textDirection: direction })
- );
+ ({ state, tr, dispatch }) => {
+ const value = direction || "";
+ const { $from, from, to } = state.selection;
+
+ // Expand to the outermost block that carries a direction, so a
+ // whole task list turns together — every item's paragraph and
+ // all — instead of only the row the cursor is in. Otherwise the
+ // list's own direction flips while its items keep theirs, and the
+ // checkboxes and text end up on opposite sides.
+ let start = from;
+ let end = to;
+ for (let depth = $from.depth; depth > 0; depth--) {
+ if (this.options.types.includes($from.node(depth).type.name)) {
+ start = Math.min(start, $from.before(depth));
+ end = Math.max(end, $from.after(depth));
+ }
+ }
+
+ let changed = false;
+ state.doc.nodesBetween(start, end, (node, pos) => {
+ if (
+ !this.options.types.includes(node.type.name) ||
+ node.attrs.textDirection === value
+ )
+ return;
+ tr.setNodeMarkup(pos, undefined, {
+ ...node.attrs,
+ textDirection: value
+ });
+ changed = true;
+ });
+
+ if (changed) dispatch?.(tr);
+ return changed;
}
};
}
diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts
index 0c04a44e8..18f720993 100644
--- a/packages/editor/src/index.ts
+++ b/packages/editor/src/index.ts
@@ -63,6 +63,7 @@ import { SearchReplace } from "./extensions/search-replace/index.js";
import { Table } from "./extensions/table/index.js";
import TableCell from "./extensions/table-cell/index.js";
import { TaskItemNode } from "./extensions/task-item/index.js";
+import { DragDrop } from "./extensions/drag-drop/index.js";
import { TaskListNode } from "./extensions/task-list/index.js";
import TextDirection from "./extensions/text-direction/index.js";
import { WebClipNode, WebClipOptions } from "./extensions/web-clip/index.js";
@@ -283,6 +284,7 @@ const useTiptap = (
OrderedList.configure({ keepMarks: true, keepAttributes: true }),
TaskItemNode.configure({ nested: true }),
TaskListNode,
+ DragDrop,
Link.extend({
inclusive: true
}).configure({
diff --git a/packages/editor/styles/styles.css b/packages/editor/styles/styles.css
index b59a9488b..7a68fbfbd 100644
--- a/packages/editor/styles/styles.css
+++ b/packages/editor/styles/styles.css
@@ -1035,3 +1035,63 @@ del.diffdel {
.scroll-bar::-webkit-scrollbar-thumb:active {
background-color: var(--border);
}
+
+/* Drag & drop (see extensions/drag-drop) */
+.ProseMirror .drop-gap {
+ list-style-type: none;
+ box-sizing: border-box;
+ overflow: hidden;
+ margin-block: 8px;
+ border-radius: 5px;
+ background-color: var(--background-secondary);
+ border: 1px dashed var(--border);
+ transition: height 120ms ease-out, margin-inline-start 120ms ease-out;
+}
+
+.drag-preview {
+ position: fixed;
+ top: 0;
+ z-index: 9999;
+ margin: 0;
+ padding-block: 6px;
+ padding-inline: 8px;
+ box-sizing: border-box;
+ pointer-events: none;
+ user-select: none;
+ border-radius: 5px;
+ background-color: var(--background);
+ box-shadow: 0px 2px 10px 0px rgba(0, 0, 0, 0.2);
+ transition: left 120ms ease-out, width 120ms ease-out;
+}
+
+/* the item is styled by the list it was taken out of, so the preview has to
+ say what a task item looks like on its own */
+.drag-preview ul {
+ margin: 0;
+ padding: 0;
+}
+
+.drag-preview li {
+ display: flex;
+ list-style-type: none;
+ margin: 0;
+}
+
+/* how many children are coming along with the item */
+.drag-preview-badge {
+ align-self: center;
+ flex-shrink: 0;
+ margin-inline-start: 4px;
+ padding: 0px 6px;
+ border-radius: 100px;
+ font-size: 0.8em;
+ background-color: var(--background-secondary);
+ color: var(--paragraph-secondary);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ProseMirror .drop-gap,
+ .drag-preview {
+ transition: none;
+ }
+}