mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-08-29 01:58:51 +02:00
Merge pull request #10137 from streetwriters/fix/task-list-touch-drag
editor: drag & drop task list items with pointer events
This commit is contained in:
512
packages/editor/src/extensions/drag-drop/index.ts
Normal file
512
packages/editor/src/extensions/drag-drop/index.ts
Normal file
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<DropGap | null>("drop-gap");
|
||||
|
||||
function dropGapPlugin() {
|
||||
return new Plugin<DropGap | null>({
|
||||
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<HTMLElement>(`.${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<HTMLElement>("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<HTMLElement>("ul.tasklist-content-wrapper") ||
|
||||
element
|
||||
.closest(".taskList-view-content-wrap")
|
||||
?.querySelector<HTMLElement>("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<HTMLElement>(
|
||||
"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;
|
||||
}
|
||||
@@ -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 && (
|
||||
<Icon
|
||||
className="dragHandle"
|
||||
draggable="true"
|
||||
// NOTE: Turning this off somehow makes drag/drop stop working
|
||||
// properly on touch devices.
|
||||
// contentEditable={false}
|
||||
data-drag-handle
|
||||
// dragging is ours, not the browser's: see extensions/drag-drop
|
||||
onPointerDown={(e) => 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)"
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<string, string[]> = {};
|
||||
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: `<ul class="checklist" dir="rtl"><li class="checklist--item"><p dir="rtl">one</p></li><li class="checklist--item"><p dir="rtl">two</p></li></ul>`
|
||||
},
|
||||
{
|
||||
name: "bullet list",
|
||||
extensions: {
|
||||
bulletList: BulletList,
|
||||
listItem: ListItem,
|
||||
paragraph: Paragraph
|
||||
},
|
||||
content: `<ul dir="rtl"><li><p dir="rtl">one</p></li><li><p dir="rtl">two</p></li></ul>`
|
||||
}
|
||||
];
|
||||
|
||||
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");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -114,10 +114,40 @@ export const TextDirection = Extension.create<TextDirectionOptions>({
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user