Add contribution page and Docker deployment
This commit is contained in:
@@ -62,6 +62,14 @@ const FORMAT_OPTIONS = [
|
||||
{ label: "WebP", value: "image/webp" },
|
||||
];
|
||||
|
||||
// On small screens the control panels collapse into a tabbed bottom dock.
|
||||
type MobileTab = "style" | "layers" | "export";
|
||||
const MOBILE_TABS: { id: MobileTab; label: string }[] = [
|
||||
{ id: "style", label: "Style" },
|
||||
{ id: "layers", label: "Layers" },
|
||||
{ id: "export", label: "Export" },
|
||||
];
|
||||
|
||||
function objectType(object: FabricObject | undefined): string {
|
||||
if (!object) return "None";
|
||||
if (object.type === "textbox" || object.type === "i-text") return "Text";
|
||||
@@ -101,6 +109,9 @@ export default function ImageEditorUi({
|
||||
const sourceFileRef = useRef<File | undefined>(undefined);
|
||||
const isPanningRef = useRef(false);
|
||||
const panStartRef = useRef({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 });
|
||||
// Once the user zooms manually we stop auto-fitting on every resize,
|
||||
// otherwise mobile viewport reflows keep snapping the zoom back to "fit".
|
||||
const userZoomedRef = useRef(false);
|
||||
const { ref: workspaceRef, size: workspaceSize } = useElementSize<HTMLDivElement>();
|
||||
|
||||
const [sourceFile, setSourceFile] = useState<File | undefined>();
|
||||
@@ -117,6 +128,7 @@ export default function ImageEditorUi({
|
||||
useState<ImageEditorOptions["format"]>("image/png");
|
||||
const [quality, setQuality] = useState(92);
|
||||
const [fontFamily, setFontFamily] = useState(FONT_OPTIONS[0].value);
|
||||
const [mobileTab, setMobileTab] = useState<MobileTab>("style");
|
||||
const [state, setState] = useState<EditorState>({
|
||||
selectedType: "None",
|
||||
fill: "#ffffff",
|
||||
@@ -221,16 +233,22 @@ export default function ImageEditorUi({
|
||||
const fitCanvasToWorkspace = useCallback(() => {
|
||||
if (workspaceSize.width === 0 || workspaceSize.height === 0) return;
|
||||
|
||||
// Fit re-establishes the auto-fit baseline (resets manual-zoom lock).
|
||||
userZoomedRef.current = false;
|
||||
|
||||
const margin = workspaceSize.width < 480 ? 24 : 56;
|
||||
const zoom = Math.max(0.12, Math.min(
|
||||
1,
|
||||
(workspaceSize.width - 56) / canvasSize.width,
|
||||
(workspaceSize.height - 56) / canvasSize.height
|
||||
(workspaceSize.width - margin) / canvasSize.width,
|
||||
(workspaceSize.height - margin) / canvasSize.height
|
||||
));
|
||||
|
||||
applyDisplayZoom(zoom);
|
||||
}, [applyDisplayZoom, canvasSize.height, canvasSize.width, workspaceSize.height, workspaceSize.width]);
|
||||
|
||||
const setCanvasZoom = useCallback((zoom: number) => {
|
||||
// Manual zoom — stop auto-fit from overriding the user on resize.
|
||||
userZoomedRef.current = true;
|
||||
applyDisplayZoom(zoom);
|
||||
}, [applyDisplayZoom]);
|
||||
|
||||
@@ -273,6 +291,8 @@ export default function ImageEditorUi({
|
||||
}, [pushHistory, updateActiveState]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only auto-fit while the user hasn't taken manual control of the zoom.
|
||||
if (userZoomedRef.current) return;
|
||||
fitCanvasToWorkspace();
|
||||
}, [fitCanvasToWorkspace]);
|
||||
|
||||
@@ -328,10 +348,13 @@ export default function ImageEditorUi({
|
||||
pushHistory();
|
||||
refreshHistoryControls();
|
||||
refreshLayerRows();
|
||||
// New image — fit it fresh and re-enable auto-fit.
|
||||
userZoomedRef.current = false;
|
||||
const margin = workspaceSize.width > 0 && workspaceSize.width < 480 ? 24 : 56;
|
||||
const zoom = Math.max(0.12, Math.min(
|
||||
1,
|
||||
workspaceSize.width > 0 ? (workspaceSize.width - 56) / width : 1,
|
||||
workspaceSize.height > 0 ? (workspaceSize.height - 56) / height : 1
|
||||
workspaceSize.width > 0 ? (workspaceSize.width - margin) / width : 1,
|
||||
workspaceSize.height > 0 ? (workspaceSize.height - margin) / height : 1
|
||||
));
|
||||
applyDisplayZoom(zoom, { width, height });
|
||||
} finally {
|
||||
@@ -544,9 +567,165 @@ export default function ImageEditorUi({
|
||||
setCanvasZoom(state.zoom + delta);
|
||||
}, [setCanvasZoom, state.zoom]);
|
||||
|
||||
// Control panels are declared once and placed either in the desktop
|
||||
// sidebar or the mobile tabbed dock — the single <canvas> is never cloned.
|
||||
const propertiesPanel = (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
|
||||
properties
|
||||
</div>
|
||||
<div className="rounded-[12px] border border-[var(--p-border)] bg-[var(--p-surface)] p-3">
|
||||
<div className="mb-3 font-sans text-sm font-semibold text-[var(--p-text)]">
|
||||
{state.selectedType}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="flex flex-col gap-1 font-sans text-[12px] text-[var(--p-muted)]">
|
||||
Fill
|
||||
<input
|
||||
type="color"
|
||||
value={state.fill}
|
||||
onChange={(event) => {
|
||||
setState((prev) => ({ ...prev, fill: event.target.value }));
|
||||
applyToActive({ fill: event.target.value });
|
||||
}}
|
||||
className="h-9 w-full rounded border border-[var(--p-border)] bg-transparent"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 font-sans text-[12px] text-[var(--p-muted)]">
|
||||
Stroke / text
|
||||
<input
|
||||
type="color"
|
||||
value={state.stroke}
|
||||
onChange={(event) => {
|
||||
setState((prev) => ({ ...prev, stroke: event.target.value }));
|
||||
applyToActive({ stroke: event.target.value, fill: activeObject?.type === "textbox" ? event.target.value : activeObject?.get("fill") });
|
||||
}}
|
||||
className="h-9 w-full rounded border border-[var(--p-border)] bg-transparent"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<Select
|
||||
label="Font"
|
||||
value={fontFamily}
|
||||
options={FONT_OPTIONS}
|
||||
onChange={(event) => {
|
||||
setFontFamily(event.target.value);
|
||||
applyToActive({ fontFamily: event.target.value });
|
||||
}}
|
||||
/>
|
||||
<Slider
|
||||
label="Text size"
|
||||
min={12}
|
||||
max={160}
|
||||
value={state.fontSize}
|
||||
onChange={(event) => {
|
||||
const fontSize = Number(event.target.value);
|
||||
setState((prev) => ({ ...prev, fontSize }));
|
||||
applyToActive({ fontSize });
|
||||
}}
|
||||
/>
|
||||
<Slider
|
||||
label="Brush"
|
||||
min={1}
|
||||
max={48}
|
||||
value={state.brushWidth}
|
||||
onChange={(event) => {
|
||||
setState((prev) => ({ ...prev, brushWidth: Number(event.target.value) }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
const arrangePanel = (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
|
||||
arrange
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="secondary" onClick={duplicateActive} disabled={!activeObject}>Duplicate</Button>
|
||||
<Button variant="danger" onClick={deleteActive} disabled={!activeObject}>Delete</Button>
|
||||
<Button variant="secondary" onClick={() => moveLayer("forward")} disabled={!activeObject}>Forward</Button>
|
||||
<Button variant="secondary" onClick={() => moveLayer("backward")} disabled={!activeObject}>Backward</Button>
|
||||
<Button variant="secondary" onClick={() => loadHistory(historyIndexRef.current - 1)} disabled={!canUndo}>Undo</Button>
|
||||
<Button variant="secondary" onClick={() => loadHistory(historyIndexRef.current + 1)} disabled={!canRedo}>Redo</Button>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={clearObjects}>
|
||||
Clear editable objects
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
|
||||
const layersPanel = (
|
||||
<section className="flex min-h-0 flex-col gap-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
|
||||
layers
|
||||
</div>
|
||||
<div className="max-h-[160px] overflow-y-auto rounded-[12px] border border-[var(--p-border)] bg-[var(--p-surface)] p-2">
|
||||
{layerRows.length === 0 ? (
|
||||
<div className="px-2 py-4 text-center text-sm text-[var(--p-muted)]">
|
||||
No editable layers yet
|
||||
</div>
|
||||
) : (
|
||||
layerRows.map((row) => (
|
||||
<button
|
||||
key={row.id}
|
||||
onClick={() => {
|
||||
canvasRef.current?.setActiveObject(row.object);
|
||||
canvasRef.current?.requestRenderAll();
|
||||
updateActiveState();
|
||||
}}
|
||||
className={`mb-1 w-full rounded-lg px-3 py-2 text-left font-sans text-[13px] ${
|
||||
activeObject === row.object
|
||||
? "bg-[var(--p-accent)] text-[var(--p-accent-ink)]"
|
||||
: "bg-[var(--p-chip)] text-[var(--p-text)]"
|
||||
}`}
|
||||
>
|
||||
{row.label}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
const exportPanel = (
|
||||
<section className="flex flex-col gap-3 lg:mt-auto">
|
||||
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
|
||||
export
|
||||
</div>
|
||||
<Select
|
||||
label="Format"
|
||||
value={exportFormat}
|
||||
options={FORMAT_OPTIONS}
|
||||
onChange={(event) => setExportFormat(event.target.value as ImageEditorOptions["format"])}
|
||||
/>
|
||||
<Slider
|
||||
label="Quality"
|
||||
min={10}
|
||||
max={100}
|
||||
value={quality}
|
||||
onChange={(event) => setQuality(Number(event.target.value))}
|
||||
/>
|
||||
<Button onClick={exportImage} disabled={!canvasReady || isExecuting}>
|
||||
{isExecuting ? "Exporting..." : "Export image"}
|
||||
</Button>
|
||||
{sourceFile && (
|
||||
<div className="truncate font-mono text-[10px] text-[var(--p-muted)]">
|
||||
source: {sourceFile.name}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="text-sm text-red-700">{error}</div>}
|
||||
{result && <ToolResultPanel result={result} />}
|
||||
</section>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[620px] flex-col bg-[var(--p-surface)]">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-[var(--p-border)] px-4 py-3">
|
||||
<div className="flex h-full min-h-[460px] flex-col bg-[var(--p-surface)]">
|
||||
<div className="flex shrink-0 flex-col gap-2 border-b border-[var(--p-border)] px-3 py-2.5 lg:flex-row lg:flex-wrap lg:items-center lg:gap-2 lg:px-4 lg:py-3">
|
||||
<Dropzone
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
multiple={false}
|
||||
@@ -554,55 +733,58 @@ export default function ImageEditorUi({
|
||||
onFilesDrop={(files) => {
|
||||
if (files[0]) void loadImageFile(files[0]);
|
||||
}}
|
||||
className="min-h-0 w-full p-3 sm:w-[260px]"
|
||||
className="min-h-0 w-full p-2.5 sm:w-[240px]"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex gap-1.5 overflow-x-auto no-scrollbar -mx-3 px-3 lg:mx-0 lg:px-0 lg:flex-wrap lg:overflow-visible">
|
||||
<Button
|
||||
className="shrink-0"
|
||||
variant={state.mode === "select" ? "primary" : "secondary"}
|
||||
onClick={() => setState((prev) => ({ ...prev, mode: "select" }))}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
variant={state.mode === "draw" ? "primary" : "secondary"}
|
||||
onClick={() => setState((prev) => ({ ...prev, mode: "draw" }))}
|
||||
>
|
||||
Draw
|
||||
</Button>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
variant={state.mode === "pan" ? "primary" : "secondary"}
|
||||
onClick={() => setState((prev) => ({ ...prev, mode: "pan" }))}
|
||||
>
|
||||
Pan
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={addText}>Text</Button>
|
||||
<Button variant="secondary" onClick={() => addShape("rect")}>Rect</Button>
|
||||
<Button variant="secondary" onClick={() => addShape("circle")}>Circle</Button>
|
||||
<Button variant="secondary" onClick={() => addShape("triangle")}>Triangle</Button>
|
||||
<Button variant="secondary" onClick={() => addShape("line")}>Line</Button>
|
||||
<Button className="shrink-0" variant="secondary" onClick={addText}>Text</Button>
|
||||
<Button className="shrink-0" variant="secondary" onClick={() => addShape("rect")}>Rect</Button>
|
||||
<Button className="shrink-0" variant="secondary" onClick={() => addShape("circle")}>Circle</Button>
|
||||
<Button className="shrink-0" variant="secondary" onClick={() => addShape("triangle")}>Triangle</Button>
|
||||
<Button className="shrink-0" variant="secondary" onClick={() => addShape("line")}>Line</Button>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={() => setCanvasZoom(state.zoom - 0.12)}>
|
||||
<div className="flex items-center gap-1.5 lg:ml-auto">
|
||||
<Button className="shrink-0" variant="secondary" onClick={() => setCanvasZoom(state.zoom - 0.12)}>
|
||||
-
|
||||
</Button>
|
||||
<button
|
||||
onClick={fitCanvasToWorkspace}
|
||||
className="rounded-[10px] border border-[var(--p-border)] bg-[var(--p-chip)] px-3 py-2 font-mono text-[12px] text-[var(--p-text)]"
|
||||
className="shrink-0 rounded-[10px] border border-[var(--p-border)] bg-[var(--p-chip)] px-3 py-2 font-mono text-[12px] text-[var(--p-text)]"
|
||||
>
|
||||
{Math.round(state.zoom * 100)}%
|
||||
</button>
|
||||
<Button variant="secondary" onClick={() => setCanvasZoom(state.zoom + 0.12)}>
|
||||
<Button className="shrink-0" variant="secondary" onClick={() => setCanvasZoom(state.zoom + 0.12)}>
|
||||
+
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setCanvasZoom(1)}>
|
||||
<Button className="shrink-0" variant="secondary" onClick={() => setCanvasZoom(1)}>
|
||||
100%
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_300px]">
|
||||
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||
<div
|
||||
ref={workspaceRef}
|
||||
onPointerDown={handleWorkspacePointerDown}
|
||||
@@ -610,7 +792,7 @@ export default function ImageEditorUi({
|
||||
onPointerUp={handleWorkspacePointerUp}
|
||||
onPointerCancel={handleWorkspacePointerUp}
|
||||
onWheel={handleWorkspaceWheel}
|
||||
className={`min-h-[520px] overflow-auto bg-[var(--p-bg)] p-6 ${
|
||||
className={`min-h-[200px] flex-1 overflow-auto bg-[var(--p-bg)] p-4 lg:min-h-0 lg:p-6 ${
|
||||
state.mode === "pan" ? "cursor-grab active:cursor-grabbing" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -627,152 +809,35 @@ export default function ImageEditorUi({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="flex min-h-0 flex-col gap-4 overflow-y-auto border-l border-[var(--p-border)] bg-[var(--p-surface-2)] p-4">
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
|
||||
properties
|
||||
</div>
|
||||
<div className="rounded-[12px] border border-[var(--p-border)] bg-[var(--p-surface)] p-3">
|
||||
<div className="mb-3 font-sans text-sm font-semibold text-[var(--p-text)]">
|
||||
{state.selectedType}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="flex flex-col gap-1 font-sans text-[12px] text-[var(--p-muted)]">
|
||||
Fill
|
||||
<input
|
||||
type="color"
|
||||
value={state.fill}
|
||||
onChange={(event) => {
|
||||
setState((prev) => ({ ...prev, fill: event.target.value }));
|
||||
applyToActive({ fill: event.target.value });
|
||||
}}
|
||||
className="h-9 w-full rounded border border-[var(--p-border)] bg-transparent"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 font-sans text-[12px] text-[var(--p-muted)]">
|
||||
Stroke / text
|
||||
<input
|
||||
type="color"
|
||||
value={state.stroke}
|
||||
onChange={(event) => {
|
||||
setState((prev) => ({ ...prev, stroke: event.target.value }));
|
||||
applyToActive({ stroke: event.target.value, fill: activeObject?.type === "textbox" ? event.target.value : activeObject?.get("fill") });
|
||||
}}
|
||||
className="h-9 w-full rounded border border-[var(--p-border)] bg-transparent"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<aside className="flex min-h-0 shrink-0 flex-col border-t border-[var(--p-border)] bg-[var(--p-surface-2)] lg:w-[300px] lg:border-l lg:border-t-0">
|
||||
<div className="flex gap-1 border-b border-[var(--p-border)] p-1.5 lg:hidden">
|
||||
{MOBILE_TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setMobileTab(t.id)}
|
||||
className={`flex-1 rounded-lg px-3 py-2 font-sans text-[13px] font-medium transition-colors ${
|
||||
mobileTab === t.id
|
||||
? "bg-[var(--p-accent)] text-[var(--p-accent-ink)]"
|
||||
: "text-[var(--p-muted)] hover:text-[var(--p-text)]"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<Select
|
||||
label="Font"
|
||||
value={fontFamily}
|
||||
options={FONT_OPTIONS}
|
||||
onChange={(event) => {
|
||||
setFontFamily(event.target.value);
|
||||
applyToActive({ fontFamily: event.target.value });
|
||||
}}
|
||||
/>
|
||||
<Slider
|
||||
label="Text size"
|
||||
min={12}
|
||||
max={160}
|
||||
value={state.fontSize}
|
||||
onChange={(event) => {
|
||||
const fontSize = Number(event.target.value);
|
||||
setState((prev) => ({ ...prev, fontSize }));
|
||||
applyToActive({ fontSize });
|
||||
}}
|
||||
/>
|
||||
<Slider
|
||||
label="Brush"
|
||||
min={1}
|
||||
max={48}
|
||||
value={state.brushWidth}
|
||||
onChange={(event) => {
|
||||
setState((prev) => ({ ...prev, brushWidth: Number(event.target.value) }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-0 max-h-[44vh] flex-col gap-4 overflow-y-auto p-4 pb-[max(1rem,env(safe-area-inset-bottom))] lg:max-h-none lg:flex-1 lg:pb-4">
|
||||
<div className={`${mobileTab === "style" ? "flex flex-col gap-4" : "hidden"} lg:contents`}>
|
||||
{propertiesPanel}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
|
||||
arrange
|
||||
<div className={`${mobileTab === "layers" ? "flex flex-col gap-4" : "hidden"} lg:contents`}>
|
||||
{arrangePanel}
|
||||
{layersPanel}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="secondary" onClick={duplicateActive} disabled={!activeObject}>Duplicate</Button>
|
||||
<Button variant="danger" onClick={deleteActive} disabled={!activeObject}>Delete</Button>
|
||||
<Button variant="secondary" onClick={() => moveLayer("forward")} disabled={!activeObject}>Forward</Button>
|
||||
<Button variant="secondary" onClick={() => moveLayer("backward")} disabled={!activeObject}>Backward</Button>
|
||||
<Button variant="secondary" onClick={() => loadHistory(historyIndexRef.current - 1)} disabled={!canUndo}>Undo</Button>
|
||||
<Button variant="secondary" onClick={() => loadHistory(historyIndexRef.current + 1)} disabled={!canRedo}>Redo</Button>
|
||||
<div className={`${mobileTab === "export" ? "flex flex-col gap-4" : "hidden"} lg:contents`}>
|
||||
{exportPanel}
|
||||
</div>
|
||||
<Button variant="secondary" onClick={clearObjects}>
|
||||
Clear editable objects
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-0 flex-col gap-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
|
||||
layers
|
||||
</div>
|
||||
<div className="max-h-[160px] overflow-y-auto rounded-[12px] border border-[var(--p-border)] bg-[var(--p-surface)] p-2">
|
||||
{layerRows.length === 0 ? (
|
||||
<div className="px-2 py-4 text-center text-sm text-[var(--p-muted)]">
|
||||
No editable layers yet
|
||||
</div>
|
||||
) : (
|
||||
layerRows.map((row) => (
|
||||
<button
|
||||
key={row.id}
|
||||
onClick={() => {
|
||||
canvasRef.current?.setActiveObject(row.object);
|
||||
canvasRef.current?.requestRenderAll();
|
||||
updateActiveState();
|
||||
}}
|
||||
className={`mb-1 w-full rounded-lg px-3 py-2 text-left font-sans text-[13px] ${
|
||||
activeObject === row.object
|
||||
? "bg-[var(--p-accent)] text-[var(--p-accent-ink)]"
|
||||
: "bg-[var(--p-chip)] text-[var(--p-text)]"
|
||||
}`}
|
||||
>
|
||||
{row.label}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-auto flex flex-col gap-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
|
||||
export
|
||||
</div>
|
||||
<Select
|
||||
label="Format"
|
||||
value={exportFormat}
|
||||
options={FORMAT_OPTIONS}
|
||||
onChange={(event) => setExportFormat(event.target.value as ImageEditorOptions["format"])}
|
||||
/>
|
||||
<Slider
|
||||
label="Quality"
|
||||
min={10}
|
||||
max={100}
|
||||
value={quality}
|
||||
onChange={(event) => setQuality(Number(event.target.value))}
|
||||
/>
|
||||
<Button onClick={exportImage} disabled={!canvasReady || isExecuting}>
|
||||
{isExecuting ? "Exporting..." : "Export image"}
|
||||
</Button>
|
||||
{sourceFile && (
|
||||
<div className="truncate font-mono text-[10px] text-[var(--p-muted)]">
|
||||
source: {sourceFile.name}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="text-sm text-red-700">{error}</div>}
|
||||
{result && <ToolResultPanel result={result} />}
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user