Merge pull request #188 from Coffey-Labs/fix/ctrl-click-selection
Take the message you were reading into the selection
This commit is contained in:
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { rowClick, type RowClick } from "@/lib/listSelection";
|
||||||
|
|
||||||
|
const IDS = ["a", "b", "c", "d", "e"];
|
||||||
|
const click = (over: Partial<Parameters<typeof rowClick>[0]> = {}): RowClick =>
|
||||||
|
rowClick({
|
||||||
|
rowId: "c", ids: IDS, anchor: null, selected: {},
|
||||||
|
modifiers: { shift: false, ctrl: false }, isMobile: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a plain click", () => {
|
||||||
|
it("opens the message rather than selecting it", () => {
|
||||||
|
expect(click()).toEqual({ kind: "open" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens it even when another message is already open", () => {
|
||||||
|
expect(click({ anchor: "a" })).toEqual({ kind: "open" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("goes on selecting on a touchscreen once a selection exists", () => {
|
||||||
|
// There is no modifier to hold on a phone, and opening a message in the
|
||||||
|
// middle of picking several is almost never what the tap meant.
|
||||||
|
expect(click({ isMobile: true, selected: { a: true } })).toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still opens on a touchscreen when nothing is selected", () => {
|
||||||
|
expect(click({ isMobile: true })).toEqual({ kind: "open" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ctrl-clicking", () => {
|
||||||
|
it("takes the message that was already current with it", () => {
|
||||||
|
// Issue #186: this used to select only the row clicked, leaving the open
|
||||||
|
// message highlighted but unticked, so actions applied to one of two.
|
||||||
|
expect(click({ anchor: "a", modifiers: { shift: false, ctrl: true } }))
|
||||||
|
.toEqual({ kind: "select", ids: ["a", "c"], on: true, moveAnchor: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles one row once there is a selection, and leaves the rest alone", () => {
|
||||||
|
expect(click({ anchor: "a", selected: { a: true, c: true }, modifiers: { shift: false, ctrl: true } }))
|
||||||
|
.toEqual({ kind: "select", ids: ["c"], on: false, moveAnchor: true });
|
||||||
|
expect(click({ anchor: "a", selected: { a: true }, modifiers: { shift: false, ctrl: true } }))
|
||||||
|
.toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects just the row when there is nothing current to bring along", () => {
|
||||||
|
expect(click({ anchor: null, modifiers: { shift: false, ctrl: true } }))
|
||||||
|
.toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not bring along a row that has scrolled out of the list", () => {
|
||||||
|
// The anchor can name a message from a folder that is no longer shown.
|
||||||
|
expect(click({ anchor: "gone", modifiers: { shift: false, ctrl: true } }))
|
||||||
|
.toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not pair a row with itself", () => {
|
||||||
|
expect(click({ rowId: "a", anchor: "a", modifiers: { shift: false, ctrl: true } }))
|
||||||
|
.toEqual({ kind: "select", ids: ["a"], on: true, moveAnchor: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shift-clicking", () => {
|
||||||
|
it("takes the whole run, including the row it started from", () => {
|
||||||
|
expect(click({ rowId: "d", anchor: "b", modifiers: { shift: true, ctrl: false } }))
|
||||||
|
.toEqual({ kind: "select", ids: ["b", "c", "d"], on: true, moveAnchor: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works the same way backwards", () => {
|
||||||
|
expect(click({ rowId: "b", anchor: "d", modifiers: { shift: true, ctrl: false } }))
|
||||||
|
.toEqual({ kind: "select", ids: ["b", "c", "d"], on: true, moveAnchor: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the anchor where it is, so the range grows from one place", () => {
|
||||||
|
const first = click({ rowId: "c", anchor: "a", modifiers: { shift: true, ctrl: false } });
|
||||||
|
expect(first).toMatchObject({ moveAnchor: false });
|
||||||
|
// Extending again still starts at "a" rather than at "c".
|
||||||
|
expect(click({ rowId: "e", anchor: "a", modifiers: { shift: true, ctrl: false } }))
|
||||||
|
.toMatchObject({ ids: ["a", "b", "c", "d", "e"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to opening when there is nothing to extend from", () => {
|
||||||
|
expect(click({ anchor: null, modifiers: { shift: true, ctrl: false } })).toEqual({ kind: "open" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back when the anchor is no longer in the list", () => {
|
||||||
|
expect(click({ anchor: "gone", modifiers: { shift: true, ctrl: false } })).toEqual({ kind: "open" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the two rules agree with each other", () => {
|
||||||
|
it("both include the row the selection started from", () => {
|
||||||
|
// The bug was that only one of them did. Whatever else changes, a modifier
|
||||||
|
// click that begins a selection has to contain the anchor.
|
||||||
|
const withCtrl = click({ rowId: "d", anchor: "b", modifiers: { shift: false, ctrl: true } });
|
||||||
|
const withShift = click({ rowId: "d", anchor: "b", modifiers: { shift: true, ctrl: false } });
|
||||||
|
for (const result of [withCtrl, withShift]) {
|
||||||
|
expect(result.kind, JSON.stringify(result)).toBe("select");
|
||||||
|
expect((result as { ids: string[] }).ids).toContain("b");
|
||||||
|
expect((result as { ids: string[] }).ids).toContain("d");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { Id } from "@/jmap/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a click on a message row means.
|
||||||
|
*
|
||||||
|
* Lifted out of the list so the rules sit together and can be tested. They had
|
||||||
|
* drifted apart while they were two branches of one handler: shift-click
|
||||||
|
* selected the whole range including the row it started from, and ctrl-click
|
||||||
|
* selected only the row clicked, leaving the message you had open highlighted
|
||||||
|
* but unticked. Both looked picked; one was. That is issue #186, and the reason
|
||||||
|
* this is a function rather than a comment asking the next person to be careful.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RowClick =
|
||||||
|
| { kind: "open" }
|
||||||
|
| { kind: "select"; ids: Id[]; on: boolean; moveAnchor: boolean };
|
||||||
|
|
||||||
|
export function rowClick(opts: {
|
||||||
|
/** The row clicked. */
|
||||||
|
rowId: Id;
|
||||||
|
/** Every row on screen, in the order they are shown. */
|
||||||
|
ids: Id[];
|
||||||
|
/** The row a range would extend from: the last one clicked without shift. */
|
||||||
|
anchor: Id | null;
|
||||||
|
selected: Record<Id, boolean>;
|
||||||
|
modifiers: { shift: boolean; ctrl: boolean };
|
||||||
|
isMobile: boolean;
|
||||||
|
}): RowClick {
|
||||||
|
const { rowId, ids, anchor, selected, modifiers, isMobile } = opts;
|
||||||
|
const selectedCount = Object.keys(selected).length;
|
||||||
|
|
||||||
|
// A range, from the anchor to here, inclusive at both ends.
|
||||||
|
if (modifiers.shift && anchor) {
|
||||||
|
const from = ids.indexOf(anchor);
|
||||||
|
const to = ids.indexOf(rowId);
|
||||||
|
if (from >= 0 && to >= 0) {
|
||||||
|
const [start, end] = from < to ? [from, to] : [to, from];
|
||||||
|
// The anchor stays where it is, so extending the range again grows it
|
||||||
|
// from the same place rather than from wherever it last reached.
|
||||||
|
return { kind: "select", ids: ids.slice(start, end + 1), on: true, moveAnchor: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modifiers.ctrl) {
|
||||||
|
/*
|
||||||
|
* The row that was already current joins the selection.
|
||||||
|
*
|
||||||
|
* Opening a message does not select it -- it is highlighted because it is
|
||||||
|
* the one being read, which is a different state -- so picking a second one
|
||||||
|
* with ctrl used to select only the second, and every action that followed
|
||||||
|
* quietly applied to half of what the screen showed.
|
||||||
|
*
|
||||||
|
* Only while nothing is selected yet. Once there is a selection, ctrl-click
|
||||||
|
* toggles exactly one row, which is the whole point of it.
|
||||||
|
*/
|
||||||
|
if (!selectedCount && anchor && anchor !== rowId && ids.includes(anchor)) {
|
||||||
|
return { kind: "select", ids: [anchor, rowId], on: true, moveAnchor: true };
|
||||||
|
}
|
||||||
|
return { kind: "select", ids: [rowId], on: !selected[rowId], moveAnchor: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// On a touchscreen, once anything is selected a plain tap goes on selecting:
|
||||||
|
// there is no modifier to hold, and opening a message mid-selection is almost
|
||||||
|
// never what the tap meant.
|
||||||
|
if (selectedCount > 0 && isMobile) {
|
||||||
|
return { kind: "select", ids: [rowId], on: !selected[rowId], moveAnchor: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "open" };
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { formatListDate } from "@/lib/format";
|
|||||||
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
||||||
import { displayName, shortName } from "@/lib/address";
|
import { displayName, shortName } from "@/lib/address";
|
||||||
import { Avatar, Empty, useIsMobile, useIsTouch } from "@/ui/misc";
|
import { Avatar, Empty, useIsMobile, useIsTouch } from "@/ui/misc";
|
||||||
|
import { rowClick } from "@/lib/listSelection";
|
||||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||||||
import { useCompose } from "@/store/compose";
|
import { useCompose } from "@/store/compose";
|
||||||
import { useCalendar } from "@/store/calendar";
|
import { useCalendar } from "@/store/calendar";
|
||||||
@@ -153,29 +154,23 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
|||||||
|
|
||||||
const onRowClick = useCallback(
|
const onRowClick = useCallback(
|
||||||
(e: MouseEvent, rowId: Id) => {
|
(e: MouseEvent, rowId: Id) => {
|
||||||
if (e.shiftKey && lastClick.current) {
|
const action = rowClick({
|
||||||
const a = ids.indexOf(lastClick.current);
|
rowId, ids, anchor: lastClick.current, selected,
|
||||||
const b = ids.indexOf(rowId);
|
modifiers: { shift: e.shiftKey, ctrl: e.ctrlKey || e.metaKey },
|
||||||
if (a >= 0 && b >= 0) {
|
isMobile,
|
||||||
const [s, en] = a < b ? [a, b] : [b, a];
|
});
|
||||||
select(ids.slice(s, en + 1), true);
|
if (action.kind === "open") {
|
||||||
window.getSelection()?.removeAllRanges();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
|
||||||
select([rowId], !selected[rowId]);
|
|
||||||
lastClick.current = rowId;
|
lastClick.current = rowId;
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastClick.current = rowId;
|
|
||||||
if (selCount > 0 && isMobile) {
|
|
||||||
select([rowId], !selected[rowId]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onOpen(rowId);
|
onOpen(rowId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
select(action.ids, action.on);
|
||||||
|
if (action.moveAnchor) lastClick.current = rowId;
|
||||||
|
// Shift-clicking a list also drags a text selection across it, which
|
||||||
|
// leaves the rows looking smeared blue over the selection they meant.
|
||||||
|
else window.getSelection()?.removeAllRanges();
|
||||||
},
|
},
|
||||||
[ids, select, selected, selCount, isMobile, onOpen],
|
[ids, select, selected, isMobile, onOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onContext = useCallback(
|
const onContext = useCallback(
|
||||||
|
|||||||
Reference in New Issue
Block a user