fix: polish manual ordering drag reorder

This commit is contained in:
callumalpass 2026-03-10 06:23:42 +11:00
parent 9fed004fc7
commit 26712c4f7d
16 changed files with 1223 additions and 189 deletions

View file

@ -47,7 +47,14 @@ Example:
- Fixed docs site link generation so internal Markdown links resolve to route URLs instead of broken `.md` paths (for example `/views/default-base-templates/`)
- Fixed docs release-note links by building all Markdown docs pages, including pages not listed directly in sidebar nav
- Drag-to-reorder now scopes Kanban ordering to the active swimlane, initializes sparse manual ordering using the visible drag order, rebalances oversized sort keys back to compact LexoRank values, and warns before large multi-note reorder writes
- The default manual-order metadata property is now `tasknotes_order`
- The default manual-order metadata property is now `tasknotes_manual_order`, and the setting is labeled "Manual Order" to make its drag-to-reorder role clearer
- Task List drag-to-reorder now blocks formula-group drops and updates list-backed group properties safely during cross-group moves
- Grouped Task List drag previews now keep the landing gap inside the hovered group instead of shifting unrelated groups
- Task List drag-to-reorder now computes persisted placement from the visible list order, reducing off-by-one drops when grouped sections or hidden tasks are involved
- Fixed drag-to-reorder rank generation when a view sorts the manual-order field descending, so drops persist in the same direction the list is displayed
- Grouped Task List drag previews now use a real boundary slot, so later group headers move out of the way instead of being overlapped during drag
- Fixed Task List reorder mode swallowing subtask and dependency toggle clicks by treating interactive task-card controls as no-drag targets, with matching drag/pointer cursor feedback
- Fixed Task List drag-to-reorder resolving against expanded subtask/dependency cards instead of the top-level rows, which could shift drops to the wrong task
- Stabilized Task List drag-to-reorder hit-testing so the preview gap no longer shifts the effective drop boundary while you drag
- Grouped Task List drag-to-reorder now resolves a Kanban-style insertion slot per group, so the previewed slot and the persisted drop stay aligned across group headers
- Generated default Kanban, Relationships, and Tasks `.base` files now surface manual ordering by default where the view is task-focused, including a new grouped "Manual Order" task-list view
- Thanks to @ac8318740 for the original drag-to-reorder groundwork in PR #1619

View file

@ -226,6 +226,9 @@ views:
- recurrence
- complete_instances
- file.tasks
sort:
- column: tasknotes_manual_order
direction: DESC
groupBy:
property: status
direction: ASC
@ -238,7 +241,7 @@ views:
Used by the **Tasks** command to display filtered task views.
This template includes multiple views: All Tasks, Not Blocked, Today, Overdue, This Week, and Unscheduled. Each view (except All Tasks) filters for incomplete tasks, handling both recurring and non-recurring tasks. For recurring tasks, the generated filters treat a missing `complete_instances` property as "not completed today" so newly created recurring tasks still appear by default. The "Not Blocked" view additionally filters for tasks that are ready to work on (no incomplete blocking dependencies).
This template includes multiple views: Manual Order, All Tasks, Not Blocked, Today, Overdue, This Week, and Unscheduled. The Manual Order view groups by status and sorts by the manual-order property so drag-to-reorder works immediately in new bases. The default property name is `tasknotes_manual_order`. The remaining views keep their existing date- and urgency-focused defaults. Each filtered view (except All Tasks) filters for incomplete tasks, handling both recurring and non-recurring tasks. For recurring tasks, the generated filters treat a missing `complete_instances` property as "not completed today" so newly created recurring tasks still appear by default. The "Not Blocked" view additionally filters for tasks that are ready to work on (no incomplete blocking dependencies).
The default views cover common review horizons and can be kept, removed, or cloned with modified filters.
```yaml
@ -252,6 +255,27 @@ formulas:
# ... same formulas as Mini Calendar above ...
views:
- type: tasknotesTaskList
name: "Manual Order"
order:
- status
- priority
- due
- scheduled
- projects
- contexts
- tags
- blockedBy
- file.name
- recurrence
- complete_instances
- file.tasks
sort:
- column: tasknotes_manual_order
direction: DESC
groupBy:
property: status
direction: ASC
- type: tasknotesTaskList
name: "All Tasks"
order:
@ -548,8 +572,8 @@ This template uses the special `this` object to reference the current file's pro
Note: Unlike other templates, this one does not have a top-level task filter. Each view applies filters as appropriate:
- **Subtasks, Blocked By, Blocking**: Include the task filter (these views show tasks)
- **Projects**: No task filter (project files can be any file type, not just tasks)
- **Subtasks, Blocked By, Blocking**: Include the task filter and default to manual-order sorting so drag-to-reorder works immediately
- **Projects**: No task filter and no default manual-order sort (project files can be any file type, not just tasks)
When debugging empty relationship tabs, check tab-specific filters first, then verify property values on linked notes.
```yaml
@ -580,6 +604,9 @@ views:
- recurrence
- complete_instances
- file.tasks
sort:
- column: tasknotes_manual_order
direction: DESC
groupBy:
property: status
direction: ASC
@ -620,6 +647,9 @@ views:
- recurrence
- complete_instances
- file.tasks
sort:
- column: tasknotes_manual_order
direction: DESC
- type: tasknotesKanban
name: "Blocking"
filters:
@ -639,6 +669,9 @@ views:
- recurrence
- complete_instances
- file.tasks
sort:
- column: tasknotes_manual_order
direction: DESC
groupBy:
property: status
direction: ASC

View file

