From e9d9bb4a8372b5d33a1645756091087b7bccb798 Mon Sep 17 00:00:00 2001 From: abose Date: Wed, 8 Apr 2026 16:38:32 +0530 Subject: [PATCH 1/5] feat(mdviewer): dark/light theme toggle, typography, toolbar collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add theme toggle button (sun/moon) in md viewer embedded toolbar, visible in both edit and reader modes - Theme persisted via PreferencesManager preference (mdViewerTheme) - Communication: iframe sends mdviewrThemeToggle → MarkdownSync persists and sends MDVIEWR_SET_THEME back to apply - Content max-width changed to 90ch for paper-like readability - Responsive padding: 70px on wider viewports, 24px on narrow - Collapse all toolbar format groups below 590px panel width - Update CLAUDE.md and CLAUDE-markdown-viewer.md to document md viewer's own i18n system (src-mdviewer/src/locales/en.json) --- CLAUDE.md | 1 + src-mdviewer/CLAUDE-markdown-viewer.md | 11 +++++- src-mdviewer/src/bridge.js | 12 +++--- .../src/components/embedded-toolbar.js | 39 ++++++++++++++++--- src-mdviewer/src/locales/en.json | 2 +- .../Phoenix-live-preview/MarkdownSync.js | 29 ++++++++++++-- .../Phoenix-live-preview/main.js | 12 ++++++ src/nls/root/strings.js | 1 + 8 files changed, 90 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 412192df2..6a0bc2647 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ - For parameterized strings use `StringUtils.format(Strings.KEY, arg0, arg1)` with `{0}`, `{1}` placeholders. - Keys use UPPER_SNAKE_CASE grouped by feature prefix (e.g. `AI_CHAT_*`). - Only `src/nls/root/strings.js` (English) needs manual edits — other locales are auto-translated by GitHub Actions. +- **Exception — Markdown viewer iframe** (`src-mdviewer/`): Has its own i18n system. Strings go in `src-mdviewer/src/locales/en.json` (root), not `src/nls/`. Other locale files in that folder are auto-translated by GitHub Actions. Use `t("key")` / `tp("key", { param })` from `src-mdviewer/src/core/i18n.js`. - Never compare `$(el).text()` against English strings for logic — use data attributes or CSS classes instead. ## Phoenix MCP (Desktop App Testing) diff --git a/src-mdviewer/CLAUDE-markdown-viewer.md b/src-mdviewer/CLAUDE-markdown-viewer.md index 61fbe6fdd..d72e72ded 100644 --- a/src-mdviewer/CLAUDE-markdown-viewer.md +++ b/src-mdviewer/CLAUDE-markdown-viewer.md @@ -21,11 +21,18 @@ Test Runner Window - `src-mdviewer/src/bridge.js` — postMessage bridge between Phoenix and md iframe. Handles file switching, content sync, keyboard shortcuts, edit mode. - `src-mdviewer/src/core/doc-cache.js` — Document DOM cache with LRU eviction for file switching. - `src-mdviewer/src/components/editor.js` — Contenteditable WYSIWYG editing, Turndown HTML→Markdown conversion. -- `src-mdviewer/src/components/embedded-toolbar.js` — Reader/edit toggle, cursor sync, format buttons. +- `src-mdviewer/src/components/embedded-toolbar.js` — Reader/edit toggle, cursor sync, theme toggle, format buttons. - `src-mdviewer/src/components/format-bar.js` — Floating format bar on text selection (bold, italic, underline, link). - `src-mdviewer/src/components/link-popover.js` — Link popover for editing/removing links in edit mode. - `src-mdviewer/src/components/viewer.js` — Reader mode click handling, link interception, copy buttons. -- `src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js` — Phoenix-side sync: CM↔iframe content, cursor, scroll, selection. +- `src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js` — Phoenix-side sync: CM↔iframe content, cursor, scroll, selection, theme. + +### Translations / i18n +- The md viewer iframe has its **own** i18n system separate from Phoenix's `src/nls/` strings. +- Root strings (English) are in `src-mdviewer/src/locales/en.json` — edit this file for new/changed strings. +- Other locale files in `src-mdviewer/src/locales/` are **auto-translated by GitHub Actions** — do not edit them manually. +- Use `t("key.subkey")` and `tp("key", { param })` from `src-mdviewer/src/core/i18n.js` for string lookups. +- Phoenix-side strings (e.g. preference descriptions) still go in `src/nls/root/strings.js` as usual. ## Communication: postMessage (reliable in both directions) diff --git a/src-mdviewer/src/bridge.js b/src-mdviewer/src/bridge.js index 102901dc9..4a51a622d 100644 --- a/src-mdviewer/src/bridge.js +++ b/src-mdviewer/src/bridge.js @@ -806,11 +806,13 @@ function handleReloadFile(data) { // --- Theme, edit mode, locale --- -function handleSetTheme(_data) { - // Force light theme for a paper-like appearance regardless of editor theme. - // The theme infrastructure is preserved for future use. - // Theme is set in index.html (data-theme="light") so no action needed here. - // Avoid setting attributes/styles to prevent reflows that reset scroll position. +function handleSetTheme(data) { + const { theme } = data; + // Skip if already applied to avoid reflows that can reset scroll position + if (document.documentElement.getAttribute("data-theme") === theme) return; + document.documentElement.setAttribute("data-theme", theme); + document.documentElement.style.colorScheme = theme === "dark" ? "dark" : "light"; + setState({ theme }); } function handleSetEditMode(data) { diff --git a/src-mdviewer/src/components/embedded-toolbar.js b/src-mdviewer/src/components/embedded-toolbar.js index 9e229fce7..214dbfb99 100644 --- a/src-mdviewer/src/components/embedded-toolbar.js +++ b/src-mdviewer/src/components/embedded-toolbar.js @@ -32,7 +32,9 @@ import { Link2Off, Printer, Image as ImageIcon, - Upload + Upload, + Sun, + Moon } from "lucide"; import { on, emit } from "../core/events.js"; import { getState, setState } from "../core/state.js"; @@ -45,11 +47,11 @@ let collapseLevel = 0; // 0=expanded, 1=blocks, 2=blocks+lists, 3=all // Width thresholds for progressive collapse const THRESHOLD_BLOCKS = 640; // collapse block elements + image first -const THRESHOLD_LISTS = 520; // then lists -const THRESHOLD_TEXT = 500; // finally text formatting (all dropdowns collapsed) +const THRESHOLD_LISTS = 590; // then lists +const THRESHOLD_TEXT = 590; // finally text formatting (all dropdowns collapsed) const allIcons = { Bold, Italic, Strikethrough, Underline, Code, Link, List, ListOrdered, - ListChecks, Quote, Minus, Table, FileCode, ChevronDown, Type, MoreHorizontal, Pencil, BookOpen, Link2, Link2Off, Printer, Image: ImageIcon, Upload }; + ListChecks, Quote, Minus, Table, FileCode, ChevronDown, Type, MoreHorizontal, Pencil, BookOpen, Link2, Link2Off, Printer, Image: ImageIcon, Upload, Sun, Moon }; export function initEmbeddedToolbar() { toolbar = document.getElementById("toolbar"); @@ -58,6 +60,7 @@ export function initEmbeddedToolbar() { render(); on("state:editMode", () => render()); + on("state:theme", () => render()); on("editor:selection-state", updateFormatState); on("state:locale", () => render()); } @@ -80,8 +83,12 @@ function render() { } function renderReadMode() { + const isDark = getState().theme === "dark"; toolbar.innerHTML = `
+ @@ -96,9 +103,9 @@ function renderReadMode() {
`; createIcons({ icons: allIcons, attrs: { class: "" } }); - // Remove data-lucide from replaced SVGs to prevent warnings on subsequent createIcons calls toolbar.querySelectorAll("svg[data-lucide]").forEach(svg => svg.removeAttribute("data-lucide")); + wireThemeToggle(); wireCursorSyncButton(); wirePrintButton(); @@ -192,9 +199,13 @@ function renderEditMode(level) { ${imageSection} `; + const isDark = getState().theme === "dark"; toolbar.innerHTML = `
${formatRow}
+ @@ -209,12 +220,12 @@ function renderEditMode(level) {
`; createIcons({ icons: allIcons, attrs: { class: "" } }); - // Remove data-lucide from replaced SVGs to prevent warnings on subsequent createIcons calls toolbar.querySelectorAll("svg[data-lucide]").forEach(svg => svg.removeAttribute("data-lucide")); wireFormatButtons(); wireBlockTypeSelect(); wireDropdowns(); + wireThemeToggle(); wireCursorSyncButton(); wirePrintButton(); wireDoneButton(); @@ -307,6 +318,22 @@ function wireCursorSyncButton() { } } +function wireThemeToggle() { + const toggleBtn = document.getElementById("emb-theme-toggle"); + if (toggleBtn) { + toggleBtn.addEventListener("click", () => { + const current = getState().theme || "light"; + const newTheme = current === "light" ? "dark" : "light"; + // Send to parent (Phoenix) for persistence + window.parent.postMessage({ + type: "MDVIEWR_EVENT", + eventName: "mdviewrThemeToggle", + theme: newTheme + }, "*"); + }); + } +} + function wirePrintButton() { const printBtn = document.getElementById("emb-print-btn"); if (printBtn) { diff --git a/src-mdviewer/src/locales/en.json b/src-mdviewer/src/locales/en.json index dc47807a6..d73a32539 100644 --- a/src-mdviewer/src/locales/en.json +++ b/src-mdviewer/src/locales/en.json @@ -1,7 +1,7 @@ { "toolbar": { "open": "Open File", - "theme": "Toggle Theme", + "theme": "Toggle Dark/Light Theme", "toc": "Table of Contents", "search": "Search", "focus": "Focus Mode", diff --git a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js index 29c7fe6b3..78f90080b 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js @@ -132,6 +132,13 @@ define(function (require, exports, module) { case "mdviewrCursorSyncToggle": _cursorSyncEnabled = !!data.enabled; break; + case "mdviewrThemeToggle": + sendThemeOverride(data.theme); + // Persist via StateManager (accessed through main.js callback) + if (_onThemeToggle) { + _onThemeToggle(data.theme); + } + break; case "mdviewrImageUploadRequest": _handleImageUploadFromIframe(data); break; @@ -409,6 +416,10 @@ define(function (require, exports, module) { iframeWindow.postMessage(msg, "*"); } + // User's explicit theme choice (null = use editor theme) + let _themeOverride = null; + let _onThemeToggle = null; + function _sendTheme() { if (!_active || !_iframeReady) { return; @@ -418,14 +429,24 @@ define(function (require, exports, module) { return; } - const currentTheme = ThemeManager.getCurrentTheme(); - const isDark = currentTheme && currentTheme.dark; + let theme; + if (_themeOverride) { + theme = _themeOverride; + } else { + const currentTheme = ThemeManager.getCurrentTheme(); + theme = (currentTheme && currentTheme.dark) ? "dark" : "light"; + } iframeWindow.postMessage({ type: "MDVIEWR_SET_THEME", - theme: isDark ? "dark" : "light" + theme: theme }, "*"); } + function sendThemeOverride(theme) { + _themeOverride = theme; + _sendTheme(); + } + function _sendLocale() { if (!_active || !_iframeReady) { return; @@ -1098,4 +1119,6 @@ define(function (require, exports, module) { exports.setEditMode = setEditMode; exports.setIframeReadyHandler = setIframeReadyHandler; exports.setCursorSyncEnabled = setCursorSyncEnabled; + exports.sendThemeOverride = sendThemeOverride; + exports.setThemeToggleHandler = function(handler) { _onThemeToggle = handler; }; }); diff --git a/src/extensionsIntegrated/Phoenix-live-preview/main.js b/src/extensionsIntegrated/Phoenix-live-preview/main.js index 51523aaf9..d92ffb64d 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/main.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/main.js @@ -83,6 +83,10 @@ define(function (require, exports, module) { const StateManager = PreferencesManager.stateManager; const STATE_CUSTOM_SERVER_BANNER_ACK = "customServerBannerDone"; + const PREF_MD_THEME = "mdViewerTheme"; + PreferencesManager.definePreference(PREF_MD_THEME, "string", "light", { + description: Strings.MD_VIEWER_THEME_DESCRIPTION + }); let customServerModalBar; const isBrowser = !Phoenix.isNativeApp; @@ -788,6 +792,11 @@ define(function (require, exports, module) { $modeBtn = $panel.find("#livePreviewModeBtn"); $previewBtn = $panel.find("#previewModeLivePreviewButton"); + // Markdown theme toggle — persist user choice + MarkdownSync.setThemeToggleHandler((theme) => { + PreferencesManager.set(PREF_MD_THEME, theme); + }); + $panel.find(".live-preview-settings-banner-btn").on("click", ()=>{ CommandManager.execute(Commands.FILE_LIVE_FILE_PREVIEW_SETTINGS); Metrics.countEvent(Metrics.EVENT_TYPE.LIVE_PREVIEW, "settingsBtnBanner", "click"); @@ -919,6 +928,9 @@ define(function (require, exports, module) { _isMdviewrActive = true; MarkdownSync.activate(currentDoc, $iframe, baseURL); + // Apply persisted theme preference + const savedTheme = PreferencesManager.get(PREF_MD_THEME) || "light"; + MarkdownSync.sendThemeOverride(savedTheme); // Sync preview mode and edit mode for reuse case where iframe is already ready _updateLPControlsForMdviewer(); diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 4a75bee35..a0d119bac 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -161,6 +161,7 @@ define({ "LIVE_DEV_OPEN_ERROR_TITLE": "Error Opening Live Preview in {0}", "LIVE_DEV_OPEN_ERROR_MESSAGE": "Make sure that {0} browser is installed and try again.", "LIVE_DEV_CLICK_TO_PIN_UNPIN": "Pin or Unpin Preview Page", + "MD_VIEWER_THEME_DESCRIPTION": "Theme for the Markdown viewer (light or dark)", "LIVE_DEV_STATUS_TIP_SYNC_ERROR": "Live Preview (not updating due to syntax error)", "LIVE_DEV_SETTINGS": "Live Preview Settings\u2026", "LIVE_DEV_SETTINGS_BANNER": "Set up a custom server to live preview `{0}` and other server-rendered files (PHP, JSP, etc.)", From af3ff4f3bbbb86be62c75b931e5e9cbaa74f0720 Mon Sep 17 00:00:00 2001 From: abose Date: Wed, 8 Apr 2026 16:39:45 +0530 Subject: [PATCH 2/5] chore: update docs --- docs/API-Reference/search/FindUtils.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/API-Reference/search/FindUtils.md b/docs/API-Reference/search/FindUtils.md index 2d805e27f..a7084ef2b 100644 --- a/docs/API-Reference/search/FindUtils.md +++ b/docs/API-Reference/search/FindUtils.md @@ -107,6 +107,24 @@ enable/disable instant search ## isInstantSearchDisabled() ⇒ boolean if instant search is disabled, this will return true we can only do instant search through worker +**Kind**: global function + + +## setIndexingSuspended(suspended) +Set whether indexing has been suspended due to cache size limit + +**Kind**: global function + +| Param | Type | Description | +| --- | --- | --- | +| suspended | boolean | true if indexing was suspended | + + + +## isIndexingSuspended() ⇒ boolean +Check if indexing was suspended due to cache size limit. +When true, Find in Files should not perform searches. + **Kind**: global function From da4821a1147c3735b3c6dbb3de9b5b6d871326c4 Mon Sep 17 00:00:00 2001 From: abose Date: Wed, 8 Apr 2026 17:18:44 +0530 Subject: [PATCH 3/5] fix: mac md viewer not working --- src-mdviewer/index.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src-mdviewer/index.html b/src-mdviewer/index.html index a03bf493c..33b47bf68 100644 --- a/src-mdviewer/index.html +++ b/src-mdviewer/index.html @@ -5,12 +5,12 @@ + font-src 'self' data: http://localhost:* https://localhost:* phtauri: https://phtauri.localhost https://*.phcode.dev; + connect-src 'self' http://localhost:* https://localhost:* ws://localhost:* wss://localhost:* phtauri: https://phtauri.localhost https://*.phcode.dev;" /> From f2ce5c3e8936b655343fd3ab0648a6525369600e Mon Sep 17 00:00:00 2001 From: abose Date: Wed, 8 Apr 2026 17:24:56 +0530 Subject: [PATCH 4/5] fix(test): wait for checkboxes to render after mode switch The checkbox enable/disable test was flaky because _getCheckboxes() ran before the DOM re-rendered after mode switch. Add awaitsFor to wait for checkboxes to appear before asserting. --- test/spec/md-editor-edit-integ-test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/spec/md-editor-edit-integ-test.js b/test/spec/md-editor-edit-integ-test.js index 7399de92d..0e40fc006 100644 --- a/test/spec/md-editor-edit-integ-test.js +++ b/test/spec/md-editor-edit-integ-test.js @@ -210,6 +210,8 @@ define(function (require, exports, module) { // In reader mode: checkboxes should be disabled await _enterReaderMode(); + await awaitsFor(() => _getCheckboxes().length > 0, + "checkboxes to appear in reader mode"); let checkboxes = _getCheckboxes(); expect(checkboxes.length).toBeGreaterThan(0); for (const cb of checkboxes) { @@ -218,6 +220,8 @@ define(function (require, exports, module) { // In edit mode: checkboxes should be enabled await _enterEditMode(); + await awaitsFor(() => _getCheckboxes().length > 0, + "checkboxes to appear in edit mode"); checkboxes = _getCheckboxes(); expect(checkboxes.length).toBeGreaterThan(0); for (const cb of checkboxes) { From bcb6c37a945a5acc9f65f4deee0ce543cfae5481 Mon Sep 17 00:00:00 2001 From: abose Date: Wed, 8 Apr 2026 17:35:26 +0530 Subject: [PATCH 5/5] fix(mdviewer): scroll position lost on panel reopen, theme reflow guard - Fix scroll position destroyed when panel is hidden then reopened: saveActiveScrollPos now skips saving when viewer is hidden (scrollTop=0 from hidden element would overwrite the correct cached value) - Stricter theme skip: check both data-theme attr and colorScheme style - Increase panel reopen scroll test timeout for CI stability --- src-mdviewer/src/bridge.js | 8 ++++++-- src-mdviewer/src/core/doc-cache.js | 4 ++++ test/spec/md-editor-integ-test.js | 7 ++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src-mdviewer/src/bridge.js b/src-mdviewer/src/bridge.js index 4a51a622d..ee35477ca 100644 --- a/src-mdviewer/src/bridge.js +++ b/src-mdviewer/src/bridge.js @@ -808,10 +808,14 @@ function handleReloadFile(data) { function handleSetTheme(data) { const { theme } = data; + const newScheme = theme === "dark" ? "dark" : "light"; // Skip if already applied to avoid reflows that can reset scroll position - if (document.documentElement.getAttribute("data-theme") === theme) return; + if (document.documentElement.getAttribute("data-theme") === theme && + document.documentElement.style.colorScheme === newScheme) { + return; + } document.documentElement.setAttribute("data-theme", theme); - document.documentElement.style.colorScheme = theme === "dark" ? "dark" : "light"; + document.documentElement.style.colorScheme = newScheme; setState({ theme }); } diff --git a/src-mdviewer/src/core/doc-cache.js b/src-mdviewer/src/core/doc-cache.js index 8462e3423..e771f1236 100644 --- a/src-mdviewer/src/core/doc-cache.js +++ b/src-mdviewer/src/core/doc-cache.js @@ -176,6 +176,10 @@ export function saveActiveScrollPos() { const entry = cache.get(activeFilePath); if (!entry) return; + // Don't overwrite scroll position if viewer is hidden (e.g. panel closed) + // — hidden elements report scrollTop = 0 which would destroy the saved value. + if (!viewerContainer.offsetParent && viewerContainer.scrollTop === 0) return; + entry.scrollPos = viewerContainer.scrollTop; // Also save source line for reload scenarios (DOM rebuilt, pixel pos unreliable) diff --git a/test/spec/md-editor-integ-test.js b/test/spec/md-editor-integ-test.js index d0da270c3..e6620034b 100644 --- a/test/spec/md-editor-integ-test.js +++ b/test/spec/md-editor-integ-test.js @@ -774,12 +774,13 @@ define(function (require, exports, module) { // Verify edit mode preserved await _assertMdEditMode(true); - // Verify scroll position preserved (wider tolerance for CI) + // Verify scroll position preserved (wider tolerance for CI). + // Wait longer as theme/content sync after reopen can cause transient reflows. await awaitsFor(() => { const scroll = _getViewerScrollTop(); return scroll > 10 && Math.abs(scroll - scrollBefore) < 150; - }, "scroll position to be preserved after panel reopen", 5000); - }, 15000); + }, "scroll position to be preserved after panel reopen", 8000); + }, 20000); it("should reload button re-render current file with fresh DOM preserving scroll and edit mode", async function () { await _openMdFileAndWaitForPreview("long.md");