// 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;
})();