jmou/yakatak
git clone https://github.com/jmou/yakatak.git
git clone git@github.com:jmou/yakatak.git
Loading…
(top)/ui/src/App.vue
language: Vue
10.1 KB / 326 lines / 273 loc
History
View raw
<script setup lang="ts">
import { useFetch, whenever } from "@vueuse/core";
import { computed, nextTick, reactive, ref, useTemplateRef, watch, watchEffect } from "vue";
import ChooserDialog from "./components/ChooserDialog.vue";
import DetailsPane from "./components/DetailsPane.vue";
import PilesPane from "./components/PilesPane.vue";
import { assert, Card, checked, Pile, type CardData } from "./lib/common.ts";

// A Command describes how to run an operation defined in opsByName. It is
// a type-checked tuple of the form [opName, ...opArgs].
type Command = {
  [K in keyof typeof opsByName]: [K, ...Parameters<(typeof opsByName)[K]>];
}[keyof typeof opsByName];

// The operation (or undo) log stores pairs of Commands that built up the
// current state or can be reversed in sequence to revert to a previous state.
interface OpLogEntry {
  forward: Command;
  reverse: Command;
  location: Location;
}

type Location = readonly [pileIndex: number, cardIndex: number];

const getDeck = reactive(useFetch("/api/deck").json<CardData[]>());

const state = reactive({
  piles: [new Pile(), new Pile()],
  active: Pile.START,
  opLog: [] as OpLogEntry[],
  opLogIndex: 0,
});

const activePile = computed(() => checked(state.piles[state.active]));
const pickedCard = computed(() => activePile.value.cards[activePile.value.picked]);
const currentLocation = computed<Location>(() => [state.active, activePile.value.picked]);

whenever(
  () => getDeck.isFinished && !getDeck.error,
  () => {
    // TODO validate data
    assert(getDeck.data);
    state.piles[1]!.cards = getDeck.data.map((data) => new Card(data));
  },
);

// Bounds constrain active.
watchEffect(() => {
  if (state.active < Pile.START) {
    state.active = Pile.START;
  } else if (state.active >= state.piles.length) {
    state.active = state.piles.length - 1;
  }
});

function scrollToActivePile() {
  activePile.value.elem?.scrollIntoView?.({ block: "nearest" });
}
watchEffect(scrollToActivePile);

const chooser = useTemplateRef("chooser");
const detailsElem = ref<HTMLElement>();

watch(pickedCard, () => (detailsElem.value!.scrollTop = 0));

async function viewTransition(fn: () => void): Promise<void> {
  if (!document.startViewTransition) {
    fn();
  } else {
    return document.startViewTransition(() => {
      fn();
      return nextTick();
    }).updateCallbackDone;
  }
}

async function placeCardInto(
  target: Location,
  options: { source?: Location; followTarget?: boolean } = {},
): Promise<Command | void> {
  const { source = currentLocation.value, followTarget = false } = options;

  const [sourcePile, targetPile] = [source, target].map((loc) => state.piles[loc[0]]);
  const [sourceCardIndex, targetCardIndex] = [source[1], target[1]];
  if (!sourcePile || !targetPile) return;
  if (!sourcePile.cards[sourceCardIndex]) return;
  if (targetCardIndex < 0 || targetCardIndex > targetPile.cards.length) return;

  await viewTransition(() => {
    const card = sourcePile.cards.splice(sourceCardIndex, 1)[0];
    assert(card);
    sourcePile.pickCardClamped(sourcePile.picked);
    targetPile.cards.splice(targetCardIndex, 0, card);

    // 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) [state.active, activePile.value.picked] = target;
  });

  return ["placeCardInto", source, { source: target, followTarget }];
}

async function movePickedCardToPile(pileIndex: number): Promise<Command | void> {
  const pile = state.piles[pileIndex];
  if (!pile) return;
  return placeCardInto([pileIndex, pile.cards.length]);
}

async function swapPickedCardWithNeighbor(direction: "left" | "right"): Promise<Command | void> {
  const source = currentLocation.value;
  const offset = direction == "left" ? -1 : 1;
  const target: Location = [source[0], source[1] + offset];
  return placeCardInto(target, { source, followTarget: true });
}

function reverseCardsInPile(pileIndex: number = state.active): Command {
  const pile = state.piles[pileIndex];
  assert(pile);
  pile.cards.reverse();
  pile.picked = pile.cards.length - 1 - pile.picked;
  return ["reverseCardsInPile", pileIndex];
}

function createPileAt(pileIndex: number): Command {
  state.piles.splice(pileIndex, 0, new Pile());
  return ["removePileUnchecked", pileIndex];
}

function nameActivePile(name?: string | null): Command | void {
  if (!name) name = prompt("Pile name", activePile.value.name);
  if (!name) return;
  const oldName = activePile.value.name;
  activePile.value.name = name;
  return ["nameActivePile", oldName];
}

async function goToChosenPile(): Promise<void> {
  const options = state.piles.slice(1).map((pile, i) => `${i + 1}. ${pile.name ?? "(unnamed)"}`);
  const position = await chooser.value!.ask("Piles", options);
  if (position == null) return;
  state.active = position + 1;
}

function swapPiles(aPileIndex: number, bPileIndex: number): Command | void {
  const [aPile, bPile] = [state.piles[aPileIndex], state.piles[bPileIndex]];
  if (aPileIndex < Pile.START || bPileIndex < Pile.START || !aPile || !bPile) return;
  [state.piles[aPileIndex], state.piles[bPileIndex]] = [bPile, aPile];
  return ["swapPiles", aPileIndex, bPileIndex];
}

