diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 3b5f35d99..1c21176f0 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -38,6 +38,7 @@ export default defineConfig({ "**/observer-feed-screenshots.spec.ts", "**/file-attachment.spec.ts", "**/image-attachment-gallery.spec.ts", + "**/composer-image-draw.spec.ts", "**/video-attachment.spec.ts", "**/spoiler.spec.ts", "**/mentions.spec.ts", diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index c9ce4494e..760a133c2 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -129,6 +129,25 @@ pub async fn download_file( save_bytes_with_dialog(&app, &filename, "All Files", &extensions, &bytes).await } +/// Fetch relay media bytes for the composer image editor. +/// +/// The editor composites the image onto a canvas and needs pixel access. +/// Handing the webview raw bytes over IPC (which it wraps in a same-origin +/// `blob:` URL) keeps the canvas un-tainted without involving CORS — and +/// therefore without any media-proxy header or origin-gate changes. +/// +/// Same SSRF validation, size cap, and content policy as the download +/// commands above. +#[tauri::command] +pub async fn fetch_media_bytes(url: String, state: State<'_, AppState>) -> Result, String> { + let relay_base = relay_api_base_url_with_override(&state); + validate_download_url(&url, &relay_base)?; + + let bytes = fetch_blob_bytes(&url, &state).await?; + detect_and_validate_mime(&bytes)?; + Ok(bytes) +} + /// Fetch blob bytes from a (pre-validated) relay media URL through the app's /// HTTP client, enforcing the download size cap. The caller is responsible for /// validating the URL origin and for any content-type checks on the result. diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 93424cd0f..039e9d4ee 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -513,6 +513,7 @@ pub fn run() { upload_media_bytes, download_image, download_file, + fetch_media_bytes, list_relay_members, get_my_relay_membership, add_relay_member, diff --git a/desktop/src/features/forum/ui/ForumComposerMediaStatus.tsx b/desktop/src/features/forum/ui/ForumComposerMediaStatus.tsx index abdf0f38e..3a245bdfc 100644 --- a/desktop/src/features/forum/ui/ForumComposerMediaStatus.tsx +++ b/desktop/src/features/forum/ui/ForumComposerMediaStatus.tsx @@ -1,3 +1,5 @@ +import * as React from "react"; + import type { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; import { ComposerAttachments } from "@/features/messages/ui/ComposerAttachments"; @@ -5,9 +7,12 @@ type ComposerMedia = Pick< ReturnType, | "isUploading" | "cancelUpload" + | "originalUrlByUrl" | "pendingImeta" | "removeAttachment" + | "revertAttachment" | "setUploadState" + | "uploadEditedAttachment" | "uploadState" | "uploadingCount" | "uploadingPreviews" @@ -20,6 +25,13 @@ type ForumComposerMediaStatusProps = { export function ForumComposerMediaStatus({ media, }: ForumComposerMediaStatusProps) { + const handleEditSave = React.useCallback( + async (url: string, bytes: Uint8Array) => { + await media.uploadEditedAttachment(url, bytes); + }, + [media.uploadEditedAttachment], + ); + return ( <> {media.uploadState.status === "error" ? ( @@ -41,7 +53,10 @@ export function ForumComposerMediaStatus({ attachments={media.pendingImeta} isUploading={media.isUploading} onCancelUpload={media.cancelUpload} + onEditSave={handleEditSave} onRemove={media.removeAttachment} + onRevert={media.revertAttachment} + originalUrlByUrl={media.originalUrlByUrl} uploadingCount={media.uploadingCount} uploadingPreviews={media.uploadingPreviews} /> diff --git a/desktop/src/features/messages/lib/useAttachmentEditing.ts b/desktop/src/features/messages/lib/useAttachmentEditing.ts new file mode 100644 index 000000000..084ef39c4 --- /dev/null +++ b/desktop/src/features/messages/lib/useAttachmentEditing.ts @@ -0,0 +1,53 @@ +import * as React from "react"; + +import type { MediaUploadController } from "./useMediaUpload"; + +type UseAttachmentEditingArgs = { + revertAttachment: MediaUploadController["revertAttachment"]; + /** Spoiler-set updater; membership follows the attachment across URL swaps. */ + setSpoileredAttachmentUrls: React.Dispatch>>; + uploadEditedAttachment: MediaUploadController["uploadEditedAttachment"]; +}; + +/** + * Composer-side glue for the attachment drawing editor: uploads annotated + * bytes as a replacement / reverts to the pre-edit original, migrating + * spoiler membership from the replaced URL to its replacement so an edited + * spoilered image stays spoilered. + */ +export function useAttachmentEditing({ + revertAttachment, + setSpoileredAttachmentUrls, + uploadEditedAttachment, +}: UseAttachmentEditingArgs) { + const migrateSpoileredUrl = React.useCallback( + (fromUrl: string, toUrl: string) => { + setSpoileredAttachmentUrls((current) => { + if (!current.has(fromUrl)) return current; + const next = new Set(current); + next.delete(fromUrl); + next.add(toUrl); + return next; + }); + }, + [setSpoileredAttachmentUrls], + ); + + const handleAttachmentEditSave = React.useCallback( + async (url: string, bytes: Uint8Array) => { + const descriptor = await uploadEditedAttachment(url, bytes); + if (descriptor) migrateSpoileredUrl(url, descriptor.url); + }, + [migrateSpoileredUrl, uploadEditedAttachment], + ); + + const handleAttachmentRevert = React.useCallback( + (url: string) => { + const original = revertAttachment(url); + if (original) migrateSpoileredUrl(url, original.url); + }, + [migrateSpoileredUrl, revertAttachment], + ); + + return { handleAttachmentEditSave, handleAttachmentRevert }; +} diff --git a/desktop/src/features/messages/lib/useMediaUpload.ts b/desktop/src/features/messages/lib/useMediaUpload.ts index 994e0fa0c..06be653e6 100644 --- a/desktop/src/features/messages/lib/useMediaUpload.ts +++ b/desktop/src/features/messages/lib/useMediaUpload.ts @@ -205,6 +205,44 @@ export function useMediaUpload() { const pendingImetaRef = React.useRef(pendingImeta); pendingImetaRef.current = pendingImeta; + /** + * Pre-edit originals of annotated attachments, keyed by the annotated + * attachment's URL. Powers "revert to original" in the composer lightbox. + * In-memory only — cleared implicitly when the attachment leaves the + * composer (send, remove, draft switch). + */ + const [originalsByUrl, setOriginalsByUrl] = React.useState< + Map + >(() => new Map()); + const originalsByUrlRef = React.useRef(originalsByUrl); + originalsByUrlRef.current = originalsByUrl; + + /** Annotated URL → original URL (derived; handy for stable list keys). */ + const originalUrlByUrl = React.useMemo(() => { + const map = new Map(); + for (const [url, original] of originalsByUrl) map.set(url, original.url); + return map; + }, [originalsByUrl]); + + // Prune originals whose annotated attachment is no longer pending — + // covers remove, cancel, send-clear, and draft restore in one place. + React.useEffect(() => { + setOriginalsByUrl((prev) => { + if (prev.size === 0) return prev; + const liveUrls = new Set(pendingImeta.map((d) => d.url)); + let changed = false; + const next = new Map(); + for (const [url, original] of prev) { + if (liveUrls.has(url)) { + next.set(url, original); + } else { + changed = true; + } + } + return changed ? next : prev; + }); + }, [pendingImeta]); + /** Monotonic slot counter — ensures each batch gets unique indices even * before React flushes the state update. */ const nextSlotRef = React.useRef(0); @@ -511,6 +549,80 @@ export function useMediaUpload() { [isUploadCanceled, onUploaded, onUploadError, reserveUploadingPreview], ); + /** + * Upload an annotated replacement for an existing image attachment and + * swap it into the same slot (attachment order is preserved). The pre-edit + * descriptor is remembered in `originalsByUrl` so the edit can be reverted; + * chained edits keep the earliest original as the single revert point. + * + * Returns the new descriptor, or null if `oldUrl` is no longer pending. + * Rejects on upload failure (after surfacing the standard error banner) so + * callers can keep their editing UI open. + */ + const uploadEditedAttachment = React.useCallback( + async ( + oldUrl: string, + bytes: Uint8Array, + ): Promise => { + const oldDescriptor = pendingImetaRef.current.find( + (d) => d.url === oldUrl, + ); + if (!oldDescriptor) return null; + + // The annotated output is always PNG — swap the extension accordingly. + const stem = (oldDescriptor.filename ?? "image").replace(/\.[^.]+$/, ""); + const filename = `${stem}.png`; + + const previewId = reserveUploadingPreview(); + setUploadingCount((c) => c + 1); + try { + const descriptor = await uploadMediaBytes( + [...bytes], + filename, + uploadProgressId(previewId), + ); + if (isUploadCanceled(previewId)) return null; + finishUpload(previewId); + setImetaSlots((prev) => + prev.map((d) => (d?.url === oldUrl ? descriptor : d)), + ); + setOriginalsByUrl((prev) => { + const next = new Map(prev); + // Re-editing an annotated image keeps the earliest original. + const original = prev.get(oldUrl) ?? oldDescriptor; + next.delete(oldUrl); + next.set(descriptor.url, original); + return next; + }); + return descriptor; + } catch (err) { + onUploadError(err, previewId); + throw err; + } + }, + [finishUpload, isUploadCanceled, onUploadError, reserveUploadingPreview], + ); + + /** + * Swap an annotated attachment back to its pre-edit original (same slot) + * and forget the stored original. Returns the restored descriptor, or null + * if the URL has no recorded original. + */ + const revertAttachment = React.useCallback( + (url: string): BlobDescriptor | null => { + const original = originalsByUrlRef.current.get(url); + if (!original) return null; + setImetaSlots((prev) => prev.map((d) => (d?.url === url ? original : d))); + setOriginalsByUrl((prev) => { + const next = new Map(prev); + next.delete(url); + return next; + }); + return original; + }, + [], + ); + const removeAttachment = React.useCallback((url: string) => { setImetaSlots((prev) => prev.map((d) => (d?.url === url ? null : d))); }, []); @@ -541,11 +653,14 @@ export function useMediaUpload() { handlePaste, isDragOver, isUploading, + originalUrlByUrl, pendingImeta, pendingImetaRef, removeAttachment, + revertAttachment, setPendingImeta, setUploadState, + uploadEditedAttachment, uploadFile, uploadingCount, uploadingPreviews, @@ -561,9 +676,12 @@ export function useMediaUpload() { handlePaste, isDragOver, isUploading, + originalUrlByUrl, pendingImeta, removeAttachment, + revertAttachment, setPendingImeta, + uploadEditedAttachment, uploadFile, uploadingCount, uploadingPreviews, diff --git a/desktop/src/features/messages/ui/ComposerAttachments.tsx b/desktop/src/features/messages/ui/ComposerAttachments.tsx index 261960cb7..4631e82df 100644 --- a/desktop/src/features/messages/ui/ComposerAttachments.tsx +++ b/desktop/src/features/messages/ui/ComposerAttachments.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { AnimatePresence, LayoutGroup, motion } from "motion/react"; -import { FileText, HatGlasses, Play, X } from "lucide-react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { FileText, HatGlasses, Pencil, Play, X } from "lucide-react"; import type { BlobDescriptor } from "@/shared/api/tauri"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; @@ -9,10 +10,12 @@ import { type UploadingAttachmentPreview, } from "@/features/messages/lib/useMediaUpload"; import { cn } from "@/shared/lib/cn"; -import { SimpleImageLightbox } from "@/shared/ui/SimpleImageLightbox"; +import { Button } from "@/shared/ui/button"; +import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop"; import { Progress } from "@/shared/ui/progress"; import { Toggle } from "@/shared/ui/toggle"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { ComposerImageEditor } from "./ComposerImageEditor"; /** Dashed-border overlay shown when a file is dragged over the composer form. */ export function DropZoneOverlay({ className }: { className?: string }) { @@ -36,7 +39,13 @@ type ComposerAttachmentsProps = { onCancelUpload?: (previewId: number) => void; uploadingCount?: number; uploadingPreviews?: UploadingAttachmentPreview[]; + /** Upload annotated bytes as a replacement for the attachment at `url`. */ + onEditSave?: (url: string, bytes: Uint8Array) => Promise; onRemove: (url: string) => void; + /** Restore the pre-edit original for an annotated attachment. */ + onRevert?: (url: string) => void; + /** Annotated attachment URL → original (pre-edit) URL. */ + originalUrlByUrl?: ReadonlyMap; onToggleSpoiler?: (url: string) => void; spoileredUrls?: ReadonlySet; }; @@ -54,6 +63,300 @@ function composerMediaStyle(): React.CSSProperties { }; } +type MediaAttachmentItemProps = { + attachment: BlobDescriptor; + isSpoilered: boolean; + onEditSave?: (url: string, bytes: Uint8Array) => Promise; + onRemove: (url: string) => void; + onRevert?: (url: string) => void; + onToggleSpoiler?: (url: string) => void; + /** Set when this attachment is an annotated replacement of an original. */ + originalUrl?: string; +}; + +/** + * A single image/video attachment thumbnail with its lightbox dialog. + * Images support an in-lightbox canvas edit mode (freehand drawing) and, + * once annotated, an in-place revert to the original. Saving a drawing + * closes the dialog (each save uploads a new blob, so the close adds + * friction against rapid re-edit cycles); revert keeps it open (the + * parent keys this item by its original URL so the swap doesn't remount + * it). + * + * Forwards its ref to the root motion.div — required by the parent + * `AnimatePresence mode="popLayout"`, which measures exiting children. + */ +const MediaAttachmentItem = React.forwardRef< + HTMLDivElement, + MediaAttachmentItemProps +>(function MediaAttachmentItem( + { + attachment, + isSpoilered, + onEditSave, + onRemove, + onRevert, + onToggleSpoiler, + originalUrl, + }, + ref, +) { + const [open, setOpen] = React.useState(false); + const [mode, setMode] = React.useState<"view" | "edit">("view"); + + const hash = shortHash(attachment.sha256); + const isVideo = attachment.type.startsWith("video/"); + const thumbUrl = attachment.thumb + ? rewriteRelayUrl(attachment.thumb) + : rewriteRelayUrl(attachment.url); + const videoPosterUrl = attachment.image + ? rewriteRelayUrl(attachment.image) + : attachment.thumb + ? rewriteRelayUrl(attachment.thumb) + : undefined; + + const canEdit = !isVideo && onEditSave !== undefined; + const canRevert = + !isVideo && onRevert !== undefined && originalUrl !== undefined; + + const handleOpenChange = React.useCallback((next: boolean) => { + setOpen(next); + if (!next) setMode("view"); + }, []); + + const handleEscapeKeyDown = React.useCallback( + (event: KeyboardEvent) => { + if (mode === "edit") { + // Escape leaves canvas mode but keeps the lightbox open. + event.preventDefault(); + setMode("view"); + } + }, + [mode], + ); + + const handleEditorSave = React.useCallback( + async (bytes: Uint8Array) => { + if (!onEditSave) return; + await onEditSave(attachment.url, bytes); + // Close the lightbox on save. Each save uploads a fresh blob, so the + // added friction of reopening discourages rapid save/redraw cycles + // that would otherwise orphan a blob per iteration. + setMode("view"); + setOpen(false); + }, + [attachment.url, onEditSave], + ); + + const handleEditorCancel = React.useCallback(() => setMode("view"), []); + + const handleRevert = React.useCallback(() => { + onRevert?.(attachment.url); + }, [attachment.url, onRevert]); + + return ( + +
+ + +
+ {isVideo ? ( +
+ {videoPosterUrl ? ( + {`Video + ) : ( +
+ )} +
+
+ +
+
+ ) : ( + {`Attachment + )} + {isSpoilered ? ( +
+ +
+ ) : null} +
+ + + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onEscapeKeyDown={handleEscapeKeyDown} + > + + Attachment {hash} preview + + + Full-size attachment preview. Press Escape or click outside to + close. + + {mode === "view" ? ( + + ) : null} + {mode === "edit" && !isVideo ? ( + + ) : isVideo ? ( + // biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available + + + + + + + + Remove attachment + +
+ + ); +}); + /** * Thumbnail previews for uploaded attachments in the composer. * Each attachment shows as a small image with a remove button and @@ -65,7 +368,10 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ uploadingCount = 0, uploadingPreviews = [], onCancelUpload, + onEditSave, onRemove, + onRevert, + originalUrlByUrl, onToggleSpoiler, spoileredUrls, }: ComposerAttachmentsProps) { @@ -91,16 +397,6 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ const isVideo = attachment.type.startsWith("video/"); const isImage = attachment.type.startsWith("image/"); const isFile = !isVideo && !isImage; - const isSpoilered = spoileredUrls?.has(attachment.url) ?? false; - const thumbUrl = attachment.thumb - ? rewriteRelayUrl(attachment.thumb) - : rewriteRelayUrl(attachment.url); - const videoPosterUrl = attachment.image - ? rewriteRelayUrl(attachment.image) - : attachment.thumb - ? rewriteRelayUrl(attachment.thumb) - : undefined; - const mediaStyle = composerMediaStyle(); // Generic file: compact chip with a file icon + filename, plus the // same remove button. No lightbox (nothing to preview). @@ -119,7 +415,7 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ transition={{ type: "spring", stiffness: 500, damping: 30 }} className="group relative" > -
+
{label} @@ -141,40 +437,21 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ ); } + const originalUrl = originalUrlByUrl?.get(attachment.url); return ( - - - - - - - Remove attachment - - + ); })} {isUploading && @@ -239,133 +516,3 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ ); }); - -function AttachmentMediaLightbox({ - alt, - hash, - isSpoilered, - isVideo, - mediaStyle, - onToggleSpoiler, - thumbUrl, - url, - videoPosterUrl, -}: { - alt: string; - hash: string; - isSpoilered: boolean; - isVideo: boolean; - mediaStyle: React.CSSProperties; - onToggleSpoiler?: (url: string) => void; - thumbUrl: string; - url: string; - videoPosterUrl: string | null; -}) { - const [lightboxOpen, setLightboxOpen] = React.useState(false); - const previewSrc = rewriteRelayUrl(url); - - return ( -
- - - - onToggleSpoiler(url)} - pressed={isSpoilered} - > - - - - - {isSpoilered ? "Remove spoiler" : "Mark as spoiler"} - - - ) : null - } - > - {isVideo ? ( - // biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available - -
- ); -} diff --git a/desktop/src/features/messages/ui/ComposerImageEditor.tsx b/desktop/src/features/messages/ui/ComposerImageEditor.tsx new file mode 100644 index 000000000..a9bfb3bfd --- /dev/null +++ b/desktop/src/features/messages/ui/ComposerImageEditor.tsx @@ -0,0 +1,465 @@ +import * as React from "react"; +import { Loader2, Redo2, Undo2 } from "lucide-react"; + +import { fetchMediaBytes } from "@/shared/api/tauriMedia"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; + +type EditorPoint = { x: number; y: number }; + +/** A committed pen stroke, in natural-image pixel coordinates. */ +type EditorStroke = { + color: string; + points: EditorPoint[]; + /** Line width in natural-image pixels (already scaled from CSS px). */ + width: number; +}; + +const PEN_COLORS = [ + { label: "Red", value: "#ef4444" }, + { label: "Yellow", value: "#f59e0b" }, + { label: "Green", value: "#22c55e" }, + { label: "Blue", value: "#3b82f6" }, + { label: "White", value: "#ffffff" }, + { label: "Black", value: "#111111" }, +] as const; + +/** Pen stroke width range, in CSS pixels: five whole-pixel slider stops. */ +const PEN_WIDTH_MIN_CSS = 4; +const PEN_WIDTH_MAX_CSS = 12; +const PEN_WIDTH_STEP_CSS = 2; +const PEN_WIDTH_DEFAULT_CSS = 6; + +function drawStroke(ctx: CanvasRenderingContext2D, stroke: EditorStroke) { + const [first, ...rest] = stroke.points; + if (!first) return; + ctx.strokeStyle = stroke.color; + ctx.fillStyle = stroke.color; + ctx.lineWidth = stroke.width; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + if (rest.length === 0) { + // Single click — leave a dot instead of an invisible zero-length line. + ctx.beginPath(); + ctx.arc(first.x, first.y, stroke.width / 2, 0, Math.PI * 2); + ctx.fill(); + return; + } + ctx.beginPath(); + ctx.moveTo(first.x, first.y); + for (const point of rest) ctx.lineTo(point.x, point.y); + ctx.stroke(); +} + +function drawSegment( + ctx: CanvasRenderingContext2D, + from: EditorPoint, + to: EditorPoint, + stroke: EditorStroke, +) { + ctx.strokeStyle = stroke.color; + ctx.lineWidth = stroke.width; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.beginPath(); + ctx.moveTo(from.x, from.y); + ctx.lineTo(to.x, to.y); + ctx.stroke(); +} + +/** + * Composite the source image and strokes into a PNG at natural resolution. + * + * The source bytes are fetched over Tauri IPC and wrapped in a `blob:` URL. + * Blob URLs are same-origin, so the canvas stays un-tainted and `toBlob` + * works without any CORS involvement (the media proxy sends no CORS + * headers, and cross-origin `crossOrigin="anonymous"` loads would need + * them). + */ +async function renderAnnotatedPng( + sourceUrl: string, + sourceType: string, + strokes: EditorStroke[], +): Promise { + const bytes = await fetchMediaBytes(sourceUrl); + // The explicit type matters: blob: image decoding is not content-sniffed + // for all formats, so an untyped blob may fail to decode. + const sourceBlob = new Blob([bytes], { type: sourceType }); + const blobUrl = URL.createObjectURL(sourceBlob); + try { + const image = new Image(); + image.src = blobUrl; + await image.decode(); + + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Canvas 2D context unavailable"); + ctx.drawImage(image, 0, 0); + for (const stroke of strokes) drawStroke(ctx, stroke); + + const blob = await new Promise((resolve) => { + canvas.toBlob(resolve, "image/png"); + }); + if (!blob) throw new Error("PNG encoding failed"); + return new Uint8Array(await blob.arrayBuffer()); + } finally { + URL.revokeObjectURL(blobUrl); + } +} + +type ComposerImageEditorProps = { + alt: string; + /** Resolved (proxy-rewritten) image URL, for display. */ + src: string; + /** Original relay media URL — export fetches its bytes over IPC. */ + sourceUrl: string; + /** MIME type of the source image (from the blob descriptor). */ + sourceType: string; + onCancel: () => void; + /** Upload the annotated PNG; rejection keeps the editor open. */ + onSave: (bytes: Uint8Array) => Promise; +}; + +/** + * Freehand drawing mode for a composer image attachment: the image at + * lightbox size with a canvas overlay, plus a pen toolbar (color, stroke + * width, undo, clear, cancel, save). Strokes are stored in natural-image + * coordinates so the exported PNG matches what's on screen. + */ +export function ComposerImageEditor({ + alt, + src, + sourceUrl, + sourceType, + onCancel, + onSave, +}: ComposerImageEditorProps) { + const canvasRef = React.useRef(null); + const activeStrokeRef = React.useRef(null); + // Committed strokes plus the undone strokes available for redo. Kept in + // one state object so undo/redo move strokes between stacks atomically. + const [history, setHistory] = React.useState<{ + strokes: EditorStroke[]; + undone: EditorStroke[]; + }>({ strokes: [], undone: [] }); + const strokes = history.strokes; + const [activeColor, setActiveColor] = React.useState( + PEN_COLORS[0].value, + ); + const [activeWidthCss, setActiveWidthCss] = React.useState( + PEN_WIDTH_DEFAULT_CSS, + ); + const [naturalSize, setNaturalSize] = React.useState<{ + height: number; + width: number; + } | null>(null); + const [saving, setSaving] = React.useState(false); + const [saveError, setSaveError] = React.useState(null); + + const handleImageLoad = React.useCallback( + (event: React.SyntheticEvent) => { + const { naturalHeight, naturalWidth } = event.currentTarget; + if (naturalWidth > 0 && naturalHeight > 0) { + setNaturalSize({ height: naturalHeight, width: naturalWidth }); + } + }, + [], + ); + + // Redraw committed strokes whenever they change (undo/clear/commit). + // Live segments are drawn imperatively during pointermove for latency. + React.useEffect(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (!canvas || !ctx) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + for (const stroke of strokes) drawStroke(ctx, stroke); + }, [strokes]); + + const undo = React.useCallback(() => { + setHistory((prev) => { + const last = prev.strokes[prev.strokes.length - 1]; + if (!last) return prev; + return { + strokes: prev.strokes.slice(0, -1), + undone: [...prev.undone, last], + }; + }); + }, []); + + const redo = React.useCallback(() => { + setHistory((prev) => { + const last = prev.undone[prev.undone.length - 1]; + if (!last) return prev; + return { + strokes: [...prev.strokes, last], + undone: prev.undone.slice(0, -1), + }; + }); + }, []); + + React.useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + const isModZ = + (event.metaKey || event.ctrlKey) && + !event.altKey && + event.key.toLowerCase() === "z"; + if (!isModZ) return; + event.preventDefault(); + if (event.shiftKey) { + redo(); + } else { + undo(); + } + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [redo, undo]); + + const toNaturalPoint = React.useCallback( + (event: React.PointerEvent): EditorPoint | null => { + const canvas = canvasRef.current; + if (!canvas) return null; + const rect = canvas.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return null; + return { + x: ((event.clientX - rect.left) / rect.width) * canvas.width, + y: ((event.clientY - rect.top) / rect.height) * canvas.height, + }; + }, + [], + ); + + const handlePointerDown = React.useCallback( + (event: React.PointerEvent) => { + if (event.button !== 0 || saving) return; + const canvas = canvasRef.current; + const point = toNaturalPoint(event); + if (!canvas || !point) return; + canvas.setPointerCapture(event.pointerId); + const rect = canvas.getBoundingClientRect(); + const stroke: EditorStroke = { + color: activeColor, + points: [point], + // Scale the chosen CSS width into natural pixels so the on-screen + // preview matches the exported PNG exactly. + width: Math.max(1, activeWidthCss * (canvas.width / rect.width)), + }; + activeStrokeRef.current = stroke; + const ctx = canvas.getContext("2d"); + if (ctx) drawStroke(ctx, stroke); + }, + [activeColor, activeWidthCss, saving, toNaturalPoint], + ); + + const handlePointerMove = React.useCallback( + (event: React.PointerEvent) => { + const stroke = activeStrokeRef.current; + const canvas = canvasRef.current; + if (!stroke || !canvas) return; + const point = toNaturalPoint(event); + if (!point) return; + const previous = stroke.points[stroke.points.length - 1]; + stroke.points.push(point); + const ctx = canvas.getContext("2d"); + if (ctx && previous) drawSegment(ctx, previous, point, stroke); + }, + [toNaturalPoint], + ); + + const commitActiveStroke = React.useCallback(() => { + const stroke = activeStrokeRef.current; + if (!stroke) return; + activeStrokeRef.current = null; + // A new stroke invalidates the redo stack, matching editor conventions. + setHistory((prev) => ({ strokes: [...prev.strokes, stroke], undone: [] })); + }, []); + + const handleSave = React.useCallback(async () => { + if (saving || strokes.length === 0) return; + setSaving(true); + setSaveError(null); + try { + const bytes = await renderAnnotatedPng(sourceUrl, sourceType, strokes); + await onSave(bytes); + // On success the parent closes the lightbox and unmounts this component. + } catch { + setSaveError("Could not save the drawing. Please try again."); + setSaving(false); + } + }, [onSave, saving, sourceType, sourceUrl, strokes]); + + const hasStrokes = strokes.length > 0; + + // The native cursor is hidden over the canvas; this DOM dot follows the + // pointer instead. Unlike a `cursor: url(...)` image, an element sized in + // CSS pixels is guaranteed to match the on-screen stroke width exactly. + // Positioned imperatively during pointermove to avoid re-rendering. + const brushPreviewRef = React.useRef(null); + + const moveBrushPreview = React.useCallback( + (event: React.PointerEvent) => { + const preview = brushPreviewRef.current; + const rect = canvasRef.current?.getBoundingClientRect(); + if (!preview || !rect) return; + preview.style.opacity = "1"; + preview.style.transform = `translate(${event.clientX - rect.left}px, ${event.clientY - rect.top}px) translate(-50%, -50%)`; + }, + [], + ); + + const hideBrushPreview = React.useCallback(() => { + const preview = brushPreviewRef.current; + if (preview) preview.style.opacity = "0"; + }, []); + + return ( +
+
+ {alt} + {naturalSize ? ( + <> + { + handlePointerMove(event); + moveBrushPreview(event); + }} + onPointerUp={commitActiveStroke} + ref={canvasRef} + width={naturalSize.width} + /> +
+ + ) : null} +
+ +
+
+ setActiveWidthCss(Number(event.target.value))} + step={PEN_WIDTH_STEP_CSS} + type="range" + value={activeWidthCss} + /> + +
+ {PEN_COLORS.map((color) => ( + + ))} +
+ + + + + + Undo (⌘Z) + + + + + + Redo (⇧⌘Z) + +
+ + + +
+ + {saveError ? ( +

+ {saveError} +

+ ) : null} +
+ ); +} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 54636c613..14f64714d 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -17,6 +17,7 @@ import { stripImetaMediaLines, } from "@/features/messages/lib/imetaMediaMarkdown"; +import { useAttachmentEditing } from "@/features/messages/lib/useAttachmentEditing"; import { type MediaUploadController, useMediaUpload, @@ -805,6 +806,13 @@ function MessageComposerImpl({ [media.removeAttachment], ); + const { handleAttachmentEditSave, handleAttachmentRevert } = + useAttachmentEditing({ + revertAttachment: media.revertAttachment, + setSpoileredAttachmentUrls, + uploadEditedAttachment: media.uploadEditedAttachment, + }); + const handleToggleAttachmentSpoiler = React.useCallback((url: string) => { setSpoileredAttachmentUrls((current) => { const next = new Set(current); @@ -900,7 +908,10 @@ function MessageComposerImpl({ onCancelUpload={media.cancelUpload} uploadingCount={media.uploadingCount} uploadingPreviews={media.uploadingPreviews} + onEditSave={handleAttachmentEditSave} onRemove={handleRemoveAttachment} + onRevert={handleAttachmentRevert} + originalUrlByUrl={media.originalUrlByUrl} onToggleSpoiler={handleToggleAttachmentSpoiler} spoileredUrls={spoileredAttachmentUrls} /> diff --git a/desktop/src/shared/api/tauriMedia.ts b/desktop/src/shared/api/tauriMedia.ts new file mode 100644 index 000000000..caee5106a --- /dev/null +++ b/desktop/src/shared/api/tauriMedia.ts @@ -0,0 +1,16 @@ +import { invokeTauri } from "./tauri"; + +/** + * Fetch relay media bytes over IPC (Rust reqwest, WARP-tunneled). + * + * Used by the composer image editor: wrapping the bytes in a same-origin + * `blob:` URL gives the canvas pixel access without CORS, so the media + * proxy needs no special headers. The Rust side enforces the same URL + * validation and size cap as the download commands. + */ +export async function fetchMediaBytes( + url: string, +): Promise> { + const bytes = await invokeTauri("fetch_media_bytes", { url }); + return new Uint8Array(bytes); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 05b2ef882..0f7608e75 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -8309,6 +8309,13 @@ export function maybeInstallE2eTauriMocks() { return await resolveMockUploadDescriptors(activeConfig); case "upload_media_bytes": return (await resolveMockUploadDescriptors(activeConfig))[0]; + case "fetch_media_bytes": { + // The real command fetches relay media through Rust reqwest. In E2E + // the browser fetch suffices — specs serve the URL via page.route. + const response = await fetch((payload as { url: string }).url); + if (!response.ok) throw new Error(`fetch failed: ${response.status}`); + return Array.from(new Uint8Array(await response.arrayBuffer())); + } case "download_image": case "download_file": // The save dialog can't run headlessly; report a successful save so the diff --git a/desktop/tests/e2e/composer-image-draw.spec.ts b/desktop/tests/e2e/composer-image-draw.spec.ts new file mode 100644 index 000000000..ea0ce16c8 --- /dev/null +++ b/desktop/tests/e2e/composer-image-draw.spec.ts @@ -0,0 +1,189 @@ +import { expect, type Page, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +const ORIGINAL_SHA = "a".repeat(64); +const EDITED_SHA = "b".repeat(64); +const ORIGINAL_URL = "https://example.com/e2e/draw-original.svg"; +const EDITED_URL = "https://example.com/e2e/draw-edited.svg"; + +const ORIGINAL_DESCRIPTOR = { + url: ORIGINAL_URL, + sha256: ORIGINAL_SHA, + size: 1234, + type: "image/svg+xml", + uploaded: Math.floor(Date.now() / 1000), + dim: "320x200", + filename: "draw-original.svg", +}; + +const EDITED_DESCRIPTOR = { + url: EDITED_URL, + sha256: EDITED_SHA, + size: 2345, + type: "image/png", + uploaded: Math.floor(Date.now() / 1000), + dim: "320x200", + filename: "draw-original.png", +}; + +/** + * Serve deterministic same-size SVGs for both attachment URLs. These back + * the display loads and the mock bridge's `fetch_media_bytes` + * handler (the editor exports via IPC bytes + blob: URL, so no CORS + * headers are needed). The CORS header is required only because the mock + * bridge's in-page `fetch()` of this cross-origin URL is CORS-mode — + * production fetches the bytes in Rust instead. + */ +async function installImageRoutes(page: Page) { + await page.route("https://example.com/e2e/draw-*.svg*", (route) => { + const fill = route.request().url().includes("edited") + ? "#b3574a" + : "#4aa3df"; + route.fulfill({ + body: ``, + contentType: "image/svg+xml", + headers: { "access-control-allow-origin": "*" }, + }); + }); +} + +async function drawStrokeOnCanvas(page: Page) { + const canvas = page.getByTestId("composer-image-editor-canvas"); + await expect(canvas).toBeVisible(); + const box = await canvas.boundingBox(); + if (!box) throw new Error("Expected drawing canvas to have a layout box"); + const centerY = box.y + box.height / 2; + await page.mouse.move(box.x + box.width * 0.25, centerY); + await page.mouse.down(); + await page.mouse.move(box.x + box.width * 0.75, centerY, { steps: 8 }); + await page.mouse.up(); +} + +test.beforeEach(async ({ page }) => { + await installImageRoutes(page); + await installMockBridge(page, { + uploadDescriptors: [ORIGINAL_DESCRIPTOR], + }); +}); + +test("draw on an uploaded image, save replaces it, revert restores in place", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + // Attach the original image via the mocked paperclip flow. + await page.getByRole("button", { name: "Attach image" }).click(); + const composer = page.getByTestId("message-composer"); + await expect(composer.getByAltText("Attachment aaaa")).toBeVisible(); + + // Open the composer lightbox. + await composer.getByAltText("Attachment aaaa").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog.locator(`img[src="${ORIGINAL_URL}"]`)).toBeVisible(); + + // No revert affordance before any edit. + await expect(page.getByTestId("composer-attachment-revert")).toHaveCount(0); + + // Enter canvas mode; Escape leaves canvas mode but keeps the dialog open. + await page.getByTestId("composer-attachment-edit").click(); + await expect(page.getByTestId("composer-image-editor-canvas")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("composer-image-editor-canvas")).toHaveCount(0); + await expect(dialog).toBeVisible(); + + // Re-enter canvas mode and draw a stroke. + await page.getByTestId("composer-attachment-edit").click(); + const saveButton = page.getByTestId("composer-image-editor-save"); + await expect(saveButton).toBeDisabled(); + await drawStrokeOnCanvas(page); + await expect(saveButton).toBeEnabled(); + + // The next mocked upload returns the annotated descriptor. + await page.evaluate((edited) => { + window.__BUZZ_E2E__ = { + ...window.__BUZZ_E2E__, + mock: { + ...window.__BUZZ_E2E__?.mock, + uploadDescriptors: [edited], + }, + }; + }, EDITED_DESCRIPTOR); + + await saveButton.click(); + + // Saving closes the lightbox; the composer thumbnail now shows the + // annotated image. + await expect(dialog).toHaveCount(0); + await expect(composer.getByAltText("Attachment bbbb")).toBeVisible(); + + // The annotated PNG went through the real upload command. + const uploadCommandCount = await page.evaluate( + () => + ( + window as Window & { __BUZZ_E2E_COMMANDS__?: string[] } + ).__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "upload_media_bytes", + ).length ?? 0, + ); + expect(uploadCommandCount).toBe(1); + + // Reopen the lightbox on the annotated attachment to revert. + await composer.getByAltText("Attachment bbbb").click(); + await expect(dialog).toBeVisible(); + await expect(dialog.locator(`img[src="${EDITED_URL}"]`)).toBeVisible(); + + // Revert swaps back to the original without closing the dialog. + await page.getByTestId("composer-attachment-revert").click(); + await expect(dialog).toBeVisible(); + await expect(dialog.locator(`img[src="${ORIGINAL_URL}"]`)).toBeVisible(); + await expect(page.getByTestId("composer-attachment-revert")).toHaveCount(0); + + // Closing the dialog shows the (restored) original thumbnail. + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + await expect(composer.getByAltText("Attachment aaaa")).toBeVisible(); +}); + +test("spoiler marking survives drawing on the attachment", async ({ page }) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.getByRole("button", { name: "Attach image" }).click(); + const composer = page.getByTestId("message-composer"); + await expect(composer.getByAltText("Attachment aaaa")).toBeVisible(); + + // Spoiler the attachment from its lightbox (media spoilers are + // per-attachment; the text spoiler control no longer affects media), + // then draw on it. + await composer.getByAltText("Attachment aaaa").click(); + await page.getByTestId("composer-attachment-spoiler").click(); + await page.keyboard.press("Escape"); + await expect(composer.locator("[data-composer-media-spoiler]")).toBeVisible(); + + await composer.getByAltText("Attachment aaaa").click(); + await page.getByTestId("composer-attachment-edit").click(); + await drawStrokeOnCanvas(page); + + await page.evaluate((edited) => { + window.__BUZZ_E2E__ = { + ...window.__BUZZ_E2E__, + mock: { + ...window.__BUZZ_E2E__?.mock, + uploadDescriptors: [edited], + }, + }; + }, EDITED_DESCRIPTOR); + await page.getByTestId("composer-image-editor-save").click(); + + // Saving closes the lightbox. + await expect(page.getByRole("dialog")).toHaveCount(0); + + // The annotated replacement is still marked as a spoiler. + await expect(composer.getByAltText("Attachment bbbb")).toBeVisible(); + await expect(composer.locator("[data-composer-media-spoiler]")).toBeVisible(); +});