Files
wursor/packages/app/src/store/canvas.ts
T
2026-05-04 12:30:56 +01:00

180 lines
9.1 KiB
TypeScript

import { create } from 'zustand';
import type { FiberNode } from '@originmain/renderer';
import type { Violation } from '@originmain/design-language';
/** Framework and CSS strategy detected by the CLI AST indexer at startup. */
export interface ProjectMeta {
framework: 'next' | 'vite' | 'remix' | 'generic';
tailwind: boolean;
cssModules: boolean;
styledComponents: boolean;
}
export type Tool = 'select' | 'pan' | 'artboard' | 'zone';
interface CanvasStore {
// ── Tool state ──────────────────────────────────────────────────────────────
activeTool: Tool;
setActiveTool: (tool: Tool) => void;
// ── Artboard selection ──────────────────────────────────────────────────────
selectedArtboardId: string | null;
selectArtboard: (id: string | null) => void;
// ── Workspace / project context (set by AppChrome on mount) ───────────────
workspaceId: string | null;
projectId: string | null;
setContext: (workspaceId: string, projectId: string) => void;
// ── Live artboard tracking ──────────────────────────────────────────────────
/** IDs of artboards that have an active LiveArtboard iframe connection */
liveArtboardIds: Set<string>;
setArtboardLive: (id: string, live: boolean) => void;
// ── Fiber tree cache (per artboard, updated on each FIBER_TREE_UPDATE) ─────
artboardFiberRoots: Record<string, FiberNode>;
setFiberRoot: (artboardId: string, root: FiberNode) => void;
// ── Component selection (from SelectionOverlay / fiber tree) ───────────────
selectedComponentId: string | null;
selectedComponentData: FiberNode | null;
selectComponent: (id: string | null, data: FiberNode | null) => void;
// ── Component computed styles (populated by ELEMENT_STYLES response) ────────
/** Computed CSS property map for the currently-selected component DOM element. */
selectedComponentStyles: Record<string, string> | null;
setComponentStyles: (styles: Record<string, string> | null) => void;
/** True if the selected element has a direct TEXT_NODE child (gates Typography section). */
selectedComponentHasDirectText: boolean;
/** True if the selected element has at least one direct <p> child (gates paragraph spacing). */
selectedComponentHasParagraphChildren: boolean;
/** Set both structural text flags together — always called alongside setComponentStyles. */
setComponentTextFlags: (hasDirectText: boolean, hasParagraphChildren: boolean) => void;
// ── Style edit queue ────────────────────────────────────────────────────────
// The Design tab and resize handles push patches here; each owning
// LiveArtboard drains entries addressed to it, then removes them.
// A queue (not a single slot) is required because resize sends width + height
// in the same synchronous event — a single slot would silently drop the first.
styleEditQueue: Array<{ artboardId: string; nodeId: string; property: string; value: string }>;
patchStyleEdit: (artboardId: string, nodeId: string, property: string, value: string) => void;
clearStyleEdits: (artboardId: string) => void;
// ── Children style edit queue (PATCH_CHILDREN_STYLE — paragraph spacing etc.) ──
childrenStyleEditQueue: Array<{ artboardId: string; parentNodeId: string; selector: string; property: string; value: string }>;
patchChildrenStyleEdit: (artboardId: string, parentNodeId: string, selector: string, property: string, value: string) => void;
clearChildrenStyleEdits: (artboardId: string) => void;
// ── Element removal mailbox ─────────────────────────────────────────────────
removeElementEvent: { artboardId: string; nodeId: string } | null;
dispatchRemoveElement: (artboardId: string, nodeId: string) => void;
clearRemoveElement: () => void;
// ── Selected element mode (Phase 2) ─────────────────────────────────────────
/** 'component' = capitalized React component; 'element' = lowercase DOM tag; null = nothing selected */
selectedElementMode: 'component' | 'element' | null;
setSelectedElementMode: (mode: 'component' | 'element' | null) => void;
// ── CLI AST indexer integration (Phase 3) ────────────────────────────────────
/** Status of the CLI AST indexer; drives Props tab and Code tab behavior */
indexerStatus: 'offline' | 'indexing' | 'ready';
setIndexerStatus: (status: 'offline' | 'indexing' | 'ready') => void;
/** Project metadata fetched from GET /health on CLI connection */
projectMeta: ProjectMeta | null;
setProjectMeta: (meta: ProjectMeta | null) => void;
// ── DLF violations (spec Layer 5.2) ──────────────────────────────────────────
// Written by Inspector's DesignTab when it evaluates the selected component
// against the active DLF; read by SelectionOverlay to render inline badges.
activeViolations: Violation[];
setActiveViolations: (violations: Violation[]) => void;
// ── Active agent session (spec Layer 6 — diff attribution) ──────────────────
// Set by the Agent Bridge when a session starts/ends. Inspector and
// CompletionZone read this to populate session_id on intent_diffs so the
// Agent Bridge can later query diffs by session (getDiffsByStatus etc.).
// null = no agent session active; user-created diffs get session_id ''.
activeAgentSessionId: string | null;
setActiveAgentSessionId: (id: string | null) => void;
}
export const useCanvas = create<CanvasStore>((set) => ({
activeTool: 'select',
setActiveTool: (tool) => set({ activeTool: tool }),
selectedArtboardId: null,
selectArtboard: (id) =>
set({ selectedArtboardId: id, selectedComponentId: null, selectedComponentData: null, selectedComponentStyles: null }),
workspaceId: null,
projectId: null,
setContext: (workspaceId, projectId) => set({ workspaceId, projectId }),
liveArtboardIds: new Set<string>(),
setArtboardLive: (id, live) =>
set((state) => {
const next = new Set(state.liveArtboardIds);
if (live) next.add(id); else next.delete(id);
return { liveArtboardIds: next };
}),
artboardFiberRoots: {},
setFiberRoot: (artboardId, root) =>
set((state) => ({ artboardFiberRoots: { ...state.artboardFiberRoots, [artboardId]: root } })),
selectedComponentId: null,
selectedComponentData: null,
selectComponent: (id, data) =>
// BUG-2 fix: reset text flags alongside styles so a component without
// direct text doesn't inherit the previous selection's Typography section.
set({
selectedComponentId: id,
selectedComponentData: data,
selectedComponentStyles: null,
selectedComponentHasDirectText: false,
selectedComponentHasParagraphChildren: false,
}),
selectedComponentStyles: null,
setComponentStyles: (styles) => set({ selectedComponentStyles: styles }),
selectedComponentHasDirectText: false,
selectedComponentHasParagraphChildren: false,
setComponentTextFlags: (hasDirectText, hasParagraphChildren) =>
set({ selectedComponentHasDirectText: hasDirectText, selectedComponentHasParagraphChildren: hasParagraphChildren }),
styleEditQueue: [],
patchStyleEdit: (artboardId, nodeId, property, value) =>
set((s) => ({ styleEditQueue: [...s.styleEditQueue, { artboardId, nodeId, property, value }] })),
clearStyleEdits: (artboardId) =>
set((s) => ({ styleEditQueue: s.styleEditQueue.filter((e) => e.artboardId !== artboardId) })),
childrenStyleEditQueue: [],
patchChildrenStyleEdit: (artboardId, parentNodeId, selector, property, value) =>
set((s) => ({ childrenStyleEditQueue: [...s.childrenStyleEditQueue, { artboardId, parentNodeId, selector, property, value }] })),
clearChildrenStyleEdits: (artboardId) =>
set((s) => ({ childrenStyleEditQueue: s.childrenStyleEditQueue.filter((e) => e.artboardId !== artboardId) })),
removeElementEvent: null,
dispatchRemoveElement: (artboardId, nodeId) =>
set({ removeElementEvent: { artboardId, nodeId } }),
clearRemoveElement: () => set({ removeElementEvent: null }),
selectedElementMode: null,
setSelectedElementMode: (mode) => set({ selectedElementMode: mode }),
indexerStatus: 'offline',
setIndexerStatus: (status) => set({ indexerStatus: status }),
projectMeta: null,
setProjectMeta: (meta) => set({ projectMeta: meta }),
activeViolations: [],
setActiveViolations: (violations) => set({ activeViolations: violations }),
activeAgentSessionId: null,
setActiveAgentSessionId: (id) => set({ activeAgentSessionId: id }),
}));