async function applyOpLogReverse() {
  const entry = state.opLog[state.opLogIndex - 1];
  if (!entry) return;

  await invokeCommand(entry.reverse);
  [state.active, activePile.value.picked] = entry.location;
  state.opLogIndex--;
}

async function applyOpLogForward() {
  const entry = state.opLog[state.opLogIndex];
  if (!entry) return;

  [state.active, activePile.value.picked] = entry.location;
  await invokeCommand(entry.forward);
  state.opLogIndex++;
}

// Perform the operation requested by the Command.
async function invokeCommand(cmd: Command) {
  const [opName, ...opArgs] = cmd;
  // This partially elided type exists only to check that all operations return
  // Command (or void). We still use the discriminated type elsewhere.
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  type FunctionReturnsCommand = (...args: any[]) => Command | void | Promise<Command | void>;
  // If type checking has an error on this line, there probably exists an
  // operation in opsByName that does not return Command or void.
  const opFn: FunctionReturnsCommand = opsByName[opName];
  return Promise.resolve(opFn(...opArgs));
}

// Operations are actions on state, typically by the user. They return a
// reciprocable undo Command that restores any nontrivial state; if all
// modified state is trivial (like which card is picked) it returns void.
const opsByName = {
  pickCardLeft: () => activePile.value.pickCardClamped(activePile.value.picked - 1),
  pickCardRight: () => activePile.value.pickCardClamped(activePile.value.picked + 1),
  pickCardFirst: () => activePile.value.pickCardClamped(0),
  pickCardLast: () => activePile.value.pickCardClamped(activePile.value.cards.length - 1),

  placeCardInto,
  swapPickedCardWithNeighbor,
  movePickedCardToPile,
  discardPickedCard: () => movePickedCardToPile(Pile.DISCARD),

  openPickedCardPage: () => {
    if (pickedCard.value) open(pickedCard.value.url);
  },

  reverseCardsInPile,

  activatePileUp: () => void state.active--,
  activatePileDown: () => void state.active++,

  createPileUp: () => createPileAt(state.active),
  createPileDown: () => createPileAt(++state.active),
  removePileUnchecked: (pileIndex: number) => void state.piles.splice(pileIndex, 1),

  nameActivePile,
  goToChosenPile,

  swapPiles,
  swapActivePileUp: () => swapPiles(state.active, --state.active),
  swapActivePileDown: () => swapPiles(state.active, ++state.active),

  applyOpLogReverse,
  applyOpLogForward,
};

const rootKeyBindings: Readonly<Record<string, Command>> = {
  h: ["pickCardLeft"],
  j: ["activatePileDown"],
  k: ["activatePileUp"],
  l: ["pickCardRight"],

  H: ["swapPickedCardWithNeighbor", "left"],
  K: ["swapActivePileUp"],
  J: ["swapActivePileDown"],
  L: ["swapPickedCardWithNeighbor", "right"],

  "^": ["pickCardFirst"],
  $: ["pickCardLast"],

  "1": ["movePickedCardToPile", 1],
  "2": ["movePickedCardToPile", 2],
  "3": ["movePickedCardToPile", 3],
  "4": ["movePickedCardToPile", 4],
  "5": ["movePickedCardToPile", 5],
  "6": ["movePickedCardToPile", 6],
  "7": ["movePickedCardToPile", 7],
  "8": ["movePickedCardToPile", 8],
  "9": ["movePickedCardToPile", 9],
  "0": ["movePickedCardToPile", 10],

  Enter: ["openPickedCardPage"],
  d: ["discardPickedCard"],

  o: ["createPileDown"],
  O: ["createPileUp"],
  n: ["nameActivePile"],
  g: ["goToChosenPile"],
  R: ["reverseCardsInPile"],

  u: ["applyOpLogReverse"],
  U: ["applyOpLogForward"],
};

async function handleKeyEvent(event: KeyboardEvent) {
  const forward = rootKeyBindings[event.key];
  if (!forward) return;

  const location = currentLocation.value;
  const reverse = await invokeCommand(forward);
  if (reverse) {
    state.opLog.splice(state.opLogIndex);
    state.opLog[state.opLogIndex++] = { forward, reverse, location };
  }
}

// FIFO to serialize event handling.
const events: KeyboardEvent[] = [];
let eventsPromise: Promise<void> | null = null;

async function startEventProcessing() {
  while (true) {
    const event = events.shift();
    if (!event) break;
    await handleKeyEvent(event);
  }
  eventsPromise = null;
}

function onKeydown(event: KeyboardEvent) {
  events.push(event);
  if (!eventsPromise) eventsPromise = startEventProcessing();
}
</script>

<template>
  <main>
    <DetailsPane
      :loading="getDeck.isFetching"
      :error="getDeck.error"
      :card="pickedCard"
      v-model:elem="detailsElem"
      tabindex="0"
      autofocus
      @keydown="onKeydown"
    />
    <PilesPane
      v-model:active="state.active"
      :piles="state.piles"
      tabindex="-1"
      @auto-scroll="scrollToActivePile"
      @focus="detailsElem!.focus()"
    />
    <ChooserDialog ref="chooser" />
  </main>
</template>

<style scoped>
main {
  display: flex;
  height: 100vh;
}
</style>

<style>
:root {
  font-family: system-ui, Arial, Helvetica, sans-serif;
  font-size: 16px;
  background: #eee;
}
</style>