ZDDC/mdedit/js/file-tree.js
ZDDC 8c2e65e4a2 fix(mdedit): two-line ZDDC tree display + dark-mode editor contrast
Two issues from one session:

* File tree: ZDDC-conforming filenames render as a single line
  even though the JS already produced two-div markup (filename-main +
  filename-secondary). Cause: .tree-row__label was display:flex
  (row-direction), so the two divs laid out side-by-side. Fix: wrap
  each label's text in a new .tree-row__name span styled
  flex-direction:column. Both file and folder code paths use the
  same wrapper now; non-ZDDC entries collapse to a single
  .filename-main line so typography stays consistent across the tree.
  Tested by injecting a ZDDC filename into a mock directory and
  asserting filename-secondary's bounding-box top is below
  filename-main's bottom.

* Toast UI Editor was unreadable in dark mode. Toast UI ships with
  light-only chrome; its .toastui-editor-md-container has color #222
  on a transparent bg, so when mdedit's dark theme rendered the
  surrounding pane in #1e1e1e the editor text fell on near-black
  background → effectively invisible. Fix: add CSS overrides in
  mdedit/css/editor.css that target the editor's load-bearing
  surfaces (md-container, md-preview, ww-container, ProseMirror,
  toolbar, mode-switch tabs, popups) and apply var(--bg) /
  var(--text). Toolbar icons get a filter:invert(0.85) hue-rotate
  to flip the sprite-baked dark glyphs. Both manual override
  (data-theme="dark") and OS-pref auto fallback (prefers-color-scheme)
  are covered. Tested by computing contrast ratios on every editor
  surface in dark mode — all came in at 10:1+ (well above WCAG AA's
  4.5:1).

Embedded snapshots refreshed to current main HEAD's dev build label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:09:46 -05:00

869 lines
36 KiB
JavaScript

