diff --git a/index.html b/index.html index b1bfdbe..53575ba 100644 --- a/index.html +++ b/index.html @@ -307,7 +307,7 @@

Menu

- +
@@ -373,15 +373,15 @@

Menu

diff --git a/script.js b/script.js index a724848..a74971a 100644 --- a/script.js +++ b/script.js @@ -56,6 +56,16 @@ document.addEventListener("DOMContentLoaded", function () { let activeFindIndex = -1; let lastFindQuery = ''; + // Custom Editor History State Manager variables + const tabHistories = {}; + let currentHistoryTabId = null; + let lastPushedValue = ''; + let typingTimeout = null; + let lastInputType = null; // 'insert', 'delete', 'programmatic', or null + let lastCursorStart = 0; + let lastCursorEnd = 0; + let pendingState = null; + const markdownEditor = document.getElementById("markdown-editor"); const markdownPreview = document.getElementById("markdown-preview"); const markdownFormatToolbar = document.getElementById("markdown-format-toolbar"); @@ -1270,11 +1280,26 @@ document.addEventListener("DOMContentLoaded", function () { function switchTab(tabId) { if (tabId === activeTabId) return; saveCurrentTabState(); + + // Clear typing timeout and reset tracking for the new tab + if (typingTimeout) { + clearTimeout(typingTimeout); + typingTimeout = null; + } + lastInputType = null; + pendingState = null; + activeTabId = tabId; saveActiveTabId(activeTabId); const tab = tabs.find(function(t) { return t.id === tabId; }); if (!tab) return; markdownEditor.value = tab.content; + + initTabHistory(tabId, tab.content); + lastPushedValue = tab.content; + currentHistoryTabId = tabId; + updateUndoRedoButtons(); + restoreViewMode(tab.viewMode); renderMarkdown(); requestAnimationFrame(function() { @@ -1299,6 +1324,12 @@ document.addEventListener("DOMContentLoaded", function () { function closeTab(tabId) { const idx = tabs.findIndex(function(t) { return t.id === tabId; }); if (idx === -1) return; + + // Clean up history of the closed tab + if (tabHistories[tabId]) { + delete tabHistories[tabId]; + } + tabs.splice(idx, 1); if (tabs.length === 0) { // Auto-create new "Untitled" when last tab is deleted @@ -1465,6 +1496,8 @@ document.addEventListener("DOMContentLoaded", function () { } const activeTab = tabs.find(function(t) { return t.id === activeTabId; }); markdownEditor.value = activeTab.content; + initTabHistory(activeTabId, activeTab.content); + updateUndoRedoButtons(); restoreViewMode(activeTab.viewMode); renderMarkdown(); const editorPane = document.querySelector('.editor-pane'); @@ -2382,12 +2415,15 @@ document.addEventListener("DOMContentLoaded", function () { } function replaceEditorRange(start, end, replacement, selectStart, selectEnd) { + pushProgrammaticHistoryState(); markdownEditor.focus(); markdownEditor.setRangeText(replacement, start, end, 'end'); const nextStart = typeof selectStart === 'number' ? selectStart : start + replacement.length; const nextEnd = typeof selectEnd === 'number' ? selectEnd : nextStart; markdownEditor.setSelectionRange(nextStart, nextEnd); markdownEditor.dispatchEvent(new Event('input', { bubbles: true })); + lastPushedValue = markdownEditor.value; + lastInputType = 'programmatic'; } function wrapEditorSelection(prefix, suffix, placeholder) { @@ -2591,6 +2627,286 @@ document.addEventListener("DOMContentLoaded", function () { .replace(/`([^`]+)`/g, '$1'); } + function getOrCreateTabHistory(tabId) { + if (!tabId) return { undoStack: [], redoStack: [] }; + if (!tabHistories[tabId]) { + tabHistories[tabId] = { + undoStack: [], + redoStack: [] + }; + } + return tabHistories[tabId]; + } + + function initTabHistory(tabId, initialValue) { + const hist = getOrCreateTabHistory(tabId); + if (hist.undoStack.length === 0) { + hist.undoStack.push({ + value: initialValue || '', + selectionStart: 0, + selectionEnd: 0 + }); + lastPushedValue = initialValue || ''; + currentHistoryTabId = tabId; + pendingState = null; + } + } + + function pushProgrammaticHistoryState() { + if (typingTimeout) { + clearTimeout(typingTimeout); + typingTimeout = null; + } + + const tabId = activeTabId; + const hist = getOrCreateTabHistory(tabId); + const currentValue = markdownEditor.value; + + if (pendingState) { + hist.undoStack.push(pendingState); + if (hist.undoStack.length > 200) { + hist.undoStack.shift(); + } + hist.redoStack.length = 0; + pendingState = null; + lastPushedValue = currentValue; + } else if (currentValue !== lastPushedValue) { + hist.undoStack.push({ + value: currentValue, + selectionStart: markdownEditor.selectionStart, + selectionEnd: markdownEditor.selectionEnd + }); + if (hist.undoStack.length > 200) { + hist.undoStack.shift(); + } + hist.redoStack.length = 0; + lastPushedValue = currentValue; + } + updateUndoRedoButtons(); + } + + function commitPendingState() { + if (typingTimeout) { + clearTimeout(typingTimeout); + typingTimeout = null; + } + if (!pendingState) return; + + const tabId = activeTabId; + const hist = getOrCreateTabHistory(tabId); + + hist.undoStack.push(pendingState); + if (hist.undoStack.length > 200) { + hist.undoStack.shift(); + } + + hist.redoStack.length = 0; + lastPushedValue = markdownEditor.value; + pendingState = null; + updateUndoRedoButtons(); + } + + function handleKeystrokeHistory(e) { + const currentValue = markdownEditor.value; + if (currentValue === lastPushedValue) return; + + const inputType = e ? e.inputType : ''; + + if (!pendingState) { + pendingState = { + value: lastPushedValue, + selectionStart: lastCursorStart, + selectionEnd: lastCursorEnd + }; + } + + let shouldCommit = false; + + if (inputType === 'insertLineBreak' || inputType === 'insertParagraph' || inputType === 'insertFromPaste' || lastInputType === 'programmatic') { + shouldCommit = true; + } else if (e && e.data === ' ') { + shouldCommit = true; + } else { + const isDelete = inputType.startsWith('delete'); + const wasDelete = lastInputType === 'delete'; + const isInsert = inputType.startsWith('insert'); + const wasInsert = lastInputType === 'insert'; + + if ((isDelete && wasInsert) || (isInsert && wasDelete)) { + shouldCommit = true; + } + } + + if (shouldCommit) { + commitPendingState(); + } + + if (typingTimeout) { + clearTimeout(typingTimeout); + } + typingTimeout = setTimeout(function() { + commitPendingState(); + }, 1000); + + if (inputType.startsWith('delete')) { + lastInputType = 'delete'; + } else if (inputType.startsWith('insert')) { + lastInputType = 'insert'; + } else { + lastInputType = 'other'; + } + } + + function updateLastCursor() { + if (markdownEditor) { + lastCursorStart = markdownEditor.selectionStart; + lastCursorEnd = markdownEditor.selectionEnd; + } + } + + function updateUndoRedoButtons() { + const undoBtn = document.querySelector('[data-md-action="undo"]'); + const redoBtn = document.querySelector('[data-md-action="redo"]'); + if (!undoBtn || !redoBtn) return; + + const tabId = activeTabId; + const hist = getOrCreateTabHistory(tabId); + + const canUndo = hist.undoStack.length > 0 || pendingState !== null; + const canRedo = hist.redoStack.length > 0; + + undoBtn.disabled = !canUndo; + undoBtn.classList.toggle('disabled', !canUndo); + + redoBtn.disabled = !canRedo; + redoBtn.classList.toggle('disabled', !canRedo); + } + + function executeUndo() { + if (typingTimeout) { + clearTimeout(typingTimeout); + typingTimeout = null; + } + + const tabId = activeTabId; + const hist = getOrCreateTabHistory(tabId); + const currentValue = markdownEditor.value; + + let stateToRestore = null; + + if (pendingState) { + stateToRestore = pendingState; + pendingState = null; + + hist.redoStack.push({ + value: currentValue, + selectionStart: markdownEditor.selectionStart, + selectionEnd: markdownEditor.selectionEnd + }); + if (hist.redoStack.length > 200) { + hist.redoStack.shift(); + } + } else if (hist.undoStack.length > 0) { + const topState = hist.undoStack.pop(); + if (topState) { + stateToRestore = topState; + + hist.redoStack.push({ + value: currentValue, + selectionStart: markdownEditor.selectionStart, + selectionEnd: markdownEditor.selectionEnd + }); + if (hist.redoStack.length > 200) { + hist.redoStack.shift(); + } + } + } + + if (stateToRestore) { + markdownEditor.value = stateToRestore.value; + markdownEditor.setSelectionRange(stateToRestore.selectionStart, stateToRestore.selectionEnd); + lastPushedValue = stateToRestore.value; + lastInputType = null; + + markdownEditor.dispatchEvent(new Event('input', { bubbles: true })); + saveCurrentTabState(); + } + + updateUndoRedoButtons(); + } + + function executeRedo() { + if (typingTimeout) { + clearTimeout(typingTimeout); + typingTimeout = null; + } + + const tabId = activeTabId; + const hist = getOrCreateTabHistory(tabId); + const currentValue = markdownEditor.value; + + if (hist.redoStack.length > 0) { + const stateToRestore = hist.redoStack.pop(); + + hist.undoStack.push({ + value: currentValue, + selectionStart: markdownEditor.selectionStart, + selectionEnd: markdownEditor.selectionEnd + }); + if (hist.undoStack.length > 200) { + hist.undoStack.shift(); + } + + markdownEditor.value = stateToRestore.value; + markdownEditor.setSelectionRange(stateToRestore.selectionStart, stateToRestore.selectionEnd); + lastPushedValue = stateToRestore.value; + lastInputType = null; + pendingState = null; + + markdownEditor.dispatchEvent(new Event('input', { bubbles: true })); + saveCurrentTabState(); + } + + updateUndoRedoButtons(); + } + + function stripMarkdownFormatting(text) { + if (!text) return ''; + return text + // Remove fenced code block syntax + .replace(/^```[a-zA-Z0-9-]*\r?\n?/gm, '') + .replace(/```\r?$/gm, '') + // Remove reference link definitions (e.g., [id]: url "title") + .replace(/^\[[^\]]+\]:\s*\S+(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*$/gm, '') + // Strip basic markdown constructs (headers, blockquotes, lists, bold, italic, strikethrough, code) + .replace(/^#{1,6}\s+/gm, '') + .replace(/^>\s?/gm, '') + .replace(/^(\s*)([-*+]|\d+\.)\s+/gm, '$1') + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + // HTML alignment tags or custom tags (strip the tags, keep inner text) + .replace(/<[^>]+>/g, '') + // Bold, Italic, Strikethrough, Inline code + .replace(/(\*\*|__)(.*?)\1/g, '$2') + .replace(/(\*|_)(.*?)\1/g, '$2') + .replace(/~~(.*?)~~/g, '$1') + .replace(/`([^`]+)`/g, '$1') + // Remove horizontal rules + .replace(/^\s*[-*_]{3,}\s*$/gm, ''); + } + + function applyClearFormatting() { + const fullText = markdownEditor.value; + pushProgrammaticHistoryState(); + replaceEditorRange(0, fullText.length, '', 0, 0); + + // Force immediate visual rendering and gutter update + renderMarkdown(); + updateLineNumbers(); + updateFindHighlights(); + saveCurrentTabState(); + } + function toTitleCase(text) { return text.toLowerCase().replace(/\b\w/g, function(letter) { return letter.toUpperCase(); @@ -4788,10 +5104,12 @@ document.addEventListener("DOMContentLoaded", function () { } function runMarkdownTool(action, button) { - if (action === 'undo' || action === 'redo') { - markdownEditor.focus(); - document.execCommand(action); - markdownEditor.dispatchEvent(new Event('input', { bubbles: true })); + if (action === 'undo') { + executeUndo(); + return; + } + if (action === 'redo') { + executeRedo(); return; } @@ -5123,7 +5441,8 @@ document.addEventListener("DOMContentLoaded", function () { }); }); - markdownEditor.addEventListener("input", function() { + markdownEditor.addEventListener("input", function(e) { + handleKeystrokeHistory(e); debouncedRender(); clearTimeout(saveTabStateTimeout); saveTabStateTimeout = setTimeout(saveCurrentTabState, 500); @@ -5135,6 +5454,12 @@ document.addEventListener("DOMContentLoaded", function () { scheduleLineNumberUpdate(); }); + markdownEditor.addEventListener('keydown', updateLastCursor); + markdownEditor.addEventListener('keyup', updateLastCursor); + markdownEditor.addEventListener('mousedown', updateLastCursor); + markdownEditor.addEventListener('mouseup', updateLastCursor); + markdownEditor.addEventListener('focus', updateLastCursor); + initMarkdownFormatToolbar(); initFindReplaceModal(); initAppModals(); @@ -6551,6 +6876,19 @@ document.addEventListener("DOMContentLoaded", function () { } document.addEventListener("keydown", function (e) { + if (document.activeElement === markdownEditor) { + const isCmdOrCtrl = e.ctrlKey || e.metaKey; + if (isCmdOrCtrl && !e.shiftKey && e.key.toLowerCase() === 'z') { + e.preventDefault(); + executeUndo(); + return; + } else if ((isCmdOrCtrl && e.shiftKey && e.key.toLowerCase() === 'z') || (isCmdOrCtrl && e.key.toLowerCase() === 'y')) { + e.preventDefault(); + executeRedo(); + return; + } + } + if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); exportMd.click(); diff --git a/styles.css b/styles.css index fb88af6..b4814fa 100644 --- a/styles.css +++ b/styles.css @@ -693,6 +693,13 @@ body { background-color: var(--button-active); } +.markdown-tool-btn:disabled, +.markdown-tool-btn.disabled { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; +} + .markdown-tool-btn i { font-size: 15px; }