Resize images in the composer (ihasmail #33)

(cherry picked from commit f17bdc9fff1455a734e19fdd8004fcd6b7970825)
This commit is contained in:
jcoffey-dev authored and John Coffey committed 2026-10-05 10:33:31 -07:00
1 parent 3f19f82960
commit 300ec1df99
13 files changed
+357

No files matched your search

+3
View File
@@ -812,6 +812,9 @@ export const catalog: Catalog = {
"Text size": "Schriftgröße",
"Font size": "Schriftgröße",
"Small": "Klein",
"Original size": "Originalgröße",
"Drag to resize": "Zum Ändern der Größe ziehen",
"Image size": "Bildgröße",
"Medium": "Mittel",
"Large": "Groß",
"Huge": "Sehr groß",
+3
View File
@@ -808,6 +808,9 @@ export const catalog: Catalog = {
"Text size": "Tamaño del texto",
"Font size": "Tamaño de letra",
"Small": "Pequeño",
"Original size": "Tamaño original",
"Drag to resize": "Arrastra para cambiar el tamaño",
"Image size": "Tamaño de la imagen",
"Medium": "Mediano",
"Large": "Grande",
"Huge": "Muy grande",
+3
View File
@@ -814,6 +814,9 @@ export const catalog: Catalog = {
"Text size": "Taille du texte",
"Font size": "Taille de police",
"Small": "Petite",
"Original size": "Taille d’origine",
"Drag to resize": "Faites glisser pour redimensionner",
"Image size": "Taille de l’image",
"Medium": "Moyenne",
"Large": "Grande",
"Huge": "Très grande",
+3
View File
@@ -808,6 +808,9 @@ export const catalog: Catalog = {
"Text size": "文字サイズ",
"Font size": "フォントサイズ",
"Small": "小",
"Original size": "元のサイズ",
"Drag to resize": "ドラッグしてサイズを変更",
"Image size": "画像のサイズ",
"Medium": "中",
"Large": "大",
"Huge": "特大",
+3
View File
@@ -809,6 +809,9 @@ export const catalog: Catalog = {
"Text size": "Tekstgrootte",
"Font size": "Lettergrootte",
"Small": "Klein",
"Original size": "Oorspronkelijke grootte",
"Drag to resize": "Sleep om het formaat te wijzigen",
"Image size": "Afbeeldingsgrootte",
"Medium": "Middel",
"Large": "Groot",
"Huge": "Zeer groot",
+3
View File
@@ -811,6 +811,9 @@ export const catalog: Catalog = {
"Text size": "Tamanho do texto",
"Font size": "Tamanho da fonte",
"Small": "Pequeno",
"Original size": "Tamanho original",
"Drag to resize": "Arraste para redimensionar",
"Image size": "Tamanho da imagem",
"Medium": "Médio",
"Large": "Grande",
"Huge": "Muito grande",
+3
View File
@@ -811,6 +811,9 @@ export const catalog: Catalog = {
"Text size": "Размер текста",
"Font size": "Размер шрифта",
"Small": "Мелкий",
"Original size": "Исходный размер",
"Drag to resize": "Перетащите, чтобы изменить размер",
"Image size": "Размер изображения",
"Medium": "Средний",
"Large": "Крупный",
"Huge": "Очень крупный",
+3
View File
@@ -805,6 +805,9 @@ export const catalog: Catalog = {
"Text size": "Розмір тексту",
"Font size": "Розмір шрифту",
"Small": "Дрібний",
"Original size": "Початковий розмір",
"Drag to resize": "Перетягніть, щоб змінити розмір",
"Image size": "Розмір зображення",
"Medium": "Середній",
"Large": "Великий",
"Huge": "Дуже великий",
+3
View File
@@ -807,6 +807,9 @@ export const catalog: Catalog = {
"Text size": "文字大小",
"Font size": "字号",
"Small": "小",
"Original size": "原始大小",
"Drag to resize": "拖动以调整大小",
"Image size": "图片大小",
"Medium": "中",
"Large": "大",
"Huge": "特大",
+8
View File
@@ -1622,6 +1622,14 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
.editor-area:empty::before, .editor-area[data-empty="true"]::before { content: attr(data-placeholder); color: var(--fg-faint); pointer-events: none; position: absolute; }
.editor-area blockquote { margin: 0 0 0 .8ex; border-left: 2px solid var(--border-strong); padding-left: 1ex; color: var(--fg-muted); }
.editor-area img { max-width: 100%; height: auto; }
/* Image resizing (Gitea issue #26): drawn over the editor, never inside it, so none of it is sent. */
.img-resize-layer { position: absolute; pointer-events: none; overflow: hidden; z-index: 5; }
.img-resize-frame { position: absolute; outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
.img-resize-handle { position: absolute; right: -7px; bottom: -7px; width: 14px; height: 14px; background: var(--accent); border: 2px solid var(--bg-elev); border-radius: 3px; cursor: nwse-resize; pointer-events: auto; touch-action: none; }
.img-resize-bar { position: absolute; display: flex; gap: 4px; padding: 3px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius-sm); box-shadow: var(--shadow-2); pointer-events: auto; }
.img-resize-bar .btn-sm { height: 26px; padding: 0 8px; }
.img-resize-bar .btn[aria-pressed="true"] { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
@media (pointer: coarse) { .img-resize-handle { width: 24px; height: 24px; right: -12px; bottom: -12px; border-radius: 50%; } }
.editor-area a { color: var(--link); }
.editor-area .ihm-signature { color: var(--fg-muted); }
.editor-area pre { font-family: var(--font-mono); background: var(--bg-sunken); padding: 8px; border-radius: 6px; overflow: auto; }
+194
View File
@@ -0,0 +1,194 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { t as translate } from "@/lib/i18n";
/*
* Resizing an image in the composer (Gitea issue #26).
*
* An inserted image went in at its own size, capped at the editor's width,
* and nothing could change it. A screenshot pasted from a large monitor
* filled the message. Click an image and this puts a frame round it, with a
* handle in the corner to drag it to any width, and three fixed sizes plus
* Original size above it.
*
* The frame is drawn over the editor rather than inside it, so none of it
* ends up in the message. The only thing that changes in the message is the
* image's own width: as a `width` attribute, which Outlook needs because it
* ignores CSS on images, and as an inline style, which everything else
* reads. `max-width:100%` stays, so a large image still shrinks to fit a
* phone on the receiving end.
*/
/** Fixed widths, in CSS pixels, as offered in the report. */
export const IMAGE_SIZE_PRESETS: ReadonlyArray<{ label: string; width: number }> = [
{ label: "Small", width: 300 },
{ label: "Medium", width: 600 },
{ label: "Large", width: 900 },
];
const MIN_WIDTH = 24;
/** Set an image's width, or clear it with `null` to go back to its own size. */
export function setImageWidth(img: HTMLImageElement, width: number | null): void {
// A height would fight the new width and stretch the image.
img.removeAttribute("height");
img.style.height = "auto";
img.style.maxWidth = "100%";
if (width === null) {
img.removeAttribute("width");
img.style.width = "";
return;
}
const w = Math.max(MIN_WIDTH, Math.round(width));
img.setAttribute("width", String(w));
img.style.width = `${w}px`;
}
/** The width the image has been given, or null when it is at its own size. */
export function imageWidth(img: HTMLImageElement): number | null {
const w = Number.parseInt(img.getAttribute("width") ?? "", 10);
return Number.isFinite(w) && w > 0 ? w : null;
}
interface Box {
/** Where the editor area sits inside the composer, which is what the layer is positioned against. */
areaTop: number;
areaLeft: number;
areaWidth: number;
areaHeight: number;
/** The image, relative to the editor area's visible box. */
top: number;
left: number;
width: number;
height: number;
}
interface Props {
/** The editable area. Its parent must be positioned, as `.composer-editor` is. */
area: HTMLDivElement;
img: HTMLImageElement;
/** Called once a change is finished, to save the draft. */
onChange: () => void;
onClose: () => void;
}
export function ImageResizer({ area, img, onChange, onClose }: Props) {
const [box, setBox] = useState<Box | null>(null);
const [width, setWidth] = useState<number | null>(() => imageWidth(img));
const layerRef = useRef<HTMLDivElement>(null);
const drag = useRef<{ x: number; width: number; max: number } | null>(null);
const measure = useCallback(() => {
const a = area.getBoundingClientRect();
const r = img.getBoundingClientRect();
setBox({
areaTop: area.offsetTop, areaLeft: area.offsetLeft, areaWidth: a.width, areaHeight: a.height,
top: r.top - a.top, left: r.left - a.left, width: r.width, height: r.height,
});
}, [area, img]);
useLayoutEffect(() => {
measure();
const ro = new ResizeObserver(measure);
ro.observe(img);
ro.observe(area);
area.addEventListener("scroll", measure);
window.addEventListener("resize", measure);
return () => {
ro.disconnect();
area.removeEventListener("scroll", measure);
window.removeEventListener("resize", measure);
};
}, [area, img, measure]);
// Anything but the image or the frame lets it go, and so does Escape.
useEffect(() => {
const onDown = (e: PointerEvent) => {
const target = e.target as Node;
if (target === img || layerRef.current?.contains(target)) return;
onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("pointerdown", onDown, true);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("pointerdown", onDown, true);
document.removeEventListener("keydown", onKey);
};
}, [img, onClose]);
const apply = (w: number | null) => {
setImageWidth(img, w);
setWidth(imageWidth(img));
measure();
onChange();
};
const onHandleDown = (e: ReactPointerEvent<HTMLSpanElement>) => {
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
const style = getComputedStyle(area);
const max = area.clientWidth - Number.parseFloat(style.paddingLeft) - Number.parseFloat(style.paddingRight);
drag.current = { x: e.clientX, width: img.getBoundingClientRect().width, max };
};
const onHandleMove = (e: ReactPointerEvent<HTMLSpanElement>) => {
const d = drag.current;
if (!d) return;
const w = Math.min(d.max, Math.max(MIN_WIDTH, d.width + (e.clientX - d.x)));
setImageWidth(img, w);
measure();
};
const onHandleUp = (e: ReactPointerEvent<HTMLSpanElement>) => {
if (!drag.current) return;
drag.current = null;
e.currentTarget.releasePointerCapture(e.pointerId);
setWidth(imageWidth(img));
onChange();
};
if (!box) return null;
// The size bar sits above the image, or inside its top edge when the image
// starts at the top of what is visible.
const barTop = Math.max(4, box.top - 34);
return (
<div
ref={layerRef}
className="img-resize-layer"
style={{ top: box.areaTop, left: box.areaLeft, width: box.areaWidth, height: box.areaHeight }}
>
<div className="img-resize-frame" style={{ top: box.top, left: box.left, width: box.width, height: box.height }}>
<span
className="img-resize-handle"
role="slider"
aria-label={translate("Drag to resize")}
aria-valuenow={Math.round(box.width)}
aria-valuemin={MIN_WIDTH}
title={translate("Drag to resize")}
onPointerDown={onHandleDown}
onPointerMove={onHandleMove}
onPointerUp={onHandleUp}
onPointerCancel={onHandleUp}
/>
</div>
<div className="img-resize-bar" role="toolbar" aria-label={translate("Image size")} style={{ top: barTop, left: Math.max(4, box.left) }}>
{IMAGE_SIZE_PRESETS.map((p) => (
<button
key={p.width}
type="button"
className="btn btn-sm"
aria-pressed={width === p.width}
onMouseDown={(e) => e.preventDefault()}
onClick={() => apply(p.width)}
>
{translate(p.label)}
</button>
))}
<button type="button" className="btn btn-sm" aria-pressed={width === null} onMouseDown={(e) => e.preventDefault()} onClick={() => apply(null)}>
{translate("Original size")}
</button>
</div>
</div>
);
}
+17
View File
@@ -3,6 +3,7 @@ import { AlignCenter, AlignLeft, AlignRight, Bold, Code, Eraser, Image as ImageI
import { sanitizeEditorHtml } from "@/lib/text/html";
import { Popover, useMenu } from "@/ui/popover";
import { t as translate } from "@/lib/i18n";
import { ImageResizer } from "./ImageResizer";
export interface RichEditorHandle {
focus(): void;
@@ -37,6 +38,8 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
const linkMenu = useMenu();
const [linkUrl, setLinkUrl] = useState("");
const savedRange = useRef<Range | null>(null);
/** The image whose size is being changed, if one has been clicked. */
const [selectedImg, setSelectedImg] = useState<HTMLImageElement | null>(null);
// Sync external html → DOM (only when it differs from what we emitted)
useEffect(() => {
@@ -45,6 +48,7 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
if (html !== lastEmitted.current) {
el.innerHTML = html;
lastEmitted.current = html;
setSelectedImg(null);
setEmpty(!el.textContent?.trim() && !el.querySelector("img"));
}
}, [html]);
@@ -72,6 +76,8 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
if (!el) return;
const v = el.innerHTML;
lastEmitted.current = v;
// Deleted, or typed over: there is nothing left to resize.
setSelectedImg((img) => (img && el.contains(img) ? img : null));
setEmpty(!el.textContent?.trim() && !el.querySelector("img"));
onChange(v);
}, [onChange]);
@@ -205,6 +211,14 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
onMouseUp={saveRange}
onPaste={onPaste}
onDrop={onDrop}
onClick={(e) => setSelectedImg(e.target instanceof HTMLImageElement ? e.target : null)}
onContextMenu={(e) => {
// A right-click on an image offers its sizes, which is where the
// report went looking for them, instead of the browser's menu.
if (!(e.target instanceof HTMLImageElement)) return;
e.preventDefault();
setSelectedImg(e.target);
}}
onDragOver={(e) => e.preventDefault()}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
@@ -221,6 +235,9 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
aria-multiline="true"
aria-label={translate("Message body")}
/>
{selectedImg && elRef.current && (
<ImageResizer area={elRef.current} img={selectedImg} onChange={emit} onClose={() => setSelectedImg(null)} />
)}
{showToolbar && (
<div className="editor-toolbar" role="toolbar" aria-label={translate("Formatting")}>
<button type="button" className="icon-btn" title={translate("Undo (Ctrl+Z)")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("undo")}><Undo size={16} /></button>
@@ -0,0 +1,111 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RichEditor } from "../RichEditor";
import { imageWidth, setImageWidth } from "../ImageResizer";
import { sanitizeEditorHtml } from "@/lib/text/html";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver;
/*
* An image in the composer could not be resized at all (Gitea issue #26).
* jsdom does no layout, so dragging the handle is checked in a browser; these
* pin what a size does to the message, and that the frame never ends up in it.
*/
describe("setting an image's width", () => {
it("writes both the attribute Outlook reads and the style everything else reads", () => {
const img = document.createElement("img");
img.setAttribute("height", "480");
setImageWidth(img, 300);
expect(img.getAttribute("width")).toBe("300");
expect(img.style.width).toBe("300px");
// A leftover height would stretch it out of shape.
expect(img.hasAttribute("height")).toBe(false);
expect(img.style.height).toBe("auto");
// Still shrinks to fit a narrow screen on the receiving end.
expect(img.style.maxWidth).toBe("100%");
expect(imageWidth(img)).toBe(300);
});
it("goes back to the image's own size", () => {
const img = document.createElement("img");
setImageWidth(img, 600);
setImageWidth(img, null);
expect(img.hasAttribute("width")).toBe(false);
expect(img.style.width).toBe("");
expect(imageWidth(img)).toBeNull();
});
it("survives the sanitizer a draft goes through", () => {
const img = document.createElement("img");
img.src = "https://example.com/a.png";
setImageWidth(img, 450);
const out = sanitizeEditorHtml(img.outerHTML);
expect(out).toContain('width="450"');
expect(out).toContain("width: 450px");
});
});
describe("resizing an image in the editor", () => {
let host: HTMLDivElement;
let root: Root;
let onChange: ReturnType<typeof vi.fn<(html: string) => void>>;
beforeEach(async () => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
onChange = vi.fn<(html: string) => void>();
await act(async () => {
root.render(<RichEditor html='<p>Hi</p><img src="data:image/png;base64,AA==" alt="shot" style="max-width:100%">' onChange={onChange} showToolbar={false} />);
});
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
const editor = () => host.querySelector<HTMLElement>('[contenteditable="true"]')!;
const img = () => editor().querySelector("img")!;
const button = (label: string) => [...host.querySelectorAll<HTMLButtonElement>(".img-resize-bar button")].find((b) => b.textContent === label);
it("offers sizes when the image is clicked, and applies one", async () => {
expect(host.querySelector(".img-resize-bar")).toBeNull();
await act(async () => img().click());
expect(button("Medium")).toBeTruthy();
await act(async () => button("Medium")!.click());
expect(imageWidth(img())).toBe(600);
expect(button("Medium")!.getAttribute("aria-pressed")).toBe("true");
// The draft is saved with the new width in it.
expect(onChange).toHaveBeenLastCalledWith(expect.stringContaining('width="600"'));
});
it("offers them on a right-click too, instead of the browser's menu", async () => {
const ev = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
await act(async () => img().dispatchEvent(ev));
expect(ev.defaultPrevented).toBe(true);
expect(button("Original size")).toBeTruthy();
});
it("never puts the frame or its buttons into the message", async () => {
await act(async () => img().click());
await act(async () => button("Small")!.click());
const sent = onChange.mock.lastCall![0] as string;
expect(sent).not.toContain("img-resize");
expect(sent).not.toContain("Original size");
expect(editor().querySelector(".img-resize-layer")).toBeNull();
});
it("lets go on a click elsewhere and on Escape", async () => {
await act(async () => img().click());
await act(async () => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
expect(host.querySelector(".img-resize-bar")).toBeNull();
await act(async () => img().click());
await act(async () => editor().querySelector("p")!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })));
expect(host.querySelector(".img-resize-bar")).toBeNull();
});
});