// 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 = ``; 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 }); } });