language: TypeScript
5 KB / 142 lines / 115 loc
import type { CardLocation, PileData } from "./types";
import { assert, checked } from "./utils";
// Operations are mutations to workspace state; they should not interact
// directly with the user nor have other side effects. They return a
// reciprocable undo Command that restores any nontrivial state.
// https://stackoverflow.com/a/67605309/13773246
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ParametersExceptFirst<F> = F extends (arg0: any, ...rest: infer R) => any ? R : never;
// Command serializes an operation as defined in opsByName. It is a type-checked
// tuple of the form [opName, ...opArgs].
export type Command = {
[K in keyof typeof opsByName]: [K, ...ParametersExceptFirst<(typeof opsByName)[K]>];
}[keyof typeof opsByName];
export type CommandOf<K extends Command[0]> = Extract<Command, [K, ...any[]]>;
// TODO refine and simplify
interface StoreInterface {
piles: PileData[];
activePileIndex: number;
readonly activePile: PileData;
insertPile(pileIndex: number): void;
}
export interface OperationContext {
store: StoreInterface;
makeCard: (uid: string) => { uid: string };
}
// TODO options for presentation seem inappropriate at this layer
function moveCard(
ctx: OperationContext,
source: CardLocation,
target: CardLocation,
options: { restorePicked?: number; followTarget?: boolean } = {},
): Command {
const { restorePicked, followTarget = false } = options;
const sourcePile = checked(ctx.store.piles[source[0]]);
const targetPile = checked(ctx.store.piles[target[0]]);
const [sourceCardIndex, targetCardIndex] = [source[1], target[1]];
// TODO reenable assertion after removing xform noops
// assert(sourcePile != targetPile || sourceCardIndex != targetCardIndex);
checked(sourcePile.cards[sourceCardIndex]);
assert(targetCardIndex >= 0);
assert(targetCardIndex < targetPile.cards.length + (sourcePile === targetPile ? 0 : 1));
const originalTargetPicked = targetPile.pickedCardIndex;
const card = checked(sourcePile.cards.splice(sourceCardIndex, 1)[0]);
if (restorePicked !== undefined) sourcePile.pickedCardIndex = restorePicked;
targetPile.cards.splice(targetCardIndex, 0, card);
targetPile.pickedCardIndex = targetCardIndex;
// followTarget exists for cosmetic reasons. When swapping cards within
// a pile, the selection follows the picked card to its target location.
// While our caller could arrange for this update, it would happen after
// the view transition. To avoid artifacts, we push it down here.
if (followTarget) [ctx.store.activePileIndex, ctx.store.activePile.pickedCardIndex] = target;
return ["moveCard", target, source, { followTarget, restorePicked: originalTargetPicked }];
}
function createPile(ctx: OperationContext, pileIndex: number): Command {
ctx.store.insertPile(pileIndex);
ctx.store.activePileIndex = pileIndex;
return ["removeEmptyPile", pileIndex];
}
function removeEmptyPile(ctx: OperationContext, pileIndex: number): Command {
if (ctx.store.piles[pileIndex]?.cards?.length !== 0) {
throw new Error("Cannot remove non-empty pile");
}
ctx.store.piles.splice(pileIndex, 1);
if (pileIndex <= ctx.store.activePileIndex) ctx.store.activePileIndex--;
return ["createPile", pileIndex];
}
function namePile(ctx: OperationContext, pileIndex: number, name: string): Command {
const pile = checked(ctx.store.piles[pileIndex]);
const oldName = pile.name;
pile.name = name;
return ["namePile", pileIndex, oldName];
}
function movePile(ctx: OperationContext, fromIndex: number, toIndex: number): Command {
const pile = checked(ctx.store.piles.splice(fromIndex, 1)[0]);
ctx.store.piles.splice(toIndex, 0, pile);
return ["movePile", toIndex, fromIndex];
}
function spliceCards(
ctx: OperationContext,
pileIndex: number,
cardIndex: number,
deleteCount: number,
cardUids: string[],
): Command {
const pile = checked(ctx.store.piles[pileIndex]);
const cards = cardUids.map((uid) => ctx.makeCard(uid));
const oldCards = pile.cards.splice(cardIndex, deleteCount, ...cards);
pile.pickedCardIndex = Math.max(cardIndex, pile.cards.length - 1);
const oldCardUids = oldCards.map(({ uid }) => uid);
return ["spliceCards", pileIndex, cardIndex, cardUids.length, oldCardUids];
}
const opsByName = {
createPile,
removeEmptyPile,
namePile,
movePile,
spliceCards,
moveCard,
};
// Perform the operation requested by the Command. Returns the reverse operation
// Command to undo.
export function invokeCommand(ctx: OperationContext, cmd: Command) {
const [opName, ...opArgs] = cmd;
// This partially elided type exists only to check that all operations return
// Command. We still use the discriminated type elsewhere.
type FunctionReturnsCommand = (
ctx: OperationContext,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...args: any[]
) => Command;
// If type checking has an error on this line, there probably exists an
// operation in opsByName that does not return Command.
const opFn: FunctionReturnsCommand = opsByName[opName];
return opFn(ctx, ...opArgs);
}