Ctrl-clicking a second message selected only the second. The first stayed highlighted, because it was the one open -- which is a different state wearing a similar colour -- and was never actually selected. So both looked picked, one was, and every action that followed applied to half of what the screen showed, silently. The cause is visible once the two rules sit together: shift-click took the whole run *including* the row it started from, and ctrl-click took only the row clicked. Two branches of one handler that had drifted apart, with nothing asserting they agreed. So they are one function now, and tested. Ctrl-click brings the current row with it while nothing is selected yet; once there is a selection it toggles exactly one row, which is what it is for. Shift-click is unchanged, and keeps its anchor where it is so extending a range twice grows it from the same place rather than from wherever it last reached. The last test asserts the property that failed rather than the branches: whichever modifier begins a selection, the anchor is in it.
71 lines
2.8 KiB
TypeScript
71 lines
2.8 KiB
TypeScript
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" };
|
|
}
|