570 lines
19 KiB
JavaScript
570 lines
19 KiB
JavaScript
// Constants ///////////////////////////////////////////////////////////////////
|
|
const kDimensions = {
|
|
wheelSize: 96,
|
|
leverWidth: 98,
|
|
sausageHeight: 48,
|
|
};
|
|
const kState = {
|
|
waiting: "waiting",
|
|
readyToGrate: "readyToGrate",
|
|
readyToLoad: "readyToLoad",
|
|
};
|
|
const kAction = {
|
|
completeLoading: Symbol("completeLoading"),
|
|
startPullingLever: Symbol("startPullingLever"),
|
|
stopPullingLever: Symbol("stopPullingLever"),
|
|
pullLever: Symbol("pullLever"),
|
|
startTurningWheel: Symbol("startTurningWheel"),
|
|
stopTurningWheel: Symbol("stopTurningWheel"),
|
|
turnWheel: Symbol("turnWheel"),
|
|
setIsWaitingForFrame: Symbol("setIsWaitingForFrame"),
|
|
createSausage: Symbol("createSausage"),
|
|
completeGrating: Symbol("completeGrating"),
|
|
};
|
|
const kRotationDirection = {
|
|
clockwise: Symbol("clockwise"),
|
|
counterClockwise: Symbol("counterClockwise"),
|
|
};
|
|
const kPullDirection = {
|
|
right: Symbol("right"),
|
|
left: Symbol("left"),
|
|
};
|
|
|
|
// Configuration ///////////////////////////////////////////////////////////////
|
|
const configuration = {
|
|
wheelStrength: 1,
|
|
maxLoad: 100,
|
|
leverTravel: 80.72,
|
|
graterTravel: 69,
|
|
chainSpeed: 0.5,
|
|
grateStrength: 0.01,
|
|
defaultUrl: "/wiki/Machine",
|
|
};
|
|
|
|
// App State ///////////////////////////////////////////////////////////////////
|
|
/**
|
|
* The main app state
|
|
*/
|
|
let appState = null;
|
|
|
|
/**
|
|
* Functions to handle side effects: Creation or removal of HTML elements,
|
|
* actions that occur as a result of a change in state.
|
|
*/
|
|
const middleware = [
|
|
/**
|
|
* Makes text unselectable while we move the lever or wheel.
|
|
*/
|
|
function disableSelect(_, action) {
|
|
if (
|
|
action === kAction.startTurningWheel ||
|
|
action === kAction.startPullingLever
|
|
) {
|
|
document.querySelector(".machine")?.classList.add("no-select");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Makes text selectable when we're done moving the lever or wheel.
|
|
*/
|
|
function enableSelect(_, action) {
|
|
if (
|
|
action === kAction.stopTurningWheel ||
|
|
action === kAction.stopPullingLever
|
|
) {
|
|
document.querySelector(".machine")?.classList.remove("no-select");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Cleans up the old iFrames when we load the new one, and triggers a complete.
|
|
*/
|
|
function completeLoading(state, action) {
|
|
if (
|
|
action === kAction.turnWheel &&
|
|
state.currentState === kState.readyToLoad &&
|
|
parseFloat(state.load) >= configuration.maxLoad
|
|
) {
|
|
const iframes = $iframeContainer.querySelectorAll("iframe");
|
|
for (const iframe of iframes) {
|
|
if (iframe !== appState.currentIframe) {
|
|
$iframeContainer.removeChild(iframe);
|
|
}
|
|
}
|
|
send(kAction.completeLoading);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Sets the individual load state to the current iframe.
|
|
*/
|
|
function updateIframe(state, action) {
|
|
if (
|
|
action === kAction.turnWheel &&
|
|
state.currentState === kState.readyToLoad &&
|
|
parseFloat(state.load) <= configuration.maxLoad
|
|
) {
|
|
state.currentIframe?.style.setProperty(
|
|
"--loadProgress",
|
|
`${100 - state.load}vh`,
|
|
);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Cleans up the sausage and loads the iframe once the grating is complete.
|
|
*/
|
|
function completeGrating(state, action) {
|
|
if (
|
|
action === kAction.pullLever &&
|
|
state.currentState === kState.readyToGrate &&
|
|
parseFloat(state.grateLevel) >= kDimensions.sausageHeight
|
|
) {
|
|
removeSausages();
|
|
const $iframe = loadIframe(state.currentUrl, 0);
|
|
send(kAction.completeGrating, { iframe: $iframe });
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Creates the sausage element if we are at the right state.
|
|
*/
|
|
function createSausageIfWaiting(state, action, data) {
|
|
if (
|
|
action === kAction.createSausage &&
|
|
state.currentState === kState.waiting
|
|
) {
|
|
createSausage(data.href, data.label);
|
|
}
|
|
},
|
|
];
|
|
|
|
/**
|
|
* We store the state as CSS variables and data attributes in an element
|
|
* declared as the $stateContainer. UI updates as a result of this. We
|
|
* try to avoid manipulating the elements with JS, except for creating and
|
|
* removing elements.
|
|
*/
|
|
const $stateContainer = document.querySelector(".machine");
|
|
const updateState = function updateState(state) {
|
|
for (const [key, value] of Object.entries(state)) {
|
|
$stateContainer.style.setProperty(`--${key}`, value);
|
|
$stateContainer.dataset[key] = value;
|
|
}
|
|
appState = state;
|
|
};
|
|
|
|
/**
|
|
* We modify a copy of the state and return it.
|
|
*/
|
|
const reduce = function reduce(stateReference, action, data) {
|
|
let state = Object.assign({}, stateReference);
|
|
|
|
switch (action) {
|
|
case kAction.turnWheel:
|
|
state.wheelRotation = `${data.angle}rad`;
|
|
if (state.currentState === kState.readyToLoad) {
|
|
const sign = data.direction === kRotationDirection.clockwise ? 1 : -1;
|
|
const load = Math.max(
|
|
Math.min(
|
|
state.load - sign * data.magnitude * configuration.wheelStrength,
|
|
configuration.maxLoad,
|
|
),
|
|
0,
|
|
);
|
|
state.load = load;
|
|
if (load < configuration.maxLoad && load > 0) {
|
|
const chainOffset =
|
|
parseFloat(state.chainOffset) +
|
|
sign * data.magnitude * configuration.chainSpeed;
|
|
state.chainOffset = `${chainOffset}%`;
|
|
}
|
|
}
|
|
break;
|
|
case kAction.pullLever:
|
|
const diff = Math.abs(state.leverTravel - data.magnitude);
|
|
state.leverTravel = data.magnitude;
|
|
const rotation = (data.magnitude * configuration.leverTravel) / 100;
|
|
state.leverRotation = `${rotation}deg`;
|
|
const gratingTravel = (data.magnitude * configuration.graterTravel) / 100;
|
|
state.graterTravel = `${gratingTravel}px`;
|
|
if (state.currentState === kState.readyToGrate) {
|
|
const grateLevel = Math.min(
|
|
parseFloat(state.grateLevel) + diff * configuration.grateStrength,
|
|
kDimensions.sausageHeight,
|
|
);
|
|
state.grateLevel = `${grateLevel}px`;
|
|
}
|
|
break;
|
|
case kAction.createSausage:
|
|
state.currentUrl = data.href;
|
|
state.grateLevel = "0%";
|
|
state.currentState = kState.readyToGrate;
|
|
break;
|
|
case kAction.startTurningWheel:
|
|
state.isTurningWheel = true;
|
|
break;
|
|
case kAction.stopTurningWheel:
|
|
state.isTurningWheel = false;
|
|
break;
|
|
case kAction.startPullingLever:
|
|
state.isPullingLever = true;
|
|
break;
|
|
case kAction.stopPullingLever:
|
|
state.isPullingLever = false;
|
|
break;
|
|
case kAction.completeLoading:
|
|
state.currentState = kState.waiting;
|
|
state.currentIframe = null;
|
|
state.load = 0;
|
|
break;
|
|
case kAction.completeGrating:
|
|
state.currentState = kState.readyToLoad;
|
|
state.currentIframe = data.iframe;
|
|
state.grateLevel = "0%";
|
|
state.load = 0;
|
|
break;
|
|
}
|
|
|
|
return state;
|
|
};
|
|
|
|
/**
|
|
* The main mechanism for view changes to trigger updates.
|
|
* It receives an action and its payload.
|
|
* It queues the middleware to run after this.
|
|
*/
|
|
const send = function send(action, data) {
|
|
const state = Object.assign({}, appState);
|
|
for (const m of middleware) {
|
|
setTimeout(() => m(state, action, data), 0);
|
|
}
|
|
const newState = reduce(state, action, data);
|
|
updateState(newState);
|
|
};
|
|
|
|
// Initializzation /////////////////////////////////////////////////////////////
|
|
|
|
/**
|
|
* Initializes state and events.
|
|
*/
|
|
const initialize = function initialize() {
|
|
const state = {
|
|
currentState: kState.waiting,
|
|
isPullingLever: false,
|
|
isTurningWheel: false,
|
|
leverTravel: 0,
|
|
grateLevel: 0,
|
|
leverRotation: "0deg",
|
|
graterTravel: "0rem",
|
|
wheelRotation: "0deg",
|
|
chainOffset: "0%",
|
|
load: 0,
|
|
};
|
|
updateState(state);
|
|
|
|
// Reset interaction with widgets if we lift a mouse button.
|
|
document.addEventListener("mouseup", () => {
|
|
send(kAction.stopTurningWheel);
|
|
send(kAction.stopPullingLever);
|
|
});
|
|
|
|
// The lever handle initiates interactions with the lever.
|
|
document
|
|
.querySelector(".lever .handle")
|
|
?.addEventListener("mousedown", () => {
|
|
send(kAction.startPullingLever);
|
|
});
|
|
|
|
// The wheel handle initiates interactions with the wheel.
|
|
document
|
|
.querySelector(".wheel .handle")
|
|
?.addEventListener("mousedown", () => {
|
|
send(kAction.startTurningWheel);
|
|
});
|
|
|
|
// Rotation is calculated relatively to the whole wheel.
|
|
// And travel based no the full arc of the lever
|
|
const $wheel = document.querySelector(".wheel");
|
|
const $lever = document.querySelector(".lever");
|
|
document.addEventListener("mousemove", (event) => {
|
|
if (appState.isTurningWheel && !appState.waitingForFrame) {
|
|
requestAnimationFrame(() => {
|
|
const elementPosition = $wheel.getBoundingClientRect();
|
|
const x = event.clientX - elementPosition.left;
|
|
const y = event.clientY - elementPosition.top;
|
|
const angle = angleFromCenter(
|
|
x,
|
|
y,
|
|
kDimensions.wheelSize,
|
|
kDimensions.wheelSize,
|
|
);
|
|
const oldAngle = parseFloat(appState.wheelRotation);
|
|
const { direction, magnitude } = rotationDirection(oldAngle, angle);
|
|
send(kAction.turnWheel, { angle, direction, magnitude });
|
|
send(kAction.setIsWaitingForFrame, { state: false });
|
|
});
|
|
send(kAction.setIsWaitingForFrame, { state: true });
|
|
}
|
|
if (appState.isPullingLever && !appState.waitingForFrame) {
|
|
requestAnimationFrame(() => {
|
|
const elementPosition = $lever.getBoundingClientRect();
|
|
const x = event.clientX - elementPosition.left;
|
|
const magnitude = Math.max(
|
|
Math.min((x * 100) / kDimensions.leverWidth, 100),
|
|
0,
|
|
);
|
|
const direction =
|
|
magnitude >= appState.leverTravel
|
|
? kPullDirection.right
|
|
: kPullDirection.left;
|
|
send(kAction.pullLever, { magnitude, direction });
|
|
send(kAction.setIsWaitingForFrame, { state: false });
|
|
});
|
|
send(kAction.setIsWaitingForFrame, { state: true });
|
|
}
|
|
});
|
|
|
|
// The URL Grater is the drop area.
|
|
const $grater = document.querySelector(".grater-bed");
|
|
$grater.addEventListener("dragenter", (event) => {
|
|
event.preventDefault();
|
|
if (appState.currentState === kState.waiting) {
|
|
$grater.classList.add("drag-over");
|
|
}
|
|
});
|
|
$grater.addEventListener("dragover", (event) => event.preventDefault());
|
|
$grater.addEventListener("dragleave", (event) => {
|
|
if (!$grater.contains(event.relatedTarget)) {
|
|
$grater.classList.remove("drag-over");
|
|
}
|
|
});
|
|
$grater.addEventListener("drop", (event) => {
|
|
event.preventDefault();
|
|
if (appState.currentState !== kState.waiting) {
|
|
return;
|
|
}
|
|
const href = event.dataTransfer.getData("text/uri-list");
|
|
const label = event.dataTransfer.getData("text/plain");
|
|
$grater.classList.remove("drag-over");
|
|
send(kAction.createSausage, { href, label });
|
|
});
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
const urlFromQuery = params.get("url");
|
|
const url = urlFromQuery ?? configuration.defaultUrl;
|
|
loadIframe(url, 100);
|
|
};
|
|
|
|
// Utility Functions ///////////////////////////////////////////////////////////
|
|
|
|
/**
|
|
* Calculates the angle of a point (x,y) relative to the center of a square
|
|
* with dimensions (w, h). The result is in radians.
|
|
*/
|
|
const angleFromCenter = function angleFromCenter(x, y, w, h) {
|
|
const centerX = w / 2;
|
|
const centerY = h / 2;
|
|
const dx = x - centerX;
|
|
const dy = y - centerY;
|
|
return Math.atan2(dy, dx);
|
|
};
|
|
|
|
/**
|
|
* Given two angles, calculates the direction of the rotation.
|
|
*/
|
|
const rotationDirection = function rotationDirection(angle1, angle2) {
|
|
let diff = angle2 - angle1;
|
|
|
|
if (diff > Math.PI) diff -= 2 * Math.PI;
|
|
if (diff < -Math.PI) diff += 2 * Math.PI;
|
|
|
|
const magnitude = Math.abs(diff);
|
|
const direction =
|
|
diff > 0
|
|
? kRotationDirection.counterClockwise
|
|
: kRotationDirection.clockwise;
|
|
|
|
return {
|
|
direction,
|
|
magnitude,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Loads an iframe and appends it into the body.
|
|
*/
|
|
const $iframeContainer = document.querySelector(".web-content");
|
|
const loadIframe = function loadIframe(url, loadProgress) {
|
|
const $iframe = document.createElement("iframe");
|
|
$iframe.src = `/proxy?url=${encodeURIComponent(url)}`;
|
|
$iframe.style.setProperty("--loadProgress", `${100 - loadProgress}vh`);
|
|
$iframeContainer.appendChild($iframe);
|
|
|
|
$iframe.addEventListener("load", () => {
|
|
const iframeDocument = $iframe.contentDocument;
|
|
|
|
iframeDocument.querySelectorAll("a").forEach(($link) => {
|
|
$link.addEventListener("click", (event) => event.preventDefault());
|
|
$link.draggable = true;
|
|
$link.addEventListener("dragstart", (event) => {
|
|
event.dataTransfer.setData("text/uri-list", $link.href);
|
|
event.dataTransfer.setData("text/plain", $link.textContent.trim());
|
|
const ghost = $link.cloneNode(true);
|
|
ghost.style.background = "url('/images/Sausage.svg')";
|
|
ghost.style.backgroundRepeat = "no-repeat";
|
|
ghost.style.cursor = "grabbing";
|
|
ghost.style.display = "block";
|
|
ghost.style.height = "3rem";
|
|
ghost.style.width = "8.125rem";
|
|
ghost.style.color = "#fff";
|
|
ghost.style.textAlign = "center";
|
|
ghost.style.fontSize = "0.625rem";
|
|
ghost.style.fontFamily = "sans-serif";
|
|
ghost.style.textTransform = "uppercase";
|
|
ghost.style.paddingTop = "1rem";
|
|
|
|
document.body.appendChild(ghost);
|
|
event.dataTransfer.setDragImage(ghost, 10, 10);
|
|
});
|
|
});
|
|
});
|
|
|
|
return $iframe;
|
|
};
|
|
|
|
/**
|
|
* Creates a sausage link for grating.
|
|
*/
|
|
const createSausage = function createSausage(href, label) {
|
|
const $sausage = document.createElement("article");
|
|
$sausage.classList.add("sausage");
|
|
$sausage.dataset.href = href;
|
|
$sausage.innerText = label;
|
|
const $graterBed = document.querySelector(".grater-bed");
|
|
$graterBed?.appendChild($sausage);
|
|
};
|
|
|
|
/**
|
|
* Removes a completely grated sausage link.
|
|
*/
|
|
const removeSausages = function removeSausages() {
|
|
const $graterBed = document.querySelector(".grater-bed");
|
|
const $existingSausages = $graterBed.querySelectorAll(".sausage");
|
|
for (const $existingSausage of $existingSausages) {
|
|
$graterBed?.removeChild($existingSausage);
|
|
}
|
|
};
|
|
|
|
initialize();
|
|
|
|
// MOBILE CHEESE SYSTEM
|
|
let mobileState = { href: null, label: null, isDragging: false };
|
|
|
|
window.addEventListener('message', (e) => {
|
|
if (e.data.type === 'linkTapped') {
|
|
mobileState.href = e.data.href;
|
|
mobileState.label = e.data.label;
|
|
|
|
const cheese = document.createElement('div');
|
|
cheese.textContent = '🧀';
|
|
cheese.style.position = 'fixed';
|
|
cheese.style.fontSize = '3rem';
|
|
cheese.style.zIndex = '10000';
|
|
cheese.style.left = '50%';
|
|
cheese.style.top = '50%';
|
|
cheese.style.transform = 'translate(-50%, -50%)';
|
|
document.body.appendChild(cheese);
|
|
|
|
cheese.addEventListener('touchstart', () => { mobileState.isDragging = true; });
|
|
|
|
let lastCheese = cheese;
|
|
document.addEventListener('touchmove', (e2) => {
|
|
if (!mobileState.isDragging) return;
|
|
const t = e2.touches[0];
|
|
lastCheese.style.left = t.clientX + 'px';
|
|
lastCheese.style.top = t.clientY + 'px';
|
|
}, { passive: false });
|
|
|
|
document.addEventListener('touchend', (e2) => {
|
|
if (!mobileState.isDragging) return;
|
|
mobileState.isDragging = false;
|
|
lastCheese.remove();
|
|
|
|
if (mobileState.href) {
|
|
const sausage = document.createElement('button');
|
|
sausage.textContent = mobileState.label;
|
|
sausage.style.cssText = `
|
|
position: fixed;
|
|
bottom: 50px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
padding: 14px 28px;
|
|
background: linear-gradient(180deg, #ff5555 0%, #dd0000 50%, #bb0000 100%);
|
|
color: white;
|
|
border: 4px solid #ffdd00;
|
|
border-radius: 50px;
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
z-index: 99999;
|
|
cursor: pointer;
|
|
box-shadow: 0 6px 20px rgba(0,0,0,0.4), inset 0 2px 8px rgba(255,255,255,0.3);
|
|
max-width: 85%;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
`;
|
|
|
|
sausage.addEventListener('click', async () => {
|
|
const grater = document.querySelector('.grater-bed');
|
|
const machine = document.querySelector('[class*="machine"]') || document.querySelector('body > div');
|
|
const levers = document.querySelectorAll('.lever');
|
|
const cheeses = document.querySelectorAll('div[style*="font-size: 3rem"]');
|
|
|
|
if (grater) grater.style.animation = 'grind 0.12s ease-in-out 6';
|
|
if (machine && machine !== document.body) machine.style.animation = 'shake 0.1s ease-in-out 6';
|
|
|
|
// Animate levers - pull/push them
|
|
levers.forEach((lever, index) => {
|
|
lever.style.animation = 'pump 0.15s ease-in-out 6';
|
|
// Also transform them to actually move
|
|
const handle = lever.querySelector('.handle');
|
|
if (handle) {
|
|
handle.style.animation = index === 0 ? 'pullPush 0.15s ease-in-out 6' : 'rotateWheel 0.15s ease-in-out 6';
|
|
}
|
|
});
|
|
|
|
cheeses.forEach(cheese => { cheese.style.animation = 'bounceGrate 0.2s ease-in-out 6'; });
|
|
|
|
await new Promise(r => setTimeout(r, 750));
|
|
|
|
if (grater) grater.style.animation = '';
|
|
if (machine) machine.style.animation = '';
|
|
levers.forEach(lever => { lever.style.animation = ''; });
|
|
cheeses.forEach(cheese => { cheese.style.animation = ''; });
|
|
|
|
const url = `${window.location.origin}/proxy?url=${encodeURIComponent(mobileState.href)}`;
|
|
document.querySelector('.web-content').innerHTML = `<iframe src="${url}" style="width:100%;height:100%;border:none;"></iframe>`;
|
|
sausage.remove();
|
|
});
|
|
|
|
if (!document.getElementById('grind-animations')) {
|
|
const style = document.createElement('style');
|
|
style.id = 'grind-animations';
|
|
style.innerHTML = `
|
|
@keyframes grind { 0% { transform: rotateZ(-3deg); } 25% { transform: rotateZ(3deg); } 50% { transform: rotateZ(-3deg); } 75% { transform: rotateZ(3deg); } 100% { transform: rotateZ(-3deg); } }
|
|
@keyframes bounceGrate { 0% { transform: translateY(0) scale(1); } 50% { transform: translateY(-5px) scale(1.05); } 100% { transform: translateY(0) scale(1); } }
|
|
@keyframes shake { 0% { transform: translateX(0); } 25% { transform: translateX(-2px); } 50% { transform: translateX(2px); } 75% { transform: translateX(-2px); } 100% { transform: translateX(0); } }
|
|
@keyframes pump { 0% { transform: rotateZ(0deg); } 25% { transform: rotateZ(-15deg); } 50% { transform: rotateZ(0deg); } 75% { transform: rotateZ(-15deg); } 100% { transform: rotateZ(0deg); } }
|
|
@keyframes pullPush { 0% { transform: translateY(0); } 25% { transform: translateY(20px); } 50% { transform: translateY(0); } 75% { transform: translateY(20px); } 100% { transform: translateY(0); } }
|
|
@keyframes rotateWheel { 0% { transform: rotate(0deg); } 25% { transform: rotate(90deg); } 50% { transform: rotate(0deg); } 75% { transform: rotate(90deg); } 100% { transform: rotate(0deg); } }
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
document.body.appendChild(sausage);
|
|
}
|
|
}, { passive: false });
|
|
}
|
|
});
|