diff --git a/apps/web/src/components/editor/common/shape-color-picker.tsx b/apps/web/src/components/editor/common/shape-color-picker.tsx new file mode 100644 index 00000000..287f6279 --- /dev/null +++ b/apps/web/src/components/editor/common/shape-color-picker.tsx @@ -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(null); + const popoverRef = useRef(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 ( +
+ {label} + + + {open && + createPortal( +
+ {allowNone && ( + + )} + + {!isNone && ( + <> +
+ +
+ +
+ # + 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" + /> + + {Math.round(opacity * 100)}% + +
+ + )} +
, + document.body, + )} +
+ ); +} diff --git a/apps/web/src/components/editor/editor-canvas.tsx b/apps/web/src/components/editor/editor-canvas.tsx index b55ac77c..a3310a50 100644 --- a/apps/web/src/components/editor/editor-canvas.tsx +++ b/apps/web/src/components/editor/editor-canvas.tsx @@ -408,6 +408,7 @@ function CanvasObjectRenderer({ tension={a.tension} lineCap={a.lineCap} lineJoin={a.lineJoin} + dash={a.dash} opacity={a.opacity} globalCompositeOperation={ a.globalCompositeOperation as "source-over" | "destination-out" | undefined @@ -433,6 +434,7 @@ function CanvasObjectRenderer({ stroke={a.stroke} strokeWidth={a.strokeWidth} cornerRadius={a.cornerRadius} + dash={a.dash} rotation={a.rotation} opacity={a.opacity} draggable={draggable} @@ -457,6 +459,7 @@ function CanvasObjectRenderer({ fill={a.fill} stroke={a.stroke} strokeWidth={a.strokeWidth} + dash={a.dash} rotation={a.rotation} opacity={a.opacity} draggable={draggable} @@ -510,6 +513,7 @@ function CanvasObjectRenderer({ strokeWidth={a.strokeWidth} pointerLength={a.pointerLength} pointerWidth={a.pointerWidth} + dash={a.dash} rotation={a.rotation} opacity={a.opacity} draggable={draggable} @@ -534,6 +538,7 @@ function CanvasObjectRenderer({ fill={a.fill} stroke={a.stroke} strokeWidth={a.strokeWidth} + dash={a.dash} rotation={a.rotation} opacity={a.opacity} draggable={draggable} @@ -559,6 +564,7 @@ function CanvasObjectRenderer({ fill={a.fill} stroke={a.stroke} strokeWidth={a.strokeWidth} + dash={a.dash} rotation={a.rotation} opacity={a.opacity} draggable={draggable} diff --git a/apps/web/src/components/editor/options/shape-options.tsx b/apps/web/src/components/editor/options/shape-options.tsx index 6925b6a2..21afb689 100644 --- a/apps/web/src/components/editor/options/shape-options.tsx +++ b/apps/web/src/components/editor/options/shape-options.tsx @@ -1,7 +1,9 @@ // apps/web/src/components/editor/options/shape-options.tsx +import { useTranslation } from "@/contexts/i18n-context"; 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([ "shape-rect", @@ -12,38 +14,49 @@ const SHAPE_TOOLS = new Set([ "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() { - const activeTool = useEditorStore((s) => s.activeTool); - const setTool = useEditorStore((s) => s.setTool); - const shapeFill = useEditorStore((s) => s.shapeFill); - const shapeStroke = useEditorStore((s) => s.shapeStroke); - const shapeStrokeWidth = useEditorStore((s) => s.shapeStrokeWidth); - const shapeCornerRadius = useEditorStore((s) => s.shapeCornerRadius); - const shapePolygonSides = useEditorStore((s) => s.shapePolygonSides); - const shapeStarPoints = useEditorStore((s) => s.shapeStarPoints); - const setShapeFill = useEditorStore((s) => s.setShapeFill); - const setShapeStroke = useEditorStore((s) => s.setShapeStroke); - const setShapeStrokeWidth = useEditorStore((s) => s.setShapeStrokeWidth); - const setShapeCornerRadius = useEditorStore((s) => s.setShapeCornerRadius); - const setShapePolygonSides = useEditorStore((s) => s.setShapePolygonSides); - const setShapeStarPoints = useEditorStore((s) => s.setShapeStarPoints); + const { t } = useTranslation(); + const s = t.editor.shapes; + + const activeTool = useEditorStore((st) => st.activeTool); + const setTool = useEditorStore((st) => st.setTool); + const shapeFill = useEditorStore((st) => st.shapeFill); + const shapeFillOpacity = useEditorStore((st) => st.shapeFillOpacity); + const shapeStroke = useEditorStore((st) => st.shapeStroke); + const shapeStrokeOpacity = useEditorStore((st) => st.shapeStrokeOpacity); + const shapeStrokeWidth = useEditorStore((st) => st.shapeStrokeWidth); + const shapeStrokeDash = useEditorStore((st) => st.shapeStrokeDash); + const shapeCornerRadius = useEditorStore((st) => st.shapeCornerRadius); + const shapePolygonSides = useEditorStore((st) => st.shapePolygonSides); + 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; + 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 (
{/* Shape type selector */} + )} {/* Stroke color */} - + {/* Stroke width */} + {/* Stroke dash style */} + + {/* Corner radius (only for rect) */} {activeTool === "shape-rect" && (