diff --git a/frontend/src/components/canvas-zoom-slider.tsx b/frontend/src/components/canvas-zoom-slider.tsx index 7e64b3e..f22e28b 100644 --- a/frontend/src/components/canvas-zoom-slider.tsx +++ b/frontend/src/components/canvas-zoom-slider.tsx @@ -1,13 +1,13 @@ -import EditorRangeSlider from './editor-range-slider' +import EditorRangeSlider from "./editor-range-slider"; type CanvasZoomSliderProps = { - value: number - min?: number - max?: number - onChange: (value: number) => void - onFitRequest?: () => void - disabled?: boolean -} + value: number; + min?: number; + max?: number; + onChange: (value: number) => void; + onFitRequest?: () => void; + disabled?: boolean; +}; export default function CanvasZoomSlider({ value, @@ -39,13 +39,13 @@ export default function CanvasZoomSlider({ onClick={onFitRequest} className="min-w-[2.75rem] text-left text-sm tabular-nums text-neutral-600 outline-none hover:text-neutral-900 disabled:pointer-events-none disabled:opacity-40" > - {value}% + {Math.round(value)}% ) : ( - {value}% + {Math.round(value)}% )} - ) + ); } diff --git a/frontend/src/components/scene-editor.tsx b/frontend/src/components/scene-editor.tsx index 0612ccb..71d8191 100644 --- a/frontend/src/components/scene-editor.tsx +++ b/frontend/src/components/scene-editor.tsx @@ -9,9 +9,9 @@ import { useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, -} from 'react' -import { createPortal } from 'react-dom' -import { useStore } from 'zustand' +} from "react"; +import { createPortal } from "react-dom"; +import { useStore } from "zustand"; import { AVNAC_DOC_VERSION, cloneAvnacDocument, @@ -41,68 +41,66 @@ import { type SceneLine, type SceneObject, type SceneText, -} from '../lib/avnac-scene' -import { - AVNAC_VECTOR_BOARD_DRAG_MIME, -} from '../lib/avnac-vector-board-document' +} from "../lib/avnac-scene"; +import { AVNAC_VECTOR_BOARD_DRAG_MIME } from "../lib/avnac-vector-board-document"; import { layoutSceneText, renderAvnacDocumentToDataUrl, sceneTextLineHeight, -} from '../lib/avnac-scene-render' +} from "../lib/avnac-scene-render"; import { DEFAULT_SHADOW_UI, averageShadowUi, type ShadowUi, -} from '../lib/avnac-shadow' -import { loadImageMetadata } from '../lib/avnac-image-proxy' -import { extractImageUrlFromDataTransfer } from '../lib/extract-image-url-from-data-transfer' -import { loadGoogleFontFamily } from '../lib/load-google-font' -import { useViewportAwarePopoverPlacement } from '../hooks/use-viewport-aware-popover' -import TransparencyToolbarPopover from './transparency-toolbar-popover' -import type { PopoverShapeKind, ShapesQuickAddKind } from './shapes-popover' -import type { TextFormatToolbarValues } from './text-format-toolbar' -import type { BgValue } from './background-popover' -import BlurToolbarControl from './blur-toolbar-control' -import ShadowToolbarPopover from './shadow-toolbar-popover' -import StrokeToolbarPopover from './stroke-toolbar-popover' -import type { CanvasAlignKind } from './canvas-element-toolbar' -import { FloatingToolbarDivider } from './floating-toolbar-shell' +} from "../lib/avnac-shadow"; +import { loadImageMetadata } from "../lib/avnac-image-proxy"; +import { extractImageUrlFromDataTransfer } from "../lib/extract-image-url-from-data-transfer"; +import { loadGoogleFontFamily } from "../lib/load-google-font"; +import { useViewportAwarePopoverPlacement } from "../hooks/use-viewport-aware-popover"; +import TransparencyToolbarPopover from "./transparency-toolbar-popover"; +import type { PopoverShapeKind, ShapesQuickAddKind } from "./shapes-popover"; +import type { TextFormatToolbarValues } from "./text-format-toolbar"; +import type { BgValue } from "./background-popover"; +import BlurToolbarControl from "./blur-toolbar-control"; +import ShadowToolbarPopover from "./shadow-toolbar-popover"; +import StrokeToolbarPopover from "./stroke-toolbar-popover"; +import type { CanvasAlignKind } from "./canvas-element-toolbar"; +import { FloatingToolbarDivider } from "./floating-toolbar-shell"; import ImageCropModal, { type ImageCropModalApplyPayload, -} from './image-crop-modal' -import type { ExportImageOptions } from './editor-export-menu' -import type { EditorSidebarPanelId } from './editor-floating-sidebar' -import EditorShortcutsModal from './editor-shortcuts-modal' -import { AiControllerProvider } from './scene-editor/ai-controller-context' -import { CanvasStage } from './scene-editor/canvas-stage' +} from "./image-crop-modal"; +import type { ExportImageOptions } from "./editor-export-menu"; +import type { EditorSidebarPanelId } from "./editor-floating-sidebar"; +import EditorShortcutsModal from "./editor-shortcuts-modal"; +import { AiControllerProvider } from "./scene-editor/ai-controller-context"; +import { CanvasStage } from "./scene-editor/canvas-stage"; import { CanvasStageProvider, type CanvasStageContextValue, -} from './scene-editor/canvas-stage-context' -import { EditorBottomTools } from './scene-editor/editor-bottom-tools' +} from "./scene-editor/canvas-stage-context"; +import { EditorBottomTools } from "./scene-editor/editor-bottom-tools"; import { EditorContextMenu, type EditorContextMenuState, -} from './scene-editor/editor-context-menu' -import { EditorSidePanels } from './scene-editor/editor-side-panels' -import { EditorSelectionToolbar } from './scene-editor/editor-selection-toolbar' +} from "./scene-editor/editor-context-menu"; +import { EditorSidePanels } from "./scene-editor/editor-side-panels"; +import { EditorSelectionToolbar } from "./scene-editor/editor-selection-toolbar"; import { createEditorStore, EditorStoreProvider, type EditorStoreApi, -} from './scene-editor/editor-store' +} from "./scene-editor/editor-store"; import { EditorSelectionToolbarProvider, type EditorSelectionToolbarContextValue, -} from './scene-editor/editor-selection-toolbar-context' -import { useAiDesignController } from './scene-editor/use-ai-design-controller' -import { useEditorKeyboardShortcuts } from './scene-editor/use-editor-keyboard-shortcuts' -import { useSceneDocumentLifecycle } from './scene-editor/use-scene-document-lifecycle' +} from "./scene-editor/editor-selection-toolbar-context"; +import { useAiDesignController } from "./scene-editor/use-ai-design-controller"; +import { useEditorKeyboardShortcuts } from "./scene-editor/use-editor-keyboard-shortcuts"; +import { useSceneDocumentLifecycle } from "./scene-editor/use-scene-document-lifecycle"; import { useVectorBoardControls, VectorBoardControlsProvider, -} from './scene-editor/use-vector-board-controls' +} from "./scene-editor/use-vector-board-controls"; import { SNAP_DEADBAND_PX, angleFromPoints, @@ -133,39 +131,39 @@ import { type ResizeHandleId, type SceneSnapGuide, type TransformDimensionUi, -} from '../scene-engine/primitives' - -const DEFAULT_ARTBOARD_W = 4000 -const DEFAULT_ARTBOARD_H = 4000 -const ARTBOARD_ALIGN_PAD = 32 -const ZOOM_MIN_PCT = 5 -const ZOOM_MAX_PCT = 100 -const FIT_PADDING = 48 -const CLIPBOARD_PASTE_OFFSET = 24 -const DEFAULT_FILL: BgValue = { type: 'solid', color: '#262626' } -const DEFAULT_STROKE: BgValue = { type: 'solid', color: 'transparent' } -const DEFAULT_LINE_STROKE: BgValue = { type: 'solid', color: '#262626' } +} from "../scene-engine/primitives"; + +const DEFAULT_ARTBOARD_W = 4000; +const DEFAULT_ARTBOARD_H = 4000; +const ARTBOARD_ALIGN_PAD = 32; +const ZOOM_MIN_PCT = 5; +const ZOOM_MAX_PCT = 500; +const FIT_PADDING = 48; +const CLIPBOARD_PASTE_OFFSET = 24; +const DEFAULT_FILL: BgValue = { type: "solid", color: "#262626" }; +const DEFAULT_STROKE: BgValue = { type: "solid", color: "transparent" }; +const DEFAULT_LINE_STROKE: BgValue = { type: "solid", color: "#262626" }; export type SceneEditorHandle = { - exportImage: (opts?: ExportImageOptions) => void - saveDocument: () => void - loadDocument: (file: File) => Promise -} + exportImage: (opts?: ExportImageOptions) => void; + saveDocument: () => void; + loadDocument: (file: File) => Promise; +}; type SceneEditorProps = { - onReadyChange?: (ready: boolean) => void - persistId?: string - persistDisplayName?: string - initialArtboardWidth?: number - initialArtboardHeight?: number -} + onReadyChange?: (ready: boolean) => void; + persistId?: string; + persistDisplayName?: string; + initialArtboardWidth?: number; + initialArtboardHeight?: number; +}; function artboardAlignAlreadySatisfied( bounds: { left: number; top: number; width: number; height: number }, boardW: number, - boardH: number, + boardH: number ): Record { - const pad = ARTBOARD_ALIGN_PAD + const pad = ARTBOARD_ALIGN_PAD; return { left: Math.abs(bounds.left - pad) <= 2, centerH: Math.abs(bounds.left + bounds.width / 2 - boardW / 2) <= 2, @@ -173,29 +171,33 @@ function artboardAlignAlreadySatisfied( top: Math.abs(bounds.top - pad) <= 2, centerV: Math.abs(bounds.top + bounds.height / 2 - boardH / 2) <= 2, bottom: Math.abs(bounds.top + bounds.height - (boardH - pad)) <= 2, - } + }; } function computeTransformDimensionUi( frameEl: HTMLElement, sceneW: number, sceneH: number, - bounds: { left: number; top: number; width: number; height: number }, + bounds: { left: number; top: number; width: number; height: number } ): TransformDimensionUi | null { - const frameRect = frameEl.getBoundingClientRect() - if (frameRect.width <= 0 || frameRect.height <= 0 || sceneW <= 0 || sceneH <= 0) { - return null + const frameRect = frameEl.getBoundingClientRect(); + if ( + frameRect.width <= 0 || + frameRect.height <= 0 || + sceneW <= 0 || + sceneH <= 0 + ) { + return null; } - const sx = frameRect.width / sceneW - const sy = frameRect.height / sceneH + const sx = frameRect.width / sceneW; + const sy = frameRect.height / sceneH; return { left: frameRect.left + (bounds.left + bounds.width) * sx + 8, top: frameRect.top + (bounds.top + bounds.height) * sy + 8, - text: `w: ${Math.round(bounds.width).toLocaleString('en-US')} h: ${Math.round(bounds.height).toLocaleString('en-US')}`, - } + text: `w: ${Math.round(bounds.width).toLocaleString("en-US")} h: ${Math.round(bounds.height).toLocaleString("en-US")}`, + }; } - const SceneEditor = forwardRef( function SceneEditor( { @@ -205,85 +207,90 @@ const SceneEditor = forwardRef( initialArtboardWidth, initialArtboardHeight, }, - ref, + ref ) { - const persistIdRef = useRef(persistId) - persistIdRef.current = persistId + const persistIdRef = useRef(persistId); + persistIdRef.current = persistId; const persistDisplayNameRef = useRef( - persistDisplayName?.trim() || 'Untitled', - ) - persistDisplayNameRef.current = persistDisplayName?.trim() || 'Untitled' - - const viewportRef = useRef(null) - const artboardOuterRef = useRef(null) - const artboardInnerRef = useRef(null) - const elementToolbarRef = useRef(null) - const selectionToolsRef = useRef(null) - const shapeToolSplitRef = useRef(null) - const imageInputRef = useRef(null) - const zoomUserAdjustedRef = useRef(false) - const historyRef = useRef([]) - const historyIndexRef = useRef(-1) - const applyingHistoryRef = useRef(false) - const dragStateRef = useRef(null) - const autosaveTimerRef = useRef(null) - const historyTimerRef = useRef(null) - const snapGuideXRef = useRef(null) - const snapGuideYRef = useRef(null) - const editorStoreRef = useRef(null) + persistDisplayName?.trim() || "Untitled" + ); + persistDisplayNameRef.current = persistDisplayName?.trim() || "Untitled"; + + const viewportRef = useRef(null); + const artboardOuterRef = useRef(null); + const artboardInnerRef = useRef(null); + const elementToolbarRef = useRef(null); + const selectionToolsRef = useRef(null); + const shapeToolSplitRef = useRef(null); + const imageInputRef = useRef(null); + const zoomUserAdjustedRef = useRef(false); + const historyRef = useRef([]); + const historyIndexRef = useRef(-1); + const applyingHistoryRef = useRef(false); + const dragStateRef = useRef(null); + const autosaveTimerRef = useRef(null); + const historyTimerRef = useRef(null); + const snapGuideXRef = useRef(null); + const snapGuideYRef = useRef(null); + const editorStoreRef = useRef(null); if (!editorStoreRef.current) { editorStoreRef.current = createEditorStore( createEmptyAvnacDocument( clampDimension(initialArtboardWidth, DEFAULT_ARTBOARD_W), - clampDimension(initialArtboardHeight, DEFAULT_ARTBOARD_H), - ), - ) + clampDimension(initialArtboardHeight, DEFAULT_ARTBOARD_H) + ) + ); } - const editorStore = editorStoreRef.current - const doc = useStore(editorStore, (state) => state.doc) - const setDoc = useStore(editorStore, (state) => state.setDoc) - const selectedIds = useStore(editorStore, (state) => state.selectedIds) - const setSelectedIds = useStore(editorStore, (state) => state.setSelectedIds) - const setHoveredId = useStore(editorStore, (state) => state.setHoveredId) - - const [ready, setReady] = useState(false) - const [zoomPercent, setZoomPercent] = useState(null) + const editorStore = editorStoreRef.current; + const doc = useStore(editorStore, (state) => state.doc); + const setDoc = useStore(editorStore, (state) => state.setDoc); + const selectedIds = useStore(editorStore, (state) => state.selectedIds); + const setSelectedIds = useStore( + editorStore, + (state) => state.setSelectedIds + ); + const setHoveredId = useStore(editorStore, (state) => state.setHoveredId); + + const [ready, setReady] = useState(false); + const [zoomPercent, setZoomPercent] = useState(null); + const [panX, setPanX] = useState(0); + const [panY, setPanY] = useState(0); const [editorSidebarPanel, setEditorSidebarPanel] = - useState(null) - const [bgPopoverOpen, setBgPopoverOpen] = useState(false) - const [shortcutsOpen, setShortcutsOpen] = useState(false) - const [shapesPopoverOpen, setShapesPopoverOpen] = useState(false) + useState(null); + const [bgPopoverOpen, setBgPopoverOpen] = useState(false); + const [shortcutsOpen, setShortcutsOpen] = useState(false); + const [shapesPopoverOpen, setShapesPopoverOpen] = useState(false); const [shapesQuickAddKind, setShapesQuickAddKind] = - useState('generic') - const [imageCropOpen, setImageCropOpen] = useState(false) - const [imageCropSrc, setImageCropSrc] = useState('') + useState("generic"); + const [imageCropOpen, setImageCropOpen] = useState(false); + const [imageCropSrc, setImageCropSrc] = useState(""); const [imageCropInitial, setImageCropInitial] = useState({ x: 0, y: 0, w: 0, h: 0, - }) - const imageCropTargetIdRef = useRef(null) - const [exportError, setExportError] = useState(null) + }); + const imageCropTargetIdRef = useRef(null); + const [exportError, setExportError] = useState(null); const [contextMenu, setContextMenu] = - useState(null) - const [textEditingId, setTextEditingId] = useState(null) - const [textDraft, setTextDraft] = useState('') - const [backgroundActive, setBackgroundActive] = useState(false) - const [backgroundHovered, setBackgroundHovered] = useState(false) - const [marqueeRect, setMarqueeRect] = useState(null) - const [snapGuides, setSnapGuides] = useState([]) - const [, setSelectionRev] = useState(0) + useState(null); + const [textEditingId, setTextEditingId] = useState(null); + const [textDraft, setTextDraft] = useState(""); + const [backgroundActive, setBackgroundActive] = useState(false); + const [backgroundHovered, setBackgroundHovered] = useState(false); + const [marqueeRect, setMarqueeRect] = useState(null); + const [snapGuides, setSnapGuides] = useState([]); + const [, setSelectionRev] = useState(0); const [transformDimensionUi, setTransformDimensionUi] = - useState(null) + useState(null); - const backgroundPopoverAnchorRef = useRef(null) - const backgroundPopoverPanelRef = useRef(null) + const backgroundPopoverAnchorRef = useRef(null); + const backgroundPopoverPanelRef = useRef(null); const pickBackgroundPopoverPanel = useCallback( () => backgroundPopoverPanelRef.current, - [], - ) + [] + ); const { openUpward: backgroundPopoverOpenUpward, shiftX: backgroundPopoverShiftX, @@ -292,66 +299,74 @@ const SceneEditor = forwardRef( backgroundPopoverAnchorRef, 440, pickBackgroundPopoverPanel, - 'center', - ) + "center" + ); useEffect(() => { - if (!bgPopoverOpen) return + if (!bgPopoverOpen) return; const onDown = (e: MouseEvent) => { - if (backgroundPopoverAnchorRef.current?.contains(e.target as Node)) return - setBgPopoverOpen(false) - } + if (backgroundPopoverAnchorRef.current?.contains(e.target as Node)) + return; + setBgPopoverOpen(false); + }; const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setBgPopoverOpen(false) - } - document.addEventListener('mousedown', onDown) - document.addEventListener('keydown', onKey) + if (e.key === "Escape") setBgPopoverOpen(false); + }; + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); return () => { - document.removeEventListener('mousedown', onDown) - document.removeEventListener('keydown', onKey) - } - }, [bgPopoverOpen]) - - const scale = (zoomPercent ?? 100) / 100 - const artboardW = doc.artboard.width - const artboardH = doc.artboard.height + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [bgPopoverOpen]); + + const scale = (zoomPercent ?? 100) / 100; + const artboardW = doc.artboard.width; + const artboardH = doc.artboard.height; const selectedObjects = useMemo( () => doc.objects.filter((obj) => selectedIds.includes(obj.id)), - [doc.objects, selectedIds], - ) - const selectedSingle = selectedObjects.length === 1 ? selectedObjects[0] : null + [doc.objects, selectedIds] + ); + const selectedSingle = + selectedObjects.length === 1 ? selectedObjects[0] : null; const editingSelectedText = - selectedSingle?.type === 'text' && textEditingId === selectedSingle.id - const hasObjectSelected = selectedObjects.length > 0 - const canvasBodySelected = ready && !hasObjectSelected + selectedSingle?.type === "text" && textEditingId === selectedSingle.id; + const hasObjectSelected = selectedObjects.length > 0; + const canvasBodySelected = ready && !hasObjectSelected; useEffect(() => { if (!canvasBodySelected && bgPopoverOpen) { - setBgPopoverOpen(false) + setBgPopoverOpen(false); } - }, [bgPopoverOpen, canvasBodySelected]) + }, [bgPopoverOpen, canvasBodySelected]); const fitZoom = useCallback(() => { - const viewport = viewportRef.current - if (!viewport) return - const availW = Math.max(200, viewport.clientWidth - FIT_PADDING * 2) - const availH = Math.max(200, viewport.clientHeight - FIT_PADDING * 2) + const viewport = viewportRef.current; + if (!viewport) return; + const availW = Math.max(200, viewport.clientWidth - FIT_PADDING * 2); + const availH = Math.max(200, viewport.clientHeight - FIT_PADDING * 2); const pct = Math.max( ZOOM_MIN_PCT, Math.min( ZOOM_MAX_PCT, Math.floor( - Math.min(availW / Math.max(1, artboardW), availH / Math.max(1, artboardH)) * 100, - ), - ), - ) - setZoomPercent(pct) - }, [artboardH, artboardW]) + Math.min( + availW / Math.max(1, artboardW), + availH / Math.max(1, artboardH) + ) * 100 + ) + ) + ); + const fitScale = pct / 100; + setPanX((viewport.clientWidth - artboardW * fitScale) / 2); + setPanY((viewport.clientHeight - artboardH * fitScale) / 2); + setZoomPercent(pct); + }, [artboardH, artboardW]); useLayoutEffect(() => { - if (zoomPercent === null || zoomUserAdjustedRef.current) return - fitZoom() - }, [artboardW, artboardH, fitZoom, zoomPercent]) + if (zoomPercent === null || zoomUserAdjustedRef.current) return; + fitZoom(); + }, [artboardW, artboardH, fitZoom, zoomPercent]); useSceneDocumentLifecycle({ applyingHistoryRef, @@ -375,197 +390,221 @@ const SceneEditor = forwardRef( setTextEditingId, setZoomPercent, zoomUserAdjustedRef, - }) + }); useEffect(() => { - if (!exportError) return - const timer = window.setTimeout(() => setExportError(null), 4500) - return () => window.clearTimeout(timer) - }, [exportError]) + if (!exportError) return; + const timer = window.setTimeout(() => setExportError(null), 4500); + return () => window.clearTimeout(timer); + }, [exportError]); const selectionBounds = useMemo( () => getSelectionBounds(selectedObjects), - [selectedObjects], - ) + [selectedObjects] + ); const textToolbarValues = useMemo(() => { - if (!selectedSingle || selectedSingle.type !== 'text') return null + if (!selectedSingle || selectedSingle.type !== "text") return null; return { fontFamily: selectedSingle.fontFamily, fontSize: selectedSingle.fontSize, fillStyle: selectedSingle.fill, textAlign: selectedSingle.textAlign, bold: - selectedSingle.fontWeight === 'bold' || + selectedSingle.fontWeight === "bold" || selectedSingle.fontWeight === 700 || selectedSingle.fontWeight === 600, - italic: selectedSingle.fontStyle === 'italic', + italic: selectedSingle.fontStyle === "italic", underline: selectedSingle.underline, - } - }, [selectedSingle]) + }; + }, [selectedSingle]); const shapeToolbarModel = useMemo(() => { - if (!selectedSingle) return null - const meta = sceneObjectToShapeMeta(selectedSingle) - if (!meta) return null + if (!selectedSingle) return null; + const meta = sceneObjectToShapeMeta(selectedSingle); + if (!meta) return null; const paint = - selectedSingle.type === 'line' || selectedSingle.type === 'arrow' + selectedSingle.type === "line" || selectedSingle.type === "arrow" ? selectedSingle.stroke - : getObjectFill(selectedSingle) ?? DEFAULT_FILL + : (getObjectFill(selectedSingle) ?? DEFAULT_FILL); return { meta, paint, rectCornerRadius: - selectedSingle.type === 'rect' ? selectedSingle.cornerRadius : undefined, + selectedSingle.type === "rect" + ? selectedSingle.cornerRadius + : undefined, rectCornerRadiusMax: - selectedSingle.type === 'rect' + selectedSingle.type === "rect" ? maxCornerRadiusForObject(selectedSingle) : undefined, - } - }, [selectedSingle]) + }; + }, [selectedSingle]); const imageCornerToolbar = useMemo(() => { - if (!selectedSingle || selectedSingle.type !== 'image') return null + if (!selectedSingle || selectedSingle.type !== "image") return null; return { radius: selectedSingle.cornerRadius, max: maxCornerRadiusForObject(selectedSingle), - } - }, [selectedSingle]) + }; + }, [selectedSingle]); const selectionBlurPct = useMemo(() => { - if (selectedObjects.length === 0) return 0 + if (selectedObjects.length === 0) return 0; return Math.round( selectedObjects.reduce((sum, obj) => sum + obj.blurPct, 0) / - selectedObjects.length, - ) - }, [selectedObjects]) + selectedObjects.length + ); + }, [selectedObjects]); const selectionOpacityPct = useMemo(() => { - if (selectedObjects.length === 0) return 100 + if (selectedObjects.length === 0) return 100; return Math.round( (selectedObjects.reduce((sum, obj) => sum + obj.opacity, 0) / selectedObjects.length) * - 100, - ) - }, [selectedObjects]) + 100 + ); + }, [selectedObjects]); const selectionOutlineStrokeAllowed = useMemo( () => selectedObjects.some((obj) => objectSupportsOutlineStroke(obj)), - [selectedObjects], - ) + [selectedObjects] + ); const selectionOutlineStrokeWidth = useMemo(() => { - const targets = selectedObjects.filter((obj) => objectSupportsOutlineStroke(obj)) - if (targets.length === 0) return 0 + const targets = selectedObjects.filter((obj) => + objectSupportsOutlineStroke(obj) + ); + if (targets.length === 0) return 0; return Math.round( targets.reduce((sum, obj) => sum + getObjectStrokeWidth(obj), 0) / - targets.length, - ) - }, [selectedObjects]) + targets.length + ); + }, [selectedObjects]); const selectionOutlineStrokePaint = useMemo(() => { - const targets = selectedObjects.filter((obj) => objectSupportsOutlineStroke(obj)) - if (targets.length === 0) return { type: 'solid', color: '#000000' } - return getObjectStroke(targets[0]) ?? { type: 'solid', color: '#000000' } - }, [selectedObjects]) + const targets = selectedObjects.filter((obj) => + objectSupportsOutlineStroke(obj) + ); + if (targets.length === 0) return { type: "solid", color: "#000000" }; + return getObjectStroke(targets[0]) ?? { type: "solid", color: "#000000" }; + }, [selectedObjects]); const selectionShadowActive = useMemo( () => selectedObjects.some((obj) => obj.shadow != null), - [selectedObjects], - ) + [selectedObjects] + ); const selectionShadowUi = useMemo(() => { const rows = selectedObjects .map((obj) => obj.shadow) - .filter((row): row is ShadowUi => row != null) - return rows.length > 0 ? averageShadowUi(rows) : { ...DEFAULT_SHADOW_UI } - }, [selectedObjects]) + .filter((row): row is ShadowUi => row != null); + return rows.length > 0 ? averageShadowUi(rows) : { ...DEFAULT_SHADOW_UI }; + }, [selectedObjects]); const elementToolbarLayout = useMemo(() => { - if (!selectionBounds || !zoomPercent) return null - const top = selectionBounds.top * scale - const bottom = (selectionBounds.top + selectionBounds.height) * scale - const placement: 'above' | 'below' = top <= 56 ? 'below' : 'above' + if (!selectionBounds || !zoomPercent) return null; + const top = selectionBounds.top * scale; + const bottom = (selectionBounds.top + selectionBounds.height) * scale; + const placement: "above" | "below" = top <= 56 ? "below" : "above"; return { left: (selectionBounds.left + selectionBounds.width / 2) * scale, top: top <= 56 ? bottom : top, placement, - } - }, [selectionBounds, zoomPercent, scale]) + }; + }, [selectionBounds, zoomPercent, scale]); const elementToolbarAlignAlready = useMemo(() => { - if (!selectionBounds) return null - return artboardAlignAlreadySatisfied(selectionBounds, artboardW, artboardH) - }, [selectionBounds, artboardW, artboardH]) + if (!selectionBounds) return null; + return artboardAlignAlreadySatisfied( + selectionBounds, + artboardW, + artboardH + ); + }, [selectionBounds, artboardW, artboardH]); const elementToolbarLockedDisplay = useMemo( - () => selectedObjects.length > 0 && selectedObjects.every((obj) => obj.locked), - [selectedObjects], - ) - const elementToolbarCanGroup = selectedObjects.length >= 2 - const elementToolbarCanAlignElements = selectedObjects.length >= 2 + () => + selectedObjects.length > 0 && + selectedObjects.every((obj) => obj.locked), + [selectedObjects] + ); + const elementToolbarCanGroup = selectedObjects.length >= 2; + const elementToolbarCanAlignElements = selectedObjects.length >= 2; const elementToolbarCanUngroup = - selectedSingle?.type === 'group' && !selectedSingle.locked + selectedSingle?.type === "group" && !selectedSingle.locked; - const nudgeSelection = useCallback((dx: number, dy: number) => { - if (selectedIds.length === 0) return - setDoc((prev) => ({ - ...prev, - objects: prev.objects.map((obj) => - selectedIds.includes(obj.id) - ? { ...obj, x: obj.x + dx, y: obj.y + dy } - : obj, - ), - })) - setSelectionRev((n) => n + 1) - }, [selectedIds]) + const nudgeSelection = useCallback( + (dx: number, dy: number) => { + if (selectedIds.length === 0) return; + setDoc((prev) => ({ + ...prev, + objects: prev.objects.map((obj) => + selectedIds.includes(obj.id) + ? { ...obj, x: obj.x + dx, y: obj.y + dy } + : obj + ), + })); + setSelectionRev((n) => n + 1); + }, + [selectedIds] + ); const pushSelectionToTop = useCallback((ids: string[]) => { - setSelectedIds(ids) - setSelectionRev((n) => n + 1) - }, []) + setSelectedIds(ids); + setSelectionRev((n) => n + 1); + }, []); useEffect(() => { - const fonts = new Set() + const fonts = new Set(); const visit = (obj: SceneObject) => { - if (obj.type === 'text' && obj.fontFamily.trim()) { - fonts.add(obj.fontFamily.trim()) + if (obj.type === "text" && obj.fontFamily.trim()) { + fonts.add(obj.fontFamily.trim()); } - if (obj.type === 'group') { - obj.children.forEach(visit) + if (obj.type === "group") { + obj.children.forEach(visit); } - } - doc.objects.forEach(visit) + }; + doc.objects.forEach(visit); fonts.forEach((fontFamily) => { - void loadGoogleFontFamily(fontFamily) - }) - }, [doc.objects]) + void loadGoogleFontFamily(fontFamily); + }); + }, [doc.objects]); const reorderSelectionLayers = useCallback( (kind: LayerReorderKind) => { - if (selectedIds.length === 0) return + if (selectedIds.length === 0) return; setDoc((prev) => { - const next = reorderTopLevelObjects(prev.objects, selectedIds, kind) - return next === prev.objects ? prev : { ...prev, objects: next } - }) + const next = reorderTopLevelObjects(prev.objects, selectedIds, kind); + return next === prev.objects ? prev : { ...prev, objects: next }; + }); }, - [selectedIds], - ) - - const pointerToScene = useCallback((clientX: number, clientY: number) => { - const el = artboardInnerRef.current - if (!el) return { x: 0, y: 0 } - const rect = el.getBoundingClientRect() - return { - x: Math.max(0, Math.min(artboardW, (clientX - rect.left) / scale)), - y: Math.max(0, Math.min(artboardH, (clientY - rect.top) / scale)), - } - }, [artboardW, artboardH, scale]) + [selectedIds] + ); + + const pointerToScene = useCallback( + (clientX: number, clientY: number) => { + const el = artboardInnerRef.current; + if (!el) return { x: 0, y: 0 }; + const rect = el.getBoundingClientRect(); + return { + x: Math.max(0, Math.min(artboardW, (clientX - rect.left) / scale)), + y: Math.max(0, Math.min(artboardH, (clientY - rect.top) / scale)), + }; + }, + [artboardW, artboardH, scale] + ); - const addObjects = useCallback((objectsToAdd: SceneObject[]) => { - setDoc((prev) => ({ ...prev, objects: [...prev.objects, ...objectsToAdd] })) - pushSelectionToTop(objectsToAdd.map((obj) => obj.id)) - }, [pushSelectionToTop]) + const addObjects = useCallback( + (objectsToAdd: SceneObject[]) => { + setDoc((prev) => ({ + ...prev, + objects: [...prev.objects, ...objectsToAdd], + })); + pushSelectionToTop(objectsToAdd.map((obj) => obj.id)); + }, + [pushSelectionToTop] + ); const vectorBoardControls = useVectorBoardControls({ addObjects, @@ -574,11 +613,9 @@ const SceneEditor = forwardRef( persistId, ready, setDoc, - }) - const { - boardDocs: vectorBoardDocs, - placeVectorBoard, - } = vectorBoardControls + }); + const { boardDocs: vectorBoardDocs, placeVectorBoard } = + vectorBoardControls; const defaultShapeBox = useMemo( () => ({ @@ -587,28 +624,35 @@ const SceneEditor = forwardRef( lineW: Math.round(artboardW * 0.24), lineH: Math.round(artboardH * 0.12), }), - [artboardW, artboardH], - ) + [artboardW, artboardH] + ); const createCenteredObject = useCallback( (obj: SceneObject) => { - return { ...obj, x: artboardW / 2 - obj.width / 2, y: artboardH / 2 - obj.height / 2 } + return { + ...obj, + x: artboardW / 2 - obj.width / 2, + y: artboardH / 2 - obj.height / 2, + }; }, - [artboardW, artboardH], - ) + [artboardW, artboardH] + ); const addShapeFromKind = useCallback( (kind: PopoverShapeKind) => { - const perfectSize = defaultShapeBox.shapeSize - const lineW = defaultShapeBox.lineW - const lineH = defaultShapeBox.lineH + const perfectSize = defaultShapeBox.shapeSize; + const lineW = defaultShapeBox.lineW; + const lineH = defaultShapeBox.lineH; const common = { id: crypto.randomUUID(), x: 0, y: 0, - width: kind === 'line' || kind === 'arrow' ? Math.round(lineW * 1.2) : perfectSize, + width: + kind === "line" || kind === "arrow" + ? Math.round(lineW * 1.2) + : perfectSize, height: - kind === 'line' || kind === 'arrow' + kind === "line" || kind === "arrow" ? Math.max(24, Math.round(lineH * 0.35)) : perfectSize, rotation: 0, @@ -617,94 +661,94 @@ const SceneEditor = forwardRef( locked: false, blurPct: 0, shadow: null, - } - if (kind === 'rect') { + }; + if (kind === "rect") { addObjects([ createCenteredObject({ ...common, - type: 'rect', + type: "rect", fill: DEFAULT_FILL, stroke: DEFAULT_STROKE, strokeWidth: 0, cornerRadius: Math.round(perfectSize * 0.06), }), - ]) - return + ]); + return; } - if (kind === 'ellipse') { + if (kind === "ellipse") { addObjects([ createCenteredObject({ ...common, - type: 'ellipse', + type: "ellipse", fill: DEFAULT_FILL, stroke: DEFAULT_STROKE, strokeWidth: 0, }), - ]) - return + ]); + return; } - if (kind === 'polygon') { + if (kind === "polygon") { addObjects([ createCenteredObject({ ...common, - type: 'polygon', + type: "polygon", fill: DEFAULT_FILL, stroke: DEFAULT_STROKE, strokeWidth: 0, sides: 6, }), - ]) - return + ]); + return; } - if (kind === 'star') { + if (kind === "star") { addObjects([ createCenteredObject({ ...common, - type: 'star', + type: "star", fill: DEFAULT_FILL, stroke: DEFAULT_STROKE, strokeWidth: 0, points: 5, }), - ]) - return + ]); + return; } - if (kind === 'line') { + if (kind === "line") { addObjects([ createCenteredObject({ ...common, - type: 'line', + type: "line", stroke: DEFAULT_LINE_STROKE, strokeWidth: 6, - lineStyle: 'solid', + lineStyle: "solid", roundedEnds: true, }), - ]) - return + ]); + return; } addObjects([ createCenteredObject({ ...common, - type: 'arrow', + type: "arrow", stroke: DEFAULT_LINE_STROKE, strokeWidth: 6, - lineStyle: 'solid', + lineStyle: "solid", roundedEnds: true, - pathType: 'straight', + pathType: "straight", headSize: 1, curveBulge: Math.round(common.height * 0.4), curveT: 0.5, }), - ]) + ]); }, - [addObjects, createCenteredObject, defaultShapeBox], - ) + [addObjects, createCenteredObject, defaultShapeBox] + ); const addText = useCallback(() => { addObjects([ createCenteredObject({ id: crypto.randomUUID(), - type: 'text', + type: "text", x: 0, y: 0, width: Math.round(artboardW * 0.28), @@ -715,57 +759,61 @@ const SceneEditor = forwardRef( locked: false, blurPct: 0, shadow: null, - text: 'Add text', - fill: { type: 'solid', color: '#171717' }, + text: "Add text", + fill: { type: "solid", color: "#171717" }, stroke: DEFAULT_STROKE, strokeWidth: 0, - fontFamily: 'Inter', + fontFamily: "Inter", fontSize: defaultShapeBox.fontSize, lineHeight: 1.22, - fontWeight: 'normal', - fontStyle: 'normal', + fontWeight: "normal", + fontStyle: "normal", underline: false, - textAlign: 'left', + textAlign: "left", }), - ]) - }, [addObjects, artboardW, createCenteredObject, defaultShapeBox.fontSize]) + ]); + }, [addObjects, artboardW, createCenteredObject, defaultShapeBox.fontSize]); const placeImageObject = useCallback( async ( rawUrl: string, opts?: { - x?: number - y?: number - width?: number - height?: number - origin?: 'center' | 'top-left' - }, + x?: number; + y?: number; + width?: number; + height?: number; + origin?: "center" | "top-left"; + } ) => { - const meta = await loadImageMetadata(rawUrl) - const maxEdge = 800 - let width = opts?.width ?? meta.naturalWidth - let height = opts?.height ?? meta.naturalHeight + const meta = await loadImageMetadata(rawUrl); + const maxEdge = 800; + let width = opts?.width ?? meta.naturalWidth; + let height = opts?.height ?? meta.naturalHeight; if (!opts?.width && !opts?.height) { - const scaleDown = Math.min(1, maxEdge / Math.max(width, height)) - width = Math.round(width * scaleDown) - height = Math.round(height * scaleDown) + const scaleDown = Math.min(1, maxEdge / Math.max(width, height)); + width = Math.round(width * scaleDown); + height = Math.round(height * scaleDown); } else if (opts?.width && !opts?.height) { - height = Math.round((meta.naturalHeight / meta.naturalWidth) * opts.width) + height = Math.round( + (meta.naturalHeight / meta.naturalWidth) * opts.width + ); } else if (!opts?.width && opts?.height) { - width = Math.round((meta.naturalWidth / meta.naturalHeight) * opts.height) + width = Math.round( + (meta.naturalWidth / meta.naturalHeight) * opts.height + ); } - const origin = opts?.origin ?? 'center' + const origin = opts?.origin ?? "center"; const x = - origin === 'center' + origin === "center" ? (opts?.x ?? artboardW / 2) - width / 2 - : (opts?.x ?? artboardW / 2 - width / 2) + : (opts?.x ?? artboardW / 2 - width / 2); const y = - origin === 'center' + origin === "center" ? (opts?.y ?? artboardH / 2) - height / 2 - : (opts?.y ?? artboardH / 2 - height / 2) + : (opts?.y ?? artboardH / 2 - height / 2); const obj: SceneImage = { id: crypto.randomUUID(), - type: 'image', + type: "image", x, y, width, @@ -786,220 +834,251 @@ const SceneEditor = forwardRef( height: meta.naturalHeight, }, cornerRadius: 0, - } - addObjects([obj]) - return obj.id + }; + addObjects([obj]); + return obj.id; }, - [addObjects, artboardH, artboardW], - ) + [addObjects, artboardH, artboardW] + ); const addImageFromFiles = useCallback( async ( files: FileList | File[] | null | undefined, opts?: { - x?: number - y?: number - origin?: 'center' | 'top-left' - }, + x?: number; + y?: number; + origin?: "center" | "top-left"; + } ) => { - const list = files ? Array.from(files) : [] - let placedCount = 0 + const list = files ? Array.from(files) : []; + let placedCount = 0; for (const file of list) { - if (!isImageFile(file)) continue + if (!isImageFile(file)) continue; const dataUrl = await new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => resolve(String(reader.result)) - reader.onerror = () => reject(reader.error) - reader.readAsDataURL(file) - }) + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); await placeImageObject( dataUrl, opts ? { ...opts, x: - typeof opts.x === 'number' + typeof opts.x === "number" ? opts.x + placedCount * CLIPBOARD_PASTE_OFFSET : opts.x, y: - typeof opts.y === 'number' + typeof opts.y === "number" ? opts.y + placedCount * CLIPBOARD_PASTE_OFFSET : opts.y, } - : undefined, - ) - placedCount += 1 + : undefined + ); + placedCount += 1; } }, - [placeImageObject], - ) + [placeImageObject] + ); const updateSelectedObjects = useCallback( (updater: (obj: SceneObject) => SceneObject) => { setDoc((prev) => ({ ...prev, objects: prev.objects.map((obj) => - selectedIds.includes(obj.id) ? updater(obj) : obj, + selectedIds.includes(obj.id) ? updater(obj) : obj ), - })) + })); }, - [selectedIds], - ) + [selectedIds] + ); const deleteSelection = useCallback(() => { - if (selectedIds.length === 0) return + if (selectedIds.length === 0) return; setDoc((prev) => ({ ...prev, objects: removeTopLevelObjects(prev.objects, selectedIds), - })) - setSelectedIds([]) - setTextEditingId(null) - }, [selectedIds]) + })); + setSelectedIds([]); + setTextEditingId(null); + }, [selectedIds]); const duplicateElement = useCallback(async () => { - if (selectedIds.length === 0) return + if (selectedIds.length === 0) return; const duplicates = doc.objects .filter((obj) => selectedIds.includes(obj.id)) .map((obj) => { - const dup = renameWithFreshIds(obj) - dup.x += CLIPBOARD_PASTE_OFFSET - dup.y += CLIPBOARD_PASTE_OFFSET - dup.locked = false - return dup - }) - addObjects(duplicates) - }, [addObjects, doc.objects, selectedIds]) + const dup = renameWithFreshIds(obj); + dup.x += CLIPBOARD_PASTE_OFFSET; + dup.y += CLIPBOARD_PASTE_OFFSET; + dup.locked = false; + return dup; + }); + addObjects(duplicates); + }, [addObjects, doc.objects, selectedIds]); const copyElementToClipboard = useCallback(async () => { - if (selectedIds.length === 0) return + if (selectedIds.length === 0) return; const objects = doc.objects .filter((obj) => selectedIds.includes(obj.id)) - .map((obj) => cloneSceneObject(obj)) + .map((obj) => cloneSceneObject(obj)); await navigator.clipboard.writeText( - JSON.stringify({ avnacClip: true, v: 2, objects }), - ) - }, [doc.objects, selectedIds]) + JSON.stringify({ avnacClip: true, v: 2, objects }) + ); + }, [doc.objects, selectedIds]); const pasteFromClipboard = useCallback( async (anchor?: { x: number; y: number }) => { - const imageFiles = await readClipboardImageFiles().catch(() => []) + const imageFiles = await readClipboardImageFiles().catch(() => []); if (imageFiles.length > 0) { - await addImageFromFiles(imageFiles) - return + await addImageFromFiles(imageFiles); + return; } - const text = await navigator.clipboard.readText().catch(() => '') - if (!text) return - let parsed: { avnacClip?: boolean; objects?: unknown[] } | null = null + const text = await navigator.clipboard.readText().catch(() => ""); + if (!text) return; + let parsed: { avnacClip?: boolean; objects?: unknown[] } | null = null; try { - parsed = JSON.parse(text) as { avnacClip?: boolean; objects?: unknown[] } + parsed = JSON.parse(text) as { + avnacClip?: boolean; + objects?: unknown[]; + }; } catch { - parsed = null + parsed = null; } if (parsed?.avnacClip && Array.isArray(parsed.objects)) { const objects = parsed.objects - .map((row) => parseAvnacDocument({ v: AVNAC_DOC_VERSION, artboard: doc.artboard, bg: doc.bg, objects: [row] })?.objects[0] ?? null) + .map( + (row) => + parseAvnacDocument({ + v: AVNAC_DOC_VERSION, + artboard: doc.artboard, + bg: doc.bg, + objects: [row], + })?.objects[0] ?? null + ) .filter((row): row is SceneObject => row != null) - .map((obj) => renameWithFreshIds(obj)) + .map((obj) => renameWithFreshIds(obj)); if (objects.length > 0) { if (anchor) { - const bounds = getSelectionBounds(objects) + const bounds = getSelectionBounds(objects); if (bounds) { - const dx = anchor.x - bounds.left - const dy = anchor.y - bounds.top + const dx = anchor.x - bounds.left; + const dy = anchor.y - bounds.top; for (const obj of objects) { - obj.x += dx - obj.y += dy + obj.x += dx; + obj.y += dy; } } } else { for (const obj of objects) { - obj.x += CLIPBOARD_PASTE_OFFSET - obj.y += CLIPBOARD_PASTE_OFFSET + obj.x += CLIPBOARD_PASTE_OFFSET; + obj.y += CLIPBOARD_PASTE_OFFSET; } } - addObjects(objects) - return + addObjects(objects); + return; } } - if (/^https?:\/\//i.test(text.trim()) || /^data:image\//i.test(text.trim())) { - await placeImageObject(text.trim(), anchor ? { ...anchor, origin: 'top-left' } : undefined) + if ( + /^https?:\/\//i.test(text.trim()) || + /^data:image\//i.test(text.trim()) + ) { + await placeImageObject( + text.trim(), + anchor ? { ...anchor, origin: "top-left" } : undefined + ); } }, - [addImageFromFiles, addObjects, doc.artboard, doc.bg, placeImageObject], - ) + [addImageFromFiles, addObjects, doc.artboard, doc.bg, placeImageObject] + ); const toggleElementLock = useCallback(() => { - updateSelectedObjects((obj) => ({ ...obj, locked: !elementToolbarLockedDisplay })) - }, [elementToolbarLockedDisplay, updateSelectedObjects]) + updateSelectedObjects((obj) => ({ + ...obj, + locked: !elementToolbarLockedDisplay, + })); + }, [elementToolbarLockedDisplay, updateSelectedObjects]); const groupSelection = useCallback(() => { - const picked = doc.objects.filter((obj) => selectedIds.includes(obj.id)) - const group = createGroupFromSelection(picked) - if (!group) return - const firstIndex = doc.objects.findIndex((obj) => selectedIds.includes(obj.id)) - const remaining = doc.objects.filter((obj) => !selectedIds.includes(obj.id)) - remaining.splice(firstIndex < 0 ? remaining.length : firstIndex, 0, group) - setDoc((prev) => ({ ...prev, objects: remaining })) - setSelectedIds([group.id]) - }, [doc.objects, selectedIds]) + const picked = doc.objects.filter((obj) => selectedIds.includes(obj.id)); + const group = createGroupFromSelection(picked); + if (!group) return; + const firstIndex = doc.objects.findIndex((obj) => + selectedIds.includes(obj.id) + ); + const remaining = doc.objects.filter( + (obj) => !selectedIds.includes(obj.id) + ); + remaining.splice( + firstIndex < 0 ? remaining.length : firstIndex, + 0, + group + ); + setDoc((prev) => ({ ...prev, objects: remaining })); + setSelectedIds([group.id]); + }, [doc.objects, selectedIds]); const ungroupSelection = useCallback(() => { - if (!selectedSingle || selectedSingle.type !== 'group') return - const children = ungroupSceneObject(selectedSingle) + if (!selectedSingle || selectedSingle.type !== "group") return; + const children = ungroupSceneObject(selectedSingle); setDoc((prev) => { - const idx = prev.objects.findIndex((obj) => obj.id === selectedSingle.id) - const next = prev.objects.filter((obj) => obj.id !== selectedSingle.id) - next.splice(idx < 0 ? next.length : idx, 0, ...children) - return { ...prev, objects: next } - }) - setSelectedIds(children.map((child) => child.id)) - }, [selectedSingle]) + const idx = prev.objects.findIndex( + (obj) => obj.id === selectedSingle.id + ); + const next = prev.objects.filter((obj) => obj.id !== selectedSingle.id); + next.splice(idx < 0 ? next.length : idx, 0, ...children); + return { ...prev, objects: next }; + }); + setSelectedIds(children.map((child) => child.id)); + }, [selectedSingle]); const applyBackgroundPicked = useCallback((bg: BgValue) => { - setDoc((prev) => ({ ...prev, bg })) - }, []) + setDoc((prev) => ({ ...prev, bg })); + }, []); const applyPaintToSelection = useCallback( (paint: BgValue) => { updateSelectedObjects((obj) => { - if (obj.type === 'line' || obj.type === 'arrow') return setObjectStroke(obj, paint) - if (objectSupportsFill(obj)) return setObjectFill(obj, paint) - return obj - }) + if (obj.type === "line" || obj.type === "arrow") + return setObjectStroke(obj, paint); + if (objectSupportsFill(obj)) return setObjectFill(obj, paint); + return obj; + }); }, - [updateSelectedObjects], - ) + [updateSelectedObjects] + ); const applyOutlineStrokeWidth = useCallback( (px: number) => { - updateSelectedObjects((obj) => setObjectStrokeWidth(obj, px)) + updateSelectedObjects((obj) => setObjectStrokeWidth(obj, px)); }, - [updateSelectedObjects], - ) + [updateSelectedObjects] + ); const applyOutlineStrokePaint = useCallback( (paint: BgValue) => { - updateSelectedObjects((obj) => setObjectStroke(obj, paint)) + updateSelectedObjects((obj) => setObjectStroke(obj, paint)); }, - [updateSelectedObjects], - ) + [updateSelectedObjects] + ); const applyBlurToSelection = useCallback( (blurPct: number) => { - updateSelectedObjects((obj) => ({ ...obj, blurPct })) + updateSelectedObjects((obj) => ({ ...obj, blurPct })); }, - [updateSelectedObjects], - ) + [updateSelectedObjects] + ); const applyOpacityToSelection = useCallback( (opacityPct: number) => { updateSelectedObjects((obj) => ({ ...obj, opacity: Math.max(0, Math.min(1, opacityPct / 100)), - })) + })); }, - [updateSelectedObjects], - ) + [updateSelectedObjects] + ); const applyShadowToSelection = useCallback( (shadow: ShadowUi) => { @@ -1007,207 +1086,226 @@ const SceneEditor = forwardRef( shadow.blur === 0 && shadow.offsetX === 0 && shadow.offsetY === 0 && - shadow.opacityPct === 0 + shadow.opacityPct === 0; updateSelectedObjects((obj) => ({ ...obj, shadow: inactive ? null : { ...shadow }, - })) + })); }, - [updateSelectedObjects], - ) + [updateSelectedObjects] + ); const applyRectCornerRadius = useCallback( (radius: number) => { updateSelectedObjects((obj) => - obj.type === 'rect' ? setObjectCornerRadius(obj, radius) : obj, - ) + obj.type === "rect" ? setObjectCornerRadius(obj, radius) : obj + ); }, - [updateSelectedObjects], - ) + [updateSelectedObjects] + ); const applyImageCornerRadius = useCallback( (radius: number) => { updateSelectedObjects((obj) => - obj.type === 'image' ? setObjectCornerRadius(obj, radius) : obj, - ) + obj.type === "image" ? setObjectCornerRadius(obj, radius) : obj + ); }, - [updateSelectedObjects], - ) + [updateSelectedObjects] + ); const applyPolygonSides = useCallback( (sides: number) => { updateSelectedObjects((obj) => - obj.type === 'polygon' + obj.type === "polygon" ? { ...obj, sides: Math.max(3, Math.min(32, Math.round(sides))) } - : obj, - ) + : obj + ); }, - [updateSelectedObjects], - ) + [updateSelectedObjects] + ); const applyStarPoints = useCallback( (points: number) => { updateSelectedObjects((obj) => - obj.type === 'star' + obj.type === "star" ? { ...obj, points: Math.max(4, Math.min(32, Math.round(points))) } - : obj, - ) + : obj + ); }, - [updateSelectedObjects], - ) + [updateSelectedObjects] + ); const applyArrowLineStyle = useCallback( - (lineStyle: SceneLine['lineStyle']) => { + (lineStyle: SceneLine["lineStyle"]) => { updateSelectedObjects((obj) => - obj.type === 'line' || obj.type === 'arrow' ? { ...obj, lineStyle } : obj, - ) + obj.type === "line" || obj.type === "arrow" + ? { ...obj, lineStyle } + : obj + ); }, - [updateSelectedObjects], - ) + [updateSelectedObjects] + ); const applyArrowRoundedEnds = useCallback( (roundedEnds: boolean) => { updateSelectedObjects((obj) => - obj.type === 'line' || obj.type === 'arrow' ? { ...obj, roundedEnds } : obj, - ) + obj.type === "line" || obj.type === "arrow" + ? { ...obj, roundedEnds } + : obj + ); }, - [updateSelectedObjects], - ) + [updateSelectedObjects] + ); const applyArrowStrokeWidth = useCallback( (strokeWidth: number) => { updateSelectedObjects((obj) => - obj.type === 'line' || obj.type === 'arrow' + obj.type === "line" || obj.type === "arrow" ? { ...obj, strokeWidth: Math.max(1, strokeWidth) } - : obj, - ) + : obj + ); }, - [updateSelectedObjects], - ) + [updateSelectedObjects] + ); const applyArrowPathType = useCallback( - (pathType: SceneArrow['pathType']) => { + (pathType: SceneArrow["pathType"]) => { updateSelectedObjects((obj) => - obj.type === 'arrow' ? { ...obj, pathType } : obj, - ) + obj.type === "arrow" ? { ...obj, pathType } : obj + ); }, - [updateSelectedObjects], - ) + [updateSelectedObjects] + ); const onTextFormatChange = useCallback( (patch: Partial) => { - if (patch.fontFamily) void loadGoogleFontFamily(patch.fontFamily) + if (patch.fontFamily) void loadGoogleFontFamily(patch.fontFamily); updateSelectedObjects((obj) => { - if (obj.type !== 'text') return obj - const next = { ...obj } - if (patch.fontFamily) next.fontFamily = patch.fontFamily - if (patch.fontSize) next.fontSize = patch.fontSize - if (patch.fillStyle) next.fill = patch.fillStyle - if (patch.textAlign) next.textAlign = patch.textAlign - if (patch.bold !== undefined) next.fontWeight = patch.bold ? 'bold' : 'normal' - if (patch.italic !== undefined) next.fontStyle = patch.italic ? 'italic' : 'normal' - if (patch.underline !== undefined) next.underline = patch.underline - const layout = layoutSceneText(next) + if (obj.type !== "text") return obj; + const next = { ...obj }; + if (patch.fontFamily) next.fontFamily = patch.fontFamily; + if (patch.fontSize) next.fontSize = patch.fontSize; + if (patch.fillStyle) next.fill = patch.fillStyle; + if (patch.textAlign) next.textAlign = patch.textAlign; + if (patch.bold !== undefined) + next.fontWeight = patch.bold ? "bold" : "normal"; + if (patch.italic !== undefined) + next.fontStyle = patch.italic ? "italic" : "normal"; + if (patch.underline !== undefined) next.underline = patch.underline; + const layout = layoutSceneText(next); next.height = Math.max( layout.height, - next.fontSize * sceneTextLineHeight(next), - ) - return next - }) + next.fontSize * sceneTextLineHeight(next) + ); + return next; + }); }, - [updateSelectedObjects], - ) + [updateSelectedObjects] + ); const alignElementToArtboard = useCallback( (kind: CanvasAlignKind) => { - if (!selectionBounds) return - let dx = 0 - let dy = 0 - if (kind === 'left') dx = ARTBOARD_ALIGN_PAD - selectionBounds.left - if (kind === 'centerH') - dx = artboardW / 2 - (selectionBounds.left + selectionBounds.width / 2) - if (kind === 'right') - dx = artboardW - ARTBOARD_ALIGN_PAD - (selectionBounds.left + selectionBounds.width) - if (kind === 'top') dy = ARTBOARD_ALIGN_PAD - selectionBounds.top - if (kind === 'centerV') - dy = artboardH / 2 - (selectionBounds.top + selectionBounds.height / 2) - if (kind === 'bottom') - dy = artboardH - ARTBOARD_ALIGN_PAD - (selectionBounds.top + selectionBounds.height) - updateSelectedObjects((obj) => ({ ...obj, x: obj.x + dx, y: obj.y + dy })) + if (!selectionBounds) return; + let dx = 0; + let dy = 0; + if (kind === "left") dx = ARTBOARD_ALIGN_PAD - selectionBounds.left; + if (kind === "centerH") + dx = + artboardW / 2 - (selectionBounds.left + selectionBounds.width / 2); + if (kind === "right") + dx = + artboardW - + ARTBOARD_ALIGN_PAD - + (selectionBounds.left + selectionBounds.width); + if (kind === "top") dy = ARTBOARD_ALIGN_PAD - selectionBounds.top; + if (kind === "centerV") + dy = + artboardH / 2 - (selectionBounds.top + selectionBounds.height / 2); + if (kind === "bottom") + dy = + artboardH - + ARTBOARD_ALIGN_PAD - + (selectionBounds.top + selectionBounds.height); + updateSelectedObjects((obj) => ({ + ...obj, + x: obj.x + dx, + y: obj.y + dy, + })); }, - [artboardH, artboardW, selectionBounds, updateSelectedObjects], - ) + [artboardH, artboardW, selectionBounds, updateSelectedObjects] + ); const alignSelectedElements = useCallback( (kind: CanvasAlignKind) => { - if (selectedObjects.length < 2) return - const bounds = getSelectionBounds(selectedObjects) - if (!bounds) return + if (selectedObjects.length < 2) return; + const bounds = getSelectionBounds(selectedObjects); + if (!bounds) return; updateSelectedObjects((obj) => { - const box = getObjectRotatedBounds(obj) - if (kind === 'left') return { ...obj, x: obj.x + (bounds.left - box.left) } - if (kind === 'right') + const box = getObjectRotatedBounds(obj); + if (kind === "left") + return { ...obj, x: obj.x + (bounds.left - box.left) }; + if (kind === "right") return { ...obj, - x: - obj.x + - (bounds.left + bounds.width - (box.left + box.width)), - } - if (kind === 'centerH') + x: obj.x + (bounds.left + bounds.width - (box.left + box.width)), + }; + if (kind === "centerH") return { ...obj, x: obj.x + (bounds.left + bounds.width / 2 - (box.left + box.width / 2)), - } - if (kind === 'top') return { ...obj, y: obj.y + (bounds.top - box.top) } - if (kind === 'bottom') + }; + if (kind === "top") + return { ...obj, y: obj.y + (bounds.top - box.top) }; + if (kind === "bottom") return { ...obj, - y: - obj.y + - (bounds.top + bounds.height - (box.top + box.height)), - } + y: obj.y + (bounds.top + bounds.height - (box.top + box.height)), + }; return { ...obj, y: obj.y + (bounds.top + bounds.height / 2 - (box.top + box.height / 2)), - } - }) + }; + }); }, - [selectedObjects, updateSelectedObjects], - ) + [selectedObjects, updateSelectedObjects] + ); - const onArtboardResize = useCallback((width: number, height: number) => { - setDoc((prev) => ({ - ...prev, - artboard: { width, height }, - })) - if (!zoomUserAdjustedRef.current) window.setTimeout(() => fitZoom(), 0) - }, [fitZoom]) + const onArtboardResize = useCallback( + (width: number, height: number) => { + setDoc((prev) => ({ + ...prev, + artboard: { width, height }, + })); + if (!zoomUserAdjustedRef.current) window.setTimeout(() => fitZoom(), 0); + }, + [fitZoom] + ); const openImageCropModal = useCallback(() => { - if (!selectedSingle || selectedSingle.type !== 'image') return - imageCropTargetIdRef.current = selectedSingle.id - setImageCropSrc(selectedSingle.src) + if (!selectedSingle || selectedSingle.type !== "image") return; + imageCropTargetIdRef.current = selectedSingle.id; + setImageCropSrc(selectedSingle.src); setImageCropInitial({ x: selectedSingle.crop.x, y: selectedSingle.crop.y, w: selectedSingle.crop.width, h: selectedSingle.crop.height, - }) - setImageCropOpen(true) - }, [selectedSingle]) + }); + setImageCropOpen(true); + }, [selectedSingle]); const applyImageCropFromModal = useCallback( (rect: ImageCropModalApplyPayload) => { - const targetId = imageCropTargetIdRef.current - if (!targetId) return + const targetId = imageCropTargetIdRef.current; + if (!targetId) return; setDoc((prev) => ({ ...prev, objects: prev.objects.map((obj) => - obj.id === targetId && obj.type === 'image' + obj.id === targetId && obj.type === "image" ? { ...obj, crop: { @@ -1217,150 +1315,216 @@ const SceneEditor = forwardRef( height: rect.height, }, } - : obj, + : obj ), - })) - setImageCropOpen(false) + })); + setImageCropOpen(false); }, - [], - ) + [] + ); const cancelImageCrop = useCallback(() => { - setImageCropOpen(false) - }, []) + setImageCropOpen(false); + }, []); const downloadDocumentJson = useCallback((value: AvnacDocument) => { const blob = new Blob([JSON.stringify(value, null, 2)], { - type: 'application/json', - }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = 'avnac-document.json' - a.click() - URL.revokeObjectURL(url) - }, []) + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "avnac-document.json"; + a.click(); + URL.revokeObjectURL(url); + }, []); const saveDocument = useCallback(() => { - downloadDocumentJson(doc) - }, [doc, downloadDocumentJson]) + downloadDocumentJson(doc); + }, [doc, downloadDocumentJson]); const loadDocument = useCallback(async (file: File) => { - const text = await file.text() - let parsed: unknown + const text = await file.text(); + let parsed: unknown; try { - parsed = JSON.parse(text) + parsed = JSON.parse(text); } catch { - throw new Error('Invalid JSON file.') + throw new Error("Invalid JSON file."); } - const next = parseAvnacDocument(parsed) - if (!next) throw new Error('This file is not an Avnac document.') - applyingHistoryRef.current = true - setDoc(next) - setSelectedIds([]) - setTextEditingId(null) - historyRef.current = [cloneAvnacDocument(next)] - historyIndexRef.current = 0 + const next = parseAvnacDocument(parsed); + if (!next) throw new Error("This file is not an Avnac document."); + applyingHistoryRef.current = true; + setDoc(next); + setSelectedIds([]); + setTextEditingId(null); + historyRef.current = [cloneAvnacDocument(next)]; + historyIndexRef.current = 0; window.setTimeout(() => { - applyingHistoryRef.current = false - }, 0) - }, []) + applyingHistoryRef.current = false; + }, 0); + }, []); const exportImage = useCallback( (opts?: ExportImageOptions) => { void (async () => { try { - const url = await renderAvnacDocumentToDataUrl(doc, vectorBoardDocs, { - format: opts?.format ?? 'png', - multiplier: opts?.multiplier ?? 1, - transparent: opts?.transparent ?? false, - }) - const a = document.createElement('a') - a.href = url - a.download = `${persistDisplayNameRef.current || 'avnac'}.${opts?.format ?? 'png'}` - a.click() + const url = await renderAvnacDocumentToDataUrl( + doc, + vectorBoardDocs, + { + format: opts?.format ?? "png", + multiplier: opts?.multiplier ?? 1, + transparent: opts?.transparent ?? false, + } + ); + const a = document.createElement("a"); + a.href = url; + a.download = `${persistDisplayNameRef.current || "avnac"}.${opts?.format ?? "png"}`; + a.click(); } catch (error) { - console.error('[avnac] image export failed', error) + console.error("[avnac] image export failed", error); setExportError( - 'Could not export this canvas. Some images could not be prepared.', - ) + "Could not export this canvas. Some images could not be prepared." + ); } - })() + })(); }, - [doc, vectorBoardDocs], - ) + [doc, vectorBoardDocs] + ); useImperativeHandle( ref, () => ({ exportImage, saveDocument, loadDocument }), - [exportImage, saveDocument, loadDocument], - ) + [exportImage, saveDocument, loadDocument] + ); const onZoomSliderChange = useCallback((pct: number) => { - zoomUserAdjustedRef.current = true - setZoomPercent(Math.max(ZOOM_MIN_PCT, Math.min(ZOOM_MAX_PCT, Math.round(pct)))) - }, []) + zoomUserAdjustedRef.current = true; + const clamped = Math.max( + ZOOM_MIN_PCT, + Math.min(ZOOM_MAX_PCT, Math.round(pct)) + ); + const viewport = viewportRef.current; + if (viewport) { + const cx = viewport.clientWidth / 2; + const cy = viewport.clientHeight / 2; + setZoomPercent((prev) => { + const oldScale = (prev ?? 100) / 100; + const newScale = clamped / 100; + setPanX((prevPan) => cx - ((cx - prevPan) / oldScale) * newScale); + setPanY((prevPan) => cy - ((cy - prevPan) / oldScale) * newScale); + return clamped; + }); + } else { + setZoomPercent(clamped); + } + }, []); const onZoomFitRequest = useCallback(() => { - zoomUserAdjustedRef.current = false - fitZoom() - }, [fitZoom]) + zoomUserAdjustedRef.current = false; + fitZoom(); + }, [fitZoom]); + + const onViewportWheel = useCallback((e: WheelEvent) => { + e.preventDefault(); + if (e.ctrlKey || e.metaKey) { + const viewport = viewportRef.current; + if (!viewport) return; + const rect = viewport.getBoundingClientRect(); + const cursorX = e.clientX - rect.left; + const cursorY = e.clientY - rect.top; + + const raw = -e.deltaY; + const clamped = Math.max(-25, Math.min(25, raw)); + const factor = 1 + clamped * 0.008; + + setZoomPercent((prev) => { + const oldPct = prev ?? 100; + const oldScale = oldPct / 100; + const newPct = Math.max( + ZOOM_MIN_PCT, + Math.min(ZOOM_MAX_PCT, oldPct * factor) + ); + const newScale = newPct / 100; + setPanX( + (prevPan) => cursorX - ((cursorX - prevPan) / oldScale) * newScale + ); + setPanY( + (prevPan) => cursorY - ((cursorY - prevPan) / oldScale) * newScale + ); + return newPct; + }); + zoomUserAdjustedRef.current = true; + } else if (e.shiftKey) { + setPanX((prev) => prev - e.deltaY); + } else { + setPanX((prev) => prev - e.deltaX); + setPanY((prev) => prev - e.deltaY); + } + }, []); + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + viewport.addEventListener("wheel", onViewportWheel, { passive: false }); + return () => viewport.removeEventListener("wheel", onViewportWheel); + }, [onViewportWheel]); const commitTextDraft = useCallback(() => { - if (!textEditingId) return + if (!textEditingId) return; setDoc((prev) => ({ ...prev, objects: prev.objects.map((obj) => { - if (obj.id !== textEditingId || obj.type !== 'text') return obj - const next: SceneText = { ...obj, text: textDraft } - const layout = layoutSceneText(next) - next.height = Math.max(layout.height, next.fontSize * 1.22) - return next + if (obj.id !== textEditingId || obj.type !== "text") return obj; + const next: SceneText = { ...obj, text: textDraft }; + const layout = layoutSceneText(next); + next.height = Math.max(layout.height, next.fontSize * 1.22); + return next; }), - })) - setTextEditingId(null) - }, [textDraft, textEditingId]) + })); + setTextEditingId(null); + }, [textDraft, textEditingId]); const startWindowDrag = useCallback( (state: DragState) => { - dragStateRef.current = state - setHoveredId(null) - setMarqueeRect(null) - snapGuideXRef.current = null - snapGuideYRef.current = null - setSnapGuides([]) - setTransformDimensionUi(null) + dragStateRef.current = state; + setHoveredId(null); + setMarqueeRect(null); + snapGuideXRef.current = null; + snapGuideYRef.current = null; + setSnapGuides([]); + setTransformDimensionUi(null); const onMove = (e: PointerEvent) => { - const drag = dragStateRef.current - if (!drag) return - const pt = pointerToScene(e.clientX, e.clientY) - if (drag.kind === 'marquee') { + const drag = dragStateRef.current; + if (!drag) return; + const pt = pointerToScene(e.clientX, e.clientY); + if (drag.kind === "marquee") { const nextRect = rectFromPoints( drag.startSceneX, drag.startSceneY, pt.x, - pt.y, - ) - setMarqueeRect(nextRect) + pt.y + ); + setMarqueeRect(nextRect); const intersectedIds = drag.objects .filter( (obj) => obj.visible && - boundsIntersect(getObjectRotatedBounds(obj), nextRect), + boundsIntersect(getObjectRotatedBounds(obj), nextRect) ) - .map((obj) => obj.id) + .map((obj) => obj.id); setSelectedIds( drag.additive ? mergeUniqueIds(drag.initialSelection, intersectedIds) - : intersectedIds, - ) - return + : intersectedIds + ); + return; } - if (drag.kind === 'move') { - const rawDx = pt.x - drag.startSceneX - const rawDy = pt.y - drag.startSceneY - let dx = rawDx - let dy = rawDy + if (drag.kind === "move") { + const rawDx = pt.x - drag.startSceneX; + const rawDy = pt.y - drag.startSceneY; + let dx = rawDx; + let dy = rawDy; if (drag.initialBounds) { const snap = computeSceneSnap( { @@ -1373,232 +1537,257 @@ const SceneEditor = forwardRef( artboardH, sceneSnapThreshold(artboardW, artboardH), snapGuideXRef.current, - snapGuideYRef.current, - ) - dx += Math.abs(snap.dx) >= SNAP_DEADBAND_PX ? snap.dx : 0 - dy += Math.abs(snap.dy) >= SNAP_DEADBAND_PX ? snap.dy : 0 + snapGuideYRef.current + ); + dx += Math.abs(snap.dx) >= SNAP_DEADBAND_PX ? snap.dx : 0; + dy += Math.abs(snap.dy) >= SNAP_DEADBAND_PX ? snap.dy : 0; snapGuideXRef.current = - snap.guides.find((guide) => guide.axis === 'v')?.pos ?? null + snap.guides.find((guide) => guide.axis === "v")?.pos ?? null; snapGuideYRef.current = - snap.guides.find((guide) => guide.axis === 'h')?.pos ?? null - setSnapGuides(snap.guides) + snap.guides.find((guide) => guide.axis === "h")?.pos ?? null; + setSnapGuides(snap.guides); } else { - snapGuideXRef.current = null - snapGuideYRef.current = null - setSnapGuides([]) + snapGuideXRef.current = null; + snapGuideYRef.current = null; + setSnapGuides([]); } setDoc((prev) => ({ ...prev, objects: prev.objects.map((obj) => { - const start = drag.initial.get(obj.id) - return start ? { ...obj, x: start.x + dx, y: start.y + dy } : obj + const start = drag.initial.get(obj.id); + return start + ? { ...obj, x: start.x + dx, y: start.y + dy } + : obj; }), - })) - return + })); + return; } - if (drag.kind === 'rotate') { + if (drag.kind === "rotate") { const angle = angleFromPoints( drag.center.x, drag.center.y, pt.x, - pt.y, - ) - const delta = angle - drag.startAngle - const nextRotation = drag.initialRotation + delta + pt.y + ); + const delta = angle - drag.startAngle; + const nextRotation = drag.initialRotation + delta; setDoc((prev) => ({ ...prev, objects: prev.objects.map((obj) => obj.id === drag.id - ? { ...obj, rotation: e.shiftKey ? snapAngle(nextRotation) : nextRotation } - : obj, + ? { + ...obj, + rotation: e.shiftKey + ? snapAngle(nextRotation) + : nextRotation, + } + : obj ), - })) - return + })); + return; } - const initial = drag.initial - const center = getObjectCenter(initial) - const local = pointerSceneDelta(pt.x - center.x, pt.y - center.y, initial.rotation) - const centeredScaling = e.altKey - const freeformScaling = e.shiftKey + const initial = drag.initial; + const center = getObjectCenter(initial); + const local = pointerSceneDelta( + pt.x - center.x, + pt.y - center.y, + initial.rotation + ); + const centeredScaling = e.altKey; + const freeformScaling = e.shiftKey; const shouldLockShapeAspect = isCornerHandle(drag.handle) && - (initial.type === 'image' || - ((initial.type === 'group' || isPerfectShapeObject(initial)) && - !freeformScaling)) + (initial.type === "image" || + ((initial.type === "group" || isPerfectShapeObject(initial)) && + !freeformScaling)); const anchor = getHandleLocalPosition( oppositeHandle(drag.handle), initial.width, - initial.height, - ) - const current = getHandleLocalPosition(drag.handle, initial.width, initial.height) + initial.height + ); + const current = getHandleLocalPosition( + drag.handle, + initial.width, + initial.height + ); const px = - drag.handle === 'n' || drag.handle === 's' ? current.x : local.x + drag.handle === "n" || drag.handle === "s" ? current.x : local.x; const py = - drag.handle === 'e' || drag.handle === 'w' ? current.y : local.y - let minX = Math.min(anchor.x, px) - let maxX = Math.max(anchor.x, px) - let minY = Math.min(anchor.y, py) - let maxY = Math.max(anchor.y, py) + drag.handle === "e" || drag.handle === "w" ? current.y : local.y; + let minX = Math.min(anchor.x, px); + let maxX = Math.max(anchor.x, px); + let minY = Math.min(anchor.y, py); + let maxY = Math.max(anchor.y, py); if (centeredScaling) { const halfW = - drag.handle === 'n' || drag.handle === 's' + drag.handle === "n" || drag.handle === "s" ? initial.width / 2 - : Math.max(6, Math.abs(local.x)) + : Math.max(6, Math.abs(local.x)); const halfH = - drag.handle === 'e' || drag.handle === 'w' + drag.handle === "e" || drag.handle === "w" ? initial.height / 2 - : Math.max(6, Math.abs(local.y)) - minX = -halfW - maxX = halfW - minY = -halfH - maxY = halfH - } else if (drag.handle === 'e' || drag.handle === 'w') { - minY = -initial.height / 2 - maxY = initial.height / 2 - } else if (drag.handle === 'n' || drag.handle === 's') { - minX = -initial.width / 2 - maxX = initial.width / 2 + : Math.max(6, Math.abs(local.y)); + minX = -halfW; + maxX = halfW; + minY = -halfH; + maxY = halfH; + } else if (drag.handle === "e" || drag.handle === "w") { + minY = -initial.height / 2; + maxY = initial.height / 2; + } else if (drag.handle === "n" || drag.handle === "s") { + minX = -initial.width / 2; + maxX = initial.width / 2; } else if (shouldLockShapeAspect) { const constrained = constrainAspectRatioBounds( drag.handle, anchor, { x: px, y: py }, initial.width, - initial.height, - ) - minX = constrained.minX - maxX = constrained.maxX - minY = constrained.minY - maxY = constrained.maxY + initial.height + ); + minX = constrained.minX; + maxX = constrained.maxX; + minY = constrained.minY; + maxY = constrained.maxY; } if (centeredScaling && shouldLockShapeAspect) { const scale = Math.max( 12 / Math.max(1, initial.width), 12 / Math.max(1, initial.height), (maxX - minX) / Math.max(1, initial.width), - (maxY - minY) / Math.max(1, initial.height), - ) - const nextW = Math.max(1, initial.width) * scale - const nextH = Math.max(1, initial.height) * scale - minX = -nextW / 2 - maxX = nextW / 2 - minY = -nextH / 2 - maxY = nextH / 2 + (maxY - minY) / Math.max(1, initial.height) + ); + const nextW = Math.max(1, initial.width) * scale; + const nextH = Math.max(1, initial.height) * scale; + minX = -nextW / 2; + maxX = nextW / 2; + minY = -nextH / 2; + maxY = nextH / 2; } if (maxX - minX < 12) { - const mid = (maxX + minX) / 2 - minX = mid - 6 - maxX = mid + 6 + const mid = (maxX + minX) / 2; + minX = mid - 6; + maxX = mid + 6; } if (maxY - minY < 12) { - const mid = (maxY + minY) / 2 - minY = mid - 6 - maxY = mid + 6 + const mid = (maxY + minY) / 2; + minY = mid - 6; + maxY = mid + 6; } - const localCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 } + const localCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }; const globalCenterDelta = rotateDeltaToScene( localCenter.x, localCenter.y, - initial.rotation, - ) + initial.rotation + ); const nextCenter = { x: centeredScaling ? center.x : center.x + globalCenterDelta.x, y: centeredScaling ? center.y : center.y + globalCenterDelta.y, - } + }; const nextBox = { x: nextCenter.x - (maxX - minX) / 2, y: nextCenter.y - (maxY - minY) / 2, width: maxX - minX, height: maxY - minY, - } + }; const nextObject = resizeObjectWithBox(initial, nextBox, { handle: drag.handle, initial, centered: centeredScaling, - }) - const nextBounds = getObjectRotatedBounds(nextObject) - const frameEl = artboardInnerRef.current + }); + const nextBounds = getObjectRotatedBounds(nextObject); + const frameEl = artboardInnerRef.current; if (frameEl) { setTransformDimensionUi( - computeTransformDimensionUi(frameEl, artboardW, artboardH, nextBounds), - ) + computeTransformDimensionUi( + frameEl, + artboardW, + artboardH, + nextBounds + ) + ); } setDoc((prev) => ({ ...prev, objects: prev.objects.map((obj) => - obj.id === drag.id ? nextObject : obj, + obj.id === drag.id ? nextObject : obj ), - })) - } + })); + }; const onUp = () => { - dragStateRef.current = null - setMarqueeRect(null) - snapGuideXRef.current = null - snapGuideYRef.current = null - setSnapGuides([]) - setTransformDimensionUi(null) - window.removeEventListener('pointermove', onMove) - window.removeEventListener('pointerup', onUp) - window.removeEventListener('pointercancel', onUp) - } - - window.addEventListener('pointermove', onMove) - window.addEventListener('pointerup', onUp) - window.addEventListener('pointercancel', onUp) + dragStateRef.current = null; + setMarqueeRect(null); + snapGuideXRef.current = null; + snapGuideYRef.current = null; + setSnapGuides([]); + setTransformDimensionUi(null); + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + }; + + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); }, - [artboardH, artboardW, pointerToScene], - ) + [artboardH, artboardW, pointerToScene] + ); const onObjectPointerDown = useCallback( (e: ReactPointerEvent, obj: SceneObject) => { - if (e.button !== 0) return - e.stopPropagation() - setBackgroundActive(false) + if (e.button !== 0) return; + e.stopPropagation(); + setBackgroundActive(false); if (textEditingId && textEditingId !== obj.id) { - commitTextDraft() + commitTextDraft(); } if (e.shiftKey || e.metaKey || e.ctrlKey) { setSelectedIds((prev) => - prev.includes(obj.id) ? prev.filter((id) => id !== obj.id) : [...prev, obj.id], - ) - return + prev.includes(obj.id) + ? prev.filter((id) => id !== obj.id) + : [...prev, obj.id] + ); + return; } - if (!selectedIds.includes(obj.id)) setSelectedIds([obj.id]) - setContextMenu(null) - if (obj.locked) return - const pt = pointerToScene(e.clientX, e.clientY) - const sourceIds = selectedIds.includes(obj.id) ? selectedIds : [obj.id] - let ids = sourceIds - let movingObjects = doc.objects.filter((row) => ids.includes(row.id)) + if (!selectedIds.includes(obj.id)) setSelectedIds([obj.id]); + setContextMenu(null); + if (obj.locked) return; + const pt = pointerToScene(e.clientX, e.clientY); + const sourceIds = selectedIds.includes(obj.id) ? selectedIds : [obj.id]; + let ids = sourceIds; + let movingObjects = doc.objects.filter((row) => ids.includes(row.id)); if (e.altKey && movingObjects.length > 0) { const duplicates = movingObjects.map((row) => { - const dup = renameWithFreshIds(row) - dup.locked = false - return dup - }) - ids = duplicates.map((row) => row.id) - movingObjects = duplicates - setDoc((prev) => ({ ...prev, objects: [...prev.objects, ...duplicates] })) - setSelectedIds(ids) + const dup = renameWithFreshIds(row); + dup.locked = false; + return dup; + }); + ids = duplicates.map((row) => row.id); + movingObjects = duplicates; + setDoc((prev) => ({ + ...prev, + objects: [...prev.objects, ...duplicates], + })); + setSelectedIds(ids); } - const initial = new Map() + const initial = new Map(); for (const row of movingObjects) { - initial.set(row.id, { x: row.x, y: row.y }) + initial.set(row.id, { x: row.x, y: row.y }); } - const initialBounds = getSelectionBounds(movingObjects) + const initialBounds = getSelectionBounds(movingObjects); const snapTargets = doc.objects .filter((row) => row.visible && !sourceIds.includes(row.id)) - .map((row) => getObjectRotatedBounds(row)) + .map((row) => getObjectRotatedBounds(row)); startWindowDrag({ - kind: 'move', + kind: "move", ids, startSceneX: pt.x, startSceneY: pt.y, initial, initialBounds, snapTargets, - }) + }); }, [ commitTextDraft, @@ -1607,63 +1796,63 @@ const SceneEditor = forwardRef( selectedIds, startWindowDrag, textEditingId, - ], - ) + ] + ); const onSelectionHandlePointerDown = useCallback( (e: ReactPointerEvent, handle: ResizeHandleId) => { - if (!selectedSingle || selectedSingle.locked) return - e.preventDefault() - e.stopPropagation() + if (!selectedSingle || selectedSingle.locked) return; + e.preventDefault(); + e.stopPropagation(); startWindowDrag({ - kind: 'resize', + kind: "resize", id: selectedSingle.id, handle, initial: cloneSceneObject(selectedSingle), - }) + }); }, - [selectedSingle, startWindowDrag], - ) + [selectedSingle, startWindowDrag] + ); const onRotateHandlePointerDown = useCallback( (e: ReactPointerEvent) => { - if (!selectedSingle || selectedSingle.locked) return - e.preventDefault() - e.stopPropagation() - const pt = pointerToScene(e.clientX, e.clientY) - const center = getObjectCenter(selectedSingle) + if (!selectedSingle || selectedSingle.locked) return; + e.preventDefault(); + e.stopPropagation(); + const pt = pointerToScene(e.clientX, e.clientY); + const center = getObjectCenter(selectedSingle); startWindowDrag({ - kind: 'rotate', + kind: "rotate", id: selectedSingle.id, center, initialRotation: selectedSingle.rotation, startAngle: angleFromPoints(center.x, center.y, pt.x, pt.y), - }) + }); }, - [pointerToScene, selectedSingle, startWindowDrag], - ) + [pointerToScene, selectedSingle, startWindowDrag] + ); const onViewportPointerDown = useCallback( (e: ReactPointerEvent) => { - if (e.button !== 0) return + if (e.button !== 0) return; if (textEditingId) { - commitTextDraft() + commitTextDraft(); } - setContextMenu(null) - setHoveredId(null) - setBackgroundHovered(true) - const additive = e.shiftKey || e.metaKey || e.ctrlKey - const pt = pointerToScene(e.clientX, e.clientY) - setBackgroundActive(!additive) - if (!additive) setSelectedIds([]) + setContextMenu(null); + setHoveredId(null); + setBackgroundHovered(true); + const additive = e.shiftKey || e.metaKey || e.ctrlKey; + const pt = pointerToScene(e.clientX, e.clientY); + setBackgroundActive(!additive); + if (!additive) setSelectedIds([]); startWindowDrag({ - kind: 'marquee', + kind: "marquee", startSceneX: pt.x, startSceneY: pt.y, additive, initialSelection: additive ? selectedIds : [], objects: doc.objects.filter((obj) => obj.visible), - }) + }); }, [ commitTextDraft, @@ -1672,46 +1861,46 @@ const SceneEditor = forwardRef( selectedIds, startWindowDrag, textEditingId, - ], - ) + ] + ); const onWorkspacePointerDown = useCallback( (e: ReactPointerEvent) => { - if (e.button !== 0) return - const targetNode = e.target as Node - if (artboardOuterRef.current?.contains(targetNode)) return - const targetEl = e.target as HTMLElement | null - if (targetEl?.closest?.('[data-avnac-chrome]')) return + if (e.button !== 0) return; + const targetNode = e.target as Node; + if (artboardOuterRef.current?.contains(targetNode)) return; + const targetEl = e.target as HTMLElement | null; + if (targetEl?.closest?.("[data-avnac-chrome]")) return; if (textEditingId) { - commitTextDraft() + commitTextDraft(); } - setContextMenu(null) - setHoveredId(null) - setBackgroundHovered(false) - setBackgroundActive(false) - setMarqueeRect(null) - setSelectedIds([]) + setContextMenu(null); + setHoveredId(null); + setBackgroundHovered(false); + setBackgroundActive(false); + setMarqueeRect(null); + setSelectedIds([]); }, - [commitTextDraft, setHoveredId, setSelectedIds, textEditingId], - ) + [commitTextDraft, setHoveredId, setSelectedIds, textEditingId] + ); const onArtboardPointerEnter = useCallback(() => { - setBackgroundHovered(true) - }, []) + setBackgroundHovered(true); + }, []); const onArtboardPointerMove = useCallback(() => { - setBackgroundHovered(true) - }, []) + setBackgroundHovered(true); + }, []); const onArtboardPointerLeave = useCallback(() => { - setHoveredId(null) - setBackgroundHovered(false) - }, []) + setHoveredId(null); + setBackgroundHovered(false); + }, []); const onViewportContextMenu = useCallback( (e: ReactMouseEvent) => { - e.preventDefault() - const pt = pointerToScene(e.clientX, e.clientY) + e.preventDefault(); + const pt = pointerToScene(e.clientX, e.clientY); setContextMenu({ x: e.clientX, y: e.clientY, @@ -1719,12 +1908,12 @@ const SceneEditor = forwardRef( sceneY: pt.y, hasSelection: selectedIds.length > 0, locked: elementToolbarLockedDisplay, - }) + }); }, - [elementToolbarLockedDisplay, pointerToScene, selectedIds.length], - ) + [elementToolbarLockedDisplay, pointerToScene, selectedIds.length] + ); - const closeContextMenu = useCallback(() => setContextMenu(null), []) + const closeContextMenu = useCallback(() => setContextMenu(null), []); useEditorKeyboardShortcuts({ applyingHistoryRef, @@ -1742,60 +1931,72 @@ const SceneEditor = forwardRef( setDoc, setShortcutsOpen, ungroupSelection, - }) + }); useEffect(() => { const onPaste = (e: ClipboardEvent) => { - if (textEditingId) return - const files = imageFilesFromTransfer(e.clipboardData) + if (textEditingId) return; + const files = imageFilesFromTransfer(e.clipboardData); if (files.length > 0) { - e.preventDefault() - void addImageFromFiles(files) - return + e.preventDefault(); + void addImageFromFiles(files); + return; } const imageUrl = e.clipboardData ? extractImageUrlFromDataTransfer(e.clipboardData) - : null + : null; if (imageUrl) { - e.preventDefault() - void placeImageObject(imageUrl) + e.preventDefault(); + void placeImageObject(imageUrl); } - } - window.addEventListener('paste', onPaste) - return () => window.removeEventListener('paste', onPaste) - }, [addImageFromFiles, placeImageObject, textEditingId]) - - const onViewportDragOver = useCallback((e: React.DragEvent) => { - if ( - e.dataTransfer.types.includes(AVNAC_VECTOR_BOARD_DRAG_MIME) || - transferMayContainFiles(e.dataTransfer) || - imageFilesFromTransfer(e.dataTransfer).length > 0 || - !!extractImageUrlFromDataTransfer(e.dataTransfer) - ) { - e.preventDefault() - e.dataTransfer.dropEffect = 'copy' - } - }, []) + }; + window.addEventListener("paste", onPaste); + return () => window.removeEventListener("paste", onPaste); + }, [addImageFromFiles, placeImageObject, textEditingId]); + + const onViewportDragOver = useCallback( + (e: React.DragEvent) => { + if ( + e.dataTransfer.types.includes(AVNAC_VECTOR_BOARD_DRAG_MIME) || + transferMayContainFiles(e.dataTransfer) || + imageFilesFromTransfer(e.dataTransfer).length > 0 || + !!extractImageUrlFromDataTransfer(e.dataTransfer) + ) { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + } + }, + [] + ); const onViewportDrop = useCallback( (e: React.DragEvent) => { - e.preventDefault() - const pt = pointerToScene(e.clientX, e.clientY) - const boardId = e.dataTransfer.getData(AVNAC_VECTOR_BOARD_DRAG_MIME) + e.preventDefault(); + const pt = pointerToScene(e.clientX, e.clientY); + const boardId = e.dataTransfer.getData(AVNAC_VECTOR_BOARD_DRAG_MIME); if (boardId) { - placeVectorBoard(boardId, pt.x, pt.y) - return + placeVectorBoard(boardId, pt.x, pt.y); + return; } - const files = imageFilesFromTransfer(e.dataTransfer) + const files = imageFilesFromTransfer(e.dataTransfer); if (files.length > 0) { - void addImageFromFiles(files, { x: pt.x, y: pt.y, origin: 'top-left' }) - return + void addImageFromFiles(files, { + x: pt.x, + y: pt.y, + origin: "top-left", + }); + return; } - const imageUrl = extractImageUrlFromDataTransfer(e.dataTransfer) - if (imageUrl) void placeImageObject(imageUrl, { x: pt.x, y: pt.y, origin: 'top-left' }) + const imageUrl = extractImageUrlFromDataTransfer(e.dataTransfer); + if (imageUrl) + void placeImageObject(imageUrl, { + x: pt.x, + y: pt.y, + origin: "top-left", + }); }, - [addImageFromFiles, placeImageObject, placeVectorBoard, pointerToScene], - ) + [addImageFromFiles, placeImageObject, placeVectorBoard, pointerToScene] + ); const aiController = useAiDesignController({ addObjects, @@ -1805,11 +2006,14 @@ const SceneEditor = forwardRef( placeImageObject, setDoc, setSelectedIds, - }) + }); const selectionEffectsFooterSlot = hasObjectSelected ? ( <> - + ( onChange={applyShadowToSelection} /> - ) : null + ) : null; const selectionToolbarValue: EditorSelectionToolbarContextValue = { actions: { @@ -1871,7 +2075,7 @@ const SceneEditor = forwardRef( shapeToolbarModel, textToolbarValues, }, - } + }; const canvasStageValue: CanvasStageContextValue = { actions: { @@ -1887,18 +2091,18 @@ const SceneEditor = forwardRef( onArtboardPointerMove, onObjectHoverChange: (id, hovering) => { setHoveredId((current) => { - if (hovering) return id - return current === id ? null : current - }) + if (hovering) return id; + return current === id ? null : current; + }); }, onObjectPointerDown, onRotateHandlePointerDown, onSelectionHandlePointerDown, onTextDoubleClick: (textObj) => { - if (textObj.locked) return - setSelectedIds([textObj.id]) - setTextEditingId(textObj.id) - setTextDraft(textObj.text) + if (textObj.locked) return; + setSelectedIds([textObj.id]); + setTextEditingId(textObj.id); + setTextDraft(textObj.text); }, onTextDraftChange: setTextDraft, onViewportPointerDown, @@ -1924,6 +2128,8 @@ const SceneEditor = forwardRef( elementToolbarLockedDisplay, hasObjectSelected, marqueeRect, + panX, + panY, ready, scale, selectedObjects, @@ -1933,123 +2139,127 @@ const SceneEditor = forwardRef( textDraft, textEditingId, }, - } + }; return (
- { - void addImageFromFiles(e.target.files) - e.target.value = '' - }} - /> - - - - - - {exportError ? ( -
-
- {exportError} -
-
- ) : null} + { + void addImageFromFiles(e.target.files); + e.target.value = ""; + }} + /> -
- - - -
- - void copyElementToClipboard()} - onDelete={deleteSelection} - onDuplicate={() => void duplicateElement()} - onPaste={(point) => void pasteFromClipboard(point)} - onToggleLock={toggleElementLock} - /> + + + + + {exportError ? ( +
+
+ {exportError} +
+
+ ) : null} + +
+ + + +
- + void copyElementToClipboard()} + onDelete={deleteSelection} + onDuplicate={() => void duplicateElement()} + onPaste={(point) => void pasteFromClipboard(point)} + onToggleLock={toggleElementLock} + /> - {!ready ? ( -
- Loading canvas… -
- ) : null} + - - setEditorSidebarPanel(null)} - onSelectPanel={(id) => setEditorSidebarPanel((prev) => (prev === id ? null : id))} - ready={ready} - /> - - setShortcutsOpen(false)} - /> - - {ready && transformDimensionUi - ? createPortal( -
- {transformDimensionUi.text} -
, - document.body, - ) - : null} + {!ready ? ( +
+ + Loading canvas… + +
+ ) : null} + + + setEditorSidebarPanel(null)} + onSelectPanel={(id) => + setEditorSidebarPanel((prev) => (prev === id ? null : id)) + } + ready={ready} + /> + + setShortcutsOpen(false)} + /> + + {ready && transformDimensionUi + ? createPortal( +
+ {transformDimensionUi.text} +
, + document.body + ) + : null}
- ) - }, -) + ); + } +); -export default SceneEditor +export default SceneEditor; diff --git a/frontend/src/components/scene-editor/canvas-stage-context.tsx b/frontend/src/components/scene-editor/canvas-stage-context.tsx index 722682c..542f12d 100644 --- a/frontend/src/components/scene-editor/canvas-stage-context.tsx +++ b/frontend/src/components/scene-editor/canvas-stage-context.tsx @@ -4,103 +4,111 @@ import { type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject, -} from 'react' +} from "react"; -import type { - SceneObject, - SceneText, -} from '../../lib/avnac-scene' -import type { CanvasAlignKind } from '../canvas-element-toolbar' +import type { SceneObject, SceneText } from "../../lib/avnac-scene"; +import type { CanvasAlignKind } from "../canvas-element-toolbar"; import type { MarqueeRect, ResizeHandleId, SceneSnapGuide, -} from '../../scene-engine/primitives' +} from "../../scene-engine/primitives"; type ElementToolbarLayout = { - left: number - top: number - placement: 'above' | 'below' -} + left: number; + top: number; + placement: "above" | "below"; +}; export type CanvasStageContextValue = { actions: { - alignElementToArtboard: (kind: CanvasAlignKind) => void - alignSelectedElements: (kind: CanvasAlignKind) => void - commitTextDraft: () => void - copyElementToClipboard: () => void - deleteSelection: () => void - duplicateElement: () => void - groupSelection: () => void - onArtboardPointerEnter: () => void - onArtboardPointerLeave: () => void - onArtboardPointerMove: () => void - onObjectHoverChange: (id: string, hovering: boolean) => void + alignElementToArtboard: (kind: CanvasAlignKind) => void; + alignSelectedElements: (kind: CanvasAlignKind) => void; + commitTextDraft: () => void; + copyElementToClipboard: () => void; + deleteSelection: () => void; + duplicateElement: () => void; + groupSelection: () => void; + onArtboardPointerEnter: () => void; + onArtboardPointerLeave: () => void; + onArtboardPointerMove: () => void; + onObjectHoverChange: (id: string, hovering: boolean) => void; onObjectPointerDown: ( e: ReactPointerEvent, - obj: SceneObject, - ) => void - onRotateHandlePointerDown: (e: ReactPointerEvent) => void + obj: SceneObject + ) => void; + onRotateHandlePointerDown: ( + e: ReactPointerEvent + ) => void; onSelectionHandlePointerDown: ( e: ReactPointerEvent, - handle: ResizeHandleId, - ) => void - onTextDoubleClick: (textObj: SceneText) => void - onTextDraftChange: (value: string) => void - onViewportPointerDown: (e: ReactPointerEvent) => void - pasteFromClipboard: () => void - toggleElementLock: () => void - ungroupSelection: () => void - } + handle: ResizeHandleId + ) => void; + onTextDoubleClick: (textObj: SceneText) => void; + onTextDraftChange: (value: string) => void; + onViewportPointerDown: (e: ReactPointerEvent) => void; + pasteFromClipboard: () => void; + toggleElementLock: () => void; + ungroupSelection: () => void; + }; refs: { - artboardInnerRef: RefObject - artboardOuterRef: RefObject - elementToolbarRef: RefObject - viewportRef: RefObject - } + artboardInnerRef: RefObject; + artboardOuterRef: RefObject; + elementToolbarRef: RefObject; + viewportRef: RefObject; + }; state: { - backgroundActive: boolean - backgroundHovered: boolean - editingSelectedText: boolean - elementToolbarAlignAlready: Record | null - elementToolbarCanAlignElements: boolean - elementToolbarCanGroup: boolean - elementToolbarCanUngroup: boolean - elementToolbarLayout: ElementToolbarLayout | null - elementToolbarLockedDisplay: boolean - hasObjectSelected: boolean - marqueeRect: MarqueeRect | null - ready: boolean - scale: number - selectedObjects: SceneObject[] - selectedSingle: SceneObject | null - selectionBounds: { left: number; top: number; width: number; height: number } | null - snapGuides: SceneSnapGuide[] - textDraft: string - textEditingId: string | null - } -} + backgroundActive: boolean; + backgroundHovered: boolean; + editingSelectedText: boolean; + elementToolbarAlignAlready: Record | null; + elementToolbarCanAlignElements: boolean; + elementToolbarCanGroup: boolean; + elementToolbarCanUngroup: boolean; + elementToolbarLayout: ElementToolbarLayout | null; + elementToolbarLockedDisplay: boolean; + hasObjectSelected: boolean; + marqueeRect: MarqueeRect | null; + panX: number; + panY: number; + ready: boolean; + scale: number; + selectedObjects: SceneObject[]; + selectedSingle: SceneObject | null; + selectionBounds: { + left: number; + top: number; + width: number; + height: number; + } | null; + snapGuides: SceneSnapGuide[]; + textDraft: string; + textEditingId: string | null; + }; +}; -const CanvasStageContext = createContext(null) +const CanvasStageContext = createContext(null); export function CanvasStageProvider({ children, value, }: { - children: ReactNode - value: CanvasStageContextValue + children: ReactNode; + value: CanvasStageContextValue; }) { return ( {children} - ) + ); } export function useCanvasStageContext() { - const value = useContext(CanvasStageContext) + const value = useContext(CanvasStageContext); if (!value) { - throw new Error('useCanvasStageContext must be used within CanvasStageProvider') + throw new Error( + "useCanvasStageContext must be used within CanvasStageProvider" + ); } - return value + return value; } diff --git a/frontend/src/components/scene-editor/canvas-stage.tsx b/frontend/src/components/scene-editor/canvas-stage.tsx index 29d8719..5a24979 100644 --- a/frontend/src/components/scene-editor/canvas-stage.tsx +++ b/frontend/src/components/scene-editor/canvas-stage.tsx @@ -1,18 +1,18 @@ -import { useMemo } from 'react' +import { useMemo } from "react"; -import { - getObjectRotatedBounds, -} from '../../lib/avnac-scene' -import CanvasElementToolbar, { type CanvasAlignKind } from '../canvas-element-toolbar' -import { SceneObjectView } from './object-view' +import { getObjectRotatedBounds } from "../../lib/avnac-scene"; +import CanvasElementToolbar, { + type CanvasAlignKind, +} from "../canvas-element-toolbar"; +import { SceneObjectView } from "./object-view"; import { SelectionBoundsOverlay, SelectionOverlay, SnapGuidesOverlay, -} from './selection-overlays' -import { useEditorStore } from './editor-store' -import { useVectorBoardControlsContext } from './use-vector-board-controls' -import { useCanvasStageContext } from './canvas-stage-context' +} from "./selection-overlays"; +import { useEditorStore } from "./editor-store"; +import { useVectorBoardControlsContext } from "./use-vector-board-controls"; +import { useCanvasStageContext } from "./canvas-stage-context"; const EMPTY_ALIGN_STATE: Record = { left: false, @@ -21,10 +21,10 @@ const EMPTY_ALIGN_STATE: Record = { top: false, centerV: false, bottom: false, -} +}; export function CanvasStage() { - const { actions, refs, state } = useCanvasStageContext() + const { actions, refs, state } = useCanvasStageContext(); const { alignElementToArtboard, alignSelectedElements, @@ -46,13 +46,9 @@ export function CanvasStage() { pasteFromClipboard, toggleElementLock, ungroupSelection, - } = actions - const { - artboardInnerRef, - artboardOuterRef, - elementToolbarRef, - viewportRef, - } = refs + } = actions; + const { artboardInnerRef, artboardOuterRef, elementToolbarRef, viewportRef } = + refs; const { backgroundActive, backgroundHovered, @@ -65,6 +61,8 @@ export function CanvasStage() { elementToolbarLockedDisplay, hasObjectSelected, marqueeRect, + panX, + panY, ready, scale, selectedObjects, @@ -73,27 +71,47 @@ export function CanvasStage() { snapGuides, textDraft, textEditingId, - } = state - const artboard = useEditorStore((storeState) => storeState.doc.artboard) - const bg = useEditorStore((state) => state.doc.bg) - const objects = useEditorStore((state) => state.doc.objects) - const selectedIds = useEditorStore((state) => state.selectedIds) - const hoveredId = useEditorStore((state) => state.hoveredId) - const { boardDocs } = useVectorBoardControlsContext() - const artboardW = artboard.width - const artboardH = artboard.height + } = state; + const artboard = useEditorStore((storeState) => storeState.doc.artboard); + const bg = useEditorStore((state) => state.doc.bg); + const objects = useEditorStore((state) => state.doc.objects); + const selectedIds = useEditorStore((state) => state.selectedIds); + const hoveredId = useEditorStore((state) => state.hoveredId); + const { boardDocs } = useVectorBoardControlsContext(); + const artboardW = artboard.width; + const artboardH = artboard.height; const hoveredObject = useMemo( () => hoveredId - ? objects.find((obj) => obj.id === hoveredId && obj.visible) ?? null + ? (objects.find((obj) => obj.id === hoveredId && obj.visible) ?? null) : null, - [hoveredId, objects], - ) + [hoveredId, objects] + ); + + const dotSize = 1; + const dotSpacing = 24 * scale; + const dotColor = "rgba(0,0,0,0.12)"; + const bgOffsetX = panX % dotSpacing; + const bgOffsetY = panY % dotSpacing; + const gridBg = `radial-gradient(circle, ${dotColor} ${dotSize}px, transparent ${dotSize}px)`; return ( -
-
- {ready && hasObjectSelected && elementToolbarLayout && !editingSelectedText ? ( +
+
+ {ready && + hasObjectSelected && + elementToolbarLayout && + !editingSelectedText ? (
) : null} {selectedObjects.length > 1 && selectionBounds ? ( ) : null} - {marqueeRect && (marqueeRect.width > 0 || marqueeRect.height > 0) ? ( + {marqueeRect && + (marqueeRect.width > 0 || marqueeRect.height > 0) ? ( ) : null} - {selectedSingle && !selectedSingle.locked && !editingSelectedText ? ( + {selectedSingle && + !selectedSingle.locked && + !editingSelectedText ? (
- ) + ); }