Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 0 additions & 3 deletions phoenix-builder-mcp/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 19 additions & 3 deletions src-mdviewer/src/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
});

Expand Down Expand Up @@ -576,6 +585,7 @@ function handleSetContent(data) {
_baseURL = baseURL;
}

flushPendingContentChange();
_suppressContentChange = true;
const parseResult = parseMarkdownToHTML(markdown);

Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src-mdviewer/src/components/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
9 changes: 6 additions & 3 deletions src-mdviewer/src/components/embedded-toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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");
Expand All @@ -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());
}
Expand Down Expand Up @@ -102,9 +104,10 @@ function renderReadMode() {
<i data-lucide="link-2" class="sync-on-icon"${cursorSyncEnabled ? "" : ' style="display:none"'}></i>
<i data-lucide="link-2-off" class="sync-off-icon"${cursorSyncEnabled ? ' style="display:none"' : ""}></i>
</button>
<button class="edit-toggle-btn" id="emb-edit-btn" title="${t("toolbar.switch_to_edit") || "Switch to edit mode"}">
<button class="edit-toggle-btn${getState().isPro === false ? " pro-locked" : ""}" id="emb-edit-btn" title="${t("toolbar.switch_to_edit") || "Switch to edit mode"}">
<i data-lucide="pencil"></i>
<span>${t("toolbar.edit") || "Edit"}</span>
${getState().isPro === false ? '<i data-lucide="crown" class="pro-crown-icon"></i>' : ""}
</button>
</div>`;

Expand Down
4 changes: 4 additions & 0 deletions src-mdviewer/src/core/doc-cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions src-mdviewer/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
36 changes: 36 additions & 0 deletions src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,25 @@
_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({

Check failure on line 471 in src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Specify a target origin for this message.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ2AQBbQSTzC4JOHazhG&open=AZ2AQBbQSTzC4JOHazhG&pullRequest=2811

Check failure

Code scanning / SonarCloud

Origins should be verified during cross-origin communications High

Specify a target origin for this message. See more on SonarQube Cloud
type: "MDVIEWR_SET_PRO_STATUS",
isPro: !!isPro
}, "*");
}

function _sendLocale() {
if (!_active || !_iframeReady) {
return;
Expand Down Expand Up @@ -517,6 +536,22 @@
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 &&

Check warning on line 550 in src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ2FblleGCkM6Oziio_f&open=AZ2FblleGCkM6Oziio_f&pullRequest=2811
data.filePath !== _doc.file.fullPath) {
return;
}

const markdown = data.markdown;
const remoteSyncId = data._syncId;

Expand Down Expand Up @@ -1128,5 +1163,6 @@
exports.setIframeReadyHandler = setIframeReadyHandler;
exports.setCursorSyncEnabled = setCursorSyncEnabled;
exports.sendThemeOverride = sendThemeOverride;
exports.sendProStatus = sendProStatus;
exports.setThemeToggleHandler = function(handler) { _onThemeToggle = handler; };
});
24 changes: 24 additions & 0 deletions src/extensionsIntegrated/Phoenix-live-preview/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@
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(
Expand Down Expand Up @@ -608,6 +610,16 @@
// 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) {

Check warning on line 617 in src/extensionsIntegrated/Phoenix-live-preview/main.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ2Fblg0GCkM6Oziio_b&open=AZ2Fblg0GCkM6Oziio_b&pullRequest=2811
mdWin.__saveScrollPos();
}
} catch (e) {
// Cross-origin access blocked by sandbox — scroll will use source-line restore
}

Check warning on line 622 in src/extensionsIntegrated/Phoenix-live-preview/main.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ2Fblg0GCkM6Oziio_c&open=AZ2Fblg0GCkM6Oziio_c&pullRequest=2811
MarkdownSync.deactivate();
_isMdviewrActive = false;
_updateLPControlsForMdviewer();
Expand Down Expand Up @@ -962,6 +974,16 @@
// 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) {

Check warning on line 981 in src/extensionsIntegrated/Phoenix-live-preview/main.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ2Fblg0GCkM6Oziio_d&open=AZ2Fblg0GCkM6Oziio_d&pullRequest=2811
$mdviewrIframe[0].contentWindow.__saveScrollPos();
}
} catch (e) {
// Cross-origin access blocked by sandbox — scroll will use source-line restore
}

Check warning on line 986 in src/extensionsIntegrated/Phoenix-live-preview/main.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ2Fblg0GCkM6Oziio_e&open=AZ2Fblg0GCkM6Oziio_e&pullRequest=2811
MarkdownSync.deactivate();
_isMdviewrActive = false;
if ($mdviewrIframe) {
Expand Down Expand Up @@ -1572,6 +1594,8 @@
// 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);
Expand Down
2 changes: 1 addition & 1 deletion src/nls/root/strings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 5 additions & 2 deletions test/spec/md-editor-edit-integ-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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; }
Expand Down
9 changes: 6 additions & 3 deletions test/spec/md-editor-edit-more-integ-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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) {
Expand Down
11 changes: 7 additions & 4 deletions test/spec/md-editor-integ-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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 () {
Expand Down
Loading
Loading