2157 lines
63 KiB
JavaScript
2157 lines
63 KiB
JavaScript
const ICON_APPS = {
|
|
mycomputer: {
|
|
title: "My Computer",
|
|
type: "explorer",
|
|
startPath: "/",
|
|
},
|
|
notepad: {
|
|
title: "Notepad",
|
|
type: "notepad",
|
|
},
|
|
paint: {
|
|
title: "Paint",
|
|
type: "paint",
|
|
},
|
|
ie: {
|
|
title: "Internet Explorer",
|
|
type: "ie",
|
|
},
|
|
recycle: {
|
|
title: "Recycle Bin",
|
|
type: "explorer",
|
|
startPath: "/Recycle Bin",
|
|
},
|
|
};
|
|
|
|
const RETRO_WEB_HOME = 'home://portal';
|
|
let retroWebIndexPromise = null;
|
|
let retroWebRoutes = new Map();
|
|
|
|
async function loadRetroWebIndex() {
|
|
if (retroWebIndexPromise) return retroWebIndexPromise;
|
|
|
|
retroWebIndexPromise = fetch('/fakeweb/index.json', { cache: 'no-store' })
|
|
.then((res) => (res.ok ? res.json() : { pages: [] }))
|
|
.catch(() => ({ pages: [] }))
|
|
.then((data) => {
|
|
const pages = Array.isArray(data.pages) ? data.pages : [];
|
|
retroWebRoutes = new Map(
|
|
pages
|
|
.filter((p) => p && p.route && p.file)
|
|
.map((p) => [String(p.route).toLowerCase(), p]),
|
|
);
|
|
return pages;
|
|
});
|
|
|
|
return retroWebIndexPromise;
|
|
}
|
|
|
|
function retroRouteKey(url) {
|
|
return String(url || '').trim().toLowerCase();
|
|
}
|
|
|
|
function normalizeRetroUrl(raw) {
|
|
if (!raw) return RETRO_WEB_HOME;
|
|
const text = String(raw).trim();
|
|
if (!text) return RETRO_WEB_HOME;
|
|
|
|
if (text.startsWith('home://') || text.startsWith('site://') || text.startsWith('search://')) {
|
|
return text;
|
|
}
|
|
|
|
if (/^https?:\/\//i.test(text)) {
|
|
return `search://${encodeURIComponent(text)}`;
|
|
}
|
|
|
|
if (text.includes(' ')) {
|
|
return `search://${encodeURIComponent(text)}`;
|
|
}
|
|
|
|
if (text.includes('.')) {
|
|
return `site://${text.toLowerCase()}`;
|
|
}
|
|
|
|
return `search://${encodeURIComponent(text)}`;
|
|
}
|
|
|
|
function buildRetroSearchPage(query, pages) {
|
|
const q = decodeURIComponent(query || '').trim() || 'empty search';
|
|
const needle = q.toLowerCase();
|
|
|
|
const matches = pages
|
|
.filter((p) => {
|
|
const hay = `${p.route || ''} ${p.title || ''} ${(p.tags || []).join(' ')}`.toLowerCase();
|
|
return hay.includes(needle);
|
|
})
|
|
.slice(0, 12)
|
|
.map((p) => `<li><a href="${p.route}">${p.title || p.route}</a><div>${p.route}</div></li>`)
|
|
.join('');
|
|
|
|
return {
|
|
title: `Search results for "${q}"`,
|
|
html: `
|
|
<h2>RetroSearch 2000</h2>
|
|
<p>Results for: <strong>${q}</strong></p>
|
|
<ol class="ie-results">${matches || '<li>No direct matches. Try <a href="home://portal">home://portal</a></li>'}</ol>
|
|
`,
|
|
};
|
|
}
|
|
|
|
async function getRetroPage(url) {
|
|
const pages = await loadRetroWebIndex();
|
|
|
|
if (url.startsWith('search://')) {
|
|
return buildRetroSearchPage(url.slice('search://'.length), pages);
|
|
}
|
|
|
|
const entry = retroWebRoutes.get(retroRouteKey(url));
|
|
if (!entry) return null;
|
|
|
|
try {
|
|
const res = await fetch(entry.file, { cache: 'no-store' });
|
|
if (!res.ok) return null;
|
|
const html = await res.text();
|
|
return {
|
|
title: entry.title || entry.route,
|
|
html,
|
|
route: entry.route,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function htmlEscape(text) {
|
|
return String(text || '').replace(/[&<>"']/g, (m) => ({
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": ''',
|
|
}[m]));
|
|
}
|
|
|
|
const FILE_SYSTEM = {
|
|
type: 'folder',
|
|
name: 'My Computer',
|
|
children: [
|
|
{
|
|
type: 'folder',
|
|
name: 'C:',
|
|
children: [
|
|
{ type: 'folder', name: 'Program Files', children: [
|
|
{ type: 'file', name: 'readme.txt', fileType: 'txt', content: 'Welcome to veryextraOS 1.0\n\nThis is a mock file system.' },
|
|
]},
|
|
{ type: 'folder', name: 'WINDOWS', children: [
|
|
{ type: 'file', name: 'win.ini', fileType: 'txt', content: '[fonts]\n[extensions]\n[desktop]' },
|
|
]},
|
|
{ type: 'folder', name: 'Documents and Settings', children: [
|
|
{ type: 'folder', name: 'Dion', children: [
|
|
{ type: 'folder', name: 'My Documents', children: [
|
|
{ type: 'file', name: 'shopping-list.txt', fileType: 'txt', content: 'milk\ncoffee\nstickers' },
|
|
{ type: 'file', name: 'desktop-ideas.txt', fileType: 'txt', content: 'Make everything cuter\nAdd sounds\nAdd themes' },
|
|
]},
|
|
{ type: 'folder', name: 'My Pictures', children: [
|
|
{ type: 'file', name: 'wallpaper-preview.jpg', fileType: 'image', content: 'https://wallpaperaccess.com/full/4810999.jpg' },
|
|
{ type: 'file', name: 'pink-theme.png', fileType: 'image', content: 'https://picsum.photos/seed/pinktheme/720/450' },
|
|
]},
|
|
]},
|
|
]},
|
|
],
|
|
},
|
|
{
|
|
type: 'folder',
|
|
name: 'D:',
|
|
children: [
|
|
{ type: 'folder', name: 'Music', children: [
|
|
{ type: 'file', name: 'favorites.txt', fileType: 'txt', content: 'Track 01\nTrack 02\nTrack 03' },
|
|
{ type: 'file', name: 'violin.mp3', fileType: 'audio', content: '/assets/audio/violin.mp3' },
|
|
]},
|
|
],
|
|
},
|
|
{
|
|
type: 'folder',
|
|
name: 'Recycle Bin',
|
|
children: [
|
|
{ type: 'file', name: 'old-theme.zip', fileType: 'txt', content: 'Archive is empty.' },
|
|
{ type: 'file', name: 'notes (1).txt', fileType: 'txt', content: 'Draft notes moved to recycle bin.' },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const statusEl = document.getElementById('status');
|
|
const clockEl = document.getElementById('clock');
|
|
const iconsEl = document.getElementById('icons');
|
|
const iconButtons = Array.from(document.querySelectorAll('.desktop-icon'));
|
|
const resetLayoutBtn = document.getElementById('reset-layout-btn');
|
|
const startBtn = document.getElementById('start-btn');
|
|
const taskbarWindowsEl = document.getElementById('taskbar-windows');
|
|
const showDesktopBtn = document.getElementById('show-desktop-btn');
|
|
const windowTemplate = document.getElementById('window-template');
|
|
|
|
const ICON_LAYOUT_KEY = 'veryextraOS.iconLayout.v1';
|
|
const selectedIcons = new Set();
|
|
const windowsById = new Map();
|
|
const startMenuEl = document.getElementById('start-menu');
|
|
|
|
let selectionBoxEl = null;
|
|
let topWindowZ = 1000;
|
|
let windowSeq = 0;
|
|
|
|
function clamp(value, min, max) {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
function setStatus(text) {
|
|
if (statusEl) statusEl.textContent = text;
|
|
}
|
|
|
|
let contextMenuEl = null;
|
|
|
|
function ensureContextMenu() {
|
|
if (contextMenuEl) return contextMenuEl;
|
|
|
|
contextMenuEl = document.createElement('div');
|
|
contextMenuEl.id = 'context-menu';
|
|
contextMenuEl.className = 'hidden';
|
|
contextMenuEl.setAttribute('role', 'menu');
|
|
contextMenuEl.addEventListener('click', (e) => e.stopPropagation());
|
|
document.body.appendChild(contextMenuEl);
|
|
return contextMenuEl;
|
|
}
|
|
|
|
function hideContextMenu() {
|
|
if (!contextMenuEl) return;
|
|
contextMenuEl.classList.add('hidden');
|
|
contextMenuEl.innerHTML = '';
|
|
}
|
|
|
|
function showContextMenu(x, y, items = []) {
|
|
if (!items.length) {
|
|
hideContextMenu();
|
|
return;
|
|
}
|
|
|
|
const menu = ensureContextMenu();
|
|
menu.innerHTML = '';
|
|
|
|
items.forEach((item) => {
|
|
if (item === 'separator') {
|
|
const sep = document.createElement('div');
|
|
sep.className = 'context-menu-sep';
|
|
menu.appendChild(sep);
|
|
return;
|
|
}
|
|
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'context-menu-item';
|
|
btn.textContent = item.label || 'Action';
|
|
btn.disabled = Boolean(item.disabled);
|
|
btn.setAttribute('role', 'menuitem');
|
|
btn.addEventListener('click', () => {
|
|
hideContextMenu();
|
|
item.action?.();
|
|
});
|
|
menu.appendChild(btn);
|
|
});
|
|
|
|
menu.classList.remove('hidden');
|
|
menu.style.left = '0px';
|
|
menu.style.top = '0px';
|
|
|
|
const pad = 6;
|
|
const menuRect = menu.getBoundingClientRect();
|
|
const maxLeft = Math.max(pad, window.innerWidth - menuRect.width - pad);
|
|
const maxTop = Math.max(pad, window.innerHeight - menuRect.height - pad);
|
|
menu.style.left = `${clamp(x, pad, maxLeft)}px`;
|
|
menu.style.top = `${clamp(y, pad, maxTop)}px`;
|
|
}
|
|
|
|
function openPropertiesWindow(title, rows = []) {
|
|
const htmlRows = rows
|
|
.map((row) => `<div><strong>${htmlEscape(row.label)}:</strong> ${htmlEscape(row.value)}</div>`)
|
|
.join('');
|
|
|
|
openAppFromDescriptor({
|
|
title: `${title} Properties`,
|
|
width: 360,
|
|
height: 240,
|
|
html: `
|
|
<div class="xp-props">
|
|
<div class="xp-props-title">${htmlEscape(title)}</div>
|
|
<div class="xp-props-body">${htmlRows || '<div>No details available.</div>'}</div>
|
|
</div>
|
|
`,
|
|
});
|
|
}
|
|
|
|
function parsePosition(btn) {
|
|
return {
|
|
x: parseInt(btn.style.left || '0', 10) || 0,
|
|
y: parseInt(btn.style.top || '0', 10) || 0,
|
|
};
|
|
}
|
|
|
|
function setIconPosition(btn, x, y) {
|
|
const maxX = Math.max(0, iconsEl.clientWidth - btn.offsetWidth - 6);
|
|
const maxY = Math.max(0, iconsEl.clientHeight - btn.offsetHeight - 6);
|
|
btn.style.left = `${clamp(x, 0, maxX)}px`;
|
|
btn.style.top = `${clamp(y, 0, maxY)}px`;
|
|
}
|
|
|
|
function saveIconLayout() {
|
|
const payload = iconButtons.map((btn) => ({
|
|
app: btn.dataset.app,
|
|
...parsePosition(btn),
|
|
}));
|
|
localStorage.setItem(ICON_LAYOUT_KEY, JSON.stringify(payload));
|
|
}
|
|
|
|
function loadIconLayout() {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(ICON_LAYOUT_KEY) || 'null');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function resetIconLayout() {
|
|
iconButtons.forEach((btn, i) => {
|
|
setIconPosition(btn, 8, 8 + i * 106);
|
|
});
|
|
saveIconLayout();
|
|
setStatus('Icons reset to vertical stack');
|
|
}
|
|
|
|
function applyInitialLayout() {
|
|
const saved = loadIconLayout();
|
|
if (Array.isArray(saved) && saved.length) {
|
|
const savedMap = new Map(saved.map((item) => [item.app, item]));
|
|
iconButtons.forEach((btn, i) => {
|
|
const s = savedMap.get(btn.dataset.app);
|
|
if (s) {
|
|
setIconPosition(btn, Number(s.x) || 0, Number(s.y) || 0);
|
|
} else {
|
|
setIconPosition(btn, 8, 8 + i * 106);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
resetIconLayout();
|
|
}
|
|
|
|
function clearSelection() {
|
|
selectedIcons.forEach((btn) => btn.classList.remove('selected'));
|
|
selectedIcons.clear();
|
|
}
|
|
|
|
function setSelection(buttons) {
|
|
clearSelection();
|
|
buttons.forEach((btn) => {
|
|
selectedIcons.add(btn);
|
|
btn.classList.add('selected');
|
|
});
|
|
setStatus(`${selectedIcons.size} item(s) selected`);
|
|
}
|
|
|
|
function toggleSelection(btn) {
|
|
if (selectedIcons.has(btn)) {
|
|
selectedIcons.delete(btn);
|
|
btn.classList.remove('selected');
|
|
} else {
|
|
selectedIcons.add(btn);
|
|
btn.classList.add('selected');
|
|
}
|
|
if (selectedIcons.size) {
|
|
setStatus(`${selectedIcons.size} item(s) selected`);
|
|
} else {
|
|
setStatus(windowsById.size ? `${windowsById.size} window(s) open` : 'No windows open');
|
|
}
|
|
}
|
|
|
|
function toIconRect(btn) {
|
|
return {
|
|
left: btn.offsetLeft,
|
|
top: btn.offsetTop,
|
|
right: btn.offsetLeft + btn.offsetWidth,
|
|
bottom: btn.offsetTop + btn.offsetHeight,
|
|
};
|
|
}
|
|
|
|
function intersects(a, b) {
|
|
return !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom);
|
|
}
|
|
|
|
function ensureSelectionBox() {
|
|
if (!selectionBoxEl) {
|
|
selectionBoxEl = document.createElement('div');
|
|
selectionBoxEl.id = 'selection-box';
|
|
selectionBoxEl.hidden = true;
|
|
iconsEl.appendChild(selectionBoxEl);
|
|
}
|
|
return selectionBoxEl;
|
|
}
|
|
|
|
function getWindowRect(winEl) {
|
|
return winEl.getBoundingClientRect();
|
|
}
|
|
|
|
function splitPath(path) {
|
|
return path.split('/').filter(Boolean);
|
|
}
|
|
|
|
function joinPath(parts) {
|
|
return `/${parts.join('/')}` || '/';
|
|
}
|
|
|
|
function getNodeByPath(path) {
|
|
const parts = splitPath(path);
|
|
let node = FILE_SYSTEM;
|
|
for (const part of parts) {
|
|
if (!node.children) return null;
|
|
const next = node.children.find((child) => child.name === part);
|
|
if (!next) return null;
|
|
node = next;
|
|
}
|
|
return node;
|
|
}
|
|
|
|
function updateFileByPath(path, updater) {
|
|
const node = getNodeByPath(path);
|
|
if (!node || node.type !== 'file') return false;
|
|
updater(node);
|
|
return true;
|
|
}
|
|
|
|
function listFolder(path) {
|
|
const node = getNodeByPath(path);
|
|
if (!node || node.type !== 'folder') return [];
|
|
|
|
const rank = (item) => {
|
|
if (item.type === 'folder') return 0;
|
|
if (item.fileType === 'audio') return 1;
|
|
if (item.fileType === 'image') return 2;
|
|
if (item.fileType === 'txt') return 3;
|
|
return 4;
|
|
};
|
|
|
|
return [...(node.children || [])].sort((a, b) => {
|
|
const ra = rank(a);
|
|
const rb = rank(b);
|
|
if (ra !== rb) return ra - rb;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
}
|
|
|
|
function fileIconFor(item) {
|
|
if (item.type === 'folder') return '📁';
|
|
if (item.fileType === 'image') return '🖼️';
|
|
if (item.fileType === 'audio') return '🎵';
|
|
if (item.fileType === 'txt') return '📄';
|
|
return '📦';
|
|
}
|
|
|
|
function itemTypeLabel(item) {
|
|
if (item.type === 'folder') return 'File Folder';
|
|
if (item.fileType === 'image') return 'Image File';
|
|
if (item.fileType === 'audio') return 'Audio File';
|
|
if (item.fileType === 'txt') return 'Text Document';
|
|
return 'File';
|
|
}
|
|
|
|
function itemSizeLabel(item) {
|
|
if (item.type === 'folder') return `${(item.children || []).length} item(s)`;
|
|
const raw = String(item.content || '').length;
|
|
const kb = Math.max(1, Math.round(raw / 40));
|
|
return `${kb} KB`;
|
|
}
|
|
|
|
function itemModifiedLabel(item) {
|
|
const seed = item.name.length * 37;
|
|
const month = ((seed % 12) + 1).toString().padStart(2, '0');
|
|
const day = ((seed % 27) + 1).toString().padStart(2, '0');
|
|
return `${month}/${day}/2006 3:${(seed % 60).toString().padStart(2, '0')} PM`;
|
|
}
|
|
|
|
function renderExplorerDetails(path, selectedItem) {
|
|
return selectedItem ? `
|
|
<div class="xp-details-title">${selectedItem.name}</div>
|
|
<div><strong>Type:</strong> ${itemTypeLabel(selectedItem)}</div>
|
|
<div><strong>Size:</strong> ${itemSizeLabel(selectedItem)}</div>
|
|
<div><strong>Modified:</strong> ${itemModifiedLabel(selectedItem)}</div>
|
|
<div><strong>Location:</strong> ${path === '/' ? 'My Computer' : path}</div>
|
|
` : `
|
|
<div class="xp-details-title">No item selected</div>
|
|
<div>Select a file or folder to view details.</div>
|
|
`;
|
|
}
|
|
|
|
function renderExplorer(path, selectedItem, nav, selectedAll = false) {
|
|
const items = listFolder(path);
|
|
const breadcrumb = path === '/' ? 'My Computer' : `My Computer > ${splitPath(path).join(' > ')}`;
|
|
|
|
const list = items.map((item) => {
|
|
const encodedName = encodeURIComponent(item.name);
|
|
const selected = selectedAll || (selectedItem && selectedItem.name === item.name && selectedItem.type === item.type);
|
|
return `
|
|
<button class="xp-file-item ${selected ? 'selected' : ''}" data-kind="${item.type}" data-name="${encodedName}">
|
|
<span class="xp-file-icon">${fileIconFor(item)}</span>
|
|
<span class="xp-file-label">${item.name}</span>
|
|
</button>
|
|
`;
|
|
}).join('');
|
|
|
|
const detailsHtml = selectedAll
|
|
? `<div class="xp-details-title">${items.length} item(s) selected</div><div>Press Enter to open the first selected item.</div>`
|
|
: renderExplorerDetails(path, selectedItem);
|
|
|
|
return `
|
|
<div class="xp-explorer" data-path="${path}">
|
|
<div class="xp-toolbar">
|
|
<button class="xp-nav-btn xp-nav-icon" data-action="back" title="Back" aria-label="Back" ${nav?.canBack ? '' : 'disabled'}><span aria-hidden="true">◀</span></button>
|
|
<button class="xp-nav-btn xp-nav-icon" data-action="forward" title="Forward" aria-label="Forward" ${nav?.canForward ? '' : 'disabled'}><span aria-hidden="true">▶</span></button>
|
|
<button class="xp-nav-btn xp-nav-icon" data-action="up" title="Up" aria-label="Up"><span aria-hidden="true">⤴</span></button>
|
|
<button class="xp-nav-btn" data-action="root" title="My Computer" aria-label="My Computer">🖥️</button>
|
|
<div class="xp-breadcrumb">${breadcrumb}</div>
|
|
</div>
|
|
<div class="xp-explorer-body">
|
|
<aside class="xp-sidebar">
|
|
<div class="xp-side-title">Quick Access</div>
|
|
<button class="xp-side-link" data-jump="/">🖥️ My Computer</button>
|
|
<button class="xp-side-link" data-jump="/C:">💽 Local Disk (C:)</button>
|
|
<button class="xp-side-link" data-jump="/D:">💾 Data (D:)</button>
|
|
<button class="xp-side-link" data-jump="/C:/Documents and Settings/Dion/My Documents">📂 My Documents</button>
|
|
<button class="xp-side-link" data-jump="/C:/Documents and Settings/Dion/My Pictures">🖼️ My Pictures</button>
|
|
<button class="xp-side-link" data-jump="/D:/Music">🎵 Music</button>
|
|
<button class="xp-side-link" data-jump="/Recycle Bin">🗑️ Recycle Bin</button>
|
|
</aside>
|
|
<div class="xp-main">
|
|
<section class="xp-files">
|
|
${list || '<div class="xp-empty">This folder is empty.</div>'}
|
|
</section>
|
|
<aside class="xp-details">
|
|
${detailsHtml}
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderNotepadApp(filePath, fileName, textValue = '') {
|
|
const escapedName = String(fileName || 'Untitled').replace(/"/g, '"');
|
|
const escapedPath = String(filePath || '').replace(/"/g, '"');
|
|
const escapedText = String(textValue).replace(/[&<>]/g, (m) => ({ '&': '&', '<': '<', '>': '>' }[m]));
|
|
|
|
return `
|
|
<div class="xp-notepad" data-file-path="${escapedPath}">
|
|
<div class="xp-notepad-toolbar">
|
|
<button class="xp-note-btn" data-note-action="new">New</button>
|
|
<button class="xp-note-btn" data-note-action="save">Save</button>
|
|
<span class="xp-note-file">${escapedName}</span>
|
|
</div>
|
|
<textarea class="xp-note-editor" spellcheck="false">${escapedText}</textarea>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function wireNotepadInteractions(winEl, options = {}) {
|
|
const contentEl = winEl.querySelector('.window-content');
|
|
if (!contentEl) return;
|
|
|
|
const editorEl = contentEl.querySelector('.xp-note-editor');
|
|
const filePath = options.filePath || null;
|
|
const fileName = options.fileName || 'Untitled.txt';
|
|
|
|
if (!editorEl) return;
|
|
|
|
function updateDirtyTitle(isDirty) {
|
|
const titleEl = winEl.querySelector('.window-title');
|
|
if (!titleEl) return;
|
|
titleEl.textContent = `${isDirty ? '* ' : ''}${fileName}`;
|
|
|
|
const entry = windowsById.get(winEl.dataset.windowId || '');
|
|
if (entry) {
|
|
entry.title = titleEl.textContent;
|
|
updateTaskbarButtons();
|
|
}
|
|
}
|
|
|
|
let dirty = false;
|
|
editorEl.addEventListener('input', () => {
|
|
dirty = true;
|
|
updateDirtyTitle(true);
|
|
});
|
|
|
|
contentEl.querySelectorAll('.xp-note-btn').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const action = btn.getAttribute('data-note-action');
|
|
if (action === 'new') {
|
|
editorEl.value = '';
|
|
dirty = true;
|
|
updateDirtyTitle(true);
|
|
return;
|
|
}
|
|
|
|
if (action === 'save') {
|
|
if (filePath) {
|
|
updateFileByPath(filePath, (node) => {
|
|
node.content = editorEl.value;
|
|
});
|
|
dirty = false;
|
|
updateDirtyTitle(false);
|
|
setStatus(`${fileName} saved`);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
editorEl.addEventListener('keydown', (e) => {
|
|
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
|
|
e.preventDefault();
|
|
contentEl.querySelector('[data-note-action="save"]')?.click();
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
function openNotepadFile(filePath, fileName, textValue = '') {
|
|
const winEl = openAppFromDescriptor({
|
|
title: fileName || 'Notepad',
|
|
html: renderNotepadApp(filePath, fileName, textValue),
|
|
});
|
|
if (winEl) wireNotepadInteractions(winEl, { filePath, fileName });
|
|
}
|
|
|
|
function renderPaintApp() {
|
|
return `
|
|
<div class="xp-paint">
|
|
<div class="xp-paint-toolbar">
|
|
<label>Tool
|
|
<select class="xp-paint-tool">
|
|
<option value="brush">Brush</option>
|
|
<option value="eraser">Eraser</option>
|
|
</select>
|
|
</label>
|
|
<label>Color <input class="xp-paint-color" type="color" value="#d13f8c" /></label>
|
|
<label>Size <input class="xp-paint-size" type="range" min="1" max="32" value="4" /></label>
|
|
<button class="xp-paint-btn" data-paint-action="undo">Undo</button>
|
|
<button class="xp-paint-btn" data-paint-action="redo">Redo</button>
|
|
<button class="xp-paint-btn" data-paint-action="clear">Clear</button>
|
|
<button class="xp-paint-btn" data-paint-action="save">Save PNG</button>
|
|
</div>
|
|
<div class="xp-paint-stage">
|
|
<canvas class="xp-paint-canvas" width="900" height="520"></canvas>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function wirePaintInteractions(winEl) {
|
|
const contentEl = winEl.querySelector('.window-content');
|
|
if (!contentEl) return;
|
|
|
|
const canvas = contentEl.querySelector('.xp-paint-canvas');
|
|
const ctx = canvas?.getContext('2d');
|
|
const toolEl = contentEl.querySelector('.xp-paint-tool');
|
|
const colorEl = contentEl.querySelector('.xp-paint-color');
|
|
const sizeEl = contentEl.querySelector('.xp-paint-size');
|
|
if (!canvas || !ctx || !toolEl || !colorEl || !sizeEl) return;
|
|
|
|
const undoStack = [];
|
|
const redoStack = [];
|
|
|
|
function snapshot() {
|
|
try {
|
|
return canvas.toDataURL('image/png');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function pushUndoState() {
|
|
const shot = snapshot();
|
|
if (!shot) return;
|
|
undoStack.push(shot);
|
|
if (undoStack.length > 30) undoStack.shift();
|
|
redoStack.length = 0;
|
|
}
|
|
|
|
function restoreSnapshot(dataUrl) {
|
|
if (!dataUrl) return;
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
};
|
|
img.src = dataUrl;
|
|
}
|
|
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
let drawing = false;
|
|
let lastX = 0;
|
|
let lastY = 0;
|
|
|
|
function applyStrokeStyle() {
|
|
const tool = toolEl.value;
|
|
ctx.lineCap = 'round';
|
|
ctx.lineJoin = 'round';
|
|
ctx.lineWidth = Number(sizeEl.value || 4);
|
|
if (tool === 'eraser') {
|
|
ctx.strokeStyle = '#ffffff';
|
|
} else {
|
|
ctx.strokeStyle = colorEl.value || '#d13f8c';
|
|
}
|
|
}
|
|
|
|
function pointFromEvent(e) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
return {
|
|
x: (e.clientX - rect.left) * (canvas.width / rect.width),
|
|
y: (e.clientY - rect.top) * (canvas.height / rect.height),
|
|
};
|
|
}
|
|
|
|
canvas.addEventListener('pointerdown', (e) => {
|
|
e.preventDefault();
|
|
pushUndoState();
|
|
drawing = true;
|
|
const p = pointFromEvent(e);
|
|
lastX = p.x;
|
|
lastY = p.y;
|
|
applyStrokeStyle();
|
|
canvas.setPointerCapture(e.pointerId);
|
|
});
|
|
|
|
canvas.addEventListener('pointermove', (e) => {
|
|
if (!drawing) return;
|
|
const p = pointFromEvent(e);
|
|
applyStrokeStyle();
|
|
ctx.beginPath();
|
|
ctx.moveTo(lastX, lastY);
|
|
ctx.lineTo(p.x, p.y);
|
|
ctx.stroke();
|
|
lastX = p.x;
|
|
lastY = p.y;
|
|
});
|
|
|
|
function stopDrawing() {
|
|
if (!drawing) return;
|
|
drawing = false;
|
|
}
|
|
|
|
canvas.addEventListener('pointerup', stopDrawing);
|
|
canvas.addEventListener('pointercancel', stopDrawing);
|
|
canvas.addEventListener('pointerleave', stopDrawing);
|
|
|
|
contentEl.querySelectorAll('.xp-paint-btn').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const action = btn.getAttribute('data-paint-action');
|
|
|
|
if (action === 'undo') {
|
|
if (!undoStack.length) return;
|
|
const current = snapshot();
|
|
if (current) redoStack.push(current);
|
|
const prev = undoStack.pop();
|
|
restoreSnapshot(prev);
|
|
return;
|
|
}
|
|
|
|
if (action === 'redo') {
|
|
if (!redoStack.length) return;
|
|
const current = snapshot();
|
|
if (current) undoStack.push(current);
|
|
const next = redoStack.pop();
|
|
restoreSnapshot(next);
|
|
return;
|
|
}
|
|
|
|
if (action === 'clear') {
|
|
pushUndoState();
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
return;
|
|
}
|
|
|
|
if (action === 'save') {
|
|
const a = document.createElement('a');
|
|
a.href = canvas.toDataURL('image/png');
|
|
a.download = `paint-${Date.now()}.png`;
|
|
a.click();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function openPaintApp() {
|
|
const winEl = openAppFromDescriptor({
|
|
title: 'Paint',
|
|
html: renderPaintApp(),
|
|
});
|
|
if (winEl) wirePaintInteractions(winEl);
|
|
}
|
|
|
|
async function renderIeApp(startUrl = RETRO_WEB_HOME) {
|
|
await loadRetroWebIndex();
|
|
|
|
return `
|
|
<div class="xp-ie" data-url="${htmlEscape(startUrl)}">
|
|
<div class="xp-ie-toolbar">
|
|
<button class="xp-ie-btn" data-ie-action="back">Back</button>
|
|
<button class="xp-ie-btn" data-ie-action="forward">Forward</button>
|
|
<button class="xp-ie-btn" data-ie-action="refresh">Refresh</button>
|
|
<button class="xp-ie-btn" data-ie-action="home">Home</button>
|
|
</div>
|
|
<form class="xp-ie-address-row">
|
|
<span>Address</span>
|
|
<input class="xp-ie-address" type="text" value="${htmlEscape(startUrl)}" />
|
|
<button class="xp-ie-btn" type="submit">Go</button>
|
|
</form>
|
|
<div class="xp-ie-content">Loading...</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function wrapRetroShell(page, url) {
|
|
const stamp = Math.floor(Math.abs(url.split('').reduce((a, c) => a + c.charCodeAt(0), 0)) % 9000) + 1000;
|
|
return `
|
|
<div class="ie-retro-shell">
|
|
<marquee behavior="alternate" scrollamount="4">★ Welcome to ${htmlEscape(page.title || url)} ★</marquee>
|
|
<div class="ie-retro-meta">Visitors: ${stamp} · Best viewed at 800x600 · Netscape/IE compatible</div>
|
|
<article class="ie-page">${page.html}</article>
|
|
<div class="ie-retro-footer">\u00A9 2001-2006 retro web collective · <a href="home://portal">return to portal</a></div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function wireIeInteractions(winEl, startUrl = RETRO_WEB_HOME) {
|
|
const contentEl = winEl.querySelector('.window-content');
|
|
if (!contentEl) return;
|
|
|
|
const root = contentEl.querySelector('.xp-ie');
|
|
const addr = contentEl.querySelector('.xp-ie-address');
|
|
const body = contentEl.querySelector('.xp-ie-content');
|
|
const form = contentEl.querySelector('.xp-ie-address-row');
|
|
if (!root || !addr || !body || !form) return;
|
|
|
|
const backStack = [];
|
|
const forwardStack = [];
|
|
let currentUrl = normalizeRetroUrl(startUrl);
|
|
|
|
function setTitle(url, page) {
|
|
const titleEl = winEl.querySelector('.window-title');
|
|
if (titleEl) {
|
|
titleEl.textContent = `${page?.title || 'Internet Explorer'} - Internet Explorer`;
|
|
}
|
|
|
|
const entry = windowsById.get(winEl.dataset.windowId || '');
|
|
if (entry) {
|
|
entry.title = `IE: ${page?.title || url}`;
|
|
updateTaskbarButtons();
|
|
}
|
|
}
|
|
|
|
function updateNavButtons() {
|
|
contentEl.querySelectorAll('.xp-ie-btn').forEach((btn) => {
|
|
const action = btn.getAttribute('data-ie-action');
|
|
if (action === 'back') btn.disabled = backStack.length === 0;
|
|
if (action === 'forward') btn.disabled = forwardStack.length === 0;
|
|
});
|
|
}
|
|
|
|
async function renderPage(url, pushHistory = false) {
|
|
const normalized = normalizeRetroUrl(url);
|
|
if (pushHistory && normalized !== currentUrl) {
|
|
backStack.push(currentUrl);
|
|
forwardStack.length = 0;
|
|
}
|
|
|
|
currentUrl = normalized;
|
|
root.dataset.url = currentUrl;
|
|
addr.value = currentUrl;
|
|
body.innerHTML = '<div class="ie-loading">Dialing up... Loading retro page...</div>';
|
|
|
|
const page = await getRetroPage(currentUrl);
|
|
if (!page) {
|
|
body.innerHTML = `
|
|
<div class="ie-error">
|
|
<h3>The page cannot be displayed</h3>
|
|
<p>URL: ${htmlEscape(currentUrl)}</p>
|
|
<p>Try <a href="${RETRO_WEB_HOME}">home://portal</a> or run a search.</p>
|
|
</div>
|
|
`;
|
|
setTitle(currentUrl, { title: 'Cannot display page' });
|
|
} else {
|
|
body.innerHTML = wrapRetroShell(page, currentUrl);
|
|
setTitle(currentUrl, page);
|
|
}
|
|
|
|
updateNavButtons();
|
|
|
|
body.querySelectorAll('a[href]').forEach((a) => {
|
|
a.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
const href = a.getAttribute('href');
|
|
if (href) renderPage(href, true);
|
|
});
|
|
});
|
|
}
|
|
|
|
contentEl.querySelectorAll('.xp-ie-btn').forEach((btn) => {
|
|
btn.addEventListener('click', async () => {
|
|
const action = btn.getAttribute('data-ie-action');
|
|
if (action === 'home') {
|
|
renderPage(RETRO_WEB_HOME, true);
|
|
return;
|
|
}
|
|
if (action === 'refresh') {
|
|
renderPage(currentUrl, false);
|
|
return;
|
|
}
|
|
if (action === 'back' && backStack.length) {
|
|
forwardStack.push(currentUrl);
|
|
const prev = backStack.pop();
|
|
if (prev) renderPage(prev, false);
|
|
return;
|
|
}
|
|
if (action === 'forward' && forwardStack.length) {
|
|
backStack.push(currentUrl);
|
|
const next = forwardStack.pop();
|
|
if (next) renderPage(next, false);
|
|
}
|
|
});
|
|
});
|
|
|
|
form.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
renderPage(addr.value, true);
|
|
});
|
|
|
|
renderPage(currentUrl, false);
|
|
}
|
|
|
|
async function openIeApp() {
|
|
const html = await renderIeApp(RETRO_WEB_HOME);
|
|
const winEl = openAppFromDescriptor({
|
|
title: 'Internet Explorer',
|
|
html,
|
|
});
|
|
if (winEl) wireIeInteractions(winEl, RETRO_WEB_HOME);
|
|
}
|
|
|
|
function renderImageViewer(fileName, imageUrl) {
|
|
const safeName = htmlEscape(fileName || 'Image');
|
|
const safeUrl = htmlEscape(imageUrl || '');
|
|
return `
|
|
<div class="xp-image-viewer">
|
|
<div class="xp-image-toolbar">
|
|
<button class="xp-image-btn" data-img-action="zoom-out">-</button>
|
|
<button class="xp-image-btn" data-img-action="zoom-in">+</button>
|
|
<button class="xp-image-btn" data-img-action="actual">100%</button>
|
|
<button class="xp-image-btn" data-img-action="fit">Fit</button>
|
|
<span class="xp-image-title">${safeName}</span>
|
|
</div>
|
|
<div class="xp-image-stage">
|
|
<img class="xp-image-content" src="${safeUrl}" alt="${safeName}" />
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function wireImageViewerInteractions(winEl) {
|
|
const contentEl = winEl.querySelector('.window-content');
|
|
if (!contentEl) return;
|
|
|
|
const img = contentEl.querySelector('.xp-image-content');
|
|
const stage = contentEl.querySelector('.xp-image-stage');
|
|
if (!img || !stage) return;
|
|
|
|
let scale = 1;
|
|
let fitMode = true;
|
|
|
|
function applyScale() {
|
|
if (fitMode) {
|
|
img.style.maxWidth = '100%';
|
|
img.style.maxHeight = '100%';
|
|
img.style.width = 'auto';
|
|
img.style.height = 'auto';
|
|
img.style.transform = 'none';
|
|
return;
|
|
}
|
|
|
|
img.style.maxWidth = 'none';
|
|
img.style.maxHeight = 'none';
|
|
img.style.width = 'auto';
|
|
img.style.height = 'auto';
|
|
img.style.transformOrigin = 'center center';
|
|
img.style.transform = `scale(${scale})`;
|
|
}
|
|
|
|
contentEl.querySelectorAll('.xp-image-btn').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const action = btn.getAttribute('data-img-action');
|
|
|
|
if (action === 'fit') {
|
|
fitMode = true;
|
|
applyScale();
|
|
return;
|
|
}
|
|
|
|
if (action === 'actual') {
|
|
fitMode = false;
|
|
scale = 1;
|
|
applyScale();
|
|
return;
|
|
}
|
|
|
|
if (action === 'zoom-in') {
|
|
fitMode = false;
|
|
scale = Math.min(6, scale + 0.2);
|
|
applyScale();
|
|
return;
|
|
}
|
|
|
|
if (action === 'zoom-out') {
|
|
fitMode = false;
|
|
scale = Math.max(0.2, scale - 0.2);
|
|
applyScale();
|
|
}
|
|
});
|
|
});
|
|
|
|
img.addEventListener('dblclick', () => {
|
|
fitMode = !fitMode;
|
|
if (!fitMode) scale = 1;
|
|
applyScale();
|
|
});
|
|
|
|
applyScale();
|
|
}
|
|
|
|
function openImageViewer(fileName, imageUrl) {
|
|
const winEl = openAppFromDescriptor({
|
|
title: fileName || 'Image Viewer',
|
|
html: renderImageViewer(fileName, imageUrl),
|
|
});
|
|
if (winEl) wireImageViewerInteractions(winEl);
|
|
}
|
|
|
|
function renderAudioPlayer(fileName, audioUrl) {
|
|
const safeName = htmlEscape(fileName || 'Track');
|
|
const safeUrl = htmlEscape(audioUrl || '');
|
|
return `
|
|
<div class="xp-audio-player">
|
|
<div class="xp-audio-top">
|
|
<div class="xp-audio-logo">WA</div>
|
|
<div class="xp-audio-track-wrap">
|
|
<div class="xp-audio-track-marquee">${safeName} • ${safeName} • ${safeName}</div>
|
|
</div>
|
|
</div>
|
|
<div class="xp-audio-controls">
|
|
<button class="xp-audio-btn" data-audio-action="play">▶</button>
|
|
<button class="xp-audio-btn" data-audio-action="pause">⏸</button>
|
|
<button class="xp-audio-btn" data-audio-action="stop">⏹</button>
|
|
<input class="xp-audio-seek" type="range" min="0" max="1000" value="0" step="1" />
|
|
</div>
|
|
<div class="xp-audio-time">00:00 / 00:00</div>
|
|
<audio class="xp-audio-element" preload="metadata" src="${safeUrl}"></audio>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function wireAudioPlayerInteractions(winEl) {
|
|
const contentEl = winEl.querySelector('.window-content');
|
|
if (!contentEl) return;
|
|
|
|
const audio = contentEl.querySelector('.xp-audio-element');
|
|
const seek = contentEl.querySelector('.xp-audio-seek');
|
|
const timeEl = contentEl.querySelector('.xp-audio-time');
|
|
if (!audio || !seek || !timeEl) return;
|
|
|
|
function fmt(sec) {
|
|
if (!Number.isFinite(sec) || sec < 0) return '00:00';
|
|
const m = Math.floor(sec / 60).toString().padStart(2, '0');
|
|
const s = Math.floor(sec % 60).toString().padStart(2, '0');
|
|
return `${m}:${s}`;
|
|
}
|
|
|
|
function updateTime() {
|
|
const dur = Number.isFinite(audio.duration) ? audio.duration : 0;
|
|
const cur = Number.isFinite(audio.currentTime) ? audio.currentTime : 0;
|
|
const pct = dur > 0 ? Math.round((cur / dur) * 1000) : 0;
|
|
seek.value = String(pct);
|
|
timeEl.textContent = `${fmt(cur)} / ${fmt(dur)}`;
|
|
}
|
|
|
|
let seeking = false;
|
|
seek.addEventListener('input', () => {
|
|
seeking = true;
|
|
const dur = Number.isFinite(audio.duration) ? audio.duration : 0;
|
|
const pct = Number(seek.value) / 1000;
|
|
if (dur > 0) audio.currentTime = pct * dur;
|
|
updateTime();
|
|
});
|
|
seek.addEventListener('change', () => {
|
|
seeking = false;
|
|
});
|
|
|
|
contentEl.querySelectorAll('.xp-audio-btn').forEach((btn) => {
|
|
btn.addEventListener('click', async () => {
|
|
const action = btn.getAttribute('data-audio-action');
|
|
if (action === 'play') {
|
|
try { await audio.play(); } catch {}
|
|
return;
|
|
}
|
|
if (action === 'pause') {
|
|
audio.pause();
|
|
return;
|
|
}
|
|
if (action === 'stop') {
|
|
audio.pause();
|
|
audio.currentTime = 0;
|
|
updateTime();
|
|
}
|
|
});
|
|
});
|
|
|
|
audio.addEventListener('timeupdate', () => {
|
|
if (!seeking) updateTime();
|
|
});
|
|
audio.addEventListener('loadedmetadata', updateTime);
|
|
audio.addEventListener('ended', () => {
|
|
audio.currentTime = 0;
|
|
updateTime();
|
|
});
|
|
|
|
updateTime();
|
|
}
|
|
|
|
function openAudioPlayer(fileName, audioUrl) {
|
|
const winEl = openAppFromDescriptor({
|
|
title: `Winamp - ${fileName || 'Track'}`,
|
|
html: renderAudioPlayer(fileName, audioUrl),
|
|
width: 420,
|
|
height: 180,
|
|
});
|
|
if (winEl) wireAudioPlayerInteractions(winEl);
|
|
}
|
|
|
|
function openVirtualFile(item, fullPath) {
|
|
if (item.fileType === 'txt') {
|
|
openNotepadFile(fullPath || null, item.name, String(item.content || ''));
|
|
return;
|
|
}
|
|
|
|
if (item.fileType === 'image') {
|
|
openImageViewer(item.name, item.content);
|
|
return;
|
|
}
|
|
|
|
if (item.fileType === 'audio') {
|
|
openAudioPlayer(item.name, item.content);
|
|
return;
|
|
}
|
|
|
|
openAppFromDescriptor({
|
|
title: item.name,
|
|
html: `<p>Cannot open this file type.</p>`,
|
|
});
|
|
}
|
|
|
|
function wireExplorerInteractions(winEl, startPath) {
|
|
const initialPath = startPath;
|
|
let currentPath = startPath;
|
|
let selectedKey = null;
|
|
let selectedAll = false;
|
|
const backStack = [];
|
|
const forwardStack = [];
|
|
|
|
const contentEl = winEl.querySelector('.window-content');
|
|
if (!contentEl) return;
|
|
|
|
function folderItems() {
|
|
return listFolder(currentPath);
|
|
}
|
|
|
|
function selectedItemFromKey() {
|
|
if (!selectedKey) return null;
|
|
const [type, encoded] = selectedKey.split('|');
|
|
const name = decodeURIComponent(encoded || '');
|
|
const folder = getNodeByPath(currentPath);
|
|
if (!folder || !folder.children) return null;
|
|
return folder.children.find((child) => child.type === type && child.name === name) || null;
|
|
}
|
|
|
|
function firstSelectableKey() {
|
|
const items = folderItems();
|
|
if (!items.length) return null;
|
|
return `${items[0].type}|${encodeURIComponent(items[0].name)}`;
|
|
}
|
|
|
|
function openItemFromKey(key) {
|
|
if (!key) return;
|
|
const [type, encoded] = key.split('|');
|
|
const name = decodeURIComponent(encoded || '');
|
|
const folder = getNodeByPath(currentPath);
|
|
if (!folder || !folder.children) return;
|
|
const item = folder.children.find((child) => child.type === type && child.name === name);
|
|
if (!item) return;
|
|
|
|
const fullPath = joinPath([...splitPath(currentPath), item.name]);
|
|
|
|
if (item.type === 'folder') {
|
|
navigateTo(fullPath);
|
|
return;
|
|
}
|
|
|
|
openVirtualFile(item, fullPath);
|
|
}
|
|
|
|
function selectByIndex(index) {
|
|
const items = folderItems();
|
|
if (!items.length) return;
|
|
const i = clamp(index, 0, items.length - 1);
|
|
selectedAll = false;
|
|
selectedKey = `${items[i].type}|${encodeURIComponent(items[i].name)}`;
|
|
rerender();
|
|
}
|
|
|
|
function moveSelection(delta) {
|
|
const items = folderItems();
|
|
if (!items.length) return;
|
|
|
|
if (!selectedKey || selectedAll) {
|
|
selectByIndex(delta > 0 ? 0 : items.length - 1);
|
|
return;
|
|
}
|
|
|
|
const currentIndex = items.findIndex((it) => `${it.type}|${encodeURIComponent(it.name)}` === selectedKey);
|
|
if (currentIndex === -1) {
|
|
selectByIndex(0);
|
|
return;
|
|
}
|
|
|
|
selectByIndex(currentIndex + delta);
|
|
}
|
|
|
|
function navigateTo(path, pushHistory = true) {
|
|
const node = getNodeByPath(path);
|
|
if (!node || node.type !== 'folder') return;
|
|
if (path === currentPath) return;
|
|
|
|
if (pushHistory) {
|
|
backStack.push(currentPath);
|
|
forwardStack.length = 0;
|
|
}
|
|
|
|
currentPath = path;
|
|
selectedKey = null;
|
|
selectedAll = false;
|
|
rerender();
|
|
}
|
|
|
|
function rerender() {
|
|
const selectedItem = selectedAll ? null : selectedItemFromKey();
|
|
contentEl.innerHTML = renderExplorer(currentPath, selectedItem, {
|
|
canBack: backStack.length > 0,
|
|
canForward: forwardStack.length > 0,
|
|
}, selectedAll);
|
|
|
|
const titleEl = winEl.querySelector('.window-title');
|
|
if (titleEl) {
|
|
if (initialPath === '/Recycle Bin') {
|
|
titleEl.textContent = 'Recycle Bin';
|
|
} else {
|
|
titleEl.textContent = currentPath === '/' ? 'My Computer' : splitPath(currentPath).slice(-1)[0] || 'Explorer';
|
|
}
|
|
}
|
|
|
|
contentEl.querySelectorAll('.xp-nav-btn').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const action = btn.getAttribute('data-action');
|
|
|
|
if (action === 'back' && backStack.length) {
|
|
forwardStack.push(currentPath);
|
|
currentPath = backStack.pop() || '/';
|
|
selectedKey = null;
|
|
selectedAll = false;
|
|
rerender();
|
|
return;
|
|
}
|
|
|
|
if (action === 'forward' && forwardStack.length) {
|
|
backStack.push(currentPath);
|
|
currentPath = forwardStack.pop() || '/';
|
|
selectedKey = null;
|
|
selectedAll = false;
|
|
rerender();
|
|
return;
|
|
}
|
|
|
|
if (action === 'root') {
|
|
navigateTo('/');
|
|
return;
|
|
}
|
|
|
|
if (action === 'up') {
|
|
const parts = splitPath(currentPath);
|
|
if (!parts.length) return;
|
|
parts.pop();
|
|
navigateTo(joinPath(parts));
|
|
}
|
|
});
|
|
});
|
|
|
|
contentEl.querySelectorAll('.xp-side-link').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const jump = btn.getAttribute('data-jump') || '/';
|
|
navigateTo(jump);
|
|
});
|
|
});
|
|
|
|
contentEl.querySelectorAll('.xp-file-item').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const kind = btn.getAttribute('data-kind');
|
|
const encoded = btn.getAttribute('data-name');
|
|
if (!kind || !encoded) return;
|
|
selectedAll = false;
|
|
selectedKey = `${kind}|${encoded}`;
|
|
|
|
contentEl.querySelectorAll('.xp-file-item.selected').forEach((el) => el.classList.remove('selected'));
|
|
btn.classList.add('selected');
|
|
|
|
const folder = getNodeByPath(currentPath);
|
|
const name = decodeURIComponent(encoded);
|
|
const item = folder?.children?.find((child) => child.type === kind && child.name === name) || null;
|
|
const detailsEl = contentEl.querySelector('.xp-details');
|
|
if (detailsEl) detailsEl.innerHTML = renderExplorerDetails(currentPath, item);
|
|
});
|
|
|
|
btn.addEventListener('dblclick', () => {
|
|
const encoded = btn.getAttribute('data-name');
|
|
const kind = btn.getAttribute('data-kind');
|
|
const name = decodeURIComponent(encoded || '');
|
|
if (!name || !kind) return;
|
|
|
|
const folder = getNodeByPath(currentPath);
|
|
if (!folder || !folder.children) return;
|
|
const item = folder.children.find((child) => child.name === name && child.type === kind);
|
|
if (!item) return;
|
|
|
|
const fullPath = joinPath([...splitPath(currentPath), item.name]);
|
|
|
|
if (item.type === 'folder') {
|
|
navigateTo(fullPath);
|
|
return;
|
|
}
|
|
|
|
openVirtualFile(item, fullPath);
|
|
});
|
|
|
|
btn.addEventListener('contextmenu', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const encoded = btn.getAttribute('data-name');
|
|
const kind = btn.getAttribute('data-kind');
|
|
const name = decodeURIComponent(encoded || '');
|
|
if (!name || !kind) return;
|
|
|
|
const folder = getNodeByPath(currentPath);
|
|
if (!folder || !folder.children) return;
|
|
const item = folder.children.find((child) => child.name === name && child.type === kind);
|
|
if (!item) return;
|
|
|
|
selectedAll = false;
|
|
selectedKey = `${kind}|${encodeURIComponent(name)}`;
|
|
contentEl.querySelectorAll('.xp-file-item.selected').forEach((el) => el.classList.remove('selected'));
|
|
btn.classList.add('selected');
|
|
const detailsEl = contentEl.querySelector('.xp-details');
|
|
if (detailsEl) detailsEl.innerHTML = renderExplorerDetails(currentPath, item);
|
|
|
|
const fullPath = joinPath([...splitPath(currentPath), item.name]);
|
|
const menuItems = [
|
|
{
|
|
label: 'Open',
|
|
action: () => {
|
|
if (item.type === 'folder') navigateTo(fullPath);
|
|
else openVirtualFile(item, fullPath);
|
|
},
|
|
},
|
|
item.type === 'folder'
|
|
? {
|
|
label: 'Open in New Window',
|
|
action: () => openExplorerAtPath(fullPath, item.name),
|
|
}
|
|
: {
|
|
label: 'Open Parent Folder',
|
|
action: () => navigateTo(currentPath),
|
|
},
|
|
'separator',
|
|
{
|
|
label: 'Properties',
|
|
action: () => {
|
|
openPropertiesWindow(item.name, [
|
|
{ label: 'Type', value: itemTypeLabel(item) },
|
|
{ label: 'Path', value: fullPath },
|
|
{ label: 'Size', value: itemSizeLabel(item) },
|
|
{ label: 'Modified', value: itemModifiedLabel(item) },
|
|
]);
|
|
},
|
|
},
|
|
];
|
|
|
|
showContextMenu(e.clientX, e.clientY, menuItems);
|
|
});
|
|
});
|
|
}
|
|
|
|
if (!getNodeByPath(currentPath)) currentPath = '/';
|
|
rerender();
|
|
|
|
winEl.addEventListener('keydown', (e) => {
|
|
const activeEl = document.activeElement;
|
|
const isTyping = activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA');
|
|
if (isTyping) return;
|
|
|
|
if (e.ctrlKey && (e.key === 'a' || e.key === 'A')) {
|
|
e.preventDefault();
|
|
const items = folderItems();
|
|
if (!items.length) return;
|
|
selectedAll = true;
|
|
selectedKey = firstSelectableKey();
|
|
rerender();
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
|
|
e.preventDefault();
|
|
moveSelection(1);
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
|
|
e.preventDefault();
|
|
moveSelection(-1);
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
openItemFromKey(selectedKey || firstSelectableKey());
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'Backspace') {
|
|
e.preventDefault();
|
|
const parts = splitPath(currentPath);
|
|
if (!parts.length) return;
|
|
parts.pop();
|
|
navigateTo(joinPath(parts));
|
|
}
|
|
});
|
|
}
|
|
|
|
function setWindowPosition(winEl, left, top) {
|
|
const maxLeft = Math.max(0, window.innerWidth - winEl.offsetWidth - 8);
|
|
const maxTop = Math.max(36, window.innerHeight - winEl.offsetHeight - 40);
|
|
winEl.style.left = `${clamp(left, 0, maxLeft)}px`;
|
|
winEl.style.top = `${clamp(top, 32, maxTop)}px`;
|
|
winEl.style.transform = 'none';
|
|
}
|
|
|
|
function updateTaskbarButtons() {
|
|
windowsById.forEach((entry) => {
|
|
const { taskBtn, winEl, title } = entry;
|
|
taskBtn.textContent = title;
|
|
taskBtn.classList.toggle('active', !winEl.classList.contains('hidden') && entry.active);
|
|
});
|
|
}
|
|
|
|
function activateWindow(id) {
|
|
const target = windowsById.get(id);
|
|
if (!target) return;
|
|
|
|
windowsById.forEach((entry) => {
|
|
entry.active = false;
|
|
entry.winEl.classList.remove('active');
|
|
});
|
|
|
|
target.active = true;
|
|
target.winEl.classList.add('active');
|
|
showDesktopBtn?.classList.remove('active');
|
|
topWindowZ += 1;
|
|
target.winEl.style.zIndex = String(topWindowZ);
|
|
updateTaskbarButtons();
|
|
}
|
|
|
|
function bringToFront(winEl) {
|
|
const id = winEl.dataset.windowId;
|
|
if (!id) return;
|
|
activateWindow(id);
|
|
winEl.focus();
|
|
}
|
|
|
|
function minimizeWindow(id) {
|
|
const entry = windowsById.get(id);
|
|
if (!entry) return;
|
|
|
|
entry.winEl.classList.add('hidden');
|
|
entry.active = false;
|
|
|
|
const nextVisible = Array.from(windowsById.values()).find((w) => !w.winEl.classList.contains('hidden'));
|
|
if (nextVisible) {
|
|
activateWindow(nextVisible.id);
|
|
}
|
|
|
|
updateTaskbarButtons();
|
|
setStatus(`${entry.title} minimized`);
|
|
}
|
|
|
|
function restoreWindow(id) {
|
|
const entry = windowsById.get(id);
|
|
if (!entry) return;
|
|
|
|
entry.winEl.classList.remove('hidden');
|
|
activateWindow(id);
|
|
setStatus(`${entry.title} restored`);
|
|
}
|
|
|
|
function toggleShowDesktop() {
|
|
const entries = Array.from(windowsById.values());
|
|
if (!entries.length) return;
|
|
|
|
const hasVisible = entries.some((entry) => !entry.winEl.classList.contains('hidden'));
|
|
|
|
if (hasVisible) {
|
|
entries.forEach((entry) => {
|
|
entry.winEl.classList.add('hidden');
|
|
entry.active = false;
|
|
});
|
|
showDesktopBtn?.classList.add('active');
|
|
updateTaskbarButtons();
|
|
setStatus('Showing desktop');
|
|
return;
|
|
}
|
|
|
|
const lastFocused = entries.sort((a, b) => {
|
|
const za = parseInt(a.winEl.style.zIndex || '0', 10);
|
|
const zb = parseInt(b.winEl.style.zIndex || '0', 10);
|
|
return zb - za;
|
|
})[0];
|
|
|
|
entries.forEach((entry) => entry.winEl.classList.remove('hidden'));
|
|
showDesktopBtn?.classList.remove('active');
|
|
if (lastFocused) activateWindow(lastFocused.id);
|
|
else updateTaskbarButtons();
|
|
setStatus('Windows restored');
|
|
}
|
|
|
|
function closeWindow(id) {
|
|
const entry = windowsById.get(id);
|
|
if (!entry) return;
|
|
|
|
entry.winEl.remove();
|
|
entry.taskBtn.remove();
|
|
windowsById.delete(id);
|
|
|
|
if (windowsById.size) {
|
|
const newest = Array.from(windowsById.values()).sort((a, b) => {
|
|
const za = parseInt(a.winEl.style.zIndex || '0', 10);
|
|
const zb = parseInt(b.winEl.style.zIndex || '0', 10);
|
|
return zb - za;
|
|
})[0];
|
|
if (newest) activateWindow(newest.id);
|
|
}
|
|
|
|
updateTaskbarButtons();
|
|
setStatus(windowsById.size ? `${windowsById.size} window(s) open` : 'No windows open');
|
|
}
|
|
|
|
function bindWindowDrag(winEl) {
|
|
const titlebar = winEl.querySelector('.window-titlebar');
|
|
const closeBtn = winEl.querySelector('.window-close');
|
|
const minimizeBtn = winEl.querySelector('.window-minimize');
|
|
const resizeHandle = winEl.querySelector('.window-resize-handle');
|
|
const id = winEl.dataset.windowId;
|
|
if (!titlebar || !closeBtn || !minimizeBtn || !id) return;
|
|
|
|
winEl.addEventListener('pointerdown', () => bringToFront(winEl));
|
|
|
|
titlebar.addEventListener('pointerdown', (e) => {
|
|
if (e.button !== 0) return;
|
|
if (e.target === closeBtn || e.target === minimizeBtn) return;
|
|
|
|
bringToFront(winEl);
|
|
|
|
const rect = getWindowRect(winEl);
|
|
setWindowPosition(winEl, rect.left, rect.top);
|
|
|
|
const startLeft = rect.left;
|
|
const startTop = rect.top;
|
|
const startX = e.clientX;
|
|
const startY = e.clientY;
|
|
let moved = false;
|
|
|
|
winEl.classList.add('dragging');
|
|
titlebar.setPointerCapture(e.pointerId);
|
|
|
|
const onMove = (ev) => {
|
|
const dx = ev.clientX - startX;
|
|
const dy = ev.clientY - startY;
|
|
if (!moved && Math.hypot(dx, dy) > 2) moved = true;
|
|
if (!moved) return;
|
|
setWindowPosition(winEl, startLeft + dx, startTop + dy);
|
|
};
|
|
|
|
const onUp = () => {
|
|
winEl.classList.remove('dragging');
|
|
titlebar.removeEventListener('pointermove', onMove);
|
|
titlebar.removeEventListener('pointerup', onUp);
|
|
titlebar.removeEventListener('pointercancel', onUp);
|
|
};
|
|
|
|
titlebar.addEventListener('pointermove', onMove);
|
|
titlebar.addEventListener('pointerup', onUp);
|
|
titlebar.addEventListener('pointercancel', onUp);
|
|
});
|
|
|
|
if (resizeHandle) {
|
|
resizeHandle.addEventListener('pointerdown', (e) => {
|
|
if (e.button !== 0) return;
|
|
e.stopPropagation();
|
|
bringToFront(winEl);
|
|
|
|
const rect = getWindowRect(winEl);
|
|
const startX = e.clientX;
|
|
const startY = e.clientY;
|
|
const startW = rect.width;
|
|
const startH = rect.height;
|
|
|
|
winEl.classList.add('resizing');
|
|
resizeHandle.setPointerCapture(e.pointerId);
|
|
|
|
const onMove = (ev) => {
|
|
const dx = ev.clientX - startX;
|
|
const dy = ev.clientY - startY;
|
|
const minW = 360;
|
|
const minH = 240;
|
|
const maxW = Math.max(minW, window.innerWidth - rect.left - 8);
|
|
const maxH = Math.max(minH, window.innerHeight - rect.top - 40);
|
|
const nextW = clamp(startW + dx, minW, maxW);
|
|
const nextH = clamp(startH + dy, minH, maxH);
|
|
winEl.style.width = `${nextW}px`;
|
|
winEl.style.height = `${nextH}px`;
|
|
};
|
|
|
|
const onUp = () => {
|
|
winEl.classList.remove('resizing');
|
|
resizeHandle.removeEventListener('pointermove', onMove);
|
|
resizeHandle.removeEventListener('pointerup', onUp);
|
|
resizeHandle.removeEventListener('pointercancel', onUp);
|
|
};
|
|
|
|
resizeHandle.addEventListener('pointermove', onMove);
|
|
resizeHandle.addEventListener('pointerup', onUp);
|
|
resizeHandle.addEventListener('pointercancel', onUp);
|
|
});
|
|
}
|
|
|
|
minimizeBtn.addEventListener('click', () => minimizeWindow(id));
|
|
closeBtn.addEventListener('click', () => closeWindow(id));
|
|
}
|
|
|
|
function createTaskbarButton(id, title) {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'taskbar-btn';
|
|
btn.type = 'button';
|
|
btn.textContent = title;
|
|
btn.title = title;
|
|
btn.addEventListener('click', () => {
|
|
const entry = windowsById.get(id);
|
|
if (!entry) return;
|
|
|
|
if (entry.winEl.classList.contains('hidden')) {
|
|
restoreWindow(id);
|
|
return;
|
|
}
|
|
|
|
if (entry.active) {
|
|
minimizeWindow(id);
|
|
} else {
|
|
activateWindow(id);
|
|
setStatus(`${entry.title} focused`);
|
|
}
|
|
});
|
|
taskbarWindowsEl.appendChild(btn);
|
|
return btn;
|
|
}
|
|
|
|
function addWindowControls(winEl) {
|
|
const titlebar = winEl.querySelector('.window-titlebar');
|
|
const closeBtn = winEl.querySelector('.window-close');
|
|
if (!titlebar || !closeBtn) return;
|
|
|
|
const controls = document.createElement('div');
|
|
controls.className = 'window-controls';
|
|
|
|
const minimizeBtn = document.createElement('button');
|
|
minimizeBtn.className = 'window-minimize';
|
|
minimizeBtn.type = 'button';
|
|
minimizeBtn.textContent = '—';
|
|
minimizeBtn.setAttribute('aria-label', 'minimize');
|
|
|
|
closeBtn.classList.add('window-close');
|
|
|
|
controls.appendChild(minimizeBtn);
|
|
controls.appendChild(closeBtn);
|
|
titlebar.appendChild(controls);
|
|
|
|
const resizeHandle = document.createElement('div');
|
|
resizeHandle.className = 'window-resize-handle';
|
|
resizeHandle.setAttribute('aria-hidden', 'true');
|
|
winEl.appendChild(resizeHandle);
|
|
}
|
|
|
|
function openAppFromDescriptor(descriptor) {
|
|
if (!descriptor || !windowTemplate) return null;
|
|
|
|
hideStartMenu();
|
|
|
|
windowSeq += 1;
|
|
const id = `w${windowSeq}`;
|
|
|
|
const winEl = windowTemplate.cloneNode(true);
|
|
winEl.id = '';
|
|
winEl.dataset.windowId = id;
|
|
winEl.classList.remove('hidden');
|
|
|
|
const titleEl = winEl.querySelector('.window-title');
|
|
const contentEl = winEl.querySelector('.window-content');
|
|
|
|
if (titleEl) titleEl.textContent = descriptor.title || 'Window';
|
|
if (contentEl) contentEl.innerHTML = descriptor.html || '<p>Empty window</p>';
|
|
|
|
addWindowControls(winEl);
|
|
document.body.appendChild(winEl);
|
|
|
|
const offset = ((windowSeq - 1) % 8) * 22;
|
|
const left = (window.innerWidth - winEl.offsetWidth) / 2 + offset;
|
|
const top = 68 + offset;
|
|
|
|
if (descriptor.width) {
|
|
winEl.style.width = typeof descriptor.width === 'number' ? `${descriptor.width}px` : String(descriptor.width);
|
|
}
|
|
if (descriptor.height) {
|
|
winEl.style.height = typeof descriptor.height === 'number' ? `${descriptor.height}px` : String(descriptor.height);
|
|
}
|
|
|
|
setWindowPosition(winEl, left, top);
|
|
|
|
const taskBtn = createTaskbarButton(id, descriptor.title || 'Window');
|
|
|
|
windowsById.set(id, {
|
|
id,
|
|
title: descriptor.title || 'Window',
|
|
winEl,
|
|
taskBtn,
|
|
active: false,
|
|
});
|
|
|
|
bindWindowDrag(winEl);
|
|
activateWindow(id);
|
|
updateTaskbarButtons();
|
|
|
|
winEl.setAttribute('tabindex', '0');
|
|
|
|
winEl.focus();
|
|
setStatus(`${descriptor.title || 'Window'} opened`);
|
|
return winEl;
|
|
}
|
|
|
|
function openExplorerAtPath(path = '/', title = 'Explorer') {
|
|
const winEl = openAppFromDescriptor({
|
|
title,
|
|
html: '<div class="xp-loading">Loading...</div>',
|
|
});
|
|
if (winEl) wireExplorerInteractions(winEl, path);
|
|
}
|
|
|
|
function openSystemPanel(kind) {
|
|
const panels = {
|
|
'control-panel': {
|
|
title: 'Control Panel',
|
|
html: '<div class="xp-loading">⚙️ Control Panel is under construction.<br/>Try My Documents, Music, Paint, and Notepad from Start.</div>',
|
|
},
|
|
help: {
|
|
title: 'Help and Support',
|
|
html: '<div class="xp-loading">❓ Need help? Double-click folders/files in Explorer, drag desktop icons, and use the tray reset/show-desktop buttons.</div>',
|
|
},
|
|
run: {
|
|
title: 'Run',
|
|
html: '<div class="xp-loading">🏃 Run shortcut: open Internet, Notepad, Paint, or My Computer from Start.</div>',
|
|
},
|
|
};
|
|
|
|
const panel = panels[kind];
|
|
if (!panel) return;
|
|
openAppFromDescriptor(panel);
|
|
}
|
|
|
|
function performSystemAction(action) {
|
|
if (action === 'logoff') {
|
|
hideStartMenu();
|
|
clearSelection();
|
|
setStatus('Logged off (mock)');
|
|
return;
|
|
}
|
|
|
|
if (action === 'shutdown') {
|
|
hideStartMenu();
|
|
windowsById.forEach((entry) => {
|
|
entry.winEl.classList.add('hidden');
|
|
entry.active = false;
|
|
});
|
|
showDesktopBtn?.classList.add('active');
|
|
updateTaskbarButtons();
|
|
setStatus('It is now safe to turn off your imagination ✨');
|
|
return;
|
|
}
|
|
|
|
openSystemPanel(action);
|
|
}
|
|
|
|
function openApp(key) {
|
|
const app = ICON_APPS[key];
|
|
if (!app) return;
|
|
|
|
if (app.type === 'explorer') {
|
|
openExplorerAtPath(app.startPath || '/', app.title || 'Explorer');
|
|
return;
|
|
}
|
|
|
|
if (app.type === 'notepad') {
|
|
openNotepadFile(null, 'Untitled.txt', '');
|
|
return;
|
|
}
|
|
|
|
if (app.type === 'paint') {
|
|
openPaintApp();
|
|
return;
|
|
}
|
|
|
|
if (app.type === 'ie') {
|
|
openIeApp();
|
|
return;
|
|
}
|
|
|
|
openAppFromDescriptor(app);
|
|
}
|
|
|
|
function bindIconDrag(btn) {
|
|
btn.addEventListener('pointerdown', (e) => {
|
|
if (e.button !== 0) return;
|
|
e.stopPropagation();
|
|
|
|
if (e.shiftKey) {
|
|
toggleSelection(btn);
|
|
} else if (!selectedIcons.has(btn)) {
|
|
setSelection([btn]);
|
|
}
|
|
|
|
const dragSet = selectedIcons.size ? Array.from(selectedIcons) : [btn];
|
|
const starts = dragSet.map((icon) => ({ icon, ...parsePosition(icon) }));
|
|
const startX = e.clientX;
|
|
const startY = e.clientY;
|
|
let moved = false;
|
|
|
|
dragSet.forEach((icon) => icon.classList.add('dragging'));
|
|
btn.setPointerCapture(e.pointerId);
|
|
|
|
const onMove = (ev) => {
|
|
const dx = ev.clientX - startX;
|
|
const dy = ev.clientY - startY;
|
|
if (!moved && Math.hypot(dx, dy) > 3) moved = true;
|
|
if (!moved) return;
|
|
|
|
starts.forEach(({ icon, x, y }) => setIconPosition(icon, x + dx, y + dy));
|
|
setStatus(`Moving ${dragSet.length} item(s)`);
|
|
};
|
|
|
|
const onUp = () => {
|
|
dragSet.forEach((icon) => icon.classList.remove('dragging'));
|
|
btn.removeEventListener('pointermove', onMove);
|
|
btn.removeEventListener('pointerup', onUp);
|
|
btn.removeEventListener('pointercancel', onUp);
|
|
|
|
if (moved) {
|
|
btn.dataset.wasDragged = '1';
|
|
saveIconLayout();
|
|
setStatus('Desktop layout saved');
|
|
}
|
|
};
|
|
|
|
btn.addEventListener('pointermove', onMove);
|
|
btn.addEventListener('pointerup', onUp);
|
|
btn.addEventListener('pointercancel', onUp);
|
|
});
|
|
|
|
btn.addEventListener('click', (e) => {
|
|
if (btn.dataset.wasDragged === '1') {
|
|
btn.dataset.wasDragged = '0';
|
|
return;
|
|
}
|
|
|
|
if (e.shiftKey) {
|
|
toggleSelection(btn);
|
|
return;
|
|
}
|
|
|
|
if (!selectedIcons.has(btn) || selectedIcons.size !== 1) {
|
|
setSelection([btn]);
|
|
return;
|
|
}
|
|
});
|
|
|
|
btn.addEventListener('dblclick', (e) => {
|
|
e.stopPropagation();
|
|
setSelection([btn]);
|
|
openApp(btn.dataset.app);
|
|
});
|
|
|
|
btn.addEventListener('contextmenu', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
if (!selectedIcons.has(btn)) {
|
|
setSelection([btn]);
|
|
}
|
|
|
|
showContextMenu(e.clientX, e.clientY, [
|
|
{
|
|
label: 'Open',
|
|
action: () => openApp(btn.dataset.app),
|
|
},
|
|
{
|
|
label: 'Open All Selected',
|
|
disabled: selectedIcons.size <= 1,
|
|
action: () => {
|
|
Array.from(selectedIcons).forEach((icon) => openApp(icon.dataset.app));
|
|
},
|
|
},
|
|
'separator',
|
|
{
|
|
label: 'Reset Icon Layout',
|
|
action: () => {
|
|
clearSelection();
|
|
localStorage.removeItem(ICON_LAYOUT_KEY);
|
|
resetIconLayout();
|
|
},
|
|
},
|
|
{
|
|
label: 'Properties',
|
|
action: () => {
|
|
const appKey = btn.dataset.app || '';
|
|
const appTitle = ICON_APPS[appKey]?.title || btn.innerText || 'Desktop Item';
|
|
const pos = parsePosition(btn);
|
|
openPropertiesWindow(appTitle, [
|
|
{ label: 'Type', value: 'Desktop Shortcut' },
|
|
{ label: 'App Key', value: appKey || 'unknown' },
|
|
{ label: 'Position', value: `${pos.x}, ${pos.y}` },
|
|
]);
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
}
|
|
|
|
function showStartMenu() {
|
|
if (!startMenuEl) return;
|
|
startMenuEl.classList.remove('hidden');
|
|
startBtn?.classList.add('active');
|
|
}
|
|
|
|
function hideStartMenu() {
|
|
if (!startMenuEl) return;
|
|
startMenuEl.classList.add('hidden');
|
|
startBtn?.classList.remove('active');
|
|
}
|
|
|
|
function toggleStartMenu() {
|
|
if (!startMenuEl) return;
|
|
if (startMenuEl.classList.contains('hidden')) {
|
|
showStartMenu();
|
|
} else {
|
|
hideStartMenu();
|
|
}
|
|
}
|
|
|
|
function bindDesktopSelection() {
|
|
const selectionBox = ensureSelectionBox();
|
|
|
|
iconsEl.addEventListener('pointerdown', (e) => {
|
|
if (e.button !== 0) return;
|
|
if (e.target.closest('.desktop-icon')) return;
|
|
|
|
const rect = iconsEl.getBoundingClientRect();
|
|
const startX = clamp(e.clientX - rect.left, 0, iconsEl.clientWidth);
|
|
const startY = clamp(e.clientY - rect.top, 0, iconsEl.clientHeight);
|
|
let moved = false;
|
|
|
|
if (!e.shiftKey) clearSelection();
|
|
|
|
selectionBox.hidden = false;
|
|
selectionBox.style.left = `${startX}px`;
|
|
selectionBox.style.top = `${startY}px`;
|
|
selectionBox.style.width = '0px';
|
|
selectionBox.style.height = '0px';
|
|
|
|
iconsEl.setPointerCapture(e.pointerId);
|
|
|
|
const onMove = (ev) => {
|
|
const currentX = clamp(ev.clientX - rect.left, 0, iconsEl.clientWidth);
|
|
const currentY = clamp(ev.clientY - rect.top, 0, iconsEl.clientHeight);
|
|
const left = Math.min(startX, currentX);
|
|
const top = Math.min(startY, currentY);
|
|
const width = Math.abs(currentX - startX);
|
|
const height = Math.abs(currentY - startY);
|
|
|
|
moved = moved || width > 2 || height > 2;
|
|
|
|
selectionBox.style.left = `${left}px`;
|
|
selectionBox.style.top = `${top}px`;
|
|
selectionBox.style.width = `${width}px`;
|
|
selectionBox.style.height = `${height}px`;
|
|
|
|
if (!moved) return;
|
|
|
|
const boxRect = { left, top, right: left + width, bottom: top + height };
|
|
|
|
iconButtons.forEach((btn) => {
|
|
const hit = intersects(boxRect, toIconRect(btn));
|
|
if (hit) {
|
|
selectedIcons.add(btn);
|
|
btn.classList.add('selected');
|
|
} else if (!e.shiftKey) {
|
|
selectedIcons.delete(btn);
|
|
btn.classList.remove('selected');
|
|
}
|
|
});
|
|
|
|
if (selectedIcons.size) {
|
|
setStatus(`${selectedIcons.size} item(s) selected`);
|
|
}
|
|
};
|
|
|
|
const onUp = () => {
|
|
selectionBox.hidden = true;
|
|
iconsEl.removeEventListener('pointermove', onMove);
|
|
iconsEl.removeEventListener('pointerup', onUp);
|
|
iconsEl.removeEventListener('pointercancel', onUp);
|
|
|
|
if (!moved && !e.shiftKey) {
|
|
clearSelection();
|
|
setStatus(windowsById.size ? `${windowsById.size} window(s) open` : 'No windows open');
|
|
}
|
|
};
|
|
|
|
iconsEl.addEventListener('pointermove', onMove);
|
|
iconsEl.addEventListener('pointerup', onUp);
|
|
iconsEl.addEventListener('pointercancel', onUp);
|
|
});
|
|
}
|
|
|
|
iconButtons.forEach(bindIconDrag);
|
|
bindDesktopSelection();
|
|
|
|
window.addEventListener('resize', () => {
|
|
hideContextMenu();
|
|
|
|
iconButtons.forEach((btn) => {
|
|
const { x, y } = parsePosition(btn);
|
|
setIconPosition(btn, x, y);
|
|
});
|
|
|
|
windowsById.forEach(({ winEl }) => {
|
|
if (winEl.classList.contains('hidden')) return;
|
|
const rect = getWindowRect(winEl);
|
|
setWindowPosition(winEl, rect.left, rect.top);
|
|
});
|
|
|
|
saveIconLayout();
|
|
});
|
|
|
|
resetLayoutBtn?.addEventListener('click', () => {
|
|
clearSelection();
|
|
localStorage.removeItem(ICON_LAYOUT_KEY);
|
|
resetIconLayout();
|
|
});
|
|
|
|
startBtn?.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
hideContextMenu();
|
|
toggleStartMenu();
|
|
});
|
|
|
|
showDesktopBtn?.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
hideContextMenu();
|
|
toggleShowDesktop();
|
|
});
|
|
|
|
startMenuEl?.querySelectorAll('[data-open-app]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const appKey = btn.getAttribute('data-open-app');
|
|
if (appKey) openApp(appKey);
|
|
});
|
|
});
|
|
|
|
startMenuEl?.querySelectorAll('[data-open-path]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const path = btn.getAttribute('data-open-path') || '/';
|
|
const label = btn.textContent?.replace(/^[^\w]+\s*/, '').trim() || 'Explorer';
|
|
openExplorerAtPath(path, label);
|
|
});
|
|
});
|
|
|
|
startMenuEl?.querySelectorAll('[data-system-action]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const action = btn.getAttribute('data-system-action');
|
|
if (action) performSystemAction(action);
|
|
});
|
|
});
|
|
|
|
document.addEventListener('click', (e) => {
|
|
hideContextMenu();
|
|
if (!startMenuEl || startMenuEl.classList.contains('hidden')) return;
|
|
if (startMenuEl.contains(e.target)) return;
|
|
if (startBtn && startBtn.contains(e.target)) return;
|
|
hideStartMenu();
|
|
});
|
|
|
|
document.addEventListener('contextmenu', () => {
|
|
hideContextMenu();
|
|
});
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
hideContextMenu();
|
|
hideStartMenu();
|
|
}
|
|
});
|
|
|
|
function tickClock() {
|
|
const d = new Date();
|
|
const timeText = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
const dateText = d.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric', year: 'numeric' });
|
|
clockEl.innerHTML = `<span class="clock-time">${timeText}</span><span class="clock-date">${dateText}</span>`;
|
|
}
|
|
|
|
applyInitialLayout();
|
|
setInterval(tickClock, 1000);
|
|
tickClock();
|