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..b2436b7de1
--- /dev/null
+++ b/src-mdviewer/src/components/image-lightbox.js
@@ -0,0 +1,295 @@
+/**
+ * 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 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";
+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 = '';
+
+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
+ * 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 });
+}
+
+/**
+ * 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. 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");
+ 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);
+ });
+ }
+ if (appViewer && expandButton.parentNode !== appViewer) {
+ appViewer.appendChild(expandButton);
+ }
+ return expandButton;
+}
+
+/** 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) {
+ _hideExpandButton();
+ return;
+ }
+ 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 = (centreX - hostRect.left - host.clientLeft + host.scrollLeft) + "px";
+ button.style.top = (centreY - hostRect.top - host.clientTop + host.scrollTop) + "px";
+ button.classList.add("visible");
+}
+
+function _hideExpandButton() {
+ hoveredImg = null;
+ if (expandButton) {
+ expandButton.classList.remove("visible");
+ }
+}
+
+/**
+ * @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) {
+ 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("scroll", () => _requestExpandSync());
+ 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();
+ }
+ });
+
+ 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();
+ _hideExpandButton();
+ };
+ on("file:switched", reset);
+ on("file:closed", reset);
+ on("state:editMode", reset);
+}
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..bbacb057a8 100644
--- a/src-mdviewer/src/styles/markdown.css
+++ b/src-mdviewer/src/styles/markdown.css
@@ -570,3 +570,74 @@ 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 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: 48px;
+ height: 48px;
+ padding: 0;
+ border: none;
+ border-radius: 10px;
+ 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..4a8020765c 100644
--- a/src-mdviewer/to-create-tests.md
+++ b/src-mdviewer/to-create-tests.md
@@ -44,3 +44,13 @@
- [ ] 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
+- [ ] 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/src/nls/root/strings.js b/src/nls/root/strings.js
index c6a7c804b1..4e58756484 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",
@@ -3142,13 +3142,16 @@ define({
"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 a65a153fac..9ff7d79129 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;
}
@@ -4511,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
@@ -4772,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;
}
@@ -4808,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;
@@ -4886,7 +4858,6 @@
}
}
- .ai-screenshot-dropup,
.ai-attach-dropup {
position: fixed;
background: @bc-ai-input-bg;
@@ -4897,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;
@@ -4912,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;
@@ -4961,7 +4947,8 @@
}
}
- .ai-screenshot-cancel-btn {
+ .ai-screenshot-cancel-btn,
+ .ai-screenshot-fit-btn {
background: rgba(255, 255, 255, 0.12);
color: #ccc;
@@ -4969,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;
+ }
}
}
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..f0767b2fca
--- /dev/null
+++ b/test/spec/LiveDevelopment-Markdown-test-files/image-test.md
@@ -0,0 +1,11 @@
+# Image Test
+
+A plain image:
+
+
+
+A linked image:
+
+[](https://test-image-link.example.com/)
+
+Final paragraph.
diff --git a/test/spec/md-editor-integ-test.js b/test/spec/md-editor-integ-test.js
index 8f09b06d30..35542f0c98 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,205 @@ 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());
+ // 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");
+ return img && img.complete && img.naturalWidth > 0;
+ }, "the sample image to load");
+ }
+
+ 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);
+
+ /** 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
+ }));
+ }
+
+ function _getExpandButton() {
+ return _getMdIFrameDoc().querySelector(".image-lightbox-expand");
+ }
+
+ function _isExpandButtonVisible() {
+ const expand = _getExpandButton();
+ return !!expand && expand.classList.contains("visible");
+ }
+
+ /** 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();
+ 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",
+ async function () {
+ await _openImageDoc();
+ await _enterEditMode();
+
+ 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();
+ _expectExpandButtonInCornerOf(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();
+
+ 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 in the image's corner.
+ expect(_getExpandButton().parentNode.id).toBe("app-viewer");
+ _getMdIFrameDoc().getElementById("app-viewer").dispatchEvent(new Event("scroll"));
+ expect(_isExpandButtonVisible()).toBeTrue();
+ _expectExpandButtonInCornerOf(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);
+ });
+
describe("Splash over the md viewer", function () {
function _visiblePreviewFrames() {
diff --git a/tracking-repos.json b/tracking-repos.json
index 19f65a113a..8574181cc9 100644
--- a/tracking-repos.json
+++ b/tracking-repos.json
@@ -1,5 +1,5 @@
{
"phoenixPro": {
- "commitID": "18799f3e83dfc7f8d4e351e15d367bb053587081"
+ "commitID": "3b8bab92f704fcf5d50e863abec0fa5c8088fc1a"
}
}