Files
Life-OS/public/desktop-personalization.js
T

199 lines
16 KiB
JavaScript

/* 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 => `<div class="desktop-app-choices">${APP_IDS.map(id => `<button type="button" class="chip ${(kind === 'desktopShortcuts' ? chosenShortcuts() : validApps(state.startupApps)).includes(id) ? 'selected' : ''}" aria-pressed="${(kind === 'desktopShortcuts' ? chosenShortcuts() : validApps(state.startupApps)).includes(id)}" onclick="toggleDesktopApp('${kind}','${id}')">${appName(id)}</button>`).join('')}</div>`;
const customUrl = String(state.customWallpaperUrl || '').replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
const personalization = `<section class="desktop-preferences"><div class="eyebrow">${desktopText('heading')}</div><h2>${desktopText('wallpaper')}</h2><div class="choices">${['bing','calm'].map(value => `<button class="chip ${mode === value ? 'selected' : ''}" aria-pressed="${mode === value}" onclick="setWallpaperSource('${value}')">${desktopText(value)}</button>`).join('')}</div><div class="desktop-wallpaper-custom"><label for="desktop-wallpaper-url">${desktopText('custom')}</label><div><input id="desktop-wallpaper-url" type="url" maxlength="2048" placeholder="https://example.com/image.jpg" value="${customUrl}"><button class="secondary" onclick="setWallpaperSource('custom')">${desktopText('useUrl')}</button></div><p>${desktopText('urlHint')}</p></div><h2>${desktopText('shortcuts')}</h2><p>${desktopText('shortcutsHint')}</p>${appChoices('desktopShortcuts')}<button class="desktop-text-button" onclick="resetIconPositions()">${desktopText('resetPositions')}</button><h2>${desktopText('windowLayout')}</h2><button class="chip ${state.rememberWindowLayout !== false ? 'selected' : ''}" aria-pressed="${state.rememberWindowLayout !== false}" onclick="toggleWindowMemory()">${state.rememberWindowLayout !== false ? desktopText('rememberOn') : desktopText('rememberOff')}</button><p>${desktopText('windowHint')}</p><h2>${desktopText('startup')}</h2><p>${desktopText('startupHint')}</p>${appChoices('startupApps')}</section>`;
const panel = container.querySelector('.settings');
if (panel) panel.insertAdjacentHTML('beforeend', personalization);
};
const style = document.createElement('style');
style.textContent = `.desktop-preferences h2{font-size:14px;margin:18px 0 9px}.desktop-preferences .desktop-app-choices{display:flex;flex-wrap:wrap;gap:8px}.desktop-preferences .chip{font-size:12px;min-height:36px}.desktop-wallpaper-custom{margin-top:13px}.desktop-wallpaper-custom label{display:block;font-size:13px;color:var(--muted);margin-bottom:7px}.desktop-wallpaper-custom>div{display:flex;gap:8px}.desktop-wallpaper-custom input{flex:1;min-width:0;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:10px;color:var(--ink)}.desktop-wallpaper-custom .secondary{font-size:12px;white-space:nowrap}.desktop-preferences p{font-size:13px;line-height:1.5}.desktop-text-button{margin:10px 0 2px;border:0;background:transparent;padding:5px 0;color:var(--accent);font-size:13px;text-decoration:underline}.app-icon.dragging{z-index:9;cursor:grabbing;touch-action:none}.desktop-icons .app-icon{touch-action:none}@media(max-width:650px){.desktop-icons .app-icon{position:static!important;left:auto!important;top:auto!important;touch-action:auto}.desktop-wallpaper-custom>div{flex-wrap:wrap}.desktop-wallpaper-custom input{flex-basis:100%}}`;
document.head.append(style);
window.renderIcons();
})();