feat: add shape fill/stroke transparency, RGBA color picker, and dash styles

Closes #193. Shapes now support no-fill and no-stroke toggles for drawing
outlines or fill-only shapes. Adds RGBA color picker with opacity control,
stroke dash styles (solid/dashed/dotted), and i18n for all shape labels.
This commit is contained in:
SnapOtter
2026-06-05 16:40:57 +08:00
parent e03c6089af
commit bec3b1e289
27 changed files with 832 additions and 80 deletions
@@ -0,0 +1,208 @@
// apps/web/src/components/editor/common/shape-color-picker.tsx
import { useCallback, useEffect, useRef, useState } from "react";
import { RgbaColorPicker } from "react-colorful";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
const CHECKERBOARD =
"repeating-conic-gradient(rgba(128,128,128,0.3) 0% 25%, transparent 0% 50%) 0 0 / 8px 8px";
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const r = Number.parseInt(hex.slice(1, 3), 16);
const g = Number.parseInt(hex.slice(3, 5), 16);
const b = Number.parseInt(hex.slice(5, 7), 16);
return { r, g, b };
}
function rgbToHex(r: number, g: number, b: number): string {
const toHex = (n: number) => n.toString(16).padStart(2, "0");
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
interface ShapeColorPickerProps {
label: string;
color: string | null;
opacity: number;
onColorChange: (color: string | null) => void;
onOpacityChange: (opacity: number) => void;
allowNone?: boolean;
}
export function ShapeColorPicker({
label,
color,
opacity,
onColorChange,
onOpacityChange,
allowNone = true,
}: ShapeColorPickerProps) {
const [open, setOpen] = useState(false);
const [hexInput, setHexInput] = useState(color ?? "#000000");
const buttonRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [popoverPos, setPopoverPos] = useState({ top: 0, left: 0 });
useEffect(() => {
if (color) setHexInput(color);
}, [color]);
useEffect(() => {
if (!open) return;
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setPopoverPos({ top: rect.bottom + 6, left: rect.left });
}
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (
popoverRef.current &&
!popoverRef.current.contains(target) &&
buttonRef.current &&
!buttonRef.current.contains(target)
) {
setOpen(false);
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [open]);
const rgbaValue = color ? { ...hexToRgb(color), a: opacity } : { r: 0, g: 0, b: 0, a: 0 };
const handleRgbaChange = useCallback(
(rgba: { r: number; g: number; b: number; a: number }) => {
const hex = rgbToHex(rgba.r, rgba.g, rgba.b);
onColorChange(hex);
onOpacityChange(rgba.a);
setHexInput(hex);
},
[onColorChange, onOpacityChange],
);
const handleHexCommit = useCallback(
(value: string) => {
let hex = value.trim();
if (!hex.startsWith("#")) hex = `#${hex}`;
if (/^#[0-9a-f]{6}$/i.test(hex)) {
onColorChange(hex.toLowerCase());
setHexInput(hex.toLowerCase());
} else {
setHexInput(color ?? "#000000");
}
},
[color, onColorChange],
);
const isNone = color === null;
return (
<div className="relative flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{label}</span>
<button
ref={buttonRef}
type="button"
onClick={() => setOpen(!open)}
className={cn(
"relative w-6 h-6 rounded border border-border cursor-pointer",
"hover:ring-1 hover:ring-primary/50 transition-shadow shrink-0",
)}
style={{
background: isNone ? undefined : CHECKERBOARD,
}}
aria-label={`${label} color picker`}
>
{isNone ? (
<span className="absolute inset-0 rounded bg-white">
<span
className="absolute inset-0 rounded"
style={{
background:
"linear-gradient(to top right, transparent calc(50% - 1px), #ef4444 calc(50% - 1px), #ef4444 calc(50% + 1px), transparent calc(50% + 1px))",
}}
/>
</span>
) : (
<span
className="absolute inset-0 rounded"
style={{
backgroundColor: color,
opacity,
}}
/>
)}
</button>
{open &&
createPortal(
<div
ref={popoverRef}
className={cn(
"fixed z-[9999] p-3 rounded-lg shadow-xl",
"bg-card border border-border w-[220px]",
)}
style={{ top: popoverPos.top, left: popoverPos.left }}
>
{allowNone && (
<button
type="button"
onClick={() => {
if (isNone) {
onColorChange("#000000");
setHexInput("#000000");
} else {
onColorChange(null);
}
}}
className={cn(
"w-full mb-2 px-2 py-1 text-xs rounded border transition-colors",
isNone
? "bg-primary text-primary-foreground border-primary"
: "bg-muted text-muted-foreground border-border hover:text-foreground",
)}
>
{isNone ? `${label}: None` : `No ${label}`}
</button>
)}
{!isNone && (
<>
<div className="[&_.react-colorful]:!w-full [&_.react-colorful]:!h-[150px] rounded overflow-hidden">
<RgbaColorPicker color={rgbaValue} onChange={handleRgbaChange} />
</div>
<div className="mt-2 flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground font-medium">#</span>
<input
type="text"
value={hexInput.replace("#", "")}
onChange={(e) => setHexInput(`#${e.target.value}`)}
onBlur={(e) => handleHexCommit(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleHexCommit(e.currentTarget.value);
}}
maxLength={6}
className="flex-1 h-6 text-xs bg-muted border border-border rounded px-1.5 font-mono"
/>
<span className="text-[10px] text-muted-foreground font-medium ms-1">
{Math.round(opacity * 100)}%
</span>
</div>
</>
)}
</div>,
document.body,
)}
</div>
);
}
@@ -408,6 +408,7 @@ function CanvasObjectRenderer({
tension={a.tension} tension={a.tension}
lineCap={a.lineCap} lineCap={a.lineCap}
lineJoin={a.lineJoin} lineJoin={a.lineJoin}
dash={a.dash}
opacity={a.opacity} opacity={a.opacity}
globalCompositeOperation={ globalCompositeOperation={
a.globalCompositeOperation as "source-over" | "destination-out" | undefined a.globalCompositeOperation as "source-over" | "destination-out" | undefined
@@ -433,6 +434,7 @@ function CanvasObjectRenderer({
stroke={a.stroke} stroke={a.stroke}
strokeWidth={a.strokeWidth} strokeWidth={a.strokeWidth}
cornerRadius={a.cornerRadius} cornerRadius={a.cornerRadius}
dash={a.dash}
rotation={a.rotation} rotation={a.rotation}
opacity={a.opacity} opacity={a.opacity}
draggable={draggable} draggable={draggable}
@@ -457,6 +459,7 @@ function CanvasObjectRenderer({
fill={a.fill} fill={a.fill}
stroke={a.stroke} stroke={a.stroke}
strokeWidth={a.strokeWidth} strokeWidth={a.strokeWidth}
dash={a.dash}
rotation={a.rotation} rotation={a.rotation}
opacity={a.opacity} opacity={a.opacity}
draggable={draggable} draggable={draggable}
@@ -510,6 +513,7 @@ function CanvasObjectRenderer({
strokeWidth={a.strokeWidth} strokeWidth={a.strokeWidth}
pointerLength={a.pointerLength} pointerLength={a.pointerLength}
pointerWidth={a.pointerWidth} pointerWidth={a.pointerWidth}
dash={a.dash}
rotation={a.rotation} rotation={a.rotation}
opacity={a.opacity} opacity={a.opacity}
draggable={draggable} draggable={draggable}
@@ -534,6 +538,7 @@ function CanvasObjectRenderer({
fill={a.fill} fill={a.fill}
stroke={a.stroke} stroke={a.stroke}
strokeWidth={a.strokeWidth} strokeWidth={a.strokeWidth}
dash={a.dash}
rotation={a.rotation} rotation={a.rotation}
opacity={a.opacity} opacity={a.opacity}
draggable={draggable} draggable={draggable}
@@ -559,6 +564,7 @@ function CanvasObjectRenderer({
fill={a.fill} fill={a.fill}
stroke={a.stroke} stroke={a.stroke}
strokeWidth={a.strokeWidth} strokeWidth={a.strokeWidth}
dash={a.dash}
rotation={a.rotation} rotation={a.rotation}
opacity={a.opacity} opacity={a.opacity}
draggable={draggable} draggable={draggable}
@@ -1,7 +1,9 @@
// apps/web/src/components/editor/options/shape-options.tsx // apps/web/src/components/editor/options/shape-options.tsx
import { useTranslation } from "@/contexts/i18n-context";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import type { ToolType } from "@/types/editor"; import type { StrokeDashStyle, ToolType } from "@/types/editor";
import { ShapeColorPicker } from "../common/shape-color-picker";
const SHAPE_TOOLS = new Set<ToolType>([ const SHAPE_TOOLS = new Set<ToolType>([
"shape-rect", "shape-rect",
@@ -12,38 +14,49 @@ const SHAPE_TOOLS = new Set<ToolType>([
"shape-star", "shape-star",
]); ]);
const SHAPE_TYPE_OPTIONS: { value: ToolType; label: string }[] = [
{ value: "shape-rect", label: "Rectangle" },
{ value: "shape-ellipse", label: "Ellipse" },
{ value: "shape-line", label: "Line" },
{ value: "shape-arrow", label: "Arrow" },
{ value: "shape-polygon", label: "Polygon" },
{ value: "shape-star", label: "Star" },
];
export function ShapeOptions() { export function ShapeOptions() {
const activeTool = useEditorStore((s) => s.activeTool); const { t } = useTranslation();
const setTool = useEditorStore((s) => s.setTool); const s = t.editor.shapes;
const shapeFill = useEditorStore((s) => s.shapeFill);
const shapeStroke = useEditorStore((s) => s.shapeStroke); const activeTool = useEditorStore((st) => st.activeTool);
const shapeStrokeWidth = useEditorStore((s) => s.shapeStrokeWidth); const setTool = useEditorStore((st) => st.setTool);
const shapeCornerRadius = useEditorStore((s) => s.shapeCornerRadius); const shapeFill = useEditorStore((st) => st.shapeFill);
const shapePolygonSides = useEditorStore((s) => s.shapePolygonSides); const shapeFillOpacity = useEditorStore((st) => st.shapeFillOpacity);
const shapeStarPoints = useEditorStore((s) => s.shapeStarPoints); const shapeStroke = useEditorStore((st) => st.shapeStroke);
const setShapeFill = useEditorStore((s) => s.setShapeFill); const shapeStrokeOpacity = useEditorStore((st) => st.shapeStrokeOpacity);
const setShapeStroke = useEditorStore((s) => s.setShapeStroke); const shapeStrokeWidth = useEditorStore((st) => st.shapeStrokeWidth);
const setShapeStrokeWidth = useEditorStore((s) => s.setShapeStrokeWidth); const shapeStrokeDash = useEditorStore((st) => st.shapeStrokeDash);
const setShapeCornerRadius = useEditorStore((s) => s.setShapeCornerRadius); const shapeCornerRadius = useEditorStore((st) => st.shapeCornerRadius);
const setShapePolygonSides = useEditorStore((s) => s.setShapePolygonSides); const shapePolygonSides = useEditorStore((st) => st.shapePolygonSides);
const setShapeStarPoints = useEditorStore((s) => s.setShapeStarPoints); const shapeStarPoints = useEditorStore((st) => st.shapeStarPoints);
const setShapeFill = useEditorStore((st) => st.setShapeFill);
const setShapeFillOpacity = useEditorStore((st) => st.setShapeFillOpacity);
const setShapeStroke = useEditorStore((st) => st.setShapeStroke);
const setShapeStrokeOpacity = useEditorStore((st) => st.setShapeStrokeOpacity);
const setShapeStrokeWidth = useEditorStore((st) => st.setShapeStrokeWidth);
const setShapeStrokeDash = useEditorStore((st) => st.setShapeStrokeDash);
const setShapeCornerRadius = useEditorStore((st) => st.setShapeCornerRadius);
const setShapePolygonSides = useEditorStore((st) => st.setShapePolygonSides);
const setShapeStarPoints = useEditorStore((st) => st.setShapeStarPoints);
if (!SHAPE_TOOLS.has(activeTool)) return null; if (!SHAPE_TOOLS.has(activeTool)) return null;
const SHAPE_TYPE_OPTIONS: { value: ToolType; label: string }[] = [
{ value: "shape-rect", label: s.rectangle },
{ value: "shape-ellipse", label: s.ellipse },
{ value: "shape-line", label: s.line },
{ value: "shape-arrow", label: s.arrow },
{ value: "shape-polygon", label: s.polygon },
{ value: "shape-star", label: s.star },
];
const showFill = activeTool !== "shape-line";
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Shape type selector */} {/* Shape type selector */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground"> <label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Shape {s.shape}
<select <select
value={activeTool} value={activeTool}
onChange={(e) => setTool(e.target.value as ToolType)} onChange={(e) => setTool(e.target.value as ToolType)}
@@ -58,30 +71,28 @@ export function ShapeOptions() {
</label> </label>
{/* Fill color */} {/* Fill color */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground"> {showFill && (
Fill <ShapeColorPicker
<input label={s.fill}
type="color" color={shapeFill}
value={shapeFill} opacity={shapeFillOpacity}
onChange={(e) => setShapeFill(e.target.value)} onColorChange={setShapeFill}
className="w-6 h-6 border border-border rounded cursor-pointer" onOpacityChange={setShapeFillOpacity}
/> />
</label> )}
{/* Stroke color */} {/* Stroke color */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground"> <ShapeColorPicker
Stroke label={s.stroke}
<input color={shapeStroke}
type="color" opacity={shapeStrokeOpacity}
value={shapeStroke} onColorChange={setShapeStroke}
onChange={(e) => setShapeStroke(e.target.value)} onOpacityChange={setShapeStrokeOpacity}
className="w-6 h-6 border border-border rounded cursor-pointer" />
/>
</label>
{/* Stroke width */} {/* Stroke width */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground"> <label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Width {s.strokeWidth}
<input <input
type="range" type="range"
min={0} min={0}
@@ -100,10 +111,24 @@ export function ShapeOptions() {
/> />
</label> </label>
{/* Stroke dash style */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
{s.dashStyle}
<select
value={shapeStrokeDash}
onChange={(e) => setShapeStrokeDash(e.target.value as StrokeDashStyle)}
className="h-6 text-xs bg-muted border border-border rounded px-1"
>
<option value="solid">{s.solid}</option>
<option value="dashed">{s.dashed}</option>
<option value="dotted">{s.dotted}</option>
</select>
</label>
{/* Corner radius (only for rect) */} {/* Corner radius (only for rect) */}
{activeTool === "shape-rect" && ( {activeTool === "shape-rect" && (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground"> <label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Radius {s.cornerRadius}
<input <input
type="range" type="range"
min={0} min={0}
@@ -126,7 +151,7 @@ export function ShapeOptions() {
{/* Polygon sides */} {/* Polygon sides */}
{activeTool === "shape-polygon" && ( {activeTool === "shape-polygon" && (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground"> <label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Sides {s.sides}
<input <input
type="number" type="number"
min={3} min={3}
@@ -141,7 +166,7 @@ export function ShapeOptions() {
{/* Star points */} {/* Star points */}
{activeTool === "shape-star" && ( {activeTool === "shape-star" && (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground"> <label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Points {s.points}
<input <input
type="number" type="number"
min={3} min={3}
@@ -3,7 +3,7 @@
import type Konva from "konva"; import type Konva from "konva";
import { useCallback, useRef } from "react"; import { useCallback, useRef } from "react";
import { generateId } from "@/lib/utils"; import { generateId } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store"; import { dashStyleToArray, hexToRgba, useEditorStore } from "@/stores/editor-store";
import type { CanvasObject, ToolType } from "@/types/editor"; import type { CanvasObject, ToolType } from "@/types/editor";
interface PendingShape { interface PendingShape {
@@ -46,6 +46,22 @@ function constrainToDimension(
return { w, h }; return { w, h };
} }
function computeShapeColors(state: {
shapeFill: string | null;
shapeFillOpacity: number;
shapeStroke: string | null;
shapeStrokeOpacity: number;
shapeStrokeDash: "solid" | "dashed" | "dotted";
shapeStrokeWidth: number;
}) {
const fill = state.shapeFill ? hexToRgba(state.shapeFill, state.shapeFillOpacity) : undefined;
const stroke = state.shapeStroke
? hexToRgba(state.shapeStroke, state.shapeStrokeOpacity)
: undefined;
const dash = dashStyleToArray(state.shapeStrokeDash, state.shapeStrokeWidth);
return { fill, stroke, dash };
}
export function useShapeTool() { export function useShapeTool() {
const dragRef = useRef<DragState | null>(null); const dragRef = useRef<DragState | null>(null);
const pendingRef = useRef<PendingShape | null>(null); const pendingRef = useRef<PendingShape | null>(null);
@@ -59,8 +75,11 @@ export function useShapeTool() {
const { const {
activeTool, activeTool,
shapeFill, shapeFill,
shapeFillOpacity,
shapeStroke, shapeStroke,
shapeStrokeOpacity,
shapeStrokeWidth, shapeStrokeWidth,
shapeStrokeDash,
shapeCornerRadius, shapeCornerRadius,
shapePolygonSides, shapePolygonSides,
shapeStarPoints, shapeStarPoints,
@@ -78,6 +97,14 @@ export function useShapeTool() {
const y = (pointer.y - panOffset.y) / zoom; const y = (pointer.y - panOffset.y) / zoom;
const id = generateId(); const id = generateId();
const { fill, stroke, dash } = computeShapeColors({
shapeFill,
shapeFillOpacity,
shapeStroke,
shapeStrokeOpacity,
shapeStrokeDash,
shapeStrokeWidth,
});
let obj: CanvasObject; let obj: CanvasObject;
switch (activeTool) { switch (activeTool) {
@@ -91,10 +118,11 @@ export function useShapeTool() {
y, y,
width: 0, width: 0,
height: 0, height: 0,
fill: shapeFill, fill,
stroke: shapeStroke, stroke,
strokeWidth: shapeStrokeWidth, strokeWidth: shapeStrokeWidth,
cornerRadius: shapeCornerRadius, cornerRadius: shapeCornerRadius,
dash,
rotation: 0, rotation: 0,
opacity: 1, opacity: 1,
}, },
@@ -110,9 +138,10 @@ export function useShapeTool() {
y, y,
radiusX: 0, radiusX: 0,
radiusY: 0, radiusY: 0,
fill: shapeFill, fill,
stroke: shapeStroke, stroke,
strokeWidth: shapeStrokeWidth, strokeWidth: shapeStrokeWidth,
dash,
rotation: 0, rotation: 0,
opacity: 1, opacity: 1,
}, },
@@ -125,11 +154,12 @@ export function useShapeTool() {
layerId: activeLayerId, layerId: activeLayerId,
attrs: { attrs: {
points: [x, y, x, y], points: [x, y, x, y],
stroke: shapeStroke, stroke,
strokeWidth: shapeStrokeWidth, strokeWidth: shapeStrokeWidth,
tension: 0, tension: 0,
lineCap: "round", lineCap: "round",
lineJoin: "round", lineJoin: "round",
dash,
opacity: 1, opacity: 1,
globalCompositeOperation: "source-over", globalCompositeOperation: "source-over",
}, },
@@ -142,11 +172,12 @@ export function useShapeTool() {
layerId: activeLayerId, layerId: activeLayerId,
attrs: { attrs: {
points: [x, y, x, y], points: [x, y, x, y],
fill: shapeFill, fill,
stroke: shapeStroke, stroke,
strokeWidth: shapeStrokeWidth, strokeWidth: shapeStrokeWidth,
pointerLength: 15, pointerLength: 15,
pointerWidth: 12, pointerWidth: 12,
dash,
rotation: 0, rotation: 0,
opacity: 1, opacity: 1,
}, },
@@ -162,9 +193,10 @@ export function useShapeTool() {
y, y,
sides: shapePolygonSides, sides: shapePolygonSides,
radius: 0, radius: 0,
fill: shapeFill, fill,
stroke: shapeStroke, stroke,
strokeWidth: shapeStrokeWidth, strokeWidth: shapeStrokeWidth,
dash,
rotation: 0, rotation: 0,
opacity: 1, opacity: 1,
}, },
@@ -181,9 +213,10 @@ export function useShapeTool() {
numPoints: shapeStarPoints, numPoints: shapeStarPoints,
innerRadius: 0, innerRadius: 0,
outerRadius: 0, outerRadius: 0,
fill: shapeFill, fill,
stroke: shapeStroke, stroke,
strokeWidth: shapeStrokeWidth, strokeWidth: shapeStrokeWidth,
dash,
rotation: 0, rotation: 0,
opacity: 1, opacity: 1,
}, },
@@ -193,12 +226,10 @@ export function useShapeTool() {
return; return;
} }
// Don't add the object yet -- wait until the user drags past the threshold
pendingRef.current = { startX: x, startY: y, toolType: activeTool, obj }; pendingRef.current = { startX: x, startY: y, toolType: activeTool, obj };
}, []); }, []);
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => { const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
// If we have a pending shape but haven't committed it yet, check threshold
if (pendingRef.current && !dragRef.current) { if (pendingRef.current && !dragRef.current) {
const stage = e.target.getStage(); const stage = e.target.getStage();
if (!stage) return; if (!stage) return;
@@ -214,7 +245,6 @@ export function useShapeTool() {
if (Math.abs(dx) < MIN_DRAG_THRESHOLD && Math.abs(dy) < MIN_DRAG_THRESHOLD) return; if (Math.abs(dx) < MIN_DRAG_THRESHOLD && Math.abs(dy) < MIN_DRAG_THRESHOLD) return;
// Threshold exceeded -- add the object to the store and promote to active drag
const { obj, startX, startY, toolType } = pendingRef.current; const { obj, startX, startY, toolType } = pendingRef.current;
useEditorStore.getState().addObject(obj); useEditorStore.getState().addObject(obj);
dragRef.current = { startX, startY, objectId: obj.id, toolType }; dragRef.current = { startX, startY, objectId: obj.id, toolType };
@@ -313,7 +343,6 @@ export function useShapeTool() {
}, []); }, []);
const handleMouseUp = useCallback(() => { const handleMouseUp = useCallback(() => {
// Click without drag -- pending shape was never added, just discard it
if (pendingRef.current) { if (pendingRef.current) {
pendingRef.current = null; pendingRef.current = null;
} }
@@ -325,7 +354,6 @@ export function useShapeTool() {
const obj = objects.find((o) => o.id === objectId); const obj = objects.find((o) => o.id === objectId);
if (obj) { if (obj) {
// Remove degenerate shapes that are still too small
const attrs = obj.attrs; const attrs = obj.attrs;
let isDegenerate = false; let isDegenerate = false;
if ("width" in attrs && "height" in attrs) { if ("width" in attrs && "height" in attrs) {
+32 -2
View File
@@ -10,9 +10,31 @@ import type {
EditorState, EditorState,
FilterConfig, FilterConfig,
SelectionMode, SelectionMode,
StrokeDashStyle,
ToolType, ToolType,
} from "@/types/editor"; } from "@/types/editor";
export function hexToRgba(hex: string, opacity: number): string {
const r = Number.parseInt(hex.slice(1, 3), 16);
const g = Number.parseInt(hex.slice(3, 5), 16);
const b = Number.parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
}
export function dashStyleToArray(
style: StrokeDashStyle,
strokeWidth: number,
): number[] | undefined {
switch (style) {
case "dashed":
return [strokeWidth * 4, strokeWidth * 2];
case "dotted":
return [strokeWidth, strokeWidth * 2];
default:
return undefined;
}
}
// Helpers for objects that use points arrays (line, arrow) vs positioned objects // Helpers for objects that use points arrays (line, arrow) vs positioned objects
function hasPointsArray(obj: CanvasObject): obj is CanvasObject & { attrs: { points: number[] } } { function hasPointsArray(obj: CanvasObject): obj is CanvasObject & { attrs: { points: number[] } } {
return "points" in obj.attrs && Array.isArray((obj.attrs as { points: number[] }).points); return "points" in obj.attrs && Array.isArray((obj.attrs as { points: number[] }).points);
@@ -165,9 +187,12 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
editingTextId: null, editingTextId: null,
// --- Shape settings --- // --- Shape settings ---
shapeFill: "#3b82f6", shapeFill: "#3b82f6" as string | null,
shapeStroke: "#000000", shapeFillOpacity: 1,
shapeStroke: "#000000" as string | null,
shapeStrokeOpacity: 1,
shapeStrokeWidth: 2, shapeStrokeWidth: 2,
shapeStrokeDash: "solid" as const,
shapeCornerRadius: 0, shapeCornerRadius: 0,
shapePolygonSides: 6, shapePolygonSides: 6,
shapeStarPoints: 5, shapeStarPoints: 5,
@@ -1148,8 +1173,13 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
// Shape settings // Shape settings
setShapeFill: (fill) => set({ shapeFill: fill }), setShapeFill: (fill) => set({ shapeFill: fill }),
setShapeFillOpacity: (opacity) =>
set({ shapeFillOpacity: Math.max(0, Math.min(1, opacity)) }),
setShapeStroke: (stroke) => set({ shapeStroke: stroke }), setShapeStroke: (stroke) => set({ shapeStroke: stroke }),
setShapeStrokeOpacity: (opacity) =>
set({ shapeStrokeOpacity: Math.max(0, Math.min(1, opacity)) }),
setShapeStrokeWidth: (width) => set({ shapeStrokeWidth: width }), setShapeStrokeWidth: (width) => set({ shapeStrokeWidth: width }),
setShapeStrokeDash: (dash) => set({ shapeStrokeDash: dash }),
setShapeCornerRadius: (radius) => set({ shapeCornerRadius: radius }), setShapeCornerRadius: (radius) => set({ shapeCornerRadius: radius }),
setShapePolygonSides: (sides) => set({ shapePolygonSides: sides }), setShapePolygonSides: (sides) => set({ shapePolygonSides: sides }),
setShapeStarPoints: (points) => set({ shapeStarPoints: points }), setShapeStarPoints: (points) => set({ shapeStarPoints: points }),
+29 -15
View File
@@ -2,6 +2,8 @@
export type SelectionMode = "new" | "add" | "subtract"; export type SelectionMode = "new" | "add" | "subtract";
export type StrokeDashStyle = "solid" | "dashed" | "dotted";
export type ToolType = export type ToolType =
| "move" | "move"
| "marquee-rect" | "marquee-rect"
@@ -36,11 +38,12 @@ export type ToolType =
export interface LineAttrs { export interface LineAttrs {
points: number[]; points: number[];
stroke: string; stroke?: string;
strokeWidth: number; strokeWidth: number;
tension: number; tension: number;
lineCap: "butt" | "round" | "square"; lineCap: "butt" | "round" | "square";
lineJoin: "bevel" | "round" | "miter"; lineJoin: "bevel" | "round" | "miter";
dash?: number[];
opacity: number; opacity: number;
globalCompositeOperation: string; globalCompositeOperation: string;
shadowBlur?: number; shadowBlur?: number;
@@ -54,10 +57,11 @@ export interface RectAttrs {
y: number; y: number;
width: number; width: number;
height: number; height: number;
fill: string; fill?: string;
stroke: string; stroke?: string;
strokeWidth: number; strokeWidth: number;
cornerRadius: number; cornerRadius: number;
dash?: number[];
rotation: number; rotation: number;
opacity: number; opacity: number;
} }
@@ -67,9 +71,10 @@ export interface EllipseAttrs {
y: number; y: number;
radiusX: number; radiusX: number;
radiusY: number; radiusY: number;
fill: string; fill?: string;
stroke: string; stroke?: string;
strokeWidth: number; strokeWidth: number;
dash?: number[];
rotation: number; rotation: number;
opacity: number; opacity: number;
} }
@@ -106,11 +111,12 @@ export interface ImageAttrs {
export interface ArrowAttrs { export interface ArrowAttrs {
points: number[]; points: number[];
fill: string; fill?: string;
stroke: string; stroke?: string;
strokeWidth: number; strokeWidth: number;
pointerLength: number; pointerLength: number;
pointerWidth: number; pointerWidth: number;
dash?: number[];
rotation: number; rotation: number;
opacity: number; opacity: number;
} }
@@ -120,9 +126,10 @@ export interface PolygonAttrs {
y: number; y: number;
sides: number; sides: number;
radius: number; radius: number;
fill: string; fill?: string;
stroke: string; stroke?: string;
strokeWidth: number; strokeWidth: number;
dash?: number[];
rotation: number; rotation: number;
opacity: number; opacity: number;
} }
@@ -133,9 +140,10 @@ export interface StarAttrs {
numPoints: number; numPoints: number;
innerRadius: number; innerRadius: number;
outerRadius: number; outerRadius: number;
fill: string; fill?: string;
stroke: string; stroke?: string;
strokeWidth: number; strokeWidth: number;
dash?: number[];
rotation: number; rotation: number;
opacity: number; opacity: number;
} }
@@ -305,9 +313,12 @@ export interface EditorState {
editingTextId: string | null; editingTextId: string | null;
// Shape settings // Shape settings
shapeFill: string; shapeFill: string | null;
shapeStroke: string; shapeFillOpacity: number;
shapeStroke: string | null;
shapeStrokeOpacity: number;
shapeStrokeWidth: number; shapeStrokeWidth: number;
shapeStrokeDash: StrokeDashStyle;
shapeCornerRadius: number; shapeCornerRadius: number;
shapePolygonSides: number; shapePolygonSides: number;
shapeStarPoints: number; shapeStarPoints: number;
@@ -455,9 +466,12 @@ export interface EditorState {
setSpongeFlow: (flow: number) => void; setSpongeFlow: (flow: number) => void;
// Shape settings // Shape settings
setShapeFill: (fill: string) => void; setShapeFill: (fill: string | null) => void;
setShapeStroke: (stroke: string) => void; setShapeFillOpacity: (opacity: number) => void;
setShapeStroke: (stroke: string | null) => void;
setShapeStrokeOpacity: (opacity: number) => void;
setShapeStrokeWidth: (width: number) => void; setShapeStrokeWidth: (width: number) => void;
setShapeStrokeDash: (dash: StrokeDashStyle) => void;
setShapeCornerRadius: (radius: number) => void; setShapeCornerRadius: (radius: number) => void;
setShapePolygonSides: (sides: number) => void; setShapePolygonSides: (sides: number) => void;
setShapeStarPoints: (points: number) => void; setShapeStarPoints: (points: number) => void;
+21
View File
@@ -1365,6 +1365,27 @@ export const ar: TranslationKeys = {
errors: { errors: {
serverDecodeFailed: "فشل فك الترميز من الخادم", serverDecodeFailed: "فشل فك الترميز من الخادم",
}, },
shapes: {
shape: "Shape",
fill: "Fill",
stroke: "Stroke",
strokeWidth: "Width",
cornerRadius: "Radius",
sides: "Sides",
points: "Points",
dashStyle: "Dash",
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
none: "None",
opacity: "Opacity",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
arrow: "Arrow",
polygon: "Polygon",
star: "Star",
},
}, },
settings: { settings: {
heading: "الإعدادات", heading: "الإعدادات",
+21
View File
@@ -1379,6 +1379,27 @@ export const de: TranslationKeys = {
errors: { errors: {
serverDecodeFailed: "Server-Dekodierung fehlgeschlagen", serverDecodeFailed: "Server-Dekodierung fehlgeschlagen",
}, },
shapes: {
shape: "Shape",
fill: "Fill",
stroke: "Stroke",
strokeWidth: "Width",
cornerRadius: "Radius",
sides: "Sides",
points: "Points",
dashStyle: "Dash",
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
none: "None",
opacity: "Opacity",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
arrow: "Arrow",
polygon: "Polygon",
star: "Star",
},
}, },
settings: { settings: {
heading: "Einstellungen", heading: "Einstellungen",
+21
View File
@@ -1324,6 +1324,27 @@ export const en = {
errors: { errors: {
serverDecodeFailed: "Server decode failed", serverDecodeFailed: "Server decode failed",
}, },
shapes: {
shape: "Shape",
fill: "Fill",
stroke: "Stroke",
strokeWidth: "Width",
cornerRadius: "Radius",
sides: "Sides",
points: "Points",
dashStyle: "Dash",
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
none: "None",
opacity: "Opacity",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
arrow: "Arrow",
polygon: "Polygon",
star: "Star",
},
}, },
settings: { settings: {
heading: "Settings", heading: "Settings",
+21
View File
@@ -1361,6 +1361,27 @@ export const es: TranslationKeys = {
errors: { errors: {
serverDecodeFailed: "Error de decodificacion del servidor", serverDecodeFailed: "Error de decodificacion del servidor",
}, },
shapes: {
shape: "Shape",
fill: "Fill",
stroke: "Stroke",
strokeWidth: "Width",
cornerRadius: "Radius",
sides: "Sides",
points: "Points",
dashStyle: "Dash",
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
none: "None",
opacity: "Opacity",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
arrow: "Arrow",
polygon: "Polygon",
star: "Star",
},
}, },
settings: { settings: {
heading: "Configuracion", heading: "Configuracion",
+21
View File
@@ -1380,6 +1380,27 @@ export const fr: TranslationKeys = {
errors: { errors: {
serverDecodeFailed: "Echec du decodage serveur", serverDecodeFailed: "Echec du decodage serveur",
}, },
shapes: {
shape: "Shape",
fill: "Fill",
stroke: "Stroke",
strokeWidth: "Width",
cornerRadius: "Radius",
sides: "Sides",
points: "Points",
dashStyle: "Dash",
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
none: "None",
opacity: "Opacity",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
arrow: "Arrow",
polygon: "Polygon",
star: "Star",
},
}, },
settings: { settings: {
heading: "Parametres", heading: "Parametres",
+21
View File
@@ -1362,6 +1362,27 @@ export const hi: TranslationKeys = {
errors: { errors: {
serverDecodeFailed: "सर्वर डीकोड विफल", serverDecodeFailed: "सर्वर डीकोड विफल",
}, },
shapes: {
shape: "Shape",
fill: "Fill",
stroke: "Stroke",
strokeWidth: "Width",
cornerRadius: "Radius",
sides: "Sides",
points: "Points",
dashStyle: "Dash",
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
none: "None",
opacity: "Opacity",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
arrow: "Arrow",
polygon: "Polygon",
star: "Star",
},
}, },
settings: { settings: {
heading: "सेटिंग्स", heading: "सेटिंग्स",
+21
View File
@@ -1374,6 +1374,27 @@ export const id: TranslationKeys = {
errors: { errors: {
serverDecodeFailed: "Dekoding server gagal", serverDecodeFailed: "Dekoding server gagal",
}, },
shapes: {
shape: "Shape",
fill: "Fill",
stroke: "Stroke",
strokeWidth: "Width",
cornerRadius: "Radius",
sides: "Sides",
points: "Points",
dashStyle: "Dash",
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
none: "None",
opacity: "Opacity",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
arrow: "Arrow",
polygon: "Polygon",
star: "Star",
},
}, },
settings: { settings: {
heading: "Pengaturan", heading: "Pengaturan",
+21
View File
@@ -1373,6 +1373,27 @@ export const it: TranslationKeys = {
errors: { errors: {
serverDecodeFailed: "Decodifica del server non riuscita", serverDecodeFailed: "Decodifica del server non riuscita",
}, },
shapes: {
shape: "Shape",
fill: "Fill",
stroke: "Stroke",
strokeWidth: "Width",
cornerRadius: "Radius",
sides: "Sides",
points: "Points",
dashStyle: "Dash",
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
none: "None",
opacity: "Opacity",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
arrow: "Arrow",
polygon: "Polygon",
star: "Star",
},
}, },
settings: { settings: {
heading: "Impostazioni", heading: "Impostazioni",
+21
View File
@@ -1332,6 +1332,27 @@ export const ja: TranslationKeys = {
errors: { errors: {
serverDecodeFailed: "サーバーでのデコードに失敗しました", serverDecodeFailed: "サーバーでのデコードに失敗しました",
}, },
shapes: {
shape: "Shape",
fill: "Fill",
stroke: "Stroke",
strokeWidth: "Width",
cornerRadius: "Radius",
sides: "Sides",
points: "Points",
dashStyle: "Dash",
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
none: "None",
opacity: "Opacity",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
arrow: "Arrow",
polygon: "Polygon",
star: "Star",
},
}, },
settings: { settings: {
heading: "設定", heading: "設定",
+21
View File
@@ -1317,6 +1317,27 @@ export const ko: TranslationKeys = {
errors: { errors: {
serverDecodeFailed: "서버 디코딩 실패", serverDecodeFailed: "서버 디코딩 실패",
}, },
shapes: {
shape: "Shape",
fill: "Fill",
stroke: "Stroke",
strokeWidth: "Width",
cornerRadius: "Radius",
sides: "Sides",
points: "Points",
dashStyle: "Dash",
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
none: "None",
opacity: "Opacity",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
arrow: "Arrow",
polygon: "Polygon",
star: "Star",
},
}, },
settings: { settings: {
heading: "설정", heading: "설정",
+21
View File
@@ -1376,6 +1376,27 @@ export const nl: TranslationKeys = {
errors: { errors: {
serverDecodeFailed: "Server-decodering mislukt", serverDecodeFailed: "Server-decodering mislukt",
}, },
shapes: {
shape: "Shape",
fill: "Fill",
stroke: "Stroke",
strokeWidth: "Width",
cornerRadius: "Radius",
sides: "Sides",
points: "Points",
dashStyle: "Dash",
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
none: "None",
opacity: "Opacity",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
arrow: "Arrow",
polygon: "Polygon",
star: "Star",
},
}, },
settings: { settings: {
heading: "Instellingen", heading: "Instellingen",
+21
View File
@@ -1377,6 +1377,27 @@ export const pl: TranslationKeys = {
errors: { errors: {
serverDecodeFailed: "Błąd dekodowania na serwerze", serverDecodeFailed: "Błąd dekodowania na serwerze",
}, },
shapes: {
shape: "Shape",
fill: "Fill",
stroke: "Stroke",
strokeWidth: "Width",
cornerRadius: "Radius",
sides: "Sides",
points: "Points",
dashStyle: "Dash",
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
none: "None",
opacity: "Opacity",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
arrow: "Arrow",
polygon: "Polygon",
star: "Star",
},
}, },
settings: { settings: {
heading: "Ustawienia", heading: "Ustawienia",
+21
View File
@@ -1373,6 +1373,27 @@ export const ptBR: TranslationKeys = {
errors: { errors: {
serverDecodeFailed: "Falha na decodificacao do servidor", serverDecodeFailed: "Falha na decodificacao do servidor",
}, },
shapes: {
shape: "Shape",
fill: "Fill",
stroke: "Stroke",
strokeWidth: "Width",
cornerRadius: "Radius",
sides: "Sides",
points: "Points",
dashStyle: "Dash",
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
none: "None",
opacity: "Opacity",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
arrow: "Arrow",
polygon: "Polygon",
star: "Star",
},
}, },
settings: { settings: {
heading: "Configuracoes", heading: "Configuracoes",
+21
View File
@@ -1375,6 +1375,27 @@ export const ru: TranslationKeys = {
errors: { errors: {
serverDecodeFailed: "Ошибка декодирования на сервере", serverDecodeFailed: "Ошибка декодирования на сервере",
}, },
shapes: {
shape: "Shape",
fill: "Fill",
stroke: "Stroke",
strokeWidth: "Width",
cornerRadius: "Radius",
sides: "Sides",
points: "Points",
dashStyle: "Dash",
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
none: "None",
opacity: "Opacity",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
arrow: "Arrow",
polygon: "Polygon",
star: "Star",
},
}, },
settings: { settings: {
heading: "Настройки", heading: "Настройки",
+21
View File
@@ -1372,6 +1372,27 @@ export const sv: TranslationKeys = {
errors: { errors: {
serverDecodeFailed: "Serveravkodning misslyckades", serverDecodeFailed: "Serveravkodning misslyckades",
}, },
shapes: {
shape: "Shape",
fill: "Fill",
stroke: "Stroke",
strokeWidth: "Width",
cornerRadius: "Radius",
sides: "Sides",
points: "Points",
dashStyle: "Dash",
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
none: "None",
opacity: "Opacity",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
arrow: "Arrow",
polygon: "Polygon",
star: "Star",
},
}, },
settings: { settings: {
heading: "Installningar", heading: "Installningar",
+21
View File
@@ -1354,6 +1354,27 @@ export const th: TranslationKeys = {
errors: { errors: {
serverDecodeFailed: "การถอดรหัสจากเซิร์ฟเวอร์ล้มเหลว", serverDecodeFailed: "การถอดรหัสจากเซิร์ฟเวอร์ล้มเหลว",
}, },
shapes: {
shape: "Shape",
fill: "Fill",
stroke: "Stroke",
strokeWidth: "Width",
cornerRadius: "Radius",
sides: "Sides",
points: "Points",
dashStyle: "Dash",
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
none: "None",
opacity: "Opacity",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
arrow: "Arrow",
polygon: "Polygon",
star: "Star",
},
}, },
settings: { settings: {
heading: "ตั้งค่า", heading: "ตั้งค่า",
+21
View File
@@ -1377,6 +1377,27 @@ export const tr: TranslationKeys = {
errors: { errors: {
serverDecodeFailed: "Sunucu çözümleme hatası", serverDecodeFailed: "Sunucu çözümleme hatası",
}, },
shapes: {
shape: "Shape",
fill: "Fill",
stroke: "Stroke",
strokeWidth: "Width",
cornerRadius: "Radius",
sides: "Sides",
points: "Points",
dashStyle: "Dash",
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
none: "None",
opacity: "Opacity",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
arrow: "Arrow",
polygon: "Polygon",
star: "Star",
},
}, },
settings: { settings: {
heading: "Ayarlar", heading: "Ayarlar",
+21
View File
@@ -1375,6 +1375,27 @@ export const uk: TranslationKeys = {
errors: { errors: {
serverDecodeFailed: "Помилка декодування на сервері", serverDecodeFailed: "Помилка декодування на сервері",
}, },
shapes: {
shape: "Shape",
fill: "Fill",
stroke: "Stroke",
strokeWidth: "Width",
cornerRadius: "Radius",
sides: "Sides",
points: "Points",
dashStyle: "Dash",
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
none: "None",
opacity: "Opacity",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
arrow: "Arrow",
polygon: "Polygon",
star: "Star",
},
}, },
settings: { settings: {
heading: "Налаштування", heading: "Налаштування",
+21
View File
@@ -1374,6 +1374,27 @@ export const vi: TranslationKeys = {
errors: { errors: {
serverDecodeFailed: "Máy chủ giải mã thất bại", serverDecodeFailed: "Máy chủ giải mã thất bại",
}, },
shapes: {
shape: "Shape",
fill: "Fill",
stroke: "Stroke",
strokeWidth: "Width",
cornerRadius: "Radius",
sides: "Sides",
points: "Points",
dashStyle: "Dash",
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
none: "None",
opacity: "Opacity",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
arrow: "Arrow",
polygon: "Polygon",
star: "Star",
},
}, },
settings: { settings: {
heading: "Cài đặt", heading: "Cài đặt",
+21
View File
@@ -1306,6 +1306,27 @@ export const zhCN: TranslationKeys = {
errors: { errors: {
serverDecodeFailed: "服务器解码失败", serverDecodeFailed: "服务器解码失败",
}, },
shapes: {
shape: "Shape",
fill: "Fill",
stroke: "Stroke",
strokeWidth: "Width",
cornerRadius: "Radius",
sides: "Sides",
points: "Points",
dashStyle: "Dash",
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
none: "None",
opacity: "Opacity",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
arrow: "Arrow",
polygon: "Polygon",
star: "Star",
},
}, },
settings: { settings: {
heading: "设置", heading: "设置",
+21
View File
@@ -1304,6 +1304,27 @@ export const zhTW: TranslationKeys = {
errors: { errors: {
serverDecodeFailed: "伺服器解碼失敗", serverDecodeFailed: "伺服器解碼失敗",
}, },
shapes: {
shape: "Shape",
fill: "Fill",
stroke: "Stroke",
strokeWidth: "Width",
cornerRadius: "Radius",
sides: "Sides",
points: "Points",
dashStyle: "Dash",
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
none: "None",
opacity: "Opacity",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
arrow: "Arrow",
polygon: "Polygon",
star: "Star",
},
}, },
settings: { settings: {
heading: "設定", heading: "設定",