feat: add crop and canvas resize workflows
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { BoundingBox, Copy, MaskHappy } from "@phosphor-icons/react";
|
||||
import { ArrowsOut, BoundingBox, Copy, Crop, MaskHappy } from "@phosphor-icons/react";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { Rect } from "@core/geometry";
|
||||
import type { AppStore } from "@editor/store";
|
||||
@@ -24,6 +24,10 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
|
||||
const [draft, setDraft] = useState(() => draftFromBounds(bounds));
|
||||
const [opacityDraft, setOpacityDraft] = useState(() => String(Math.round((layerInfo?.layer.opacity ?? 1) * 100)));
|
||||
const [rotationDraft, setRotationDraft] = useState(() => rotationDegrees(layerInfo));
|
||||
const [cropOpen, setCropOpen] = useState(false);
|
||||
const [cropDraft, setCropDraft] = useState(() => cropDraftFor(layerInfo, documentIndex));
|
||||
const [resizeOpen, setResizeOpen] = useState(false);
|
||||
const [resizeDraft, setResizeDraft] = useState(() => ({ w: String(Math.round(bounds.w)), h: String(Math.round(bounds.h)), scaleContents: false }));
|
||||
const layer = layerInfo?.layer;
|
||||
const locked = layer?.locked ?? false;
|
||||
const mask = layer ? getLayerMask(layer) : undefined;
|
||||
@@ -35,6 +39,8 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
|
||||
|
||||
useEffect(() => setOpacityDraft(String(Math.round((layer?.opacity ?? 1) * 100))), [layer?.id, layer?.opacity]);
|
||||
useEffect(() => setRotationDraft(rotationDegrees(layerInfo)), [layer?.id, layer?.transform.rotation]);
|
||||
useEffect(() => setCropDraft(cropDraftFor(layerInfo, documentIndex)), [documentIndex, layer?.id, layer?.type === "group" ? undefined : layer?.sourceRect]);
|
||||
useEffect(() => setResizeDraft((current) => ({ ...current, w: String(Math.round(bounds.w)), h: String(Math.round(bounds.h)) })), [bounds.w, bounds.h, target]);
|
||||
|
||||
const commitField = (field: BoundsField) => {
|
||||
const value = Number.parseFloat(draft[field]);
|
||||
@@ -96,6 +102,11 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
|
||||
<button type="button" className={actionButtonClass()} disabled={locked} title={locked ? "Unlock the layer to duplicate it" : "Duplicate layer"} onClick={() => duplicateLayer(documentIndex, layerInfo!, dispatch)}>
|
||||
<Copy size={20} /> Duplicate
|
||||
</button>
|
||||
{layer.type !== "group" ? (
|
||||
<button type="button" className={actionButtonClass()} disabled={locked || layer.transform.rotation !== 0} title={layer.transform.rotation !== 0 ? "Reset rotation before cropping" : "Crop visible source pixels"} onClick={() => setCropOpen((open) => !open)}>
|
||||
<Crop size={20} /> Crop
|
||||
</button>
|
||||
) : null}
|
||||
{layer.type !== "group" ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -111,12 +122,49 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
|
||||
</button>
|
||||
) : null}
|
||||
{locked ? <span className="text-xs text-amber-200/70">Unlock to edit</span> : null}
|
||||
{cropOpen && layer.type !== "group" ? <CropEditor draft={cropDraft} setDraft={setCropDraft} onCancel={() => { setCropDraft(cropDraftFor(layerInfo, documentIndex)); setCropOpen(false); }} onReset={() => { dispatch(commandIds.documentSetLayerSourceRect, { layerId: layer.id }); setCropOpen(false); }} onApply={() => { const sourceRect = parseRectDraft(cropDraft); if (sourceRect) { dispatch(commandIds.documentSetLayerSourceRect, { layerId: layer.id, sourceRect }); setCropOpen(false); } }} /> : null}
|
||||
</>
|
||||
) : target.type === "artboard" ? (
|
||||
<>
|
||||
<BottomControlDivider />
|
||||
<button type="button" className={actionButtonClass()} disabled={locked} onClick={() => setResizeOpen((open) => !open)}><ArrowsOut size={20} /> Resize canvas</button>
|
||||
{resizeOpen ? <ArtboardResizeEditor draft={resizeDraft} setDraft={setResizeDraft} onCancel={() => setResizeOpen(false)} onApply={() => { const w = Number.parseFloat(resizeDraft.w); const h = Number.parseFloat(resizeDraft.h); if (Number.isFinite(w) && Number.isFinite(h) && w >= 1 && h >= 1) { dispatch(commandIds.documentResizeArtboard, { id: target.id, bounds: { ...bounds, w, h }, scaleContents: resizeDraft.scaleContents }); setResizeOpen(false); } }} /> : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CropDraft = Record<BoundsField, string>;
|
||||
|
||||
function CropEditor({ draft, setDraft, onApply, onCancel, onReset }: { draft: CropDraft; setDraft: Dispatch<SetStateAction<CropDraft>>; onApply: () => void; onCancel: () => void; onReset: () => void }) {
|
||||
return <div className="flex items-center gap-2 rounded-2xl bg-black/25 px-3 py-2" aria-label="Crop source pixels">
|
||||
{(["x", "y", "w", "h"] as const).map((field) => <label key={field} className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>{field.toUpperCase()}</span><input className={bottomControlInputClass()} inputMode="decimal" value={draft[field]} aria-label={`Crop ${field}`} onChange={(event) => setDraft((current) => ({ ...current, [field]: event.target.value }))} onKeyDown={(event) => event.stopPropagation()} /></label>)}
|
||||
<button type="button" className={actionButtonClass()} onClick={onApply}>Apply</button><button type="button" className={actionButtonClass()} onClick={onCancel}>Cancel</button><button type="button" className={actionButtonClass()} onClick={onReset}>Reset</button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ArtboardResizeEditor({ draft, setDraft, onApply, onCancel }: { draft: { w: string; h: string; scaleContents: boolean }; setDraft: Dispatch<SetStateAction<{ w: string; h: string; scaleContents: boolean }>>; onApply: () => void; onCancel: () => void }) {
|
||||
return <div className="flex items-center gap-2 rounded-2xl bg-black/25 px-3 py-2" aria-label="Resize artboard canvas">
|
||||
{(["w", "h"] as const).map((field) => <label key={field} className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>{field.toUpperCase()}</span><input className={bottomControlInputClass()} inputMode="decimal" value={draft[field]} aria-label={`Canvas ${field}`} onChange={(event) => setDraft((current) => ({ ...current, [field]: event.target.value }))} onKeyDown={(event) => event.stopPropagation()} /></label>)}
|
||||
<label className="flex items-center gap-2 text-xs text-white/70"><input type="checkbox" checked={draft.scaleContents} onChange={(event) => setDraft((current) => ({ ...current, scaleContents: event.target.checked }))} />Scale contents</label>
|
||||
<span className="max-w-40 text-[10px] text-white/45">Off changes canvas bounds only.</span>
|
||||
<button type="button" className={actionButtonClass()} onClick={onApply}>Apply</button><button type="button" className={actionButtonClass()} onClick={onCancel}>Cancel</button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function cropDraftFor(layerInfo: IndexedLayerInfo | undefined, index: DocumentReadIndex): CropDraft {
|
||||
if (!layerInfo || layerInfo.layer.type === "group") return { x: "0", y: "0", w: "1", h: "1" };
|
||||
const asset = index.assetById.get(layerInfo.layer.assetId);
|
||||
const rect = layerInfo.layer.sourceRect ?? { x: 0, y: 0, w: asset?.intrinsicSize.w ?? 1, h: asset?.intrinsicSize.h ?? 1 };
|
||||
return draftFromBounds(rect);
|
||||
}
|
||||
|
||||
function parseRectDraft(draft: CropDraft): Rect | undefined {
|
||||
const rect = { x: Number.parseFloat(draft.x), y: Number.parseFloat(draft.y), w: Number.parseFloat(draft.w), h: Number.parseFloat(draft.h) };
|
||||
return Object.values(rect).every(Number.isFinite) && rect.w >= 1 && rect.h >= 1 ? rect : undefined;
|
||||
}
|
||||
|
||||
function SimpleInput({ label, suffix, value, disabled, title, onChange, onCommit }: { label: string; suffix: string; value: string; disabled?: boolean; title?: string; onChange: (value: string) => void; onCommit: () => void }) {
|
||||
return (
|
||||
<label className={`${bottomControlFieldClass()} ${disabled ? "opacity-40" : ""}`} title={title}>
|
||||
|
||||
Reference in New Issue
Block a user