From 63b34f4c851bab21c5f6cec88455df5d6d459e75 Mon Sep 17 00:00:00 2001 From: daniellee-ux <54412342+daniellee-ux@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:25:57 +0800 Subject: [PATCH 1/2] feat(core): group layers by board, Figma-style, and zoom-to-board navigation - Fix the layers panel boards rail: the canvas board caption reused the sidebar's .ox-board-label class, absolutely positioning the rail's board names over the header/thumbnails. The caption is now .ox-board-caption. - Clicking a board (rail or toolbar select) now navigates: new viewport.fitTo(rect) zooms + centers the board (with caption headroom); layoutBoards bakes the vertical-centering y so Stage and fitTo share it. - Group the LAYERS tree by board: per-board collapsible sections with sticky headers (name + count). The active board's group auto-expands and the others collapse on board switch; manual expand/collapse overrides are latched per selection so canvas DOM churn and stale selections can't defeat them, and they reset when the board id sequence changes (delete/duplicate/reorder). Cross-board canvas selections force their group open and scroll into view (clear of the stuck header via scroll-margin). Single-board designs keep the flat list. Co-Authored-By: Claude Fable 5 --- packages/core/src/app/app.tsx | 17 +- .../core/src/app/components/LayersPanel.tsx | 277 ++++++++++++------ packages/core/src/app/components/Stage.tsx | 11 +- packages/core/src/app/lib/viewport.ts | 30 +- packages/core/src/app/styles.css | 70 ++++- 5 files changed, 293 insertions(+), 112 deletions(-) diff --git a/packages/core/src/app/app.tsx b/packages/core/src/app/app.tsx index f0ebb1e..30c4321 100644 --- a/packages/core/src/app/app.tsx +++ b/packages/core/src/app/app.tsx @@ -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; @@ -231,7 +244,7 @@ function DesignPage({ id }: { id: string }) { label="Active board" value={activeBoard} options={scenes.map((s, i) => ({ value: i, label: s.label ?? s.id ?? `Board ${i + 1}` }))} - onChange={setActiveBoard} + onChange={focusBoard} /> )} @@ -267,7 +280,7 @@ function DesignPage({ id }: { id: string }) { scenes={scenes} designKey={id} activeBoard={activeBoard} - onFocusBoard={(i) => { setActiveBoard(i); vp.fit(); }} + onFocusBoard={focusBoard} design={design} moduleArtboard={mod.artboard} selectedEl={selectedEl} diff --git a/packages/core/src/app/components/LayersPanel.tsx b/packages/core/src/app/components/LayersPanel.tsx index ab3dcda..29c44eb 100644 --- a/packages/core/src/app/components/LayersPanel.tsx +++ b/packages/core/src/app/components/LayersPanel.tsx @@ -23,6 +23,8 @@ interface ObjRow { y: number; /** Nesting depth (objects inside a Group/Box), for the indented layer tree. */ depth: number; + /** Index of the canvas board this object lives on, for per-board grouping. */ + board: number; /** Stable per-element React key, so reordering rows doesn't strand a hover class. */ key: number; } @@ -56,6 +58,10 @@ export function LayersPanel({ selectedEl?: HTMLElement | null; }) { const [rows, setRows] = useState([]); + // Per-board expand/collapse overrides for the grouped layer tree. Without an + // override a group is open iff its board is active, so switching boards + // "filters" the tree to the focused board while still allowing a manual peek. + const [openOverrides, setOpenOverrides] = useState>({}); const tick = useRef(0); const activeBoardRef = useRef(null); const selectedRowRef = useRef(null); @@ -83,10 +89,50 @@ export function LayersPanel({ activeBoardRef.current?.scrollIntoView({ block: 'nearest' }); }, [activeBoard]); - // Bring the selected object's row into view (canvas selection → panel). + // Reset expand/collapse overrides when the focus context changes, so the tree + // always re-opens on (only) the newly active board's group. Overrides are + // keyed by board index, so a structural change (delete/duplicate/reorder — + // anything that alters the id sequence) must also reset them or they'd stick + // to the wrong boards; plain content edits keep the sequence and the state. + const boardsSig = scenes.map((s) => s.id ?? '').join('|'); useEffect(() => { - if (selectedEl) selectedRowRef.current?.scrollIntoView({ block: 'nearest' }); - }, [selectedEl]); + setOpenOverrides({}); + }, [activeBoard, designKey, boardsSig]); + + // A canvas selection can land in a collapsed group (an object on a non-active + // board, or on the active board after a manual collapse) — force that group + // open so the selected row exists to highlight and scroll to. Latched per + // element: the effect's rows/activeBoard deps churn (MutationObserver refresh, + // board switches with a stale selection), and re-forcing on churn would both + // defeat a manual collapse and resurrect overrides the reset above just wiped. + const lastForced = useRef(null); + useEffect(() => { + if (!selectedEl) { + lastForced.current = null; + return; + } + if (lastForced.current === selectedEl) return; + const board = rows.find((r) => r.el === selectedEl)?.board; + if (board == null) return; // rows may lag the DOM; retry on the next refresh + lastForced.current = selectedEl; + setOpenOverrides((o) => ((o[board] ?? board === activeBoard) ? o : { ...o, [board]: true })); + }, [selectedEl, rows, activeBoard]); + + // Bring the selected object's row into view (canvas selection → panel), once + // per selection. openOverrides is a dep so a selection landing on a collapsed + // group retries after the forced expand mounts the row — but a manual + // expand/collapse elsewhere must not yank the scroll back here. + const lastScrolled = useRef(null); + useEffect(() => { + if (!selectedEl) { + lastScrolled.current = null; + return; + } + if (selectedRowRef.current && lastScrolled.current !== selectedEl) { + selectedRowRef.current.scrollIntoView({ block: 'nearest' }); + lastScrolled.current = selectedEl; + } + }, [selectedEl, openOverrides]); useEffect(() => { const canvas = document.querySelector('.ox-canvas'); @@ -102,6 +148,12 @@ export function LayersPanel({ } return d; }; + // Canvas boards render in scenes order, so DOM index == scene index. + const boardEls = Array.from(canvas.querySelectorAll('[data-ox-board]')); + const boardOf = (el: HTMLElement) => { + const board = el.closest('[data-ox-board]'); + return board ? Math.max(0, boardEls.indexOf(board)) : 0; + }; const objs = Array.from(canvas.querySelectorAll('[data-ox-obj]')); setRows( objs.map((el) => ({ @@ -115,6 +167,7 @@ export function LayersPanel({ x: Number(el.getAttribute('data-ox-x') ?? 0), y: Number(el.getAttribute('data-ox-y') ?? 0), depth: depthOf(el), + board: boardOf(el), key: keyFor(el), })), ); @@ -164,105 +217,137 @@ export function LayersPanel({ fire(); }; + const boardsRail = + scenes.length > 1 ? ( + <> +
Boards
+
    + {scenes.map((s, i) => ( +
  • { + dragIdx.current = i; + e.dataTransfer.effectAllowed = 'move'; + }} + onDragOver={(e) => { + if (dragIdx.current == null) return; + e.preventDefault(); + setDropIdx(i); + }} + onDragLeave={() => setDropIdx((d) => (d === i ? null : d))} + onDrop={(e) => { + e.preventDefault(); + const from = dragIdx.current; + dragIdx.current = null; + setDropIdx(null); + if (from == null || from === i) return; + const order = scenes.map((_, k) => k); + const [m] = order.splice(from, 1); + order.splice(i, 0, m); + onBoardOp(reorderBoards(designKey, order), 'Boards reordered'); + }} + onDragEnd={() => { + dragIdx.current = null; + setDropIdx(null); + }} + > + + {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} 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..624dfa2 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; From 39c5c012b3dbfb8c5954de197a8b4bfd20b84f40 Mon Sep 17 00:00:00 2001 From: daniellee-ux <54412342+daniellee-ux@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:46:52 +0800 Subject: [PATCH 2/2] feat(core): all-boards export (PDF pages, PNG/SVG zip); drop the toolbar board dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the toolbar "Active board" SelectMenu — the layers panel's boards rail now owns board switching (same focusBoard path), so the dropdown was a duplicate control with worse affordance. - Its remaining job (saying which board Export targets) moves into the Export menu: on multi-board designs items name the active board ("PNG — Cover"), and a second section exports every board — "PDF — all boards" (one page per board via exportPdfPages) and "PNG/SVG — all boards (.zip)" (exportZip via fflate; entries named NN-label). - Compress PDF rasters (jsPDF compress + FAST addImage): a 4-board PDF drops from 56 MB (raw pixels) to 0.4 MB, lossless. Applies to the single-board PDF too, which now shares the multi-page path. - Pre-bundle jspdf/fflate in dev so a first export can't trigger a mid-session "new dependency" page reload. - Cap dropdown popovers (max-width + label ellipsis): menu labels now carry user content (board names), which must not stretch the menu across the viewport. Co-Authored-By: Claude Fable 5 --- package-lock.json | 1 + packages/core/package.json | 1 + packages/core/src/app/app.tsx | 72 ++++++++++++++++++++++------- packages/core/src/app/lib/export.ts | 53 +++++++++++++++++---- packages/core/src/app/styles.css | 7 +++ packages/core/src/vite/config.ts | 5 +- 6 files changed, 112 insertions(+), 27 deletions(-) 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 30c4321..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; @@ -177,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` @@ -188,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 { @@ -203,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) { @@ -239,15 +272,6 @@ function DesignPage({ id }: { id: string }) {
    - {scenes.length > 1 && ( - ({ value: i, label: s.label ?? s.id ?? `Board ${i + 1}` }))} - onChange={focusBoard} - /> - )} - {isDev && (