import { icons as initialIcons, fetchAllIcons } from './icons.js'; import { iconSets } from './iconSets.js'; const bookmarkList = document.getElementById('bookmarkList'); const titleInput = document.getElementById('titleInput'); const urlInput = document.getElementById('urlInput'); const saveBookmarkBtn = document.getElementById('saveBookmarkBtn'); const cancelBtn = document.getElementById('cancelBtn'); const addPopup = document.getElementById('addPopup'); const searchBtn = document.getElementById('searchBtn'); const searchInput = document.getElementById('searchInput'); const searchEngineBtn = document.getElementById('searchEngineBtn'); const searchEngineDropdown = document.getElementById('searchEngineDropdown'); const searchEngineLogo = document.getElementById('searchEngineLogo'); const iconFilter = document.getElementById('iconFilter'); const iconGrid = document.getElementById('iconGrid'); const selectedIconInput= document.getElementById('selectedIcon'); const iconCategoryNav = document.getElementById('iconCategoryNav'); const useFaviconCheckbox = document.getElementById('useFavicon'); const greetingEl = document.getElementById('greeting'); const resetTopSitesBtn = document.getElementById('resetTopSites'); const clockEl = document.getElementById('clock'); const weatherEl = document.getElementById('weather'); const glanceEl = document.querySelector('.glance'); const searchContainerEl = document.querySelector('.search-container'); const topSitesEl = document.querySelector('.top-sites-card'); const editBtn = document.getElementById('editLayoutBtn'); const greetingTitleEl = document.getElementById('greeting'); const editToolbar = document.getElementById('editToolbar'); const saveEditBtn = document.getElementById('saveEditBtn'); const cancelEditBtn = document.getElementById('cancelEditBtn'); let selectedIcon = initialIcons[0]; let availableIcons = initialIcons; let currentIconSetKey = 'material'; const loadedSetsCache = new Map(); // key -> array let unifiedCatalog = []; // aggregated icons with categories // Semantic icon categories (ordered) with predicate tests const iconCategories = [ { id: 'services', label: 'Services', test: (n, set) => set === 'simple' || /(github|gitlab|google|twitter|facebook|discord|slack|whatsapp|youtube|spotify|apple|microsoft|aws|azure|gcp|cloudflare|figma|notion|paypal|stripe|reddit|steam|xbox|playstation|nintendo|openai|vercel|netlify|docker|kubernetes)/.test(n), icon: 'cloud' }, { id: 'settings', label: 'Settings', test: n => /(setting|settings|cog|gear|tools?|wrench|sliders?|command|preferences?)/.test(n), icon: 'settings' }, { id: 'files', label: 'Files & Data', test: n => /(file|folder|archive|book|bookmark|save|upload|download|cloud|database|server)/.test(n), icon: 'folder' }, { id: 'media', label: 'Media', test: n => /(camera|video|film|image|photo|music|play|pause|mic|microphone|volume|speaker)/.test(n), icon: 'video_camera_front' }, { id: 'social', label: 'Social & Communication', test: n => /(chat|message|mail|envelope|phone|comment|share|rss)/.test(n), icon: 'chat' }, { id: 'nav', label: 'Navigation', test: n => /(map|compass|globe|route|pin|location|world|earth)/.test(n), icon: 'explore' }, { id: 'security', label: 'Security', test: n => /(lock|shield|key|alert|warning|info|question|bug)/.test(n), icon: 'security' }, { id: 'commerce', label: 'Commerce', test: n => /(cart|shopping|wallet|credit|bank|price|tag|sale|bag|store|shop)/.test(n), icon: 'shopping_cart' }, { id: 'status', label: 'Status', test: n => /(star|heart|award|trophy|badge|bell|notification)/.test(n), icon: 'star' }, { id: 'food', label: 'Food', test: n => /(apple|cake|coffee|cookie|beer|wine|food|restaurant|cup|tea)/.test(n), icon: 'restaurant' }, { id: 'devices', label: 'Devices', test: n => /(cpu|laptop|desktop|tablet|phone|smartphone|device|monitor|tv)/.test(n), icon: 'devices' }, { id: 'other', label: 'Other', test: () => true, icon: 'more_horiz' } ]; const searchEngines = { google: 'https://www.google.com/search?q=', bing: 'https://www.bing.com/search?q=', duckduckgo: 'https://duckduckgo.com/?q=' }; let selectedSearchEngine = 'google'; let bookmarks = []; // Load bookmarks from main via Electron IPC // Load bookmarks via contextBridge API async function loadBookmarks() { try { let data = []; // Use bookmarksAPI if available if (window.bookmarksAPI && typeof window.bookmarksAPI.load === 'function') { data = await window.bookmarksAPI.load(); } else if (window.electronAPI && typeof window.electronAPI.invoke === 'function') { data = await window.electronAPI.invoke('load-bookmarks'); } else { console.error('No API available to load bookmarks'); } return Array.isArray(data) ? data : []; } catch (error) { console.error('Error loading bookmarks:', error); return []; } } // Save bookmarks to main process // Save bookmarks via contextBridge API async function saveBookmarks() { try { await window.bookmarksAPI.save(bookmarks); } catch (error) { console.error('Error saving bookmarks:', error); } } // Render bookmarks function renderBookmarks() { bookmarkList.innerHTML = ''; // Render each bookmark bookmarks.forEach((b, index) => { const box = document.createElement('div'); box.className = 'bookmark'; // prepend icon const iconVal = b.icon || 'bookmark'; let iconEl; if (typeof iconVal === 'string' && /^(https?:|data:)/.test(iconVal)) { // Treat as favicon/image URL iconEl = document.createElement('img'); iconEl.src = iconVal; iconEl.alt = 'favicon'; iconEl.className = 'bookmark-favicon'; iconEl.referrerPolicy = 'no-referrer'; // Apply filter for dark backgrounds to ensure visibility if (isDarkBackground()) { iconEl.style.filter = 'brightness(0) saturate(100%) invert(100%)'; } box.appendChild(iconEl); } else { iconEl = document.createElement('span'); iconEl.className = 'material-symbols-outlined'; iconEl.textContent = iconVal; box.appendChild(iconEl); } const label = document.createElement('span'); label.className = 'bookmark-title'; label.textContent = b.title; const close = document.createElement('button'); close.textContent = '×'; close.className = 'delete-btn'; close.onclick = async (e) => { e.stopPropagation(); bookmarks.splice(index, 1); await saveBookmarks(); renderBookmarks(); }; // Navigate via IPC to host page box.onclick = () => { const url = b.url; if (window.electronAPI && typeof window.electronAPI.sendToHost === 'function') { window.electronAPI.sendToHost('navigate', url); } else { console.error('Unable to send navigation IPC to host'); } // Fallback: post message to embedding page if (window.parent && typeof window.parent.postMessage === 'function') { window.parent.postMessage({ type: 'navigate', url }, '*'); } }; box.appendChild(label); box.appendChild(close); bookmarkList.appendChild(box); }); // Add "+" box const addBox = document.createElement('div'); addBox.className = 'bookmark add-bookmark'; addBox.textContent = '+'; addBox.onclick = () => addPopup.classList.remove('hidden'); bookmarkList.appendChild(addBox); } // Reset Top Sites (bookmarks) to empty state if (resetTopSitesBtn) { resetTopSitesBtn.addEventListener('click', async (e) => { e.preventDefault(); if (!bookmarks.length) return; const yes = confirm('Clear all Top Sites?'); if (!yes) return; bookmarks = []; await saveBookmarks(); renderBookmarks(); }); } // draw the icon‐grid, filtering by the search term function renderIconGrid(filter = '') { const f = filter.toLowerCase(); iconGrid.innerHTML = ''; const frag = document.createDocumentFragment(); let lastCat = null; const filtered = unifiedCatalog.filter(e => !f || e.name.includes(f)); filtered.forEach(entry => { if (entry.category !== lastCat) { lastCat = entry.category; const anchor = document.createElement('div'); anchor.className = 'icon-section-anchor'; anchor.id = `section-${entry.category}`; frag.appendChild(anchor); } const span = document.createElement('span'); span.className = 'icon-item'; const def = iconSets[entry.set]; if (entry.set === 'material') { span.classList.add('material-symbols-outlined'); span.textContent = entry.name; } else if (def && def.fontClass) { const i = document.createElement('i'); i.className = def.fontClass(entry.name); span.appendChild(i); } else if (entry.dataUrl) { const img = document.createElement('img'); img.src = entry.dataUrl; img.alt = entry.name; img.className = 'grid-svg'; span.appendChild(img); } else { span.textContent = '…'; (async () => { if (def && def.fetchIcon) { const dataUrl = await def.fetchIcon(entry.name); if (dataUrl) { entry.dataUrl = dataUrl; if (span.isConnected) { span.textContent=''; const img=document.createElement('img'); img.src=dataUrl; img.alt=entry.name; img.className='grid-svg'; span.appendChild(img); } } else { // If SVG fetch fails, try font class or show truncated name if (def.fontClass && span.isConnected) { span.textContent=''; const i = document.createElement('i'); i.className = def.fontClass(entry.name); span.appendChild(i); } else { span.textContent = entry.name.slice(0,3); } } } else { // No fetchIcon available, show name span.textContent = entry.name.slice(0,3); } })(); } span.onclick = () => { const currentSelected = iconGrid.querySelector('.icon-item.selected'); if (currentSelected) currentSelected.classList.remove('selected'); span.classList.add('selected'); selectedIcon = entry.name; selectedIconInput.value = entry.name; selectedIconInput.dataset.iconSet = entry.set; if (entry.dataUrl) selectedIconInput.dataset.dataUrl = entry.dataUrl; else delete selectedIconInput.dataset.dataUrl; }; frag.appendChild(span); }); iconGrid.appendChild(frag); // Don't auto-select first icon to allow favicon usage } // filter as the user types iconFilter.addEventListener('input', () => renderIconGrid(iconFilter.value.trim())); // initial render renderIconGrid(); // Asynchronously fetch all icons and update the grid async function buildUnifiedCatalog() { const keys = Object.keys(iconSets); for (const k of keys) { if (!loadedSetsCache.has(k)) { try { loadedSetsCache.set(k, await iconSets[k].loader()); } catch(e) { console.warn('Icon set load failed', k, e); loadedSetsCache.set(k, []); } } } const temp = []; for (const k of keys) { const arr = loadedSetsCache.get(k) || []; for (const name of arr) { const lower = name.toLowerCase(); const category = iconCategories.find(c => c.test(lower, k)).id; temp.push({ set: k, name, category }); } } // order by category then by name unifiedCatalog = temp.sort((a,b)=> { if (a.category === b.category) return a.name.localeCompare(b.name); return iconCategories.findIndex(c=>c.id===a.category) - iconCategories.findIndex(c=>c.id===b.category); }); buildCategoryNav(); renderIconGrid(iconFilter.value.trim()); } buildUnifiedCatalog(); // --- Favicon resolution helpers --- async function resolveFavicon(rawUrl) { if (!rawUrl) return null; let url = rawUrl.trim(); if (!/^https?:\/\//i.test(url)) { url = 'https://' + url; // assume https if protocol missing } try { const u = new URL(url); // Prefer Google favicon service for simplicity & size; fall back to /favicon.ico const googleService = `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(u.origin)}`; // We'll optimisticly use google service; optionally we could verify it loads, but browsers will handle 404 gracefully. return googleService; } catch (_) { return null; } } // Helper function to detect if background is dark function isDarkBackground() { // For SVG color modification, check if we have a dark theme const rootStyles = window.getComputedStyle(document.documentElement); const bgVar = rootStyles.getPropertyValue('--bg').trim(); if (bgVar && bgVar.startsWith('#')) { const hex = bgVar.slice(1); const r = parseInt(hex.substr(0, 2), 16); const g = parseInt(hex.substr(2, 2), 16); const b = parseInt(hex.substr(4, 2), 16); const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; return luminance < 0.5; } // Fallback: assume dark theme for this app return true; } saveBookmarkBtn.onclick = async () => { const title = titleInput.value.trim(); const url = urlInput.value.trim(); let icon = selectedIcon; if (!title || !url) return; // Check if user wants to use favicon via checkbox const wantFavicon = useFaviconCheckbox.checked; if (wantFavicon) { try { const faviconUrl = await resolveFavicon(url); if (faviconUrl) icon = faviconUrl; } catch (e) { console.warn('Favicon fetch failed, falling back to icon symbol:', e); } } else { // Use selected icon if available const hasSelectedIcon = document.querySelector('.icon-item.selected'); if (hasSelectedIcon) { if (selectedIconInput.dataset.iconSet && selectedIconInput.dataset.iconSet !== 'material') { if (selectedIconInput.dataset.dataUrl) { icon = selectedIconInput.dataset.dataUrl; // For SVG icons, modify color based on background if (icon.startsWith('data:image/svg+xml') && isDarkBackground()) { try { // Decode the SVG and modify its color const svgData = decodeURIComponent(icon.split(',')[1]); const modifiedSvg = svgData.replace(/fill="[^"]*"/g, 'fill="white"') .replace(/stroke="[^"]*"/g, 'stroke="white"') .replace(/]*)>/, ''); icon = 'data:image/svg+xml;utf8,' + encodeURIComponent(modifiedSvg); } catch (e) { console.warn('Failed to modify SVG color:', e); } } } else { const def = iconSets[selectedIconInput.dataset.iconSet]; if (def && def.fetchIcon) { const dataUrl = await def.fetchIcon(selectedIcon); if (dataUrl) { icon = dataUrl; // Apply same color modification for fetched SVGs if (icon.startsWith('data:image/svg+xml') && isDarkBackground()) { try { const svgData = decodeURIComponent(icon.split(',')[1]); const modifiedSvg = svgData.replace(/fill="[^"]*"/g, 'fill="white"') .replace(/stroke="[^"]*"/g, 'stroke="white"') .replace(/]*)>/, ''); icon = 'data:image/svg+xml;utf8,' + encodeURIComponent(modifiedSvg); } catch (e) { console.warn('Failed to modify fetched SVG color:', e); } } } } } } else { // For Material icons, just use the icon name - CSS will handle color icon = selectedIcon; } } else { // No icon selected and no favicon requested, use default bookmark icon icon = 'bookmark'; } } bookmarks.push({ title, url, icon, iconSet: selectedIconInput.dataset.iconSet || 'material' }); await saveBookmarks(); renderBookmarks(); titleInput.value = ''; urlInput.value = ''; iconFilter.value = ''; useFaviconCheckbox.checked = false; // Clear any selected icon const selected = document.querySelector('.icon-item.selected'); if (selected) selected.classList.remove('selected'); addPopup.classList.add('hidden'); }; // Disable icon selection when favicon toggle is checked useFaviconCheckbox.addEventListener('change', () => { const iconItems = document.querySelectorAll('.icon-item'); if (useFaviconCheckbox.checked) { iconItems.forEach(item => { item.style.opacity = '0.5'; item.style.pointerEvents = 'none'; }); // Clear any selection const selected = document.querySelector('.icon-item.selected'); if (selected) selected.classList.remove('selected'); } else { iconItems.forEach(item => { item.style.opacity = ''; item.style.pointerEvents = ''; }); } }); cancelBtn.onclick = () => { addPopup.classList.add('hidden'); }; // --- Search Engine Dropdown Logic --- searchEngineBtn.addEventListener('click', (e) => { e.stopPropagation(); searchEngineDropdown.classList.toggle('hidden'); }); document.addEventListener('click', () => { if (!searchEngineDropdown.classList.contains('hidden')) { searchEngineDropdown.classList.add('hidden'); } }); searchEngineDropdown.addEventListener('click', (e) => { const option = e.target.closest('.search-engine-option'); if (option) { selectedSearchEngine = option.dataset.engine; const newLogoSrc = option.querySelector('img').src; searchEngineLogo.src = newLogoSrc; searchEngineDropdown.classList.add('hidden'); } }); // --- End Search Engine Dropdown Logic --- searchBtn.addEventListener('click', () => { const input = searchInput.value.trim(); const hasProtocol = /^https?:\/\//i.test(input); const looksLikeUrl = hasProtocol || /\./.test(input); let target; if (looksLikeUrl) { target = hasProtocol ? input : `https://${input}`; } else { const searchEngineUrl = searchEngines[selectedSearchEngine]; target = `${searchEngineUrl}${encodeURIComponent(input)}`; } // Always send navigation request to host if (window.electronAPI && typeof window.electronAPI.sendToHost === 'function') { window.electronAPI.sendToHost('navigate', target); return; } // Fallback: post message to embedding page if (window.parent && typeof window.parent.postMessage === 'function') { window.parent.postMessage({ type: 'navigate', url: target }, '*'); return; } }); searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') searchBtn.click(); }); function buildCategoryNav() { iconCategoryNav.innerHTML = ''; const usedCategories = [...new Set(unifiedCatalog.map(e=>e.category))]; iconCategories.filter(c=>usedCategories.includes(c.id)).forEach(cat => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'icon-cat-btn'; // Create icon element const iconSpan = document.createElement('span'); iconSpan.className = 'material-symbols-outlined'; iconSpan.textContent = cat.icon; // Create text element const textSpan = document.createElement('span'); textSpan.textContent = cat.label; btn.appendChild(iconSpan); btn.appendChild(textSpan); btn.onclick = () => { const target = document.getElementById(`section-${cat.id}`); if (target) { const top = target.offsetTop; iconGrid.scrollTo({ top: top - 4, behavior: 'smooth' }); iconCategoryNav.querySelectorAll('.icon-cat-btn').forEach(b => b.classList.toggle('active', b === btn)); } }; iconCategoryNav.appendChild(btn); }); setupSectionObserver(); } function setupSectionObserver() { const observer = new IntersectionObserver(entries => { entries.forEach(entry => { if (entry.isIntersecting) { const id = entry.target.id.replace('section-',''); const cat = iconCategories.find(c=>c.id===id); if (!cat) return; iconCategoryNav.querySelectorAll('.icon-cat-btn').forEach(b => { const isActive = b.querySelector('span:last-child').textContent === cat.label; b.classList.toggle('active', isActive); }); } }); }, { root: iconGrid, threshold: 0, rootMargin: '0px 0px -85% 0px' }); // Observe after grid populated const watch = () => { iconGrid.querySelectorAll('.icon-section-anchor').forEach(l => observer.observe(l)); }; // Re-run after each render const origRender = renderIconGrid; renderIconGrid = function(filter='') { origRender(filter); watch(); }; watch(); } // Load and render bookmarks immediately (async () => { bookmarks = await loadBookmarks(); // Wait a bit for styles to load before rendering setTimeout(() => { renderBookmarks(); }, 100); })(); // ---- Greeting / Clock / Weather widgets ---- function computeGreeting(d = new Date()) { const h = d.getHours(); if (h < 5) return 'Good Night'; if (h < 12) return 'Good Morning'; if (h < 18) return 'Good Afternoon'; return 'Good Evening'; } function startClock() { const tick = () => { const now = new Date(); if (greetingEl) greetingEl.textContent = computeGreeting(now); if (clockEl) clockEl.textContent = now.toLocaleTimeString([], { hour12: true }); }; tick(); setInterval(tick, 1000); } // Unit helpers const WEATHER_UNIT_KEY = 'nebula-weather-unit'; // 'auto' | 'c' | 'f' const COUNTRIES_FAHRENHEIT = new Set(['US','BS','KY','LR','PW','FM','MH']); function useFahrenheit() { try { const pref = localStorage.getItem(WEATHER_UNIT_KEY); if (pref === 'c') return false; if (pref === 'f') return true; } catch {} try { const loc = Intl.DateTimeFormat().resolvedOptions().locale || navigator.language || ''; const region = loc.split('-')[1]; return region ? COUNTRIES_FAHRENHEIT.has(region.toUpperCase()) : false; } catch { return false; } } function getPosition(timeoutMs = 6000) { return new Promise((resolve, reject) => { if (!('geolocation' in navigator)) return reject(new Error('geolocation unavailable')); const opts = { enableHighAccuracy: false, timeout: timeoutMs, maximumAge: 60_000 }; navigator.geolocation.getCurrentPosition( pos => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude }), err => reject(err), opts ); }); } async function geoByIP() { // Try a couple of CORS-friendly IP services try { const r = await fetch('https://ipapi.co/json/'); if (r.ok) { const j = await r.json(); if (j && typeof j.latitude === 'number' && typeof j.longitude === 'number') { return { lat: j.latitude, lon: j.longitude, city: j.city, country: j.country_code }; } } } catch {} try { const r = await fetch('https://ipwho.is/'); if (r.ok) { const j = await r.json(); if (j && j.success && j.latitude && j.longitude) { return { lat: j.latitude, lon: j.longitude, city: j.city, country: j.country_code }; } } } catch {} return null; } async function fetchOpenMeteo(lat, lon, fahrenheit) { const tUnit = fahrenheit ? 'fahrenheit' : 'celsius'; const wUnit = fahrenheit ? 'mph' : 'kmh'; const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m,weather_code,wind_speed_10m&temperature_unit=${tUnit}&windspeed_unit=${wUnit}&timezone=auto`; const r = await fetch(url); if (!r.ok) throw new Error('weather fetch failed'); const j = await r.json(); return { temp: j?.current?.temperature_2m, wind: j?.current?.wind_speed_10m, code: j?.current?.weather_code, tUnit: fahrenheit ? '°F' : '°C', wUnit: fahrenheit ? 'mph' : 'km/h', }; } function codeToSummary(code) { // Minimal Open‑Meteo WMO code mapping const m = new Map([ [0,'Clear'], [1,'Mainly clear'], [2,'Partly cloudy'], [3,'Cloudy'], [45,'Fog'], [48,'Rime fog'], [51,'Drizzle'], [53,'Drizzle'], [55,'Drizzle'], [56,'Freezing drizzle'], [57,'Freezing drizzle'], [61,'Rain'], [63,'Rain'], [65,'Rain'], [66,'Freezing rain'], [67,'Freezing rain'], [71,'Snow'], [73,'Snow'], [75,'Snow'], [77,'Snow grains'], [80,'Showers'], [81,'Showers'], [82,'Heavy showers'], [85,'Snow showers'], [86,'Snow showers'], [95,'Thunderstorm'], [96,'Storm'], [99,'Severe storm'] ]); return m.get(Number(code)) || 'Weather'; } async function loadWeather() { if (!weatherEl) return; // Prefer an app-provided IPC source if available try { if (window.electronAPI && typeof window.electronAPI.invoke === 'function') { const res = await window.electronAPI.invoke('get-weather'); if (res && (res.temp || res.summary)) { const summaryText = res.summary || ''; const tempText = typeof res.temp === 'number' ? `${Math.round(res.temp)}°` : ''; const windText = res.wind ? ` · Wind ${Math.round(res.wind)} ${res.wUnit || 'km/h'}` : ''; weatherEl.textContent = `${tempText}${summaryText ? ' · ' + summaryText : ''}${windText}`.trim() || '—'; return; } } } catch (e) { console.warn('IPC weather failed', e); } try { // 1) Try browser geolocation let loc = null; try { loc = await getPosition(); } catch {} if (!loc) loc = await geoByIP(); if (!loc) throw new Error('no location'); const f = useFahrenheit(); const data = await fetchOpenMeteo(loc.lat, loc.lon, f); const summary = codeToSummary(data.code); const temp = typeof data.temp === 'number' ? Math.round(data.temp) : data.temp; const wind = typeof data.wind === 'number' ? Math.round(data.wind) : data.wind; weatherEl.textContent = `${temp}${data.tUnit} · Wind ${wind} ${data.wUnit}`; } catch (err) { console.warn('Weather fetch failed', err); weatherEl.textContent = '—'; } } startClock(); loadWeather(); // Refresh weather when unit preference changes window.addEventListener('storage', (e) => { if (e && e.key === WEATHER_UNIT_KEY) { loadWeather(); } }); // ---- Home layout preferences ---- const HOME_SEARCH_Y_KEY = 'nebula-home-search-y'; const HOME_BOOKMARKS_Y_KEY = 'nebula-home-bookmarks-y'; const HOME_GLANCE_CORNER_KEY = 'nebula-home-glance-corner'; const HOME_GREETING_Y_KEY = 'nebula-home-greeting-y'; function applyHomeLayoutPrefs() { try { const root = document.documentElement; const greetY = Number(localStorage.getItem(HOME_GREETING_Y_KEY) || 12); const searchY = Number(localStorage.getItem(HOME_SEARCH_Y_KEY) || 22); const bmY = Number(localStorage.getItem(HOME_BOOKMARKS_Y_KEY) || 40); root.style.setProperty('--home-greeting-y', `${greetY}vh`); root.style.setProperty('--home-search-y', `${searchY}vh`); root.style.setProperty('--home-bookmarks-y', `${bmY}vh`); const corner = localStorage.getItem(HOME_GLANCE_CORNER_KEY) || 'br'; if (glanceEl) { glanceEl.classList.remove('pos-br','pos-bl','pos-tr','pos-tl'); glanceEl.classList.add(`pos-${corner}`); } // Position edit controls at the opposite horizontal side of glance (X-only move) const oppositeHorizontal = (c) => ({ br:'bl', bl:'br', tr:'tl', tl:'tr' }[c] || 'tr'); const editCorner = oppositeHorizontal(corner); [editBtn, editToolbar].forEach(ctrl => { if (!ctrl) return; ctrl.classList.remove('pos-br','pos-bl','pos-tr','pos-tl'); ctrl.classList.add(`pos-${editCorner}`); }); } catch (e) { console.warn('applyHomeLayoutPrefs failed', e); } } applyHomeLayoutPrefs(); // React to settings updates via storage or host messages window.addEventListener('storage', (e) => { if (!e) return; if ([HOME_SEARCH_Y_KEY, HOME_BOOKMARKS_Y_KEY, HOME_GLANCE_CORNER_KEY].includes(e.key)) { applyHomeLayoutPrefs(); } }); if (window.electronAPI && typeof window.electronAPI.on === 'function') { window.electronAPI.on('settings-update', (payload) => { if (!payload) return; if (payload.searchY != null) document.documentElement.style.setProperty('--home-search-y', `${payload.searchY}vh`); if (payload.bookmarksY != null) document.documentElement.style.setProperty('--home-bookmarks-y', `${payload.bookmarksY}vh`); if (payload.glanceCorner && glanceEl) { glanceEl.classList.remove('pos-br','pos-bl','pos-tr','pos-tl'); glanceEl.classList.add(`pos-${payload.glanceCorner}`); // Update edit controls to opposite horizontal side (X-only) const oppositeHorizontal = (c) => ({ br:'bl', bl:'br', tr:'tl', tl:'tr' }[c] || 'tr'); const editCorner = oppositeHorizontal(payload.glanceCorner); [editBtn, editToolbar].forEach(ctrl => { if (!ctrl) return; ctrl.classList.remove('pos-br','pos-bl','pos-tr','pos-tl'); ctrl.classList.add(`pos-${editCorner}`); }); } }); } // ---- Edit mode drag support ---- let editMode = false; let snapshot = null; // stores values before edits function setEditMode(on) { editMode = !!on; document.body.classList.toggle('edit-mode', editMode); if (editBtn) editBtn.setAttribute('aria-pressed', String(editMode)); if (editToolbar) editToolbar.hidden = !editMode; if (editMode) { // Take a snapshot of current persisted values snapshot = { greetY: Number(localStorage.getItem('nebula-home-greeting-y') || 12), searchY: Number(localStorage.getItem('nebula-home-search-y') || 22), bmY: Number(localStorage.getItem('nebula-home-bookmarks-y') || 40), corner: localStorage.getItem('nebula-home-glance-corner') || 'br' }; } else { snapshot = null; } } if (editBtn) { editBtn.addEventListener('click', () => setEditMode(!editMode)); } function vhFromPx(px) { return (px / window.innerHeight) * 100; } function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); } function makeDragY(el, storageKey, cssVar) { if (!el) return; let startY = 0; let startTop = 0; let dragging = false; const onDown = (ev) => { if (!editMode) return; dragging = true; startY = (ev.touches ? ev.touches[0].clientY : ev.clientY); // current computed var const current = Number((getComputedStyle(document.documentElement).getPropertyValue(cssVar) || '0vh').replace('vh','')); startTop = isNaN(current) ? 0 : current; ev.preventDefault(); }; const onMove = (ev) => { if (!dragging) return; const y = (ev.touches ? ev.touches[0].clientY : ev.clientY); const deltaPx = y - startY; const deltaVh = vhFromPx(deltaPx); const next = clamp(startTop + deltaVh, 0, 90); document.documentElement.style.setProperty(cssVar, `${next}vh`); }; const onUp = () => { if (!dragging) return; dragging = false; // Don't persist here; only on Save. Values still applied via CSS var. }; el.addEventListener('mousedown', onDown); el.addEventListener('touchstart', onDown, { passive:false }); window.addEventListener('mousemove', onMove); window.addEventListener('touchmove', onMove, { passive:false }); window.addEventListener('mouseup', onUp); window.addEventListener('touchend', onUp); } function makeDragGlance(el) { if (!el) return; let dragging = false; let start; const onDown = (ev) => { if (!editMode) return; dragging = true; el.classList.add('dragging'); const p = ev.touches?ev.touches[0]:ev; start = { x:p.clientX, y:p.clientY }; // reset any prior drag offsets el.style.setProperty('--drag-x','0px'); el.style.setProperty('--drag-y','0px'); ev.preventDefault(); }; const onMove = (ev) => { if (!dragging) return; const p = ev.touches?ev.touches[0]:ev; const dx = p.clientX - start.x; const dy = p.clientY - start.y; el.style.setProperty('--drag-x', `${dx}px`); el.style.setProperty('--drag-y', `${dy}px`); }; const onUp = (ev) => { if (!dragging) return; dragging = false; el.classList.remove('dragging'); el.style.removeProperty('--drag-x'); el.style.removeProperty('--drag-y'); const p = ev.changedTouches?ev.changedTouches[0]:ev; const x = p.clientX; const y = p.clientY; // snap to nearest corner const left = x < window.innerWidth/2; const top = y < window.innerHeight/2; const corner = top ? (left ? 'tl' : 'tr') : (left ? 'bl' : 'br'); // Only store corner on Save; temporarily apply class for preview if (glanceEl) { glanceEl.classList.remove('pos-br','pos-bl','pos-tr','pos-tl'); glanceEl.classList.add(`pos-${corner}`); // Stash pending corner choice on the element during edit mode glanceEl.dataset.pendingCorner = corner; } // Also move edit controls to opposite corner during preview const opposite = (c) => ({ br:'tl', bl:'tr', tr:'bl', tl:'br' }[c] || 'tl'); const editCorner = opposite(corner); [editBtn, editToolbar].forEach(ctrl => { if (!ctrl) return; ctrl.classList.remove('pos-br','pos-bl','pos-tr','pos-tl'); ctrl.classList.add(`pos-${editCorner}`); }); }; el.addEventListener('mousedown', onDown); el.addEventListener('touchstart', onDown, { passive:false }); window.addEventListener('mousemove', onMove); window.addEventListener('touchmove', onMove, { passive:false }); window.addEventListener('mouseup', onUp); window.addEventListener('touchend', onUp); } makeDragY(searchContainerEl, 'nebula-home-search-y', '--home-search-y'); makeDragY(topSitesEl, 'nebula-home-bookmarks-y', '--home-bookmarks-y'); makeDragGlance(glanceEl); // Restore greeting to Y-only drag makeDragY(greetingTitleEl, 'nebula-home-greeting-y', '--home-greeting-y'); // Save/Cancel handlers if (saveEditBtn) saveEditBtn.addEventListener('click', () => { // Persist current CSS variable values and pending corner const rootStyle = getComputedStyle(document.documentElement); const getVh = (v) => Math.round(Number((v || '0vh').replace('vh',''))); const gy = getVh(rootStyle.getPropertyValue('--home-greeting-y')); const sy = getVh(rootStyle.getPropertyValue('--home-search-y')); const by = getVh(rootStyle.getPropertyValue('--home-bookmarks-y')); try { localStorage.setItem('nebula-home-greeting-y', String(gy)); localStorage.setItem('nebula-home-search-y', String(sy)); localStorage.setItem('nebula-home-bookmarks-y', String(by)); } catch {} const corner = glanceEl?.dataset?.pendingCorner || localStorage.getItem(HOME_GLANCE_CORNER_KEY) || 'br'; try { localStorage.setItem(HOME_GLANCE_CORNER_KEY, corner); } catch {} if (glanceEl) delete glanceEl.dataset.pendingCorner; setEditMode(false); }); if (cancelEditBtn) cancelEditBtn.addEventListener('click', () => { // Revert CSS vars and glance corner to snapshot if (snapshot) { document.documentElement.style.setProperty('--home-greeting-y', `${snapshot.greetY}vh`); document.documentElement.style.setProperty('--home-search-y', `${snapshot.searchY}vh`); document.documentElement.style.setProperty('--home-bookmarks-y', `${snapshot.bmY}vh`); if (glanceEl) { glanceEl.classList.remove('pos-br','pos-bl','pos-tr','pos-tl'); glanceEl.classList.add(`pos-${snapshot.corner}`); delete glanceEl.dataset.pendingCorner; } } else { applyHomeLayoutPrefs(); } setEditMode(false); });