// Image Editor — client-side, non-destructive image editor, at #image-editor. // // Everything runs in the browser: no /image/* endpoint is involved (the older // server-rendered Image Tools page still lives under Test Pages). Ops live in // javascript/image_editor_ops.js, presentational components in // jsx/ImageEditorParts.jsx; this file owns state and layout. // // Editing model: the original image is kept aside and never modified. Applied // edits are a stack of { uid, id, params, enabled } entries; any change to the // stack (add, reorder, toggle, remove, re-edit) re-runs the whole enabled stack // from the original via Ops.runPipeline. // // The whole file is wrapped in an IIFE so its helpers stay out of the shared // global scope that every text/babel script compiles into (see the "no // top-level const" gotcha in docs/design_style_guide.md). (function () { 'use strict'; const { Box, Drawer, Alert, useMediaQuery } = MaterialUI; // The preview pipeline runs on a copy no larger than this on its long edge — // full resolution is only touched on export. Pixel-unit params (blur radius) // are scaled by Ops.runPipeline so both look the same. const WORKING_MAX = 1600; const MIME = { png: 'image/png', jpeg: 'image/jpeg', webp: 'image/webp' }; const EXT = { png: 'png', jpeg: 'jpg', webp: 'webp' }; const QUALITY = 0.92; // lossy formats; a quality control can be added to ExportPanel // Coalesce work onto the next frame, returning a cancel function. Falls back // to a timer when the tab is hidden, where requestAnimationFrame never fires — // otherwise a queued render (or an export) would stall until the user came // back to the tab. const scheduleFrame = (fn) => { if (document.hidden) { const id = setTimeout(fn, 0); return () => clearTimeout(id); } const id = requestAnimationFrame(fn); return () => cancelAnimationFrame(id); }; const loadImageFile = (file) => new Promise((resolve, reject) => { const url = URL.createObjectURL(file); const img = new window.Image(); img.onload = () => { URL.revokeObjectURL(url); resolve(img); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Could not decode that file.')); }; img.src = url; }); const ImageEditorPage = () => { const theme = MaterialUI.useTheme(); const darkMode = theme.palette.mode === 'dark'; const DS = window.DesignSystem; const T = DS.tokens(darkMode); const Ops = window.ImageEditorOps; const Parts = window.ImageEditorParts; const Graph = window.ImageEditorGraph; const isMdUp = useMediaQuery('(min-width:900px)'); const [source, setSource] = React.useState(null); // { name, width, height } const [sourceKey, setSourceKey] = React.useState(0); const [ops, setOps] = React.useState([]); // committed edit stack const [draft, setDraft] = React.useState(null); // { id, params, editingUid } const [tab, setTab] = React.useState('edit'); const [view, setView] = React.useState({ zoom: 'fit', panX: 0, panY: 0 }); const [result, setResult] = React.useState(null); const [resultKey, setResultKey] = React.useState(0); const [exportCfg, setExportCfg] = React.useState({ format: 'png', mode: 'original', width: '', height: '', preserveAspect: true }); // What the graph panel plots: the canvas on one side of the op being edited // (which side is the tool's call — see graph.histogram), plus the cache key // identifying it. Held on the page rather than inside the panel so the // histogram survives switching tools or reopening the drawer. const [graphStage, setGraphStage] = React.useState(null); const [graphMode, setGraphMode] = React.useState('rgb'); // Hold the preview on the unedited image, for an A/B against the edit stack. const [compare, setCompare] = React.useState(false); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); const [panelOpen, setPanelOpen] = React.useState(false); // Canvases and the pipeline cache are refs, not state: they are large, // mutable, and never rendered directly. const fullRef = React.useRef(null); // original, full resolution const workingRef = React.useRef(null); // downscaled preview source const scaleRef = React.useRef(1); // working / full const cacheRef = React.useRef(Ops.newCache()); const rafRef = React.useRef(null); const fileInputRef = React.useRef(null); // The draft previews in its real pipeline position: appended for a new edit, // substituted in place when re-editing an existing one (forced enabled so // you can see what you are editing). const effectiveOps = React.useMemo(() => { if (!draft) return ops; const entry = { uid: draft.editingUid || '__draft', id: draft.id, params: draft.params, enabled: true }; if (draft.editingUid) return ops.map((o) => (o.uid === draft.editingUid ? entry : o)); return ops.concat([entry]); }, [ops, draft]); // One pipeline run per frame at most, so a slider drag firing dozens of // events per second still only costs one replay per frame (and the pipeline // cache means that replay usually re-runs a single op). React.useEffect(() => { const src = workingRef.current; if (!src) { setResult(null); return; } if (rafRef.current) rafRef.current(); rafRef.current = scheduleFrame(() => { rafRef.current = null; try { const out = Ops.runPipeline(src, effectiveOps, { scale: scaleRef.current, cache: cacheRef.current }); setResult(out); setResultKey((k) => k + 1); // Resolved in the same frame, because it reads the prefix cache that // runPipeline has just filled — a sibling effect would race it. A tool // whose graph draws no curve plots what leaves it instead of what // enters it, so its histogram tracks the slider. const graph = draft && Ops.byId[draft.id] ? Ops.byId[draft.id].graph : null; const stageOf = graph && graph.histogram === 'output' ? Ops.stageOutput : Ops.stageInput; setGraphStage(draft ? stageOf(src, effectiveOps, draft.editingUid || '__draft', { scale: scaleRef.current, cache: cacheRef.current }) : null); } catch (e) { setError('Rendering failed: ' + e.message); } }); return () => { if (rafRef.current) { rafRef.current(); rafRef.current = null; } }; }, [effectiveOps, sourceKey, draft]); // ---- image intake ------------------------------------------------------ const handleFile = (file) => { if (!file) return; if (!/^image\//.test(file.type)) { setError('That file is not an image (' + (file.type || 'unknown type') + ').'); return; } setError(null); loadImageFile(file).then((img) => { const full = Ops.makeCanvas(img.naturalWidth, img.naturalHeight); full.getContext('2d').drawImage(img, 0, 0); const scale = Math.min(1, WORKING_MAX / Math.max(full.width, full.height)); fullRef.current = full; workingRef.current = scale < 1 ? Ops.resizeCanvas(full, full.width * scale, full.height * scale) : full; scaleRef.current = workingRef.current.width / full.width; cacheRef.current = Ops.newCache(); setOps([]); setDraft(null); setResult(null); setCompare(false); setView({ zoom: 'fit', panX: 0, panY: 0 }); setExportCfg((c) => Object.assign({}, c, { mode: 'original', width: '', height: '' })); setSource({ name: file.name, width: full.width, height: full.height }); setSourceKey((k) => k + 1); }).catch((e) => setError(e.message)); }; // ---- edit stack -------------------------------------------------------- const pickTool = (id) => { const tool = Ops.byId[id]; if (!tool) return; setDraft({ id: id, params: Object.assign({}, tool.defaultParams), editingUid: null }); }; const setParam = (key, value) => { setDraft((d) => (d ? Object.assign({}, d, { params: Object.assign({}, d.params, { [key]: value }) }) : d)); }; const applyDraft = () => { if (!draft) return; if (draft.editingUid) { setOps((list) => list.map((o) => ( o.uid === draft.editingUid ? Object.assign({}, o, { params: draft.params }) : o ))); } else { const entry = Ops.createOp(draft.id, draft.params); if (entry) setOps((list) => list.concat([entry])); } // Deliberately stays on the Edit tab: applying one adjustment is usually // the middle of a run of them, and bouncing to History cost a click back // every time. The History tab's count is the feedback that it landed. setDraft(null); }; const resetDraft = () => { setDraft((d) => (d ? Object.assign({}, d, { params: Object.assign({}, Ops.byId[d.id].defaultParams) }) : d)); }; const editOp = (uid) => { const op = ops.find((o) => o.uid === uid); if (!op) return; setDraft({ id: op.id, params: Object.assign({}, op.params), editingUid: uid }); setTab('edit'); }; const toggleOp = (uid) => { setOps((list) => list.map((o) => (o.uid === uid ? Object.assign({}, o, { enabled: !o.enabled }) : o))); }; const removeOp = (uid) => { setOps((list) => list.filter((o) => o.uid !== uid)); setDraft((d) => (d && d.editingUid === uid ? null : d)); }; const reorderOps = (from, to) => { setOps((list) => { const next = list.slice(); const [moved] = next.splice(from, 1); next.splice(to, 0, moved); return next; }); }; const clearOps = () => { setOps([]); setDraft(null); }; // ---- export ------------------------------------------------------------ const outputDims = React.useMemo(() => ( source ? Ops.outputSizeFor(source.width, source.height, ops) : null ), [source, ops]); const exportDims = React.useMemo(() => { if (!outputDims) return null; if (exportCfg.mode !== 'custom') return outputDims; const w = parseInt(exportCfg.width, 10); const h = parseInt(exportCfg.height, 10); const ratio = outputDims.width / outputDims.height; if (exportCfg.preserveAspect) { if (w > 0) return { width: w, height: Math.max(1, Math.round(w / ratio)) }; if (h > 0) return { width: Math.max(1, Math.round(h * ratio)), height: h }; return outputDims; } return { width: w > 0 ? w : outputDims.width, height: h > 0 ? h : outputDims.height }; }, [outputDims, exportCfg]); const runExport = () => { if (!fullRef.current || !exportDims) return; setError(null); setBusy(true); // Yield first so the busy state commits before the (synchronous) // full-resolution replay blocks the thread. scheduleFrame(() => { try { const rendered = Ops.runPipeline(fullRef.current, ops, { scale: 1 }); const isJpeg = exportCfg.format === 'jpeg'; const needsResize = exportDims.width !== rendered.width || exportDims.height !== rendered.height; let out = rendered; if (needsResize || isJpeg) { out = Ops.makeCanvas(exportDims.width, exportDims.height); const ctx = out.getContext('2d'); // JPEG has no alpha channel — flatten onto white rather than // black. Deliberately a literal, NOT a token: this is pixel data // in the exported file, so it must not follow the site theme. if (isJpeg) { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, out.width, out.height); } ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(rendered, 0, 0, out.width, out.height); } const base = (source.name || 'image').replace(/\.[^.]+$/, ''); out.toBlob((blob) => { setBusy(false); if (!blob) { setError('Export failed — the browser could not encode that format.'); return; } const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = base + '-edited.' + EXT[exportCfg.format]; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); }, MIME[exportCfg.format], exportCfg.format === 'png' ? undefined : QUALITY); } catch (e) { setBusy(false); setError('Export failed: ' + e.message); } }); }; // ---- sidebar ----------------------------------------------------------- const enabledCount = ops.filter((o) => o.enabled).length; const draftIsNoop = !!draft && !draft.editingUid && Ops.isNoop({ id: draft.id, params: draft.params }); // Non-null only while a tool is selected; its `graph` block decides whether // the histogram/curve panel appears under the controls. const draftTool = draft ? Ops.byId[draft.id] : null; const graphPanel = draftTool && draftTool.graph && graphStage ? ( ) : null; const sidebar = ( setTab('edit') }, { value: 'history', label: 'History' + (ops.length ? ' (' + ops.length + ')' : ''), active: tab === 'history', onClick: () => setTab('history') } ]} /> {tab === 'edit' ? ( setDraft(null)} onReset={resetDraft} /> ) : ( {ops.length ? ( Remove all edits ) : null} )} ); // ---- layout ------------------------------------------------------------ const zoomControl = ( setView({ zoom: 'fit', panX: 0, panY: 0 }) }, { value: '1', label: '100%', active: view.zoom === 1, onClick: () => setView({ zoom: 1, panX: 0, panY: 0 }) } ]} /> ); return ( {/* Header */} {/* Top divider */} {source ? ( ) : null} {error ? ( setError(null)}> {error} ) : null} {/* preview + export */} Preview {zoomControl} {source ? ( setCompare((c) => !c)} title={compare ? 'Back to the edited image' : 'Show the original image'} sx={{ ...DS.secondaryBtnSx(T), py: '7px', ...(compare ? { color: T.bg, backgroundColor: T.accent, border: '1px solid ' + T.accent, '&:hover': { backgroundColor: T.accent, border: '1px solid ' + T.accent } } : null) }} > Compare ) : null} fileInputRef.current && fileInputRef.current.click()} sx={{ ...DS.secondaryBtnSx(T), py: '7px' }} > {source ? 'Replace image' : 'Open image'} { const file = e.target.files && e.target.files[0]; e.target.value = ''; handleFile(file); }} /> {source ? null : } {source ? ( Preview renders at {workingRef.current ? workingRef.current.width : 0} px wide; export replays every edit at full resolution. Drop another image here to replace it. ) : null} setExportCfg((c) => Object.assign({}, c, { [key]: value }))} onExport={runExport} outputDims={exportDims} busy={busy} disabled={!source} /> {/* editing controls — right column on desktop, overlay panel on mobile */} {isMdUp ? ( {sidebar} ) : null} {!isMdUp ? ( setPanelOpen(true)} sx={{ position: 'fixed', right: '16px', bottom: '16px', zIndex: 1200, fontFamily: DS.ARCHIVO, fontSize: '13px', fontWeight: 800, letterSpacing: '.04em', textTransform: 'uppercase', padding: '12px 18px', border: 'none', borderRadius: 0, cursor: 'pointer', color: T.bg, background: T.accent }} > Edits{ops.length ? ' · ' + ops.length : ''} setPanelOpen(false)} ModalProps={{ keepMounted: true }} PaperProps={{ sx: { width: 320, maxWidth: '88vw', background: T.card, border: 'none', borderLeft: '1px solid ' + T.divider, padding: '16px' } }} > Editing controls setPanelOpen(false)} sx={{ ...DS.secondaryBtnSx(T), px: '10px', py: '4px' }} > Close {sidebar} ) : null} ); }; window.ImageEditorPage = ImageEditorPage; })();