From 40b6f51d01cbf70e92cf2b0b2a43daad8750dc18 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 8 Oct 2026 20:34:08 +0530 Subject: [PATCH 01/11] feat(ai): reuse composer pulse and update Pro pin Apply the existing input-outline animation to the floating Ask AI composer, so unused-space focus uses the same visual cue as the AI sidebar. Pin Phoenix Pro to 89b11573b9d64eb22130f83c56702aa490492178 for shared click-to-focus handling in both composers, registered regression coverage and updated task tracking. Preserve text selection, dragging, normal controls and preview interactions while retaining the draft and caret. Validation: 19 focus integration specs and 42 existing Ask AI dialog specs pass on each of Windows, macOS and Linux (183 checks). Targeted ESLint, LESS compilation and whitespace checks pass. Both surfaces were also checked in the connected Linux app. The user confirmed the earlier CI fixes pass fully. --- src/styles/Extn-AIChatPanel.less | 3 ++- tracking-repos.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index a65a153fac..4ed2194cfd 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -2037,7 +2037,8 @@ } } -.ai-chat-input-area .ai-chat-input-wrap { +.ai-chat-input-area .ai-chat-input-wrap, +#phoenix-live-preview-chat .lp-ai-composer { &.ai-pulse { animation: ai-input-pulse 600ms ease-out 1; } diff --git a/tracking-repos.json b/tracking-repos.json index 19f65a113a..8d4e91d46d 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "18799f3e83dfc7f8d4e351e15d367bb053587081" + "commitID": "89b11573b9d64eb22130f83c56702aa490492178" } } From 36fe699f9e9a71bdf8750d7218ff90abe3f3c76d Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 8 Oct 2026 20:45:15 +0530 Subject: [PATCH 02/11] fix(ai): keep the usage dropdown arrow pointing down Remove the usage chip's open-state chevron rotation and its unused transform transition. The arrow now keeps the same direction as the other composer dropdowns, independently of popup placement or visibility. Pin Phoenix Pro to 4cef1b9ae230952c519da4381059ebc25eaf93a5 for the matching downward icon and completed task documentation. Validation: LESS compilation and whitespace checks pass. In the connected Linux app, the arrow remains down with no transform in both open and closed states, while the usage popup continues toggling normally. --- src/styles/Extn-AIChatPanel.less | 5 ----- tracking-repos.json | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 4ed2194cfd..beefcdee8d 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -4512,11 +4512,6 @@ width: auto; font-size: 9px; opacity: 0.6; - transition: transform 120ms ease; - } - - &.ai-usage-open .ai-usage-caret { - transform: rotate(180deg); } // Dim the chip's own text and icon before any turn; never the chip itself, since an diff --git a/tracking-repos.json b/tracking-repos.json index 8d4e91d46d..4320664745 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "89b11573b9d64eb22130f83c56702aa490492178" + "commitID": "4cef1b9ae230952c519da4381059ebc25eaf93a5" } } From 3406b077f466b0277d5542901970fc217e238af3 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 8 Oct 2026 21:02:01 +0530 Subject: [PATCH 03/11] feat(mdviewer): view images full size in the Markdown preview Show an image on its own over a dimmed backdrop inside the Markdown preview frame, with no dialog chrome. Any click or Escape closes it. Reader mode opens it on a click; linked images keep following their link. In edit mode a click still selects the image, and the lightbox opens from a centred expand button shown while hovering the image, a double-click, or a new expand icon in the image toolbar. A reader image click no longer moves focus back to the code editor, and Escape stays in the frame while the lightbox is open. File and mode switches close it. Tests: livepreview:Markdown Editor 1 > Image Lightbox (7) with a new image-test.md fixture. --- src-mdviewer/src/bridge.js | 8 +- src-mdviewer/src/components/image-lightbox.js | 205 ++++++++++++++++++ src-mdviewer/src/components/image-popover.js | 15 +- src-mdviewer/src/embedded-main.js | 2 + src-mdviewer/src/locales/en.json | 1 + src-mdviewer/src/styles/markdown.css | 70 ++++++ src-mdviewer/to-create-tests.md | 11 + .../image-test.md | 11 + .../images/sample.svg | 1 + test/spec/md-editor-integ-test.js | 158 +++++++++++++- 10 files changed, 479 insertions(+), 3 deletions(-) create mode 100644 src-mdviewer/src/components/image-lightbox.js create mode 100644 test/spec/LiveDevelopment-Markdown-test-files/image-test.md create mode 100644 test/spec/LiveDevelopment-Markdown-test-files/images/sample.svg diff --git a/src-mdviewer/src/bridge.js b/src-mdviewer/src/bridge.js index 3660005ed9..ee674d2681 100644 --- a/src-mdviewer/src/bridge.js +++ b/src-mdviewer/src/bridge.js @@ -10,6 +10,7 @@ import { marked } from "marked"; import * as docCache from "./core/doc-cache.js"; import { broadcastSelectionStateSync, flushPendingContentChange } from "./components/editor.js"; import { captureSelection, restoreSelection, getRenderedMdLineText } from "./core/selection-context.js"; +import { readerClickImage } from "./components/image-lightbox.js"; let _syncId = 0; let _lastReceivedSyncId = -1; @@ -402,7 +403,8 @@ export function initBridge() { "#search-bar.open", "#slash-menu-anchor.visible", "#lang-picker.visible", - "#link-popover.visible" + "#link-popover.visible", + ".image-lightbox" ]; const hasOpenPopup = popupSelectors.some(sel => document.querySelector(sel)); if (hasOpenPopup) { @@ -505,6 +507,10 @@ export function initBridge() { } return; } + // An image click opens the lightbox here, which needs keyboard focus for Escape. + if (readerClickImage(e.target)) { + return; + } const selection = window.getSelection(); if (!selection || selection.toString().length === 0) { sendToParent("embeddedIframeFocusEditor", { sourceLine }); diff --git a/src-mdviewer/src/components/image-lightbox.js b/src-mdviewer/src/components/image-lightbox.js new file mode 100644 index 0000000000..e6cf6ff16c --- /dev/null +++ b/src-mdviewer/src/components/image-lightbox.js @@ -0,0 +1,205 @@ +/** + * Image lightbox — shows an image on its own over a dimmed backdrop, inside the viewer frame. + * Opens on a click in reader mode. In edit mode it opens from the expand button shown over a + * hovered image, a double-click, or the image popover's view button. Any click or Escape closes it. + */ +import { on } from "../core/events.js"; +import { getState } from "../core/state.js"; +import { t } from "../core/i18n.js"; + +// Images smaller than this get no hover expand button; it would cover them. +const EXPAND_BUTTON_MIN_IMAGE_SIZE = 64; +const EXPAND_ICON = '' + + '' + + ''; + +let overlay = null; +let returnFocus = null; +let expandButton = null; +let hoveredImg = null; + +/** + * The previewable image a pointer event landed on, if any: a loaded document image, not one + * still uploading. + * @param {EventTarget} target + * @return {?HTMLImageElement} + */ +export function previewableImage(target) { + const img = target && target.closest ? target.closest("img") : null; + const content = document.getElementById("viewer-content"); + if (!img || !content || !content.contains(img)) { + return null; + } + const src = img.getAttribute("src"); + if (!src || src.includes("uploading.svg")) { + return null; + } + return img; +} + +/** + * The image a reader-mode click opens in the lightbox. A linked image follows its link instead. + * @param {EventTarget} target + * @return {?HTMLImageElement} + */ +export function readerClickImage(target) { + if (getState().editMode) { + return null; + } + const img = previewableImage(target); + return img && !img.closest("a[href]") ? img : null; +} + +/** @return {boolean} Whether the lightbox is showing. */ +export function isImageLightboxOpen() { + return !!overlay; +} + +/** + * Show the image on its own over a dimmed backdrop. + * @param {HTMLImageElement} img + */ +export function openImageLightbox(img) { + if (!img) { + return; + } + closeImageLightbox(); + _hideExpandButton(); + returnFocus = document.activeElement; + overlay = document.createElement("div"); + overlay.className = "image-lightbox"; + overlay.setAttribute("role", "dialog"); + overlay.setAttribute("aria-modal", "true"); + overlay.setAttribute("aria-label", img.getAttribute("alt") || t("image.view")); + overlay.tabIndex = -1; + + const view = document.createElement("img"); + view.className = "image-lightbox-img"; + view.src = img.currentSrc || img.src; + view.alt = img.getAttribute("alt") || ""; + overlay.appendChild(view); + overlay.addEventListener("click", closeImageLightbox); + + document.body.appendChild(overlay); + overlay.focus({ preventScroll: true }); +} + +function _getExpandButton() { + if (!expandButton) { + expandButton = document.createElement("button"); + expandButton.type = "button"; + expandButton.className = "image-lightbox-expand"; + expandButton.setAttribute("aria-label", t("image.view")); + expandButton.innerHTML = EXPAND_ICON; + // Keep the editor's focus and caret where they are. + expandButton.addEventListener("mousedown", (e) => e.preventDefault()); + expandButton.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + const img = hoveredImg; + _hideExpandButton(); + openImageLightbox(img); + }); + expandButton.addEventListener("mouseleave", (e) => { + if (e.relatedTarget !== hoveredImg) { + _hideExpandButton(); + } + }); + document.body.appendChild(expandButton); + } + return expandButton; +} + +/** In edit mode, show the expand button over the centre of the hovered image. */ +function _showExpandButton(img) { + const rect = img.getBoundingClientRect(); + if (rect.width < EXPAND_BUTTON_MIN_IMAGE_SIZE || rect.height < EXPAND_BUTTON_MIN_IMAGE_SIZE) { + _hideExpandButton(); + return; + } + const button = _getExpandButton(); + hoveredImg = img; + button.style.left = (rect.left + rect.width / 2) + "px"; + button.style.top = (rect.top + rect.height / 2) + "px"; + button.classList.add("visible"); +} + +function _hideExpandButton() { + hoveredImg = null; + if (expandButton) { + expandButton.classList.remove("visible"); + } +} + +/** Close the lightbox, returning focus to where it was. */ +export function closeImageLightbox() { + if (!overlay) { + return; + } + overlay.remove(); + overlay = null; + const focusTarget = returnFocus; + returnFocus = null; + if (focusTarget && focusTarget.isConnected && focusTarget !== document.body) { + focusTarget.focus({ preventScroll: true }); + } +} + +export function initImageLightbox() { + const appViewer = document.getElementById("app-viewer"); + if (appViewer) { + appViewer.addEventListener("click", (e) => { + const img = readerClickImage(e.target); + if (img) { + e.preventDefault(); + openImageLightbox(img); + } + }); + appViewer.addEventListener("mouseover", (e) => { + if (!getState().editMode) { + return; + } + const img = previewableImage(e.target); + if (img) { + _showExpandButton(img); + } + }); + appViewer.addEventListener("mouseout", (e) => { + // Leaving the image for anything but its expand button hides the button. + if (hoveredImg && e.target === hoveredImg && e.relatedTarget !== expandButton) { + _hideExpandButton(); + } + }); + appViewer.addEventListener("scroll", _hideExpandButton); + appViewer.addEventListener("dblclick", (e) => { + if (!getState().editMode) { + return; + } + const img = previewableImage(e.target); + if (img) { + e.preventDefault(); + openImageLightbox(img); + } + }); + } + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && overlay) { + // Escape closes only the lightbox, not the image popover under it. + e.preventDefault(); + e.stopImmediatePropagation(); + closeImageLightbox(); + } + }); + + window.addEventListener("resize", _hideExpandButton); + // A file or mode switch leaves nothing of the lightbox behind. + const reset = () => { + closeImageLightbox(); + _hideExpandButton(); + }; + on("file:switched", reset); + on("file:closed", reset); + on("state:editMode", reset); +} diff --git a/src-mdviewer/src/components/image-popover.js b/src-mdviewer/src/components/image-popover.js index 0ff9d6c076..96d526d30f 100644 --- a/src-mdviewer/src/components/image-popover.js +++ b/src-mdviewer/src/components/image-popover.js @@ -1,10 +1,11 @@ /** * Image popover — appears when clicking an image in edit mode. - * Shows Edit (opens image URL dialog) and Delete buttons. + * Shows View (opens the image lightbox), Edit (opens image URL dialog) and Delete buttons. */ import { emit, on } from "../core/events.js"; import { t } from "../core/i18n.js"; import { getState } from "../core/state.js"; +import { openImageLightbox } from "./image-lightbox.js"; const UPLOAD_PLACEHOLDER_SRC = "https://user-cdn.phcode.site/images/uploading.svg"; const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"]; @@ -98,6 +99,18 @@ export function initImagePopover(content) { popover.innerHTML = ""; + const viewBtn = document.createElement("button"); + viewBtn.className = "image-popover-btn image-popover-btn-view"; + viewBtn.setAttribute("aria-label", t("image.view")); + viewBtn.innerHTML = ''; + viewBtn.addEventListener("mousedown", (e) => e.preventDefault()); + viewBtn.addEventListener("click", () => { + const img = currentImg; + hide(); + openImageLightbox(img); + }); + popover.appendChild(viewBtn); + const editBtn = document.createElement("button"); editBtn.className = "image-popover-btn"; editBtn.setAttribute("aria-label", t("image.edit") || "Edit image"); diff --git a/src-mdviewer/src/embedded-main.js b/src-mdviewer/src/embedded-main.js index 14638b4b18..bcfaf87d61 100644 --- a/src-mdviewer/src/embedded-main.js +++ b/src-mdviewer/src/embedded-main.js @@ -5,6 +5,7 @@ import "./styles/app.css"; import { initI18n } from "./core/i18n.js"; import { initViewer } from "./components/viewer.js"; +import { initImageLightbox } from "./components/image-lightbox.js"; import { initEditor } from "./components/editor.js"; import { initEmbeddedToolbar } from "./components/embedded-toolbar.js"; import { initContextMenu } from "./components/context-menu.js"; @@ -17,6 +18,7 @@ async function init() { // Initialize components initViewer(); + initImageLightbox(); initEditor(); initEmbeddedToolbar(); initContextMenu(); diff --git a/src-mdviewer/src/locales/en.json b/src-mdviewer/src/locales/en.json index 01b7cc5e3e..21d0604b81 100644 --- a/src-mdviewer/src/locales/en.json +++ b/src-mdviewer/src/locales/en.json @@ -121,6 +121,7 @@ "no_results": "No results" }, "image": { + "view": "View image", "edit": "Edit Image URL", "delete": "Delete image" }, diff --git a/src-mdviewer/src/styles/markdown.css b/src-mdviewer/src/styles/markdown.css index 44e432ebb4..f745c24b15 100644 --- a/src-mdviewer/src/styles/markdown.css +++ b/src-mdviewer/src/styles/markdown.css @@ -570,3 +570,73 @@ pre .cursor-sync-highlight { font-size: var(--font-size-xs); } + +/* ===== Image lightbox: an image on its own over a dimmed backdrop ===== */ +#viewer-content:not(.editing) img:not(a img) { + cursor: zoom-in; +} + +.image-lightbox { + position: fixed; + inset: 0; + z-index: calc(var(--z-modal) + 20); + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-lg); + background: rgba(0, 0, 0, 0.85); + cursor: zoom-out; + outline: none; + animation: image-lightbox-in 150ms ease-out; +} + +.image-lightbox-img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: var(--radius-md); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + animation: image-lightbox-zoom 150ms ease-out; +} + +@keyframes image-lightbox-in { + from { opacity: 0; } +} + +@keyframes image-lightbox-zoom { + from { transform: scale(0.96); } +} + +@media (prefers-reduced-motion: reduce) { + .image-lightbox, .image-lightbox-img { animation: none; } +} + +/* Edit mode: an expand button over the centre of a hovered image opens the lightbox. */ +.image-lightbox-expand { + position: fixed; + z-index: 340; + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + padding: 0; + border: none; + border-radius: 50%; + background: rgba(0, 0, 0, 0.55); + color: #fff; + cursor: zoom-in; + opacity: 0; + pointer-events: none; + transform: translate(-50%, -50%); + transition: opacity 120ms ease-out, background 120ms ease-out; +} + +.image-lightbox-expand.visible { + opacity: 1; + pointer-events: auto; +} + +.image-lightbox-expand:hover { + background: rgba(0, 0, 0, 0.75); +} diff --git a/src-mdviewer/to-create-tests.md b/src-mdviewer/to-create-tests.md index 3d66eb1a83..aa86ad03cd 100644 --- a/src-mdviewer/to-create-tests.md +++ b/src-mdviewer/to-create-tests.md @@ -44,3 +44,14 @@ - [ ] Cmd+Right near image goes to end of block on Mac - [ ] Cmd+Left near image goes to start of block on Mac - [ ] End/Home work normally on lines without images + +## Image Lightbox +- [x] Reader mode: clicking an image opens it alone over a dimmed backdrop; a click closes it +- [x] Escape closes the lightbox +- [x] Reader mode: a linked image follows its link instead of opening the lightbox +- [x] Edit mode: click selects the image (popover), double-click opens the lightbox +- [x] Edit mode: hovering an image shows a centred expand button that opens the lightbox +- [x] Reader mode: no expand button on hover +- [x] Edit mode: the image toolbar's view button opens the lightbox +- [ ] Switching files or modes closes an open lightbox +- [ ] Escape with the lightbox open is not forwarded to Phoenix (editor keeps its focus state) diff --git a/test/spec/LiveDevelopment-Markdown-test-files/image-test.md b/test/spec/LiveDevelopment-Markdown-test-files/image-test.md new file mode 100644 index 0000000000..aefe7d1214 --- /dev/null +++ b/test/spec/LiveDevelopment-Markdown-test-files/image-test.md @@ -0,0 +1,11 @@ +# Image Test + +A plain image: + +![Sample image](images/sample.svg) + +A linked image: + +[![Linked image](images/sample.svg)](https://test-image-link.example.com/) + +Final paragraph. diff --git a/test/spec/LiveDevelopment-Markdown-test-files/images/sample.svg b/test/spec/LiveDevelopment-Markdown-test-files/images/sample.svg new file mode 100644 index 0000000000..ed8a606c62 --- /dev/null +++ b/test/spec/LiveDevelopment-Markdown-test-files/images/sample.svg @@ -0,0 +1 @@ + diff --git a/test/spec/md-editor-integ-test.js b/test/spec/md-editor-integ-test.js index 8f09b06d30..1525594c44 100644 --- a/test/spec/md-editor-integ-test.js +++ b/test/spec/md-editor-integ-test.js @@ -18,7 +18,7 @@ * */ -/*global describe, beforeAll, beforeEach, afterAll, awaitsFor, it, awaitsForDone, expect, awaits*/ +/*global describe, beforeAll, beforeEach, afterAll, afterEach, awaitsFor, it, awaitsForDone, expect, awaits*/ define(function (require, exports, module) { @@ -2158,6 +2158,162 @@ define(function (require, exports, module) { }, 10000); }); + describe("Image Lightbox", function () { + + let _originalOpenURL; + + beforeAll(async function () { + _originalOpenURL = NativeApp.openURLInDefaultBrowser; + // The fixture lives in the markdown test project, which earlier suites switch to. + if (testWindow && brackets.test.ProjectManager.getProjectRoot().fullPath !== mdTestFolder + "/") { + await SpecRunnerUtils.loadProjectInTestWindow(mdTestFolder); + await SpecRunnerUtils.deletePathAsync(mdTestFolder + "/.phcode.json", true); + } + if (testWindow && LiveDevMultiBrowser.status !== LiveDevMultiBrowser.STATUS_ACTIVE) { + await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple.html"]), + "open simple.html for live dev"); + LiveDevMultiBrowser.open(); + await awaitsFor(() => + LiveDevMultiBrowser.status === LiveDevMultiBrowser.STATUS_ACTIVE, + "live dev to open", 20000); + } + }, 30000); + + afterAll(function () { + NativeApp.openURLInDefaultBrowser = _originalOpenURL; + }); + + afterEach(async function () { + const lightbox = _getLightbox(); + if (lightbox) { + lightbox.click(); + } + NativeApp.openURLInDefaultBrowser = _originalOpenURL; + await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE, { _forceClose: true }), + "force close image-test.md"); + }); + + async function _openImageDoc() { + await awaitsForDone(SpecRunnerUtils.openProjectFiles(["image-test.md"]), + "open image-test.md"); + await _waitForMdPreviewReady(EditorManager.getActiveEditor()); + } + + function _getLightbox() { + const mdDoc = _getMdIFrameDoc(); + return mdDoc && mdDoc.querySelector(".image-lightbox"); + } + + function _getImage(alt) { + return _getMdIFrameDoc().querySelector(`#viewer-content img[alt="${alt}"]`); + } + + function _expectLightboxShows(img) { + const lightbox = _getLightbox(); + expect(lightbox).not.toBeNull(); + expect(lightbox.querySelector(".image-lightbox-img").src).toBe(img.src); + expect(_getMdIFrameDoc().activeElement).toBe(lightbox); + } + + it("should open an image on its own in reader mode on click and close on click", async function () { + await _openImageDoc(); + await _enterReaderMode(); + + const img = _getImage("Sample image"); + expect(img).not.toBeNull(); + img.click(); + _expectLightboxShows(img); + + _getLightbox().click(); + expect(_getLightbox()).toBeNull(); + }, 10000); + + it("should close the lightbox on Escape", async function () { + await _openImageDoc(); + await _enterReaderMode(); + + _getImage("Sample image").click(); + expect(_getLightbox()).not.toBeNull(); + _dispatchPlainKeyInMdIframe("Escape", { keyCode: 27 }); + expect(_getLightbox()).toBeNull(); + }, 10000); + + it("should follow a linked image's link in reader mode instead of opening it", async function () { + await _openImageDoc(); + await _enterReaderMode(); + + let capturedURL = null; + NativeApp.openURLInDefaultBrowser = function (url) { + capturedURL = url; + }; + _getImage("Linked image").click(); + await awaitsFor(() => capturedURL !== null, "the image's link to open"); + expect(capturedURL).toContain("test-image-link.example.com"); + expect(_getLightbox()).toBeNull(); + }, 10000); + + it("should select an image on click in edit mode and open it on double-click", async function () { + await _openImageDoc(); + await _enterEditMode(); + + const img = _getImage("Sample image"); + img.click(); + const popover = _getMdIFrameDoc().getElementById("image-popover"); + await awaitsFor(() => popover.classList.contains("visible"), "image popover to show"); + expect(_getLightbox()).toBeNull(); + + img.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true })); + _expectLightboxShows(img); + _dispatchPlainKeyInMdIframe("Escape", { keyCode: 27 }); + expect(_getLightbox()).toBeNull(); + }, 10000); + + it("should open a hovered image from its expand button in edit mode", async function () { + await _openImageDoc(); + await _enterEditMode(); + + const mdDoc = _getMdIFrameDoc(); + const img = _getImage("Sample image"); + img.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + const expand = mdDoc.querySelector(".image-lightbox-expand"); + expect(expand).not.toBeNull(); + expect(expand.classList.contains("visible")).toBeTrue(); + + // Moving off the image hides it; hovering again brings it back. + img.dispatchEvent(new MouseEvent("mouseout", { bubbles: true, relatedTarget: mdDoc.body })); + expect(expand.classList.contains("visible")).toBeFalse(); + img.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + expect(expand.classList.contains("visible")).toBeTrue(); + + expand.click(); + _expectLightboxShows(img); + expect(expand.classList.contains("visible")).toBeFalse(); + }, 10000); + + it("should show no expand button over an image in reader mode", async function () { + await _openImageDoc(); + await _enterReaderMode(); + + const expand = _getMdIFrameDoc().querySelector(".image-lightbox-expand"); + _getImage("Sample image").dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + expect(!expand || !expand.classList.contains("visible")).toBeTrue(); + }, 10000); + + it("should open the selected image from the image toolbar's view button in edit mode", async function () { + await _openImageDoc(); + await _enterEditMode(); + + const img = _getImage("Sample image"); + img.click(); + const popover = _getMdIFrameDoc().getElementById("image-popover"); + await awaitsFor(() => popover.classList.contains("visible"), "image popover to show"); + + popover.querySelector(".image-popover-btn-view").click(); + _expectLightboxShows(img); + expect(popover.classList.contains("visible")).toBeFalse(); + }, 10000); + }); + describe("Splash over the md viewer", function () { function _visiblePreviewFrames() { From 211c4ba136f017d01c3f710cde9b41d9c28e1986 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 8 Oct 2026 21:19:19 +0530 Subject: [PATCH 04/11] fix(mdviewer): keep the image expand button on its image The edit-mode expand button only appeared on mouseover, so a scroll that hid it, or a pointer already inside a large image, left it hidden until the pointer left and re-entered. It now follows the pointer's position: each mouse move (using the event's own target) and each scroll or resize shows, moves or hides it for whatever image is under the pointer. Moving onto a different element is handled at once; repeated moves and scrolls are checked at most once per 16 ms with a trailing check, since not every engine coalesces mouse moves per frame. The button now sits in the viewer's scroll container, centred on the image in content coordinates, so it scrolls with the image instead of trailing it. The image toolbar's expand icon is removed: the centre button does the same. Tests: Image Lightbox covers hovering anywhere on the image, moving off and back, and a scroll while the pointer stays. --- src-mdviewer/src/components/image-lightbox.js | 130 ++++++++++++++---- src-mdviewer/src/components/image-popover.js | 15 +- src-mdviewer/src/styles/markdown.css | 5 +- src-mdviewer/to-create-tests.md | 1 - test/spec/md-editor-integ-test.js | 115 +++++++++++----- 5 files changed, 186 insertions(+), 80 deletions(-) diff --git a/src-mdviewer/src/components/image-lightbox.js b/src-mdviewer/src/components/image-lightbox.js index e6cf6ff16c..444c40e57d 100644 --- a/src-mdviewer/src/components/image-lightbox.js +++ b/src-mdviewer/src/components/image-lightbox.js @@ -1,7 +1,7 @@ /** * Image lightbox — shows an image on its own over a dimmed backdrop, inside the viewer frame. * Opens on a click in reader mode. In edit mode it opens from the expand button shown over a - * hovered image, a double-click, or the image popover's view button. Any click or Escape closes it. + * hovered image, or a double-click. Any click or Escape closes it. */ import { on } from "../core/events.js"; import { getState } from "../core/state.js"; @@ -18,6 +18,14 @@ let overlay = null; let returnFocus = null; let expandButton = null; let hoveredImg = null; +// Last pointer position in edit mode; null when it is outside the frame or a button is held. +let lastPointer = null; +// Moving onto a different element is handled at once. Repeated moves over the same one, and +// scrolls, are checked at most once per frame interval, whatever rate an engine sends them at (not +// all coalesce mouse moves per frame); a trailing check catches the last position. +const SYNC_INTERVAL_MS = 16; +let lastSyncAt = 0; +let trailingSync = null; /** * The previewable image a pointer event landed on, if any: a loaded document image, not one @@ -85,7 +93,13 @@ export function openImageLightbox(img) { overlay.focus({ preventScroll: true }); } +/** + * The hover expand button. It lives in the viewer's scroll container, positioned in that + * container's content coordinates, so a scroll carries it along with its image instead of it + * being moved after each scroll event. + */ function _getExpandButton() { + const appViewer = document.getElementById("app-viewer"); if (!expandButton) { expandButton = document.createElement("button"); expandButton.type = "button"; @@ -101,12 +115,9 @@ function _getExpandButton() { _hideExpandButton(); openImageLightbox(img); }); - expandButton.addEventListener("mouseleave", (e) => { - if (e.relatedTarget !== hoveredImg) { - _hideExpandButton(); - } - }); - document.body.appendChild(expandButton); + } + if (appViewer && expandButton.parentNode !== appViewer) { + appViewer.appendChild(expandButton); } return expandButton; } @@ -119,9 +130,11 @@ function _showExpandButton(img) { return; } const button = _getExpandButton(); + const host = button.parentNode; + const hostRect = host.getBoundingClientRect(); hoveredImg = img; - button.style.left = (rect.left + rect.width / 2) + "px"; - button.style.top = (rect.top + rect.height / 2) + "px"; + button.style.left = (rect.left - hostRect.left - host.clientLeft + host.scrollLeft + rect.width / 2) + "px"; + button.style.top = (rect.top - hostRect.top - host.clientTop + host.scrollTop + rect.height / 2) + "px"; button.classList.add("visible"); } @@ -132,6 +145,73 @@ function _hideExpandButton() { } } +/** + * @param {Element} el - What is under the pointer. + * @return {?HTMLImageElement} The image the expand button belongs on for it: the image itself, or + * the button's own image while the pointer is on the button. + */ +function _hoverTargetFor(el) { + if (expandButton && expandButton.contains(el)) { + return hoveredImg; + } + return previewableImage(el); +} + +/** + * Show the expand button for the image under the pointer, wherever on the image it is, or hide + * it. Checked on every pointer move and scroll, so it never depends on catching the moment the + * pointer entered the image (a scroll or a large image could miss that). + * @param {?Element} [underPointer] - What is under the pointer, when the caller already knows (a + * mouse event's target); otherwise it is looked up at the last pointer position. + */ +function _syncExpandButton(underPointer) { + if (!lastPointer || overlay || !getState().editMode) { + _hideExpandButton(); + return; + } + const el = underPointer instanceof Element ? underPointer : + document.elementFromPoint(lastPointer.x, lastPointer.y); + if (expandButton && el && expandButton.contains(el)) { + // On the button itself, which sits over its image. + if (!hoveredImg || !hoveredImg.isConnected) { + _hideExpandButton(); + } + return; + } + const img = previewableImage(el); + if (img) { + _showExpandButton(img); + } else { + _hideExpandButton(); + } +} + +/** + * Run the hover check now, or once the current frame interval has passed. + * @param {?Element} [underPointer] - As for _syncExpandButton; ignored when the check is deferred. + */ +function _requestExpandSync(underPointer) { + if (underPointer && _hoverTargetFor(underPointer) !== hoveredImg) { + lastSyncAt = performance.now(); + _syncExpandButton(underPointer); + return; + } + if (trailingSync) { + return; + } + const wait = lastSyncAt + SYNC_INTERVAL_MS - performance.now(); + if (wait <= 0) { + lastSyncAt = performance.now(); + _syncExpandButton(underPointer); + return; + } + trailingSync = setTimeout(() => { + trailingSync = null; + lastSyncAt = performance.now(); + _syncExpandButton(); + }, wait); +} + /** Close the lightbox, returning focus to where it was. */ export function closeImageLightbox() { if (!overlay) { @@ -156,22 +236,7 @@ export function initImageLightbox() { openImageLightbox(img); } }); - appViewer.addEventListener("mouseover", (e) => { - if (!getState().editMode) { - return; - } - const img = previewableImage(e.target); - if (img) { - _showExpandButton(img); - } - }); - appViewer.addEventListener("mouseout", (e) => { - // Leaving the image for anything but its expand button hides the button. - if (hoveredImg && e.target === hoveredImg && e.relatedTarget !== expandButton) { - _hideExpandButton(); - } - }); - appViewer.addEventListener("scroll", _hideExpandButton); + appViewer.addEventListener("scroll", () => _requestExpandSync()); appViewer.addEventListener("dblclick", (e) => { if (!getState().editMode) { return; @@ -193,7 +258,20 @@ export function initImageLightbox() { } }); - window.addEventListener("resize", _hideExpandButton); + document.addEventListener("mousemove", (e) => { + if (!getState().editMode) { + return; + } + // Not while a button is held: selecting text or dragging an image. + lastPointer = e.buttons ? null : { x: e.clientX, y: e.clientY }; + // The event's target is the browser's own hit test; no need for another. + _requestExpandSync(e.target); + }); + document.documentElement.addEventListener("mouseleave", () => { + lastPointer = null; + _hideExpandButton(); + }); + window.addEventListener("resize", () => _requestExpandSync()); // A file or mode switch leaves nothing of the lightbox behind. const reset = () => { closeImageLightbox(); diff --git a/src-mdviewer/src/components/image-popover.js b/src-mdviewer/src/components/image-popover.js index 96d526d30f..0ff9d6c076 100644 --- a/src-mdviewer/src/components/image-popover.js +++ b/src-mdviewer/src/components/image-popover.js @@ -1,11 +1,10 @@ /** * Image popover — appears when clicking an image in edit mode. - * Shows View (opens the image lightbox), Edit (opens image URL dialog) and Delete buttons. + * Shows Edit (opens image URL dialog) and Delete buttons. */ import { emit, on } from "../core/events.js"; import { t } from "../core/i18n.js"; import { getState } from "../core/state.js"; -import { openImageLightbox } from "./image-lightbox.js"; const UPLOAD_PLACEHOLDER_SRC = "https://user-cdn.phcode.site/images/uploading.svg"; const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"]; @@ -99,18 +98,6 @@ export function initImagePopover(content) { popover.innerHTML = ""; - const viewBtn = document.createElement("button"); - viewBtn.className = "image-popover-btn image-popover-btn-view"; - viewBtn.setAttribute("aria-label", t("image.view")); - viewBtn.innerHTML = ''; - viewBtn.addEventListener("mousedown", (e) => e.preventDefault()); - viewBtn.addEventListener("click", () => { - const img = currentImg; - hide(); - openImageLightbox(img); - }); - popover.appendChild(viewBtn); - const editBtn = document.createElement("button"); editBtn.className = "image-popover-btn"; editBtn.setAttribute("aria-label", t("image.edit") || "Edit image"); diff --git a/src-mdviewer/src/styles/markdown.css b/src-mdviewer/src/styles/markdown.css index f745c24b15..4c963bcb3d 100644 --- a/src-mdviewer/src/styles/markdown.css +++ b/src-mdviewer/src/styles/markdown.css @@ -611,9 +611,10 @@ pre .cursor-sync-highlight { .image-lightbox, .image-lightbox-img { animation: none; } } -/* Edit mode: an expand button over the centre of a hovered image opens the lightbox. */ +/* Edit mode: an expand button over the centre of a hovered image opens the lightbox. It sits in the + viewer's scroll container, so it scrolls with the image. */ .image-lightbox-expand { - position: fixed; + position: absolute; z-index: 340; display: flex; align-items: center; diff --git a/src-mdviewer/to-create-tests.md b/src-mdviewer/to-create-tests.md index aa86ad03cd..4a8020765c 100644 --- a/src-mdviewer/to-create-tests.md +++ b/src-mdviewer/to-create-tests.md @@ -52,6 +52,5 @@ - [x] Edit mode: click selects the image (popover), double-click opens the lightbox - [x] Edit mode: hovering an image shows a centred expand button that opens the lightbox - [x] Reader mode: no expand button on hover -- [x] Edit mode: the image toolbar's view button opens the lightbox - [ ] Switching files or modes closes an open lightbox - [ ] Escape with the lightbox open is not forwarded to Phoenix (editor keeps its focus state) diff --git a/test/spec/md-editor-integ-test.js b/test/spec/md-editor-integ-test.js index 1525594c44..5f2822d8f3 100644 --- a/test/spec/md-editor-integ-test.js +++ b/test/spec/md-editor-integ-test.js @@ -2197,6 +2197,11 @@ define(function (require, exports, module) { await awaitsForDone(SpecRunnerUtils.openProjectFiles(["image-test.md"]), "open image-test.md"); await _waitForMdPreviewReady(EditorManager.getActiveEditor()); + // Hover checks need the image at its real size. + await awaitsFor(() => { + const img = _getImage("Sample image"); + return img && img.complete && img.naturalWidth > 0; + }, "the sample image to load"); } function _getLightbox() { @@ -2268,49 +2273,85 @@ define(function (require, exports, module) { expect(_getLightbox()).toBeNull(); }, 10000); - it("should open a hovered image from its expand button in edit mode", async function () { - await _openImageDoc(); - await _enterEditMode(); + /** Move the pointer to a point of an element, as a real mouse move would report it. */ + function _moveMouseTo(el, xFraction = 0.5, yFraction = 0.5) { + const rect = el.getBoundingClientRect(); + el.dispatchEvent(new MouseEvent("mousemove", { + bubbles: true, + clientX: rect.left + rect.width * xFraction, + clientY: rect.top + rect.height * yFraction + })); + } - const mdDoc = _getMdIFrameDoc(); - const img = _getImage("Sample image"); - img.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); - const expand = mdDoc.querySelector(".image-lightbox-expand"); - expect(expand).not.toBeNull(); - expect(expand.classList.contains("visible")).toBeTrue(); - - // Moving off the image hides it; hovering again brings it back. - img.dispatchEvent(new MouseEvent("mouseout", { bubbles: true, relatedTarget: mdDoc.body })); - expect(expand.classList.contains("visible")).toBeFalse(); - img.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); - expect(expand.classList.contains("visible")).toBeTrue(); - - expand.click(); - _expectLightboxShows(img); - expect(expand.classList.contains("visible")).toBeFalse(); - }, 10000); + function _getExpandButton() { + return _getMdIFrameDoc().querySelector(".image-lightbox-expand"); + } - it("should show no expand button over an image in reader mode", async function () { - await _openImageDoc(); - await _enterReaderMode(); + function _isExpandButtonVisible() { + const expand = _getExpandButton(); + return !!expand && expand.classList.contains("visible"); + } - const expand = _getMdIFrameDoc().querySelector(".image-lightbox-expand"); - _getImage("Sample image").dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); - expect(!expand || !expand.classList.contains("visible")).toBeTrue(); - }, 10000); + /** The expand button sits on the centre of the image, on screen. */ + function _expectExpandButtonCentredOn(img) { + const imgRect = img.getBoundingClientRect(); + const btnRect = _getExpandButton().getBoundingClientRect(); + expect(btnRect.left + btnRect.width / 2).toBeCloseTo(imgRect.left + imgRect.width / 2, 0); + expect(btnRect.top + btnRect.height / 2).toBeCloseTo(imgRect.top + imgRect.height / 2, 0); + } - it("should open the selected image from the image toolbar's view button in edit mode", async function () { - await _openImageDoc(); - await _enterEditMode(); + it("should show the expand button wherever the pointer moves over an image in edit mode", + async function () { + await _openImageDoc(); + await _enterEditMode(); - const img = _getImage("Sample image"); - img.click(); - const popover = _getMdIFrameDoc().getElementById("image-popover"); - await awaitsFor(() => popover.classList.contains("visible"), "image popover to show"); + const img = _getImage("Sample image"); + // Each move below is handled before the next statement, so the real pointer resting + // over the runner cannot interleave its own moves. + // Near a corner, not just the centre: any point over the image counts. + _moveMouseTo(img, 0.1, 0.1); + expect(_isExpandButtonVisible()).toBeTrue(); + _expectExpandButtonCentredOn(img); + const expand = _getExpandButton(); + + // Off the image it hides; moving back over it shows it again. + _moveMouseTo(_getMdIFrameDoc().querySelector("#viewer-content h1")); + expect(_isExpandButtonVisible()).toBeFalse(); + _moveMouseTo(img, 0.8, 0.7); + expect(_isExpandButtonVisible()).toBeTrue(); + + expand.click(); + _expectLightboxShows(img); + expect(_isExpandButtonVisible()).toBeFalse(); + }, 10000); + + it("should keep the expand button over the image across a scroll while the pointer stays", + async function () { + await _openImageDoc(); + await _enterEditMode(); - popover.querySelector(".image-popover-btn-view").click(); - _expectLightboxShows(img); - expect(popover.classList.contains("visible")).toBeFalse(); + const img = _getImage("Sample image"); + _moveMouseTo(img); + expect(_isExpandButtonVisible()).toBeTrue(); + // A scroll used to hide it for good while the pointer stayed on the image. It now + // lives in the scroll container, so it stays on the image's centre. + expect(_getExpandButton().parentNode.id).toBe("app-viewer"); + _getMdIFrameDoc().getElementById("app-viewer").dispatchEvent(new Event("scroll")); + expect(_isExpandButtonVisible()).toBeTrue(); + _expectExpandButtonCentredOn(img); + // Moving off and back is still tracked after the scroll. + _moveMouseTo(_getMdIFrameDoc().querySelector("#viewer-content h1")); + expect(_isExpandButtonVisible()).toBeFalse(); + _moveMouseTo(img); + expect(_isExpandButtonVisible()).toBeTrue(); + }, 10000); + + it("should show no expand button over an image in reader mode", async function () { + await _openImageDoc(); + await _enterReaderMode(); + + _moveMouseTo(_getImage("Sample image")); + expect(_isExpandButtonVisible()).toBeFalse(); }, 10000); }); From f062e3995f79fc48b0032e84ea4e70db59c598d7 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 8 Oct 2026 21:24:30 +0530 Subject: [PATCH 05/11] fix(mdviewer): enlarge the image expand button Make the edit-mode centre expand button 56px (from 44px) with a 24px icon, so there is more area to click. Images under 96px no longer get the button, since it would cover most of them. --- src-mdviewer/src/components/image-lightbox.js | 4 ++-- src-mdviewer/src/styles/markdown.css | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src-mdviewer/src/components/image-lightbox.js b/src-mdviewer/src/components/image-lightbox.js index 444c40e57d..f44d323c87 100644 --- a/src-mdviewer/src/components/image-lightbox.js +++ b/src-mdviewer/src/components/image-lightbox.js @@ -8,8 +8,8 @@ import { getState } from "../core/state.js"; import { t } from "../core/i18n.js"; // Images smaller than this get no hover expand button; it would cover them. -const EXPAND_BUTTON_MIN_IMAGE_SIZE = 64; -const EXPAND_ICON = '' + '' + ''; diff --git a/src-mdviewer/src/styles/markdown.css b/src-mdviewer/src/styles/markdown.css index 4c963bcb3d..7aaad7138f 100644 --- a/src-mdviewer/src/styles/markdown.css +++ b/src-mdviewer/src/styles/markdown.css @@ -619,8 +619,8 @@ pre .cursor-sync-highlight { display: flex; align-items: center; justify-content: center; - width: 44px; - height: 44px; + width: 56px; + height: 56px; padding: 0; border: none; border-radius: 50%; From a53496d1e958fe94862821ab9279234f74cc1b42 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 8 Oct 2026 21:30:46 +0530 Subject: [PATCH 06/11] fix(mdviewer): move the image expand button to the top-right corner Show the edit-mode expand button as a 48px rounded square in the hovered image's top-right corner, inset 10px, instead of a circle over its centre, so it no longer covers the image content or the spot where an image is grabbed to drag it. On an image taller than the preview, the button moves down to stay on the visible part once the image's top has scrolled out of view. Tests: the Image Lightbox hover specs check the corner position. --- src-mdviewer/src/components/image-lightbox.js | 24 ++++++++++++++----- src-mdviewer/src/styles/markdown.css | 10 ++++---- test/spec/md-editor-integ-test.js | 15 ++++++------ 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src-mdviewer/src/components/image-lightbox.js b/src-mdviewer/src/components/image-lightbox.js index f44d323c87..b2436b7de1 100644 --- a/src-mdviewer/src/components/image-lightbox.js +++ b/src-mdviewer/src/components/image-lightbox.js @@ -1,7 +1,7 @@ /** * Image lightbox — shows an image on its own over a dimmed backdrop, inside the viewer frame. - * Opens on a click in reader mode. In edit mode it opens from the expand button shown over a - * hovered image, or a double-click. Any click or Escape closes it. + * Opens on a click in reader mode. In edit mode it opens from the expand button shown in the + * top-right corner of a hovered image, or a double-click. Any click or Escape closes it. */ import { on } from "../core/events.js"; import { getState } from "../core/state.js"; @@ -9,6 +9,8 @@ import { t } from "../core/i18n.js"; // Images smaller than this get no hover expand button; it would cover them. const EXPAND_BUTTON_MIN_IMAGE_SIZE = 96; +// Gap between the expand button and the image's top and right edges. +const EXPAND_BUTTON_INSET = 10; const EXPAND_ICON = '' + '' + @@ -96,7 +98,8 @@ export function openImageLightbox(img) { /** * The hover expand button. It lives in the viewer's scroll container, positioned in that * container's content coordinates, so a scroll carries it along with its image instead of it - * being moved after each scroll event. + * being moved after each scroll event. Only an image whose top has scrolled out of view moves it, + * to keep it on the visible part of the image. */ function _getExpandButton() { const appViewer = document.getElementById("app-viewer"); @@ -122,7 +125,7 @@ function _getExpandButton() { return expandButton; } -/** In edit mode, show the expand button over the centre of the hovered image. */ +/** In edit mode, show the expand button in the top-right corner of the hovered image. */ function _showExpandButton(img) { const rect = img.getBoundingClientRect(); if (rect.width < EXPAND_BUTTON_MIN_IMAGE_SIZE || rect.height < EXPAND_BUTTON_MIN_IMAGE_SIZE) { @@ -132,9 +135,18 @@ function _showExpandButton(img) { const button = _getExpandButton(); const host = button.parentNode; const hostRect = host.getBoundingClientRect(); + const half = button.offsetWidth / 2; + // Centre of the button, in viewport coordinates: the image's top-right corner, moved down to + // the top of the visible area when a tall image's top has scrolled out of view. + const centreX = rect.right - EXPAND_BUTTON_INSET - half; + let centreY = rect.top + EXPAND_BUTTON_INSET + half; + const visibleTop = hostRect.top + host.clientTop + EXPAND_BUTTON_INSET + half; + if (centreY < visibleTop) { + centreY = Math.min(visibleTop, rect.bottom - EXPAND_BUTTON_INSET - half); + } hoveredImg = img; - button.style.left = (rect.left - hostRect.left - host.clientLeft + host.scrollLeft + rect.width / 2) + "px"; - button.style.top = (rect.top - hostRect.top - host.clientTop + host.scrollTop + rect.height / 2) + "px"; + button.style.left = (centreX - hostRect.left - host.clientLeft + host.scrollLeft) + "px"; + button.style.top = (centreY - hostRect.top - host.clientTop + host.scrollTop) + "px"; button.classList.add("visible"); } diff --git a/src-mdviewer/src/styles/markdown.css b/src-mdviewer/src/styles/markdown.css index 7aaad7138f..bbacb057a8 100644 --- a/src-mdviewer/src/styles/markdown.css +++ b/src-mdviewer/src/styles/markdown.css @@ -611,19 +611,19 @@ pre .cursor-sync-highlight { .image-lightbox, .image-lightbox-img { animation: none; } } -/* Edit mode: an expand button over the centre of a hovered image opens the lightbox. It sits in the - viewer's scroll container, so it scrolls with the image. */ +/* Edit mode: an expand button in the top-right corner of a hovered image opens the lightbox. It sits + in the viewer's scroll container, so it scrolls with the image. */ .image-lightbox-expand { position: absolute; z-index: 340; display: flex; align-items: center; justify-content: center; - width: 56px; - height: 56px; + width: 48px; + height: 48px; padding: 0; border: none; - border-radius: 50%; + border-radius: 10px; background: rgba(0, 0, 0, 0.55); color: #fff; cursor: zoom-in; diff --git a/test/spec/md-editor-integ-test.js b/test/spec/md-editor-integ-test.js index 5f2822d8f3..ffce36e2d8 100644 --- a/test/spec/md-editor-integ-test.js +++ b/test/spec/md-editor-integ-test.js @@ -2292,12 +2292,13 @@ define(function (require, exports, module) { return !!expand && expand.classList.contains("visible"); } - /** The expand button sits on the centre of the image, on screen. */ - function _expectExpandButtonCentredOn(img) { + /** The expand button sits in the image's top-right corner, inset from its edges, on screen. */ + function _expectExpandButtonInCornerOf(img) { const imgRect = img.getBoundingClientRect(); const btnRect = _getExpandButton().getBoundingClientRect(); - expect(btnRect.left + btnRect.width / 2).toBeCloseTo(imgRect.left + imgRect.width / 2, 0); - expect(btnRect.top + btnRect.height / 2).toBeCloseTo(imgRect.top + imgRect.height / 2, 0); + const inset = 10; + expect(btnRect.right).toBeCloseTo(imgRect.right - inset, 0); + expect(btnRect.top).toBeCloseTo(imgRect.top + inset, 0); } it("should show the expand button wherever the pointer moves over an image in edit mode", @@ -2311,7 +2312,7 @@ define(function (require, exports, module) { // Near a corner, not just the centre: any point over the image counts. _moveMouseTo(img, 0.1, 0.1); expect(_isExpandButtonVisible()).toBeTrue(); - _expectExpandButtonCentredOn(img); + _expectExpandButtonInCornerOf(img); const expand = _getExpandButton(); // Off the image it hides; moving back over it shows it again. @@ -2334,11 +2335,11 @@ define(function (require, exports, module) { _moveMouseTo(img); expect(_isExpandButtonVisible()).toBeTrue(); // A scroll used to hide it for good while the pointer stayed on the image. It now - // lives in the scroll container, so it stays on the image's centre. + // lives in the scroll container, so it stays in the image's corner. expect(_getExpandButton().parentNode.id).toBe("app-viewer"); _getMdIFrameDoc().getElementById("app-viewer").dispatchEvent(new Event("scroll")); expect(_isExpandButtonVisible()).toBeTrue(); - _expectExpandButtonCentredOn(img); + _expectExpandButtonInCornerOf(img); // Moving off and back is still tracked after the scroll. _moveMouseTo(_getMdIFrameDoc().querySelector("#viewer-content h1")); expect(_isExpandButtonVisible()).toBeFalse(); From 2bdd6248371ae3c9de282cb2e7d88b90a09496c3 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 9 Oct 2026 11:14:56 +0530 Subject: [PATCH 07/11] feat(ai): strings and styles for the composer + attach menu Rename the screenshot menu items to lead with what differs (Area, Live Preview, Live Preview selection screenshot), add the whole-window toggle and disabled-item tooltips, and drop the camera, Full Editor and Upload from Device strings. Style the + button, the menu divider, disabled items and the area picker's toggle; remove the camera button and its drop-up styles. --- src/nls/root/strings.js | 16 +++++---- src/styles/Extn-AIChatPanel.less | 56 +++++++++++++++----------------- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index c6a7c804b1..85ab19a6dc 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -3095,7 +3095,7 @@ define({ "AI_CHAT_IMAGE_SERVER_LIMIT": "Image search is temporarily rate limited. Wait before searching again.", "AI_CHAT_IMAGE_TIMEOUT": "Image search timed out. Try again later.", "AI_CHAT_ATTACH_FILE": "Attach files", - "AI_CHAT_ATTACH_TITLE": "Attach file or folder", + "AI_CHAT_ATTACH_TITLE": "Attach files, folders or screenshots", "AI_CHAT_ATTACH_FILE_OPTION": "Attach a file", "AI_CHAT_ATTACH_FOLDER": "Add folder as context", "AI_CHAT_ATTACH_FOLDER_PICK_TITLE": "Choose folder to add as context", @@ -3141,14 +3141,16 @@ define({ "LIVE_DEV_AI_CHAT_SOURCE": "{0}:{1}", "LIVE_DEV_AI_CHAT_SOURCE_UNAVAILABLE": "Source location unavailable", - "AI_CHAT_SCREENSHOT_TITLE": "Take Screenshot", - "AI_CHAT_SCREENSHOT_LIVE_PREVIEW": "Live Preview", - "AI_CHAT_SCREENSHOT_AREA": "Select Area", - "AI_CHAT_SCREENSHOT_FULL_EDITOR": "Full Editor", - "AI_CHAT_SCREENSHOT_SELECTED_ELEMENT": "Live Preview Selection", + "AI_CHAT_SCREENSHOT_LIVE_PREVIEW": "Live Preview screenshot", + "AI_CHAT_SCREENSHOT_AREA": "Area screenshot\u2026", + "AI_CHAT_SCREENSHOT_SELECTED_ELEMENT": "Live Preview selection screenshot", + "AI_CHAT_SCREENSHOT_SELECTION_NO_HTML": "Open an HTML page in Live Preview to use this", + "AI_CHAT_SCREENSHOT_SELECTION_NONE": "Nothing selected in Live Preview", + "AI_CHAT_SCREENSHOT_SELECTION_CUSTOM_SERVER": "Not available with a custom server preview", "AI_CHAT_SCREENSHOT_CAPTURE": "Capture", "AI_CHAT_SCREENSHOT_CANCEL": "Cancel", - "AI_CHAT_SCREENSHOT_UPLOAD": "Upload from Device", + "AI_CHAT_SCREENSHOT_WHOLE_WINDOW": "Select the whole window", + "AI_CHAT_SCREENSHOT_SMALLER_AREA": "Select a smaller area", "AI_CHAT_PRIVACY_TITLE": "Start AI Chat with Claude Code", "AI_CHAT_PRIVACY_BODY": "To provide responses, this feature sends your prompts and relevant context to Claude Code.

Learn more and manage your privacy settings.", "AI_CHAT_PRIVACY_OK": "OK", diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index beefcdee8d..9ff7d79129 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -4768,11 +4768,11 @@ justify-content: center; flex-shrink: 0; align-self: stretch; - border-radius: 7px 0 0 7px; opacity: 0.5; transition: opacity 0.15s ease, color 0.15s ease; - &:hover { + &:hover, + &[aria-expanded="true"] { opacity: 1; color: @project-panel-text-1; } @@ -4804,30 +4804,6 @@ } } - .ai-screenshot-btn { - background: none; - border: none; - color: @project-panel-text-2; - width: 28px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - align-self: stretch; - opacity: 0.5; - transition: opacity 0.15s ease, color 0.15s ease; - - &:hover { - opacity: 1; - color: @project-panel-text-1; - } - - i { - font-size: @ai-text-body; - } - } - .ai-send-btn { background: none; border: none; @@ -4882,7 +4858,6 @@ } } - .ai-screenshot-dropup, .ai-attach-dropup { position: fixed; background: @bc-ai-input-bg; @@ -4893,7 +4868,12 @@ z-index: 100; min-width: 200px; - .ai-screenshot-option, + .ai-attach-divider { + height: 1px; + margin: 4px 0; + background: rgba(255, 255, 255, 0.08); + } + .ai-attach-option { padding: 8px 12px; cursor: pointer; @@ -4908,6 +4888,16 @@ background: rgba(255, 255, 255, 0.08); } + // Nothing to capture right now (e.g. no Live Preview selection). + &.disabled { + opacity: 0.45; + cursor: default; + + &:hover { + background: none; + } + } + i { width: 16px; text-align: center; @@ -4957,7 +4947,8 @@ } } - .ai-screenshot-cancel-btn { + .ai-screenshot-cancel-btn, + .ai-screenshot-fit-btn { background: rgba(255, 255, 255, 0.12); color: #ccc; @@ -4965,6 +4956,13 @@ background: rgba(255, 255, 255, 0.2); } } + + // Whole-window / smaller-area toggle: an icon-only button before Capture. + .ai-screenshot-fit-btn { + display: flex; + align-items: center; + padding: 4px 9px; + } } } From 47cb671f8ef19279ef5000eb608a06b2575be6b7 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 9 Oct 2026 11:23:50 +0530 Subject: [PATCH 08/11] fix(ai): restore the screenshot title string and update Pro pin The Live Preview chat capture button still uses AI_CHAT_SCREENSHOT_TITLE for its tooltip, which the attach menu change removed. Advance the Pro pin to 5fc672d, the + attach menu. --- src/nls/root/strings.js | 1 + tracking-repos.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 85ab19a6dc..4e58756484 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -3141,6 +3141,7 @@ define({ "LIVE_DEV_AI_CHAT_SOURCE": "{0}:{1}", "LIVE_DEV_AI_CHAT_SOURCE_UNAVAILABLE": "Source location unavailable", + "AI_CHAT_SCREENSHOT_TITLE": "Take Screenshot", "AI_CHAT_SCREENSHOT_LIVE_PREVIEW": "Live Preview screenshot", "AI_CHAT_SCREENSHOT_AREA": "Area screenshot\u2026", "AI_CHAT_SCREENSHOT_SELECTED_ELEMENT": "Live Preview selection screenshot", diff --git a/tracking-repos.json b/tracking-repos.json index 4320664745..e48de80cdd 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "4cef1b9ae230952c519da4381059ebc25eaf93a5" + "commitID": "5fc672d16b2d07c291f6da141e53e41ebfb4bf96" } } From f651a4dfab9a27fc50f38bcefca29c63a1344355 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 9 Oct 2026 13:11:10 +0530 Subject: [PATCH 09/11] test(mdviewer): embed images in the lightbox fixture Use data URLs for the existing SVG so Chromium lightbox tests do not depend on a relative virtual-server URL that returns 404. Preserve the image dimensions and interaction checks, and remove the unused SVG file. Validation: connected Chromium lightbox suite 7/7; full Chromium Live Preview CI command 320/320 on Linux. The embedded image also loads with the production sandbox restrictions. Production code is unchanged. --- test/spec/LiveDevelopment-Markdown-test-files/image-test.md | 4 ++-- .../LiveDevelopment-Markdown-test-files/images/sample.svg | 1 - test/spec/md-editor-integ-test.js | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 test/spec/LiveDevelopment-Markdown-test-files/images/sample.svg diff --git a/test/spec/LiveDevelopment-Markdown-test-files/image-test.md b/test/spec/LiveDevelopment-Markdown-test-files/image-test.md index aefe7d1214..f0767b2fca 100644 --- a/test/spec/LiveDevelopment-Markdown-test-files/image-test.md +++ b/test/spec/LiveDevelopment-Markdown-test-files/image-test.md @@ -2,10 +2,10 @@ A plain image: -![Sample image](images/sample.svg) +![Sample image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMjAiIGhlaWdodD0iMTgwIiB2aWV3Qm94PSIwIDAgMzIwIDE4MCI+PHJlY3Qgd2lkdGg9IjMyMCIgaGVpZ2h0PSIxODAiIGZpbGw9IiM0Mjg1RjQiLz48Y2lyY2xlIGN4PSIxNjAiIGN5PSI5MCIgcj0iNTAiIGZpbGw9IiNmZmZmZmYiLz48L3N2Zz4K) A linked image: -[![Linked image](images/sample.svg)](https://test-image-link.example.com/) +[![Linked image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMjAiIGhlaWdodD0iMTgwIiB2aWV3Qm94PSIwIDAgMzIwIDE4MCI+PHJlY3Qgd2lkdGg9IjMyMCIgaGVpZ2h0PSIxODAiIGZpbGw9IiM0Mjg1RjQiLz48Y2lyY2xlIGN4PSIxNjAiIGN5PSI5MCIgcj0iNTAiIGZpbGw9IiNmZmZmZmYiLz48L3N2Zz4K)](https://test-image-link.example.com/) Final paragraph. diff --git a/test/spec/LiveDevelopment-Markdown-test-files/images/sample.svg b/test/spec/LiveDevelopment-Markdown-test-files/images/sample.svg deleted file mode 100644 index ed8a606c62..0000000000 --- a/test/spec/LiveDevelopment-Markdown-test-files/images/sample.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/test/spec/md-editor-integ-test.js b/test/spec/md-editor-integ-test.js index ffce36e2d8..35542f0c98 100644 --- a/test/spec/md-editor-integ-test.js +++ b/test/spec/md-editor-integ-test.js @@ -2197,6 +2197,7 @@ define(function (require, exports, module) { await awaitsForDone(SpecRunnerUtils.openProjectFiles(["image-test.md"]), "open image-test.md"); await _waitForMdPreviewReady(EditorManager.getActiveEditor()); + // The fixture embeds its SVG so these interaction tests need no virtual image server. // Hover checks need the image at its real size. await awaitsFor(() => { const img = _getImage("Sample image"); From 39d19c0afce9bfe175bbe181c04c0648abd68244 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 9 Oct 2026 13:12:07 +0530 Subject: [PATCH 10/11] chore(deps): update Phoenix Pro pin Advance Phoenix Pro from 5fc672d to 558213d to include the latest attach menu and Chromium lightbox verification notes. The intervening Pro changes are documentation only. Validation: the tracking JSON parses and its commit ID matches Pro HEAD. --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index e48de80cdd..c835b3acd0 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "5fc672d16b2d07c291f6da141e53e41ebfb4bf96" + "commitID": "558213deff968d8da653b0fbaa711ef31b464eb9" } } From 26a23cab5f318bd29e135f3192014733eba82201 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 9 Oct 2026 13:52:08 +0530 Subject: [PATCH 11/11] build: update pro deps --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index c835b3acd0..8574181cc9 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "558213deff968d8da653b0fbaa711ef31b464eb9" + "commitID": "3b8bab92f704fcf5d50e863abec0fa5c8088fc1a" } }