diff --git a/frontend/src/components/scene-editor.tsx b/frontend/src/components/scene-editor.tsx index 0612ccb..54bf733 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, @@ -28,6 +28,7 @@ import { objectSupportsFill, objectSupportsOutlineStroke, parseAvnacDocument, + parseSceneObject, removeTopLevelObjects, sceneObjectToShapeMeta, setObjectCornerRadius, @@ -41,68 +42,67 @@ 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 { EditorPagesPanel } from "./scene-editor/editor-pages-panel"; 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 +133,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 = 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" }; 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, ): 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,7 +173,7 @@ 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( @@ -182,20 +182,24 @@ function computeTransformDimensionUi( sceneH: 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( { @@ -207,30 +211,30 @@ const SceneEditor = forwardRef( }, 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( @@ -238,52 +242,83 @@ const SceneEditor = forwardRef( clampDimension(initialArtboardWidth, DEFAULT_ARTBOARD_W), 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 activePageId = useStore(editorStore, (state) => state.activePageId); + const setActivePageId = useStore( + editorStore, + (state) => state.setActivePageId, + ); + const selectedIds = useStore(editorStore, (state) => state.selectedIds); + const setSelectedIds = useStore( + editorStore, + (state) => state.setSelectedIds, + ); + const setHoveredId = useStore(editorStore, (state) => state.setHoveredId); + + // Derive the active page — falls back to first page if id not found + const activePage = useMemo( + () => doc.pages.find((p) => p.id === activePageId) ?? doc.pages[0], + [doc.pages, activePageId], + ); + + // Helper: update any property on the active page + const updateActivePage = useCallback( + ( + updater: ( + page: import("../lib/avnac-scene").AvnacPage, + ) => import("../lib/avnac-scene").AvnacPage, + ) => { + setDoc((prev) => ({ + ...prev, + pages: prev.pages.map((p) => + p.id === activePageId ? updater(p) : p, + ), + })); + }, + [activePageId, setDoc], + ); + + const [ready, setReady] = useState(false); + const [zoomPercent, setZoomPercent] = useState(null); 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 +327,71 @@ 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 = activePage.artboard.width; + const artboardH = activePage.artboard.height; const selectedObjects = useMemo( - () => doc.objects.filter((obj) => selectedIds.includes(obj.id)), - [doc.objects, selectedIds], - ) - const selectedSingle = selectedObjects.length === 1 ? selectedObjects[0] : null + () => activePage.objects.filter((obj) => selectedIds.includes(obj.id)), + [activePage.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, + Math.min( + availW / Math.max(1, artboardW), + availH / Math.max(1, artboardH), + ) * 100, ), ), - ) - setZoomPercent(pct) - }, [artboardH, artboardW]) + ); + 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, @@ -369,203 +409,228 @@ const SceneEditor = forwardRef( persistId, persistIdRef, ready, + setActivePageId, setDoc, setReady, setSelectedIds, 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], - ) + ); 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]); 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]) + ); + }, [selectedObjects]); const selectionOutlineStrokeAllowed = useMemo( () => selectedObjects.some((obj) => objectSupportsOutlineStroke(obj)), [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]) + ); + }, [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], - ) + ); 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.length > 0 && + selectedObjects.every((obj) => obj.locked), [selectedObjects], - ) - const elementToolbarCanGroup = selectedObjects.length >= 2 - const elementToolbarCanAlignElements = selectedObjects.length >= 2 + ); + const elementToolbarCanGroup = selectedObjects.length >= 2; + const elementToolbarCanAlignElements = selectedObjects.length >= 2; const elementToolbarCanUngroup = - 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]) + selectedSingle?.type === "group" && !selectedSingle.locked; + + const nudgeSelection = useCallback( + (dx: number, dy: number) => { + if (selectedIds.length === 0) return; + updateActivePage((p) => ({ + ...p, + objects: p.objects.map((obj) => + selectedIds.includes(obj.id) + ? { ...obj, x: obj.x + dx, y: obj.y + dy } + : obj, + ), + })); + setSelectionRev((n) => n + 1); + }, + [selectedIds, updateActivePage], + ); 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) + }; + activePage.objects.forEach(visit); fonts.forEach((fontFamily) => { - void loadGoogleFontFamily(fontFamily) - }) - }, [doc.objects]) + void loadGoogleFontFamily(fontFamily); + }); + }, [activePage.objects]); const reorderSelectionLayers = useCallback( (kind: LayerReorderKind) => { - if (selectedIds.length === 0) return - setDoc((prev) => { - const next = reorderTopLevelObjects(prev.objects, selectedIds, kind) - return next === prev.objects ? prev : { ...prev, objects: next } - }) + if (selectedIds.length === 0) return; + updateActivePage((p) => { + const next = reorderTopLevelObjects(p.objects, selectedIds, kind); + return next === p.objects ? p : { ...p, 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]) - - const addObjects = useCallback((objectsToAdd: SceneObject[]) => { - setDoc((prev) => ({ ...prev, objects: [...prev.objects, ...objectsToAdd] })) - pushSelectionToTop(objectsToAdd.map((obj) => obj.id)) - }, [pushSelectionToTop]) + [selectedIds, updateActivePage], + ); + + 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[]) => { + updateActivePage((p) => ({ + ...p, + objects: [...p.objects, ...objectsToAdd], + })); + pushSelectionToTop(objectsToAdd.map((obj) => obj.id)); + }, + [pushSelectionToTop, updateActivePage], + ); const vectorBoardControls = useVectorBoardControls({ addObjects, @@ -574,11 +639,9 @@ const SceneEditor = forwardRef( persistId, ready, setDoc, - }) - const { - boardDocs: vectorBoardDocs, - placeVectorBoard, - } = vectorBoardControls + }); + const { boardDocs: vectorBoardDocs, placeVectorBoard } = + vectorBoardControls; const defaultShapeBox = useMemo( () => ({ @@ -588,27 +651,34 @@ const SceneEditor = forwardRef( lineH: Math.round(artboardH * 0.12), }), [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], - ) + ); 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 +687,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], - ) + ); const addText = useCallback(() => { addObjects([ createCenteredObject({ id: crypto.randomUUID(), - type: 'text', + type: "text", x: 0, y: 0, width: Math.round(artboardW * 0.28), @@ -715,57 +785,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 +860,246 @@ const SceneEditor = forwardRef( height: meta.naturalHeight, }, cornerRadius: 0, - } - addObjects([obj]) - return obj.id + }; + addObjects([obj]); + return obj.id; }, [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 + ); + placedCount += 1; } }, [placeImageObject], - ) + ); const updateSelectedObjects = useCallback( (updater: (obj: SceneObject) => SceneObject) => { - setDoc((prev) => ({ - ...prev, - objects: prev.objects.map((obj) => + updateActivePage((p) => ({ + ...p, + objects: p.objects.map((obj) => selectedIds.includes(obj.id) ? updater(obj) : obj, ), - })) + })); }, - [selectedIds], - ) + [selectedIds, updateActivePage], + ); const deleteSelection = useCallback(() => { - if (selectedIds.length === 0) return - setDoc((prev) => ({ - ...prev, - objects: removeTopLevelObjects(prev.objects, selectedIds), - })) - setSelectedIds([]) - setTextEditingId(null) - }, [selectedIds]) + if (selectedIds.length === 0) return; + updateActivePage((p) => ({ + ...p, + objects: removeTopLevelObjects(p.objects, selectedIds), + })); + setSelectedIds([]); + setTextEditingId(null); + }, [selectedIds, updateActivePage]); const duplicateElement = useCallback(async () => { - if (selectedIds.length === 0) return - const duplicates = doc.objects + if (selectedIds.length === 0) return; + const duplicates = activePage.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, activePage.objects, selectedIds]); const copyElementToClipboard = useCallback(async () => { - if (selectedIds.length === 0) return - const objects = doc.objects + if (selectedIds.length === 0) return; + const objects = activePage.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]) + ); + }, [activePage.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) => parseSceneObject(row)) .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, 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 = activePage.objects.filter((obj) => + selectedIds.includes(obj.id), + ); + const group = createGroupFromSelection(picked); + if (!group) return; + const firstIndex = activePage.objects.findIndex((obj) => + selectedIds.includes(obj.id), + ); + const remaining = activePage.objects.filter( + (obj) => !selectedIds.includes(obj.id), + ); + remaining.splice( + firstIndex < 0 ? remaining.length : firstIndex, + 0, + group, + ); + updateActivePage((p) => ({ ...p, objects: remaining })); + setSelectedIds([group.id]); + }, [activePage.objects, selectedIds, updateActivePage]); const ungroupSelection = useCallback(() => { - 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 applyBackgroundPicked = useCallback((bg: BgValue) => { - setDoc((prev) => ({ ...prev, bg })) - }, []) + if (!selectedSingle || selectedSingle.type !== "group") return; + const children = ungroupSceneObject(selectedSingle); + updateActivePage((p) => { + const idx = p.objects.findIndex((obj) => obj.id === selectedSingle.id); + const next = p.objects.filter((obj) => obj.id !== selectedSingle.id); + next.splice(idx < 0 ? next.length : idx, 0, ...children); + return { ...p, objects: next }; + }); + setSelectedIds(children.map((child) => child.id)); + }, [selectedSingle, updateActivePage]); + + const applyBackgroundPicked = useCallback( + (bg: BgValue) => { + updateActivePage((p) => ({ ...p, bg })); + }, + [updateActivePage], + ); 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], - ) + ); const applyOutlineStrokeWidth = useCallback( (px: number) => { - updateSelectedObjects((obj) => setObjectStrokeWidth(obj, px)) + updateSelectedObjects((obj) => setObjectStrokeWidth(obj, px)); }, [updateSelectedObjects], - ) + ); const applyOutlineStrokePaint = useCallback( (paint: BgValue) => { - updateSelectedObjects((obj) => setObjectStroke(obj, paint)) + updateSelectedObjects((obj) => setObjectStroke(obj, paint)); }, [updateSelectedObjects], - ) + ); const applyBlurToSelection = useCallback( (blurPct: number) => { - updateSelectedObjects((obj) => ({ ...obj, blurPct })) + updateSelectedObjects((obj) => ({ ...obj, blurPct })); }, [updateSelectedObjects], - ) + ); const applyOpacityToSelection = useCallback( (opacityPct: number) => { updateSelectedObjects((obj) => ({ ...obj, opacity: Math.max(0, Math.min(1, opacityPct / 100)), - })) + })); }, [updateSelectedObjects], - ) + ); const applyShadowToSelection = useCallback( (shadow: ShadowUi) => { @@ -1007,207 +1107,223 @@ 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], - ) + ); const applyRectCornerRadius = useCallback( (radius: number) => { updateSelectedObjects((obj) => - obj.type === 'rect' ? setObjectCornerRadius(obj, radius) : obj, - ) + obj.type === "rect" ? setObjectCornerRadius(obj, radius) : obj, + ); }, [updateSelectedObjects], - ) + ); const applyImageCornerRadius = useCallback( (radius: number) => { updateSelectedObjects((obj) => - obj.type === 'image' ? setObjectCornerRadius(obj, radius) : obj, - ) + obj.type === "image" ? setObjectCornerRadius(obj, radius) : obj, + ); }, [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, - ) + ); }, [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, - ) + ); }, [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], - ) + ); 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], - ) + ); 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, - ) + ); }, [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], - ) + ); 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 - }) + ); + return next; + }); }, [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], - ) + ); 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], - ) + ); - 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) => { + updateActivePage((p) => ({ ...p, artboard: { width, height } })); + if (!zoomUserAdjustedRef.current) window.setTimeout(() => fitZoom(), 0); + }, + [fitZoom, updateActivePage], + ); 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 - setDoc((prev) => ({ - ...prev, - objects: prev.objects.map((obj) => - obj.id === targetId && obj.type === 'image' + const targetId = imageCropTargetIdRef.current; + if (!targetId) return; + updateActivePage((p) => ({ + ...p, + objects: p.objects.map((obj) => + obj.id === targetId && obj.type === "image" ? { ...obj, crop: { @@ -1219,148 +1335,154 @@ const SceneEditor = forwardRef( } : obj, ), - })) - setImageCropOpen(false) + })); + setImageCropOpen(false); }, - [], - ) + [updateActivePage], + ); 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( + activePage, + 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], - ) + [activePage, vectorBoardDocs], + ); useImperativeHandle( ref, () => ({ 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; + setZoomPercent( + Math.max(ZOOM_MIN_PCT, Math.min(ZOOM_MAX_PCT, Math.round(pct))), + ); + }, []); const onZoomFitRequest = useCallback(() => { - zoomUserAdjustedRef.current = false - fitZoom() - }, [fitZoom]) + zoomUserAdjustedRef.current = false; + fitZoom(); + }, [fitZoom]); const commitTextDraft = useCallback(() => { - 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 (!textEditingId) return; + updateActivePage((p) => ({ + ...p, + objects: p.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; }), - })) - setTextEditingId(null) - }, [textDraft, textEditingId]) + })); + setTextEditingId(null); + }, [textDraft, textEditingId, updateActivePage]); 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) + ); + setMarqueeRect(nextRect); const intersectedIds = drag.objects .filter( (obj) => obj.visible && boundsIntersect(getObjectRotatedBounds(obj), nextRect), ) - .map((obj) => obj.id) + .map((obj) => obj.id); setSelectedIds( drag.additive ? mergeUniqueIds(drag.initialSelection, intersectedIds) : intersectedIds, - ) - return + ); + 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( { @@ -1374,90 +1496,105 @@ const SceneEditor = forwardRef( 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 + ); + 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 + updateActivePage((p) => ({ + ...p, + objects: p.objects.map((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 - setDoc((prev) => ({ - ...prev, - objects: prev.objects.map((obj) => + ); + const delta = angle - drag.startAngle; + const nextRotation = drag.initialRotation + delta; + updateActivePage((p) => ({ + ...p, + objects: p.objects.map((obj) => obj.id === drag.id - ? { ...obj, rotation: e.shiftKey ? snapAngle(nextRotation) : nextRotation } + ? { + ...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) + ); + 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, @@ -1465,11 +1602,11 @@ const SceneEditor = forwardRef( { x: px, y: py }, initial.width, initial.height, - ) - minX = constrained.minX - maxX = constrained.maxX - minY = constrained.minY - maxY = constrained.maxY + ); + minX = constrained.minX; + maxX = constrained.maxX; + minY = constrained.minY; + maxY = constrained.maxY; } if (centeredScaling && shouldLockShapeAspect) { const scale = Math.max( @@ -1477,241 +1614,255 @@ const SceneEditor = forwardRef( 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 + ); + 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, - ) + ); 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) => + updateActivePage((p) => ({ + ...p, + objects: p.objects.map((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, updateActivePage], + ); 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 = activePage.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; + updateActivePage((p) => ({ + ...p, + objects: [...p.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 snapTargets = doc.objects + const initialBounds = getSelectionBounds(movingObjects); + const snapTargets = activePage.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, - doc.objects, + activePage.objects, pointerToScene, selectedIds, startWindowDrag, textEditingId, + updateActivePage, ], - ) + ); 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], - ) + ); 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], - ) + ); 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), - }) + objects: activePage.objects.filter((obj) => obj.visible), + }); }, [ commitTextDraft, - doc.objects, + activePage.objects, pointerToScene, selectedIds, startWindowDrag, textEditingId, + updateActivePage, ], - ) + ); 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], - ) + ); 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 +1870,12 @@ const SceneEditor = forwardRef( sceneY: pt.y, hasSelection: selectedIds.length > 0, locked: elementToolbarLockedDisplay, - }) + }); }, [elementToolbarLockedDisplay, pointerToScene, selectedIds.length], - ) + ); - const closeContextMenu = useCallback(() => setContextMenu(null), []) + const closeContextMenu = useCallback(() => setContextMenu(null), []); useEditorKeyboardShortcuts({ applyingHistoryRef, @@ -1742,74 +1893,92 @@ 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], - ) + ); const aiController = useAiDesignController({ addObjects, artboardH, artboardW, - doc, + doc: activePage, placeImageObject, - setDoc, + setDoc: (updater) => + updateActivePage( + typeof updater === "function" ? updater : () => updater, + ), setSelectedIds, - }) + }); const selectionEffectsFooterSlot = hasObjectSelected ? ( <> - + ( onChange={applyShadowToSelection} /> - ) : null + ) : null; const selectionToolbarValue: EditorSelectionToolbarContextValue = { actions: { @@ -1871,7 +2040,7 @@ const SceneEditor = forwardRef( shapeToolbarModel, textToolbarValues, }, - } + }; const canvasStageValue: CanvasStageContextValue = { actions: { @@ -1887,18 +2056,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, @@ -1933,123 +2102,139 @@ const SceneEditor = forwardRef( textDraft, textEditingId, }, - } + }; return (
- { - void addImageFromFiles(e.target.files) - e.target.value = '' - }} - /> - - - - - - {exportError ? ( -
-
- {exportError} + { + void addImageFromFiles(e.target.files); + e.target.value = ""; + }} + /> + + + + + + {exportError ? ( +
+
+ {exportError} +
+
+ ) : null} + +
+ + +
-
- ) : null} -
- - - -
- - void copyElementToClipboard()} - onDelete={deleteSelection} - onDuplicate={() => void duplicateElement()} - onPaste={(point) => void pasteFromClipboard(point)} - onToggleLock={toggleElementLock} - /> + void copyElementToClipboard()} + onDelete={deleteSelection} + onDuplicate={() => void duplicateElement()} + onPaste={(point) => void pasteFromClipboard(point)} + onToggleLock={toggleElementLock} + /> - + { + setSelectedIds([]); + setTextEditingId(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} + {!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.tsx b/frontend/src/components/scene-editor/canvas-stage.tsx index 29d8719..c602f40 100644 --- a/frontend/src/components/scene-editor/canvas-stage.tsx +++ b/frontend/src/components/scene-editor/canvas-stage.tsx @@ -74,9 +74,19 @@ export function CanvasStage() { 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 artboard = useEditorStore((storeState) => { + const { doc, activePageId } = storeState + const page = doc.pages.find((p) => p.id === activePageId) ?? doc.pages[0] + return page.artboard + }) + const bg = useEditorStore((state) => { + const page = state.doc.pages.find((p) => p.id === state.activePageId) ?? state.doc.pages[0] + return page.bg + }) + const objects = useEditorStore((state) => { + const page = state.doc.pages.find((p) => p.id === state.activePageId) ?? state.doc.pages[0] + return page.objects + }) const selectedIds = useEditorStore((state) => state.selectedIds) const hoveredId = useEditorStore((state) => state.hoveredId) const { boardDocs } = useVectorBoardControlsContext() diff --git a/frontend/src/components/scene-editor/editor-pages-panel.tsx b/frontend/src/components/scene-editor/editor-pages-panel.tsx new file mode 100644 index 0000000..60f1cc2 --- /dev/null +++ b/frontend/src/components/scene-editor/editor-pages-panel.tsx @@ -0,0 +1,408 @@ +import { HugeiconsIcon } from '@hugeicons/react' +import { + Add01Icon, + ArrowLeft01Icon, + Copy01Icon, + Delete02Icon, + DragDropVerticalIcon, +} from '@hugeicons/core-free-icons' +import { + useCallback, + useEffect, + useRef, + useState, + type KeyboardEvent, +} from 'react' + +import { + createEmptyAvnacPage, + cloneAvnacPage, + type AvnacDocument, + type AvnacPage, +} from '../../lib/avnac-document' + +type Props = { + doc: AvnacDocument + activePageId: string + ready: boolean + onSelectPage: (id: string) => void + onSetDoc: (updater: (prev: AvnacDocument) => AvnacDocument) => void + onClearSelection: () => void +} + +type ContextMenu = { pageId: string; x: number; y: number } | null + +function getDefaultSize(doc: AvnacDocument) { + const first = doc.pages[0] + return { width: first.artboard.width, height: first.artboard.height } +} + +function PageThumb({ + page, + index, + isActive, + isRenaming, + isDragOver, + onSelect, + onContextMenu, + onRenameCommit, + onRenameStart, + onDragHandlePointerDown, +}: { + page: AvnacPage + index: number + isActive: boolean + isRenaming: boolean + isDragOver: boolean + onSelect: () => void + onContextMenu: (x: number, y: number) => void + onRenameCommit: (name: string) => void + onRenameStart: () => void + onDragHandlePointerDown: (e: React.PointerEvent) => void +}) { + const inputRef = useRef(null) + const [draft, setDraft] = useState(page.name) + + useEffect(() => { + setDraft(page.name) + }, [page.name]) + + const commit = useCallback(() => { + onRenameCommit(draft.trim() || page.name) + }, [draft, onRenameCommit, page.name]) + + const handleKey = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter') commit() + if (e.key === 'Escape') onRenameCommit(page.name) + }, + [commit, onRenameCommit, page.name], + ) + + return ( +
+ {/* Drag handle */} +
+ +
+ + {/* Thumbnail */} + + + {/* Name */} + {isRenaming ? ( + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={handleKey} + className="w-[4.5rem] rounded border border-neutral-400 bg-white px-1 text-center text-[10px] text-neutral-700 outline-none ring-2 ring-neutral-200" + maxLength={40} + /> + ) : ( + + )} +
+ ) +} + +export function EditorPagesPanel({ + doc, + activePageId, + ready, + onSelectPage, + onSetDoc, + onClearSelection, +}: Props) { + const [contextMenu, setContextMenu] = useState(null) + const [renamingId, setRenamingId] = useState(null) + const [dragging, setDragging] = useState<{ id: string; overId: string | null } | null>(null) + const [canScrollLeft, setCanScrollLeft] = useState(false) + const [canScrollRight, setCanScrollRight] = useState(false) + const scrollRef = useRef(null) + const panelRef = useRef(null) + + const hasMultiplePages = doc.pages.length > 1 + + // Track scroll state to show/hide arrows and fade + const updateScrollState = useCallback(() => { + const el = scrollRef.current + if (!el) return + setCanScrollLeft(el.scrollLeft > 4) + setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4) + }, []) + + useEffect(() => { + const el = scrollRef.current + if (!el) return + updateScrollState() + el.addEventListener('scroll', updateScrollState, { passive: true }) + const ro = new ResizeObserver(updateScrollState) + ro.observe(el) + return () => { + el.removeEventListener('scroll', updateScrollState) + ro.disconnect() + } + }, [updateScrollState, doc.pages.length]) + + const scrollLeft = useCallback(() => { + scrollRef.current?.scrollBy({ left: -160, behavior: 'smooth' }) + }, []) + + const addPage = useCallback(() => { + const { width, height } = getDefaultSize(doc) + const newPage = createEmptyAvnacPage(width, height, `Page ${doc.pages.length + 1}`) + onSetDoc((prev) => ({ ...prev, pages: [...prev.pages, newPage] })) + onSelectPage(newPage.id) + onClearSelection() + // Scroll to end after adding + setTimeout(() => { + const el = scrollRef.current + if (el) el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' }) + }, 50) + }, [doc, onClearSelection, onSelectPage, onSetDoc]) + + const duplicatePage = useCallback( + (pageId: string) => { + const src = doc.pages.find((p) => p.id === pageId) + if (!src) return + const cloned: AvnacPage = { ...cloneAvnacPage(src), id: crypto.randomUUID(), name: `${src.name} copy` } + const idx = doc.pages.findIndex((p) => p.id === pageId) + onSetDoc((prev) => { + const next = [...prev.pages] + next.splice(idx + 1, 0, cloned) + return { ...prev, pages: next } + }) + onSelectPage(cloned.id) + onClearSelection() + }, + [doc.pages, onClearSelection, onSelectPage, onSetDoc], + ) + + const deletePage = useCallback( + (pageId: string) => { + if (doc.pages.length <= 1) return + const idx = doc.pages.findIndex((p) => p.id === pageId) + if (pageId === activePageId) { + const neighbour = doc.pages[idx + 1] ?? doc.pages[idx - 1] + if (neighbour) onSelectPage(neighbour.id) + } + onSetDoc((prev) => ({ ...prev, pages: prev.pages.filter((p) => p.id !== pageId) })) + onClearSelection() + }, + [activePageId, doc.pages, onClearSelection, onSelectPage, onSetDoc], + ) + + const renamePage = useCallback( + (pageId: string, name: string) => { + onSetDoc((prev) => ({ + ...prev, + pages: prev.pages.map((p) => (p.id === pageId ? { ...p, name: name.trim() || p.name } : p)), + })) + setRenamingId(null) + }, + [onSetDoc], + ) + + const onDragHandlePointerDown = useCallback( + (pageId: string) => (e: React.PointerEvent) => { + e.preventDefault() + setDragging({ id: pageId, overId: null }) + + const onMove = (ev: PointerEvent) => { + const panel = panelRef.current + if (!panel) return + const thumbs = panel.querySelectorAll('[data-page-id]') + let closest: string | null = null + let minDist = Infinity + thumbs.forEach((el) => { + const rect = el.getBoundingClientRect() + const cx = rect.left + rect.width / 2 + const dist = Math.abs(ev.clientX - cx) + if (dist < minDist) { minDist = dist; closest = el.dataset.pageId ?? null } + }) + setDragging((prev) => prev ? { ...prev, overId: closest } : null) + } + + const onUp = () => { + setDragging((prev) => { + if (prev?.overId && prev.overId !== prev.id) { + onSetDoc((d) => { + const pages = [...d.pages] + const from = pages.findIndex((p) => p.id === prev.id) + const to = pages.findIndex((p) => p.id === prev.overId) + if (from < 0 || to < 0) return d + const [moved] = pages.splice(from, 1) + pages.splice(to, 0, moved) + return { ...d, pages } + }) + } + return null + }) + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + } + + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + }, + [onSetDoc], + ) + + if (!ready) return null + + return ( + <> + {/* Context menu backdrop */} + {contextMenu ? ( +
setContextMenu(null)} /> + ) : null} + + {/* Context menu */} + {contextMenu ? ( +
+ + + {doc.pages.length > 1 ? ( + + ) : null} +
+ ) : null} + + {/* Bottom-left pages strip */} +
+ + {/* Left scroll arrow — only shows when multiple pages AND can scroll left */} +
+ +
+ + {/* Scrollable container with fade edges */} +
+ {/* Left fade */} +
+ {/* Right fade */} +
+ + {/* The actual scrollable list */} +
+
+ {doc.pages.map((page, index) => ( + { onSelectPage(page.id); onClearSelection() }} + onContextMenu={(x, y) => setContextMenu({ pageId: page.id, x, y })} + onRenameCommit={(name) => renamePage(page.id, name)} + onRenameStart={() => setRenamingId(page.id)} + onDragHandlePointerDown={onDragHandlePointerDown(page.id)} + /> + ))} +
+
+
+ + {/* Add button — always visible, outside the scroll container, perfect square */} +
+ +
+ +
+ + ) +} diff --git a/frontend/src/components/scene-editor/editor-selection-toolbar.tsx b/frontend/src/components/scene-editor/editor-selection-toolbar.tsx index 91a5a82..3b1c478 100644 --- a/frontend/src/components/scene-editor/editor-selection-toolbar.tsx +++ b/frontend/src/components/scene-editor/editor-selection-toolbar.tsx @@ -25,8 +25,14 @@ function backgroundTopBtn(disabled?: boolean) { export function EditorSelectionToolbar() { const { actions, refs, state } = useEditorSelectionToolbar() - const artboard = useEditorStore((storeState) => storeState.doc.artboard) - const bg = useEditorStore((storeState) => storeState.doc.bg) + const artboard = useEditorStore((storeState) => { + const page = storeState.doc.pages.find((p) => p.id === storeState.activePageId) ?? storeState.doc.pages[0] + return page.artboard + }) + const bg = useEditorStore((storeState) => { + const page = storeState.doc.pages.find((p) => p.id === storeState.activePageId) ?? storeState.doc.pages[0] + return page.bg + }) const { applyArrowLineStyle, applyArrowPathType, diff --git a/frontend/src/components/scene-editor/editor-store.tsx b/frontend/src/components/scene-editor/editor-store.tsx index 9d6e3f3..284bd72 100644 --- a/frontend/src/components/scene-editor/editor-store.tsx +++ b/frontend/src/components/scene-editor/editor-store.tsx @@ -22,9 +22,11 @@ function applySetter(next: EditorSetter, current: T) { export type EditorStoreState = { doc: AvnacDocument + activePageId: string hoveredId: string | null selectedIds: string[] setDoc: (next: EditorSetter) => void + setActivePageId: (id: string) => void setHoveredId: (next: EditorSetter) => void setSelectedIds: (next: EditorSetter) => void } @@ -34,9 +36,11 @@ export type EditorStoreApi = StoreApi export function createEditorStore(initialDoc: AvnacDocument): EditorStoreApi { return createStore((set) => ({ doc: initialDoc, + activePageId: initialDoc.pages[0].id, hoveredId: null, selectedIds: [], setDoc: (next) => set((state) => ({ doc: applySetter(next, state.doc) })), + setActivePageId: (id) => set({ activePageId: id }), setHoveredId: (next) => set((state) => ({ hoveredId: applySetter(next, state.hoveredId) })), setSelectedIds: (next) => diff --git a/frontend/src/components/scene-editor/use-ai-design-controller.ts b/frontend/src/components/scene-editor/use-ai-design-controller.ts index 8a6ebd0..91f31d7 100644 --- a/frontend/src/components/scene-editor/use-ai-design-controller.ts +++ b/frontend/src/components/scene-editor/use-ai-design-controller.ts @@ -13,7 +13,7 @@ import { setObjectFill, setObjectStroke, setObjectStrokeWidth, - type AvnacDocument, + type AvnacPage, type SceneLine, type SceneObject, type SceneText, @@ -44,9 +44,9 @@ type UseAiDesignControllerArgs = { addObjects: (objectsToAdd: SceneObject[]) => void artboardH: number artboardW: number - doc: AvnacDocument + doc: AvnacPage placeImageObject: PlaceImageObject - setDoc: Dispatch> + setDoc: Dispatch> setSelectedIds: Dispatch> } diff --git a/frontend/src/components/scene-editor/use-editor-layer-controls.ts b/frontend/src/components/scene-editor/use-editor-layer-controls.ts index 5ffc7af..5938926 100644 --- a/frontend/src/components/scene-editor/use-editor-layer-controls.ts +++ b/frontend/src/components/scene-editor/use-editor-layer-controls.ts @@ -5,11 +5,28 @@ import type { EditorLayerRow } from '../editor-layers-panel' import { useEditorStore } from './editor-store' export function useEditorLayerControls() { - const objects = useEditorStore((state) => state.doc.objects) + const objects = useEditorStore((state) => { + const page = state.doc.pages.find((p) => p.id === state.activePageId) ?? state.doc.pages[0] + return page.objects + }) + const activePageId = useEditorStore((state) => state.activePageId) const selectedIds = useEditorStore((state) => state.selectedIds) const setDoc = useEditorStore((state) => state.setDoc) const setSelectedIds = useEditorStore((state) => state.setSelectedIds) + // Helper: update objects only on the active page + const updatePageObjects = useCallback( + (updater: (objects: SceneObject[]) => SceneObject[]) => { + setDoc((prev) => ({ + ...prev, + pages: prev.pages.map((p) => + p.id === activePageId ? { ...p, objects: updater(p.objects) } : p, + ), + })) + }, + [activePageId, setDoc], + ) + const layerRows = useMemo( () => [...objects] @@ -31,9 +48,9 @@ export function useEditorLayerControls() { .reverse() .map((id) => byId.get(id)) .filter((obj): obj is SceneObject => !!obj) - setDoc((prev) => ({ ...prev, objects: next })) + updatePageObjects(() => next) }, - [objects, setDoc], + [objects, updatePageObjects], ) const onSelectLayer = useCallback( @@ -47,54 +64,52 @@ export function useEditorLayerControls() { const onToggleLayerVisible = useCallback( (stackIndex: number) => { - setDoc((prev) => ({ - ...prev, - objects: prev.objects.map((obj, index) => + updatePageObjects((objs) => + objs.map((obj, index) => index === stackIndex ? { ...obj, visible: !obj.visible } : obj, ), - })) + ) }, - [setDoc], + [updatePageObjects], ) const onLayerBringForward = useCallback( (stackIndex: number) => { - setDoc((prev) => { - if (stackIndex >= prev.objects.length - 1) return prev - const next = [...prev.objects] + updatePageObjects((objs) => { + if (stackIndex >= objs.length - 1) return objs + const next = [...objs] const swap = next[stackIndex] next[stackIndex] = next[stackIndex + 1] next[stackIndex + 1] = swap - return { ...prev, objects: next } + return next }) }, - [setDoc], + [updatePageObjects], ) const onLayerSendBackward = useCallback( (stackIndex: number) => { - setDoc((prev) => { - if (stackIndex <= 0) return prev - const next = [...prev.objects] + updatePageObjects((objs) => { + if (stackIndex <= 0) return objs + const next = [...objs] const swap = next[stackIndex] next[stackIndex] = next[stackIndex - 1] next[stackIndex - 1] = swap - return { ...prev, objects: next } + return next }) }, - [setDoc], + [updatePageObjects], ) const onRenameLayer = useCallback( (stackIndex: number, name: string) => { - setDoc((prev) => ({ - ...prev, - objects: prev.objects.map((obj, index) => + updatePageObjects((objs) => + objs.map((obj, index) => index === stackIndex ? { ...obj, name: name.trim() || undefined } : obj, ), - })) + ) }, - [setDoc], + [updatePageObjects], ) return { diff --git a/frontend/src/components/scene-editor/use-scene-document-lifecycle.ts b/frontend/src/components/scene-editor/use-scene-document-lifecycle.ts index dec2e6e..8646d68 100644 --- a/frontend/src/components/scene-editor/use-scene-document-lifecycle.ts +++ b/frontend/src/components/scene-editor/use-scene-document-lifecycle.ts @@ -26,6 +26,7 @@ type UseSceneDocumentLifecycleArgs = { persistId?: string persistIdRef: MutableRefObject ready: boolean + setActivePageId: (id: string) => void setDoc: Dispatch> setReady: Dispatch> setSelectedIds: Dispatch> @@ -50,6 +51,7 @@ export function useSceneDocumentLifecycle({ persistId, persistIdRef, ready, + setActivePageId, setDoc, setReady, setSelectedIds, @@ -81,6 +83,7 @@ export function useSceneDocumentLifecycle({ ) if (cancelled) return setDoc(base) + setActivePageId(base.pages[0].id) setSelectedIds([]) setTextEditingId(null) historyRef.current = [cloneAvnacDocument(base)] @@ -100,6 +103,7 @@ export function useSceneDocumentLifecycle({ initialArtboardHeight, initialArtboardWidth, persistId, + setActivePageId, setDoc, setReady, setSelectedIds, diff --git a/frontend/src/hooks/use-editor-device-support.ts b/frontend/src/hooks/use-editor-device-support.ts index 0b857ef..1f26862 100644 --- a/frontend/src/hooks/use-editor-device-support.ts +++ b/frontend/src/hooks/use-editor-device-support.ts @@ -4,6 +4,8 @@ function detectEditorUnsupportedOnThisDevice(): boolean { if (typeof window === 'undefined' || typeof navigator === 'undefined') { return false } + // Never block in local dev + if (import.meta.env.DEV) return false const nav = navigator as Navigator & { userAgentData?: { mobile?: boolean } diff --git a/frontend/src/lib/avnac-document-preview.ts b/frontend/src/lib/avnac-document-preview.ts index bbba499..602815f 100644 --- a/frontend/src/lib/avnac-document-preview.ts +++ b/frontend/src/lib/avnac-document-preview.ts @@ -37,13 +37,14 @@ export async function renderAvnacDocumentPreviewDataUrl( if (hit) return hit } const maxCssPx = options?.maxCssPx ?? 400 - const maxEdge = Math.max(doc.artboard.width, doc.artboard.height) + const firstPage = doc.pages[0] + const maxEdge = Math.max(firstPage.artboard.width, firstPage.artboard.height) const multiplier = maxEdge > 0 ? Math.max(1, Math.round(Math.min(3, maxCssPx / maxEdge))) : 1 try { const url = await renderAvnacDocumentToDataUrl( - doc, + firstPage, loadVectorBoardDocs(persistId), { multiplier, transparent: false }, ) diff --git a/frontend/src/lib/avnac-document.ts b/frontend/src/lib/avnac-document.ts index bff81a9..87270de 100644 --- a/frontend/src/lib/avnac-document.ts +++ b/frontend/src/lib/avnac-document.ts @@ -2,11 +2,15 @@ export { AVNAC_DOC_VERSION, AVNAC_STORAGE_KEY, cloneAvnacDocument, + cloneAvnacPage, createEmptyAvnacDocument, + createEmptyAvnacPage, getAvnacDocumentStorageKind, parseAvnacDocument, + parseSceneObject, type AvnacDocumentStorageKind, type AvnacDocument, + type AvnacPage, type SceneArrow, type SceneGroup, type SceneImage, diff --git a/frontend/src/lib/avnac-editor-idb.ts b/frontend/src/lib/avnac-editor-idb.ts index dae26f3..762c129 100644 --- a/frontend/src/lib/avnac-editor-idb.ts +++ b/frontend/src/lib/avnac-editor-idb.ts @@ -120,8 +120,8 @@ export async function idbListDocuments(): Promise { id: row.id, name: row.name?.trim() || 'Untitled', updatedAt: row.updatedAt, - artboardWidth: row.document.artboard.width, - artboardHeight: row.document.artboard.height, + artboardWidth: row.document.pages[0]?.artboard.width ?? 800, + artboardHeight: row.document.pages[0]?.artboard.height ?? 600, isLegacy: row.storageKind === 'legacy', })) items.sort((a, b) => b.updatedAt - a.updatedAt) @@ -138,6 +138,9 @@ export async function idbPutDocument( document: AvnacDocument, opts?: { name?: string }, ): Promise { + const storageKind = getAvnacDocumentStorageKind(document) + if (storageKind === 'invalid') throw new Error(`idbPutDocument: invalid document storage kind for id "${id}"`) + const prev = await idbGetEditorRecord(id) const name = opts && opts.name !== undefined @@ -153,6 +156,7 @@ export async function idbPutDocument( id, updatedAt: Date.now(), document, + storageKind, name, } satisfies AvnacEditorIdbRecord) }) diff --git a/frontend/src/lib/avnac-scene-render.ts b/frontend/src/lib/avnac-scene-render.ts index 4f6b41b..098661a 100644 --- a/frontend/src/lib/avnac-scene-render.ts +++ b/frontend/src/lib/avnac-scene-render.ts @@ -2,6 +2,7 @@ import { bgValueToCss, type BgValue } from '../components/background-popover' import { loadGoogleFontFamily } from './load-google-font' import type { AvnacDocument, + AvnacPage, SceneArrow, SceneLine, SceneObject, @@ -138,7 +139,7 @@ function drawRoundedRectPath( ) { const r = Math.max(0, Math.min(radius, Math.min(width, height) / 2)) ctx.beginPath() - if ('roundRect' in ctx) { + if (typeof (ctx as CanvasRenderingContext2D & { roundRect?: unknown }).roundRect === 'function') { ctx.roundRect(x, y, width, height, r) return } @@ -275,7 +276,8 @@ export function layoutSceneText( } } -export async function preloadFontsForDocument(doc: AvnacDocument): Promise { +export async function preloadFontsForDocument(doc: AvnacDocument | AvnacPage): Promise { + const objects = 'pages' in doc ? doc.pages.flatMap((p) => p.objects) : doc.objects const fonts = new Set() const visit = (obj: SceneObject) => { if (obj.type === 'text') fonts.add(obj.fontFamily) @@ -283,7 +285,7 @@ export async function preloadFontsForDocument(doc: AvnacDocument): Promise for (const child of obj.children) visit(child) } } - for (const obj of doc.objects) visit(obj) + for (const obj of objects) visit(obj) await Promise.all([...fonts].map((font) => loadGoogleFontFamily(font))) } @@ -569,24 +571,25 @@ async function drawSceneObject( export async function renderAvnacDocumentToCanvas( ctx: CanvasRenderingContext2D, - doc: AvnacDocument, + doc: AvnacDocument | AvnacPage, vectorBoardDocs: Record, opts?: { transparent?: boolean }, ): Promise { - const { width, height } = doc.artboard + const page = 'pages' in doc ? doc.pages[0] : doc + const { width, height } = page.artboard ctx.clearRect(0, 0, width, height) if (!opts?.transparent) { - ctx.fillStyle = bgValueToCanvasPaint(ctx, doc.bg, width, height) + ctx.fillStyle = bgValueToCanvasPaint(ctx, page.bg, width, height) ctx.fillRect(0, 0, width, height) } - await preloadFontsForDocument(doc) - for (const obj of doc.objects) { + await preloadFontsForDocument(page) + for (const obj of page.objects) { await drawSceneObject(ctx, obj, vectorBoardDocs) } } export async function renderAvnacDocumentToDataUrl( - doc: AvnacDocument, + doc: AvnacDocument | AvnacPage, vectorBoardDocs: Record, opts?: { format?: 'png' | 'jpg' | 'webp' @@ -596,13 +599,14 @@ export async function renderAvnacDocumentToDataUrl( ): Promise { const multiplier = Math.max(1, Math.round(opts?.multiplier ?? 1)) const format = opts?.format ?? 'png' + const page = 'pages' in doc ? doc.pages[0] : doc const canvas = document.createElement('canvas') - canvas.width = Math.max(1, Math.round(doc.artboard.width * multiplier)) - canvas.height = Math.max(1, Math.round(doc.artboard.height * multiplier)) + canvas.width = Math.max(1, Math.round(page.artboard.width * multiplier)) + canvas.height = Math.max(1, Math.round(page.artboard.height * multiplier)) const ctx = canvas.getContext('2d') if (!ctx) throw new Error('Could not create export canvas.') ctx.setTransform(multiplier, 0, 0, multiplier, 0, 0) - await renderAvnacDocumentToCanvas(ctx, doc, vectorBoardDocs, { + await renderAvnacDocumentToCanvas(ctx, page, vectorBoardDocs, { transparent: opts?.transparent, }) const mimeType = diff --git a/frontend/src/lib/avnac-scene.ts b/frontend/src/lib/avnac-scene.ts index d8c472e..45a1c9c 100644 --- a/frontend/src/lib/avnac-scene.ts +++ b/frontend/src/lib/avnac-scene.ts @@ -1,4 +1,4 @@ -import type { BgValue } from '../components/background-popover' +import type { BgValue, GradientStop } from '../components/background-popover' import { parseShadowColor, type ShadowUi, @@ -9,7 +9,8 @@ import type { AvnacShapeMeta, } from './avnac-shape-meta' -export const AVNAC_DOC_VERSION = 2 as const +export const AVNAC_DOC_VERSION = 3 as const +export const AVNAC_DOC_VERSION_V2 = 2 as const export const AVNAC_STORAGE_KEY = 'avnac-editor-document' export type SceneObjectType = @@ -142,14 +143,20 @@ export type SceneObject = | SceneVectorBoard | SceneGroup -export type AvnacDocument = { - v: typeof AVNAC_DOC_VERSION +export type AvnacPage = { + id: string + name: string artboard: { width: number; height: number } bg: BgValue objects: SceneObject[] } -export type AvnacDocumentStorageKind = 'current' | 'legacy' | 'invalid' +export type AvnacDocument = { + v: typeof AVNAC_DOC_VERSION + pages: AvnacPage[] +} + +export type AvnacDocumentStorageKind = 'current' | 'v2' | 'legacy' | 'invalid' const DEFAULT_SHAPE_FILL: BgValue = { type: 'solid', color: '#262626' } const DEFAULT_SHAPE_STROKE: BgValue = { type: 'solid', color: 'transparent' } @@ -237,7 +244,7 @@ export function cloneShadow( return { ...shadow } } -function isGradientStopArray(raw: unknown): raw is BgValue['stops'] { +function isGradientStopArray(raw: unknown): raw is GradientStop[] { return ( Array.isArray(raw) && raw.every( @@ -262,11 +269,12 @@ function parseBgValue(raw: unknown, fallback: BgValue): BgValue { typeof obj.angle === 'number' && isGradientStopArray(obj.stops) ) { + const stops = obj.stops as GradientStop[] return { type: 'gradient', css: obj.css, angle: obj.angle, - stops: obj.stops.map((stop) => ({ ...stop })), + stops: stops.map((stop) => ({ ...stop })), } } return cloneBgValue(fallback) @@ -346,7 +354,7 @@ function baseObjectFromUnknown( } } -function parseSceneObject(raw: unknown): SceneObject | null { +export function parseSceneObject(raw: unknown): SceneObject | null { if (!raw || typeof raw !== 'object') return null const obj = raw as Record const type = @@ -354,6 +362,7 @@ function parseSceneObject(raw: unknown): SceneObject | null { if (type === 'rect') { return { ...baseObjectFromUnknown(obj, 'rect'), + type: 'rect' as const, fill: parseBgValue(obj.fill, DEFAULT_SHAPE_FILL), stroke: parseBgValue(obj.stroke, DEFAULT_SHAPE_STROKE), strokeWidth: @@ -365,6 +374,7 @@ function parseSceneObject(raw: unknown): SceneObject | null { if (type === 'ellipse') { return { ...baseObjectFromUnknown(obj, 'ellipse'), + type: 'ellipse' as const, fill: parseBgValue(obj.fill, DEFAULT_SHAPE_FILL), stroke: parseBgValue(obj.stroke, DEFAULT_SHAPE_STROKE), strokeWidth: @@ -374,6 +384,7 @@ function parseSceneObject(raw: unknown): SceneObject | null { if (type === 'polygon') { return { ...baseObjectFromUnknown(obj, 'polygon'), + type: 'polygon' as const, fill: parseBgValue(obj.fill, DEFAULT_SHAPE_FILL), stroke: parseBgValue(obj.stroke, DEFAULT_SHAPE_STROKE), strokeWidth: @@ -387,6 +398,7 @@ function parseSceneObject(raw: unknown): SceneObject | null { if (type === 'star') { return { ...baseObjectFromUnknown(obj, 'star'), + type: 'star' as const, fill: parseBgValue(obj.fill, DEFAULT_SHAPE_FILL), stroke: parseBgValue(obj.stroke, DEFAULT_SHAPE_STROKE), strokeWidth: @@ -400,6 +412,7 @@ function parseSceneObject(raw: unknown): SceneObject | null { if (type === 'line') { return { ...baseObjectFromUnknown(obj, 'line'), + type: 'line' as const, stroke: parseBgValue(obj.stroke, DEFAULT_LINE_STROKE), strokeWidth: typeof obj.strokeWidth === 'number' ? Math.max(1, obj.strokeWidth) : 4, @@ -413,6 +426,7 @@ function parseSceneObject(raw: unknown): SceneObject | null { if (type === 'arrow') { return { ...baseObjectFromUnknown(obj, 'arrow'), + type: 'arrow' as const, stroke: parseBgValue(obj.stroke, DEFAULT_LINE_STROKE), strokeWidth: typeof obj.strokeWidth === 'number' ? Math.max(1, obj.strokeWidth) : 4, @@ -421,7 +435,7 @@ function parseSceneObject(raw: unknown): SceneObject | null { ? obj.lineStyle : 'solid', roundedEnds: obj.roundedEnds !== false, - pathType: obj.pathType === 'curved' ? 'curved' : 'straight', + pathType: obj.pathType === 'curved' ? 'curved' as const : 'straight' as const, headSize: typeof obj.headSize === 'number' ? Math.max(0.2, obj.headSize) : 1, curveBulge: @@ -435,6 +449,7 @@ function parseSceneObject(raw: unknown): SceneObject | null { if (type === 'text') { return { ...baseObjectFromUnknown(obj, 'text'), + type: 'text' as const, text: typeof obj.text === 'string' ? obj.text : '', fill: parseBgValue(obj.fill, DEFAULT_TEXT_FILL), stroke: parseBgValue(obj.stroke, DEFAULT_SHAPE_STROKE), @@ -450,7 +465,7 @@ function parseSceneObject(raw: unknown): SceneObject | null { typeof obj.lineHeight === 'number' ? obj.lineHeight : 1.22, ), fontWeight: parseFontWeight(obj.fontWeight), - fontStyle: obj.fontStyle === 'italic' ? 'italic' : 'normal', + fontStyle: obj.fontStyle === 'italic' ? 'italic' as const : 'normal' as const, underline: obj.underline === true, textAlign: obj.textAlign === 'center' || @@ -468,6 +483,7 @@ function parseSceneObject(raw: unknown): SceneObject | null { const cropRaw = obj.crop as Record | undefined return { ...baseObjectFromUnknown(obj, 'image'), + type: 'image' as const, src: typeof obj.src === 'string' ? obj.src : '', naturalWidth, naturalHeight, @@ -490,6 +506,7 @@ function parseSceneObject(raw: unknown): SceneObject | null { if (type === 'vector-board') { return { ...baseObjectFromUnknown(obj, 'vector-board'), + type: 'vector-board' as const, boardId: typeof obj.boardId === 'string' && obj.boardId.trim() ? obj.boardId @@ -500,6 +517,7 @@ function parseSceneObject(raw: unknown): SceneObject | null { const childrenRaw = Array.isArray(obj.children) ? obj.children : [] return { ...baseObjectFromUnknown(obj, 'group'), + type: 'group' as const, children: childrenRaw .map((child) => parseSceneObject(child)) .filter((child): child is SceneObject => child != null), @@ -769,12 +787,14 @@ function migrateLegacyObject(raw: unknown): SceneObject | null { return null } -export function createEmptyAvnacDocument( +export function createEmptyAvnacPage( width: number, height: number, -): AvnacDocument { + name = 'Page 1', +): AvnacPage { return { - v: AVNAC_DOC_VERSION, + id: crypto.randomUUID(), + name, artboard: { width: clampSize(width, 100), height: clampSize(height, 100), @@ -784,6 +804,16 @@ export function createEmptyAvnacDocument( } } +export function createEmptyAvnacDocument( + width: number, + height: number, +): AvnacDocument { + return { + v: AVNAC_DOC_VERSION, + pages: [createEmptyAvnacPage(width, height)], + } +} + function migrateLegacyDocument(raw: Record): AvnacDocument | null { const artboardRaw = raw.artboard as Record | undefined const width = @@ -797,11 +827,48 @@ function migrateLegacyDocument(raw: Record): AvnacDocument | nu : [] return { v: AVNAC_DOC_VERSION, - artboard: { width: clampSize(width, 100), height: clampSize(height, 100) }, - bg: parseBgValue(raw.bg, DEFAULT_BG), - objects: objectsRaw - .map((obj) => migrateLegacyObject(obj)) - .filter((obj): obj is SceneObject => obj != null), + pages: [ + { + id: crypto.randomUUID(), + name: 'Page 1', + artboard: { width: clampSize(width, 100), height: clampSize(height, 100) }, + bg: parseBgValue(raw.bg, DEFAULT_BG), + objects: objectsRaw + .map((obj) => migrateLegacyObject(obj)) + .filter((obj): obj is SceneObject => obj != null), + }, + ], + } +} + +function migrateV2Document(raw: Record): AvnacDocument | null { + const artboardRaw = raw.artboard as Record | undefined + if ( + !artboardRaw || + typeof artboardRaw.width !== 'number' || + typeof artboardRaw.height !== 'number' + ) { + return null + } + const objects = Array.isArray(raw.objects) + ? raw.objects + .map((row) => parseSceneObject(row)) + .filter((row): row is SceneObject => row != null) + : [] + return { + v: AVNAC_DOC_VERSION, + pages: [ + { + id: crypto.randomUUID(), + name: 'Page 1', + artboard: { + width: clampSize(artboardRaw.width, 100), + height: clampSize(artboardRaw.height, 100), + }, + bg: parseBgValue(raw.bg, DEFAULT_BG), + objects, + }, + ], } } @@ -810,9 +877,15 @@ export function getAvnacDocumentStorageKind( ): AvnacDocumentStorageKind { if (!raw || typeof raw !== 'object') return 'invalid' const obj = raw as Record - if (obj.v === AVNAC_DOC_VERSION && Array.isArray(obj.objects)) { + // v3: current format with pages array + if (obj.v === AVNAC_DOC_VERSION && Array.isArray(obj.pages)) { return 'current' } + // v2: old format with artboard + objects at top level + if (obj.v === AVNAC_DOC_VERSION_V2 && Array.isArray(obj.objects)) { + return 'v2' + } + // v1: fabric.js legacy format const legacySceneState = obj['fabric'] if (obj.v === 1 && legacySceneState && typeof legacySceneState === 'object') { return 'legacy' @@ -820,30 +893,46 @@ export function getAvnacDocumentStorageKind( return 'invalid' } +function parseAvnacPage(raw: unknown): AvnacPage | null { + if (!raw || typeof raw !== 'object') return null + const obj = raw as Record + const artboard = obj.artboard as Record | undefined + if ( + !artboard || + typeof artboard.width !== 'number' || + typeof artboard.height !== 'number' + ) { + return null + } + return { + id: typeof obj.id === 'string' && obj.id.trim() ? obj.id : crypto.randomUUID(), + name: typeof obj.name === 'string' && obj.name.trim() ? obj.name : 'Page', + artboard: { + width: clampSize(artboard.width, 100), + height: clampSize(artboard.height, 100), + }, + bg: parseBgValue(obj.bg, DEFAULT_BG), + objects: Array.isArray(obj.objects) + ? obj.objects + .map((row) => parseSceneObject(row)) + .filter((row): row is SceneObject => row != null) + : [], + } +} + export function parseAvnacDocument(raw: unknown): AvnacDocument | null { const kind = getAvnacDocumentStorageKind(raw) if (kind === 'invalid' || !raw || typeof raw !== 'object') return null const obj = raw as Record if (kind === 'current') { - const artboard = obj.artboard as Record | undefined - if ( - !artboard || - typeof artboard.width !== 'number' || - typeof artboard.height !== 'number' - ) { - return null - } - return { - v: AVNAC_DOC_VERSION, - artboard: { - width: clampSize(artboard.width, 100), - height: clampSize(artboard.height, 100), - }, - bg: parseBgValue(obj.bg, DEFAULT_BG), - objects: obj.objects - .map((row) => parseSceneObject(row)) - .filter((row): row is SceneObject => row != null), - } + const pages = Array.isArray(obj.pages) + ? obj.pages.map((p) => parseAvnacPage(p)).filter((p): p is AvnacPage => p != null) + : [] + if (pages.length === 0) return null + return { v: AVNAC_DOC_VERSION, pages } + } + if (kind === 'v2') { + return migrateV2Document(obj) } if (kind === 'legacy') { return migrateLegacyDocument(obj) @@ -891,12 +980,20 @@ export function cloneSceneObject(obj: T): T { } } +export function cloneAvnacPage(page: AvnacPage): AvnacPage { + return { + id: page.id, + name: page.name, + artboard: { ...page.artboard }, + bg: cloneBgValue(page.bg), + objects: page.objects.map((obj) => cloneSceneObject(obj)), + } +} + export function cloneAvnacDocument(doc: AvnacDocument): AvnacDocument { return { v: AVNAC_DOC_VERSION, - artboard: { ...doc.artboard }, - bg: cloneBgValue(doc.bg), - objects: doc.objects.map((obj) => cloneSceneObject(obj)), + pages: doc.pages.map((page) => cloneAvnacPage(page)), } } @@ -971,7 +1068,9 @@ export function sceneObjectToShapeMeta(obj: SceneObject): AvnacShapeMeta | null } } -export function objectSupportsOutlineStroke(obj: SceneObject): boolean { +type SceneObjectWithStroke = SceneRect | SceneEllipse | ScenePolygon | SceneStar | SceneLine | SceneArrow | SceneText + +export function objectSupportsOutlineStroke(obj: SceneObject): obj is SceneObjectWithStroke { return ( obj.type === 'rect' || obj.type === 'ellipse' || @@ -983,7 +1082,9 @@ export function objectSupportsOutlineStroke(obj: SceneObject): boolean { ) } -export function objectSupportsFill(obj: SceneObject): boolean { +type SceneObjectWithFill = SceneRect | SceneEllipse | ScenePolygon | SceneStar | SceneText + +export function objectSupportsFill(obj: SceneObject): obj is SceneObjectWithFill { return ( obj.type === 'rect' || obj.type === 'ellipse' ||