You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

469 lines
15 KiB

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Card Sorting</title>
<style>
/* Reset */
body, html {
margin: 0;
padding: 0;
font-family: sans-serif;
}
* {
box-sizing: border-box;
}
/* Layout */
.main-layout {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: minmax(60px, min-content) 1fr;
gap: 10px 10px;
grid-template-areas:
"top top"
"left right";
margin: 10px;
}
.toolbar {
grid-area: top;
}
.uncategorized-cards {
position: relative;
grid-area: left;
}
.categories {
position: relative;
grid-area: right;
}
/* Muuri functional */
.grid {
position: relative;
}
.item {
display: block;
position: absolute;
}
.item.muuri-item-dragging {
z-index: 3;
}
.item.muuri-item-releasing {
z-index: 2;
}
.item.muuri-item-hidden {
z-index: 0;
}
.item-content {
position: relative;
width: 100%;
height: 100%;
}
/* Toolbar */
.toolbar {
display: flex;
padding: 5px;
align-items: center;
justify-content: space-around;
}
.toolbar button {
font-size: 24px;
cursor: pointer;
margin: 5px;
}
/* Uncategorized cards */
.uncategorized-cards {
border-radius: 5px;
width: 200px;
max-width: calc(50vw - 35px);
}
.uncategorized-cards .grid {
min-height: 100%;
}
.uncategorized-cards .item {
width: 100%;
}
.card {
width: 190px;
max-width: calc(50vw - 45px);
margin: 5px;
padding: 5px 7px;
border-radius: 3px;
font-size: 18px;
background: #ee6;
box-shadow: 0 2px 2px rgba(172, 121, 0, 0.58);
cursor: grab;
}
.card .card-image {
width: 100%;
border-radius: 4px;
}
.card .card-image-text:first-child {
display: inline-block;
margin-bottom: 4px;
}
.muuri-item-placeholder .card {
pointer-events: none;
background: transparent;
border: 2px dashed #bbb;
color: #999;
box-shadow: none;
border-radius: 7px;
}
.muuri-item-placeholder .card .card-image {
filter: grayscale(1);
opacity: .3;
}
/* Categories */
.category {
margin: 5px 15px 30px 5px;
padding: calc(5px + 5px);
background: #fff;
box-shadow: 0 2px 10px rgba(0, 0, 0, .1);
}
.category .title {
width: 100%;
max-width: 200px;
padding: 7px 5px;
margin-bottom: 10px;
border: 0;
border-bottom: 1px solid #bbf;
font-size: 20px;
color: inherit;
}
.category-cards .grid {
min-height: 100px;
}
.category-cards .item {
width: 100%;
}
.category-controls {
margin-bottom: 5px;
}
.category-drag-handle {
width: 100%;
height: 16px;
background: radial-gradient(#ddd 35%, transparent 36%) 0 0;
background-color: #fff;
background-size: 8px 8px;
cursor: grab;
}
</style>
</head>
<body>
<div class="main-layout">
<div class="toolbar">
<button class="btn-create-category">Create a new category</button>
<button class="btn-share-result">Share result</button>
<button class="btn-export-result">Export result</button>
</div>
<div class="uncategorized-cards">
<div class="grid"></div>
</div>
<div class="categories">
<div class="grid"></div>
</div>
</div>
<template class="card-template">
<div class="item">
<div class="item-content">
<div class="card"></div>
</div>
</div>
</template>
<template class="category-template">
<div class="item">
<div class="item-content">
<div class="category">
<div class="category-controls">
<div class="category-drag-handle"></div>
</div>
<input class="title" placeholder="Type a name" />
<div class="category-cards">
<div class="grid"></div>
</div>
</div>
</div>
</div>
</template>
<script src="muuri-0.9.5.min.js"></script>
<script src="pako-2.0.4.min.js"></script>
<script src="common-scripts.js"></script>
<script>
let data = {};
const settings = Object.assign({}, settingsDefaults);
/* Card sorting */
const buttonCreateCategory = document.querySelector('.btn-create-category');
const buttonShareResult = document.querySelector('.btn-share-result');
const buttonExportResult = document.querySelector('.btn-export-result');
const cardTemplate = document.querySelector('.card-template');
const categoryTemplate = document.querySelector('.category-template');
const cardImageRegex = /\s*image\s*:\s*((?:https?|data):\S+)/i;
const uncategorizedCardsGrid = new Muuri('.uncategorized-cards .grid', {
dragEnabled: true,
dragSort: () => cardGrids,
dragPlaceholder: {
enabled: true,
createElement: (item) => item.getElement().cloneNode(true),
},
dragAutoScroll: {
targets: [window],
sortDuringScroll: false,
},
});
const categoriesGrid = new Muuri('.categories .grid', {
dragEnabled: true,
dragSort: true,
dragHandle: '.category-drag-handle',
layoutOnResize: false,
}).on('layoutEnd', () => categoriesGrid.synchronize());
const cardGrids = [uncategorizedCardsGrid];
const debouncedUpdateData = debounce(updateData, 500);
initialize();
async function initialize() {
if ( ! window.location.hash) {
if (window.location.search) {
data = loadFromQueryParameters();
}
else {
window.location.href = 'index.html';
return;
}
}
else {
data = await loadFromString(window.location.hash.split('#').pop());
}
const cardTexts = data[uncategorizedKey];
Object.assign(settings, loadSettings(data));
if (settings.allowCategoryEditing === false) {
buttonCreateCategory.style.display = 'none';
categoryTemplate.content.querySelector('.title').disabled = true;
categoryTemplate.content.querySelector('.title').removeAttribute('placeholder');
}
buttonCreateCategory.addEventListener('click', () => createCategory());
if (settings.isRandomized) {
cardTexts.sort(() => Math.random() - 0.5);
}
for (const text of cardTexts) {
createCard(text);
}
const categories = Object.keys(data).filter(categoryName => !reservedKeys.includes(categoryName));
if (categories.length > 0) {
for (categoryName of categories) {
createCategory(categoryName);
}
}
else {
createCategory();
createCategory();
createCategory();
}
uncategorizedCardsGrid.on('dragEnd', function handleDragEnd(data) {
debouncedUpdateData();
categoriesGrid.refreshItems().layout();
});
buttonShareResult.addEventListener('click', shareResult);
buttonExportResult.addEventListener('click', exportResult);
// Fix for window resize caused by virtual keyboard
// Muuri layout is deferred until input loses focus
let isDeferredResize = false;
window.addEventListener('resize', debounce(function handleWindowResize() {
if (document.activeElement.tagName.toLowerCase() === 'input') {
isDeferredResize = true;
return;
}
categoriesGrid.refreshItems().layout();
}, Muuri.defaultOptions.layoutOnResize));
document.addEventListener('focusout', function handleInputDefocus() {
if ( ! isDeferredResize) {
return;
}
categoriesGrid.refreshItems().layout();
isDeferredResize = false;
});
}
function createCard(text, categoryGrid) {
const targetGrid = categoryGrid || uncategorizedCardsGrid;
const itemElement = document.importNode(cardTemplate.content.children[0], true);
const cardElement = itemElement.querySelector('.card');
const hasImage = text.match(cardImageRegex);
if (hasImage) {
const [textBefore, imageUrl, textAfter] = text.split(cardImageRegex);
const cardChildElements = [];
if (textBefore.trim()) {
const span = document.createElement('span');
span.className = 'card-image-text';
span.appendChild(document.createTextNode(textBefore));
cardChildElements.push(span);
}
const imageElement = document.createElement('img');
imageElement.className = 'card-image';
imageElement.onload = () => targetGrid.refreshItems().layout();
imageElement.src = imageUrl;
cardChildElements.push(imageElement);
if (textAfter.trim()) {
const span = document.createElement('span');
span.className = 'card-image-text';
span.appendChild(document.createTextNode(textAfter));
cardChildElements.push(span);
}
cardElement.replaceChildren(...cardChildElements);
}
else {
cardElement.textContent = text;
}
cardElement.setAttribute('data-text', text);
targetGrid.show(
targetGrid.add([itemElement], {
active: false,
}),
{
onFinish: () => {
categoriesGrid.refreshItems().layout();
},
},
);
}
function createCategory(name, cards) {
const itemElement = document.importNode(categoryTemplate.content.children[0], true);
const titleInput = itemElement.querySelector('.title');
titleInput.addEventListener('input', function handleInput() {
debouncedUpdateData();
});
if (name) {
titleInput.value = name;
}
else if (settings.allowCategoryEditing === false) {
titleInput.style.opacity = '0';
titleInput.style.height = '0';
titleInput.style.margin = '0';
titleInput.style.padding = '0';
}
categoriesGrid.show(
categoriesGrid.add([itemElement], {
active: false,
index: !!name ? -1 : 0,
}),
{
onFinish: () => {
const categoryGrid = new Muuri(itemElement.querySelector('.grid'), {
dragEnabled: true,
dragSort: () => cardGrids,
dragPlaceholder: {
enabled: true,
createElement: (item) => item.getElement().cloneNode(true),
},
dragContainer: document.body,
dragAutoScroll: {
targets: [window],
sortDuringScroll: false,
},
});
categoryGrid.on('dragEnd', function handleDragEnd(data) {
categoriesGrid.refreshItems().layout();
});
categoryGrid.on('dragReleaseEnd', function handleDragReleaseEnd(data) {
categoryGrid.synchronize();
debouncedUpdateData();
});
cardGrids.push(categoryGrid);
const cardTexts = data[name] || [];
for (const text of cardTexts) {
createCard(text, categoryGrid);
}
},
}
);
}
function updateData() {
let untitledCounter = 0;
data = {};
for (const categoryElement of document.querySelectorAll('.category')) {
let categoryName = categoryElement.querySelector('.title').value;
if ( ! categoryName) {
untitledCounter += 1;
categoryName = `(Untitled ${untitledCounter})`;
}
if (categoryName in data) {
categoryName = getUniqueCategoryName(categoryName, data);
}
data[categoryName] = [];
for (const cardElement of categoryElement.querySelectorAll('.card')) {
data[categoryName].push(cardElement.getAttribute('data-text'));
}
}
data[uncategorizedKey] = [];
for (const cardElement of document.querySelectorAll('.uncategorized-cards .card')) {
data[uncategorizedKey].push(cardElement.getAttribute('data-text'));
}
data[settingsFlagsKey] = saveSettings(settings);
}
function getUniqueCategoryName(originalName, data) {
let candidateName = originalName
let counter = 1;
while (candidateName in data) {
counter += 1;
candidateName = `${originalName} (${counter})`;
}
return candidateName;
}
async function shareResult() {
updateData();
const dataString = await saveToString(data);
const baseUrl = window.location.href.replace(regexRemoveBasenameFromUrl, '/');
const url = baseUrl + 'card-sort.html#' + dataString;
if (navigator.share) {
navigator.share({
title: '',
text: '',
url: url,
});
}
else if (navigator.clipboard) {
navigator.clipboard.writeText(url);
const originalText = buttonShareResult.textContent;
buttonShareResult.textContent = 'Link copied to clipboard';
clearTimeout(buttonShareResult._textTimeout);
buttonShareResult._textTimeout = setTimeout(() => {
buttonShareResult.textContent = originalText;
}, 5000);
}
else {
window.location.href = url;
alert('Your browser does not support sharing. Please copy from the address bar instead.');
}
}
function exportResult() {
updateData();
const cleanedData = {};
for ([key, value] of Object.entries(data)) {
if (reservedKeys.includes(key)) {
continue;
}
cleanedData[key] = value;
}
const serializedData = JSON.stringify(cleanedData, null, 2);
if (navigator.clipboard) {
navigator.clipboard.writeText(serializedData);
const originalText = buttonExportResult.textContent;
buttonExportResult.textContent = 'Data copied to clipboard';
clearTimeout(buttonExportResult._textTimeout);
buttonExportResult._textTimeout = setTimeout(() => {
buttonExportResult.textContent = originalText;
}, 5000);
}
else {
prompt('Your browser does not support clipboard. You may try copying the content from below.', serializedData);
}
}
</script>
</body>
</html>