card-factory
Version:
A comprehensive library for card manipulation
692 lines (691 loc) • 29.6 kB
JavaScript
import "../../styles/pile.css";
import { animateMoveCardToNewPile, denyMove, slideCard, } from "../animate/animate";
import { Rules } from "../rules/rules";
// These are recipes for cascade()
const layouts = {
stack: {
offset: [-0.003, -0.003],
},
cascade: {
offset: [0.4, 0],
},
visibleStack: {
offset: [0, 0.25],
},
};
export const createDefaultOptions = () => ({
cardElements: [],
layout: "stack",
rules: new Rules(),
draggable: true,
groupDrag: true,
receiveCardCallback: () => true,
passCardCallback: () => true,
animatePass: true,
passCardAnimationCallback: null,
receiveCardAnimationCallback: null,
});
// Adds a base the size of the card to be the basis of deck layouts.\
export const pileElement = (pile, deck, partialOptions = {}) => {
const options = {
...createDefaultOptions(),
...partialOptions,
};
// Cascade values and setters
const cascadeOffset = [0, 0];
const cascadeDuration = 0;
applyCascadeLayout(options.layout);
// creating the container
const container = document.createElement("div");
container.classList.add("deck-base");
// add a random id to the container. This is for storing the id during click handlers
container.id = Math.random().toString(36).slice(2, 11);
// if this pile is draggable, we will add all of the drag functions
if (options.draggable) {
container.ondragstart = drag;
container.ondragend = dragend;
container.ondrop = drop;
container.ondragover = allowDrop;
container.addEventListener("touchstart", handleTouchStart, {
passive: false,
});
container.addEventListener("touchmove", handleTouchMove, {
passive: false,
});
container.addEventListener("touchend", handleTouchEnd);
}
window.addEventListener("resize", () => {
cascade();
});
const { cardElements } = options;
/**
* Fixes the shadows on cards when a card is moved
* @returns undefined always
*/
const updateShadows = () => {
if (cardElements.length <= 0)
return;
for (let i = 0; i < cardElements.length; i++) {
const front = cardElements[i].front;
const back = cardElements[i].back;
if (Math.abs(cascadeOffset[0]) > 0.04 ||
Math.abs(cascadeOffset[1]) > 0.04 ||
i === cardElements.length - 1) {
if (front) {
front.classList.add("card-shadow");
}
back.classList.add("card-shadow");
}
else {
if (front) {
front.classList.remove("card-shadow");
}
back.classList.remove("card-shadow");
}
}
if (cardElements[0].front) {
cardElements[0].front.classList.add("card-shadow");
}
cardElements[0].back.classList.add("card-shadow");
};
/**
*
* Use this to initiate decks, or if you have removed/added cards unconventionally this will stack them correctly again
* @param duration how long the animation will take
* @returns an array of promises of the animations
*/
const cascade = (duration = cascadeDuration) => {
reset();
updateShadows();
const arrayFinished = [];
for (let i = 0; i < cardElements.length; i++) {
const vector2 = [];
const cardElement = cardElements[i].container;
vector2[0] = cascadeOffset[0] * cardElement.offsetWidth * i;
vector2[1] = cascadeOffset[1] * cardElement.offsetHeight * i;
const slide = slideCard(cardElements[i], vector2, duration);
arrayFinished.push(slide);
}
return Promise.all(arrayFinished);
};
function applyCascadeLayout(layoutName) {
const newOffset = layouts[layoutName].offset.slice();
if (Object.keys(layouts).includes(layoutName)) {
cascadeOffset[0] = newOffset[0];
cascadeOffset[1] = newOffset[1];
}
else {
throw new Error(`No cascade layout with that name found: ${layouts}`);
}
}
/**
*
* @param layoutName a name for your layout
* @param offset a 2 value array, which represents the x and y shift the cards will appear in
*/
function createCascadeLayout(layoutName, offset) {
if (Object.keys(layouts).includes(layoutName)) {
throw new Error("A layout with that name already exists");
}
else {
Object.defineProperty(layouts, layoutName, {
value: { offset },
enumerable: true, // So it shows up in Object.keys() for your existence check
});
}
}
/**
*
* @param destinationPile PileElement that the card is moving to
* @param cardElement The card being moved. Defaults to the top card of source pile
* @param gameRules ability to pass specific rules for this card moving. Defaults to the piles rules which defaults: () => true
* @param groupOffset this is provided by the drag group move functionality
* @param animationCallback Allows you to change the default animation, null for no animation
* @returns false is unsuccessful, an animation with animation.finished as a promise if successful
*/
function moveCardToPile(destinationPile, cardElement = cardElements[cardElements.length - 1], groupOffset = 0) {
const gameRules = options.rules;
//const animationCallback = options.moveCardAnimation;
try {
// checks to find the card in the source pile
if (cardElements.indexOf(cardElement) === -1) {
throw "could not find card in source pile";
}
// checks to see if this deck can pass that card
if (gameRules.canPass(this, destinationPile, cardElement) === false) {
throw "source pile cannot pass card";
}
// checks to see if the destination deck can receive this card
if (destinationPile.options.rules.canReceive(this, destinationPile, cardElement) === false) {
throw "destination pile cannot receive card";
}
// attempt the pass within the pile objects
const cardPassed = pile.passCard(destinationPile.pile, cardElement.card);
// if the attempt to pass the card is a fail, return false
if (cardPassed === false) {
throw "pile object could not pass card object";
}
}
catch (error) {
console.error(error);
return false;
}
// if the animation callback is set to null, don't animate anything and return
//! untested
if (options.animatePass === false) {
destinationPile.cardElements.push(cardElements.splice(cardElements.indexOf(cardElement), 1)[0]);
cascade();
destinationPile.cascade();
// hit the callbacks for both passing and recieving cards
options.passCardCallback(cardElement, this, destinationPile);
destinationPile.options.receiveCardCallback(cardElement, this, destinationPile);
return Promise.resolve(undefined);
}
// Adds card to destination, removes from this pile
// append the new card to the other container, and cardElement array
destinationPile.container.appendChild(cardElement.container);
destinationPile.cardElements.push(cardElement);
// find the indes of the card element
const index = cardElements.findIndex((element) => {
return JSON.stringify(element) === JSON.stringify(cardElement);
});
// Should never be -1, but if the index wasn't found abort
if (index === -1)
return Promise.reject(false);
// If the card wasn't the top card, cascade the hand back together.
// If group Drag is on, it will cause unneccesary shifting, as the whole pile it leaving anyways
if (index !== cardElements.length - 1 && options.groupDrag === false) {
cardElements.splice(cardElements.indexOf(cardElement), 1);
cascade(300);
}
else {
cardElements.splice(cardElements.indexOf(cardElement), 1);
}
// the card got passed, and this is the animation we want to show.
return animateMoveCardToNewPile(this, destinationPile, cardElement, index, groupOffset).then((animation) => {
// wait for the card to move, then update the shadows on both piles
// hit the callbacks for both passing and recieving cards
options.passCardCallback(cardElement, this, destinationPile);
destinationPile.options.receiveCardCallback(cardElement, this, destinationPile);
updateShadows();
destinationPile.updateShadows();
if (options.passCardAnimationCallback !== null)
options.passCardAnimationCallback();
if (destinationPile.options.receiveCardAnimationCallback !== null)
destinationPile.options.receiveCardAnimationCallback();
return animation;
});
}
// resets the container of the DeckBase
const reset = () => {
while (container.firstElementChild) {
container.removeChild(container.firstElementChild);
}
adjustZIndex(cardElements);
cardElements.forEach((element) => {
element.container.draggable = options.draggable;
container.appendChild(element.container);
});
};
/**
*
* @param cardElements adjusts the zIndex of a piles CardElements. Used during card moving operations
*/
const adjustZIndex = (cardElements) => {
for (let index = 0; index < cardElements.length; index++) {
const card = cardElements[index];
card.container.style.zIndex = String(index);
}
};
/**
* shuffles the pile object, then sorts the cardElements to match the pile
*/
const shuffle = () => {
pile.shuffle();
// Sort cardElements[] to match the shuffled order of cards[]
cardElements.sort((a, b) => pile.cards.indexOf(a.card) - pile.cards.indexOf(b.card));
};
//
//
//
//
//
//
/********************************************************************************
* ********************** Drag and Drop Below ***********************************
* ******************************************************************************
*/
//
//
//
//
//
//
//
/**
* Used during custom click handlers to return which card element was clicked on.
* @param element any html element that is a child of a card container
* @returns the cardElement<T> that was clicked on
*/
const findCardContainer = (element) => {
if (element.classList.contains("card-container")) {
const returnElement = cardElements.find((cardElement) => cardElement.container === element);
if (!returnElement)
return null;
else
return returnElement;
}
if (element.classList.contains("deck-base"))
return null;
else if (element.parentElement)
return findCardContainer(element.parentElement);
else
throw "something went wrong in find card container";
};
// using the id of the pile, step into deck to find which pile it is
const findPileElement = (id) => {
return deck.pileElements.filter((item) => item.container.id === id)[0];
};
// Define the function before it's used
function allowDrop(e) {
e.preventDefault();
}
function drag(e) {
if (cardElements[cardElements.length - 1].transform.active === true) {
e.preventDefault();
e.stopPropagation();
return;
}
if (!(e.target instanceof HTMLElement))
return;
// Find the main card container.
const cardElement = findCardContainer(e.target);
if (cardElement === null)
return;
if (options.rules.canPass(findPileElement(container.id), {}, cardElement) === false) {
denyMove(cardElement);
e.preventDefault();
e.stopPropagation();
return;
}
// Prepare your drag data.
const data = {
indexs: [cardElements.indexOf(cardElement)],
sourcePileContainerId: container.id,
};
// Create a custom drag image that visually represents the group.
const dragImage = document.createElement("div");
dragImage.id = "card-dragImage";
dragImage.classList.add("drag-image");
// Get the parent element that holds the card and its siblings.
const pileElement = cardElement.container.parentElement;
if (!pileElement)
return;
// Card dragged index
const originalIndex = cardElements.indexOf(cardElement);
function isChrome() {
const userAgent = navigator.userAgent;
// Check if 'Chrome' exists in the userAgent string but exclude 'Edge' and 'Opera'
return /Chrome/.test(userAgent) && !/Edge|OPR|Opera/.test(userAgent);
}
//! Fix this to actually work for safari and firefox. Chrome Version works fine
if (isChrome()) {
cardElements.forEach((element) => {
// Get the card's z-index as a number.
const cardIndex = cardElements.indexOf(element);
if (cardIndex === originalIndex && options.groupDrag === false) {
element.container.classList.add("card-dragging");
const originalTransform = element.container.style.transform;
const containerScale = container.style.transform;
const newTransform = `${originalTransform} ${containerScale}`;
element.container.style.transform = newTransform;
const clone = element.container.cloneNode(true);
element.container.style.transform = originalTransform;
dragImage.appendChild(clone);
}
// Only add the class if the card's z-index is higher than the original.
// Clone each card element and append to dragImage.
if (cardIndex >= originalIndex && options.groupDrag === true) {
element.container.classList.add("card-dragging");
const originalTransform = element.container.style.transform;
const containerScale = container.style.transform;
const newTransform = `${originalTransform} ${containerScale}`;
element.container.style.transform = newTransform;
const clone = element.container.cloneNode(true);
element.container.style.transform = originalTransform;
dragImage.appendChild(clone);
if (cardIndex !== originalIndex) {
data.indexs.push(cardIndex);
}
}
});
}
else {
//! this rotates the card back to straight, and only shows 1 card in a group
cardElements.forEach((element) => {
// Get the card's z-index as a number.
const cardIndex = cardElements.indexOf(element);
if (cardIndex === originalIndex && options.groupDrag === false) {
element.container.classList.add("card-dragging");
const originalTransform = element.container.style.transform;
element.container.style.transform = "";
const clone = element.container.cloneNode(true);
element.container.style.transform = originalTransform;
dragImage.appendChild(clone);
}
// Only add the class if the card's z-index is higher than the original.
// Clone each card element and append to dragImage.
if (cardIndex >= originalIndex && options.groupDrag === true) {
element.container.classList.add("card-dragging");
if (cardIndex === originalIndex) {
const originalTransform = element.container.style.transform;
element.container.style.transform = "";
const clone = element.container.cloneNode(true);
element.container.style.transform = originalTransform;
dragImage.appendChild(clone);
}
if (cardIndex !== originalIndex) {
data.indexs.push(cardIndex);
}
}
});
}
// It is necessary to add the drag image element off-screen before using it.
dragImage.style.position = "absolute";
dragImage.style.top = "-9999px";
dragImage.style.pointerEvents = "none"; // Prevent interference
dragImage.style.zIndex = "1";
dragImage.style.transform = pileElement.style.transform;
document.body.appendChild(dragImage);
// calculating where the click occurred on the original card
const rect = cardElement.container.getBoundingClientRect(); // Get element position
const offsetX = e.clientX - rect.left; // X offset from where user clicked
const offsetY = e.clientY - rect.top; // Y offset from where user clicked
e.dataTransfer?.setDragImage(dragImage, offsetX, offsetY);
e.dataTransfer?.setData("application/json", JSON.stringify(data));
}
function dragend(e) {
// clears the image being used by drag
const dragImage = document.getElementById("card-dragImage");
if (dragImage) {
dragImage.remove();
}
// if the drop target isnt an element then abort
if (!(e.target instanceof HTMLElement))
return;
const cardElement = findCardContainer(e.target);
if (cardElement === null || cardElement === undefined)
return;
const parent = cardElement.container.parentElement;
// clears dragging class from all selected elements
if (parent) {
Array.from(parent.children).forEach((child) => {
child.classList.remove("card-dragging");
});
}
}
function drop(e) {
// if drop target isnt element, get out
if (!(e.target instanceof HTMLElement))
return;
const jsonData = e.dataTransfer?.getData("application/json");
// if the data isnt there, the draggable probably shouldn't have been draggable
if (!jsonData)
throw "no json data... source probably isnt draggable";
const { indexs, sourcePileContainerId } = JSON.parse(jsonData);
// something went wrong with the data
if (indexs.length === 0 || !sourcePileContainerId) {
throw "no card index during drop";
}
// figure out which piles the cards came from / are going to
const sourcePile = findPileElement(sourcePileContainerId);
const destinationPile = findPileElement(container.id);
// dont animate when cards set back down
if (sourcePile.container.id === container.id) {
return "cant drop in own container";
}
// grabs all the card elements from the index data
const cardElements = indexs.map((index) => {
return sourcePile.cardElements[parseInt(index)];
});
// removes all the card-dragging classes
cardElements.forEach((element) => {
element.container.classList.remove("card-dragging");
});
// try passing the first card
const attemptPrimaryMove = sourcePile.moveCardToPile(destinationPile, sourcePile.cardElements[parseInt(indexs[0])]);
if (attemptPrimaryMove === false) {
cardElements.forEach((element) => {
denyMove(element);
});
}
// if the first card is successful, pass the rest
else {
cardElements.splice(0, 1);
cardElements.forEach((element, index) => {
sourcePile.moveCardToPile(destinationPile, element, index + 1);
});
}
}
//
//
//
//
//
//
/********************************************************************************
* ********************** Touch and Drop Below ***********************************
* ******************************************************************************
*/
//
//
//
//
//
//
//
const touchData = {
startX: 0,
startY: 0,
cardElement: [],
indexs: [],
dragImage: null,
};
// Handle touch start
function handleTouchStart(e) {
// prevent default stops the scrolling of the page during a touch event
e.preventDefault();
e.stopPropagation();
// if the top card is moving, return
if (cardElements[cardElements.length - 1].transform.active === true) {
return;
}
if (!(e.target instanceof HTMLElement))
return;
// separate the touch event and the card element touched
const touch = e.touches[0];
const cardElement = findCardContainer(e.target);
if (!cardElement)
return;
// ensure the card grabbed is passable
if (options.rules.canPass(findPileElement(container.id), {}, cardElement) === false) {
denyMove(cardElement);
return;
}
// update touch data
touchData.startX = touch.clientX;
touchData.startY = touch.clientY;
touchData.cardElement.push(cardElement);
// Create a drag image similar to the desktop version
const rect = cardElement.container.getBoundingClientRect();
// using window.scrollX vs clientX because on zoom on mobile clientX causes wrong placement
touchData.startX = touch.pageX - window.scrollX - rect.left;
touchData.startY = touch.pageY - window.scrollY - rect.top;
// save original transform info
const originalTransform = cardElement.container.style.transform;
// setup the drag image div
const dragImage = document.createElement("div");
dragImage.id = "card-dragImage";
dragImage.classList.add("drag-image");
dragImage.style.position = "absolute";
dragImage.style.left = `${touch.pageX - touchData.startX}px`;
dragImage.style.top = `${touch.pageY - touchData.startY}px`;
dragImage.style.opacity = "0.5";
dragImage.style.pointerEvents = "none";
dragImage.style.zIndex = "1000";
dragImage.id = "card-dragImage";
// clear the transform for the original drag image. (this one is absolute, so a transform will unnecessarily move it)
cardElement.container.style.transform = "";
const currentDragItem = cardElement.container.cloneNode(true);
// add the original transform back on after cloned
cardElement.container.style.transform = originalTransform;
// Apply dragging class
cardElement.container.classList.add("card-dragging");
// append dragItem to dragImage, add to page
dragImage.appendChild(currentDragItem);
document.body.appendChild(dragImage);
// save data to touchData
touchData.indexs.push(cardElements.indexOf(cardElement));
if (options.groupDrag) {
// Get the parent element that holds the card and its siblings.
const pileElement = cardElement.container.parentElement;
if (!pileElement)
return;
const originalIndex = cardElements.indexOf(cardElement);
// Iterate over all children in the pile.
cardElements.forEach((element) => {
// Get the card's z-index as a number.
const cardIndex = cardElements.indexOf(element);
// Only add the class if the card's z-index is higher than the original.
// Clone each card element and append to dragImage.
if (cardIndex > originalIndex) {
// since were dealing with absolute positioning we have to manually figure out the cascade offset for each subsequent card
const offsetDifference = cardIndex - originalIndex;
const Xoffset = cascadeOffset[0] *
cardElement.container.offsetWidth *
offsetDifference;
const Yoffset = cascadeOffset[1] *
cardElement.container.offsetHeight *
offsetDifference;
// add dragging class to next element
element.container.classList.add("card-dragging");
// save original transform info
const originalTransform = element.container.style.transform;
const containerScale = container.style.transform;
// change the transform of the card, so we can accurately clone the element
const newTransform = `translate(${Xoffset}px, ${Yoffset}px) ${containerScale}`;
element.container.style.transform = newTransform;
const clone = element.container.cloneNode(true);
// after clone, revert to original
element.container.style.transform = originalTransform;
// add new element to dragImage
dragImage.appendChild(clone);
if (cardIndex !== originalIndex) {
touchData.indexs.push(cardIndex);
}
}
});
}
// save data to touchData
touchData.dragImage = dragImage;
}
// Handle touch move
function handleTouchMove(e) {
// stops scrolling
e.preventDefault();
// separate the touch event and get the dragImage from data
const touch = e.touches[0];
const { dragImage } = touchData;
if (dragImage) {
// Use pageX/pageY for zoom-safe movement
dragImage.style.left = `${touch.pageX - touchData.startX}px`;
dragImage.style.top = `${touch.pageY - touchData.startY}px`;
}
}
// Handle touch end
function handleTouchEnd(e) {
if (cardElements[cardElements.length - 1].transform.active === true) {
// stop scrolling
e.preventDefault();
e.stopPropagation();
return;
}
if (!(e.target instanceof HTMLElement))
return;
// get the cardElement
const cardElement = findCardContainer(e.target);
const { dragImage } = touchData;
// ensure all the data was found
if (!cardElement || !dragImage)
return;
if (cardElement === null || cardElement === undefined)
return;
const parent = cardElement.container.parentElement;
// clears dragging class from all selected elements
if (parent) {
Array.from(parent.children).forEach((child) => {
child.classList.remove("card-dragging");
});
}
// Remove drag image and dragging class
dragImage.remove();
// Simulate drop based on final touch position
const touch = e.changedTouches[0];
const dropTarget = document.elementFromPoint(touch.clientX, touch.clientY);
const data = {
indexs: touchData.indexs,
sourcePileContainerId: container.id,
};
// simulate dataTransfer object
const dataXfer = new DataTransfer();
dataXfer.setData("application/json", JSON.stringify(data));
if (dropTarget) {
// use the drop event from mouse events
const dropEvent = new DragEvent("drop", {
bubbles: true,
cancelable: true,
clientX: touch.clientX,
clientY: touch.clientY,
dataTransfer: dataXfer,
});
dropTarget.dispatchEvent(dropEvent);
}
// Reset touchData
touchData.startX = 0;
touchData.startY = 0;
touchData.cardElement.length = 0;
touchData.indexs.length = 0;
touchData.dragImage = null;
}
return {
get pile() {
return pile;
},
get cards() {
return pile.cards;
},
get cascadeOffset() {
return cascadeOffset;
},
get topCardElement() {
return cardElements[cardElements.length - 1];
},
get cardElements() {
return cardElements;
},
get container() {
return container;
},
get cascadeDuration() {
return cascadeDuration;
},
options,
moveCardToPile,
updateShadows,
cascade,
applyCascadeLayout,
createCascadeLayout,
findCardContainer,
shuffle,
};
};