|
|
@@ -0,0 +1,9922 @@
|
|
|
+document.addEventListener("DOMContentLoaded", function () {
|
|
|
+ // PERF-002: Lazy script loader for optional heavy libraries
|
|
|
+ const _loadedScripts = new Set();
|
|
|
+ function loadScript(url) {
|
|
|
+ if (_loadedScripts.has(url)) return Promise.resolve();
|
|
|
+ return new Promise(function(resolve, reject) {
|
|
|
+ const script = document.createElement('script');
|
|
|
+ script.src = url;
|
|
|
+ script.onload = function() { _loadedScripts.add(url); resolve(); };
|
|
|
+ script.onerror = function() { reject(new Error('Failed to load: ' + url)); };
|
|
|
+ document.head.appendChild(script);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ const _loadedStyles = new Set();
|
|
|
+ function loadStyle(url) {
|
|
|
+ if (_loadedStyles.has(url)) return Promise.resolve();
|
|
|
+ return new Promise(function(resolve, reject) {
|
|
|
+ const link = document.createElement('link');
|
|
|
+ link.rel = 'stylesheet';
|
|
|
+ link.href = url;
|
|
|
+ link.onload = function() { _loadedStyles.add(url); resolve(); };
|
|
|
+ link.onerror = function() { reject(new Error('Failed to load style: ' + url)); };
|
|
|
+ document.head.appendChild(link);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // CDN URLs for lazy-loaded libraries
|
|
|
+ const CDN = {
|
|
|
+ mermaid: 'https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.min.js',
|
|
|
+ mathjax: 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.min.js',
|
|
|
+ jspdf: 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js',
|
|
|
+ html2canvas: 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js',
|
|
|
+ pako: 'https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js',
|
|
|
+ joypixels: 'https://cdn.jsdelivr.net/npm/emoji-toolkit@9.0.1/lib/js/joypixels.min.js',
|
|
|
+ joypixels_css: 'https://cdn.jsdelivr.net/npm/emoji-toolkit@9.0.1/extras/css/joypixels.min.css'
|
|
|
+ };
|
|
|
+
|
|
|
+ let markdownRenderTimeout = null;
|
|
|
+ let pendingPreviewRenderCancel = null;
|
|
|
+ let previewRenderGeneration = 0;
|
|
|
+ let previewHasCommittedRender = false;
|
|
|
+ let previewLastRenderedTabId = null;
|
|
|
+ // PERF-003: Track last rendered content to skip redundant renders
|
|
|
+ let _lastRenderedContent = null;
|
|
|
+ const LARGE_DOCUMENT_THRESHOLD = 15000;
|
|
|
+ const HUGE_DOCUMENT_THRESHOLD = 100000;
|
|
|
+ const PREVIEW_ENGINE_V2_ENABLED = true;
|
|
|
+ const PREVIEW_WORKER_THRESHOLD = 50000;
|
|
|
+ const PREVIEW_WORKER_TIMEOUT = 12000;
|
|
|
+ const PREVIEW_SEGMENT_MIN_BLOCKS = 8;
|
|
|
+ const PREVIEW_BLOCK_REUSE_LIMIT = 12000;
|
|
|
+ const PREVIEW_SANITIZE_OPTIONS = {
|
|
|
+ ADD_TAGS: ['mjx-container', 'input'],
|
|
|
+ ADD_ATTR: ['id', 'class', 'style', 'align', 'type', 'checked', 'disabled', 'data-original-code'],
|
|
|
+ ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i
|
|
|
+ };
|
|
|
+ const RENDER_DELAY = 100;
|
|
|
+ const LARGE_RENDER_DELAY = 160;
|
|
|
+ const HUGE_RENDER_DELAY = 240;
|
|
|
+ let syncScrollingEnabled = true;
|
|
|
+ let isEditorScrolling = false;
|
|
|
+ let isPreviewScrolling = false;
|
|
|
+ let isProgrammaticScrolling = false;
|
|
|
+ let scrollSyncTimeout = null;
|
|
|
+ const SCROLL_SYNC_DELAY = 10;
|
|
|
+
|
|
|
+ // View Mode State - Story 1.1
|
|
|
+ let currentViewMode = 'split'; // 'editor', 'split', or 'preview'
|
|
|
+ const APP_VERSION = '3.7.4';
|
|
|
+ let activeModal = null;
|
|
|
+ let lastFocusedElement = null;
|
|
|
+ let isFindModalOpen = false;
|
|
|
+ let findMatches = [];
|
|
|
+ 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;
|
|
|
+ let previewWorker = null;
|
|
|
+ let previewWorkerUnavailable = false;
|
|
|
+ let previewWorkerRequestCounter = 0;
|
|
|
+ let previewWorkerFailureCount = 0;
|
|
|
+ const previewWorkerRequests = new Map();
|
|
|
+ const previewSegmentHtmlCache = new Map();
|
|
|
+ let previewSegmentCacheTabId = null;
|
|
|
+
|
|
|
+ const markdownEditor = document.getElementById("markdown-editor");
|
|
|
+ const markdownPreview = document.getElementById("markdown-preview");
|
|
|
+ const markdownFormatToolbar = document.getElementById("markdown-format-toolbar");
|
|
|
+ const themeToggle = document.getElementById("theme-toggle");
|
|
|
+ const directionToggle = document.getElementById("direction-toggle");
|
|
|
+ const importFromFileButton = document.getElementById("import-from-file");
|
|
|
+ const importFromGithubButton = document.getElementById("import-from-github");
|
|
|
+ const fileInput = document.getElementById("file-input");
|
|
|
+ const exportMd = document.getElementById("export-md");
|
|
|
+ const exportHtml = document.getElementById("export-html");
|
|
|
+ const exportPdf = document.getElementById("export-pdf");
|
|
|
+ const copyMarkdownButton = document.getElementById("copy-markdown-button");
|
|
|
+ const dragOverlay = document.getElementById("drag-overlay");
|
|
|
+ const toggleSyncButton = document.getElementById("toggle-sync");
|
|
|
+ const editorPane = document.getElementById("markdown-editor");
|
|
|
+ const previewPane = document.querySelector(".preview-pane");
|
|
|
+ const readingTimeElement = document.getElementById("reading-time");
|
|
|
+ const wordCountElement = document.getElementById("word-count");
|
|
|
+ const charCountElement = document.getElementById("char-count");
|
|
|
+
|
|
|
+ // View Mode Elements - Story 1.1
|
|
|
+ const contentContainer = document.querySelector(".content-container");
|
|
|
+ const viewModeButtons = document.querySelectorAll(".view-toggle-btn");
|
|
|
+
|
|
|
+ // Mobile View Mode Elements - Story 1.4
|
|
|
+ const mobileViewModeButtons = document.querySelectorAll(".mobile-view-mode-btn");
|
|
|
+
|
|
|
+ // Resize Divider Elements - Story 1.3
|
|
|
+ const resizeDivider = document.querySelector(".resize-divider");
|
|
|
+ const editorPaneElement = document.querySelector(".editor-pane");
|
|
|
+ const previewPaneElement = document.querySelector(".preview-pane");
|
|
|
+ let isResizing = false;
|
|
|
+ let editorWidthPercent = 50; // Default 50%
|
|
|
+ const MIN_PANE_PERCENT = 20; // Minimum 20% width
|
|
|
+
|
|
|
+ const mobileMenuToggle = document.getElementById("mobile-menu-toggle");
|
|
|
+ const mobileMenuPanel = document.getElementById("mobile-menu-panel");
|
|
|
+ const mobileMenuOverlay = document.getElementById("mobile-menu-overlay");
|
|
|
+ const mobileCloseMenu = document.getElementById("close-mobile-menu");
|
|
|
+ const mobileReadingTime = document.getElementById("mobile-reading-time");
|
|
|
+ const mobileWordCount = document.getElementById("mobile-word-count");
|
|
|
+ const mobileCharCount = document.getElementById("mobile-char-count");
|
|
|
+ const mobileToggleSync = document.getElementById("mobile-toggle-sync");
|
|
|
+ const mobileImportBtn = document.getElementById("mobile-import-button");
|
|
|
+ const mobileImportGithubBtn = document.getElementById("mobile-import-github-button");
|
|
|
+ const mobileExportMd = document.getElementById("mobile-export-md");
|
|
|
+ const mobileExportHtml = document.getElementById("mobile-export-html");
|
|
|
+ const mobileExportPdf = document.getElementById("mobile-export-pdf");
|
|
|
+ const mobileCopyMarkdown = document.getElementById("mobile-copy-markdown");
|
|
|
+ const mobileThemeToggle = document.getElementById("mobile-theme-toggle");
|
|
|
+ const shareButton = document.getElementById("share-button");
|
|
|
+ const mobileShareButton = document.getElementById("mobile-share-button");
|
|
|
+ const githubImportModal = document.getElementById("github-import-modal");
|
|
|
+ const githubImportTitle = document.getElementById("github-import-title");
|
|
|
+ const githubImportUrlInput = document.getElementById("github-import-url");
|
|
|
+ const githubImportFileSelect = document.getElementById("github-import-file-select");
|
|
|
+ const githubImportSelectionToolbar = document.getElementById("github-import-selection-toolbar");
|
|
|
+ const githubImportSelectedCount = document.getElementById("github-import-selected-count");
|
|
|
+ const githubImportSelectAllBtn = document.getElementById("github-import-select-all");
|
|
|
+ const githubImportTree = document.getElementById("github-import-tree");
|
|
|
+ const githubImportError = document.getElementById("github-import-error");
|
|
|
+ const githubImportCancelBtn = document.getElementById("github-import-cancel");
|
|
|
+ const githubImportSubmitBtn = document.getElementById("github-import-submit");
|
|
|
+ const editorHighlightLayer = document.getElementById("editor-highlight-layer");
|
|
|
+ const lineNumbers = document.getElementById("line-numbers");
|
|
|
+ const clearFormattingModal = document.getElementById("clear-formatting-modal");
|
|
|
+ const clearFormattingConfirm = document.getElementById("clear-formatting-confirm");
|
|
|
+ const clearFormattingCancel = document.getElementById("clear-formatting-cancel");
|
|
|
+ const clearFormattingClose = document.getElementById("clear-formatting-close");
|
|
|
+ const findReplaceModal = document.getElementById("find-replace-modal");
|
|
|
+ const findReplaceInput = document.getElementById("find-replace-input");
|
|
|
+ const findReplaceWith = document.getElementById("find-replace-with");
|
|
|
+ const findReplaceCount = document.getElementById("find-replace-count");
|
|
|
+ const findReplacePrev = document.getElementById("find-prev");
|
|
|
+ const findReplaceNext = document.getElementById("find-next");
|
|
|
+ const findReplaceCurrent = document.getElementById("find-replace-current");
|
|
|
+ const findReplaceAll = document.getElementById("find-replace-all");
|
|
|
+ const findReplaceClose = document.getElementById("find-replace-close");
|
|
|
+ const findReplaceCloseIcon = document.getElementById("find-replace-close-icon");
|
|
|
+ const helpModal = document.getElementById("help-modal");
|
|
|
+ const helpModalClose = document.getElementById("help-modal-close");
|
|
|
+ const helpModalCloseIcon = document.getElementById("help-modal-close-icon");
|
|
|
+ const aboutModal = document.getElementById("about-modal");
|
|
|
+ const aboutModalClose = document.getElementById("about-modal-close");
|
|
|
+ const aboutModalCloseIcon = document.getElementById("about-modal-close-icon");
|
|
|
+ const aboutVersion = document.getElementById("about-version");
|
|
|
+ if (aboutVersion) {
|
|
|
+ aboutVersion.textContent = APP_VERSION;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ========================================
|
|
|
+ // GLOBAL STATE (persisted across reloads)
|
|
|
+ // ========================================
|
|
|
+ const GLOBAL_STATE_KEY = 'markdownViewerGlobalState';
|
|
|
+ let referenceCounter = 1;
|
|
|
+ const imageObjectUrls = new Set();
|
|
|
+ const EMOJI_API_URL = 'https://api.github.com/emojis';
|
|
|
+ let emojiLoadPromise = null;
|
|
|
+ let emojiEntries = [];
|
|
|
+ let emojiUrlMap = new Map();
|
|
|
+ let emojiLookupLoaded = false;
|
|
|
+ let emojiRenderScheduled = false;
|
|
|
+ let emojiItems = [];
|
|
|
+ const emojiSelection = new Set();
|
|
|
+ let symbolItems = [];
|
|
|
+ const symbolSelection = new Set();
|
|
|
+ const SYMBOL_SECTIONS = [
|
|
|
+ {
|
|
|
+ title: 'Common symbols',
|
|
|
+ items: [
|
|
|
+ { symbol: '©', entity: '©', name: 'copyright' },
|
|
|
+ { symbol: '®', entity: '®', name: 'registered' },
|
|
|
+ { symbol: '™', entity: '™', name: 'trademark' },
|
|
|
+ { symbol: '✓', entity: '✓', name: 'check' },
|
|
|
+ { symbol: '★', entity: '☆', name: 'star' },
|
|
|
+ { symbol: '•', entity: '•', name: 'bullet' },
|
|
|
+ { symbol: '…', entity: '…', name: 'ellipsis' },
|
|
|
+ { symbol: '—', entity: '—', name: 'em dash' },
|
|
|
+ { symbol: '–', entity: '–', name: 'en dash' },
|
|
|
+ { symbol: '→', entity: '→', name: 'right arrow' },
|
|
|
+ { symbol: '←', entity: '←', name: 'left arrow' },
|
|
|
+ { symbol: '↑', entity: '↑', name: 'up arrow' },
|
|
|
+ { symbol: '↓', entity: '↓', name: 'down arrow' },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: 'HTML entities',
|
|
|
+ items: [
|
|
|
+ { symbol: '€', entity: '€', name: 'euro' },
|
|
|
+ { symbol: '£', entity: '£', name: 'pound' },
|
|
|
+ { symbol: '¥', entity: '¥', name: 'yen' },
|
|
|
+ { symbol: '§', entity: '§', name: 'section' },
|
|
|
+ { symbol: '°', entity: '°', name: 'degree' },
|
|
|
+ { symbol: '±', entity: '±', name: 'plus minus' },
|
|
|
+ { symbol: '×', entity: '×', name: 'times' },
|
|
|
+ { symbol: '÷', entity: '÷', name: 'divide' },
|
|
|
+ { symbol: '≠', entity: '≠', name: 'not equal' },
|
|
|
+ { symbol: '≤', entity: '≤', name: 'less equal' },
|
|
|
+ { symbol: '≥', entity: '≥', name: 'greater equal' },
|
|
|
+ { symbol: '∞', entity: '∞', name: 'infinity' },
|
|
|
+ { symbol: 'µ', entity: 'µ', name: 'micro' },
|
|
|
+ { symbol: '¼', entity: '¼', name: 'quarter' },
|
|
|
+ { symbol: '½', entity: '½', name: 'half' },
|
|
|
+ { symbol: '¾', entity: '¾', name: 'three quarters' },
|
|
|
+ { symbol: '«', entity: '«', name: 'left quote' },
|
|
|
+ { symbol: '»', entity: '»', name: 'right quote' },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: 'Markdown-safe characters',
|
|
|
+ items: [
|
|
|
+ { symbol: '&', entity: '&', name: 'ampersand' },
|
|
|
+ { symbol: '<', entity: '<', name: 'less than' },
|
|
|
+ { symbol: '>', entity: '>', name: 'greater than' },
|
|
|
+ { symbol: '"', entity: '"', name: 'double quote' },
|
|
|
+ { symbol: "'", entity: ''', name: 'apostrophe' },
|
|
|
+ { symbol: '|', entity: '|', name: 'pipe' },
|
|
|
+ { symbol: '\\', entity: '\', name: 'backslash' },
|
|
|
+ { symbol: '`', entity: '`', name: 'backtick' },
|
|
|
+ { symbol: '*', entity: '*', name: 'asterisk' },
|
|
|
+ { symbol: '_', entity: '_', name: 'underscore' },
|
|
|
+ { symbol: '{', entity: '{', name: 'left brace' },
|
|
|
+ { symbol: '}', entity: '}', name: 'right brace' },
|
|
|
+ { symbol: '[', entity: '[', name: 'left bracket' },
|
|
|
+ { symbol: ']', entity: ']', name: 'right bracket' },
|
|
|
+ { symbol: '(', entity: '(', name: 'left parenthesis' },
|
|
|
+ { symbol: ')', entity: ')', name: 'right parenthesis' },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ ];
|
|
|
+
|
|
|
+ // In-memory cache for global state to avoid repeated JSON parse/stringify round-trips (PERF-008/024)
|
|
|
+ let _globalStateCache = null;
|
|
|
+ function loadGlobalState() {
|
|
|
+ if (_globalStateCache) return _globalStateCache;
|
|
|
+ try { _globalStateCache = JSON.parse(localStorage.getItem(GLOBAL_STATE_KEY)) || {}; }
|
|
|
+ catch { _globalStateCache = {}; }
|
|
|
+ return _globalStateCache;
|
|
|
+ }
|
|
|
+
|
|
|
+ function saveGlobalState(patch) {
|
|
|
+ _globalStateCache = { ...loadGlobalState(), ...patch };
|
|
|
+ localStorage.setItem(GLOBAL_STATE_KEY, JSON.stringify(_globalStateCache));
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check dark mode preference first for proper initialization
|
|
|
+ const prefersDarkMode =
|
|
|
+ window.matchMedia &&
|
|
|
+ window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
|
+ const savedTheme = loadGlobalState().theme;
|
|
|
+ const initialTheme = savedTheme ?? (prefersDarkMode ? "dark" : "light");
|
|
|
+
|
|
|
+ document.documentElement.setAttribute("data-theme", initialTheme);
|
|
|
+
|
|
|
+ themeToggle.innerHTML = initialTheme === "dark"
|
|
|
+ ? '<i class="bi bi-sun"></i>'
|
|
|
+ : '<i class="bi bi-moon"></i>';
|
|
|
+
|
|
|
+ function updateDirectionToggleUI(direction) {
|
|
|
+ const isRtl = direction === "rtl";
|
|
|
+ const toggleLabel = isRtl ? "Switch to LTR" : "Switch to RTL";
|
|
|
+ if (directionToggle) {
|
|
|
+ directionToggle.textContent = isRtl ? "R" : "L";
|
|
|
+ directionToggle.setAttribute("title", toggleLabel);
|
|
|
+ directionToggle.setAttribute("aria-label", toggleLabel);
|
|
|
+ directionToggle.setAttribute("aria-pressed", isRtl.toString());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const savedDirection = loadGlobalState().direction;
|
|
|
+ const initialDirection = savedDirection === "rtl" ? "rtl" : "ltr";
|
|
|
+ function applyDirectionToContent(direction) {
|
|
|
+ if (markdownEditor) markdownEditor.setAttribute("dir", direction);
|
|
|
+ if (markdownPreview) markdownPreview.setAttribute("dir", direction);
|
|
|
+ }
|
|
|
+ applyDirectionToContent(initialDirection);
|
|
|
+ updateDirectionToggleUI(initialDirection);
|
|
|
+
|
|
|
+ // Track last Mermaid theme to avoid redundant re-initialization (PERF-005)
|
|
|
+ let _lastMermaidTheme = null;
|
|
|
+ let _mermaidThemeReinitTimeout = null;
|
|
|
+ let _themeTransitionTimeout = null;
|
|
|
+ const initMermaid = (forceReinit) => {
|
|
|
+ if (typeof mermaid === 'undefined') return; // PERF-002: Not loaded yet
|
|
|
+ const currentTheme = document.documentElement.getAttribute("data-theme");
|
|
|
+ const mermaidTheme = currentTheme === "dark" ? "dark" : "default";
|
|
|
+
|
|
|
+ // Skip re-initialization if theme hasn't changed (PERF-005)
|
|
|
+ if (!forceReinit && _lastMermaidTheme === mermaidTheme) return;
|
|
|
+ _lastMermaidTheme = mermaidTheme;
|
|
|
+
|
|
|
+ mermaid.initialize({
|
|
|
+ startOnLoad: false,
|
|
|
+ theme: mermaidTheme,
|
|
|
+ securityLevel: 'strict',
|
|
|
+ flowchart: { useMaxWidth: true, htmlLabels: true },
|
|
|
+ fontSize: 16
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ // PERF-002: Removed eager initMermaid() — mermaid is now lazy-loaded on first use
|
|
|
+
|
|
|
+ const markedOptions = {
|
|
|
+ gfm: true,
|
|
|
+ breaks: true,
|
|
|
+ pedantic: false,
|
|
|
+ sanitize: false,
|
|
|
+ smartypants: false,
|
|
|
+ xhtml: false,
|
|
|
+ headerIds: true,
|
|
|
+ mangle: false,
|
|
|
+ };
|
|
|
+ const LINE_NUMBER_GUTTER_MIN_CH = 3;
|
|
|
+ const LINE_NUMBER_GUTTER_PADDING_CH = 1;
|
|
|
+ const LINE_NUMBER_EMPTY_PLACEHOLDER = '\u200b';
|
|
|
+ const LINE_CACHE_MAX_ENTRIES = 5000;
|
|
|
+ const LARGE_EDITOR_WORK_DELAY = 180;
|
|
|
+ const HUGE_EDITOR_WORK_DELAY = 320;
|
|
|
+ const FIND_REFRESH_DELAY = 120;
|
|
|
+ const LARGE_FIND_REFRESH_DELAY = 320;
|
|
|
+ let lineNumberMeasure = null;
|
|
|
+ let lineNumberUpdateFrame = null;
|
|
|
+ let lineNumberUpdateTimeout = null;
|
|
|
+ let editorOverlayScrollFrame = null;
|
|
|
+ let findRefreshTimeout = null;
|
|
|
+
|
|
|
+ const renderer = new marked.Renderer();
|
|
|
+ const BLOCK_MATH_MARKER_PATTERN = /^\$\$/m;
|
|
|
+ const BLOCK_MATH_PATTERN = /^\$\$[ \t]*\n?([\s\S]*?)\n?\$\$[ \t]*(?:\n|$)/;
|
|
|
+ const DEFINITION_LIST_ITEM_PATTERN = /^:[ \t]+(.*)$/;
|
|
|
+ const SUPERSCRIPT_PATTERN = /^\^(?!\s)([^^\n]*?\S)\^(?!\^)/;
|
|
|
+ const SUBSCRIPT_PATTERN = /^~(?!~)(?!\s)([^~\n]*?\S)~(?!~)/;
|
|
|
+ const HIGHLIGHT_PATTERN = /^==(?=\S)([\s\S]*?\S)==/;
|
|
|
+ const MARKDOWN_LIST_MARKER_PATTERN = /^(\s*)(?:[-*+]\s+|\d+\.\s+|>\s+)/;
|
|
|
+ const EMPTY_LINE_PATTERN = /^\s*$/;
|
|
|
+ const footnoteDefinitions = new Map();
|
|
|
+ const footnoteOrder = [];
|
|
|
+ const footnoteRefCounts = new Map();
|
|
|
+ const footnoteFirstRefId = new Map();
|
|
|
+ let anonymousFootnoteCounter = 0;
|
|
|
+ let suppressFootnotePreprocess = false;
|
|
|
+
|
|
|
+ function resetExtendedMarkdownState() {
|
|
|
+ footnoteDefinitions.clear();
|
|
|
+ footnoteOrder.length = 0;
|
|
|
+ footnoteRefCounts.clear();
|
|
|
+ footnoteFirstRefId.clear();
|
|
|
+ anonymousFootnoteCounter = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ function normalizeFootnoteId(id) {
|
|
|
+ const normalized = String(id || "")
|
|
|
+ .trim()
|
|
|
+ .toLowerCase()
|
|
|
+ .replace(/[^a-z0-9_-]+/g, "-")
|
|
|
+ .replace(/^-+|-+$/g, "");
|
|
|
+ if (normalized) {
|
|
|
+ return normalized;
|
|
|
+ }
|
|
|
+
|
|
|
+ anonymousFootnoteCounter += 1;
|
|
|
+ return `footnote-${anonymousFootnoteCounter}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function escapeHtmlAttribute(value) {
|
|
|
+ return String(value)
|
|
|
+ .replace(/&/g, "&")
|
|
|
+ .replace(/"/g, """)
|
|
|
+ .replace(/'/g, "'")
|
|
|
+ .replace(/</g, "<")
|
|
|
+ .replace(/>/g, ">");
|
|
|
+ }
|
|
|
+
|
|
|
+ function sanitizePreviewHtml(html) {
|
|
|
+ if (typeof DOMPurify === "undefined") {
|
|
|
+ throw new ReferenceError("DOMPurify is not defined. Secure rendering aborted.");
|
|
|
+ }
|
|
|
+ return DOMPurify.sanitize(html, PREVIEW_SANITIZE_OPTIONS);
|
|
|
+ }
|
|
|
+
|
|
|
+ function getLoadedScriptUrl(needle, fallbackUrl) {
|
|
|
+ const scripts = document.getElementsByTagName("script");
|
|
|
+ for (let i = 0; i < scripts.length; i += 1) {
|
|
|
+ const src = scripts[i].getAttribute("src") || "";
|
|
|
+ if (src.includes(needle)) {
|
|
|
+ try {
|
|
|
+ return new URL(src, window.location.href).toString();
|
|
|
+ } catch (e) {
|
|
|
+ return src;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return fallbackUrl;
|
|
|
+ }
|
|
|
+
|
|
|
+ function getPreviewWorkerUrl() {
|
|
|
+ const scripts = document.getElementsByTagName("script");
|
|
|
+ let scriptUrl = "";
|
|
|
+ for (let i = scripts.length - 1; i >= 0; i -= 1) {
|
|
|
+ const src = scripts[i].getAttribute("src") || "";
|
|
|
+ if (src.includes("script.js")) {
|
|
|
+ scriptUrl = src;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ return new URL("preview-worker.js", scriptUrl ? new URL(scriptUrl, window.location.href) : window.location.href).toString();
|
|
|
+ } catch (e) {
|
|
|
+ return "preview-worker.js";
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function getPreviewWorkerLibraryUrls() {
|
|
|
+ return {
|
|
|
+ marked: getLoadedScriptUrl("marked", "https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"),
|
|
|
+ highlight: getLoadedScriptUrl("highlight", "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ function isSegmentedPreviewSafe(markdown) {
|
|
|
+ if (!markdown || markdown.length < PREVIEW_WORKER_THRESHOLD) return false;
|
|
|
+ if (/^\s*---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/.test(markdown)) return false;
|
|
|
+ if (/^\[[^\]\n]+\]:\s+\S+/m.test(markdown)) return false;
|
|
|
+ if (/\[\^[^\]\n]+\]/.test(markdown)) return false;
|
|
|
+ if (/\n:[ \t]+/.test(markdown)) return false;
|
|
|
+ if (/^\s{0,3}<\/?[a-zA-Z][\w:-]*(?:\s|>|\/>)/m.test(markdown)) return false;
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ function shouldUsePreviewWorker(rawVal, context) {
|
|
|
+ if (!PREVIEW_ENGINE_V2_ENABLED || previewWorkerUnavailable || context.disableWorker) return false;
|
|
|
+ if (typeof Worker === "undefined" || typeof URL === "undefined") return false;
|
|
|
+ return isSegmentedPreviewSafe(rawVal);
|
|
|
+ }
|
|
|
+
|
|
|
+ function resetPreviewSegmentCache(previewDocumentId) {
|
|
|
+ if (previewSegmentCacheTabId !== previewDocumentId) {
|
|
|
+ previewSegmentHtmlCache.clear();
|
|
|
+ previewSegmentCacheTabId = previewDocumentId;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function trimPreviewSegmentCache() {
|
|
|
+ while (previewSegmentHtmlCache.size > PREVIEW_BLOCK_REUSE_LIMIT) {
|
|
|
+ const firstKey = previewSegmentHtmlCache.keys().next().value;
|
|
|
+ previewSegmentHtmlCache.delete(firstKey);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function buildSegmentedPreviewHtml(blocks, previewDocumentId) {
|
|
|
+ resetPreviewSegmentCache(previewDocumentId);
|
|
|
+ const htmlParts = [];
|
|
|
+
|
|
|
+ blocks.forEach(function(block, index) {
|
|
|
+ const hash = String(block.hash || "");
|
|
|
+ const cacheKey = `${hash}:${block.sourceLength || 0}:${block.htmlLength || (block.html ? block.html.length : 0)}`;
|
|
|
+ let sanitizedBlock = previewSegmentHtmlCache.get(cacheKey);
|
|
|
+ if (sanitizedBlock === undefined) {
|
|
|
+ sanitizedBlock = sanitizePreviewHtml(block.html || "");
|
|
|
+ previewSegmentHtmlCache.set(cacheKey, sanitizedBlock);
|
|
|
+ }
|
|
|
+ const blockId = block.id || `preview-block-${index}`;
|
|
|
+ htmlParts.push(
|
|
|
+ `<section class="preview-render-block" style="content-visibility: auto; contain-intrinsic-size: auto 220px;" data-preview-block-id="${escapeHtmlAttribute(blockId)}" data-preview-block-hash="${escapeHtmlAttribute(cacheKey)}">${sanitizedBlock}</section>`
|
|
|
+ );
|
|
|
+ });
|
|
|
+
|
|
|
+ trimPreviewSegmentCache();
|
|
|
+ return htmlParts.join("");
|
|
|
+ }
|
|
|
+
|
|
|
+ function markPreviewWorkerFailure(error) {
|
|
|
+ previewWorkerFailureCount += 1;
|
|
|
+ if (previewWorkerFailureCount >= 2) {
|
|
|
+ previewWorkerUnavailable = true;
|
|
|
+ }
|
|
|
+ if (previewWorker) {
|
|
|
+ try {
|
|
|
+ previewWorker.terminate();
|
|
|
+ } catch (e) {
|
|
|
+ // Ignore worker shutdown failures; fallback rendering will continue on main.
|
|
|
+ }
|
|
|
+ previewWorker = null;
|
|
|
+ }
|
|
|
+ previewWorkerRequests.forEach(function(pending) {
|
|
|
+ clearTimeout(pending.timeoutId);
|
|
|
+ pending.reject(error || new Error("Preview worker unavailable."));
|
|
|
+ });
|
|
|
+ previewWorkerRequests.clear();
|
|
|
+ }
|
|
|
+
|
|
|
+ function recordPreviewWorkerRenderFailure() {
|
|
|
+ previewWorkerFailureCount += 1;
|
|
|
+ if (previewWorkerFailureCount < 2) return;
|
|
|
+ previewWorkerUnavailable = true;
|
|
|
+ if (previewWorker) {
|
|
|
+ try {
|
|
|
+ previewWorker.terminate();
|
|
|
+ } catch (e) {
|
|
|
+ // Ignore worker shutdown failures; fallback rendering will continue on main.
|
|
|
+ }
|
|
|
+ previewWorker = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function getPreviewWorker() {
|
|
|
+ if (previewWorkerUnavailable) return null;
|
|
|
+ if (previewWorker) return previewWorker;
|
|
|
+ try {
|
|
|
+ previewWorker = new Worker(getPreviewWorkerUrl());
|
|
|
+ previewWorker.onmessage = function(event) {
|
|
|
+ const data = event.data || {};
|
|
|
+ const pending = previewWorkerRequests.get(data.requestId);
|
|
|
+ if (!pending) return;
|
|
|
+ clearTimeout(pending.timeoutId);
|
|
|
+ previewWorkerRequests.delete(data.requestId);
|
|
|
+ if (data.type === "render-result") {
|
|
|
+ previewWorkerFailureCount = 0;
|
|
|
+ pending.resolve(data.result);
|
|
|
+ } else {
|
|
|
+ recordPreviewWorkerRenderFailure();
|
|
|
+ pending.reject(new Error(data.error || "Preview worker render failed."));
|
|
|
+ }
|
|
|
+ };
|
|
|
+ previewWorker.onerror = function(event) {
|
|
|
+ markPreviewWorkerFailure(event && event.message ? new Error(event.message) : new Error("Preview worker failed."));
|
|
|
+ };
|
|
|
+ } catch (e) {
|
|
|
+ markPreviewWorkerFailure(e);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return previewWorker;
|
|
|
+ }
|
|
|
+
|
|
|
+ function requestPreviewWorkerRender(rawVal, context) {
|
|
|
+ const worker = getPreviewWorker();
|
|
|
+ if (!worker) {
|
|
|
+ return Promise.reject(new Error("Preview worker unavailable."));
|
|
|
+ }
|
|
|
+
|
|
|
+ const requestId = ++previewWorkerRequestCounter;
|
|
|
+ return new Promise(function(resolve, reject) {
|
|
|
+ const timeoutId = setTimeout(function() {
|
|
|
+ previewWorkerRequests.delete(requestId);
|
|
|
+ recordPreviewWorkerRenderFailure();
|
|
|
+ reject(new Error("Preview worker timed out."));
|
|
|
+ }, PREVIEW_WORKER_TIMEOUT);
|
|
|
+
|
|
|
+ previewWorkerRequests.set(requestId, { resolve, reject, timeoutId });
|
|
|
+ worker.postMessage({
|
|
|
+ type: "render",
|
|
|
+ requestId,
|
|
|
+ markdown: rawVal,
|
|
|
+ options: {
|
|
|
+ minimumBlocks: PREVIEW_SEGMENT_MIN_BLOCKS,
|
|
|
+ libraryUrls: getPreviewWorkerLibraryUrls(),
|
|
|
+ renderId: context.renderId,
|
|
|
+ },
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function parseInlineWithoutFootnotes(text) {
|
|
|
+ suppressFootnotePreprocess = true;
|
|
|
+ try {
|
|
|
+ return marked.parseInline(text);
|
|
|
+ } finally {
|
|
|
+ suppressFootnotePreprocess = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderDefinitionContent(content, options = {}) {
|
|
|
+ const { appendHtml = "" } = options;
|
|
|
+ const paragraphs = String(content || "")
|
|
|
+ .split(/\n(?:[ \t]*\n)+/)
|
|
|
+ .map((paragraph) => paragraph.trim())
|
|
|
+ .filter(Boolean);
|
|
|
+
|
|
|
+ if (appendHtml) {
|
|
|
+ if (paragraphs.length === 0) {
|
|
|
+ paragraphs.push(appendHtml);
|
|
|
+ } else {
|
|
|
+ paragraphs[paragraphs.length - 1] = `${paragraphs[paragraphs.length - 1]} ${appendHtml}`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return paragraphs
|
|
|
+ .map((paragraph) => {
|
|
|
+ const renderedParagraph = parseInlineWithoutFootnotes(paragraph);
|
|
|
+ if (typeof DOMPurify === "undefined") {
|
|
|
+ throw new ReferenceError("DOMPurify is not defined. Secure rendering aborted.");
|
|
|
+ }
|
|
|
+ const safeParagraph = DOMPurify.sanitize(renderedParagraph);
|
|
|
+ return `<p>${safeParagraph}</p>`;
|
|
|
+ })
|
|
|
+ .join("");
|
|
|
+ }
|
|
|
+
|
|
|
+ function extractFootnoteDefinitions(markdown) {
|
|
|
+ const lines = markdown.split("\n");
|
|
|
+ const preservedLines = [];
|
|
|
+ let index = 0;
|
|
|
+
|
|
|
+ while (index < lines.length) {
|
|
|
+ const match = /^([ \t]{0,3})\[\^([^\]\n]+)\]:[ \t]*(.*)$/.exec(lines[index]);
|
|
|
+ if (!match) {
|
|
|
+ preservedLines.push(lines[index]);
|
|
|
+ index += 1;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ const baseIndent = match[1] || "";
|
|
|
+ const id = match[2].trim();
|
|
|
+ const definitionLines = [match[3] || ""];
|
|
|
+ index += 1;
|
|
|
+
|
|
|
+ while (index < lines.length) {
|
|
|
+ const line = lines[index];
|
|
|
+ if (!line.startsWith(baseIndent)) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ const lineAfterBase = line.slice(baseIndent.length);
|
|
|
+ const indentedMatch = /^(?: {2,}|\t)(.*)$/.exec(lineAfterBase);
|
|
|
+ if (indentedMatch) {
|
|
|
+ definitionLines.push(indentedMatch[1]);
|
|
|
+ index += 1;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (lineAfterBase.trim() === "") {
|
|
|
+ const nextLine = lines[index + 1] || "";
|
|
|
+ const nextAfterBase = nextLine.startsWith(baseIndent)
|
|
|
+ ? nextLine.slice(baseIndent.length)
|
|
|
+ : "";
|
|
|
+ if (/^(?: {2,}|\t)/.test(nextAfterBase)) {
|
|
|
+ definitionLines.push("");
|
|
|
+ index += 1;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ footnoteDefinitions.set(id, definitionLines.join("\n").trim());
|
|
|
+ }
|
|
|
+
|
|
|
+ return preservedLines.join("\n");
|
|
|
+ }
|
|
|
+
|
|
|
+ function applyFootnotes(markdown) {
|
|
|
+ const markdownWithReferences = markdown.replace(/\[\^([^\]\n]+)\]/g, function(match, idText) {
|
|
|
+ const id = idText.trim();
|
|
|
+ if (!id) {
|
|
|
+ return match;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!footnoteOrder.includes(id)) {
|
|
|
+ footnoteOrder.push(id);
|
|
|
+ }
|
|
|
+
|
|
|
+ const refCount = (footnoteRefCounts.get(id) || 0) + 1;
|
|
|
+ footnoteRefCounts.set(id, refCount);
|
|
|
+
|
|
|
+ const normalizedId = normalizeFootnoteId(id);
|
|
|
+ const refId = `fnref-${normalizedId}${refCount > 1 ? `-${refCount}` : ""}`;
|
|
|
+ if (!footnoteFirstRefId.has(id)) {
|
|
|
+ footnoteFirstRefId.set(id, refId);
|
|
|
+ }
|
|
|
+
|
|
|
+ const noteNumber = footnoteOrder.indexOf(id) + 1;
|
|
|
+ const safeRefId = escapeHtmlAttribute(refId);
|
|
|
+ const safeNormalizedId = escapeHtmlAttribute(normalizedId);
|
|
|
+ return `<sup id="${safeRefId}" class="footnote-ref"><a href="#fn-${safeNormalizedId}" aria-label="Footnote ${noteNumber}">[${noteNumber}]</a></sup>`;
|
|
|
+ });
|
|
|
+
|
|
|
+ const footnotesHtml = footnoteOrder
|
|
|
+ .filter((id) => footnoteDefinitions.has(id))
|
|
|
+ .map((id) => {
|
|
|
+ const normalizedId = normalizeFootnoteId(id);
|
|
|
+ const backRefId = footnoteFirstRefId.get(id) || `fnref-${normalizedId}`;
|
|
|
+ const safeNormalizedId = escapeHtmlAttribute(normalizedId);
|
|
|
+ const safeBackRefId = escapeHtmlAttribute(backRefId);
|
|
|
+ const backRefHtml = `<a href="#${safeBackRefId}" class="footnote-backref" aria-label="Back to content">←</a>`;
|
|
|
+ const noteHtml = renderDefinitionContent(
|
|
|
+ footnoteDefinitions.get(id) || "",
|
|
|
+ { appendHtml: backRefHtml }
|
|
|
+ );
|
|
|
+ return `<li id="fn-${safeNormalizedId}">${noteHtml}</li>`;
|
|
|
+ })
|
|
|
+ .join("");
|
|
|
+
|
|
|
+ if (!footnotesHtml) {
|
|
|
+ return markdownWithReferences;
|
|
|
+ }
|
|
|
+
|
|
|
+ return `${markdownWithReferences}\n\n<section class="footnotes"><hr><ol>${footnotesHtml}</ol></section>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ const blockMathExtension = {
|
|
|
+ name: 'blockMath',
|
|
|
+ level: 'block',
|
|
|
+ start(src) {
|
|
|
+ const match = src.match(BLOCK_MATH_MARKER_PATTERN);
|
|
|
+ if (!match) {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ return match.index;
|
|
|
+ },
|
|
|
+ tokenizer(src) {
|
|
|
+ const match = BLOCK_MATH_PATTERN.exec(src);
|
|
|
+ if (!match) {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ type: 'blockMath',
|
|
|
+ raw: match[0],
|
|
|
+ text: match[1],
|
|
|
+ };
|
|
|
+ },
|
|
|
+ renderer(token) {
|
|
|
+ return `<div class="math-block">$$\n${token.text}\n$$</div>\n`;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ const definitionListExtension = {
|
|
|
+ name: "definitionList",
|
|
|
+ level: "block",
|
|
|
+ start(src) {
|
|
|
+ const match = src.match(/\n:[ \t]+/);
|
|
|
+ if (!match) {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ return match.index + 1;
|
|
|
+ },
|
|
|
+ tokenizer(src) {
|
|
|
+ const lines = src.split("\n");
|
|
|
+ if (lines.length < 2) {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ const term = lines[0];
|
|
|
+ if (EMPTY_LINE_PATTERN.test(term) || MARKDOWN_LIST_MARKER_PATTERN.test(term)) {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!DEFINITION_LIST_ITEM_PATTERN.test(lines[1])) {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ const definitions = [];
|
|
|
+ const rawLines = [term];
|
|
|
+ let index = 1;
|
|
|
+ while (index < lines.length) {
|
|
|
+ const itemMatch = DEFINITION_LIST_ITEM_PATTERN.exec(lines[index]);
|
|
|
+ if (!itemMatch) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ rawLines.push(lines[index]);
|
|
|
+ const definitionLines = [itemMatch[1]];
|
|
|
+ index += 1;
|
|
|
+
|
|
|
+ while (index < lines.length) {
|
|
|
+ const line = lines[index];
|
|
|
+ if (DEFINITION_LIST_ITEM_PATTERN.test(line)) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ if (EMPTY_LINE_PATTERN.test(line)) {
|
|
|
+ const nextLine = lines[index + 1] || "";
|
|
|
+ if (/^(?: {2,}|\t)/.test(nextLine)) {
|
|
|
+ rawLines.push(line);
|
|
|
+ definitionLines.push("");
|
|
|
+ index += 1;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ const continuationMatch = /^(?: {2,}|\t)(.*)$/.exec(line);
|
|
|
+ if (!continuationMatch) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ rawLines.push(line);
|
|
|
+ definitionLines.push(continuationMatch[1]);
|
|
|
+ index += 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ definitions.push(definitionLines.join("\n").trim());
|
|
|
+ }
|
|
|
+
|
|
|
+ if (definitions.length === 0) {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ let raw = rawLines.join("\n");
|
|
|
+ if (src.startsWith(raw + "\n")) {
|
|
|
+ raw += "\n";
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ type: "definitionList",
|
|
|
+ raw: raw,
|
|
|
+ term: term.trim(),
|
|
|
+ definitions: definitions,
|
|
|
+ };
|
|
|
+ },
|
|
|
+ renderer(token) {
|
|
|
+ const termHtml = parseInlineWithoutFootnotes(token.term);
|
|
|
+ const definitionHtml = token.definitions
|
|
|
+ .map((definition) => `<dd>${renderDefinitionContent(definition)}</dd>`)
|
|
|
+ .join("");
|
|
|
+ return `<dl><dt>${termHtml}</dt>${definitionHtml}</dl>\n`;
|
|
|
+ },
|
|
|
+ };
|
|
|
+ const superscriptExtension = {
|
|
|
+ name: "superscript",
|
|
|
+ level: "inline",
|
|
|
+ start(src) {
|
|
|
+ const index = src.indexOf("^");
|
|
|
+ return index >= 0 ? index : undefined;
|
|
|
+ },
|
|
|
+ tokenizer(src) {
|
|
|
+ const match = SUPERSCRIPT_PATTERN.exec(src);
|
|
|
+ if (!match) {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ type: "superscript",
|
|
|
+ raw: match[0],
|
|
|
+ text: match[1],
|
|
|
+ };
|
|
|
+ },
|
|
|
+ renderer(token) {
|
|
|
+ return `<sup>${marked.parseInline(token.text)}</sup>`;
|
|
|
+ },
|
|
|
+ };
|
|
|
+ const subscriptExtension = {
|
|
|
+ name: "subscript",
|
|
|
+ level: "inline",
|
|
|
+ start(src) {
|
|
|
+ const index = src.indexOf("~");
|
|
|
+ return index >= 0 ? index : undefined;
|
|
|
+ },
|
|
|
+ tokenizer(src) {
|
|
|
+ const match = SUBSCRIPT_PATTERN.exec(src);
|
|
|
+ if (!match) {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ type: "subscript",
|
|
|
+ raw: match[0],
|
|
|
+ text: match[1],
|
|
|
+ };
|
|
|
+ },
|
|
|
+ renderer(token) {
|
|
|
+ return `<sub>${marked.parseInline(token.text)}</sub>`;
|
|
|
+ },
|
|
|
+ };
|
|
|
+ const highlightExtension = {
|
|
|
+ name: "highlight",
|
|
|
+ level: "inline",
|
|
|
+ start(src) {
|
|
|
+ const index = src.indexOf("==");
|
|
|
+ return index >= 0 ? index : undefined;
|
|
|
+ },
|
|
|
+ tokenizer(src) {
|
|
|
+ const match = HIGHLIGHT_PATTERN.exec(src);
|
|
|
+ if (!match) {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ type: "highlight",
|
|
|
+ raw: match[0],
|
|
|
+ text: match[1],
|
|
|
+ };
|
|
|
+ },
|
|
|
+ renderer(token) {
|
|
|
+ return `<mark>${marked.parseInline(token.text)}</mark>`;
|
|
|
+ },
|
|
|
+ };
|
|
|
+
|
|
|
+ renderer.code = function (code, language) {
|
|
|
+ if (language === 'mermaid') {
|
|
|
+ const uniqueId = 'mermaid-diagram-' + Math.random().toString(36).substr(2, 9);
|
|
|
+ const escapedCode = code
|
|
|
+ .replace(/&/g, "&")
|
|
|
+ .replace(/</g, "<")
|
|
|
+ .replace(/>/g, ">");
|
|
|
+ return `<div class="mermaid-container is-loading"><div class="mermaid" id="${uniqueId}" data-original-code="${encodeURIComponent(code)}">${escapedCode}</div></div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ const validLanguage = hljs.getLanguage(language) ? language : "plaintext";
|
|
|
+ const highlightedCode = hljs.highlight(code, {
|
|
|
+ language: validLanguage,
|
|
|
+ }).value;
|
|
|
+ return `<pre><code class="hljs ${validLanguage}">${highlightedCode}</code></pre>`;
|
|
|
+ };
|
|
|
+
|
|
|
+ renderer.heading = function (text, level, raw) {
|
|
|
+ let id = raw
|
|
|
+ .toLowerCase()
|
|
|
+ .trim()
|
|
|
+ .replace(/<[^>]*>/g, '')
|
|
|
+ .replace(/\s+/g, '-')
|
|
|
+ .replace(/[^\w-]/g, '')
|
|
|
+ .replace(/-+/g, '-');
|
|
|
+ if (!id) {
|
|
|
+ id = 'heading-' + Math.random().toString(36).substr(2, 9);
|
|
|
+ }
|
|
|
+ return `<h${level} id="${id}">${text}</h${level}>`;
|
|
|
+ };
|
|
|
+
|
|
|
+ marked.use({
|
|
|
+ extensions: [
|
|
|
+ blockMathExtension,
|
|
|
+ definitionListExtension,
|
|
|
+ superscriptExtension,
|
|
|
+ subscriptExtension,
|
|
|
+ highlightExtension,
|
|
|
+ ],
|
|
|
+ hooks: {
|
|
|
+ preprocess(markdown) {
|
|
|
+ if (suppressFootnotePreprocess) {
|
|
|
+ return markdown;
|
|
|
+ }
|
|
|
+ resetExtendedMarkdownState();
|
|
|
+ // ✅ Replace escaped dollar signs before marked.js strips the backslash.
|
|
|
+ // This prevents MathJax from treating lone $ as a math delimiter.
|
|
|
+ const protectedMarkdown = markdown.replace(/\\\$/g, '$');
|
|
|
+ return applyFootnotes(extractFootnoteDefinitions(protectedMarkdown));
|
|
|
+ },
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ marked.setOptions({
|
|
|
+ ...markedOptions,
|
|
|
+ renderer: renderer,
|
|
|
+ });
|
|
|
+
|
|
|
+ const GITHUB_ALERT_META = {
|
|
|
+ note: {
|
|
|
+ label: "Note",
|
|
|
+ viewBox: "0 0 512 512",
|
|
|
+ path: "M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336l24 0 0-64-24 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l48 0c13.3 0 24 10.7 24 24l0 88 8 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z",
|
|
|
+ },
|
|
|
+ tip: {
|
|
|
+ label: "Tip",
|
|
|
+ viewBox: "0 0 384 512",
|
|
|
+ path: "M297.2 248.9C311.6 228.3 320 203.2 320 176c0-70.7-57.3-128-128-128S64 105.3 64 176c0 27.2 8.4 52.3 22.8 72.9c3.7 5.3 8.1 11.3 12.8 17.7c0 0 0 0 0 0c12.9 17.7 28.3 38.9 39.8 59.8c10.4 19 15.7 38.8 18.3 57.5L109 384c-2.2-12-5.9-23.7-11.8-34.5c-9.9-18-22.2-34.9-34.5-51.8c0 0 0 0 0 0s0 0 0 0c-5.2-7.1-10.4-14.2-15.4-21.4C27.6 247.9 16 213.3 16 176C16 78.8 94.8 0 192 0s176 78.8 176 176c0 37.3-11.6 71.9-31.4 100.3c-5 7.2-10.2 14.3-15.4 21.4c0 0 0 0 0 0s0 0 0 0c-12.3 16.8-24.6 33.7-34.5 51.8c-5.9 10.8-9.6 22.5-11.8 34.5l-48.6 0c2.6-18.7 7.9-38.6 18.3-57.5c11.5-20.9 26.9-42.1 39.8-59.8c0 0 0 0 0 0s0 0 0 0s0 0 0 0c4.7-6.4 9-12.4 12.7-17.7zM192 128c-26.5 0-48 21.5-48 48c0 8.8-7.2 16-16 16s-16-7.2-16-16c0-44.2 35.8-80 80-80c8.8 0 16 7.2 16 16s-7.2 16-16 16zm0 384c-44.2 0-80-35.8-80-80l0-16 160 0 0 16c0 44.2-35.8 80-80 80z",
|
|
|
+ },
|
|
|
+ important: {
|
|
|
+ label: "Important",
|
|
|
+ viewBox: "0 0 512 512",
|
|
|
+ path: "M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24l0 112c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-112c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z",
|
|
|
+ },
|
|
|
+ warning: {
|
|
|
+ label: "Warning",
|
|
|
+ viewBox: "0 0 512 512",
|
|
|
+ path: "M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480L40 480c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z",
|
|
|
+ },
|
|
|
+ caution: {
|
|
|
+ label: "Caution",
|
|
|
+ viewBox: "0 0 512 512",
|
|
|
+ path: "M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM175 175c9.4-9.4 24.6-9.4 33.9 0l47 47 47-47c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-47 47 47 47c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-47-47-47 47c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l47-47-47-47c-9.4-9.4-9.4-24.6 0-33.9z",
|
|
|
+ },
|
|
|
+ };
|
|
|
+ const GITHUB_ALERT_MARKER_REGEX = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:(?:\s| |<br\s*\/?>)+|$)/i;
|
|
|
+
|
|
|
+ function enhanceGitHubAlerts(container) {
|
|
|
+ if (!container) return;
|
|
|
+
|
|
|
+ const blockquotes = container.querySelectorAll("blockquote");
|
|
|
+ blockquotes.forEach((blockquote) => {
|
|
|
+ let firstParagraph = null;
|
|
|
+ for (const child of blockquote.children) {
|
|
|
+ if (child.tagName === "P") {
|
|
|
+ firstParagraph = child;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!firstParagraph) return;
|
|
|
+
|
|
|
+ const firstParagraphHtml = firstParagraph.innerHTML.trim();
|
|
|
+ const markerMatch = firstParagraphHtml.match(GITHUB_ALERT_MARKER_REGEX);
|
|
|
+ if (!markerMatch) return;
|
|
|
+
|
|
|
+ const alertType = markerMatch[1].toLowerCase();
|
|
|
+ blockquote.classList.add("markdown-alert", `markdown-alert-${alertType}`);
|
|
|
+
|
|
|
+ const title = document.createElement("p");
|
|
|
+ title.className = "markdown-alert-title";
|
|
|
+ const alertMeta = GITHUB_ALERT_META[alertType] || { label: markerMatch[1], path: "" };
|
|
|
+ const icon = document.createElement("span");
|
|
|
+ icon.className = "markdown-alert-icon";
|
|
|
+ icon.setAttribute("aria-hidden", "true");
|
|
|
+
|
|
|
+ if (alertMeta.path) {
|
|
|
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
|
+ svg.setAttribute("viewBox", alertMeta.viewBox || "0 0 512 512");
|
|
|
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
|
+ path.setAttribute("d", alertMeta.path);
|
|
|
+ svg.appendChild(path);
|
|
|
+ icon.appendChild(svg);
|
|
|
+ }
|
|
|
+
|
|
|
+ const label = document.createElement("span");
|
|
|
+ label.textContent = alertMeta.label;
|
|
|
+ title.appendChild(icon);
|
|
|
+ title.appendChild(label);
|
|
|
+
|
|
|
+ blockquote.insertBefore(title, blockquote.firstChild);
|
|
|
+
|
|
|
+ const remainingHtml = firstParagraphHtml
|
|
|
+ .replace(GITHUB_ALERT_MARKER_REGEX, "")
|
|
|
+ .trim();
|
|
|
+ if (remainingHtml) {
|
|
|
+ firstParagraph.innerHTML = remainingHtml;
|
|
|
+ } else {
|
|
|
+ firstParagraph.remove();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function parseFrontmatter(markdown) {
|
|
|
+ const match = markdown.match(/^\s*---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/);
|
|
|
+ if (!match) return { frontmatter: null, body: markdown };
|
|
|
+ try {
|
|
|
+ const data = jsyaml.load(match[1]) || {};
|
|
|
+ return { frontmatter: data, body: markdown.slice(match[0].length) };
|
|
|
+ } catch (e) {
|
|
|
+ console.warn('Frontmatter YAML parse error:', e);
|
|
|
+ return { frontmatter: null, body: markdown };
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderFrontmatterValue(value) {
|
|
|
+ if (value === null || value === undefined) return '';
|
|
|
+ if (value instanceof Date) {
|
|
|
+ const y = value.getUTCFullYear();
|
|
|
+ const m = String(value.getUTCMonth() + 1).padStart(2, '0');
|
|
|
+ const d = String(value.getUTCDate()).padStart(2, '0');
|
|
|
+ return `${y}-${m}-${d}`;
|
|
|
+ }
|
|
|
+ if (Array.isArray(value)) {
|
|
|
+ const allPrimitive = value.every(v => v === null || typeof v !== 'object');
|
|
|
+ if (allPrimitive) {
|
|
|
+ return value
|
|
|
+ .map(v => `<span class="fm-tag">${escapeHtml(String(v ?? ''))}</span>`)
|
|
|
+ .join('');
|
|
|
+ }
|
|
|
+ return `<pre class="fm-complex">${escapeHtml(jsyaml.dump(value).trimEnd())}</pre>`;
|
|
|
+ }
|
|
|
+ if (typeof value === 'object') {
|
|
|
+ return `<pre class="fm-complex">${escapeHtml(jsyaml.dump(value).trimEnd())}</pre>`;
|
|
|
+ }
|
|
|
+ return escapeHtml(String(value));
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderFrontmatterTable(data) {
|
|
|
+ const rows = Object.entries(data).map(([key, value]) =>
|
|
|
+ `<tr><th>${escapeHtml(key)}</th><td>${renderFrontmatterValue(value)}</td></tr>`
|
|
|
+ );
|
|
|
+ return `<table class="frontmatter-table"><tbody>${rows.join('')}</tbody></table>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function escapeHtml(str) {
|
|
|
+ return str
|
|
|
+ .replace(/&/g, '&')
|
|
|
+ .replace(/</g, '<')
|
|
|
+ .replace(/>/g, '>')
|
|
|
+ .replace(/"/g, '"');
|
|
|
+ }
|
|
|
+
|
|
|
+ // PERF-012: Inlined default template to eliminate network request, FOUC, and layout shifts
|
|
|
+ const defaultMarkdownTemplate = document.getElementById('default-markdown');
|
|
|
+ let templateText = '';
|
|
|
+ if (defaultMarkdownTemplate) {
|
|
|
+ if (defaultMarkdownTemplate.content && typeof defaultMarkdownTemplate.content.textContent === 'string') {
|
|
|
+ templateText = defaultMarkdownTemplate.content.textContent.trim();
|
|
|
+ } else {
|
|
|
+ templateText = defaultMarkdownTemplate.textContent ? defaultMarkdownTemplate.textContent.trim() : '';
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const sampleMarkdown = templateText || '# Welcome to Markdown Viewer\n\nStart typing your markdown here...';
|
|
|
+
|
|
|
+ if (!markdownEditor.value) {
|
|
|
+ markdownEditor.value = sampleMarkdown;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ========================================
|
|
|
+ // DOCUMENT TABS & SESSION MANAGEMENT
|
|
|
+ // ========================================
|
|
|
+
|
|
|
+ const STORAGE_KEY = 'markdownViewerTabs';
|
|
|
+ const ACTIVE_TAB_KEY = 'markdownViewerActiveTab';
|
|
|
+ const UNTITLED_COUNTER_KEY = 'markdownViewerUntitledCounter';
|
|
|
+ let tabs = [];
|
|
|
+ let activeTabId = null;
|
|
|
+ let draggedTabId = null;
|
|
|
+ let saveTabStateTimeout = null;
|
|
|
+ let untitledCounter = 0;
|
|
|
+
|
|
|
+ function loadTabsFromStorage() {
|
|
|
+ try {
|
|
|
+ return JSON.parse(localStorage.getItem(STORAGE_KEY)) || [];
|
|
|
+ } catch (e) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function saveTabsToStorage(tabsArr) {
|
|
|
+ // PERF-008: Debounce tab saves to reduce main thread blocking from JSON.stringify
|
|
|
+ // on large document arrays. Immediate flush happens on visibilitychange/beforeunload.
|
|
|
+ clearTimeout(saveTabStateTimeout);
|
|
|
+ saveTabStateTimeout = setTimeout(function() {
|
|
|
+ _flushTabsToStorage(tabsArr);
|
|
|
+ }, 500);
|
|
|
+ }
|
|
|
+
|
|
|
+ function _flushTabsToStorage(tabsArr) {
|
|
|
+ clearTimeout(saveTabStateTimeout);
|
|
|
+ try {
|
|
|
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(tabsArr || tabs));
|
|
|
+ } catch (e) {
|
|
|
+ console.warn('Failed to save tabs to localStorage:', e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Ensure tabs are persisted before page close (PERF-008)
|
|
|
+ window.addEventListener('beforeunload', function() { _flushTabsToStorage(tabs); });
|
|
|
+ document.addEventListener('visibilitychange', function() {
|
|
|
+ if (document.visibilityState === 'hidden') _flushTabsToStorage(tabs);
|
|
|
+ });
|
|
|
+
|
|
|
+ function loadActiveTabId() {
|
|
|
+ return localStorage.getItem(ACTIVE_TAB_KEY);
|
|
|
+ }
|
|
|
+
|
|
|
+ function saveActiveTabId(id) {
|
|
|
+ localStorage.setItem(ACTIVE_TAB_KEY, id);
|
|
|
+ }
|
|
|
+
|
|
|
+ function loadUntitledCounter() {
|
|
|
+ return parseInt(localStorage.getItem(UNTITLED_COUNTER_KEY) || '0', 10);
|
|
|
+ }
|
|
|
+
|
|
|
+ function saveUntitledCounter(val) {
|
|
|
+ localStorage.setItem(UNTITLED_COUNTER_KEY, String(val));
|
|
|
+ }
|
|
|
+
|
|
|
+ function nextUntitledTitle() {
|
|
|
+ untitledCounter += 1;
|
|
|
+ saveUntitledCounter(untitledCounter);
|
|
|
+ return 'Untitled ' + untitledCounter;
|
|
|
+ }
|
|
|
+
|
|
|
+ function createTab(content, title, viewMode) {
|
|
|
+ if (content === undefined) content = '';
|
|
|
+ if (title === undefined) title = null;
|
|
|
+ if (viewMode === undefined) viewMode = 'split';
|
|
|
+ return {
|
|
|
+ id: 'tab_' + Date.now() + '_' + Math.random().toString(36).substring(2, 8),
|
|
|
+ title: title || 'Untitled',
|
|
|
+ content: content,
|
|
|
+ scrollPos: 0,
|
|
|
+ viewMode: viewMode,
|
|
|
+ createdAt: Date.now()
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeTabMenus() {
|
|
|
+ document.querySelectorAll('.tab-menu-btn.open').forEach(function(btn) {
|
|
|
+ btn.classList.remove('open');
|
|
|
+ btn.setAttribute('aria-expanded', 'false');
|
|
|
+ });
|
|
|
+ document.querySelectorAll('.tab-menu-dropdown.open').forEach(function(dropdown) {
|
|
|
+ dropdown.classList.remove('open');
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function removeTabMenuDropdowns() {
|
|
|
+ document.querySelectorAll('.tab-menu-dropdown[data-tab-menu-dropdown="true"]').forEach(function(dropdown) {
|
|
|
+ dropdown.remove();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function positionTabMenu(menuBtn, dropdown) {
|
|
|
+ const rect = menuBtn.getBoundingClientRect();
|
|
|
+ const margin = 8;
|
|
|
+ const dropdownWidth = dropdown.offsetWidth || 130;
|
|
|
+ const dropdownHeight = dropdown.offsetHeight || 110;
|
|
|
+ let left = rect.right - dropdownWidth;
|
|
|
+ let top = rect.bottom + 4;
|
|
|
+
|
|
|
+ left = Math.max(margin, Math.min(left, window.innerWidth - dropdownWidth - margin));
|
|
|
+ if (top + dropdownHeight > window.innerHeight - margin) {
|
|
|
+ top = Math.max(margin, rect.top - dropdownHeight - 4);
|
|
|
+ }
|
|
|
+
|
|
|
+ dropdown.style.top = top + 'px';
|
|
|
+ dropdown.style.left = left + 'px';
|
|
|
+ dropdown.style.right = 'auto';
|
|
|
+ }
|
|
|
+
|
|
|
+ function runTabMenuAction(tabId, action, isMobileMenu) {
|
|
|
+ if (action === 'rename') {
|
|
|
+ if (isMobileMenu) closeMobileMenu();
|
|
|
+ renameTab(tabId);
|
|
|
+ } else if (action === 'duplicate') {
|
|
|
+ duplicateTab(tabId);
|
|
|
+ if (isMobileMenu) closeMobileMenu();
|
|
|
+ } else if (action === 'delete') {
|
|
|
+ deleteTab(tabId);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function createTabActionMenu(tab, options) {
|
|
|
+ const isMobileMenu = options && options.isMobileMenu;
|
|
|
+ const menuIdPrefix = options && options.menuIdPrefix ? options.menuIdPrefix : 'tab-menu';
|
|
|
+ const menuId = menuIdPrefix + '-' + tab.id;
|
|
|
+
|
|
|
+ const menuBtn = document.createElement('button');
|
|
|
+ menuBtn.type = 'button';
|
|
|
+ menuBtn.className = 'tab-menu-btn';
|
|
|
+ menuBtn.setAttribute('aria-label', 'File options for ' + (tab.title || 'Untitled'));
|
|
|
+ menuBtn.setAttribute('aria-haspopup', 'menu');
|
|
|
+ menuBtn.setAttribute('aria-expanded', 'false');
|
|
|
+ menuBtn.setAttribute('aria-controls', menuId);
|
|
|
+ menuBtn.setAttribute('draggable', 'false');
|
|
|
+ menuBtn.title = 'File options';
|
|
|
+ // PERF-007: Replace HTML entity with plain unicode character set via textContent
|
|
|
+ menuBtn.textContent = '⋯';
|
|
|
+
|
|
|
+ const dropdown = document.createElement('div');
|
|
|
+ dropdown.id = menuId;
|
|
|
+ dropdown.className = 'tab-menu-dropdown';
|
|
|
+ dropdown.setAttribute('data-tab-menu-dropdown', 'true');
|
|
|
+ dropdown.setAttribute('role', 'menu');
|
|
|
+ dropdown.innerHTML =
|
|
|
+ '<button type="button" class="tab-menu-item" role="menuitem" data-action="rename"><i class="bi bi-pencil"></i> Rename</button>' +
|
|
|
+ '<button type="button" class="tab-menu-item" role="menuitem" data-action="duplicate"><i class="bi bi-files"></i> Duplicate</button>' +
|
|
|
+ '<button type="button" class="tab-menu-item tab-menu-item-danger" role="menuitem" data-action="delete"><i class="bi bi-trash"></i> Delete</button>';
|
|
|
+
|
|
|
+ menuBtn.addEventListener('click', function(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ e.stopPropagation();
|
|
|
+ const shouldOpen = !menuBtn.classList.contains('open');
|
|
|
+ closeTabMenus();
|
|
|
+ if (shouldOpen) {
|
|
|
+ menuBtn.classList.add('open');
|
|
|
+ menuBtn.setAttribute('aria-expanded', 'true');
|
|
|
+ dropdown.classList.add('open');
|
|
|
+ positionTabMenu(menuBtn, dropdown);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ menuBtn.addEventListener('mousedown', function(e) {
|
|
|
+ e.stopPropagation();
|
|
|
+ });
|
|
|
+
|
|
|
+ menuBtn.addEventListener('dragstart', function(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ e.stopPropagation();
|
|
|
+ });
|
|
|
+
|
|
|
+ dropdown.addEventListener('click', function(e) {
|
|
|
+ e.stopPropagation();
|
|
|
+ });
|
|
|
+
|
|
|
+ dropdown.querySelectorAll('.tab-menu-item').forEach(function(actionBtn) {
|
|
|
+ actionBtn.addEventListener('click', function(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ e.stopPropagation();
|
|
|
+ const action = actionBtn.getAttribute('data-action');
|
|
|
+ closeTabMenus();
|
|
|
+ runTabMenuAction(tab.id, action, isMobileMenu);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ document.body.appendChild(dropdown);
|
|
|
+
|
|
|
+ return { button: menuBtn, dropdown: dropdown };
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderTabBar(tabsArr, currentActiveTabId) {
|
|
|
+ const tabList = document.getElementById('tab-list');
|
|
|
+ if (!tabList) return;
|
|
|
+ closeTabMenus();
|
|
|
+ removeTabMenuDropdowns();
|
|
|
+ // PERF-007: Use textContent instead of innerHTML to clear elements faster
|
|
|
+ tabList.textContent = '';
|
|
|
+ tabsArr.forEach(function(tab) {
|
|
|
+ const item = document.createElement('div');
|
|
|
+ item.className = 'tab-item' + (tab.id === currentActiveTabId ? ' active' : '');
|
|
|
+ item.setAttribute('data-tab-id', tab.id);
|
|
|
+ item.setAttribute('role', 'tab');
|
|
|
+ item.setAttribute('aria-selected', tab.id === currentActiveTabId ? 'true' : 'false');
|
|
|
+ item.setAttribute('draggable', 'true');
|
|
|
+ item.setAttribute('tabindex', tab.id === currentActiveTabId ? '0' : '-1');
|
|
|
+
|
|
|
+ const titleSpan = document.createElement('span');
|
|
|
+ titleSpan.className = 'tab-title';
|
|
|
+ titleSpan.textContent = tab.title || 'Untitled';
|
|
|
+ titleSpan.title = tab.title || 'Untitled';
|
|
|
+
|
|
|
+ const tabMenu = createTabActionMenu(tab, { menuIdPrefix: 'desktop-tab-menu' });
|
|
|
+
|
|
|
+ item.appendChild(titleSpan);
|
|
|
+ item.appendChild(tabMenu.button);
|
|
|
+
|
|
|
+ item.addEventListener('dragstart', function() {
|
|
|
+ draggedTabId = tab.id;
|
|
|
+ setTimeout(function() { item.classList.add('dragging'); }, 0);
|
|
|
+ });
|
|
|
+
|
|
|
+ item.addEventListener('dragend', function() {
|
|
|
+ item.classList.remove('dragging');
|
|
|
+ draggedTabId = null;
|
|
|
+ });
|
|
|
+
|
|
|
+ item.addEventListener('dragover', function(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ item.classList.add('drag-over');
|
|
|
+ });
|
|
|
+
|
|
|
+ item.addEventListener('dragleave', function() {
|
|
|
+ item.classList.remove('drag-over');
|
|
|
+ });
|
|
|
+
|
|
|
+ item.addEventListener('drop', function(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ item.classList.remove('drag-over');
|
|
|
+ if (!draggedTabId || draggedTabId === tab.id) return;
|
|
|
+ const fromIdx = tabs.findIndex(function(t) { return t.id === draggedTabId; });
|
|
|
+ const toIdx = tabs.findIndex(function(t) { return t.id === tab.id; });
|
|
|
+ if (fromIdx === -1 || toIdx === -1) return;
|
|
|
+ const moved = tabs.splice(fromIdx, 1)[0];
|
|
|
+ tabs.splice(toIdx, 0, moved);
|
|
|
+ saveTabsToStorage(tabs);
|
|
|
+ renderTabBar(tabs, activeTabId);
|
|
|
+ });
|
|
|
+
|
|
|
+ tabList.appendChild(item);
|
|
|
+ });
|
|
|
+
|
|
|
+ // PERF-006: Event delegation — single click handler for all tabs
|
|
|
+ tabList.onclick = function(e) {
|
|
|
+ const tabItem = e.target.closest('.tab-item');
|
|
|
+ if (!tabItem) return;
|
|
|
+ // Don't switch tab if clicking the menu button
|
|
|
+ if (e.target.closest('.tab-menu-btn')) return;
|
|
|
+ const tabId = tabItem.getAttribute('data-tab-id');
|
|
|
+ if (tabId) switchTab(tabId);
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+ // Auto-scroll active tab into view (paint-aligned to prevent forced reflows)
|
|
|
+ const activeItem = tabList.querySelector('.tab-item.active');
|
|
|
+ if (activeItem) {
|
|
|
+ requestAnimationFrame(function() {
|
|
|
+ activeItem.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // Arrow-key keyboard navigation inside tabList (WAI-ARIA compliance with manual selection)
|
|
|
+ tabList.onkeydown = function(e) {
|
|
|
+ const items = Array.from(tabList.querySelectorAll('.tab-item'));
|
|
|
+ if (items.length === 0) return;
|
|
|
+
|
|
|
+ const focusedItem = document.activeElement.closest('.tab-item');
|
|
|
+ if (!focusedItem) return;
|
|
|
+
|
|
|
+ const activeIdx = items.indexOf(focusedItem);
|
|
|
+ if (activeIdx === -1) return;
|
|
|
+
|
|
|
+ let targetIdx = -1;
|
|
|
+ if (e.key === 'ArrowRight') {
|
|
|
+ targetIdx = (activeIdx + 1) % items.length;
|
|
|
+ } else if (e.key === 'ArrowLeft') {
|
|
|
+ targetIdx = (activeIdx - 1 + items.length) % items.length;
|
|
|
+ } else if (e.key === 'Home') {
|
|
|
+ targetIdx = 0;
|
|
|
+ } else if (e.key === 'End') {
|
|
|
+ targetIdx = items.length - 1;
|
|
|
+ } else if (e.key === 'Enter' || e.key === ' ') {
|
|
|
+ e.preventDefault();
|
|
|
+ const tabId = focusedItem.getAttribute('data-tab-id');
|
|
|
+ switchTab(tabId);
|
|
|
+ // After switch tab, focus the active item in the newly rendered tab bar
|
|
|
+ requestAnimationFrame(function() {
|
|
|
+ const newActive = tabList.querySelector(`.tab-item[data-tab-id="${tabId}"]`);
|
|
|
+ if (newActive) newActive.focus();
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (targetIdx !== -1) {
|
|
|
+ e.preventDefault();
|
|
|
+ // Update roving tabindex focus without triggering heavy re-renders
|
|
|
+ items.forEach(function(item, idx) {
|
|
|
+ if (idx === targetIdx) {
|
|
|
+ item.setAttribute('tabindex', '0');
|
|
|
+ item.focus();
|
|
|
+ } else {
|
|
|
+ item.setAttribute('tabindex', '-1');
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ renderMobileTabList(tabsArr, currentActiveTabId);
|
|
|
+ if (typeof tabList.dispatchEvent === 'function') {
|
|
|
+ tabList.dispatchEvent(new Event('scroll'));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ========================================
|
|
|
+ // TAB OVERFLOW — Scroll Buttons, Wheel, Indicators
|
|
|
+ // ========================================
|
|
|
+
|
|
|
+ var _tabOverflowInitialized = false;
|
|
|
+
|
|
|
+ function setupTabOverflow() {
|
|
|
+ if (_tabOverflowInitialized) return;
|
|
|
+ _tabOverflowInitialized = true;
|
|
|
+
|
|
|
+ var tabBar = document.getElementById('tab-bar');
|
|
|
+ var tabList = document.getElementById('tab-list');
|
|
|
+ if (!tabBar || !tabList) return;
|
|
|
+
|
|
|
+ // --- Create scroll arrow buttons ---
|
|
|
+ var scrollLeftBtn = document.createElement('button');
|
|
|
+ scrollLeftBtn.className = 'tab-scroll-btn tab-scroll-left';
|
|
|
+ scrollLeftBtn.setAttribute('aria-label', 'Scroll tabs left');
|
|
|
+ scrollLeftBtn.title = 'Scroll left';
|
|
|
+ scrollLeftBtn.innerHTML = '<i class="bi bi-chevron-left"></i>';
|
|
|
+ scrollLeftBtn.addEventListener('click', function() {
|
|
|
+ tabList.scrollBy({ left: -200, behavior: 'smooth' });
|
|
|
+ });
|
|
|
+
|
|
|
+ var scrollRightBtn = document.createElement('button');
|
|
|
+ scrollRightBtn.className = 'tab-scroll-btn tab-scroll-right';
|
|
|
+ scrollRightBtn.setAttribute('aria-label', 'Scroll tabs right');
|
|
|
+ scrollRightBtn.title = 'Scroll right';
|
|
|
+ scrollRightBtn.innerHTML = '<i class="bi bi-chevron-right"></i>';
|
|
|
+ scrollRightBtn.addEventListener('click', function() {
|
|
|
+ tabList.scrollBy({ left: 200, behavior: 'smooth' });
|
|
|
+ });
|
|
|
+
|
|
|
+ // Insert scroll buttons flanking the tab-list
|
|
|
+ tabBar.insertBefore(scrollLeftBtn, tabList);
|
|
|
+ var newBtn = document.getElementById('tab-new-btn');
|
|
|
+ if (newBtn) {
|
|
|
+ tabBar.insertBefore(scrollRightBtn, newBtn);
|
|
|
+ } else {
|
|
|
+ var resetBtn = document.getElementById('tab-reset-btn');
|
|
|
+ if (resetBtn) {
|
|
|
+ tabBar.insertBefore(scrollRightBtn, resetBtn);
|
|
|
+ } else {
|
|
|
+ tabBar.appendChild(scrollRightBtn);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // --- Overflow detection ---
|
|
|
+ var _overflowRafId = null;
|
|
|
+ function updateOverflowState() {
|
|
|
+ if (_overflowRafId) return;
|
|
|
+ _overflowRafId = requestAnimationFrame(function() {
|
|
|
+ _overflowRafId = null;
|
|
|
+ var hasLeft = tabList.scrollLeft > 1;
|
|
|
+ var hasRight = tabList.scrollLeft < (tabList.scrollWidth - tabList.clientWidth - 1);
|
|
|
+ tabBar.classList.toggle('has-overflow-left', hasLeft);
|
|
|
+ tabBar.classList.toggle('has-overflow-right', hasRight);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ tabList.addEventListener('scroll', updateOverflowState);
|
|
|
+
|
|
|
+ // Use ResizeObserver to detect when overflow state changes due to window resize
|
|
|
+ if (typeof ResizeObserver !== 'undefined') {
|
|
|
+ var resizeObs = new ResizeObserver(updateOverflowState);
|
|
|
+ resizeObs.observe(tabList);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Initial check
|
|
|
+ updateOverflowState();
|
|
|
+
|
|
|
+ // --- Mouse wheel scroll: vertical wheel → horizontal scroll ---
|
|
|
+ tabList.addEventListener('wheel', function(e) {
|
|
|
+ // Only intercept vertical wheel (don't fight native horizontal wheel/trackpad)
|
|
|
+ if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
|
|
|
+ e.preventDefault();
|
|
|
+ tabList.scrollLeft += e.deltaY;
|
|
|
+ updateOverflowState();
|
|
|
+ }
|
|
|
+ }, { passive: false });
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderMobileTabList(tabsArr, currentActiveTabId) {
|
|
|
+ const mobileTabList = document.getElementById('mobile-tab-list');
|
|
|
+ if (!mobileTabList) return;
|
|
|
+ // PERF-007: Clear element content using textContent instead of innerHTML
|
|
|
+ mobileTabList.textContent = '';
|
|
|
+ tabsArr.forEach(function(tab) {
|
|
|
+ const item = document.createElement('div');
|
|
|
+ item.className = 'mobile-tab-item' + (tab.id === currentActiveTabId ? ' active' : '');
|
|
|
+ item.setAttribute('role', 'tab');
|
|
|
+ item.setAttribute('aria-selected', tab.id === currentActiveTabId ? 'true' : 'false');
|
|
|
+ item.setAttribute('data-tab-id', tab.id);
|
|
|
+
|
|
|
+ const titleSpan = document.createElement('span');
|
|
|
+ titleSpan.className = 'mobile-tab-title';
|
|
|
+ titleSpan.textContent = tab.title || 'Untitled';
|
|
|
+ titleSpan.title = tab.title || 'Untitled';
|
|
|
+
|
|
|
+ const tabMenu = createTabActionMenu(tab, {
|
|
|
+ isMobileMenu: true,
|
|
|
+ menuIdPrefix: 'mobile-tab-menu'
|
|
|
+ });
|
|
|
+
|
|
|
+ item.appendChild(titleSpan);
|
|
|
+ item.appendChild(tabMenu.button);
|
|
|
+
|
|
|
+ item.addEventListener('click', function() {
|
|
|
+ switchTab(tab.id);
|
|
|
+ closeMobileMenu();
|
|
|
+ });
|
|
|
+
|
|
|
+ mobileTabList.appendChild(item);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // Close any open tab dropdown when clicking elsewhere in the document
|
|
|
+ document.addEventListener('click', function() {
|
|
|
+ closeTabMenus();
|
|
|
+ });
|
|
|
+
|
|
|
+ function saveCurrentTabState() {
|
|
|
+ const tab = tabs.find(function(t) { return t.id === activeTabId; });
|
|
|
+ if (!tab) return;
|
|
|
+ tab.content = markdownEditor.value;
|
|
|
+ tab.scrollPos = markdownEditor.scrollTop;
|
|
|
+ tab.viewMode = currentViewMode || 'split';
|
|
|
+ saveTabsToStorage(tabs);
|
|
|
+ }
|
|
|
+
|
|
|
+ function restoreViewMode(mode) {
|
|
|
+ currentViewMode = null;
|
|
|
+ setViewMode(mode || 'split');
|
|
|
+ }
|
|
|
+
|
|
|
+ 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() {
|
|
|
+ markdownEditor.scrollTop = tab.scrollPos || 0;
|
|
|
+ });
|
|
|
+ renderTabBar(tabs, activeTabId);
|
|
|
+ }
|
|
|
+
|
|
|
+ function newTab(content, title) {
|
|
|
+ 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 (!title) title = nextUntitledTitle();
|
|
|
+ const tab = createTab(content, title);
|
|
|
+ tabs.push(tab);
|
|
|
+ switchTab(tab.id);
|
|
|
+ markdownEditor.focus();
|
|
|
+ }
|
|
|
+
|
|
|
+ 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
|
|
|
+ const newT = createTab('', nextUntitledTitle());
|
|
|
+ tabs.push(newT);
|
|
|
+ activeTabId = newT.id;
|
|
|
+ saveActiveTabId(activeTabId);
|
|
|
+ markdownEditor.value = '';
|
|
|
+ restoreViewMode('split');
|
|
|
+ renderMarkdown();
|
|
|
+ } else if (activeTabId === tabId) {
|
|
|
+ const newIdx = Math.max(0, idx - 1);
|
|
|
+ activeTabId = tabs[newIdx].id;
|
|
|
+ saveActiveTabId(activeTabId);
|
|
|
+ const newActiveTab = tabs[newIdx];
|
|
|
+ markdownEditor.value = newActiveTab.content;
|
|
|
+ restoreViewMode(newActiveTab.viewMode);
|
|
|
+ renderMarkdown();
|
|
|
+ requestAnimationFrame(function() {
|
|
|
+ markdownEditor.scrollTop = newActiveTab.scrollPos || 0;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ saveTabsToStorage(tabs);
|
|
|
+ renderTabBar(tabs, activeTabId);
|
|
|
+ }
|
|
|
+
|
|
|
+ function deleteTab(tabId) {
|
|
|
+ closeTab(tabId);
|
|
|
+ }
|
|
|
+
|
|
|
+ function renameTab(tabId) {
|
|
|
+ const tab = tabs.find(function(t) { return t.id === tabId; });
|
|
|
+ if (!tab) return;
|
|
|
+ const modal = document.getElementById('rename-modal');
|
|
|
+ const input = document.getElementById('rename-modal-input');
|
|
|
+ const confirmBtn = document.getElementById('rename-modal-confirm');
|
|
|
+ const cancelBtn = document.getElementById('rename-modal-cancel');
|
|
|
+ if (!modal || !input) return;
|
|
|
+ input.value = tab.title;
|
|
|
+
|
|
|
+ function doRename() {
|
|
|
+ const newName = input.value.trim();
|
|
|
+ if (newName) {
|
|
|
+ tab.title = newName;
|
|
|
+ saveTabsToStorage(tabs);
|
|
|
+ renderTabBar(tabs, activeTabId);
|
|
|
+ }
|
|
|
+ closeAppModal(modal);
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+
|
|
|
+ function doCancel() {
|
|
|
+ closeAppModal(modal);
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+
|
|
|
+ function onKey(e) {
|
|
|
+ if (e.key === 'Enter') doRename();
|
|
|
+ }
|
|
|
+
|
|
|
+ function cleanup() {
|
|
|
+ confirmBtn.removeEventListener('click', doRename);
|
|
|
+ cancelBtn.removeEventListener('click', doCancel);
|
|
|
+ input.removeEventListener('keydown', onKey);
|
|
|
+ }
|
|
|
+
|
|
|
+ confirmBtn.addEventListener('click', doRename);
|
|
|
+ cancelBtn.addEventListener('click', doCancel);
|
|
|
+ input.addEventListener('keydown', onKey);
|
|
|
+
|
|
|
+ openAppModal(modal, {
|
|
|
+ focusTarget: input,
|
|
|
+ onClose: doCancel
|
|
|
+ });
|
|
|
+ input.select();
|
|
|
+ }
|
|
|
+
|
|
|
+ function duplicateTab(tabId) {
|
|
|
+ const tab = tabs.find(function(t) { return t.id === tabId; });
|
|
|
+ if (!tab) return;
|
|
|
+ if (tabs.length >= 20) {
|
|
|
+ alert('Maximum of 20 tabs reached. Please close an existing tab to open a new one.');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const shouldSwitchToDuplicate = tabId === activeTabId;
|
|
|
+ saveCurrentTabState();
|
|
|
+ const dupTitle = tab.title + ' (copy)';
|
|
|
+ const dup = createTab(tab.content, dupTitle, tab.viewMode);
|
|
|
+ const idx = tabs.findIndex(function(t) { return t.id === tabId; });
|
|
|
+ tabs.splice(idx + 1, 0, dup);
|
|
|
+ if (shouldSwitchToDuplicate) {
|
|
|
+ switchTab(dup.id);
|
|
|
+ } else {
|
|
|
+ saveTabsToStorage(tabs);
|
|
|
+ renderTabBar(tabs, activeTabId);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function resetAllTabs() {
|
|
|
+ const modal = document.getElementById('reset-confirm-modal');
|
|
|
+ const confirmBtn = document.getElementById('reset-modal-confirm');
|
|
|
+ const cancelBtn = document.getElementById('reset-modal-cancel');
|
|
|
+ if (!modal) return;
|
|
|
+
|
|
|
+ function doReset() {
|
|
|
+ closeAppModal(modal);
|
|
|
+ cleanup();
|
|
|
+ tabs = [];
|
|
|
+ untitledCounter = 0;
|
|
|
+ saveUntitledCounter(0);
|
|
|
+ const welcome = createTab(sampleMarkdown, 'Welcome to Markdown');
|
|
|
+ tabs.push(welcome);
|
|
|
+ activeTabId = welcome.id;
|
|
|
+ saveActiveTabId(activeTabId);
|
|
|
+ saveTabsToStorage(tabs);
|
|
|
+ markdownEditor.value = sampleMarkdown;
|
|
|
+ restoreViewMode('split');
|
|
|
+ renderMarkdown();
|
|
|
+ renderTabBar(tabs, activeTabId);
|
|
|
+ }
|
|
|
+
|
|
|
+ function doCancel() {
|
|
|
+ closeAppModal(modal);
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+
|
|
|
+ function cleanup() {
|
|
|
+ confirmBtn.removeEventListener('click', doReset);
|
|
|
+ cancelBtn.removeEventListener('click', doCancel);
|
|
|
+ }
|
|
|
+
|
|
|
+ confirmBtn.addEventListener('click', doReset);
|
|
|
+ cancelBtn.addEventListener('click', doCancel);
|
|
|
+
|
|
|
+ openAppModal(modal, {
|
|
|
+ focusTarget: confirmBtn,
|
|
|
+ onClose: doCancel
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function initTabs() {
|
|
|
+ untitledCounter = loadUntitledCounter();
|
|
|
+ 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);
|
|
|
+ delete window.NL_INITIAL_FILE_CONTENT;
|
|
|
+ } else if (tabs.length === 0) {
|
|
|
+ const tab = createTab(sampleMarkdown, 'Welcome to Markdown');
|
|
|
+ tabs.push(tab);
|
|
|
+ activeTabId = tab.id;
|
|
|
+ saveTabsToStorage(tabs);
|
|
|
+ saveActiveTabId(activeTabId);
|
|
|
+ } else if (!tabs.find(function(t) { return t.id === activeTabId; })) {
|
|
|
+ activeTabId = tabs[0].id;
|
|
|
+ saveActiveTabId(activeTabId);
|
|
|
+ }
|
|
|
+ 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');
|
|
|
+ if (editorPane) {
|
|
|
+ editorPane.classList.remove('is-loading');
|
|
|
+ }
|
|
|
+ requestAnimationFrame(function() {
|
|
|
+ markdownEditor.scrollTop = activeTab.scrollPos || 0;
|
|
|
+ });
|
|
|
+ renderTabBar(tabs, activeTabId);
|
|
|
+ setupTabOverflow();
|
|
|
+
|
|
|
+ const staticNewBtn = document.getElementById('tab-new-btn');
|
|
|
+ if (staticNewBtn) {
|
|
|
+ staticNewBtn.onclick = function() {
|
|
|
+ newTab();
|
|
|
+ };
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Late-load callback hook for Neutralino command-line files
|
|
|
+ window.NL_IMPORT_EXTERNAL_FILE = function(content, name) {
|
|
|
+ if (typeof tabs === 'undefined') return;
|
|
|
+ const existing = tabs.find(function(t) { return t.title === name && t.content === content; });
|
|
|
+ if (existing) {
|
|
|
+ switchTab(existing.id);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ newTab(content, name);
|
|
|
+ };
|
|
|
+
|
|
|
+ function showPreviewSkeleton() {
|
|
|
+ if (markdownPreview && !markdownPreview.querySelector('#markdown-preview-skeleton')) {
|
|
|
+ markdownPreview.setAttribute('aria-busy', 'true');
|
|
|
+ markdownPreview.dataset.renderState = 'loading';
|
|
|
+ markdownPreview.innerHTML = `
|
|
|
+ <div class="skeleton-preview-container" id="markdown-preview-skeleton" aria-hidden="true">
|
|
|
+ <div class="skeleton-placeholder skeleton-title"></div>
|
|
|
+ <div class="skeleton-placeholder skeleton-line skeleton-w90"></div>
|
|
|
+ <div class="skeleton-placeholder skeleton-line skeleton-w85"></div>
|
|
|
+ <div class="skeleton-placeholder skeleton-line skeleton-w60"></div>
|
|
|
+
|
|
|
+ <div class="skeleton-placeholder skeleton-subtitle"></div>
|
|
|
+ <div class="skeleton-placeholder skeleton-line skeleton-w88"></div>
|
|
|
+ <div class="skeleton-placeholder skeleton-line skeleton-w92"></div>
|
|
|
+ <div class="skeleton-placeholder skeleton-line skeleton-w45"></div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function previewContainsSkeleton() {
|
|
|
+ return Boolean(markdownPreview && markdownPreview.querySelector('#markdown-preview-skeleton'));
|
|
|
+ }
|
|
|
+
|
|
|
+ function getActivePreviewDocumentId() {
|
|
|
+ return activeTabId || '__single-document__';
|
|
|
+ }
|
|
|
+
|
|
|
+ function clearPendingPreviewWork() {
|
|
|
+ if (pendingPreviewRenderCancel) {
|
|
|
+ pendingPreviewRenderCancel();
|
|
|
+ pendingPreviewRenderCancel = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function getPreviewRenderDelay(markdown) {
|
|
|
+ const length = markdown.length;
|
|
|
+ if (length >= HUGE_DOCUMENT_THRESHOLD) return HUGE_RENDER_DELAY;
|
|
|
+ if (length >= LARGE_DOCUMENT_THRESHOLD) return LARGE_RENDER_DELAY;
|
|
|
+ return RENDER_DELAY;
|
|
|
+ }
|
|
|
+
|
|
|
+ function getEditorWorkDelay(markdown) {
|
|
|
+ const length = markdown.length;
|
|
|
+ if (length >= HUGE_DOCUMENT_THRESHOLD) return HUGE_EDITOR_WORK_DELAY;
|
|
|
+ if (length >= LARGE_DOCUMENT_THRESHOLD) return LARGE_EDITOR_WORK_DELAY;
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ function isEditorVisible() {
|
|
|
+ return currentViewMode === 'editor' || currentViewMode === 'split';
|
|
|
+ }
|
|
|
+
|
|
|
+ function deferPreviewWork(callback, rawLength) {
|
|
|
+ let cancelled = false;
|
|
|
+ let rafId = null;
|
|
|
+ let idleId = null;
|
|
|
+ let timeoutId = null;
|
|
|
+
|
|
|
+ rafId = requestAnimationFrame(function() {
|
|
|
+ rafId = null;
|
|
|
+ if (cancelled) return;
|
|
|
+
|
|
|
+ if ('requestIdleCallback' in window) {
|
|
|
+ idleId = window.requestIdleCallback(function() {
|
|
|
+ idleId = null;
|
|
|
+ if (!cancelled) callback();
|
|
|
+ }, { timeout: rawLength >= HUGE_DOCUMENT_THRESHOLD ? 700 : 350 });
|
|
|
+ } else {
|
|
|
+ timeoutId = setTimeout(function() {
|
|
|
+ timeoutId = null;
|
|
|
+ if (!cancelled) callback();
|
|
|
+ }, 0);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ return function cancelDeferredPreviewWork() {
|
|
|
+ cancelled = true;
|
|
|
+ if (rafId !== null) cancelAnimationFrame(rafId);
|
|
|
+ if (idleId !== null && 'cancelIdleCallback' in window) window.cancelIdleCallback(idleId);
|
|
|
+ if (timeoutId !== null) clearTimeout(timeoutId);
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ function capturePreviewScroll() {
|
|
|
+ if (!previewPane) return null;
|
|
|
+ return {
|
|
|
+ top: previewPane.scrollTop,
|
|
|
+ left: previewPane.scrollLeft,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ function restorePreviewScroll(snapshot) {
|
|
|
+ if (!snapshot || !previewPane) return;
|
|
|
+ requestAnimationFrame(function() {
|
|
|
+ const maxTop = Math.max(0, previewPane.scrollHeight - previewPane.clientHeight);
|
|
|
+ previewPane.scrollTop = Math.min(maxTop, snapshot.top);
|
|
|
+ previewPane.scrollLeft = snapshot.left;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function canReusePreviewNode(currentNode, nextNode, options) {
|
|
|
+ if (!currentNode || !nextNode || currentNode.nodeType !== nextNode.nodeType) return false;
|
|
|
+
|
|
|
+ if (currentNode.nodeType === Node.TEXT_NODE) {
|
|
|
+ if (currentNode.nodeValue !== nextNode.nodeValue) {
|
|
|
+ currentNode.nodeValue = nextNode.nodeValue;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (currentNode.nodeType !== Node.ELEMENT_NODE) {
|
|
|
+ return currentNode.nodeValue === nextNode.nodeValue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (currentNode.nodeName !== nextNode.nodeName) return false;
|
|
|
+
|
|
|
+ const currentEl = currentNode;
|
|
|
+ const nextEl = nextNode;
|
|
|
+ if ((currentEl.id || nextEl.id) && currentEl.id !== nextEl.id) return false;
|
|
|
+
|
|
|
+ if (
|
|
|
+ options &&
|
|
|
+ options.reusePreviewBlocks &&
|
|
|
+ currentEl.dataset &&
|
|
|
+ nextEl.dataset &&
|
|
|
+ currentEl.dataset.previewBlockHash &&
|
|
|
+ currentEl.dataset.previewBlockHash === nextEl.dataset.previewBlockHash
|
|
|
+ ) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (currentEl.outerHTML === nextEl.outerHTML) return true;
|
|
|
+
|
|
|
+ if (currentEl.tagName === 'DETAILS' && nextEl.tagName === 'DETAILS' && currentEl.hasAttribute('open')) {
|
|
|
+ nextEl.setAttribute('open', '');
|
|
|
+ }
|
|
|
+
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ function patchPreviewDom(container, html, options) {
|
|
|
+ const result = {
|
|
|
+ fullReplace: false,
|
|
|
+ updatedNodes: [],
|
|
|
+ };
|
|
|
+
|
|
|
+ if (!previewHasCommittedRender || previewContainsSkeleton()) {
|
|
|
+ container.innerHTML = html;
|
|
|
+ result.fullReplace = true;
|
|
|
+ result.updatedNodes = [container];
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ const template = document.createElement('template');
|
|
|
+ template.innerHTML = html;
|
|
|
+ const nextNodes = Array.from(template.content.childNodes);
|
|
|
+ const currentNodeCount = container.childNodes.length;
|
|
|
+
|
|
|
+ if (nextNodes.length > 6000 || currentNodeCount > 6000) {
|
|
|
+ container.replaceChildren(...nextNodes);
|
|
|
+ result.fullReplace = true;
|
|
|
+ result.updatedNodes = [container];
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ let index = 0;
|
|
|
+ while (index < nextNodes.length || index < container.childNodes.length) {
|
|
|
+ const currentNode = container.childNodes[index];
|
|
|
+ const nextNode = nextNodes[index];
|
|
|
+
|
|
|
+ if (!nextNode) {
|
|
|
+ currentNode.remove();
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!currentNode) {
|
|
|
+ container.appendChild(nextNode);
|
|
|
+ result.updatedNodes.push(nextNode);
|
|
|
+ index += 1;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (canReusePreviewNode(currentNode, nextNode, options)) {
|
|
|
+ index += 1;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ result.updatedNodes.push(nextNode);
|
|
|
+ currentNode.replaceWith(nextNode);
|
|
|
+ index += 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ function commitPreviewHtml(sanitizedHtml, referenceData, rawVal, context) {
|
|
|
+ const shouldRestoreScroll = previewHasCommittedRender && !previewContainsSkeleton();
|
|
|
+ const scrollSnapshot = shouldRestoreScroll ? capturePreviewScroll() : null;
|
|
|
+
|
|
|
+ const patchResult = patchPreviewDom(markdownPreview, sanitizedHtml, {
|
|
|
+ reusePreviewBlocks: context.previewEngineMode === 'segmented' && !context.force,
|
|
|
+ });
|
|
|
+ applyReferencePreviewLinks(markdownPreview, referenceData.definitions);
|
|
|
+ enhanceGitHubAlerts(markdownPreview);
|
|
|
+
|
|
|
+ _lastRenderedContent = rawVal;
|
|
|
+ previewHasCommittedRender = true;
|
|
|
+ previewLastRenderedTabId = context.previewDocumentId;
|
|
|
+ markdownPreview.removeAttribute('aria-busy');
|
|
|
+ markdownPreview.dataset.renderState = 'ready';
|
|
|
+
|
|
|
+ restorePreviewScroll(scrollSnapshot);
|
|
|
+ return patchResult;
|
|
|
+ }
|
|
|
+
|
|
|
+ function getPreviewPostProcessRoots(patchResult) {
|
|
|
+ if (!patchResult || patchResult.fullReplace || !patchResult.updatedNodes || patchResult.updatedNodes.length === 0) {
|
|
|
+ return [markdownPreview];
|
|
|
+ }
|
|
|
+
|
|
|
+ const roots = [];
|
|
|
+ const seen = new Set();
|
|
|
+ patchResult.updatedNodes.forEach(function(node) {
|
|
|
+ const root = node && node.nodeType === Node.ELEMENT_NODE ? node : (node && node.parentElement);
|
|
|
+ if (root && !seen.has(root)) {
|
|
|
+ seen.add(root);
|
|
|
+ roots.push(root);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ return roots.length ? roots : [markdownPreview];
|
|
|
+ }
|
|
|
+
|
|
|
+ function queryPreviewRoots(roots, selector) {
|
|
|
+ const matches = [];
|
|
|
+ const seen = new Set();
|
|
|
+ roots.forEach(function(root) {
|
|
|
+ if (!root || root.nodeType !== Node.ELEMENT_NODE) return;
|
|
|
+ if (root.matches && root.matches(selector) && !seen.has(root)) {
|
|
|
+ seen.add(root);
|
|
|
+ matches.push(root);
|
|
|
+ }
|
|
|
+ root.querySelectorAll(selector).forEach(function(node) {
|
|
|
+ if (!seen.has(node)) {
|
|
|
+ seen.add(node);
|
|
|
+ matches.push(node);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+ return matches;
|
|
|
+ }
|
|
|
+
|
|
|
+ function markPreviewRootsReady(roots) {
|
|
|
+ queryPreviewRoots(roots, '.mermaid-container.is-loading').forEach(function(container) {
|
|
|
+ container.classList.remove('is-loading');
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function postProcessPreview(rawVal, context, patchResult) {
|
|
|
+ const roots = getPreviewPostProcessRoots(patchResult);
|
|
|
+
|
|
|
+ roots.forEach(function(root) {
|
|
|
+ processEmojis(root);
|
|
|
+ });
|
|
|
+
|
|
|
+ queryPreviewRoots(roots, 'input[type="checkbox"]').forEach(function(input) {
|
|
|
+ if (!input.hasAttribute('aria-label')) {
|
|
|
+ const parentText = input.parentElement ? input.parentElement.textContent.trim() : '';
|
|
|
+ input.setAttribute('aria-label', parentText || 'Task item');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ try {
|
|
|
+ const mermaidNodes = queryPreviewRoots(roots, '.mermaid');
|
|
|
+ if (mermaidNodes.length > 0) {
|
|
|
+ const renderMermaidNodes = function() {
|
|
|
+ if (context.renderId !== previewRenderGeneration) return;
|
|
|
+ initMermaid(false);
|
|
|
+ Promise.resolve(mermaid.init(undefined, mermaidNodes))
|
|
|
+ .then(() => {
|
|
|
+ if (context.renderId !== previewRenderGeneration) return;
|
|
|
+ markPreviewRootsReady(roots);
|
|
|
+ addMermaidToolbars();
|
|
|
+ })
|
|
|
+ .catch((e) => {
|
|
|
+ if (context.renderId !== previewRenderGeneration) return;
|
|
|
+ console.warn("Mermaid rendering failed:", e);
|
|
|
+ markPreviewRootsReady(roots);
|
|
|
+ addMermaidToolbars();
|
|
|
+ });
|
|
|
+ };
|
|
|
+ if (typeof mermaid === 'undefined') {
|
|
|
+ loadScript(CDN.mermaid).then(function() {
|
|
|
+ if (context.renderId !== previewRenderGeneration) return;
|
|
|
+ initMermaid(true);
|
|
|
+ renderMermaidNodes();
|
|
|
+ }).catch(function(e) { console.warn('Failed to load mermaid:', e); });
|
|
|
+ } else {
|
|
|
+ renderMermaidNodes();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.warn("Mermaid rendering failed:", e);
|
|
|
+ }
|
|
|
+
|
|
|
+ const hasMath = /\$\$|\$[^$]|\\\(|\\\[/.test(rawVal || '');
|
|
|
+ if (hasMath) {
|
|
|
+ const typesetTargets = roots.filter(function(root) {
|
|
|
+ return root && root.nodeType === Node.ELEMENT_NODE && /\$\$|\$[^$]|\\\(|\\\[/.test(root.textContent || '');
|
|
|
+ });
|
|
|
+ const mathTargets = typesetTargets.length ? typesetTargets : roots;
|
|
|
+ if (window.MathJax) {
|
|
|
+ try {
|
|
|
+ MathJax.typesetPromise(mathTargets).then(function() {
|
|
|
+ if (context.renderId !== previewRenderGeneration) return;
|
|
|
+ queryPreviewRoots(mathTargets, 'mjx-container[tabindex="0"]').forEach(function(mjx) {
|
|
|
+ mjx.removeAttribute('tabindex');
|
|
|
+ });
|
|
|
+ }).catch(function(err) {
|
|
|
+ console.warn('MathJax typesetting failed:', err);
|
|
|
+ });
|
|
|
+ } catch (e) {
|
|
|
+ console.warn("MathJax rendering failed:", e);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ window.MathJax = {
|
|
|
+ loader: { load: ['[tex]/ams', '[tex]/boldsymbol'] },
|
|
|
+ options: {
|
|
|
+ a11y: { inTabOrder: false }
|
|
|
+ },
|
|
|
+ tex: {
|
|
|
+ inlineMath: [['$', '$'], ['\\(', '\\)']],
|
|
|
+ displayMath: [['$$', '$$'], ['\\[', '\\]']],
|
|
|
+ processEscapes: true,
|
|
|
+ packages: { '[+]': ['ams', 'boldsymbol'] }
|
|
|
+ }
|
|
|
+ };
|
|
|
+ loadScript(CDN.mathjax).then(function() {
|
|
|
+ if (context.renderId !== previewRenderGeneration) return;
|
|
|
+ try {
|
|
|
+ MathJax.typesetPromise(mathTargets).then(function() {
|
|
|
+ if (context.renderId !== previewRenderGeneration) return;
|
|
|
+ queryPreviewRoots(mathTargets, 'mjx-container[tabindex="0"]').forEach(function(mjx) {
|
|
|
+ mjx.removeAttribute('tabindex');
|
|
|
+ });
|
|
|
+ }).catch(function(err) {
|
|
|
+ console.warn('MathJax typesetting failed:', err);
|
|
|
+ });
|
|
|
+ } catch (e) {
|
|
|
+ console.warn('MathJax rendering failed:', e);
|
|
|
+ }
|
|
|
+ }).catch(function(e) { console.warn('Failed to load MathJax:', e); });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ updateDocumentStats();
|
|
|
+ updateFindHighlights();
|
|
|
+ cleanupImageObjectUrls();
|
|
|
+ scheduleLineNumberUpdate();
|
|
|
+ }
|
|
|
+
|
|
|
+ function executeMainThreadRender(rawVal, context) {
|
|
|
+ const { frontmatter, body } = parseFrontmatter(rawVal);
|
|
|
+ const tableHtml = frontmatter ? renderFrontmatterTable(frontmatter) : '';
|
|
|
+ const referenceData = extractReferenceDefinitions(body);
|
|
|
+ const html = tableHtml + marked.parse(referenceData.cleanedMarkdown);
|
|
|
+ const sanitizedHtml = sanitizePreviewHtml(html);
|
|
|
+
|
|
|
+ if (context.renderId !== previewRenderGeneration || markdownEditor.value !== rawVal) return;
|
|
|
+
|
|
|
+ previewSegmentHtmlCache.clear();
|
|
|
+ const patchResult = commitPreviewHtml(sanitizedHtml, referenceData, rawVal, context);
|
|
|
+ postProcessPreview(rawVal, context, patchResult);
|
|
|
+ }
|
|
|
+
|
|
|
+ function executeWorkerRender(rawVal, context) {
|
|
|
+ requestPreviewWorkerRender(rawVal, context)
|
|
|
+ .then(function(result) {
|
|
|
+ if (context.renderId !== previewRenderGeneration || markdownEditor.value !== rawVal) return;
|
|
|
+ if (!result || result.mode !== 'segmented' || !Array.isArray(result.blocks) || result.blocks.length < PREVIEW_SEGMENT_MIN_BLOCKS) {
|
|
|
+ executeMainThreadRender(rawVal, Object.assign({}, context, { disableWorker: true }));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const segmentedHtml = buildSegmentedPreviewHtml(result.blocks, context.previewDocumentId);
|
|
|
+ if (context.renderId !== previewRenderGeneration || markdownEditor.value !== rawVal) return;
|
|
|
+
|
|
|
+ const patchResult = commitPreviewHtml(segmentedHtml, { definitions: new Map() }, rawVal, Object.assign({}, context, {
|
|
|
+ previewEngineMode: 'segmented',
|
|
|
+ }));
|
|
|
+ postProcessPreview(rawVal, context, patchResult);
|
|
|
+ })
|
|
|
+ .catch(function(error) {
|
|
|
+ if (context.renderId !== previewRenderGeneration || markdownEditor.value !== rawVal) return;
|
|
|
+ console.warn('Preview worker unavailable; falling back to main-thread renderer:', error);
|
|
|
+ executeMainThreadRender(rawVal, Object.assign({}, context, { disableWorker: true }));
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderMarkdown(options) {
|
|
|
+ options = options || {};
|
|
|
+ const rawVal = markdownEditor.value;
|
|
|
+ const force = options.force === true;
|
|
|
+ const previewDocumentId = getActivePreviewDocumentId();
|
|
|
+ const hasCurrentPreview =
|
|
|
+ previewHasCommittedRender &&
|
|
|
+ previewLastRenderedTabId === previewDocumentId &&
|
|
|
+ _lastRenderedContent === rawVal &&
|
|
|
+ !previewContainsSkeleton();
|
|
|
+
|
|
|
+ if (hasCurrentPreview && !force) return;
|
|
|
+
|
|
|
+ clearPendingPreviewWork();
|
|
|
+ const renderId = ++previewRenderGeneration;
|
|
|
+ const isLargeDocument = rawVal.length >= LARGE_DOCUMENT_THRESHOLD;
|
|
|
+ const isDocumentSwap = previewHasCommittedRender && previewLastRenderedTabId !== previewDocumentId;
|
|
|
+ const needsInitialPreview = !previewHasCommittedRender || previewContainsSkeleton();
|
|
|
+ const shouldShowSkeleton =
|
|
|
+ isLargeDocument &&
|
|
|
+ (options.showSkeleton === true || needsInitialPreview || isDocumentSwap);
|
|
|
+
|
|
|
+ if (shouldShowSkeleton) {
|
|
|
+ showPreviewSkeleton();
|
|
|
+ } else if (markdownPreview) {
|
|
|
+ markdownPreview.setAttribute('aria-busy', 'true');
|
|
|
+ markdownPreview.dataset.renderState = 'refreshing';
|
|
|
+ }
|
|
|
+
|
|
|
+ const runRender = function() {
|
|
|
+ pendingPreviewRenderCancel = null;
|
|
|
+ if (renderId !== previewRenderGeneration || markdownEditor.value !== rawVal) return;
|
|
|
+ executeRender(rawVal, {
|
|
|
+ force,
|
|
|
+ renderId,
|
|
|
+ previewDocumentId,
|
|
|
+ reason: options.reason || 'direct',
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ if (isLargeDocument) {
|
|
|
+ pendingPreviewRenderCancel = deferPreviewWork(runRender, rawVal.length);
|
|
|
+ } else {
|
|
|
+ runRender();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function executeRender(rawVal, context) {
|
|
|
+ context = context || {};
|
|
|
+ // PERF-003: Skip render if content hasn't changed
|
|
|
+ if (
|
|
|
+ !context.force &&
|
|
|
+ rawVal === _lastRenderedContent &&
|
|
|
+ previewLastRenderedTabId === context.previewDocumentId &&
|
|
|
+ previewHasCommittedRender &&
|
|
|
+ !previewContainsSkeleton()
|
|
|
+ ) {
|
|
|
+ markdownPreview.removeAttribute('aria-busy');
|
|
|
+ markdownPreview.dataset.renderState = 'ready';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ if (shouldUsePreviewWorker(rawVal, context)) {
|
|
|
+ executeWorkerRender(rawVal, context);
|
|
|
+ } else {
|
|
|
+ executeMainThreadRender(rawVal, context);
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error("Markdown rendering failed:", e);
|
|
|
+ const safeMessage = escapeHtml(e && e.message ? e.message : 'Unknown error');
|
|
|
+ const safeMarkdown = escapeHtml(rawVal);
|
|
|
+ markdownPreview.removeAttribute('aria-busy');
|
|
|
+ markdownPreview.dataset.renderState = 'error';
|
|
|
+ if (!previewHasCommittedRender || previewContainsSkeleton()) {
|
|
|
+ markdownPreview.innerHTML = `<div class="alert alert-danger">
|
|
|
+ <strong>Error rendering markdown:</strong> ${safeMessage}
|
|
|
+ </div>
|
|
|
+ <pre>${safeMarkdown}</pre>`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function importMarkdownFile(file) {
|
|
|
+ if (file.size > 10 * 1024 * 1024) {
|
|
|
+ alert('File is too large (maximum 10MB supported).');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const reader = new FileReader();
|
|
|
+ reader.onload = function(e) {
|
|
|
+ const text = e.target.result || '';
|
|
|
+
|
|
|
+ // Simple binary check: look for null bytes in the first 8KB
|
|
|
+ const checkLength = Math.min(text.length, 8000);
|
|
|
+ for (let i = 0; i < checkLength; i++) {
|
|
|
+ if (text.charCodeAt(i) === 0) {
|
|
|
+ alert('Cannot import: The selected file appears to be a binary file.');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ newTab(text, file.name.replace(/\.md$/i, ''));
|
|
|
+ };
|
|
|
+ reader.onerror = function() {
|
|
|
+ alert('Failed to read the file. Please check permissions and try again.');
|
|
|
+ };
|
|
|
+ reader.readAsText(file);
|
|
|
+ }
|
|
|
+
|
|
|
+ function isMarkdownPath(path) {
|
|
|
+ return /\.(md|markdown)$/i.test(path || "");
|
|
|
+ }
|
|
|
+ const MAX_GITHUB_FILES_SHOWN = 30;
|
|
|
+ const GITHUB_IMPORT_MIN_REQUEST_INTERVAL_MS = 800;
|
|
|
+ let lastGitHubImportRequestAt = 0;
|
|
|
+ const selectedGitHubImportPaths = new Set();
|
|
|
+ let availableGitHubImportPaths = [];
|
|
|
+
|
|
|
+ function getFileName(path) {
|
|
|
+ return (path || "").split("/").pop() || "document.md";
|
|
|
+ }
|
|
|
+
|
|
|
+ function buildRawGitHubUrl(owner, repo, ref, filePath) {
|
|
|
+ const encodedPath = filePath
|
|
|
+ .split("/")
|
|
|
+ .map((part) => encodeURIComponent(part))
|
|
|
+ .join("/");
|
|
|
+ return `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(ref)}/${encodedPath}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ async function fetchGitHubJson(url) {
|
|
|
+ const now = Date.now();
|
|
|
+ const waitTime = GITHUB_IMPORT_MIN_REQUEST_INTERVAL_MS - (now - lastGitHubImportRequestAt);
|
|
|
+ if (waitTime > 0) {
|
|
|
+ await new Promise((resolve) => setTimeout(resolve, waitTime));
|
|
|
+ }
|
|
|
+ lastGitHubImportRequestAt = Date.now();
|
|
|
+ const response = await fetch(url, {
|
|
|
+ headers: {
|
|
|
+ Accept: "application/vnd.github+json"
|
|
|
+ }
|
|
|
+ });
|
|
|
+ if (!response.ok) {
|
|
|
+ throw new Error(`GitHub API request failed (${response.status})`);
|
|
|
+ }
|
|
|
+ return response.json();
|
|
|
+ }
|
|
|
+
|
|
|
+ async function fetchTextContent(url) {
|
|
|
+ const response = await fetch(url);
|
|
|
+ if (!response.ok) {
|
|
|
+ throw new Error(`Failed to fetch file (${response.status})`);
|
|
|
+ }
|
|
|
+ return response.text();
|
|
|
+ }
|
|
|
+
|
|
|
+ function parseGitHubImportUrl(input) {
|
|
|
+ let parsedUrl;
|
|
|
+ try {
|
|
|
+ parsedUrl = new URL((input || "").trim());
|
|
|
+ } catch (_) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ const host = parsedUrl.hostname.replace(/^www\./, "");
|
|
|
+ const segments = parsedUrl.pathname.split("/").filter(Boolean);
|
|
|
+
|
|
|
+ if (host === "raw.githubusercontent.com") {
|
|
|
+ if (segments.length < 4) return null;
|
|
|
+ const [owner, repo, ref, ...rest] = segments;
|
|
|
+ const filePath = rest.join("/");
|
|
|
+ return { owner, repo, ref, type: "file", filePath };
|
|
|
+ }
|
|
|
+
|
|
|
+ if (host !== "github.com" || segments.length < 2) return null;
|
|
|
+
|
|
|
+ const owner = segments[0];
|
|
|
+ const repo = segments[1].replace(/\.git$/i, "");
|
|
|
+ if (segments.length === 2) {
|
|
|
+ return { owner, repo, type: "repo" };
|
|
|
+ }
|
|
|
+
|
|
|
+ const mode = segments[2];
|
|
|
+ if (mode === "blob" && segments.length >= 5) {
|
|
|
+ return {
|
|
|
+ owner,
|
|
|
+ repo,
|
|
|
+ type: "file",
|
|
|
+ ref: segments[3],
|
|
|
+ filePath: segments.slice(4).join("/")
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ if (mode === "tree" && segments.length >= 4) {
|
|
|
+ return {
|
|
|
+ owner,
|
|
|
+ repo,
|
|
|
+ type: "tree",
|
|
|
+ ref: segments[3],
|
|
|
+ basePath: segments.slice(4).join("/")
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ return { owner, repo, type: "repo" };
|
|
|
+ }
|
|
|
+
|
|
|
+ async function getDefaultBranch(owner, repo) {
|
|
|
+ const repoInfo = await fetchGitHubJson(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`);
|
|
|
+ return repoInfo.default_branch;
|
|
|
+ }
|
|
|
+
|
|
|
+ async function listMarkdownFiles(owner, repo, ref, basePath) {
|
|
|
+ const treeResponse = await fetchGitHubJson(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(ref)}?recursive=1`);
|
|
|
+ const normalizedBasePath = (basePath || "").replace(/^\/+|\/+$/g, "");
|
|
|
+
|
|
|
+ return (treeResponse.tree || [])
|
|
|
+ .filter((entry) => entry.type === "blob" && isMarkdownPath(entry.path))
|
|
|
+ .filter((entry) => !normalizedBasePath || entry.path === normalizedBasePath || entry.path.startsWith(normalizedBasePath + "/"))
|
|
|
+ .map((entry) => entry.path)
|
|
|
+ .sort((a, b) => a.localeCompare(b));
|
|
|
+ }
|
|
|
+
|
|
|
+ function buildMarkdownFileTree(paths) {
|
|
|
+ const root = { folders: {}, files: [] };
|
|
|
+ (paths || []).forEach((path) => {
|
|
|
+ const segments = (path || "").split("/").filter(Boolean);
|
|
|
+ if (!segments.length) return;
|
|
|
+ const fileName = segments.pop();
|
|
|
+ let node = root;
|
|
|
+ segments.forEach((segment) => {
|
|
|
+ if (!node.folders[segment]) {
|
|
|
+ node.folders[segment] = { folders: {}, files: [] };
|
|
|
+ }
|
|
|
+ node = node.folders[segment];
|
|
|
+ });
|
|
|
+ node.files.push({ name: fileName, path });
|
|
|
+ });
|
|
|
+ return root;
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateGitHubImportSelectedCount() {
|
|
|
+ if (!githubImportSelectedCount) return;
|
|
|
+ const count = selectedGitHubImportPaths.size;
|
|
|
+ githubImportSelectedCount.textContent = `${count} selected`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateGitHubSelectAllButtonLabel() {
|
|
|
+ if (!githubImportSelectAllBtn) return;
|
|
|
+ const total = availableGitHubImportPaths.length;
|
|
|
+ const allSelected = total > 0 && selectedGitHubImportPaths.size === total;
|
|
|
+ githubImportSelectAllBtn.textContent = allSelected ? "Clear All" : "Select All";
|
|
|
+ }
|
|
|
+
|
|
|
+ function syncGitHubSelectionToButtons() {
|
|
|
+ if (!githubImportTree) return;
|
|
|
+ Array.from(githubImportTree.querySelectorAll(".github-tree-file-btn")).forEach((btn) => {
|
|
|
+ const isSelected = selectedGitHubImportPaths.has(btn.dataset.path);
|
|
|
+ btn.classList.toggle("is-selected", isSelected);
|
|
|
+ btn.setAttribute("aria-pressed", isSelected ? "true" : "false");
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function setGitHubSelectedPaths(paths) {
|
|
|
+ selectedGitHubImportPaths.clear();
|
|
|
+ (paths || []).forEach((path) => selectedGitHubImportPaths.add(path));
|
|
|
+ updateGitHubImportSelectedCount();
|
|
|
+ syncGitHubSelectionToButtons();
|
|
|
+ updateGitHubSelectAllButtonLabel();
|
|
|
+ }
|
|
|
+
|
|
|
+ function toggleGitHubSelectedPath(path) {
|
|
|
+ if (!path) return;
|
|
|
+ if (selectedGitHubImportPaths.has(path)) {
|
|
|
+ selectedGitHubImportPaths.delete(path);
|
|
|
+ } else {
|
|
|
+ selectedGitHubImportPaths.add(path);
|
|
|
+ }
|
|
|
+ updateGitHubImportSelectedCount();
|
|
|
+ syncGitHubSelectionToButtons();
|
|
|
+ updateGitHubSelectAllButtonLabel();
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderGitHubImportTree(paths) {
|
|
|
+ if (!githubImportTree || !githubImportFileSelect) return;
|
|
|
+ githubImportTree.innerHTML = "";
|
|
|
+ const tree = buildMarkdownFileTree(paths);
|
|
|
+
|
|
|
+ const createTreeBranch = function(node, parentPath) {
|
|
|
+ const list = document.createElement("ul");
|
|
|
+ const folderNames = Object.keys(node.folders).sort((a, b) => a.localeCompare(b));
|
|
|
+ folderNames.forEach((folderName) => {
|
|
|
+ const folderPath = parentPath ? `${parentPath}/${folderName}` : folderName;
|
|
|
+ const item = document.createElement("li");
|
|
|
+ const folderLabel = document.createElement("span");
|
|
|
+ folderLabel.className = "github-tree-folder-label";
|
|
|
+ folderLabel.textContent = `📁 ${folderName}`;
|
|
|
+ item.appendChild(folderLabel);
|
|
|
+ item.appendChild(createTreeBranch(node.folders[folderName], folderPath));
|
|
|
+ list.appendChild(item);
|
|
|
+ });
|
|
|
+
|
|
|
+ node.files
|
|
|
+ .sort((a, b) => a.path.localeCompare(b.path))
|
|
|
+ .forEach((file) => {
|
|
|
+ const fileItem = document.createElement("li");
|
|
|
+ const fileButton = document.createElement("button");
|
|
|
+ fileButton.type = "button";
|
|
|
+ fileButton.className = "github-tree-file-btn";
|
|
|
+ fileButton.dataset.path = file.path;
|
|
|
+ fileButton.setAttribute("aria-pressed", "false");
|
|
|
+ fileButton.textContent = `📄 ${file.name}`;
|
|
|
+ fileButton.addEventListener("click", function() {
|
|
|
+ toggleGitHubSelectedPath(file.path);
|
|
|
+ });
|
|
|
+ fileItem.appendChild(fileButton);
|
|
|
+ list.appendChild(fileItem);
|
|
|
+ });
|
|
|
+
|
|
|
+ return list;
|
|
|
+ };
|
|
|
+
|
|
|
+ githubImportTree.appendChild(createTreeBranch(tree, ""));
|
|
|
+ syncGitHubSelectionToButtons();
|
|
|
+ }
|
|
|
+
|
|
|
+ function setGitHubImportLoading(isLoading) {
|
|
|
+ if (!githubImportSubmitBtn) return;
|
|
|
+ if (isLoading) {
|
|
|
+ githubImportSubmitBtn.dataset.loadingText = githubImportSubmitBtn.textContent;
|
|
|
+ githubImportSubmitBtn.textContent = "Importing...";
|
|
|
+ } else if (githubImportSubmitBtn.dataset.loadingText) {
|
|
|
+ githubImportSubmitBtn.textContent = githubImportSubmitBtn.dataset.loadingText;
|
|
|
+ delete githubImportSubmitBtn.dataset.loadingText;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function setGitHubImportMessage(message, options = {}) {
|
|
|
+ if (!githubImportError) return;
|
|
|
+ const { isError = true } = options;
|
|
|
+ githubImportError.classList.toggle("is-info", !isError);
|
|
|
+ if (!message) {
|
|
|
+ githubImportError.textContent = "";
|
|
|
+ githubImportError.style.display = "none";
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ githubImportError.textContent = message;
|
|
|
+ githubImportError.style.display = "block";
|
|
|
+ }
|
|
|
+
|
|
|
+ function resetGitHubImportModal() {
|
|
|
+ if (!githubImportUrlInput || !githubImportFileSelect || !githubImportSubmitBtn) return;
|
|
|
+ if (githubImportTitle) {
|
|
|
+ githubImportTitle.textContent = "Import Markdown from GitHub";
|
|
|
+ }
|
|
|
+ githubImportUrlInput.value = "";
|
|
|
+ githubImportUrlInput.style.display = "block";
|
|
|
+ githubImportUrlInput.disabled = false;
|
|
|
+ githubImportFileSelect.innerHTML = "";
|
|
|
+ githubImportFileSelect.style.display = "none";
|
|
|
+ githubImportFileSelect.disabled = false;
|
|
|
+ if (githubImportSelectionToolbar) {
|
|
|
+ githubImportSelectionToolbar.style.display = "none";
|
|
|
+ }
|
|
|
+ availableGitHubImportPaths = [];
|
|
|
+ setGitHubSelectedPaths([]);
|
|
|
+ if (githubImportTree) {
|
|
|
+ githubImportTree.innerHTML = "";
|
|
|
+ githubImportTree.style.display = "none";
|
|
|
+ }
|
|
|
+ githubImportSubmitBtn.dataset.step = "url";
|
|
|
+ delete githubImportSubmitBtn.dataset.owner;
|
|
|
+ delete githubImportSubmitBtn.dataset.repo;
|
|
|
+ delete githubImportSubmitBtn.dataset.ref;
|
|
|
+ githubImportSubmitBtn.textContent = "Import";
|
|
|
+ setGitHubImportMessage("");
|
|
|
+ }
|
|
|
+
|
|
|
+ function openGitHubImportModal() {
|
|
|
+ if (!githubImportModal || !githubImportUrlInput || !githubImportSubmitBtn) return;
|
|
|
+ resetGitHubImportModal();
|
|
|
+ openAppModal(githubImportModal, {
|
|
|
+ focusTarget: githubImportUrlInput,
|
|
|
+ onClose: closeGitHubImportModal
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeGitHubImportModal() {
|
|
|
+ if (!githubImportModal) return;
|
|
|
+ closeAppModal(githubImportModal);
|
|
|
+ resetGitHubImportModal();
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleGitHubImportSubmit() {
|
|
|
+ if (!githubImportSubmitBtn || !githubImportUrlInput || !githubImportFileSelect) return;
|
|
|
+ const setGitHubImportDialogDisabled = (disabled) => {
|
|
|
+ githubImportSubmitBtn.disabled = disabled;
|
|
|
+ if (githubImportCancelBtn) {
|
|
|
+ githubImportCancelBtn.disabled = disabled;
|
|
|
+ }
|
|
|
+ if (githubImportSelectAllBtn) {
|
|
|
+ githubImportSelectAllBtn.disabled = disabled;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ const step = githubImportSubmitBtn.dataset.step || "url";
|
|
|
+ if (step === "select") {
|
|
|
+ const selectedPaths = Array.from(selectedGitHubImportPaths);
|
|
|
+ const owner = githubImportSubmitBtn.dataset.owner;
|
|
|
+ const repo = githubImportSubmitBtn.dataset.repo;
|
|
|
+ const ref = githubImportSubmitBtn.dataset.ref;
|
|
|
+ if (!owner || !repo || !ref || !selectedPaths.length) {
|
|
|
+ setGitHubImportMessage("Please select at least one file to import.");
|
|
|
+ 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, ""));
|
|
|
+ }
|
|
|
+ closeGitHubImportModal();
|
|
|
+ announceToScreenReader("Files imported successfully.");
|
|
|
+ } catch (error) {
|
|
|
+ console.error("GitHub import failed:", error);
|
|
|
+ setGitHubImportMessage("GitHub import failed: " + error.message);
|
|
|
+ announceToScreenReader("GitHub import failed.");
|
|
|
+ } finally {
|
|
|
+ setGitHubImportDialogDisabled(false);
|
|
|
+ setGitHubImportLoading(false);
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const urlInput = githubImportUrlInput.value.trim();
|
|
|
+ if (!urlInput) {
|
|
|
+ setGitHubImportMessage("Please enter a GitHub URL.");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const parsed = parseGitHubImportUrl(urlInput);
|
|
|
+ if (!parsed || !parsed.owner || !parsed.repo) {
|
|
|
+ setGitHubImportMessage("Please enter a valid GitHub URL.");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ setGitHubImportMessage("");
|
|
|
+ setGitHubImportLoading(true);
|
|
|
+ setGitHubImportDialogDisabled(true);
|
|
|
+ try {
|
|
|
+ if (parsed.type === "file") {
|
|
|
+ if (!isMarkdownPath(parsed.filePath)) {
|
|
|
+ throw new Error("The provided URL does not point to a Markdown file.");
|
|
|
+ }
|
|
|
+ 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, ""));
|
|
|
+ closeGitHubImportModal();
|
|
|
+ announceToScreenReader("File imported successfully.");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Accessibility dynamic live announcer
|
|
|
+ const fetchingText = I18N_DICTS[activeLang].loadingFiles || "Fetching file tree...";
|
|
|
+ announceToScreenReader(fetchingText);
|
|
|
+
|
|
|
+ // Render hierarchical visual skeleton tree while the list is loading
|
|
|
+ if (githubImportTree) {
|
|
|
+ renderGitHubImportTreeSkeleton();
|
|
|
+ githubImportTree.style.display = "block";
|
|
|
+ }
|
|
|
+
|
|
|
+ const ref = parsed.ref || await getDefaultBranch(parsed.owner, parsed.repo);
|
|
|
+ const files = await listMarkdownFiles(parsed.owner, parsed.repo, ref, parsed.basePath || "");
|
|
|
+
|
|
|
+ if (!files.length) {
|
|
|
+ if (githubImportTree) {
|
|
|
+ githubImportTree.innerHTML = "";
|
|
|
+ githubImportTree.style.display = "none";
|
|
|
+ }
|
|
|
+ setGitHubImportMessage("No Markdown files were found at that GitHub location.");
|
|
|
+ announceToScreenReader("Failed to locate Markdown files.");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const shownFiles = files.slice(0, MAX_GITHUB_FILES_SHOWN);
|
|
|
+ if (files.length === 1) {
|
|
|
+ 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, ""));
|
|
|
+ closeGitHubImportModal();
|
|
|
+ announceToScreenReader("File imported successfully.");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ githubImportFileSelect.innerHTML = "";
|
|
|
+ githubImportUrlInput.style.display = "none";
|
|
|
+ githubImportFileSelect.style.display = "none";
|
|
|
+ if (githubImportSelectionToolbar) {
|
|
|
+ githubImportSelectionToolbar.style.display = "flex";
|
|
|
+ }
|
|
|
+ if (githubImportTree) {
|
|
|
+ githubImportTree.style.display = "block";
|
|
|
+ }
|
|
|
+ shownFiles.forEach((filePath) => {
|
|
|
+ const option = document.createElement("option");
|
|
|
+ option.value = filePath;
|
|
|
+ option.textContent = filePath;
|
|
|
+ githubImportFileSelect.appendChild(option);
|
|
|
+ });
|
|
|
+ availableGitHubImportPaths = shownFiles.slice();
|
|
|
+ setGitHubSelectedPaths(shownFiles[0] ? [shownFiles[0]] : []);
|
|
|
+ renderGitHubImportTree(shownFiles);
|
|
|
+
|
|
|
+ // Announce load complete
|
|
|
+ announceToScreenReader("GitHub files loaded. " + files.length + " files available in the tree.");
|
|
|
+
|
|
|
+ if (files.length > MAX_GITHUB_FILES_SHOWN) {
|
|
|
+ setGitHubImportMessage(`Showing first ${MAX_GITHUB_FILES_SHOWN} of ${files.length} Markdown files.`, { isError: false });
|
|
|
+ } else {
|
|
|
+ setGitHubImportMessage("");
|
|
|
+ }
|
|
|
+ if (githubImportTitle) {
|
|
|
+ githubImportTitle.textContent = "Select Markdown file(s) to import";
|
|
|
+ }
|
|
|
+ githubImportSubmitBtn.dataset.step = "select";
|
|
|
+ githubImportSubmitBtn.dataset.owner = parsed.owner;
|
|
|
+ githubImportSubmitBtn.dataset.repo = parsed.repo;
|
|
|
+ githubImportSubmitBtn.dataset.ref = ref;
|
|
|
+ githubImportSubmitBtn.textContent = "Import Selected";
|
|
|
+ } catch (error) {
|
|
|
+ console.error("GitHub import failed:", error);
|
|
|
+ setGitHubImportMessage("GitHub import failed: " + error.message);
|
|
|
+ announceToScreenReader("GitHub import failed.");
|
|
|
+ if (githubImportTree) {
|
|
|
+ githubImportTree.innerHTML = "";
|
|
|
+ githubImportTree.style.display = "none";
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ setGitHubImportDialogDisabled(false);
|
|
|
+ setGitHubImportLoading(false);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function scheduleEmojiLookupRefresh() {
|
|
|
+ if (emojiLookupLoaded || emojiRenderScheduled) return;
|
|
|
+ emojiRenderScheduled = true;
|
|
|
+ loadEmojiEntries()
|
|
|
+ .then(() => {
|
|
|
+ if (emojiUrlMap.size) {
|
|
|
+ renderMarkdown({ force: true, reason: 'emoji-refresh' });
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .finally(() => {
|
|
|
+ emojiRenderScheduled = false;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function processEmojis(element) {
|
|
|
+ // Early exit if the raw text content has no colon characters (PERF-013)
|
|
|
+ // This avoids the expensive TreeWalker DOM walk for documents without emoji shortcodes
|
|
|
+ if (!element.textContent || !element.textContent.includes(':')) return;
|
|
|
+
|
|
|
+ // PERF-002: Lazy-load JoyPixels on first use
|
|
|
+ if (typeof joypixels === 'undefined') {
|
|
|
+ Promise.all([
|
|
|
+ loadScript(CDN.joypixels),
|
|
|
+ loadStyle(CDN.joypixels_css)
|
|
|
+ ]).then(function() { processEmojis(element); });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const walker = document.createTreeWalker(
|
|
|
+ element,
|
|
|
+ NodeFilter.SHOW_TEXT,
|
|
|
+ null,
|
|
|
+ false
|
|
|
+ );
|
|
|
+
|
|
|
+ const textNodes = [];
|
|
|
+ let node;
|
|
|
+ while ((node = walker.nextNode())) {
|
|
|
+ let parent = node.parentNode;
|
|
|
+ let isInCode = false;
|
|
|
+ while (parent && parent !== element) {
|
|
|
+ if (parent.tagName === 'PRE' || parent.tagName === 'CODE') {
|
|
|
+ isInCode = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ parent = parent.parentNode;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!isInCode && node.nodeValue.includes(':')) {
|
|
|
+ textNodes.push(node);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ let needsEmojiLookup = false;
|
|
|
+ textNodes.forEach(textNode => {
|
|
|
+ const text = textNode.nodeValue;
|
|
|
+ const emojiRegex = /:([\w+-]+):/g;
|
|
|
+
|
|
|
+ let match;
|
|
|
+ let lastIndex = 0;
|
|
|
+ let hasEmoji = false;
|
|
|
+ const fragment = document.createDocumentFragment();
|
|
|
+
|
|
|
+ while ((match = emojiRegex.exec(text)) !== null) {
|
|
|
+ const shortcode = match[1];
|
|
|
+ const emoji = joypixels.shortnameToUnicode(`:${shortcode}:`);
|
|
|
+
|
|
|
+ if (emoji !== `:${shortcode}:`) { // If conversion was successful
|
|
|
+ hasEmoji = true;
|
|
|
+ if (match.index > lastIndex) {
|
|
|
+ fragment.appendChild(document.createTextNode(text.substring(lastIndex, match.index)));
|
|
|
+ }
|
|
|
+ fragment.appendChild(document.createTextNode(emoji));
|
|
|
+ lastIndex = emojiRegex.lastIndex;
|
|
|
+ } else {
|
|
|
+ const emojiUrl = emojiUrlMap.get(shortcode);
|
|
|
+ if (emojiUrl) {
|
|
|
+ hasEmoji = true;
|
|
|
+ if (match.index > lastIndex) {
|
|
|
+ fragment.appendChild(document.createTextNode(text.substring(lastIndex, match.index)));
|
|
|
+ }
|
|
|
+ const image = document.createElement('img');
|
|
|
+ image.className = 'emoji-inline';
|
|
|
+ image.src = emojiUrl;
|
|
|
+ image.alt = `:${shortcode}:`;
|
|
|
+ image.loading = 'lazy';
|
|
|
+ image.setAttribute('aria-label', `:${shortcode}:`);
|
|
|
+ fragment.appendChild(image);
|
|
|
+ lastIndex = emojiRegex.lastIndex;
|
|
|
+ } else if (!emojiLookupLoaded) {
|
|
|
+ needsEmojiLookup = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (hasEmoji) {
|
|
|
+ if (lastIndex < text.length) {
|
|
|
+ fragment.appendChild(document.createTextNode(text.substring(lastIndex)));
|
|
|
+ }
|
|
|
+ textNode.parentNode.replaceChild(fragment, textNode);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ if (needsEmojiLookup) {
|
|
|
+ scheduleEmojiLookupRefresh();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function debouncedRender() {
|
|
|
+ clearTimeout(markdownRenderTimeout);
|
|
|
+ const delay = getPreviewRenderDelay(markdownEditor.value);
|
|
|
+ markdownRenderTimeout = setTimeout(function() {
|
|
|
+ renderMarkdown({ reason: 'edit' });
|
|
|
+ }, delay);
|
|
|
+ }
|
|
|
+
|
|
|
+ function countWordsFast(text) {
|
|
|
+ let count = 0;
|
|
|
+ let inWord = false;
|
|
|
+ for (let i = 0; i < text.length; i += 1) {
|
|
|
+ const code = text.charCodeAt(i);
|
|
|
+ if (
|
|
|
+ code === 32 ||
|
|
|
+ code === 9 ||
|
|
|
+ code === 10 ||
|
|
|
+ code === 13 ||
|
|
|
+ code === 12 ||
|
|
|
+ code === 11 ||
|
|
|
+ code === 160
|
|
|
+ ) {
|
|
|
+ inWord = false;
|
|
|
+ } else if (!inWord) {
|
|
|
+ count += 1;
|
|
|
+ inWord = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return count;
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateDocumentStats() {
|
|
|
+ const text = markdownEditor.value;
|
|
|
+
|
|
|
+ const charCount = text.length;
|
|
|
+ charCountElement.textContent = charCount.toLocaleString();
|
|
|
+
|
|
|
+ const wordCount = countWordsFast(text);
|
|
|
+ wordCountElement.textContent = wordCount.toLocaleString();
|
|
|
+
|
|
|
+ const readingTimeMinutes = Math.ceil(wordCount / 200);
|
|
|
+ readingTimeElement.textContent = readingTimeMinutes;
|
|
|
+ }
|
|
|
+
|
|
|
+ function syncEditorToPreview() {
|
|
|
+ if (!syncScrollingEnabled || isPreviewScrolling || isProgrammaticScrolling) return;
|
|
|
+ isEditorScrolling = true;
|
|
|
+
|
|
|
+ if (scrollSyncTimeout) cancelAnimationFrame(scrollSyncTimeout);
|
|
|
+ scrollSyncTimeout = requestAnimationFrame(function() {
|
|
|
+ const editorScrollRange = markdownEditor.scrollHeight - markdownEditor.clientHeight;
|
|
|
+ const editorScrollRatio =
|
|
|
+ editorScrollRange > 0 ? markdownEditor.scrollTop / editorScrollRange : 0;
|
|
|
+ const previewScrollPosition =
|
|
|
+ (previewPane.scrollHeight - previewPane.clientHeight) *
|
|
|
+ editorScrollRatio;
|
|
|
+
|
|
|
+ if (!isNaN(previewScrollPosition) && isFinite(previewScrollPosition)) {
|
|
|
+ previewPane.scrollTop = previewScrollPosition;
|
|
|
+ }
|
|
|
+
|
|
|
+ setTimeout(function() {
|
|
|
+ isEditorScrolling = false;
|
|
|
+ }, 50);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function syncPreviewToEditor() {
|
|
|
+ if (!syncScrollingEnabled || isEditorScrolling || isProgrammaticScrolling) return;
|
|
|
+ isPreviewScrolling = true;
|
|
|
+
|
|
|
+ if (scrollSyncTimeout) cancelAnimationFrame(scrollSyncTimeout);
|
|
|
+ scrollSyncTimeout = requestAnimationFrame(function() {
|
|
|
+ const previewScrollRange = previewPane.scrollHeight - previewPane.clientHeight;
|
|
|
+ const previewScrollRatio =
|
|
|
+ previewScrollRange > 0 ? previewPane.scrollTop / previewScrollRange : 0;
|
|
|
+ const editorScrollPosition =
|
|
|
+ (markdownEditor.scrollHeight - markdownEditor.clientHeight) *
|
|
|
+ previewScrollRatio;
|
|
|
+
|
|
|
+ if (!isNaN(editorScrollPosition) && isFinite(editorScrollPosition)) {
|
|
|
+ markdownEditor.scrollTop = editorScrollPosition;
|
|
|
+ syncEditorScrollOverlays();
|
|
|
+ }
|
|
|
+
|
|
|
+ setTimeout(function() {
|
|
|
+ isPreviewScrolling = false;
|
|
|
+ }, 50);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function toggleSyncScrolling() {
|
|
|
+ syncScrollingEnabled = !syncScrollingEnabled;
|
|
|
+ if (syncScrollingEnabled) {
|
|
|
+ toggleSyncButton.innerHTML = '<i class="bi bi-link-45deg"></i> <span class="btn-text">Sync Off</span>';
|
|
|
+ toggleSyncButton.classList.add("sync-disabled");
|
|
|
+ toggleSyncButton.classList.remove("sync-enabled");
|
|
|
+ toggleSyncButton.classList.add("sync-active");
|
|
|
+ } else {
|
|
|
+ toggleSyncButton.innerHTML = '<i class="bi bi-link"></i> <span class="btn-text">Sync On</span>';
|
|
|
+ toggleSyncButton.classList.add("sync-enabled");
|
|
|
+ toggleSyncButton.classList.remove("sync-disabled");
|
|
|
+ toggleSyncButton.classList.remove("sync-active");
|
|
|
+ }
|
|
|
+ saveGlobalState({ syncScrollingEnabled });
|
|
|
+ }
|
|
|
+
|
|
|
+ // View Mode Functions - Story 1.1 & 1.2
|
|
|
+ function setViewMode(mode) {
|
|
|
+ if (mode === currentViewMode) return;
|
|
|
+
|
|
|
+ const previousMode = currentViewMode;
|
|
|
+ currentViewMode = mode;
|
|
|
+
|
|
|
+ // Update content container class
|
|
|
+ contentContainer.classList.remove('view-editor-only', 'view-preview-only', 'view-split');
|
|
|
+ contentContainer.classList.add('view-' + (mode === 'editor' ? 'editor-only' : mode === 'preview' ? 'preview-only' : 'split'));
|
|
|
+
|
|
|
+ // Update button active states (desktop)
|
|
|
+ viewModeButtons.forEach(btn => {
|
|
|
+ const btnMode = btn.getAttribute('data-view-mode');
|
|
|
+ if (btnMode === mode) {
|
|
|
+ btn.classList.add('is-active');
|
|
|
+ btn.setAttribute('aria-pressed', 'true');
|
|
|
+ } else {
|
|
|
+ btn.classList.remove('is-active');
|
|
|
+ btn.setAttribute('aria-pressed', 'false');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // Story 1.4: Update mobile button active states
|
|
|
+ mobileViewModeButtons.forEach(btn => {
|
|
|
+ const btnMode = btn.getAttribute('data-mode');
|
|
|
+ if (btnMode === mode) {
|
|
|
+ btn.classList.add('active');
|
|
|
+ btn.setAttribute('aria-pressed', 'true');
|
|
|
+ } else {
|
|
|
+ btn.classList.remove('active');
|
|
|
+ btn.setAttribute('aria-pressed', 'false');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // Story 1.2: Show/hide sync toggle based on view mode
|
|
|
+ updateSyncToggleVisibility(mode);
|
|
|
+
|
|
|
+ // Story 1.3: Handle pane widths when switching modes
|
|
|
+ if (mode === 'split') {
|
|
|
+ // Restore preserved pane widths when entering split mode
|
|
|
+ applyPaneWidths();
|
|
|
+ } else {
|
|
|
+ // Reset inline pane widths when not in split mode
|
|
|
+ resetPaneWidths();
|
|
|
+ }
|
|
|
+
|
|
|
+ // Re-render markdown when switching to a view that includes preview
|
|
|
+ if (mode === 'split' || mode === 'preview') {
|
|
|
+ renderMarkdown({ reason: 'view-switch' });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (mode === 'split' || mode === 'editor') {
|
|
|
+ refreshEditorWidth();
|
|
|
+ scheduleLineNumberUpdate({ force: true });
|
|
|
+ updateFindHighlights();
|
|
|
+ scheduleEditorOverlayScrollSync();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function resolveViewToggleMode(mode) {
|
|
|
+ if ((mode === 'editor' || mode === 'preview') && currentViewMode === mode) {
|
|
|
+ return 'split';
|
|
|
+ }
|
|
|
+ return mode;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Story 1.2: Update sync toggle visibility
|
|
|
+ function updateSyncToggleVisibility(mode) {
|
|
|
+ const isSplitView = mode === 'split';
|
|
|
+
|
|
|
+ // Desktop sync toggle
|
|
|
+ if (toggleSyncButton) {
|
|
|
+ toggleSyncButton.style.display = '';
|
|
|
+ toggleSyncButton.disabled = !isSplitView;
|
|
|
+ toggleSyncButton.setAttribute('aria-disabled', String(!isSplitView));
|
|
|
+ toggleSyncButton.removeAttribute('aria-hidden');
|
|
|
+ }
|
|
|
+
|
|
|
+ // Mobile sync toggle
|
|
|
+ if (mobileToggleSync) {
|
|
|
+ mobileToggleSync.style.display = '';
|
|
|
+ mobileToggleSync.disabled = !isSplitView;
|
|
|
+ mobileToggleSync.setAttribute('aria-disabled', String(!isSplitView));
|
|
|
+ mobileToggleSync.removeAttribute('aria-hidden');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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) {
|
|
|
+ const start = markdownEditor.selectionStart;
|
|
|
+ const end = markdownEditor.selectionEnd;
|
|
|
+ const selected = markdownEditor.value.slice(start, end) || placeholder;
|
|
|
+ const replacement = prefix + selected + suffix;
|
|
|
+ const selectionStart = start + prefix.length;
|
|
|
+ const selectionEnd = selectionStart + selected.length;
|
|
|
+ replaceEditorRange(start, end, replacement, selectionStart, selectionEnd);
|
|
|
+ }
|
|
|
+
|
|
|
+ function getCurrentLineRange() {
|
|
|
+ const value = markdownEditor.value;
|
|
|
+ const start = markdownEditor.selectionStart;
|
|
|
+ const lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1;
|
|
|
+ let lineEnd = value.indexOf('\n', start);
|
|
|
+ if (lineEnd === -1) lineEnd = value.length;
|
|
|
+ return { start: lineStart, end: lineEnd, text: value.slice(lineStart, lineEnd) };
|
|
|
+ }
|
|
|
+
|
|
|
+ function getSelectedLineRange() {
|
|
|
+ const value = markdownEditor.value;
|
|
|
+ const start = markdownEditor.selectionStart;
|
|
|
+ const end = markdownEditor.selectionEnd;
|
|
|
+ const lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1;
|
|
|
+ let lineEnd = value.indexOf('\n', end);
|
|
|
+ if (lineEnd === -1) lineEnd = value.length;
|
|
|
+ return { start: lineStart, end: lineEnd, text: value.slice(lineStart, lineEnd) };
|
|
|
+ }
|
|
|
+
|
|
|
+ function transformEditorLines(transformer) {
|
|
|
+ const range = getSelectedLineRange();
|
|
|
+ const replacement = range.text.split('\n').map(transformer).join('\n');
|
|
|
+ replaceEditorRange(range.start, range.end, replacement, range.start, range.start + replacement.length);
|
|
|
+ }
|
|
|
+
|
|
|
+ function getListLineRange() {
|
|
|
+ const value = markdownEditor.value;
|
|
|
+ const start = markdownEditor.selectionStart;
|
|
|
+ const end = markdownEditor.selectionEnd;
|
|
|
+ const effectiveEnd = end > start && value[end - 1] === '\n' ? end - 1 : end;
|
|
|
+ const lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1;
|
|
|
+ let lineEnd = value.indexOf('\n', effectiveEnd);
|
|
|
+ if (lineEnd === -1) lineEnd = value.length;
|
|
|
+ return { start: lineStart, end: lineEnd, text: value.slice(lineStart, lineEnd) };
|
|
|
+ }
|
|
|
+
|
|
|
+ function parseMarkdownListItem(line) {
|
|
|
+ const match = line.match(/^(\s*)((\d+)\.|[-*+])(?:\s+|$)(.*)$/);
|
|
|
+ if (!match) return null;
|
|
|
+ const isOrdered = typeof match[3] !== 'undefined';
|
|
|
+ return {
|
|
|
+ type: isOrdered ? 'ordered' : 'unordered',
|
|
|
+ indent: match[1],
|
|
|
+ marker: match[2],
|
|
|
+ number: isOrdered ? parseInt(match[3], 10) : null,
|
|
|
+ bullet: isOrdered ? null : match[2],
|
|
|
+ body: match[4] || '',
|
|
|
+ prefix: match[1] + match[2] + ' '
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ function stripListMarkerForApply(line) {
|
|
|
+ const parsed = parseMarkdownListItem(line);
|
|
|
+ if (parsed) {
|
|
|
+ return { indent: parsed.indent, body: parsed.body };
|
|
|
+ }
|
|
|
+ const match = line.match(/^(\s*)(.*)$/);
|
|
|
+ return { indent: match ? match[1] : '', body: match ? match[2] : line };
|
|
|
+ }
|
|
|
+
|
|
|
+ function getPreviousLineInfo(lineStart) {
|
|
|
+ if (lineStart <= 0) return null;
|
|
|
+ const value = markdownEditor.value;
|
|
|
+ const previousEnd = lineStart - 1;
|
|
|
+ const previousStart = previousEnd > 0 ? value.lastIndexOf('\n', previousEnd - 1) + 1 : 0;
|
|
|
+ return { start: previousStart, text: value.slice(previousStart, previousEnd) };
|
|
|
+ }
|
|
|
+
|
|
|
+ function getOrderedListStartNumber(lineStart) {
|
|
|
+ const previousLine = getPreviousLineInfo(lineStart);
|
|
|
+ if (!previousLine || !previousLine.text.trim()) return 1;
|
|
|
+ const parsed = parseMarkdownListItem(previousLine.text);
|
|
|
+ return parsed && parsed.type === 'ordered' ? parsed.number + 1 : 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ function applyMarkdownList(type) {
|
|
|
+ const range = getListLineRange();
|
|
|
+ const hadSelection = markdownEditor.selectionStart !== markdownEditor.selectionEnd;
|
|
|
+ const lines = range.text.split('\n');
|
|
|
+ let nextNumber = type === 'ordered' ? getOrderedListStartNumber(range.start) : 1;
|
|
|
+ let firstPrefixLength = null;
|
|
|
+
|
|
|
+ const replacement = lines.map(function(line) {
|
|
|
+ const stripped = stripListMarkerForApply(line);
|
|
|
+ const prefix = type === 'ordered'
|
|
|
+ ? stripped.indent + (nextNumber++) + '. '
|
|
|
+ : stripped.indent + '- ';
|
|
|
+ if (firstPrefixLength === null) firstPrefixLength = prefix.length;
|
|
|
+ return prefix + stripped.body;
|
|
|
+ }).join('\n');
|
|
|
+
|
|
|
+ const isSingleLine = lines.length === 1;
|
|
|
+ const caret = (!hadSelection || isSingleLine)
|
|
|
+ ? range.start + (firstPrefixLength || 0)
|
|
|
+ : range.start + replacement.length;
|
|
|
+
|
|
|
+ replaceEditorRange(range.start, range.end, replacement, caret, caret);
|
|
|
+ }
|
|
|
+
|
|
|
+ function renumberOrderedListAfterPosition(position, nextNumber) {
|
|
|
+ let value = markdownEditor.value;
|
|
|
+ let lineStart = value.indexOf('\n', position);
|
|
|
+ if (lineStart === -1) return;
|
|
|
+ lineStart += 1;
|
|
|
+
|
|
|
+ let changed = false;
|
|
|
+ while (lineStart < value.length) {
|
|
|
+ let lineEnd = value.indexOf('\n', lineStart);
|
|
|
+ const hasNewline = lineEnd !== -1;
|
|
|
+ if (!hasNewline) lineEnd = value.length;
|
|
|
+
|
|
|
+ const line = value.slice(lineStart, lineEnd);
|
|
|
+ if (!line.trim()) break;
|
|
|
+
|
|
|
+ const parsed = parseMarkdownListItem(line);
|
|
|
+ if (!parsed || parsed.type !== 'ordered') break;
|
|
|
+
|
|
|
+ const replacement = parsed.indent + nextNumber + '. ' + parsed.body;
|
|
|
+ if (replacement !== line) {
|
|
|
+ value = value.slice(0, lineStart) + replacement + value.slice(lineEnd);
|
|
|
+ changed = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ lineStart += replacement.length + (hasNewline ? 1 : 0);
|
|
|
+ nextNumber += 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (changed) {
|
|
|
+ const selectionStart = markdownEditor.selectionStart;
|
|
|
+ const selectionEnd = markdownEditor.selectionEnd;
|
|
|
+ markdownEditor.value = value;
|
|
|
+ markdownEditor.setSelectionRange(selectionStart, selectionEnd);
|
|
|
+ markdownEditor.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function handleListEnter(e) {
|
|
|
+ if (e.key !== 'Enter' || e.shiftKey || markdownEditor.selectionStart !== markdownEditor.selectionEnd) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ const range = getCurrentLineRange();
|
|
|
+ const parsed = parseMarkdownListItem(range.text);
|
|
|
+ if (!parsed) return false;
|
|
|
+
|
|
|
+ e.preventDefault();
|
|
|
+ if (!parsed.body.trim()) {
|
|
|
+ const caret = range.start + parsed.indent.length;
|
|
|
+ replaceEditorRange(range.start, range.end, parsed.indent, caret, caret);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ const nextPrefix = parsed.type === 'ordered'
|
|
|
+ ? parsed.indent + (parsed.number + 1) + '. '
|
|
|
+ : parsed.indent + parsed.bullet + ' ';
|
|
|
+ const insertAt = markdownEditor.selectionStart;
|
|
|
+ const caret = insertAt + 1 + nextPrefix.length;
|
|
|
+ replaceEditorRange(insertAt, insertAt, '\n' + nextPrefix, caret, caret);
|
|
|
+
|
|
|
+ if (parsed.type === 'ordered') {
|
|
|
+ renumberOrderedListAfterPosition(caret, parsed.number + 2);
|
|
|
+ }
|
|
|
+
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ function transformSelectionOrCurrentLine(transformer) {
|
|
|
+ let start = markdownEditor.selectionStart;
|
|
|
+ let end = markdownEditor.selectionEnd;
|
|
|
+ if (start === end) {
|
|
|
+ const range = getCurrentLineRange();
|
|
|
+ start = range.start;
|
|
|
+ end = range.end;
|
|
|
+ }
|
|
|
+ const replacement = transformer(markdownEditor.value.slice(start, end));
|
|
|
+ replaceEditorRange(start, end, replacement, start, start + replacement.length);
|
|
|
+ }
|
|
|
+
|
|
|
+ function stripBasicMarkdown(text) {
|
|
|
+ return text
|
|
|
+ .replace(/^#{1,6}\s+/gm, '')
|
|
|
+ .replace(/^>\s?/gm, '')
|
|
|
+ .replace(/^(\s*)([-*+]|\d+\.)\s+/gm, '$1')
|
|
|
+ .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
|
|
|
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
|
+ .replace(/(\*\*|__)(.*?)\1/g, '$2')
|
|
|
+ .replace(/(\*|_)(.*?)\1/g, '$2')
|
|
|
+ .replace(/~~(.*?)~~/g, '$1')
|
|
|
+ .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 && typeof e.inputType === 'string' ? 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();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function toSlug(text) {
|
|
|
+ const slug = text.toLowerCase().trim()
|
|
|
+ .replace(/[^a-z0-9\s-]/g, '')
|
|
|
+ .replace(/\s+/g, '-')
|
|
|
+ .replace(/-+/g, '-');
|
|
|
+ return slug || 'section';
|
|
|
+ }
|
|
|
+
|
|
|
+ function getUsedReferenceNumbers(text) {
|
|
|
+ const used = new Set();
|
|
|
+ const regex = /^\[(\d+)\]:/gm;
|
|
|
+ let match = regex.exec(text);
|
|
|
+ while (match) {
|
|
|
+ const num = parseInt(match[1], 10);
|
|
|
+ if (!Number.isNaN(num)) used.add(num);
|
|
|
+ match = regex.exec(text);
|
|
|
+ }
|
|
|
+ return used;
|
|
|
+ }
|
|
|
+
|
|
|
+ function extractReferenceDefinitions(markdown) {
|
|
|
+ const definitions = new Map();
|
|
|
+ // Matches reference definitions: [1]: <url> "title", [1]: url 'title', or [1]: url (title)
|
|
|
+ const definitionRegex = /^\[(\d+)\]:\s*(?:<([^>\s]+)>|(\S+))(?:\s+(?:"([^"]*)"|'([^']*)'|\(([^)]+)\)))?\s*$/gm;
|
|
|
+ const cleanedMarkdown = markdown.replace(
|
|
|
+ definitionRegex,
|
|
|
+ function(match, numberText, angleUrl, plainUrl, titleDouble, titleSingle, titleParen) {
|
|
|
+ const number = parseInt(numberText, 10);
|
|
|
+ if (Number.isNaN(number)) return match;
|
|
|
+ const url = (angleUrl || plainUrl || '').trim();
|
|
|
+ if (!url) return match;
|
|
|
+ const title = titleDouble || titleSingle || titleParen || '';
|
|
|
+ definitions.set(number, { url: url, title: title });
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+ );
|
|
|
+ return { definitions, cleanedMarkdown };
|
|
|
+ }
|
|
|
+
|
|
|
+ function getNextAvailableReferenceNumber(used, startNumber) {
|
|
|
+ let next = Math.max(1, startNumber || 1);
|
|
|
+ while (used.has(next)) next += 1;
|
|
|
+ return next;
|
|
|
+ }
|
|
|
+
|
|
|
+ function sanitizeMarkdownTitle(title) {
|
|
|
+ return title
|
|
|
+ .replace(/\\/g, '\\\\')
|
|
|
+ .replace(/"/g, '\\"');
|
|
|
+ }
|
|
|
+
|
|
|
+ function isSafeReferenceUrl(url) {
|
|
|
+ if (!url) return false;
|
|
|
+ try {
|
|
|
+ const parsed = new URL(url, window.location.href);
|
|
|
+ return ['http:', 'https:', 'mailto:', 'tel:', 'blob:'].includes(parsed.protocol);
|
|
|
+ } catch (e) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function applyReferencePreviewLinks(container, referenceDefinitions) {
|
|
|
+ if (!container || !referenceDefinitions || referenceDefinitions.size === 0) return;
|
|
|
+
|
|
|
+ function applyReferenceStyle(link, number) {
|
|
|
+ const definition = referenceDefinitions.get(number);
|
|
|
+ if (definition && definition.url && isSafeReferenceUrl(definition.url)) {
|
|
|
+ link.setAttribute('href', definition.url);
|
|
|
+ if (definition.title) {
|
|
|
+ link.setAttribute('title', definition.title);
|
|
|
+ } else {
|
|
|
+ link.removeAttribute('title');
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ link.removeAttribute('href');
|
|
|
+ }
|
|
|
+ link.textContent = '[' + number + ']';
|
|
|
+ link.classList.add('reference-link');
|
|
|
+ }
|
|
|
+
|
|
|
+ const links = container.querySelectorAll('a');
|
|
|
+ links.forEach(function(link) {
|
|
|
+ const text = link.textContent.trim();
|
|
|
+ let number = null;
|
|
|
+ if (/^\d+$/.test(text)) {
|
|
|
+ number = parseInt(text, 10);
|
|
|
+ } else {
|
|
|
+ const match = text.match(/^\[(\d+)\]$/);
|
|
|
+ if (match) number = parseInt(match[1], 10);
|
|
|
+ }
|
|
|
+ if (number && referenceDefinitions.has(number)) {
|
|
|
+ applyReferenceStyle(link, number);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ const referenceRegex = /\[(\d+)\](?!\s*:)/g;
|
|
|
+ const nodesToProcess = [];
|
|
|
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
|
|
|
+ while (walker.nextNode()) {
|
|
|
+ const node = walker.currentNode;
|
|
|
+ const parent = node.parentElement;
|
|
|
+ if (!parent || !node.nodeValue) continue;
|
|
|
+ if (parent.closest('a, code, pre, script, style, mjx-container')) continue;
|
|
|
+ referenceRegex.lastIndex = 0;
|
|
|
+ if (referenceRegex.test(node.nodeValue)) {
|
|
|
+ nodesToProcess.push(node);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ nodesToProcess.forEach(function(node) {
|
|
|
+ const text = node.nodeValue;
|
|
|
+ referenceRegex.lastIndex = 0;
|
|
|
+ let match;
|
|
|
+ let lastIndex = 0;
|
|
|
+ const fragment = document.createDocumentFragment();
|
|
|
+ while ((match = referenceRegex.exec(text)) !== null) {
|
|
|
+ const before = text.slice(lastIndex, match.index);
|
|
|
+ if (before) fragment.appendChild(document.createTextNode(before));
|
|
|
+ const number = parseInt(match[1], 10);
|
|
|
+ const definition = referenceDefinitions.get(number);
|
|
|
+ if (definition && definition.url && isSafeReferenceUrl(definition.url)) {
|
|
|
+ const link = document.createElement('a');
|
|
|
+ link.href = definition.url;
|
|
|
+ if (definition.title) link.title = definition.title;
|
|
|
+ link.textContent = '[' + number + ']';
|
|
|
+ link.classList.add('reference-link');
|
|
|
+ fragment.appendChild(link);
|
|
|
+ } else {
|
|
|
+ fragment.appendChild(document.createTextNode(match[0]));
|
|
|
+ }
|
|
|
+ lastIndex = match.index + match[0].length;
|
|
|
+ }
|
|
|
+ const after = text.slice(lastIndex);
|
|
|
+ if (after) fragment.appendChild(document.createTextNode(after));
|
|
|
+ node.parentNode.replaceChild(fragment, node);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function cleanupImageObjectUrls() {
|
|
|
+ if (imageObjectUrls.size === 0) return;
|
|
|
+ const contents = [markdownEditor.value];
|
|
|
+ if (Array.isArray(tabs)) {
|
|
|
+ tabs.forEach(function(tab) {
|
|
|
+ if (tab && typeof tab.content === 'string' && tab.content) {
|
|
|
+ contents.push(tab.content);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ const snapshot = contents.join('\n');
|
|
|
+ Array.from(imageObjectUrls).forEach(function(url) {
|
|
|
+ if (!snapshot.includes(url)) {
|
|
|
+ URL.revokeObjectURL(url);
|
|
|
+ imageObjectUrls.delete(url);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function insertAlignmentBlock(align) {
|
|
|
+ const allowedAlignments = new Set(['left', 'center', 'right']);
|
|
|
+ const isAllowed = allowedAlignments.has(align);
|
|
|
+ if (!isAllowed) {
|
|
|
+ console.warn('Unsupported alignment:', align);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const safeAlign = align;
|
|
|
+ const value = markdownEditor.value;
|
|
|
+ const start = markdownEditor.selectionStart;
|
|
|
+ const end = markdownEditor.selectionEnd;
|
|
|
+ const selected = value.slice(start, end);
|
|
|
+ const hasSelection = start !== end;
|
|
|
+ const blockStart = `<div align="${safeAlign}">\n`;
|
|
|
+ const blockEnd = `\n</div>`;
|
|
|
+ const block = `${blockStart}${hasSelection ? selected : ''}${blockEnd}`;
|
|
|
+ const needsLeadingBreak = start > 0 && value[start - 1] !== '\n';
|
|
|
+ const needsTrailingBreak = end < value.length && value[end] !== '\n';
|
|
|
+ const replacement = (needsLeadingBreak ? '\n' : '') + block + (needsTrailingBreak ? '\n' : '');
|
|
|
+ const contentStart = start + (needsLeadingBreak ? 1 : 0) + blockStart.length;
|
|
|
+ const contentEnd = contentStart + (hasSelection ? selected.length : 0);
|
|
|
+ replaceEditorRange(start, end, replacement, contentStart, hasSelection ? contentEnd : contentStart);
|
|
|
+ }
|
|
|
+
|
|
|
+ function insertMarkdownBlock(block, startOverride, endOverride) {
|
|
|
+ const value = markdownEditor.value;
|
|
|
+ const start = typeof startOverride === 'number' ? startOverride : markdownEditor.selectionStart;
|
|
|
+ const end = typeof endOverride === 'number' ? endOverride : markdownEditor.selectionEnd;
|
|
|
+ const needsLeadingBreak = start > 0 && value[start - 1] !== '\n';
|
|
|
+ const needsTrailingBreak = end < value.length && value[end] !== '\n';
|
|
|
+ const replacement = (needsLeadingBreak ? '\n' : '') + block + (needsTrailingBreak ? '\n' : '');
|
|
|
+ const caret = start + replacement.length;
|
|
|
+ replaceEditorRange(start, end, replacement, caret, caret);
|
|
|
+ }
|
|
|
+
|
|
|
+ function clampNumber(value, min, max, fallback) {
|
|
|
+ const parsed = parseInt(value, 10);
|
|
|
+ if (Number.isNaN(parsed)) return fallback;
|
|
|
+ return Math.max(min, Math.min(max, parsed));
|
|
|
+ }
|
|
|
+
|
|
|
+ function buildMarkdownTable(columns, rows) {
|
|
|
+ const header = Array.from({ length: columns }, (_, index) => `Column ${index + 1}`).join(' | ');
|
|
|
+ const divider = Array.from({ length: columns }, () => '---').join(' | ');
|
|
|
+ const bodyRows = Array.from({ length: rows }, () => `| ${Array.from({ length: columns }, () => 'Value').join(' | ')} |`);
|
|
|
+ return `| ${header} |\n| ${divider} |\n${bodyRows.join('\n')}\n`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function loadEmojiEntries() {
|
|
|
+ if (emojiLoadPromise) return emojiLoadPromise;
|
|
|
+ emojiLoadPromise = fetch(EMOJI_API_URL)
|
|
|
+ .then((response) => {
|
|
|
+ if (!response.ok) throw new Error(`Emoji request failed (${response.status})`);
|
|
|
+ return response.json();
|
|
|
+ })
|
|
|
+ .then((data) => {
|
|
|
+ emojiEntries = Object.keys(data)
|
|
|
+ .sort((a, b) => a.localeCompare(b))
|
|
|
+ .map((name) => ({
|
|
|
+ name,
|
|
|
+ url: data[name],
|
|
|
+ shortcode: `:${name}:`,
|
|
|
+ search: `${name} :${name}:`.toLowerCase(),
|
|
|
+ }));
|
|
|
+ emojiUrlMap = new Map(emojiEntries.map((entry) => [entry.name, entry.url]));
|
|
|
+ emojiLookupLoaded = true;
|
|
|
+ return emojiEntries;
|
|
|
+ })
|
|
|
+ .catch((error) => {
|
|
|
+ console.error('Failed to load GitHub emojis:', error);
|
|
|
+ emojiEntries = [];
|
|
|
+ emojiUrlMap = new Map();
|
|
|
+ emojiLookupLoaded = true;
|
|
|
+ return emojiEntries;
|
|
|
+ });
|
|
|
+ return emojiLoadPromise;
|
|
|
+ }
|
|
|
+
|
|
|
+ function createAlertPreview(type, meta) {
|
|
|
+ const wrapper = document.createElement('div');
|
|
|
+ wrapper.className = `markdown-alert markdown-alert-${type}`;
|
|
|
+ const title = document.createElement('p');
|
|
|
+ title.className = 'markdown-alert-title';
|
|
|
+ const icon = document.createElement('span');
|
|
|
+ icon.className = 'markdown-alert-icon';
|
|
|
+ icon.setAttribute('aria-hidden', 'true');
|
|
|
+ if (meta.path) {
|
|
|
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
|
+ svg.setAttribute('viewBox', meta.viewBox || '0 0 512 512');
|
|
|
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
|
+ path.setAttribute('d', meta.path);
|
|
|
+ svg.appendChild(path);
|
|
|
+ icon.appendChild(svg);
|
|
|
+ }
|
|
|
+ const label = document.createElement('span');
|
|
|
+ label.textContent = meta.label;
|
|
|
+ title.appendChild(icon);
|
|
|
+ title.appendChild(label);
|
|
|
+ const body = document.createElement('p');
|
|
|
+ body.textContent = `${meta.label} details go here.`;
|
|
|
+ wrapper.appendChild(title);
|
|
|
+ wrapper.appendChild(body);
|
|
|
+ return wrapper;
|
|
|
+ }
|
|
|
+
|
|
|
+ function flashCopyButton(button) {
|
|
|
+ const icon = button.querySelector('i');
|
|
|
+ if (!icon) return;
|
|
|
+ icon.className = 'bi bi-check-lg';
|
|
|
+ button.classList.add('is-copied');
|
|
|
+ clearTimeout(button.copyTimeout);
|
|
|
+ button.copyTimeout = setTimeout(() => {
|
|
|
+ icon.className = 'bi bi-clipboard';
|
|
|
+ button.classList.remove('is-copied');
|
|
|
+ }, 1200);
|
|
|
+ }
|
|
|
+
|
|
|
+ function openTableModal() {
|
|
|
+ const modal = document.getElementById('table-modal');
|
|
|
+ const columnInput = document.getElementById('table-modal-columns');
|
|
|
+ const rowInput = document.getElementById('table-modal-rows');
|
|
|
+ const confirmBtn = document.getElementById('table-modal-insert');
|
|
|
+ const cancelBtn = document.getElementById('table-modal-cancel');
|
|
|
+ if (!modal || !columnInput || !rowInput || !confirmBtn || !cancelBtn) return;
|
|
|
+ const start = markdownEditor.selectionStart;
|
|
|
+ const end = markdownEditor.selectionEnd;
|
|
|
+ columnInput.value = '3';
|
|
|
+ rowInput.value = '1';
|
|
|
+ modal.style.display = 'flex';
|
|
|
+
|
|
|
+ function insertTable() {
|
|
|
+ const columns = clampNumber(columnInput.value, 1, 20, 3);
|
|
|
+ const rows = clampNumber(rowInput.value, 1, 20, 1);
|
|
|
+ const table = buildMarkdownTable(columns, rows);
|
|
|
+ modal.style.display = 'none';
|
|
|
+ cleanup();
|
|
|
+ insertMarkdownBlock(table, start, end);
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeModal() {
|
|
|
+ modal.style.display = 'none';
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+
|
|
|
+ function onKey(e) {
|
|
|
+ if (e.key === 'Enter') {
|
|
|
+ e.preventDefault();
|
|
|
+ insertTable();
|
|
|
+ } else if (e.key === 'Escape') {
|
|
|
+ e.preventDefault();
|
|
|
+ closeModal();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function cleanup() {
|
|
|
+ confirmBtn.removeEventListener('click', insertTable);
|
|
|
+ cancelBtn.removeEventListener('click', closeModal);
|
|
|
+ columnInput.removeEventListener('keydown', onKey);
|
|
|
+ rowInput.removeEventListener('keydown', onKey);
|
|
|
+ }
|
|
|
+
|
|
|
+ confirmBtn.addEventListener('click', insertTable);
|
|
|
+ cancelBtn.addEventListener('click', closeModal);
|
|
|
+ columnInput.addEventListener('keydown', onKey);
|
|
|
+ rowInput.addEventListener('keydown', onKey);
|
|
|
+
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ columnInput.focus();
|
|
|
+ columnInput.select();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function openEmojiModal() {
|
|
|
+ const modal = document.getElementById('emoji-modal');
|
|
|
+ const grid = document.getElementById('emoji-modal-grid');
|
|
|
+ const emptyMessage = document.getElementById('emoji-modal-empty');
|
|
|
+ const searchInput = document.getElementById('emoji-modal-search');
|
|
|
+ const confirmBtn = document.getElementById('emoji-modal-insert');
|
|
|
+ const cancelBtn = document.getElementById('emoji-modal-cancel');
|
|
|
+ if (!modal || !grid || !emptyMessage || !searchInput || !confirmBtn || !cancelBtn) return;
|
|
|
+ const start = markdownEditor.selectionStart;
|
|
|
+ const end = markdownEditor.selectionEnd;
|
|
|
+ modal.style.display = 'flex';
|
|
|
+ confirmBtn.disabled = true;
|
|
|
+
|
|
|
+ // Localized and accessible announcements for loading
|
|
|
+ const loadingText = I18N_DICTS[activeLang].loadingEmojis || "Loading emojis...";
|
|
|
+ emptyMessage.textContent = loadingText;
|
|
|
+ emptyMessage.style.display = 'block';
|
|
|
+ announceToScreenReader(loadingText);
|
|
|
+
|
|
|
+ searchInput.value = '';
|
|
|
+ emojiSelection.clear();
|
|
|
+ // PERF-007: Clear elements using textContent
|
|
|
+ grid.textContent = '';
|
|
|
+ grid.scrollTop = 0;
|
|
|
+ emojiItems = [];
|
|
|
+
|
|
|
+ // Render visual placeholders during active requests
|
|
|
+ if (!emojiLookupLoaded) {
|
|
|
+ renderEmojiSkeletons();
|
|
|
+ }
|
|
|
+
|
|
|
+ let currentFilteredEntries = [];
|
|
|
+ let renderedCount = 0;
|
|
|
+ const CHUNK_SIZE = 120;
|
|
|
+
|
|
|
+ function updateInsertState() {
|
|
|
+ confirmBtn.disabled = emojiSelection.size === 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ function toggleSelection(shortcode, element) {
|
|
|
+ if (emojiSelection.has(shortcode)) {
|
|
|
+ emojiSelection.delete(shortcode);
|
|
|
+ element.classList.remove('is-selected');
|
|
|
+ } else {
|
|
|
+ emojiSelection.add(shortcode);
|
|
|
+ element.classList.add('is-selected');
|
|
|
+ }
|
|
|
+ element.setAttribute('aria-pressed', emojiSelection.has(shortcode).toString());
|
|
|
+ updateInsertState();
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderEmojiChunk(clear = false) {
|
|
|
+ if (clear) {
|
|
|
+ // PERF-007: Clear elements using textContent
|
|
|
+ grid.textContent = '';
|
|
|
+ emojiItems = [];
|
|
|
+ renderedCount = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ const nextBatch = currentFilteredEntries.slice(renderedCount, renderedCount + CHUNK_SIZE);
|
|
|
+ if (nextBatch.length === 0) {
|
|
|
+ emptyMessage.style.display = emojiItems.length ? 'none' : 'block';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const fragment = document.createDocumentFragment();
|
|
|
+ const newItems = nextBatch.map((entry) => {
|
|
|
+ const item = document.createElement('button');
|
|
|
+ item.type = 'button';
|
|
|
+ item.className = 'emoji-item';
|
|
|
+
|
|
|
+ const isSelected = emojiSelection.has(entry.shortcode);
|
|
|
+ if (isSelected) {
|
|
|
+ item.classList.add('is-selected');
|
|
|
+ }
|
|
|
+ item.setAttribute('aria-pressed', isSelected ? 'true' : 'false');
|
|
|
+ item.dataset.search = entry.search;
|
|
|
+ item.dataset.shortcode = entry.shortcode;
|
|
|
+
|
|
|
+ const preview = document.createElement('span');
|
|
|
+ preview.className = 'emoji-preview';
|
|
|
+ const image = document.createElement('img');
|
|
|
+ image.src = entry.url;
|
|
|
+ image.alt = entry.shortcode;
|
|
|
+ image.loading = 'lazy';
|
|
|
+ preview.appendChild(image);
|
|
|
+
|
|
|
+ const shortcodeRow = document.createElement('div');
|
|
|
+ shortcodeRow.className = 'emoji-shortcode';
|
|
|
+ const code = document.createElement('span');
|
|
|
+ code.textContent = entry.shortcode;
|
|
|
+ const copyBtn = document.createElement('button');
|
|
|
+ copyBtn.type = 'button';
|
|
|
+ copyBtn.className = 'emoji-copy-btn';
|
|
|
+ copyBtn.setAttribute('aria-label', `Copy ${entry.shortcode}`);
|
|
|
+ copyBtn.innerHTML = '<i class="bi bi-clipboard"></i>';
|
|
|
+ copyBtn.addEventListener('click', (event) => {
|
|
|
+ event.stopPropagation();
|
|
|
+ copyTextToClipboard(entry.shortcode)
|
|
|
+ .then(() => flashCopyButton(copyBtn))
|
|
|
+ .catch((error) => console.error('Copy failed:', error));
|
|
|
+ });
|
|
|
+ shortcodeRow.appendChild(code);
|
|
|
+ shortcodeRow.appendChild(copyBtn);
|
|
|
+
|
|
|
+ item.appendChild(preview);
|
|
|
+ item.appendChild(shortcodeRow);
|
|
|
+ item.addEventListener('click', () => toggleSelection(entry.shortcode, item));
|
|
|
+ fragment.appendChild(item);
|
|
|
+ return { element: item, search: entry.search, shortcode: entry.shortcode };
|
|
|
+ });
|
|
|
+
|
|
|
+ emojiItems = emojiItems.concat(newItems);
|
|
|
+ grid.appendChild(fragment);
|
|
|
+ renderedCount += nextBatch.length;
|
|
|
+ emptyMessage.style.display = emojiItems.length ? 'none' : 'block';
|
|
|
+ }
|
|
|
+
|
|
|
+ function applyFilter() {
|
|
|
+ const query = searchInput.value.trim().toLowerCase();
|
|
|
+ currentFilteredEntries = emojiEntries.filter(entry => !query || entry.search.includes(query));
|
|
|
+ renderEmojiChunk(true);
|
|
|
+ }
|
|
|
+
|
|
|
+ function handleScroll() {
|
|
|
+ if (grid.scrollTop + grid.clientHeight >= grid.scrollHeight - 60) {
|
|
|
+ renderEmojiChunk(false);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function insertEmojis() {
|
|
|
+ if (!emojiSelection.size) return;
|
|
|
+ // Retain the modal's visual/definition order of emojis, consistent with the Symbols modal
|
|
|
+ const ordered = emojiItems
|
|
|
+ .filter(item => emojiSelection.has(item.shortcode))
|
|
|
+ .map(item => item.shortcode);
|
|
|
+ const insertion = ordered.join(' ');
|
|
|
+ modal.style.display = 'none';
|
|
|
+ cleanup();
|
|
|
+ replaceEditorRange(start, end, insertion, start + insertion.length, start + insertion.length);
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeModal() {
|
|
|
+ modal.style.display = 'none';
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+
|
|
|
+ function onKey(e) {
|
|
|
+ if (e.key === 'Escape') {
|
|
|
+ e.preventDefault();
|
|
|
+ closeModal();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function cleanup() {
|
|
|
+ confirmBtn.removeEventListener('click', insertEmojis);
|
|
|
+ cancelBtn.removeEventListener('click', closeModal);
|
|
|
+ searchInput.removeEventListener('input', applyFilter);
|
|
|
+ searchInput.removeEventListener('keydown', onKey);
|
|
|
+ grid.removeEventListener('scroll', handleScroll);
|
|
|
+ }
|
|
|
+
|
|
|
+ loadEmojiEntries().then((entries) => {
|
|
|
+ if (!entries.length) {
|
|
|
+ emptyMessage.textContent = 'Unable to load emojis.';
|
|
|
+ emptyMessage.style.display = 'block';
|
|
|
+ // PERF-007: Clear elements using textContent
|
|
|
+ grid.textContent = '';
|
|
|
+ emojiItems = [];
|
|
|
+ announceToScreenReader("Failed to load emojis.");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ currentFilteredEntries = entries;
|
|
|
+ renderEmojiChunk(true);
|
|
|
+ emptyMessage.textContent = 'No emojis found.';
|
|
|
+ announceToScreenReader("Emojis loaded. " + entries.length + " items available.");
|
|
|
+ updateInsertState();
|
|
|
+ });
|
|
|
+
|
|
|
+ confirmBtn.addEventListener('click', insertEmojis);
|
|
|
+ cancelBtn.addEventListener('click', closeModal);
|
|
|
+ searchInput.addEventListener('input', applyFilter);
|
|
|
+ searchInput.addEventListener('keydown', onKey);
|
|
|
+ grid.addEventListener('scroll', handleScroll);
|
|
|
+
|
|
|
+ requestAnimationFrame(() => searchInput.focus());
|
|
|
+ }
|
|
|
+
|
|
|
+ function openSymbolsModal() {
|
|
|
+ const modal = document.getElementById('symbols-modal');
|
|
|
+ const grid = document.getElementById('symbols-modal-grid');
|
|
|
+ const emptyMessage = document.getElementById('symbols-modal-empty');
|
|
|
+ const searchInput = document.getElementById('symbols-modal-search');
|
|
|
+ const confirmBtn = document.getElementById('symbols-modal-insert');
|
|
|
+ const cancelBtn = document.getElementById('symbols-modal-cancel');
|
|
|
+ if (!modal || !grid || !emptyMessage || !searchInput || !confirmBtn || !cancelBtn) return;
|
|
|
+ const start = markdownEditor.selectionStart;
|
|
|
+ const end = markdownEditor.selectionEnd;
|
|
|
+ modal.style.display = 'flex';
|
|
|
+ confirmBtn.disabled = true;
|
|
|
+ searchInput.value = '';
|
|
|
+ symbolSelection.clear();
|
|
|
+ // PERF-007: Clear elements using textContent
|
|
|
+ grid.textContent = '';
|
|
|
+
|
|
|
+ const sectionEntries = [];
|
|
|
+ SYMBOL_SECTIONS.forEach((section) => {
|
|
|
+ const sectionWrapper = document.createElement('div');
|
|
|
+ sectionWrapper.className = 'symbol-section';
|
|
|
+ const title = document.createElement('p');
|
|
|
+ title.className = 'symbol-section-title';
|
|
|
+ title.textContent = section.title;
|
|
|
+ const sectionGrid = document.createElement('div');
|
|
|
+ sectionGrid.className = 'symbol-section-grid';
|
|
|
+ const sectionItems = section.items.map((entry) => {
|
|
|
+ const item = document.createElement('button');
|
|
|
+ item.type = 'button';
|
|
|
+ item.className = 'symbol-item';
|
|
|
+ item.setAttribute('aria-pressed', 'false');
|
|
|
+ const preview = document.createElement('span');
|
|
|
+ preview.className = 'symbol-preview';
|
|
|
+ preview.textContent = entry.symbol;
|
|
|
+ const codeRow = document.createElement('div');
|
|
|
+ codeRow.className = 'symbol-code';
|
|
|
+ const code = document.createElement('span');
|
|
|
+ code.textContent = entry.entity;
|
|
|
+ const copyBtn = document.createElement('button');
|
|
|
+ copyBtn.type = 'button';
|
|
|
+ copyBtn.className = 'symbol-copy-btn';
|
|
|
+ copyBtn.setAttribute('aria-label', `Copy ${entry.entity}`);
|
|
|
+ copyBtn.innerHTML = '<i class="bi bi-clipboard"></i>';
|
|
|
+ copyBtn.addEventListener('click', (event) => {
|
|
|
+ event.stopPropagation();
|
|
|
+ copyTextToClipboard(entry.entity)
|
|
|
+ .then(() => flashCopyButton(copyBtn))
|
|
|
+ .catch((error) => console.error('Copy failed:', error));
|
|
|
+ });
|
|
|
+ codeRow.appendChild(code);
|
|
|
+ codeRow.appendChild(copyBtn);
|
|
|
+ item.appendChild(preview);
|
|
|
+ item.appendChild(codeRow);
|
|
|
+
|
|
|
+ item.dataset.search = `${entry.symbol} ${entry.entity} ${entry.name}`.toLowerCase();
|
|
|
+ item.dataset.entity = entry.entity;
|
|
|
+ item.addEventListener('click', () => {
|
|
|
+ if (symbolSelection.has(entry.entity)) {
|
|
|
+ symbolSelection.delete(entry.entity);
|
|
|
+ item.classList.remove('is-selected');
|
|
|
+ } else {
|
|
|
+ symbolSelection.add(entry.entity);
|
|
|
+ item.classList.add('is-selected');
|
|
|
+ }
|
|
|
+ item.setAttribute('aria-pressed', symbolSelection.has(entry.entity).toString());
|
|
|
+ confirmBtn.disabled = symbolSelection.size === 0;
|
|
|
+ });
|
|
|
+
|
|
|
+ sectionGrid.appendChild(item);
|
|
|
+ return { element: item, search: item.dataset.search, entity: entry.entity };
|
|
|
+ });
|
|
|
+ sectionWrapper.appendChild(title);
|
|
|
+ sectionWrapper.appendChild(sectionGrid);
|
|
|
+ grid.appendChild(sectionWrapper);
|
|
|
+ sectionEntries.push({ wrapper: sectionWrapper, items: sectionItems });
|
|
|
+ });
|
|
|
+
|
|
|
+ symbolItems = sectionEntries.flatMap((section) => section.items);
|
|
|
+
|
|
|
+ function applyFilter() {
|
|
|
+ const query = searchInput.value.trim().toLowerCase();
|
|
|
+ let visibleCount = 0;
|
|
|
+ sectionEntries.forEach((section) => {
|
|
|
+ let sectionVisible = 0;
|
|
|
+ section.items.forEach((item) => {
|
|
|
+ const match = !query || item.search.includes(query);
|
|
|
+ item.element.style.display = match ? '' : 'none';
|
|
|
+ if (match) {
|
|
|
+ visibleCount += 1;
|
|
|
+ sectionVisible += 1;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ section.wrapper.style.display = sectionVisible ? '' : 'none';
|
|
|
+ });
|
|
|
+ emptyMessage.style.display = visibleCount ? 'none' : 'block';
|
|
|
+ }
|
|
|
+
|
|
|
+ function insertSymbols() {
|
|
|
+ if (!symbolSelection.size) return;
|
|
|
+ const ordered = symbolItems
|
|
|
+ .filter((item) => symbolSelection.has(item.entity))
|
|
|
+ .map((item) => item.entity);
|
|
|
+ const insertion = ordered.join(' ');
|
|
|
+ modal.style.display = 'none';
|
|
|
+ cleanup();
|
|
|
+ replaceEditorRange(start, end, insertion, start + insertion.length, start + insertion.length);
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeModal() {
|
|
|
+ modal.style.display = 'none';
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+
|
|
|
+ function onKey(e) {
|
|
|
+ if (e.key === 'Escape') {
|
|
|
+ e.preventDefault();
|
|
|
+ closeModal();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function cleanup() {
|
|
|
+ confirmBtn.removeEventListener('click', insertSymbols);
|
|
|
+ cancelBtn.removeEventListener('click', closeModal);
|
|
|
+ searchInput.removeEventListener('input', applyFilter);
|
|
|
+ searchInput.removeEventListener('keydown', onKey);
|
|
|
+ }
|
|
|
+
|
|
|
+ emptyMessage.textContent = 'No symbols found.';
|
|
|
+ applyFilter();
|
|
|
+ confirmBtn.addEventListener('click', insertSymbols);
|
|
|
+ cancelBtn.addEventListener('click', closeModal);
|
|
|
+ searchInput.addEventListener('input', applyFilter);
|
|
|
+ searchInput.addEventListener('keydown', onKey);
|
|
|
+ requestAnimationFrame(() => searchInput.focus());
|
|
|
+ }
|
|
|
+
|
|
|
+ function openAlertModal() {
|
|
|
+ const modal = document.getElementById('alert-modal');
|
|
|
+ const grid = document.getElementById('alert-modal-grid');
|
|
|
+ const confirmBtn = document.getElementById('alert-modal-insert');
|
|
|
+ const cancelBtn = document.getElementById('alert-modal-cancel');
|
|
|
+ if (!modal || !grid || !confirmBtn || !cancelBtn) return;
|
|
|
+ const start = markdownEditor.selectionStart;
|
|
|
+ const end = markdownEditor.selectionEnd;
|
|
|
+ modal.style.display = 'flex';
|
|
|
+ // PERF-007: Clear elements using textContent
|
|
|
+ grid.textContent = '';
|
|
|
+
|
|
|
+ const alertTypes = ['note', 'tip', 'important', 'warning', 'caution'];
|
|
|
+ let selectedType = alertTypes[0];
|
|
|
+ const options = [];
|
|
|
+ alertTypes.forEach((type) => {
|
|
|
+ const meta = GITHUB_ALERT_META[type] || { label: type };
|
|
|
+ const option = document.createElement('button');
|
|
|
+ option.type = 'button';
|
|
|
+ option.className = 'alert-option';
|
|
|
+ option.dataset.alertType = type;
|
|
|
+ option.setAttribute('aria-pressed', (type === selectedType).toString());
|
|
|
+ const preview = document.createElement('div');
|
|
|
+ preview.className = 'alert-preview';
|
|
|
+ preview.appendChild(createAlertPreview(type, meta));
|
|
|
+ option.appendChild(preview);
|
|
|
+ if (type === selectedType) option.classList.add('is-selected');
|
|
|
+ option.addEventListener('click', () => {
|
|
|
+ selectedType = type;
|
|
|
+ options.forEach((item) => {
|
|
|
+ const isSelected = item === option;
|
|
|
+ item.classList.toggle('is-selected', isSelected);
|
|
|
+ item.setAttribute('aria-pressed', isSelected.toString());
|
|
|
+ });
|
|
|
+ });
|
|
|
+ options.push(option);
|
|
|
+ grid.appendChild(option);
|
|
|
+ });
|
|
|
+
|
|
|
+ function insertAlert() {
|
|
|
+ const type = selectedType.toUpperCase();
|
|
|
+ const meta = GITHUB_ALERT_META[selectedType] || { label: selectedType };
|
|
|
+ const body = `${meta.label} details go here.`;
|
|
|
+ const block = `> [!${type}]\n> ${body}\n`;
|
|
|
+ modal.style.display = 'none';
|
|
|
+ cleanup();
|
|
|
+ insertMarkdownBlock(block, start, end);
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeModal() {
|
|
|
+ modal.style.display = 'none';
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+
|
|
|
+ function onKey(e) {
|
|
|
+ if (e.key === 'Escape') {
|
|
|
+ e.preventDefault();
|
|
|
+ closeModal();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function cleanup() {
|
|
|
+ confirmBtn.removeEventListener('click', insertAlert);
|
|
|
+ cancelBtn.removeEventListener('click', closeModal);
|
|
|
+ modal.removeEventListener('keydown', onKey);
|
|
|
+ }
|
|
|
+
|
|
|
+ confirmBtn.addEventListener('click', insertAlert);
|
|
|
+ cancelBtn.addEventListener('click', closeModal);
|
|
|
+ modal.addEventListener('keydown', onKey);
|
|
|
+ }
|
|
|
+
|
|
|
+ function insertMarkdownLink() {
|
|
|
+ const modal = document.getElementById('link-modal');
|
|
|
+ const urlInput = document.getElementById('link-modal-url');
|
|
|
+ const textInput = document.getElementById('link-modal-text');
|
|
|
+ const confirmBtn = document.getElementById('link-modal-apply');
|
|
|
+ const cancelBtn = document.getElementById('link-modal-cancel');
|
|
|
+ if (!modal || !urlInput || !textInput || !confirmBtn || !cancelBtn) return;
|
|
|
+ const start = markdownEditor.selectionStart;
|
|
|
+ const end = markdownEditor.selectionEnd;
|
|
|
+ const selected = markdownEditor.value.slice(start, end);
|
|
|
+ urlInput.value = 'https://';
|
|
|
+ textInput.value = selected || '';
|
|
|
+ modal.style.display = 'flex';
|
|
|
+
|
|
|
+ function applyLink() {
|
|
|
+ const url = urlInput.value.trim() || 'https://';
|
|
|
+ const linkText = textInput.value.trim() || selected || 'link text';
|
|
|
+ const replacement = '[' + linkText + '](' + url + ')';
|
|
|
+ modal.style.display = 'none';
|
|
|
+ cleanup();
|
|
|
+ replaceEditorRange(start, end, replacement, start + replacement.length, start + replacement.length);
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeModal() {
|
|
|
+ modal.style.display = 'none';
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+
|
|
|
+ function onKey(e) {
|
|
|
+ if (e.key === 'Enter') {
|
|
|
+ e.preventDefault();
|
|
|
+ applyLink();
|
|
|
+ } else if (e.key === 'Escape') {
|
|
|
+ e.preventDefault();
|
|
|
+ closeModal();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function cleanup() {
|
|
|
+ confirmBtn.removeEventListener('click', applyLink);
|
|
|
+ cancelBtn.removeEventListener('click', closeModal);
|
|
|
+ urlInput.removeEventListener('keydown', onKey);
|
|
|
+ textInput.removeEventListener('keydown', onKey);
|
|
|
+ }
|
|
|
+
|
|
|
+ confirmBtn.addEventListener('click', applyLink);
|
|
|
+ cancelBtn.addEventListener('click', closeModal);
|
|
|
+ urlInput.addEventListener('keydown', onKey);
|
|
|
+ textInput.addEventListener('keydown', onKey);
|
|
|
+
|
|
|
+ requestAnimationFrame(function() {
|
|
|
+ urlInput.focus();
|
|
|
+ urlInput.select();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function insertMarkdownImage() {
|
|
|
+ const modal = document.getElementById('image-modal');
|
|
|
+ const uploadOption = document.getElementById('image-source-upload');
|
|
|
+ const urlOption = document.getElementById('image-source-url');
|
|
|
+ const uploadFields = document.getElementById('image-upload-fields');
|
|
|
+ const urlFields = document.getElementById('image-url-fields');
|
|
|
+ const fileInput = document.getElementById('image-modal-file');
|
|
|
+ const urlInput = document.getElementById('image-modal-url');
|
|
|
+ const altInput = document.getElementById('image-modal-alt');
|
|
|
+ const confirmBtn = document.getElementById('image-modal-insert');
|
|
|
+ const cancelBtn = document.getElementById('image-modal-cancel');
|
|
|
+ if (!modal || !uploadOption || !urlOption || !uploadFields || !urlFields || !fileInput || !urlInput || !altInput || !confirmBtn || !cancelBtn) return;
|
|
|
+ const start = markdownEditor.selectionStart;
|
|
|
+ const end = markdownEditor.selectionEnd;
|
|
|
+ const selected = markdownEditor.value.slice(start, end);
|
|
|
+ urlInput.value = 'https://';
|
|
|
+ altInput.value = selected || '';
|
|
|
+ fileInput.value = '';
|
|
|
+ urlOption.checked = true;
|
|
|
+ uploadOption.checked = false;
|
|
|
+ modal.style.display = 'flex';
|
|
|
+
|
|
|
+ function buildImageMarkdown(url) {
|
|
|
+ const titleText = altInput.value.trim();
|
|
|
+ const altText = titleText || 'alt text';
|
|
|
+ const safeTitle = sanitizeMarkdownTitle(titleText);
|
|
|
+ const titlePart = safeTitle ? ' "' + safeTitle + '"' : '';
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+
|
|
|
+ function insertImage(url) {
|
|
|
+ const safeUrl = url.trim() || 'https://';
|
|
|
+ const replacement = buildImageMarkdown(safeUrl);
|
|
|
+ modal.style.display = 'none';
|
|
|
+ cleanup();
|
|
|
+ replaceEditorRange(start, end, replacement, start + replacement.length, start + replacement.length);
|
|
|
+ }
|
|
|
+
|
|
|
+ function insertFromFile(file) {
|
|
|
+ const objectUrl = URL.createObjectURL(file);
|
|
|
+ imageObjectUrls.add(objectUrl);
|
|
|
+ insertImage(objectUrl);
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateMode(shouldFocus) {
|
|
|
+ const isUpload = uploadOption.checked;
|
|
|
+ uploadFields.style.display = isUpload ? 'flex' : 'none';
|
|
|
+ urlFields.style.display = isUpload ? 'none' : 'flex';
|
|
|
+ if (shouldFocus) {
|
|
|
+ requestAnimationFrame(function() {
|
|
|
+ if (isUpload) {
|
|
|
+ fileInput.focus();
|
|
|
+ } else {
|
|
|
+ urlInput.focus();
|
|
|
+ urlInput.select();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function onModeChange() {
|
|
|
+ updateMode(true);
|
|
|
+ }
|
|
|
+
|
|
|
+ function onFileChange() {
|
|
|
+ const file = fileInput.files && fileInput.files[0];
|
|
|
+ if (file) {
|
|
|
+ insertFromFile(file);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function onKey(e) {
|
|
|
+ if (e.key === 'Enter') {
|
|
|
+ e.preventDefault();
|
|
|
+ if (uploadOption.checked) {
|
|
|
+ const file = fileInput.files && fileInput.files[0];
|
|
|
+ if (file) insertFromFile(file);
|
|
|
+ else fileInput.click();
|
|
|
+ } else {
|
|
|
+ insertImage(urlInput.value);
|
|
|
+ }
|
|
|
+ } else if (e.key === 'Escape') {
|
|
|
+ e.preventDefault();
|
|
|
+ closeModal();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeModal() {
|
|
|
+ modal.style.display = 'none';
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+
|
|
|
+ function cleanup() {
|
|
|
+ confirmBtn.removeEventListener('click', onConfirm);
|
|
|
+ cancelBtn.removeEventListener('click', closeModal);
|
|
|
+ uploadOption.removeEventListener('change', onModeChange);
|
|
|
+ urlOption.removeEventListener('change', onModeChange);
|
|
|
+ fileInput.removeEventListener('change', onFileChange);
|
|
|
+ fileInput.removeEventListener('keydown', onKey);
|
|
|
+ urlInput.removeEventListener('keydown', onKey);
|
|
|
+ altInput.removeEventListener('keydown', onKey);
|
|
|
+ }
|
|
|
+
|
|
|
+ function onConfirm() {
|
|
|
+ if (uploadOption.checked) {
|
|
|
+ const file = fileInput.files && fileInput.files[0];
|
|
|
+ if (file) insertFromFile(file);
|
|
|
+ else fileInput.click();
|
|
|
+ } else {
|
|
|
+ insertImage(urlInput.value);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ confirmBtn.addEventListener('click', onConfirm);
|
|
|
+ cancelBtn.addEventListener('click', closeModal);
|
|
|
+ uploadOption.addEventListener('change', onModeChange);
|
|
|
+ urlOption.addEventListener('change', onModeChange);
|
|
|
+ fileInput.addEventListener('change', onFileChange);
|
|
|
+ fileInput.addEventListener('keydown', onKey);
|
|
|
+ urlInput.addEventListener('keydown', onKey);
|
|
|
+ altInput.addEventListener('keydown', onKey);
|
|
|
+ updateMode(true);
|
|
|
+ }
|
|
|
+
|
|
|
+ function insertMarkdownReference() {
|
|
|
+ const modal = document.getElementById('reference-modal');
|
|
|
+ const numberInput = document.getElementById('reference-modal-number');
|
|
|
+ const urlInput = document.getElementById('reference-modal-url');
|
|
|
+ const titleInput = document.getElementById('reference-modal-title-input');
|
|
|
+ const confirmBtn = document.getElementById('reference-modal-apply');
|
|
|
+ const cancelBtn = document.getElementById('reference-modal-cancel');
|
|
|
+ if (!modal || !numberInput || !urlInput || !titleInput || !confirmBtn || !cancelBtn) return;
|
|
|
+ const start = markdownEditor.selectionStart;
|
|
|
+ const end = markdownEditor.selectionEnd;
|
|
|
+ const currentValue = markdownEditor.value;
|
|
|
+ const used = getUsedReferenceNumbers(currentValue);
|
|
|
+ const maxUsed = used.size ? Math.max(...used) : 0;
|
|
|
+ referenceCounter = Math.max(1, maxUsed + 1);
|
|
|
+ const suggestedNumber = getNextAvailableReferenceNumber(used, referenceCounter);
|
|
|
+ numberInput.value = '[' + suggestedNumber + ']';
|
|
|
+ urlInput.value = 'https://';
|
|
|
+ titleInput.value = '';
|
|
|
+ modal.style.display = 'flex';
|
|
|
+
|
|
|
+ function insertReference() {
|
|
|
+ const latestValue = markdownEditor.value;
|
|
|
+ const usedNumbers = getUsedReferenceNumbers(latestValue);
|
|
|
+ const parsed = parseInt(numberInput.value.replace(/[^\d]/g, ''), 10);
|
|
|
+ const baseNumber = Number.isNaN(parsed) ? suggestedNumber : parsed;
|
|
|
+ const finalNumber = getNextAvailableReferenceNumber(usedNumbers, baseNumber);
|
|
|
+ const url = urlInput.value.trim() || 'https://';
|
|
|
+ const title = titleInput.value.trim();
|
|
|
+ const safeTitle = sanitizeMarkdownTitle(title);
|
|
|
+ const definition = '[' + finalNumber + ']: ' + url + (safeTitle ? ' "' + safeTitle + '"' : '');
|
|
|
+ const selected = latestValue.slice(start, end);
|
|
|
+ const inlineReference = selected + '[' + finalNumber + ']';
|
|
|
+ const baseValue = latestValue.slice(0, start) + inlineReference + latestValue.slice(end);
|
|
|
+ let separator = '';
|
|
|
+ if (baseValue.length && !baseValue.endsWith('\n')) {
|
|
|
+ separator = '\n';
|
|
|
+ }
|
|
|
+ const updatedValue = baseValue + separator + definition;
|
|
|
+ markdownEditor.value = updatedValue;
|
|
|
+ markdownEditor.focus();
|
|
|
+ const caret = start + inlineReference.length;
|
|
|
+ markdownEditor.setSelectionRange(caret, caret);
|
|
|
+ markdownEditor.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
+ referenceCounter = Math.max(referenceCounter, finalNumber + 1);
|
|
|
+ modal.style.display = 'none';
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeModal() {
|
|
|
+ modal.style.display = 'none';
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+
|
|
|
+ function onKey(e) {
|
|
|
+ if (e.key === 'Enter') {
|
|
|
+ e.preventDefault();
|
|
|
+ insertReference();
|
|
|
+ } else if (e.key === 'Escape') {
|
|
|
+ e.preventDefault();
|
|
|
+ closeModal();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function cleanup() {
|
|
|
+ confirmBtn.removeEventListener('click', insertReference);
|
|
|
+ cancelBtn.removeEventListener('click', closeModal);
|
|
|
+ numberInput.removeEventListener('keydown', onKey);
|
|
|
+ urlInput.removeEventListener('keydown', onKey);
|
|
|
+ titleInput.removeEventListener('keydown', onKey);
|
|
|
+ }
|
|
|
+
|
|
|
+ confirmBtn.addEventListener('click', insertReference);
|
|
|
+ cancelBtn.addEventListener('click', closeModal);
|
|
|
+ numberInput.addEventListener('keydown', onKey);
|
|
|
+ urlInput.addEventListener('keydown', onKey);
|
|
|
+ titleInput.addEventListener('keydown', onKey);
|
|
|
+
|
|
|
+ requestAnimationFrame(function() {
|
|
|
+ numberInput.focus();
|
|
|
+ numberInput.select();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function escapeRegExp(text) {
|
|
|
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
+ }
|
|
|
+
|
|
|
+ function getFocusableElements(container) {
|
|
|
+ return Array.from(container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'))
|
|
|
+ .filter(element => !element.disabled && element.offsetParent !== null);
|
|
|
+ }
|
|
|
+
|
|
|
+ function trapFocusInModal(modal, event) {
|
|
|
+ const focusable = getFocusableElements(modal);
|
|
|
+ if (!focusable.length) {
|
|
|
+ event.preventDefault();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const first = focusable[0];
|
|
|
+ const last = focusable[focusable.length - 1];
|
|
|
+ if (event.shiftKey && document.activeElement === first) {
|
|
|
+ event.preventDefault();
|
|
|
+ last.focus();
|
|
|
+ } else if (!event.shiftKey && document.activeElement === last) {
|
|
|
+ event.preventDefault();
|
|
|
+ first.focus();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function openAppModal(modal, options = {}) {
|
|
|
+ if (!modal) return;
|
|
|
+ if (activeModal && activeModal !== modal) {
|
|
|
+ closeAppModal(activeModal);
|
|
|
+ }
|
|
|
+ lastFocusedElement = document.activeElement;
|
|
|
+ modal.style.display = 'flex';
|
|
|
+ requestAnimationFrame(function() {
|
|
|
+ modal.classList.add('is-visible');
|
|
|
+ });
|
|
|
+ modal.setAttribute('aria-hidden', 'false');
|
|
|
+ activeModal = modal;
|
|
|
+ const focusTarget = options.focusTarget || getFocusableElements(modal)[0];
|
|
|
+ if (focusTarget) {
|
|
|
+ focusTarget.focus();
|
|
|
+ }
|
|
|
+ const handleKeydown = function(event) {
|
|
|
+ if (event.key === 'Escape') {
|
|
|
+ event.preventDefault();
|
|
|
+ if (options.onClose) {
|
|
|
+ options.onClose();
|
|
|
+ } else {
|
|
|
+ closeAppModal(modal);
|
|
|
+ }
|
|
|
+ } else if (event.key === 'Tab') {
|
|
|
+ trapFocusInModal(modal, event);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ const handlePointerDown = function(event) {
|
|
|
+ if (event.target === modal) {
|
|
|
+ if (options.onClose) {
|
|
|
+ options.onClose();
|
|
|
+ } else {
|
|
|
+ closeAppModal(modal);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+ modal.addEventListener('keydown', handleKeydown);
|
|
|
+ modal.addEventListener('mousedown', handlePointerDown);
|
|
|
+ modal._modalHandlers = { handleKeydown, handlePointerDown };
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeAppModal(modal) {
|
|
|
+ if (!modal) return;
|
|
|
+ modal.classList.remove('is-visible');
|
|
|
+ modal.setAttribute('aria-hidden', 'true');
|
|
|
+ const handlers = modal._modalHandlers || {};
|
|
|
+ if (handlers.handleKeydown) modal.removeEventListener('keydown', handlers.handleKeydown);
|
|
|
+ if (handlers.handlePointerDown) modal.removeEventListener('mousedown', handlers.handlePointerDown);
|
|
|
+ if (activeModal === modal) activeModal = null;
|
|
|
+ window.setTimeout(function() {
|
|
|
+ if (!modal.classList.contains('is-visible')) {
|
|
|
+ modal.style.display = 'none';
|
|
|
+ }
|
|
|
+ }, 200);
|
|
|
+ if (lastFocusedElement && typeof lastFocusedElement.focus === 'function') {
|
|
|
+ lastFocusedElement.focus();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateFindHighlights() {
|
|
|
+ updatePreviewFindHighlights();
|
|
|
+
|
|
|
+ if (!editorHighlightLayer) return;
|
|
|
+ if (!isEditorVisible()) return;
|
|
|
+ if (!isFindModalOpen || !findReplaceInput || !findReplaceInput.value || !findMatches.length) {
|
|
|
+ if (editorHighlightLayer.textContent !== '') {
|
|
|
+ editorHighlightLayer.textContent = '';
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const text = markdownEditor.value || '';
|
|
|
+ const scrollTop = cachedScrollTop;
|
|
|
+ const scrollLeft = cachedScrollLeft;
|
|
|
+ const fragment = document.createDocumentFragment();
|
|
|
+ let lastIndex = 0;
|
|
|
+ findMatches.forEach(function(match, index) {
|
|
|
+ fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.start)));
|
|
|
+ const mark = document.createElement('mark');
|
|
|
+ mark.className = 'find-highlight' + (index === activeFindIndex ? ' active' : '');
|
|
|
+ mark.textContent = text.slice(match.start, match.end);
|
|
|
+ fragment.appendChild(mark);
|
|
|
+ lastIndex = match.end;
|
|
|
+ });
|
|
|
+ fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
|
|
|
+ editorHighlightLayer.textContent = '';
|
|
|
+ editorHighlightLayer.appendChild(fragment);
|
|
|
+ editorHighlightLayer.scrollTop = scrollTop;
|
|
|
+ editorHighlightLayer.scrollLeft = scrollLeft;
|
|
|
+ }
|
|
|
+
|
|
|
+ let previewHighlights = [];
|
|
|
+ let activePreviewHighlightIndex = -1;
|
|
|
+
|
|
|
+ function isPreviewVisible() {
|
|
|
+ return currentViewMode === 'preview' || currentViewMode === 'split';
|
|
|
+ }
|
|
|
+
|
|
|
+ function clearPreviewFindHighlights() {
|
|
|
+ if (!markdownPreview) return;
|
|
|
+ const highlights = markdownPreview.querySelectorAll('.preview-find-highlight');
|
|
|
+ highlights.forEach(function(el) {
|
|
|
+ const parent = el.parentNode;
|
|
|
+ if (parent) {
|
|
|
+ parent.replaceChild(document.createTextNode(el.textContent), el);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ markdownPreview.normalize();
|
|
|
+ }
|
|
|
+
|
|
|
+ function highlightPreviewText(node, regex) {
|
|
|
+ if (!node) return;
|
|
|
+ if (node.nodeType === Node.TEXT_NODE) {
|
|
|
+ const val = node.nodeValue;
|
|
|
+ if (!val) return;
|
|
|
+
|
|
|
+ regex.lastIndex = 0;
|
|
|
+ let match;
|
|
|
+ const matches = [];
|
|
|
+ while ((match = regex.exec(val)) !== null) {
|
|
|
+ if (match[0].length === 0) {
|
|
|
+ regex.lastIndex++;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ matches.push({
|
|
|
+ start: match.index,
|
|
|
+ end: match.index + match[0].length,
|
|
|
+ text: match[0]
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (matches.length > 0) {
|
|
|
+ const parent = node.parentNode;
|
|
|
+ if (!parent) return;
|
|
|
+
|
|
|
+ const fragment = document.createDocumentFragment();
|
|
|
+ let lastIdx = 0;
|
|
|
+
|
|
|
+ matches.forEach(function(m) {
|
|
|
+ if (m.start > lastIdx) {
|
|
|
+ fragment.appendChild(document.createTextNode(val.slice(lastIdx, m.start)));
|
|
|
+ }
|
|
|
+ const mark = document.createElement('mark');
|
|
|
+ mark.className = 'preview-find-highlight';
|
|
|
+ mark.textContent = m.text;
|
|
|
+ fragment.appendChild(mark);
|
|
|
+ lastIdx = m.end;
|
|
|
+ });
|
|
|
+
|
|
|
+ if (lastIdx < val.length) {
|
|
|
+ fragment.appendChild(document.createTextNode(val.slice(lastIdx)));
|
|
|
+ }
|
|
|
+
|
|
|
+ parent.replaceChild(fragment, node);
|
|
|
+ }
|
|
|
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
|
+ const tagName = node.tagName.toLowerCase();
|
|
|
+ if (tagName === 'script' || tagName === 'style' || tagName === 'textarea' || tagName === 'noscript' || tagName === 'svg') {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (node.classList.contains('mermaid') || node.classList.contains('mjx-container') || node.closest('.mermaid') || node.closest('.mjx-container')) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const children = Array.from(node.childNodes);
|
|
|
+ children.forEach(function(child) {
|
|
|
+ highlightPreviewText(child, regex);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function updatePreviewFindHighlights() {
|
|
|
+ clearPreviewFindHighlights();
|
|
|
+ previewHighlights = [];
|
|
|
+
|
|
|
+ if (!isFindModalOpen || !findReplaceInput || !findReplaceInput.value || !isPreviewVisible()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const query = findReplaceInput.value;
|
|
|
+ const isRegex = document.getElementById('find-regex').classList.contains('active');
|
|
|
+ const isCaseSensitive = document.getElementById('find-case').classList.contains('active');
|
|
|
+ const isWholeWord = document.getElementById('find-word').classList.contains('active');
|
|
|
+
|
|
|
+ let regex;
|
|
|
+ try {
|
|
|
+ let pattern = isRegex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
+ if (isWholeWord) {
|
|
|
+ pattern = `\\b${pattern}\\b`;
|
|
|
+ }
|
|
|
+ const flags = isCaseSensitive ? 'g' : 'gi';
|
|
|
+ regex = new RegExp(pattern, flags);
|
|
|
+ } catch (e) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ highlightPreviewText(markdownPreview, regex);
|
|
|
+ previewHighlights = Array.from(markdownPreview.querySelectorAll('.preview-find-highlight'));
|
|
|
+ updateActivePreviewHighlight();
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateActivePreviewHighlight() {
|
|
|
+ previewHighlights.forEach(function(el) {
|
|
|
+ el.classList.remove('active');
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!previewHighlights.length) {
|
|
|
+ activePreviewHighlightIndex = -1;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (findMatches.length > 0 && activeFindIndex >= 0) {
|
|
|
+ const ratio = activeFindIndex / findMatches.length;
|
|
|
+ activePreviewHighlightIndex = Math.min(
|
|
|
+ previewHighlights.length - 1,
|
|
|
+ Math.floor(ratio * previewHighlights.length)
|
|
|
+ );
|
|
|
+ } else {
|
|
|
+ activePreviewHighlightIndex = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (activePreviewHighlightIndex >= 0 && activePreviewHighlightIndex < previewHighlights.length) {
|
|
|
+ const activeEl = previewHighlights[activePreviewHighlightIndex];
|
|
|
+ activeEl.classList.add('active');
|
|
|
+ scrollPreviewHighlightIntoView(activeEl);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function scrollPreviewHighlightIntoView(element) {
|
|
|
+ if (!element || !previewPane) return;
|
|
|
+ const paneRect = previewPane.getBoundingClientRect();
|
|
|
+ const elemRect = element.getBoundingClientRect();
|
|
|
+ const isVisible = (
|
|
|
+ elemRect.top >= paneRect.top + 40 &&
|
|
|
+ elemRect.bottom <= paneRect.bottom - 40
|
|
|
+ );
|
|
|
+ if (!isVisible) {
|
|
|
+ const scrollTop = previewPane.scrollTop + (elemRect.top - paneRect.top) - (paneRect.height / 2) + (elemRect.height / 2);
|
|
|
+ previewPane.scrollTop = scrollTop;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function syncHighlightScroll() {
|
|
|
+ if (!editorHighlightLayer) return;
|
|
|
+ editorHighlightLayer.scrollTop = cachedScrollTop;
|
|
|
+ editorHighlightLayer.scrollLeft = cachedScrollLeft;
|
|
|
+ }
|
|
|
+
|
|
|
+ function scheduleEditorOverlayScrollSync() {
|
|
|
+ if (editorOverlayScrollFrame) return;
|
|
|
+ editorOverlayScrollFrame = requestAnimationFrame(function() {
|
|
|
+ editorOverlayScrollFrame = null;
|
|
|
+ syncHighlightScroll();
|
|
|
+ syncLineNumberScroll();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateLineNumberGutter(lineCount) {
|
|
|
+ if (!editorPaneElement) return;
|
|
|
+ const digits = String(Math.max(1, lineCount)).length;
|
|
|
+ const gutterSize = `${Math.max(LINE_NUMBER_GUTTER_MIN_CH, digits + LINE_NUMBER_GUTTER_PADDING_CH)}ch`;
|
|
|
+ editorPaneElement.style.setProperty('--line-number-gutter', gutterSize);
|
|
|
+ }
|
|
|
+
|
|
|
+ function getLineHeight(styles) {
|
|
|
+ const computed = parseFloat(styles.lineHeight);
|
|
|
+ if (!Number.isNaN(computed)) return computed;
|
|
|
+ const fontSize = parseFloat(styles.fontSize) || 14;
|
|
|
+ return fontSize * 1.5;
|
|
|
+ }
|
|
|
+
|
|
|
+ function getWrappedLineCountMonospace(lineText, maxCharsPerLine) {
|
|
|
+ if (!lineText) return 1;
|
|
|
+ const words = lineText.replace(/\t/g, ' ').split(' ');
|
|
|
+ let linesCount = 1;
|
|
|
+ let currentLineLength = 0;
|
|
|
+
|
|
|
+ for (let i = 0; i < words.length; i++) {
|
|
|
+ const word = words[i];
|
|
|
+ const wordLength = word.length;
|
|
|
+
|
|
|
+ if (wordLength === 0) {
|
|
|
+ if (currentLineLength + 1 > maxCharsPerLine) {
|
|
|
+ linesCount++;
|
|
|
+ currentLineLength = 1;
|
|
|
+ } else {
|
|
|
+ currentLineLength++;
|
|
|
+ }
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (wordLength > maxCharsPerLine) {
|
|
|
+ const remainingSpace = maxCharsPerLine - currentLineLength;
|
|
|
+ if (remainingSpace > 0 && currentLineLength > 0) {
|
|
|
+ const firstPart = wordLength - remainingSpace;
|
|
|
+ linesCount += 1 + Math.floor(firstPart / maxCharsPerLine);
|
|
|
+ currentLineLength = firstPart % maxCharsPerLine;
|
|
|
+ } else {
|
|
|
+ linesCount += Math.floor(wordLength / maxCharsPerLine);
|
|
|
+ currentLineLength = wordLength % maxCharsPerLine;
|
|
|
+ }
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ const spaceRequired = currentLineLength === 0 ? 0 : 1;
|
|
|
+ if (currentLineLength + spaceRequired + wordLength > maxCharsPerLine) {
|
|
|
+ linesCount++;
|
|
|
+ currentLineLength = wordLength;
|
|
|
+ } else {
|
|
|
+ currentLineLength += spaceRequired + wordLength;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return Math.max(1, linesCount);
|
|
|
+ }
|
|
|
+
|
|
|
+ const lineCache = new Map();
|
|
|
+ let cachedPaddingLeft = 10;
|
|
|
+ let cachedPaddingRight = 10;
|
|
|
+ let cachedCharWidth = 0;
|
|
|
+ let cachedLineHeight = 21;
|
|
|
+ let cachedEditorWidth = 0;
|
|
|
+ let cachedMaxCharsPerLine = 80;
|
|
|
+ let cachedScrollTop = 0;
|
|
|
+ let cachedScrollLeft = 0;
|
|
|
+ let isGeometryInitialized = false;
|
|
|
+ let lastLineNumberLineCount = 0;
|
|
|
+ let lastLineNumberDocumentLength = 0;
|
|
|
+
|
|
|
+ function countLinesFast(text) {
|
|
|
+ if (!text) return 1;
|
|
|
+ let count = 1;
|
|
|
+ for (let i = 0; i < text.length; i += 1) {
|
|
|
+ if (text.charCodeAt(i) === 10) count += 1;
|
|
|
+ }
|
|
|
+ return count;
|
|
|
+ }
|
|
|
+
|
|
|
+ function countLinesBeforeIndex(text, endIndex) {
|
|
|
+ let count = 0;
|
|
|
+ const max = Math.max(0, Math.min(text.length, endIndex));
|
|
|
+ for (let i = 0; i < max; i += 1) {
|
|
|
+ if (text.charCodeAt(i) === 10) count += 1;
|
|
|
+ }
|
|
|
+ return count;
|
|
|
+ }
|
|
|
+
|
|
|
+ function initEditorGeometry() {
|
|
|
+ if (!markdownEditor) return;
|
|
|
+ const styles = window.getComputedStyle(markdownEditor);
|
|
|
+ cachedPaddingLeft = parseFloat(styles.paddingLeft) || 10;
|
|
|
+ cachedPaddingRight = parseFloat(styles.paddingRight) || 10;
|
|
|
+
|
|
|
+ // Measure character width
|
|
|
+ const testSpan = document.createElement('span');
|
|
|
+ testSpan.style.fontFamily = styles.fontFamily;
|
|
|
+ testSpan.style.fontSize = styles.fontSize;
|
|
|
+ testSpan.style.visibility = 'hidden';
|
|
|
+ testSpan.style.position = 'absolute';
|
|
|
+ testSpan.style.whiteSpace = 'pre';
|
|
|
+ testSpan.textContent = 'a'.repeat(100);
|
|
|
+ document.body.appendChild(testSpan);
|
|
|
+ cachedCharWidth = testSpan.getBoundingClientRect().width / 100;
|
|
|
+ document.body.removeChild(testSpan);
|
|
|
+
|
|
|
+ // Calculate line height
|
|
|
+ const computed = parseFloat(styles.lineHeight);
|
|
|
+ if (!Number.isNaN(computed)) {
|
|
|
+ cachedLineHeight = computed;
|
|
|
+ } else {
|
|
|
+ const fontSize = parseFloat(styles.fontSize) || 14;
|
|
|
+ cachedLineHeight = fontSize * 1.5;
|
|
|
+ }
|
|
|
+
|
|
|
+ isGeometryInitialized = true;
|
|
|
+ lineCache.clear();
|
|
|
+ }
|
|
|
+
|
|
|
+ function refreshEditorWidth() {
|
|
|
+ if (!markdownEditor) return;
|
|
|
+ if (!isGeometryInitialized) {
|
|
|
+ initEditorGeometry();
|
|
|
+ }
|
|
|
+ cachedEditorWidth = markdownEditor.clientWidth;
|
|
|
+ const availableWidth = cachedEditorWidth - cachedPaddingLeft - cachedPaddingRight;
|
|
|
+ const nextMaxCharsPerLine = Math.max(1, Math.floor(availableWidth / cachedCharWidth));
|
|
|
+ if (nextMaxCharsPerLine !== cachedMaxCharsPerLine) {
|
|
|
+ cachedMaxCharsPerLine = nextMaxCharsPerLine;
|
|
|
+ lineCache.clear();
|
|
|
+ }
|
|
|
+
|
|
|
+ cachedScrollTop = markdownEditor.scrollTop;
|
|
|
+ cachedScrollLeft = markdownEditor.scrollLeft;
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateLineNumberItem(item, index, lineText, lineHeight) {
|
|
|
+ if (!item) return;
|
|
|
+ let wrapHeight = lineCache.get(lineText);
|
|
|
+ if (wrapHeight === undefined) {
|
|
|
+ const wrapCount = getWrappedLineCountMonospace(lineText, cachedMaxCharsPerLine);
|
|
|
+ wrapHeight = wrapCount * lineHeight;
|
|
|
+ if (lineCache.size >= LINE_CACHE_MAX_ENTRIES) {
|
|
|
+ lineCache.clear();
|
|
|
+ }
|
|
|
+ lineCache.set(lineText, wrapHeight);
|
|
|
+ }
|
|
|
+
|
|
|
+ const targetText = String(index + 1);
|
|
|
+ if (item.textContent !== targetText) {
|
|
|
+ item.textContent = targetText;
|
|
|
+ }
|
|
|
+ const targetHeight = `${wrapHeight}px`;
|
|
|
+ if (item.style.height !== targetHeight) {
|
|
|
+ item.style.height = targetHeight;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateActiveLineNumberHeight(text, lineCount, lineHeight) {
|
|
|
+ const caret = markdownEditor.selectionStart || 0;
|
|
|
+ const lineIndex = Math.min(lineCount - 1, countLinesBeforeIndex(text, caret));
|
|
|
+ const lineStart = text.lastIndexOf('\n', Math.max(0, caret - 1)) + 1;
|
|
|
+ const lineEndIndex = text.indexOf('\n', caret);
|
|
|
+ const lineEnd = lineEndIndex === -1 ? text.length : lineEndIndex;
|
|
|
+ updateLineNumberItem(lineNumbers.children[lineIndex], lineIndex, text.slice(lineStart, lineEnd), lineHeight);
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateLineNumbers(options) {
|
|
|
+ const opts = options || {};
|
|
|
+ if (!lineNumbers || !markdownEditor) return;
|
|
|
+ if (!isEditorVisible()) return;
|
|
|
+ const text = markdownEditor.value || '';
|
|
|
+ const lineCount = countLinesFast(text);
|
|
|
+
|
|
|
+ if (cachedEditorWidth === 0) {
|
|
|
+ refreshEditorWidth();
|
|
|
+ }
|
|
|
+
|
|
|
+ updateLineNumberGutter(lineCount);
|
|
|
+ const lineHeight = cachedLineHeight;
|
|
|
+
|
|
|
+ const existingItems = lineNumbers.children;
|
|
|
+ const existingCount = existingItems.length;
|
|
|
+ const isLargeEditorDocument = text.length >= LARGE_DOCUMENT_THRESHOLD;
|
|
|
+ const canUseActiveLineFastPath =
|
|
|
+ isLargeEditorDocument &&
|
|
|
+ !opts.force &&
|
|
|
+ lineCount === lastLineNumberLineCount &&
|
|
|
+ existingCount === lineCount;
|
|
|
+
|
|
|
+ if (canUseActiveLineFastPath) {
|
|
|
+ updateActiveLineNumberHeight(text, lineCount, lineHeight);
|
|
|
+ lastLineNumberDocumentLength = text.length;
|
|
|
+ syncLineNumberScroll();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const lines = text.split('\n');
|
|
|
+
|
|
|
+ // Adjust the number of DOM elements in-place to avoid complete tear-down
|
|
|
+ if (existingCount < lineCount) {
|
|
|
+ const fragment = document.createDocumentFragment();
|
|
|
+ for (let i = existingCount; i < lineCount; i += 1) {
|
|
|
+ const lineNumber = document.createElement('div');
|
|
|
+ lineNumber.className = 'line-number';
|
|
|
+ fragment.appendChild(lineNumber);
|
|
|
+ }
|
|
|
+ lineNumbers.appendChild(fragment);
|
|
|
+ } else if (existingCount > lineCount) {
|
|
|
+ while (lineNumbers.children.length > lineCount) {
|
|
|
+ lineNumbers.removeChild(lineNumbers.lastChild);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Update only the heights and numbers that changed, using monospace simulator to avoid forced reflows
|
|
|
+ for (let i = 0; i < lineCount; i += 1) {
|
|
|
+ updateLineNumberItem(existingItems[i], i, lines[i], lineHeight);
|
|
|
+ }
|
|
|
+
|
|
|
+ lastLineNumberLineCount = lineCount;
|
|
|
+ lastLineNumberDocumentLength = text.length;
|
|
|
+ syncLineNumberScroll();
|
|
|
+ }
|
|
|
+
|
|
|
+ function scheduleLineNumberUpdate(options) {
|
|
|
+ const opts = options || {};
|
|
|
+ if (!lineNumbers) return;
|
|
|
+ if (!isEditorVisible()) return;
|
|
|
+
|
|
|
+ if (opts.force) {
|
|
|
+ if (lineNumberUpdateTimeout) {
|
|
|
+ clearTimeout(lineNumberUpdateTimeout);
|
|
|
+ lineNumberUpdateTimeout = null;
|
|
|
+ }
|
|
|
+ if (lineNumberUpdateFrame) {
|
|
|
+ cancelAnimationFrame(lineNumberUpdateFrame);
|
|
|
+ lineNumberUpdateFrame = null;
|
|
|
+ }
|
|
|
+ } else if (lineNumberUpdateFrame || lineNumberUpdateTimeout) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const text = markdownEditor ? markdownEditor.value || '' : '';
|
|
|
+ const delay = opts.delay !== undefined
|
|
|
+ ? opts.delay
|
|
|
+ : (opts.inputType === 'insertFromPaste' || text.length >= LARGE_DOCUMENT_THRESHOLD ? getEditorWorkDelay(text) : 0);
|
|
|
+
|
|
|
+ const runUpdate = function() {
|
|
|
+ lineNumberUpdateFrame = window.requestAnimationFrame(function() {
|
|
|
+ lineNumberUpdateFrame = null;
|
|
|
+ updateLineNumbers(opts);
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ if (delay > 0) {
|
|
|
+ lineNumberUpdateTimeout = setTimeout(function() {
|
|
|
+ lineNumberUpdateTimeout = null;
|
|
|
+ runUpdate();
|
|
|
+ }, delay);
|
|
|
+ } else {
|
|
|
+ runUpdate();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function syncLineNumberScroll() {
|
|
|
+ if (!lineNumbers) return;
|
|
|
+ lineNumbers.scrollTop = cachedScrollTop;
|
|
|
+ }
|
|
|
+
|
|
|
+ function syncEditorScrollOverlays() {
|
|
|
+ cachedScrollTop = markdownEditor.scrollTop;
|
|
|
+ cachedScrollLeft = markdownEditor.scrollLeft;
|
|
|
+ syncHighlightScroll();
|
|
|
+ syncLineNumberScroll();
|
|
|
+ }
|
|
|
+
|
|
|
+ function clampEditorScrollTop(scrollTop) {
|
|
|
+ const maxScrollTop = Math.max(0, markdownEditor.scrollHeight - markdownEditor.clientHeight);
|
|
|
+ return Math.min(maxScrollTop, Math.max(0, scrollTop));
|
|
|
+ }
|
|
|
+
|
|
|
+ function estimateEditorOffsetForIndex(index) {
|
|
|
+ if (!isGeometryInitialized || cachedEditorWidth !== markdownEditor.clientWidth) {
|
|
|
+ refreshEditorWidth();
|
|
|
+ }
|
|
|
+
|
|
|
+ const styles = window.getComputedStyle(markdownEditor);
|
|
|
+ const paddingTop = parseFloat(styles.paddingTop) || 10;
|
|
|
+ const textBefore = (markdownEditor.value || '').slice(0, Math.max(0, index));
|
|
|
+ const lines = textBefore.split('\n');
|
|
|
+ let visualRows = 0;
|
|
|
+
|
|
|
+ for (let i = 0; i < lines.length - 1; i += 1) {
|
|
|
+ visualRows += getWrappedLineCountMonospace(lines[i], cachedMaxCharsPerLine);
|
|
|
+ }
|
|
|
+
|
|
|
+ const currentLinePrefix = lines[lines.length - 1] || '';
|
|
|
+ visualRows += Math.max(0, getWrappedLineCountMonospace(currentLinePrefix, cachedMaxCharsPerLine) - 1);
|
|
|
+ return paddingTop + (visualRows * cachedLineHeight);
|
|
|
+ }
|
|
|
+
|
|
|
+ function getActiveFindHighlight() {
|
|
|
+ if (!editorHighlightLayer) return null;
|
|
|
+ return editorHighlightLayer.querySelector('.find-highlight.active');
|
|
|
+ }
|
|
|
+
|
|
|
+ function scrollActiveMatchIntoView(match) {
|
|
|
+ let matchTop = null;
|
|
|
+ let matchHeight = cachedLineHeight;
|
|
|
+ let activeHighlight = getActiveFindHighlight();
|
|
|
+
|
|
|
+ if (!activeHighlight) {
|
|
|
+ updateFindHighlights();
|
|
|
+ activeHighlight = getActiveFindHighlight();
|
|
|
+ }
|
|
|
+
|
|
|
+ if (activeHighlight) {
|
|
|
+ matchTop = activeHighlight.offsetTop;
|
|
|
+ matchHeight = activeHighlight.offsetHeight || matchHeight;
|
|
|
+ } else {
|
|
|
+ matchTop = estimateEditorOffsetForIndex(match.start);
|
|
|
+ }
|
|
|
+
|
|
|
+ const targetScrollTop = clampEditorScrollTop(
|
|
|
+ matchTop - (markdownEditor.clientHeight / 2) + (matchHeight / 2)
|
|
|
+ );
|
|
|
+
|
|
|
+ markdownEditor.scrollTop = targetScrollTop;
|
|
|
+ syncEditorScrollOverlays();
|
|
|
+ }
|
|
|
+
|
|
|
+ // Class encapsulating Search & Replace Engine
|
|
|
+ class FindReplaceEngine {
|
|
|
+ constructor(editor) {
|
|
|
+ this.editor = editor;
|
|
|
+ this.history = { find: [], replace: [] };
|
|
|
+ this.activeMatches = [];
|
|
|
+ this.currentMatchIndex = -1;
|
|
|
+ this._cachedScopeText = null;
|
|
|
+ this._cachedScopeMap = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ buildASTScopeMap(text) {
|
|
|
+ // PERF-026: Cache scope map to avoid re-lexing on every search keystroke
|
|
|
+ if (text === this._cachedScopeText && this._cachedScopeMap) {
|
|
|
+ return this._cachedScopeMap;
|
|
|
+ }
|
|
|
+ if (typeof marked === 'undefined' || !marked.lexer) return [];
|
|
|
+ try {
|
|
|
+ const tokens = marked.lexer(text);
|
|
|
+ const scopeMap = [];
|
|
|
+ let currentIndex = 0;
|
|
|
+
|
|
|
+ const traverse = (tokenList) => {
|
|
|
+ for (const token of tokenList) {
|
|
|
+ const start = text.indexOf(token.raw, currentIndex);
|
|
|
+ if (start === -1) continue;
|
|
|
+ const end = start + token.raw.length;
|
|
|
+ currentIndex = end;
|
|
|
+
|
|
|
+ let scope = 'plain';
|
|
|
+ if (token.type === 'heading') scope = 'heading';
|
|
|
+ else if (token.type === 'code') {
|
|
|
+ if (token.lang === 'mermaid') scope = 'mermaid';
|
|
|
+ else scope = 'code';
|
|
|
+ } else if (token.type === 'paragraph' && token.raw.startsWith('$$') && token.raw.endsWith('$$')) {
|
|
|
+ scope = 'latex';
|
|
|
+ }
|
|
|
+
|
|
|
+ scopeMap.push({ start, end, scope, type: token.type });
|
|
|
+ if (token.tokens) traverse(token.tokens);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ traverse(tokens);
|
|
|
+ this._cachedScopeText = text;
|
|
|
+ this._cachedScopeMap = scopeMap;
|
|
|
+ return scopeMap;
|
|
|
+ } catch (e) {
|
|
|
+ console.warn("AST scope parsing failed:", e);
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ compileRegExp(query, isRegex, isCaseSensitive, isWholeWord) {
|
|
|
+ if (!query) return null;
|
|
|
+ let pattern = isRegex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
+ if (isWholeWord) {
|
|
|
+ pattern = `\\b${pattern}\\b`;
|
|
|
+ }
|
|
|
+ const flags = isCaseSensitive ? 'gd' : 'gid';
|
|
|
+ return new RegExp(pattern, flags);
|
|
|
+ }
|
|
|
+
|
|
|
+ executeSearch(options) {
|
|
|
+ const { query, isRegex, isCaseSensitive, isWholeWord, scopeFilter, findInSelection } = options;
|
|
|
+ const fullText = this.editor.value || '';
|
|
|
+
|
|
|
+ let searchRange = { start: 0, end: fullText.length };
|
|
|
+ if (findInSelection) {
|
|
|
+ searchRange.start = this.editor.selectionStart;
|
|
|
+ searchRange.end = this.editor.selectionEnd;
|
|
|
+ }
|
|
|
+
|
|
|
+ let regex;
|
|
|
+ try {
|
|
|
+ regex = this.compileRegExp(query, isRegex, isCaseSensitive, isWholeWord);
|
|
|
+ } catch (err) {
|
|
|
+ throw new Error(err.message);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!regex) {
|
|
|
+ this.activeMatches = [];
|
|
|
+ this.currentMatchIndex = -1;
|
|
|
+ return this.activeMatches;
|
|
|
+ }
|
|
|
+
|
|
|
+ const rawMatches = [];
|
|
|
+ let match;
|
|
|
+
|
|
|
+ while ((match = regex.exec(fullText)) !== null) {
|
|
|
+ if (match.index >= searchRange.end) break;
|
|
|
+ if (match.index >= searchRange.start) {
|
|
|
+ rawMatches.push({
|
|
|
+ start: match.index,
|
|
|
+ end: match.index + match[0].length,
|
|
|
+ value: match[0],
|
|
|
+ groups: match.groups || null,
|
|
|
+ matchArray: match
|
|
|
+ });
|
|
|
+ }
|
|
|
+ if (regex.lastIndex === match.index) {
|
|
|
+ regex.lastIndex++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (scopeFilter && scopeFilter !== 'entire') {
|
|
|
+ const scopeMap = this.buildASTScopeMap(fullText);
|
|
|
+ this.activeMatches = rawMatches.filter(m => {
|
|
|
+ const matchingScope = scopeMap.find(s => m.start >= s.start && m.end <= s.end);
|
|
|
+ return matchingScope && matchingScope.scope === scopeFilter;
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ this.activeMatches = rawMatches;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.currentMatchIndex = this.activeMatches.length > 0 ? 0 : -1;
|
|
|
+ this.addHistory('find', query);
|
|
|
+ return this.activeMatches;
|
|
|
+ }
|
|
|
+
|
|
|
+ preserveCase(source, replacement) {
|
|
|
+ if (source === source.toUpperCase()) {
|
|
|
+ return replacement.toUpperCase();
|
|
|
+ }
|
|
|
+ if (source === source.toLowerCase()) {
|
|
|
+ return replacement.toLowerCase();
|
|
|
+ }
|
|
|
+ if (source[0] === source[0].toUpperCase() && source.slice(1) === source.slice(1).toLowerCase()) {
|
|
|
+ return replacement[0].toUpperCase() + replacement.slice(1).toLowerCase();
|
|
|
+ }
|
|
|
+ return replacement;
|
|
|
+ }
|
|
|
+
|
|
|
+ applyCaptureGroups(match, replacementTemplate) {
|
|
|
+ if (!match.matchArray) return replacementTemplate;
|
|
|
+ let result = replacementTemplate;
|
|
|
+
|
|
|
+ result = result.replace(/\$(\d+)/g, (m, number) => {
|
|
|
+ const idx = parseInt(number, 10);
|
|
|
+ return match.matchArray[idx] !== undefined ? match.matchArray[idx] : m;
|
|
|
+ });
|
|
|
+
|
|
|
+ if (match.groups) {
|
|
|
+ result = result.replace(/\$<([^>]+)>/g, (m, name) => {
|
|
|
+ return match.groups[name] !== undefined ? match.groups[name] : m;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ executeReplace(match, replacementTemplate, options) {
|
|
|
+ const { preserveCase, isRegex } = options;
|
|
|
+ const text = this.editor.value;
|
|
|
+
|
|
|
+ let finalReplacement = replacementTemplate;
|
|
|
+ if (isRegex) {
|
|
|
+ finalReplacement = this.applyCaptureGroups(match, finalReplacement);
|
|
|
+ }
|
|
|
+ if (preserveCase) {
|
|
|
+ finalReplacement = this.preserveCase(match.value, finalReplacement);
|
|
|
+ }
|
|
|
+
|
|
|
+ const before = text.slice(0, match.start);
|
|
|
+ const after = text.slice(match.end);
|
|
|
+ this.editor.value = before + finalReplacement + after;
|
|
|
+ this.editor.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
+
|
|
|
+ this.addHistory('replace', replacementTemplate);
|
|
|
+ return finalReplacement.length - match.value.length;
|
|
|
+ }
|
|
|
+
|
|
|
+ addHistory(type, query) {
|
|
|
+ if (!query) return;
|
|
|
+ const list = this.history[type];
|
|
|
+ const index = list.indexOf(query);
|
|
|
+ if (index !== -1) {
|
|
|
+ list.splice(index, 1);
|
|
|
+ }
|
|
|
+ list.unshift(query);
|
|
|
+ if (list.length > 10) {
|
|
|
+ list.pop();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Verification helper for rich text blocks
|
|
|
+ function validateBlockSyntax(originalBlockText, newBlockText, scope) {
|
|
|
+ if (scope === 'latex') {
|
|
|
+ const origDisplay = (originalBlockText.match(/\$\$/g) || []).length;
|
|
|
+ const newDisplay = (newBlockText.match(/\$\$/g) || []).length;
|
|
|
+ const origInline = (originalBlockText.match(/[^\$]\$[^\$]/g) || []).length;
|
|
|
+ const newInline = (newBlockText.match(/[^\$]\$[^\$]/g) || []).length;
|
|
|
+
|
|
|
+ if (origDisplay !== newDisplay || origInline !== newInline) {
|
|
|
+ return { valid: false, reason: "LaTeX math block delimiters are unbalanced." };
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (scope === 'mermaid') {
|
|
|
+ const diagramTypePattern = /^(graph|flowchart|sequenceDiagram|classDiagram|stateDiagram-v2|erDiagram|gantt|pie|quadrantChart|c4Context|mindmap|timeline|zenuml)/i;
|
|
|
+ if (!diagramTypePattern.test(newBlockText.trim())) {
|
|
|
+ return { valid: false, reason: "Missing diagram type definition (e.g. flowchart TD)." };
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return { valid: true };
|
|
|
+ }
|
|
|
+
|
|
|
+ // Global Engine Instance
|
|
|
+ let frEngine = null;
|
|
|
+ let isFrDocked = false;
|
|
|
+ let dragOffset = { x: 0, y: 0 };
|
|
|
+ let isPanelDragging = false;
|
|
|
+ let lastFloatingLeft = null;
|
|
|
+ let lastFloatingTop = null;
|
|
|
+ let lastFloatingRight = null;
|
|
|
+
|
|
|
+ function initFindReplacePanelDrag() {
|
|
|
+ const handle = document.getElementById('find-replace-drag-handle');
|
|
|
+ const panel = document.getElementById('find-replace-modal');
|
|
|
+ if (!handle || !panel) return;
|
|
|
+
|
|
|
+ const startDrag = (clientX, clientY) => {
|
|
|
+ isPanelDragging = true;
|
|
|
+ dragOffset.x = clientX - panel.offsetLeft;
|
|
|
+ dragOffset.y = clientY - panel.offsetTop;
|
|
|
+ document.body.classList.add('resizing');
|
|
|
+ };
|
|
|
+
|
|
|
+ const moveDrag = (clientX, clientY) => {
|
|
|
+ const x = clientX - dragOffset.x;
|
|
|
+ const y = clientY - dragOffset.y;
|
|
|
+
|
|
|
+ // Keep panel inside viewport boundaries
|
|
|
+ const maxX = window.innerWidth - panel.offsetWidth;
|
|
|
+ const maxY = window.innerHeight - panel.offsetHeight;
|
|
|
+ const newLeft = `${Math.max(0, Math.min(maxX, x))}px`;
|
|
|
+ const newTop = `${Math.max(0, Math.min(maxY, y))}px`;
|
|
|
+ panel.style.left = newLeft;
|
|
|
+ panel.style.top = newTop;
|
|
|
+ panel.style.right = 'auto';
|
|
|
+
|
|
|
+ lastFloatingLeft = newLeft;
|
|
|
+ lastFloatingTop = newTop;
|
|
|
+ lastFloatingRight = 'auto';
|
|
|
+ };
|
|
|
+
|
|
|
+ const stopDrag = () => {
|
|
|
+ if (isPanelDragging) {
|
|
|
+ isPanelDragging = false;
|
|
|
+ document.body.classList.remove('resizing');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // Mouse events
|
|
|
+ handle.addEventListener('mousedown', (e) => {
|
|
|
+ if (isFrDocked) return;
|
|
|
+ if (window.innerWidth < 768) return; // Do NOT allow dragging on mobile layouts
|
|
|
+ if (e.target.closest('.find-replace-header-actions')) return;
|
|
|
+ startDrag(e.clientX, e.clientY);
|
|
|
+ });
|
|
|
+
|
|
|
+ document.addEventListener('mousemove', (e) => {
|
|
|
+ if (!isPanelDragging || isFrDocked) return;
|
|
|
+ moveDrag(e.clientX, e.clientY);
|
|
|
+ });
|
|
|
+
|
|
|
+ document.addEventListener('mouseup', stopDrag);
|
|
|
+
|
|
|
+ // Touch events for tablets
|
|
|
+ handle.addEventListener('touchstart', (e) => {
|
|
|
+ if (isFrDocked) return;
|
|
|
+ if (window.innerWidth < 768) return; // Do NOT allow dragging on mobile layouts
|
|
|
+ if (e.target.closest('.find-replace-header-actions')) return;
|
|
|
+ if (e.touches && e.touches[0]) {
|
|
|
+ startDrag(e.touches[0].clientX, e.touches[0].clientY);
|
|
|
+ }
|
|
|
+ }, { passive: true });
|
|
|
+
|
|
|
+ document.addEventListener('touchmove', (e) => {
|
|
|
+ if (!isPanelDragging || isFrDocked) return;
|
|
|
+ if (e.touches && e.touches[0]) {
|
|
|
+ moveDrag(e.touches[0].clientX, e.touches[0].clientY);
|
|
|
+ }
|
|
|
+ }, { passive: true });
|
|
|
+
|
|
|
+ document.addEventListener('touchend', stopDrag);
|
|
|
+ }
|
|
|
+
|
|
|
+ let frPreferredDocked = false;
|
|
|
+
|
|
|
+ function toggleFrDockMode(forceFloat = false) {
|
|
|
+ // If forceFloat is an Event (e.g. from click listener directly), treat as false
|
|
|
+ if (forceFloat instanceof Event || (forceFloat && typeof forceFloat === 'object')) {
|
|
|
+ forceFloat = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ const panel = document.getElementById('find-replace-modal');
|
|
|
+ const dockBtn = document.getElementById('find-replace-dock');
|
|
|
+ const contentCont = document.querySelector('.content-container');
|
|
|
+ if (!panel || !dockBtn || !contentCont) return;
|
|
|
+
|
|
|
+ // Save active element focus and selection before DOM movement
|
|
|
+ const activeEl = document.activeElement;
|
|
|
+ const activeId = activeEl ? activeEl.id : null;
|
|
|
+ const isInput = activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'SELECT' || activeEl.tagName === 'TEXTAREA');
|
|
|
+ let selStart = 0;
|
|
|
+ let selEnd = 0;
|
|
|
+ if (isInput && typeof activeEl.selectionStart === 'number') {
|
|
|
+ selStart = activeEl.selectionStart;
|
|
|
+ selEnd = activeEl.selectionEnd;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (window.innerWidth < 1080 || forceFloat) {
|
|
|
+ isFrDocked = false;
|
|
|
+ panel.classList.remove('docked');
|
|
|
+ if (panel.parentElement !== document.body) {
|
|
|
+ document.body.appendChild(panel);
|
|
|
+ }
|
|
|
+ contentCont.classList.remove('fr-docked');
|
|
|
+ contentCont.style.setProperty('--dock-width', '0px');
|
|
|
+
|
|
|
+ panel.style.left = lastFloatingLeft !== null ? lastFloatingLeft : '';
|
|
|
+ panel.style.top = lastFloatingTop !== null ? lastFloatingTop : '';
|
|
|
+ panel.style.right = lastFloatingRight !== null ? lastFloatingRight : '';
|
|
|
+
|
|
|
+ dockBtn.innerHTML = '<i class="bi bi-layout-sidebar-reverse"></i>';
|
|
|
+ dockBtn.title = "Toggle Dock Mode";
|
|
|
+
|
|
|
+ panel.style.display = 'flex';
|
|
|
+ applyPaneWidths();
|
|
|
+
|
|
|
+ // Restore focus and selection
|
|
|
+ if (activeId) {
|
|
|
+ const el = document.getElementById(activeId);
|
|
|
+ if (el) {
|
|
|
+ el.focus();
|
|
|
+ if (isInput && typeof el.selectionStart === 'number') {
|
|
|
+ el.setSelectionRange(selStart, selEnd);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ isFrDocked = !isFrDocked;
|
|
|
+
|
|
|
+ // Save preference to localStorage
|
|
|
+ frPreferredDocked = isFrDocked;
|
|
|
+ localStorage.setItem('find-replace-docked', frPreferredDocked ? 'true' : 'false');
|
|
|
+
|
|
|
+ if (isFrDocked) {
|
|
|
+ panel.classList.add('docked');
|
|
|
+ panel.style.left = 'auto';
|
|
|
+ panel.style.top = 'auto';
|
|
|
+ panel.style.right = 'auto';
|
|
|
+
|
|
|
+ // Append panel to dock container (.content-container)
|
|
|
+ contentCont.appendChild(panel);
|
|
|
+ contentCont.classList.add('fr-docked');
|
|
|
+ contentCont.style.setProperty('--dock-width', '340px');
|
|
|
+
|
|
|
+ dockBtn.innerHTML = '<i class="bi bi-window"></i>';
|
|
|
+ dockBtn.title = "Toggle Floating Mode";
|
|
|
+ } else {
|
|
|
+ panel.classList.remove('docked');
|
|
|
+
|
|
|
+ // Reset position and float on body
|
|
|
+ document.body.appendChild(panel);
|
|
|
+ contentCont.classList.remove('fr-docked');
|
|
|
+ contentCont.style.setProperty('--dock-width', '0px');
|
|
|
+
|
|
|
+ panel.style.left = lastFloatingLeft !== null ? lastFloatingLeft : '';
|
|
|
+ panel.style.top = lastFloatingTop !== null ? lastFloatingTop : '';
|
|
|
+ panel.style.right = lastFloatingRight !== null ? lastFloatingRight : '';
|
|
|
+
|
|
|
+ dockBtn.innerHTML = '<i class="bi bi-layout-sidebar-reverse"></i>';
|
|
|
+ dockBtn.title = "Toggle Dock Mode";
|
|
|
+ }
|
|
|
+
|
|
|
+ // Ensure display is flex and recalculate split panes
|
|
|
+ panel.style.display = 'flex';
|
|
|
+ applyPaneWidths();
|
|
|
+
|
|
|
+ // Restore focus and selection after layout change
|
|
|
+ if (activeId) {
|
|
|
+ const el = document.getElementById(activeId);
|
|
|
+ if (el) {
|
|
|
+ el.focus();
|
|
|
+ if (isInput && typeof el.selectionStart === 'number') {
|
|
|
+ el.setSelectionRange(selStart, selEnd);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateFindControls() {
|
|
|
+ const total = findMatches.length;
|
|
|
+ const current = total && activeFindIndex >= 0 ? activeFindIndex + 1 : 0;
|
|
|
+
|
|
|
+ const countSpan = document.getElementById('find-replace-count');
|
|
|
+ if (countSpan) {
|
|
|
+ countSpan.textContent = `${current} of ${total} matches`;
|
|
|
+ }
|
|
|
+
|
|
|
+ const prevBtn = document.getElementById('find-prev');
|
|
|
+ const nextBtn = document.getElementById('find-next');
|
|
|
+ const replaceCurrentBtn = document.getElementById('find-replace-current');
|
|
|
+ const replaceAllBtn = document.getElementById('find-replace-all');
|
|
|
+
|
|
|
+ const hasMatches = total > 0;
|
|
|
+ const hasQuery = !!(findReplaceInput && findReplaceInput.value);
|
|
|
+
|
|
|
+ if (prevBtn) prevBtn.disabled = !hasMatches;
|
|
|
+ if (nextBtn) nextBtn.disabled = !hasMatches;
|
|
|
+ if (replaceCurrentBtn) replaceCurrentBtn.disabled = !hasMatches;
|
|
|
+ if (replaceAllBtn) replaceAllBtn.disabled = !hasQuery || !hasMatches;
|
|
|
+ }
|
|
|
+
|
|
|
+ function refreshFindMatches(options) {
|
|
|
+ clearTimeout(findRefreshTimeout);
|
|
|
+ findRefreshTimeout = null;
|
|
|
+ const opts = options || {};
|
|
|
+ const query = findReplaceInput ? findReplaceInput.value : '';
|
|
|
+ const errorBox = document.getElementById('find-replace-error');
|
|
|
+ const errorMsg = document.getElementById('regex-error-msg');
|
|
|
+
|
|
|
+ if (errorBox) errorBox.style.display = 'none';
|
|
|
+
|
|
|
+ if (!isFindModalOpen || !query) {
|
|
|
+ findMatches = [];
|
|
|
+ activeFindIndex = -1;
|
|
|
+ updateFindControls();
|
|
|
+ updateFindHighlights();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const isRegex = document.getElementById('find-regex').classList.contains('active');
|
|
|
+ const isCaseSensitive = document.getElementById('find-case').classList.contains('active');
|
|
|
+ const isWholeWord = document.getElementById('find-word').classList.contains('active');
|
|
|
+ const scopeFilter = document.getElementById('find-replace-scope').value;
|
|
|
+ const findInSelection = document.getElementById('find-sel').classList.contains('active');
|
|
|
+
|
|
|
+ try {
|
|
|
+ findMatches = frEngine.executeSearch({
|
|
|
+ query,
|
|
|
+ isRegex,
|
|
|
+ isCaseSensitive,
|
|
|
+ isWholeWord,
|
|
|
+ scopeFilter,
|
|
|
+ findInSelection
|
|
|
+ });
|
|
|
+ } catch (err) {
|
|
|
+ findMatches = [];
|
|
|
+ activeFindIndex = -1;
|
|
|
+ if (errorBox && errorMsg) {
|
|
|
+ errorMsg.textContent = err.message;
|
|
|
+ errorBox.style.display = 'block';
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const shouldResetActiveIndex = opts.resetIndex || query !== lastFindQuery;
|
|
|
+ if (shouldResetActiveIndex) {
|
|
|
+ activeFindIndex = findMatches.length ? 0 : -1;
|
|
|
+ } else if (activeFindIndex >= findMatches.length) {
|
|
|
+ activeFindIndex = findMatches.length - 1;
|
|
|
+ }
|
|
|
+ lastFindQuery = query;
|
|
|
+ updateFindControls();
|
|
|
+ updateFindHighlights();
|
|
|
+ if (shouldResetActiveIndex && findMatches.length && activeFindIndex >= 0) {
|
|
|
+ scrollActiveMatchIntoView(findMatches[activeFindIndex]);
|
|
|
+ }
|
|
|
+ updateHistoryDropdowns();
|
|
|
+ }
|
|
|
+
|
|
|
+ function scheduleFindRefresh(options) {
|
|
|
+ clearTimeout(findRefreshTimeout);
|
|
|
+ const text = markdownEditor ? markdownEditor.value || '' : '';
|
|
|
+ const delay = text.length >= LARGE_DOCUMENT_THRESHOLD ? LARGE_FIND_REFRESH_DELAY : FIND_REFRESH_DELAY;
|
|
|
+ findRefreshTimeout = setTimeout(function() {
|
|
|
+ findRefreshTimeout = null;
|
|
|
+ refreshFindMatches(options);
|
|
|
+ }, delay);
|
|
|
+ }
|
|
|
+
|
|
|
+ function selectActiveMatch() {
|
|
|
+ if (!findMatches.length || activeFindIndex < 0) return;
|
|
|
+ const match = findMatches[activeFindIndex];
|
|
|
+ markdownEditor.focus();
|
|
|
+ markdownEditor.setSelectionRange(match.start, match.end);
|
|
|
+
|
|
|
+ try {
|
|
|
+ scrollActiveMatchIntoView(match);
|
|
|
+ } catch (e) {
|
|
|
+ console.warn("Viewport centering scroll failed:", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function cycleFindMatch(direction) {
|
|
|
+ const totalMatches = findMatches.length;
|
|
|
+ if (!totalMatches) return;
|
|
|
+ activeFindIndex = (activeFindIndex + direction + totalMatches) % totalMatches;
|
|
|
+ updateFindControls();
|
|
|
+ updateFindHighlights();
|
|
|
+ selectActiveMatch();
|
|
|
+ }
|
|
|
+
|
|
|
+ function openFindReplaceModal() {
|
|
|
+ if (!findReplaceModal || !findReplaceInput) return;
|
|
|
+
|
|
|
+ if (!frEngine) {
|
|
|
+ frEngine = new FindReplaceEngine(markdownEditor);
|
|
|
+ }
|
|
|
+
|
|
|
+ isFindModalOpen = true;
|
|
|
+ const selected = markdownEditor.value.slice(markdownEditor.selectionStart, markdownEditor.selectionEnd);
|
|
|
+ if (selected && selected.length < 100) {
|
|
|
+ findReplaceInput.value = selected;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Restore docked/floating mode preference
|
|
|
+ let wasDockedPref = localStorage.getItem('find-replace-docked') === 'true';
|
|
|
+
|
|
|
+ // Force floating-only mode on mobile/tablet viewports
|
|
|
+ if (window.innerWidth < 1080) {
|
|
|
+ wasDockedPref = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (wasDockedPref) {
|
|
|
+ isFrDocked = false; // Set false so toggleFrDockMode() turns it to true
|
|
|
+ toggleFrDockMode();
|
|
|
+ } else {
|
|
|
+ isFrDocked = true; // Set true so toggleFrDockMode() turns it to false
|
|
|
+ toggleFrDockMode();
|
|
|
+ }
|
|
|
+
|
|
|
+ findReplaceModal.style.display = 'flex';
|
|
|
+
|
|
|
+ requestAnimationFrame(function() {
|
|
|
+ findReplaceInput.focus();
|
|
|
+ findReplaceInput.select();
|
|
|
+ });
|
|
|
+
|
|
|
+ refreshFindMatches({ resetIndex: true });
|
|
|
+ if (findMatches.length) {
|
|
|
+ selectActiveMatch();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeFindReplaceModal() {
|
|
|
+ isFindModalOpen = false;
|
|
|
+ const panel = document.getElementById('find-replace-modal');
|
|
|
+ const contentCont = document.querySelector('.content-container');
|
|
|
+ if (panel) {
|
|
|
+ panel.style.display = 'none';
|
|
|
+ if (isFrDocked) {
|
|
|
+ // Reset split layout styles when closed
|
|
|
+ if (contentCont) {
|
|
|
+ contentCont.classList.remove('fr-docked');
|
|
|
+ contentCont.style.setProperty('--dock-width', '0px');
|
|
|
+ applyPaneWidths();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ findMatches = [];
|
|
|
+ activeFindIndex = -1;
|
|
|
+ updateFindControls();
|
|
|
+ updateFindHighlights();
|
|
|
+ }
|
|
|
+
|
|
|
+ function replaceCurrentMatch() {
|
|
|
+ if (!findMatches.length || activeFindIndex < 0) return;
|
|
|
+ const replacement = findReplaceWith ? findReplaceWith.value : '';
|
|
|
+ const match = findMatches[activeFindIndex];
|
|
|
+
|
|
|
+ const preserveCase = document.getElementById('replace-preserve-case').classList.contains('active');
|
|
|
+ const isRegex = document.getElementById('find-regex').classList.contains('active');
|
|
|
+
|
|
|
+ // Syntax validation
|
|
|
+ const scopeFilter = document.getElementById('find-replace-scope').value;
|
|
|
+ if (scopeFilter === 'latex' || scopeFilter === 'mermaid') {
|
|
|
+ const check = validateBlockSyntax(match.value, replacement, scopeFilter);
|
|
|
+ if (!check.valid) {
|
|
|
+ alert(`Blocked replacement: ${check.reason}`);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ frEngine.executeReplace(match, replacement, { preserveCase, isRegex });
|
|
|
+
|
|
|
+ refreshFindMatches();
|
|
|
+ if (findMatches.length) {
|
|
|
+ activeFindIndex = Math.min(activeFindIndex, findMatches.length - 1);
|
|
|
+ selectActiveMatch();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function replaceAllMatches() {
|
|
|
+ if (!findMatches.length) return;
|
|
|
+
|
|
|
+ const query = findReplaceInput ? findReplaceInput.value : '';
|
|
|
+ const replacement = findReplaceWith ? findReplaceWith.value : '';
|
|
|
+ const showDiff = document.getElementById('find-replace-diff-toggle').checked;
|
|
|
+
|
|
|
+ if (showDiff) {
|
|
|
+ renderDiffPreview();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ executeBulkReplace();
|
|
|
+ }
|
|
|
+
|
|
|
+ function executeBulkReplace() {
|
|
|
+ const replacement = findReplaceWith ? findReplaceWith.value : '';
|
|
|
+ const preserveCase = document.getElementById('replace-preserve-case').classList.contains('active');
|
|
|
+ const isRegex = document.getElementById('find-regex').classList.contains('active');
|
|
|
+
|
|
|
+ // Reverse sorting to replace from bottom up
|
|
|
+ const matchesCopy = [...findMatches];
|
|
|
+ matchesCopy.sort((a, b) => b.start - a.start);
|
|
|
+
|
|
|
+ for (const match of matchesCopy) {
|
|
|
+ frEngine.executeReplace(match, replacement, { preserveCase, isRegex });
|
|
|
+ }
|
|
|
+
|
|
|
+ refreshFindMatches({ resetIndex: true });
|
|
|
+ if (findMatches.length) {
|
|
|
+ selectActiveMatch();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderDiffPreview() {
|
|
|
+ const container = document.getElementById('find-replace-diff-container');
|
|
|
+ const modal = document.getElementById('find-replace-diff-modal');
|
|
|
+ if (!container || !modal) return;
|
|
|
+
|
|
|
+ const replacement = findReplaceWith ? findReplaceWith.value : '';
|
|
|
+ const preserveCase = document.getElementById('replace-preserve-case').classList.contains('active');
|
|
|
+ const isRegex = document.getElementById('find-regex').classList.contains('active');
|
|
|
+
|
|
|
+ const lines = markdownEditor.value.split('\n');
|
|
|
+ const matchesCopy = [...findMatches];
|
|
|
+ matchesCopy.sort((a, b) => b.start - a.start);
|
|
|
+
|
|
|
+ // Draft the replaced value in memory
|
|
|
+ let draftValue = markdownEditor.value;
|
|
|
+ for (const match of matchesCopy) {
|
|
|
+ let finalRepl = replacement;
|
|
|
+ if (isRegex) {
|
|
|
+ finalRepl = frEngine.applyCaptureGroups(match, finalRepl);
|
|
|
+ }
|
|
|
+ if (preserveCase) {
|
|
|
+ finalRepl = frEngine.preserveCase(match.value, finalRepl);
|
|
|
+ }
|
|
|
+ draftValue = draftValue.slice(0, match.start) + finalRepl + draftValue.slice(match.end);
|
|
|
+ }
|
|
|
+
|
|
|
+ const draftLines = draftValue.split('\n');
|
|
|
+
|
|
|
+ // PERF-007: Clear elements using textContent
|
|
|
+ container.textContent = '';
|
|
|
+ const fragment = document.createDocumentFragment();
|
|
|
+
|
|
|
+ const maxLines = Math.max(lines.length, draftLines.length);
|
|
|
+ for (let i = 0; i < maxLines; i++) {
|
|
|
+ const origLine = lines[i] !== undefined ? lines[i] : null;
|
|
|
+ const newLine = draftLines[i] !== undefined ? draftLines[i] : null;
|
|
|
+
|
|
|
+ if (origLine !== newLine) {
|
|
|
+ if (origLine !== null) {
|
|
|
+ const delLine = document.createElement('div');
|
|
|
+ delLine.className = 'diff-line deletion';
|
|
|
+ delLine.innerHTML = `<span class="diff-line-num">${i + 1}</span><span class="diff-line-content">- ${escapeHtml(origLine)}</span>`;
|
|
|
+ fragment.appendChild(delLine);
|
|
|
+ }
|
|
|
+ if (newLine !== null) {
|
|
|
+ const addLine = document.createElement('div');
|
|
|
+ addLine.className = 'diff-line addition';
|
|
|
+ addLine.innerHTML = `<span class="diff-line-num">${i + 1}</span><span class="diff-line-content">+ ${escapeHtml(newLine)}</span>`;
|
|
|
+ fragment.appendChild(addLine);
|
|
|
+ }
|
|
|
+ } else if (origLine !== null) {
|
|
|
+ // Show context for matching lines to prevent giant blank diff spaces
|
|
|
+ // Show context only if it surrounds a modified line
|
|
|
+ const hasDiffNearby = Array.from({length: 5}, (_, idx) => i - 2 + idx)
|
|
|
+ .some(lineIdx => lines[lineIdx] !== undefined && draftLines[lineIdx] !== undefined && lines[lineIdx] !== draftLines[lineIdx]);
|
|
|
+
|
|
|
+ if (hasDiffNearby) {
|
|
|
+ const ctxLine = document.createElement('div');
|
|
|
+ ctxLine.className = 'diff-line context';
|
|
|
+ ctxLine.innerHTML = `<span class="diff-line-num">${i + 1}</span><span class="diff-line-content"> ${escapeHtml(origLine)}</span>`;
|
|
|
+ fragment.appendChild(ctxLine);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ container.appendChild(fragment);
|
|
|
+ openAppModal(modal, {
|
|
|
+ focusTarget: document.getElementById('find-replace-diff-confirm'),
|
|
|
+ onClose: () => closeAppModal(modal)
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateHistoryDropdowns() {
|
|
|
+ const select = document.getElementById('find-replace-history');
|
|
|
+ if (!select || !frEngine) return;
|
|
|
+
|
|
|
+ // Preserve the first option
|
|
|
+ select.innerHTML = '<option value="">Recent queries...</option>';
|
|
|
+
|
|
|
+ frEngine.history.find.forEach(q => {
|
|
|
+ const opt = document.createElement('option');
|
|
|
+ opt.value = q;
|
|
|
+ opt.textContent = q;
|
|
|
+ select.appendChild(opt);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function initFindReplaceModal() {
|
|
|
+ const modal = document.getElementById('find-replace-modal');
|
|
|
+ if (!modal) return;
|
|
|
+
|
|
|
+ initFindReplacePanelDrag();
|
|
|
+
|
|
|
+ // Toggle options
|
|
|
+ const toggleButtons = ['find-case', 'find-word', 'find-regex', 'find-sel', 'replace-preserve-case', 'find-wrap'];
|
|
|
+ toggleButtons.forEach(id => {
|
|
|
+ const btn = document.getElementById(id);
|
|
|
+ if (btn) {
|
|
|
+ btn.addEventListener('click', () => {
|
|
|
+ const isActive = btn.classList.contains('active');
|
|
|
+ if (isActive) {
|
|
|
+ btn.classList.remove('active');
|
|
|
+ btn.setAttribute('aria-pressed', 'false');
|
|
|
+ } else {
|
|
|
+ btn.classList.add('active');
|
|
|
+ btn.setAttribute('aria-pressed', 'true');
|
|
|
+ }
|
|
|
+ refreshFindMatches({ resetIndex: true });
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // History select handler
|
|
|
+ const historySelect = document.getElementById('find-replace-history');
|
|
|
+ if (historySelect) {
|
|
|
+ historySelect.addEventListener('change', () => {
|
|
|
+ if (historySelect.value) {
|
|
|
+ findReplaceInput.value = historySelect.value;
|
|
|
+ refreshFindMatches({ resetIndex: true });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // Scope select handler
|
|
|
+ const scopeSelect = document.getElementById('find-replace-scope');
|
|
|
+ if (scopeSelect) {
|
|
|
+ scopeSelect.addEventListener('change', () => {
|
|
|
+ refreshFindMatches({ resetIndex: true });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // Reset position handler
|
|
|
+ const resetBtn = document.getElementById('find-replace-reset');
|
|
|
+ const resetFooterBtn = document.getElementById('find-replace-reset-footer');
|
|
|
+ const doResetPosition = () => {
|
|
|
+ lastFloatingLeft = null;
|
|
|
+ lastFloatingTop = null;
|
|
|
+ lastFloatingRight = null;
|
|
|
+ modal.style.left = '';
|
|
|
+ modal.style.top = '';
|
|
|
+ modal.style.right = '';
|
|
|
+ };
|
|
|
+
|
|
|
+ if (resetBtn) {
|
|
|
+ resetBtn.addEventListener('click', doResetPosition);
|
|
|
+ }
|
|
|
+ if (resetFooterBtn) {
|
|
|
+ resetFooterBtn.addEventListener('click', doResetPosition);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Dock toggle handler
|
|
|
+ const dockBtn = document.getElementById('find-replace-dock');
|
|
|
+ if (dockBtn) {
|
|
|
+ dockBtn.addEventListener('click', () => toggleFrDockMode(false));
|
|
|
+ }
|
|
|
+
|
|
|
+ // Advanced Drawer Toggle
|
|
|
+ const drawerToggle = document.getElementById('fr-drawer-toggle');
|
|
|
+ const drawerContent = document.getElementById('fr-drawer-content');
|
|
|
+ if (drawerToggle && drawerContent) {
|
|
|
+ drawerToggle.addEventListener('click', () => {
|
|
|
+ const isOpen = drawerContent.style.display === 'flex';
|
|
|
+ if (isOpen) {
|
|
|
+ drawerContent.style.display = 'none';
|
|
|
+ drawerToggle.setAttribute('aria-expanded', 'false');
|
|
|
+ drawerToggle.innerHTML = '<i class="bi bi-chevron-right me-1"></i> Advanced Options';
|
|
|
+ } else {
|
|
|
+ drawerContent.style.display = 'flex';
|
|
|
+ drawerToggle.setAttribute('aria-expanded', 'true');
|
|
|
+ drawerToggle.innerHTML = '<i class="bi bi-chevron-down me-1"></i> Advanced Options';
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // Inputs
|
|
|
+ if (findReplaceInput) {
|
|
|
+ findReplaceInput.addEventListener('input', function() {
|
|
|
+ refreshFindMatches({ resetIndex: true });
|
|
|
+ });
|
|
|
+ findReplaceInput.addEventListener('keydown', function(event) {
|
|
|
+ if (event.key === 'Enter') {
|
|
|
+ event.preventDefault();
|
|
|
+ cycleFindMatch(event.shiftKey ? -1 : 1);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (findReplaceWith) {
|
|
|
+ findReplaceWith.addEventListener('input', updateFindControls);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Navigation buttons
|
|
|
+ const prevBtn = document.getElementById('find-prev');
|
|
|
+ const nextBtn = document.getElementById('find-next');
|
|
|
+ if (prevBtn) prevBtn.addEventListener('click', () => cycleFindMatch(-1));
|
|
|
+ if (nextBtn) nextBtn.addEventListener('click', () => cycleFindMatch(1));
|
|
|
+
|
|
|
+ // Action buttons
|
|
|
+ const currentBtn = document.getElementById('find-replace-current');
|
|
|
+ const allBtn = document.getElementById('find-replace-all');
|
|
|
+ if (currentBtn) currentBtn.addEventListener('click', replaceCurrentMatch);
|
|
|
+ if (allBtn) allBtn.addEventListener('click', replaceAllMatches);
|
|
|
+
|
|
|
+ // Close buttons
|
|
|
+ const closeBtn = document.getElementById('find-replace-close');
|
|
|
+ const closeIcon = document.getElementById('find-replace-close-icon');
|
|
|
+ if (closeBtn) closeBtn.addEventListener('click', closeFindReplaceModal);
|
|
|
+ if (closeIcon) closeIcon.addEventListener('click', closeFindReplaceModal);
|
|
|
+
|
|
|
+ // Diff modal confirmation triggers
|
|
|
+ const diffConfirmBtn = document.getElementById('find-replace-diff-confirm');
|
|
|
+ const diffCancelBtn = document.getElementById('find-replace-diff-cancel');
|
|
|
+ const diffCloseIcon = document.getElementById('find-replace-diff-close-icon');
|
|
|
+ const diffModal = document.getElementById('find-replace-diff-modal');
|
|
|
+
|
|
|
+ if (diffConfirmBtn) {
|
|
|
+ diffConfirmBtn.addEventListener('click', () => {
|
|
|
+ executeBulkReplace();
|
|
|
+ closeAppModal(diffModal);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ if (diffCancelBtn) diffCancelBtn.addEventListener('click', () => closeAppModal(diffModal));
|
|
|
+ if (diffCloseIcon) diffCloseIcon.addEventListener('click', () => closeAppModal(diffModal));
|
|
|
+ }
|
|
|
+
|
|
|
+ function initAppModals() {
|
|
|
+ if (clearFormattingConfirm) {
|
|
|
+ clearFormattingConfirm.addEventListener('click', function() {
|
|
|
+ applyClearFormatting();
|
|
|
+ closeAppModal(clearFormattingModal);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ if (clearFormattingCancel) {
|
|
|
+ clearFormattingCancel.addEventListener('click', function() { closeAppModal(clearFormattingModal); });
|
|
|
+ }
|
|
|
+ if (clearFormattingClose) {
|
|
|
+ clearFormattingClose.addEventListener('click', function() { closeAppModal(clearFormattingModal); });
|
|
|
+ }
|
|
|
+ if (helpModalClose) {
|
|
|
+ helpModalClose.addEventListener('click', function() { closeAppModal(helpModal); });
|
|
|
+ }
|
|
|
+ if (helpModalCloseIcon) {
|
|
|
+ helpModalCloseIcon.addEventListener('click', function() { closeAppModal(helpModal); });
|
|
|
+ }
|
|
|
+ if (aboutModalClose) {
|
|
|
+ aboutModalClose.addEventListener('click', function() { closeAppModal(aboutModal); });
|
|
|
+ }
|
|
|
+ if (aboutModalCloseIcon) {
|
|
|
+ aboutModalCloseIcon.addEventListener('click', function() { closeAppModal(aboutModal); });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function openHelpModal() {
|
|
|
+ if (helpModal) {
|
|
|
+ openAppModal(helpModal);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function openAboutModal() {
|
|
|
+ if (aboutModal) {
|
|
|
+ const aboutVersion = document.getElementById("about-version");
|
|
|
+ if (aboutVersion) {
|
|
|
+ aboutVersion.textContent = APP_VERSION;
|
|
|
+ }
|
|
|
+ openAppModal(aboutModal);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function openClearFormattingModal() {
|
|
|
+ if (clearFormattingModal) {
|
|
|
+ openAppModal(clearFormattingModal);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function runMarkdownTool(action, button) {
|
|
|
+ if (action === 'undo') {
|
|
|
+ executeUndo();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (action === 'redo') {
|
|
|
+ executeRedo();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (action === 'bold') wrapEditorSelection('**', '**', 'bold text');
|
|
|
+ else if (action === 'strike') wrapEditorSelection('~~', '~~', 'struck text');
|
|
|
+ else if (action === 'italic') wrapEditorSelection('*', '*', 'italic text');
|
|
|
+ else if (action === 'quote') transformEditorLines(function(line) { return line ? '> ' + line.replace(/^>\s?/, '') : '>'; });
|
|
|
+ else if (action === 'align-left') insertAlignmentBlock('left');
|
|
|
+ else if (action === 'align-center') insertAlignmentBlock('center');
|
|
|
+ else if (action === 'align-right') insertAlignmentBlock('right');
|
|
|
+ else if (action === 'title-case') transformSelectionOrCurrentLine(toTitleCase);
|
|
|
+ else if (action === 'uppercase') transformSelectionOrCurrentLine(function(text) { return text.toUpperCase(); });
|
|
|
+ else if (action === 'lowercase') transformSelectionOrCurrentLine(function(text) { return text.toLowerCase(); });
|
|
|
+ else if (action === 'heading') {
|
|
|
+ const level = parseInt(button.getAttribute('data-md-level') || '1', 10);
|
|
|
+ const marker = '#'.repeat(Math.max(1, Math.min(6, level))) + ' ';
|
|
|
+ transformEditorLines(function(line) { return marker + line.replace(/^#{1,6}\s+/, ''); });
|
|
|
+ } else if (action === 'unordered-list') {
|
|
|
+ applyMarkdownList('unordered');
|
|
|
+ } else if (action === 'ordered-list') {
|
|
|
+ applyMarkdownList('ordered');
|
|
|
+ } else if (action === 'horizontal-rule') insertMarkdownBlock('---\n');
|
|
|
+ else if (action === 'link') insertMarkdownLink();
|
|
|
+ else if (action === 'reference') insertMarkdownReference();
|
|
|
+ else if (action === 'image') insertMarkdownImage();
|
|
|
+ else if (action === 'inline-code') wrapEditorSelection('`', '`', 'code');
|
|
|
+ else if (action === 'code-block') insertMarkdownBlock('```js\n' + (markdownEditor.value.slice(markdownEditor.selectionStart, markdownEditor.selectionEnd) || 'console.log("Hello, Markdown!");') + '\n```\n');
|
|
|
+ else if (action === 'table') openTableModal();
|
|
|
+ else if (action === 'date-time') {
|
|
|
+ const now = new Date();
|
|
|
+ const datePart = now.toLocaleDateString('en-CA');
|
|
|
+ const timePart = now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
|
|
+ const dayName = now.toLocaleDateString('en-US', { weekday: 'long' });
|
|
|
+ const timestamp = `${datePart} ${timePart} ${dayName}`;
|
|
|
+ replaceEditorRange(markdownEditor.selectionStart, markdownEditor.selectionEnd, timestamp, markdownEditor.selectionStart + timestamp.length, markdownEditor.selectionStart + timestamp.length);
|
|
|
+ } else if (action === 'emoji') {
|
|
|
+ openEmojiModal();
|
|
|
+ }
|
|
|
+ else if (action === 'symbols') openSymbolsModal();
|
|
|
+ else if (action === 'alert') openAlertModal();
|
|
|
+ else if (action === 'terminal-block') insertMarkdownBlock('```bash\nnpm run dev\n```\n');
|
|
|
+ else if (action === 'fullscreen') {
|
|
|
+ if (document.fullscreenElement && document.exitFullscreen) document.exitFullscreen();
|
|
|
+ else if (document.documentElement.requestFullscreen) document.documentElement.requestFullscreen();
|
|
|
+ } else if (action === 'clear-formatting') openClearFormattingModal();
|
|
|
+ else if (action === 'find') openFindReplaceModal();
|
|
|
+ else if (action === 'help') openHelpModal();
|
|
|
+ else if (action === 'info') openAboutModal();
|
|
|
+ }
|
|
|
+
|
|
|
+ function initMarkdownFormatToolbar() {
|
|
|
+ if (!markdownFormatToolbar) return;
|
|
|
+ markdownFormatToolbar.addEventListener('mousedown', function(e) {
|
|
|
+ if (e.target.closest('[data-md-action]')) e.preventDefault();
|
|
|
+ });
|
|
|
+ markdownFormatToolbar.addEventListener('click', function(e) {
|
|
|
+ const button = e.target.closest('[data-md-action]');
|
|
|
+ if (!button) return;
|
|
|
+ e.preventDefault();
|
|
|
+ runMarkdownTool(button.getAttribute('data-md-action'), button);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // Story 1.3: Resize Divider Functions
|
|
|
+ function initResizer() {
|
|
|
+ if (!resizeDivider) return;
|
|
|
+
|
|
|
+ // Set up WAI-ARIA accessibility tags
|
|
|
+ resizeDivider.setAttribute('role', 'separator');
|
|
|
+ resizeDivider.setAttribute('aria-orientation', 'vertical');
|
|
|
+ resizeDivider.setAttribute('tabindex', '0');
|
|
|
+ resizeDivider.setAttribute('aria-valuemin', MIN_PANE_PERCENT.toString());
|
|
|
+ resizeDivider.setAttribute('aria-valuemax', (100 - MIN_PANE_PERCENT).toString());
|
|
|
+ updateResizerAria();
|
|
|
+
|
|
|
+ resizeDivider.addEventListener('mousedown', startResize);
|
|
|
+ document.addEventListener('mousemove', handleResize);
|
|
|
+ document.addEventListener('mouseup', stopResize);
|
|
|
+
|
|
|
+ // Touch support for tablets (though disabled via CSS, keeping for future)
|
|
|
+ resizeDivider.addEventListener('touchstart', startResizeTouch);
|
|
|
+ document.addEventListener('touchmove', handleResizeTouch);
|
|
|
+ document.addEventListener('touchend', stopResize);
|
|
|
+
|
|
|
+ resizeDivider.addEventListener('keydown', handleResizerKeydown);
|
|
|
+
|
|
|
+ function handleResizerKeydown(e) {
|
|
|
+ if (currentViewMode !== 'split') return;
|
|
|
+
|
|
|
+ let delta = 0;
|
|
|
+ if (e.key === 'ArrowLeft') {
|
|
|
+ delta = -5; // Shift left by 5%
|
|
|
+ } else if (e.key === 'ArrowRight') {
|
|
|
+ delta = 5; // Shift right by 5%
|
|
|
+ } else {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ e.preventDefault();
|
|
|
+ editorWidthPercent = Math.max(MIN_PANE_PERCENT, Math.min(100 - MIN_PANE_PERCENT, editorWidthPercent + delta));
|
|
|
+ applyPaneWidths();
|
|
|
+ updateResizerAria();
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateResizerAria() {
|
|
|
+ resizeDivider.setAttribute('aria-valuenow', Math.round(editorWidthPercent));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function startResize(e) {
|
|
|
+ if (window.innerWidth < 1080) return;
|
|
|
+ if (currentViewMode !== 'split') return;
|
|
|
+ e.preventDefault();
|
|
|
+ isResizing = true;
|
|
|
+ resizeDivider.classList.add('dragging');
|
|
|
+ document.body.classList.add('resizing');
|
|
|
+ }
|
|
|
+
|
|
|
+ function startResizeTouch(e) {
|
|
|
+ if (window.innerWidth < 1080) return;
|
|
|
+ if (currentViewMode !== 'split') return;
|
|
|
+ e.preventDefault();
|
|
|
+ isResizing = true;
|
|
|
+ resizeDivider.classList.add('dragging');
|
|
|
+ document.body.classList.add('resizing');
|
|
|
+ }
|
|
|
+
|
|
|
+ function handleResize(e) {
|
|
|
+ if (!isResizing) return;
|
|
|
+
|
|
|
+ const containerRect = contentContainer.getBoundingClientRect();
|
|
|
+ const containerWidth = containerRect.width;
|
|
|
+ const mouseX = e.clientX - containerRect.left;
|
|
|
+
|
|
|
+ // Calculate percentage
|
|
|
+ let newEditorPercent = (mouseX / containerWidth) * 100;
|
|
|
+
|
|
|
+ // Enforce minimum pane widths
|
|
|
+ newEditorPercent = Math.max(MIN_PANE_PERCENT, Math.min(100 - MIN_PANE_PERCENT, newEditorPercent));
|
|
|
+
|
|
|
+ editorWidthPercent = newEditorPercent;
|
|
|
+ applyPaneWidths();
|
|
|
+ }
|
|
|
+
|
|
|
+ function handleResizeTouch(e) {
|
|
|
+ if (!isResizing || !e.touches[0]) return;
|
|
|
+
|
|
|
+ const containerRect = contentContainer.getBoundingClientRect();
|
|
|
+ const containerWidth = containerRect.width;
|
|
|
+ const touchX = e.touches[0].clientX - containerRect.left;
|
|
|
+
|
|
|
+ let newEditorPercent = (touchX / containerWidth) * 100;
|
|
|
+ newEditorPercent = Math.max(MIN_PANE_PERCENT, Math.min(100 - MIN_PANE_PERCENT, newEditorPercent));
|
|
|
+
|
|
|
+ editorWidthPercent = newEditorPercent;
|
|
|
+ applyPaneWidths();
|
|
|
+ }
|
|
|
+
|
|
|
+ function stopResize() {
|
|
|
+ if (!isResizing) return;
|
|
|
+ isResizing = false;
|
|
|
+ resizeDivider.classList.remove('dragging');
|
|
|
+ document.body.classList.remove('resizing');
|
|
|
+ }
|
|
|
+
|
|
|
+ function applyPaneWidths() {
|
|
|
+ if (window.innerWidth < 1080) {
|
|
|
+ resetPaneWidths();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (currentViewMode !== 'split') return;
|
|
|
+
|
|
|
+ const previewPercent = 100 - editorWidthPercent;
|
|
|
+ editorPaneElement.style.flex = `0 0 calc((100% - var(--dock-width, 0px)) * ${editorWidthPercent / 100} - 4px)`;
|
|
|
+ previewPaneElement.style.flex = `0 0 calc((100% - var(--dock-width, 0px)) * ${previewPercent / 100} - 4px)`;
|
|
|
+ refreshEditorWidth();
|
|
|
+ scheduleLineNumberUpdate();
|
|
|
+ }
|
|
|
+
|
|
|
+ function resetPaneWidths() {
|
|
|
+ editorPaneElement.style.flex = '';
|
|
|
+ previewPaneElement.style.flex = '';
|
|
|
+ refreshEditorWidth();
|
|
|
+ }
|
|
|
+
|
|
|
+ function openMobileMenu() {
|
|
|
+ mobileMenuPanel.classList.add("active");
|
|
|
+ mobileMenuOverlay.classList.add("active");
|
|
|
+ }
|
|
|
+ function closeMobileMenu() {
|
|
|
+ mobileMenuPanel.classList.remove("active");
|
|
|
+ mobileMenuOverlay.classList.remove("active");
|
|
|
+ }
|
|
|
+ mobileMenuToggle.addEventListener("click", openMobileMenu);
|
|
|
+ mobileCloseMenu.addEventListener("click", closeMobileMenu);
|
|
|
+ mobileMenuOverlay.addEventListener("click", closeMobileMenu);
|
|
|
+
|
|
|
+ function updateMobileStats() {
|
|
|
+ mobileCharCount.textContent = charCountElement.textContent;
|
|
|
+ mobileWordCount.textContent = wordCountElement.textContent;
|
|
|
+ mobileReadingTime.textContent = readingTimeElement.textContent;
|
|
|
+ }
|
|
|
+
|
|
|
+ const origUpdateStats = updateDocumentStats;
|
|
|
+ updateDocumentStats = function() {
|
|
|
+ origUpdateStats();
|
|
|
+ updateMobileStats();
|
|
|
+ };
|
|
|
+
|
|
|
+ mobileToggleSync.addEventListener("click", () => {
|
|
|
+ toggleSyncScrolling();
|
|
|
+ if (syncScrollingEnabled) {
|
|
|
+ mobileToggleSync.innerHTML = '<i class="bi bi-link-45deg me-2"></i> Sync Off';
|
|
|
+ mobileToggleSync.classList.add("sync-disabled");
|
|
|
+ mobileToggleSync.classList.remove("sync-enabled");
|
|
|
+ mobileToggleSync.classList.add("sync-active");
|
|
|
+ } else {
|
|
|
+ mobileToggleSync.innerHTML = '<i class="bi bi-link me-2"></i> Sync On';
|
|
|
+ mobileToggleSync.classList.add("sync-enabled");
|
|
|
+ mobileToggleSync.classList.remove("sync-disabled");
|
|
|
+ mobileToggleSync.classList.remove("sync-active");
|
|
|
+ }
|
|
|
+ });
|
|
|
+ mobileImportBtn.addEventListener("click", () => {
|
|
|
+ if (typeof Neutralino !== 'undefined') {
|
|
|
+ nativeImportMarkdown();
|
|
|
+ } else {
|
|
|
+ fileInput.click();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ mobileImportGithubBtn.addEventListener("click", () => {
|
|
|
+ closeMobileMenu();
|
|
|
+ openGitHubImportModal();
|
|
|
+ });
|
|
|
+ mobileExportMd.addEventListener("click", () => exportMd.click());
|
|
|
+ mobileExportHtml.addEventListener("click", () => exportHtml.click());
|
|
|
+ mobileExportPdf.addEventListener("click", () => exportPdf.click());
|
|
|
+ mobileCopyMarkdown.addEventListener("click", () => copyMarkdownButton.click());
|
|
|
+ mobileThemeToggle.addEventListener("click", () => {
|
|
|
+ themeToggle.click();
|
|
|
+ mobileThemeToggle.innerHTML = themeToggle.innerHTML + " Toggle Dark Mode";
|
|
|
+ });
|
|
|
+
|
|
|
+ const mobileNewTabBtn = document.getElementById("mobile-new-tab-btn");
|
|
|
+ if (mobileNewTabBtn) {
|
|
|
+ mobileNewTabBtn.addEventListener("click", function() {
|
|
|
+ newTab();
|
|
|
+ closeMobileMenu();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ const mobileTabResetBtn = document.getElementById("mobile-tab-reset-btn");
|
|
|
+ if (mobileTabResetBtn) {
|
|
|
+ mobileTabResetBtn.addEventListener("click", function() {
|
|
|
+ closeMobileMenu();
|
|
|
+ resetAllTabs();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ initTabs();
|
|
|
+ if (loadGlobalState().syncScrollingEnabled === false) toggleSyncScrolling();
|
|
|
+ updateMobileStats();
|
|
|
+ updateFindHighlights();
|
|
|
+ syncHighlightScroll();
|
|
|
+
|
|
|
+ // Defer DOM geometry measurement until after FCP/LCP critical paint path
|
|
|
+ setTimeout(function() {
|
|
|
+ initEditorGeometry();
|
|
|
+ refreshEditorWidth();
|
|
|
+ scheduleLineNumberUpdate();
|
|
|
+ }, 100);
|
|
|
+
|
|
|
+ // Initialize resizer - Story 1.3
|
|
|
+ initResizer();
|
|
|
+ function constrainFloatingPanelPosition() {
|
|
|
+ const panel = document.getElementById('find-replace-modal');
|
|
|
+ if (!panel || isFrDocked || panel.style.display === 'none') return;
|
|
|
+ if (window.innerWidth < 768) return; // Mobile layout forces fixed responsive positioning via CSS
|
|
|
+
|
|
|
+ // Only adjust if the inline style has custom dragged coordinates
|
|
|
+ if (!panel.style.left || panel.style.left === 'auto') return;
|
|
|
+
|
|
|
+ const leftVal = parseFloat(panel.style.left) || 0;
|
|
|
+ const topVal = parseFloat(panel.style.top) || 0;
|
|
|
+
|
|
|
+ const maxX = window.innerWidth - panel.offsetWidth;
|
|
|
+ const maxY = window.innerHeight - panel.offsetHeight;
|
|
|
+
|
|
|
+ const constrainedLeft = `${Math.max(0, Math.min(maxX, leftVal))}px`;
|
|
|
+ const constrainedTop = `${Math.max(0, Math.min(maxY, topVal))}px`;
|
|
|
+
|
|
|
+ panel.style.left = constrainedLeft;
|
|
|
+ panel.style.top = constrainedTop;
|
|
|
+
|
|
|
+ lastFloatingLeft = constrainedLeft;
|
|
|
+ lastFloatingTop = constrainedTop;
|
|
|
+ }
|
|
|
+
|
|
|
+ let resizeLayoutTimeout = null;
|
|
|
+ window.addEventListener('resize', () => {
|
|
|
+ clearTimeout(resizeLayoutTimeout);
|
|
|
+ resizeLayoutTimeout = setTimeout(function() {
|
|
|
+ initEditorGeometry();
|
|
|
+ refreshEditorWidth();
|
|
|
+ scheduleLineNumberUpdate();
|
|
|
+ if (window.innerWidth < 1080 && isFrDocked && isFindModalOpen) {
|
|
|
+ toggleFrDockMode(true);
|
|
|
+ }
|
|
|
+ constrainFloatingPanelPosition();
|
|
|
+ }, 100);
|
|
|
+ });
|
|
|
+
|
|
|
+ // View Mode Button Event Listeners - Story 1.1
|
|
|
+ viewModeButtons.forEach(btn => {
|
|
|
+ btn.addEventListener('click', function() {
|
|
|
+ const mode = this.getAttribute('data-view-mode');
|
|
|
+ setViewMode(resolveViewToggleMode(mode));
|
|
|
+ saveCurrentTabState();
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ // Story 1.4: Mobile View Mode Button Event Listeners
|
|
|
+ mobileViewModeButtons.forEach(btn => {
|
|
|
+ btn.addEventListener('click', function() {
|
|
|
+ const mode = this.getAttribute('data-mode');
|
|
|
+ setViewMode(mode);
|
|
|
+ saveCurrentTabState();
|
|
|
+ closeMobileMenu();
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ markdownEditor.addEventListener("input", function(e) {
|
|
|
+ handleKeystrokeHistory(e);
|
|
|
+ debouncedRender();
|
|
|
+ clearTimeout(saveTabStateTimeout);
|
|
|
+ saveTabStateTimeout = setTimeout(saveCurrentTabState, 500);
|
|
|
+ if (isFindModalOpen) {
|
|
|
+ scheduleFindRefresh();
|
|
|
+ } else {
|
|
|
+ updateFindHighlights();
|
|
|
+ }
|
|
|
+ scheduleLineNumberUpdate({
|
|
|
+ inputType: e && typeof e.inputType === 'string' ? e.inputType : '',
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ markdownEditor.addEventListener('keydown', updateLastCursor);
|
|
|
+ markdownEditor.addEventListener('keyup', updateLastCursor);
|
|
|
+ markdownEditor.addEventListener('mousedown', updateLastCursor);
|
|
|
+ markdownEditor.addEventListener('mouseup', updateLastCursor);
|
|
|
+ markdownEditor.addEventListener('focus', updateLastCursor);
|
|
|
+
|
|
|
+ initMarkdownFormatToolbar();
|
|
|
+ initFindReplaceModal();
|
|
|
+ initAppModals();
|
|
|
+
|
|
|
+ // Editor key handlers for list continuation and indentation
|
|
|
+ markdownEditor.addEventListener("keydown", function(e) {
|
|
|
+ if (handleListEnter(e)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (e.key === 'Tab') {
|
|
|
+ e.preventDefault();
|
|
|
+
|
|
|
+ const start = this.selectionStart;
|
|
|
+ const end = this.selectionEnd;
|
|
|
+ const value = this.value;
|
|
|
+
|
|
|
+ // Insert 2 spaces
|
|
|
+ const indent = ' '; // 2 spaces
|
|
|
+
|
|
|
+ // Update textarea value
|
|
|
+ this.value = value.substring(0, start) + indent + value.substring(end);
|
|
|
+
|
|
|
+ // Update cursor position
|
|
|
+ this.selectionStart = this.selectionEnd = start + indent.length;
|
|
|
+
|
|
|
+ // Trigger input event to update preview
|
|
|
+ this.dispatchEvent(new Event('input'));
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ markdownEditor.addEventListener("scroll", function() {
|
|
|
+ cachedScrollTop = this.scrollTop;
|
|
|
+ cachedScrollLeft = this.scrollLeft;
|
|
|
+ syncEditorToPreview();
|
|
|
+ scheduleEditorOverlayScrollSync();
|
|
|
+ });
|
|
|
+ previewPane.addEventListener("scroll", syncPreviewToEditor);
|
|
|
+ toggleSyncButton.addEventListener("click", toggleSyncScrolling);
|
|
|
+ if (directionToggle) {
|
|
|
+ directionToggle.addEventListener("click", function () {
|
|
|
+ const currentDir = markdownEditor ? markdownEditor.getAttribute("dir") : "ltr";
|
|
|
+ const direction = currentDir === "rtl" ? "ltr" : "rtl";
|
|
|
+ applyDirectionToContent(direction);
|
|
|
+ saveGlobalState({ direction });
|
|
|
+ updateDirectionToggleUI(direction);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ themeToggle.addEventListener("click", function () {
|
|
|
+ _lastRenderedContent = null;
|
|
|
+ const theme =
|
|
|
+ document.documentElement.getAttribute("data-theme") === "dark"
|
|
|
+ ? "light"
|
|
|
+ : "dark";
|
|
|
+ document.documentElement.setAttribute("data-theme", theme);
|
|
|
+ saveGlobalState({ theme });
|
|
|
+
|
|
|
+ if (theme === "dark") {
|
|
|
+ themeToggle.innerHTML = '<i class="bi bi-sun"></i>';
|
|
|
+ } else {
|
|
|
+ themeToggle.innerHTML = '<i class="bi bi-moon"></i>';
|
|
|
+ }
|
|
|
+
|
|
|
+ // PERF-004: Only re-render Mermaid diagrams on theme change instead of full renderMarkdown()
|
|
|
+ // CSS custom properties handle all other theme transitions automatically.
|
|
|
+ // PERF-002: Guard mermaid re-render — skip if not loaded yet
|
|
|
+ if (typeof mermaid !== 'undefined') {
|
|
|
+ initMermaid(true); // Force re-init with new theme
|
|
|
+ try {
|
|
|
+ const mermaidNodes = markdownPreview.querySelectorAll('.mermaid');
|
|
|
+ if (mermaidNodes.length > 0) {
|
|
|
+ // Clear existing rendered Mermaid SVGs and re-render with new theme
|
|
|
+ mermaidNodes.forEach(function(node) {
|
|
|
+ // Restore original diagram code to prevent parsing already-rendered SVG as source
|
|
|
+ const originalCode = node.getAttribute('data-original-code');
|
|
|
+ if (originalCode) {
|
|
|
+ const decodedCode = decodeURIComponent(originalCode);
|
|
|
+ const escapedCode = decodedCode
|
|
|
+ .replace(/&/g, "&")
|
|
|
+ .replace(/</g, "<")
|
|
|
+ .replace(/>/g, ">");
|
|
|
+ node.innerHTML = escapedCode;
|
|
|
+ }
|
|
|
+ node.removeAttribute('data-processed');
|
|
|
+ const container = node.closest('.mermaid-container');
|
|
|
+ if (container) {
|
|
|
+ container.classList.add('is-loading');
|
|
|
+ const oldToolbar = container.querySelector('.mermaid-toolbar');
|
|
|
+ if (oldToolbar) oldToolbar.remove();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ Promise.resolve(mermaid.init(undefined, mermaidNodes))
|
|
|
+ .then(function() {
|
|
|
+ markdownPreview.querySelectorAll('.mermaid-container.is-loading').forEach(function(c) {
|
|
|
+ c.classList.remove('is-loading');
|
|
|
+ });
|
|
|
+ addMermaidToolbars();
|
|
|
+ })
|
|
|
+ .catch(function(e) {
|
|
|
+ console.warn('Mermaid theme re-render failed:', e);
|
|
|
+ markdownPreview.querySelectorAll('.mermaid-container.is-loading').forEach(function(c) {
|
|
|
+ c.classList.remove('is-loading');
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.warn('Mermaid theme re-render failed:', e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ async function nativeSaveMarkdown() {
|
|
|
+ try {
|
|
|
+ const content = markdownEditor.value;
|
|
|
+ const result = await Neutralino.os.showSaveDialog("Save Markdown File", {
|
|
|
+ filters: [
|
|
|
+ { name: "Markdown files (*.md)", extensions: ["md", "markdown"] },
|
|
|
+ { name: "All files (*.*)", extensions: ["*"] }
|
|
|
+ ]
|
|
|
+ });
|
|
|
+ if (result) {
|
|
|
+ await Neutralino.filesystem.writeFile(result, content);
|
|
|
+ const fileName = result.split(/[/\\]/).pop().replace(/\.(md|markdown)$/i, "");
|
|
|
+ const activeTab = tabs.find(function(t) { return t.id === activeTabId; });
|
|
|
+ if (activeTab) {
|
|
|
+ activeTab.title = fileName;
|
|
|
+ activeTab.content = content;
|
|
|
+ saveTabsToStorage(tabs);
|
|
|
+ renderTabBar(tabs, activeTabId);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error("Native save failed:", e);
|
|
|
+ alert("Native save failed: " + e.message);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function nativeSaveHtml(htmlContent) {
|
|
|
+ try {
|
|
|
+ const result = await Neutralino.os.showSaveDialog("Save HTML document", {
|
|
|
+ filters: [
|
|
|
+ { name: "HTML documents (*.html)", extensions: ["html", "htm"] }
|
|
|
+ ]
|
|
|
+ });
|
|
|
+ if (result) {
|
|
|
+ await Neutralino.filesystem.writeFile(result, htmlContent);
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error("Native HTML save failed:", e);
|
|
|
+ alert("Native HTML save failed: " + e.message);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function nativeImportMarkdown() {
|
|
|
+ try {
|
|
|
+ const result = await Neutralino.os.showOpenDialog("Open Markdown file", {
|
|
|
+ filters: [
|
|
|
+ { name: "Markdown files (*.md)", extensions: ["md", "markdown"] },
|
|
|
+ { name: "All files (*.*)", extensions: ["*"] }
|
|
|
+ ],
|
|
|
+ multiSelections: true
|
|
|
+ });
|
|
|
+ if (result && result.length > 0) {
|
|
|
+ for (const filePath of result) {
|
|
|
+ const content = await Neutralino.filesystem.readFile(filePath);
|
|
|
+ const fileName = filePath.split(/[/\\]/).pop().replace(/\.(md|markdown)$/i, "");
|
|
|
+ newTab(content, fileName);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error("Native import failed:", e);
|
|
|
+ alert("Native import failed: " + e.message);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (importFromFileButton) {
|
|
|
+ importFromFileButton.addEventListener("click", function (e) {
|
|
|
+ e.preventDefault();
|
|
|
+ if (typeof Neutralino !== 'undefined') {
|
|
|
+ nativeImportMarkdown();
|
|
|
+ } else {
|
|
|
+ fileInput.click();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (importFromGithubButton) {
|
|
|
+ importFromGithubButton.addEventListener("click", function (e) {
|
|
|
+ e.preventDefault();
|
|
|
+ openGitHubImportModal();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (githubImportSubmitBtn) {
|
|
|
+ githubImportSubmitBtn.addEventListener("click", handleGitHubImportSubmit);
|
|
|
+ }
|
|
|
+ if (githubImportCancelBtn) {
|
|
|
+ githubImportCancelBtn.addEventListener("click", closeGitHubImportModal);
|
|
|
+ }
|
|
|
+ const handleGitHubImportInputKeydown = function(e) {
|
|
|
+ if (e.key === "Enter") {
|
|
|
+ e.preventDefault();
|
|
|
+ handleGitHubImportSubmit();
|
|
|
+ } else if (e.key === "Escape") {
|
|
|
+ closeGitHubImportModal();
|
|
|
+ }
|
|
|
+ };
|
|
|
+ if (githubImportUrlInput) {
|
|
|
+ githubImportUrlInput.addEventListener("keydown", handleGitHubImportInputKeydown);
|
|
|
+ }
|
|
|
+ if (githubImportFileSelect) {
|
|
|
+ githubImportFileSelect.addEventListener("keydown", handleGitHubImportInputKeydown);
|
|
|
+ }
|
|
|
+ if (githubImportSelectAllBtn) {
|
|
|
+ githubImportSelectAllBtn.addEventListener("click", function() {
|
|
|
+ const allPaths = availableGitHubImportPaths.slice();
|
|
|
+ const shouldSelectAll = selectedGitHubImportPaths.size !== allPaths.length;
|
|
|
+ setGitHubSelectedPaths(shouldSelectAll ? allPaths : []);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ fileInput.addEventListener("change", function (e) {
|
|
|
+ const file = e.target.files[0];
|
|
|
+ if (file) {
|
|
|
+ importMarkdownFile(file);
|
|
|
+ }
|
|
|
+ this.value = "";
|
|
|
+ });
|
|
|
+
|
|
|
+ exportMd.addEventListener("click", function () {
|
|
|
+ if (typeof Neutralino !== 'undefined') {
|
|
|
+ nativeSaveMarkdown();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const blob = new Blob([markdownEditor.value], {
|
|
|
+ type: "text/markdown;charset=utf-8",
|
|
|
+ });
|
|
|
+ saveAs(blob, "document.md");
|
|
|
+ } catch (e) {
|
|
|
+ console.error("Export failed:", e);
|
|
|
+ alert("Export failed: " + e.message);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ exportHtml.addEventListener("click", function () {
|
|
|
+ try {
|
|
|
+ const { frontmatter, body } = parseFrontmatter(markdownEditor.value);
|
|
|
+ const tableHtml = frontmatter ? renderFrontmatterTable(frontmatter) : '';
|
|
|
+ const referenceData = extractReferenceDefinitions(body);
|
|
|
+ const html = tableHtml + marked.parse(referenceData.cleanedMarkdown);
|
|
|
+ const sanitizedHtml = DOMPurify.sanitize(html, {
|
|
|
+ ADD_TAGS: ['mjx-container', 'input'],
|
|
|
+ ADD_ATTR: ['id', 'class', 'style', 'align', 'type', 'checked', 'disabled', 'data-original-code'],
|
|
|
+ ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i
|
|
|
+ });
|
|
|
+ const tempContainer = document.createElement("div");
|
|
|
+ tempContainer.innerHTML = sanitizedHtml;
|
|
|
+ applyReferencePreviewLinks(tempContainer, referenceData.definitions);
|
|
|
+ enhanceGitHubAlerts(tempContainer);
|
|
|
+ const enhancedHtml = tempContainer.innerHTML;
|
|
|
+ const isDarkTheme =
|
|
|
+ document.documentElement.getAttribute("data-theme") === "dark";
|
|
|
+ const cssTheme = isDarkTheme
|
|
|
+ ? "https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.3.0/github-markdown-dark.min.css"
|
|
|
+ : "https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.3.0/github-markdown.min.css";
|
|
|
+ const fullHtml = `<!DOCTYPE html>
|
|
|
+<html lang="en">
|
|
|
+<head>
|
|
|
+ <meta charset="UTF-8">
|
|
|
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
+ <title>Markdown Export</title>
|
|
|
+ <link rel="stylesheet" href="${cssTheme}">
|
|
|
+ <script>
|
|
|
+ window.MathJax = {
|
|
|
+ loader: { load: ['[tex]/ams', '[tex]/boldsymbol'] },
|
|
|
+ tex: {
|
|
|
+ inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],
|
|
|
+ displayMath: [['$$', '$$'], ['\\\\[', '\\\\]']],
|
|
|
+ processEscapes: true,
|
|
|
+ packages: { '[+]': ['ams', 'boldsymbol'] }
|
|
|
+ }
|
|
|
+ };
|
|
|
+ </script>
|
|
|
+ <script defer src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.min.js"></script>
|
|
|
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@11.6.0/dist/mermaid.min.js"></script>
|
|
|
+ <style>
|
|
|
+ html {
|
|
|
+ background-color: ${isDarkTheme ? "#0d1117" : "#ffffff"};
|
|
|
+ }
|
|
|
+ body {
|
|
|
+ margin: 0;
|
|
|
+ background-color: ${isDarkTheme ? "#0d1117" : "#ffffff"};
|
|
|
+ color: ${isDarkTheme ? "#c9d1d9" : "#24292e"};
|
|
|
+ }
|
|
|
+ .markdown-body {
|
|
|
+ box-sizing: border-box;
|
|
|
+ min-width: 200px;
|
|
|
+ max-width: 100%;
|
|
|
+ width: fit-content;
|
|
|
+ margin: 0 auto;
|
|
|
+ padding: 45px;
|
|
|
+ background-color: ${isDarkTheme ? "#0d1117" : "#ffffff"};
|
|
|
+ color: ${isDarkTheme ? "#c9d1d9" : "#24292e"};
|
|
|
+ }
|
|
|
+ .markdown-body > p,
|
|
|
+ .markdown-body > ul,
|
|
|
+ .markdown-body > ol,
|
|
|
+ .markdown-body > blockquote,
|
|
|
+ .markdown-body > h1,
|
|
|
+ .markdown-body > h2,
|
|
|
+ .markdown-body > h3,
|
|
|
+ .markdown-body > h4,
|
|
|
+ .markdown-body > h5,
|
|
|
+ .markdown-body > h6,
|
|
|
+ .markdown-body > pre,
|
|
|
+ .markdown-body > table,
|
|
|
+ .markdown-body > details,
|
|
|
+ .markdown-body > dl,
|
|
|
+ .markdown-body > hr {
|
|
|
+ max-width: 980px;
|
|
|
+ margin-left: auto !important;
|
|
|
+ margin-right: auto !important;
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ /* Syntax Highlighting */
|
|
|
+ .hljs-doctag, .hljs-keyword, .hljs-template-tag, .hljs-template-variable, .hljs-type, .hljs-variable.language_ { color: ${isDarkTheme ? "#ff7b72" : "#d73a49"}; }
|
|
|
+ .hljs-title, .hljs-title.class_, .hljs-title.class_.inherited__, .hljs-title.function_ { color: ${isDarkTheme ? "#d2a8ff" : "#6f42c1"}; }
|
|
|
+ .hljs-attr, .hljs-attribute, .hljs-literal, .hljs-meta, .hljs-number, .hljs-operator, .hljs-variable, .hljs-selector-attr, .hljs-selector-class, .hljs-selector-id { color: ${isDarkTheme ? "#79c0ff" : "#005cc5"}; }
|
|
|
+ .hljs-regexp, .hljs-string, .hljs-meta .hljs-string { color: ${isDarkTheme ? "#a5d6ff" : "#032f62"}; }
|
|
|
+ .hljs-built_in, .hljs-symbol { color: ${isDarkTheme ? "#ffa657" : "#e36209"}; }
|
|
|
+ .hljs-comment, .hljs-code, .hljs-formula { color: ${isDarkTheme ? "#8b949e" : "#6a737d"}; }
|
|
|
+ .hljs-name, .hljs-quote, .hljs-selector-tag, .hljs-selector-pseudo { color: ${isDarkTheme ? "#7ee787" : "#22863a"}; }
|
|
|
+ .hljs-subst { color: ${isDarkTheme ? "#c9d1d9" : "#24292e"}; }
|
|
|
+ .hljs-section { color: ${isDarkTheme ? "#1f6feb" : "#005cc5"}; font-weight: bold; }
|
|
|
+ .hljs-bullet { color: ${isDarkTheme ? "#79c0ff" : "#005cc5"}; }
|
|
|
+ .hljs-emphasis { font-style: italic; }
|
|
|
+ .hljs-strong { font-weight: bold; }
|
|
|
+ .hljs-addition { color: ${isDarkTheme ? "#aff5b4" : "#22863a"}; background-color: ${isDarkTheme ? "#033a16" : "#f0fff4"}; }
|
|
|
+ .hljs-deletion { color: ${isDarkTheme ? "#ffdcd7" : "#b31d28"}; background-color: ${isDarkTheme ? "#67060c" : "#ffeef0"}; }
|
|
|
+
|
|
|
+ .markdown-alert {
|
|
|
+ padding: 0.5rem 1rem;
|
|
|
+ margin-bottom: 16px;
|
|
|
+ border-left: 0.25em solid;
|
|
|
+ border-radius: 0.375rem;
|
|
|
+ }
|
|
|
+ .markdown-alert > :last-child {
|
|
|
+ margin-bottom: 0;
|
|
|
+ }
|
|
|
+ .markdown-alert-title {
|
|
|
+ margin: 0 0 8px;
|
|
|
+ font-weight: 600;
|
|
|
+ line-height: 1.25;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ }
|
|
|
+ .markdown-alert-icon {
|
|
|
+ display: inline-flex;
|
|
|
+ width: 16px;
|
|
|
+ height: 16px;
|
|
|
+ }
|
|
|
+ .markdown-alert-icon svg {
|
|
|
+ width: 16px;
|
|
|
+ height: 16px;
|
|
|
+ fill: currentColor;
|
|
|
+ }
|
|
|
+ .markdown-alert-note { color: ${isDarkTheme ? "#4493f8" : "#0969da"}; border-left-color: ${isDarkTheme ? "#4493f8" : "#0969da"}; background-color: ${isDarkTheme ? "rgba(31, 111, 235, 0.15)" : "#ddf4ff"}; }
|
|
|
+ .markdown-alert-tip { color: ${isDarkTheme ? "#3fb950" : "#1a7f37"}; border-left-color: ${isDarkTheme ? "#3fb950" : "#1a7f37"}; background-color: ${isDarkTheme ? "rgba(35, 134, 54, 0.15)" : "#dafbe1"}; }
|
|
|
+ .markdown-alert-important { color: ${isDarkTheme ? "#ab7df8" : "#8250df"}; border-left-color: ${isDarkTheme ? "#ab7df8" : "#8250df"}; background-color: ${isDarkTheme ? "rgba(137, 87, 229, 0.15)" : "#fbefff"}; }
|
|
|
+ .markdown-alert-warning { color: ${isDarkTheme ? "#d29922" : "#9a6700"}; border-left-color: ${isDarkTheme ? "#d29922" : "#9a6700"}; background-color: ${isDarkTheme ? "rgba(210, 153, 34, 0.18)" : "#fff8c5"}; }
|
|
|
+ .markdown-alert-caution { color: ${isDarkTheme ? "#f85149" : "#cf222e"}; border-left-color: ${isDarkTheme ? "#f85149" : "#cf222e"}; background-color: ${isDarkTheme ? "rgba(248, 81, 73, 0.18)" : "#ffebe9"}; }
|
|
|
+ .markdown-alert > *:not(.markdown-alert-title) { color: ${isDarkTheme ? "#c9d1d9" : "#24292e"}; }
|
|
|
+
|
|
|
+ .frontmatter-table {
|
|
|
+ width: 100%;
|
|
|
+ border-collapse: collapse;
|
|
|
+ margin-bottom: 24px;
|
|
|
+ font-size: 14px;
|
|
|
+ }
|
|
|
+ .frontmatter-table th,
|
|
|
+ .frontmatter-table td {
|
|
|
+ border: 1px solid ${isDarkTheme ? "#30363d" : "#e1e4e8"};
|
|
|
+ padding: 8px 12px;
|
|
|
+ text-align: left;
|
|
|
+ }
|
|
|
+ .frontmatter-table th {
|
|
|
+ font-weight: 600;
|
|
|
+ background-color: ${isDarkTheme ? "#161b22" : "#f6f8fa"};
|
|
|
+ width: 150px;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* Footnote styles */
|
|
|
+ .footnotes {
|
|
|
+ margin-top: 1.5rem;
|
|
|
+ font-size: 0.9em;
|
|
|
+ border-top: 1px solid ${isDarkTheme ? "#30363d" : "#eaecef"};
|
|
|
+ padding-top: 8px;
|
|
|
+ }
|
|
|
+ .footnotes ol {
|
|
|
+ padding-left: 1.5em;
|
|
|
+ }
|
|
|
+ .footnotes ol > li::marker {
|
|
|
+ content: "[" counter(list-item) "] ";
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+ .footnotes li > p {
|
|
|
+ margin: 0.2em 0;
|
|
|
+ }
|
|
|
+ .footnote-ref a,
|
|
|
+ .footnote-backref {
|
|
|
+ text-decoration: none;
|
|
|
+ }
|
|
|
+ .footnote-backref {
|
|
|
+ margin-left: 0.4em;
|
|
|
+ }
|
|
|
+ a.reference-link {
|
|
|
+ font-size: 0.75em;
|
|
|
+ letter-spacing: -0.02em;
|
|
|
+ line-height: 1;
|
|
|
+ vertical-align: super;
|
|
|
+ position: relative;
|
|
|
+ top: 0.08em;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* Mermaid and Math styles */
|
|
|
+ .mermaid-container {
|
|
|
+ position: relative;
|
|
|
+ margin-bottom: 16px;
|
|
|
+ }
|
|
|
+ .math-block {
|
|
|
+ margin: 1em 0;
|
|
|
+ overflow-x: auto;
|
|
|
+ text-align: center;
|
|
|
+ }
|
|
|
+
|
|
|
+ @media (max-width: 767px) {
|
|
|
+ .markdown-body {
|
|
|
+ padding: 15px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ </style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+ <article class="markdown-body">
|
|
|
+ ${enhancedHtml}
|
|
|
+ </article>
|
|
|
+ <script>
|
|
|
+ function fitMarkdownExportToContent() {
|
|
|
+ var article = document.querySelector('.markdown-body');
|
|
|
+ if (!article) return;
|
|
|
+
|
|
|
+ article.style.width = '';
|
|
|
+ article.style.maxWidth = '980px';
|
|
|
+
|
|
|
+ var overflow = article.scrollWidth - article.clientWidth;
|
|
|
+ if (overflow <= 1) return;
|
|
|
+
|
|
|
+ var styles = window.getComputedStyle(article);
|
|
|
+ var paddingLeft = parseFloat(styles.paddingLeft) || 0;
|
|
|
+ var paddingRight = parseFloat(styles.paddingRight) || 0;
|
|
|
+ var borderRight = parseFloat(styles.borderRightWidth) || 0;
|
|
|
+ var borderLeft = parseFloat(styles.borderLeftWidth) || 0;
|
|
|
+ var boxSizing = styles.boxSizing;
|
|
|
+
|
|
|
+ var requiredWidth = boxSizing === 'border-box'
|
|
|
+ ? Math.ceil(article.scrollWidth + borderLeft + borderRight)
|
|
|
+ : Math.ceil(article.scrollWidth - paddingLeft - paddingRight);
|
|
|
+
|
|
|
+ article.style.width = requiredWidth + 'px';
|
|
|
+ article.style.maxWidth = 'none';
|
|
|
+ }
|
|
|
+
|
|
|
+ function queueMarkdownExportFit() {
|
|
|
+ window.requestAnimationFrame(function () {
|
|
|
+ window.requestAnimationFrame(fitMarkdownExportToContent);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ window.addEventListener('load', function () {
|
|
|
+ var mathReady = Promise.resolve();
|
|
|
+ var article = document.querySelector('.markdown-body');
|
|
|
+ if (article && window.MutationObserver) {
|
|
|
+ new MutationObserver(queueMarkdownExportFit).observe(article, {
|
|
|
+ childList: true,
|
|
|
+ subtree: true
|
|
|
+ });
|
|
|
+ }
|
|
|
+ if (window.MathJax && typeof window.MathJax.typesetPromise === 'function') {
|
|
|
+ mathReady = window.MathJax.typesetPromise().catch(function (err) {
|
|
|
+ console.warn('MathJax typeset failed:', err);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ if (window.mermaid) {
|
|
|
+ try {
|
|
|
+ window.mermaid.initialize({ startOnLoad: true, theme: '${isDarkTheme ? "dark" : "default"}' });
|
|
|
+ } catch (e) {
|
|
|
+ console.warn('Mermaid initialization failed:', e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ mathReady.finally(queueMarkdownExportFit);
|
|
|
+ queueMarkdownExportFit();
|
|
|
+ });
|
|
|
+ window.addEventListener('resize', queueMarkdownExportFit);
|
|
|
+ </script>
|
|
|
+</body>
|
|
|
+</html>`;
|
|
|
+ const blob = new Blob([fullHtml], { type: "text/html;charset=utf-8" });
|
|
|
+ if (typeof Neutralino !== 'undefined') {
|
|
|
+ nativeSaveHtml(fullHtml);
|
|
|
+ } else {
|
|
|
+ saveAs(blob, "document.html");
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error("HTML export failed:", e);
|
|
|
+ alert("HTML export failed: " + e.message);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // ============================================
|
|
|
+ // Page-Break Detection Functions (Story 1.1)
|
|
|
+ // ============================================
|
|
|
+
|
|
|
+ // Page configuration constants for A4 PDF export
|
|
|
+ const PAGE_CONFIG = {
|
|
|
+ a4Width: 210, // mm
|
|
|
+ a4Height: 297, // mm
|
|
|
+ margin: 15, // mm each side
|
|
|
+ contentWidth: 180, // 210 - 30 (margins)
|
|
|
+ contentHeight: 267, // 297 - 30 (margins)
|
|
|
+ windowWidth: 1000, // html2canvas config
|
|
|
+ scale: 2 // html2canvas scale factor
|
|
|
+ };
|
|
|
+
|
|
|
+ const PDF_EXPORT_DEBUG = true;
|
|
|
+ let activePdfExport = null;
|
|
|
+
|
|
|
+ class PdfExportCancelledError extends Error {
|
|
|
+ constructor() {
|
|
|
+ super("PDF generation cancelled.");
|
|
|
+ this.name = "PdfExportCancelledError";
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function logPdfExportDebug(...args) {
|
|
|
+ if (PDF_EXPORT_DEBUG) console.log(...args);
|
|
|
+ }
|
|
|
+
|
|
|
+ function throwIfPdfExportAborted(signal) {
|
|
|
+ if (signal && signal.aborted) {
|
|
|
+ throw new PdfExportCancelledError();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function runPdfAbortable(state, promise) {
|
|
|
+ throwIfPdfExportAborted(state.signal);
|
|
|
+
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ const handleAbort = () => reject(new PdfExportCancelledError());
|
|
|
+ state.signal.addEventListener("abort", handleAbort, { once: true });
|
|
|
+
|
|
|
+ Promise.resolve(promise)
|
|
|
+ .then(resolve, reject)
|
|
|
+ .finally(() => {
|
|
|
+ state.signal.removeEventListener("abort", handleAbort);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function formatPdfExportEta(ms) {
|
|
|
+ if (!Number.isFinite(ms) || ms <= 0) return "Calculating...";
|
|
|
+ const seconds = Math.ceil(ms / 1000);
|
|
|
+ if (seconds < 60) return `${seconds}s`;
|
|
|
+ const minutes = Math.floor(seconds / 60);
|
|
|
+ const remainder = seconds % 60;
|
|
|
+ return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function createPdfProgressState() {
|
|
|
+ const abortController = new AbortController();
|
|
|
+ const overlay = document.createElement("div");
|
|
|
+ overlay.className = "pdf-progress-overlay";
|
|
|
+ overlay.setAttribute("role", "dialog");
|
|
|
+ overlay.setAttribute("aria-modal", "true");
|
|
|
+ overlay.setAttribute("aria-labelledby", "pdf-progress-title");
|
|
|
+
|
|
|
+ overlay.innerHTML = `
|
|
|
+ <div class="pdf-progress-modal">
|
|
|
+ <div class="pdf-progress-header">
|
|
|
+ <p class="pdf-progress-title" id="pdf-progress-title">Generating PDF</p>
|
|
|
+ <button type="button" class="modal-close-btn pdf-progress-cancel-icon" aria-label="Cancel PDF generation" title="Cancel PDF generation">
|
|
|
+ <i class="bi bi-x-lg"></i>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <div class="pdf-progress-percent">0%</div>
|
|
|
+ <div class="pdf-progress-track"
|
|
|
+ role="progressbar"
|
|
|
+ aria-label="PDF generation progress"
|
|
|
+ aria-valuemin="0"
|
|
|
+ aria-valuemax="100"
|
|
|
+ aria-valuenow="0">
|
|
|
+ <div class="pdf-progress-fill"></div>
|
|
|
+ </div>
|
|
|
+ <div class="pdf-progress-details">
|
|
|
+ <div class="pdf-progress-detail">
|
|
|
+ <span>Current Step</span>
|
|
|
+ <strong class="pdf-progress-step">Preparing</strong>
|
|
|
+ </div>
|
|
|
+ <div class="pdf-progress-detail">
|
|
|
+ <span>Estimated remaining</span>
|
|
|
+ <strong class="pdf-progress-eta">Calculating...</strong>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="pdf-progress-actions">
|
|
|
+ <button type="button" class="reset-modal-btn reset-modal-cancel pdf-progress-cancel">Cancel</button>
|
|
|
+ </div>
|
|
|
+ </div>`;
|
|
|
+
|
|
|
+ const state = {
|
|
|
+ abortController,
|
|
|
+ signal: abortController.signal,
|
|
|
+ startedAt: performance.now(),
|
|
|
+ overlay,
|
|
|
+ fill: overlay.querySelector(".pdf-progress-fill"),
|
|
|
+ percentText: overlay.querySelector(".pdf-progress-percent"),
|
|
|
+ progressBar: overlay.querySelector(".pdf-progress-track"),
|
|
|
+ stepText: overlay.querySelector(".pdf-progress-step"),
|
|
|
+ etaText: overlay.querySelector(".pdf-progress-eta"),
|
|
|
+ cancelButtons: overlay.querySelectorAll(".pdf-progress-cancel, .pdf-progress-cancel-icon"),
|
|
|
+ triggerHtml: new Map(),
|
|
|
+ tempElement: null,
|
|
|
+ cleanedUp: false
|
|
|
+ };
|
|
|
+
|
|
|
+ state.cancelButtons.forEach(button => {
|
|
|
+ button.addEventListener("click", () => cancelPdfExport(state));
|
|
|
+ });
|
|
|
+
|
|
|
+ return state;
|
|
|
+ }
|
|
|
+
|
|
|
+ function updatePdfProgress(state, percent, step) {
|
|
|
+ if (!state || state.cleanedUp) return;
|
|
|
+ const nextPercent = Math.max(0, Math.min(100, Math.round(percent)));
|
|
|
+ state.fill.style.width = `${nextPercent}%`;
|
|
|
+ state.percentText.textContent = `${nextPercent}%`;
|
|
|
+ state.progressBar.setAttribute("aria-valuenow", String(nextPercent));
|
|
|
+ state.stepText.textContent = step;
|
|
|
+
|
|
|
+ const elapsed = performance.now() - state.startedAt;
|
|
|
+ const eta = nextPercent > 5 && nextPercent < 100
|
|
|
+ ? (elapsed / nextPercent) * (100 - nextPercent)
|
|
|
+ : 0;
|
|
|
+ state.etaText.textContent = nextPercent >= 100 ? "Complete" : formatPdfExportEta(eta);
|
|
|
+ }
|
|
|
+
|
|
|
+ function setPdfExportTriggersBusy(state, busy) {
|
|
|
+ const triggers = [exportPdf, mobileExportPdf].filter(Boolean);
|
|
|
+ triggers.forEach((trigger, index) => {
|
|
|
+ if (busy) {
|
|
|
+ state.triggerHtml.set(trigger, trigger.innerHTML);
|
|
|
+ trigger.innerHTML = index === 0
|
|
|
+ ? '<i class="bi bi-hourglass-split"></i> Generating...'
|
|
|
+ : '<i class="bi bi-hourglass-split me-2"></i> Generating PDF...';
|
|
|
+ trigger.classList.add("pdf-export-loading");
|
|
|
+ trigger.setAttribute("aria-disabled", "true");
|
|
|
+ trigger.disabled = true;
|
|
|
+ } else {
|
|
|
+ if (state.triggerHtml.has(trigger)) {
|
|
|
+ trigger.innerHTML = state.triggerHtml.get(trigger);
|
|
|
+ }
|
|
|
+ trigger.classList.remove("pdf-export-loading");
|
|
|
+ trigger.removeAttribute("aria-disabled");
|
|
|
+ trigger.disabled = false;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function cleanupPdfExport(state) {
|
|
|
+ if (!state || state.cleanedUp) return;
|
|
|
+ state.cleanedUp = true;
|
|
|
+
|
|
|
+ if (state.tempElement && state.tempElement.parentNode) {
|
|
|
+ if (window.keepTempElementForAudit) {
|
|
|
+ window.auditedTempElement = state.tempElement;
|
|
|
+ console.log("Skipped tempElement removal for audit");
|
|
|
+ } else {
|
|
|
+ state.tempElement.parentNode.removeChild(state.tempElement);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (state.overlay && state.overlay.parentNode) {
|
|
|
+ state.overlay.parentNode.removeChild(state.overlay);
|
|
|
+ }
|
|
|
+
|
|
|
+ setPdfExportTriggersBusy(state, false);
|
|
|
+ if (activePdfExport === state) {
|
|
|
+ activePdfExport = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function cancelPdfExport(state) {
|
|
|
+ if (!state || state.signal.aborted) return;
|
|
|
+ state.abortController.abort();
|
|
|
+ cleanupPdfExport(state);
|
|
|
+ }
|
|
|
+
|
|
|
+ async function waitForPdfFrame(state) {
|
|
|
+ throwIfPdfExportAborted(state.signal);
|
|
|
+ await new Promise(resolve => requestAnimationFrame(resolve));
|
|
|
+ throwIfPdfExportAborted(state.signal);
|
|
|
+ }
|
|
|
+
|
|
|
+ function markdownLikelyContainsMath(markdown) {
|
|
|
+ return /(^|[^\\])\$\$|\\\[|\\\(|(^|[^\\])\$[^$\n]+\$/.test(markdown);
|
|
|
+ }
|
|
|
+
|
|
|
+ function choosePdfCanvasScale(element) {
|
|
|
+ const pixelArea = element.offsetWidth * element.scrollHeight;
|
|
|
+ if (pixelArea > 14000000) return 1.25;
|
|
|
+ if (pixelArea > 8000000) return 1.5;
|
|
|
+ return PAGE_CONFIG.scale;
|
|
|
+ }
|
|
|
+
|
|
|
+ function readPixelStyle(element, propertyName) {
|
|
|
+ const value = window.getComputedStyle(element).getPropertyValue(propertyName);
|
|
|
+ return parseFloat(value) || 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ function fitExportElementToContent(element) {
|
|
|
+ if (!element) return false;
|
|
|
+
|
|
|
+ const overflow = element.scrollWidth - element.clientWidth;
|
|
|
+ if (overflow <= 1) return false;
|
|
|
+
|
|
|
+ const paddingLeft = readPixelStyle(element, 'padding-left');
|
|
|
+ const paddingRight = readPixelStyle(element, 'padding-right');
|
|
|
+ const borderLeft = readPixelStyle(element, 'border-left-width');
|
|
|
+ const borderRight = readPixelStyle(element, 'border-right-width');
|
|
|
+ const boxSizing = window.getComputedStyle(element).boxSizing;
|
|
|
+
|
|
|
+ const requiredWidth = boxSizing === 'border-box'
|
|
|
+ ? Math.ceil(element.scrollWidth + borderLeft + borderRight)
|
|
|
+ : Math.ceil(element.scrollWidth - paddingLeft - paddingRight);
|
|
|
+
|
|
|
+ element.style.width = `${requiredWidth}px`;
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Task 1: Identifies all graphic elements that may need page-break handling
|
|
|
+ * @param {HTMLElement} container - The container element to search within
|
|
|
+ * @returns {Array} Array of {element, type} objects
|
|
|
+ */
|
|
|
+ function identifyGraphicElements(container) {
|
|
|
+ const graphics = [];
|
|
|
+
|
|
|
+ // Query all targeting elements in precise DOM layout flow order
|
|
|
+ container.querySelectorAll('img, svg, pre, table, p, li, h1, h2, h3, h4, h5, h6, blockquote, hr, .math-block, mjx-container[display="true"]').forEach(el => {
|
|
|
+ const tag = el.tagName.toLowerCase();
|
|
|
+
|
|
|
+ // Skip any elements nested inside blockquotes to treat blockquotes as atomic containers
|
|
|
+ if (el.parentElement && el.parentElement.closest('blockquote')) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Skip any elements nested inside list items that contain block children (treat list items as atomic)
|
|
|
+ if (el.parentElement) {
|
|
|
+ const liAncestor = el.parentElement.closest('li');
|
|
|
+ if (liAncestor) {
|
|
|
+ const hasBlockChildren = liAncestor.querySelector('p, blockquote, pre, table, ul, ol') !== null;
|
|
|
+ if (hasBlockChildren) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ let type = '';
|
|
|
+
|
|
|
+ if (tag === 'img') type = 'img';
|
|
|
+ else if (tag === 'svg') {
|
|
|
+ if (!el.closest('mjx-container, .math-block')) {
|
|
|
+ type = 'svg';
|
|
|
+ }
|
|
|
+ }
|
|
|
+ else if (tag === 'pre') type = 'pre';
|
|
|
+ else if (tag === 'table') type = 'table';
|
|
|
+ else if (tag === 'hr') type = 'hr';
|
|
|
+ else if (tag === 'blockquote') {
|
|
|
+ type = 'blockquote';
|
|
|
+ }
|
|
|
+ else if (['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag)) {
|
|
|
+ type = 'text';
|
|
|
+ } else if (tag === 'li') {
|
|
|
+ // Treat list items with block children as atomic containers, otherwise treat as text
|
|
|
+ const hasBlockChildren = el.querySelector('p, blockquote, pre, table, ul, ol') !== null;
|
|
|
+ if (hasBlockChildren) {
|
|
|
+ type = 'li';
|
|
|
+ } else {
|
|
|
+ type = 'text';
|
|
|
+ }
|
|
|
+ } else if (el.classList.contains('math-block') || tag === 'mjx-container') {
|
|
|
+ type = 'math';
|
|
|
+ }
|
|
|
+
|
|
|
+ if (type) {
|
|
|
+ graphics.push({ element: el, type: type });
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ return graphics;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Calculates the computed line-height of a text element, defaulting based on tag if "normal"
|
|
|
+ * @param {HTMLElement} element
|
|
|
+ * @returns {number} The line-height in pixels
|
|
|
+ */
|
|
|
+ function getElementLineHeight(element) {
|
|
|
+ const style = window.getComputedStyle(element);
|
|
|
+ const fontSize = parseFloat(style.fontSize) || 14;
|
|
|
+ let lineHeight = parseFloat(style.lineHeight);
|
|
|
+
|
|
|
+ if (isNaN(lineHeight)) {
|
|
|
+ const tag = element.tagName.toLowerCase();
|
|
|
+ if (tag.startsWith('h')) {
|
|
|
+ lineHeight = fontSize * 1.25;
|
|
|
+ } else {
|
|
|
+ lineHeight = fontSize * 1.5;
|
|
|
+ }
|
|
|
+ } else if (lineHeight < 10) {
|
|
|
+ // Handle unitless line-height (e.g. "1.5")
|
|
|
+ lineHeight = lineHeight * fontSize;
|
|
|
+ }
|
|
|
+ return lineHeight;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Calculates the shift needed to align a split text element's lines with the page boundary
|
|
|
+ * @param {Object} item - The split element item
|
|
|
+ * @param {Array} pageBoundaries - Page break positions
|
|
|
+ * @returns {number} The shift in pixels
|
|
|
+ */
|
|
|
+ function calculateTextElementShift(item, pageBoundaries) {
|
|
|
+ const boundaryY = pageBoundaries[item.splitPageIndex];
|
|
|
+ if (boundaryY === undefined) return 0;
|
|
|
+
|
|
|
+ const element = item.element;
|
|
|
+ const style = window.getComputedStyle(element);
|
|
|
+
|
|
|
+ const tag = element.tagName.toLowerCase();
|
|
|
+ const isHeading = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag);
|
|
|
+
|
|
|
+ // Safety buffer (in pixels) to ensure text rendering, glyph ascenders,
|
|
|
+ // and sub-pixel anti-aliasing are pushed cleanly past the slicing boundary.
|
|
|
+ const SAFETY_BUFFER = 4;
|
|
|
+
|
|
|
+ // Headings should never be split. Push the entire heading to the next page.
|
|
|
+ if (isHeading) {
|
|
|
+ return (boundaryY - item.top) + SAFETY_BUFFER;
|
|
|
+ }
|
|
|
+
|
|
|
+ const paddingTop = parseFloat(style.paddingTop) || 0;
|
|
|
+ const borderTop = parseFloat(style.borderTopWidth) || 0;
|
|
|
+ const paddingBottom = parseFloat(style.paddingBottom) || 0;
|
|
|
+ const borderBottom = parseFloat(style.borderBottomWidth) || 0;
|
|
|
+
|
|
|
+ const lh = getElementLineHeight(element);
|
|
|
+ const contentTop = item.top + paddingTop + borderTop;
|
|
|
+ const contentHeight = item.height - paddingTop - paddingBottom - borderTop - borderBottom;
|
|
|
+ if (contentHeight <= 0) return 0;
|
|
|
+
|
|
|
+ const numLines = Math.max(1, Math.round(contentHeight / lh));
|
|
|
+
|
|
|
+ for (let i = 0; i < numLines; i++) {
|
|
|
+ const lineTop = contentTop + i * lh;
|
|
|
+ const lineBottom = contentTop + (i + 1) * lh;
|
|
|
+
|
|
|
+ // Check if this line is split by boundaryY (using a small tolerance to prevent float vibrations)
|
|
|
+ if (lineTop < boundaryY - 0.5 && lineBottom > boundaryY + 0.5) {
|
|
|
+ // Calculate shift to align the line's top with boundaryY, plus a safety buffer
|
|
|
+ return (boundaryY - lineTop) + SAFETY_BUFFER;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Fallback: If a short paragraph/list item is split across pages but no single line is cut
|
|
|
+ // (e.g. boundary falls exactly in padding/margin), push the whole element.
|
|
|
+ if (item.height <= lh * 3) {
|
|
|
+ return (boundaryY - item.top) + SAFETY_BUFFER;
|
|
|
+ }
|
|
|
+
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Task 2: Calculates element positions relative to the container
|
|
|
+ * @param {Array} elements - Array of {element, type} objects
|
|
|
+ * @param {HTMLElement} container - The container element
|
|
|
+ * @returns {Array} Array with position data added
|
|
|
+ */
|
|
|
+ function calculateElementPositions(elements, container) {
|
|
|
+ const containerRect = container.getBoundingClientRect();
|
|
|
+
|
|
|
+ return elements.map(item => {
|
|
|
+ const rect = item.element.getBoundingClientRect();
|
|
|
+ const top = rect.top - containerRect.top;
|
|
|
+ const height = rect.height;
|
|
|
+ const bottom = top + height;
|
|
|
+
|
|
|
+ return {
|
|
|
+ element: item.element,
|
|
|
+ type: item.type,
|
|
|
+ top: top,
|
|
|
+ height: height,
|
|
|
+ bottom: bottom
|
|
|
+ };
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Task 3: Calculates page boundary positions
|
|
|
+ * @param {number} totalHeight - Total height of content in pixels
|
|
|
+ * @param {number} elementWidth - Actual width of the rendered element in pixels
|
|
|
+ * @param {Object} pageConfig - Page configuration object
|
|
|
+ * @returns {Array} Array of y-coordinates where pages end
|
|
|
+ */
|
|
|
+ function calculatePageBoundaries(totalHeight, elementWidth, pageConfig) {
|
|
|
+ const aspectRatio = pageConfig.contentHeight / pageConfig.contentWidth;
|
|
|
+ const pageHeightPx = elementWidth * aspectRatio;
|
|
|
+
|
|
|
+ const boundaries = [];
|
|
|
+ let y = pageHeightPx;
|
|
|
+
|
|
|
+ while (y < totalHeight) {
|
|
|
+ boundaries.push(y);
|
|
|
+ y += pageHeightPx;
|
|
|
+ }
|
|
|
+
|
|
|
+ return { boundaries, pageHeightPx };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Task 4: Detects which elements would be split across page boundaries
|
|
|
+ * @param {Array} elements - Array of elements with position data
|
|
|
+ * @param {Array} pageBoundaries - Array of page break y-coordinates
|
|
|
+ * @returns {Array} Array of split elements with additional split info
|
|
|
+ */
|
|
|
+ function detectSplitElements(elements, pageBoundaries) {
|
|
|
+ if (!elements || elements.length === 0) return [];
|
|
|
+ if (!pageBoundaries || pageBoundaries.length === 0) return [];
|
|
|
+
|
|
|
+ const splitElements = [];
|
|
|
+
|
|
|
+ for (const item of elements) {
|
|
|
+ let startPage = 0;
|
|
|
+ for (let i = 0; i < pageBoundaries.length; i++) {
|
|
|
+ if (item.top >= pageBoundaries[i]) {
|
|
|
+ startPage = i + 1;
|
|
|
+ } else {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ let endPage = 0;
|
|
|
+ for (let i = 0; i < pageBoundaries.length; i++) {
|
|
|
+ if (item.bottom > pageBoundaries[i]) {
|
|
|
+ endPage = i + 1;
|
|
|
+ } else {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (endPage > startPage) {
|
|
|
+ const boundaryY = pageBoundaries[startPage] || pageBoundaries[0];
|
|
|
+ const overflowAmount = item.bottom - boundaryY;
|
|
|
+
|
|
|
+ splitElements.push({
|
|
|
+ element: item.element,
|
|
|
+ type: item.type,
|
|
|
+ top: item.top,
|
|
|
+ height: item.height,
|
|
|
+ splitPageIndex: startPage,
|
|
|
+ overflowAmount: overflowAmount
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return splitElements;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Task 5: Main entry point for analyzing graphics for page breaks
|
|
|
+ * @param {HTMLElement} tempElement - The rendered content container
|
|
|
+ * @returns {Object} Analysis result with totalElements, splitElements, pageCount
|
|
|
+ */
|
|
|
+ function analyzeGraphicsForPageBreaks(tempElement, signal) {
|
|
|
+ try {
|
|
|
+ throwIfPdfExportAborted(signal);
|
|
|
+
|
|
|
+ const graphics = identifyGraphicElements(tempElement);
|
|
|
+ const elementsWithPositions = calculateElementPositions(graphics, tempElement);
|
|
|
+
|
|
|
+ throwIfPdfExportAborted(signal);
|
|
|
+
|
|
|
+ const totalHeight = Math.ceil(tempElement.getBoundingClientRect().height);
|
|
|
+ const elementWidth = tempElement.offsetWidth;
|
|
|
+ const { boundaries: pageBoundaries, pageHeightPx } = calculatePageBoundaries(
|
|
|
+ totalHeight,
|
|
|
+ elementWidth,
|
|
|
+ PAGE_CONFIG
|
|
|
+ );
|
|
|
+
|
|
|
+ const splitElements = detectSplitElements(elementsWithPositions, pageBoundaries);
|
|
|
+ const pageCount = pageBoundaries.length + 1;
|
|
|
+
|
|
|
+ return {
|
|
|
+ totalElements: graphics.length,
|
|
|
+ splitElements: splitElements,
|
|
|
+ pageCount: pageCount,
|
|
|
+ pageBoundaries: pageBoundaries,
|
|
|
+ pageHeightPx: pageHeightPx
|
|
|
+ };
|
|
|
+ } catch (error) {
|
|
|
+ if (error instanceof PdfExportCancelledError) throw error;
|
|
|
+ console.error('Page-break analysis failed:', error);
|
|
|
+ return {
|
|
|
+ totalElements: 0,
|
|
|
+ splitElements: [],
|
|
|
+ pageCount: 1,
|
|
|
+ pageBoundaries: [],
|
|
|
+ pageHeightPx: 0
|
|
|
+ };
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================
|
|
|
+ // End Page-Break Detection Functions
|
|
|
+ // ============================================
|
|
|
+
|
|
|
+ // ============================================
|
|
|
+ // Page-Break Insertion Functions (Story 1.2)
|
|
|
+ // ============================================
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Resets temporary styles applied to graphics elements back to their original state.
|
|
|
+ * This is called at the start of each layout iteration in the cascade loop.
|
|
|
+ * @param {HTMLElement} container - The container element to process
|
|
|
+ */
|
|
|
+ function resetGraphicsStyles(container) {
|
|
|
+ // Remove all dynamically inserted page-break spacers
|
|
|
+ container.querySelectorAll('.pdf-page-break-spacer').forEach(el => el.remove());
|
|
|
+
|
|
|
+ container.querySelectorAll('[data-pdf-original-margin-top]').forEach(el => {
|
|
|
+ el.style.marginTop = el.dataset.pdfOriginalMarginTop;
|
|
|
+ el.removeAttribute('data-pdf-original-margin-top');
|
|
|
+ });
|
|
|
+ container.querySelectorAll('[data-pdf-original-margin-bottom]').forEach(el => {
|
|
|
+ el.style.marginBottom = el.dataset.pdfOriginalMarginBottom;
|
|
|
+ el.removeAttribute('data-pdf-original-margin-bottom');
|
|
|
+ });
|
|
|
+ container.querySelectorAll('[data-pdf-original-transform]').forEach(el => {
|
|
|
+ el.style.transform = el.dataset.pdfOriginalTransform;
|
|
|
+ el.style.transformOrigin = '';
|
|
|
+ el.removeAttribute('data-pdf-original-transform');
|
|
|
+ });
|
|
|
+ container.querySelectorAll('[data-pdf-original-width]').forEach(el => {
|
|
|
+ el.style.width = el.dataset.pdfOriginalWidth;
|
|
|
+ el.removeAttribute('data-pdf-original-width');
|
|
|
+ });
|
|
|
+ container.querySelectorAll('[data-pdf-original-height]').forEach(el => {
|
|
|
+ el.style.height = el.dataset.pdfOriginalHeight;
|
|
|
+ el.removeAttribute('data-pdf-original-height');
|
|
|
+ });
|
|
|
+ container.querySelectorAll('[data-pdf-original-max-width]').forEach(el => {
|
|
|
+ el.style.maxWidth = el.dataset.pdfOriginalMaxWidth;
|
|
|
+ el.removeAttribute('data-pdf-original-max-width');
|
|
|
+ });
|
|
|
+ container.querySelectorAll('[data-pdf-original-font-size]').forEach(el => {
|
|
|
+ el.style.fontSize = el.dataset.pdfOriginalFontSize;
|
|
|
+ el.removeAttribute('data-pdf-original-font-size');
|
|
|
+ });
|
|
|
+ container.querySelectorAll('[data-pdf-original-overflow]').forEach(el => {
|
|
|
+ el.style.overflow = el.dataset.pdfOriginalOverflow;
|
|
|
+ el.removeAttribute('data-pdf-original-overflow');
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function mergeSplitTables(container) {
|
|
|
+ const groupIds = new Set();
|
|
|
+ container.querySelectorAll('table[data-split-group-id]').forEach(t => {
|
|
|
+ if (t.dataset.splitGroupId) {
|
|
|
+ groupIds.add(t.dataset.splitGroupId);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ for (const groupId of groupIds) {
|
|
|
+ const originalTable = container.querySelector(`table[data-split-group-id="${groupId}"]:not([data-split-part="true"])`);
|
|
|
+ if (!originalTable) continue;
|
|
|
+
|
|
|
+ const parts = Array.from(container.querySelectorAll(`table[data-split-group-id="${groupId}"][data-split-part="true"]`));
|
|
|
+ const tbody = originalTable.tBodies[0] || originalTable.querySelector('tbody') || originalTable;
|
|
|
+
|
|
|
+ for (const part of parts) {
|
|
|
+ const partTbody = part.tBodies[0] || part.querySelector('tbody') || part;
|
|
|
+ const rows = Array.from(partTbody.children).filter(child => child.tagName.toLowerCase() === 'tr');
|
|
|
+ for (const row of rows) {
|
|
|
+ tbody.appendChild(row);
|
|
|
+ }
|
|
|
+ part.remove();
|
|
|
+ }
|
|
|
+
|
|
|
+ const spacers = Array.from(container.querySelectorAll(`div[data-split-group-id="${groupId}"][data-split-spacer="true"]`));
|
|
|
+ for (const spacer of spacers) {
|
|
|
+ spacer.remove();
|
|
|
+ }
|
|
|
+
|
|
|
+ originalTable.removeAttribute('data-split-group-id');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function splitTables(container, pageHeightPx) {
|
|
|
+ mergeSplitTables(container);
|
|
|
+
|
|
|
+ const tables = Array.from(container.querySelectorAll('table'));
|
|
|
+ let groupCounter = 0;
|
|
|
+
|
|
|
+ for (const table of tables) {
|
|
|
+ if (table.dataset.splitPart === "true") continue;
|
|
|
+ const tableRect = table.getBoundingClientRect();
|
|
|
+ if (tableRect.height <= pageHeightPx) continue;
|
|
|
+
|
|
|
+ const tbody = table.tBodies[0] || table.querySelector('tbody');
|
|
|
+ if (!tbody) continue;
|
|
|
+
|
|
|
+ const rows = Array.from(tbody.children).filter(child => child.tagName.toLowerCase() === 'tr');
|
|
|
+ if (rows.length === 0) continue;
|
|
|
+
|
|
|
+ const groupId = `table-group-${groupCounter++}`;
|
|
|
+ table.dataset.splitGroupId = groupId;
|
|
|
+ const containerRect = container.getBoundingClientRect();
|
|
|
+
|
|
|
+ const rowPositions = rows.map(row => {
|
|
|
+ const rect = row.getBoundingClientRect();
|
|
|
+ return {
|
|
|
+ row: row,
|
|
|
+ top: rect.top - containerRect.top,
|
|
|
+ bottom: rect.bottom - containerRect.top,
|
|
|
+ height: rect.height
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ let currentTable = table;
|
|
|
+ let currentTbody = tbody;
|
|
|
+ let accumulatedShift = 0;
|
|
|
+
|
|
|
+ for (let i = 0; i < rowPositions.length; i++) {
|
|
|
+ const pos = rowPositions[i];
|
|
|
+ const shiftedTop = pos.top + accumulatedShift;
|
|
|
+ const shiftedBottom = pos.bottom + accumulatedShift;
|
|
|
+ const currentPageIndex = Math.floor(shiftedTop / pageHeightPx);
|
|
|
+ const nextPageBoundary = (currentPageIndex + 1) * pageHeightPx;
|
|
|
+
|
|
|
+ if (shiftedBottom > nextPageBoundary) {
|
|
|
+ const spacerHeight = nextPageBoundary - shiftedTop;
|
|
|
+ const originalThead = table.querySelector('thead');
|
|
|
+ const theadHeight = originalThead ? originalThead.getBoundingClientRect().height : 0;
|
|
|
+ accumulatedShift += spacerHeight + theadHeight;
|
|
|
+
|
|
|
+ const nextTable = table.cloneNode(false);
|
|
|
+ nextTable.removeAttribute('id');
|
|
|
+ nextTable.dataset.splitGroupId = groupId;
|
|
|
+ nextTable.dataset.splitPart = "true";
|
|
|
+
|
|
|
+ if (originalThead) nextTable.appendChild(originalThead.cloneNode(true));
|
|
|
+ const nextTbody = document.createElement('tbody');
|
|
|
+ nextTable.appendChild(nextTbody);
|
|
|
+
|
|
|
+ const spacer = document.createElement('div');
|
|
|
+ spacer.className = 'table-page-break-spacer';
|
|
|
+ spacer.dataset.splitGroupId = groupId;
|
|
|
+ spacer.dataset.splitSpacer = "true";
|
|
|
+ spacer.style.height = `${spacerHeight}px`;
|
|
|
+ spacer.style.margin = '0';
|
|
|
+ spacer.style.padding = '0';
|
|
|
+ spacer.style.border = 'none';
|
|
|
+
|
|
|
+ currentTable.parentNode.insertBefore(spacer, currentTable.nextSibling);
|
|
|
+ spacer.parentNode.insertBefore(nextTable, spacer.nextSibling);
|
|
|
+ currentTable = nextTable;
|
|
|
+ currentTbody = nextTbody;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (currentTable !== table) currentTbody.appendChild(pos.row);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function applyPageBreaksWithCascade(tempElement, pageConfig, maxIterations = 10, signal) {
|
|
|
+ let iteration = 0;
|
|
|
+ let analysis;
|
|
|
+
|
|
|
+ const elementWidth = tempElement.offsetWidth;
|
|
|
+ const aspectRatio = pageConfig.contentHeight / pageConfig.contentWidth;
|
|
|
+ const pageHeightPx = elementWidth * aspectRatio;
|
|
|
+
|
|
|
+ const lastAdjustments = new Map(); // Store {margin, scale} for each element
|
|
|
+
|
|
|
+ do {
|
|
|
+ throwIfPdfExportAborted(signal);
|
|
|
+
|
|
|
+ // Reset graphics element styles applied in previous iterations
|
|
|
+ resetGraphicsStyles(tempElement);
|
|
|
+
|
|
|
+ // Split tables at page breaks dynamically using calculated pageHeightPx
|
|
|
+ splitTables(tempElement, pageHeightPx);
|
|
|
+
|
|
|
+ // We must get ALL target elements in document order
|
|
|
+ const graphics = identifyGraphicElements(tempElement);
|
|
|
+ const elementsWithPositions = calculateElementPositions(graphics, tempElement);
|
|
|
+
|
|
|
+ // Get page boundaries based on current height
|
|
|
+ const totalHeight = Math.ceil(tempElement.getBoundingClientRect().height);
|
|
|
+ const { boundaries: pageBoundaries, pageHeightPx: pageHeightPxFromAnalysis } = calculatePageBoundaries(
|
|
|
+ totalHeight,
|
|
|
+ elementWidth,
|
|
|
+ pageConfig
|
|
|
+ );
|
|
|
+
|
|
|
+ let adjustmentsMade = false;
|
|
|
+ let accumulatedShift = 0;
|
|
|
+ const currentIterationAdjustments = new Map();
|
|
|
+
|
|
|
+ for (const item of elementsWithPositions) {
|
|
|
+ throwIfPdfExportAborted(signal);
|
|
|
+
|
|
|
+ const currentTop = item.top + accumulatedShift;
|
|
|
+ const currentBottom = currentTop + item.height;
|
|
|
+
|
|
|
+ let targetMargin = 0;
|
|
|
+ let targetScale = 1.0;
|
|
|
+
|
|
|
+ // 1. Heading Keep-With-Next Rule (must run for all headings regardless of split)
|
|
|
+ const tag = item.element.tagName.toLowerCase();
|
|
|
+ const isHeading = item.type === 'text' && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag);
|
|
|
+
|
|
|
+ if (isHeading) {
|
|
|
+ let nextBoundaryY = null;
|
|
|
+ for (const boundary of pageBoundaries) {
|
|
|
+ if (currentTop < boundary) {
|
|
|
+ nextBoundaryY = boundary;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (nextBoundaryY !== null) {
|
|
|
+ const distanceToBoundary = nextBoundaryY - currentTop;
|
|
|
+ if (distanceToBoundary < 70) {
|
|
|
+ targetMargin = distanceToBoundary + 4; // Push heading entirely to next page
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. If not already pushed by Keep-With-Next, perform standard page-split calculations
|
|
|
+ if (targetMargin === 0) {
|
|
|
+ // Check if this element crosses any page boundary or starts extremely close to it (sub-pixel safety)
|
|
|
+ let splitPageIndex = -1;
|
|
|
+ for (let i = 0; i < pageBoundaries.length; i++) {
|
|
|
+ if (currentTop < pageBoundaries[i] + 12 && currentBottom > pageBoundaries[i]) {
|
|
|
+ splitPageIndex = i;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (splitPageIndex !== -1) {
|
|
|
+ const boundaryY = pageBoundaries[splitPageIndex];
|
|
|
+ const remainingSpace = boundaryY - currentTop;
|
|
|
+
|
|
|
+ if (item.type === 'text') {
|
|
|
+ const shiftedItem = { ...item, top: currentTop, splitPageIndex: splitPageIndex };
|
|
|
+ const shift = calculateTextElementShift(shiftedItem, pageBoundaries);
|
|
|
+ if (shift > 0.5) {
|
|
|
+ targetMargin = shift;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // Graphic element splitting (with larger buffer to ensure complete clearance)
|
|
|
+ const buffer = 15;
|
|
|
+ const scaleNeeded = (remainingSpace - buffer) / item.height;
|
|
|
+ const remainingSpacePercent = remainingSpace / pageHeightPxFromAnalysis;
|
|
|
+
|
|
|
+ const isTextContainer = ['blockquote', 'li', 'table', 'pre', 'math'].includes(item.type);
|
|
|
+
|
|
|
+ // Fit on current page by scaling if it's an image/svg and space/scale are acceptable.
|
|
|
+ // Otherwise, always push text/block containers to next page to prevent transform-scaling bugs.
|
|
|
+ if (!isTextContainer && remainingSpacePercent >= 0.20 && scaleNeeded >= 0.6) {
|
|
|
+ targetScale = Math.min(1.0, scaleNeeded);
|
|
|
+ } else {
|
|
|
+ // Push to next page
|
|
|
+ const marginNeeded = boundaryY - currentTop + buffer;
|
|
|
+ targetMargin = marginNeeded;
|
|
|
+
|
|
|
+ // Check if it fits the next page height after being pushed (Rule 3 Case C)
|
|
|
+ const newTop = currentTop + marginNeeded;
|
|
|
+ const newBottom = newTop + item.height;
|
|
|
+ const nextBoundaryY = pageBoundaries[splitPageIndex + 1] || (boundaryY + pageHeightPxFromAnalysis);
|
|
|
+ if (newBottom > nextBoundaryY) {
|
|
|
+ const scaleToFitPage = (pageHeightPxFromAnalysis - 20) / item.height;
|
|
|
+ targetScale = Math.max(0.5, Math.min(1.0, scaleToFitPage));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // Element is not split. But graphic elements taller than a page must still scale to fit!
|
|
|
+ if (item.type !== 'text' && item.height > pageHeightPxFromAnalysis) {
|
|
|
+ const scaleToFitPage = (pageHeightPxFromAnalysis - 20) / item.height;
|
|
|
+ targetScale = Math.max(0.5, Math.min(1.0, scaleToFitPage));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check if this calculated adjustment is different from the previous iteration
|
|
|
+ const prevAdjustment = lastAdjustments.get(item.element) || { margin: 0, scale: 1.0 };
|
|
|
+ if (Math.abs(targetMargin - prevAdjustment.margin) > 0.1 || Math.abs(targetScale - prevAdjustment.scale) > 0.001) {
|
|
|
+ adjustmentsMade = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ currentIterationAdjustments.set(item.element, { margin: targetMargin, scale: targetScale });
|
|
|
+
|
|
|
+ // Apply style adjustments to the DOM
|
|
|
+ if (targetMargin > 0) {
|
|
|
+ let targetElement = item.element;
|
|
|
+
|
|
|
+ // Redirect inline image or svg margins to their parent block element if nested
|
|
|
+ if (item.type === 'svg' && item.element.parentElement) {
|
|
|
+ const parent = item.element.parentElement;
|
|
|
+ if (['p', 'li', 'blockquote'].includes(parent.tagName.toLowerCase())) {
|
|
|
+ targetElement = parent;
|
|
|
+ } else {
|
|
|
+ targetElement = parent;
|
|
|
+ }
|
|
|
+ } else if (item.type === 'img' && item.element.parentElement) {
|
|
|
+ const parent = item.element.parentElement;
|
|
|
+ if (item.element.classList.contains('mermaid-img')) {
|
|
|
+ if (parent.parentElement && parent.parentElement.classList.contains('mermaid-container')) {
|
|
|
+ targetElement = parent.parentElement;
|
|
|
+ } else {
|
|
|
+ targetElement = parent;
|
|
|
+ }
|
|
|
+ } else if (['p', 'li', 'blockquote'].includes(parent.tagName.toLowerCase())) {
|
|
|
+ targetElement = parent;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // If target is a list item, apply marginTop directly to avoid invalid HTML / collapsed spacers
|
|
|
+ if (targetElement.tagName.toLowerCase() === 'li') {
|
|
|
+ if (!targetElement.dataset.hasOwnProperty('pdfOriginalMarginTop')) {
|
|
|
+ targetElement.dataset.pdfOriginalMarginTop = targetElement.style.marginTop || '';
|
|
|
+ }
|
|
|
+ targetElement.style.marginTop = `${targetMargin}px`;
|
|
|
+ } else {
|
|
|
+ // Create a physical spacer element to avoid margin collapse issues entirely
|
|
|
+ const spacer = document.createElement('div');
|
|
|
+ spacer.className = 'pdf-page-break-spacer';
|
|
|
+ spacer.style.height = `${targetMargin}px`;
|
|
|
+ spacer.style.margin = '0';
|
|
|
+ spacer.style.padding = '0';
|
|
|
+ spacer.style.border = 'none';
|
|
|
+ spacer.style.display = 'block';
|
|
|
+
|
|
|
+ targetElement.parentNode.insertBefore(spacer, targetElement);
|
|
|
+ }
|
|
|
+ accumulatedShift += targetMargin;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (targetScale < 1.0) {
|
|
|
+ applyGraphicScaling(item.element, targetScale, item.type);
|
|
|
+ const heightSaved = item.height * (1.0 - targetScale);
|
|
|
+ accumulatedShift -= heightSaved;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Copy current adjustments to lastAdjustments
|
|
|
+ lastAdjustments.clear();
|
|
|
+ for (const [el, adj] of currentIterationAdjustments) {
|
|
|
+ lastAdjustments.set(el, adj);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!adjustmentsMade) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ iteration++;
|
|
|
+ } while (iteration < maxIterations);
|
|
|
+
|
|
|
+ if (iteration >= maxIterations) {
|
|
|
+ console.warn('Page-break stabilization reached max iterations:', maxIterations);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Return the final page boundaries and height analysis for the export flow
|
|
|
+ analysis = analyzeGraphicsForPageBreaks(tempElement, signal);
|
|
|
+
|
|
|
+ logPdfExportDebug('Page-break cascade complete:', {
|
|
|
+ iterations: iteration,
|
|
|
+ finalSplitCount: analysis.splitElements.length
|
|
|
+ });
|
|
|
+
|
|
|
+ return analysis;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================
|
|
|
+ // End Page-Break Insertion Functions
|
|
|
+ // ============================================
|
|
|
+
|
|
|
+ // ============================================
|
|
|
+ // Oversized Graphics Scaling Functions (Story 1.3)
|
|
|
+ // ============================================
|
|
|
+
|
|
|
+ const MIN_SCALE_FACTOR = 0.5;
|
|
|
+
|
|
|
+ function calculateScaleFactor(elementHeight, availableHeight, buffer = 5) {
|
|
|
+ const targetHeight = availableHeight - buffer;
|
|
|
+ let scaleFactor = targetHeight / elementHeight;
|
|
|
+ let wasClampedToMin = false;
|
|
|
+
|
|
|
+ if (scaleFactor < MIN_SCALE_FACTOR) {
|
|
|
+ console.warn(
|
|
|
+ `Warning: Large graphic requires ${(scaleFactor * 100).toFixed(0)}% scaling. ` +
|
|
|
+ `Clamping to minimum ${MIN_SCALE_FACTOR * 100}%. Content may be cut off.`
|
|
|
+ );
|
|
|
+ scaleFactor = MIN_SCALE_FACTOR;
|
|
|
+ wasClampedToMin = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ return { scaleFactor, wasClampedToMin };
|
|
|
+ }
|
|
|
+
|
|
|
+ function applyGraphicScaling(element, scaleFactor, elementType) {
|
|
|
+ if (!element.dataset.hasOwnProperty('pdfOriginalTransform')) {
|
|
|
+ element.dataset.pdfOriginalTransform = element.style.transform || '';
|
|
|
+ }
|
|
|
+ if (!element.dataset.hasOwnProperty('pdfOriginalMarginBottom')) {
|
|
|
+ element.dataset.pdfOriginalMarginBottom = element.style.marginBottom || '';
|
|
|
+ }
|
|
|
+
|
|
|
+ if (elementType === 'svg' || elementType === 'img') {
|
|
|
+ if (!element.dataset.hasOwnProperty('pdfOriginalWidth')) {
|
|
|
+ element.dataset.pdfOriginalWidth = element.style.width || '';
|
|
|
+ }
|
|
|
+ if (!element.dataset.hasOwnProperty('pdfOriginalHeight')) {
|
|
|
+ element.dataset.pdfOriginalHeight = element.style.height || '';
|
|
|
+ }
|
|
|
+ if (!element.dataset.hasOwnProperty('pdfOriginalMaxWidth')) {
|
|
|
+ element.dataset.pdfOriginalMaxWidth = element.style.maxWidth || '';
|
|
|
+ }
|
|
|
+
|
|
|
+ let origWidth = parseFloat(element.dataset.pdfOriginalClientWidth);
|
|
|
+ let origHeight = parseFloat(element.dataset.pdfOriginalClientHeight);
|
|
|
+
|
|
|
+ if (isNaN(origWidth) || isNaN(origHeight)) {
|
|
|
+ origWidth = element.clientWidth || element.getBoundingClientRect().width;
|
|
|
+ origHeight = element.clientHeight || element.getBoundingClientRect().height;
|
|
|
+ element.dataset.pdfOriginalClientWidth = String(origWidth);
|
|
|
+ element.dataset.pdfOriginalClientHeight = String(origHeight);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Apply physical scale
|
|
|
+ element.style.width = `${origWidth * scaleFactor}px`;
|
|
|
+ element.style.height = `${origHeight * scaleFactor}px`;
|
|
|
+ if (elementType === 'svg') {
|
|
|
+ element.style.maxWidth = 'none';
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // For pre, table, blockquote, math, li, etc.
|
|
|
+ // Use transform: scale combined with physical height and overflow hidden to guarantee no native splits
|
|
|
+ if (!element.dataset.hasOwnProperty('pdfOriginalHeight')) {
|
|
|
+ element.dataset.pdfOriginalHeight = element.style.height || '';
|
|
|
+ }
|
|
|
+ if (!element.dataset.hasOwnProperty('pdfOriginalOverflow')) {
|
|
|
+ element.dataset.pdfOriginalOverflow = element.style.overflow || '';
|
|
|
+ }
|
|
|
+
|
|
|
+ element.style.transform = `scale(${scaleFactor})`;
|
|
|
+ element.style.transformOrigin = 'top left';
|
|
|
+
|
|
|
+ let origHeight = parseFloat(element.dataset.pdfOriginalClientHeight);
|
|
|
+ if (isNaN(origHeight)) {
|
|
|
+ origHeight = element.offsetHeight || element.getBoundingClientRect().height;
|
|
|
+ element.dataset.pdfOriginalClientHeight = String(origHeight);
|
|
|
+ }
|
|
|
+
|
|
|
+ const scaledHeight = origHeight * scaleFactor;
|
|
|
+ element.style.height = `${scaledHeight}px`;
|
|
|
+ element.style.overflow = 'hidden';
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ function waitForAllImages(container) {
|
|
|
+ const imgs = Array.from(container.querySelectorAll('img'));
|
|
|
+ const promises = imgs.map(img => {
|
|
|
+ if (img.complete) return Promise.resolve();
|
|
|
+ return new Promise(resolve => {
|
|
|
+ img.addEventListener('load', resolve, { once: true });
|
|
|
+ img.addEventListener('error', resolve, { once: true });
|
|
|
+ });
|
|
|
+ });
|
|
|
+ return Promise.all(promises);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================
|
|
|
+ // End Oversized Graphics Scaling Functions
|
|
|
+ // ============================================
|
|
|
+
|
|
|
+ exportPdf.addEventListener("click", async function (event) {
|
|
|
+ event.preventDefault();
|
|
|
+ logPdfExportDebug("PDF export button clicked!");
|
|
|
+ if (activePdfExport) {
|
|
|
+ logPdfExportDebug("PDF export already active, ignoring click");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const progressState = createPdfProgressState();
|
|
|
+ activePdfExport = progressState;
|
|
|
+ setPdfExportTriggersBusy(progressState, true);
|
|
|
+ document.body.appendChild(progressState.overlay);
|
|
|
+ updatePdfProgress(progressState, 3, "Starting");
|
|
|
+ progressState.overlay.querySelector(".pdf-progress-cancel")?.focus();
|
|
|
+
|
|
|
+ try {
|
|
|
+ logPdfExportDebug("PDF export try block entered. typeof jspdf:", typeof jspdf, "typeof html2canvas:", typeof html2canvas);
|
|
|
+ // PERF-002: Lazy-load PDF libraries on first export
|
|
|
+ if (typeof jspdf === 'undefined' || typeof html2canvas === 'undefined') {
|
|
|
+ logPdfExportDebug("Lazy loading PDF libraries started...");
|
|
|
+ updatePdfProgress(progressState, 8, "Loading PDF libraries");
|
|
|
+ await runPdfAbortable(progressState, Promise.all([
|
|
|
+ loadScript(CDN.jspdf).then(() => logPdfExportDebug("jspdf load callback fired")),
|
|
|
+ loadScript(CDN.html2canvas).then(() => logPdfExportDebug("html2canvas load callback fired"))
|
|
|
+ ]));
|
|
|
+ logPdfExportDebug("Lazy loading PDF libraries resolved.");
|
|
|
+ throwIfPdfExportAborted(progressState.signal);
|
|
|
+ }
|
|
|
+ logPdfExportDebug("Parsing markdown...");
|
|
|
+ updatePdfProgress(progressState, 15, "Parsing markdown");
|
|
|
+ await waitForPdfFrame(progressState);
|
|
|
+ const markdown = markdownEditor.value;
|
|
|
+ const html = marked.parse(markdown);
|
|
|
+ const sanitizedHtml = DOMPurify.sanitize(html, {
|
|
|
+ ADD_TAGS: ['mjx-container', 'svg', 'path', 'g', 'marker', 'defs', 'pattern', 'clipPath', 'input'],
|
|
|
+ ADD_ATTR: ['id', 'class', 'style', 'align', 'viewBox', 'd', 'fill', 'stroke', 'transform', 'marker-end', 'marker-start', 'type', 'checked', 'disabled', 'data-original-code'],
|
|
|
+ ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i
|
|
|
+ });
|
|
|
+ throwIfPdfExportAborted(progressState.signal);
|
|
|
+
|
|
|
+ updatePdfProgress(progressState, 24, "Preparing document");
|
|
|
+ await waitForPdfFrame(progressState);
|
|
|
+ const tempElement = document.createElement("div");
|
|
|
+ progressState.tempElement = tempElement;
|
|
|
+ tempElement.className = "markdown-body pdf-export";
|
|
|
+ tempElement.innerHTML = sanitizedHtml;
|
|
|
+ enhanceGitHubAlerts(tempElement);
|
|
|
+ tempElement.style.padding = "0px";
|
|
|
+ tempElement.style.width = "210mm";
|
|
|
+ tempElement.style.margin = "0 auto";
|
|
|
+ tempElement.style.fontSize = "14px";
|
|
|
+ tempElement.style.position = "fixed";
|
|
|
+ tempElement.style.left = "-9999px";
|
|
|
+ tempElement.style.top = "0";
|
|
|
+
|
|
|
+ const currentTheme = document.documentElement.getAttribute("data-theme");
|
|
|
+ tempElement.style.backgroundColor = currentTheme === "dark" ? "#0d1117" : "#ffffff";
|
|
|
+ tempElement.style.color = currentTheme === "dark" ? "#c9d1d9" : "#24292e";
|
|
|
+
|
|
|
+ document.body.appendChild(tempElement);
|
|
|
+ await waitForPdfFrame(progressState);
|
|
|
+
|
|
|
+ const mermaidNodes = tempElement.querySelectorAll('.mermaid');
|
|
|
+ if (mermaidNodes.length > 0) {
|
|
|
+ updatePdfProgress(progressState, 34, "Rendering diagrams");
|
|
|
+ try {
|
|
|
+ if (typeof mermaid === 'undefined') {
|
|
|
+ await runPdfAbortable(progressState, loadScript(CDN.mermaid));
|
|
|
+ }
|
|
|
+ throwIfPdfExportAborted(progressState.signal);
|
|
|
+ initMermaid(true);
|
|
|
+ await runPdfAbortable(progressState, mermaid.init(undefined, mermaidNodes));
|
|
|
+ tempElement.querySelectorAll('.mermaid-container.is-loading').forEach(container => {
|
|
|
+ container.classList.remove('is-loading');
|
|
|
+ });
|
|
|
+
|
|
|
+ // Convert all rendered Mermaid SVGs inside tempElement to <img> tags with data URI sources
|
|
|
+ const compiledMermaids = tempElement.querySelectorAll('.mermaid-container');
|
|
|
+ compiledMermaids.forEach(container => {
|
|
|
+ const svgElement = container.querySelector('svg');
|
|
|
+ if (svgElement) {
|
|
|
+ const rect = svgElement.getBoundingClientRect();
|
|
|
+ const width = rect.width || svgElement.clientWidth || parseFloat(svgElement.getAttribute('width')) || 600;
|
|
|
+ const height = rect.height || svgElement.clientHeight || parseFloat(svgElement.getAttribute('height')) || 400;
|
|
|
+
|
|
|
+ const clonedSvg = svgElement.cloneNode(true);
|
|
|
+ clonedSvg.setAttribute('width', width);
|
|
|
+ clonedSvg.setAttribute('height', height);
|
|
|
+ if (!clonedSvg.getAttribute('viewBox')) {
|
|
|
+ clonedSvg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
|
+ }
|
|
|
+ clonedSvg.style.width = `${width}px`;
|
|
|
+ clonedSvg.style.height = `${height}px`;
|
|
|
+
|
|
|
+ const svgString = new XMLSerializer().serializeToString(clonedSvg);
|
|
|
+ const svgBase64 = btoa(unescape(encodeURIComponent(svgString)));
|
|
|
+
|
|
|
+ const img = document.createElement('img');
|
|
|
+ img.className = 'mermaid-img';
|
|
|
+ if (svgElement.id) img.id = svgElement.id + '-img';
|
|
|
+ img.src = 'data:image/svg+xml;base64,' + svgBase64;
|
|
|
+
|
|
|
+ img.style.width = `${width}px`;
|
|
|
+ img.style.height = `${height}px`;
|
|
|
+ img.style.maxWidth = '100%';
|
|
|
+ img.style.display = 'block';
|
|
|
+ img.style.margin = '0 auto';
|
|
|
+
|
|
|
+ img.dataset.originalWidth = String(width);
|
|
|
+ img.dataset.originalHeight = String(height);
|
|
|
+
|
|
|
+ container.innerHTML = '';
|
|
|
+ container.appendChild(img);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ } catch (mermaidError) {
|
|
|
+ if (mermaidError instanceof PdfExportCancelledError) throw mermaidError;
|
|
|
+ console.warn("Mermaid rendering issue:", mermaidError);
|
|
|
+ tempElement.querySelectorAll('.mermaid-container.is-loading').forEach(container => {
|
|
|
+ container.classList.remove('is-loading');
|
|
|
+ });
|
|
|
+ }
|
|
|
+ throwIfPdfExportAborted(progressState.signal);
|
|
|
+ await waitForPdfFrame(progressState);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (window.MathJax && markdownLikelyContainsMath(markdown)) {
|
|
|
+ updatePdfProgress(progressState, 44, "Rendering math");
|
|
|
+ try {
|
|
|
+ await runPdfAbortable(progressState, MathJax.typesetPromise([tempElement]));
|
|
|
+ } catch (mathJaxError) {
|
|
|
+ if (mathJaxError instanceof PdfExportCancelledError) throw mathJaxError;
|
|
|
+ console.warn("MathJax rendering issue:", mathJaxError);
|
|
|
+ }
|
|
|
+ throwIfPdfExportAborted(progressState.signal);
|
|
|
+
|
|
|
+ // Hide MathJax assistive elements that cause duplicate text in PDF
|
|
|
+ // These are screen reader elements that html2canvas captures as visible
|
|
|
+ // Use multiple CSS properties to ensure html2canvas doesn't render them
|
|
|
+ const assistiveElements = tempElement.querySelectorAll('mjx-assistive-mml');
|
|
|
+ assistiveElements.forEach(el => {
|
|
|
+ el.style.display = 'none';
|
|
|
+ el.style.visibility = 'hidden';
|
|
|
+ el.style.position = 'absolute';
|
|
|
+ el.style.width = '0';
|
|
|
+ el.style.height = '0';
|
|
|
+ el.style.overflow = 'hidden';
|
|
|
+ el.remove(); // Remove entirely from DOM
|
|
|
+ });
|
|
|
+
|
|
|
+ // Also hide any MathJax script elements that might contain source
|
|
|
+ const mathScripts = tempElement.querySelectorAll('script[type*="math"], script[type*="tex"]');
|
|
|
+ mathScripts.forEach(el => el.remove());
|
|
|
+ }
|
|
|
+
|
|
|
+ await waitForPdfFrame(progressState);
|
|
|
+ fitExportElementToContent(tempElement);
|
|
|
+ await waitForPdfFrame(progressState);
|
|
|
+
|
|
|
+ // Await loading of all images and fonts (including converted Mermaid base64 images) before cascade sizing runs
|
|
|
+ updatePdfProgress(progressState, 50, "Loading document assets");
|
|
|
+ await runPdfAbortable(progressState, Promise.all([
|
|
|
+ waitForAllImages(tempElement),
|
|
|
+ document.fonts ? document.fonts.ready : Promise.resolve()
|
|
|
+ ]));
|
|
|
+ throwIfPdfExportAborted(progressState.signal);
|
|
|
+ await waitForPdfFrame(progressState);
|
|
|
+
|
|
|
+ // Analyze and apply page-breaks for graphics (Story 1.1 + 1.2)
|
|
|
+ updatePdfProgress(progressState, 55, "Optimizing page breaks");
|
|
|
+ const pageBreakAnalysis = applyPageBreaksWithCascade(tempElement, PAGE_CONFIG, 10, progressState.signal);
|
|
|
+ throwIfPdfExportAborted(progressState.signal);
|
|
|
+
|
|
|
+ await waitForPdfFrame(progressState);
|
|
|
+
|
|
|
+ const pdfOptions = {
|
|
|
+ orientation: 'portrait',
|
|
|
+ unit: 'mm',
|
|
|
+ format: 'a4',
|
|
|
+ compress: true,
|
|
|
+ hotfixes: ["px_scaling"]
|
|
|
+ };
|
|
|
+
|
|
|
+ const pdf = new jspdf.jsPDF(pdfOptions);
|
|
|
+ const pageWidth = pdf.internal.pageSize.getWidth();
|
|
|
+ const pageHeight = pdf.internal.pageSize.getHeight();
|
|
|
+ const margin = 15;
|
|
|
+ const contentWidth = pageWidth - (margin * 2);
|
|
|
+ const captureScale = choosePdfCanvasScale(tempElement);
|
|
|
+
|
|
|
+ updatePdfProgress(progressState, 65, "Capturing document");
|
|
|
+ const canvas = await runPdfAbortable(progressState, html2canvas(tempElement, {
|
|
|
+ scale: captureScale,
|
|
|
+ useCORS: true,
|
|
|
+ allowTaint: false,
|
|
|
+ logging: false,
|
|
|
+ windowWidth: Math.max(PAGE_CONFIG.windowWidth, Math.ceil(tempElement.getBoundingClientRect().width)),
|
|
|
+ windowHeight: Math.ceil(tempElement.getBoundingClientRect().height)
|
|
|
+ }));
|
|
|
+ await waitForPdfFrame(progressState);
|
|
|
+ throwIfPdfExportAborted(progressState.signal);
|
|
|
+
|
|
|
+ console.log(`[PDF DEBUG] canvas.width = ${canvas.width}, canvas.height = ${canvas.height}`);
|
|
|
+ console.log(`[PDF DEBUG] tempElement.offsetWidth = ${tempElement.offsetWidth}, rect.width = ${tempElement.getBoundingClientRect().width}`);
|
|
|
+ const scaleFactor = canvas.width / contentWidth;
|
|
|
+ console.log(`[PDF DEBUG] scaleFactor = ${scaleFactor}, PAGE_CONFIG.scale = ${PAGE_CONFIG.scale}, captureScale = ${captureScale}`);
|
|
|
+ const imgHeight = canvas.height / scaleFactor;
|
|
|
+ console.log(`[PDF DEBUG] imgHeight = ${imgHeight}, contentHeight = ${pageHeight - margin * 2}`);
|
|
|
+ // Introduce a 0.5mm tolerance to prevent rounding errors from creating a trailing blank page
|
|
|
+ const pagesCount = Math.ceil((imgHeight - 0.5) / (pageHeight - margin * 2));
|
|
|
+ console.log(`[PDF DEBUG] pagesCount = ${pagesCount}`);
|
|
|
+
|
|
|
+ updatePdfProgress(progressState, 76, "Rendering pages");
|
|
|
+ for (let page = 0; page < pagesCount; page++) {
|
|
|
+ throwIfPdfExportAborted(progressState.signal);
|
|
|
+ const pageProgress = 76 + ((page + 1) / pagesCount) * 18;
|
|
|
+ updatePdfProgress(progressState, pageProgress, `Rendering page ${page + 1} of ${pagesCount}`);
|
|
|
+
|
|
|
+ if (page > 0) pdf.addPage();
|
|
|
+
|
|
|
+ const sourceY = page * (pageHeight - margin * 2) * scaleFactor;
|
|
|
+ const sourceHeight = Math.min(canvas.height - sourceY, (pageHeight - margin * 2) * scaleFactor);
|
|
|
+ const destHeight = sourceHeight / scaleFactor;
|
|
|
+
|
|
|
+ const pageCanvas = document.createElement('canvas');
|
|
|
+ pageCanvas.width = canvas.width;
|
|
|
+ pageCanvas.height = sourceHeight;
|
|
|
+
|
|
|
+ const ctx = pageCanvas.getContext('2d');
|
|
|
+ ctx.drawImage(canvas, 0, sourceY, canvas.width, sourceHeight, 0, 0, canvas.width, sourceHeight);
|
|
|
+
|
|
|
+ const imgData = pageCanvas.toDataURL('image/png');
|
|
|
+ pdf.addImage(imgData, 'PNG', margin, margin, contentWidth, destHeight);
|
|
|
+ await waitForPdfFrame(progressState);
|
|
|
+ }
|
|
|
+
|
|
|
+ throwIfPdfExportAborted(progressState.signal);
|
|
|
+ updatePdfProgress(progressState, 98, "Preparing download");
|
|
|
+ pdf.save("document.pdf");
|
|
|
+ updatePdfProgress(progressState, 100, "Complete");
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ if (error instanceof PdfExportCancelledError || progressState.signal.aborted) {
|
|
|
+ console.info("PDF export cancelled");
|
|
|
+ } else {
|
|
|
+ console.error("PDF export failed:", error);
|
|
|
+ alert("PDF export failed: " + error.message);
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ cleanupPdfExport(progressState);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ copyMarkdownButton.addEventListener("click", function () {
|
|
|
+ try {
|
|
|
+ const markdownText = markdownEditor.value;
|
|
|
+ copyToClipboard(markdownText);
|
|
|
+ } catch (e) {
|
|
|
+ console.error("Copy failed:", e);
|
|
|
+ alert("Failed to copy Markdown: " + e.message);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ async function copyTextToClipboard(text) {
|
|
|
+ if (navigator.clipboard && window.isSecureContext) {
|
|
|
+ await navigator.clipboard.writeText(text);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const textArea = document.createElement("textarea");
|
|
|
+ textArea.value = text;
|
|
|
+ textArea.style.position = "fixed";
|
|
|
+ textArea.style.opacity = "0";
|
|
|
+ document.body.appendChild(textArea);
|
|
|
+ textArea.focus();
|
|
|
+ textArea.select();
|
|
|
+ const successful = document.execCommand("copy");
|
|
|
+ document.body.removeChild(textArea);
|
|
|
+ if (!successful) {
|
|
|
+ throw new Error("Copy command was unsuccessful");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function copyToClipboard(text) {
|
|
|
+ try {
|
|
|
+ await copyTextToClipboard(text);
|
|
|
+ showCopiedMessage();
|
|
|
+ } catch (err) {
|
|
|
+ console.error("Copy failed:", err);
|
|
|
+ alert("Failed to copy HTML: " + err.message);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function showCopiedMessage() {
|
|
|
+ const originalText = copyMarkdownButton.innerHTML;
|
|
|
+ copyMarkdownButton.innerHTML = '<i class="bi bi-check-lg"></i> Copied!';
|
|
|
+
|
|
|
+ setTimeout(() => {
|
|
|
+ copyMarkdownButton.innerHTML = originalText;
|
|
|
+ }, 2000);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================
|
|
|
+ // Share via URL (pako compression + base64url)
|
|
|
+ // ============================================
|
|
|
+
|
|
|
+ const MAX_SHARE_URL_LENGTH = 32000;
|
|
|
+
|
|
|
+ function encodeMarkdownForShare(text) {
|
|
|
+ if (typeof pako === 'undefined') throw new Error('pako not loaded');
|
|
|
+ const compressed = pako.deflate(new TextEncoder().encode(text));
|
|
|
+ const chunkSize = 0x8000;
|
|
|
+ let binary = '';
|
|
|
+ for (let i = 0; i < compressed.length; i += chunkSize) {
|
|
|
+ binary += String.fromCharCode.apply(null, compressed.subarray(i, i + chunkSize));
|
|
|
+ }
|
|
|
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
|
+ }
|
|
|
+
|
|
|
+ function decodeMarkdownFromShare(encoded) {
|
|
|
+ if (typeof pako === 'undefined') throw new Error('pako not loaded');
|
|
|
+ const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
|
|
|
+ const binary = atob(base64);
|
|
|
+ const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
|
|
|
+ return new TextDecoder().decode(pako.inflate(bytes));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================
|
|
|
+ // Share Modal
|
|
|
+ // ============================================
|
|
|
+
|
|
|
+ const shareModal = document.getElementById('share-modal');
|
|
|
+ const shareModalCloseX = document.getElementById('share-modal-close-icon');
|
|
|
+ const shareModalClose = document.getElementById('share-modal-close');
|
|
|
+ const shareUrlInput = document.getElementById('share-url-input');
|
|
|
+ const shareCopyBtn = document.getElementById('share-copy-btn');
|
|
|
+ const shareModeView = document.getElementById('share-mode-view');
|
|
|
+ const shareModeEdit = document.getElementById('share-mode-edit');
|
|
|
+ const shareCardView = document.getElementById('share-card-view');
|
|
|
+ const shareCardEdit = document.getElementById('share-card-edit');
|
|
|
+
|
|
|
+ function buildShareUrl(mode) {
|
|
|
+ const markdownText = markdownEditor.value;
|
|
|
+ let encoded;
|
|
|
+ try {
|
|
|
+ encoded = encodeMarkdownForShare(markdownText);
|
|
|
+ } catch (e) {
|
|
|
+ console.error('Share encoding failed:', e);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ const isLocal = window.location.origin.includes('localhost') ||
|
|
|
+ window.location.origin.startsWith('file://') ||
|
|
|
+ typeof Neutralino !== 'undefined';
|
|
|
+
|
|
|
+ const baseUrl = isLocal
|
|
|
+ ? 'https://markdownviewer.pages.dev/'
|
|
|
+ : window.location.origin + window.location.pathname;
|
|
|
+
|
|
|
+ const base = baseUrl + '#share=' + encoded;
|
|
|
+ return mode === 'edit' ? base + '&edit=1' : base;
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateShareUrlField() {
|
|
|
+ const mode = shareModeView.checked ? 'view' : 'edit';
|
|
|
+ const url = buildShareUrl(mode);
|
|
|
+ if (!url) {
|
|
|
+ shareUrlInput.value = 'Error generating link.';
|
|
|
+ shareCopyBtn.disabled = true;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const tooLarge = url.length > MAX_SHARE_URL_LENGTH;
|
|
|
+ if (tooLarge) {
|
|
|
+ shareUrlInput.value = 'Document too large to share via URL.';
|
|
|
+ shareCopyBtn.disabled = true;
|
|
|
+ } else {
|
|
|
+ shareUrlInput.value = url;
|
|
|
+ shareCopyBtn.disabled = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function openShareModal() {
|
|
|
+ // PERF-002: Lazy-load pako on first share
|
|
|
+ if (typeof pako === 'undefined') {
|
|
|
+ loadScript(CDN.pako).then(function() {
|
|
|
+ openShareModal();
|
|
|
+ }).catch(function(e) {
|
|
|
+ console.error('Failed to load pako:', e);
|
|
|
+ alert('Failed to load sharing library. Please check your internet connection.');
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // Reset to view-only by default each time
|
|
|
+ shareModeView.checked = true;
|
|
|
+ syncShareCardStyles();
|
|
|
+ updateShareUrlField();
|
|
|
+ shareModal.style.display = '';
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ shareModal.classList.add('is-visible');
|
|
|
+ shareModal.setAttribute('aria-hidden', 'false');
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeShareModal() {
|
|
|
+ shareModal.classList.remove('is-visible');
|
|
|
+ shareModal.setAttribute('aria-hidden', 'true');
|
|
|
+ shareModal.addEventListener('transitionend', function handler() {
|
|
|
+ shareModal.style.display = 'none';
|
|
|
+ shareModal.removeEventListener('transitionend', handler);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function syncShareCardStyles() {
|
|
|
+ if (shareModeView.checked) {
|
|
|
+ shareCardView.classList.add('is-selected');
|
|
|
+ shareCardEdit.classList.remove('is-selected');
|
|
|
+ } else {
|
|
|
+ shareCardEdit.classList.add('is-selected');
|
|
|
+ shareCardView.classList.remove('is-selected');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ shareModeView.addEventListener('change', function () {
|
|
|
+ syncShareCardStyles();
|
|
|
+ updateShareUrlField();
|
|
|
+ });
|
|
|
+ shareModeEdit.addEventListener('change', function () {
|
|
|
+ syncShareCardStyles();
|
|
|
+ updateShareUrlField();
|
|
|
+ });
|
|
|
+
|
|
|
+ shareCopyBtn.addEventListener('click', function () {
|
|
|
+ const url = shareUrlInput.value;
|
|
|
+ if (!url || shareCopyBtn.disabled) return;
|
|
|
+
|
|
|
+ function onCopied() {
|
|
|
+ const orig = shareCopyBtn.innerHTML;
|
|
|
+ shareCopyBtn.innerHTML = '<i class="bi bi-check-lg"></i>';
|
|
|
+ setTimeout(() => { shareCopyBtn.innerHTML = orig; }, 2000);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (navigator.clipboard && window.isSecureContext) {
|
|
|
+ navigator.clipboard.writeText(url).then(onCopied).catch(() => {});
|
|
|
+ } else {
|
|
|
+ try {
|
|
|
+ const tmp = document.createElement('textarea');
|
|
|
+ tmp.value = url;
|
|
|
+ document.body.appendChild(tmp);
|
|
|
+ tmp.select();
|
|
|
+ document.execCommand('copy');
|
|
|
+ document.body.removeChild(tmp);
|
|
|
+ onCopied();
|
|
|
+ } catch (_) {}
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ shareModalCloseX.addEventListener('click', closeShareModal);
|
|
|
+ shareModalClose.addEventListener('click', closeShareModal);
|
|
|
+ shareModal.addEventListener('click', function (e) {
|
|
|
+ if (e.target === shareModal) closeShareModal();
|
|
|
+ });
|
|
|
+ document.addEventListener('keydown', function (e) {
|
|
|
+ if (e.key === 'Escape' && shareModal.classList.contains('is-visible')) {
|
|
|
+ closeShareModal();
|
|
|
+ }
|
|
|
+
|
|
|
+ // Global Ctrl+F / Cmd+F interception
|
|
|
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') {
|
|
|
+ e.preventDefault();
|
|
|
+ openFindReplaceModal();
|
|
|
+ const findInput = document.getElementById('find-replace-input');
|
|
|
+ if (findInput) {
|
|
|
+ findInput.focus();
|
|
|
+ findInput.select();
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Global Ctrl+H / Cmd+H interception
|
|
|
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'h') {
|
|
|
+ e.preventDefault();
|
|
|
+ openFindReplaceModal();
|
|
|
+ const replaceInput = document.getElementById('find-replace-with');
|
|
|
+ if (replaceInput) {
|
|
|
+ replaceInput.focus();
|
|
|
+ replaceInput.select();
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Global Escape dismissal for find-replace panel
|
|
|
+ if (e.key === 'Escape' && isFindModalOpen) {
|
|
|
+ e.preventDefault();
|
|
|
+ closeFindReplaceModal();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ shareButton.addEventListener('click', openShareModal);
|
|
|
+ mobileShareButton.addEventListener('click', openShareModal);
|
|
|
+
|
|
|
+ function loadFromShareHash() {
|
|
|
+ // PERF-002: Lazy-load pako when loading shared URL content
|
|
|
+ if (typeof pako === 'undefined') {
|
|
|
+ const hash = window.location.hash;
|
|
|
+ if (!hash.startsWith('#share=')) return;
|
|
|
+ loadScript(CDN.pako).then(function() {
|
|
|
+ loadFromShareHash();
|
|
|
+ }).catch(function(e) {
|
|
|
+ console.error('Failed to load pako for shared URL:', e);
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const hash = window.location.hash;
|
|
|
+ if (!hash.startsWith('#share=')) return;
|
|
|
+
|
|
|
+ // Parse encoded content and optional &edit=1 flag.
|
|
|
+ // Hash format: #share=<encoded> or #share=<encoded>&edit=1
|
|
|
+ const rest = hash.slice('#share='.length);
|
|
|
+ const ampIdx = rest.indexOf('&');
|
|
|
+ const encoded = ampIdx === -1 ? rest : rest.slice(0, ampIdx);
|
|
|
+ const params = ampIdx === -1 ? '' : rest.slice(ampIdx + 1);
|
|
|
+ const isEdit = params.split('&').includes('edit=1');
|
|
|
+
|
|
|
+ if (!encoded) return;
|
|
|
+ try {
|
|
|
+ const decoded = decodeMarkdownFromShare(encoded);
|
|
|
+ markdownEditor.value = decoded;
|
|
|
+ renderMarkdown({ reason: 'document-load', showSkeleton: true });
|
|
|
+ saveCurrentTabState();
|
|
|
+ // Apply the correct view mode: edit=1 → split, default → preview only
|
|
|
+ setViewMode(isEdit ? 'split' : 'preview');
|
|
|
+ } catch (e) {
|
|
|
+ console.error("Failed to load shared content:", e);
|
|
|
+ alert("The shared URL could not be decoded. It may be corrupted or incomplete.");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ loadFromShareHash();
|
|
|
+
|
|
|
+ // Full-window drag-and-drop: track nesting level for reliable enter/leave detection
|
|
|
+ let dragDepth = 0;
|
|
|
+
|
|
|
+ document.addEventListener("dragenter", function(e) {
|
|
|
+ if (e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes("Files")) {
|
|
|
+ e.preventDefault();
|
|
|
+ dragDepth++;
|
|
|
+ dragOverlay.classList.add("active");
|
|
|
+ dragOverlay.setAttribute("aria-hidden", "false");
|
|
|
+ }
|
|
|
+ }, false);
|
|
|
+
|
|
|
+ document.addEventListener("dragover", function(e) {
|
|
|
+ if (e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes("Files")) {
|
|
|
+ e.preventDefault();
|
|
|
+ }
|
|
|
+ }, false);
|
|
|
+
|
|
|
+ document.addEventListener("dragleave", function(e) {
|
|
|
+ if (e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes("Files")) {
|
|
|
+ dragDepth--;
|
|
|
+ if (dragDepth <= 0) {
|
|
|
+ dragDepth = 0;
|
|
|
+ dragOverlay.classList.remove("active");
|
|
|
+ dragOverlay.setAttribute("aria-hidden", "true");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }, false);
|
|
|
+
|
|
|
+ document.addEventListener("drop", function(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ dragDepth = 0;
|
|
|
+ dragOverlay.classList.remove("active");
|
|
|
+ dragOverlay.setAttribute("aria-hidden", "true");
|
|
|
+ handleDrop(e);
|
|
|
+ }, false);
|
|
|
+
|
|
|
+ function handleDrop(e) {
|
|
|
+ const dt = e.dataTransfer;
|
|
|
+ const files = dt.files;
|
|
|
+ if (files.length) {
|
|
|
+ const file = files[0];
|
|
|
+ const isMarkdownFile =
|
|
|
+ file.type === "text/markdown" ||
|
|
|
+ /\.(md|markdown)$/i.test(file.name || "");
|
|
|
+ if (isMarkdownFile) {
|
|
|
+ importMarkdownFile(file);
|
|
|
+ } else {
|
|
|
+ alert("Please upload a Markdown file (.md or .markdown)");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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();
|
|
|
+ }
|
|
|
+ if ((e.ctrlKey || e.metaKey) && e.key === "c") {
|
|
|
+ const activeEl = document.activeElement;
|
|
|
+ const isTextControl = activeEl && (activeEl.tagName === "TEXTAREA" || activeEl.tagName === "INPUT");
|
|
|
+ const hasSelection = window.getSelection && window.getSelection().toString().trim().length > 0;
|
|
|
+ const editorHasSelection = markdownEditor.selectionStart !== markdownEditor.selectionEnd;
|
|
|
+ if (!isTextControl && !hasSelection && !editorHasSelection) {
|
|
|
+ e.preventDefault();
|
|
|
+ copyMarkdownButton.click();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // Story 1.2: Only allow sync toggle shortcut when in split view
|
|
|
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "s") {
|
|
|
+ e.preventDefault();
|
|
|
+ if (currentViewMode === 'split') {
|
|
|
+ toggleSyncScrolling();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const isDesktop = typeof Neutralino !== 'undefined';
|
|
|
+ // New tab (Ctrl+T on desktop, Alt+Shift+T on web/desktop)
|
|
|
+ if ((isDesktop && (e.ctrlKey || e.metaKey) && e.key === "t") || (e.altKey && e.shiftKey && e.key.toLowerCase() === "t")) {
|
|
|
+ e.preventDefault();
|
|
|
+ newTab();
|
|
|
+ }
|
|
|
+ // Close tab (Ctrl+W on desktop, Alt+Shift+W on web/desktop)
|
|
|
+ if ((isDesktop && (e.ctrlKey || e.metaKey) && e.key === "w") || (e.altKey && e.shiftKey && e.key.toLowerCase() === "w")) {
|
|
|
+ e.preventDefault();
|
|
|
+ closeTab(activeTabId);
|
|
|
+ }
|
|
|
+ // Close Mermaid zoom modal with Escape
|
|
|
+ if (e.key === "Escape") {
|
|
|
+ closeTabMenus();
|
|
|
+ closeMermaidModal();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ document.getElementById('tab-reset-btn').addEventListener('click', function() {
|
|
|
+ resetAllTabs();
|
|
|
+ });
|
|
|
+
|
|
|
+ // ========================================
|
|
|
+ // MERMAID DIAGRAM TOOLBAR
|
|
|
+ // ========================================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Serialises an SVG element to a data URL suitable for use as an image source.
|
|
|
+ * Inline styles and dimensions are preserved so the PNG matches the rendered diagram.
|
|
|
+ */
|
|
|
+ function svgToDataUrl(svgEl) {
|
|
|
+ const clone = svgEl.cloneNode(true);
|
|
|
+ // Ensure explicit width/height so the canvas has the right dimensions
|
|
|
+ const bbox = svgEl.getBoundingClientRect();
|
|
|
+ if (!clone.getAttribute('width')) clone.setAttribute('width', Math.round(bbox.width));
|
|
|
+ if (!clone.getAttribute('height')) clone.setAttribute('height', Math.round(bbox.height));
|
|
|
+ const serialized = new XMLSerializer().serializeToString(clone);
|
|
|
+ return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(serialized);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Renders an SVG element onto a canvas and resolves with the canvas.
|
|
|
+ */
|
|
|
+ function svgToCanvas(svgEl) {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ const bbox = svgEl.getBoundingClientRect();
|
|
|
+ const scale = window.devicePixelRatio || 1;
|
|
|
+ const width = Math.max(Math.round(bbox.width), 1);
|
|
|
+ const height = Math.max(Math.round(bbox.height), 1);
|
|
|
+
|
|
|
+ const canvas = document.createElement('canvas');
|
|
|
+ canvas.width = width * scale;
|
|
|
+ canvas.height = height * scale;
|
|
|
+ const ctx = canvas.getContext('2d');
|
|
|
+ ctx.scale(scale, scale);
|
|
|
+
|
|
|
+ // Fill background matching current theme using the CSS variable value
|
|
|
+ const bgColor = getComputedStyle(document.documentElement)
|
|
|
+ .getPropertyValue('--bg-color').trim() || '#ffffff';
|
|
|
+ ctx.fillStyle = bgColor;
|
|
|
+ ctx.fillRect(0, 0, width, height);
|
|
|
+
|
|
|
+ const img = new Image();
|
|
|
+ img.onload = () => { ctx.drawImage(img, 0, 0, width, height); resolve(canvas); };
|
|
|
+ img.onerror = reject;
|
|
|
+ img.src = svgToDataUrl(svgEl);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Downloads the diagram in the given container as a PNG file. */
|
|
|
+ async function downloadMermaidPng(container, btn) {
|
|
|
+ const svgEl = container.querySelector('svg');
|
|
|
+ if (!svgEl) return;
|
|
|
+ const original = btn.innerHTML;
|
|
|
+ btn.innerHTML = '<i class="bi bi-hourglass-split"></i>';
|
|
|
+ try {
|
|
|
+ const canvas = await svgToCanvas(svgEl);
|
|
|
+ canvas.toBlob(blob => {
|
|
|
+ const url = URL.createObjectURL(blob);
|
|
|
+ const a = document.createElement('a');
|
|
|
+ a.href = url;
|
|
|
+ a.download = `diagram-${Date.now()}.png`;
|
|
|
+ a.click();
|
|
|
+ URL.revokeObjectURL(url);
|
|
|
+ btn.innerHTML = '<i class="bi bi-check-lg"></i>';
|
|
|
+ setTimeout(() => { btn.innerHTML = original; }, 1500);
|
|
|
+ }, 'image/png');
|
|
|
+ } catch (e) {
|
|
|
+ console.error('Mermaid PNG export failed:', e);
|
|
|
+ btn.innerHTML = original;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Copies the diagram in the given container as a PNG image to the clipboard. */
|
|
|
+ async function copyMermaidImage(container, btn) {
|
|
|
+ const svgEl = container.querySelector('svg');
|
|
|
+ if (!svgEl) return;
|
|
|
+ const original = btn.innerHTML;
|
|
|
+ btn.innerHTML = '<i class="bi bi-hourglass-split"></i>';
|
|
|
+ try {
|
|
|
+ const canvas = await svgToCanvas(svgEl);
|
|
|
+ canvas.toBlob(async blob => {
|
|
|
+ try {
|
|
|
+ await navigator.clipboard.write([
|
|
|
+ new ClipboardItem({ 'image/png': blob })
|
|
|
+ ]);
|
|
|
+ btn.innerHTML = '<i class="bi bi-check-lg"></i> Copied!';
|
|
|
+ } catch (clipErr) {
|
|
|
+ console.error('Clipboard write failed:', clipErr);
|
|
|
+ btn.innerHTML = '<i class="bi bi-x-lg"></i>';
|
|
|
+ }
|
|
|
+ setTimeout(() => { btn.innerHTML = original; }, 1800);
|
|
|
+ }, 'image/png');
|
|
|
+ } catch (e) {
|
|
|
+ console.error('Mermaid copy failed:', e);
|
|
|
+ btn.innerHTML = original;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Downloads the SVG source of a diagram. */
|
|
|
+ function downloadMermaidSvg(container, btn) {
|
|
|
+ const svgEl = container.querySelector('svg');
|
|
|
+ if (!svgEl) return;
|
|
|
+ const clone = svgEl.cloneNode(true);
|
|
|
+ const serialized = new XMLSerializer().serializeToString(clone);
|
|
|
+ const blob = new Blob([serialized], { type: 'image/svg+xml' });
|
|
|
+ const url = URL.createObjectURL(blob);
|
|
|
+ const a = document.createElement('a');
|
|
|
+ a.href = url;
|
|
|
+ a.download = `diagram-${Date.now()}.svg`;
|
|
|
+ a.click();
|
|
|
+ URL.revokeObjectURL(url);
|
|
|
+ const original = btn.innerHTML;
|
|
|
+ btn.innerHTML = '<i class="bi bi-check-lg"></i>';
|
|
|
+ setTimeout(() => { btn.innerHTML = original; }, 1500);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- Zoom modal state ----
|
|
|
+ let modalZoomScale = 1;
|
|
|
+ let modalPanX = 0;
|
|
|
+ let modalPanY = 0;
|
|
|
+ let modalIsDragging = false;
|
|
|
+ let modalDragStart = { x: 0, y: 0 };
|
|
|
+ let modalCurrentSvgEl = null;
|
|
|
+
|
|
|
+ const mermaidZoomModal = document.getElementById('mermaid-zoom-modal');
|
|
|
+ const mermaidModalDiagram = document.getElementById('mermaid-modal-diagram');
|
|
|
+
|
|
|
+ function applyModalTransform() {
|
|
|
+ if (modalCurrentSvgEl) {
|
|
|
+ modalCurrentSvgEl.style.transform =
|
|
|
+ `translate(${modalPanX}px, ${modalPanY}px) scale(${modalZoomScale})`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeMermaidModal() {
|
|
|
+ if (!mermaidZoomModal.classList.contains('active')) return;
|
|
|
+ mermaidZoomModal.classList.remove('active');
|
|
|
+ // PERF-007: Clear elements using textContent
|
|
|
+ mermaidModalDiagram.textContent = '';
|
|
|
+ modalCurrentSvgEl = null;
|
|
|
+ modalZoomScale = 1;
|
|
|
+ modalPanX = 0;
|
|
|
+ modalPanY = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Opens the zoom modal with the SVG from the given container. */
|
|
|
+ function openMermaidZoomModal(container) {
|
|
|
+ const svgEl = container.querySelector('svg');
|
|
|
+ if (!svgEl) return;
|
|
|
+
|
|
|
+ // PERF-007: Clear elements using textContent
|
|
|
+ mermaidModalDiagram.textContent = '';
|
|
|
+ modalZoomScale = 1;
|
|
|
+ modalPanX = 0;
|
|
|
+ modalPanY = 0;
|
|
|
+
|
|
|
+ const svgClone = svgEl.cloneNode(true);
|
|
|
+ // Remove fixed dimensions so it sizes naturally inside the modal
|
|
|
+ svgClone.removeAttribute('width');
|
|
|
+ svgClone.removeAttribute('height');
|
|
|
+ svgClone.style.width = 'auto';
|
|
|
+ svgClone.style.height = 'auto';
|
|
|
+ svgClone.style.maxWidth = '80vw';
|
|
|
+ svgClone.style.maxHeight = '60vh';
|
|
|
+ svgClone.style.transformOrigin = 'center';
|
|
|
+ mermaidModalDiagram.appendChild(svgClone);
|
|
|
+ modalCurrentSvgEl = svgClone;
|
|
|
+
|
|
|
+ mermaidZoomModal.classList.add('active');
|
|
|
+ }
|
|
|
+
|
|
|
+ // Modal close button
|
|
|
+ document.getElementById('mermaid-modal-close').addEventListener('click', closeMermaidModal);
|
|
|
+ // Click backdrop to close
|
|
|
+ mermaidZoomModal.addEventListener('click', function(e) {
|
|
|
+ if (e.target === mermaidZoomModal) closeMermaidModal();
|
|
|
+ });
|
|
|
+
|
|
|
+ // Zoom controls
|
|
|
+ document.getElementById('mermaid-modal-zoom-in').addEventListener('click', () => {
|
|
|
+ modalZoomScale = Math.min(modalZoomScale + 0.25, 10);
|
|
|
+ applyModalTransform();
|
|
|
+ });
|
|
|
+ document.getElementById('mermaid-modal-zoom-out').addEventListener('click', () => {
|
|
|
+ modalZoomScale = Math.max(modalZoomScale - 0.25, 0.1);
|
|
|
+ applyModalTransform();
|
|
|
+ });
|
|
|
+ document.getElementById('mermaid-modal-zoom-reset').addEventListener('click', () => {
|
|
|
+ modalZoomScale = 1; modalPanX = 0; modalPanY = 0;
|
|
|
+ applyModalTransform();
|
|
|
+ });
|
|
|
+
|
|
|
+ // Mouse-wheel zoom inside modal
|
|
|
+ mermaidModalDiagram.addEventListener('wheel', function(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ const delta = e.deltaY < 0 ? 0.15 : -0.15;
|
|
|
+ modalZoomScale = Math.min(Math.max(modalZoomScale + delta, 0.1), 10);
|
|
|
+ applyModalTransform();
|
|
|
+ }, { passive: false });
|
|
|
+
|
|
|
+ // Drag to pan inside modal
|
|
|
+ mermaidModalDiagram.addEventListener('mousedown', function(e) {
|
|
|
+ modalIsDragging = true;
|
|
|
+ modalDragStart = { x: e.clientX - modalPanX, y: e.clientY - modalPanY };
|
|
|
+ mermaidModalDiagram.classList.add('dragging');
|
|
|
+ });
|
|
|
+ document.addEventListener('mousemove', function(e) {
|
|
|
+ if (!modalIsDragging) return;
|
|
|
+ modalPanX = e.clientX - modalDragStart.x;
|
|
|
+ modalPanY = e.clientY - modalDragStart.y;
|
|
|
+ applyModalTransform();
|
|
|
+ });
|
|
|
+ document.addEventListener('mouseup', function() {
|
|
|
+ if (modalIsDragging) {
|
|
|
+ modalIsDragging = false;
|
|
|
+ mermaidModalDiagram.classList.remove('dragging');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // Modal download buttons (operate on the currently displayed SVG)
|
|
|
+ document.getElementById('mermaid-modal-download-png').addEventListener('click', async function() {
|
|
|
+ if (!modalCurrentSvgEl) return;
|
|
|
+ const btn = this;
|
|
|
+ const original = btn.innerHTML;
|
|
|
+ btn.innerHTML = '<i class="bi bi-hourglass-split"></i>';
|
|
|
+ try {
|
|
|
+ // Use the original SVG (with dimensions) for proper PNG rendering
|
|
|
+ const canvas = await svgToCanvas(modalCurrentSvgEl);
|
|
|
+ canvas.toBlob(blob => {
|
|
|
+ const url = URL.createObjectURL(blob);
|
|
|
+ const a = document.createElement('a');
|
|
|
+ a.href = url; a.download = `diagram-${Date.now()}.png`; a.click();
|
|
|
+ URL.revokeObjectURL(url);
|
|
|
+ btn.innerHTML = '<i class="bi bi-check-lg"></i>';
|
|
|
+ setTimeout(() => { btn.innerHTML = original; }, 1500);
|
|
|
+ }, 'image/png');
|
|
|
+ } catch (e) {
|
|
|
+ console.error('Modal PNG export failed:', e);
|
|
|
+ btn.innerHTML = original;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ document.getElementById('mermaid-modal-copy').addEventListener('click', async function() {
|
|
|
+ if (!modalCurrentSvgEl) return;
|
|
|
+ const btn = this;
|
|
|
+ const original = btn.innerHTML;
|
|
|
+ btn.innerHTML = '<i class="bi bi-hourglass-split"></i>';
|
|
|
+ try {
|
|
|
+ const canvas = await svgToCanvas(modalCurrentSvgEl);
|
|
|
+ canvas.toBlob(async blob => {
|
|
|
+ try {
|
|
|
+ await navigator.clipboard.write([
|
|
|
+ new ClipboardItem({ 'image/png': blob })
|
|
|
+ ]);
|
|
|
+ btn.innerHTML = '<i class="bi bi-check-lg"></i> Copied!';
|
|
|
+ } catch (clipErr) {
|
|
|
+ console.error('Clipboard write failed:', clipErr);
|
|
|
+ btn.innerHTML = '<i class="bi bi-x-lg"></i>';
|
|
|
+ }
|
|
|
+ setTimeout(() => { btn.innerHTML = original; }, 1800);
|
|
|
+ }, 'image/png');
|
|
|
+ } catch (e) {
|
|
|
+ console.error('Modal copy failed:', e);
|
|
|
+ btn.innerHTML = original;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ document.getElementById('mermaid-modal-download-svg').addEventListener('click', function() {
|
|
|
+ if (!modalCurrentSvgEl) return;
|
|
|
+ const serialized = new XMLSerializer().serializeToString(modalCurrentSvgEl);
|
|
|
+ const blob = new Blob([serialized], { type: 'image/svg+xml' });
|
|
|
+ const url = URL.createObjectURL(blob);
|
|
|
+ const a = document.createElement('a');
|
|
|
+ a.href = url; a.download = `diagram-${Date.now()}.svg`; a.click();
|
|
|
+ URL.revokeObjectURL(url);
|
|
|
+ });
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Adds the hover toolbar to every rendered Mermaid container.
|
|
|
+ * Safe to call multiple times – existing toolbars are not duplicated.
|
|
|
+ */
|
|
|
+ function addMermaidToolbars() {
|
|
|
+ markdownPreview.querySelectorAll('.mermaid-container').forEach(container => {
|
|
|
+ if (container.querySelector('.mermaid-toolbar')) return; // already added
|
|
|
+ const svgEl = container.querySelector('svg');
|
|
|
+ if (!svgEl) return; // diagram not yet rendered
|
|
|
+
|
|
|
+ const toolbar = document.createElement('div');
|
|
|
+ toolbar.className = 'mermaid-toolbar';
|
|
|
+ toolbar.setAttribute('aria-label', 'Diagram actions');
|
|
|
+
|
|
|
+ const btnZoom = document.createElement('button');
|
|
|
+ btnZoom.className = 'mermaid-toolbar-btn';
|
|
|
+ btnZoom.title = 'Zoom diagram';
|
|
|
+ btnZoom.setAttribute('aria-label', 'Zoom diagram');
|
|
|
+ btnZoom.innerHTML = '<i class="bi bi-arrows-fullscreen"></i>';
|
|
|
+ btnZoom.addEventListener('click', () => openMermaidZoomModal(container));
|
|
|
+
|
|
|
+ const btnPng = document.createElement('button');
|
|
|
+ btnPng.className = 'mermaid-toolbar-btn';
|
|
|
+ btnPng.title = 'Download PNG';
|
|
|
+ btnPng.setAttribute('aria-label', 'Download PNG');
|
|
|
+ btnPng.innerHTML = '<i class="bi bi-file-image"></i> PNG';
|
|
|
+ btnPng.addEventListener('click', () => downloadMermaidPng(container, btnPng));
|
|
|
+
|
|
|
+ const btnCopy = document.createElement('button');
|
|
|
+ btnCopy.className = 'mermaid-toolbar-btn';
|
|
|
+ btnCopy.title = 'Copy image to clipboard';
|
|
|
+ btnCopy.setAttribute('aria-label', 'Copy image to clipboard');
|
|
|
+ btnCopy.innerHTML = '<i class="bi bi-clipboard-image"></i> Copy';
|
|
|
+ btnCopy.addEventListener('click', () => copyMermaidImage(container, btnCopy));
|
|
|
+
|
|
|
+ const btnSvg = document.createElement('button');
|
|
|
+ btnSvg.className = 'mermaid-toolbar-btn';
|
|
|
+ btnSvg.title = 'Download SVG';
|
|
|
+ btnSvg.setAttribute('aria-label', 'Download SVG');
|
|
|
+ btnSvg.innerHTML = '<i class="bi bi-filetype-svg"></i> SVG';
|
|
|
+ btnSvg.addEventListener('click', () => downloadMermaidSvg(container, btnSvg));
|
|
|
+
|
|
|
+ toolbar.appendChild(btnZoom);
|
|
|
+ toolbar.appendChild(btnCopy);
|
|
|
+ toolbar.appendChild(btnPng);
|
|
|
+ toolbar.appendChild(btnSvg);
|
|
|
+ container.appendChild(toolbar);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==========================================================================
|
|
|
+ // Aegis SEO agency Multilingual & Internationalization (i18n) engine
|
|
|
+ // ==========================================================================
|
|
|
+ const I18N_DICTS = {
|
|
|
+ en: {
|
|
|
+ title: "Markdown Viewer",
|
|
|
+ syncOff: "Sync Off",
|
|
|
+ syncOn: "Sync On",
|
|
|
+ import: "Import",
|
|
|
+ importFile: "From files",
|
|
|
+ importGithub: "From GitHub",
|
|
|
+ export: "Export",
|
|
|
+ exportMd: "Markdown (.md)",
|
|
|
+ exportHtml: "HTML",
|
|
|
+ exportPdf: "PDF",
|
|
|
+ copy: "Copy",
|
|
|
+ copied: "Copied!",
|
|
|
+ share: "Share",
|
|
|
+ reset: "Reset",
|
|
|
+ editor: "Editor",
|
|
|
+ split: "Split",
|
|
|
+ preview: "Preview",
|
|
|
+ minRead: "Min Read",
|
|
|
+ words: "Words",
|
|
|
+ chars: "Chars",
|
|
|
+ switchRtl: "Switch to RTL",
|
|
|
+ switchLtr: "Switch to LTR",
|
|
|
+ darkMode: "Dark Mode",
|
|
|
+ lightMode: "Light Mode",
|
|
|
+ helpTitle: "Markdown Viewer Help",
|
|
|
+ aboutTitle: "About Markdown",
|
|
|
+ shareTitle: "Share Document",
|
|
|
+ renameTitle: "Rename file",
|
|
|
+ insertLink: "Insert link",
|
|
|
+ insertRef: "Insert reference",
|
|
|
+ insertImg: "Insert image",
|
|
|
+ insertTable: "Insert table",
|
|
|
+ findReplace: "Find & Replace",
|
|
|
+ placeholder: "Type your markdown here...",
|
|
|
+ loadingEmojis: "Loading emojis...",
|
|
|
+ loadingFiles: "Fetching file tree..."
|
|
|
+ },
|
|
|
+ zh: {
|
|
|
+ title: "Markdown 阅读器",
|
|
|
+ syncOff: "同步关闭",
|
|
|
+ syncOn: "同步开启",
|
|
|
+ import: "导入",
|
|
|
+ importFile: "本地文件导入",
|
|
|
+ importGithub: "从 GitHub 导入",
|
|
|
+ export: "导出",
|
|
|
+ exportMd: "导出 Markdown (.md)",
|
|
|
+ exportHtml: "导出 HTML",
|
|
|
+ exportPdf: "导出 PDF",
|
|
|
+ copy: "复制",
|
|
|
+ copied: "已复制!",
|
|
|
+ share: "分享",
|
|
|
+ reset: "重置",
|
|
|
+ editor: "编辑器",
|
|
|
+ split: "分栏预览",
|
|
|
+ preview: "纯预览",
|
|
|
+ minRead: "分钟阅读",
|
|
|
+ words: "字数",
|
|
|
+ chars: "字符数",
|
|
|
+ switchRtl: "切换为右至左布局",
|
|
|
+ switchLtr: "切换为左至右布局",
|
|
|
+ darkMode: "深色模式",
|
|
|
+ lightMode: "浅色模式",
|
|
|
+ helpTitle: "Markdown 阅读器帮助说明",
|
|
|
+ aboutTitle: "关于 Markdown 阅读器",
|
|
|
+ shareTitle: "分享当前文档",
|
|
|
+ renameTitle: "重命名文件",
|
|
|
+ insertLink: "插入超链接",
|
|
|
+ insertRef: "插入脚注引用",
|
|
|
+ insertImg: "插入图片",
|
|
|
+ insertTable: "插入表格",
|
|
|
+ findReplace: "查找与替换",
|
|
|
+ placeholder: "在此输入您的 Markdown 文本...",
|
|
|
+ loadingEmojis: "正在加载表情...",
|
|
|
+ loadingFiles: "正在获取文件树..."
|
|
|
+ },
|
|
|
+ ja: {
|
|
|
+ title: "Markdown ビューア",
|
|
|
+ syncOff: "同期オフ",
|
|
|
+ syncOn: "同期オン",
|
|
|
+ import: "インポート",
|
|
|
+ importFile: "ファイルから",
|
|
|
+ importGithub: "GitHub から",
|
|
|
+ export: "エクスポート",
|
|
|
+ exportMd: "Markdown (.md)",
|
|
|
+ exportHtml: "HTML",
|
|
|
+ exportPdf: "PDF",
|
|
|
+ copy: "コピー",
|
|
|
+ copied: "コピー完了!",
|
|
|
+ share: "共有",
|
|
|
+ reset: "リセット",
|
|
|
+ editor: "エディタ",
|
|
|
+ split: "分割表示",
|
|
|
+ preview: "プレビュー",
|
|
|
+ minRead: "分 読了",
|
|
|
+ words: "単語数",
|
|
|
+ chars: "文字数",
|
|
|
+ switchRtl: "RTL表示に切替",
|
|
|
+ switchLtr: "LTR表示に切替",
|
|
|
+ darkMode: "ダークモード",
|
|
|
+ lightMode: "ライトモード",
|
|
|
+ helpTitle: "Markdown ビューア ヘルプ",
|
|
|
+ aboutTitle: "Markdown について",
|
|
|
+ shareTitle: "ドキュメントの共有",
|
|
|
+ renameTitle: "ファイル名の変更",
|
|
|
+ insertLink: "リンクの挿入",
|
|
|
+ insertRef: "参照の挿入",
|
|
|
+ insertImg: "画像の挿入",
|
|
|
+ insertTable: "テーブルの挿入",
|
|
|
+ findReplace: "検索と置換",
|
|
|
+ placeholder: "ここにMarkdownを入力してください...",
|
|
|
+ loadingEmojis: "絵文字を読み込んでいます...",
|
|
|
+ loadingFiles: "ファイルツリーを取得しています..."
|
|
|
+ },
|
|
|
+ ko: {
|
|
|
+ title: "마크다운 뷰어",
|
|
|
+ syncOff: "동기화 끄기",
|
|
|
+ syncOn: "동기화 켜기",
|
|
|
+ import: "가져오기",
|
|
|
+ importFile: "로컬 파일에서",
|
|
|
+ importGithub: "GitHub에서",
|
|
|
+ export: "내보내기",
|
|
|
+ exportMd: "마크다운 (.md)",
|
|
|
+ exportHtml: "HTML로 내보내기",
|
|
|
+ exportPdf: "PDF로 내보내기",
|
|
|
+ copy: "복사",
|
|
|
+ copied: "복사됨!",
|
|
|
+ share: "공유",
|
|
|
+ reset: "초기화",
|
|
|
+ editor: "편집기",
|
|
|
+ split: "분할 보기",
|
|
|
+ preview: "미리보기",
|
|
|
+ minRead: "분 읽기",
|
|
|
+ words: "단어 수",
|
|
|
+ chars: "글자 수",
|
|
|
+ switchRtl: "우측 정렬로 전환",
|
|
|
+ switchLtr: "좌측 정렬로 전환",
|
|
|
+ darkMode: "다크 모드",
|
|
|
+ lightMode: "라이트 모드",
|
|
|
+ helpTitle: "마크다운 뷰어 도움말",
|
|
|
+ aboutTitle: "마크다운 정보",
|
|
|
+ shareTitle: "문서 공유",
|
|
|
+ renameTitle: "파일 이름 바꾸기",
|
|
|
+ insertLink: "링크 삽입",
|
|
|
+ insertRef: "참조 삽입",
|
|
|
+ insertImg: "이미지 삽입",
|
|
|
+ insertTable: "표 삽입",
|
|
|
+ findReplace: "찾기 및 바꾸기",
|
|
|
+ placeholder: "여기에 마크다운 내용을 입력하세요...",
|
|
|
+ loadingEmojis: "이모지 로딩 중...",
|
|
|
+ loadingFiles: "파일 트리 가져오는 중..."
|
|
|
+ },
|
|
|
+ pt: {
|
|
|
+ title: "Visualizador de Markdown",
|
|
|
+ syncOff: "Desativar Sincronia",
|
|
|
+ syncOn: "Ativar Sincronia",
|
|
|
+ import: "Importar",
|
|
|
+ importFile: "De arquivos",
|
|
|
+ importGithub: "Do GitHub",
|
|
|
+ export: "Exportar",
|
|
|
+ exportMd: "Markdown (.md)",
|
|
|
+ exportHtml: "HTML",
|
|
|
+ exportPdf: "PDF",
|
|
|
+ copy: "Copiar",
|
|
|
+ copied: "Copiado!",
|
|
|
+ share: "Compartilhar",
|
|
|
+ reset: "Redefinir",
|
|
|
+ editor: "Editor",
|
|
|
+ split: "Dividido",
|
|
|
+ preview: "Visualizar",
|
|
|
+ minRead: "Min de leitura",
|
|
|
+ words: "Palavras",
|
|
|
+ chars: "Caracteres",
|
|
|
+ switchRtl: "Mudar para RTL",
|
|
|
+ switchLtr: "Mudar para LTR",
|
|
|
+ darkMode: "Modo Escuro",
|
|
|
+ lightMode: "Modo Claro",
|
|
|
+ helpTitle: "Ajuda do Visualizador de Markdown",
|
|
|
+ aboutTitle: "Sobre o Markdown",
|
|
|
+ shareTitle: "Compartilhar Documento",
|
|
|
+ renameTitle: "Renomear arquivo",
|
|
|
+ insertLink: "Inserir link",
|
|
|
+ insertRef: "Inserir referência",
|
|
|
+ insertImg: "Inserir imagem",
|
|
|
+ insertTable: "Inserir tabela",
|
|
|
+ findReplace: "Localizar & Substituir",
|
|
|
+ placeholder: "Digite seu markdown aqui...",
|
|
|
+ loadingEmojis: "Carregando emojis...",
|
|
|
+ loadingFiles: "Buscando árvore de arquivos..."
|
|
|
+ },
|
|
|
+ es: {
|
|
|
+ title: "Visualizador de Markdown",
|
|
|
+ syncOff: "Sincronización desactivada",
|
|
|
+ syncOn: "Sincronización activada",
|
|
|
+ import: "Importar",
|
|
|
+ importFile: "Desde archivos",
|
|
|
+ importGithub: "Desde GitHub",
|
|
|
+ export: "Exportar",
|
|
|
+ exportMd: "Markdown (.md)",
|
|
|
+ exportHtml: "HTML",
|
|
|
+ exportPdf: "PDF",
|
|
|
+ copy: "Copiar",
|
|
|
+ copied: "¡Copiado!",
|
|
|
+ share: "Compartir",
|
|
|
+ reset: "Restablecer",
|
|
|
+ editor: "Editor",
|
|
|
+ split: "Dividido",
|
|
|
+ preview: "Previsualizar",
|
|
|
+ minRead: "Min de lectura",
|
|
|
+ words: "Palabras",
|
|
|
+ chars: "Caracteres",
|
|
|
+ switchRtl: "Cambiar a RTL",
|
|
|
+ switchLtr: "Cambiar a LTR",
|
|
|
+ darkMode: "Modo oscuro",
|
|
|
+ lightMode: "Modo claro",
|
|
|
+ helpTitle: "Ayuda del Visualizador de Markdown",
|
|
|
+ aboutTitle: "Acerca de Markdown",
|
|
|
+ shareTitle: "Compartir documento",
|
|
|
+ renameTitle: "Renombrar archivo",
|
|
|
+ insertLink: "Insertar enlace",
|
|
|
+ insertRef: "Insertar referencia",
|
|
|
+ insertImg: "Insertar imagen",
|
|
|
+ insertTable: "Insertar tabla",
|
|
|
+ findReplace: "Buscar y reemplazar",
|
|
|
+ placeholder: "Escribe tu markdown aquí...",
|
|
|
+ loadingEmojis: "Cargando emojis...",
|
|
|
+ loadingFiles: "Obteniendo árbol de archivos..."
|
|
|
+ },
|
|
|
+ fr: {
|
|
|
+ title: "Lecteur Markdown",
|
|
|
+ syncOff: "Désactiver la synchro",
|
|
|
+ syncOn: "Activer la synchro",
|
|
|
+ import: "Importer",
|
|
|
+ importFile: "Depuis des fichiers",
|
|
|
+ importGithub: "Depuis GitHub",
|
|
|
+ export: "Exporter",
|
|
|
+ exportMd: "Markdown (.md)",
|
|
|
+ exportHtml: "HTML",
|
|
|
+ exportPdf: "PDF",
|
|
|
+ copy: "Copier",
|
|
|
+ copied: "Copié !",
|
|
|
+ share: "Partager",
|
|
|
+ reset: "Réinitialiser",
|
|
|
+ editor: "Éditeur",
|
|
|
+ split: "Divisé",
|
|
|
+ preview: "Aperçu",
|
|
|
+ minRead: "Min de lecture",
|
|
|
+ words: "Mots",
|
|
|
+ chars: "Caractères",
|
|
|
+ switchRtl: "Passer à RTL",
|
|
|
+ switchLtr: "Passer à LTR",
|
|
|
+ darkMode: "Mode sombre",
|
|
|
+ lightMode: "Mode clair",
|
|
|
+ helpTitle: "Aide du Lecteur Markdown",
|
|
|
+ aboutTitle: "À propos de Markdown",
|
|
|
+ shareTitle: "Partager le document",
|
|
|
+ renameTitle: "Renommer le fichier",
|
|
|
+ insertLink: "Insérer un lien",
|
|
|
+ insertRef: "Insérer une référence",
|
|
|
+ insertImg: "Insérer une image",
|
|
|
+ insertTable: "Insérer un tableau",
|
|
|
+ findReplace: "Rechercher & remplacer",
|
|
|
+ placeholder: "Saisissez votre markdown ici...",
|
|
|
+ loadingEmojis: "Chargement des émojis...",
|
|
|
+ loadingFiles: "Récupération de l'arborescence des fichiers..."
|
|
|
+ },
|
|
|
+ de: {
|
|
|
+ title: "Markdown Viewer",
|
|
|
+ syncOff: "Synchronisierung aus",
|
|
|
+ syncOn: "Synchronisierung an",
|
|
|
+ import: "Importieren",
|
|
|
+ importFile: "Aus Dateien",
|
|
|
+ importGithub: "Aus GitHub",
|
|
|
+ export: "Exportieren",
|
|
|
+ exportMd: "Markdown (.md)",
|
|
|
+ exportHtml: "HTML",
|
|
|
+ exportPdf: "PDF",
|
|
|
+ copy: "Kopieren",
|
|
|
+ copied: "Kopiert!",
|
|
|
+ share: "Teilen",
|
|
|
+ reset: "Zurücksetzen",
|
|
|
+ editor: "Editor",
|
|
|
+ split: "Geteilt",
|
|
|
+ preview: "Vorschau",
|
|
|
+ minRead: "Min. Lesezeit",
|
|
|
+ words: "Wörter",
|
|
|
+ chars: "Zeichen",
|
|
|
+ switchRtl: "Zu RTL wechseln",
|
|
|
+ switchLtr: "Zu LTR wechseln",
|
|
|
+ darkMode: "Dunkelmodus",
|
|
|
+ lightMode: "Heller Modus",
|
|
|
+ helpTitle: "Markdown Viewer Hilfe",
|
|
|
+ aboutTitle: "Über Markdown",
|
|
|
+ shareTitle: "Dokument teilen",
|
|
|
+ renameTitle: "Datei umbenennen",
|
|
|
+ insertLink: "Link einfügen",
|
|
|
+ insertRef: "Referenz einfügen",
|
|
|
+ insertImg: "Bild einfügen",
|
|
|
+ insertTable: "Tabelle einfügen",
|
|
|
+ findReplace: "Suchen & Ersetzen",
|
|
|
+ placeholder: "Geben Sie hier Ihr Markdown ein...",
|
|
|
+ loadingEmojis: "Emojis werden geladen...",
|
|
|
+ loadingFiles: "Dateibaum wird abgerufen..."
|
|
|
+ },
|
|
|
+ ru: {
|
|
|
+ title: "Просмотрщик Markdown",
|
|
|
+ syncOff: "Синхронизация выкл",
|
|
|
+ syncOn: "Синхронизация вкл",
|
|
|
+ import: "Импорт",
|
|
|
+ importFile: "Из файлов",
|
|
|
+ importGithub: "Из GitHub",
|
|
|
+ export: "Экспорт",
|
|
|
+ exportMd: "Markdown (.md)",
|
|
|
+ exportHtml: "HTML",
|
|
|
+ exportPdf: "PDF",
|
|
|
+ copy: "Копировать",
|
|
|
+ copied: "Скопировано!",
|
|
|
+ share: "Поделиться",
|
|
|
+ reset: "Сбросить",
|
|
|
+ editor: "Редактор",
|
|
|
+ split: "Разделение",
|
|
|
+ preview: "Предпросмотр",
|
|
|
+ minRead: "Мин чтения",
|
|
|
+ words: "Слов",
|
|
|
+ chars: "Символов",
|
|
|
+ switchRtl: "Переключить на RTL",
|
|
|
+ switchLtr: "Переключить на LTR",
|
|
|
+ darkMode: "Темная тема",
|
|
|
+ lightMode: "Светлая тема",
|
|
|
+ helpTitle: "Справка Markdown Viewer",
|
|
|
+ aboutTitle: "О Markdown",
|
|
|
+ shareTitle: "Поделиться документом",
|
|
|
+ renameTitle: "Переименовать файл",
|
|
|
+ insertLink: "Вставить ссылку",
|
|
|
+ insertRef: "Вставить сноску",
|
|
|
+ insertImg: "Вставить изображение",
|
|
|
+ insertTable: "Вставить таблицу",
|
|
|
+ findReplace: "Найти и заменить",
|
|
|
+ placeholder: "Введите здесь ваш markdown...",
|
|
|
+ loadingEmojis: "Загрузка эмодзи...",
|
|
|
+ loadingFiles: "Получение списка файлов..."
|
|
|
+ },
|
|
|
+ it: {
|
|
|
+ title: "Visualizzatore Markdown",
|
|
|
+ syncOff: "Sincronia disattivata",
|
|
|
+ syncOn: "Sincronia attivata",
|
|
|
+ import: "Importa",
|
|
|
+ importFile: "Da file",
|
|
|
+ importGithub: "Da GitHub",
|
|
|
+ export: "Esporta",
|
|
|
+ exportMd: "Markdown (.md)",
|
|
|
+ exportHtml: "HTML",
|
|
|
+ exportPdf: "PDF",
|
|
|
+ copy: "Copia",
|
|
|
+ copied: "Copiato!",
|
|
|
+ share: "Condividi",
|
|
|
+ reset: "Ripristina",
|
|
|
+ editor: "Editor",
|
|
|
+ split: "Diviso",
|
|
|
+ preview: "Anteprima",
|
|
|
+ minRead: "Min di lettura",
|
|
|
+ words: "Parole",
|
|
|
+ chars: "Caratteri",
|
|
|
+ switchRtl: "Passa a RTL",
|
|
|
+ switchLtr: "Passa a LTR",
|
|
|
+ darkMode: "Tema scuro",
|
|
|
+ lightMode: "Tema chiaro",
|
|
|
+ helpTitle: "Aiuto di Visualizzatore Markdown",
|
|
|
+ aboutTitle: "Informazioni su Markdown",
|
|
|
+ shareTitle: "Condividi documento",
|
|
|
+ renameTitle: "Rinomina file",
|
|
|
+ insertLink: "Inserisci link",
|
|
|
+ insertRef: "Inserisci riferimento",
|
|
|
+ insertImg: "Inserisci immagine",
|
|
|
+ insertTable: "Inserisci tabella",
|
|
|
+ findReplace: "Trova e sostituisci",
|
|
|
+ placeholder: "Scrivi il tuo markdown qui...",
|
|
|
+ loadingEmojis: "Caricamento emoji...",
|
|
|
+ loadingFiles: "Recupero albero dei file..."
|
|
|
+ },
|
|
|
+ tr: {
|
|
|
+ title: "Markdown Görüntüleyici",
|
|
|
+ syncOff: "Senkronizasyonu Kapat",
|
|
|
+ syncOn: "Senkronizasyonu Aç",
|
|
|
+ import: "İçe Aktar",
|
|
|
+ importFile: "Dosyalardan",
|
|
|
+ importGithub: "GitHub'dan",
|
|
|
+ export: "Dışa Aktar",
|
|
|
+ exportMd: "Markdown (.md)",
|
|
|
+ exportHtml: "HTML",
|
|
|
+ exportPdf: "PDF",
|
|
|
+ copy: "Kopyala",
|
|
|
+ copied: "Kopyalandı!",
|
|
|
+ share: "Paylaş",
|
|
|
+ reset: "Sıfırla",
|
|
|
+ editor: "Editör",
|
|
|
+ split: "Bölünmüş",
|
|
|
+ preview: "Önizleme",
|
|
|
+ minRead: "Dk Okuma",
|
|
|
+ words: "Kelime",
|
|
|
+ chars: "Karakter",
|
|
|
+ switchRtl: "RTL'ye Geç",
|
|
|
+ switchLtr: "LTR'ye Geç",
|
|
|
+ darkMode: "Karanlık Mod",
|
|
|
+ lightMode: "Aydınlık Mod",
|
|
|
+ helpTitle: "Markdown Görüntüleyici Yardımı",
|
|
|
+ aboutTitle: "Markdown Hakkında",
|
|
|
+ shareTitle: "Belgeyi Paylaş",
|
|
|
+ renameTitle: "Dosyayı yeniden adlandır",
|
|
|
+ insertLink: "Bağlantı ekle",
|
|
|
+ insertRef: "Referans ekle",
|
|
|
+ insertImg: "Resim ekle",
|
|
|
+ insertTable: "Tablo ekle",
|
|
|
+ findReplace: "Bul ve Değiştir",
|
|
|
+ placeholder: "Markdown'ınızı buraya yazın...",
|
|
|
+ loadingEmojis: "Emoji'ler yükleniyor...",
|
|
|
+ loadingFiles: "Dosya ağacı alınıyor..."
|
|
|
+ },
|
|
|
+ pl: {
|
|
|
+ title: "Czytnik Markdown",
|
|
|
+ syncOff: "Wyłącz synchronizację",
|
|
|
+ syncOn: "Włącz synchronizację",
|
|
|
+ import: "Importuj",
|
|
|
+ importFile: "Z plików",
|
|
|
+ importGithub: "Z GitHub",
|
|
|
+ export: "Eksportuj",
|
|
|
+ exportMd: "Markdown (.md)",
|
|
|
+ exportHtml: "HTML",
|
|
|
+ exportPdf: "PDF",
|
|
|
+ copy: "Kopiuj",
|
|
|
+ copied: "Skopiowano!",
|
|
|
+ share: "Udostępnij",
|
|
|
+ reset: "Resetuj",
|
|
|
+ editor: "Edytor",
|
|
|
+ split: "Podzielony",
|
|
|
+ preview: "Podgląd",
|
|
|
+ minRead: "Min. czytania",
|
|
|
+ words: "Słowa",
|
|
|
+ chars: "Znaki",
|
|
|
+ switchRtl: "Przełącz na RTL",
|
|
|
+ switchLtr: "Przełącz na LTR",
|
|
|
+ darkMode: "Tryb ciemny",
|
|
|
+ lightMode: "Tryb jasny",
|
|
|
+ helpTitle: "Pomoc Czytnika Markdown",
|
|
|
+ aboutTitle: "O Markdown",
|
|
|
+ shareTitle: "Udostępnij dokument",
|
|
|
+ renameTitle: "Zmień nazwę pliku",
|
|
|
+ insertLink: "Wstaw link",
|
|
|
+ insertRef: "Wstaw odnośnik",
|
|
|
+ insertImg: "Wstaw obraz",
|
|
|
+ insertTable: "Wstaw tabelę",
|
|
|
+ findReplace: "Znajdź i zamień",
|
|
|
+ placeholder: "Wpisz tutaj swój markdown...",
|
|
|
+ loadingEmojis: "Ładowanie emoji...",
|
|
|
+ loadingFiles: "Pobieranie drzewa plików..."
|
|
|
+ },
|
|
|
+ tw: {
|
|
|
+ title: "Markdown 閱讀器",
|
|
|
+ syncOff: "同步關閉",
|
|
|
+ syncOn: "同步開啟",
|
|
|
+ import: "匯入",
|
|
|
+ importFile: "本地檔案匯入",
|
|
|
+ importGithub: "從 GitHub 匯入",
|
|
|
+ export: "匯出",
|
|
|
+ exportMd: "匯出 Markdown (.md)",
|
|
|
+ exportHtml: "匯出 HTML",
|
|
|
+ exportPdf: "匯出 PDF",
|
|
|
+ copy: "複製",
|
|
|
+ copied: "已複製!",
|
|
|
+ share: "分享",
|
|
|
+ reset: "重置",
|
|
|
+ editor: "編輯器",
|
|
|
+ split: "分欄預覽",
|
|
|
+ preview: "純預覽",
|
|
|
+ minRead: "分鐘閱讀",
|
|
|
+ words: "字數",
|
|
|
+ chars: "字元數",
|
|
|
+ switchRtl: "切換為右至左佈局",
|
|
|
+ switchLtr: "切換為左至右佈局",
|
|
|
+ darkMode: "深色模式",
|
|
|
+ lightMode: "淺色模式",
|
|
|
+ helpTitle: "Markdown 閱讀器說明",
|
|
|
+ aboutTitle: "關於 Markdown 閱讀器",
|
|
|
+ shareTitle: "分享當前文件",
|
|
|
+ renameTitle: "重新命名檔案",
|
|
|
+ insertLink: "插入超連結",
|
|
|
+ insertRef: "插入腳註引用",
|
|
|
+ insertImg: "插入圖片",
|
|
|
+ insertTable: "插入表格",
|
|
|
+ findReplace: "尋找與取代",
|
|
|
+ placeholder: "在此輸入您的 Markdown 文本...",
|
|
|
+ loadingEmojis: "正在載入表情...",
|
|
|
+ loadingFiles: "正在獲取檔案樹..."
|
|
|
+ },
|
|
|
+ uk: {
|
|
|
+ title: "Переглядач Markdown",
|
|
|
+ syncOff: "Вимкнути синхронізацію",
|
|
|
+ syncOn: "Увімкнути синхронізацію",
|
|
|
+ import: "Імпорт",
|
|
|
+ importFile: "З файлів",
|
|
|
+ importGithub: "З GitHub",
|
|
|
+ export: "Експорт",
|
|
|
+ exportMd: "Markdown (.md)",
|
|
|
+ exportHtml: "HTML",
|
|
|
+ exportPdf: "PDF",
|
|
|
+ copy: "Копіювати",
|
|
|
+ copied: "Скопійовано!",
|
|
|
+ share: "Поділитися",
|
|
|
+ reset: "Скинути",
|
|
|
+ editor: "Редактор",
|
|
|
+ split: "Розділений",
|
|
|
+ preview: "Перегляд",
|
|
|
+ minRead: "Хв читання",
|
|
|
+ words: "Слів",
|
|
|
+ chars: "Символів",
|
|
|
+ switchRtl: "Перемкнути на RTL",
|
|
|
+ switchLtr: "Перемкнути на LTR",
|
|
|
+ darkMode: "Темний режим",
|
|
|
+ lightMode: "Світлий режим",
|
|
|
+ helpTitle: "Довідка Markdown Viewer",
|
|
|
+ aboutTitle: "Про Markdown",
|
|
|
+ shareTitle: "Поділитися документом",
|
|
|
+ renameTitle: "Перейменувати файл",
|
|
|
+ insertLink: "Вставити посилання",
|
|
|
+ insertRef: "Вставити витяг",
|
|
|
+ insertImg: "Вставити зображення",
|
|
|
+ insertTable: "Вставити таблицю",
|
|
|
+ findReplace: "Знайти та замінити",
|
|
|
+ placeholder: "Введіть ваш markdown тут...",
|
|
|
+ loadingEmojis: "Завантаження емодзі...",
|
|
|
+ loadingFiles: "Отримання структури файлів..."
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ let activeLang = 'en';
|
|
|
+
|
|
|
+ function applyTranslations(lang) {
|
|
|
+ activeLang = lang;
|
|
|
+ document.documentElement.setAttribute('lang', lang === 'zh' ? 'zh-Hans' : (lang === 'tw' ? 'zh-Hant' : lang));
|
|
|
+ const dict = I18N_DICTS[lang] || I18N_DICTS.en;
|
|
|
+
|
|
|
+ // Update main logo and header elements
|
|
|
+ const logoEl = document.querySelector('.app-header h1');
|
|
|
+ if (logoEl) logoEl.textContent = dict.title;
|
|
|
+
|
|
|
+ // Update dynamic current language labels in drop menus
|
|
|
+ const labelEl = document.getElementById('current-lang-label');
|
|
|
+ if (labelEl) {
|
|
|
+ const flags = {
|
|
|
+ en: "🇺🇸 English",
|
|
|
+ zh: "🇨🇳 简体中文",
|
|
|
+ ja: "🇯🇵 日本語",
|
|
|
+ ko: "🇰🇷 한국어",
|
|
|
+ pt: "🇧🇷 Português (Brasil)",
|
|
|
+ es: "🇪🇸 Español",
|
|
|
+ fr: "🇫🇷 Français",
|
|
|
+ de: "🇩🇪 Deutsch",
|
|
|
+ ru: "🇷🇺 Русский",
|
|
|
+ it: "🇮🇹 Italiano",
|
|
|
+ tr: "🇹🇷 Türkçe",
|
|
|
+ pl: "🇵🇱 Polski",
|
|
|
+ tw: "🇹🇼 繁體中文",
|
|
|
+ uk: "🇺🇦 Українська"
|
|
|
+ };
|
|
|
+ labelEl.textContent = flags[lang];
|
|
|
+ }
|
|
|
+ const mobileLabelEl = document.getElementById('mobile-current-lang-label');
|
|
|
+ if (mobileLabelEl) {
|
|
|
+ const flags = {
|
|
|
+ en: "us English",
|
|
|
+ zh: "CN 简体中文",
|
|
|
+ ja: "JP 日本語",
|
|
|
+ ko: "KR 한국어",
|
|
|
+ pt: "BR Português (Brasil)",
|
|
|
+ es: "ES Español",
|
|
|
+ fr: "FR Français",
|
|
|
+ de: "DE Deutsch",
|
|
|
+ ru: "RU Русский",
|
|
|
+ it: "IT Italiano",
|
|
|
+ tr: "TR Türkçe",
|
|
|
+ pl: "PL Polski",
|
|
|
+ tw: "TW 繁體中文",
|
|
|
+ uk: "UK Українська"
|
|
|
+ };
|
|
|
+ mobileLabelEl.textContent = flags[lang];
|
|
|
+ }
|
|
|
+
|
|
|
+ // Translate buttons with text content
|
|
|
+ const toggleSyncEl = document.getElementById('toggle-sync');
|
|
|
+ if (toggleSyncEl) {
|
|
|
+ const isSyncActive = toggleSyncEl.classList.contains('sync-active');
|
|
|
+ const textSpan = toggleSyncEl.querySelector('.btn-text');
|
|
|
+ if (textSpan) textSpan.textContent = isSyncActive ? dict.syncOff : dict.syncOn;
|
|
|
+ }
|
|
|
+ const mobileToggleSyncEl = document.getElementById('mobile-toggle-sync');
|
|
|
+ if (mobileToggleSyncEl) {
|
|
|
+ const isSyncActive = mobileToggleSyncEl.classList.contains('sync-active');
|
|
|
+ mobileToggleSyncEl.innerHTML = `<i class="bi bi-link"></i> ${isSyncActive ? dict.syncOff : dict.syncOn}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Import buttons
|
|
|
+ const importDropEl = document.getElementById('importDropdown');
|
|
|
+ if (importDropEl) {
|
|
|
+ const importText = importDropEl.querySelector('.btn-text');
|
|
|
+ if (importText) importText.textContent = dict.import;
|
|
|
+ }
|
|
|
+ const importFileEl = document.getElementById('import-from-file');
|
|
|
+ if (importFileEl) importFileEl.innerHTML = `<i class="bi bi-upload me-2"></i>${dict.importFile}`;
|
|
|
+ const importGithubEl = document.getElementById('import-from-github');
|
|
|
+ if (importGithubEl) importGithubEl.innerHTML = `<i class="bi bi-github me-2"></i>${dict.importGithub}`;
|
|
|
+
|
|
|
+ const mImportFileEl = document.getElementById('mobile-import-button');
|
|
|
+ if (mImportFileEl) mImportFileEl.innerHTML = `<i class="bi bi-upload me-2"></i>${dict.importFile}`;
|
|
|
+ const mImportGithubEl = document.getElementById('mobile-import-github-button');
|
|
|
+ if (mImportGithubEl) mImportGithubEl.innerHTML = `<i class="bi bi-github me-2"></i>${dict.importGithub}`;
|
|
|
+
|
|
|
+ // Export buttons
|
|
|
+ const exportDropEl = document.getElementById('exportDropdown');
|
|
|
+ if (exportDropEl) {
|
|
|
+ const exportText = exportDropEl.querySelector('.btn-text');
|
|
|
+ if (exportText) exportText.textContent = dict.export;
|
|
|
+ }
|
|
|
+ const exportMdEl = document.getElementById('export-md');
|
|
|
+ if (exportMdEl) exportMdEl.innerHTML = `<i class="bi bi-file-earmark-text me-2"></i>${dict.exportMd}`;
|
|
|
+ const exportHtmlEl = document.getElementById('export-html');
|
|
|
+ if (exportHtmlEl) exportHtmlEl.innerHTML = `<i class="bi bi-file-earmark-code me-2"></i>${dict.exportHtml}`;
|
|
|
+ const exportPdfEl = document.getElementById('export-pdf');
|
|
|
+ if (exportPdfEl) exportPdfEl.innerHTML = `<i class="bi bi-file-earmark-pdf me-2"></i>${dict.exportPdf}`;
|
|
|
+
|
|
|
+ const mExportMdEl = document.getElementById('mobile-export-md');
|
|
|
+ if (mExportMdEl) mExportMdEl.innerHTML = `<i class="bi bi-file-earmark-text me-2"></i>${dict.exportMd}`;
|
|
|
+ const mExportHtmlEl = document.getElementById('mobile-export-html');
|
|
|
+ if (mExportHtmlEl) mExportHtmlEl.innerHTML = `<i class="bi bi-file-earmark-code me-2"></i>${dict.exportHtml}`;
|
|
|
+ const mExportPdfEl = document.getElementById('mobile-export-pdf');
|
|
|
+ if (mExportPdfEl) mExportPdfEl.innerHTML = `<i class="bi bi-file-earmark-pdf me-2"></i>${dict.exportPdf}`;
|
|
|
+
|
|
|
+ // Copy / Share
|
|
|
+ if (copyMarkdownButton) {
|
|
|
+ const copyButtonText = copyMarkdownButton.querySelector('.btn-text');
|
|
|
+ if (copyButtonText) copyButtonText.textContent = dict.copy;
|
|
|
+ }
|
|
|
+ const mCopyBtn = document.getElementById('mobile-copy-markdown');
|
|
|
+ if (mCopyBtn) mCopyBtn.innerHTML = `<i class="bi bi-clipboard me-2"></i>${dict.copy}`;
|
|
|
+
|
|
|
+ if (shareButton) {
|
|
|
+ const shareButtonText = shareButton.querySelector('.btn-text');
|
|
|
+ if (shareButtonText) shareButtonText.textContent = dict.share;
|
|
|
+ }
|
|
|
+ const mShareBtn = document.getElementById('mobile-share-button');
|
|
|
+ if (mShareBtn) mShareBtn.innerHTML = `<i class="bi bi-share me-2"></i>${dict.share}`;
|
|
|
+
|
|
|
+ // Document Reset
|
|
|
+ const tabResetBtn = document.getElementById('tab-reset-btn');
|
|
|
+ if (tabResetBtn) tabResetBtn.innerHTML = `<i class="bi bi-arrow-counterclockwise"></i> ${dict.reset}`;
|
|
|
+ const mTabResetBtn = document.getElementById('mobile-tab-reset-btn');
|
|
|
+ if (mTabResetBtn) mTabResetBtn.innerHTML = `<i class="bi bi-arrow-counterclockwise"></i> ${dict.reset} all files`;
|
|
|
+
|
|
|
+ // View toggle buttons title tooltips
|
|
|
+ document.querySelectorAll('[data-view-mode="editor"]').forEach(b => b.title = dict.editor);
|
|
|
+ document.querySelectorAll('[data-view-mode="split"]').forEach(b => b.title = dict.split);
|
|
|
+ document.querySelectorAll('[data-view-mode="preview"]').forEach(b => b.title = dict.preview);
|
|
|
+ document.querySelectorAll('.mobile-view-mode-btn[data-mode="editor"] span').forEach(s => s.textContent = dict.editor);
|
|
|
+ document.querySelectorAll('.mobile-view-mode-btn[data-mode="split"] span').forEach(s => s.textContent = dict.split);
|
|
|
+ document.querySelectorAll('.mobile-view-mode-btn[data-mode="preview"] span').forEach(s => s.textContent = dict.preview);
|
|
|
+
|
|
|
+ // Direction Toggle
|
|
|
+ const dirToggle = document.getElementById('direction-toggle');
|
|
|
+ if (dirToggle) {
|
|
|
+ const isRtl = document.body.style.direction === 'rtl';
|
|
|
+ dirToggle.title = isRtl ? dict.switchLtr : dict.switchRtl;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Modal Titles
|
|
|
+ const modalHelpTitle = document.getElementById('help-modal-title');
|
|
|
+ if (modalHelpTitle) modalHelpTitle.textContent = dict.helpTitle;
|
|
|
+ const modalAboutTitle = document.getElementById('about-modal-title');
|
|
|
+ if (modalAboutTitle) modalAboutTitle.textContent = dict.aboutTitle;
|
|
|
+ const modalShareTitle = document.getElementById('share-modal-title');
|
|
|
+ if (modalShareTitle) modalShareTitle.textContent = dict.shareTitle;
|
|
|
+ const modalRenameTitle = document.getElementById('rename-modal-title');
|
|
|
+ if (modalRenameTitle) modalRenameTitle.textContent = dict.renameTitle;
|
|
|
+ const modalLinkTitle = document.getElementById('link-modal-title');
|
|
|
+ if (modalLinkTitle) modalLinkTitle.textContent = dict.insertLink;
|
|
|
+ const modalRefTitle = document.getElementById('reference-modal-title');
|
|
|
+ if (modalRefTitle) modalRefTitle.textContent = dict.insertRef;
|
|
|
+ const modalImgTitle = document.getElementById('image-modal-title');
|
|
|
+ if (modalImgTitle) modalImgTitle.textContent = dict.insertImg;
|
|
|
+ const modalTableTitle = document.getElementById('table-modal-title');
|
|
|
+ if (modalTableTitle) modalTableTitle.textContent = dict.insertTable;
|
|
|
+ const modalFindTitle = document.getElementById('find-replace-title');
|
|
|
+ if (modalFindTitle) modalFindTitle.textContent = dict.findReplace;
|
|
|
+
|
|
|
+ // Theme titles
|
|
|
+ const mThemeToggle = document.getElementById('mobile-theme-toggle');
|
|
|
+ if (mThemeToggle) {
|
|
|
+ const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
|
|
|
+ mThemeToggle.innerHTML = `<i class="bi bi-${currentTheme === 'dark' ? 'sun' : 'moon'} me-2"></i> ${currentTheme === 'dark' ? dict.lightMode : dict.darkMode}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Stats Labels
|
|
|
+ const minReadEl = document.getElementById('lbl-min-read');
|
|
|
+ if (minReadEl) minReadEl.textContent = dict.minRead;
|
|
|
+ const wordsEl = document.getElementById('lbl-words');
|
|
|
+ if (wordsEl) wordsEl.textContent = dict.words;
|
|
|
+ const charsEl = document.getElementById('lbl-chars');
|
|
|
+ if (charsEl) charsEl.textContent = dict.chars;
|
|
|
+
|
|
|
+ const mMinReadEl = document.getElementById('lbl-mobile-min-read');
|
|
|
+ if (mMinReadEl) mMinReadEl.textContent = dict.minRead;
|
|
|
+ const mWordsEl = document.getElementById('lbl-mobile-words');
|
|
|
+ if (mWordsEl) mWordsEl.textContent = dict.words;
|
|
|
+ const mCharsEl = document.getElementById('lbl-mobile-chars');
|
|
|
+ if (mCharsEl) mCharsEl.textContent = dict.chars;
|
|
|
+
|
|
|
+ // Placeholder
|
|
|
+ if (markdownEditor) {
|
|
|
+ markdownEditor.placeholder = dict.placeholder;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Trigger state tracking update
|
|
|
+ updateDocumentStats();
|
|
|
+
|
|
|
+ // Mark current selected dropdown items as active
|
|
|
+ document.querySelectorAll('.lang-select-item').forEach(item => {
|
|
|
+ if (item.getAttribute('data-lang') === lang) {
|
|
|
+ item.classList.add('active');
|
|
|
+ } else {
|
|
|
+ item.classList.remove('active');
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function detectAndInitLanguage() {
|
|
|
+ const urlParams = new URLSearchParams(window.location.search);
|
|
|
+ let lang = urlParams.get('lang');
|
|
|
+
|
|
|
+ if (!lang) {
|
|
|
+ const hash = window.location.hash;
|
|
|
+ const hashParams = new URLSearchParams(hash.includes('?') ? hash.split('?')[1] : '');
|
|
|
+ lang = hashParams.get('lang');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!lang) {
|
|
|
+ lang = localStorage.getItem('app-lang');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!lang && navigator.language) {
|
|
|
+ const navLang = navigator.language.toLowerCase();
|
|
|
+ if (navLang.startsWith('zh-tw') || navLang.startsWith('zh-hk') || navLang.startsWith('zh-hant')) lang = 'tw';
|
|
|
+ else if (navLang.startsWith('zh')) lang = 'zh';
|
|
|
+ else if (navLang.startsWith('ja')) lang = 'ja';
|
|
|
+ else if (navLang.startsWith('ko')) lang = 'ko';
|
|
|
+ else if (navLang.startsWith('pt')) lang = 'pt';
|
|
|
+ else if (navLang.startsWith('es')) lang = 'es';
|
|
|
+ else if (navLang.startsWith('fr')) lang = 'fr';
|
|
|
+ else if (navLang.startsWith('de')) lang = 'de';
|
|
|
+ else if (navLang.startsWith('ru')) lang = 'ru';
|
|
|
+ else if (navLang.startsWith('it')) lang = 'it';
|
|
|
+ else if (navLang.startsWith('tr')) lang = 'tr';
|
|
|
+ else if (navLang.startsWith('pl')) lang = 'pl';
|
|
|
+ else if (navLang.startsWith('uk')) lang = 'uk';
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!lang || !I18N_DICTS[lang]) {
|
|
|
+ lang = 'en';
|
|
|
+ }
|
|
|
+
|
|
|
+ applyTranslations(lang);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Language selectors click event listeners
|
|
|
+ document.addEventListener('click', function(e) {
|
|
|
+ const item = e.target.closest('.lang-select-item');
|
|
|
+ if (item) {
|
|
|
+ e.preventDefault();
|
|
|
+ const lang = item.getAttribute('data-lang');
|
|
|
+ applyTranslations(lang);
|
|
|
+ localStorage.setItem('app-lang', lang);
|
|
|
+
|
|
|
+ // Update browser search parameters dynamically without page reload
|
|
|
+ const url = new URL(window.location.href);
|
|
|
+ url.searchParams.set('lang', lang);
|
|
|
+ window.history.replaceState({}, '', url.toString());
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // Accessibility dynamic screen reader announcer helper
|
|
|
+ function announceToScreenReader(message) {
|
|
|
+ const announcer = document.getElementById('app-accessibility-announcer');
|
|
|
+ if (!announcer) return;
|
|
|
+ announcer.textContent = '';
|
|
|
+ clearTimeout(announceToScreenReader._timeoutId);
|
|
|
+ announceToScreenReader._timeoutId = setTimeout(() => {
|
|
|
+ announcer.textContent = message;
|
|
|
+ }, 50);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Visual skeleton loader generator for emoji list
|
|
|
+ function renderEmojiSkeletons() {
|
|
|
+ const grid = document.getElementById('emoji-modal-grid');
|
|
|
+ if (!grid) return;
|
|
|
+ // PERF-007: Clear elements using textContent
|
|
|
+ grid.textContent = '';
|
|
|
+ const fragment = document.createDocumentFragment();
|
|
|
+ for (let i = 0; i < 18; i++) {
|
|
|
+ const item = document.createElement('div');
|
|
|
+ item.className = 'emoji-item skeleton-placeholder';
|
|
|
+ item.setAttribute('aria-hidden', 'true');
|
|
|
+ item.style.border = '1px solid var(--border-color)';
|
|
|
+ item.style.borderRadius = '10px';
|
|
|
+ item.style.padding = '10px';
|
|
|
+ item.style.display = 'flex';
|
|
|
+ item.style.flexDirection = 'column';
|
|
|
+ item.style.alignItems = 'center';
|
|
|
+ item.style.gap = '8px';
|
|
|
+
|
|
|
+ const preview = document.createElement('span');
|
|
|
+ preview.className = 'emoji-preview skeleton-circle';
|
|
|
+ item.appendChild(preview);
|
|
|
+
|
|
|
+ const shortcodeRow = document.createElement('div');
|
|
|
+ shortcodeRow.className = 'emoji-shortcode';
|
|
|
+ const code = document.createElement('span');
|
|
|
+ code.className = 'skeleton-text';
|
|
|
+ code.style.width = '60px';
|
|
|
+ shortcodeRow.appendChild(code);
|
|
|
+ item.appendChild(shortcodeRow);
|
|
|
+
|
|
|
+ fragment.appendChild(item);
|
|
|
+ }
|
|
|
+ grid.appendChild(fragment);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Visual skeleton loader generator for GitHub file list tree
|
|
|
+ function renderGitHubImportTreeSkeleton() {
|
|
|
+ if (!githubImportTree) return;
|
|
|
+ // PERF-007: Clear elements using textContent
|
|
|
+ githubImportTree.textContent = '';
|
|
|
+
|
|
|
+ const wrapper = document.createElement('div');
|
|
|
+ wrapper.className = 'github-import-tree-skeleton';
|
|
|
+
|
|
|
+ const list = document.createElement('ul');
|
|
|
+ list.style.listStyle = 'none';
|
|
|
+ list.style.paddingLeft = '4px';
|
|
|
+ list.style.margin = '0';
|
|
|
+
|
|
|
+ for (let i = 0; i < 4; i++) {
|
|
|
+ const folderItem = document.createElement('li');
|
|
|
+ folderItem.style.margin = '6px 0';
|
|
|
+
|
|
|
+ const folderSpan = document.createElement('span');
|
|
|
+ folderSpan.className = 'skeleton-placeholder skeleton-tree-folder';
|
|
|
+ folderItem.appendChild(folderSpan);
|
|
|
+
|
|
|
+ const subList = document.createElement('ul');
|
|
|
+ subList.style.listStyle = 'none';
|
|
|
+ subList.style.paddingLeft = '18px';
|
|
|
+ subList.style.margin = '0';
|
|
|
+
|
|
|
+ for (let j = 0; j < 2; j++) {
|
|
|
+ const fileItem = document.createElement('li');
|
|
|
+ fileItem.style.margin = '4px 0';
|
|
|
+
|
|
|
+ const fileSpan = document.createElement('span');
|
|
|
+ fileSpan.className = 'skeleton-placeholder skeleton-tree-file';
|
|
|
+ fileItem.appendChild(fileSpan);
|
|
|
+ subList.appendChild(fileItem);
|
|
|
+ }
|
|
|
+
|
|
|
+ folderItem.appendChild(subList);
|
|
|
+ list.appendChild(folderItem);
|
|
|
+ }
|
|
|
+
|
|
|
+ wrapper.appendChild(list);
|
|
|
+ githubImportTree.appendChild(wrapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Run detection
|
|
|
+ detectAndInitLanguage();
|
|
|
+
|
|
|
+ // Intercept all link clicks in the preview pane to open them securely and prevent page navigation
|
|
|
+ if (markdownPreview) {
|
|
|
+ markdownPreview.addEventListener('click', function(e) {
|
|
|
+ const link = e.target.closest('a');
|
|
|
+ if (link) {
|
|
|
+ const href = link.getAttribute('href');
|
|
|
+ if (href) {
|
|
|
+ if (href.startsWith('#')) {
|
|
|
+ const targetId = decodeURIComponent(href.slice(1));
|
|
|
+ let targetEl = null;
|
|
|
+ if (targetId) {
|
|
|
+ try {
|
|
|
+ targetEl = markdownPreview.querySelector(`[id="${CSS.escape(targetId)}"]`) ||
|
|
|
+ markdownPreview.querySelector(`[name="${CSS.escape(targetId)}"]`);
|
|
|
+ } catch (err) {
|
|
|
+ targetEl = Array.from(markdownPreview.querySelectorAll('[id], [name]')).find(el => {
|
|
|
+ return el.getAttribute('id') === targetId || el.getAttribute('name') === targetId;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!targetEl) {
|
|
|
+ const cleanTargetId = targetId.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
|
+ if (cleanTargetId) {
|
|
|
+ targetEl = Array.from(markdownPreview.querySelectorAll('h1, h2, h3, h4, h5, h6')).find(heading => {
|
|
|
+ const cleanText = heading.textContent.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
|
+ return cleanText === cleanTargetId;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (targetEl) {
|
|
|
+ e.preventDefault();
|
|
|
+ isProgrammaticScrolling = true;
|
|
|
+
|
|
|
+ // Scroll preview pane to target heading
|
|
|
+ targetEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
|
+
|
|
|
+ // Scroll editor pane to the matching synced position
|
|
|
+ const previewScrollRange = previewPane.scrollHeight - previewPane.clientHeight;
|
|
|
+ const targetRatio = previewScrollRange > 0 ? Math.min(1, Math.max(0, targetEl.offsetTop / previewScrollRange)) : 0;
|
|
|
+ const editorScrollPosition = targetRatio * (markdownEditor.scrollHeight - markdownEditor.clientHeight);
|
|
|
+
|
|
|
+ markdownEditor.scrollTo({
|
|
|
+ top: editorScrollPosition,
|
|
|
+ behavior: 'smooth'
|
|
|
+ });
|
|
|
+
|
|
|
+ if (window.programmaticScrollTimeout) {
|
|
|
+ clearTimeout(window.programmaticScrollTimeout);
|
|
|
+ }
|
|
|
+ window.programmaticScrollTimeout = setTimeout(() => {
|
|
|
+ isProgrammaticScrolling = false;
|
|
|
+ }, 1000);
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ e.preventDefault();
|
|
|
+ // Defense-in-depth: check that the URL protocol is safe
|
|
|
+ let isSafe = false;
|
|
|
+ try {
|
|
|
+ const parsed = new URL(href, window.location.href);
|
|
|
+ isSafe = ['http:', 'https:', 'mailto:', 'tel:', 'blob:'].includes(parsed.protocol);
|
|
|
+ } catch (err) {
|
|
|
+ // If URL constructor fails, it might be a relative path without a base, which is safe to resolve
|
|
|
+ isSafe = !href.trim().toLowerCase().startsWith('javascript:');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isSafe) {
|
|
|
+ if (typeof Neutralino !== 'undefined') {
|
|
|
+ Neutralino.os.open(href);
|
|
|
+ } else {
|
|
|
+ window.open(href, '_blank', 'noopener,noreferrer');
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ console.warn('Blocked opening potentially unsafe URL:', href);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // Register Service Worker for offline capabilities
|
|
|
+ if ('serviceWorker' in navigator) {
|
|
|
+ window.addEventListener('load', function() {
|
|
|
+ navigator.serviceWorker.register('sw.js').then(function(registration) {
|
|
|
+ console.log('ServiceWorker registration successful with scope: ', registration.scope);
|
|
|
+ }, function(err) {
|
|
|
+ console.log('ServiceWorker registration failed: ', err);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+});
|