Files
2026-07-13 13:08:41 +08:00

237 lines
7.5 KiB
TypeScript

"use client";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils";
import { OpacitySwatchIcon } from "@/components/slide-editor/toolbar/OpacitySwatchIcon";
import {
ColorField,
NumberField,
Panel,
SliderField,
} from "@/components/slide-editor/shapes/ShapeToolbar";
type RawRecord = Record<string, unknown>;
type LinePanelId = "line-width" | "line-color" | "line-style" | "line-opacity";
export type TemplateV2LineToolbarElement = RawRecord & {
type: "line";
stroke?: RawRecord | null;
};
const LINE_STYLE_OPTIONS: Array<{
key: "solid" | "dashed" | "dotted";
label: string;
dash: number[];
}> = [
{ key: "solid", label: "Solid", dash: [] },
{ key: "dashed", label: "Dashed", dash: [10, 6] },
{ key: "dotted", label: "Dotted", dash: [2, 4] },
];
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 readColor(value: unknown, fallback: string) {
const color = typeof value === "string" && value ? value : fallback;
return color.startsWith("#") ? color : `#${color}`;
}
function lineStyleLabel(dash: unknown) {
const dashArray = Array.isArray(dash)
? dash
.map((item) => (typeof item === "number" && Number.isFinite(item) ? item : null))
.filter((item): item is number => item != null && item >= 0)
: [];
const matched = LINE_STYLE_OPTIONS.find(
(option) =>
option.dash.length === dashArray.length &&
option.dash.every((value, index) => value === dashArray[index]),
);
return matched ?? LINE_STYLE_OPTIONS[0];
}
function LineWidthIcon() {
return (
<div className="flex h-4 w-4 flex-col justify-center gap-[1.14px]" aria-hidden>
<span className="h-[1.71px] w-full bg-[#191919]" />
<span className="h-[3.43px] w-full bg-[#191919]" />
<span className="h-[5.71px] w-full bg-[#191919]" />
</div>
);
}
export function isTemplateV2LineToolbarElement(
element: RawRecord | null | undefined,
): element is TemplateV2LineToolbarElement {
return element?.type === "line";
}
export function TemplateV2LineToolbarControls({
element,
onChange,
onToggle,
openPanel,
}: {
element: TemplateV2LineToolbarElement;
onChange: (changes: RawRecord) => void;
onToggle: (panel: LinePanelId) => void;
openPanel: string | null;
}) {
const stroke = asRecord(element.stroke);
const strokeColor = readColor(stroke.color, "#191919");
const strokeOpacity = Math.max(0, Math.min(1, readNumber(stroke.opacity, 1)));
const strokeWidth = Math.max(0, Math.min(8, readNumber(stroke.width, 1)));
const currentStyle = lineStyleLabel(stroke.dash);
const setStroke = (nextStroke: RawRecord) => {
onChange({ stroke: { ...stroke, ...nextStroke } });
};
return (
<>
<div className="relative">
<button
type="button"
title="Line width"
aria-label="Line width"
aria-expanded={openPanel === "line-width"}
onClick={() => onToggle("line-width")}
className={cn(
"flex items-center p-1 text-[#191919] rounded-[4px] hover:bg-[#F8F8FA]",
openPanel === "line-width" && "bg-[#F4F1FF] text-[#7C3AED]",
)}
>
<LineWidthIcon />
</button>
{openPanel === "line-width" ? (
<Panel className="w-[220px] rounded-[12px] border border-[#E8E9EE] p-3 shadow-[0_8px_24px_rgba(16,24,40,0.12)]">
<NumberField
label="Width"
value={strokeWidth}
min={0}
max={8}
step={0.25}
suffix="px"
onCommit={(width) => setStroke({ width })}
/>
</Panel>
) : null}
</div>
<div className="relative">
<button
type="button"
title="Line color"
aria-label="Line color"
aria-expanded={openPanel === "line-color"}
onClick={() => onToggle("line-color")}
className={cn(
"grid h-6 w-6 place-items-center rounded-[4px] text-[#191919] hover:bg-[#F8F8FA]",
openPanel === "line-color" && "bg-[#F4F1FF] text-[#7C3AED]",
)}
>
<Circle size={16} strokeWidth={1.6} />
</button>
{openPanel === "line-color" ? (
<Panel className="flex w-[250px] flex-col gap-[14px] rounded-[12px] border border-[#E8E9EE] p-3 font-manrope text-[12px] font-medium text-[#191919] shadow-[0_8px_24px_rgba(16,24,40,0.12)]">
<ColorField
label="Color"
color={strokeColor}
onCommit={(color) => setStroke({ color })}
/>
</Panel>
) : null}
</div>
<span aria-hidden className="h-[23px] w-px bg-[#EDEEEF]" />
<div className="relative">
<button
type="button"
title="Line style"
aria-label="Line style"
aria-expanded={openPanel === "line-style"}
onClick={() => onToggle("line-style")}
className={cn(
"flex items-center rounded-[4px] p-[6px] font-manrope text-[14px] font-medium leading-4 text-[#191919] hover:bg-[#F8F8FA]",
openPanel === "line-style" && "bg-[#F4F1FF] text-[#7C3AED]",
)}
>
{currentStyle.label}
</button>
{openPanel === "line-style" ? (
<Panel className="w-[200px] overflow-hidden rounded-[12px] border border-[#E8E9EE] py-2 shadow-[0_8px_24px_rgba(16,24,40,0.12)]">
{LINE_STYLE_OPTIONS.map((option) => (
<button
key={option.key}
type="button"
onClick={() => setStroke({ dash: option.dash })}
className={cn(
"flex w-full items-center justify-between px-4 py-2 text-left text-[14px] font-manrope text-[#191919] hover:bg-[#F8F8FA]",
currentStyle.key === option.key && "bg-[#F4F1FF] text-[#7C3AED]",
)}
>
<span>{option.label}</span>
<LinePreview dash={option.dash} />
</button>
))}
</Panel>
) : null}
</div>
<div className="relative">
<button
type="button"
title="Line opacity"
aria-label="Line opacity"
aria-expanded={openPanel === "line-opacity"}
onClick={() => onToggle("line-opacity")}
className={cn(
"flex items-center rounded-[4px] p-1 text-[#191919] hover:bg-[#F8F8FA]",
openPanel === "line-opacity" && "bg-[#F4F1FF] text-[#7C3AED]",
)}
>
<OpacitySwatchIcon />
</button>
{openPanel === "line-opacity" ? (
<Panel className="left-auto right-0 w-[220px] translate-x-0 p-3">
<SliderField
label="Line opacity"
value={strokeOpacity}
min={0}
max={1}
step={0.01}
formatValue={(value) => `${Math.round(value * 100)}%`}
onCommit={(opacity) => setStroke({ opacity })}
/>
</Panel>
) : null}
</div>
</>
);
}
function LinePreview({ dash }: { dash: number[] }) {
return (
<svg aria-hidden="true" className="h-4 w-14" viewBox="0 0 56 16">
<line
x1="4"
y1="8"
x2="52"
y2="8"
stroke="currentColor"
strokeDasharray={dash.length ? dash.join(" ") : undefined}
strokeLinecap="round"
strokeWidth="2"
/>
</svg>
);
}