|
|
|
@@ -14,6 +14,13 @@ import Icon from "./Icon.vue";
|
|
|
|
|
import LinkPreview from "./LinkPreview.vue";
|
|
|
|
|
import MarkdownText from "./MarkdownText.vue";
|
|
|
|
|
import NoteChecklist from "./NoteChecklist.vue";
|
|
|
|
|
import {
|
|
|
|
|
cardIdAt,
|
|
|
|
|
draggingId,
|
|
|
|
|
DRAG_THRESHOLD_PX,
|
|
|
|
|
endCardDrag,
|
|
|
|
|
overId,
|
|
|
|
|
} from "../composables/useCardDrag";
|
|
|
|
|
import { formatReminder, formatTrashCountdown, isOverdue, trashDaysLeft } from "../notes/datetime";
|
|
|
|
|
import { useConfigStore } from "../stores/config";
|
|
|
|
|
|
|
|
|
@@ -22,7 +29,9 @@ const emit = defineEmits<{
|
|
|
|
|
(e: "open", note: Note): void;
|
|
|
|
|
(e: "dragstart", note: Note): void;
|
|
|
|
|
(e: "dragend", note: Note): void;
|
|
|
|
|
(e: "drop", note: Note): void;
|
|
|
|
|
/** The card this one was dropped ON, by id — the dragged card hit-tests the DOM
|
|
|
|
|
* for it, so an id is all it can know without looking the note up again. */
|
|
|
|
|
(e: "drop", targetId: string): void;
|
|
|
|
|
}>();
|
|
|
|
|
const notes = useNotesStore();
|
|
|
|
|
const config = useConfigStore();
|
|
|
|
@@ -43,42 +52,71 @@ const otherAttachments = computed(() => props.note.attachments.filter((a) => !a.
|
|
|
|
|
|
|
|
|
|
const root = ref<HTMLElement | null>(null);
|
|
|
|
|
|
|
|
|
|
// --- Drag-to-reorder. Native HTML5 DnD, gated behind an explicit grip handle so
|
|
|
|
|
// a plain click/select never starts a drag by accident. `dragging` dims the
|
|
|
|
|
// source card; `dragOver` shows where the drop will land. ---
|
|
|
|
|
// --- Drag-to-reorder. Pointer Events, gated behind an explicit grip handle so a
|
|
|
|
|
// plain tap/click/select never starts a drag by accident. `dragging` dims the
|
|
|
|
|
// source card; `dragOver` shows where the drop will land.
|
|
|
|
|
//
|
|
|
|
|
// Pointer rather than native HTML5 drag-and-drop because that API never fires from
|
|
|
|
|
// touch — on a phone this did nothing at all. One code path now covers mouse, touch
|
|
|
|
|
// and stylus. See composables/useCardDrag.ts for why the state is shared. ---
|
|
|
|
|
const canDrag = () => !!props.reorderable && !props.note.trashed;
|
|
|
|
|
const grabbing = ref(false); // handle pressed → the card is momentarily draggable
|
|
|
|
|
const dragging = ref(false); // this card is the one being dragged
|
|
|
|
|
const dragOver = ref(false); // another card is hovering over this one as a drop target
|
|
|
|
|
const dragging = computed(() => draggingId.value === props.note.id);
|
|
|
|
|
const dragOver = computed(() => overId.value === props.note.id);
|
|
|
|
|
|
|
|
|
|
function onDragStart(e: DragEvent) {
|
|
|
|
|
dragging.value = true;
|
|
|
|
|
if (e.dataTransfer) {
|
|
|
|
|
e.dataTransfer.effectAllowed = "move";
|
|
|
|
|
e.dataTransfer.setData("text/plain", props.note.id); // some browsers need a payload to drag
|
|
|
|
|
/** The pointer that owns the current gesture; null when no press is in flight. */
|
|
|
|
|
let activePointer: number | null = null;
|
|
|
|
|
let startX = 0;
|
|
|
|
|
let startY = 0;
|
|
|
|
|
/** A press becomes a drag only after the threshold — below it, it's still a tap. */
|
|
|
|
|
let moved = false;
|
|
|
|
|
|
|
|
|
|
function onGripDown(e: PointerEvent) {
|
|
|
|
|
if (!canDrag()) return;
|
|
|
|
|
activePointer = e.pointerId;
|
|
|
|
|
startX = e.clientX;
|
|
|
|
|
startY = e.clientY;
|
|
|
|
|
moved = false;
|
|
|
|
|
// Capture so the gesture keeps reporting to this grip even once the finger has
|
|
|
|
|
// travelled onto another card — without it the stream stops at the first
|
|
|
|
|
// boundary crossing.
|
|
|
|
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
|
|
|
// Claims the gesture from the browser's own scroll/text-selection handling.
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onGripMove(e: PointerEvent) {
|
|
|
|
|
if (activePointer !== e.pointerId) return;
|
|
|
|
|
if (!moved) {
|
|
|
|
|
if (Math.hypot(e.clientX - startX, e.clientY - startY) < DRAG_THRESHOLD_PX) return;
|
|
|
|
|
moved = true;
|
|
|
|
|
draggingId.value = props.note.id;
|
|
|
|
|
emit("dragstart", props.note);
|
|
|
|
|
}
|
|
|
|
|
emit("dragstart", props.note);
|
|
|
|
|
overId.value = cardIdAt(e.clientX, e.clientY, props.note.id);
|
|
|
|
|
}
|
|
|
|
|
function onDragEnd() {
|
|
|
|
|
dragging.value = false;
|
|
|
|
|
grabbing.value = false;
|
|
|
|
|
dragOver.value = false;
|
|
|
|
|
emit("dragend", props.note);
|
|
|
|
|
|
|
|
|
|
function onGripUp(e: PointerEvent) {
|
|
|
|
|
if (activePointer !== e.pointerId) return;
|
|
|
|
|
const target = moved ? overId.value : null;
|
|
|
|
|
const wasDragging = moved;
|
|
|
|
|
activePointer = null;
|
|
|
|
|
moved = false;
|
|
|
|
|
endCardDrag();
|
|
|
|
|
if (target) emit("drop", target);
|
|
|
|
|
// Always paired with dragstart, dropped or not, so the parent can clear its own
|
|
|
|
|
// tracked source rather than leaking it into the next gesture.
|
|
|
|
|
if (wasDragging) emit("dragend", props.note);
|
|
|
|
|
}
|
|
|
|
|
function onDragOver(e: DragEvent) {
|
|
|
|
|
e.preventDefault(); // allow drop
|
|
|
|
|
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
|
|
|
|
if (!dragging.value) dragOver.value = true; // don't flag the source as its own target
|
|
|
|
|
}
|
|
|
|
|
function onDragLeave(e: DragEvent) {
|
|
|
|
|
// dragleave also fires when moving onto a child; only clear when truly leaving the card.
|
|
|
|
|
const to = e.relatedTarget as Node | null;
|
|
|
|
|
if (to && root.value?.contains(to)) return;
|
|
|
|
|
dragOver.value = false;
|
|
|
|
|
}
|
|
|
|
|
function onDrop() {
|
|
|
|
|
dragOver.value = false;
|
|
|
|
|
emit("drop", props.note);
|
|
|
|
|
|
|
|
|
|
// Fires when the system takes the gesture away (an incoming call, a browser-level
|
|
|
|
|
// scroll taking over). Treated as a cancel: no reorder, no half-set state left.
|
|
|
|
|
function onGripCancel(e: PointerEvent) {
|
|
|
|
|
if (activePointer !== e.pointerId) return;
|
|
|
|
|
const wasDragging = moved;
|
|
|
|
|
activePointer = null;
|
|
|
|
|
moved = false;
|
|
|
|
|
endCardDrag();
|
|
|
|
|
if (wasDragging) emit("dragend", props.note);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// When this card becomes the keyboard-focused card, scroll it into view.
|
|
|
|
@@ -132,25 +170,25 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
|
|
|
|
: '',
|
|
|
|
|
active ? 'ring-2 ring-brand' : '',
|
|
|
|
|
]"
|
|
|
|
|
:draggable="canDrag() && grabbing"
|
|
|
|
|
@dragstart="onDragStart"
|
|
|
|
|
@dragend="onDragEnd"
|
|
|
|
|
@dragover="onDragOver"
|
|
|
|
|
@dragleave="onDragLeave"
|
|
|
|
|
@drop="onDrop"
|
|
|
|
|
:data-note-id="note.id"
|
|
|
|
|
>
|
|
|
|
|
<!-- Drag handle: reorder is gated behind this grip so a normal click/select
|
|
|
|
|
never starts a drag. Appears on hover; board views only (reorderable).
|
|
|
|
|
Mouse-only affordance — native DnD has no keyboard equivalent. -->
|
|
|
|
|
<!-- Drag handle: reorder is gated behind this grip so a normal click or tap
|
|
|
|
|
never starts a drag. Board views only (reorderable). Still a pointer-only
|
|
|
|
|
affordance — there is no keyboard equivalent, hence tabindex="-1".
|
|
|
|
|
`touch-none` hands the gesture to us instead of the browser's scrolling,
|
|
|
|
|
and `hover-reveal` keeps the grip on screen where hovering is impossible
|
|
|
|
|
(it is now reachable there, which it was not under native drag-and-drop). -->
|
|
|
|
|
<button
|
|
|
|
|
v-if="canDrag()"
|
|
|
|
|
type="button"
|
|
|
|
|
tabindex="-1"
|
|
|
|
|
class="pointer-events-none absolute left-1.5 top-1.5 z-10 flex cursor-grab items-center rounded-full bg-white/85 p-1 text-neutral-500 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition hover:text-neutral-800 active:cursor-grabbing group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:text-neutral-400 dark:ring-white/10 dark:hover:text-neutral-100"
|
|
|
|
|
class="hover-reveal pointer-events-none absolute left-1.5 top-1.5 z-10 flex touch-none cursor-grab items-center rounded-full bg-white/85 p-1 text-neutral-500 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition hover:text-neutral-800 active:cursor-grabbing group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:text-neutral-400 dark:ring-white/10 dark:hover:text-neutral-100"
|
|
|
|
|
title="Drag to reorder"
|
|
|
|
|
aria-label="Drag to reorder"
|
|
|
|
|
@mousedown="grabbing = true"
|
|
|
|
|
@mouseup="grabbing = false"
|
|
|
|
|
@pointerdown="onGripDown"
|
|
|
|
|
@pointermove="onGripMove"
|
|
|
|
|
@pointerup="onGripUp"
|
|
|
|
|
@pointercancel="onGripCancel"
|
|
|
|
|
>
|
|
|
|
|
<Icon name="grip" />
|
|
|
|
|
</button>
|
|
|
|
|