From efaffc8729ace7cd4b0e6e7511fedf14694aa8d2 Mon Sep 17 00:00:00 2001 From: Ammar Ahmed Date: Thu, 23 Jul 2026 12:13:26 +0500 Subject: [PATCH 1/7] editor: drag task list items with pointer events --- .../editor/src/extensions/drag-drop/index.ts | 392 ++++++++++++++++++ .../src/extensions/task-item/component.tsx | 15 +- .../src/extensions/task-list/component.tsx | 6 +- packages/editor/src/index.ts | 2 + packages/editor/styles/styles.css | 65 +++ 5 files changed, 474 insertions(+), 6 deletions(-) create mode 100644 packages/editor/src/extensions/drag-drop/index.ts 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..e96df9998 --- /dev/null +++ b/packages/editor/src/extensions/drag-drop/index.ts @@ -0,0 +1,392 @@ +/* +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 = 60; +const NEST_INDENT = 24; +// distance from the edge of the scroller at which auto scrolling starts +const SCROLL_ZONE = 60; +const SCROLL_SPEED = 12; + +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) { + 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; + 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(); + if (event.pointerType === "mouse") 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 { preview, row } = createPreview(view, item, box); + drag = { + item, + pos, + end: pos + node.nodeSize, + offsetY: box.top - event.clientY, + height: row.getBoundingClientRect().height, + startX: event.clientX, + 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)`; + + const target = findGap(view, state, e.clientX, top); + if (target) setGap(view, (state.gap = 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); + handle.removeEventListener("pointermove", move); + handle.removeEventListener("pointerup", end); + handle.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; + }; + + handle.setPointerCapture?.(event.pointerId); + handle.addEventListener("pointermove", move, { passive: false }); + handle.addEventListener("pointerup", end); + handle.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 style = getComputedStyle(item); + const preview = document.createElement("div"); + preview.className = `drag-preview ${view.dom.className}`; + preview.style.left = `${box.left}px`; + preview.style.width = `${box.width}px`; + preview.style.font = style.font; + preview.style.color = style.color; + + const list = (item.parentElement ?? document.createElement("ul")).cloneNode( + false + ) as HTMLElement; + list.style.margin = list.style.padding = "0"; + preview.appendChild(list); + + const clone = item.cloneNode(true) as HTMLElement; + clone.style.margin = "0"; + 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); + + 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. 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. + */ +function findGap(view: EditorView, drag: Drag, x: number, top: number) { + const element = document.elementFromPoint( + Math.max(x, view.dom.getBoundingClientRect().left + 1), + top + ); + const list = element?.closest("ul"); + if (!list || !view.dom.contains(list)) return drag.gap ?? 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 || (closest > drag.pos && closest < drag.end)) + return drag.gap ?? null; + + const nest = x - drag.startX > NEST_THRESHOLD && canNest(view, closest, drag); + return { pos: closest, height: drag.height, indent: nest ? NEST_INDENT : 0 }; +} + +/** 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 item = view.state.doc.nodeAt(from); + if (!item) return null; + + const tr = view.state.tr.deleteRange(from, from + item.nodeSize); + const at = tr.mapping.map(to); + const deleted = tr.doc; + + tr.replaceRangeWith(at, at, item); + if (tr.doc.eq(deleted)) return null; + if (tr.doc.eq(view.state.doc)) return at; + + 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) { + const { overflowY } = getComputedStyle(node); + if (/auto|scroll/.test(overflowY) && node.scrollHeight > node.clientHeight) + 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..d8592a0b7 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"; @@ -80,11 +81,9 @@ export function TaskItemComponent( {editor.isEditable && ( startItemDrag(editor, getPos, e.nativeEvent)} path={Icons.dragHandle} sx={{ opacity: [1, 1, 0], @@ -93,7 +92,13 @@ 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", + WebkitTouchCallout: "none", + svg: { pointerEvents: "none" } }} size={isMobile ? "2.46ch" : "2.22ch"} /> diff --git a/packages/editor/src/extensions/task-list/component.tsx b/packages/editor/src/extensions/task-list/component.tsx index 5264c5c1e..82a064640 100644 --- a/packages/editor/src/extensions/task-list/component.tsx +++ b/packages/editor/src/extensions/task-list/component.tsx @@ -236,7 +236,11 @@ export function TaskListComponent( if (readonly) e.preventDefault(); }} sx={{ - ul: { + // NOTE: the class is part of the selector to win over + // `.ProseMirror ul ul` (styles.css), which is more specific than + // a plain `ul` and squashed a nested list against the item it + // belongs to + "ul.tasklist-content-wrapper": { display: "block", paddingInlineStart: 0, marginBlockStart: isNested ? 10 : 0, 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..13af9e32b 100644 --- a/packages/editor/styles/styles.css +++ b/packages/editor/styles/styles.css @@ -1035,3 +1035,68 @@ 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); + /* the gap opens up rather than popping into place, and follows the level + it will drop at */ + transition: height 120ms ease-out, margin-inline-start 120ms ease-out; +} + +.drag-preview { + position: fixed; + top: 0; + z-index: 9999; + margin: 0; + /* nothing on the left: the item has to sit exactly where it will land. + The right is only ever the count's breathing room */ + padding: 6px 8px 6px 0; + 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); + /* it follows the level it will drop at; the pointer is followed with a + transform, which this must not touch */ + 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; + } +} From 1070831b63cf6b4c92155ec6c4785aae1dfa6d85 Mon Sep 17 00:00:00 2001 From: Ammar Ahmed Date: Wed, 29 Jul 2026 11:36:30 +0500 Subject: [PATCH 2/7] editor: only drop a task item into a task list & fix crash --- .../editor/src/extensions/drag-drop/index.ts | 68 ++++++++++++++----- .../src/extensions/task-item/component.tsx | 6 +- .../src/extensions/task-list/component.tsx | 5 +- 3 files changed, 59 insertions(+), 20 deletions(-) diff --git a/packages/editor/src/extensions/drag-drop/index.ts b/packages/editor/src/extensions/drag-drop/index.ts index e96df9998..d601509dc 100644 --- a/packages/editor/src/extensions/drag-drop/index.ts +++ b/packages/editor/src/extensions/drag-drop/index.ts @@ -79,6 +79,7 @@ function dropGapPlugin() { } 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"; @@ -182,8 +183,13 @@ export function startItemDrag( 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, top); - if (target) setGap(view, (state.gap = target)); + if (target !== undefined) { + state.gap = target ?? undefined; + setGap(view, target); + } fitPreview(view, state); autoScroll(state, e.clientY); }; @@ -259,18 +265,29 @@ function createPreview(view: EditorView, item: HTMLElement, box: DOMRect) { /** * Where the item would land: the sibling top edge nearest to the top edge - * of the item being dragged. 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. + * 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. */ -function findGap(view: EditorView, drag: Drag, x: number, top: number) { +function findGap( + view: EditorView, + drag: Drag, + x: number, + top: number +): DropGap | null | undefined { const element = document.elementFromPoint( Math.max(x, view.dom.getBoundingClientRect().left + 1), top ); - const list = element?.closest("ul"); - if (!list || !view.dom.contains(list)) return drag.gap ?? null; + if (!element || !view.dom.contains(element)) return undefined; + + const list = element.closest("ul.tasklist-content-wrapper"); + if (!list || !view.dom.contains(list)) return null; let closest: number | null = null; let distance = Infinity; @@ -295,8 +312,9 @@ function findGap(view: EditorView, drag: Drag, x: number, top: number) { if (child === children.at(-1)) consider(box.bottom, pos.after); } - if (closest === null || (closest > drag.pos && closest < drag.end)) - return drag.gap ?? null; + 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 nest = x - drag.startX > NEST_THRESHOLD && canNest(view, closest, drag); return { pos: closest, height: drag.height, indent: nest ? NEST_INDENT : 0 }; @@ -340,18 +358,34 @@ function canNest(view: EditorView, pos: number, drag: Drag) { /** moves the item at `from` to `to`, returning where it ended up */ function moveItem(view: EditorView, from: number, to: number) { - const item = view.state.doc.nodeAt(from); + const { state } = view; + const item = state.doc.nodeAt(from); if (!item) return null; - const tr = view.state.tr.deleteRange(from, from + item.nodeSize); - const at = tr.mapping.map(to); + // 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); const deleted = tr.doc; - tr.replaceRangeWith(at, at, item); - if (tr.doc.eq(deleted)) return null; - if (tr.doc.eq(view.state.doc)) return at; + // 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; + + tr.replaceRangeWith(at, at, item); + // nowhere it fits, or already exactly there + if (tr.doc.eq(deleted)) return null; + if (tr.doc.eq(state.doc)) return at; + + // 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)); // prettier-ignore - tr.setSelection(NodeSelection.create(tr.doc, at)); view.dispatch(tr.setMeta("uiEvent", "drop")); return at; } diff --git a/packages/editor/src/extensions/task-item/component.tsx b/packages/editor/src/extensions/task-item/component.tsx index d8592a0b7..9af284d5f 100644 --- a/packages/editor/src/extensions/task-item/component.tsx +++ b/packages/editor/src/extensions/task-item/component.tsx @@ -74,7 +74,7 @@ export function TaskItemComponent( style={{ flexDirection: "row", alignItems: "center", - maxWidth: "95%", + maxWidth: "100%", flexGrow: 1 }} > @@ -159,7 +159,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 82a064640..272e693da 100644 --- a/packages/editor/src/extensions/task-list/component.tsx +++ b/packages/editor/src/extensions/task-list/component.tsx @@ -245,7 +245,10 @@ export function TaskListComponent( paddingInlineStart: 0, marginBlockStart: isNested ? 10 : 0, marginBlockEnd: 0, - marginLeft: isNested ? (editor.isEditable ? -35 : -10) : 0, + // NOTE: inline-start, not left: the indent has to be on the + // side the text flows from, so it stays on the right in RTL + // instead of showing up on the wrong side + marginInlineStart: isNested ? (editor.isEditable ? -35 : -10) : 0, padding: 0 }, li: { From 79086108a57b7d8f90d9710eccaf2b0ce1cdbf79 Mon Sep 17 00:00:00 2001 From: Ammar Ahmed Date: Wed, 29 Jul 2026 19:11:39 +0500 Subject: [PATCH 3/7] editor: drop placeholder on first item on task list should not hide when moving the dragged item above it or above the task list header. it should safely drop as the first list item. - Improve drag/drop reliability be increasing the hit slop area for starting the drag. - Ensure drag survives between rerenders - Add a solid background to dragged item so it doesn't conflict with items underneath it --- .../editor/src/extensions/drag-drop/index.ts | 59 +++++++++++++++---- .../src/extensions/task-item/component.tsx | 14 ++++- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/packages/editor/src/extensions/drag-drop/index.ts b/packages/editor/src/extensions/drag-drop/index.ts index d601509dc..4d3d4d6e7 100644 --- a/packages/editor/src/extensions/drag-drop/index.ts +++ b/packages/editor/src/extensions/drag-drop/index.ts @@ -133,7 +133,10 @@ export function startItemDrag( if (!editor.isEditable || event.button !== 0 || !item) return; event.stopPropagation(); - if (event.pointerType === "mouse") event.preventDefault(); + // 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; @@ -185,7 +188,7 @@ export function startItemDrag( // 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, top); + const target = findGap(view, state, e.clientX, e.clientY, top); if (target !== undefined) { state.gap = target ?? undefined; setGap(view, target); @@ -205,9 +208,12 @@ export function startItemDrag( const cleanup = () => { clearTimeout(hold); - handle.removeEventListener("pointermove", move); - handle.removeEventListener("pointerup", end); - handle.removeEventListener("pointercancel", cleanup); + // 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(); @@ -217,14 +223,26 @@ export function startItemDrag( drag = undefined; }; - handle.setPointerCapture?.(event.pointerId); - handle.addEventListener("pointermove", move, { passive: false }); - handle.addEventListener("pointerup", end); - handle.addEventListener("pointercancel", cleanup); + 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; } +/** the nearest background colour that is not see-through */ +function opaqueBackground(element: HTMLElement) { + for ( + let node: HTMLElement | null = element; + node; + node = node.parentElement + ) { + const bg = getComputedStyle(node).backgroundColor; + if (bg && bg !== "transparent" && !bg.startsWith("rgba(0, 0, 0, 0")) return bg; // prettier-ignore + } + return "var(--background, #fff)"; +} + /** * A copy of the item that follows the pointer, with its nested items left * out so that tall items stay easy to place. @@ -237,6 +255,12 @@ function createPreview(view: EditorView, item: HTMLElement, box: DOMRect) { preview.style.width = `${box.width}px`; preview.style.font = style.font; preview.style.color = style.color; + // the preview lives on `document.body`, where the editor's theme + // variables are not defined, so the CSS `var(--background)` resolves to + // transparent — a WebKit issue in particular. Read a concrete colour + // while the item is still in the editor, or the checkboxes below show + // through it. + preview.style.backgroundColor = opaqueBackground(item); const list = (item.parentElement ?? document.createElement("ul")).cloneNode( false @@ -273,20 +297,33 @@ function createPreview(view: EditorView, item: HTMLElement, box: DOMRect) { * 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 element = document.elementFromPoint( Math.max(x, view.dom.getBoundingClientRect().left + 1), - top + pointerY ); if (!element || !view.dom.contains(element)) return undefined; - const list = element.closest("ul.tasklist-content-wrapper"); + // the list under the pointer, or — when the pointer is on a list's header + // (the tools bar sits above the first item, outside the `ul`) — that + // list, so the item can still be dropped into its first slot + const list = + element.closest("ul.tasklist-content-wrapper") || + element + .closest(".taskList-view-content-wrap") + ?.querySelector("ul.tasklist-content-wrapper"); if (!list || !view.dom.contains(list)) return null; let closest: number | null = null; diff --git a/packages/editor/src/extensions/task-item/component.tsx b/packages/editor/src/extensions/task-item/component.tsx index 9af284d5f..17a19cccf 100644 --- a/packages/editor/src/extensions/task-item/component.tsx +++ b/packages/editor/src/extensions/task-item/component.tsx @@ -97,8 +97,20 @@ export function TaskItemComponent( // selection or the long press callout touchAction: "none", userSelect: "none", + WebkitUserSelect: "none", WebkitTouchCallout: "none", - svg: { pointerEvents: "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"} /> From 8bc072a5439581b1c90f9515c572660f87df7c53 Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Fri, 31 Jul 2026 12:25:08 +0500 Subject: [PATCH 4/7] editor: remove unnecessary comments Co-authored-by: Abdullah Atta Signed-off-by: Abdullah Atta --- packages/editor/src/extensions/task-list/component.tsx | 7 ------- packages/editor/styles/styles.css | 4 ---- 2 files changed, 11 deletions(-) diff --git a/packages/editor/src/extensions/task-list/component.tsx b/packages/editor/src/extensions/task-list/component.tsx index 272e693da..8483079ff 100644 --- a/packages/editor/src/extensions/task-list/component.tsx +++ b/packages/editor/src/extensions/task-list/component.tsx @@ -236,18 +236,11 @@ export function TaskListComponent( if (readonly) e.preventDefault(); }} sx={{ - // NOTE: the class is part of the selector to win over - // `.ProseMirror ul ul` (styles.css), which is more specific than - // a plain `ul` and squashed a nested list against the item it - // belongs to "ul.tasklist-content-wrapper": { display: "block", paddingInlineStart: 0, marginBlockStart: isNested ? 10 : 0, marginBlockEnd: 0, - // NOTE: inline-start, not left: the indent has to be on the - // side the text flows from, so it stays on the right in RTL - // instead of showing up on the wrong side marginInlineStart: isNested ? (editor.isEditable ? -35 : -10) : 0, padding: 0 }, diff --git a/packages/editor/styles/styles.css b/packages/editor/styles/styles.css index 13af9e32b..3dd6ea3ff 100644 --- a/packages/editor/styles/styles.css +++ b/packages/editor/styles/styles.css @@ -1045,8 +1045,6 @@ del.diffdel { border-radius: 5px; background-color: var(--background-secondary); border: 1px dashed var(--border); - /* the gap opens up rather than popping into place, and follows the level - it will drop at */ transition: height 120ms ease-out, margin-inline-start 120ms ease-out; } @@ -1064,8 +1062,6 @@ del.diffdel { border-radius: 5px; background-color: var(--background); box-shadow: 0px 2px 10px 0px rgba(0, 0, 0, 0.2); - /* it follows the level it will drop at; the pointer is followed with a - transform, which this must not touch */ transition: left 120ms ease-out, width 120ms ease-out; } From e326b912fd065a1d03bf86bafdf997a8e1c82c1c Mon Sep 17 00:00:00 2001 From: Ammar Ahmed Date: Mon, 3 Aug 2026 10:28:31 +0500 Subject: [PATCH 5/7] editor: RTL nesting, cheaper move and scroller lookup --- .../editor/src/extensions/drag-drop/index.ts | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/editor/src/extensions/drag-drop/index.ts b/packages/editor/src/extensions/drag-drop/index.ts index 4d3d4d6e7..cb3d56ba2 100644 --- a/packages/editor/src/extensions/drag-drop/index.ts +++ b/packages/editor/src/extensions/drag-drop/index.ts @@ -112,6 +112,8 @@ type Drag = { /** 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 */ @@ -164,6 +166,7 @@ export function startItemDrag( offsetY: box.top - event.clientY, height: row.getBoundingClientRect().height, startX: event.clientX, + rtl: getComputedStyle(item).direction === "rtl", preview, previewWidth: box.width, scroller: getScroller(view.dom) @@ -353,7 +356,8 @@ function findGap( // dropping the item into itself is a no-op: leave the gap alone if (closest > drag.pos && closest < drag.end) return undefined; - const nest = x - drag.startX > NEST_THRESHOLD && canNest(view, closest, drag); + 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 }; } @@ -399,13 +403,14 @@ function moveItem(view: EditorView, from: number, to: number) { 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); - const deleted = tr.doc; // the target is always a task list, but guard anyway: dropping the item // where it does not fit would put it somewhere unexpected @@ -413,15 +418,17 @@ function moveItem(view: EditorView, from: number, to: number) { if (!$at.parent.canReplaceWith($at.index(), $at.index(), item.type)) return null; + const steps = tr.steps.length; tr.replaceRangeWith(at, at, item); - // nowhere it fits, or already exactly there - if (tr.doc.eq(deleted)) return null; - if (tr.doc.eq(state.doc)) return at; + + 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)); // prettier-ignore + if (node?.type === item.type) { + tr.setSelection(NodeSelection.create(tr.doc, at)); + } view.dispatch(tr.setMeta("uiEvent", "drop")); return at; @@ -455,9 +462,8 @@ function autoScroll(drag: Drag, y: number) { function getScroller(element: HTMLElement): HTMLElement | null { for (let node = element.parentElement; node; node = node.parentElement) { - const { overflowY } = getComputedStyle(node); - if (/auto|scroll/.test(overflowY) && node.scrollHeight > node.clientHeight) - return node; + if (node.scrollHeight <= node.clientHeight) continue; + if (/auto|scroll/.test(getComputedStyle(node).overflowY)) return node; } return null; } From 01da7cbb198907c2fe3c4faa5dada392c23f323d Mon Sep 17 00:00:00 2001 From: Ammar Ahmed Date: Mon, 3 Aug 2026 12:26:27 +0500 Subject: [PATCH 6/7] editor: render the drag preview in the editor's own context, 1:1 --- .../editor/src/extensions/drag-drop/index.ts | 125 ++++++++++++------ packages/editor/styles/styles.css | 5 +- 2 files changed, 86 insertions(+), 44 deletions(-) diff --git a/packages/editor/src/extensions/drag-drop/index.ts b/packages/editor/src/extensions/drag-drop/index.ts index cb3d56ba2..cb6213834 100644 --- a/packages/editor/src/extensions/drag-drop/index.ts +++ b/packages/editor/src/extensions/drag-drop/index.ts @@ -43,11 +43,13 @@ 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 = 60; +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"); @@ -158,7 +160,10 @@ export function startItemDrag( 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, @@ -166,7 +171,7 @@ export function startItemDrag( offsetY: box.top - event.clientY, height: row.getBoundingClientRect().height, startX: event.clientX, - rtl: getComputedStyle(item).direction === "rtl", + rtl, preview, previewWidth: box.width, scroller: getScroller(view.dom) @@ -233,46 +238,34 @@ export function startItemDrag( hold = setTimeout(start, HOLD_DELAY) as unknown as number; } -/** the nearest background colour that is not see-through */ -function opaqueBackground(element: HTMLElement) { - for ( - let node: HTMLElement | null = element; - node; - node = node.parentElement - ) { - const bg = getComputedStyle(node).backgroundColor; - if (bg && bg !== "transparent" && !bg.startsWith("rgba(0, 0, 0, 0")) return bg; // prettier-ignore - } - return "var(--background, #fff)"; -} - /** * 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 style = getComputedStyle(item); const preview = document.createElement("div"); - preview.className = `drag-preview ${view.dom.className}`; + preview.className = "drag-preview"; preview.style.left = `${box.left}px`; preview.style.width = `${box.width}px`; - preview.style.font = style.font; - preview.style.color = style.color; - // the preview lives on `document.body`, where the editor's theme - // variables are not defined, so the CSS `var(--background)` resolves to - // transparent — a WebKit issue in particular. Read a concrete colour - // while the item is still in the editor, or the checkboxes below show - // through it. - preview.style.backgroundColor = opaqueBackground(item); + + 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"; - preview.appendChild(list); + 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; @@ -286,7 +279,7 @@ function createPreview(view: EditorView, item: HTMLElement, box: DOMRect) { } list.appendChild(clone); - document.body.appendChild(preview); + (view.dom.parentElement ?? document.body).appendChild(preview); return { preview, row: clone }; } @@ -313,21 +306,24 @@ function findGap( pointerY: number, top: number ): DropGap | null | undefined { - const element = document.elementFromPoint( - Math.max(x, view.dom.getBoundingClientRect().left + 1), - pointerY - ); - if (!element || !view.dom.contains(element)) return undefined; + const hx = Math.max(x, view.dom.getBoundingClientRect().left + 1); - // the list under the pointer, or — when the pointer is on a list's header - // (the tools bar sits above the first item, outside the `ul`) — that - // list, so the item can still be dropped into its first slot - const list = - element.closest("ul.tasklist-content-wrapper") || - element - .closest(".taskList-view-content-wrap") - ?.querySelector("ul.tasklist-content-wrapper"); - if (!list || !view.dom.contains(list)) return null; + // 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; @@ -361,6 +357,53 @@ function findGap( 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 diff --git a/packages/editor/styles/styles.css b/packages/editor/styles/styles.css index 3dd6ea3ff..7a68fbfbd 100644 --- a/packages/editor/styles/styles.css +++ b/packages/editor/styles/styles.css @@ -1053,9 +1053,8 @@ del.diffdel { top: 0; z-index: 9999; margin: 0; - /* nothing on the left: the item has to sit exactly where it will land. - The right is only ever the count's breathing room */ - padding: 6px 8px 6px 0; + padding-block: 6px; + padding-inline: 8px; box-sizing: border-box; pointer-events: none; user-select: none; From 08bb2163340e77ab2ffe3a498c032b441a0e0e63 Mon Sep 17 00:00:00 2001 From: Ammar Ahmed Date: Sun, 9 Aug 2026 18:00:53 +0500 Subject: [PATCH 7/7] editor: apply text direction to a whole list, not just the cursor's item --- .../__tests__/text-direction.test.ts | 96 +++++++++++++++++++ .../text-direction/text-direction.ts | 38 +++++++- 2 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 packages/editor/src/extensions/text-direction/__tests__/text-direction.test.ts 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: `
  • one

  • two

` + }, + { + name: "bullet list", + extensions: { + bulletList: BulletList, + listItem: ListItem, + paragraph: Paragraph + }, + content: `
  • one

  • two

` + } + ]; + + 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; } }; }