mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Make tracks draggable to reorder
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
/* ── Track ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
/* --pt-tile-w / --pt-tile-h / --pt-tile-footer-h / --pt-gap come from
|
||||
constants.ts, so the virtualiser and the CSS agree on tile geometry. */
|
||||
/* The scroller is a flex column: without this, an overflowing stack of
|
||||
@@ -36,6 +37,7 @@
|
||||
}
|
||||
|
||||
.trackHeader {
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
@@ -231,6 +233,37 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* ── Track reorder ───────────────────────────────────────────────────────── */
|
||||
|
||||
.trackDragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Same trick as the page marker: drawn on the track rather than inserted
|
||||
between them, so nothing shifts while the pointer is deciding. */
|
||||
.trackDropBefore::before,
|
||||
.trackDropAfterLast::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--c-primary);
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* Drawn just inside the edge, not in the gap above it: .track clips its own
|
||||
overflow to round the lane's corners, so an outside offset is invisible. */
|
||||
.trackDropBefore::before {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.trackDropAfterLast::after {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* ── Drop indicator ──────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { useFileState } from "@app/contexts/FileContext";
|
||||
import { useFileActions, useFileState } from "@app/contexts/FileContext";
|
||||
import {
|
||||
useNavigationActions,
|
||||
useNavigationGuard,
|
||||
@@ -32,17 +32,30 @@ import styles from "@app/components/pageTracks/PageTracks.module.css";
|
||||
|
||||
const PAGE_PREFIX = "page:";
|
||||
const TRACK_PREFIX = "track:";
|
||||
const ZONE_PREFIX = "zone:";
|
||||
const HANDLE_PREFIX = "trackhandle:";
|
||||
|
||||
/**
|
||||
* Prefer the page under the pointer over the track that contains it: the
|
||||
* lane is a droppable too, so nested hits need an explicit ordering.
|
||||
* Droppables nest (zone > lane > page), so the hit has to be picked by what is
|
||||
* being dragged: a track header only ever lands on a whole track, while a page
|
||||
* prefers the tile under the pointer and falls back to the track around it.
|
||||
*/
|
||||
const collisionDetection: CollisionDetection = (args) => {
|
||||
const within = pointerWithin(args);
|
||||
const page = within.find((c) => String(c.id).startsWith(PAGE_PREFIX));
|
||||
const first = (prefix: string) =>
|
||||
within.find((c) => String(c.id).startsWith(prefix));
|
||||
|
||||
if (args.active.data.current?.type === "trackHandle") {
|
||||
const zone = first(ZONE_PREFIX);
|
||||
return zone ? [zone] : [];
|
||||
}
|
||||
|
||||
const page = first(PAGE_PREFIX);
|
||||
if (page) return [page];
|
||||
const track = within.find((c) => String(c.id).startsWith(TRACK_PREFIX));
|
||||
if (track) return [track];
|
||||
const lane = first(TRACK_PREFIX);
|
||||
if (lane) return [lane];
|
||||
const zone = first(ZONE_PREFIX);
|
||||
if (zone) return [zone];
|
||||
return rectIntersection(args);
|
||||
};
|
||||
|
||||
@@ -58,6 +71,17 @@ const sameHint = (a: DropHint | null, b: DropHint | null): boolean =>
|
||||
* the drag delta. Using this rather than a live pointermove listener keeps the
|
||||
* side-of-tile decision consistent with the reported collision.
|
||||
*/
|
||||
function pointerYOf(event: DragMoveEvent | DragEndEvent): number {
|
||||
const activator = event.activatorEvent;
|
||||
const originY =
|
||||
activator instanceof MouseEvent
|
||||
? activator.clientY
|
||||
: activator instanceof TouchEvent && activator.touches.length > 0
|
||||
? activator.touches[0].clientY
|
||||
: 0;
|
||||
return originY + event.delta.y;
|
||||
}
|
||||
|
||||
function pointerXOf(event: DragMoveEvent | DragEndEvent): number {
|
||||
const activator = event.activatorEvent;
|
||||
const originX =
|
||||
@@ -123,6 +147,11 @@ export default function PageTracks() {
|
||||
() => new Set<string>(),
|
||||
);
|
||||
const [dropHint, setDropHint] = useState<DropHint | null>(null);
|
||||
const [draggingTrack, setDraggingTrack] = useState<FileId | null>(null);
|
||||
// undefined = no target, null = append to the end.
|
||||
const [trackDropTarget, setTrackDropTarget] = useState<
|
||||
FileId | null | undefined
|
||||
>(undefined);
|
||||
|
||||
const totalPages = useMemo(() => totalPageCount(workspace), [workspace]);
|
||||
const changedSet = useMemo(() => new Set(changedFileIds), [changedFileIds]);
|
||||
@@ -152,6 +181,40 @@ export default function PageTracks() {
|
||||
|
||||
const clearSelection = selection.clear;
|
||||
|
||||
const { actions: fileActions } = useFileActions();
|
||||
|
||||
/**
|
||||
* Moves one track before `beforeFileId` (or to the end when null). Track
|
||||
* order IS the workbench file order, and REORDER_FILES replaces the whole id
|
||||
* list, so non-PDFs (which have no track) must be written back in place or
|
||||
* they would drop out of the workbench entirely.
|
||||
*/
|
||||
const reorderTracks = useCallback(
|
||||
(sourceFileId: FileId, beforeFileId: FileId | null) => {
|
||||
if (sourceFileId === beforeFileId) return;
|
||||
const trackOrder = workspace.order.filter((id) => id !== sourceFileId);
|
||||
const at =
|
||||
beforeFileId == null
|
||||
? trackOrder.length
|
||||
: trackOrder.indexOf(beforeFileId);
|
||||
const insertAt = at === -1 ? trackOrder.length : at;
|
||||
const nextTrackOrder = [
|
||||
...trackOrder.slice(0, insertAt),
|
||||
sourceFileId,
|
||||
...trackOrder.slice(insertAt),
|
||||
];
|
||||
if (nextTrackOrder.every((id, i) => workspace.order[i] === id)) return;
|
||||
|
||||
const isTrack = new Set(workspace.order);
|
||||
let next = 0;
|
||||
const merged = fileState.files.ids.map((id) =>
|
||||
isTrack.has(id) ? nextTrackOrder[next++] : id,
|
||||
);
|
||||
fileActions.reorderFiles(merged);
|
||||
},
|
||||
[fileActions, fileState.files.ids, workspace.order],
|
||||
);
|
||||
|
||||
// ── Drag and drop ────────────────────────────────────────────────────────
|
||||
|
||||
const sensors = useSensors(
|
||||
@@ -179,8 +242,10 @@ export default function PageTracks() {
|
||||
(overId: string | null, pointerX: number): DropHint | null => {
|
||||
if (!overId) return null;
|
||||
|
||||
if (overId.startsWith(TRACK_PREFIX)) {
|
||||
const fileId = overId.slice(TRACK_PREFIX.length) as FileId;
|
||||
// The lane, or anywhere else on the track: append to it.
|
||||
for (const prefix of [TRACK_PREFIX, ZONE_PREFIX]) {
|
||||
if (!overId.startsWith(prefix)) continue;
|
||||
const fileId = overId.slice(prefix.length) as FileId;
|
||||
if (!workspace.tracks[fileId]) return null;
|
||||
return { fileId, beforePageId: null };
|
||||
}
|
||||
@@ -206,9 +271,34 @@ export default function PageTracks() {
|
||||
[trackByPageId, workspace],
|
||||
);
|
||||
|
||||
/**
|
||||
* Track reorder target: insert before this file, or append when null.
|
||||
*/
|
||||
const resolveTrackHint = useCallback(
|
||||
(overId: string | null, pointerY: number): FileId | null | undefined => {
|
||||
if (!overId || !overId.startsWith(ZONE_PREFIX)) return undefined;
|
||||
const overFileId = overId.slice(ZONE_PREFIX.length) as FileId;
|
||||
const index = workspace.order.indexOf(overFileId);
|
||||
if (index === -1) return undefined;
|
||||
|
||||
const element = document.querySelector<HTMLElement>(
|
||||
`[data-track-file-id="${overFileId}"]`,
|
||||
);
|
||||
const rect = element?.getBoundingClientRect();
|
||||
const dropAfter = rect ? pointerY > rect.top + rect.height / 2 : false;
|
||||
return workspace.order[dropAfter ? index + 1 : index] ?? null;
|
||||
},
|
||||
[workspace.order],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const pageId = String(event.active.id).slice(PAGE_PREFIX.length);
|
||||
const activeId = String(event.active.id);
|
||||
if (activeId.startsWith(HANDLE_PREFIX)) {
|
||||
setDraggingTrack(activeId.slice(HANDLE_PREFIX.length) as FileId);
|
||||
return;
|
||||
}
|
||||
const pageId = activeId.slice(PAGE_PREFIX.length);
|
||||
// Dragging a page that is part of the selection moves the whole
|
||||
// selection; dragging an unselected page moves only that page.
|
||||
const ids = selection.selectedIds.has(pageId)
|
||||
@@ -224,6 +314,14 @@ export default function PageTracks() {
|
||||
// latter. Recomputing per move is what keeps the marker and the drop in sync.
|
||||
const handleDragMove = useCallback(
|
||||
(event: DragMoveEvent) => {
|
||||
if (draggingTrack) {
|
||||
const target = resolveTrackHint(
|
||||
event.over ? String(event.over.id) : null,
|
||||
pointerYOf(event),
|
||||
);
|
||||
setTrackDropTarget(target === undefined ? undefined : target);
|
||||
return;
|
||||
}
|
||||
const next = resolveHint(
|
||||
event.over ? String(event.over.id) : null,
|
||||
pointerXOf(event),
|
||||
@@ -232,11 +330,23 @@ export default function PageTracks() {
|
||||
// object bails the re-render out, so only a real change costs anything.
|
||||
setDropHint((prev) => (sameHint(prev, next) ? prev : next));
|
||||
},
|
||||
[resolveHint],
|
||||
[draggingTrack, resolveHint, resolveTrackHint],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
if (draggingTrack) {
|
||||
const target = resolveTrackHint(
|
||||
event.over ? String(event.over.id) : null,
|
||||
pointerYOf(event),
|
||||
);
|
||||
const source = draggingTrack;
|
||||
setDraggingTrack(null);
|
||||
setTrackDropTarget(undefined);
|
||||
if (target !== undefined) reorderTracks(source, target);
|
||||
return;
|
||||
}
|
||||
|
||||
const hint = resolveHint(
|
||||
event.over ? String(event.over.id) : null,
|
||||
pointerXOf(event),
|
||||
@@ -251,12 +361,21 @@ export default function PageTracks() {
|
||||
beforePageId: hint.beforePageId,
|
||||
});
|
||||
},
|
||||
[dispatch, draggingIds, resolveHint],
|
||||
[
|
||||
dispatch,
|
||||
draggingIds,
|
||||
draggingTrack,
|
||||
reorderTracks,
|
||||
resolveHint,
|
||||
resolveTrackHint,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setDraggingIds(new Set());
|
||||
setDropHint(null);
|
||||
setDraggingTrack(null);
|
||||
setTrackDropTarget(undefined);
|
||||
}, []);
|
||||
|
||||
// ── Save + navigation guard ──────────────────────────────────────────────
|
||||
@@ -388,6 +507,15 @@ export default function PageTracks() {
|
||||
selectedIds={selection.selectedIds}
|
||||
draggingIds={draggingIds}
|
||||
dropHint={dropHint}
|
||||
trackDropBefore={
|
||||
draggingTrack != null && trackDropTarget === fileId
|
||||
}
|
||||
trackDropAfterLast={
|
||||
draggingTrack != null &&
|
||||
trackDropTarget === null &&
|
||||
fileId === workspace.order[workspace.order.length - 1]
|
||||
}
|
||||
trackDragging={draggingTrack === fileId}
|
||||
changed={changedSet.has(fileId)}
|
||||
thumbnails={thumbnails}
|
||||
onSelectPage={selection.selectPage}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useMemo, useRef } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
|
||||
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
import styles from "@app/components/pageTracks/PageTracks.module.css";
|
||||
|
||||
export const trackDroppableId = (fileId: FileId) => `track:${fileId}`;
|
||||
/** Whole-track drop zone, used when a track header is being dragged. */
|
||||
export const trackZoneId = (fileId: FileId) => `zone:${fileId}`;
|
||||
export const trackHandleId = (fileId: FileId) => `trackhandle:${fileId}`;
|
||||
|
||||
export interface DropHint {
|
||||
fileId: FileId;
|
||||
@@ -35,6 +38,12 @@ export interface TrackRowProps {
|
||||
selectedIds: Set<string>;
|
||||
draggingIds: Set<string>;
|
||||
dropHint: DropHint | null;
|
||||
/** Draw the track-reorder line above this track. */
|
||||
trackDropBefore: boolean;
|
||||
/** Draw it below (last track, moving to the end). */
|
||||
trackDropAfterLast: boolean;
|
||||
/** This track's header is the one being dragged. */
|
||||
trackDragging: boolean;
|
||||
changed: boolean;
|
||||
thumbnails: TrackThumbnailStore;
|
||||
onSelectPage: (
|
||||
@@ -57,6 +66,9 @@ function TrackRowImpl({
|
||||
selectedIds,
|
||||
draggingIds,
|
||||
dropHint,
|
||||
trackDropBefore,
|
||||
trackDropAfterLast,
|
||||
trackDragging,
|
||||
changed,
|
||||
thumbnails,
|
||||
onSelectPage,
|
||||
@@ -72,6 +84,21 @@ function TrackRowImpl({
|
||||
data: { type: "track", fileId: track.fileId },
|
||||
});
|
||||
|
||||
// Reordering tracks: the header is the handle, the whole section the target.
|
||||
// Only the pointer listeners are applied, deliberately NOT dnd-kit's ARIA
|
||||
// attributes: those would make the header a role="button" whose accessible
|
||||
// name is everything inside it, with the real controls nested inside.
|
||||
const { listeners: handleListeners, setNodeRef: setHandleRef } = useDraggable(
|
||||
{
|
||||
id: trackHandleId(track.fileId),
|
||||
data: { type: "trackHandle", fileId: track.fileId },
|
||||
},
|
||||
);
|
||||
const { setNodeRef: setZoneRef } = useDroppable({
|
||||
id: trackZoneId(track.fileId),
|
||||
data: { type: "zone", fileId: track.fileId },
|
||||
});
|
||||
|
||||
// A lane can hold hundreds of pages. Mounting them all is what made a single
|
||||
// click cost ~700ms and a drag ~300ms per pointer move: every tile is a
|
||||
// dnd-kit draggable AND droppable, so the whole set gets re-registered on
|
||||
@@ -126,15 +153,27 @@ function TrackRowImpl({
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={setZoneRef}
|
||||
style={geometry.cssVars}
|
||||
className={[styles.track, isOver ? styles.trackDropActive : ""]
|
||||
className={[
|
||||
styles.track,
|
||||
isOver ? styles.trackDropActive : "",
|
||||
trackDragging ? styles.trackDragging : "",
|
||||
trackDropBefore ? styles.trackDropBefore : "",
|
||||
trackDropAfterLast ? styles.trackDropAfterLast : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
data-track-file-id={track.fileId}
|
||||
data-changed={changed}
|
||||
data-track-drop-before={trackDropBefore || undefined}
|
||||
aria-label={name}
|
||||
>
|
||||
<header className={styles.trackHeader}>
|
||||
<header
|
||||
ref={setHandleRef}
|
||||
className={styles.trackHeader}
|
||||
{...handleListeners}
|
||||
>
|
||||
<span className={styles.trackName} title={name}>
|
||||
{name}
|
||||
</span>
|
||||
|
||||
@@ -184,6 +184,26 @@ describe("trackEditorReducer history", () => {
|
||||
expect(changedTrackIds(state)).toEqual([A]);
|
||||
});
|
||||
|
||||
it("keeps history when tracks are merely reordered", () => {
|
||||
let state = twoTracks();
|
||||
state = trackEditorReducer(state, {
|
||||
type: "delete",
|
||||
pageIds: [pagesOf(state, A)[0].id],
|
||||
});
|
||||
expect(state.past).toHaveLength(1);
|
||||
|
||||
state = sync(state, [source(B, 2), source(A, 3, [0, 90, 0])]);
|
||||
|
||||
expect(state.present.order).toEqual([B, A]);
|
||||
expect(state.past).toHaveLength(1);
|
||||
// The pending delete survives the reorder rather than being re-baselined.
|
||||
expect(pagesOf(state, A)).toHaveLength(2);
|
||||
expect(changedTrackIds(state)).toEqual([A]);
|
||||
|
||||
state = trackEditorReducer(state, { type: "undo" });
|
||||
expect(pagesOf(state, A)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("clears history on a file-set change, since undo could revive dead pages", () => {
|
||||
let state = twoTracks();
|
||||
state = trackEditorReducer(state, {
|
||||
|
||||
@@ -118,6 +118,21 @@ function syncSources(
|
||||
);
|
||||
if (orderUnchanged && signaturesUnchanged) return state;
|
||||
|
||||
// A pure permutation (dragging tracks around) touches no page, so the undo
|
||||
// history stays valid: only a changed FILE SET can leave an entry pointing at
|
||||
// pages that no longer exist.
|
||||
const sameFileSet =
|
||||
order.length === state.present.order.length &&
|
||||
order.every((id) => state.present.tracks[id] != null);
|
||||
if (sameFileSet && signaturesUnchanged) {
|
||||
return {
|
||||
...state,
|
||||
present: { order, tracks: state.present.tracks },
|
||||
baseline: { order, tracks: state.baseline.tracks },
|
||||
sourceSignatures: signatures,
|
||||
};
|
||||
}
|
||||
|
||||
const liveIds = new Set(order);
|
||||
let seq = state.seq;
|
||||
const tracks: Record<FileId, Track> = {};
|
||||
|
||||
@@ -109,6 +109,48 @@ async function dragPageOnto(
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/** The order of the tracks, top to bottom. */
|
||||
async function trackOrder(page: import("@playwright/test").Page) {
|
||||
return page
|
||||
.locator("section[aria-label] header")
|
||||
.evaluateAll((headers) =>
|
||||
headers.map(
|
||||
(h) =>
|
||||
(h.closest("section") as HTMLElement).getAttribute("aria-label") ??
|
||||
"",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Drags a track's header onto another track, vertically. */
|
||||
async function dragTrackOnto(
|
||||
page: import("@playwright/test").Page,
|
||||
sourceName: string,
|
||||
targetName: string,
|
||||
fractionDown = 0.25,
|
||||
) {
|
||||
const from = await page
|
||||
.locator(`section[aria-label="${sourceName}"] header`)
|
||||
.boundingBox();
|
||||
const target = await page
|
||||
.locator(`section[aria-label="${targetName}"]`)
|
||||
.boundingBox();
|
||||
if (!from || !target) throw new Error("tracks are not laid out");
|
||||
|
||||
await page.mouse.move(from.x + 140, from.y + from.height / 2);
|
||||
await page.mouse.down();
|
||||
const toX = target.x + 140;
|
||||
const toY = target.y + target.height * fractionDown;
|
||||
for (const step of [0.3, 0.7, 1]) {
|
||||
await page.mouse.move(
|
||||
from.x + 140 + (toX - (from.x + 140)) * step,
|
||||
from.y + from.height / 2 + (toY - (from.y + from.height / 2)) * step,
|
||||
{ steps: 8 },
|
||||
);
|
||||
}
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function openPageEditor(page: import("@playwright/test").Page) {
|
||||
await page.goto("/editor", {
|
||||
waitUntil: "domcontentloaded",
|
||||
@@ -536,4 +578,56 @@ test.describe("Page Editor tracks", () => {
|
||||
await expect(saved).toContainText("v2", { timeout: 90_000 });
|
||||
await expect(saved.locator("[data-page-id]")).toHaveCount(3);
|
||||
});
|
||||
|
||||
test("dragging a track header reorders the tracks", async ({ page }) => {
|
||||
await openPageEditor(page);
|
||||
await expect(
|
||||
track(page, "sample.pdf").locator("[data-page-id]"),
|
||||
).toHaveCount(1, { timeout: 30_000 });
|
||||
expect(await trackOrder(page)).toEqual(["rotated-pages.pdf", "sample.pdf"]);
|
||||
|
||||
// Drop sample.pdf on the top half of rotated-pages.pdf: above it.
|
||||
await dragTrackOnto(page, "sample.pdf", "rotated-pages.pdf", 0.2);
|
||||
await expect
|
||||
.poll(async () => trackOrder(page), { timeout: 30_000 })
|
||||
.toEqual(["sample.pdf", "rotated-pages.pdf"]);
|
||||
|
||||
// Pages stayed with their own files rather than moving between tracks.
|
||||
await expect(
|
||||
track(page, "sample.pdf").locator("[data-page-id]"),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
track(page, "rotated-pages.pdf").locator("[data-page-id]"),
|
||||
).toHaveCount(4);
|
||||
await expect(track(page, "rotated-pages.pdf")).not.toContainText("edited");
|
||||
});
|
||||
|
||||
test("reordering tracks keeps pending page edits and their undo history", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openPageEditor(page);
|
||||
const rotated = track(page, "rotated-pages.pdf");
|
||||
const tiles = rotated.locator("[data-page-id]");
|
||||
await expect(tiles).toHaveCount(4, { timeout: 30_000 });
|
||||
|
||||
const last = tiles.nth(3);
|
||||
await last.hover();
|
||||
await last.getByRole("button", { name: "Delete page" }).click();
|
||||
await expect(tiles).toHaveCount(3);
|
||||
|
||||
await dragTrackOnto(page, "sample.pdf", "rotated-pages.pdf", 0.2);
|
||||
await expect
|
||||
.poll(async () => trackOrder(page), { timeout: 30_000 })
|
||||
.toEqual(["sample.pdf", "rotated-pages.pdf"]);
|
||||
|
||||
// The edit is still pending, not silently re-baselined by the reorder.
|
||||
const moved = track(page, "rotated-pages.pdf");
|
||||
await expect(moved.locator("[data-page-id]")).toHaveCount(3);
|
||||
await expect(moved).toContainText("edited");
|
||||
|
||||
// And it is still undoable.
|
||||
await page.getByRole("button", { name: "Undo" }).click();
|
||||
await expect(moved.locator("[data-page-id]")).toHaveCount(4);
|
||||
await expect(moved).not.toContainText("edited");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user