497 lines
16 KiB
TypeScript
497 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { type ReactNode } from "react";
|
|
import { Circle, Scan, SlidersHorizontal } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
ColorField,
|
|
NumberField,
|
|
Panel,
|
|
} from "@/components/slide-editor/shapes/ShapeToolbar";
|
|
import type {
|
|
TemplateV2LayoutElement,
|
|
TemplateV2LayoutToolbarBox,
|
|
} from "@/components/slide-editor/layout/LayoutToolbar";
|
|
import {
|
|
numericInputMode,
|
|
preventInvalidNumberInput,
|
|
sanitizeNumericInput,
|
|
} from "@/components/slide-editor/toolbar/numericInput";
|
|
|
|
type RawRecord = Record<string, unknown>;
|
|
type ContainerPanelId = "fill" | "stroke" | "radius" | "padding" | "shadow";
|
|
|
|
const ALIGNMENT_MATRIX = [
|
|
{ horizontal: "left", vertical: "top" },
|
|
{ horizontal: "center", vertical: "top" },
|
|
{ horizontal: "right", vertical: "top" },
|
|
{ horizontal: "left", vertical: "middle" },
|
|
{ horizontal: "center", vertical: "middle" },
|
|
{ horizontal: "right", vertical: "middle" },
|
|
{ horizontal: "left", vertical: "bottom" },
|
|
{ horizontal: "center", vertical: "bottom" },
|
|
{ horizontal: "right", vertical: "bottom" },
|
|
] as const;
|
|
|
|
function asRecord(value: unknown): RawRecord {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as RawRecord)
|
|
: {};
|
|
}
|
|
|
|
function readNumber(value: unknown, fallback = 0) {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
}
|
|
|
|
function readString(value: unknown, fallback: string) {
|
|
return typeof value === "string" && value ? value : fallback;
|
|
}
|
|
|
|
function readColor(value: unknown, fallback: string) {
|
|
const color = readString(value, fallback);
|
|
return color.startsWith("#") ? color : `#${color}`;
|
|
}
|
|
|
|
function capitalize(value: string) {
|
|
return value
|
|
.replace("flex-", "")
|
|
.replace(/(^|[-_])\w/g, (part) => part.replace(/[-_]/, "").toUpperCase());
|
|
}
|
|
|
|
function clampNumber(value: number, min: number, max: number) {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
function ContainerControlButton({
|
|
children,
|
|
className,
|
|
open,
|
|
title,
|
|
onClick,
|
|
}: {
|
|
children: ReactNode;
|
|
className?: string;
|
|
open?: boolean;
|
|
title: string;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
title={title}
|
|
aria-label={title}
|
|
aria-pressed={open}
|
|
onClick={onClick}
|
|
className={cn(
|
|
"inline-flex h-7 min-w-7 items-center justify-center gap-1 rounded-[6px] border-0 bg-transparent px-2 font-manrope text-[14px] font-medium leading-4 text-[#191919] hover:bg-[#F4F4F6]",
|
|
open && "bg-[#F0ECFF] text-[#2B1D71]",
|
|
className,
|
|
)}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function CompactNumberInput({
|
|
marker,
|
|
min,
|
|
max,
|
|
step = 1,
|
|
value,
|
|
onCommit,
|
|
label,
|
|
}: {
|
|
marker?: string;
|
|
min?: number;
|
|
max?: number;
|
|
step?: number;
|
|
value: number;
|
|
onCommit: (value: number) => void;
|
|
label: string;
|
|
}) {
|
|
const numericInputOptions = {
|
|
allowDecimal: true,
|
|
min,
|
|
};
|
|
const commitNumber = (nextValue: number) => {
|
|
if (!Number.isFinite(nextValue)) return;
|
|
const bounded = clampNumber(
|
|
nextValue,
|
|
min ?? -Infinity,
|
|
max ?? Infinity,
|
|
);
|
|
onCommit(bounded);
|
|
};
|
|
|
|
return (
|
|
<label className="flex items-center justify-between gap-3">
|
|
<span className="font-manrope text-[12px] font-medium leading-6 text-[#191919]">
|
|
{label}
|
|
</span>
|
|
<span className="flex h-9 min-w-[140px] items-center rounded-[10px] border border-[#DDDEE4] bg-white px-3 text-[12px] text-[#191919] focus-within:border-[#6A52E2]">
|
|
{marker ? (
|
|
<span className="mr-3 flex h-5 min-w-5 items-center justify-center rounded-md border border-[#B7BAC6] bg-[#F7F8FB] px-1 text-[11px] font-semibold leading-none text-[#727584]">
|
|
{marker}
|
|
</span>
|
|
) : null}
|
|
<input
|
|
aria-label={label}
|
|
type="text"
|
|
inputMode={numericInputMode(numericInputOptions)}
|
|
value={Number.isFinite(value) ? value : 0}
|
|
onKeyDown={(event) => {
|
|
if (preventInvalidNumberInput(event, numericInputOptions)) return;
|
|
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
const direction = event.key === "ArrowUp" ? 1 : -1;
|
|
commitNumber(value + step * direction);
|
|
}
|
|
}}
|
|
onChange={(event) => {
|
|
const sanitizedValue = sanitizeNumericInput(
|
|
event.target.value,
|
|
numericInputOptions,
|
|
);
|
|
commitNumber(Number(sanitizedValue));
|
|
}}
|
|
className="w-full border-0 bg-transparent p-0 text-[12px] leading-none text-[#191919] outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
|
/>
|
|
</span>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
export function TemplateV2ContainerToolbarControls({
|
|
box,
|
|
element,
|
|
onChange,
|
|
onToggle,
|
|
openPanel,
|
|
}: {
|
|
box: TemplateV2LayoutToolbarBox;
|
|
element: TemplateV2LayoutElement;
|
|
onChange: (changes: RawRecord) => void;
|
|
onToggle: (panel: ContainerPanelId) => void;
|
|
openPanel: string | null;
|
|
}) {
|
|
const alignment = asRecord(element.alignment);
|
|
const fill = asRecord(element.fill);
|
|
const stroke = asRecord(element.stroke);
|
|
const padding = asRecord(element.padding);
|
|
const shadow = asRecord(element.shadow);
|
|
const borderRadius = asRecord(element.border_radius);
|
|
const horizontal = readString(alignment.horizontal, "left");
|
|
const vertical = readString(alignment.vertical, "top");
|
|
const radius =
|
|
typeof element.border_radius === "number"
|
|
? element.border_radius
|
|
: readNumber(borderRadius.radius, readNumber(borderRadius.tl));
|
|
const maxRadius = Math.max(0, Math.min(box.width, box.height) / 2);
|
|
const fillColor = readColor(fill.color, "#4A6FF3");
|
|
const strokeColor = readColor(stroke.color, "#1A1F36");
|
|
const paddingX = readNumber(
|
|
padding.x,
|
|
(readNumber(padding.left) + readNumber(padding.right)) / 2,
|
|
);
|
|
const paddingY = readNumber(
|
|
padding.y,
|
|
(readNumber(padding.top) + readNumber(padding.bottom)) / 2,
|
|
);
|
|
const updatePaddingX = (value: number) =>
|
|
onChange({
|
|
padding: {
|
|
...padding,
|
|
x: value,
|
|
left: value,
|
|
right: value,
|
|
},
|
|
});
|
|
const updatePaddingY = (value: number) =>
|
|
onChange({
|
|
padding: {
|
|
...padding,
|
|
y: value,
|
|
top: value,
|
|
bottom: value,
|
|
},
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<div className="relative">
|
|
<ContainerControlButton
|
|
title="Fill"
|
|
open={openPanel === "fill"}
|
|
onClick={() => onToggle("fill")}
|
|
className="w-7 px-0"
|
|
>
|
|
<span
|
|
aria-hidden
|
|
className="h-4 w-4 rounded-full border border-[#D6D9E1]"
|
|
style={{ backgroundColor: fillColor }}
|
|
/>
|
|
</ContainerControlButton>
|
|
{openPanel === "fill" ? (
|
|
<Panel className="flex w-[250px] flex-col items-start gap-[14px] rounded-[12px] border border-[#E8E9EE] p-3 font-manrope text-[12px] font-medium leading-6 text-[#191919] shadow-[0_8px_24px_rgba(16,24,40,0.12)]">
|
|
<ColorField
|
|
label="Color"
|
|
color={fillColor}
|
|
onCommit={(color) => onChange({ fill: { ...fill, color } })}
|
|
/>
|
|
<NumberField
|
|
label="Opacity"
|
|
value={readNumber(fill.opacity, 1)}
|
|
min={0}
|
|
max={1}
|
|
step={0.05}
|
|
onCommit={(opacity) => onChange({ fill: { ...fill, opacity } })}
|
|
/>
|
|
</Panel>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<ContainerControlButton
|
|
title="Stroke"
|
|
open={openPanel === "stroke"}
|
|
onClick={() => onToggle("stroke")}
|
|
className="w-7 px-0"
|
|
>
|
|
<Circle size={16} strokeWidth={1.33} className="text-[#182042]" aria-hidden />
|
|
</ContainerControlButton>
|
|
{openPanel === "stroke" ? (
|
|
<Panel className="flex w-[250px] flex-col items-start gap-[14px] rounded-[12px] border border-[#E8E9EE] p-3 font-manrope text-[12px] font-medium leading-6 text-[#191919] shadow-[0_8px_24px_rgba(16,24,40,0.12)]">
|
|
<ColorField
|
|
label="Color"
|
|
color={strokeColor}
|
|
onCommit={(color) => onChange({ stroke: { ...stroke, color } })}
|
|
/>
|
|
<NumberField
|
|
label="Width"
|
|
value={readNumber(stroke.width)}
|
|
min={0}
|
|
max={32}
|
|
step={0.25}
|
|
suffix="px"
|
|
onCommit={(width) => onChange({ stroke: { ...stroke, width } })}
|
|
/>
|
|
<NumberField
|
|
label="Opacity"
|
|
value={readNumber(stroke.opacity, 1)}
|
|
min={0}
|
|
max={1}
|
|
step={0.05}
|
|
onCommit={(opacity) => onChange({ stroke: { ...stroke, opacity } })}
|
|
/>
|
|
</Panel>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<ContainerControlButton
|
|
title="Corner Radius"
|
|
open={openPanel === "radius"}
|
|
onClick={() => onToggle("radius")}
|
|
className="w-7 px-0"
|
|
>
|
|
<Scan size={16} strokeWidth={1.33} aria-hidden />
|
|
</ContainerControlButton>
|
|
{openPanel === "radius" ? (
|
|
<Panel className="flex w-[240px] flex-col items-start gap-[14px] rounded-[12px] border border-[#E8E9EE] p-3 font-manrope text-[12px] font-medium leading-6 text-[#191919] shadow-[0_8px_24px_rgba(16,24,40,0.12)]">
|
|
<div className="flex w-full items-center justify-between">
|
|
<span className="font-manrope text-[12px] font-medium leading-6 text-[#191919]">
|
|
Border Radius
|
|
</span>
|
|
<span
|
|
aria-hidden
|
|
className="h-7 w-7 border border-[#D7DAE3] bg-[#F7F8FB]"
|
|
style={{ borderRadius: `${clampNumber(radius, 0, 12)}px` }}
|
|
/>
|
|
</div>
|
|
<CompactNumberInput
|
|
label="Radius"
|
|
marker="R"
|
|
value={radius}
|
|
min={0}
|
|
max={maxRadius}
|
|
step={0.5}
|
|
onCommit={(value) =>
|
|
onChange({
|
|
border_radius: { tl: value, tr: value, br: value, bl: value },
|
|
})
|
|
}
|
|
/>
|
|
<div className="flex w-full items-center gap-2">
|
|
{[0, 8, 16, 24].map((preset) => (
|
|
<button
|
|
key={preset}
|
|
type="button"
|
|
onClick={() =>
|
|
onChange({
|
|
border_radius: {
|
|
tl: Math.min(maxRadius, preset),
|
|
tr: Math.min(maxRadius, preset),
|
|
br: Math.min(maxRadius, preset),
|
|
bl: Math.min(maxRadius, preset),
|
|
},
|
|
})
|
|
}
|
|
className="inline-flex h-7 items-center justify-center rounded-[6px] border border-[#D7DAE3] bg-white px-2 text-[11px] leading-none text-[#353742] hover:bg-[#F7F8FB]"
|
|
>
|
|
{preset}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</Panel>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<ContainerControlButton
|
|
title="Shadow"
|
|
open={openPanel === "shadow"}
|
|
onClick={() => onToggle("shadow")}
|
|
className="px-3"
|
|
>
|
|
Shadow
|
|
</ContainerControlButton>
|
|
{openPanel === "shadow" ? (
|
|
<Panel className="flex w-[320px] flex-col items-start gap-[14px] rounded-[12px] border border-[#E8E9EE] p-3 font-manrope text-[12px] font-medium leading-6 text-[#191919] shadow-[0_8px_24px_rgba(16,24,40,0.12)]">
|
|
<CompactNumberInput
|
|
label="Position"
|
|
marker="X"
|
|
value={readNumber(shadow.offset_x)}
|
|
step={0.5}
|
|
onCommit={(offset_x) =>
|
|
onChange({
|
|
shadow: {
|
|
...shadow,
|
|
offset_x,
|
|
opacity: Math.max(0.05, readNumber(shadow.opacity, 0.24)),
|
|
},
|
|
})
|
|
}
|
|
/>
|
|
<CompactNumberInput
|
|
label="Position"
|
|
marker="Y"
|
|
value={readNumber(shadow.offset_y)}
|
|
step={0.5}
|
|
onCommit={(offset_y) =>
|
|
onChange({
|
|
shadow: {
|
|
...shadow,
|
|
offset_y,
|
|
opacity: Math.max(0.05, readNumber(shadow.opacity, 0.24)),
|
|
},
|
|
})
|
|
}
|
|
/>
|
|
<CompactNumberInput
|
|
label="Blur"
|
|
marker="X"
|
|
min={0}
|
|
max={100}
|
|
step={0.5}
|
|
value={readNumber(shadow.blur, 12)}
|
|
onCommit={(blur) => onChange({ shadow: { ...shadow, blur } })}
|
|
/>
|
|
<ColorField
|
|
label="Color"
|
|
color={readColor(shadow.color, "#4A6FF3")}
|
|
onCommit={(color) => onChange({ shadow: { ...shadow, color } })}
|
|
/>
|
|
<NumberField
|
|
label="Opacity"
|
|
value={clampNumber(readNumber(shadow.opacity, 0.24), 0, 1) * 100}
|
|
min={0}
|
|
max={100}
|
|
step={1}
|
|
suffix="%"
|
|
onCommit={(opacityPercent) =>
|
|
onChange({
|
|
shadow: {
|
|
...shadow,
|
|
opacity: clampNumber(opacityPercent, 0, 100) / 100,
|
|
},
|
|
})
|
|
}
|
|
/>
|
|
</Panel>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<ContainerControlButton
|
|
title="Layout Settings"
|
|
open={openPanel === "padding"}
|
|
onClick={() => onToggle("padding")}
|
|
className="w-7 px-0"
|
|
>
|
|
<SlidersHorizontal size={16} strokeWidth={1.33} aria-hidden />
|
|
</ContainerControlButton>
|
|
{openPanel === "padding" ? (
|
|
<Panel className="flex w-[320px] flex-col items-start gap-[14px] rounded-[12px] border border-[#E8E9EE] p-3 font-manrope text-[12px] font-medium leading-6 text-[#191919] shadow-[0_8px_24px_rgba(16,24,40,0.12)]">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<span className="pt-1 font-manrope text-[12px] font-medium leading-6 text-[#191919]">
|
|
Alignment
|
|
</span>
|
|
<div className="grid grid-cols-3 gap-2 rounded-[10px] border border-[#DDDEE4] bg-[#F8F9FC] p-2">
|
|
{ALIGNMENT_MATRIX.map((point) => {
|
|
const selected =
|
|
point.horizontal === horizontal && point.vertical === vertical;
|
|
return (
|
|
<button
|
|
key={`${point.horizontal}-${point.vertical}`}
|
|
type="button"
|
|
aria-label={`${capitalize(point.horizontal)} ${capitalize(point.vertical)}`}
|
|
aria-pressed={selected}
|
|
onClick={() =>
|
|
onChange({
|
|
alignment: {
|
|
...alignment,
|
|
horizontal: point.horizontal,
|
|
vertical: point.vertical,
|
|
},
|
|
})
|
|
}
|
|
className={cn(
|
|
"grid h-8 w-8 place-items-center rounded-md transition-colors hover:bg-[#EDEFFF]",
|
|
selected && "bg-[#ECE7FF]",
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"h-2.5 w-2.5 rounded-full bg-[#C8CBD6]",
|
|
selected && "bg-[#6A52E2]",
|
|
)}
|
|
/>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<CompactNumberInput
|
|
label="Position"
|
|
marker="X"
|
|
min={0}
|
|
value={paddingX}
|
|
step={0.5}
|
|
onCommit={updatePaddingX}
|
|
/>
|
|
<CompactNumberInput
|
|
label="Position"
|
|
marker="Y"
|
|
min={0}
|
|
value={paddingY}
|
|
step={0.5}
|
|
onCommit={updatePaddingY}
|
|
/>
|
|
</Panel>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|