diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index a42fc20..a64efaa 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -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ß", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 8b97110..3bd5cbe 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -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", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 4603980..c5da778 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -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", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index fa5965b..204cba9 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -808,6 +808,9 @@ export const catalog: Catalog = { "Text size": "文字サイズ", "Font size": "フォントサイズ", "Small": "小", + "Original size": "元のサイズ", + "Drag to resize": "ドラッグしてサイズを変更", + "Image size": "画像のサイズ", "Medium": "中", "Large": "大", "Huge": "特大", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index ad54aba..87e3534 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -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", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index c781ce3..ff7869d 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -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", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 429d556..b3482c9 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -811,6 +811,9 @@ export const catalog: Catalog = { "Text size": "Размер текста", "Font size": "Размер шрифта", "Small": "Мелкий", + "Original size": "Исходный размер", + "Drag to resize": "Перетащите, чтобы изменить размер", + "Image size": "Размер изображения", "Medium": "Средний", "Large": "Крупный", "Huge": "Очень крупный", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index 5bc34fb..b9aa752 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -805,6 +805,9 @@ export const catalog: Catalog = { "Text size": "Розмір тексту", "Font size": "Розмір шрифту", "Small": "Дрібний", + "Original size": "Початковий розмір", + "Drag to resize": "Перетягніть, щоб змінити розмір", + "Image size": "Розмір зображення", "Medium": "Середній", "Large": "Великий", "Huge": "Дуже великий", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index d4b9917..9ee4592 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -807,6 +807,9 @@ export const catalog: Catalog = { "Text size": "文字大小", "Font size": "字号", "Small": "小", + "Original size": "原始大小", + "Drag to resize": "拖动以调整大小", + "Image size": "图片大小", "Medium": "中", "Large": "大", "Huge": "特大", diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 54033f7..1b57813 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -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; } diff --git a/web/src/views/compose/ImageResizer.tsx b/web/src/views/compose/ImageResizer.tsx new file mode 100644 index 0000000..67361a7 --- /dev/null +++ b/web/src/views/compose/ImageResizer.tsx @@ -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(null); + const [width, setWidth] = useState(() => imageWidth(img)); + const layerRef = useRef(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) => { + 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) => { + 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) => { + 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 ( +
+
+ +
+
+ {IMAGE_SIZE_PRESETS.map((p) => ( + + ))} + +
+
+ ); +} diff --git a/web/src/views/compose/RichEditor.tsx b/web/src/views/compose/RichEditor.tsx index b4f8388..defb0e9 100644 --- a/web/src/views/compose/RichEditor.tsx +++ b/web/src/views/compose/RichEditor.tsx @@ -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(function RichEdito const linkMenu = useMenu(); const [linkUrl, setLinkUrl] = useState(""); const savedRange = useRef(null); + /** The image whose size is being changed, if one has been clicked. */ + const [selectedImg, setSelectedImg] = useState(null); // Sync external html → DOM (only when it differs from what we emitted) useEffect(() => { @@ -45,6 +48,7 @@ export const RichEditor = forwardRef(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(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(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(function RichEdito aria-multiline="true" aria-label={translate("Message body")} /> + {selectedImg && elRef.current && ( + setSelectedImg(null)} /> + )} {showToolbar && (
diff --git a/web/src/views/compose/__tests__/image-resize.test.tsx b/web/src/views/compose/__tests__/image-resize.test.tsx new file mode 100644 index 0000000..59bb33a --- /dev/null +++ b/web/src/views/compose/__tests__/image-resize.test.tsx @@ -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 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(); + }); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + }); + + const editor = () => host.querySelector('[contenteditable="true"]')!; + const img = () => editor().querySelector("img")!; + const button = (label: string) => [...host.querySelectorAll(".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(); + }); +});