/**
* File tree rendering and navigation
*/
// Cache for lazily loaded CDN libraries
const loadedLibraries = new Map();
/**
* Lazily load a script from CDN. Returns a promise that resolves when loaded.
* Caches the promise so subsequent calls return immediately.
*/
function loadLibrary(url) {
if (loadedLibraries.has(url)) return loadedLibraries.get(url);
const promise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.onload = resolve;
script.onerror = () => reject(new Error(`Failed to load library: ${url}`));
document.head.appendChild(script);
});
loadedLibraries.set(url, promise);
return promise;
}
/**
* Render the file tree in the UI
*/
/**
* Create action buttons for file/directory items
* @param {string} filePath - Full path of the file/dir
* @param {string} type - 'file' or 'directory'
*/
function createActionButtons(filePath, type) {
const actionsDiv = document.createElement('div');
actionsDiv.className = 'tree-actions';
// Server mode is read-only: no rename, delete, or new-file actions.
if (serverSourceMode) return actionsDiv;
if (type === 'directory') {
// Directory: + (new file) + ✕ (delete)
const newFileBtn = document.createElement('button');
newFileBtn.className = 'tree-btn';
newFileBtn.setAttribute('title', 'New file');
newFileBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>';
newFileBtn.onclick = (e) => {
e.stopPropagation();
createNewFile(filePath);
};
const deleteBtn = document.createElement('button');
deleteBtn.className = 'tree-btn tree-btn--danger';
deleteBtn.setAttribute('title', 'Delete');
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
deleteBtn.onclick = (e) => {
e.stopPropagation();
deleteEntry(filePath, true);
};
actionsDiv.appendChild(newFileBtn);
actionsDiv.appendChild(deleteBtn);
} else {
// File: ✎ (rename) + ✕ (delete)
const renameBtn = document.createElement('button');
renameBtn.className = 'tree-btn';
renameBtn.setAttribute('title', 'Rename');
renameBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>';
renameBtn.onclick = (e) => {
e.stopPropagation();
renameEntry(filePath, false);
};
const deleteBtn = document.createElement('button');
deleteBtn.className = 'tree-btn tree-btn--danger';
deleteBtn.setAttribute('title', 'Delete');
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
deleteBtn.onclick = (e) => {
e.stopPropagation();
deleteEntry(filePath, false);
};
actionsDiv.appendChild(renameBtn);
actionsDiv.appendChild(deleteBtn);
}
return actionsDiv;
}
function renderFileTree() {
const fileTreeElement = document.getElementById('file-tree');
if (!fileTreeElement) return;
fileTreeElement.innerHTML = '';
// Always show scratchpad at top
const scratchpadElement = document.createElement('div');
scratchpadElement.className = 'file-item tree-row px-2 py-1 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-gray-800 border-b border-gray-200 dark:border-gray-700 mb-2';
scratchpadElement.dataset.type = 'file';
scratchpadElement.dataset.path = SCRATCHPAD_ID;
scratchpadElement.dataset.name = 'Scratchpad';
const scratchLabel = document.createElement('span');
scratchLabel.className = 'tree-row__label';
scratchLabel.innerHTML = '<span class="tree-row__name"><div class="filename-main">📝 Scratchpad</div><div class="filename-secondary">Quick notes — no directory needed</div></span>';
scratchpadElement.appendChild(scratchLabel);
const scratchActions = document.createElement('div');
scratchActions.className = 'tree-actions tree-actions--always';
const scratchDownloadBtn = document.createElement('button');
scratchDownloadBtn.id = 'scratchpad-download-btn';
scratchDownloadBtn.className = 'tree-btn';
scratchDownloadBtn.title = 'Download scratchpad as a Markdown file';
scratchDownloadBtn.setAttribute('aria-label', 'Download scratchpad');
scratchDownloadBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M5 21h14"/></svg>';
scratchDownloadBtn.disabled = true;
scratchDownloadBtn.classList.add('is-disabled');
scratchDownloadBtn.onclick = (e) => {
e.stopPropagation();
if (scratchDownloadBtn.disabled) return;
downloadScratchpad();
};
scratchActions.appendChild(scratchDownloadBtn);
scratchpadElement.appendChild(scratchActions);
scratchpadElement.addEventListener('click', (event) => {
event.stopPropagation();
openScratchpad();
document.querySelectorAll('.file-item').forEach(el => el.classList.remove('active-file'));
scratchpadElement.classList.add('active-file');
updateScratchpadDownloadState();
});
fileTreeElement.appendChild(scratchpadElement);
// Sync button state with current scratchpad content (re-renders preserve it)
updateScratchpadDownloadState();
function createFileTreeHTML(directory, parentElement, path = '') {
if (!directory || !directory.entries) return;
// Sort entries: files first, then directories, alphabetically
const sortedEntries = Object.entries(directory.entries).sort((a, b) => {
const [nameA, itemA] = a;
const [nameB, itemB] = b;
if (itemA.type !== itemB.type) {
return itemA.type === 'file' ? -1 : 1;
}
return nameA.localeCompare(nameB);
});
for (const [name, item] of sortedEntries) {
if (item.type === 'directory') {
const dirElement = document.createElement('div');
dirElement.className = 'directory-item tree-row px-2 py-1 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-gray-800 collapsed';
dirElement.dataset.type = 'directory';
const currentPath = path ? `${path}/${name}` : name;
dirElement.dataset.path = currentPath;
const dirIcon = document.createElement('span');
dirIcon.className = 'dir-icon mr-1';
dirIcon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>';
const dirName = document.createElement('span');
dirName.className = 'tree-row__name';
const parsedFolder = zddc.parseFolder(name);
if (parsedFolder && parsedFolder.valid) {
const meta = `${parsedFolder.trackingNumber} (${parsedFolder.status}) — ${parsedFolder.date}`;
dirName.innerHTML = `<div class="filename-main">📁 ${escapeHtml(parsedFolder.title)}</div><div class="filename-secondary">${escapeHtml(meta)}</div>`;
} else {
// Non-ZDDC folder: still wrap in filename-main so
// typography matches the two-line entries (same font
// size + weight; just no secondary line).
dirName.innerHTML = `<div class="filename-main">📁 ${escapeHtml(name)}</div>`;
}
const dirLabel = document.createElement('span');
dirLabel.className = 'tree-row__label';
dirLabel.appendChild(dirIcon);
dirLabel.appendChild(dirName);
const dirActions = createActionButtons(currentPath, 'directory');
dirElement.appendChild(dirLabel);
dirElement.appendChild(dirActions);
parentElement.appendChild(dirElement);
const contentsElement = document.createElement('div');
contentsElement.className = 'directory-contents ml-4';
contentsElement.style.display = 'none';
parentElement.appendChild(contentsElement);
dirElement.addEventListener('click', (event) => {
event.stopPropagation();
dirElement.classList.toggle('collapsed');
const contents = dirElement.nextElementSibling;
if (contents && contents.classList.contains('directory-contents')) {
contents.style.display = dirElement.classList.contains('collapsed') ? 'none' : 'block';
}
});
createFileTreeHTML(item, contentsElement, currentPath);
} else if (item.type === 'file') {
const fileElement = document.createElement('div');
fileElement.className = 'file-item tree-row px-2 py-1 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-gray-800';
fileElement.dataset.type = 'file';
const filePath = path ? `${path}/${name}` : name;
fileElement.dataset.path = filePath;
fileElement.dataset.name = name;
const fileIcon = getFileTypeIcon(name);
// Build the inner two-line text inside a tree-row__name
// wrapper (column-flex). ZDDC-conforming filenames split
// into title + meta; "Title - filename.ext" pattern uses
// the dash as the same split. Plain names get a single
// line via filename-main only — same wrapper, just no
// secondary div, so the layout stays consistent.
let fileNameInner;
const parsed = zddc.parseFilename(name);
if (parsed && parsed.valid) {
const titleDisplay = escapeHtml(parsed.title);
const metaDisplay = escapeHtml(`${parsed.trackingNumber}_${parsed.revision} (${parsed.status})`);
fileNameInner = `<div class="filename-main">${fileIcon} ${titleDisplay}</div><div class="filename-secondary">${metaDisplay}</div>`;
} else if (name.includes(' - ')) {
const dashIdx = name.lastIndexOf(' - ');
const secondary = escapeHtml(name.substring(0, dashIdx));
const primary = escapeHtml(name.substring(dashIdx + 3).replace(/\.[^.]+$/, ''));
fileNameInner = `<div class="filename-main">${fileIcon} ${primary}</div><div class="filename-secondary">${secondary}</div>`;
} else {
fileNameInner = `<div class="filename-main">${fileIcon} ${escapeHtml(name)}</div>`;
}
const fileLabel = document.createElement('span');
fileLabel.className = 'tree-row__label';
fileLabel.innerHTML = `<span class="tree-row__name">${fileNameInner}</span>`;
const fileActions = createActionButtons(filePath, 'file');
fileElement.innerHTML = '';
fileElement.appendChild(fileLabel);
fileElement.appendChild(fileActions);
fileElement.addEventListener('click', (event) => {
event.stopPropagation();
handleFileClick(item.handle, filePath, fileElement);
});
parentElement.appendChild(fileElement);
}
}
}
createFileTreeHTML(fileTree, fileTreeElement);
}
/**
* Handle click on a file in the file tree
* @param {FileSystemFileHandle} fileHandle - The file handle
* @param {string} filePath - Path of the file
* @param {HTMLElement} fileElement - The clicked element
*/
async function handleFileClick(fileHandle, filePath, fileElement) {
try {
currentFileHandle = fileHandle;
// Remove active class from all file items
const allFileItems = document.querySelectorAll('.file-item');
allFileItems.forEach(item => {
item.classList.remove('active-file');
item.style.backgroundColor = '';
item.style.color = '';
});
// Add active class to clicked file
fileElement.classList.add('active-file');
fileElement.style.backgroundColor = '#3b82f6';
fileElement.style.color = 'white';
await displayFileContent(fileHandle, filePath);
} catch (error) {
console.error('Error handling file click:', error);
alert(`Error opening file: ${error.message}`);
}
}
/**
* Display file content in main area
* @param {FileSystemFileHandle} fileHandle - File handle
* @param {string} filePath - Path of the file
*/
async function displayFileContent(fileHandle, filePath) {
try {
currentFileHandle = fileHandle;
const file = await fileHandle.getFile();
const fileName = file.name;
const lastModified = file.lastModified;
document.getElementById('welcome-screen').classList.add('hidden');
document.getElementById('content-container').classList.remove('hidden');
const lower = fileName.toLowerCase();
const lastDot = lower.lastIndexOf('.');
const ext = lastDot >= 0 ? lower.substring(lastDot + 1) : '';
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'];
const isImage = imageExtensions.some(e => lower.endsWith(e));
const isTiff = window.zddc && window.zddc.preview && window.zddc.preview.isTiff(ext);
const isZip = lower.endsWith('.zip');
const isHtml = lower.endsWith('.html') || lower.endsWith('.htm');
const isDocx = lower.endsWith('.docx');
const isXlsx = lower.endsWith('.xlsx') || lower.endsWith('.xls');
const isPdf = lower.endsWith('.pdf');
if (isImage) {
displayImagePreview(file, filePath, fileName, fileHandle, lastModified);
} else if (isTiff) {
displayTiffPreview(file, filePath, fileName, fileHandle, lastModified);
} else if (isZip) {
displayZipPreview(file, filePath, fileName, fileHandle, lastModified);
} else if (isHtml) {
displayHtmlPreview(file, filePath, fileName, fileHandle, lastModified);
} else if (isDocx) {
displayDocxPreview(file, filePath, fileName, fileHandle, lastModified);
} else if (isXlsx) {
displayXlsxPreview(file, filePath, fileName, fileHandle, lastModified);
} else if (isPdf) {
displayPdfPreview(file, filePath, fileName, fileHandle, lastModified);
} else {
const content = await file.text();
if (fileName.toLowerCase().endsWith('.md')) {
initializeEditor(content, true, filePath, fileName, fileHandle, lastModified);
} else {
initializeEditor(content, false, filePath, fileName, fileHandle, lastModified);
}
}
} catch (error) {
console.error('Error displaying file content:', error);
alert(`Error opening file: ${error.message}`);
}
}
/**
* Display image preview
*/
async function displayImagePreview(file, filePath, fileName, fileHandle, lastModified) {
const contentContainer = document.getElementById('content-container');
if (!contentContainer) {
alert('Error: content-container element not found!');
return;
}
document.querySelectorAll('.file-view-container').forEach(container => {
container.style.display = 'none';
});
if (editorInstances.has(filePath)) {
const existingInstance = editorInstances.get(filePath);
if (existingInstance.fileViewContainer) {
existingInstance.fileViewContainer.style.display = 'flex';
}
return;
}
const fileViewContainer = document.createElement('div');
fileViewContainer.className = 'file-view-container flex flex-col h-full';
const fileHeader = document.createElement('div');
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
const fileTitle = document.createElement('span');
fileTitle.textContent = fileName || 'No file selected';
fileHeader.appendChild(fileTitle);
fileViewContainer.appendChild(fileHeader);
const imageContainer = document.createElement('div');
imageContainer.className = 'image-preview-container flex-1 overflow-auto p-4';
const imageElement = document.createElement('img');
imageElement.className = 'image-preview';
imageElement.alt = fileName;
const objectUrl = URL.createObjectURL(file);
imageElement.src = objectUrl;
imageContainer.appendChild(imageElement);
fileViewContainer.appendChild(imageContainer);
contentContainer.appendChild(fileViewContainer);
const instanceData = {
fileViewContainer: fileViewContainer,
fileHandle: fileHandle,
lastModified: lastModified,
isDirty: false,
objectUrl: objectUrl
};
editorInstances.set(filePath, instanceData);
}
/**
* Display TIFF preview using shared zddc.preview.renderTiff (UTIF.js + canvas).
*/
async function displayTiffPreview(file, filePath, fileName, fileHandle, lastModified) {
const contentContainer = document.getElementById('content-container');
if (!contentContainer) return;
document.querySelectorAll('.file-view-container').forEach(c => { c.style.display = 'none'; });
if (editorInstances.has(filePath)) {
const existing = editorInstances.get(filePath);
if (existing.fileViewContainer) existing.fileViewContainer.style.display = 'flex';
return;
}
const fileViewContainer = document.createElement('div');
fileViewContainer.className = 'file-view-container flex flex-col h-full';
const fileHeader = document.createElement('div');
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
const fileTitle = document.createElement('span');
fileTitle.textContent = fileName || 'No file selected';
fileHeader.appendChild(fileTitle);
fileViewContainer.appendChild(fileHeader);
const tiffContainer = document.createElement('div');
tiffContainer.className = 'flex-1 min-h-0';
tiffContainer.style.display = 'flex';
tiffContainer.style.flexDirection = 'column';
fileViewContainer.appendChild(tiffContainer);
contentContainer.appendChild(fileViewContainer);
try {
const arrayBuffer = await file.arrayBuffer();
await window.zddc.preview.renderTiff(document, tiffContainer, arrayBuffer, { fileName: fileName });
} catch (err) {
console.error('Error rendering TIFF:', err);
tiffContainer.textContent = 'Error rendering TIFF: ' + (err.message || err);
}
editorInstances.set(filePath, { fileViewContainer, fileHandle, lastModified, isDirty: false });
}
/**
* Display ZIP listing using shared zddc.preview.renderZipListing.
*/
async function displayZipPreview(file, filePath, fileName, fileHandle, lastModified) {
const contentContainer = document.getElementById('content-container');
if (!contentContainer) return;
document.querySelectorAll('.file-view-container').forEach(c => { c.style.display = 'none'; });
if (editorInstances.has(filePath)) {
const existing = editorInstances.get(filePath);
if (existing.fileViewContainer) existing.fileViewContainer.style.display = 'flex';
return;
}
const fileViewContainer = document.createElement('div');
fileViewContainer.className = 'file-view-container flex flex-col h-full';
const fileHeader = document.createElement('div');
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
const fileTitle = document.createElement('span');
fileTitle.textContent = fileName || 'No file selected';
fileHeader.appendChild(fileTitle);
fileViewContainer.appendChild(fileHeader);
const zipContainer = document.createElement('div');
zipContainer.className = 'flex-1 min-h-0';
zipContainer.style.display = 'flex';
zipContainer.style.flexDirection = 'column';
fileViewContainer.appendChild(zipContainer);
contentContainer.appendChild(fileViewContainer);
try {
const arrayBuffer = await file.arrayBuffer();
await window.zddc.preview.renderZipListing(document, zipContainer, arrayBuffer, { fileName: fileName });
} catch (err) {
console.error('Error rendering ZIP listing:', err);
zipContainer.textContent = 'Error reading ZIP: ' + (err.message || err);
}
editorInstances.set(filePath, { fileViewContainer, fileHandle, lastModified, isDirty: false });
}
/**
* Display HTML preview in sandboxed iframe
*/
async function displayHtmlPreview(file, filePath, fileName, fileHandle, lastModified) {
const contentContainer = document.getElementById('content-container');
if (!contentContainer) {
alert('Error: content-container element not found!');
return;
}
document.querySelectorAll('.file-view-container').forEach(container => {
container.style.display = 'none';
});
if (editorInstances.has(filePath)) {
const existingInstance = editorInstances.get(filePath);
if (existingInstance.fileViewContainer) {
existingInstance.fileViewContainer.style.display = 'flex';
}
return;
}
const htmlContent = await file.text();
const fileViewContainer = document.createElement('div');
fileViewContainer.className = 'file-view-container flex flex-col h-full';
const fileHeader = document.createElement('div');
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
const fileTitle = document.createElement('span');
fileTitle.textContent = fileName || 'No file selected';
fileHeader.appendChild(fileTitle);
fileViewContainer.appendChild(fileHeader);
const htmlContainer = document.createElement('div');
htmlContainer.className = 'html-preview-container flex-1 overflow-hidden';
const iframe = document.createElement('iframe');
iframe.className = 'html-preview-iframe w-full h-full border-0';
iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts allow-forms allow-popups allow-modals');
iframe.setAttribute('loading', 'lazy');
iframe.srcdoc = htmlContent;
htmlContainer.appendChild(iframe);
fileViewContainer.appendChild(htmlContainer);
contentContainer.appendChild(fileViewContainer);
const instanceData = {
fileViewContainer: fileViewContainer,
fileHandle: fileHandle,
lastModified: lastModified,
isDirty: false,
iframe: iframe
};
editorInstances.set(filePath, instanceData);
iframe.addEventListener('load', () => {
try {
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
if (iframeDoc) {
iframeDoc.addEventListener('click', function (e) {
const link = e.target.closest('a');
if (link && link.getAttribute('href')) {
const href = link.getAttribute('href');
if (href.startsWith('#')) {
e.preventDefault();
const targetId = href.substring(1);
const targetElement = iframeDoc.getElementById(targetId);
if (targetElement) {
targetElement.scrollIntoView({ behavior: 'smooth' });
}
}
}
});
}
} catch (error) {
if (DEBUG) console.log('Cannot access iframe content for navigation handling:', error);
}
});
}
/**
* Display DOCX preview in main content area
*/
async function displayDocxPreview(file, filePath, fileName, fileHandle, lastModified) {
const contentContainer = document.getElementById('content-container');
if (!contentContainer) {
alert('Error: content-container element not found!');
return;
}
document.querySelectorAll('.file-view-container').forEach(container => {
container.style.display = 'none';
});
if (editorInstances.has(filePath)) {
const existingInstance = editorInstances.get(filePath);
if (existingInstance.fileViewContainer) {
existingInstance.fileViewContainer.style.display = 'flex';
}
return;
}
const fileViewContainer = document.createElement('div');
fileViewContainer.className = 'file-view-container flex flex-col h-full';
const fileHeader = document.createElement('div');
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
const fileTitle = document.createElement('span');
fileTitle.textContent = fileName || 'No file selected';
fileHeader.appendChild(fileTitle);
fileViewContainer.appendChild(fileHeader);
const docxContainer = document.createElement('div');
docxContainer.className = 'flex-1 overflow-auto p-4';
docxContainer.innerHTML = '<div style="text-align:center;padding:2rem;color:#666;">Loading preview...</div>';
fileViewContainer.appendChild(docxContainer);
contentContainer.appendChild(fileViewContainer);
const instanceData = {
fileViewContainer: fileViewContainer,
fileHandle: fileHandle,
lastModified: lastModified,
isDirty: false
};
editorInstances.set(filePath, instanceData);
try {
await loadLibrary('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js');
await loadLibrary('https://cdn.jsdelivr.net/npm/docx-preview@latest/dist/docx-preview.min.js');
const arrayBuffer = await file.arrayBuffer();
docxContainer.innerHTML = '';
await window.docx.renderAsync(arrayBuffer, docxContainer);
} catch (err) {
console.error('Error rendering DOCX:', err);
docxContainer.innerHTML = `<div style="text-align:center;padding:2rem;color:#c00;">Error rendering DOCX: ${err.message}</div>`;
}
}
/**
* Display XLSX/XLS preview in main content area
*/
async function displayXlsxPreview(file, filePath, fileName, fileHandle, lastModified) {
const contentContainer = document.getElementById('content-container');
if (!contentContainer) {
alert('Error: content-container element not found!');
return;
}
document.querySelectorAll('.file-view-container').forEach(container => {
container.style.display = 'none';
});
if (editorInstances.has(filePath)) {
const existingInstance = editorInstances.get(filePath);
if (existingInstance.fileViewContainer) {
existingInstance.fileViewContainer.style.display = 'flex';
}
return;
}
const fileViewContainer = document.createElement('div');
fileViewContainer.className = 'file-view-container flex flex-col h-full';
const fileHeader = document.createElement('div');
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
const fileTitle = document.createElement('span');
fileTitle.textContent = fileName || 'No file selected';
fileHeader.appendChild(fileTitle);
fileViewContainer.appendChild(fileHeader);
const xlsxContainer = document.createElement('div');
xlsxContainer.className = 'flex-1 overflow-auto';
xlsxContainer.innerHTML = '<div style="text-align:center;padding:2rem;color:#666;">Loading preview...</div>';
fileViewContainer.appendChild(xlsxContainer);
contentContainer.appendChild(fileViewContainer);
const instanceData = {
fileViewContainer: fileViewContainer,
fileHandle: fileHandle,
lastModified: lastModified,
isDirty: false
};
editorInstances.set(filePath, instanceData);
try {
await loadLibrary('https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js');
const arrayBuffer = await file.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
xlsxContainer.innerHTML = '';
if (workbook.SheetNames.length > 1) {
const tabs = document.createElement('div');
tabs.style.cssText = 'display:flex;gap:0;border-bottom:1px solid #ddd;background:#f5f5f5;';
const tableArea = document.createElement('div');
tableArea.className = 'flex-1 overflow-auto';
workbook.SheetNames.forEach((name, i) => {
const tab = document.createElement('button');
tab.textContent = name;
tab.style.cssText = 'padding:0.4rem 1rem;cursor:pointer;border:1px solid transparent;border-bottom:none;font-size:0.85rem;background:transparent;';
if (i === 0) tab.style.cssText += 'background:white;border-color:#ddd;border-bottom-color:white;margin-bottom:-1px;font-weight:500;';
tab.onclick = () => {
tabs.querySelectorAll('button').forEach(t => { t.style.background = 'transparent'; t.style.borderColor = 'transparent'; t.style.fontWeight = 'normal'; });
tab.style.cssText = 'padding:0.4rem 1rem;cursor:pointer;border:1px solid #ddd;border-bottom-color:white;font-size:0.85rem;background:white;margin-bottom:-1px;font-weight:500;';
renderXlsxSheet(workbook, name, tableArea);
};
tabs.appendChild(tab);
});
xlsxContainer.appendChild(tabs);
xlsxContainer.appendChild(tableArea);
renderXlsxSheet(workbook, workbook.SheetNames[0], tableArea);
} else {
renderXlsxSheet(workbook, workbook.SheetNames[0], xlsxContainer);
}
} catch (err) {
console.error('Error rendering XLSX:', err);
xlsxContainer.innerHTML = `<div style="text-align:center;padding:2rem;color:#c00;">Error rendering spreadsheet: ${err.message}</div>`;
}
}
/**
* Render a single XLSX sheet as an HTML table
*/
function renderXlsxSheet(workbook, sheetName, container) {
const sheet = workbook.Sheets[sheetName];
const html = XLSX.utils.sheet_to_html(sheet, { editable: false });
container.innerHTML = html;
const table = container.querySelector('table');
if (table) {
table.style.cssText = 'border-collapse:collapse;width:100%;font-size:0.85rem;';
table.querySelectorAll('th,td').forEach(cell => {
cell.style.cssText = 'border:1px solid #ddd;padding:0.35rem 0.5rem;text-align:left;white-space:nowrap;';
});
table.querySelectorAll('th').forEach(th => {
th.style.background = '#f0f0f0';
th.style.fontWeight = '600';
});
}
}
/**
* Display PDF preview using browser's built-in PDF viewer
*/
async function displayPdfPreview(file, filePath, fileName, fileHandle, lastModified) {
const contentContainer = document.getElementById('content-container');
if (!contentContainer) return;
document.querySelectorAll('.file-view-container').forEach(container => {
container.style.display = 'none';
});
if (editorInstances.has(filePath)) {
const existingInstance = editorInstances.get(filePath);
if (existingInstance.fileViewContainer) {
existingInstance.fileViewContainer.style.display = 'flex';
}
return;
}
const fileViewContainer = document.createElement('div');
fileViewContainer.className = 'file-view-container flex flex-col h-full';
const fileHeader = document.createElement('div');
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
const fileTitle = document.createElement('span');
fileTitle.textContent = fileName;
fileHeader.appendChild(fileTitle);
fileViewContainer.appendChild(fileHeader);
const pdfContainer = document.createElement('div');
pdfContainer.className = 'flex-1 overflow-hidden';
const objectUrl = URL.createObjectURL(file);
const iframe = document.createElement('iframe');
iframe.className = 'w-full h-full border-0';
iframe.src = objectUrl;
iframe.setAttribute('title', fileName);
pdfContainer.appendChild(iframe);
fileViewContainer.appendChild(pdfContainer);
contentContainer.appendChild(fileViewContainer);
editorInstances.set(filePath, {
fileViewContainer,
fileHandle,
lastModified,
isDirty: false,
objectUrl
});
}
/**
* Update status bar counts
*/
function updateStatusCounts(folderCount, fileCount) {
const folderCountElement = document.getElementById('folder-count');
const fileCountElement = document.getElementById('file-count');
if (folderCountElement) {
folderCountElement.textContent = `${folderCount} folder${folderCount !== 1 ? 's' : ''}`;
}
if (fileCountElement) {
fileCountElement.textContent = `${fileCount} file${fileCount !== 1 ? 's' : ''}`;
}
updateUnsavedCount();
}
/**
* Update unsaved count in status bar
*/
function updateUnsavedCount() {
const unsavedCountElement = document.getElementById('unsaved-count');
if (!unsavedCountElement) return;
let dirtyCount = 0;
editorInstances.forEach(instance => {
if (instance.isDirty) {
dirtyCount++;
}
});
unsavedCountElement.textContent = `${dirtyCount} unsaved`;
if (dirtyCount > 0) {
unsavedCountElement.classList.add('text-amber-500', 'font-medium');
} else {
unsavedCountElement.classList.remove('text-amber-500', 'font-medium');
}
}
/**
* Update file dirty status indicator in tree
*/
function updateFileDirtyStatus(filePath, isDirty) {
const fileElement = document.querySelector(`.file-item[data-path="${filePath}"]`);
if (!fileElement) return;
if (isDirty) {
if (!fileElement.querySelector('.dirty-indicator')) {
const indicator = document.createElement('span');
indicator.className = 'dirty-indicator ml-1 text-amber-500 font-bold';
indicator.textContent = '●';
fileElement.appendChild(indicator);
}
fileElement.classList.add('is-dirty');
} else {
const indicator = fileElement.querySelector('.dirty-indicator');
if (indicator) {
fileElement.removeChild(indicator);
}
fileElement.classList.remove('is-dirty');
}
}