diff --git a/package-lock.json b/package-lock.json index ba77c58a85..48f4ee8dfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "phoenix", - "version": "5.1.6-0", + "version": "5.1.7-0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "phoenix", - "version": "5.1.6-0", + "version": "5.1.7-0", "hasInstallScript": true, "dependencies": { "@bugsnag/js": "^7.18.0", diff --git a/phoenix-builder-mcp/package-lock.json b/phoenix-builder-mcp/package-lock.json index 64359b6072..e19671cbc0 100644 --- a/phoenix-builder-mcp/package-lock.json +++ b/phoenix-builder-mcp/package-lock.json @@ -370,7 +370,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -575,7 +574,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -1146,7 +1144,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src-mdviewer/src/bridge.js b/src-mdviewer/src/bridge.js index dfd25b292a..a998a76ea0 100644 --- a/src-mdviewer/src/bridge.js +++ b/src-mdviewer/src/bridge.js @@ -8,7 +8,7 @@ import { getState, setState } from "./core/state.js"; import { setLocale } from "./core/i18n.js"; import { marked } from "marked"; import * as docCache from "./core/doc-cache.js"; -import { broadcastSelectionStateSync } from "./components/editor.js"; +import { broadcastSelectionStateSync, flushPendingContentChange } from "./components/editor.js"; let _syncId = 0; let _lastReceivedSyncId = -1; @@ -224,6 +224,9 @@ export function initBridge() { window.__broadcastSelectionStateForTest = function () { broadcastSelectionStateSync(); }; + window.__saveScrollPos = function () { + docCache.saveActiveScrollPos(); + }; window.__triggerContentSync = function () { const content = document.getElementById("viewer-content"); if (content) { @@ -269,6 +272,9 @@ export function initBridge() { case "MDVIEWR_SET_THEME": handleSetTheme(data); break; + case "MDVIEWR_SET_PRO_STATUS": + setState({ isPro: !!data.isPro }); + break; case "MDVIEWR_SET_EDIT_MODE": handleSetEditMode(data); break; @@ -509,8 +515,11 @@ export function initBridge() { entry.mdSrc = markdown; } } - // Send cursor position BEFORE the edit for undo restore - sendToParent("mdviewrContentChanged", { markdown, _syncId, cursorPos: _cursorPosBeforeEdit }); + // Send cursor position BEFORE the edit for undo restore. + // Include file path so MarkdownSync can verify the change matches the active document. + sendToParent("mdviewrContentChanged", { + markdown, _syncId, cursorPos: _cursorPosBeforeEdit, filePath: activePath + }); _cursorPosDirty = false; // allow cursor tracking again }); @@ -576,6 +585,7 @@ function handleSetContent(data) { _baseURL = baseURL; } + flushPendingContentChange(); _suppressContentChange = true; const parseResult = parseMarkdownToHTML(markdown); @@ -665,6 +675,12 @@ function handleSwitchFile(data) { _baseURL = baseURL; } + // Flush any pending debounced content-change from the outgoing file's edits + // BEFORE suppressing. This ensures the outgoing file's cache entry and + // currentContent are updated with the latest edits, preventing data loss + // when the user switches files quickly (within the 50ms debounce window). + flushPendingContentChange(); + _suppressContentChange = true; // Suppress scroll-to-line from CM during file switch — the doc cache diff --git a/src-mdviewer/src/components/editor.js b/src-mdviewer/src/components/editor.js index aadd1d08b8..5492bd5ae4 100644 --- a/src-mdviewer/src/components/editor.js +++ b/src-mdviewer/src/components/editor.js @@ -1714,6 +1714,23 @@ function emitContentChange(contentEl) { }, CONTENT_CHANGE_DEBOUNCE); } +/** + * Flush any pending debounced content-change emission immediately. + * Called during file switch so the outgoing file's edits are synced + * to its cache entry and CM document before switching away. + */ +export function flushPendingContentChange() { + if (contentChangeTimer) { + clearTimeout(contentChangeTimer); + contentChangeTimer = null; + const contentEl = document.getElementById("viewer-content"); + if (contentEl) { + const markdown = convertToMarkdown(contentEl); + emit("bridge:contentChanged", { markdown }); + } + } +} + function getContentEl() { return document.getElementById("viewer-content"); } diff --git a/src-mdviewer/src/components/embedded-toolbar.js b/src-mdviewer/src/components/embedded-toolbar.js index aa3f01699e..4589a98e75 100644 --- a/src-mdviewer/src/components/embedded-toolbar.js +++ b/src-mdviewer/src/components/embedded-toolbar.js @@ -34,7 +34,8 @@ import { Image as ImageIcon, Upload, Sun, - Moon + Moon, + Crown } from "lucide"; import { on, emit } from "../core/events.js"; import { getState, setState } from "../core/state.js"; @@ -57,7 +58,7 @@ const _isMacWebKit = /Mac/.test(navigator.platform) && !/Chrome|CriOS|Edg|Firefox|FxiOS/.test(navigator.userAgent); 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, Sun, Moon }; + ListChecks, Quote, Minus, Table, FileCode, ChevronDown, Type, MoreHorizontal, Pencil, BookOpen, Link2, Link2Off, Printer, Image: ImageIcon, Upload, Sun, Moon, Crown }; export function initEmbeddedToolbar() { toolbar = document.getElementById("toolbar"); @@ -67,6 +68,7 @@ export function initEmbeddedToolbar() { on("state:editMode", () => render()); on("state:theme", () => render()); + on("state:isPro", () => render()); on("editor:selection-state", updateFormatState); on("state:locale", () => render()); } @@ -102,9 +104,10 @@ function renderReadMode() { - `; diff --git a/src-mdviewer/src/core/doc-cache.js b/src-mdviewer/src/core/doc-cache.js index e771f12366..aed85988f9 100644 --- a/src-mdviewer/src/core/doc-cache.js +++ b/src-mdviewer/src/core/doc-cache.js @@ -180,6 +180,10 @@ export function saveActiveScrollPos() { // — hidden elements report scrollTop = 0 which would destroy the saved value. if (!viewerContainer.offsetParent && viewerContainer.scrollTop === 0) return; + // Don't overwrite a saved non-zero scroll position with 0 — this happens when + // the browser resets scrollTop after hide/show and the caller hasn't scrolled yet. + if (viewerContainer.scrollTop === 0 && entry.scrollPos > 0) return; + entry.scrollPos = viewerContainer.scrollTop; // Also save source line for reload scenarios (DOM rebuilt, pixel pos unreliable) diff --git a/src-mdviewer/src/styles/app.css b/src-mdviewer/src/styles/app.css index f050228571..41166f161e 100644 --- a/src-mdviewer/src/styles/app.css +++ b/src-mdviewer/src/styles/app.css @@ -198,6 +198,16 @@ html, body { border-color: var(--color-border); } +/* Crown icon shown after "Edit" text for free users — indicates a Pro feature */ +.embedded-toolbar .edit-toggle-btn .pro-crown-icon { + color: #f0b400; + margin-left: 2px; +} +.embedded-toolbar .edit-toggle-btn .pro-crown-icon svg { + width: 12px; + height: 12px; +} + .embedded-toolbar .format-btn { width: 22px; height: 22px; diff --git a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js index e3fd3f808f..4bfdeee5f7 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js @@ -455,6 +455,25 @@ define(function (require, exports, module) { _sendTheme(); } + /** + * Push the Pro edit entitlement state to the iframe so it can show/hide + * the crown upsell indicator on the Edit button. + * @param {boolean} isPro + */ + function sendProStatus(isPro) { + if (!_active || !_iframeReady) { + return; + } + const iframeWindow = _getIframeWindow(); + if (!iframeWindow) { + return; + } + iframeWindow.postMessage({ + type: "MDVIEWR_SET_PRO_STATUS", + isPro: !!isPro + }, "*"); + } + function _sendLocale() { if (!_active || !_iframeReady) { return; @@ -517,6 +536,22 @@ define(function (require, exports, module) { return; } + // Guard against stale content changes arriving after the document was closed. + // This can happen because iframe content changes are debounced (50ms) and + // postMessage is async, so they may arrive after FILE_CLOSE but before deactivate(). + const cm = _getCM(); + if (!cm) { + return; + } + + // Ignore content changes for a different file than the one currently active + // in MarkdownSync. This prevents stale debounced edits from a previous file + // from modifying the wrong CM document after a file switch. + if (data.filePath && _doc && _doc.file && + data.filePath !== _doc.file.fullPath) { + return; + } + const markdown = data.markdown; const remoteSyncId = data._syncId; @@ -1128,5 +1163,6 @@ define(function (require, exports, module) { exports.setIframeReadyHandler = setIframeReadyHandler; exports.setCursorSyncEnabled = setCursorSyncEnabled; exports.sendThemeOverride = sendThemeOverride; + exports.sendProStatus = sendProStatus; 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 11a885cafb..43ef8b6947 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/main.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/main.js @@ -202,6 +202,8 @@ define(function (require, exports, module) { isProEditUser = entitlement && entitlement.activated; // Sync edit mode with md iframe on entitlement change if (_isMdviewrActive && $iframe && $iframe[0] && $iframe[0].contentWindow) { + // Push pro status to iframe so it can toggle the crown indicator + MarkdownSync.sendProStatus(isProEditUser); if (isProEditUser && !wasProEditUser) { // Just got pro — switch to edit mode $iframe[0].contentWindow.postMessage( @@ -608,6 +610,16 @@ define(function (require, exports, module) { // src. so we delete the node itself to eb thorough. // Don't destroy the persistent md iframe — just hide it if ($mdviewrIframe && $iframe[0] === $mdviewrIframe[0]) { + // Save scroll position before hiding — hidden elements lose scrollTop. + // Use try-catch because the sandboxed iframe blocks cross-origin property access. + try { + const mdWin = $mdviewrIframe[0].contentWindow; + if (mdWin && mdWin.__saveScrollPos) { + mdWin.__saveScrollPos(); + } + } catch (e) { + // Cross-origin access blocked by sandbox — scroll will use source-line restore + } MarkdownSync.deactivate(); _isMdviewrActive = false; _updateLPControlsForMdviewer(); @@ -962,6 +974,16 @@ define(function (require, exports, module) { // Switching away from mdviewr to non-markdown preview // Hide the md iframe instead of destroying it so cache is preserved if(_isMdviewrActive) { + // Save scroll position before hiding — hidden elements lose scrollTop. + // Use try-catch because the sandboxed iframe blocks cross-origin property access. + try { + if ($mdviewrIframe && $mdviewrIframe[0].contentWindow && + $mdviewrIframe[0].contentWindow.__saveScrollPos) { + $mdviewrIframe[0].contentWindow.__saveScrollPos(); + } + } catch (e) { + // Cross-origin access blocked by sandbox — scroll will use source-line restore + } MarkdownSync.deactivate(); _isMdviewrActive = false; if ($mdviewrIframe) { @@ -1572,6 +1594,8 @@ define(function (require, exports, module) { // When iframe first loads, send initial edit mode based on entitlement MarkdownSync.setIframeReadyHandler(function () { _updateLPControlsForMdviewer(); + // Push pro status so the iframe can show the crown upsell indicator + MarkdownSync.sendProStatus(isProEditUser); // Pro users default to edit mode on first load if (isProEditUser) { MarkdownSync.setEditMode(true); diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 47e5812b9d..d868d37d6c 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -519,7 +519,7 @@ define({ "LIVE_DEV_IMAGE_FOLDER_DIALOG_REMEMBER": "Don't ask again for this project", "AVAILABLE_IN_PRO_TITLE": "Available in Phoenix Pro", "DEVICE_SIZE_LIMIT_MESSAGE": "Phoenix Pro lets you preview your page at the screen sizes defined in your CSS.", - "MD_EDIT_UPSELL_MESSAGE": "Write Markdown like a document. Phoenix handles the formatting so you can stay focused on writing.", + "MD_EDIT_UPSELL_MESSAGE": "Write Markdown like a document. {APP_NAME} handles the formatting so you can stay focused on writing.", "IMAGE_UPLOADING": "Uploading", "IMAGE_UPLOAD_FAILED": "Failed to upload image", "IMAGE_UPLOAD_LOGIN_REQUIRED_TITLE": "Log in to Embed Image", diff --git a/test/spec/md-editor-edit-integ-test.js b/test/spec/md-editor-edit-integ-test.js index 0e40fc0060..3531ecbea3 100644 --- a/test/spec/md-editor-edit-integ-test.js +++ b/test/spec/md-editor-edit-integ-test.js @@ -74,7 +74,6 @@ define(function (require, exports, module) { } async function _waitForMdPreviewReady(editor) { - const expectedSrc = editor ? editor.document.getText() : null; await awaitsFor(() => { const mdIFrame = _getMdPreviewIFrame(); if (!mdIFrame || mdIFrame.style.display === "none") { return false; } @@ -84,7 +83,11 @@ define(function (require, exports, module) { if (win.__isSuppressingContentChange && win.__isSuppressingContentChange()) { return false; } const content = mdIFrame.contentDocument && mdIFrame.contentDocument.getElementById("viewer-content"); if (!content || content.children.length === 0) { return false; } - if (!EditorManager.getActiveEditor()) { return false; } + const activeEditor = EditorManager.getActiveEditor(); + if (!activeEditor) { return false; } + // Re-read editor content each iteration — content sync from a previous + // test's DOM edit can modify the document asynchronously (debounced postMessage). + const expectedSrc = activeEditor.document.getText(); if (expectedSrc) { const viewerSrc = win.__getCurrentContent && win.__getCurrentContent(); if (viewerSrc !== expectedSrc) { return false; } diff --git a/test/spec/md-editor-edit-more-integ-test.js b/test/spec/md-editor-edit-more-integ-test.js index bf3e3bb6a0..2c8ba74ed8 100644 --- a/test/spec/md-editor-edit-more-integ-test.js +++ b/test/spec/md-editor-edit-more-integ-test.js @@ -71,7 +71,6 @@ define(function (require, exports, module) { } async function _waitForMdPreviewReady(editor) { - const expectedSrc = editor ? editor.document.getText() : null; await awaitsFor(() => { const mdIFrame = _getMdPreviewIFrame(); if (!mdIFrame || mdIFrame.style.display === "none") { return false; } @@ -81,13 +80,17 @@ define(function (require, exports, module) { if (win.__isSuppressingContentChange && win.__isSuppressingContentChange()) { return false; } const content = mdIFrame.contentDocument && mdIFrame.contentDocument.getElementById("viewer-content"); if (!content || content.children.length === 0) { return false; } - if (!EditorManager.getActiveEditor()) { return false; } + const activeEditor = EditorManager.getActiveEditor(); + if (!activeEditor) { return false; } + // Re-read editor content each iteration — content sync from a previous + // test's DOM edit can modify the document asynchronously (debounced postMessage). + const expectedSrc = activeEditor.document.getText(); if (expectedSrc) { const viewerSrc = win.__getCurrentContent && win.__getCurrentContent(); if (viewerSrc !== expectedSrc) { return false; } } return true; - }, "md preview synced with editor content"); + }, "md preview synced with editor content", 5000); } function _dispatchKeyInMdIframe(key, options) { diff --git a/test/spec/md-editor-integ-test.js b/test/spec/md-editor-integ-test.js index e6620034ba..3dc43a1056 100644 --- a/test/spec/md-editor-integ-test.js +++ b/test/spec/md-editor-integ-test.js @@ -199,7 +199,6 @@ define(function (require, exports, module) { * @param {Object} editor - The active Editor instance whose content should be synced to the viewer. */ async function _waitForMdPreviewReady(editor) { - const expectedSrc = editor ? editor.document.getText() : null; await awaitsFor(() => { const mdIFrame = _getMdPreviewIFrame(); if (!mdIFrame || mdIFrame.style.display === "none") { return false; } @@ -209,14 +208,18 @@ define(function (require, exports, module) { if (win.__isSuppressingContentChange && win.__isSuppressingContentChange()) { return false; } const content = mdIFrame.contentDocument && mdIFrame.contentDocument.getElementById("viewer-content"); if (!content || content.children.length === 0) { return false; } - if (!EditorManager.getActiveEditor()) { return false; } - // Verify the viewer has synced with the editor's content + const activeEditor = EditorManager.getActiveEditor(); + if (!activeEditor) { return false; } + // Verify the viewer has synced with the editor's content. + // Re-read editor content each iteration — content sync from a previous + // test's DOM edit can modify the document asynchronously (debounced postMessage). + const expectedSrc = activeEditor.document.getText(); if (expectedSrc) { const viewerSrc = win.__getCurrentContent && win.__getCurrentContent(); if (viewerSrc !== expectedSrc) { return false; } } return true; - }, "md preview synced with editor content"); + }, "md preview synced with editor content", 5000); } describe("livepreview:Markdown Editor", function () { diff --git a/test/spec/md-editor-table-integ-test.js b/test/spec/md-editor-table-integ-test.js index 4559880f1f..7ba97c7f00 100644 --- a/test/spec/md-editor-table-integ-test.js +++ b/test/spec/md-editor-table-integ-test.js @@ -59,7 +59,6 @@ define(function (require, exports, module) { async function _waitForMdPreviewReady(editor) { - const expectedSrc = editor ? editor.document.getText() : null; await awaitsFor(() => { const mdIFrame = _getMdPreviewIFrame(); if (!mdIFrame || mdIFrame.style.display === "none") { return false; } @@ -69,13 +68,17 @@ define(function (require, exports, module) { if (win.__isSuppressingContentChange && win.__isSuppressingContentChange()) { return false; } const content = mdIFrame.contentDocument && mdIFrame.contentDocument.getElementById("viewer-content"); if (!content || content.children.length === 0) { return false; } - if (!EditorManager.getActiveEditor()) { return false; } + const activeEditor = EditorManager.getActiveEditor(); + if (!activeEditor) { return false; } + // Re-read editor content each iteration — content sync from a previous + // test's DOM edit can modify the document asynchronously (debounced postMessage). + const expectedSrc = activeEditor.document.getText(); if (expectedSrc) { const viewerSrc = win.__getCurrentContent && win.__getCurrentContent(); if (viewerSrc !== expectedSrc) { return false; } } return true; - }, "md preview synced with editor content"); + }, "md preview synced with editor content", 5000); } describe("livepreview:Markdown Editor Table Editing", function () {