// Image Editor — presentational components. // // Layout and state live in jsx/ImageEditorPage.jsx; the image ops live in // javascript/image_editor_ops.js. Everything here is driven off the op // descriptors, so adding a tool needs no change in this file. // // 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, Slider, TextField, Checkbox, FormControlLabel, CircularProgress } = MaterialUI; const ROW_GAP = 8; // px between history rows — also the drag step const CHECKER = 9; // checkerboard square size in CSS px // Static styling for mapped sibling elements lives in a stylesheet rather than // `sx`: the vendored emotion UMD build's class dedup mis-assigns dynamic sx // values across mapped siblings (see the segmented-control gotcha in // docs/design_style_guide.md). Per-item dynamic values go on inline styles. if (!document.getElementById('image-editor-css')) { const style = document.createElement('style'); style.id = 'image-editor-css'; style.textContent = [ '.pe-btn {', ' font-family: Archivo, Roboto, sans-serif; font-size: 12px; font-weight: 800;', ' letter-spacing: .02em; line-height: 1.2; padding: 7px 10px; border-radius: 0;', ' cursor: pointer; white-space: nowrap; transition: background 0.12s ease;', '}', '.pe-btn:disabled { cursor: default; opacity: 0.45; }', '.pe-icon {', ' font-family: Archivo, Roboto, sans-serif; font-size: 12px; line-height: 1;', ' width: 24px; height: 24px; display: inline-flex; align-items: center;', ' justify-content: center; border-radius: 0; cursor: pointer; padding: 0;', ' transition: background 0.12s ease;', '}', '.pe-row { display: flex; align-items: center; gap: 8px; padding: 8px 10px; }', '.pe-grip {', ' cursor: grab; touch-action: none; user-select: none; font-size: 14px;', ' line-height: 1; padding: 4px 2px; background: none; border: none;', '}', '.pe-grip:active { cursor: grabbing; }', // The graph canvas swallows touch scrolling so a curve drag isn't read as // a page scroll or as the mobile sidebar's close gesture. '.pe-graph { display: block; width: 100%; touch-action: none; user-select: none; }' ].join('\n'); document.head.appendChild(style); } const primaryBtnStyle = (T) => ({ background: T.accent, color: T.bg, border: '1px solid ' + T.accent }); const ghostBtnStyle = (T) => ({ background: 'transparent', color: T.text, border: '1px solid ' + T.divider }); // ---- preview ------------------------------------------------------------- // Checkerboard tile for transparent areas, rebuilt per theme. This is UI // chrome, not image data, so both squares are derived from the card and ink // tokens rather than hand-picked greys: the tile stays a neutral surface // with just enough contrast to read as "nothing here" in either mode. const makeChecker = (T) => { const DS = window.DesignSystem; const lift = (amount) => DS.blend(T.card, T.text, amount); const c = document.createElement('canvas'); c.width = CHECKER * 2; c.height = CHECKER * 2; const ctx = c.getContext('2d'); ctx.fillStyle = lift(T.darkMode ? 0.18 : 0); ctx.fillRect(0, 0, c.width, c.height); ctx.fillStyle = lift(T.darkMode ? 0.13 : 0.11); ctx.fillRect(0, 0, CHECKER, CHECKER); ctx.fillRect(CHECKER, CHECKER, CHECKER, CHECKER); return c; }; // Large image preview. The visible canvas is sized to its container (via // ResizeObserver, the pattern lifted from PlantsPage) and the pipeline result // is drawn into a rect derived from `view` — so wheel-zoom / drag-pan later is // a matter of writing `view`, with no change to the draw path. const PreviewStage = ({ T, darkMode, result, resultKey, view, badge, onFile, children }) => { const wrapRef = React.useRef(null); const canvasRef = React.useRef(null); const [size, setSize] = React.useState({ w: 0, h: 0 }); const [hot, setHot] = React.useState(false); const checker = React.useMemo(() => makeChecker(T), [T.card, T.text, T.darkMode]); React.useLayoutEffect(() => { const el = wrapRef.current; if (!el) return; const update = () => { const r = el.getBoundingClientRect(); const w = Math.floor(r.width); const h = Math.floor(r.height); setSize((prev) => (prev.w === w && prev.h === h ? prev : { w: w, h: h })); }; update(); const ro = new ResizeObserver(update); ro.observe(el); return () => ro.disconnect(); }, []); React.useEffect(() => { const canvas = canvasRef.current; if (!canvas || size.w <= 0 || size.h <= 0) return; const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.max(1, Math.round(size.w * dpr)); canvas.height = Math.max(1, Math.round(size.h * dpr)); const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, size.w, size.h); if (!result) return; const fit = Math.min(1, size.w / result.width, size.h / result.height); const zoom = view && view.zoom !== 'fit' ? view.zoom : fit; const dw = result.width * zoom; const dh = result.height * zoom; const dx = Math.round((size.w - dw) / 2 + ((view && view.panX) || 0)); const dy = Math.round((size.h - dh) / 2 + ((view && view.panY) || 0)); // Checkerboard only behind the image itself, anchored to its top-left so // it travels with the image once panning exists. ctx.save(); ctx.beginPath(); ctx.rect(dx, dy, dw, dh); ctx.clip(); ctx.translate(dx, dy); ctx.fillStyle = ctx.createPattern(checker, 'repeat'); ctx.fillRect(0, 0, dw, dh); ctx.restore(); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(result, dx, dy, dw, dh); }, [result, resultKey, size, view, checker]); const handleDrop = (e) => { e.preventDefault(); setHot(false); const file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; if (file && onFile) onFile(file); }; return ( { e.preventDefault(); setHot(true); }} onDragLeave={() => setHot(false)} sx={{ position: 'relative', background: T.surface, border: '1px solid ' + (hot ? T.accent : T.divider), minHeight: { xs: '48vh', md: '58vh' }, height: { xs: '48vh', md: '58vh' }, overflow: 'hidden' }} > {badge ? ( {badge} ) : null} {children ? ( {children} ) : null} ); }; // Empty-state / replace-image target. const DropZone = ({ T, onFile, label, hint }) => { const inputRef = React.useRef(null); return ( inputRef.current && inputRef.current.click()} sx={{ border: '2px dashed ' + T.divider, padding: '32px 28px', textAlign: 'center', cursor: 'pointer', background: T.card, maxWidth: 420, '&:hover': { borderColor: T.accent } }} > { const file = e.target.files && e.target.files[0]; e.target.value = ''; if (file && onFile) onFile(file); }} /> 🖼 {label || 'Drop an image here'} {hint || 'or click to browse — PNG, JPEG, WebP, GIF'} ); }; // ---- parameter controls (schema-driven) ---------------------------------- const ParamControls = ({ T, DS, schema, params, onChange }) => ( {schema.map((s) => { const value = params[s.key]; // Curve params have no control here: the graph panel at the bottom of // the sidebar is their editor. if (s.type === 'curve') return null; if (s.type === 'segmented') { return ( {s.label} ({ value: String(o.value), label: o.label, active: String(value) === String(o.value), onClick: () => onChange(s.key, o.value) }))} /> ); } return ( {s.label} {value}{s.unit || ''} onChange(s.key, v)} // Inset by half a thumb: a thumb parked at min or max otherwise // hangs past the track and gives the sidebar a scrollbar. sx={{ marginTop: '2px', marginX: '7px', width: 'calc(100% - 14px)' }} /> ); })} ); // ---- edit tab ------------------------------------------------------------ // `graph` is the histogram/curve panel for the selected tool, supplied by the // page. It renders below the parameters and above the commit buttons: for a // curve tool the panel *is* the control, so Apply has to come after it. const EditTab = ({ T, DS, Ops, draft, disabled, applyDisabled, graph, onPick, onParam, onApply, onCancel, onReset }) => { const tool = draft ? Ops.byId[draft.id] : null; return ( {Ops.categories.map((group) => ( {group.name} {group.tools.map((t) => { const active = !!draft && draft.id === t.id; return ( onPick(t.id)} style={active ? primaryBtnStyle(T) : ghostBtnStyle(T)} > {t.name} ); })} ))} {tool ? ( {tool.name} {draft.editingUid ? 'editing' : 'pending'} {graph} {draft.editingUid ? 'Save' : 'Apply'} Cancel Reset Adjustments preview live. Nothing is committed to the edit stack until you press {draft.editingUid ? 'Save' : 'Apply'}. ) : ( {disabled ? 'Load an image to start editing.' : 'Pick a tool to add an edit. It previews live and joins the stack on Apply.'} )} ); }; // ---- history tab --------------------------------------------------------- // Reordering is hand-rolled on pointer events rather than HTML5 drag-and-drop: // `draggable` has no touch support on mobile browsers, and there is no DnD // library available (no bundler — everything is CDN UMD). const HistoryTab = ({ T, DS, Ops, ops, editingUid, onReorder, onToggle, onRemove, onEdit }) => { const listRef = React.useRef(null); const dragRef = React.useRef(null); const [drag, setDrag] = React.useState(null); const startDrag = (e, index) => { if (e.button != null && e.button !== 0) return; const list = listRef.current; if (!list || ops.length < 2) return; e.preventDefault(); const rows = Array.from(list.children); const step = rows[index].getBoundingClientRect().height + ROW_GAP; try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) { /* older browsers */ } dragRef.current = { from: index, to: index, dy: 0, startY: e.clientY, step: step, count: rows.length }; setDrag(Object.assign({}, dragRef.current)); }; const moveDrag = (e) => { const d = dragRef.current; if (!d) return; d.dy = e.clientY - d.startY; d.to = Math.max(0, Math.min(d.count - 1, d.from + Math.round(d.dy / d.step))); setDrag(Object.assign({}, d)); }; const endDrag = () => { const d = dragRef.current; dragRef.current = null; setDrag(null); if (d && d.to !== d.from) onReorder(d.from, d.to); }; if (!ops.length) { return ( No edits yet. Applied edits appear here and can be reordered, disabled, re-opened or removed — the image is always regenerated from the original. ); } return ( {ops.map((op, i) => { const tool = Ops.byId[op.id]; const dragging = !!drag && drag.from === i; let shift = 0; if (drag && !dragging) { if (drag.from < drag.to && i > drag.from && i <= drag.to) shift = -1; else if (drag.from > drag.to && i >= drag.to && i < drag.from) shift = 1; } const rowStyle = { background: T.card, border: '1px solid ' + (dragging || editingUid === op.uid ? T.accent : T.divider), marginBottom: ROW_GAP + 'px', position: 'relative', zIndex: dragging ? 2 : 1, transform: dragging ? 'translateY(' + drag.dy + 'px)' : (shift ? 'translateY(' + (shift * drag.step) + 'px)' : 'none'), transition: dragging ? 'none' : 'transform 0.12s ease', opacity: dragging ? 0.92 : 1 }; return ( startDrag(e, i)} onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={endDrag} style={{ color: T.mix(0.5) }} > ⠿ onToggle(op.uid)} style={{ border: '1px solid ' + T.divider, background: 'transparent', flex: 'none' }} > {i + 1} {tool ? tool.name : op.id} {Ops.summarize(op)} onEdit(op.uid)} style={{ border: '1px solid ' + T.divider, background: 'transparent', color: T.text, flex: 'none' }} > ✎ onRemove(op.uid)} style={{ border: '1px solid ' + T.divider, background: 'transparent', color: T.text, flex: 'none' }} > ✕ ); })} Drag the handle to reorder. The preview replays the whole stack from the original image after every change. ); }; // ---- export -------------------------------------------------------------- const FORMATS = [ { value: 'png', label: 'PNG' }, { value: 'jpeg', label: 'JPEG' }, { value: 'webp', label: 'WebP' } ]; // `cfg` is a flat bag so later settings (quality, metadata, color profile) // are one field plus one control. const ExportPanel = ({ T, DS, cfg, onChange, onExport, outputDims, busy, disabled }) => ( Format ({ value: f.value, label: f.label, active: cfg.format === f.value, onClick: () => onChange('format', f.value) }))} /> Resolution onChange('mode', 'original') }, { value: 'custom', label: 'Custom', active: cfg.mode === 'custom', onClick: () => onChange('mode', 'custom') } ]} /> {cfg.mode === 'custom' ? ( onChange('width', e.target.value)} sx={{ width: 110 }} /> onChange('height', e.target.value)} disabled={cfg.preserveAspect && cfg.width !== ''} sx={{ width: 110 }} /> onChange('preserveAspect', e.target.checked)} /> } label={Preserve aspect ratio} /> ) : null} {busy ? : null} {busy ? 'Exporting…' : 'Export image'} ); window.ImageEditorParts = { PreviewStage: PreviewStage, DropZone: DropZone, ParamControls: ParamControls, EditTab: EditTab, HistoryTab: HistoryTab, ExportPanel: ExportPanel }; })();