/* Device-aware desktop preferences. The app list and actual records stay in the existing stores. */ (() => { const APP_IDS = Object.keys(APPS); const LAYOUT_KEY = 'lifeos_window_layout_v1'; const cell = { width: 110, height: 98, left: 24, top: 72 }; const desktopText = (key, values) => LifeI18n.t(`desktop.${key}`, values); const validApps = value => Array.isArray(value) ? [...new Set(value.filter(id => APP_IDS.includes(id)))] : []; const preference = () => settings(); const desktopMode = () => innerWidth > 650; const bounds = () => ({ rows: Math.max(1, Math.floor((innerHeight - 175) / cell.height)), columns: Math.max(1, Math.floor((innerWidth - 48) / cell.width)) }); const chosenShortcuts = () => Array.isArray(preference().desktopShortcuts) ? validApps(preference().desktopShortcuts) : APP_IDS; const savedPositions = () => preference().iconPositions || {}; const updateSettings = patch => { const state = preference(); Object.assign(state, patch); save('lifeos_settings', state); }; const isSlot = (value, limit) => Number.isInteger(value) && value >= 0 && value < limit; let placed = {}, restoring = false, suppressClick = false; function arrangeIcons() { const visible = chosenShortcuts(), { rows, columns } = bounds(), occupied = new Set(), result = {}; document.querySelectorAll('#icons .app-icon').forEach(node => { if (!visible.includes(node.dataset.app)) node.remove(); }); const nodes = [...document.querySelectorAll('#icons .app-icon')]; if (!desktopMode()) { nodes.forEach(node => { node.style.left = ''; node.style.top = ''; node.style.position = ''; }); placed = {}; return; } nodes.forEach((node, index) => { const id = node.dataset.app, stored = savedPositions()[id]; let col = stored?.col, row = stored?.row; if (!isSlot(col, columns) || !isSlot(row, rows) || occupied.has(`${col}:${row}`)) { const next = Array.from({ length: rows * columns }, (_, slot) => ({ col: Math.floor(slot / rows), row: slot % rows })).find(slot => !occupied.has(`${slot.col}:${slot.row}`)); col = next?.col ?? Math.floor(index / rows); row = next?.row ?? index % rows; } occupied.add(`${col}:${row}`); result[id] = { col, row }; node.style.position = 'absolute'; node.style.left = `${cell.left + col * cell.width}px`; node.style.top = `${cell.top + row * cell.height}px`; }); placed = result; } const originalRenderIcons = window.renderIcons; window.renderIcons = () => { originalRenderIcons(); arrangeIcons(); }; const icons = document.querySelector('#icons'); let dragState = null; icons.addEventListener('pointerdown', event => { const icon = event.target.closest('.app-icon'); if (!desktopMode() || !icon || event.button !== 0) return; dragState = { icon, id: icon.dataset.app, x: event.clientX, y: event.clientY, moved: false }; icon.setPointerCapture?.(event.pointerId); }); icons.addEventListener('pointermove', event => { if (!dragState) return; const dx = event.clientX - dragState.x, dy = event.clientY - dragState.y; if (Math.hypot(dx, dy) > 6) dragState.moved = true; if (dragState.moved) { dragState.icon.classList.add('dragging'); dragState.icon.style.transform = `translate(${dx}px,${dy}px)`; const { rows, columns } = bounds(), origin = placed[dragState.id]; const col = Math.max(0, Math.min(columns - 1, origin.col + Math.round(dx / cell.width))); const row = Math.max(0, Math.min(rows - 1, origin.row + Math.round(dy / cell.height))); window.LifeOSSnapGrid?.show(col, row); } }); icons.addEventListener('pointerup', event => { if (!dragState) return; const { icon, id, moved, x, y } = dragState; dragState = null; icon.classList.remove('dragging'); icon.style.transform = ''; window.LifeOSSnapGrid?.hide(); if (!moved) return; suppressClick = true; setTimeout(() => { suppressClick = false; }, 0); const { rows, columns } = bounds(), origin = placed[id]; const col = Math.max(0, Math.min(columns - 1, Math.round((cell.left + origin.col * cell.width + event.clientX - x - cell.left) / cell.width))); const row = Math.max(0, Math.min(rows - 1, Math.round((cell.top + origin.row * cell.height + event.clientY - y - cell.top) / cell.height))); const other = Object.keys(placed).find(key => key !== id && placed[key].col === col && placed[key].row === row); const positions = { ...savedPositions(), [id]: { col, row } }; if (other) positions[other] = origin; updateSettings({ iconPositions: positions }); arrangeIcons(); }); icons.addEventListener('pointercancel', () => { if (dragState) { dragState.icon.style.transform = ''; dragState.icon.classList.remove('dragging'); dragState = null; window.LifeOSSnapGrid?.hide(); } }); icons.addEventListener('keydown', event => { const icon = event.target.closest('.app-icon'); if (!desktopMode() || !icon || !event.shiftKey || !['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(event.key)) return; event.preventDefault(); const id = icon.dataset.app, origin = placed[id], { rows, columns } = bounds(); const col = Math.max(0, Math.min(columns - 1, origin.col + (event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0))); const row = Math.max(0, Math.min(rows - 1, origin.row + (event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0))); const other = Object.keys(placed).find(key => key !== id && placed[key].col === col && placed[key].row === row); const positions = { ...savedPositions(), [id]: { col, row } }; if (other) positions[other] = origin; updateSettings({ iconPositions: positions }); arrangeIcons(); window.LifeOSSnapGrid?.show(col, row); window.LifeOSSnapGrid?.hide(900); }); icons.addEventListener('click', event => { if (suppressClick) { event.preventDefault(); event.stopImmediatePropagation(); } }, true); window.addEventListener('resize', () => { arrangeIcons(); if (desktopMode()) Object.values(windows).forEach(clampWindow); }); const readLayout = () => { try { return JSON.parse(localStorage.getItem(LAYOUT_KEY) || '{}'); } catch { return {}; } }; function clampWindow(node) { if (!desktopMode() || node.classList.contains('max')) return; const width = Math.min(node.offsetWidth || 700, Math.max(360, innerWidth - 20)); const height = Math.min(node.offsetHeight || 590, Math.max(280, innerHeight - 85)); node.style.width = `${width}px`; node.style.height = `${height}px`; node.style.left = `${Math.max(0, Math.min(parseFloat(node.style.left) || 0, innerWidth - 80))}px`; node.style.top = `${Math.max(0, Math.min(parseFloat(node.style.top) || 0, innerHeight - 95))}px`; } function rememberWindows() { if (restoring || preference().rememberWindowLayout === false || !desktopMode()) return; const entries = Object.values(windows).sort((a, b) => Number(a.style.zIndex || 0) - Number(b.style.zIndex || 0)).map(node => ({ id: node.dataset.id, left: parseFloat(node.style.left), top: parseFloat(node.style.top), width: parseFloat(node.style.width) || node.offsetWidth, height: parseFloat(node.style.height) || node.offsetHeight, max: node.classList.contains('max'), min: node.classList.contains('min') })); localStorage.setItem(LAYOUT_KEY, JSON.stringify({ entries })); } function observeWindow(node) { if (node.dataset.layoutObserved) return; node.dataset.layoutObserved = 'true'; if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => rememberWindows()).observe(node); } const originalOpenApp = window.openApp; window.openApp = id => { if (!APP_IDS.includes(id)) return; originalOpenApp(id); observeWindow(windows[id]); rememberWindows(); }; const originalFocus = window.focus; window.focus = node => { originalFocus(node); rememberWindows(); }; for (const method of ['closeWin', 'minWin', 'maxWin']) { const original = window[method]; window[method] = (...args) => { original(...args); rememberWindows(); }; } document.querySelector('#windows').addEventListener('pointerup', () => setTimeout(rememberWindows, 0)); function restoreWindows() { restoring = true; try { const remembered = desktopMode() && preference().rememberWindowLayout !== false ? readLayout().entries : []; const entries = Array.isArray(remembered) ? remembered.filter(item => APP_IDS.includes(item.id)) : []; const startup = validApps(preference().startupApps); for (const id of [...new Set([...entries.map(item => item.id), ...startup])]) { window.openApp(id); const node = windows[id], position = entries.find(item => item.id === id); if (!position || !desktopMode()) continue; for (const field of ['left', 'top', 'width', 'height']) if (Number.isFinite(position[field]) && position[field] >= 0 && position[field] < 10000) node.style[field] = `${position[field]}px`; node.classList.toggle('max', position.max === true); node.classList.toggle('min', position.min === true); clampWindow(node); } const visible = Object.values(windows).filter(node => !node.classList.contains('min')); if (visible.length) focus(visible.at(-1)); else renderTaskbar(); } finally { restoring = false; } rememberWindows(); } const originalInit = window.init; window.init = () => { originalInit(); restoreWindows(); }; window.applyDesktopPreferences = () => { window.renderIcons(); window.loadBingWallpaper(); window.applyWallpaper(); for (const id of validApps(preference().startupApps)) if (!windows[id]) window.openApp(id); }; const originalWallpaper = window.applyWallpaper; window.applyWallpaper = () => { const state = preference(), mode = state.wallpaperSource || 'bing', desktop = document.querySelector('#desktop'); if (mode === 'calm') { desktop.style.backgroundImage = 'radial-gradient(ellipse at 20% 0%,#3f4a503d,transparent 40%),radial-gradient(ellipse at 93% 65%,#59604224,transparent 42%)'; desktop.title = desktopText('calm'); return; } if (mode === 'custom') { try { const url = new URL(state.customWallpaperUrl); if (url.protocol !== 'https:') throw new Error('invalid wallpaper URL'); desktop.style.backgroundImage = `linear-gradient(120deg,color-mix(in srgb,var(--bg) 68%,transparent),color-mix(in srgb,var(--bg) 36%,transparent)),url("${url.href.replaceAll('"', '%22')}")`; desktop.style.backgroundSize = 'cover'; desktop.style.backgroundPosition = 'center'; desktop.title = desktopText('custom'); return; } catch { /* Use the built-in background until a valid URL is saved. */ } } if (state.wallpaper?.url) originalWallpaper(state.wallpaper); }; const originalLoadBing = window.loadBingWallpaper; window.loadBingWallpaper = () => { if ((preference().wallpaperSource || 'bing') === 'bing') return originalLoadBing(); window.applyWallpaper(); }; window.setWallpaperSource = mode => { if (!['bing', 'calm', 'custom'].includes(mode)) return; if (mode === 'custom') { const value = document.querySelector('#desktop-wallpaper-url')?.value.trim() || ''; try { const url = new URL(value); if (url.protocol !== 'https:' || value.length > 2048) throw new Error('invalid URL'); } catch { toast(desktopText('invalidUrl')); return; } updateSettings({ wallpaperSource: mode, customWallpaperUrl: value }); } else updateSettings({ wallpaperSource: mode }); window.loadBingWallpaper(); window.applyWallpaper(); window.renderAll(); }; window.toggleDesktopApp = (kind, id) => { if (!APP_IDS.includes(id) || !['desktopShortcuts', 'startupApps'].includes(kind)) return; const current = kind === 'desktopShortcuts' ? chosenShortcuts() : validApps(preference().startupApps); const next = current.includes(id) ? current.filter(app => app !== id) : APP_IDS.filter(app => current.includes(app) || app === id); updateSettings({ [kind]: next }); if (kind === 'desktopShortcuts') window.renderIcons(); window.renderAll(); }; window.toggleWindowMemory = () => { const enabled = preference().rememberWindowLayout === false; updateSettings({ rememberWindowLayout: enabled }); if (enabled) rememberWindows(); window.renderAll(); }; window.resetIconPositions = () => { updateSettings({ iconPositions: {} }); window.renderIcons(); window.renderAll(); }; const originalSystemApp = window.systemApp; window.systemApp = container => { originalSystemApp(container); const state = preference(), mode = state.wallpaperSource || 'bing'; const appChoices = kind => `
${desktopText('urlHint')}
${desktopText('shortcutsHint')}
${appChoices('desktopShortcuts')}${desktopText('windowHint')}
${desktopText('startupHint')}
${appChoices('startupApps')}