import { useEffect, useState, type Dispatch, type SetStateAction } from "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";
import type { TransformTarget } from "@editor/transform";
import type { DocumentReadIndex, IndexedLayerInfo } from "@editor/document-indexes";
import { addLayerMask, duplicateLayer } from "@operations/document/layerActions";
import { getLayerMask } from "@core/layer-mask-utils";
import { BottomControlDivider } from "./Divider";
import { bottomControlFieldClass, bottomControlIconSlotClass, bottomControlInputClass, bottomControlLabelClass, bottomControlMenuClass } from "./styles";
export type TransformControlsProps = {
bounds: Rect;
target: TransformTarget;
documentIndex: DocumentReadIndex;
layerInfo?: IndexedLayerInfo;
dispatch: AppStore["dispatch"];
};
type BoundsField = keyof Rect;
export function TransformControls({ bounds, target, documentIndex, layerInfo, dispatch }: TransformControlsProps) {
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;
const rotatedMaskEditingUnsupported = Boolean(layer && (layer.type === "image" || layer.type === "raster") && layer.transform.rotation !== 0);
useEffect(() => {
setDraft(draftFromBounds(bounds));
}, [bounds.x, bounds.y, bounds.w, bounds.h]);
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 === "image" || layer?.type === "raster" ? layer.sourceRect : undefined]);
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]);
if (!Number.isFinite(value)) {
setDraft(draftFromBounds(bounds));
return;
}
if (locked) return;
dispatch(commandIds.transformSetBounds, {
target,
bounds: {
...bounds,
[field]: field === "w" || field === "h" ? Math.max(1, value) : value,
},
});
};
return (
{layer ? (
<>
{
const value = Number.parseFloat(opacityDraft);
if (Number.isFinite(value)) dispatch(commandIds.documentSetLayerOpacity, { layerId: layer.id, opacity: value / 100 });
else setOpacityDraft(String(Math.round(layer.opacity * 100)));
}}
/>
{
const value = Number.parseFloat(rotationDraft);
if (Number.isFinite(value)) dispatch(commandIds.transformSetRotation, { target, rotation: value * Math.PI / 180 });
else setRotationDraft(rotationDegrees(layerInfo));
}}
/>
{layer.type === "image" || layer.type === "raster" ? (
) : null}
{layer.type === "image" || layer.type === "raster" ? (
) : null}
{locked ? Unlock to edit : null}
{cropOpen && (layer.type === "image" || layer.type === "raster") ? { 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" ? (
<>
{resizeOpen ? 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}
);
}
type CropDraft = Record;
function CropEditor({ draft, setDraft, onApply, onCancel, onReset }: { draft: CropDraft; setDraft: Dispatch>; onApply: () => void; onCancel: () => void; onReset: () => void }) {
return
{(["x", "y", "w", "h"] as const).map((field) => )}
;
}
function ArtboardResizeEditor({ draft, setDraft, onApply, onCancel }: { draft: { w: string; h: string; scaleContents: boolean }; setDraft: Dispatch>; onApply: () => void; onCancel: () => void }) {
return
{(["w", "h"] as const).map((field) => )}
Off changes canvas bounds only.
;
}
function cropDraftFor(layerInfo: IndexedLayerInfo | undefined, index: DocumentReadIndex): CropDraft {
if (!layerInfo || (layerInfo.layer.type !== "image" && layerInfo.layer.type !== "raster")) 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 (
);
}
function rotationDegrees(layerInfo?: IndexedLayerInfo) {
return String(Math.round((layerInfo?.layer.transform.rotation ?? 0) * 180 / Math.PI));
}
function actionButtonClass() {
return "inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-semibold text-white/65 transition hover:bg-white/[0.07] hover:text-white disabled:pointer-events-none disabled:opacity-35";
}
function BoundsInput({
label,
field,
draft,
disabled,
setDraft,
commitField,
}: {
label: string;
field: BoundsField;
draft: string;
disabled?: boolean;
setDraft: Dispatch>>;
commitField: (field: BoundsField) => void;
}) {
return (
);
}
function draftFromBounds(bounds: Rect): Record {
return {
x: String(Math.round(bounds.x)),
y: String(Math.round(bounds.y)),
w: String(Math.round(bounds.w)),
h: String(Math.round(bounds.h)),
};
}