From a7572d682db557563f24ff5f5873267639c19cc6 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Fri, 17 Jul 2026 14:52:19 +0530 Subject: [PATCH 01/40] Add document management sidebar --- README.md | 2 +- index.html | 131 +++- script.js | 1362 +++++++++++++++++++++++++++++++++++++++-- styles.css | 575 +++++++++++++++++ wiki/Configuration.md | 5 +- wiki/Features.md | 18 +- wiki/Usage-Guide.md | 19 +- 7 files changed, 2049 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 29bfabc6..91168de7 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ For the full feature list, details, limitations, and privacy notes, see the [fea ## Markdown Editing and Live Preview - Write plain Markdown in a focused editor while the live preview renders GitHub-Flavored Markdown, syntax highlighting, math, alerts, footnotes, tables, task lists, and sanitized HTML. -- Work with multiple Markdown documents in tabs, rename or duplicate tabs, import local Markdown files, and keep normal workspace state in browser storage. +- Organize up to 50 Markdown documents in a responsive left sidebar with workspaces, one-level folders, search, Recent, Favorites, move actions, and automatic migration into Default Workspace. The tab strip remains available for quick switching and reordering. - Use WYSIWYG-style toolbar helpers for common Markdown syntax while keeping full control of the plain-text Markdown source. - Preview large documents with debounced rendering and a background worker so typing stays responsive. diff --git a/index.html b/index.html index a2a44362..97db8045 100644 --- a/index.html +++ b/index.html @@ -133,6 +133,9 @@
+

Markdown Viewer - Online Markdown Editor with Live Preview

@@ -371,16 +374,74 @@

Menu

- -
-
- - -
+
+ + + +
+ +
+
+ + +
- + +
+
+
+ + + + + + diff --git a/script.js b/script.js index 610ae3eb..bf398b0f 100644 --- a/script.js +++ b/script.js @@ -4,7 +4,8 @@ document.addEventListener("DOMContentLoaded", async function () { 'markdownViewerGlobalState', 'markdownViewerTabs', 'markdownViewerActiveTab', - 'markdownViewerUntitledCounter' + 'markdownViewerUntitledCounter', + 'markdownViewerDocumentOrganization' ]); function isPrivateStorageMode() { @@ -30,6 +31,7 @@ document.addEventListener("DOMContentLoaded", async function () { 'markdownViewerTabs', 'markdownViewerActiveTab', 'markdownViewerUntitledCounter', + 'markdownViewerDocumentOrganization', 'find-replace-docked', 'app-lang' ]; @@ -395,7 +397,7 @@ document.addEventListener("DOMContentLoaded", async function () { function stripTemporaryTabs(tabsArr) { return (tabsArr || []).filter(function(tab) { - return !isShareSnapshotTab(tab); + return !isShareSnapshotTab(tab) && tab.temporary !== true; }); } @@ -637,7 +639,8 @@ document.addEventListener("DOMContentLoaded", async function () { markdownViewerGlobalState: '{}', markdownViewerTabs: '[]', markdownViewerActiveTab: '', - markdownViewerUntitledCounter: '0' + markdownViewerUntitledCounter: '0', + markdownViewerDocumentOrganization: '{}' }; await Promise.all(Object.keys(neutralinoValues).map(function(key) { return Neutralino.storage.setData(key, neutralinoValues[key]).catch(function(err) { @@ -2411,11 +2414,22 @@ document.addEventListener("DOMContentLoaded", async function () { const STORAGE_KEY = 'markdownViewerTabs'; const ACTIVE_TAB_KEY = 'markdownViewerActiveTab'; const UNTITLED_COUNTER_KEY = 'markdownViewerUntitledCounter'; + const DOCUMENT_ORGANIZATION_KEY = 'markdownViewerDocumentOrganization'; + const DOCUMENT_ORGANIZATION_VERSION = 1; + const MAX_DOCUMENTS = 50; + const DEFAULT_WORKSPACE_ID = 'workspace_default'; + const SIDEBAR_MIN_WIDTH = 220; + const SIDEBAR_MAX_WIDTH = 420; let tabs = []; let activeTabId = null; let draggedTabId = null; let saveTabStateTimeout = null; let untitledCounter = 0; + let documentOrganization = null; + let selectedDocumentId = null; + let documentSidebarSearch = ''; + let documentSidebarInitialized = false; + let isDocumentSidebarResizing = false; let liveCollaboration = null; let liveShareUiReady = false; let liveCollaborationModulesPromise = null; @@ -2424,6 +2438,1221 @@ document.addEventListener("DOMContentLoaded", async function () { const LIVE_REVIEW_EDIT_ORIGIN = Symbol("markdown-viewer-live-review-local-edit"); const LIVE_REVIEW_RELAY_ORIGIN = Symbol("markdown-viewer-live-review-relay"); + function createDocumentEntityId(prefix) { + if (window.crypto && typeof window.crypto.randomUUID === 'function') { + return prefix + '_' + window.crypto.randomUUID(); + } + return prefix + '_' + Date.now() + '_' + Math.random().toString(36).slice(2, 9); + } + + function createDefaultDocumentOrganization() { + return { + version: DOCUMENT_ORGANIZATION_VERSION, + workspaces: [{ + id: DEFAULT_WORKSPACE_ID, + name: 'Default Workspace', + expanded: true, + createdAt: 0 + }], + folders: [], + ui: { + filter: 'all', + collapsed: false, + width: 288, + lastWorkspaceId: DEFAULT_WORKSPACE_ID, + lastFolderId: null + } + }; + } + + function normalizeDocumentOrganization(raw) { + const fallback = createDefaultDocumentOrganization(); + const source = raw && typeof raw === 'object' ? raw : {}; + const seenWorkspaceIds = new Set(); + const workspaces = []; + const rawWorkspaces = Array.isArray(source.workspaces) ? source.workspaces : []; + + rawWorkspaces.forEach(function(workspace) { + if (!workspace || typeof workspace !== 'object') return; + const id = String(workspace.id || '').slice(0, 120); + if (!id || seenWorkspaceIds.has(id)) return; + const isDefault = id === DEFAULT_WORKSPACE_ID; + seenWorkspaceIds.add(id); + workspaces.push({ + id: id, + name: isDefault ? 'Default Workspace' : (String(workspace.name || '').trim().slice(0, 160) || 'Workspace'), + expanded: workspace.expanded !== false, + createdAt: Number.isFinite(Number(workspace.createdAt)) ? Number(workspace.createdAt) : Date.now() + }); + }); + + if (!seenWorkspaceIds.has(DEFAULT_WORKSPACE_ID)) { + workspaces.unshift(fallback.workspaces[0]); + seenWorkspaceIds.add(DEFAULT_WORKSPACE_ID); + } + + workspaces.sort(function(left, right) { + if (left.id === DEFAULT_WORKSPACE_ID) return -1; + if (right.id === DEFAULT_WORKSPACE_ID) return 1; + return left.createdAt - right.createdAt || left.name.localeCompare(right.name); + }); + + const seenFolderIds = new Set(); + const folders = (Array.isArray(source.folders) ? source.folders : []).map(function(folder) { + if (!folder || typeof folder !== 'object') return null; + const id = String(folder.id || '').slice(0, 120); + const workspaceId = String(folder.workspaceId || ''); + if (!id || seenFolderIds.has(id) || !seenWorkspaceIds.has(workspaceId)) return null; + seenFolderIds.add(id); + return { + id: id, + workspaceId: workspaceId, + name: String(folder.name || '').trim().slice(0, 160) || 'Folder', + expanded: folder.expanded !== false, + createdAt: Number.isFinite(Number(folder.createdAt)) ? Number(folder.createdAt) : Date.now() + }; + }).filter(Boolean); + + const ui = source.ui && typeof source.ui === 'object' ? source.ui : {}; + const allowedFilters = new Set(['all', 'recent', 'favorites']); + const width = Number(ui.width); + const lastWorkspaceId = seenWorkspaceIds.has(ui.lastWorkspaceId) ? ui.lastWorkspaceId : DEFAULT_WORKSPACE_ID; + const lastFolderId = seenFolderIds.has(ui.lastFolderId) ? ui.lastFolderId : null; + return { + version: DOCUMENT_ORGANIZATION_VERSION, + workspaces: workspaces, + folders: folders, + ui: { + filter: allowedFilters.has(ui.filter) ? ui.filter : 'all', + collapsed: ui.collapsed === true, + width: Number.isFinite(width) ? Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, width)) : 288, + lastWorkspaceId: lastWorkspaceId, + lastFolderId: lastFolderId + } + }; + } + + function loadDocumentOrganization() { + try { + return normalizeDocumentOrganization(JSON.parse(localStorage.getItem(DOCUMENT_ORGANIZATION_KEY)) || {}); + } catch (_) { + return createDefaultDocumentOrganization(); + } + } + + function saveDocumentOrganization() { + if (!documentOrganization) return; + saveStorageItem(DOCUMENT_ORGANIZATION_KEY, JSON.stringify(documentOrganization)); + } + + function isTemporaryDocument(tab) { + return Boolean(tab && (tab.temporary === true || isShareSnapshotTab(tab))); + } + + function hasDocumentCapacity() { + if (tabs.length < MAX_DOCUMENTS) return true; + alert('Maximum of ' + MAX_DOCUMENTS + ' documents reached. Delete an existing document to create or import another.'); + announceToScreenReader('Maximum document limit reached.'); + return false; + } + + function getWorkspaceById(workspaceId) { + if (!documentOrganization) return null; + return documentOrganization.workspaces.find(function(workspace) { return workspace.id === workspaceId; }) || null; + } + + function getFolderById(folderId) { + if (!documentOrganization || !folderId) return null; + return documentOrganization.folders.find(function(folder) { return folder.id === folderId; }) || null; + } + + function normalizeTabDocumentMetadata(tab) { + if (!tab || typeof tab !== 'object') return tab; + tab.createdAt = Number.isFinite(Number(tab.createdAt)) ? Number(tab.createdAt) : Date.now(); + tab.lastOpenedAt = Number.isFinite(Number(tab.lastOpenedAt)) ? Number(tab.lastOpenedAt) : tab.createdAt; + tab.lastEditedAt = Number.isFinite(Number(tab.lastEditedAt)) ? Number(tab.lastEditedAt) : tab.createdAt; + tab.favorite = tab.favorite === true; + + if (isTemporaryDocument(tab)) { + delete tab.workspaceId; + delete tab.folderId; + return tab; + } + + const workspace = getWorkspaceById(tab.workspaceId) || getWorkspaceById(DEFAULT_WORKSPACE_ID); + tab.workspaceId = workspace ? workspace.id : DEFAULT_WORKSPACE_ID; + const folder = getFolderById(tab.folderId); + tab.folderId = folder && folder.workspaceId === tab.workspaceId ? folder.id : null; + return tab; + } + + function migrateDocumentsToOrganization() { + tabs.forEach(function(tab) { + normalizeTabDocumentMetadata(tab); + }); + saveDocumentOrganization(); + // Always write once after normalization so legacy tabs receive location, + // favorite, and recent-activity metadata on their first migrated load. + _flushTabsToStorage(tabs); + } + + function setDocumentLocation(tab, workspaceId, folderId) { + if (!tab || isTemporaryDocument(tab)) return false; + const workspace = getWorkspaceById(workspaceId) || getWorkspaceById(DEFAULT_WORKSPACE_ID); + if (!workspace) return false; + const folder = getFolderById(folderId); + tab.workspaceId = workspace.id; + tab.folderId = folder && folder.workspaceId === workspace.id ? folder.id : null; + documentOrganization.ui.lastWorkspaceId = tab.workspaceId; + documentOrganization.ui.lastFolderId = tab.folderId; + workspace.expanded = true; + if (folder) folder.expanded = true; + saveDocumentOrganization(); + return true; + } + + function getPreferredDocumentLocation() { + const selected = tabs.find(function(tab) { return tab.id === selectedDocumentId && !isTemporaryDocument(tab); }); + if (selected) { + return { workspaceId: selected.workspaceId || DEFAULT_WORKSPACE_ID, folderId: selected.folderId || null }; + } + const workspaceId = getWorkspaceById(documentOrganization.ui.lastWorkspaceId) + ? documentOrganization.ui.lastWorkspaceId + : DEFAULT_WORKSPACE_ID; + const folder = getFolderById(documentOrganization.ui.lastFolderId); + return { + workspaceId: workspaceId, + folderId: folder && folder.workspaceId === workspaceId ? folder.id : null + }; + } + + function getDocumentActivityTime(tab) { + return Math.max(Number(tab.lastOpenedAt) || 0, Number(tab.lastEditedAt) || 0, Number(tab.createdAt) || 0); + } + + function openDocumentNameDialog(options) { + const modal = document.getElementById('document-name-modal'); + const title = document.getElementById('document-name-modal-title'); + const description = document.getElementById('document-name-modal-description'); + const label = document.getElementById('document-name-modal-label'); + const input = document.getElementById('document-name-modal-input'); + const error = document.getElementById('document-name-modal-error'); + const confirmButton = document.getElementById('document-name-modal-confirm'); + const cancelButton = document.getElementById('document-name-modal-cancel'); + const closeButton = document.getElementById('document-name-modal-close'); + if (!modal || !input || !confirmButton || !cancelButton) return; + + title.textContent = options.title || 'Name item'; + description.textContent = options.description || ''; + description.hidden = !options.description; + label.textContent = options.label || 'Name'; + input.value = options.value || ''; + input.placeholder = options.placeholder || ''; + confirmButton.textContent = options.confirmText || 'Save'; + error.hidden = true; + error.textContent = ''; + + function cleanup() { + confirmButton.removeEventListener('click', submit); + cancelButton.removeEventListener('click', cancel); + if (closeButton) closeButton.removeEventListener('click', cancel); + input.removeEventListener('keydown', onKeydown); + } + + function cancel() { + cleanup(); + closeAppModal(modal); + } + + function submit() { + const value = input.value.trim(); + const validationMessage = options.validate ? options.validate(value) : (!value ? 'Enter a name.' : ''); + if (validationMessage) { + error.textContent = validationMessage; + error.hidden = false; + input.focus(); + return; + } + cleanup(); + closeAppModal(modal); + options.onConfirm(value); + } + + function onKeydown(event) { + if (event.key === 'Enter') { + event.preventDefault(); + submit(); + } + } + + confirmButton.addEventListener('click', submit); + cancelButton.addEventListener('click', cancel); + if (closeButton) closeButton.addEventListener('click', cancel); + input.addEventListener('keydown', onKeydown); + openAppModal(modal, { focusTarget: input, onClose: cancel }); + requestAnimationFrame(function() { input.select(); }); + } + + function openDocumentConfirmation(options) { + const modal = document.getElementById('document-confirm-modal'); + const title = document.getElementById('document-confirm-modal-title'); + const description = document.getElementById('document-confirm-modal-description'); + const confirmButton = document.getElementById('document-confirm-modal-confirm'); + const cancelButton = document.getElementById('document-confirm-modal-cancel'); + const closeButton = document.getElementById('document-confirm-modal-close'); + if (!modal || !confirmButton || !cancelButton) return; + title.textContent = options.title || 'Confirm action'; + description.textContent = options.description || ''; + confirmButton.textContent = options.confirmText || 'Confirm'; + + function cleanup() { + confirmButton.removeEventListener('click', confirmAction); + cancelButton.removeEventListener('click', cancel); + if (closeButton) closeButton.removeEventListener('click', cancel); + } + + function cancel() { + cleanup(); + closeAppModal(modal); + } + + function confirmAction() { + cleanup(); + closeAppModal(modal); + options.onConfirm(); + } + + confirmButton.addEventListener('click', confirmAction); + cancelButton.addEventListener('click', cancel); + if (closeButton) closeButton.addEventListener('click', cancel); + openAppModal(modal, { focusTarget: cancelButton, onClose: cancel }); + } + + function createWorkspace() { + openDocumentNameDialog({ + title: 'Create workspace', + description: 'Workspaces keep related folders and documents together.', + label: 'Workspace name', + placeholder: 'Project notes', + confirmText: 'Create workspace', + validate: function(value) { + if (!value) return 'Enter a workspace name.'; + if (documentOrganization.workspaces.some(function(workspace) { return workspace.name.toLowerCase() === value.toLowerCase(); })) { + return 'A workspace with this name already exists.'; + } + return ''; + }, + onConfirm: function(value) { + const workspace = { + id: createDocumentEntityId('workspace'), + name: value, + expanded: true, + createdAt: Date.now() + }; + documentOrganization.workspaces.push(workspace); + documentOrganization.ui.lastWorkspaceId = workspace.id; + documentOrganization.ui.lastFolderId = null; + saveDocumentOrganization(); + renderDocumentSidebar(); + announceToScreenReader('Workspace created: ' + value); + } + }); + } + + function renameWorkspace(workspaceId) { + const workspace = getWorkspaceById(workspaceId); + if (!workspace || workspace.id === DEFAULT_WORKSPACE_ID) return; + openDocumentNameDialog({ + title: 'Rename workspace', + label: 'Workspace name', + value: workspace.name, + confirmText: 'Rename', + validate: function(value) { + if (!value) return 'Enter a workspace name.'; + if (documentOrganization.workspaces.some(function(item) { + return item.id !== workspaceId && item.name.toLowerCase() === value.toLowerCase(); + })) return 'A workspace with this name already exists.'; + return ''; + }, + onConfirm: function(value) { + workspace.name = value; + saveDocumentOrganization(); + renderDocumentSidebar(); + announceToScreenReader('Workspace renamed to ' + value); + } + }); + } + + function deleteWorkspace(workspaceId) { + const workspace = getWorkspaceById(workspaceId); + if (!workspace || workspace.id === DEFAULT_WORKSPACE_ID) return; + const documentCount = tabs.filter(function(tab) { return !isTemporaryDocument(tab) && tab.workspaceId === workspaceId; }).length; + const folderCount = documentOrganization.folders.filter(function(folder) { return folder.workspaceId === workspaceId; }).length; + const details = documentCount || folderCount + ? 'Its ' + documentCount + ' document' + (documentCount === 1 ? '' : 's') + ' will move to Default Workspace so no content is lost.' + : 'This empty workspace will be removed.'; + openDocumentConfirmation({ + title: 'Delete workspace?', + description: details, + confirmText: 'Delete workspace', + onConfirm: function() { + tabs.forEach(function(tab) { + if (!isTemporaryDocument(tab) && tab.workspaceId === workspaceId) { + tab.workspaceId = DEFAULT_WORKSPACE_ID; + tab.folderId = null; + } + }); + documentOrganization.folders = documentOrganization.folders.filter(function(folder) { return folder.workspaceId !== workspaceId; }); + documentOrganization.workspaces = documentOrganization.workspaces.filter(function(item) { return item.id !== workspaceId; }); + documentOrganization.ui.lastWorkspaceId = DEFAULT_WORKSPACE_ID; + documentOrganization.ui.lastFolderId = null; + saveDocumentOrganization(); + saveTabsToStorage(tabs); + renderTabBar(tabs, activeTabId); + announceToScreenReader('Workspace deleted. Documents moved to Default Workspace.'); + } + }); + } + + function createFolder(workspaceId) { + const workspace = getWorkspaceById(workspaceId); + if (!workspace) return; + openDocumentNameDialog({ + title: 'Create folder', + description: 'Folders stay one level deep inside a workspace.', + label: 'Folder name', + placeholder: 'Research', + confirmText: 'Create folder', + validate: function(value) { + if (!value) return 'Enter a folder name.'; + if (documentOrganization.folders.some(function(folder) { + return folder.workspaceId === workspaceId && folder.name.toLowerCase() === value.toLowerCase(); + })) return 'A folder with this name already exists in the workspace.'; + return ''; + }, + onConfirm: function(value) { + const folder = { + id: createDocumentEntityId('folder'), + workspaceId: workspaceId, + name: value, + expanded: true, + createdAt: Date.now() + }; + documentOrganization.folders.push(folder); + workspace.expanded = true; + documentOrganization.ui.lastWorkspaceId = workspaceId; + documentOrganization.ui.lastFolderId = folder.id; + saveDocumentOrganization(); + renderDocumentSidebar(); + announceToScreenReader('Folder created: ' + value); + } + }); + } + + function renameFolder(folderId) { + const folder = getFolderById(folderId); + if (!folder) return; + openDocumentNameDialog({ + title: 'Rename folder', + label: 'Folder name', + value: folder.name, + confirmText: 'Rename', + validate: function(value) { + if (!value) return 'Enter a folder name.'; + if (documentOrganization.folders.some(function(item) { + return item.id !== folderId && item.workspaceId === folder.workspaceId && item.name.toLowerCase() === value.toLowerCase(); + })) return 'A folder with this name already exists in the workspace.'; + return ''; + }, + onConfirm: function(value) { + folder.name = value; + saveDocumentOrganization(); + renderDocumentSidebar(); + announceToScreenReader('Folder renamed to ' + value); + } + }); + } + + function deleteFolder(folderId) { + const folder = getFolderById(folderId); + if (!folder) return; + const documents = tabs.filter(function(tab) { return !isTemporaryDocument(tab) && tab.folderId === folderId; }); + openDocumentConfirmation({ + title: 'Delete folder?', + description: documents.length + ? documents.length + ' document' + (documents.length === 1 ? '' : 's') + ' will move to the workspace root so no content is lost.' + : 'This empty folder will be removed.', + confirmText: 'Delete folder', + onConfirm: function() { + documents.forEach(function(tab) { tab.folderId = null; }); + documentOrganization.folders = documentOrganization.folders.filter(function(item) { return item.id !== folderId; }); + if (documentOrganization.ui.lastFolderId === folderId) documentOrganization.ui.lastFolderId = null; + saveDocumentOrganization(); + saveTabsToStorage(tabs); + renderTabBar(tabs, activeTabId); + announceToScreenReader('Folder deleted. Documents moved to the workspace root.'); + } + }); + } + + function openMoveDocumentDialog(tabId) { + const tab = tabs.find(function(item) { return item.id === tabId; }); + if (!tab || isTemporaryDocument(tab)) return; + const modal = document.getElementById('document-move-modal'); + const title = document.getElementById('document-move-modal-title'); + const select = document.getElementById('document-move-destination'); + const confirmButton = document.getElementById('document-move-modal-confirm'); + const cancelButton = document.getElementById('document-move-modal-cancel'); + const closeButton = document.getElementById('document-move-modal-close'); + if (!modal || !select || !confirmButton || !cancelButton) return; + + title.textContent = 'Move “' + (tab.title || 'Untitled') + '”'; + select.textContent = ''; + documentOrganization.workspaces.forEach(function(workspace) { + const rootOption = document.createElement('option'); + rootOption.value = workspace.id + '|'; + rootOption.textContent = workspace.name; + select.appendChild(rootOption); + documentOrganization.folders.filter(function(folder) { + return folder.workspaceId === workspace.id; + }).forEach(function(folder) { + const option = document.createElement('option'); + option.value = workspace.id + '|' + folder.id; + option.textContent = workspace.name + ' / ' + folder.name; + select.appendChild(option); + }); + }); + select.value = (tab.workspaceId || DEFAULT_WORKSPACE_ID) + '|' + (tab.folderId || ''); + + function cleanup() { + confirmButton.removeEventListener('click', move); + cancelButton.removeEventListener('click', cancel); + if (closeButton) closeButton.removeEventListener('click', cancel); + } + + function cancel() { + cleanup(); + closeAppModal(modal); + } + + function move() { + const parts = String(select.value || '').split('|'); + if (setDocumentLocation(tab, parts[0], parts[1] || null)) { + saveTabsToStorage(tabs); + renderTabBar(tabs, activeTabId); + announceToScreenReader('Document moved.'); + } + cleanup(); + closeAppModal(modal); + } + + confirmButton.addEventListener('click', move); + cancelButton.addEventListener('click', cancel); + if (closeButton) closeButton.addEventListener('click', cancel); + openAppModal(modal, { focusTarget: select, onClose: cancel }); + } + + function toggleDocumentFavorite(tabId) { + const tab = tabs.find(function(item) { return item.id === tabId; }); + if (!tab || isTemporaryDocument(tab)) return; + tab.favorite = !tab.favorite; + saveTabsToStorage(tabs); + renderTabBar(tabs, activeTabId); + announceToScreenReader(tab.favorite ? 'Document added to favorites.' : 'Document removed from favorites.'); + } + + function openSidebarDocument(tabId, options) { + const tab = tabs.find(function(item) { return item.id === tabId; }); + if (!tab) return; + selectedDocumentId = tabId; + tab.lastOpenedAt = Date.now(); + if (!isTemporaryDocument(tab)) { + documentOrganization.ui.lastWorkspaceId = tab.workspaceId || DEFAULT_WORKSPACE_ID; + documentOrganization.ui.lastFolderId = tab.folderId || null; + saveDocumentOrganization(); + saveTabsToStorage(tabs); + } + if (tabId !== activeTabId) { + switchTab(tabId); + } else { + renderDocumentSidebar(); + } + if (!options || options.closeMobile !== false) closeDocumentSidebarOnMobile(); + announceToScreenReader('Opened document ' + (tab.title || 'Untitled') + '.'); + } + + function selectSidebarDocument(tabId) { + selectedDocumentId = tabId; + document.querySelectorAll('#document-tree .document-tree-row[data-document-id]').forEach(function(row) { + const selected = row.getAttribute('data-document-id') === tabId; + row.classList.toggle('is-selected', selected); + row.setAttribute('aria-selected', selected ? 'true' : 'false'); + }); + } + + function removeDocumentSidebarMenus() { + document.querySelectorAll('.document-menu-dropdown').forEach(function(menu) { menu.remove(); }); + } + + function closeDocumentSidebarMenus() { + document.querySelectorAll('.document-menu-btn.open').forEach(function(button) { + button.classList.remove('open'); + button.setAttribute('aria-expanded', 'false'); + }); + document.querySelectorAll('.document-menu-dropdown.open').forEach(function(menu) { menu.classList.remove('open'); }); + } + + function createDocumentMenuButton(label, actions) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'document-tree-action document-menu-btn'; + button.setAttribute('aria-label', label + ' options'); + button.setAttribute('aria-haspopup', 'menu'); + button.setAttribute('aria-expanded', 'false'); + button.title = label + ' options'; + const icon = document.createElement('i'); + icon.className = 'bi bi-three-dots'; + icon.setAttribute('aria-hidden', 'true'); + button.appendChild(icon); + + const menu = document.createElement('div'); + menu.className = 'tab-menu-dropdown document-menu-dropdown'; + menu.setAttribute('role', 'menu'); + actions.forEach(function(action) { + const actionButton = document.createElement('button'); + actionButton.type = 'button'; + actionButton.className = 'tab-menu-item' + (action.danger ? ' tab-menu-item-danger' : ''); + actionButton.setAttribute('role', 'menuitem'); + actionButton.setAttribute('data-action', action.id); + const actionIcon = document.createElement('i'); + actionIcon.className = 'bi ' + action.icon; + actionIcon.setAttribute('aria-hidden', 'true'); + actionButton.appendChild(actionIcon); + actionButton.appendChild(document.createTextNode(' ' + action.label)); + actionButton.addEventListener('click', function(event) { + event.preventDefault(); + event.stopPropagation(); + closeDocumentSidebarMenus(); + action.run(); + }); + menu.appendChild(actionButton); + }); + menu.addEventListener('keydown', function(event) { + const items = Array.from(menu.querySelectorAll('[role="menuitem"]')); + const currentIndex = items.indexOf(document.activeElement); + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + closeDocumentSidebarMenus(); + button.focus(); + return; + } + if (!items.length || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + let nextIndex = currentIndex; + if (event.key === 'ArrowDown') nextIndex = (currentIndex + 1 + items.length) % items.length; + if (event.key === 'ArrowUp') nextIndex = (currentIndex - 1 + items.length) % items.length; + if (event.key === 'Home') nextIndex = 0; + if (event.key === 'End') nextIndex = items.length - 1; + items[nextIndex].focus(); + }); + document.body.appendChild(menu); + + button.addEventListener('click', function(event) { + event.preventDefault(); + event.stopPropagation(); + const shouldOpen = !button.classList.contains('open'); + closeTabMenus(); + closeDocumentSidebarMenus(); + if (shouldOpen) { + button.classList.add('open'); + button.setAttribute('aria-expanded', 'true'); + menu.classList.add('open'); + positionTabMenu(button, menu); + const firstAction = menu.querySelector('[role="menuitem"]'); + if (firstAction) firstAction.focus(); + } + }); + button.addEventListener('keydown', function(event) { + if (event.key === 'ArrowDown') { + event.preventDefault(); + button.click(); + } + }); + return button; + } + + function getDocumentMenuActions(tab) { + const actions = [{ + id: 'open', icon: 'bi-box-arrow-up-right', label: 'Open', run: function() { openSidebarDocument(tab.id); } + }, { + id: 'rename', icon: 'bi-pencil', label: 'Rename', run: function() { renameTab(tab.id); } + }]; + if (!isTemporaryDocument(tab)) { + actions.push({ id: 'duplicate', icon: 'bi-files', label: 'Duplicate', run: function() { duplicateTab(tab.id); } }); + actions.push({ + id: 'favorite', + icon: tab.favorite ? 'bi-star-fill' : 'bi-star', + label: tab.favorite ? 'Remove from Favorites' : 'Add to Favorites', + run: function() { toggleDocumentFavorite(tab.id); } + }); + actions.push({ id: 'move', icon: 'bi-folder-symlink', label: 'Move to…', run: function() { openMoveDocumentDialog(tab.id); } }); + actions.push({ id: 'download', icon: 'bi-download', label: 'Download Markdown', run: function() { downloadTabMarkdown(tab.id); } }); + } + actions.push({ + id: 'delete', + icon: isTemporaryDocument(tab) ? 'bi-x-lg' : 'bi-trash', + label: isTemporaryDocument(tab) ? 'Close' : 'Delete', + danger: true, + run: function() { deleteTab(tab.id); } + }); + return actions; + } + + function getWorkspaceMenuActions(workspace) { + const actions = [ + { id: 'new-document', icon: 'bi-file-earmark-plus', label: 'New document', run: function() { newTab('', null, { workspaceId: workspace.id }); } }, + { id: 'new-folder', icon: 'bi-folder-plus', label: 'New folder', run: function() { createFolder(workspace.id); } } + ]; + if (workspace.id !== DEFAULT_WORKSPACE_ID) { + actions.push({ id: 'rename', icon: 'bi-pencil', label: 'Rename', run: function() { renameWorkspace(workspace.id); } }); + actions.push({ id: 'delete', icon: 'bi-trash', label: 'Delete', danger: true, run: function() { deleteWorkspace(workspace.id); } }); + } + return actions; + } + + function getFolderMenuActions(folder) { + return [ + { id: 'new-document', icon: 'bi-file-earmark-plus', label: 'New document', run: function() { newTab('', null, { workspaceId: folder.workspaceId, folderId: folder.id }); } }, + { id: 'rename', icon: 'bi-pencil', label: 'Rename', run: function() { renameFolder(folder.id); } }, + { id: 'delete', icon: 'bi-trash', label: 'Delete', danger: true, run: function() { deleteFolder(folder.id); } } + ]; + } + + function createDocumentTreeRow(options) { + const row = document.createElement('div'); + row.className = 'document-tree-row'; + row.setAttribute('role', 'treeitem'); + row.setAttribute('tabindex', options.tabindex === 0 ? '0' : '-1'); + row.setAttribute('data-tree-type', options.type); + row.setAttribute('data-tree-id', options.id); + row.style.setProperty('--tree-depth', String(options.depth || 0)); + if (options.documentId) { + row.setAttribute('data-document-id', options.documentId); + row.classList.toggle('is-active', options.documentId === activeTabId); + row.classList.toggle('is-selected', options.documentId === selectedDocumentId); + row.setAttribute('aria-selected', options.documentId === selectedDocumentId ? 'true' : 'false'); + if (options.documentId === activeTabId) row.setAttribute('aria-current', 'page'); + } + if (options.favorite) row.classList.add('is-favorite'); + if (options.temporary) row.classList.add('document-tree-temporary'); + if (typeof options.expanded === 'boolean') row.setAttribute('aria-expanded', options.expanded ? 'true' : 'false'); + row.setAttribute('aria-label', options.ariaLabel || options.label); + + if (typeof options.expanded === 'boolean') { + const toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'document-tree-toggle'; + toggle.setAttribute('aria-label', (options.expanded ? 'Collapse ' : 'Expand ') + options.label); + toggle.setAttribute('tabindex', '-1'); + const toggleIcon = document.createElement('i'); + toggleIcon.className = 'bi ' + (options.expanded ? 'bi-chevron-down' : 'bi-chevron-right'); + toggleIcon.setAttribute('aria-hidden', 'true'); + toggle.appendChild(toggleIcon); + toggle.addEventListener('click', function(event) { + event.stopPropagation(); + options.onToggle(); + }); + row.appendChild(toggle); + } else { + const spacer = document.createElement('span'); + spacer.className = 'document-tree-toggle'; + spacer.setAttribute('aria-hidden', 'true'); + row.appendChild(spacer); + } + + const main = document.createElement('button'); + main.type = 'button'; + main.className = 'document-tree-main'; + main.setAttribute('tabindex', '-1'); + main.title = options.label; + const icon = document.createElement('i'); + icon.className = 'bi ' + options.icon; + icon.setAttribute('aria-hidden', 'true'); + main.appendChild(icon); + const label = document.createElement('span'); + label.className = 'document-tree-label'; + label.textContent = options.label; + main.appendChild(label); + if (options.favorite) { + const favorite = document.createElement('i'); + favorite.className = 'bi bi-star-fill document-favorite-indicator'; + favorite.setAttribute('aria-label', 'Favorite'); + main.appendChild(favorite); + } + if (options.meta) { + const meta = document.createElement('span'); + meta.className = 'document-tree-meta'; + meta.textContent = options.meta; + main.appendChild(meta); + } + main.addEventListener('click', options.onActivate); + if (options.onDoubleClick) main.addEventListener('dblclick', options.onDoubleClick); + row.appendChild(main); + + if (options.addActions) { + const addButton = document.createElement('button'); + addButton.type = 'button'; + addButton.className = 'document-tree-add'; + addButton.setAttribute('aria-label', 'Create in ' + options.label); + addButton.title = 'Create in ' + options.label; + const addIcon = document.createElement('i'); + addIcon.className = 'bi bi-plus-lg'; + addIcon.setAttribute('aria-hidden', 'true'); + addButton.appendChild(addIcon); + addButton.addEventListener('click', function(event) { + event.stopPropagation(); + const menuButton = row.querySelector('.document-menu-btn'); + if (menuButton) menuButton.click(); + }); + row.appendChild(addButton); + } + + if (options.menuActions && options.menuActions.length) { + row.appendChild(createDocumentMenuButton(options.label, options.menuActions)); + } + return row; + } + + function appendDocumentTreeItem(container, tab, depth, meta) { + const row = createDocumentTreeRow({ + type: 'document', + id: tab.id, + documentId: tab.id, + label: tab.title || 'Untitled', + ariaLabel: (tab.id === activeTabId ? 'Active document, ' : 'Document, ') + (tab.title || 'Untitled') + (tab.favorite ? ', favorite' : ''), + icon: isTemporaryDocument(tab) ? (tab.kind === SHARE_SNAPSHOT_TAB_KIND ? 'bi-share' : 'bi-broadcast') : 'bi-file-earmark-text', + depth: depth, + favorite: tab.favorite === true, + temporary: isTemporaryDocument(tab), + meta: meta || '', + menuActions: getDocumentMenuActions(tab), + onActivate: function() { + selectSidebarDocument(tab.id); + const coarsePointer = window.matchMedia && window.matchMedia('(pointer: coarse)').matches; + if (coarsePointer || window.innerWidth < 768) openSidebarDocument(tab.id); + }, + onDoubleClick: function(event) { + event.preventDefault(); + openSidebarDocument(tab.id); + } + }); + container.appendChild(row); + } + + function getDocumentLocationLabel(tab) { + const workspace = getWorkspaceById(tab.workspaceId); + const folder = getFolderById(tab.folderId); + if (folder && workspace) return workspace.name + ' / ' + folder.name; + return workspace ? workspace.name : 'Default Workspace'; + } + + function documentMatchesSidebarSearch(tab) { + if (!documentSidebarSearch) return true; + const haystack = ((tab.title || '') + ' ' + getDocumentLocationLabel(tab)).toLocaleLowerCase(); + return haystack.includes(documentSidebarSearch.toLocaleLowerCase()); + } + + function renderFlatDocumentView(tree, filter) { + let documents = tabs.filter(function(tab) { + if (isTemporaryDocument(tab)) return false; + if (filter === 'favorites' && !tab.favorite) return false; + return documentMatchesSidebarSearch(tab); + }); + if (filter === 'recent') { + documents = documents.sort(function(left, right) { return getDocumentActivityTime(right) - getDocumentActivityTime(left); }).slice(0, 30); + } else { + documents = documents.sort(function(left, right) { return (left.title || '').localeCompare(right.title || ''); }); + } + documents.forEach(function(tab) { appendDocumentTreeItem(tree, tab, 0, getDocumentLocationLabel(tab)); }); + return documents.length; + } + + function renderWorkspaceTree(tree) { + let renderedDocuments = 0; + const temporaryTabs = tabs.filter(isTemporaryDocument).filter(documentMatchesSidebarSearch); + if (temporaryTabs.length) { + const temporaryLabel = document.createElement('p'); + temporaryLabel.className = 'document-tree-section-label'; + temporaryLabel.textContent = 'Temporary'; + tree.appendChild(temporaryLabel); + temporaryTabs.forEach(function(tab) { + appendDocumentTreeItem(tree, tab, 0, tab.kind === SHARE_SNAPSHOT_TAB_KIND ? 'Snapshot' : 'Live Share'); + }); + renderedDocuments += temporaryTabs.length; + } + + documentOrganization.workspaces.forEach(function(workspace) { + const workspaceDocuments = tabs.filter(function(tab) { + return !isTemporaryDocument(tab) && tab.workspaceId === workspace.id; + }); + const folders = documentOrganization.folders.filter(function(folder) { return folder.workspaceId === workspace.id; }); + const matchingDocuments = workspaceDocuments.filter(documentMatchesSidebarSearch); + const workspaceMatchesSearch = !documentSidebarSearch || workspace.name.toLocaleLowerCase().includes(documentSidebarSearch.toLocaleLowerCase()); + if (documentSidebarSearch && !workspaceMatchesSearch && !matchingDocuments.length && !folders.some(function(folder) { + return folder.name.toLocaleLowerCase().includes(documentSidebarSearch.toLocaleLowerCase()); + })) return; + + const expanded = documentSidebarSearch ? true : workspace.expanded !== false; + const workspaceRow = createDocumentTreeRow({ + type: 'workspace', + id: workspace.id, + label: workspace.name, + icon: 'bi-collection', + depth: 0, + expanded: expanded, + meta: String(workspaceDocuments.length), + addActions: true, + menuActions: getWorkspaceMenuActions(workspace), + onToggle: function() { + workspace.expanded = !workspace.expanded; + saveDocumentOrganization(); + renderDocumentSidebar(); + }, + onActivate: function() { + documentOrganization.ui.lastWorkspaceId = workspace.id; + documentOrganization.ui.lastFolderId = null; + workspace.expanded = !workspace.expanded; + saveDocumentOrganization(); + renderDocumentSidebar(); + } + }); + tree.appendChild(workspaceRow); + + const workspaceGroup = document.createElement('div'); + workspaceGroup.className = 'document-tree-group'; + workspaceGroup.setAttribute('role', 'group'); + workspaceGroup.hidden = !expanded; + + folders.sort(function(left, right) { return left.createdAt - right.createdAt || left.name.localeCompare(right.name); }).forEach(function(folder) { + const folderDocuments = matchingDocuments.filter(function(tab) { return tab.folderId === folder.id; }); + const folderMatchesSearch = !documentSidebarSearch || folder.name.toLocaleLowerCase().includes(documentSidebarSearch.toLocaleLowerCase()); + if (documentSidebarSearch && !folderMatchesSearch && !folderDocuments.length) return; + const folderExpanded = documentSidebarSearch ? true : folder.expanded !== false; + const folderRow = createDocumentTreeRow({ + type: 'folder', + id: folder.id, + label: folder.name, + icon: folderExpanded ? 'bi-folder2-open' : 'bi-folder2', + depth: 1, + expanded: folderExpanded, + meta: String(workspaceDocuments.filter(function(tab) { return tab.folderId === folder.id; }).length), + addActions: true, + menuActions: getFolderMenuActions(folder), + onToggle: function() { + folder.expanded = !folder.expanded; + saveDocumentOrganization(); + renderDocumentSidebar(); + }, + onActivate: function() { + documentOrganization.ui.lastWorkspaceId = workspace.id; + documentOrganization.ui.lastFolderId = folder.id; + folder.expanded = !folder.expanded; + saveDocumentOrganization(); + renderDocumentSidebar(); + } + }); + workspaceGroup.appendChild(folderRow); + const folderGroup = document.createElement('div'); + folderGroup.className = 'document-tree-group'; + folderGroup.setAttribute('role', 'group'); + folderGroup.hidden = !folderExpanded; + folderDocuments.sort(function(left, right) { return (left.title || '').localeCompare(right.title || ''); }).forEach(function(tab) { + appendDocumentTreeItem(folderGroup, tab, 2); + renderedDocuments++; + }); + workspaceGroup.appendChild(folderGroup); + }); + + matchingDocuments.filter(function(tab) { return !tab.folderId; }).sort(function(left, right) { + return (left.title || '').localeCompare(right.title || ''); + }).forEach(function(tab) { + appendDocumentTreeItem(workspaceGroup, tab, 1); + renderedDocuments++; + }); + tree.appendChild(workspaceGroup); + }); + return renderedDocuments; + } + + function renderDocumentSidebar() { + const tree = document.getElementById('document-tree'); + if (!tree || !documentOrganization) return; + removeDocumentSidebarMenus(); + tree.textContent = ''; + const filter = documentOrganization.ui.filter || 'all'; + let renderedCount = filter === 'all' ? renderWorkspaceTree(tree) : renderFlatDocumentView(tree, filter); + + if (!renderedCount) { + const empty = document.createElement('div'); + empty.className = 'document-tree-empty'; + empty.textContent = documentSidebarSearch + ? 'No documents match your search.' + : (filter === 'favorites' ? 'No favorite documents yet.' : 'No documents to show.'); + tree.appendChild(empty); + } + + const count = document.getElementById('document-sidebar-count'); + if (count) count.textContent = tabs.length + ' of ' + MAX_DOCUMENTS + ' documents'; + const status = document.getElementById('document-sidebar-status'); + if (status) { + status.textContent = documentSidebarSearch ? renderedCount + ' result' + (renderedCount === 1 ? '' : 's') : ''; + } + document.querySelectorAll('.document-filter-btn').forEach(function(button) { + const active = button.getAttribute('data-document-filter') === filter; + button.classList.toggle('is-active', active); + if (active) button.setAttribute('aria-current', 'page'); + else button.removeAttribute('aria-current'); + }); + const firstRow = tree.querySelector('.document-tree-row'); + if (firstRow && !tree.querySelector('.document-tree-row[tabindex="0"]')) firstRow.setAttribute('tabindex', '0'); + } + + function isDocumentSidebarMobile() { + return window.matchMedia && window.matchMedia('(max-width: 767px)').matches; + } + + function updateDocumentSidebarVisibility() { + const sidebar = document.getElementById('document-sidebar'); + const openButton = document.getElementById('document-sidebar-open'); + const collapseButton = document.getElementById('document-sidebar-collapse'); + const backdrop = document.getElementById('document-sidebar-backdrop'); + if (!sidebar || !documentOrganization) return; + if (isDocumentSidebarMobile()) { + sidebar.style.removeProperty('--document-sidebar-width'); + } else { + const responsiveWidth = window.innerWidth < 1080 + ? Math.min(documentOrganization.ui.width, 260) + : documentOrganization.ui.width; + sidebar.style.setProperty('--document-sidebar-width', responsiveWidth + 'px'); + } + document.body.classList.toggle('document-sidebar-collapsed', documentOrganization.ui.collapsed === true); + const mobileOpen = document.body.classList.contains('document-sidebar-mobile-open'); + if (openButton) openButton.setAttribute('aria-expanded', mobileOpen || !documentOrganization.ui.collapsed ? 'true' : 'false'); + if (collapseButton) { + collapseButton.setAttribute('aria-expanded', documentOrganization.ui.collapsed ? 'false' : 'true'); + collapseButton.setAttribute('aria-label', documentOrganization.ui.collapsed ? 'Expand document sidebar' : 'Collapse document sidebar'); + } + if (backdrop) backdrop.hidden = !mobileOpen; + } + + function openDocumentSidebar() { + if (!documentOrganization) return; + if (isDocumentSidebarMobile()) { + document.body.classList.add('document-sidebar-mobile-open'); + updateDocumentSidebarVisibility(); + requestAnimationFrame(function() { + const search = document.getElementById('document-sidebar-search'); + if (search) search.focus(); + }); + return; + } + documentOrganization.ui.collapsed = false; + saveDocumentOrganization(); + updateDocumentSidebarVisibility(); + } + + function closeDocumentSidebarOnMobile() { + if (!document.body.classList.contains('document-sidebar-mobile-open')) return; + document.body.classList.remove('document-sidebar-mobile-open'); + updateDocumentSidebarVisibility(); + const openButton = document.getElementById('document-sidebar-open'); + if (openButton) openButton.focus(); + } + + function toggleDocumentSidebarCollapsed() { + if (isDocumentSidebarMobile()) { + closeDocumentSidebarOnMobile(); + return; + } + documentOrganization.ui.collapsed = !documentOrganization.ui.collapsed; + saveDocumentOrganization(); + updateDocumentSidebarVisibility(); + if (documentOrganization.ui.collapsed) { + const openButton = document.getElementById('document-sidebar-open'); + if (openButton) openButton.focus(); + } + } + + function handleDocumentTreeKeydown(event) { + const tree = document.getElementById('document-tree'); + const row = event.target.closest('.document-tree-row'); + if (!tree || !row || event.target.closest('.document-menu-btn, .document-tree-add')) return; + const rows = Array.from(tree.querySelectorAll('.document-tree-row')).filter(function(item) { + return item.offsetParent !== null; + }); + const index = rows.indexOf(row); + if (index === -1) return; + + function focusRow(target) { + rows.forEach(function(item) { item.setAttribute('tabindex', item === target ? '0' : '-1'); }); + target.focus(); + } + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Home' || event.key === 'End') { + event.preventDefault(); + let targetIndex = index; + if (event.key === 'ArrowDown') targetIndex = Math.min(rows.length - 1, index + 1); + if (event.key === 'ArrowUp') targetIndex = Math.max(0, index - 1); + if (event.key === 'Home') targetIndex = 0; + if (event.key === 'End') targetIndex = rows.length - 1; + focusRow(rows[targetIndex]); + return; + } + + if (event.key === 'ArrowRight' && row.hasAttribute('aria-expanded')) { + event.preventDefault(); + if (row.getAttribute('aria-expanded') === 'false') row.querySelector('.document-tree-toggle').click(); + else if (rows[index + 1]) focusRow(rows[index + 1]); + return; + } + if (event.key === 'ArrowLeft') { + event.preventDefault(); + if (row.getAttribute('aria-expanded') === 'true') { + row.querySelector('.document-tree-toggle').click(); + } else { + const depth = Number(row.style.getPropertyValue('--tree-depth')) || 0; + for (let previous = index - 1; previous >= 0; previous--) { + const previousDepth = Number(rows[previous].style.getPropertyValue('--tree-depth')) || 0; + if (previousDepth < depth) { + focusRow(rows[previous]); + break; + } + } + } + return; + } + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + if (row.getAttribute('data-tree-type') === 'document') { + openSidebarDocument(row.getAttribute('data-document-id')); + } else { + row.querySelector('.document-tree-main').click(); + } + } + } + + function initDocumentSidebar() { + if (documentSidebarInitialized) return; + documentSidebarInitialized = true; + const openButton = document.getElementById('document-sidebar-open'); + const closeButton = document.getElementById('document-sidebar-close'); + const collapseButton = document.getElementById('document-sidebar-collapse'); + const backdrop = document.getElementById('document-sidebar-backdrop'); + const newDocumentButton = document.getElementById('sidebar-new-document'); + const newWorkspaceButton = document.getElementById('sidebar-new-workspace'); + const search = document.getElementById('document-sidebar-search'); + const clearSearch = document.getElementById('document-sidebar-search-clear'); + const tree = document.getElementById('document-tree'); + const resizer = document.getElementById('document-sidebar-resizer'); + const sidebar = document.getElementById('document-sidebar'); + + if (openButton) openButton.addEventListener('click', openDocumentSidebar); + if (closeButton) closeButton.addEventListener('click', closeDocumentSidebarOnMobile); + if (collapseButton) collapseButton.addEventListener('click', toggleDocumentSidebarCollapsed); + if (backdrop) backdrop.addEventListener('click', closeDocumentSidebarOnMobile); + if (newWorkspaceButton) newWorkspaceButton.addEventListener('click', createWorkspace); + if (newDocumentButton) newDocumentButton.addEventListener('click', function() { + const location = getPreferredDocumentLocation(); + newTab('', null, location); + }); + + document.querySelectorAll('.document-filter-btn').forEach(function(button) { + button.addEventListener('click', function() { + documentOrganization.ui.filter = button.getAttribute('data-document-filter') || 'all'; + saveDocumentOrganization(); + renderDocumentSidebar(); + }); + }); + + if (search) { + search.addEventListener('input', function() { + documentSidebarSearch = search.value.trim(); + if (clearSearch) clearSearch.hidden = !documentSidebarSearch; + renderDocumentSidebar(); + }); + search.addEventListener('keydown', function(event) { + if (event.key === 'Escape' && search.value) { + event.preventDefault(); + search.value = ''; + search.dispatchEvent(new Event('input', { bubbles: true })); + } + }); + } + if (clearSearch) clearSearch.addEventListener('click', function() { + search.value = ''; + search.dispatchEvent(new Event('input', { bubbles: true })); + search.focus(); + }); + if (tree) tree.addEventListener('keydown', handleDocumentTreeKeydown); + + if (resizer && sidebar) { + function applyResize(clientX) { + const rect = sidebar.getBoundingClientRect(); + const isRtl = getComputedStyle(sidebar).direction === 'rtl'; + const nextWidth = isRtl ? rect.right - clientX : clientX - rect.left; + documentOrganization.ui.width = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, Math.round(nextWidth))); + sidebar.style.setProperty('--document-sidebar-width', documentOrganization.ui.width + 'px'); + resizer.setAttribute('aria-valuenow', String(documentOrganization.ui.width)); + } + resizer.addEventListener('pointerdown', function(event) { + if (isDocumentSidebarMobile()) return; + isDocumentSidebarResizing = true; + sidebar.classList.add('is-resizing'); + resizer.setPointerCapture(event.pointerId); + event.preventDefault(); + }); + resizer.addEventListener('pointermove', function(event) { + if (!isDocumentSidebarResizing) return; + applyResize(event.clientX); + }); + resizer.addEventListener('pointerup', function(event) { + if (!isDocumentSidebarResizing) return; + isDocumentSidebarResizing = false; + sidebar.classList.remove('is-resizing'); + if (resizer.hasPointerCapture(event.pointerId)) resizer.releasePointerCapture(event.pointerId); + saveDocumentOrganization(); + }); + resizer.addEventListener('keydown', function(event) { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + const direction = event.key === 'ArrowRight' ? 1 : -1; + documentOrganization.ui.width = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, documentOrganization.ui.width + direction * 10)); + sidebar.style.setProperty('--document-sidebar-width', documentOrganization.ui.width + 'px'); + resizer.setAttribute('aria-valuenow', String(documentOrganization.ui.width)); + saveDocumentOrganization(); + }); + resizer.setAttribute('aria-valuenow', String(documentOrganization.ui.width)); + } + + document.addEventListener('click', function(event) { + if (!event.target.closest('.document-menu-btn, .document-menu-dropdown')) closeDocumentSidebarMenus(); + }); + document.addEventListener('keydown', function(event) { + if (event.key !== 'Escape') return; + closeDocumentSidebarMenus(); + closeDocumentSidebarOnMobile(); + }); + window.addEventListener('resize', function() { + if (!isDocumentSidebarMobile()) { + document.body.classList.remove('document-sidebar-mobile-open'); + updateDocumentSidebarVisibility(); + } + }); + updateDocumentSidebarVisibility(); + renderDocumentSidebar(); + } + // ======================================== // COMMENTS & SUGGESTIONS // ======================================== @@ -3737,7 +4966,7 @@ document.addEventListener("DOMContentLoaded", async function () { try { return stripTemporaryTabs(JSON.parse(localStorage.getItem(STORAGE_KEY)) || []).map(function(tab) { tab.reviewThreads = normalizeReviewThreads(tab.reviewThreads); - return tab; + return normalizeTabDocumentMetadata(tab); }); } catch (e) { return []; @@ -3814,19 +5043,27 @@ document.addEventListener("DOMContentLoaded", async function () { return 'Untitled ' + untitledCounter; } - function createTab(content, title, viewMode) { + function createTab(content, title, viewMode, location) { if (content === undefined) content = ''; if (title === undefined) title = null; if (viewMode === undefined) viewMode = 'split'; - return { + const createdAt = Date.now(); + const tab = { id: 'tab_' + Date.now() + '_' + Math.random().toString(36).substring(2, 8), title: title || 'Untitled', content: content, scrollPos: 0, viewMode: viewMode, reviewThreads: [], - createdAt: Date.now() + favorite: false, + lastOpenedAt: createdAt, + lastEditedAt: createdAt, + createdAt: createdAt }; + const target = location || (documentOrganization ? getPreferredDocumentLocation() : { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null }); + tab.workspaceId = target.workspaceId || DEFAULT_WORKSPACE_ID; + tab.folderId = target.folderId || null; + return tab; } function closeTabMenus() { @@ -3837,6 +5074,7 @@ document.addEventListener("DOMContentLoaded", async function () { document.querySelectorAll('.tab-menu-dropdown.open').forEach(function(dropdown) { dropdown.classList.remove('open'); }); + closeDocumentSidebarMenus(); } function removeTabMenuDropdowns() { @@ -4081,6 +5319,7 @@ document.addEventListener("DOMContentLoaded", async function () { }; renderMobileTabList(tabsArr, currentActiveTabId); + renderDocumentSidebar(); if (typeof tabList.dispatchEvent === 'function') { tabList.dispatchEvent(new Event('scroll')); } @@ -4210,6 +5449,7 @@ document.addEventListener("DOMContentLoaded", async function () { function saveCurrentTabState() { const tab = tabs.find(function(t) { return t.id === activeTabId; }); if (!tab) return; + const contentChanged = tab.content !== markdownEditor.value; tab.content = markdownEditor.value; tab.scrollPos = markdownEditor.scrollTop; tab.viewMode = reviewModeActive && reviewPreviousViewModes.has(activeTabId) @@ -4218,7 +5458,11 @@ document.addEventListener("DOMContentLoaded", async function () { if (liveCollaboration && liveCollaboration.tabId === activeTabId) { return; } + if (contentChanged && !isTemporaryDocument(tab)) { + tab.lastEditedAt = Date.now(); + } saveTabsToStorage(tabs); + if (contentChanged) renderDocumentSidebar(); } function restoreViewMode(mode) { @@ -4248,6 +5492,9 @@ document.addEventListener("DOMContentLoaded", async function () { saveActiveTabId(activeTabId); const tab = tabs.find(function(t) { return t.id === tabId; }); if (!tab) return; + selectedDocumentId = tabId; + tab.lastOpenedAt = Date.now(); + if (!isTemporaryDocument(tab)) saveTabsToStorage(tabs); markdownEditor.value = tab.content; initTabHistory(tabId, tab.content); @@ -4267,18 +5514,19 @@ document.addEventListener("DOMContentLoaded", async function () { renderTabBar(tabs, activeTabId); } - function newTab(content, title) { + function newTab(content, title, location) { if (content === undefined) content = ''; - if (tabs.length >= 20) { - alert('Maximum of 20 tabs reached. Please close an existing tab to open a new one.'); - return; - } + if (!hasDocumentCapacity()) return false; if (reviewModeActive) setReviewMode(false); if (!title) title = nextUntitledTitle(); - const tab = createTab(content, title); + const tab = createTab(content, title, 'split', location); + normalizeTabDocumentMetadata(tab); tabs.push(tab); + selectedDocumentId = tab.id; switchTab(tab.id); markdownEditor.focus(); + closeDocumentSidebarOnMobile(); + return true; } function closeTab(tabId) { @@ -4313,6 +5561,7 @@ document.addEventListener("DOMContentLoaded", async function () { const newT = createTab('', nextUntitledTitle()); tabs.push(newT); activeTabId = newT.id; + selectedDocumentId = newT.id; saveActiveTabId(activeTabId); markdownEditor.value = ''; restoreViewMode('split'); @@ -4321,6 +5570,7 @@ document.addEventListener("DOMContentLoaded", async function () { } else if (activeTabId === tabId) { const newIdx = Math.max(0, idx - 1); activeTabId = tabs[newIdx].id; + selectedDocumentId = activeTabId; saveActiveTabId(activeTabId); const newActiveTab = tabs[newIdx]; markdownEditor.value = newActiveTab.content; @@ -4331,6 +5581,7 @@ document.addEventListener("DOMContentLoaded", async function () { markdownEditor.scrollTop = newActiveTab.scrollPos || 0; }); } + if (selectedDocumentId === tabId) selectedDocumentId = activeTabId; saveTabsToStorage(tabs); renderTabBar(tabs, activeTabId); closeReviewComposer(); @@ -4398,17 +5649,18 @@ document.addEventListener("DOMContentLoaded", async function () { alert('Shared snapshot tabs are temporary and cannot be duplicated.'); return; } - if (tabs.length >= 20) { - alert('Maximum of 20 tabs reached. Please close an existing tab to open a new one.'); - return; - } + if (!hasDocumentCapacity()) return; const shouldSwitchToDuplicate = tabId === activeTabId; saveCurrentTabState(); const dupTitle = tab.title + ' (copy)'; - const dup = createTab(tab.content, dupTitle, tab.viewMode); + const dup = createTab(tab.content, dupTitle, tab.viewMode, { + workspaceId: tab.workspaceId || DEFAULT_WORKSPACE_ID, + folderId: tab.folderId || null + }); const idx = tabs.findIndex(function(t) { return t.id === tabId; }); tabs.splice(idx + 1, 0, dup); if (shouldSwitchToDuplicate) { + selectedDocumentId = dup.id; switchTab(dup.id); } else { saveTabsToStorage(tabs); @@ -4489,6 +5741,14 @@ document.addEventListener("DOMContentLoaded", async function () { applyShareSnapshotAccessMode('edit'); resetShareSnapshotLink(); tabs = []; + documentOrganization = createDefaultDocumentOrganization(); + documentSidebarSearch = ''; + saveDocumentOrganization(); + const sidebarSearchInput = document.getElementById('document-sidebar-search'); + const sidebarSearchClear = document.getElementById('document-sidebar-search-clear'); + if (sidebarSearchInput) sidebarSearchInput.value = ''; + if (sidebarSearchClear) sidebarSearchClear.hidden = true; + updateDocumentSidebarVisibility(); untitledCounter = 0; saveUntitledCounter(0); const welcome = createTab(sampleMarkdown, 'Welcome to Markdown'); @@ -4531,17 +5791,22 @@ document.addEventListener("DOMContentLoaded", async function () { function initTabs() { untitledCounter = loadUntitledCounter(); + documentOrganization = loadDocumentOrganization(); tabs = loadTabsFromStorage(); activeTabId = loadActiveTabId(); // Check if Neutralino passed an initial file via command line (early load) if (window.NL_INITIAL_FILE_CONTENT) { const initialFile = window.NL_INITIAL_FILE_CONTENT; - const tab = createTab(initialFile.content, initialFile.name); - tabs.push(tab); - activeTabId = tab.id; - saveTabsToStorage(tabs); - saveActiveTabId(activeTabId); + if (tabs.length < MAX_DOCUMENTS) { + const tab = createTab(initialFile.content, initialFile.name); + tabs.push(tab); + activeTabId = tab.id; + saveTabsToStorage(tabs); + saveActiveTabId(activeTabId); + } else { + alert('The command-line file could not be opened because the ' + MAX_DOCUMENTS + '-document limit has been reached.'); + } delete window.NL_INITIAL_FILE_CONTENT; } else if (tabs.length === 0) { const tab = createTab(sampleMarkdown, 'Welcome to Markdown'); @@ -4553,7 +5818,9 @@ document.addEventListener("DOMContentLoaded", async function () { activeTabId = tabs[0].id; saveActiveTabId(activeTabId); } + migrateDocumentsToOrganization(); const activeTab = tabs.find(function(t) { return t.id === activeTabId; }); + selectedDocumentId = activeTabId; markdownEditor.value = activeTab.content; initTabHistory(activeTabId, activeTab.content); updateUndoRedoButtons(); @@ -4568,6 +5835,7 @@ document.addEventListener("DOMContentLoaded", async function () { markdownEditor.scrollTop = activeTab.scrollPos || 0; }); renderTabBar(tabs, activeTabId); + initDocumentSidebar(); setupTabOverflow(); const staticNewBtn = document.getElementById('tab-new-btn'); @@ -6372,8 +7640,7 @@ ${selector} .arrowheadPath { } } - newTab(text, getMarkdownFileTitle(file)); - resolve(true); + resolve(newTab(text, getMarkdownFileTitle(file)) === true); }; reader.onerror = function() { alert('Failed to read the file. Please check permissions and try again.'); @@ -6395,8 +7662,18 @@ ${selector} .arrowheadPath { return 0; } + const remainingCapacity = Math.max(0, MAX_DOCUMENTS - tabs.length); + if (!remainingCapacity) { + hasDocumentCapacity(); + return 0; + } + const filesToImport = markdownFiles.slice(0, remainingCapacity); + if (filesToImport.length < markdownFiles.length) { + alert('Only the first ' + filesToImport.length + ' file' + (filesToImport.length === 1 ? '' : 's') + ' will be imported because the ' + MAX_DOCUMENTS + '-document limit would be exceeded.'); + } + let importedCount = 0; - for (const file of markdownFiles) { + for (const file of filesToImport) { if (await importMarkdownFile(file)) { importedCount++; } @@ -6709,13 +7986,22 @@ ${selector} .arrowheadPath { setGitHubImportMessage("Please select at least one file to import."); return; } + const remainingCapacity = Math.max(0, MAX_DOCUMENTS - tabs.length); + if (!remainingCapacity) { + hasDocumentCapacity(); + return; + } + if (selectedPaths.length > remainingCapacity) { + setGitHubImportMessage('Select no more than ' + remainingCapacity + ' file' + (remainingCapacity === 1 ? '' : 's') + ' to stay within the ' + MAX_DOCUMENTS + '-document limit.'); + return; + } setGitHubImportLoading(true); setGitHubImportDialogDisabled(true); announceToScreenReader("Importing selected files from GitHub..."); try { for (const selectedPath of selectedPaths) { const markdown = await fetchTextContent(buildRawGitHubUrl(owner, repo, ref, selectedPath)); - newTab(markdown, getFileName(selectedPath).replace(/\.(md|markdown)$/i, "")); + if (!newTab(markdown, getFileName(selectedPath).replace(/\.(md|markdown)$/i, ""))) break; } closeGitHubImportModal(); announceToScreenReader("Files imported successfully."); @@ -6752,7 +8038,7 @@ ${selector} .arrowheadPath { } announceToScreenReader("Fetching file from GitHub..."); const markdown = await fetchTextContent(buildRawGitHubUrl(parsed.owner, parsed.repo, parsed.ref, parsed.filePath)); - newTab(markdown, getFileName(parsed.filePath).replace(/\.(md|markdown)$/i, "")); + if (!newTab(markdown, getFileName(parsed.filePath).replace(/\.(md|markdown)$/i, ""))) return; closeGitHubImportModal(); announceToScreenReader("File imported successfully."); return; @@ -6786,7 +8072,7 @@ ${selector} .arrowheadPath { const targetPath = files[0]; announceToScreenReader("Fetching file content..."); const markdown = await fetchTextContent(buildRawGitHubUrl(parsed.owner, parsed.repo, ref, targetPath)); - newTab(markdown, getFileName(targetPath).replace(/\.(md|markdown)$/i, "")); + if (!newTab(markdown, getFileName(targetPath).replace(/\.(md|markdown)$/i, ""))) return; closeGitHubImportModal(); announceToScreenReader("File imported successfully."); return; @@ -12098,7 +13384,7 @@ ${selector} .arrowheadPath { for (const filePath of result) { const content = await Neutralino.filesystem.readFile(filePath); const fileName = filePath.split(/[/\\]/).pop().replace(/\.(md|markdown)$/i, ""); - newTab(content, fileName); + if (!newTab(content, fileName)) break; } } } catch (e) { @@ -14212,8 +15498,8 @@ ${selector} .arrowheadPath { function openShareSnapshotTab(content, title, mode) { const shareMode = mode === 'edit' ? 'edit' : 'view'; const viewMode = shareMode === 'edit' ? 'split' : 'preview'; - if (tabs.length >= 20) { - alert('The shared snapshot could not be opened because the tab limit has been reached.'); + if (tabs.length >= MAX_DOCUMENTS) { + alert('The shared snapshot could not be opened because the ' + MAX_DOCUMENTS + '-document limit has been reached.'); return false; } const snapshotTab = createTab(typeof content === 'string' ? content : '', getSafeShareSnapshotTitle(title), viewMode); @@ -15364,13 +16650,15 @@ ${selector} .arrowheadPath { function ensureLiveParticipantTab(markdown) { if (!liveCollaboration || liveCollaboration.tabId) return Boolean(liveCollaboration && liveCollaboration.tabId); if (!liveCollaboration.pendingJoinTab) return false; - if (tabs.length >= 20) { - showLiveShareExpiredModal('The Live Share room could not be opened because the tab limit has been reached.'); + if (tabs.length >= MAX_DOCUMENTS) { + showLiveShareExpiredModal('The Live Share room could not be opened because the ' + MAX_DOCUMENTS + '-document limit has been reached.'); leaveLiveSession({ restoreOriginal: false, silent: true }); return false; } const liveTab = createTab(typeof markdown === 'string' ? markdown : '', getSafeLiveTitle(liveCollaboration.roomTitle), 'split'); + liveTab.kind = 'live-share'; + liveTab.temporary = true; tabs.push(liveTab); switchTab(liveTab.id); liveCollaboration.tabId = liveTab.id; @@ -16177,12 +17465,14 @@ ${selector} .arrowheadPath { const shouldDeferParticipantTab = !isHost && options.openInNewTab && yText.length === 0; if (!isHost && options.openInNewTab && !shouldDeferParticipantTab) { - if (tabs.length >= 20) { - throw new Error('Maximum tab limit reached'); + if (tabs.length >= MAX_DOCUMENTS) { + throw new Error('Maximum document limit reached'); } returnTabId = options.returnTabId || activeTabId; const liveTabTitle = getSafeLiveTitle(sessionTitle); const liveTab = createTab(yText.toString(), liveTabTitle, 'split'); + liveTab.kind = 'live-share'; + liveTab.temporary = true; tabs.push(liveTab); switchTab(liveTab.id); liveTabId = liveTab.id; diff --git a/styles.css b/styles.css index 848a3169..a2c54fc6 100644 --- a/styles.css +++ b/styles.css @@ -120,6 +120,577 @@ body { overflow: hidden; } +/* ======================================== + DOCUMENT MANAGEMENT SIDEBAR + ======================================== */ + +.document-workspace-shell { + display: flex; + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; + position: relative; +} + +.document-editor-area { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.document-sidebar { + --document-sidebar-width: 288px; + position: relative; + display: flex; + flex: 0 0 var(--document-sidebar-width); + flex-direction: column; + width: var(--document-sidebar-width); + min-width: 220px; + max-width: 420px; + min-height: 0; + background: var(--header-bg); + border-inline-end: 1px solid var(--border-color); + color: var(--text-color); + z-index: 35; +} + +.document-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 58px; + padding: 10px 12px 8px; + gap: 8px; +} + +.document-sidebar-eyebrow { + margin: 0 0 1px; + color: var(--text-secondary); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + line-height: 1.2; + text-transform: uppercase; +} + +.document-sidebar-header h2 { + margin: 0; + color: var(--text-color); + font-size: 15px; + font-weight: 650; + line-height: 1.3; +} + +.document-sidebar-create-row { + display: flex; + align-items: center; + gap: 6px; + padding: 0 10px 10px; +} + +.document-sidebar-primary-btn, +.document-sidebar-icon-btn, +.document-sidebar-collapse-btn, +.document-filter-btn, +.document-tree-main, +.document-tree-toggle, +.document-tree-action, +.document-tree-add { + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + color: var(--text-color); + cursor: pointer; + font: inherit; + transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, opacity 0.15s ease; + touch-action: manipulation; +} + +.document-sidebar-primary-btn:focus-visible, +.document-sidebar-icon-btn:focus-visible, +.document-sidebar-collapse-btn:focus-visible, +.document-filter-btn:focus-visible, +.document-tree-main:focus-visible, +.document-tree-toggle:focus-visible, +.document-tree-action:focus-visible, +.document-tree-add:focus-visible, +#document-sidebar-search-clear:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: 2px; +} + +.document-sidebar-primary-btn { + display: inline-flex; + flex: 1; + align-items: center; + justify-content: center; + min-width: 0; + min-height: 34px; + gap: 7px; + padding: 6px 10px; + background: var(--accent-color); + border-color: var(--accent-color); + color: var(--bg-color); + font-size: 12px; + font-weight: 600; +} + +[data-theme="dark"] .document-sidebar-primary-btn { + color: var(--bg-color); +} + +.document-sidebar-primary-btn:hover { + filter: brightness(0.96); +} + +.document-sidebar-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 34px; + width: 34px; + height: 34px; + border-color: var(--border-color); + background: var(--button-bg); +} + +.document-sidebar-icon-btn:hover, +.document-sidebar-collapse-btn:hover, +.document-filter-btn:hover, +.document-tree-main:hover, +.document-tree-toggle:hover, +.document-tree-action:hover, +.document-tree-add:hover { + background: var(--button-hover); +} + +.document-sidebar-mobile-close { + display: none; +} + +.document-sidebar-search-wrap { + position: relative; + display: flex; + align-items: center; + margin: 0 10px 9px; +} + +.document-sidebar-search-wrap > i { + position: absolute; + inset-inline-start: 10px; + color: var(--text-secondary); + font-size: 12px; + pointer-events: none; +} + +#document-sidebar-search { + width: 100%; + height: 32px; + padding: 5px 32px; + border: 1px solid var(--border-color); + border-radius: 6px; + outline: none; + background: var(--bg-color); + color: var(--text-color); + font-size: 12px; +} + +#document-sidebar-search::placeholder { + color: var(--text-secondary); +} + +#document-sidebar-search:focus { + border-color: var(--accent-color); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-color) 18%, transparent); +} + +#document-sidebar-search-clear { + position: absolute; + inset-inline-end: 3px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; +} + +.document-sidebar-filters { + display: grid; + gap: 2px; + padding: 0 8px 9px; + border-bottom: 1px solid var(--border-color); +} + +.document-filter-btn { + display: flex; + align-items: center; + width: 100%; + min-height: 31px; + gap: 9px; + padding: 5px 8px; + color: var(--text-secondary); + font-size: 12px; + text-align: start; +} + +.document-filter-btn i { + width: 16px; + font-size: 13px; + text-align: center; +} + +.document-filter-btn.is-active { + background: var(--button-active); + color: var(--text-color); + font-weight: 600; +} + +.document-sidebar-status { + min-height: 0; + padding: 7px 12px 0; + color: var(--text-secondary); + font-size: 11px; +} + +.document-sidebar-status:empty { + display: none; +} + +.document-tree { + flex: 1; + min-height: 0; + padding: 6px 6px 14px; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +} + +.document-tree-empty { + display: grid; + place-items: center; + min-height: 132px; + padding: 20px 12px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; + text-align: center; +} + +.document-tree-section-label { + margin: 8px 7px 4px; + color: var(--text-secondary); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.document-tree-row { + --tree-depth: 0; + position: relative; + display: flex; + align-items: center; + min-width: 0; + min-height: 32px; + margin: 1px 0; + padding-inline-start: calc(var(--tree-depth) * 15px); + border-radius: 5px; +} + +.document-tree-row:focus { + outline: none; +} + +.document-tree-row:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: -2px; +} + +.document-tree-row.is-selected { + background: var(--button-hover); +} + +.document-tree-row.is-active { + background: var(--button-active); + color: var(--accent-color); +} + +.document-tree-row.is-active::before { + content: ''; + position: absolute; + inset-block: 5px; + inset-inline-start: 0; + width: 2px; + border-radius: 2px; + background: var(--accent-color); +} + +.document-tree-main { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + min-height: 30px; + gap: 7px; + padding: 4px 5px; + text-align: start; +} + +.document-tree-main > i { + flex: 0 0 16px; + color: var(--text-secondary); + font-size: 13px; + text-align: center; +} + +.document-tree-row.is-active .document-tree-main > i, +.document-tree-row.is-favorite .document-tree-main > .document-favorite-indicator { + color: var(--accent-color); +} + +.document-tree-label { + flex: 1; + min-width: 0; + overflow: hidden; + color: inherit; + font-size: 12px; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} + +.document-tree-row[data-tree-type="workspace"] .document-tree-label { + font-weight: 650; +} + +.document-tree-meta { + flex: 0 0 auto; + color: var(--text-secondary); + font-size: 10px; +} + +.document-favorite-indicator { + flex: 0 0 auto; + color: var(--accent-color); + font-size: 10px; +} + +.document-tree-toggle, +.document-tree-action, +.document-tree-add { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 28px; + width: 28px; + height: 28px; + padding: 0; + color: var(--text-secondary); + font-size: 12px; +} + +.document-tree-toggle { + flex-basis: 24px; + width: 24px; +} + +.document-tree-action, +.document-tree-add { + opacity: 0.56; +} + +.document-tree-row:hover .document-tree-action, +.document-tree-row:hover .document-tree-add, +.document-tree-row:focus-within .document-tree-action, +.document-tree-row:focus-within .document-tree-add { + opacity: 1; +} + +.document-tree-group[hidden] { + display: none; +} + +.document-tree-temporary .document-tree-main > i { + color: var(--accent-color); +} + +.document-sidebar-footer { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 42px; + padding: 6px 8px 6px 12px; + gap: 8px; + border-top: 1px solid var(--border-color); + color: var(--text-secondary); + font-size: 10px; +} + +.document-sidebar-collapse-btn { + display: inline-flex; + align-items: center; + min-height: 28px; + gap: 6px; + padding: 3px 7px; + color: var(--text-secondary); + font-size: 11px; +} + +.document-sidebar-resizer { + position: absolute; + inset-block: 0; + inset-inline-end: -3px; + width: 6px; + cursor: col-resize; + touch-action: none; + z-index: 2; +} + +.document-sidebar-resizer:hover, +.document-sidebar-resizer:focus-visible, +.document-sidebar.is-resizing .document-sidebar-resizer { + background: color-mix(in srgb, var(--accent-color) 42%, transparent); + outline: none; +} + +.document-sidebar-backdrop { + display: none; +} + +.tool-button.document-sidebar-open { + display: none; + margin-inline-end: 6px; +} + +body.document-sidebar-collapsed .document-sidebar { + display: none; +} + +body.document-sidebar-collapsed .tool-button.document-sidebar-open { + display: inline-flex; +} + +.document-modal-error { + margin: 6px 0 0; + color: var(--color-danger-fg); + font-size: 12px; +} + +@media (min-width: 768px) and (max-width: 1079px) { + .document-sidebar { + --document-sidebar-width: 236px; + min-width: 210px; + max-width: 320px; + } +} + +@media (max-width: 767px) { + .tool-button.document-sidebar-open, + body.document-sidebar-collapsed .tool-button.document-sidebar-open { + display: inline-flex; + min-width: 44px; + min-height: 44px; + } + + .document-sidebar { + --document-sidebar-width: min(88vw, 340px); + position: fixed; + inset-block: 0; + inset-inline-start: 0; + width: var(--document-sidebar-width); + min-width: min(88vw, 280px); + max-width: min(88vw, 340px); + padding-block-start: env(safe-area-inset-top, 0px); + padding-block-end: env(safe-area-inset-bottom, 0px); + box-shadow: 12px 0 32px rgba(31, 35, 40, 0.2); + transform: translateX(-102%); + transition: transform 0.2s ease; + z-index: 1002; + } + + [dir="rtl"] .document-sidebar { + transform: translateX(102%); + } + + body.document-sidebar-mobile-open .document-sidebar, + [dir="rtl"] body.document-sidebar-mobile-open .document-sidebar { + display: flex; + transform: translateX(0); + } + + .document-sidebar-mobile-close { + display: inline-flex; + } + + .document-sidebar-resizer, + .document-sidebar-collapse-btn { + display: none; + } + + body.document-sidebar-mobile-open .document-sidebar-backdrop { + position: fixed; + inset: 0; + display: block; + border: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 1001; + } + + .document-sidebar-primary-btn, + .document-sidebar-icon-btn, + .document-filter-btn, + .document-tree-row, + .document-tree-main, + .document-tree-toggle, + .document-tree-action, + .document-tree-add, + #document-sidebar-search-clear { + min-height: 44px; + } + + .document-sidebar-icon-btn, + .document-tree-toggle, + .document-tree-action, + .document-tree-add { + width: 44px; + flex-basis: 44px; + } + + #document-sidebar-search { + height: 44px; + font-size: 16px; + } + + .document-sidebar-footer { + min-height: 48px; + } +} + +@media (prefers-reduced-motion: reduce) { + .document-sidebar, + .document-sidebar-primary-btn, + .document-sidebar-icon-btn, + .document-sidebar-collapse-btn, + .document-filter-btn, + .document-tree-main, + .document-tree-toggle, + .document-tree-action, + .document-tree-add { + transition: none; + } +} + .content-container { display: flex; flex: 1; @@ -6690,6 +7261,8 @@ html[data-theme="dark"] .mermaid svg { } .app-header, + .document-sidebar, + .document-sidebar-backdrop, .tab-bar, .markdown-format-toolbar, .editor-pane, @@ -6744,6 +7317,8 @@ html[data-theme="dark"] .mermaid svg { } .app-container, + .document-workspace-shell, + .document-editor-area, .content-container, .markdown-body { height: auto !important; diff --git a/wiki/Configuration.md b/wiki/Configuration.md index 616ca6ba..7f45b035 100644 --- a/wiki/Configuration.md +++ b/wiki/Configuration.md @@ -7,6 +7,7 @@ This page documents the runtime, storage, dependency, Docker, Cloudflare, and de | Key | Location | Purpose | | :--- | :--- | :--- | | `markdownViewerTabs` | `localStorage`, mirrored to Neutralino storage in desktop | Normal saved tabs, including local review threads. Temporary share/live tabs are excluded. | +| `markdownViewerDocumentOrganization` | `localStorage`, mirrored to Neutralino storage in desktop | Workspace/folder definitions, expansion state, sidebar filter, sidebar width/collapse state, and last creation location. | | `markdownViewerActiveTab` | `localStorage`, mirrored in desktop | Active tab id. | | `markdownViewerUntitledCounter` | `localStorage`, mirrored in desktop | Next Untitled document number. | | `markdownViewerGlobalState` | `localStorage`, mirrored in desktop | Theme, direction, view mode, scroll sync, and other global UI preferences. | @@ -19,9 +20,9 @@ The desktop app starts by copying known Neutralino storage values back into `loc Temporary shared content is intentionally not persisted: - Share Snapshot tabs have `kind: "share-snapshot"`. -- Live Share participant/host tabs are stripped or restored when leaving the session. +- Live Share participant tabs use `kind: "live-share"` plus `temporary: true`; host documents are restored when leaving the session. -Private mode clears the normal document-state keys (`markdownViewerTabs`, `markdownViewerActiveTab`, `markdownViewerUntitledCounter`, and `markdownViewerGlobalState`) when enabled and prevents them from being written until the mode is turned off. Use **Clear local data** in the About dialog to clear the same local state without enabling private mode. +Private mode clears the normal document-state keys (`markdownViewerTabs`, `markdownViewerDocumentOrganization`, `markdownViewerActiveTab`, `markdownViewerUntitledCounter`, and `markdownViewerGlobalState`) when enabled and prevents them from being written until the mode is turned off. Use **Clear local data** in the About dialog to clear the same local state without enabling private mode. ## Client Libraries diff --git a/wiki/Features.md b/wiki/Features.md index d70ec621..4e1843a3 100644 --- a/wiki/Features.md +++ b/wiki/Features.md @@ -10,8 +10,10 @@ Most work happens in the browser or desktop webview. Markdown parsing, syntax hi ## Main Workspace -The app opens with a header, document tab bar, formatting toolbar, editor pane, resize divider, and preview pane. +The app opens with a header, document-management sidebar, document tab bar, formatting toolbar, editor pane, resize divider, and preview pane. +- The left sidebar organizes documents into workspaces and one-level folders, with All Documents, Recent, Favorites, and search views. +- The sidebar is resizable and collapsible on desktop, narrower on tablet, and becomes a full-height drawer on mobile. - Editor mode shows only the textarea. - Split mode shows the editor and preview side by side. - Preview mode shows only the rendered document. @@ -22,14 +24,17 @@ The app opens with a header, document tab bar, formatting toolbar, editor pane, The editor includes line numbers, wrapped-line height handling, a highlight layer for find results, live cursor overlays during Live Share, and skeleton placeholders during initial or heavy rendering. Line-number calculations are cached so large documents do not force a full layout measurement on every keystroke. -## Document Tabs and Local Workspace Storage +## Document Sidebar, Tabs, and Local Workspace Storage Users can work with multiple documents at once. -- New tabs can be created from the tab bar, mobile menu, imports, shared snapshots, and Live Share joins. +- Every existing saved document is migrated into **Default Workspace**. Users can add, rename, expand, collapse, and delete custom workspaces, and add one level of folders within them. +- Deleting a folder moves its documents to the workspace root. Deleting a custom workspace moves its documents to Default Workspace, after confirmation, so container deletion does not discard content. +- New documents can be created from the sidebar, tab bar, mobile menu, imports, shared snapshots, and Live Share joins. +- The sidebar supports document open, rename, duplicate, favorite, move, Markdown download, and delete actions. Recent and Favorites are filtered references to the original documents, not copies. - Tabs can be renamed, duplicated, deleted, and reordered by drag and drop. -- The app enforces a practical tab limit of 20 tabs. Shared snapshots refuse to open when this limit has been reached. -- Each normal tab stores a title, content, scroll position, view mode, local review threads, and creation time. +- The app enforces a consistent limit of 50 open documents. New documents, duplication, local/GitHub imports, Share Snapshot, and Live Share joins all use this limit. +- Each normal tab stores a title, content, workspace/folder location, favorite state, recent activity metadata, scroll position, view mode, local review threads, and creation time. - The active tab id and untitled-document counter are stored separately. - Temporary Share Snapshot and Live Share tabs are deliberately excluded from persistent tab storage. - The Reset button clears the current saved workspace and returns the app to a clean starting state. @@ -39,6 +44,7 @@ Storage keys used by the current implementation include: | Key | What It Stores | | :--- | :--- | | `markdownViewerTabs` | Normal saved document tabs, including local comments and suggestions. Temporary shared/live tabs are stripped before saving. | +| `markdownViewerDocumentOrganization` | Workspaces, folders, expanded state, active sidebar filter, sidebar width/collapse state, and the last creation location. | | `markdownViewerActiveTab` | The active tab id. | | `markdownViewerUntitledCounter` | Counter used for new Untitled tab names. | | `markdownViewerGlobalState` | Theme, direction, view preferences, scroll sync, and similar global UI state. | @@ -50,7 +56,7 @@ On the web, these values live in browser `localStorage`. In the desktop app, the The About dialog includes two storage controls: - **Private mode** removes saved document/workspace state and prevents normal document-state keys from being written while it is enabled. The private-mode preference itself remains so the behavior survives a reload. -- **Clear local data** removes saved document tabs, active-tab state, the untitled-tab counter, global workspace preferences, and their desktop storage mirrors. It does not revoke links that were already shared. +- **Clear local data** removes saved document tabs, document organization, active-tab state, the untitled-tab counter, global workspace preferences, and their desktop storage mirrors. It does not revoke links that were already shared. ## Comments and Suggestion Mode diff --git a/wiki/Usage-Guide.md b/wiki/Usage-Guide.md index f5568817..278e5f0b 100644 --- a/wiki/Usage-Guide.md +++ b/wiki/Usage-Guide.md @@ -4,26 +4,31 @@ This guide explains how to use Markdown Viewer as a browser-based Markdown edito ## Workspace Layout for Editing and Previewing Markdown -The app has five main areas: +The app has six main areas: | Area | What It Does | | :--- | :--- | | Header | Shows app name, stats, view controls, import/export, copy, sharing, language, and theme controls. | -| Tab bar | Holds up to 20 normal document tabs, plus temporary shared/live tabs. | +| Document sidebar | Organizes up to 50 documents into workspaces and folders, with All Documents, Recent, Favorites, and search. | +| Tab bar | Preserves quick switching and drag-to-reorder for open documents. | | Formatting toolbar | Inserts Markdown syntax, opens helper modals, starts find/replace, and toggles fullscreen/help/about. | | Editor pane | Plain-text Markdown textarea with line numbers, custom undo/redo, list continuation, and find highlights. | | Preview pane | Sanitized rendered Markdown with math, diagrams, maps, models, music, alerts, and syntax highlighting. | -Use the view buttons to switch between Editor, Split, and Preview. Split view is the main live Markdown preview workflow: type, paste, or open Markdown on one side and read the rendered result on the other. Sync scrolling can keep the source and preview aligned while you write. Drag the divider to change the pane widths. The app prevents either side from becoming too narrow. On small screens, the mobile menu exposes the same main actions without forcing a cramped split layout. +Use the view buttons to switch between Editor, Split, and Preview. Split view is the main live Markdown preview workflow: type, paste, or open Markdown on one side and read the rendered result on the other. Sync scrolling can keep the source and preview aligned while you write. Drag the divider to change the pane widths. The app prevents either side from becoming too narrow. On small screens, the document sidebar opens as a full-height drawer and the mobile menu exposes the remaining main actions without forcing a cramped split layout. -## Tabs and Autosave +## Documents, Workspaces, and Autosave -- Click New Tab to create a document. -- Rename a tab from its menu or rename action. -- Duplicate, delete, or reorder tabs from the tab UI. +- Use **New document** in the sidebar to create in the last selected workspace or folder. Use the plus beside a workspace or folder to create directly inside it. +- Create custom workspaces with the folder-plus button. Workspaces contain documents and optional one-level folders. +- Use **All Documents** for the hierarchy, **Recent** for recently opened or edited documents, and **Favorites** for starred documents. +- On desktop, one click selects a sidebar document and a double click opens it. On touch layouts, one tap opens it and closes the drawer. +- Each document menu supports Open, Rename, Duplicate, Favorites, Move, Download Markdown, and Delete. The tab strip still supports quick switching and drag-to-reorder. +- Deleting a folder moves its documents to the workspace root. Deleting a custom workspace moves its documents to Default Workspace after confirmation. - Closing the last normal tab resets to a clean document. - Reset clears the saved workspace. - Normal tabs autosave to local browser storage or desktop storage. +- The document limit is 50 across creation, duplication, imports, Share Snapshot, and Live Share joins. - Temporary Share Snapshot and Live Share tabs are not saved to the recipient's workspace. - Use **Private mode** from the About dialog to clear existing document state and prevent normal document-state persistence while it is enabled. - Use **Clear local data** from the About dialog to remove saved tabs, active-tab state, workspace preferences, and desktop storage mirrors. From 115e9e43a890b48c9bf5370c9733aa9a00b9669b Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Fri, 17 Jul 2026 15:46:46 +0530 Subject: [PATCH 02/40] Refine file sidebar and add secret workspace --- README.md | 3 +- index.html | 84 +++- script.js | 983 ++++++++++++++++++++++++++++++++++-------- styles.css | 292 +++++++++++-- wiki/Configuration.md | 5 +- wiki/Features.md | 21 +- wiki/Usage-Guide.md | 17 +- 7 files changed, 1148 insertions(+), 257 deletions(-) diff --git a/README.md b/README.md index 91168de7..74ae9daa 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ For the full feature list, details, limitations, and privacy notes, see the [fea ## Markdown Editing and Live Preview - Write plain Markdown in a focused editor while the live preview renders GitHub-Flavored Markdown, syntax highlighting, math, alerts, footnotes, tables, task lists, and sanitized HTML. -- Organize up to 50 Markdown documents in a responsive left sidebar with workspaces, one-level folders, search, Recent, Favorites, move actions, and automatic migration into Default Workspace. The tab strip remains available for quick switching and reordering. +- Organize up to 50 Markdown files in a closable, responsive left sidebar with fixed Default and Secret workspaces, one-level folders, search, Recent, Favorites, and drag-and-drop moves. Secret Workspace content is password-encrypted on the device, and multi-file imports show compact progress without blocking the editor. - Use WYSIWYG-style toolbar helpers for common Markdown syntax while keeping full control of the plain-text Markdown source. - Preview large documents with debounced rendering and a background worker so typing stays responsive. @@ -276,6 +276,7 @@ Network use is user-triggered for features such as GitHub import, remote diagram ## Security and Privacy Controls - Preview HTML is sanitized before insertion, and exported HTML includes a restrictive CSP plus SRI metadata for its external assets. +- Secret Workspace derives a local encryption key from the user's password with PBKDF2-SHA-256 and encrypts its files and folder names with AES-GCM. The key is kept only for the unlocked browser session, and forgotten passwords cannot be recovered. - Cloudflare Pages deployments use `_headers` for CSP, clickjacking protection, referrer and permissions policies, and no-sniff protection; sensitive paths are redirected to 404 responses. - Stored Share Snapshot API CORS is limited to the production app, `null`, and local development origins. Stored responses are `no-store`, and creators receive a deletion token from the API. - STL rendering rejects oversized sources, non-finite geometry, and excessive vertex counts before WebGL rendering. diff --git a/index.html b/index.html index 97db8045..b8a7883a 100644 --- a/index.html +++ b/index.html @@ -133,7 +133,7 @@
-

Markdown Viewer - Online Markdown Editor with Live Preview

@@ -254,9 +254,9 @@

Menu

- Documents -
@@ -378,29 +378,27 @@

Menu

@@ -435,8 +429,8 @@

Documents

-
+ +