185 lines
6.9 KiB
TypeScript
185 lines
6.9 KiB
TypeScript
/**
|
|
* CanvasViewport — Virtualized scrolling canvas.
|
|
* Renders CanvasComponent objects sorted by layout.orderIndex.
|
|
* Uses CSS content-visibility for large component lists (virtualization-lite).
|
|
* Stable scroll anchoring: new components append without scroll jump.
|
|
*/
|
|
import { useRef } from 'react';
|
|
import { AnimatePresence, motion } from 'framer-motion';
|
|
import type { CanvasComponent, PromptExecution } from '../types/canvas';
|
|
import { ComponentRegistry, type ComponentRenderContext } from './ComponentRegistry';
|
|
import { Loader2 } from 'lucide-react';
|
|
|
|
interface CanvasViewportProps {
|
|
components: CanvasComponent[];
|
|
ctx: ComponentRenderContext;
|
|
inFlightExecution: PromptExecution | null;
|
|
selectedComponentId?: string | null;
|
|
onSelectComponent: (id: string | null) => void;
|
|
}
|
|
|
|
const SECTION_LABELS: Record<string, string> = {
|
|
sec_pipeline: 'Pipeline',
|
|
sec_leads: 'Lead Intelligence',
|
|
sec_team: 'Team Performance',
|
|
sec_actions: 'Action Queue',
|
|
sec_prompt_generated: 'Oracle Responses',
|
|
sec_geography: 'Geographic',
|
|
sec_forecast: 'Forecasting',
|
|
};
|
|
|
|
/** Groups components by sectionId, preserving orderIndex sort */
|
|
function groupBySection(components: CanvasComponent[]): Array<{ sectionId: string; components: CanvasComponent[] }> {
|
|
const sorted = [...components].sort((a, b) => a.layout.orderIndex - b.layout.orderIndex);
|
|
const sectionMap = new Map<string, CanvasComponent[]>();
|
|
|
|
for (const comp of sorted) {
|
|
const sid = comp.layout.sectionId;
|
|
if (!sectionMap.has(sid)) sectionMap.set(sid, []);
|
|
sectionMap.get(sid)!.push(comp);
|
|
}
|
|
|
|
return Array.from(sectionMap.entries()).map(([sectionId, comps]) => ({ sectionId, components: comps }));
|
|
}
|
|
|
|
/** CSS content-visibility wrapper for off-screen components, applying width mode to the flex item */
|
|
function ComponentFlexWrapper({ comp, children }: { comp: CanvasComponent; children: React.ReactNode }) {
|
|
const styles: Record<string, string> = {
|
|
full: 'w-full',
|
|
half: 'w-full xl:w-[calc(50%-8px)]',
|
|
third: 'w-full xl:w-[calc(33.333%-11px)]',
|
|
};
|
|
return (
|
|
<div
|
|
className={styles[comp.layout.widthMode] ?? 'w-full'}
|
|
style={{
|
|
contentVisibility: 'auto',
|
|
containIntrinsicSize: `0 ${comp.renderingHints.estimatedHeightPx}px`,
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CanvasViewport({
|
|
components,
|
|
ctx,
|
|
inFlightExecution,
|
|
selectedComponentId,
|
|
onSelectComponent,
|
|
}: CanvasViewportProps) {
|
|
const viewportRef = useRef<HTMLDivElement>(null);
|
|
const sections = groupBySection(components);
|
|
|
|
return (
|
|
<div
|
|
ref={viewportRef}
|
|
className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar"
|
|
style={{ scrollBehavior: 'smooth' }}
|
|
onClick={(e) => {
|
|
// Click outside component to deselect
|
|
if (e.currentTarget === e.target) onSelectComponent(null);
|
|
}}
|
|
>
|
|
<div className="px-4 py-5 space-y-8">
|
|
{sections.length === 0 && !inFlightExecution && (
|
|
<EmptyCanvasState />
|
|
)}
|
|
|
|
{sections.map(({ sectionId, components: sectionComps }) => (
|
|
<section key={sectionId} className="space-y-4">
|
|
{/* Section header */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-1 h-4 rounded-full bg-gradient-to-b from-blue-400 to-cyan-500" />
|
|
<h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500">
|
|
{SECTION_LABELS[sectionId] ?? sectionId.replace(/^sec_/, '').replace(/_/g, ' ')}
|
|
</h2>
|
|
<div className="flex-1 h-[1px]" style={{ background: 'rgba(255,255,255,0.05)' }} />
|
|
<span className="text-[10px] text-zinc-700">{sectionComps.length}</span>
|
|
</div>
|
|
|
|
{/* Flex wrap for half/third width components */}
|
|
<div className="flex flex-wrap gap-4">
|
|
{sectionComps.map((comp) => (
|
|
<ComponentFlexWrapper
|
|
key={`${comp.componentId}-${comp.version}`}
|
|
comp={comp}
|
|
>
|
|
<ComponentRegistry
|
|
component={comp}
|
|
ctx={{
|
|
...ctx,
|
|
isSelected: selectedComponentId === comp.componentId,
|
|
onSelect: onSelectComponent,
|
|
}}
|
|
/>
|
|
</ComponentFlexWrapper>
|
|
))}
|
|
</div>
|
|
</section>
|
|
))}
|
|
|
|
{/* In-flight execution placeholder */}
|
|
<AnimatePresence>
|
|
{inFlightExecution && (
|
|
<motion.div
|
|
key="in-flight-placeholder"
|
|
initial={{ opacity: 0, y: 16 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -8 }}
|
|
className="w-full rounded-2xl p-6 flex items-center gap-4"
|
|
style={{
|
|
background: 'rgba(59,130,246,0.06)',
|
|
border: '1px dashed rgba(59,130,246,0.25)',
|
|
backdropFilter: 'blur(12px)',
|
|
}}
|
|
>
|
|
<Loader2 className="w-5 h-5 text-blue-400 animate-spin flex-shrink-0" />
|
|
<div>
|
|
<p className="text-sm font-medium text-blue-300">Oracle is analyzing your prompt…</p>
|
|
<p className="text-xs text-zinc-500 mt-0.5 line-clamp-1">
|
|
"{inFlightExecution.prompt}"
|
|
</p>
|
|
</div>
|
|
<div
|
|
className="ml-auto text-xs font-mono px-2 py-1 rounded-lg"
|
|
style={{ background: 'rgba(59,130,246,0.12)', color: '#60a5fa' }}
|
|
>
|
|
{inFlightExecution.status}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Bottom padding for floating prompt bar */}
|
|
<div className="h-32" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EmptyCanvasState() {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-24 gap-6">
|
|
<div
|
|
className="w-20 h-20 rounded-2xl flex items-center justify-center"
|
|
style={{ background: 'rgba(59,130,246,0.08)', border: '1px solid rgba(59,130,246,0.15)' }}
|
|
>
|
|
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" className="w-10 h-10">
|
|
<rect x="6" y="6" width="15" height="15" rx="3" stroke="#3B82F6" strokeWidth="1.5" />
|
|
<rect x="27" y="6" width="15" height="15" rx="3" stroke="#22D3EE" strokeWidth="1.5" />
|
|
<rect x="6" y="27" width="15" height="15" rx="3" stroke="#A78BFA" strokeWidth="1.5" />
|
|
<rect x="27" y="27" width="15" height="15" rx="3" stroke="#3B82F6" strokeWidth="1.5" opacity="0.5" strokeDasharray="3 2" />
|
|
</svg>
|
|
</div>
|
|
<div className="text-center">
|
|
<h3 className="text-base font-medium text-zinc-200 mb-2">Canvas is empty</h3>
|
|
<p className="text-sm text-zinc-500 max-w-sm">
|
|
Ask Oracle anything — type a prompt below to generate analytical components on your canvas.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|