diff --git a/public/assets/desktop/desktop.css b/public/assets/desktop/desktop.css index 6e69a957..42f9bc14 100644 --- a/public/assets/desktop/desktop.css +++ b/public/assets/desktop/desktop.css @@ -109,16 +109,16 @@ h1 { .desktop-icons { position: absolute; - top: var(--icon-top); - left: var(--icon-left); + inset: 0; z-index: 10; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); - gap: 16px; - width: min(720px, calc(100% - 80px)); +} + +.desktop-icons.is-arranging { + cursor: progress; } .desktop-icon { + position: absolute; width: 96px; padding: 12px 10px; border: 1px solid transparent; @@ -126,7 +126,9 @@ h1 { text-align: center; background: rgba(255, 255, 255, 0.05); cursor: pointer; - transition: transform 140ms ease, background-color 140ms ease, border-color 140ms ease; + user-select: none; + touch-action: none; + transition: transform 140ms ease, background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease; } .desktop-icon:hover { @@ -135,6 +137,13 @@ h1 { border-color: var(--shell-border); } +.desktop-icon.is-dragging { + transform: scale(1.03); + box-shadow: 0 16px 36px rgba(15, 23, 42, 0.28); + background: rgba(255, 255, 255, 0.16); + border-color: rgba(255, 255, 255, 0.28); +} + .desktop-icon-badge { display: grid; place-items: center; @@ -154,11 +163,10 @@ h1 { padding: 3px 8px; border-radius: 6px; font-size: 14px; - color: #ffffff; + color: var(--desktop-icon-label-color, #0f172a); line-height: 1.25; - text-shadow: none; - mix-blend-mode: difference; - isolation: isolate; + text-shadow: 0 1px 2px rgba(255, 255, 255, 0.22); + transition: color 140ms ease, text-shadow 140ms ease; } .desktop-icon-label::before { @@ -167,9 +175,56 @@ h1 { inset: -2px -4px; z-index: -1; border-radius: 8px; - background: rgba(255, 255, 255, 0.18); + background: var(--desktop-icon-label-bg, rgba(255, 255, 255, 0.48)); backdrop-filter: blur(8px) saturate(1.08); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); + box-shadow: inset 0 0 0 1px var(--desktop-icon-label-border, rgba(255, 255, 255, 0.12)); +} + +.desktop-icon.is-contrast-light .desktop-icon-label { + --desktop-icon-label-color: #f8fafc; + --desktop-icon-label-bg: rgba(15, 23, 42, 0.44); + --desktop-icon-label-border: rgba(255, 255, 255, 0.12); + text-shadow: 0 1px 2px rgba(2, 6, 23, 0.38); +} + +.desktop-icon.is-contrast-dark .desktop-icon-label { + --desktop-icon-label-color: #0f172a; + --desktop-icon-label-bg: rgba(255, 255, 255, 0.52); + --desktop-icon-label-border: rgba(255, 255, 255, 0.22); + text-shadow: 0 1px 2px rgba(255, 255, 255, 0.28); +} + +.desktop-context-menu { + position: fixed; + z-index: 180; + min-width: 220px; + padding: 10px; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(20, 28, 45, 0.82); + color: #f8fafc; + backdrop-filter: blur(20px); + box-shadow: 0 20px 48px rgba(15, 23, 42, 0.32); +} + +.desktop-context-menu[hidden] { + display: none; +} + +.desktop-context-menu-button { + width: 100%; + padding: 11px 12px; + border: 0; + border-radius: 12px; + background: transparent; + color: inherit; + text-align: left; + font: inherit; + cursor: pointer; +} + +.desktop-context-menu-button:hover { + background: rgba(255, 255, 255, 0.12); } .app-icon-image, @@ -784,12 +839,24 @@ h1 { .desktop-icons { position: relative; - top: auto; - left: auto; + inset: auto; margin: 24px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); + gap: 16px; width: calc(100% - 48px); } + .desktop-icon { + position: relative; + left: auto !important; + top: auto !important; + } + + .desktop-context-menu { + display: none !important; + } + .widget-zone { position: static; width: 100%; diff --git a/public/assets/desktop/desktop.js b/public/assets/desktop/desktop.js index 3ef3df56..44ee4fef 100644 --- a/public/assets/desktop/desktop.js +++ b/public/assets/desktop/desktop.js @@ -2,6 +2,7 @@ const payloadNode = document.getElementById('desktop-payload'); if (payloadNode) { const payload = JSON.parse(payloadNode.textContent); + const desktopStage = document.querySelector('.desktop-stage'); const iconsRoot = document.getElementById('desktop-icons'); const widgetZoneRoot = document.getElementById('widget-zone'); const windowsRoot = document.getElementById('window-layer'); @@ -22,11 +23,14 @@ if (payloadNode) { const maximizeOverSystemBar = Boolean(skinProfile.maximize_over_system_bar); const storageScope = payload.desktop.storage_scope || 'guest'; const storageKey = `desktop.kusche.berlin.window-state.${storageScope}`; + const iconStorageKey = `desktop.kusche.berlin.icon-state.${storageScope}.${activeSkin}`; let zIndex = 20; let topDesktopInset = 0; let sideDesktopInset = 0; let bottomDesktopInset = 120; + let iconContextMenu = null; const windowControlsMode = document.body.dataset.windowControls || 'windows'; + let wallpaperImagePromise = null; const escapeHtml = (value) => String(value) .replaceAll('&', '&') @@ -45,6 +49,122 @@ if (payloadNode) { return `${escapeHtml(app.icon || 'AP')}`; }; + const extractWallpaperImageUrl = () => { + const wallpaper = getComputedStyle(document.body).getPropertyValue('--wallpaper') || ''; + const match = wallpaper.match(/url\((['"]?)(.*?)\1\)/i); + return match && match[2] ? match[2] : null; + }; + + const loadWallpaperImage = () => { + const wallpaperUrl = extractWallpaperImageUrl(); + + if (!wallpaperUrl) { + return Promise.resolve(null); + } + + if (wallpaperImagePromise) { + return wallpaperImagePromise; + } + + wallpaperImagePromise = new Promise((resolve) => { + const image = new Image(); + image.decoding = 'async'; + image.crossOrigin = 'anonymous'; + image.onload = () => resolve(image); + image.onerror = () => resolve(null); + image.src = wallpaperUrl; + }); + + return wallpaperImagePromise; + }; + + const computeCoverSamplePoint = (image, viewportX, viewportY) => { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const scale = Math.max(viewportWidth / image.naturalWidth, viewportHeight / image.naturalHeight); + const renderWidth = image.naturalWidth * scale; + const renderHeight = image.naturalHeight * scale; + const offsetX = (viewportWidth - renderWidth) / 2; + const offsetY = (viewportHeight - renderHeight) / 2; + const imageX = Math.max(0, Math.min(image.naturalWidth - 1, Math.round((viewportX - offsetX) / scale))); + const imageY = Math.max(0, Math.min(image.naturalHeight - 1, Math.round((viewportY - offsetY) / scale))); + + return { imageX, imageY }; + }; + + const createWallpaperSampler = (image) => { + const canvas = document.createElement('canvas'); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext('2d', { willReadFrequently: true }); + + if (!context) { + return null; + } + + context.drawImage(image, 0, 0); + + const sampleLuminance = (rect) => { + const samplePoints = [ + [rect.left + rect.width * 0.2, rect.top + rect.height * 0.2], + [rect.left + rect.width * 0.8, rect.top + rect.height * 0.2], + [rect.left + rect.width * 0.5, rect.top + rect.height * 0.5], + [rect.left + rect.width * 0.2, rect.top + rect.height * 0.8], + [rect.left + rect.width * 0.8, rect.top + rect.height * 0.8], + ]; + + let luminanceTotal = 0; + + samplePoints.forEach(([x, y]) => { + const { imageX, imageY } = computeCoverSamplePoint(image, x, y); + const pixel = context.getImageData(imageX, imageY, 1, 1).data; + luminanceTotal += (0.2126 * pixel[0]) + (0.7152 * pixel[1]) + (0.0722 * pixel[2]); + }); + + return luminanceTotal / samplePoints.length; + }; + + return { sampleLuminance }; + }; + + const applyDesktopIconContrast = async () => { + const icons = Array.from(document.querySelectorAll('.desktop-icon')); + + if (icons.length === 0) { + return; + } + + const image = await loadWallpaperImage(); + + if (!image) { + icons.forEach((icon) => { + icon.classList.remove('is-contrast-light'); + icon.classList.add('is-contrast-dark'); + }); + return; + } + + const sampler = createWallpaperSampler(image); + + if (!sampler) { + return; + } + + icons.forEach((icon) => { + const label = icon.querySelector('.desktop-icon-label'); + + if (!(label instanceof HTMLElement)) { + return; + } + + const rect = label.getBoundingClientRect(); + const luminance = sampler.sampleLuminance(rect); + const useDarkText = luminance >= 150; + icon.classList.toggle('is-contrast-dark', useDarkText); + icon.classList.toggle('is-contrast-light', !useDarkText); + }); + }; + const updateSessionDisplay = () => { const accountNode = document.querySelector('.tray-pill[data-tray-id="account"]'); @@ -166,6 +286,43 @@ if (payloadNode) { }; const persistedWindowState = loadWindowState(); + const loadIconState = () => { + try { + const raw = window.localStorage.getItem(iconStorageKey); + const parsed = raw ? JSON.parse(raw) : null; + + if (!parsed || typeof parsed !== 'object') { + return { + mode: 'auto', + positions: {}, + }; + } + + return { + mode: parsed.mode === 'manual' ? 'manual' : 'auto', + positions: typeof parsed.positions === 'object' && parsed.positions !== null ? parsed.positions : {}, + }; + } catch { + return { + mode: 'auto', + positions: {}, + }; + } + }; + + const persistedIconState = loadIconState(); + const iconState = { + mode: persistedIconState.mode, + positions: { ...persistedIconState.positions }, + }; + + const saveIconState = () => { + try { + window.localStorage.setItem(iconStorageKey, JSON.stringify(iconState)); + } catch { + // Ignore storage failures for now. + } + }; const measureDesktopBounds = () => { if (!windowsRoot) { @@ -185,6 +342,137 @@ if (payloadNode) { : 120; }; + const shouldUseDesktopGrid = () => !window.matchMedia('(max-width: 1100px)').matches; + + const getDesktopStageRect = () => desktopStage instanceof HTMLElement + ? desktopStage.getBoundingClientRect() + : new DOMRect(0, 0, window.innerWidth, window.innerHeight); + + const getIconMetrics = () => ({ + width: 112, + height: 108, + marginX: 18, + marginY: 18, + }); + + const getIconLayoutBounds = () => { + const stageRect = getDesktopStageRect(); + const { width, height, marginX, marginY } = getIconMetrics(); + const top = topDesktopInset + Math.max(24, marginY); + const left = Math.max(24, marginX); + const right = Math.max(24, marginX); + const bottom = Math.max(24, bottomDesktopInset + 12); + const availableWidth = Math.max(width, Math.round(stageRect.width - left - right)); + const availableHeight = Math.max(height, Math.round(stageRect.height - top - bottom)); + + return { + top, + left, + right, + bottom, + width, + height, + availableWidth, + availableHeight, + maxX: Math.max(left, Math.round(stageRect.width - right - width)), + maxY: Math.max(top, Math.round(stageRect.height - bottom - height)), + }; + }; + + const buildAutoIconPositions = () => { + const bounds = getIconLayoutBounds(); + const metrics = getIconMetrics(); + const columns = Math.max(1, Math.floor((bounds.availableWidth + metrics.marginX) / (metrics.width + metrics.marginX))); + const rows = Math.max(1, Math.floor((bounds.availableHeight + metrics.marginY) / (metrics.height + metrics.marginY))); + const rightAligned = activeSkin === 'apple' || skinProfile.family === 'apple'; + const positions = {}; + + payload.apps.forEach((app, index) => { + const column = Math.floor(index / rows); + const row = index % rows; + const x = rightAligned + ? bounds.maxX - (column * (metrics.width + metrics.marginX)) + : bounds.left + (column * (metrics.width + metrics.marginX)); + const y = bounds.top + (row * (metrics.height + metrics.marginY)); + + positions[app.app_id] = { + x: Math.max(bounds.left, Math.min(bounds.maxX, x)), + y: Math.max(bounds.top, Math.min(bounds.maxY, y)), + }; + }); + + return positions; + }; + + const clampIconPosition = (position) => { + const bounds = getIconLayoutBounds(); + + return { + x: Math.max(bounds.left, Math.min(bounds.maxX, Math.round(position.x))), + y: Math.max(bounds.top, Math.min(bounds.maxY, Math.round(position.y))), + }; + }; + + const applyIconPosition = (icon, position) => { + const next = clampIconPosition(position); + icon.style.left = `${next.x}px`; + icon.style.top = `${next.y}px`; + }; + + const getIconPositionMap = () => { + if (iconState.mode === 'manual') { + const autoPositions = buildAutoIconPositions(); + const positions = {}; + + payload.apps.forEach((app) => { + positions[app.app_id] = clampIconPosition(iconState.positions[app.app_id] || autoPositions[app.app_id] || { x: 24, y: 24 }); + }); + + return positions; + } + + return buildAutoIconPositions(); + }; + + const applyStoredIconLayout = () => { + if (!iconsRoot) { + return; + } + + const icons = Array.from(iconsRoot.querySelectorAll('.desktop-icon')); + + if (!shouldUseDesktopGrid()) { + icons.forEach((icon) => { + icon.style.left = ''; + icon.style.top = ''; + }); + applyDesktopIconContrast(); + return; + } + + const positions = getIconPositionMap(); + + icons.forEach((icon) => { + const appId = icon.dataset.appId; + const position = appId ? positions[appId] : null; + + if (!position) { + return; + } + + applyIconPosition(icon, position); + }); + + applyDesktopIconContrast(); + }; + + const resetIconsToAutoLayout = () => { + iconState.mode = 'auto'; + iconState.positions = {}; + saveIconState(); + applyStoredIconLayout(); + }; + const setStartMenuOpen = (isOpen) => { if (!startMenu) { return; @@ -199,6 +487,60 @@ if (payloadNode) { startButton?.setAttribute('aria-expanded', String(isOpen)); }; + const ensureDesktopContextMenu = () => { + if (iconContextMenu || !(desktopStage instanceof HTMLElement)) { + return iconContextMenu; + } + + const menu = document.createElement('div'); + menu.className = 'desktop-context-menu'; + menu.hidden = true; + menu.innerHTML = ` + + `; + + menu.addEventListener('click', (event) => { + const action = event.target instanceof Element + ? event.target.closest('.desktop-context-menu-button')?.dataset.action + : null; + + if (action === 'auto-arrange-icons') { + resetIconsToAutoLayout(); + } + + menu.hidden = true; + }); + + desktopStage.appendChild(menu); + iconContextMenu = menu; + return menu; + }; + + const closeDesktopContextMenu = () => { + if (iconContextMenu) { + iconContextMenu.hidden = true; + } + }; + + const openDesktopContextMenu = (clientX, clientY) => { + const menu = ensureDesktopContextMenu(); + + if (!(menu instanceof HTMLElement)) { + return; + } + + menu.hidden = false; + + const menuRect = menu.getBoundingClientRect(); + const maxLeft = Math.max(12, window.innerWidth - menuRect.width - 12); + const maxTop = Math.max(12, window.innerHeight - menuRect.height - 12); + + menu.style.left = `${Math.min(clientX, maxLeft)}px`; + menu.style.top = `${Math.min(clientY, maxTop)}px`; + }; + const renderClock = () => { const formatted = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', @@ -489,6 +831,89 @@ if (payloadNode) { }); }; + const wireDesktopIconDragging = (icon, app) => { + let startX = 0; + let startY = 0; + let originX = 0; + let originY = 0; + let dragging = false; + let moved = false; + + icon.addEventListener('pointerdown', (event) => { + if (event.button !== 0 || !shouldUseDesktopGrid()) { + return; + } + + startX = event.clientX; + startY = event.clientY; + originX = parseFloat(icon.style.left || '0'); + originY = parseFloat(icon.style.top || '0'); + dragging = true; + moved = false; + icon.classList.add('is-dragging'); + icon.setPointerCapture(event.pointerId); + closeDesktopContextMenu(); + }); + + icon.addEventListener('pointermove', (event) => { + if (!dragging) { + return; + } + + const nextX = originX + (event.clientX - startX); + const nextY = originY + (event.clientY - startY); + const distance = Math.abs(event.clientX - startX) + Math.abs(event.clientY - startY); + + if (distance > 4) { + moved = true; + } + + applyIconPosition(icon, { + x: nextX, + y: nextY, + }); + }); + + icon.addEventListener('pointerup', () => { + if (!dragging) { + return; + } + + dragging = false; + icon.classList.remove('is-dragging'); + + if (moved) { + const nextPosition = clampIconPosition({ + x: parseFloat(icon.style.left || '0'), + y: parseFloat(icon.style.top || '0'), + }); + + iconState.mode = 'manual'; + iconState.positions[app.app_id] = nextPosition; + saveIconState(); + applyIconPosition(icon, nextPosition); + icon.dataset.suppressClick = 'true'; + applyDesktopIconContrast(); + } + }); + + icon.addEventListener('pointercancel', () => { + dragging = false; + icon.classList.remove('is-dragging'); + }); + + icon.addEventListener('click', (event) => { + if (icon.dataset.suppressClick === 'true') { + icon.dataset.suppressClick = 'false'; + event.preventDefault(); + event.stopPropagation(); + return; + } + + openApp(app); + }); + }; + const createWindow = (definition) => { const node = document.createElement('article'); node.className = 'window'; @@ -763,17 +1188,43 @@ if (payloadNode) { payload.apps.forEach((app) => { const icon = document.createElement('button'); icon.type = 'button'; - icon.className = 'desktop-icon'; + icon.className = 'desktop-icon is-contrast-dark'; + icon.dataset.appId = app.app_id; icon.innerHTML = ` ${buildAppIconMarkup(app, 'desktop-app-icon')} ${app.title} `; - icon.addEventListener('click', () => openApp(app)); + wireDesktopIconDragging(icon, app); iconsRoot?.appendChild(icon); }); updateSessionDisplay(); + desktopStage?.addEventListener('contextmenu', (event) => { + if (!shouldUseDesktopGrid()) { + return; + } + + if (event.target instanceof Element && event.target.closest('.window, .taskbar, .start-menu, .system-bar')) { + return; + } + + event.preventDefault(); + openDesktopContextMenu(event.clientX, event.clientY); + }); + + document.addEventListener('click', (event) => { + if (!(event.target instanceof Element) || !event.target.closest('.desktop-context-menu')) { + closeDesktopContextMenu(); + } + }); + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + closeDesktopContextMenu(); + } + }); + startButton?.setAttribute('aria-expanded', 'false'); startButton?.addEventListener('click', () => { if (!startMenu || !startButton) { @@ -789,6 +1240,7 @@ if (payloadNode) { renderTraySkins(); setStartMenuOpen(false); measureDesktopBounds(); + applyStoredIconLayout(); payload.windows.forEach(createWindow); renderClock(); window.setInterval(renderClock, 1000); @@ -799,12 +1251,13 @@ if (payloadNode) { clampWindowLayout(record); if (record.state.maximized) { + const maximizedTopInset = maximizeOverSystemBar ? 0 : topDesktopInset; record.node.style.left = `${sideDesktopInset}px`; - record.node.style.top = `${topDesktopInset}px`; + record.node.style.top = `${maximizedTopInset}px`; record.node.style.width = sideDesktopInset === 0 ? '100%' : `calc(100% - ${sideDesktopInset * 2}px)`; - record.node.style.height = activeSkin === 'apple' + record.node.style.height = maximizeOverSystemBar ? '100%' - : `calc(100% - ${topDesktopInset + bottomDesktopInset}px)`; + : `calc(100% - ${maximizedTopInset + bottomDesktopInset}px)`; return; } @@ -812,5 +1265,9 @@ if (payloadNode) { }); saveWindowState(); + applyStoredIconLayout(); + closeDesktopContextMenu(); }); + + window.refreshDesktopIconContrast = applyDesktopIconContrast; } diff --git a/public/assets/desktop/skins/apple/skin.css b/public/assets/desktop/skins/apple/skin.css index a36c561e..4f2de2b4 100644 --- a/public/assets/desktop/skins/apple/skin.css +++ b/public/assets/desktop/skins/apple/skin.css @@ -27,10 +27,8 @@ body[data-skin="apple"] .system-bar { } body[data-skin="apple"] .desktop-icons { - left: auto; - right: 24px; - justify-items: end; - width: min(160px, calc(100% - 48px)); + inset: 0; + width: auto; } body[data-skin="apple"] .taskbar { diff --git a/src/App/App.php b/src/App/App.php index a510c6c4..51cd9e91 100644 --- a/src/App/App.php +++ b/src/App/App.php @@ -39,6 +39,9 @@ final class App $windows = (new WindowManager($registry))->defaultWindows(); $activeWidgetIds = DesktopState::defaultWidgetIds(); $displayName = (string) ($authUser['name'] ?? $authUser['username'] ?? 'Gast'); + $storageScope = $isAuthenticated + ? strtolower((string) ($authUser['username'] ?? $authUser['sub'] ?? 'user')) + : 'guest'; return [ 'meta' => [ @@ -51,7 +54,7 @@ final class App 'desktop' => [ 'greeting' => 'Neue Desktop-Shell fuer Kusche.Berlin', 'wallpaper' => SkinResolver::wallpaper($activeSkin), - 'storage_scope' => 'guest', + 'storage_scope' => preg_replace('/[^a-z0-9._-]+/i', '-', $storageScope) ?: 'guest', 'workspace' => DesktopState::defaultWorkspace(), 'login' => [ 'authenticated' => $isAuthenticated,