Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 1 addition & 13 deletions src/gui/choiceList/ChoiceList.crosszone.test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,8 @@
import { actionsSpy } from "../../../tests/helpers/settings/choiceActions";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render } from "@testing-library/svelte";
import { SHADOW_PLACEHOLDER_ITEM_ID, TRIGGERS } from "svelte-dnd-action";

// jsdom lacks the Web Animations API that svelte's animate:flip touches when a keyed
// {#each} removes a row (the cross-zone strip below). Stub it so the reorder doesn't throw.
beforeAll(() => {
const proto = Element.prototype as unknown as {
getAnimations?: () => unknown[];
animate?: () => unknown;
};
if (!proto.getAnimations) proto.getAnimations = () => [];
if (!proto.animate)
proto.animate = () => ({ cancel() {}, finished: Promise.resolve() });
});

import { App } from "obsidian";
import ChoiceList from "./ChoiceList.svelte";
import type IChoice from "../../types/choices/IChoice";
Expand Down
17 changes: 6 additions & 11 deletions src/gui/choiceList/ChoiceList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import ChoiceListItem from "./ChoiceListItem.svelte";
import MultiChoiceListItem from "./MultiChoiceListItem.svelte";
import { alertToScreenReader, type DndEvent, dndzone, TRIGGERS } from "svelte-dnd-action";
import { flip } from "svelte/animate";
import { baseDndOptions, capturePlaceholderRecovery, moveById, type PlaceholderRecovery, stripShadow } from "../shared/dndReorder";
import { refocusDragHandle } from "../shared/refocusDragHandle";
import { createDragArming } from "../shared/dragArming.svelte";
Expand Down Expand Up @@ -79,12 +78,6 @@
// own handler IS the top-level handler; nested lists receive it explicitly.
const persistRoots = $derived(rootReorder ?? actions.onReorderChoices);

// flipDurationMs MUST be 0 for a responsive reorder: the library ties its
// position-observation interval to it — 0 => 20ms polling (continuous), any value
// > 0 => max(flip,100)*1.07 ≈ 107ms+, which felt "batched" (move several rows, then
// a jump). We trade the row-glide animation for continuous, predictable reordering.
const flipDurationMs = 0;

const isMobile = Platform.isMobile;

let collapseId = $state("");
Expand Down Expand Up @@ -190,18 +183,20 @@

<div
bind:this={listEl}
use:dndzone={baseDndOptions({items: renderable, dragDisabled, flipDurationMs, dropTargetClasses: nested ? ["qa-folder-droptarget"] : []})}
use:dndzone={baseDndOptions({items: renderable, dragDisabled, dropTargetClasses: nested ? ["qa-folder-droptarget"] : []})}
onconsider={handleConsider}
onfinalize={handleSort}
class="choiceList"
class:qa-nested={nested}
class:qa-folder-empty={isEmptyFolder}
class:qa-empty={renderable.length === 0}>
{#each stripShadow(renderable) as choice (choice.id)}
<!-- Flip wrapper: the dndzone's direct child = the animated/draggable item.
<!-- Row wrapper: the dndzone's direct child = the draggable item.
Must stay margin/padding/border-less (the 2px inter-row margin lives on
the inner row). data-choice-id stays on the inner row for tests/menus. -->
<div animate:flip={{ duration: flipDurationMs }}>
the inner row). data-choice-id stays on the inner row for tests/menus.
No animate:flip: the zone's flip is 0 ms, so it would move nothing,
and Svelte's animate measures every row whenever a row leaves. -->
<div>
{#if choice.type !== "Multi"}
<ChoiceListItem
{app}
Expand Down
24 changes: 24 additions & 0 deletions src/gui/choiceList/ChoiceView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,30 @@ describe("ChoiceView", () => {
]);
});

// #2107: narrowing the filter must not lay out the list once per row that
// leaves. Svelte's animate measures every row (getBoundingClientRect) when a
// keyed list loses one, which took ~400 ms a keystroke at a few hundred rows.
it("does not measure the rows when the filter drops some", async () => {
const choices = Array.from({ length: 20 }, (_, i) => ({
id: `c${i}`,
name: i % 2 === 0 ? `Capture ${i}` : `Template ${i}`,
type: "Template",
})) as unknown as IChoice[];
const { container, getByPlaceholderText } = renderChoiceView(choices);
const filter = getByPlaceholderText("Filter choices...");
await fireEvent.input(filter, { target: { value: "t" } });
expect(container.querySelectorAll(".choiceListItem")).toHaveLength(20);

const measure = vi.spyOn(Element.prototype, "getBoundingClientRect");
try {
await fireEvent.input(filter, { target: { value: "capture" } });
expect(container.querySelectorAll(".choiceListItem")).toHaveLength(10);
expect(measure).not.toHaveBeenCalled();
} finally {
measure.mockRestore();
}
});

// Covers the redesign's novel add-into-folder path end-to-end through the
// real component DOM (the per-folder "New folder" affordance), which the
// Obsidian Menu / builder modal can't be synthetically driven to exercise.
Expand Down
14 changes: 8 additions & 6 deletions src/gui/shared/dndReorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,24 +92,26 @@ export function moveById<T extends Reorderable>(
* (see the alertToScreenReader calls on keyboard reorder),
* - zoneItemTabIndex:-1 keeps rows out of the tab order,
* - delayTouchStart gates touch drags (desktop is gated by the dragArmed handle).
* Per-zone overrides: items, dragDisabled, type, dropTargetClasses, flipDurationMs (kept
* in sync with animate:flip), resolveLabel (the pill text — defaults to item.name;
* the macro builder passes getCommandDisplayName, since a command's `.name` differs from
* its rendered label for Choice/Conditional commands).
* Per-zone overrides: items, dragDisabled, type, dropTargetClasses, resolveLabel (the
* pill text — defaults to item.name; the macro builder passes getCommandDisplayName,
* since a command's `.name` differs from its rendered label for Choice/Conditional
* commands).
*/
export function baseDndOptions<T extends DragItem>(opts: {
items: T[];
dragDisabled: boolean;
resolveLabel?: (item: T) => string;
type?: string;
dropTargetClasses?: string[];
flipDurationMs?: number;
}) {
const resolveLabel = opts.resolveLabel ?? ((item: T) => item.name ?? "");
return {
items: opts.items,
dragDisabled: opts.dragDisabled,
flipDurationMs: opts.flipDurationMs ?? 0,
// 0 keeps the reorder continuous: the library ties its position-observation
// interval to it (0 => 20ms polling; any value > 0 => ~107ms+, which felt
// "batched"). So the zones skip the row-glide animation.
flipDurationMs: 0,
morphDisabled: true,
useCursorForDetection: true,
centreDraggedOnCursor: false,
Expand Down
Loading