@ -2460,7 +2460,6 @@ export class KanbanView extends BasesViewBase {
scopeFilters: sortScopeFilters,
taskInfoCache: this.taskInfoCache,
visibleTaskPaths,
debugLog: (msg, data) => this.debugLog(msg, data),
}
);
if (sortOrderPlan.sortOrder === null) {

View file

@ -22,6 +22,28 @@ import {
DropOperationQueue,
} from "./sortOrderUtils";
type TaskListDropBaselineCard = {
path: string;
groupKey: string | null;
card: HTMLElement;
top: number;
bottom: number;
midpoint: number;
};
type TaskListDropSegment = {
groupKey: string | null;
cards: TaskListDropBaselineCard[];
};
type TaskListInsertionSlot = {
groupKey: string | null;
segmentIndex: number;
insertionIndex: number;
element: HTMLElement;
position: "before" | "after";
};
export class TaskListView extends BasesViewBase {
type = "tasknotesTaskList";
@ -45,15 +67,18 @@ export class TaskListView extends BasesViewBase {
private basesController: any;
private draggedTaskPath: string | null = null;
private dragGroupKey: string | null = null;
private dropTargetPath: string | null = null;
private dropAbove: boolean = false;
private currentInsertionGroupKey: string | null = null;
private currentInsertionSegmentIndex: number = -1;
private currentInsertionIndex: number = -1;
private pendingDragClientY: number | null = null;
private pendingRender: boolean = false;
private taskGroupKeys = new Map<string, string>(); // task path → group key (set during grouped render)
private sortScopeTaskPaths = new Map<string, string[]>();
private dragOverRafId: number = 0; // rAF handle for throttled dragover
private dragContainer: HTMLElement | null = null; // Container holding siblings during drag
private currentInsertionIndex: number = -1; // Current gap/slot position
private currentInsertionGroupKey: string | null = null; // Group currently showing the drag gap
private currentDropSlotElement: HTMLElement | null = null;
private currentDropSlotPosition: "before" | "after" | null = null;
private dragBaselineCards: TaskListDropBaselineCard[] = [];
private dropQueue = new DropOperationQueue();
/**
@ -65,6 +90,8 @@ export class TaskListView extends BasesViewBase {
private readonly VIRTUAL_SCROLL_THRESHOLD = 100;
private readonly LARGE_REORDER_WARNING_THRESHOLD = 10;
private readonly UNGROUPED_SORT_SCOPE_KEY = "__ungrouped__";
private readonly CARD_NO_DRAG_SELECTOR =
'[data-tn-no-drag="true"], a, button, input, select, textarea, [contenteditable="true"]';
constructor(controller: any, containerEl: HTMLElement, plugin: TaskNotesPlugin) {
super(controller, containerEl, plugin);
@ -241,11 +268,6 @@ export class TaskListView extends BasesViewBase {
}
}
private getPreviewGroupKey(taskPath: string | null): string | null {
if (!taskPath) return null;
return this.taskGroupKeys.get(taskPath) ?? null;
}
private isListTypeProperty(propertyName: string): boolean {
const metadataTypeManager = (this.plugin.app as any).metadataTypeManager;
if (metadataTypeManager?.properties) {
@ -285,13 +307,56 @@ export class TaskListView extends BasesViewBase {
});
}
private getEventTargetElement(target: EventTarget | null): HTMLElement | null {
const node = target as Node | null;
if (!node || typeof (node as any).nodeType !== "number") {
return null;
}
return node.nodeType === Node.ELEMENT_NODE
? (node as HTMLElement)
: node.parentElement;
}
private shouldSuppressCardDrag(target: EventTarget | null, cardEl: HTMLElement): boolean {
const targetEl = this.getEventTargetElement(target);
if (!targetEl || !cardEl.contains(targetEl)) {
return false;
}
return !!targetEl.closest(this.CARD_NO_DRAG_SELECTOR);
}
/**
* Attach a dragstart handler to a single card element.
* Drop-target handling (dragover/drop) is done via container-level delegation
* in setupContainerDragHandlers() for robustness with virtual scrolling.
*/
private setupCardDragHandlers(cardEl: HTMLElement, task: TaskInfo, groupKey: string | null): void {
let dragOriginTarget: EventTarget | null = null;
const restoreCardDraggable = () => {
cardEl.setAttribute("draggable", "true");
dragOriginTarget = null;
};
cardEl.addEventListener("mousedown", (e: MouseEvent) => {
dragOriginTarget = e.target;
cardEl.setAttribute(
"draggable",
this.shouldSuppressCardDrag(e.target, cardEl) ? "false" : "true"
);
}, { capture: true });
cardEl.addEventListener("mouseup", restoreCardDraggable);
cardEl.addEventListener("click", restoreCardDraggable, { capture: true });
cardEl.addEventListener("dragstart", (e: DragEvent) => {
if (this.shouldSuppressCardDrag(dragOriginTarget ?? e.target, cardEl)) {
e.preventDefault();
e.stopPropagation();
restoreCardDraggable();
return;
}
this.draggedTaskPath = task.path;
this.dragGroupKey = groupKey;
cardEl.classList.add("task-card--dragging");
@ -321,21 +386,20 @@ export class TaskListView extends BasesViewBase {
const gapStr = getComputedStyle(container).gap;
const gap = parseFloat(gapStr) || 4;
container.style.setProperty("--tn-drag-gap", `${draggedHeight + gap}px`);
const siblings = container.querySelectorAll<HTMLElement>(".task-card[data-task-path]");
for (const sib of siblings) {
if (sib.dataset.taskPath !== task.path) {
sib.classList.add("task-card--drag-shift");
}
}
this.dragContainer = container;
this.currentInsertionGroupKey = groupKey;
this.currentInsertionSegmentIndex = -1;
this.currentInsertionIndex = -1;
this.currentInsertionGroupKey = null;
this.currentDropSlotElement = null;
this.currentDropSlotPosition = null;
this.captureDropBaseline();
}
});
});
cardEl.addEventListener("dragend", () => {
restoreCardDraggable();
// Restore collapsed card
cardEl.style.cssText = "";
cardEl.classList.remove("task-card--dragging");
@ -346,13 +410,16 @@ export class TaskListView extends BasesViewBase {
this.draggedTaskPath = null;
this.dragGroupKey = null;
this.dropTargetPath = null;
this.currentInsertionGroupKey = null;
this.currentInsertionSegmentIndex = -1;
this.currentInsertionIndex = -1;
// Cancel any pending rAF
if (this.dragOverRafId) {
cancelAnimationFrame(this.dragOverRafId);
this.dragOverRafId = 0;
}
this.pendingDragClientY = null;
// Flush any render that was deferred while dragging
if (this.pendingRender) {
@ -368,11 +435,18 @@ export class TaskListView extends BasesViewBase {
}
private clearDropIndicators(): void {
// Legacy cleanup — old line indicator classes removed in CSS,
// but keep this for any stale DOM from mid-drag re-renders.
this.itemsContainer?.querySelectorAll(".task-card--drop-above, .task-card--drop-below").forEach(el => {
el.classList.remove("task-card--drop-above", "task-card--drop-below");
this.itemsContainer?.querySelectorAll(
".task-card--drop-above, .task-card--drop-below, .task-list-view__drop-slot-before, .task-list-view__drop-slot-after"
).forEach(el => {
el.classList.remove(
"task-card--drop-above",
"task-card--drop-below",
"task-list-view__drop-slot-before",
"task-list-view__drop-slot-after"
);
});
this.currentDropSlotElement = null;
this.currentDropSlotPosition = null;
}
/**
@ -384,13 +458,239 @@ export class TaskListView extends BasesViewBase {
}
// Clean from entire items container (safety net)
this.itemsContainer?.querySelectorAll<HTMLElement>(
".task-card--drag-shift, .task-card--shift-down"
".task-card--drag-shift, .task-card--shift-down, .task-list-view__drop-slot-before, .task-list-view__drop-slot-after"
).forEach(el => {
el.classList.remove("task-card--drag-shift", "task-card--shift-down");
el.classList.remove(
"task-card--drag-shift",
"task-card--shift-down",
"task-list-view__drop-slot-before",
"task-list-view__drop-slot-after"
);
});
this.dragContainer = null;
this.currentInsertionIndex = -1;
this.currentDropSlotElement = null;
this.currentDropSlotPosition = null;
this.currentInsertionGroupKey = null;
this.currentInsertionSegmentIndex = -1;
this.currentInsertionIndex = -1;
this.dragBaselineCards = [];
}
private getDropSegments(): TaskListDropSegment[] {
const cards = this.getDropBaselineCards();
if (cards.length === 0) return [];
const segments: TaskListDropSegment[] = [];
for (const card of cards) {
const previousSegment = segments[segments.length - 1];
if (!previousSegment || previousSegment.groupKey !== card.groupKey) {
segments.push({
groupKey: card.groupKey,
cards: [card],
});
continue;
}
previousSegment.cards.push(card);
}
return segments;
}
private reconstructDropTargetFromInsertionSlot(
segmentIndex: number,
insertionIndex: number
): { taskPath: string; above: boolean } | null {
const segment = this.getDropSegments()[segmentIndex];
if (!segment || segment.cards.length === 0) return null;
const clampedIndex = Math.max(0, Math.min(insertionIndex, segment.cards.length));
if (clampedIndex === 0) {
return {
taskPath: segment.cards[0].path,
above: true,
};
}
return {
taskPath: segment.cards[clampedIndex - 1].path,
above: false,
};
}
private getCurrentInsertionTarget(): { taskPath: string; above: boolean } | null {
if (this.currentInsertionSegmentIndex < 0 || this.currentInsertionIndex < 0) return null;
return this.reconstructDropTargetFromInsertionSlot(
this.currentInsertionSegmentIndex,
this.currentInsertionIndex
);
}
private getVisibleSortScopePathsForDrag(groupKey: string | null): string[] | undefined {
if (this.dragBaselineCards.length > 0) {
const scopeKey = this.getSortScopeKey(groupKey);
const visiblePaths = this.dragBaselineCards
.filter((entry) => this.getSortScopeKey(entry.groupKey) === scopeKey)
.map((entry) => entry.path);
if (visiblePaths.length > 0) {
return visiblePaths;
}
}
return this.getVisibleSortScopePaths(groupKey);
}
private updateDropSlotPreview(slot: TaskListInsertionSlot): void {
const { element, position } = slot;
if (
element === this.currentDropSlotElement &&
position === this.currentDropSlotPosition
) {
return;
}
this.clearDropIndicators();
element.classList.add(
position === "before"
? "task-list-view__drop-slot-before"
: "task-list-view__drop-slot-after"
);
this.currentDropSlotElement = element;
this.currentDropSlotPosition = position;
}
private updateResolvedInsertionSlot(clientY: number): boolean {
const insertionSlot = this.resolveClosestInsertionSlot(clientY);
if (!insertionSlot) return false;
this.currentInsertionGroupKey = insertionSlot.groupKey;
this.currentInsertionSegmentIndex = insertionSlot.segmentIndex;
this.currentInsertionIndex = insertionSlot.insertionIndex;
this.updateDropSlotPreview(insertionSlot);
return true;
}
private flushPendingInsertionSlot(clientYFallback: number): boolean {
if (this.dragOverRafId) {
cancelAnimationFrame(this.dragOverRafId);
this.dragOverRafId = 0;
}
const clientY = this.pendingDragClientY ?? clientYFallback;
if (clientY === null) {
return this.currentInsertionSegmentIndex >= 0 && this.currentInsertionIndex >= 0;
}
return this.updateResolvedInsertionSlot(clientY);
}
private getVisibleDropCards(): HTMLElement[] {
if (!this.itemsContainer) return [];
return Array.from(
this.itemsContainer.querySelectorAll<HTMLElement>(".task-card[data-task-path]")
).filter((card) => {
if (card.dataset.taskPath === this.draggedTaskPath) return false;
const parentTaskCard = card.parentElement?.closest<HTMLElement>(".task-card[data-task-path]");
return !parentTaskCard;
});
}
private captureDropBaseline(cards = this.getVisibleDropCards()): void {
if (!this.itemsContainer) {
this.dragBaselineCards = [];
return;
}
const containerRect = this.itemsContainer.getBoundingClientRect();
const scrollTop = this.itemsContainer.scrollTop;
this.dragBaselineCards = cards
.map((card) => {
const path = card.dataset.taskPath;
if (!path) return null;
const rect = card.getBoundingClientRect();
const top = rect.top - containerRect.top + scrollTop;
return {
path,
groupKey: this.taskGroupKeys.get(path) ?? null,
card,
top,
bottom: top + rect.height,
midpoint: top + rect.height / 2,
};
})
.filter((entry): entry is TaskListDropBaselineCard => !!entry);
}
private getDropBaselineCards(): TaskListDropBaselineCard[] {
const cards = this.getVisibleDropCards();
const currentPaths = cards.map((card) => card.dataset.taskPath ?? "");
const baselinePaths = this.dragBaselineCards.map((entry) => entry.path);
const baselineIsCurrent =
currentPaths.length === baselinePaths.length &&
currentPaths.every((path, index) => path === baselinePaths[index]);
if (!baselineIsCurrent) {
this.captureDropBaseline(cards);
}
return this.dragBaselineCards;
}
private getContainerLocalY(clientY: number): number {
if (!this.itemsContainer) return clientY;
const containerRect = this.itemsContainer.getBoundingClientRect();
return clientY - containerRect.top + this.itemsContainer.scrollTop;
}
private resolveClosestInsertionSlot(clientY: number): TaskListInsertionSlot | null {
const segments = this.getDropSegments();
if (segments.length === 0) return null;
const localY = this.getContainerLocalY(clientY);
let selectedSegmentIndex = segments.length - 1;
for (let index = 0; index < segments.length; index++) {
const currentSegment = segments[index];
const previousSegment = index > 0 ? segments[index - 1] : null;
const nextSegment = index < segments.length - 1 ? segments[index + 1] : null;
const firstCard = currentSegment.cards[0];
const lastCard = currentSegment.cards[currentSegment.cards.length - 1];
const lowerBoundary = previousSegment
? (previousSegment.cards[previousSegment.cards.length - 1].bottom + firstCard.top) / 2
: Number.NEGATIVE_INFINITY;
const upperBoundary = nextSegment
? (lastCard.bottom + nextSegment.cards[0].top) / 2
: Number.POSITIVE_INFINITY;
if (localY < upperBoundary || index === segments.length - 1) {
if (localY >= lowerBoundary || index === 0) {
selectedSegmentIndex = index;
break;
}
}
}
const selectedSegment = segments[selectedSegmentIndex];
const cardsInSegment = selectedSegment.cards;
const targetIndex = cardsInSegment.findIndex((card) => localY < card.midpoint);
if (targetIndex === -1) {
const lastCard = cardsInSegment[cardsInSegment.length - 1];
return {
groupKey: selectedSegment.groupKey,
segmentIndex: selectedSegmentIndex,
insertionIndex: cardsInSegment.length,
element: lastCard.card,
position: "after",
};
}
return {
groupKey: selectedSegment.groupKey,
segmentIndex: selectedSegmentIndex,
insertionIndex: targetIndex,
element: cardsInSegment[targetIndex].card,
position: "before",
};
}
/**
@ -424,61 +724,15 @@ export class TaskListView extends BasesViewBase {
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
// Throttle visual updates via rAF
const clientX = e.clientX;
const clientY = e.clientY;
this.pendingDragClientY = e.clientY;
if (!this.dragOverRafId) {
this.dragOverRafId = requestAnimationFrame(() => {
this.dragOverRafId = 0;
const card = this.containerEl.ownerDocument.elementFromPoint(
clientX, clientY
)?.closest<HTMLElement>(".task-card[data-task-path]");
if (!card || card.dataset.taskPath === this.draggedTaskPath) return;
const clientY = this.pendingDragClientY;
if (clientY === null) return;
const rect = card.getBoundingClientRect();
const isAbove = clientY < rect.top + rect.height / 2;
this.dropTargetPath = card.dataset.taskPath!;
this.dropAbove = isAbove;
const previewGroupKey = this.getPreviewGroupKey(card.dataset.taskPath!);
// In grouped views, only shift cards in the hovered group so
// the landing preview matches the visible section boundaries.
const container = this.itemsContainer;
if (!container) return;
const siblings = Array.from(
container.querySelectorAll<HTMLElement>(".task-card[data-task-path]")
).filter((sib) => {
if (sib.dataset.taskPath === this.draggedTaskPath) return false;
if (this.taskGroupKeys.size === 0) return true;
return this.getPreviewGroupKey(sib.dataset.taskPath || null) === previewGroupKey;
});
let insertionIndex = siblings.length; // default: end
for (let i = 0; i < siblings.length; i++) {
if (siblings[i].dataset.taskPath === card.dataset.taskPath) {
insertionIndex = isAbove ? i : i + 1;
break;
}
}
if (
insertionIndex !== this.currentInsertionIndex ||
previewGroupKey !== this.currentInsertionGroupKey
) {
this.currentInsertionIndex = insertionIndex;
this.currentInsertionGroupKey = previewGroupKey;
container.querySelectorAll<HTMLElement>(".task-card--shift-down").forEach((el) => {
el.classList.remove("task-card--shift-down");
});
for (let i = 0; i < siblings.length; i++) {
siblings[i].classList.toggle(
"task-card--shift-down",
i >= insertionIndex
);
}
}
this.updateResolvedInsertionSlot(clientY);
});
}
});
@ -493,25 +747,39 @@ export class TaskListView extends BasesViewBase {
this.itemsContainer.addEventListener("drop", async (e: DragEvent) => {
e.preventDefault();
if (!this.draggedTaskPath || !this.dropTargetPath) return;
if (!this.draggedTaskPath) return;
if (!this.flushPendingInsertionSlot(e.clientY) && this.currentInsertionIndex < 0) return;
const draggedPath = this.draggedTaskPath;
const targetPath = this.dropTargetPath;
const above = this.dropAbove;
const sourceGroupKey = this.dragGroupKey;
const targetGroupKey = this.currentInsertionGroupKey;
const targetVisiblePaths = this.getVisibleSortScopePathsForDrag(targetGroupKey);
const insertionSegmentIndex = this.currentInsertionSegmentIndex;
const insertionIndex = this.currentInsertionIndex;
const dropTarget = insertionSegmentIndex >= 0 && insertionIndex >= 0
? this.reconstructDropTargetFromInsertionSlot(insertionSegmentIndex, insertionIndex)
: null;
if (!draggedPath || !dropTarget) return;
this.clearDropIndicators();
this.cleanupDragShift();
// Determine target group from the render-time map (the flat DOM
// structure means closest("[data-group-key]") won't work here).
const targetGroupKey = this.taskGroupKeys.get(targetPath) ?? sourceGroupKey;
this.draggedTaskPath = null;
this.dragGroupKey = null;
this.dropTargetPath = null;
this.currentInsertionGroupKey = null;
this.currentInsertionSegmentIndex = -1;
this.currentInsertionIndex = -1;
this.pendingDragClientY = null;
await this.handleSortOrderDrop(draggedPath, targetPath, above, targetGroupKey, sourceGroupKey);
await this.handleSortOrderDrop(
draggedPath,
dropTarget.taskPath,
dropTarget.above,
targetGroupKey,
sourceGroupKey,
targetVisiblePaths
);
});
}
@ -520,7 +788,8 @@ export class TaskListView extends BasesViewBase {
targetPath: string,
above: boolean,
targetGroupKey: string | null,
sourceGroupKey: string | null
sourceGroupKey: string | null,
targetVisiblePaths?: string[]
): Promise<void> {
await this.dropQueue.enqueue(draggedPath, async () => {
const groupByPropertyId = this.getGroupByPropertyId();
@ -550,7 +819,7 @@ export class TaskListView extends BasesViewBase {
this.plugin,
{
taskInfoCache: this.taskInfoCache,
visibleTaskPaths: this.getVisibleSortScopePaths(targetGroupKey),
visibleTaskPaths: targetVisiblePaths ?? this.getVisibleSortScopePaths(targetGroupKey),
}
);
if (sortOrderPlan.sortOrder === null) return;

View file

@ -16,7 +16,6 @@ export interface SortOrderComputationOptions {
scopeFilters?: SortOrderScopeFilter[];
taskInfoCache?: Map<string, TaskInfo>;
visibleTaskPaths?: string[];
debugLog?: (msg: string, data?: Record<string, unknown>) => void;
}
export interface SortOrderWrite {
@ -31,6 +30,7 @@ export interface SortOrderPlan {
}
const REBALANCE_RANK_LENGTH_THRESHOLD = 32;
type SortDirection = "asc" | "desc";
/**
* Strip Bases property prefixes (note., file., formula., task.) from a property ID.
@ -113,6 +113,62 @@ function buildVisibleOrderLookup(visibleTaskPaths?: string[]): Map<string, numbe
return lookup;
}
function getVisibleOrderedTasks(
columnTasks: TaskInfo[],
visibleTaskPaths: string[] | undefined,
draggedPath: string
): TaskInfo[] {
if (!visibleTaskPaths || visibleTaskPaths.length === 0) {
return columnTasks.filter((task) => task.path !== draggedPath);
}
const taskByPath = new Map(columnTasks.map((task) => [task.path, task]));
const orderedVisibleTasks = visibleTaskPaths
.filter((path) => path !== draggedPath)
.map((path) => taskByPath.get(path))
.filter((task): task is TaskInfo => !!task);
return orderedVisibleTasks.length > 0 ? orderedVisibleTasks : columnTasks.filter((task) => task.path !== draggedPath);
}
function inferSortDirection(tasks: TaskInfo[]): SortDirection {
for (let index = 1; index < tasks.length; index++) {
const previousRank = tryParseLexoRank(tasks[index - 1].sortOrder);
const currentRank = tryParseLexoRank(tasks[index].sortOrder);
if (!previousRank || !currentRank) continue;
const comparison = previousRank.toString().localeCompare(currentRank.toString());
if (comparison < 0) return "asc";
if (comparison > 0) return "desc";
}
return "asc";
}
function compareInDisplayOrder(left: LexoRank, right: LexoRank, direction: SortDirection): number {
return direction === "asc"
? left.toString().localeCompare(right.toString())
: right.toString().localeCompare(left.toString());
}
function rankBeforeInDisplay(targetRank: LexoRank, direction: SortDirection): LexoRank {
return direction === "asc" ? safeGenPrev(targetRank) : safeGenNext(targetRank);
}
function rankAfterInDisplay(targetRank: LexoRank, direction: SortDirection): LexoRank {
return direction === "asc" ? safeGenNext(targetRank) : safeGenPrev(targetRank);
}
function nextRankInDisplay(currentRank: LexoRank, direction: SortDirection): LexoRank {
return direction === "asc" ? safeGenNext(currentRank) : safeGenPrev(currentRank);
}
function betweenInDisplayOrder(leftRank: LexoRank, rightRank: LexoRank, direction: SortDirection): string {
return direction === "asc"
? safeBetween(leftRank, rightRank)
: safeBetween(rightRank, leftRank);
}
/**
* Generate a rank that sorts after `rank` in plain string comparison.
*/
@ -256,7 +312,8 @@ function getContiguousVisibleSparseRun(
function createRebalancePlan(
columnTasks: TaskInfo[],
targetIndex: number,
above: boolean
above: boolean,
direction: SortDirection
): SortOrderPlan {
const insertAt = above ? targetIndex : targetIndex + 1;
const orderedPaths: Array<string | null> = columnTasks.map((task) => task.path);
@ -264,10 +321,10 @@ function createRebalancePlan(
const additionalWrites: SortOrderWrite[] = [];
let draggedSortOrder: string | null = null;
let currentRank = LexoRank.middle();
let currentRank: LexoRank | null = null;
for (let index = 0; index < orderedPaths.length; index++) {
if (index > 0) currentRank = currentRank.genNext();
currentRank = currentRank ? nextRankInDisplay(currentRank, direction) : LexoRank.middle();
const rankString = currentRank.toString();
const path = orderedPaths[index];
if (path === null) {
@ -289,7 +346,8 @@ function createSparsePlan(
targetIndex: number,
above: boolean,
draggedPath: string,
visibleTaskPaths?: string[]
visibleTaskPaths?: string[],
direction: SortDirection = "asc"
): SortOrderPlan {
const targetTask = columnTasks[targetIndex];
const visibleRun = getContiguousVisibleSparseRun(
@ -322,12 +380,12 @@ function createSparsePlan(
: targetIndex;
const previousRank = getNearestPreviousRank(columnTasks, firstRunIndex >= 0 ? firstRunIndex : targetIndex);
if (shouldRebalanceRank(previousRank)) {
return createRebalancePlan(columnTasks, targetIndex, above);
return createRebalancePlan(columnTasks, targetIndex, above, direction);
}
const targetRunIndex = runPaths.indexOf(targetTask.path);
if (above && targetRunIndex === 0) {
return {
sortOrder: previousRank ? safeGenNext(previousRank).toString() : LexoRank.middle().toString(),
sortOrder: previousRank ? nextRankInDisplay(previousRank, direction).toString() : LexoRank.middle().toString(),
additionalWrites: [],
reason: "boundary",
};
@ -343,7 +401,7 @@ function createSparsePlan(
let currentRank = previousRank;
for (const path of orderedPaths) {
const nextRank = currentRank ? safeGenNext(currentRank) : LexoRank.middle();
const nextRank = currentRank ? nextRankInDisplay(currentRank, direction) : LexoRank.middle();
const nextRankString = nextRank.toString();
if (path === null) {
draggedSortOrder = nextRankString;
@ -459,19 +517,10 @@ export async function prepareSortOrderUpdate(
plugin: TaskNotesPlugin,
options: SortOrderComputationOptions = {}
): Promise<SortOrderPlan> {
const _log = options.debugLog || (() => {});
const columnTasks = getGroupTasks(groupKey, groupByProperty, plugin, options)
.filter((task) => task.path !== draggedPath);
_log("PREPARE-SORT-ORDER", {
columnTaskCount: columnTasks.length,
targetFile: targetTaskPath.split("/").pop(),
draggedFile: draggedPath.split("/").pop(),
above,
groupKey,
groupByProperty,
scopeFilters: options.scopeFilters,
});
const orderedTasks = getVisibleOrderedTasks(columnTasks, options.visibleTaskPaths, draggedPath);
const sortDirection = inferSortDirection(orderedTasks);
if (columnTasks.length === 0) {
return {
@ -481,9 +530,9 @@ export async function prepareSortOrderUpdate(
};
}
const targetIndex = columnTasks.findIndex((task) => task.path === targetTaskPath);
const targetIndex = orderedTasks.findIndex((task) => task.path === targetTaskPath);
if (targetIndex === -1) {
const lastRankedTask = [...columnTasks].reverse().find((task) => hasValidLexoRank(task));
const lastRankedTask = [...orderedTasks].reverse().find((task) => hasValidLexoRank(task));
return {
sortOrder: lastRankedTask
? safeGenNext(tryParseLexoRank(lastRankedTask.sortOrder)!).toString()
@ -493,34 +542,39 @@ export async function prepareSortOrderUpdate(
};
}
const targetTask = columnTasks[targetIndex];
const previousTask = targetIndex > 0 ? columnTasks[targetIndex - 1] : null;
const nextTask = targetIndex < columnTasks.length - 1 ? columnTasks[targetIndex + 1] : null;
const targetTask = orderedTasks[targetIndex];
const previousTask = targetIndex > 0 ? orderedTasks[targetIndex - 1] : null;
const nextTask = targetIndex < orderedTasks.length - 1 ? orderedTasks[targetIndex + 1] : null;
const targetRank = tryParseLexoRank(targetTask.sortOrder);
const previousRank = previousTask ? tryParseLexoRank(previousTask.sortOrder) : null;
const nextRank = nextTask ? tryParseLexoRank(nextTask.sortOrder) : null;
if (!targetRank) {
return createSparsePlan(columnTasks, targetIndex, above, draggedPath, options.visibleTaskPaths);
return createSparsePlan(orderedTasks, targetIndex, above, draggedPath, options.visibleTaskPaths, sortDirection);
}
const previousBoundaryInvalid = previousRank
? compareInDisplayOrder(previousRank, targetRank, sortDirection) >= 0
: false;
const nextBoundaryInvalid = nextRank
? compareInDisplayOrder(targetRank, nextRank, sortDirection) >= 0
: false;
if ((above && previousBoundaryInvalid) || (!above && nextBoundaryInvalid)) {
return createRebalancePlan(orderedTasks, targetIndex, above, sortDirection);
}
if (shouldRebalanceRank(previousRank) || shouldRebalanceRank(targetRank) || shouldRebalanceRank(nextRank)) {
_log("PREPARE-SORT-ORDER: rebalancing oversized scope", {
targetIndex,
previousRank: previousRank?.toString() ?? null,
targetRank: targetRank.toString(),
nextRank: nextRank?.toString() ?? null,
});
return createRebalancePlan(columnTasks, targetIndex, above);
return createRebalancePlan(orderedTasks, targetIndex, above, sortDirection);
}
if (above) {
return {
sortOrder: targetIndex === 0
? safeGenPrev(targetRank).toString()
? rankBeforeInDisplay(targetRank, sortDirection).toString()
: previousRank
? safeBetween(previousRank, targetRank)
: safeGenPrev(targetRank).toString(),
? betweenInDisplayOrder(previousRank, targetRank, sortDirection)
: rankBeforeInDisplay(targetRank, sortDirection).toString(),
additionalWrites: [],
reason: targetIndex === 0 ? "boundary" : "midpoint",
};
@ -528,14 +582,14 @@ export async function prepareSortOrderUpdate(
if (!nextTask || !nextRank) {
return {
sortOrder: safeGenNext(targetRank).toString(),
sortOrder: rankAfterInDisplay(targetRank, sortDirection).toString(),
additionalWrites: [],
reason: "boundary",
};
}
return {
sortOrder: safeBetween(targetRank, nextRank),
sortOrder: betweenInDisplayOrder(targetRank, nextRank, sortDirection),
additionalWrites: [],
reason: "midpoint",
};

View file

@ -992,9 +992,9 @@ export const en: TranslationTree = {
"Links to tasks that must be completed before this one. Stored as wikilinks. Blocked tasks display a visual indicator.",
},
sortOrder: {
name: "Sort Order",
name: "Manual Order",
description:
"Frontmatter property used for manual task ordering via drag-and-drop.",
"Frontmatter property used for drag-to-reorder manual ordering. A view must be sorted by this property for drag-and-drop reordering to work.",
},
pomodoros: {
name: "Pomodoros",
@ -1155,7 +1155,7 @@ export const en: TranslationTree = {
timeEntries: "Time entries",
completeInstances: "Complete instances",
blockedBy: "Blocked by",
sortOrder: "Sort order",
sortOrder: "Manual order",
pomodoros: "Pomodoros",
icsEventId: "ICS Event ID",
icsEventTag: "ICS Event Tag",

View file

@ -47,7 +47,7 @@ export const DEFAULT_FIELD_MAPPING: FieldMapping = {
icsEventTag: "ics_event",
googleCalendarEventId: "googleCalendarEventId",
reminders: "reminders",
sortOrder: "tasknotes_order",
sortOrder: "tasknotes_manual_order",
};
// Default status configuration matches current hardcoded behavior

View file

@ -207,7 +207,7 @@ export function renderTaskPropertiesTab(
translate("settings.taskProperties.properties.blockedBy.name"),
translate("settings.taskProperties.properties.blockedBy.description"));
// Sort Order Property Card
// Manual Order Property Card
renderMetadataPropertyCard(container, plugin, save, translate, "sortOrder",
translate("settings.taskProperties.properties.sortOrder.name"),
translate("settings.taskProperties.properties.sortOrder.description"));

View file

@ -452,6 +452,7 @@ ${orderYaml}
}
case 'open-kanban-view': {
const statusProperty = getPropertyName(mapPropertyToBasesProperty('status', plugin));
const sortOrderProperty = mapPropertyToBasesProperty('sortOrder', plugin);
return `# Kanban Board
${formatFilterAsYAML([taskFilterCondition])}
@ -463,6 +464,9 @@ views:
name: "Kanban Board"
order:
${orderYaml}
sort:
- column: ${sortOrderProperty}
direction: DESC
groupBy:
property: ${statusProperty}
direction: ASC
@ -479,6 +483,7 @@ ${orderYaml}
const recurrenceProperty = mapPropertyToBasesProperty('recurrence', plugin);
const completeInstancesProperty = mapPropertyToBasesProperty('completeInstances', plugin);
const blockedByProperty = mapPropertyToBasesProperty('blockedBy', plugin);
const sortOrderProperty = mapPropertyToBasesProperty('sortOrder', plugin);
// Get all completed status values
const completedStatuses = settings.customStatuses
@ -509,6 +514,16 @@ ${formatFilterAsYAML([taskFilterCondition])}
${formulasSection}
views:
- type: tasknotesTaskList
name: "Manual Order"
order:
${orderYaml}
sort:
- column: ${sortOrderProperty}
direction: DESC
groupBy:
property: ${statusProperty}
direction: ASC
- type: tasknotesTaskList
name: "All Tasks"
order:
@ -684,12 +699,13 @@ ${orderYaml}
titleProperty: file.basename
`;
case 'relationships': {
// Unified relationships widget that shows all relationship types
// Extract just the property names (without prefixes) since the template controls the context
const projectsProperty = getPropertyName(mapPropertyToBasesProperty('projects', plugin));
const blockedByProperty = getPropertyName(mapPropertyToBasesProperty('blockedBy', plugin));
const statusProperty = getPropertyName(mapPropertyToBasesProperty('status', plugin));
case 'relationships': {
// Unified relationships widget that shows all relationship types
// Extract just the property names (without prefixes) since the template controls the context
const projectsProperty = getPropertyName(mapPropertyToBasesProperty('projects', plugin));
const blockedByProperty = getPropertyName(mapPropertyToBasesProperty('blockedBy', plugin));
const statusProperty = getPropertyName(mapPropertyToBasesProperty('status', plugin));
const sortOrderProperty = mapPropertyToBasesProperty('sortOrder', plugin);
// Note: No top-level task filter here. Each view applies filters as needed:
// - Subtasks, Blocked By, Blocking: include task filter (these are tasks)
@ -710,6 +726,9 @@ views:
- note.${projectsProperty}.contains(this.file.asLink())
order:
${orderYaml}
sort:
- column: ${sortOrderProperty}
direction: DESC
groupBy:
property: ${statusProperty}
direction: ASC
@ -728,6 +747,9 @@ ${orderYaml}
- list(this.note.${blockedByProperty}).map(value.uid).contains(file.asLink())
order:
${orderYaml}
sort:
- column: ${sortOrderProperty}
direction: DESC
- type: tasknotesKanban
name: "Blocking"
filters:
@ -736,6 +758,9 @@ ${orderYaml}
- list(note.${blockedByProperty}).map(value.uid).contains(this.file.asLink())
order:
${orderYaml}
sort:
- column: ${sortOrderProperty}
direction: DESC
groupBy:
property: ${statusProperty}
direction: ASC

View file

@ -86,6 +86,7 @@ function createBadgeIndicator(config: BadgeIndicatorConfig): HTMLElement | null
setTooltip(indicator, tooltip, { placement: "top" });
if (onClick) {
prepareInteractiveControl(indicator);
indicator.addEventListener("click", (e) => {
e.stopPropagation();
onClick(e);
@ -115,6 +116,9 @@ function updateBadgeIndicator(
// Update existing indicator
existing.setAttribute("aria-label", config.ariaLabel || config.tooltip);
setTooltip(existing, config.tooltip, { placement: "top" });
if (config.onClick) {
prepareInteractiveControl(existing);
}
return existing;
}
@ -130,6 +134,23 @@ function updateBadgeIndicator(
});
}
/**
* Mark interactive task-card controls so draggable parent cards do not swallow clicks.
*/
function prepareInteractiveControl(element: HTMLElement): void {
if (element.dataset.tnNoDrag === "true") {
element.setAttribute("draggable", "false");
return;
}
element.dataset.tnNoDrag = "true";
element.setAttribute("draggable", "false");
element.addEventListener("mousedown", (e) => {
e.preventDefault();
e.stopPropagation();
});
}
/* =================================================================
CLICK HANDLER FACTORIES
================================================================= */
@ -407,6 +428,7 @@ function attachDateClickHandler(
plugin: TaskNotesPlugin,
dateType: "due" | "scheduled"
): void {
prepareInteractiveControl(span);
span.addEventListener("click", (e) => {
e.stopPropagation(); // Don't trigger card click
const currentValue = dateType === "due" ? task.due : task.scheduled;
@ -1590,11 +1612,7 @@ export function createTaskCard(
// Add click handler to cycle through statuses
if (statusDot) {
// Prevent mousedown from propagating to editor (fixes inline widget de-rendering)
statusDot.addEventListener("mousedown", (e) => {
e.preventDefault();
e.stopPropagation();
});
prepareInteractiveControl(statusDot);
statusDot.addEventListener("click", createStatusCycleHandler(task, plugin, card, statusDot, targetDate));
}
@ -1608,6 +1626,7 @@ export function createTaskCard(
attr: { "aria-label": tTaskCard(plugin, "priorityAriaLabel", { label: priorityConfig.label }) },
});
priorityDot.style.borderColor = priorityConfig.color;
prepareInteractiveControl(priorityDot);
priorityDot.addEventListener("click", createPriorityClickHandler(task, plugin));
}
@ -1657,21 +1676,19 @@ export function createTaskCard(
// Chevron for expandable subtasks
if (plugin.settings?.showExpandableSubtasks) {
const isExpanded = plugin.expandedProjectsService?.isExpanded(task.path) || false;
const chevron = createBadgeIndicator({
createBadgeIndicator({
container: badgesContainer,
className: `task-card__chevron${isExpanded ? " task-card__chevron--expanded" : ""}`,
icon: "chevron-right",
tooltip: getChevronTooltip(plugin, isExpanded),
onClick: () => {
const chevron = card.querySelector(".task-card__chevron") as HTMLElement | null;
if (chevron) {
void createChevronClickHandler(task, plugin, card, chevron)();
}
},
});
// Chevron needs special handler since it updates its own state
if (chevron) {
chevron.addEventListener("click", (e) => {
e.stopPropagation();
createChevronClickHandler(task, plugin, card, chevron)();
});
}
// Show subtasks if already expanded
if (isExpanded) {
toggleSubtasks(card, task, plugin, true).catch((error) => {
@ -1685,19 +1702,18 @@ export function createTaskCard(
const hasBlocking = task.blocking && task.blocking.length > 0;
if (hasBlocking) {
const toggleLabel = plugin.i18n.translate("ui.taskCard.blockingToggle", { count: task.blocking!.length });
const toggle = createBadgeIndicator({
createBadgeIndicator({
container: badgesContainer,
className: "task-card__blocking-toggle is-visible",
icon: "git-branch",
tooltip: toggleLabel,
onClick: () => {
const toggle = card.querySelector(".task-card__blocking-toggle") as HTMLElement | null;
if (toggle) {
void createBlockingToggleClickHandler(task, plugin, card, toggle)();
}
},
});
if (toggle) {
toggle.addEventListener("click", (e) => {
e.stopPropagation();
createBlockingToggleClickHandler(task, plugin, card, toggle)();
});
}
}
}
@ -1712,6 +1728,7 @@ export function createTaskCard(
// Use Obsidian's built-in ellipsis-vertical icon
setIcon(contextIcon, "ellipsis-vertical");
setTooltip(contextIcon, tTaskCard(plugin, "taskOptions"), { placement: "top" });
prepareInteractiveControl(contextIcon);
contextIcon.addEventListener("click", async (e) => {
e.stopPropagation();
@ -2029,11 +2046,7 @@ export function updateTaskCard(
if (statusConfig) {
newStatusDot.style.borderColor = statusConfig.color;
}
// Prevent mousedown from propagating to editor (fixes inline widget de-rendering)
newStatusDot.addEventListener("mousedown", (e) => {
e.preventDefault();
e.stopPropagation();
});
prepareInteractiveControl(newStatusDot);
newStatusDot.addEventListener(
"click",
createStatusCycleHandler(task, plugin, element, newStatusDot, targetDate)
@ -2066,6 +2079,7 @@ export function updateTaskCard(
attr: { "aria-label": `Priority: ${priorityConfig.label}` },
});
priorityDot.style.borderColor = priorityConfig.color;
prepareInteractiveControl(priorityDot);
// Add click context menu for priority
priorityDot.addEventListener("click", (e) => {
@ -2102,6 +2116,7 @@ export function updateTaskCard(
// Remove old event listener and add new one with updated task data
const newPriorityDot = existingPriorityDot.cloneNode(true) as HTMLElement;
prepareInteractiveControl(newPriorityDot);
newPriorityDot.addEventListener("click", (e) => {
e.stopPropagation(); // Don't trigger card click
const menu = new PriorityContextMenu({
@ -2175,20 +2190,19 @@ export function updateTaskCard(
if (showChevron && !existingChevron) {
const isExpanded = plugin.expandedProjectsService?.isExpanded(task.path) || false;
const chevron = createBadgeIndicator({
createBadgeIndicator({
container: badgesContainer || mainRow,
className: `task-card__chevron${isExpanded ? " task-card__chevron--expanded" : ""}`,
icon: "chevron-right",
tooltip: getChevronTooltip(plugin, isExpanded),
onClick: () => {
const chevron = element.querySelector(".task-card__chevron") as HTMLElement | null;
if (chevron) {
void createChevronClickHandler(task, plugin, element, chevron)();
}
},
});
if (chevron) {
chevron.addEventListener("click", (e) => {
e.stopPropagation();
createChevronClickHandler(task, plugin, element, chevron)();
});
}
if (isExpanded) {
toggleSubtasks(element, task, plugin, true).catch((error) => {
console.error("Error showing initial subtasks in update:", error);

View file

@ -272,6 +272,19 @@
opacity: 0.8;
}
.tasknotes-plugin .task-card[draggable="true"] {
cursor: grab;
}
.tasknotes-plugin .task-card[draggable="true"]:active,
.tasknotes-plugin .task-card.task-card--dragging {
cursor: grabbing;
}
.tasknotes-plugin .task-card [data-tn-no-drag="true"] {
cursor: var(--cursor, pointer);
}
/* Container queries for adaptive layouts */
@container (max-width: 300px) {
.tasknotes-plugin .task-card {

View file

@ -337,18 +337,23 @@
}
}
/* Gap/slot drag-to-reorder: sibling cards shift apart to show drop position */
.tasknotes-plugin .task-card--drag-shift {
transition: transform 200ms cubic-bezier(0.2, 0, 0, 1);
will-change: transform;
/* Gap/slot drag-to-reorder: open real layout space at the insertion boundary */
.tasknotes-plugin .task-list-view__drop-slot-before,
.tasknotes-plugin .task-list-view__drop-slot-after {
transition: margin 200ms cubic-bezier(0.2, 0, 0, 1);
}
.tasknotes-plugin .task-card--shift-down {
transform: translateY(var(--tn-drag-gap, 60px));
.tasknotes-plugin .task-list-view__drop-slot-before {
margin-top: var(--tn-drag-gap, 60px);
}
.tasknotes-plugin .task-list-view__drop-slot-after {
margin-bottom: var(--tn-drag-gap, 60px);
}
@media (prefers-reduced-motion: reduce) {
.tasknotes-plugin .task-card--drag-shift {
.tasknotes-plugin .task-list-view__drop-slot-before,
.tasknotes-plugin .task-list-view__drop-slot-after {
transition: none;
}
}

View file

@ -0,0 +1,71 @@
import { generateBasesFileTemplate } from "../../../src/templates/defaultBasesFiles";
const createMockPlugin = () => {
const fieldMapping = {
status: "status",
priority: "priority",
due: "due",
scheduled: "scheduled",
projects: "projects",
contexts: "contexts",
recurrence: "recurrence",
completeInstances: "complete_instances",
blockedBy: "blockedBy",
sortOrder: "tasknotes_manual_order",
timeEstimate: "timeEstimate",
timeEntries: "timeEntries",
};
return {
settings: {
taskTag: "task",
taskIdentificationMethod: "tag",
customPriorities: [
{ value: "high", label: "High", weight: 0 },
{ value: "normal", label: "Normal", weight: 1 },
{ value: "low", label: "Low", weight: 2 },
],
customStatuses: [
{ value: "open", label: "Open", isCompleted: false },
{ value: "done", label: "Done", isCompleted: true },
],
defaultVisibleProperties: ["status", "priority", "due"],
userFields: [],
fieldMapping,
},
fieldMapper: {
toUserField: jest.fn((key: keyof typeof fieldMapping) => fieldMapping[key] ?? key),
getMapping: jest.fn(() => fieldMapping),
},
};
};
describe("defaultBasesFiles", () => {
it("adds manual-order sorting to the default kanban template", () => {
const template = generateBasesFileTemplate("open-kanban-view", createMockPlugin() as any);
expect(template).toContain('name: "Kanban Board"');
expect(template).toContain("sort:\n - column: tasknotes_manual_order\n direction: DESC");
expect(template).toContain("groupBy:\n property: status");
});
it("adds a dedicated manual-order task list view while preserving urgency views", () => {
const template = generateBasesFileTemplate("open-tasks-view", createMockPlugin() as any);
expect(template).toContain('name: "Manual Order"');
expect(template).toContain("sort:\n - column: tasknotes_manual_order\n direction: DESC");
expect(template).toContain("groupBy:\n property: status");
expect(template).toContain('name: "Not Blocked"');
expect(template).toContain("sort:\n - column: formula.urgencyScore\n direction: DESC");
});
it("adds manual-order sorting to relationship views that render tasks", () => {
const template = generateBasesFileTemplate("relationships", createMockPlugin() as any);
expect(template).toContain('name: "Subtasks"');
expect(template).toContain('name: "Blocked By"');
expect(template).toContain('name: "Blocking"');
expect((template.match(/column: tasknotes_manual_order/g) ?? []).length).toBe(3);
expect(template).toContain('name: "Projects"');
});
});

View file

@ -187,6 +187,18 @@ describe('TaskCard Component', () => {
isTaskUsedAsProject: jest.fn().mockResolvedValue(false),
isTaskUsedAsProjectSync: jest.fn().mockReturnValue(false)
},
expandedProjectsService: {
isExpanded: jest.fn(() => false),
toggle: jest.fn(() => true),
},
i18n: {
translate: jest.fn((key, vars) => {
if (key === 'ui.taskCard.blockingToggle') {
return `Blocking ${vars?.count ?? 0}`;
}
return key;
}),
},
settings: {
singleClickAction: 'edit',
doubleClickAction: 'none',
@ -1031,6 +1043,38 @@ describe('TaskCard Component', () => {
updateTaskCard(card, task, mockPlugin);
expect(card.classList.contains('task-card--chevron-left')).toBe(false);
});
it('should mark the subtask chevron as a no-drag control', () => {
const task = TaskFactory.createTask({ title: 'Project Task' });
mockPlugin.projectSubtasksService.isTaskUsedAsProjectSync.mockReturnValue(true);
const card = createTaskCard(task, mockPlugin);
const chevron = card.querySelector('.task-card__chevron') as HTMLElement;
const mouseDown = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
expect(chevron.getAttribute('data-tn-no-drag')).toBe('true');
expect(chevron.getAttribute('draggable')).toBe('false');
chevron.dispatchEvent(mouseDown);
expect(mouseDown.defaultPrevented).toBe(true);
});
it('should mark the blocking toggle as a no-drag control', () => {
const task = TaskFactory.createTask({
blocking: ['tasks/dependent-a.md'],
isBlocking: true,
});
const card = createTaskCard(task, mockPlugin);
const toggle = card.querySelector('.task-card__blocking-toggle') as HTMLElement;
const mouseDown = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
expect(toggle.getAttribute('data-tn-no-drag')).toBe('true');
expect(toggle.getAttribute('draggable')).toBe('false');
toggle.dispatchEvent(mouseDown);
expect(mouseDown.defaultPrevented).toBe(true);
});
});
describe('Accessibility', () => {
it('should have proper ARIA labels', () => {

View file

@ -0,0 +1,416 @@
import { App, MockObsidian } from "../../__mocks__/obsidian";
import { TaskListView } from "../../../src/bases/TaskListView";
import { FieldMapper } from "../../../src/services/FieldMapper";
import { DEFAULT_FIELD_MAPPING } from "../../../src/settings/defaults";
import { TaskFactory } from "../../helpers/mock-factories";
jest.mock("obsidian");
jest.mock("tasknotes-nlp-core", () => ({
NaturalLanguageParserCore: class {},
}), { virtual: true });
describe("TaskListView drag controls", () => {
const createView = () => {
const plugin = {
app: new App(),
fieldMapper: new FieldMapper(DEFAULT_FIELD_MAPPING),
settings: {
fieldMapping: DEFAULT_FIELD_MAPPING,
},
};
const containerEl = document.createElement("div");
document.body.appendChild(containerEl);
return new TaskListView({}, containerEl, plugin as any);
};
beforeEach(() => {
MockObsidian.reset();
document.body.innerHTML = "";
});
afterEach(() => {
document.body.innerHTML = "";
});
it("does not start a drag from no-drag task card controls", () => {
const view = createView();
const task = TaskFactory.createTask({ path: "tasks/drag-guard.md" });
const card = document.createElement("div");
const toggle = document.createElement("div");
card.className = "task-card";
card.setAttribute("draggable", "true");
toggle.dataset.tnNoDrag = "true";
toggle.addEventListener("mousedown", (e) => {
e.preventDefault();
e.stopPropagation();
});
card.appendChild(toggle);
(view as any).setupCardDragHandlers(card, task, null);
const mouseDown = new MouseEvent("mousedown", { bubbles: true, cancelable: true });
toggle.dispatchEvent(mouseDown);
expect(card.getAttribute("draggable")).toBe("false");
const dragStart = new Event("dragstart", { bubbles: true, cancelable: true }) as DragEvent;
card.dispatchEvent(dragStart);
expect(dragStart.defaultPrevented).toBe(true);
expect((view as any).draggedTaskPath).toBeNull();
expect(card.classList.contains("task-card--dragging")).toBe(false);
expect(card.getAttribute("draggable")).toBe("true");
});
it("opens the preview slot after the hovered card when dropping below the last item in a group", () => {
const view = createView();
const itemsContainer = document.createElement("div");
const lastCardInGroup = document.createElement("div");
const nextGroupHeader = document.createElement("div");
lastCardInGroup.className = "task-card";
nextGroupHeader.className = "task-section task-group";
itemsContainer.appendChild(lastCardInGroup);
itemsContainer.appendChild(nextGroupHeader);
(view as any).itemsContainer = itemsContainer;
(view as any).updateDropSlotPreview({
groupKey: "alpha",
insertionIndex: 1,
element: lastCardInGroup,
position: "after",
});
expect(lastCardInGroup.classList.contains("task-list-view__drop-slot-after")).toBe(true);
expect(nextGroupHeader.classList.contains("task-list-view__drop-slot-before")).toBe(false);
});
it("resolves the closest insertion slot at grouped boundaries", () => {
const view = createView();
const itemsContainer = document.createElement("div");
const firstCard = document.createElement("div");
const groupHeader = document.createElement("div");
const secondCard = document.createElement("div");
firstCard.className = "task-card";
firstCard.dataset.taskPath = "tasks/first.md";
secondCard.className = "task-card";
secondCard.dataset.taskPath = "tasks/second.md";
groupHeader.className = "task-section task-group";
itemsContainer.getBoundingClientRect = () => ({
top: 0,
bottom: 200,
height: 200,
} as DOMRect);
firstCard.getBoundingClientRect = () => ({
top: 0,
bottom: 40,
height: 40,
} as DOMRect);
secondCard.getBoundingClientRect = () => ({
top: 120,
bottom: 160,
height: 40,
} as DOMRect);
itemsContainer.appendChild(firstCard);
itemsContainer.appendChild(groupHeader);
itemsContainer.appendChild(secondCard);
(view as any).itemsContainer = itemsContainer;
(view as any).taskGroupKeys.set("tasks/first.md", "alpha");
(view as any).taskGroupKeys.set("tasks/second.md", "beta");
expect((view as any).resolveClosestInsertionSlot(70)).toMatchObject({
groupKey: "alpha",
segmentIndex: 0,
insertionIndex: 1,
element: firstCard,
position: "after",
});
expect((view as any).resolveClosestInsertionSlot(105)).toMatchObject({
groupKey: "beta",
segmentIndex: 1,
insertionIndex: 0,
element: secondCard,
position: "before",
});
});
it("ignores nested relationship cards when resolving an insertion slot", () => {
const view = createView();
const itemsContainer = document.createElement("div");
const firstCard = document.createElement("div");
const nestedSubtask = document.createElement("div");
const secondCard = document.createElement("div");
const subtasksContainer = document.createElement("div");
firstCard.className = "task-card";
firstCard.dataset.taskPath = "tasks/first.md";
nestedSubtask.className = "task-card task-card--subtask";
nestedSubtask.dataset.taskPath = "tasks/nested-subtask.md";
secondCard.className = "task-card";
secondCard.dataset.taskPath = "tasks/second.md";
subtasksContainer.className = "task-card__subtasks";
itemsContainer.getBoundingClientRect = () => ({
top: 0,
bottom: 220,
height: 220,
} as DOMRect);
firstCard.getBoundingClientRect = () => ({
top: 0,
bottom: 40,
height: 40,
} as DOMRect);
nestedSubtask.getBoundingClientRect = () => ({
top: 50,
bottom: 90,
height: 40,
} as DOMRect);
secondCard.getBoundingClientRect = () => ({
top: 120,
bottom: 160,
height: 40,
} as DOMRect);
subtasksContainer.appendChild(nestedSubtask);
firstCard.appendChild(subtasksContainer);
itemsContainer.appendChild(firstCard);
itemsContainer.appendChild(secondCard);
(view as any).itemsContainer = itemsContainer;
expect((view as any).resolveClosestInsertionSlot(70)).toMatchObject({
groupKey: null,
segmentIndex: 0,
insertionIndex: 1,
element: secondCard,
position: "before",
});
});
it("keeps using the captured insertion boundaries after the preview shifts live card positions", () => {
const view = createView();
const itemsContainer = document.createElement("div");
const firstCard = document.createElement("div");
const secondCard = document.createElement("div");
const thirdCard = document.createElement("div");
firstCard.className = "task-card";
firstCard.dataset.taskPath = "tasks/first.md";
secondCard.className = "task-card";
secondCard.dataset.taskPath = "tasks/second.md";
thirdCard.className = "task-card";
thirdCard.dataset.taskPath = "tasks/third.md";
itemsContainer.getBoundingClientRect = () => ({
top: 0,
bottom: 260,
height: 260,
} as DOMRect);
firstCard.getBoundingClientRect = () => ({
top: 0,
bottom: 40,
height: 40,
} as DOMRect);
secondCard.getBoundingClientRect = () => ({
top: 50,
bottom: 90,
height: 40,
} as DOMRect);
thirdCard.getBoundingClientRect = () => ({
top: 100,
bottom: 140,
height: 40,
} as DOMRect);
itemsContainer.appendChild(firstCard);
itemsContainer.appendChild(secondCard);
itemsContainer.appendChild(thirdCard);
(view as any).itemsContainer = itemsContainer;
(view as any).captureDropBaseline();
secondCard.getBoundingClientRect = () => ({
top: 110,
bottom: 150,
height: 40,
} as DOMRect);
thirdCard.getBoundingClientRect = () => ({
top: 160,
bottom: 200,
height: 40,
} as DOMRect);
expect((view as any).resolveClosestInsertionSlot(150)).toMatchObject({
groupKey: null,
segmentIndex: 0,
insertionIndex: 3,
element: thirdCard,
position: "after",
});
});
it("reconstructs a drop target from the current insertion slot", () => {
const view = createView();
const itemsContainer = document.createElement("div");
const firstCard = document.createElement("div");
const secondCard = document.createElement("div");
firstCard.className = "task-card";
firstCard.dataset.taskPath = "tasks/first.md";
secondCard.className = "task-card";
secondCard.dataset.taskPath = "tasks/second.md";
itemsContainer.getBoundingClientRect = () => ({
top: 0,
bottom: 120,
height: 120,
} as DOMRect);
firstCard.getBoundingClientRect = () => ({
top: 0,
bottom: 40,
height: 40,
} as DOMRect);
secondCard.getBoundingClientRect = () => ({
top: 50,
bottom: 90,
height: 40,
} as DOMRect);
itemsContainer.appendChild(firstCard);
itemsContainer.appendChild(secondCard);
(view as any).itemsContainer = itemsContainer;
(view as any).captureDropBaseline();
expect((view as any).reconstructDropTargetFromInsertionSlot(0, 0)).toEqual({
taskPath: "tasks/first.md",
above: true,
});
expect((view as any).reconstructDropTargetFromInsertionSlot(0, 2)).toEqual({
taskPath: "tasks/second.md",
above: false,
});
});
it("flushes the latest dragover insertion slot instead of recomputing from the drop event coordinates", () => {
const view = createView();
const updateResolvedInsertionSlot = jest
.spyOn(view as any, "updateResolvedInsertionSlot")
.mockReturnValue(true);
const cancelAnimationFrameSpy = jest.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
(view as any).dragOverRafId = 42;
(view as any).pendingDragClientY = 135;
(view as any).currentInsertionGroupKey = "alpha";
(view as any).currentInsertionSegmentIndex = 1;
(view as any).currentInsertionIndex = 2;
expect((view as any).flushPendingInsertionSlot(400)).toBe(true);
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(42);
expect(updateResolvedInsertionSlot).toHaveBeenCalledWith(135);
expect((view as any).dragOverRafId).toBe(0);
cancelAnimationFrameSpy.mockRestore();
updateResolvedInsertionSlot.mockRestore();
});
it("keeps repeated group keys confined to their visible segment when resolving a slot", () => {
const view = createView();
const itemsContainer = document.createElement("div");
const alphaTop = document.createElement("div");
const betaMiddle = document.createElement("div");
const alphaBottom = document.createElement("div");
alphaTop.className = "task-card";
alphaTop.dataset.taskPath = "tasks/alpha-top.md";
betaMiddle.className = "task-card";
betaMiddle.dataset.taskPath = "tasks/beta-middle.md";
alphaBottom.className = "task-card";
alphaBottom.dataset.taskPath = "tasks/alpha-bottom.md";
itemsContainer.getBoundingClientRect = () => ({
top: 0,
bottom: 700,
height: 700,
} as DOMRect);
alphaTop.getBoundingClientRect = () => ({
top: 0,
bottom: 40,
height: 40,
} as DOMRect);
betaMiddle.getBoundingClientRect = () => ({
top: 260,
bottom: 300,
height: 40,
} as DOMRect);
alphaBottom.getBoundingClientRect = () => ({
top: 560,
bottom: 600,
height: 40,
} as DOMRect);
itemsContainer.appendChild(alphaTop);
itemsContainer.appendChild(betaMiddle);
itemsContainer.appendChild(alphaBottom);
(view as any).itemsContainer = itemsContainer;
(view as any).taskGroupKeys.set("tasks/alpha-top.md", "alpha");
(view as any).taskGroupKeys.set("tasks/beta-middle.md", "beta");
(view as any).taskGroupKeys.set("tasks/alpha-bottom.md", "alpha");
expect((view as any).resolveClosestInsertionSlot(360)).toMatchObject({
groupKey: "beta",
segmentIndex: 1,
insertionIndex: 1,
element: betaMiddle,
position: "after",
});
expect((view as any).resolveClosestInsertionSlot(520)).toMatchObject({
groupKey: "alpha",
segmentIndex: 2,
insertionIndex: 0,
element: alphaBottom,
position: "before",
});
});
it("prefers the drag baseline order over cached sort scope paths when computing visible scope", () => {
const view = createView();
const itemsContainer = document.createElement("div");
const firstCard = document.createElement("div");
const secondCard = document.createElement("div");
firstCard.className = "task-card";
firstCard.dataset.taskPath = "tasks/first.md";
secondCard.className = "task-card";
secondCard.dataset.taskPath = "tasks/second.md";
itemsContainer.getBoundingClientRect = () => ({
top: 0,
bottom: 120,
height: 120,
} as DOMRect);
firstCard.getBoundingClientRect = () => ({
top: 0,
bottom: 40,
height: 40,
} as DOMRect);
secondCard.getBoundingClientRect = () => ({
top: 50,
bottom: 90,
height: 40,
} as DOMRect);
itemsContainer.appendChild(firstCard);
itemsContainer.appendChild(secondCard);
(view as any).itemsContainer = itemsContainer;
(view as any).taskGroupKeys.set("tasks/first.md", "alpha");
(view as any).taskGroupKeys.set("tasks/second.md", "alpha");
(view as any).sortScopeTaskPaths.set("alpha", ["tasks/stale.md"]);
(view as any).captureDropBaseline();
expect((view as any).getVisibleSortScopePathsForDrag("alpha")).toEqual([
"tasks/first.md",
"tasks/second.md",
]);
});
});

View file

@ -10,7 +10,7 @@ jest.mock("obsidian");
type FrontmatterMap = Record<string, Record<string, any>>;
function createPlugin(frontmatterByPath: FrontmatterMap, sortOrderField = "tasknotes_order") {
function createPlugin(frontmatterByPath: FrontmatterMap, sortOrderField = "tasknotes_manual_order") {
const processFrontMatter = jest.fn(async (file: TFile, updater: (frontmatter: any) => void) => {
const frontmatter = frontmatterByPath[file.path];
if (!frontmatter) {
@ -66,7 +66,7 @@ describe("sortOrderUtils", () => {
it("initializes a sparse visible run in the dragged display order", async () => {
const plugin = createPlugin({
"ranked.md": { status: "todo", tasknotes_order: "0|hzzzzz:" },
"ranked.md": { status: "todo", tasknotes_manual_order: "0|hzzzzz:" },
"unranked-a.md": { status: "todo" },
"unranked-b.md": { status: "todo" },
"unranked-c.md": { status: "todo" },
@ -99,7 +99,7 @@ describe("sortOrderUtils", () => {
it("uses a cheap boundary insert before the first unranked task in a sparse tail", async () => {
const plugin = createPlugin({
"ranked.md": { status: "todo", tasknotes_order: "0|hzzzzz:" },
"ranked.md": { status: "todo", tasknotes_manual_order: "0|hzzzzz:" },
"unranked-a.md": { status: "todo" },
"unranked-b.md": { status: "todo" },
});
@ -124,9 +124,9 @@ describe("sortOrderUtils", () => {
it("isolates kanban reorder calculations to the active swimlane scope", async () => {
const plugin = createPlugin({
"alpha-a.md": { status: "todo", project: "Alpha", tasknotes_order: "0|hzzzzz:" },
"alpha-b.md": { status: "todo", project: "Alpha", tasknotes_order: "0|i00007:" },
"beta-a.md": { status: "todo", project: "Beta", tasknotes_order: "0|zzzzzz:" },
"alpha-a.md": { status: "todo", project: "Alpha", tasknotes_manual_order: "0|hzzzzz:" },
"alpha-b.md": { status: "todo", project: "Alpha", tasknotes_manual_order: "0|i00007:" },
"beta-a.md": { status: "todo", project: "Beta", tasknotes_manual_order: "0|zzzzzz:" },
});
const plan = await prepareSortOrderUpdate(
@ -148,10 +148,94 @@ describe("sortOrderUtils", () => {
expect(plan.sortOrder!.localeCompare("0|i00007:")).toBeLessThan(0);
});
it("uses the visible list order as the authoritative drop scope", async () => {
const plugin = createPlugin({
"alpha.md": { status: "todo", tasknotes_manual_order: "0|hzzzzz:" },
"visible-last.md": { status: "todo", tasknotes_manual_order: "0|i00007:" },
"hidden-after.md": { status: "todo", tasknotes_manual_order: "0|i0000f:" },
});
const plan = await prepareSortOrderUpdate(
"visible-last.md",
false,
"todo",
"status",
"dragged.md",
plugin,
{
visibleTaskPaths: ["alpha.md", "visible-last.md"],
}
);
expect(plan.reason).toBe("boundary");
expect(plan.additionalWrites).toEqual([]);
expect(plan.sortOrder!.localeCompare("0|i00007:")).toBeGreaterThan(0);
});
it("respects descending visible sort order when inserting above a target", async () => {
const plugin = createPlugin({
"previous.md": { status: "todo", tasknotes_manual_order: "0|jc3j7d:" },
"target.md": { status: "todo", tasknotes_manual_order: "0|jc2tkt:" },
"next.md": { status: "todo", tasknotes_manual_order: "0|jc0oo7:" },
});
const plan = await prepareSortOrderUpdate(
"target.md",
true,
"todo",
"status",
"dragged.md",
plugin,
{
visibleTaskPaths: ["previous.md", "target.md", "next.md"],
}
);
expect(plan.reason).toBe("midpoint");
expect(plan.additionalWrites).toEqual([]);
expect(plan.sortOrder!.localeCompare("0|jc3j7d:")).toBeLessThan(0);
expect(plan.sortOrder!.localeCompare("0|jc2tkt:")).toBeGreaterThan(0);
});
it("rebalances descending duplicate boundaries instead of jumping above the previous task", async () => {
const plugin = createPlugin({
"previous.md": { status: "todo", tasknotes_manual_order: "0|jc3j7l:" },
"target.md": { status: "todo", tasknotes_manual_order: "0|jc3j7l:" },
"next.md": { status: "todo", tasknotes_manual_order: "0|jc3j7d:" },
});
const plan = await prepareSortOrderUpdate(
"target.md",
true,
"todo",
"status",
"dragged.md",
plugin,
{
visibleTaskPaths: ["previous.md", "target.md", "next.md"],
}
);
expect(plan.reason).toBe("rebalance");
expect(plan.additionalWrites.map((write) => write.path)).toEqual([
"previous.md",
"target.md",
"next.md",
]);
const previousRank = plan.additionalWrites.find((write) => write.path === "previous.md")!.sortOrder;
const targetRank = plan.additionalWrites.find((write) => write.path === "target.md")!.sortOrder;
const nextRank = plan.additionalWrites.find((write) => write.path === "next.md")!.sortOrder;
expect(previousRank.localeCompare(plan.sortOrder!)).toBeGreaterThan(0);
expect(plan.sortOrder!.localeCompare(targetRank)).toBeGreaterThan(0);
expect(targetRank.localeCompare(nextRank)).toBeGreaterThan(0);
});
it("rebalances oversized sparse scopes into compact ranks", async () => {
const oversizedRank = `0|zhzzzz:${"i".repeat(120)}`;
const plugin = createPlugin({
"seed.md": { status: "todo", tasknotes_order: oversizedRank },
"seed.md": { status: "todo", tasknotes_manual_order: oversizedRank },
"unranked-a.md": { status: "todo" },
"unranked-b.md": { status: "todo" },
});