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: + +![Sample image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMjAiIGhlaWdodD0iMTgwIiB2aWV3Qm94PSIwIDAgMzIwIDE4MCI+PHJlY3Qgd2lkdGg9IjMyMCIgaGVpZ2h0PSIxODAiIGZpbGw9IiM0Mjg1RjQiLz48Y2lyY2xlIGN4PSIxNjAiIGN5PSI5MCIgcj0iNTAiIGZpbGw9IiNmZmZmZmYiLz48L3N2Zz4K) + +A linked image: + +[![Linked image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMjAiIGhlaWdodD0iMTgwIiB2aWV3Qm94PSIwIDAgMzIwIDE4MCI+PHJlY3Qgd2lkdGg9IjMyMCIgaGVpZ2h0PSIxODAiIGZpbGw9IiM0Mjg1RjQiLz48Y2lyY2xlIGN4PSIxNjAiIGN5PSI5MCIgcj0iNTAiIGZpbGw9IiNmZmZmZmYiLz48L3N2Zz4K)](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" } }