diff --git a/package-lock.json b/package-lock.json index f85c4b6..f2615fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2783,6 +2783,7 @@ "@babel/parser": "^7.29.2", "@vitejs/plugin-react": "^4.7.0", "fast-glob": "^3.3.2", + "fflate": "^0.8.3", "jspdf": "^4.2.1", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/packages/core/package.json b/packages/core/package.json index cdccf89..37796cf 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -34,6 +34,7 @@ "@babel/parser": "^7.29.2", "@vitejs/plugin-react": "^4.7.0", "fast-glob": "^3.3.2", + "fflate": "^0.8.3", "jspdf": "^4.2.1", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/packages/core/src/app/app.tsx b/packages/core/src/app/app.tsx index f0ebb1e..8927cfc 100644 --- a/packages/core/src/app/app.tsx +++ b/packages/core/src/app/app.tsx @@ -9,7 +9,7 @@ import { useAgentSocketConnected } from './lib/use-agent-socket'; import { navigate } from './lib/navigate'; import { findLayoutIssues } from './lib/overflow'; import { useViewport } from './lib/viewport'; -import { boardToPngDataUrl, exportPdf, exportPng, exportSvg } from './lib/export'; +import { boardToPngDataUrl, exportPdf, exportPdfPages, exportPng, exportSvg, exportZip } from './lib/export'; import { Stage, layoutBoards } from './components/Stage'; import { Inspector } from './components/Inspector'; import { LayersPanel } from './components/LayersPanel'; @@ -17,7 +17,7 @@ import { AssetsPanel } from './components/AssetsPanel'; import { TokensPanel } from './components/TokensPanel'; import { Home, ThemesPage, ThemeDetail, ThemeToggle } from './components/Workspace'; import { ToastHost, toast } from './components/ui/toast'; -import { Menu, SelectMenu } from './components/ui/Menu'; +import { Menu } from './components/ui/Menu'; import { Icon } from './components/icons'; const isDev = import.meta.env.DEV; @@ -100,6 +100,19 @@ function DesignPage({ id }: { id: string }) { const layout = useMemo(() => layoutBoards(scenes, mod?.artboard), [scenes, mod]); const vp = useViewport(stageRef, { w: layout.w, h: layout.h }); + // Focus a board: make it active (toolbar, layers panel) AND bring it into + // view — zoom-to-board, so picking a board visibly navigates the canvas. + const focusBoard = (i: number) => { + setActiveBoard(i); + const b = layout.boards[i]; + if (!b) return; + // Include the caption floating above the board (bottom: 100% + 12px margin, + // ~29 canvas px) in the fitted rect, or small boards clip their name off + // the top of the stage. + const captionH = 32; + vp.fitTo({ x: b.x, y: b.y - captionH, w: b.artboard.w, h: b.artboard.h + captionH }); + }; + // Report the editor's cursor to .opencanva/current.json (the current-design skill). useEffect(() => { const hot = import.meta.hot; @@ -164,6 +177,10 @@ function DesignPage({ id }: { id: string }) { } if (!mod) return
Loading {id}…
; + const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + const slug = slugify(title) || id; + const boardName = (i: number) => scenes[i]?.label ?? scenes[i]?.id ?? `Board ${i + 1}`; + const doExport = async (kind: 'png' | 'svg' | 'pdf') => { if (exporting) return; // lock out concurrent exports // Scope to the live canvas: LayersPanel thumbnails also render real `.ox-board` @@ -175,7 +192,6 @@ function DesignPage({ id }: { id: string }) { toast.err('Nothing to export — no board is rendered.'); return; } - const slug = (title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || id); const fname = `${slug}${scenes.length > 1 ? `-${activeBoard + 1}` : ''}`; setExporting(kind); try { @@ -190,6 +206,36 @@ function DesignPage({ id }: { id: string }) { } }; + // Export every board: PDF gets one page per board; PNG/SVG get one file per + // board bundled in a .zip (browsers throttle multi-file downloads). + const doExportAll = async (kind: 'png' | 'svg' | 'pdf') => { + if (exporting) return; + const boardEls = document.querySelectorAll('.ox-canvas .ox-board'); + const items = layout.boards + .map((b, i) => ({ + el: boardEls[i], + w: b.artboard.w, + h: b.artboard.h, + name: `${String(i + 1).padStart(2, '0')}-${slugify(boardName(i)) || `board-${i + 1}`}`, + })) + .filter((it) => it.el); + if (!items.length) { + toast.err('Nothing to export — no board is rendered.'); + return; + } + setExporting(kind); + try { + const fname = kind === 'pdf' ? slug : `${slug}-boards`; + if (kind === 'pdf') await exportPdfPages(items, fname); + else await exportZip(items, fname, kind); + toast.ok(`Exported ${items.length} boards → ${fname}.${kind === 'pdf' ? 'pdf' : 'zip'}`); + } catch (err) { + toast.err(`Export failed: ${String((err as Error)?.message ?? err)}`); + } finally { + setExporting(null); + } + }; + // Dev fidelity hook: returns the real export PNG as a data URL so the rendered // output can be compared against the on-screen board (no drift). if (isDev) { @@ -226,15 +272,6 @@ function DesignPage({ id }: { id: string }) { - {scenes.length > 1 && ( - ({ value: i, label: s.label ?? s.id ?? `Board ${i + 1}` }))} - onChange={setActiveBoard} - /> - )} - {isDev && ( + {isDev ? ( +
+ } + items={[ + { label: 'Duplicate', icon: 'group', onSelect: () => onBoardOp(duplicateBoard(designKey, i), 'Board duplicated') }, + { + label: 'Delete', + icon: 'close', + danger: true, + disabled: scenes.length <= 1, + onSelect: () => onBoardOp(deleteBoard(designKey, i), 'Board deleted'), + }, + ]} + /> +
+ ) : null} + + ))} + + + ) : null; + + const renderRow = (r: ObjRow) => ( +
  • 0 ? 'is-child' : ''}${r.el === selectedEl ? ' is-selected' : ''}`.trim() || undefined} + style={{ paddingLeft: 10 + r.depth * 16 }} + onMouseEnter={() => peek(r.el, true)} + onMouseLeave={() => peek(r.el, false)} + onClick={() => selectObj(r.el)} + > + + + {r.label} + {r.x},{r.y} + +
  • + ); + + const isOpen = (i: number) => openOverrides[i] ?? i === activeBoard; + return ( ); } diff --git a/packages/core/src/app/components/Stage.tsx b/packages/core/src/app/components/Stage.tsx index 0f526ac..73512bb 100644 --- a/packages/core/src/app/components/Stage.tsx +++ b/packages/core/src/app/components/Stage.tsx @@ -6,7 +6,7 @@ import { Board } from './Board'; export const BOARD_GAP = 96; // px between boards on the canvas, in artboard units -/** Lay scenes out in a horizontal row; return content bounds + per-board x offsets. */ +/** Lay scenes out in a horizontal row; return content bounds + per-board offsets. */ export function layoutBoards(scenes: Scene[], moduleArtboard?: Artboard) { let x = 0; let maxH = 0; @@ -17,6 +17,9 @@ export function layoutBoards(scenes: Scene[], moduleArtboard?: Artboard) { maxH = Math.max(maxH, artboard.h); return at; }); + // Shorter boards sit vertically centered in the row; bake that into y so the + // Stage render and zoom-to-board (viewport.fitTo) share one source of truth. + for (const b of boards) b.y = (maxH - b.artboard.h) / 2; const w = boards.length ? x - BOARD_GAP : 0; return { boards, w, h: maxH }; } @@ -40,7 +43,7 @@ export function Stage({ design: DesignSystem; viewport: Viewport; }) { - const { boards, h: contentH } = layoutBoards(scenes, moduleArtboard); + const { boards } = layoutBoards(scenes, moduleArtboard); const total = scenes.length; return ( @@ -55,10 +58,10 @@ export function Stage({
    {total > 1 ? ( -
    {b.scene.label ?? b.scene.id ?? `Board ${b.index + 1}`}
    +
    {b.scene.label ?? b.scene.id ?? `Board ${b.index + 1}`}
    ) : null} ((resolve, reject) => { - const fr = new FileReader(); - fr.onload = () => resolve(fr.result as string); - fr.onerror = reject; - fr.readAsDataURL(blob); - }); + await exportPdfPages([{ el: board, w, h, name: filename }], filename); +} + +/** All boards as one PDF, one page per board (pages keep each board's size). */ +export async function exportPdfPages(items: BoardExportItem[], filename: string) { const { jsPDF } = await import('jspdf'); - const pdf = new jsPDF({ orientation: w >= h ? 'landscape' : 'portrait', unit: 'px', format: [w, h] }); - pdf.addImage(dataUrl, 'PNG', 0, 0, w, h); + let pdf: InstanceType | null = null; + // Sequential on purpose: one offscreen raster at a time bounds memory. + for (const it of items) { + const dataUrl = await boardToPngDataUrl(it.el, it.w, it.h, 2); + const orientation = it.w >= it.h ? ('landscape' as const) : ('portrait' as const); + // compress + FAST deflate the embedded rasters — without them jsPDF stores + // raw pixels (~14 MB per 1080² page). + if (!pdf) pdf = new jsPDF({ orientation, unit: 'px', format: [it.w, it.h], compress: true }); + else pdf.addPage([it.w, it.h], orientation); + pdf.addImage(dataUrl, 'PNG', 0, 0, it.w, it.h, undefined, 'FAST'); + } + if (!pdf) throw new Error('Nothing to export'); pdf.save(`${filename}.pdf`); } + +/** All boards as one .zip of standalone .png / .svg files. */ +export async function exportZip(items: BoardExportItem[], filename: string, kind: 'png' | 'svg') { + const { zipSync, strToU8 } = await import('fflate'); + const entries: Record = {}; + for (const it of items) { + if (kind === 'png') { + const blob = await rasterize(it.el, it.w, it.h, 2); + // PNG bytes are already deflated — store them instead of re-compressing. + entries[`${it.name}.png`] = [new Uint8Array(await blob.arrayBuffer()), { level: 0 }]; + } else { + const svg = await buildBoardSvg(it.el, it.w, it.h, true); + entries[`${it.name}.svg`] = [strToU8(svg), { level: 6 }]; + } + } + const zipped = zipSync(entries); + download(`${filename}.zip`, new Blob([zipped], { type: 'application/zip' })); +} diff --git a/packages/core/src/app/lib/viewport.ts b/packages/core/src/app/lib/viewport.ts index 9a33177..b370643 100644 --- a/packages/core/src/app/lib/viewport.ts +++ b/packages/core/src/app/lib/viewport.ts @@ -21,6 +21,8 @@ export interface Viewport { /** Pan by a screen-pixel delta (used by the inspector's edit-mode pan gesture). */ panBy: (dx: number, dy: number) => void; fit: () => void; + /** Zoom + center on one canvas-space rect (e.g. a single board). */ + fitTo: (rect: { x: number; y: number; w: number; h: number }) => void; actualSize: () => void; } @@ -56,16 +58,27 @@ export function useViewport( setZoom(z1); }, []); + const fitTo = useCallback( + (r: { x: number; y: number; w: number; h: number }) => { + const stage = stageRef.current; + if (!stage || !r.w || !r.h) return; + const rect = stage.getBoundingClientRect(); + const pad = 0.86; + const z = clamp(Math.min(rect.width / r.w, rect.height / r.h) * pad); + setZoom(z); + // Center the rect: a canvas point p lands on screen at p·z + pan. + setPan({ + x: (rect.width - r.w * z) / 2 - r.x * z, + y: (rect.height - r.h * z) / 2 - r.y * z, + }); + }, + [stageRef], + ); + const fit = useCallback(() => { - const stage = stageRef.current; const { w, h } = contentRef.current; - if (!stage || !w || !h) return; - const rect = stage.getBoundingClientRect(); - const pad = 0.86; - const z = clamp(Math.min(rect.width / w, rect.height / h) * pad); - setZoom(z); - setPan({ x: (rect.width - w * z) / 2, y: (rect.height - h * z) / 2 }); - }, [stageRef]); + fitTo({ x: 0, y: 0, w, h }); + }, [fitTo]); const actualSize = useCallback(() => { const stage = stageRef.current; @@ -223,6 +236,7 @@ export function useViewport( zoomTo, panBy, fit, + fitTo, actualSize, }; } diff --git a/packages/core/src/app/styles.css b/packages/core/src/app/styles.css index a599e10..78d0909 100644 --- a/packages/core/src/app/styles.css +++ b/packages/core/src/app/styles.css @@ -452,7 +452,9 @@ code { .ox-board-slot { position: absolute; } -.ox-board-label { +/* Caption floating above each board on the canvas. Distinct from the layers + panel's .ox-board-label row text — keep the two class names separate. */ +.ox-board-caption { position: absolute; bottom: 100%; left: 2px; @@ -476,7 +478,13 @@ code { background: var(--ui-panel); border-right: 1px solid var(--ui-line); overflow-y: auto; - padding: 12px 0; + /* No top padding on the scroll container: sticky group heads pin to its + content edge, and a padded edge leaves a strip rows scroll through. The + top breathing room lives on the first child instead. */ + padding: 0 0 12px; +} +.ox-layers > :first-child { + margin-top: 12px; } .ox-layers-section { padding: 14px 16px 6px; @@ -588,6 +596,64 @@ code { text-overflow: ellipsis; white-space: nowrap; } +/* Per-board layer groups (multi-board designs) — Figma-style collapsible + sections. The header sticks while its group scrolls so the board context + never leaves view. */ +.ox-layers-group-head { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + gap: 5px; + width: 100%; + padding: 7px 16px 7px 10px; + border: none; + background: var(--ui-panel); + color: var(--ui-muted); + font-size: 12.5px; + font-weight: 600; + text-align: left; + cursor: pointer; + transition: color var(--ui-t) var(--ui-ease); +} +.ox-layers-group-head:hover { + color: var(--ui-fg); +} +.ox-layers-group.is-active .ox-layers-group-head { + color: var(--ui-fg); +} +.ox-layers-group-caret { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + flex: 0 0 auto; + color: var(--ui-faint); + transform: rotate(-90deg); + transition: transform var(--ui-t) var(--ui-ease); +} +.ox-layers-group-head.is-open .ox-layers-group-caret { + transform: rotate(0deg); +} +.ox-layers-group-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ox-layers-group-head .ox-layers-count { + margin-left: auto; + padding-left: 8px; +} +.ox-layers-group + .ox-layers-group { + margin-top: 2px; +} +/* scrollIntoView targets must clear the stuck group head above them. */ +.ox-layers-group .ox-layers-objs li { + scroll-margin-top: 36px; +} .ox-layers-objs { list-style: none; margin: 0; @@ -1734,9 +1800,16 @@ code { .ox-dd-list { max-height: min(60vh, 380px); overflow-y: auto; + /* Labels can carry user content (board names in the Export menu) — cap the + popover and ellipsize instead of stretching across the viewport. */ + max-width: min(90vw, 320px); } .ox-dd-label { flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .ox-dd-tick { flex: 0 0 auto; diff --git a/packages/core/src/vite/config.ts b/packages/core/src/vite/config.ts index 1e53a57..b118692 100644 --- a/packages/core/src/vite/config.ts +++ b/packages/core/src/vite/config.ts @@ -84,7 +84,10 @@ export async function createViteConfig(opts: CreateViteConfigOptions): Promise