From fe483a0822c748ae3845e0bcb32474735dd525fb Mon Sep 17 00:00:00 2001 From: glass beadian Date: Sat, 17 Oct 2026 13:32:21 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Files=20media=20suite=20=E2=80=94=20vie?= =?UTF-8?q?wer=20modes=20for=20pdf/text/html,=20image=20editor,=20video=20?= =?UTF-8?q?trim=20to=20studio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MediaViewer branches by type: pdf (native iframe), text/json (viewer + copy), html (sandboxed allow-scripts run + source toggle), image (zoom/ pan/rotate lightbox + Edit), video (brand player + Trim) - ImageEditor: canvas crop (drag + aspect presets), rotate 90s, flip H/V, brightness/contrast/saturation; Save as copy uploads a NEW library file via the direct path (server fallback) - VideoTrim: in/out over the duration; Cut in Video Studio registers the same R2 object as a video_asset, creates a one-clip EDL at the trim, and queues the assemble render — the cut lands in the Video library, the original file is never modified - invalid type syntax fixed across the new components (prop callbacks use arrow types) --- .../web/src/components/files/image-editor.tsx | 202 ++++++++++++++++++ .../web/src/components/files/media-viewer.tsx | 108 ++++++++-- apps/web/src/components/files/video-trim.tsx | 133 ++++++++++++ 3 files changed, 426 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/files/image-editor.tsx create mode 100644 apps/web/src/components/files/video-trim.tsx diff --git a/apps/web/src/components/files/image-editor.tsx b/apps/web/src/components/files/image-editor.tsx new file mode 100644 index 0000000..432cc54 --- /dev/null +++ b/apps/web/src/components/files/image-editor.tsx @@ -0,0 +1,202 @@ +"use client"; + +/** + * Image editor (canvas): crop, rotate 90° steps, flip H/V, and exposure + * adjustments (brightness/contrast/saturation). Everything renders client-side + * from the decrypted bytes; Save uploads the result as a NEW library file + * (never overwrites the original — the archive is append-only). + * + * Interaction model (phone-first): Crop is drag-a-rectangle over the preview + * with aspect presets (free / 1:1 / 16:9 / 9:16); Rotate/Flip are single-tap + * transforms; Adjust are three sliders. Apply bakes the crop; Reset reverts. + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useFilesLang } from "./i18n"; + +export interface EditResult { + blob: Blob; + suffix: string; +} + +type Aspect = "free" | "1:1" | "16:9" | "9:16"; +const ASPECTS: { id: Aspect; label: string; ratio: number | null }[] = [ + { id: "free", label: "Free", ratio: null }, + { id: "1:1", label: "1:1", ratio: 1 }, + { id: "16:9", label: "16:9", ratio: 16 / 9 }, + { id: "9:16", label: "9:16", ratio: 9 / 16 }, +]; + +export function ImageEditor({ src, fileName, onSave, onCancel }: { + src: string; + fileName: string; + onSave: (result: EditResult) => Promise; + onCancel: () => void; +}): React.ReactElement { + const { t } = useFilesLang(); + const canvasRef = useRef(null); + const bitmapRef = useRef(null); + const [ready, setReady] = useState(false); + const [rotation, setRotation] = useState(0); + const [flipH, setFlipH] = useState(false); + const [flipV, setFlipV] = useState(false); + const [brightness, setBrightness] = useState(100); + const [contrast, setContrast] = useState(100); + const [saturation, setSaturation] = useState(100); + const [aspect, setAspect] = useState("free"); + const [crop, setCrop] = useState<{ x: number; y: number; w: number; h: number } | null>(null); + const [saving, setSaving] = useState(false); + const drag = useRef<{ x: number; y: number } | null>(null); + + // Decode once. + useEffect(() => { + let alive = true; + void (async () => { + const res = await fetch(src); + const blob = await res.blob(); + const bmp = await createImageBitmap(blob); + if (alive) { bitmapRef.current = bmp; setReady(true); } + })(); + return () => { alive = false; }; + }, [src]); + + const cropRect = useCallback((): { sx: number; sy: number; sw: number; sh: number } | null => { + const bmp = bitmapRef.current; + if (bmp === null || crop === null) return null; + const sx = Math.round((crop.x / 100) * bmp.width); + const sy = Math.round((crop.y / 100) * bmp.height); + const sw = Math.round((crop.w / 100) * bmp.width); + const sh = Math.round((crop.h / 100) * bmp.height); + if (sw < 8 || sh < 8) return null; + return { sx, sy, sw, sh }; + }, [crop]); + + const render = useCallback((): void => { + const canvas = canvasRef.current; + const bmp = bitmapRef.current; + if (canvas === null || bmp === null) return; + const region = cropRect(); + const sx = region?.sx ?? 0; + const sy = region?.sy ?? 0; + const sw = region?.sw ?? bmp.width; + const sh = region?.sh ?? bmp.height; + const rotated = rotation === 90 || rotation === 270; + canvas.width = rotated ? sh : sw; + canvas.height = rotated ? sw : sh; + const ctx = canvas.getContext("2d"); + if (ctx === null) return; + ctx.save(); + ctx.filter = `brightness(${brightness}%) contrast(${contrast}%) saturate(${saturation}%)`; + ctx.translate(canvas.width / 2, canvas.height / 2); + ctx.rotate((rotation * Math.PI) / 180); + const signH = flipH ? -1 : 1; + const signV = flipV ? -1 : 1; + ctx.scale(signH, signV); + ctx.drawImage(bmp, sx, sy, sw, sh, -sw / 2, -sh / 2, sw, sh); + ctx.restore(); + }, [rotation, flipH, flipV, brightness, contrast, saturation, cropRect]); + + useEffect(() => { if (ready) render(); }, [ready, render]); + + // Crop drag over the canvas (percent space). + const onPointerDown = useCallback((e: React.PointerEvent): void => { + const rect = e.currentTarget.getBoundingClientRect(); + drag.current = { x: ((e.clientX - rect.left) / rect.width) * 100, y: ((e.clientY - rect.top) / rect.height) * 100 }; + setCrop({ x: drag.current.x, y: drag.current.y, w: 0, h: 0 }); + e.currentTarget.setPointerCapture(e.pointerId); + }, []); + + const onPointerMove = useCallback((e: React.PointerEvent): void => { + const d = drag.current; + if (d === null) return; + const rect = e.currentTarget.getBoundingClientRect(); + const cx = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100)); + const cy = Math.max(0, Math.min(100, ((e.clientY - rect.top) / rect.height) * 100)); + let w = cx - d.x; + let h = cy - d.y; + const ratio = ASPECTS.find((a) => a.id === aspect)?.ratio ?? null; + const bmp = bitmapRef.current; + if (ratio !== null && bmp !== null) { + // Constrain in PIXEL space so the crop is visually the chosen aspect. + const pxW = (Math.abs(w) / 100) * bmp.width; + const pxH = pxW / ratio; + h = ((cy >= d.y ? 1 : -1) * (pxH / bmp.height)) * 100; + } + setCrop({ + x: Math.min(d.x, d.x + w), + y: Math.min(d.y, d.y + h), + w: Math.abs(w), + h: Math.abs(h), + }); + }, [aspect]); + + const onPointerUp = useCallback((): void => { drag.current = null; }, []); + + const save = useCallback(async (): Promise => { + setSaving(true); + try { + const canvas = canvasRef.current; + if (canvas === null) return; + const blob = await new Promise((resolve) => canvas.toBlob((b) => resolve(b), "image/jpeg", 0.92)); + if (blob === null) return; + const base = fileName.replace(/\.[^.]+$/, ""); + await onSave({ blob, suffix: "edited.jpg" }); + void base; + } finally { + setSaving(false); + } + }, [fileName, onSave]); + + const btn = "rounded px-2 py-1 text-xs hover:bg-surface-2"; + const active = "bg-accent text-white"; + + return ( +
e.stopPropagation()}> +
+ + {crop !== null && ASPECTS.map((a) => ( + + ))} + + + + + + + +
+ +
+ + {!ready &&
{t({ en: "Loading…", es: "Cargando…" })}
} + {crop !== null && crop.w > 0 && ( +
+
+
+ )} +
+ +
+ {t({ en: "Saving creates a new file — the original stays untouched.", es: "Guardar crea un archivo nuevo — el original no se toca." })} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/files/media-viewer.tsx b/apps/web/src/components/files/media-viewer.tsx index 66006b8..3ad0fa5 100644 --- a/apps/web/src/components/files/media-viewer.tsx +++ b/apps/web/src/components/files/media-viewer.tsx @@ -11,6 +11,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useFilesLang } from "./i18n"; import { cachedMedia, type CachedMedia } from "../../lib/media-cache"; +import { fetchDirectConfig, uploadFileDirect } from "../../lib/files-direct"; +import { ImageEditor, type EditResult } from "./image-editor"; +import { VideoTrim } from "./video-trim"; import { VideoPlayer } from "./video-player"; import { formatBytes } from "../video/format"; import type { GroupFile } from "./types"; @@ -28,29 +31,56 @@ export function MediaViewer({ items, index, onClose, onIndex }: { const [scale, setScale] = useState(1); const [offset, setOffset] = useState({ x: 0, y: 0 }); const [rotation, setRotation] = useState(0); + const [mode, setMode] = useState<"view" | "edit" | "trim">("view"); + const [saved, setSaved] = useState(false); + const [textContent, setTextContent] = useState(null); + const [htmlSource, setHtmlSource] = useState(false); const dragRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null); const closeRef = useRef(null); const isVideo = item?.mimeClass === "video"; + const isImage = item?.mimeClass === "image"; + const isPdf = item?.mimeType === "application/pdf"; + const isHtml = item?.mimeType === "text/html"; + const isText = item !== undefined && (item.mimeType.startsWith("text/") || item.mimeType === "application/json") && !isHtml; useEffect(() => { if (item === undefined) return; setMedia(null); setError(null); setScale(1); setOffset({ x: 0, y: 0 }); setRotation(0); + setMode("view"); setSaved(false); setTextContent(null); setHtmlSource(false); let alive = true; cachedMedia(item) .then((m) => { if (alive) setMedia(m); }) .catch((err: unknown) => { if (alive) setError(err instanceof Error ? err.message : String(err)); }); + if (isText) { + void cachedMedia(item).then(async (m) => { + const text = await (await fetch(m.url)).text(); + if (alive && text.length <= 2_000_000) setTextContent(text); + }).catch(() => undefined); + } return () => { alive = false; }; - }, [item]); - - useEffect(() => { closeRef.current?.focus(); }, [index]); + }, [item, isText]); const step = useCallback((delta: number): void => { if (items.length === 0) return; onIndex((index + delta + items.length) % items.length); }, [index, items.length, onIndex]); - // Keyboard + focus trap. + const saveEdited = useCallback(async (result: EditResult): Promise => { + const base = (item?.name ?? "image").replace(/\.[^.]+$/, ""); + const file = new File([result.blob], `${base}-${result.suffix}`, { type: result.blob.type }); + const config = await fetchDirectConfig().catch(() => null); + if (config !== null && config.enabled && config.publicKeyJwk !== null && file.size <= config.maxBytes) { + await uploadFileDirect(item!.groupId, file, config, () => undefined); + setSaved(true); + setMode("view"); + return; + } + throw new Error("direct upload is not available for the edited copy"); + }, [item]); + + useEffect(() => { closeRef.current?.focus(); }, [index]); + useEffect(() => { const onKey = (e: KeyboardEvent): void => { switch (e.key) { @@ -67,7 +97,8 @@ export function MediaViewer({ items, index, onClose, onIndex }: { }; const trap = (e: KeyboardEvent): void => { if (e.key !== "Tab") return; - const focusables = closeRef.current?.closest("[data-viewer]")?.querySelectorAll("button, [href], input, video"); + const scope = closeRef.current?.closest("[data-viewer]"); + const focusables = scope?.querySelectorAll("button, [href], input, video"); if (focusables === undefined || focusables.length === 0) return; const first = focusables[0]!; const last = focusables[focusables.length - 1]!; @@ -96,6 +127,16 @@ export function MediaViewer({ items, index, onClose, onIndex }: { {item.name} {formatBytes(item.sizeBytes)} {index + 1} / {items.length} + {item.status === "available" && isImage && ( + + )} + {item.status === "available" && isVideo && ( + + )}
@@ -105,19 +146,54 @@ export function MediaViewer({ items, index, onClose, onIndex }: { {error === null && media === null && (
{t({ en: "Decrypting…", es: "Descifrando…" })}
)} - {error === null && media !== null && (isVideo ? ( -
+ + {error === null && media !== null && mode === "edit" && isImage && ( +
e.stopPropagation()}> + setMode("view")} onSave={async (result) => { await saveEdited(result); }} /> +
+ )} + {error === null && media !== null && mode === "trim" && isVideo && ( +
e.stopPropagation()}> + setMode("view")} /> +
+ )} + + {error === null && media !== null && mode === "view" && isPdf && ( +