Skip to content

Commit 774e43b

Browse files
committed
feat(mdviewer): add image popover with edit/delete and keyboard nav
Show a floating popover with Edit and Delete buttons when clicking an image in edit mode. Edit opens the image URL dialog pre-filled with current src/alt. Delete removes the image. Selected image gets a blue outline. Arrow keys move cursor to adjacent elements, Enter creates a new paragraph below, Backspace/Delete removes the image. fix(mdviewer): don't intercept keyboard shortcuts in input fields Skip shortcut forwarding to Phoenix when focus is in any input/textarea outside viewer-content (dialogs, search bar, link popover inputs).
1 parent 8529392 commit 774e43b

7 files changed

Lines changed: 337 additions & 18 deletions

File tree

src-mdviewer/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
</div>
2424
<div class="format-bar" id="format-bar" role="toolbar"></div>
2525
<div class="link-popover" id="link-popover" role="dialog"></div>
26+
<div class="image-popover" id="image-popover" role="toolbar"></div>
2627
<div class="context-menu" id="context-menu" role="menu"></div>
2728
<div class="table-context-menu" id="table-context-menu" role="menu"></div>
2829
<div class="slash-menu-anchor" id="slash-menu-anchor">

src-mdviewer/src/bridge.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,15 @@ export function initBridge() {
223223
const _mdEditorHandledShiftKeys = new Set(["x", "X", "z", "Z"]); // Ctrl/Cmd + Shift + key
224224

225225
document.addEventListener("keydown", (e) => {
226+
// Don't intercept shortcuts when focus is in any input/textarea (except Escape)
227+
// This covers dialog inputs, search bar input, link popover input, etc.
228+
const activeEl = document.activeElement;
229+
if (e.key !== "Escape" && activeEl &&
230+
(activeEl.tagName === "INPUT" || activeEl.tagName === "TEXTAREA") &&
231+
!activeEl.closest("#viewer-content")) {
232+
return;
233+
}
234+
226235
if (e.key === "Escape") {
227236
// Don't forward Escape to Phoenix if any popup/overlay is open
228237
const popupSelectors = [

src-mdviewer/src/components/editor.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { t, tp } from "../core/i18n.js";
88
import { initFormatBar, destroyFormatBar, focusFormatBar } from "./format-bar.js";
99
import { initSlashMenu, destroySlashMenu, isSlashMenuVisible } from "./slash-menu.js";
1010
import { initLinkPopover, destroyLinkPopover } from "./link-popover.js";
11+
import { initImagePopover, destroyImagePopover } from "./image-popover.js";
1112
import { initLangPicker, destroyLangPicker, isLangPickerDropdownOpen } from "./lang-picker.js";
1213
import { highlightCode, renderAfterHTML, normalizeCodeLanguages } from "./viewer.js";
1314
import { initMermaidEditor, destroyMermaidEditor, insertMermaidBlock, attachOverlays } from "./mermaid-editor.js";
@@ -2191,6 +2192,7 @@ function enterEditMode(content) {
21912192

21922193
initFormatBar(content);
21932194
initLinkPopover(content);
2195+
initImagePopover(content);
21942196
initLangPicker(content);
21952197
initSlashMenu(content);
21962198

@@ -2208,6 +2210,7 @@ function cleanupEditMode(content) {
22082210

22092211
destroyFormatBar();
22102212
destroyLinkPopover();
2213+
destroyImagePopover();
22112214
destroyLangPicker();
22122215
destroySlashMenu();
22132216
destroyMermaidEditor();
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
/**
2+
* Image popover — appears when clicking an image in edit mode.
3+
* Shows Edit (opens image URL dialog) and Delete buttons.
4+
*/
5+
import { emit, on } from "../core/events.js";
6+
import { t } from "../core/i18n.js";
7+
import { getState } from "../core/state.js";
8+
9+
let popover = null;
10+
let currentImg = null;
11+
let contentEl = null;
12+
13+
function hide() {
14+
if (!popover) return;
15+
popover.classList.remove("visible");
16+
if (currentImg) {
17+
currentImg.classList.remove("image-selected");
18+
}
19+
currentImg = null;
20+
}
21+
22+
function _moveCursorBeforeImage(img) {
23+
const block = img.closest("p, div, li, blockquote") || img.parentNode;
24+
const prev = block.previousElementSibling;
25+
if (prev) {
26+
const range = document.createRange();
27+
range.selectNodeContents(prev);
28+
range.collapse(false); // end of previous element
29+
const sel = window.getSelection();
30+
sel.removeAllRanges();
31+
sel.addRange(range);
32+
}
33+
}
34+
35+
function _moveCursorAfterImage(img, content) {
36+
const block = img.closest("p, div, li, blockquote") || img.parentNode;
37+
let next = block.nextElementSibling;
38+
if (!next) {
39+
// Create a new paragraph if nothing follows
40+
next = document.createElement("p");
41+
next.innerHTML = "<br>";
42+
block.parentNode.insertBefore(next, block.nextSibling);
43+
content.dispatchEvent(new Event("input", { bubbles: true }));
44+
}
45+
const range = document.createRange();
46+
range.selectNodeContents(next);
47+
range.collapse(true); // start of next element
48+
const sel = window.getSelection();
49+
sel.removeAllRanges();
50+
sel.addRange(range);
51+
}
52+
53+
function _createParagraphAfterImage(img, content) {
54+
const block = img.closest("p, div, li, blockquote") || img.parentNode;
55+
const newP = document.createElement("p");
56+
newP.innerHTML = "<br>";
57+
block.parentNode.insertBefore(newP, block.nextSibling);
58+
const range = document.createRange();
59+
range.selectNodeContents(newP);
60+
range.collapse(true);
61+
const sel = window.getSelection();
62+
sel.removeAllRanges();
63+
sel.addRange(range);
64+
content.dispatchEvent(new Event("input", { bubbles: true }));
65+
}
66+
67+
function show(img) {
68+
if (!popover || !img) return;
69+
currentImg = img;
70+
71+
const rect = img.getBoundingClientRect();
72+
const popW = popover.offsetWidth || 80;
73+
const popH = popover.offsetHeight || 36;
74+
75+
// Position above the image, centered
76+
let left = rect.left + rect.width / 2 - popW / 2;
77+
let top = rect.top - popH - 8;
78+
79+
// Flip below if too close to top
80+
if (top < 4) {
81+
top = rect.bottom + 8;
82+
}
83+
// Clamp horizontal
84+
left = Math.max(4, Math.min(left, window.innerWidth - popW - 4));
85+
86+
popover.style.left = left + "px";
87+
popover.style.top = top + "px";
88+
popover.classList.add("visible");
89+
}
90+
91+
export function initImagePopover(content) {
92+
contentEl = content;
93+
popover = document.getElementById("image-popover");
94+
if (!popover) return;
95+
96+
popover.innerHTML = "";
97+
98+
const editBtn = document.createElement("button");
99+
editBtn.className = "image-popover-btn";
100+
editBtn.setAttribute("aria-label", t("image.edit") || "Edit image");
101+
editBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>';
102+
editBtn.addEventListener("mousedown", (e) => e.preventDefault());
103+
editBtn.addEventListener("click", () => {
104+
const img = currentImg;
105+
const src = img ? img.getAttribute("src") || "" : "";
106+
const alt = img ? img.getAttribute("alt") || "" : "";
107+
hide();
108+
if (!img) return;
109+
showEditDialog(img, src, alt);
110+
});
111+
popover.appendChild(editBtn);
112+
113+
const deleteBtn = document.createElement("button");
114+
deleteBtn.className = "image-popover-btn image-popover-btn-delete";
115+
deleteBtn.setAttribute("aria-label", t("image.delete") || "Delete image");
116+
deleteBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>';
117+
deleteBtn.addEventListener("mousedown", (e) => e.preventDefault());
118+
deleteBtn.addEventListener("click", () => {
119+
const img = currentImg;
120+
hide();
121+
if (!img || !img.parentNode) return;
122+
img.remove();
123+
if (contentEl) {
124+
contentEl.dispatchEvent(new Event("input", { bubbles: true }));
125+
}
126+
});
127+
popover.appendChild(deleteBtn);
128+
129+
// Click on images in edit mode shows the popover and selects the image
130+
content.addEventListener("click", (e) => {
131+
if (!getState().editMode) return;
132+
const img = e.target.closest("img");
133+
if (img && content.contains(img)) {
134+
e.preventDefault();
135+
show(img);
136+
// Add visual selection to the image
137+
content.querySelectorAll("img.image-selected").forEach(
138+
el => el.classList.remove("image-selected"));
139+
img.classList.add("image-selected");
140+
} else if (!popover.contains(e.target)) {
141+
hide();
142+
content.querySelectorAll("img.image-selected").forEach(
143+
el => el.classList.remove("image-selected"));
144+
}
145+
});
146+
147+
// Keyboard handling when an image is selected
148+
content.addEventListener("keydown", (e) => {
149+
if (!getState().editMode || !currentImg) return;
150+
const img = currentImg;
151+
152+
if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
153+
e.preventDefault();
154+
hide();
155+
_moveCursorBeforeImage(img);
156+
} else if (e.key === "ArrowDown" || e.key === "ArrowRight") {
157+
e.preventDefault();
158+
hide();
159+
_moveCursorAfterImage(img, content);
160+
} else if (e.key === "Enter") {
161+
e.preventDefault();
162+
hide();
163+
_createParagraphAfterImage(img, content);
164+
} else if (e.key === "Backspace" || e.key === "Delete") {
165+
e.preventDefault();
166+
hide();
167+
if (img.parentNode) {
168+
img.remove();
169+
content.dispatchEvent(new Event("input", { bubbles: true }));
170+
}
171+
}
172+
});
173+
174+
// Hide on scroll
175+
const appViewer = document.getElementById("app-viewer");
176+
if (appViewer) {
177+
appViewer.addEventListener("scroll", hide);
178+
}
179+
180+
// Hide on Escape
181+
document.addEventListener("keydown", (e) => {
182+
if (e.key === "Escape" && popover.classList.contains("visible")) {
183+
hide();
184+
content.querySelectorAll("img.image-selected").forEach(
185+
el => el.classList.remove("image-selected"));
186+
}
187+
});
188+
}
189+
190+
function showEditDialog(imgEl, currentSrc, currentAlt) {
191+
const backdrop = document.createElement("div");
192+
backdrop.className = "confirm-dialog-backdrop";
193+
backdrop.innerHTML = `
194+
<div class="confirm-dialog">
195+
<h3 class="confirm-dialog-title">${t("image.edit") || "Edit Image URL"}</h3>
196+
<div style="margin-bottom: 12px;">
197+
<input type="text" id="img-edit-url-input" placeholder="${t("image_dialog.url_placeholder") || "https://example.com/image.png"}"
198+
style="width: 100%; padding: 6px 8px; border: 1px solid var(--color-border); border-radius: 4px; background: var(--color-bg); color: var(--color-text); margin-bottom: 8px;" />
199+
<input type="text" id="img-edit-alt-input" placeholder="${t("image_dialog.alt_placeholder") || "Image description"}"
200+
style="width: 100%; padding: 6px 8px; border: 1px solid var(--color-border); border-radius: 4px; background: var(--color-bg); color: var(--color-text);" />
201+
</div>
202+
<div class="confirm-dialog-buttons">
203+
<button class="confirm-dialog-btn confirm-dialog-btn-cancel" id="img-edit-cancel">${t("dialog.cancel") || "Cancel"}</button>
204+
<button class="confirm-dialog-btn confirm-dialog-btn-save" id="img-edit-save">${t("dialog.save") || "Save"}</button>
205+
</div>
206+
</div>`;
207+
document.body.appendChild(backdrop);
208+
209+
const urlInput = backdrop.querySelector("#img-edit-url-input");
210+
const altInput = backdrop.querySelector("#img-edit-alt-input");
211+
urlInput.value = currentSrc;
212+
altInput.value = currentAlt;
213+
urlInput.focus();
214+
urlInput.select();
215+
216+
function close() {
217+
backdrop.remove();
218+
if (contentEl) {
219+
contentEl.focus({ preventScroll: true });
220+
}
221+
}
222+
223+
backdrop.querySelector("#img-edit-cancel").addEventListener("click", close);
224+
backdrop.querySelector("#img-edit-save").addEventListener("click", () => {
225+
const url = urlInput.value.trim();
226+
const alt = altInput.value.trim();
227+
if (url && imgEl && imgEl.parentNode) {
228+
imgEl.setAttribute("src", url);
229+
imgEl.setAttribute("alt", alt);
230+
if (contentEl) {
231+
contentEl.dispatchEvent(new Event("input", { bubbles: true }));
232+
}
233+
}
234+
close();
235+
});
236+
237+
backdrop.addEventListener("keydown", (e) => {
238+
if (e.key === "Enter") {
239+
e.preventDefault();
240+
backdrop.querySelector("#img-edit-save").click();
241+
} else if (e.key === "Escape") {
242+
e.preventDefault();
243+
e.stopPropagation();
244+
close();
245+
}
246+
});
247+
248+
backdrop.addEventListener("mousedown", (e) => {
249+
if (e.target === backdrop) {
250+
close();
251+
}
252+
});
253+
}
254+
255+
export function destroyImagePopover() {
256+
hide();
257+
contentEl = null;
258+
currentImg = null;
259+
}

src-mdviewer/src/locales/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@
118118
"image_upload_desc": "Upload from computer",
119119
"no_results": "No results"
120120
},
121+
"image": {
122+
"edit": "Edit Image URL",
123+
"delete": "Delete image"
124+
},
121125
"image_dialog": {
122126
"title": "Insert Image URL",
123127
"url_placeholder": "https://example.com/image.png",

src-mdviewer/src/styles/editor.css

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,3 +1356,64 @@
13561356
height: auto !important;
13571357
}
13581358
}
1359+
1360+
/* --- Image popover --- */
1361+
1362+
#viewer-content.editing img {
1363+
cursor: pointer;
1364+
}
1365+
1366+
#viewer-content.editing img.image-selected {
1367+
outline: 2px solid var(--color-accent, #4285F4);
1368+
outline-offset: 2px;
1369+
border-radius: var(--radius-md);
1370+
}
1371+
1372+
.image-popover {
1373+
position: fixed;
1374+
display: flex;
1375+
align-items: center;
1376+
gap: 2px;
1377+
padding: var(--space-xs);
1378+
background: var(--color-surface);
1379+
border: 1px solid var(--color-border);
1380+
border-radius: var(--radius-md);
1381+
box-shadow: var(--shadow-md);
1382+
z-index: 350;
1383+
opacity: 0;
1384+
pointer-events: none;
1385+
transform: translateY(4px);
1386+
transition: opacity 150ms ease-out, transform 150ms ease-out;
1387+
will-change: opacity, transform;
1388+
}
1389+
1390+
.image-popover.visible {
1391+
opacity: 1;
1392+
pointer-events: auto;
1393+
transform: translateY(0);
1394+
}
1395+
1396+
.image-popover-btn {
1397+
display: flex;
1398+
align-items: center;
1399+
justify-content: center;
1400+
width: 28px;
1401+
height: 28px;
1402+
border: none;
1403+
background: transparent;
1404+
color: var(--color-text);
1405+
border-radius: var(--radius-sm);
1406+
cursor: pointer;
1407+
}
1408+
1409+
.image-popover-btn:hover {
1410+
background: var(--color-hover);
1411+
}
1412+
1413+
.image-popover-btn-delete {
1414+
color: var(--color-danger, #f85149);
1415+
}
1416+
1417+
.image-popover-btn-delete:hover {
1418+
background: rgba(248, 81, 73, 0.15);
1419+
}

0 commit comments

Comments
 (0)