Add double-tap and press-and-hold shortcuts to the seats
Folding someone meant opening their editor, finding the Fold box, ticking it, and dismissing the sheet — four interactions for the thing that happens most often in a hand. Double-tap a seat to fold, and again to put them back. Press and hold for a quick menu of everything else you do to one player: fold, all-in, sit out or deal in, hand them the button, or open the full editor. Both are undoable the same way the header buttons are, because a gesture is easier to fire by accident than a button. The single tap now waits out the double-tap window before opening the editor — a fifth of a second of latency on opening a form, in exchange for a fold that doesn't also fling a sheet up over the table. A press that moves is a drag, so the hold is cancelled and swapping seats still works, and the puck shrinks slightly while held so a press that's being counted looks different from one that's being ignored. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
@@ -230,6 +230,18 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div id="seatMenuOverlay" hidden>
|
||||
<div id="seatMenuSheet" role="dialog" aria-labelledby="seatMenuTitle">
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
<div class="seatMenuHeader">
|
||||
<h2 id="seatMenuTitle"></h2>
|
||||
<span id="seatMenuSubtitle"></span>
|
||||
</div>
|
||||
<div id="seatMenuActions"></div>
|
||||
<button id="seatMenuCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cardPickerOverlay" hidden>
|
||||
<div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle">
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
@@ -279,6 +291,7 @@
|
||||
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/cardPicker.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/seatMenu.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/betting.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/actionBar.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script>
|
||||
|
||||
@@ -238,7 +238,12 @@ body {
|
||||
cursor: grab;
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
transition: box-shadow 0.15s ease, border-color 0.15s ease, transform 0.1s ease;
|
||||
/* A long press on a phone otherwise selects the player's name or raises the
|
||||
system callout menu instead of ours. */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.seat.dragging {
|
||||
@@ -518,6 +523,99 @@ body {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ---------- Seat long-press menu ---------- */
|
||||
|
||||
/* Held down, before the menu opens — the only feedback that a press is being
|
||||
counted rather than ignored. */
|
||||
.seat.seatPressing {
|
||||
transform: translate(-50%, -50%) scale(0.94);
|
||||
}
|
||||
|
||||
#seatMenuOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 96;
|
||||
}
|
||||
|
||||
#seatMenuOverlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#seatMenuSheet {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: linear-gradient(180deg, #242424, #1a1a1a);
|
||||
border: 1px solid #3a3a3a;
|
||||
border-bottom: none;
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 0.35rem 0.9rem calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.seatMenuHeader {
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.seatMenuHeader h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
#seatMenuSubtitle {
|
||||
color: #9a9a9a;
|
||||
font-size: 0.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
#seatMenuActions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.seatMenuBtn {
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
border-radius: 11px;
|
||||
border: 1px solid #3a3a3a;
|
||||
background: linear-gradient(180deg, #2e2e2e, #232323);
|
||||
color: #eee;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
|
||||
.seatMenuBtn:hover {
|
||||
border-color: #4a4a4a;
|
||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||
}
|
||||
|
||||
.seatMenuBtn.menuDanger {
|
||||
background: linear-gradient(180deg, #3d1f1f, #2c1515);
|
||||
border-color: #5b2626;
|
||||
color: #ffd6d6;
|
||||
}
|
||||
|
||||
.seatMenuBtn.menuAccent {
|
||||
background: linear-gradient(180deg, #3a2f18, #241d0f);
|
||||
border-color: #a9782f;
|
||||
color: #f0d9a8;
|
||||
}
|
||||
|
||||
#seatMenuCancelBtn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
/* ---------- Card picker ---------- */
|
||||
|
||||
#cardPickerOverlay {
|
||||
|
||||
@@ -1,9 +1,62 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const state = HoldemState.load();
|
||||
|
||||
// A gesture on a seat is a shortcut for something the dealer could do the long
|
||||
// way, so each one is undoable the same way the header buttons are.
|
||||
const seatAction = (index, message, mutate) => {
|
||||
const snapshot = JSON.parse(JSON.stringify(state));
|
||||
mutate(state.seats[index], index);
|
||||
HoldemState.save(state);
|
||||
Render.renderAll();
|
||||
|
||||
Toast.show(message, {
|
||||
actionLabel: "Undo",
|
||||
onAction: () => {
|
||||
Object.keys(state).forEach(key => delete state[key]);
|
||||
Object.assign(state, snapshot);
|
||||
HoldemState.save(state);
|
||||
Render.renderAll();
|
||||
Toast.show("Undone.", { duration: 2500 });
|
||||
},
|
||||
duration: 6000,
|
||||
});
|
||||
};
|
||||
|
||||
const nameOf = index => HoldemState.displayName(state.seats[index], index);
|
||||
|
||||
const hooks = {
|
||||
save: s => HoldemState.save(s),
|
||||
rerender: () => Render.renderAll(),
|
||||
|
||||
onSeatDoubleTap: index => {
|
||||
const player = state.seats[index];
|
||||
if (!player.inHand) return;
|
||||
if (player.folded) {
|
||||
seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; });
|
||||
return;
|
||||
}
|
||||
seatAction(index, `${nameOf(index)} folds.`, () => {
|
||||
if (state.actingSeatIndex === index) Betting.fold(state, index);
|
||||
else player.folded = true;
|
||||
});
|
||||
},
|
||||
|
||||
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
|
||||
};
|
||||
|
||||
const seatMenuHooks = {
|
||||
fold: index => hooks.onSeatDoubleTap(index),
|
||||
unfold: index => seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; }),
|
||||
allIn: index => seatAction(index, `${nameOf(index)} is all-in.`, () => Betting.allIn(state, index)),
|
||||
toggleDealtIn: index => {
|
||||
const dealtIn = state.seats[index].inHand;
|
||||
seatAction(index, dealtIn ? `${nameOf(index)} sits out.` : `${nameOf(index)} is dealt in.`, p => {
|
||||
p.inHand = !dealtIn;
|
||||
if (!p.inHand) p.folded = false;
|
||||
});
|
||||
},
|
||||
makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => { state.dealerSeatIndex = index; }),
|
||||
editSeat: index => Render.toggleSeatEditor(index),
|
||||
};
|
||||
|
||||
Render.mount(state, hooks);
|
||||
@@ -144,6 +197,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
|
||||
|
||||
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => SeatMenu.close());
|
||||
document.getElementById("seatMenuOverlay").addEventListener("click", e => {
|
||||
if (e.target.id === "seatMenuOverlay") SeatMenu.close();
|
||||
});
|
||||
|
||||
document.getElementById("cardPickerCloseBtn").addEventListener("click", () => CardPicker.close());
|
||||
document.getElementById("cardPickerClearBtn").addEventListener("click", () => CardPicker.clearSlot());
|
||||
document.getElementById("cardPickerOverlay").addEventListener("click", e => {
|
||||
|
||||
@@ -47,9 +47,9 @@ const Render = {
|
||||
|
||||
renderTableHint() {
|
||||
const hint = document.getElementById("tableHint");
|
||||
hint.textContent = this.state.dedicatedDealer
|
||||
? "Drag a seat onto another seat to swap players."
|
||||
: "Drag a seat onto another seat to swap players. Click the dealer chip on a seat to make it the button.";
|
||||
// Gestures are invisible until someone tells you about them, and this is the
|
||||
// only place on the table with room to say so.
|
||||
hint.textContent = "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats.";
|
||||
},
|
||||
|
||||
renderNotes() {
|
||||
@@ -113,15 +113,7 @@ const Render = {
|
||||
this.updatePuckContent(node, player, index);
|
||||
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
||||
|
||||
node.addEventListener("click", () => {
|
||||
// Tapping the open seat again closes it — on a phone the editor is a
|
||||
// sheet over the table, and the seat you just tapped is the closest
|
||||
// thing to hand for dismissing it.
|
||||
this.selectedSeatIndex = this.selectedSeatIndex === index ? null : index;
|
||||
this.renderSeats();
|
||||
this.renderSeatEditor();
|
||||
});
|
||||
|
||||
this.wireSeatGestures(node, index);
|
||||
this.wireDragAndDrop(node, index);
|
||||
|
||||
layer.appendChild(node);
|
||||
@@ -202,6 +194,109 @@ const Render = {
|
||||
if (node) this.updatePuckContent(node, this.state.seats[index], index);
|
||||
},
|
||||
|
||||
// ---- Seat gestures ----
|
||||
//
|
||||
// One puck, three intents: tap to edit, double-tap to fold, press and hold for
|
||||
// the quick menu. The single tap waits out the double-tap window before it
|
||||
// fires — a fifth of a second of latency on opening a form, in exchange for a
|
||||
// fold that doesn't also fling a sheet up over the table.
|
||||
|
||||
LONG_PRESS_MS: 500,
|
||||
DOUBLE_TAP_MS: 220,
|
||||
DRAG_SLOP_PX: 8,
|
||||
pendingTap: null,
|
||||
|
||||
wireSeatGestures(node, index) {
|
||||
let longPressTimer = null;
|
||||
let longPressFired = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
|
||||
const cancelLongPress = () => {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
node.addEventListener("pointerdown", e => {
|
||||
longPressFired = false;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
cancelLongPress();
|
||||
longPressTimer = setTimeout(() => {
|
||||
longPressTimer = null;
|
||||
longPressFired = true;
|
||||
this.cancelPendingTap();
|
||||
node.classList.remove("seatPressing");
|
||||
this.hooks.onSeatLongPress(index);
|
||||
}, this.LONG_PRESS_MS);
|
||||
node.classList.add("seatPressing");
|
||||
});
|
||||
|
||||
// Moving means they're dragging the seat, not holding it.
|
||||
node.addEventListener("pointermove", e => {
|
||||
if (Math.abs(e.clientX - startX) > this.DRAG_SLOP_PX || Math.abs(e.clientY - startY) > this.DRAG_SLOP_PX) {
|
||||
cancelLongPress();
|
||||
node.classList.remove("seatPressing");
|
||||
}
|
||||
});
|
||||
|
||||
["pointerup", "pointercancel", "pointerleave"].forEach(type => {
|
||||
node.addEventListener(type, () => {
|
||||
cancelLongPress();
|
||||
node.classList.remove("seatPressing");
|
||||
});
|
||||
});
|
||||
node.addEventListener("dragstart", () => {
|
||||
cancelLongPress();
|
||||
this.cancelPendingTap();
|
||||
node.classList.remove("seatPressing");
|
||||
});
|
||||
|
||||
node.addEventListener("click", e => {
|
||||
// The long press already did something; don't also treat the release as a tap.
|
||||
if (longPressFired) {
|
||||
longPressFired = false;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
this.handleSeatTap(index);
|
||||
});
|
||||
},
|
||||
|
||||
cancelPendingTap() {
|
||||
if (this.pendingTap) {
|
||||
clearTimeout(this.pendingTap.timer);
|
||||
this.pendingTap = null;
|
||||
}
|
||||
},
|
||||
|
||||
handleSeatTap(index) {
|
||||
if (this.pendingTap && this.pendingTap.index === index) {
|
||||
this.cancelPendingTap();
|
||||
this.hooks.onSeatDoubleTap(index);
|
||||
return;
|
||||
}
|
||||
|
||||
this.cancelPendingTap();
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingTap = null;
|
||||
this.toggleSeatEditor(index);
|
||||
}, this.DOUBLE_TAP_MS);
|
||||
this.pendingTap = { index, timer };
|
||||
},
|
||||
|
||||
// Tapping the open seat again closes it — on a phone the editor is a sheet over
|
||||
// the table, and the seat you just tapped is the closest thing to hand for
|
||||
// dismissing it.
|
||||
toggleSeatEditor(index) {
|
||||
this.selectedSeatIndex = this.selectedSeatIndex === index ? null : index;
|
||||
this.renderSeats();
|
||||
this.renderSeatEditor();
|
||||
},
|
||||
|
||||
wireDragAndDrop(node, index) {
|
||||
node.addEventListener("dragstart", e => {
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// The long-press menu on a seat. Everything a dealer does to one player mid-hand,
|
||||
// one press and one tap away, without opening the full editor and without hunting
|
||||
// for whose turn it is in the action bar.
|
||||
const SeatMenu = {
|
||||
state: null,
|
||||
hooks: null,
|
||||
seatIndex: null,
|
||||
|
||||
open(state, hooks, seatIndex) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.seatIndex = seatIndex;
|
||||
this.render();
|
||||
document.getElementById("seatMenuOverlay").hidden = false;
|
||||
},
|
||||
|
||||
close() {
|
||||
document.getElementById("seatMenuOverlay").hidden = true;
|
||||
},
|
||||
|
||||
// Only the actions that make sense for this seat right now — a folded player has
|
||||
// nothing to go all-in with, and an empty chair has nothing to fold.
|
||||
actionsFor(player, index) {
|
||||
const actions = [];
|
||||
const dealtIn = player.inHand;
|
||||
const live = dealtIn && !player.folded;
|
||||
|
||||
if (live) {
|
||||
actions.push({ label: "Fold", className: "menuDanger", run: () => this.hooks.fold(index) });
|
||||
actions.push({ label: "All-in", className: "menuAccent", run: () => this.hooks.allIn(index) });
|
||||
} else if (dealtIn && player.folded) {
|
||||
actions.push({ label: "Un-fold", run: () => this.hooks.unfold(index) });
|
||||
}
|
||||
|
||||
actions.push({
|
||||
label: dealtIn ? "Sit out" : "Deal in",
|
||||
run: () => this.hooks.toggleDealtIn(index),
|
||||
});
|
||||
|
||||
if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
|
||||
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
|
||||
}
|
||||
|
||||
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
|
||||
return actions;
|
||||
},
|
||||
|
||||
render() {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
document.getElementById("seatMenuTitle").textContent = HoldemState.displayName(player, this.seatIndex);
|
||||
|
||||
const stack = Betting.stackOf(this.state, player);
|
||||
const owed = Betting.toCall(this.state, player);
|
||||
const bits = [];
|
||||
if (stack > 0) bits.push(`${Money.format(stack)} behind`);
|
||||
if (owed > 0) bits.push(`${Money.format(owed)} to call`);
|
||||
document.getElementById("seatMenuSubtitle").textContent = bits.join(" · ");
|
||||
|
||||
const list = document.getElementById("seatMenuActions");
|
||||
list.innerHTML = "";
|
||||
this.actionsFor(player, this.seatIndex).forEach(action => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `seatMenuBtn ${action.className || ""}`;
|
||||
btn.textContent = action.label;
|
||||
btn.addEventListener("click", () => {
|
||||
this.close();
|
||||
action.run();
|
||||
});
|
||||
list.appendChild(btn);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = SeatMenu;
|
||||
}
|
||||
Reference in New Issue
Block a user