Deck
-
+
-
+
diff --git a/TableturfBattleClient/mobile-drag-drop b/TableturfBattleClient/mobile-drag-drop
new file mode 160000
index 0000000..e30fa87
--- /dev/null
+++ b/TableturfBattleClient/mobile-drag-drop
@@ -0,0 +1 @@
+Subproject commit e30fa878adfb669745aa52e4bb6de8f31fbd1828
diff --git a/TableturfBattleClient/src/CheckButtonGroup.ts b/TableturfBattleClient/src/CheckButtonGroup.ts
index 36ec73b..6a6e181 100644
--- a/TableturfBattleClient/src/CheckButtonGroup.ts
+++ b/TableturfBattleClient/src/CheckButtonGroup.ts
@@ -31,9 +31,33 @@ class CheckButtonGroup
{
}
replace(index: number, button: CheckButton, value: TValue) {
+ const existingChild = this.entries[index].button.buttonElement;
this.entries[index] = { button, value };
this.setupButton(button, value);
- // The caller is responsible for adding the button to the DOM.
+ this.parentElement?.insertBefore(button.buttonElement, existingChild);
+ this.parentElement?.removeChild(existingChild);
+ }
+
+ insert(index: number, button: CheckButton, value: TValue) {
+ const existingChild = this.entries[index].button.buttonElement;
+ this.entries.splice(index, 0, { button, value });
+ this.setupButton(button, value);
+ this.parentElement?.insertBefore(button.buttonElement, existingChild);
+ }
+
+ removeAt(index: number) {
+ const existingChild = this.entries[index].button.buttonElement;
+ this.entries.splice(index, 1)
+ this.parentElement?.removeChild(existingChild);
+ }
+
+ move(index: number, newIndex: number | null) {
+ const entry = this.entries[index];
+ this.entries.splice(index, 1)
+ const existingChild = newIndex == null || newIndex >= this.entries.length ? null : this.entries[newIndex].button.buttonElement;
+ if (newIndex == null) this.entries.push(entry);
+ else this.entries.splice(newIndex, 0, entry);
+ this.parentElement?.insertBefore(entry.button.buttonElement, existingChild);
}
clear() {
diff --git a/TableturfBattleClient/src/Config.ts b/TableturfBattleClient/src/Config.ts
index edf98b3..ab0e8fd 100644
--- a/TableturfBattleClient/src/Config.ts
+++ b/TableturfBattleClient/src/Config.ts
@@ -7,3 +7,4 @@ interface Config {
}
declare var config: Config;
+declare var polyfillActive: boolean;
diff --git a/TableturfBattleClient/src/Pages/DeckEditPage.ts b/TableturfBattleClient/src/Pages/DeckEditPage.ts
index 8c8e807..d555860 100644
--- a/TableturfBattleClient/src/Pages/DeckEditPage.ts
+++ b/TableturfBattleClient/src/Pages/DeckEditPage.ts
@@ -17,7 +17,7 @@ const testStageSelectionDialog = document.getElementById('testStageSelectionDial
const deckEditCardButtons = new CheckButtonGroup(deckCardListEdit);
-let selectedDeckCardIndex: number | null = null;
+let draggingCardButton: Element | null = null;
function deckEditInitCardDatabase(cards: Card[]) {
for (const card of cards) {
@@ -32,8 +32,9 @@ function deckEditInitCardDatabase(cards: Card[]) {
button2.checked = false;
}
- if (selectedDeckCardIndex == null) return;
- const oldEntry = deckEditCardButtons.entries[selectedDeckCardIndex];
+ const index = deckEditCardButtons.entries.findIndex(el => el.button.checked);
+ if (index < 0) return;
+ const oldEntry = deckEditCardButtons.entries[index];
const oldCardNumber = oldEntry.value;
if (oldCardNumber != 0)
@@ -43,11 +44,7 @@ function deckEditInitCardDatabase(cards: Card[]) {
const button3 = createDeckEditCardButton(card.number);
button3.checked = true;
- const oldElement = oldEntry.button.buttonElement;
- deckCardListEdit.insertBefore(button3.buttonElement, oldElement);
- deckCardListEdit.removeChild(oldElement);
-
- deckEditCardButtons.replace(selectedDeckCardIndex, button3, card.number);
+ deckEditCardButtons.replace(index, button3, card.number);
deckEditUpdateSize();
cardList.listElement.parentElement!.classList.remove('selecting');
@@ -94,7 +91,6 @@ function editDeck() {
deckNameLabel2.innerText = selectedDeck.name;
deckEditCardButtons.clear();
- selectedDeckCardIndex = null;
for (let i = 0; i < 15; i++) {
if (selectedDeck.cards[i]) {
@@ -117,27 +113,88 @@ function editDeck() {
function createDeckEditCardButton(cardNumber: number) {
const button = new CardButton(cardDatabase.get(cardNumber));
+ button.buttonElement.draggable = true;
button.buttonElement.addEventListener('click', () => {
- selectedDeckCardIndex = deckEditCardButtons.entries.findIndex(e => e.button == button);
for (const button2 of cardList.cardButtons) {
button2.checked = button2.card.number == cardNumber;
}
cardList.listElement.parentElement!.classList.add('selecting');
});
+ button.buttonElement.addEventListener('dragstart', e => {
+ if (e.dataTransfer == null) return;
+ const index = deckEditCardButtons.entries.findIndex(el => el.button.buttonElement == e.currentTarget);
+ draggingCardButton = button.buttonElement;
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('application/tableturf-card-index', index.toString());
+ button.buttonElement.classList.add('dragging');
+ });
+ button.buttonElement.addEventListener('dragend', e => {
+ button.buttonElement.classList.remove('dragging');
+ if (draggingCardButton != null && e.currentTarget == draggingCardButton) {
+ const index = deckEditCardButtons.entries.findIndex(el => el.button.buttonElement == e.currentTarget) + 1;
+ deckCardListEdit.insertBefore(draggingCardButton, index >= deckEditCardButtons.entries.length ? null : deckEditCardButtons.entries[index].button.buttonElement);
+ draggingCardButton = null;
+ }
+ });
+ button.buttonElement.addEventListener('dragenter', e => e.preventDefault());
+ button.buttonElement.addEventListener('dragover', deckEditCardButton_dragover);
+ button.buttonElement.addEventListener('drop', deckEditCardButton_drop);
+
return button;
}
+function deckEditCardButton_dragover(e: DragEvent) {
+ e.preventDefault();
+ if (e.dataTransfer == null) return;
+ const indexString = e.dataTransfer.getData('application/tableturf-card-index');
+ if (indexString != '' && draggingCardButton != null) {
+ e.dataTransfer.dropEffect = 'move';
+ if (e.currentTarget != draggingCardButton && e.currentTarget != deckCardListEdit) {
+ // Move the card being dragged into the new position as a preview.
+ for (let el = draggingCardButton.nextElementSibling; el != null; el = el.nextElementSibling) {
+ if (el == e.currentTarget) {
+ deckCardListEdit.insertBefore(draggingCardButton, el.nextElementSibling);
+ return;
+ }
+ }
+ deckCardListEdit.insertBefore(draggingCardButton, e.currentTarget as Node);
+ }
+ } else if (e.dataTransfer.getData('text/plain'))
+ e.dataTransfer.dropEffect = 'copy';
+}
+
+function deckEditCardButton_drop(e: DragEvent) {
+ e.preventDefault();
+ if (e.dataTransfer == null) return;
+ const indexString = e.dataTransfer.getData('application/tableturf-card-index');
+ if (indexString) {
+ const index = parseInt(indexString);
+ let newIndex = 0;
+ for (let el = deckCardListEdit.firstElementChild; el != null; el = el.nextElementSibling) {
+ if (el == draggingCardButton) break;
+ newIndex++;
+ }
+ if (newIndex == index) return;
+ console.log(`Moving card ${index} to ${newIndex}.`);
+
+ deckEditCardButtons.move(index, newIndex);
+ draggingCardButton = null;
+ }
+}
+
function createDeckEditEmptySlotButton() {
const buttonElement = document.createElement('button');
const button = new CheckButton(buttonElement);
buttonElement.type = 'button';
buttonElement.className = 'card emptySlot';
buttonElement.addEventListener('click', () => {
- selectedDeckCardIndex = deckEditCardButtons.entries.findIndex(e => e.button == button);
for (const button2 of cardList.cardButtons)
button2.checked = false;
cardList.listElement.parentElement!.classList.add('selecting');
});
+ buttonElement.addEventListener('dragenter', e => e.preventDefault());
+ buttonElement.addEventListener('dragover', deckEditCardButton_dragover);
+ buttonElement.addEventListener('drop', deckEditCardButton_drop);
return button;
}
diff --git a/TableturfBattleClient/src/Pages/DeckListPage.ts b/TableturfBattleClient/src/Pages/DeckListPage.ts
index 9924dbe..e661ce2 100644
--- a/TableturfBattleClient/src/Pages/DeckListPage.ts
+++ b/TableturfBattleClient/src/Pages/DeckListPage.ts
@@ -1,3 +1,4 @@
+const deckListPage = document.getElementById('deckListPage')!;
const deckListBackButton = document.getElementById('deckListBackButton') as HTMLLinkElement;
const deckViewBackButton = document.getElementById('deckViewBackButton') as HTMLLinkElement;
const deckEditorDeckViewSection = document.getElementById('deckEditorDeckViewSection')!;
@@ -38,7 +39,10 @@ const deckImportFileBox = document.getElementById('deckImportFileBox') as HTMLIn
const deckImportErrorBox = document.getElementById('deckImportErrorBox')!;
const deckImportOkButton = document.getElementById('deckImportOkButton') as HTMLButtonElement;
-const deckButtons = new CheckButtonGroup();
+const deckButtons = new CheckButtonGroup(deckList);
+
+let deckListTouchMode = false;
+let draggingDeckButton: Element | null = null;
function showDeckList() {
showPage('deckList');
@@ -46,6 +50,18 @@ function showDeckList() {
deckButtons.deselect();
}
+deckList.addEventListener('touchstart', deckListEnableTouchMode);
+
+function deckListEnableTouchMode() {
+ if (deckListTouchMode) return;
+ deckListTouchMode = true;
+ deckListPage.classList.add('touchmode');
+ for (var b of deckButtons.buttons) {
+ b.buttonElement.draggable = false;
+ (b.buttonElement.getElementsByClassName('handle')[0] as HTMLElement).draggable = true;
+ }
+}
+
deckListBackButton.addEventListener('click', e => {
e.preventDefault();
showPage('preGame');
@@ -65,7 +81,7 @@ deckViewBackButton.addEventListener('click', e => {
e.preventDefault();
clearChildren(deckCardListView);
deselectDeck();
- deckEditorDeckViewSection.hidden = true;
+ deckListPage.classList.remove('showingDeck');
});
function saveDecks() {
@@ -90,25 +106,99 @@ function saveDecks() {
}
for (let i = 0; i < decks.length; i++) {
- createDeckButton(i, decks[i]);
+ createDeckButton(decks[i]);
}
}
-function createDeckButton(index: number, deck: Deck) {
+function createDeckButton(deck: Deck) {
const buttonElement = document.createElement('button');
buttonElement.type = 'button';
+ buttonElement.draggable = true;
const button = new CheckButton(buttonElement);
deckButtons.add(button, deck);
buttonElement.addEventListener('click', () => {
selectedDeck = deckButtons.value;
selectDeck();
});
- buttonElement.innerText = deck.name;
+ buttonElement.addEventListener('dragstart', e => {
+ if (e.dataTransfer == null) return;
+ const index = decks.indexOf(deck);
+ draggingDeckButton = buttonElement;
+ e.dataTransfer.effectAllowed = 'copyMove';
+ e.dataTransfer.setData('text/plain', JSON.stringify(deck, [ 'name', 'cards' ]));
+ e.dataTransfer.setData('application/tableturf-deck-index', index.toString());
+ buttonElement.classList.add('dragging');
+ });
+ buttonElement.addEventListener('dragend', e => {
+ buttonElement.classList.remove('dragging');
+ if (draggingDeckButton != null && e.currentTarget == draggingDeckButton) {
+ const index = deckButtons.entries.findIndex(el => el.button.buttonElement == e.currentTarget) + 1;
+ deckList.insertBefore(draggingDeckButton, index >= deckButtons.entries.length ? null : deckButtons.entries[index].button.buttonElement);
+ draggingDeckButton = null;
+ }
+ });
+ buttonElement.addEventListener('dragenter', e => e.preventDefault());
+ buttonElement.addEventListener('dragover', deckButton_dragover);
+ buttonElement.addEventListener('drop', deckButton_drop);
+
+ const handle = document.createElement('div');
+ handle.className = 'handle';
+ buttonElement.appendChild(handle);
+ buttonElement.appendChild(document.createTextNode(deck.name));
- deckList.insertBefore(buttonElement, addDeckControls);
return button;
}
+function deckButton_dragover(e: DragEvent) {
+ e.preventDefault();
+ if (e.dataTransfer == null) return;
+ const indexString = e.dataTransfer.getData('application/tableturf-deck-index');
+ if (indexString != '' && draggingDeckButton != null) {
+ e.dataTransfer.dropEffect = 'move';
+ if (e.currentTarget != draggingDeckButton && e.currentTarget != deckList) {
+ // Move the deck being dragged into the new position as a preview.
+ for (let el = draggingDeckButton.nextElementSibling; el != null; el = el.nextElementSibling) {
+ if (el == e.currentTarget) {
+ deckList.insertBefore(draggingDeckButton, el.nextElementSibling);
+ return;
+ }
+ }
+ deckList.insertBefore(draggingDeckButton, e.currentTarget as Node);
+ }
+ } else if (e.dataTransfer.getData('text/plain'))
+ e.dataTransfer.dropEffect = 'copy';
+}
+
+function deckButton_drop(e: DragEvent) {
+ e.preventDefault();
+ if (e.dataTransfer == null) return;
+ const indexString = e.dataTransfer.getData('application/tableturf-deck-index');
+ if (indexString) {
+ const index = parseInt(indexString);
+ let newIndex = 0;
+ for (let el = deckList.firstElementChild; el != null; el = el.nextElementSibling) {
+ if (el == draggingDeckButton) break;
+ newIndex++;
+ }
+ if (newIndex == index) return;
+ console.log(`Moving deck ${index} to ${newIndex}.`);
+
+ deckButtons.move(index, newIndex);
+ const deck = decks[index];
+ decks.splice(index, 1);
+ decks.splice(newIndex, 0, deck);
+ saveDecks();
+ draggingDeckButton = null;
+ return;
+ }
+ const text = e.dataTransfer.getData('text/plain');
+ if (text) {
+ const data = JSON.parse(text);
+ const decks = (data instanceof Array ? data : [ data ]) as Deck[];
+ importDecks(decks);
+ }
+}
+
function importDecks(decksToImport: (Deck | number[])[]) {
let newSelectedDeck: Deck | null = null;
for (const el of decksToImport) {
@@ -119,7 +209,7 @@ function importDecks(decksToImport: (Deck | number[])[]) {
deck = el;
if (!deck.name) deck.name = `Imported Deck ${decks.length + 1}`;
}
- createDeckButton(decks.length, deck);
+ createDeckButton(deck);
decks.push(deck);
newSelectedDeck ??= deck;
}
@@ -134,7 +224,7 @@ function importDecks(decksToImport: (Deck | number[])[]) {
newDeckButton.addEventListener('click', () => {
selectedDeck = new Deck(`Deck ${decks.length + 1}`, [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ], false);
- createDeckButton(decks.length, selectedDeck);
+ createDeckButton(selectedDeck);
decks.push(selectedDeck);
editDeck();
});
@@ -196,20 +286,29 @@ function selectDeck() {
}
}
+ deckListTestButton.disabled = false;
+ deckExportButton.disabled = false;
+ deckCopyButton.disabled = false;
deckEditButton.disabled = selectedDeck.isReadOnly;
deckRenameButton.disabled = selectedDeck.isReadOnly;
- deckCopyButton.disabled = false;
deckDeleteButton.disabled = selectedDeck.isReadOnly;
deckViewSize.innerText = size.toString();
- deckEditorDeckViewSection.hidden = false;
+ deckListPage.classList.add('showingDeck');
}
function deselectDeck() {
selectedDeck = null;
+ deckButtons.deselect();
+ clearChildren(deckCardListView);
+ deckNameLabel.innerText = '\u00a0';
+ deckViewSize.innerText = '0';
+ deckListTestButton.disabled = true;
+ deckExportButton.disabled = true;
+ deckCopyButton.disabled = true;
deckEditButton.disabled = true;
deckRenameButton.disabled = true;
- deckCopyButton.disabled = true;
deckDeleteButton.disabled = true;
+ deckListPage.classList.remove('showingDeck');
}
deckExportButton.addEventListener('click', () => {
@@ -244,19 +343,10 @@ deckDeleteButton.addEventListener('click', () => {
if (selectedDeck == null) return;
if (!confirm(`Are you sure you want to delete ${selectedDeck.name}?`)) return;
- for (const el of deckButtons.entries) {
- if (el.value == selectedDeck) {
- deckList.removeChild(el.button.buttonElement);
- break;
- }
- }
-
const index = decks.indexOf(selectedDeck);
if (index >= 0) decks.splice(index, 1);
- deckButtons.entries.splice(index, 1);
- deckButtons.deselect();
- selectedDeck = null;
- deckEditorDeckViewSection.hidden = true;
+ deckButtons.removeAt(index);
+ deselectDeck();
saveDecks();
});
diff --git a/TableturfBattleClient/src/Pages/PreGamePage.ts b/TableturfBattleClient/src/Pages/PreGamePage.ts
index 685fce6..6164afc 100644
--- a/TableturfBattleClient/src/Pages/PreGamePage.ts
+++ b/TableturfBattleClient/src/Pages/PreGamePage.ts
@@ -195,6 +195,8 @@ document.getElementById('preGameBackButton')!.addEventListener('click', e => {
backPreGameForm(true);
})
+preGameDeckEditorButton.addEventListener('touchstart', deckListEnableTouchMode);
+
preGameDeckEditorButton.addEventListener('click', e => {
e.preventDefault();
showDeckList();
diff --git a/TableturfBattleClient/tableturf.css b/TableturfBattleClient/tableturf.css
index cf410e6..97bd7da 100644
--- a/TableturfBattleClient/tableturf.css
+++ b/TableturfBattleClient/tableturf.css
@@ -1328,11 +1328,16 @@ dialog::backdrop {
:is(#deckListPage, #deckEditPage):not([hidden]) {
height: 100vh;
- display: grid;
- grid-template-columns: auto 1fr;
+ display: flex;
+ justify-content: center;
}
-.deckList button {
+.deckList {
+ display: flex;
+ flex-flow: column;
+}
+
+.deckList button, #addDeckControls button {
display: flex;
justify-content: center;
align-items: center;
@@ -1341,12 +1346,15 @@ dialog::backdrop {
border: inherit;
text-shadow: 0 0 4px black;
}
-.deckList > * {
+button.dragging {
+ opacity: 0.5;
+}
+.deckList > button, #addDeckControls {
width: 20rem;
height: 3rem;
margin: 0.5em;
}
-.deckList > button { background: var(--primary-colour-2); }
+.deckList > button { background: var(--primary-colour-2); position: relative;}
.deckList > button:hover { background: var(--special-colour-2); }
.deckList > button:focus-within { outline: 2px solid var(--special-accent-colour-2); }
.deckList > button:is(:active, .checked) { background: var(--special-accent-colour-2); }
@@ -1357,6 +1365,7 @@ dialog::backdrop {
gap: 1em;
}
#addDeckControls > button {
+ flex-basis: 8em;
flex-grow: 1;
background: black;
border: 0.25em solid var(--primary-colour-2);
@@ -1365,13 +1374,22 @@ dialog::backdrop {
#addDeckControls > button:focus-within { outline: 2px solid var(--special-accent-colour-2); }
#addDeckControls > button:is(:active, .checked) { border-color: var(--special-accent-colour-2); }
-.deckList {
- display: flex;
- flex-flow: column;
+.deckList .handle {
+ position: absolute;
+ background: url('assets/grip-horizontal.svg') 0.5em center/1em no-repeat;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+}
+
+.touchmode .handle {
+ width: 2em;
}
.card.emptySlot {
--colour: dimgrey;
+ background: url('assets/plus-circle.svg') center/2em no-repeat, black;
}
#deckCardListView, #deckCardListEdit {
@@ -1379,14 +1397,15 @@ dialog::backdrop {
grid-template-columns: auto auto auto;
justify-items: center;
grid-column: 2;
- grid-row: 1 / 5;
+ grid-row: 2 / -1;
overflow-y: scroll;
+ width: 33em;
}
-#deckEditorDeckViewSection:not([hidden]), #deckEditordeckEditPage {
+#deckEditorDeckViewSection:not([hidden]), #deckEditorDeckEditPage {
display: grid;
- grid-template-columns: minmax(min-content, auto) min-content;
- grid-template-rows: auto auto auto 1fr;
+ grid-template-columns: 1fr auto;
+ grid-template-rows: auto auto 1fr;
height: 100vh;
}
@@ -1395,21 +1414,35 @@ dialog::backdrop {
}
#deckName, #deckName2 {
- grid-column: 1;
+ grid-column: 1 / -1;
grid-row: 1;
}
-:is(#deckEditorDeckViewSection, #deckEditordeckEditPage) > h3 + div {
- grid-row: 2;
+#deckEditToolbar, #deckListToolbar {
grid-column: 1;
+ grid-row: 3;
+ display: flex;
+ flex-flow: column;
+}
+:is(#deckListToolbar, #deckEditToolbar) button {
+ margin: 0.25em;
+ width: 5em;
+ height: 4em;
}
.deckSizeContainer {
grid-column: 1;
- grid-row: 3;
+ grid-row: 2;
+ background: dimgrey;
+ text-align: center;
+ line-height: 1.5em;
+ margin: 0.25em;
+ min-width: 4em;
}
#deckEditorCardListSection {
+ flex-grow: 1;
+ background: #00000080;
height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
@@ -1684,16 +1717,21 @@ dialog::backdrop {
:is(#deckListPage, #deckEditPage) section {
position: absolute;
- background: black;
left: 0;
right: 0;
top: 0;
}
+ #deckListPage:not(.showingDeck) #deckEditorDeckViewSection {
+ display: none;
+ }
+ #deckListPage.showingDeck #deckEditorDeckListSection {
+ display: none;
+ }
+
#deckEditorDeckViewSection:not([hidden]) {
z-index: 10;
- grid-template-columns: auto 1fr 1fr;
- grid-template-rows: auto auto 1fr;
+ grid-template-columns: auto 1fr auto;
}
#deckViewBackButton {
@@ -1709,16 +1747,23 @@ dialog::backdrop {
}
#deckName {
- grid-column: 2;
+ grid-column: 2 / -1;
}
- :is(#deckEditorDeckViewSection, #deckEditordeckEditPage) > h3 + div {
- grid-row: 1;
- grid-column: -2;
+ #deckListToolbar, #deckEditToolbar {
+ grid-row: 2;
+ grid-column: 1 / -2;
+ flex-flow: row;
+ }
+
+ :is(#deckListToolbar, #deckEditToolbar) button {
+ width: auto;
+ flex-grow: 1;
+ flex-basis: 4em;
}
.deckSizeContainer {
- grid-column: 1 / -1;
+ grid-column: -2;
grid-row: 2;
justify-self: end;
}
@@ -1729,6 +1774,7 @@ dialog::backdrop {
overflow-y: scroll;
font-size: 80%;
grid-template-columns: 1fr 1fr 1fr;
+ width: 100%;
}
#deckEditorCardListSection:not(.selecting) {