Skip to content

Commit 37c3158

Browse files
committed
feat(mdviewer): add image insert via toolbar dropdown, slash menu, and paste
- Add image dropdown in toolbar with "Image URL" and "Upload from Computer" - Add two image items to slash menu (image URL and upload) - Add image URL dialog with URL and alt text inputs - Add file picker for image upload from computer - Intercept image paste in mdviewer editor - Forward image blobs to Phoenix via bridge for cloud upload - Handle upload results (replace placeholder with embed URL) - Add menu-style dropdown CSS with left-aligned icon + text items - Collapse image section with blocks at narrow widths
1 parent 6fd3128 commit 37c3158

7 files changed

Lines changed: 313 additions & 7 deletions

File tree

src-mdviewer/src/bridge.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ export function initBridge() {
150150
case "MDVIEWR_RERENDER_CONTENT":
151151
handleRerenderContent(data);
152152
break;
153+
case "MDVIEWR_IMAGE_UPLOAD_RESULT":
154+
_handleImageUploadResult(data);
155+
break;
153156
}
154157
});
155158

@@ -318,6 +321,17 @@ export function initBridge() {
318321
sendToParent("mdviewrRequestEditMode", {});
319322
});
320323

324+
// Forward image upload request from editor to Phoenix
325+
on("bridge:uploadImage", async ({ blob, filename, uploadId }) => {
326+
const arrayBuffer = await blob.arrayBuffer();
327+
sendToParent("mdviewrImageUploadRequest", {
328+
arrayBuffer,
329+
mimeType: blob.type,
330+
filename,
331+
uploadId
332+
});
333+
});
334+
321335
// Cursor sync toggle
322336
on("toggle:cursorSync", ({ enabled }) => {
323337
sendToParent("mdviewrCursorSyncToggle", { enabled });
@@ -615,6 +629,23 @@ function handleRerenderContent(data) {
615629
emit("file:rendered", parseResult);
616630
}
617631

632+
function _handleImageUploadResult(data) {
633+
const { uploadId, embedURL, error } = data;
634+
const content = document.getElementById("viewer-content");
635+
if (!content || !uploadId) return;
636+
const placeholder = content.querySelector(`img[data-upload-id="${uploadId}"]`);
637+
if (!placeholder) return;
638+
639+
if (embedURL) {
640+
placeholder.src = embedURL;
641+
placeholder.alt = placeholder.alt === "Uploading..." ? "" : placeholder.alt;
642+
placeholder.removeAttribute("data-upload-id");
643+
} else {
644+
placeholder.remove();
645+
}
646+
content.dispatchEvent(new Event("input", { bubbles: true }));
647+
}
648+
618649

619650
function handleSetLocale(data) {
620651
const { locale } = data;

src-mdviewer/src/components/editor.js

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,12 @@ export function executeFormat(contentEl, command, value) {
428428
case "mermaidBlock":
429429
insertMermaidBlock(contentEl);
430430
break;
431+
case "imageFromUrl":
432+
showImageUrlDialog(contentEl);
433+
break;
434+
case "imageUpload":
435+
openImageFilePicker(contentEl);
436+
break;
431437
}
432438

433439
broadcastSelectionState();
@@ -495,6 +501,114 @@ function insertCodeBlock(contentEl) {
495501
});
496502
}
497503

504+
const UPLOAD_PLACEHOLDER_SRC = "https://user-cdn.phcode.site/images/uploading.svg";
505+
const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"];
506+
507+
function showImageUrlDialog(contentEl) {
508+
// Create a simple overlay dialog for entering image URL and alt text
509+
const backdrop = document.createElement("div");
510+
backdrop.className = "confirm-dialog-backdrop";
511+
backdrop.innerHTML = `
512+
<div class="confirm-dialog">
513+
<h3 class="confirm-dialog-title">${t("image_dialog.title") || "Insert Image URL"}</h3>
514+
<div style="margin-bottom: 12px;">
515+
<input type="text" id="img-url-input" placeholder="${t("image_dialog.url_placeholder") || "https://example.com/image.png"}"
516+
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;" />
517+
<input type="text" id="img-alt-input" placeholder="${t("image_dialog.alt_placeholder") || "Image description"}"
518+
style="width: 100%; padding: 6px 8px; border: 1px solid var(--color-border); border-radius: 4px; background: var(--color-bg); color: var(--color-text);" />
519+
</div>
520+
<div class="confirm-dialog-buttons">
521+
<button class="confirm-dialog-btn confirm-dialog-btn-cancel" id="img-dialog-cancel">${t("dialog.cancel") || "Cancel"}</button>
522+
<button class="confirm-dialog-btn confirm-dialog-btn-save" id="img-dialog-insert">${t("image_dialog.insert") || "Insert"}</button>
523+
</div>
524+
</div>`;
525+
document.body.appendChild(backdrop);
526+
527+
const urlInput = backdrop.querySelector("#img-url-input");
528+
const altInput = backdrop.querySelector("#img-alt-input");
529+
urlInput.focus();
530+
531+
function close() {
532+
backdrop.remove();
533+
contentEl.focus({ preventScroll: true });
534+
}
535+
536+
backdrop.querySelector("#img-dialog-cancel").addEventListener("click", close);
537+
backdrop.querySelector("#img-dialog-insert").addEventListener("click", () => {
538+
const url = urlInput.value.trim();
539+
const alt = altInput.value.trim();
540+
if (url) {
541+
close();
542+
const imgHtml = `<img src="${url}" alt="${alt}">`;
543+
document.execCommand("insertHTML", false, imgHtml);
544+
contentEl.dispatchEvent(new Event("input", { bubbles: true }));
545+
}
546+
});
547+
548+
// Enter key inserts, Escape cancels
549+
backdrop.addEventListener("keydown", (e) => {
550+
if (e.key === "Enter") {
551+
e.preventDefault();
552+
backdrop.querySelector("#img-dialog-insert").click();
553+
} else if (e.key === "Escape") {
554+
e.preventDefault();
555+
close();
556+
}
557+
});
558+
559+
// Click on backdrop closes
560+
backdrop.addEventListener("mousedown", (e) => {
561+
if (e.target === backdrop) {
562+
close();
563+
}
564+
});
565+
}
566+
567+
function _insertUploadPlaceholder(contentEl) {
568+
const uploadId = crypto.randomUUID();
569+
const imgHtml = `<img src="${UPLOAD_PLACEHOLDER_SRC}" alt="Uploading..." data-upload-id="${uploadId}">`;
570+
document.execCommand("insertHTML", false, imgHtml);
571+
contentEl.dispatchEvent(new Event("input", { bubbles: true }));
572+
return uploadId;
573+
}
574+
575+
function openImageFilePicker(contentEl) {
576+
const input = document.createElement("input");
577+
input.type = "file";
578+
input.accept = "image/*";
579+
input.addEventListener("change", () => {
580+
const file = input.files && input.files[0];
581+
if (!file || !ALLOWED_IMAGE_TYPES.includes(file.type)) {
582+
return;
583+
}
584+
const uploadId = _insertUploadPlaceholder(contentEl);
585+
emit("bridge:uploadImage", { blob: file, filename: file.name, uploadId });
586+
});
587+
input.click();
588+
}
589+
590+
/**
591+
* Handle image paste in the mdviewer editor.
592+
* @return {boolean} true if an image was found and handled
593+
*/
594+
function handleImagePaste(e, contentEl) {
595+
const items = e.clipboardData && e.clipboardData.items;
596+
if (!items) {
597+
return false;
598+
}
599+
for (let i = 0; i < items.length; i++) {
600+
if (items[i].kind === "file" && ALLOWED_IMAGE_TYPES.includes(items[i].type)) {
601+
e.preventDefault();
602+
const blob = items[i].getAsFile();
603+
const fileName = blob.name || ("image." + blob.type.split("/")[1]);
604+
const uploadId = _insertUploadPlaceholder(contentEl);
605+
emit("bridge:uploadImage", { blob, filename: fileName, uploadId });
606+
return true;
607+
}
608+
}
609+
return false;
610+
}
611+
498612
// ——— Table editing helpers ———
499613

500614
function getTableContext() {
@@ -1046,6 +1160,11 @@ function sanitizePastedHTML(html) {
10461160
}
10471161

10481162
function handlePaste(e, contentEl) {
1163+
// Check for image paste first — upload to cloud
1164+
if (handleImagePaste(e, contentEl)) {
1165+
return;
1166+
}
1167+
10491168
const mod = isModKey(e);
10501169

10511170
// Inside table cells: paste as single line plain text (newlines break tables)

src-mdviewer/src/components/embedded-toolbar.js

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ import {
3030
BookOpen,
3131
Link2,
3232
Link2Off,
33-
Printer
33+
Printer,
34+
Image as ImageIcon,
35+
Upload
3436
} from "lucide";
3537
import { on, emit } from "../core/events.js";
3638
import { getState, setState } from "../core/state.js";
@@ -42,12 +44,12 @@ let cursorSyncEnabled = true;
4244
let collapseLevel = 0; // 0=expanded, 1=blocks, 2=blocks+lists, 3=all
4345

4446
// Width thresholds for progressive collapse
45-
const THRESHOLD_BLOCKS = 640; // collapse block elements first
47+
const THRESHOLD_BLOCKS = 640; // collapse block elements + image first
4648
const THRESHOLD_LISTS = 520; // then lists
47-
const THRESHOLD_TEXT = 420; // finally text formatting
49+
const THRESHOLD_TEXT = 500; // finally text formatting (all dropdowns collapsed)
4850

4951
const allIcons = { Bold, Italic, Strikethrough, Underline, Code, Link, List, ListOrdered,
50-
ListChecks, Quote, Minus, Table, FileCode, ChevronDown, Type, MoreHorizontal, Pencil, BookOpen, Link2, Link2Off, Printer };
52+
ListChecks, Quote, Minus, Table, FileCode, ChevronDown, Type, MoreHorizontal, Pencil, BookOpen, Link2, Link2Off, Printer, Image: ImageIcon, Upload };
5153

5254
export function initEmbeddedToolbar() {
5355
toolbar = document.getElementById("toolbar");
@@ -155,6 +157,10 @@ function renderEditMode(level) {
155157
btn("emb-codeblock", "file-code", t("format.code_block") || "Code block")
156158
].join("");
157159

160+
const imageBtns = `
161+
<button class="toolbar-btn toolbar-menu-item" id="emb-image-url"><i data-lucide="link"></i><span>${t("format.image_url") || "Image URL"}</span></button>
162+
<button class="toolbar-btn toolbar-menu-item" id="emb-image-upload"><i data-lucide="upload"></i><span>${t("format.image_upload") || "Upload from Computer"}</span></button>`;
163+
158164
// Build the text section (inline or dropdown)
159165
const textSection = level >= 3
160166
? dropdown("text", "type", t("format.text_formatting") || "Text formatting", textBtns)
@@ -170,6 +176,9 @@ function renderEditMode(level) {
170176
? dropdown("blocks", "more-horizontal", t("format.more_elements") || "More", blockBtns)
171177
: blockBtns;
172178

179+
// Image section is always a dropdown (two options inside)
180+
const imageSection = dropdown("image", "image", t("format.image") || "Image", imageBtns);
181+
173182
const formatRow = `
174183
<div class="format-row">
175184
${blockTypeSelect}
@@ -179,6 +188,8 @@ function renderEditMode(level) {
179188
${listSection}
180189
<div class="toolbar-divider"></div>
181190
${blockSection}
191+
<div class="toolbar-divider"></div>
192+
${imageSection}
182193
</div>`;
183194

184195
toolbar.innerHTML = `<div class="embedded-toolbar">
@@ -224,7 +235,9 @@ const formatBindings = [
224235
{ id: "emb-quote", command: "formatBlock", value: "<blockquote>" },
225236
{ id: "emb-hr", command: "insertHorizontalRule" },
226237
{ id: "emb-table", command: "table" },
227-
{ id: "emb-codeblock", command: "codeBlock" }
238+
{ id: "emb-codeblock", command: "codeBlock" },
239+
{ id: "emb-image-url", command: "imageFromUrl" },
240+
{ id: "emb-image-upload", command: "imageUpload" }
228241
];
229242

230243
function wireFormatButtons() {

src-mdviewer/src/components/slash-menu.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
Minus,
1515
Pilcrow,
1616
Workflow,
17+
Image as ImageIcon,
18+
Upload,
1719
} from "lucide";
1820
import { emit } from "../core/events.js";
1921
import { getSelectionRect } from "./editor.js";
@@ -48,6 +50,8 @@ const menuItems = [
4850
{ labelKey: "slash.code_block", descKey: "slash.code_block_desc", icon: "file-code", command: "codeBlock" },
4951
{ labelKey: "slash.table", descKey: "slash.table_desc", icon: "table", command: "table" },
5052
{ labelKey: "slash.divider", descKey: "slash.divider_desc", icon: "minus", command: "insertHorizontalRule" },
53+
{ labelKey: "slash.image_url", descKey: "slash.image_url_desc", icon: "image", command: "imageFromUrl" },
54+
{ labelKey: "slash.image_upload", descKey: "slash.image_upload_desc", icon: "image", command: "imageUpload" },
5155
{ labelKey: "slash.mermaid", descKey: "slash.mermaid_desc", icon: "workflow", command: "mermaidBlock" },
5256
];
5357

@@ -193,7 +197,7 @@ function renderItems() {
193197
menu.innerHTML = html;
194198

195199
createIcons({
196-
icons: { Pilcrow, Heading1, Heading2, Heading3, Heading4, Heading5, List, ListOrdered, ListChecks, Quote, FileCode, Table, Minus, Workflow },
200+
icons: { Pilcrow, Heading1, Heading2, Heading3, Heading4, Heading5, List, ListOrdered, ListChecks, Quote, FileCode, Table, Minus, Workflow, Image: ImageIcon, Upload },
197201
attrs: { class: "" },
198202
});
199203

src-mdviewer/src/locales/en.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,10 @@
7878
"block_type": "Block type",
7979
"text_formatting": "Text formatting",
8080
"lists": "Lists",
81-
"more_elements": "More"
81+
"more_elements": "More",
82+
"image": "Image",
83+
"image_url": "Image URL",
84+
"image_upload": "Upload from Computer"
8285
},
8386
"slash": {
8487
"paragraph": "Paragraph",
@@ -109,8 +112,18 @@
109112
"divider_desc": "Horizontal rule",
110113
"mermaid": "Mermaid diagram",
111114
"mermaid_desc": "Insert a diagram",
115+
"image_url": "Image URL",
116+
"image_url_desc": "Embed from URL",
117+
"image_upload": "Upload Image",
118+
"image_upload_desc": "Upload from computer",
112119
"no_results": "No results"
113120
},
121+
"image_dialog": {
122+
"title": "Insert Image URL",
123+
"url_placeholder": "https://example.com/image.png",
124+
"alt_placeholder": "Image description",
125+
"insert": "Insert"
126+
},
114127
"mermaid": {
115128
"edit": "Edit",
116129
"done": "Done",

src-mdviewer/src/styles/app.css

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,3 +344,30 @@ mark[data-markjs].active {
344344
.toolbar-dropdown.open .toolbar-dropdown-panel {
345345
display: flex;
346346
}
347+
348+
/* Menu-style dropdown items with icon + text label */
349+
.toolbar-dropdown-panel .toolbar-menu-item {
350+
display: flex;
351+
align-items: center;
352+
justify-content: flex-start;
353+
gap: 8px;
354+
width: 100%;
355+
padding: 6px 12px;
356+
white-space: nowrap;
357+
font-size: 13px;
358+
border-radius: var(--radius-sm);
359+
text-align: left;
360+
}
361+
362+
.toolbar-dropdown-panel .toolbar-menu-item:hover {
363+
background: var(--color-surface-hover);
364+
}
365+
366+
/* Image dropdown uses vertical layout, right-aligned to avoid overflow */
367+
.toolbar-dropdown[data-group="image"] .toolbar-dropdown-panel {
368+
flex-direction: column;
369+
min-width: 180px;
370+
left: auto;
371+
right: 0;
372+
padding: 4px;
373+
}

0 commit comments

Comments
 (0)