- Adjusted button styles across various components for consistency and better UX. - Enhanced layout of action controls to utilize whitespace more effectively. - Updated slider styles for a more modern appearance and improved usability. - Refined input fields and labels for better accessibility and readability. - Introduced new app surface styles for a cohesive design across the application. - Added tests for canvas cursor behavior to ensure correct cursor display during operations.
236 lines
14 KiB
TypeScript
236 lines
14 KiB
TypeScript
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 (
|
|
<div className={bottomControlMenuClass()}>
|
|
<span className={bottomControlIconSlotClass()}>
|
|
<BoundingBox size={24} weight="regular" />
|
|
</span>
|
|
<BottomControlDivider />
|
|
<BoundsInput label="X" field="x" draft={draft.x} disabled={locked} setDraft={setDraft} commitField={commitField} />
|
|
<BoundsInput label="Y" field="y" draft={draft.y} disabled={locked} setDraft={setDraft} commitField={commitField} />
|
|
<BottomControlDivider />
|
|
<BoundsInput label="W" field="w" draft={draft.w} disabled={locked} setDraft={setDraft} commitField={commitField} />
|
|
<BoundsInput label="H" field="h" draft={draft.h} disabled={locked} setDraft={setDraft} commitField={commitField} />
|
|
{layer ? (
|
|
<>
|
|
<BottomControlDivider />
|
|
<SimpleInput
|
|
label="Opacity"
|
|
suffix="%"
|
|
value={opacityDraft}
|
|
disabled={locked}
|
|
onChange={setOpacityDraft}
|
|
onCommit={() => {
|
|
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)));
|
|
}}
|
|
/>
|
|
<SimpleInput
|
|
label="Rotation"
|
|
suffix="°"
|
|
value={rotationDraft}
|
|
disabled={locked || layer.type === "group" || layer.type === "adjustment"}
|
|
title={layer.type === "group" || layer.type === "adjustment" ? "Group rotation is not supported" : undefined}
|
|
onChange={setRotationDraft}
|
|
onCommit={() => {
|
|
const value = Number.parseFloat(rotationDraft);
|
|
if (Number.isFinite(value)) dispatch(commandIds.transformSetRotation, { target, rotation: value * Math.PI / 180 });
|
|
else setRotationDraft(rotationDegrees(layerInfo));
|
|
}}
|
|
/>
|
|
<BottomControlDivider />
|
|
<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 === "image" || layer.type === "raster" ? (
|
|
<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 === "image" || layer.type === "raster" ? (
|
|
<button
|
|
type="button"
|
|
className={actionButtonClass()}
|
|
disabled={locked || rotatedMaskEditingUnsupported}
|
|
title={locked ? "Unlock the layer to edit its mask" : rotatedMaskEditingUnsupported ? "Reset rotation to edit the layer mask" : mask ? "Edit layer mask" : "Add layer mask"}
|
|
onClick={() => {
|
|
if (mask) dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: mask.maskLayerId });
|
|
else addLayerMask(documentIndex, layerInfo!, dispatch);
|
|
}}
|
|
>
|
|
<MaskHappy size={20} /> {mask ? "Edit mask" : "Add mask"}
|
|
</button>
|
|
) : null}
|
|
{locked ? <span className="text-xs text-amber-200/70">Unlock to edit</span> : null}
|
|
{cropOpen && (layer.type === "image" || layer.type === "raster") ? <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 border-l border-white/[0.08] pl-3" 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 border-l border-white/[0.08] pl-3" 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 !== "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 (
|
|
<label className={`${bottomControlFieldClass()} ${disabled ? "opacity-40" : ""}`} title={title}>
|
|
<span className={bottomControlLabelClass()}>{label}</span>
|
|
<span className="flex items-center">
|
|
<input className={`${bottomControlInputClass()} w-16`} inputMode="decimal" value={value} disabled={disabled} aria-label={label} onChange={(event) => onChange(event.target.value)} onBlur={onCommit} onFocus={(event) => event.currentTarget.select()} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") event.currentTarget.blur(); if (event.key === "Escape") event.currentTarget.blur(); }} />
|
|
<span className="-ml-2 text-xs text-white/40">{suffix}</span>
|
|
</span>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
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<SetStateAction<Record<BoundsField, string>>>;
|
|
commitField: (field: BoundsField) => void;
|
|
}) {
|
|
return (
|
|
<label className={`${bottomControlFieldClass()} ${disabled ? "opacity-40" : ""}`}>
|
|
<span className={bottomControlLabelClass()}>{label}</span>
|
|
<input
|
|
className={bottomControlInputClass()}
|
|
inputMode="decimal"
|
|
value={draft}
|
|
aria-label={label}
|
|
disabled={disabled}
|
|
onChange={(event) => setDraft((current) => ({ ...current, [field]: event.target.value }))}
|
|
onBlur={() => commitField(field)}
|
|
onFocus={(event) => event.currentTarget.select()}
|
|
onKeyDown={(event) => {
|
|
event.stopPropagation();
|
|
if (event.key === "Enter") {
|
|
commitField(field);
|
|
event.currentTarget.blur();
|
|
}
|
|
if (event.key === "Escape") event.currentTarget.blur();
|
|
}}
|
|
/>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function draftFromBounds(bounds: Rect): Record<BoundsField, string> {
|
|
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)),
|
|
};
|
|
}
|