From 5ea1f4fcd2e2280ad09dfc133115044591d6eb27 Mon Sep 17 00:00:00 2001 From: Fabio Date: Mon, 29 Dec 2025 18:24:58 +0100 Subject: [PATCH] like ios --- app.js | 548 +++++++++++++++++++++++++++++++++++++++++++++++++++-- index.html | 17 +- style.css | 189 ++++++++++++++++-- 3 files changed, 720 insertions(+), 34 deletions(-) diff --git a/app.js b/app.js index 109599c..2c64de6 100644 --- a/app.js +++ b/app.js @@ -1,19 +1,539 @@ -async function loadApps() { - const container = document.getElementById("folder"); - const apps = await fetch("apps.json").then(r => r.json()); +document.addEventListener("DOMContentLoaded", () => { + document.addEventListener("contextmenu", e => e.preventDefault()); + // ========================================================================== + // RIFERIMENTI DOM + // ========================================================================== + const folderEl = document.getElementById("folder"); + const contextMenuEl = document.getElementById("context-menu"); - apps.forEach(app => { - const div = document.createElement("div"); - div.className = "app-icon"; - div.onclick = () => window.open(app.url, "_blank", "noopener,noreferrer"); + // ========================================================================== + // STATO GLOBALE + // ========================================================================== + let appsData = []; + let appsOrder = []; + let editMode = false; - div.innerHTML = ` - ${app.name} - ${app.name} - `; + // Zoom + let zoomLevel; + let zoomMax; + let initialPinchDistance = null; + let lastTapTime = 0; + let zoomAnimFrame = null; - container.appendChild(div); - }); + // Long‑press / drag + let longPressTimer = null; + let longPressTarget = null; + let contextMenuTargetId = null; + + let draggingIcon = null; + let draggingId = null; + let dragOffsetX = 0; + let dragOffsetY = 0; + let dragStartX = 0; + let dragStartY = 0; + + // ========================================================================== + // CARICAMENTO APPS + // ========================================================================== + function loadOrder() { + try { + const val = localStorage.getItem("appsOrder"); + if (!val) return null; + const parsed = JSON.parse(val); + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } + } + + function saveOrder() { + localStorage.setItem("appsOrder", JSON.stringify(appsOrder)); + } + + function renderApps() { + folderEl.innerHTML = ""; + + appsOrder.forEach(id => { + const app = appsData.find(a => a.id === id); + if (!app) return; + + const div = document.createElement("div"); + div.className = "app-icon"; + div.dataset.id = app.id; + + div.innerHTML = ` + ${app.name} + ${app.name} + `; + + div.addEventListener("click", () => { + if (!editMode) window.open(app.url, "_blank", "noopener"); + }); + + folderEl.appendChild(div); + }); + } + + async function loadApps() { + const apps = await fetch("apps.json").then(r => r.json()); + + appsData = apps.map((app, i) => ({ + id: app.id || `app-${i}`, + name: app.name, + url: app.url, + icon: app.icon + })); + + const stored = loadOrder(); + if (stored) { + appsOrder = stored.filter(id => appsData.some(a => a.id === id)); + appsData.forEach(a => { + if (!appsOrder.includes(a.id)) appsOrder.push(a.id); + }); + } else { + appsOrder = appsData.map(a => a.id); + } + + renderApps(); + } + + // ========================================================================== + // ZOOM STILE IPHONE (PINCH ELASTICO) + // ========================================================================== + function computeDynamicMaxZoom() { + return Math.min(window.innerWidth / 85, 4.0); + } + + function loadInitialZoom() { + const v = parseFloat(localStorage.getItem("zoomLevel")); + if (!isFinite(v) || v <= 0) return 1; + return Math.min(Math.max(v, 0.5), computeDynamicMaxZoom()); + } + + function applyZoom(z) { + zoomLevel = (!isFinite(z) || z <= 0) ? 1 : z; + document.documentElement.style.setProperty("--zoom", zoomLevel); + localStorage.setItem("zoomLevel", String(zoomLevel)); + } + + function getPinchDistance(touches) { + const [a, b] = touches; + return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); + } + + function elasticEase(x) { + return Math.sin(x * Math.PI * 0.5) * 1.05; + } + + function initZoomHandlers() { + zoomMax = computeDynamicMaxZoom(); + zoomLevel = loadInitialZoom(); + applyZoom(zoomLevel); + + document.addEventListener("touchmove", e => { + if (e.touches.length === 2) e.preventDefault(); + }, { passive: false }); + + document.addEventListener("touchstart", e => { + if (e.touches.length === 2) { + initialPinchDistance = getPinchDistance(e.touches); + if (zoomAnimFrame) cancelAnimationFrame(zoomAnimFrame); + } + + const now = Date.now(); + if (e.touches.length === 1 && now - lastTapTime < 300) { + zoomMax = computeDynamicMaxZoom(); + applyZoom(Math.min(zoomLevel * 1.15, zoomMax)); + } + lastTapTime = now; + }); + + document.addEventListener("touchmove", e => { + if (e.touches.length === 2 && initialPinchDistance) { + const newDist = getPinchDistance(e.touches); + const scale = newDist / initialPinchDistance; + + let newZoom = zoomLevel * scale; + zoomMax = computeDynamicMaxZoom(); + + if (newZoom > zoomMax) newZoom = zoomMax + (newZoom - zoomMax) * 0.25; + if (newZoom < 0.5) newZoom = 0.5 - (0.5 - newZoom) * 0.25; + + applyZoom(newZoom); + initialPinchDistance = newDist; + e.preventDefault(); + } + }, { passive: false }); + + document.addEventListener("touchend", e => { + if (e.touches.length < 2 && initialPinchDistance) { + initialPinchDistance = null; + + zoomMax = computeDynamicMaxZoom(); + const target = Math.min(Math.max(zoomLevel, 0.5), zoomMax); + const start = zoomLevel; + const duration = 250; + const startTime = performance.now(); + + function animate(t) { + const p = Math.min((t - startTime) / duration, 1); + const eased = start + (target - start) * elasticEase(p); + applyZoom(eased); + if (p < 1) zoomAnimFrame = requestAnimationFrame(animate); + } + + zoomAnimFrame = requestAnimationFrame(animate); + } + }); + } + +// ========================================================================== + // EDIT MODE + MENU CONTESTUALE + WIGGLE + // ========================================================================== + function enterEditMode() { + editMode = true; + document.body.classList.add("edit-mode"); + } + + function exitEditMode() { + editMode = false; + document.body.classList.remove("edit-mode"); + hideContextMenu(); + } + + function showContextMenuFor(id, x, y) { + contextMenuTargetId = id; + contextMenuEl.style.left = `${x}px`; + contextMenuEl.style.top = `${y}px`; + contextMenuEl.classList.remove("hidden"); + } + + function hideContextMenu() { + contextMenuEl.classList.add("hidden"); + contextMenuTargetId = null; + } + + // ========================================================================== + // LONG PRESS → SOLO ENTRA IN WIGGLE MODE (NON APRE PIÙ IL MENÙ) + // ========================================================================== +function initLongPressHandlers() { + document.addEventListener("touchstart", e => { + if (e.touches.length !== 1) return; + + const touch = e.touches[0]; + const icon = touch.target.closest(".app-icon"); + + // Se premi su un'icona + if (icon) { + longPressTarget = icon; + + longPressTimer = setTimeout(() => { + + // 1️⃣ Se NON siamo in wiggle mode → entra in wiggle mode + if (!editMode) { + enterEditMode(); + if (navigator.vibrate) navigator.vibrate(10); + return; + } + + // 2️⃣ Se SIAMO in wiggle mode → apri il menù contestuale + const r = icon.getBoundingClientRect(); + showContextMenuFor( + icon.dataset.id, + r.left + r.width / 2, + r.top + r.height + ); + if (navigator.vibrate) navigator.vibrate(10); + + }, 350); + + return; + } + + // Se premi FUORI dalle icone + longPressTimer = setTimeout(() => { + if (editMode) exitEditMode(); + }, 350); + + }, { passive: true }); + + document.addEventListener("touchmove", e => { + if (!longPressTimer) return; + + const touch = e.touches[0]; + const dx = touch.clientX - (longPressTarget?.getBoundingClientRect().left ?? touch.clientX); + const dy = touch.clientY - (longPressTarget?.getBoundingClientRect().top ?? touch.clientY); + + if (Math.hypot(dx, dy) > 15) { + clearTimeout(longPressTimer); + longPressTimer = null; + longPressTarget = null; + } + }, { passive: true }); + + document.addEventListener("touchend", () => { + if (longPressTimer) { + clearTimeout(longPressTimer); + longPressTimer = null; + longPressTarget = null; + } + }, { passive: true }); } -loadApps(); + // ========================================================================== + // DOUBLE TAP (MOBILE) + DOUBLE CLICK (DESKTOP) → APRE IL MENÙ + // ========================================================================== +/* function initDoubleTapContextMenu() { + let lastTap = 0; + + // MOBILE double tap + folderEl.addEventListener("touchend", e => { + if (!editMode) return; + + const icon = e.target.closest(".app-icon"); + if (!icon) return; + + const now = Date.now(); + if (now - lastTap < 300) { + const r = icon.getBoundingClientRect(); + showContextMenuFor( + icon.dataset.id, + r.left + r.width / 2, + r.top + r.height + ); + if (navigator.vibrate) navigator.vibrate(10); + } + lastTap = now; + }); + + // DESKTOP double click + folderEl.addEventListener("dblclick", e => { + if (!editMode) return; + + const icon = e.target.closest(".app-icon"); + if (!icon) return; + + const r = icon.getBoundingClientRect(); + showContextMenuFor( + icon.dataset.id, + r.left + r.width / 2, + r.top + r.height + ); + }); + } +*/ +// ========================================================================== + // DRAG FLUIDO STILE IPHONE CON PLACEHOLDER + FIX "SOTTO IL DITO" + // ========================================================================== + function startDrag(icon, touch) { + draggingId = icon.dataset.id; + + // 1️⃣ Cattura posizione PRIMA di toccare il DOM + const r = icon.getBoundingClientRect(); + dragOffsetX = touch.pageX - r.left; + dragOffsetY = touch.pageY - r.top; + + // 2️⃣ Sposta SUBITO l’icona vera fuori dal flusso + draggingIcon = icon; + draggingIcon.classList.add("dragging"); + draggingIcon.style.position = "fixed"; + draggingIcon.style.left = `${r.left}px`; + draggingIcon.style.top = `${r.top}px`; + draggingIcon.style.width = `${r.width}px`; + draggingIcon.style.height = `${r.height}px`; + draggingIcon.style.zIndex = "1000"; + draggingIcon.style.pointerEvents = "none"; +// draggingIcon.style.transform = "translate3d(0,0,0)`; + draggingIcon.style.transform = "translate3d(0,0,0)"; + // 3️⃣ SOLO ORA crea il placeholder invisibile + const placeholder = icon.cloneNode(true); + placeholder.classList.add("placeholder"); + placeholder.style.visibility = "hidden"; + icon.parentNode.insertBefore(placeholder, icon); + + hideContextMenu(); + } + + function updateDragPosition(touch) { + if (!draggingIcon) return; + + // Mantieni l’icona ESATTAMENTE sotto il dito + const x = touch.pageX - dragOffsetX; + const y = touch.pageY - dragOffsetY; + + draggingIcon.style.left = `${x}px`; + draggingIcon.style.top = `${y}px`; + + // Trova icona sotto il dito + const elem = document.elementFromPoint(touch.clientX, touch.clientY); + const targetIcon = elem && elem.closest(".app-icon:not(.dragging):not(.placeholder)"); + if (!targetIcon) return; + + const from = appsOrder.indexOf(draggingId); + const to = appsOrder.indexOf(targetIcon.dataset.id); + if (from === -1 || to === -1 || from === to) return; + + // Riordino + appsOrder.splice(from, 1); + appsOrder.splice(to, 0, draggingId); + saveOrder(); + //renderApps(); + + // Ricrea placeholder nella nuova posizione + /*const oldPlaceholder = folderEl.querySelector(".app-icon.placeholder"); + if (oldPlaceholder) oldPlaceholder.remove(); + + const newPos = folderEl.querySelector(`.app-icon[data-id="${draggingId}"]`); + if (newPos) { + const clone = newPos.cloneNode(true); + clone.classList.add("placeholder"); + clone.style.visibility = "hidden"; + newPos.parentNode.insertBefore(clone, newPos); + }*/ + } + + // ========================================================================== + // DROP PRECISO NELLA CELLA CORRETTA + // ========================================================================== + function endDrag() { + if (!draggingIcon) return; + + const icon = draggingIcon; + draggingIcon = null; + + // 1️⃣ Rimuovi placeholder + const placeholder = folderEl.querySelector(".app-icon.placeholder"); + if (placeholder) placeholder.remove(); + + // 2️⃣ Calcola la cella target sotto il dito + const dropX = parseFloat(icon.style.left) + icon.offsetWidth / 2; + const dropY = parseFloat(icon.style.top) + icon.offsetHeight / 2; + + const elem = document.elementFromPoint(dropX, dropY); + const targetIcon = elem && elem.closest(".app-icon:not(.dragging)"); + + if (targetIcon) { + const from = appsOrder.indexOf(icon.dataset.id); + const to = appsOrder.indexOf(targetIcon.dataset.id); + + if (from !== -1 && to !== -1 && from !== to) { + appsOrder.splice(from, 1); + appsOrder.splice(to, 0, icon.dataset.id); + saveOrder(); + } + } + + // 3️⃣ Ripristina icona + icon.classList.remove("dragging"); + icon.style.position = ""; + icon.style.left = ""; + icon.style.top = ""; + icon.style.width = ""; + icon.style.height = ""; + icon.style.zIndex = ""; + icon.style.pointerEvents = ""; + icon.style.transform = ""; + + // 4️⃣ Re-render finale + renderApps(); + } + + function initDragHandlers() { + document.addEventListener("touchstart", e => { + if (!editMode) return; + if (e.touches.length !== 1) return; + + const touch = e.touches[0]; + const icon = touch.target.closest(".app-icon"); + if (!icon) return; + + if (contextMenuTargetId) return; + + dragStartX = touch.clientX; + dragStartY = touch.clientY; + draggingIcon = null; + draggingId = null; + }, { passive: true }); + + document.addEventListener("touchmove", e => { + if (!editMode) return; + if (e.touches.length !== 1) return; + + const touch = e.touches[0]; + + if (!draggingIcon) { + const dx = touch.clientX - dragStartX; + const dy = touch.clientY - dragStartY; + if (Math.hypot(dx, dy) > 10) { + const icon = touch.target.closest(".app-icon"); + if (icon) startDrag(icon, touch); + } + } else { + updateDragPosition(touch); + e.preventDefault(); + } + }, { passive: false }); + + document.addEventListener("touchend", e => { + if (!editMode) return; + if (draggingIcon && e.touches.length === 0) { + endDrag(); + } + }, { passive: true }); + } + + // ========================================================================== + // MENU CONTESTUALE: AZIONI + // ========================================================================== + function initContextMenuActions() { + contextMenuEl.addEventListener("click", e => { + const btn = e.target.closest("button"); + if (!btn || !contextMenuTargetId) return; + + const action = btn.dataset.action; + const app = appsData.find(a => a.id === contextMenuTargetId); + if (!app) return; + + if (action === "rename") { + const nuovoNome = prompt("Nuovo nome app:", app.name); + if (nuovoNome && nuovoNome.trim()) { + app.name = nuovoNome.trim(); + renderApps(); + saveOrder(); + } + } + + if (action === "change-icon") { + const nuovaIcona = prompt("URL nuova icona:", app.icon); + if (nuovaIcona && nuovaIcona.trim()) { + app.icon = nuovaIcona.trim(); + renderApps(); + saveOrder(); + } + } + + if (action === "remove") { + if (confirm("Rimuovere questa app dalla griglia?")) { + appsOrder = appsOrder.filter(id => id !== app.id); + saveOrder(); + renderApps(); + } + } + + hideContextMenu(); + }); + } + + // ========================================================================== + // INIT GLOBALE + // ========================================================================== + (async function init() { + await loadApps(); + initZoomHandlers(); + initLongPressHandlers(); // long‑press → wiggle mode + // initDoubleTapContextMenu(); // double tap / double click → menù + initDragHandlers(); + initContextMenuActions(); + })(); + +}); diff --git a/index.html b/index.html index 2b77246..2fc1ee5 100644 --- a/index.html +++ b/index.html @@ -2,11 +2,26 @@ - Folder style macOS + Launcher + + + + + +
+ + + + diff --git a/style.css b/style.css index fd874c4..6e8d655 100644 --- a/style.css +++ b/style.css @@ -1,42 +1,193 @@ -body { - background: #1e1e1e; +/* ============================================================ + BASE PAGE + ============================================================ */ + +html, body { + margin: 0; + padding: 0; + overflow-x: hidden; /* impedisce pan orizzontale */ + max-width: 100%; + touch-action: pan-y; /* solo scroll verticale */ + background: #ffffff; font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; - color: #f5f5f5; + color: #1a1a1a; + min-height: 100vh; /* evita scroll inutile se poche icone */ } +/* Impedisce selezione testo e highlight blu Android */ +* { + -webkit-user-select: none; + user-select: none; + -webkit-tap-highlight-color: transparent; +} + +/* Variabile di zoom globale */ +:root { + --zoom: 1; +} + +/* ============================================================ + GRIGLIA ICONE + ============================================================ */ + .folder { display: grid; - grid-template-columns: repeat(auto-fill, 100px); - gap: 24px; - padding: 32px; - justify-content: center; + grid-template-columns: repeat( + auto-fill, + minmax(calc(85px * var(--zoom)), 1fr) + ); + gap: calc(16px * var(--zoom)); + padding-top: 24px; + padding-left: 24px; + padding-right: 24px; + padding-bottom: 0; /* evita scroll verticale inutile */ + justify-items: center; + width: 100%; + max-width: 100%; + box-sizing: border-box; + + transition: grid-template-columns 0.15s ease-out, + gap 0.15s ease-out; } +/* Contenitore icona */ .app-icon { text-align: center; cursor: pointer; user-select: none; - transition: transform 0.15s ease, box-shadow 0.15s ease; + transition: transform 0.18s ease, filter 0.18s ease; + touch-action: manipulation; } +/* Icona */ .app-icon img { - width: 72px; - height: 72px; - border-radius: 20px; - box-shadow: 0 8px 18px rgba(0, 0, 0, 0.45); + width: calc(70px * var(--zoom)); + height: calc(70px * var(--zoom)); + border-radius: calc(20px * var(--zoom)); + background: #ffffff; + box-shadow: + 0 4px 10px rgba(0, 0, 0, 0.12), + 0 8px 24px rgba(0, 0, 0, 0.08); + display: block; + + transition: width 0.18s ease-out, + height 0.18s ease-out, + border-radius 0.18s ease-out, + transform 0.18s ease-out; } +/* Etichetta */ .app-icon span { display: block; - margin-top: 8px; - font-size: 13px; - color: #ddd; + margin-top: calc(6px * var(--zoom)); + font-size: calc(11px * var(--zoom)); + color: #3a3a3a; + font-weight: 500; + letter-spacing: -0.2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + transition: font-size 0.18s ease-out, + margin-top 0.18s ease-out; } -.app-icon:hover { - transform: scale(1.08); +/* ============================================================ + WIGGLE MODE + ============================================================ */ + +@keyframes wiggle { + 0% { transform: rotate(-2deg) scale(1.02); } + 50% { transform: rotate( 2deg) scale(0.98); } + 100% { transform: rotate(-2deg) scale(1.02); } } -.app-icon:active { - transform: scale(0.97); +.edit-mode .app-icon:not(.dragging) img { + animation: wiggle 0.25s ease-in-out infinite; +} + +/* Icona trascinata */ +.app-icon.dragging { + opacity: 0.9; + z-index: 1000; +} + +/* Placeholder invisibile */ +.app-icon.placeholder { + opacity: 0; + visibility: hidden; +} + +/* ============================================================ + MENU CONTESTUALE — ANDROID MATERIAL + RESPONSIVE ALLO ZOOM + ============================================================ */ + +#context-menu { + position: fixed; + background: #ffffff; + border-radius: calc(14px * var(--zoom)); + min-width: calc(180px * var(--zoom)); + padding: calc(8px * var(--zoom)) 0; + z-index: 2000; + + /* Ombra Material */ + box-shadow: + 0 calc(6px * var(--zoom)) calc(20px * var(--zoom)) rgba(0,0,0,0.18), + 0 calc(2px * var(--zoom)) calc(6px * var(--zoom)) rgba(0,0,0,0.12); + + /* Animazione apertura */ + opacity: 0; + transform: scale(0.85); + transform-origin: top center; + transition: opacity 120ms ease, transform 120ms ease; +} + +#context-menu:not(.hidden) { + opacity: 1; + transform: scale(1); +} + +#context-menu.hidden { + display: block; + opacity: 0; + pointer-events: none; +} + +/* Pulsanti del menù */ +#context-menu button { + all: unset; + width: 100%; + padding: calc(14px * var(--zoom)) calc(18px * var(--zoom)); + font-size: calc(15px * var(--zoom)); + color: #222; + display: flex; + align-items: center; + gap: calc(12px * var(--zoom)); + cursor: pointer; + position: relative; + overflow: hidden; +} + +/* Ripple effect */ +#context-menu button::after { + content: ""; + position: absolute; + inset: 0; + background: rgba(0,0,0,0.08); + opacity: 0; + transition: opacity 150ms; +} + +#context-menu button:active::after { + opacity: 1; +} + +/* Separatore tra voci */ +#context-menu button + button { + border-top: 1px solid rgba(0,0,0,0.08); +} + +/* Voce "Rimuovi" in rosso */ +#context-menu button:last-child { + color: #d11a2a; }