feat: add contextual layer inspector
This commit is contained in:
@@ -1,27 +1,41 @@
|
||||
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { BoundingBox } from "@phosphor-icons/react";
|
||||
import { BoundingBox, Copy, 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, dispatch }: TransformControlsProps) {
|
||||
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 layer = layerInfo?.layer;
|
||||
const locked = layer?.locked ?? false;
|
||||
const mask = layer ? getLayerMask(layer) : undefined;
|
||||
const rotatedMaskEditingUnsupported = Boolean(layer && layer.type !== "group" && 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]);
|
||||
|
||||
const commitField = (field: BoundsField) => {
|
||||
const value = Number.parseFloat(draft[field]);
|
||||
if (!Number.isFinite(value)) {
|
||||
@@ -29,6 +43,7 @@ export function TransformControls({ bounds, target, dispatch }: TransformControl
|
||||
return;
|
||||
}
|
||||
|
||||
if (locked) return;
|
||||
dispatch(commandIds.transformSetBounds, {
|
||||
target,
|
||||
bounds: {
|
||||
@@ -44,36 +59,108 @@ export function TransformControls({ bounds, target, dispatch }: TransformControl
|
||||
<BoundingBox size={24} weight="regular" />
|
||||
</span>
|
||||
<BottomControlDivider />
|
||||
<BoundsInput label="X" field="x" draft={draft.x} setDraft={setDraft} commitField={commitField} />
|
||||
<BoundsInput label="Y" field="y" draft={draft.y} setDraft={setDraft} commitField={commitField} />
|
||||
<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} setDraft={setDraft} commitField={commitField} />
|
||||
<BoundsInput label="H" field="h" draft={draft.h} setDraft={setDraft} commitField={commitField} />
|
||||
<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"}
|
||||
title={layer.type === "group" ? "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 !== "group" ? (
|
||||
<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}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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-10 items-center gap-2 rounded-full px-3 text-xs font-semibold text-white/70 transition hover:bg-white/10 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()}>
|
||||
<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()}
|
||||
|
||||
Reference in New Issue
Block a user