improved a lot of things
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { ClerkProvider, Show, SignInButton, SignUpButton, UserButton } from '@clerk/nextjs';
|
||||
import { Providers } from './providers';
|
||||
import './globals.css';
|
||||
|
||||
@@ -11,7 +12,18 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
<ClerkProvider>
|
||||
<header style={{ position: 'fixed', top: 0, right: 0, zIndex: 9999, padding: '8px 16px', display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<Show when="signed-out">
|
||||
<SignInButton />
|
||||
<SignUpButton />
|
||||
</Show>
|
||||
<Show when="signed-in">
|
||||
<UserButton />
|
||||
</Show>
|
||||
</header>
|
||||
<Providers>{children}</Providers>
|
||||
</ClerkProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
buildFiberHookScript,
|
||||
createHostEnvelope,
|
||||
isRendererEnvelope,
|
||||
} from '@originmain/renderer';
|
||||
import type { FiberNode, RendererMessage } from '@originmain/renderer';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface LiveArtboardProps {
|
||||
id: string;
|
||||
/** URL of the connected application route to render */
|
||||
src: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
designTokens?: Record<string, string>;
|
||||
onReady?: () => void;
|
||||
onFiberTreeUpdate?: (root: FiberNode) => void;
|
||||
onComponentSelected?: (nodeId: string) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function LiveArtboard({
|
||||
id,
|
||||
src,
|
||||
width = 1280,
|
||||
height = 720,
|
||||
designTokens,
|
||||
onReady,
|
||||
onFiberTreeUpdate,
|
||||
onComponentSelected,
|
||||
style,
|
||||
}: LiveArtboardProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// Send a message to the iframe via the typed protocol
|
||||
const sendMessage = useCallback(
|
||||
(type: Parameters<typeof createHostEnvelope>[1]['type'], payload?: Record<string, unknown>) => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe?.contentWindow) return;
|
||||
const envelope = createHostEnvelope(id, { type, ...(payload ?? {}) } as Parameters<typeof createHostEnvelope>[1]);
|
||||
iframe.contentWindow.postMessage(envelope, '*');
|
||||
},
|
||||
[id]
|
||||
);
|
||||
|
||||
// Handle messages from the renderer iframe
|
||||
useEffect(() => {
|
||||
function handleMessage(event: MessageEvent) {
|
||||
if (!isRendererEnvelope(event.data)) return;
|
||||
if (event.data.artboardId !== id) return;
|
||||
|
||||
const msg: RendererMessage = event.data.message;
|
||||
switch (msg.type) {
|
||||
case 'READY':
|
||||
// Inject fiber hook after the renderer signals it's ready
|
||||
injectFiberHook(iframeRef.current, id);
|
||||
if (designTokens) sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens });
|
||||
onReady?.();
|
||||
break;
|
||||
case 'FIBER_TREE_UPDATE':
|
||||
onFiberTreeUpdate?.(msg.root);
|
||||
break;
|
||||
case 'COMPONENT_SELECTED':
|
||||
onComponentSelected?.(msg.nodeId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [id, designTokens, sendMessage, onReady, onFiberTreeUpdate, onComponentSelected]);
|
||||
|
||||
// Push updated design tokens whenever they change
|
||||
useEffect(() => {
|
||||
if (!designTokens) return;
|
||||
sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens });
|
||||
}, [designTokens, sendMessage]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={src}
|
||||
title={`artboard-${id}`}
|
||||
// Security: allow-scripts required to run React; allow-same-origin required
|
||||
// for postMessage with targeted origin validation. Do NOT combine these with
|
||||
// untrusted third-party content.
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
border: 'none',
|
||||
display: 'block',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function injectFiberHook(iframe: HTMLIFrameElement | null, artboardId: string) {
|
||||
if (!iframe?.contentDocument) return;
|
||||
try {
|
||||
const script = iframe.contentDocument.createElement('script');
|
||||
script.textContent = buildFiberHookScript(artboardId);
|
||||
iframe.contentDocument.head.appendChild(script);
|
||||
} catch {
|
||||
// Cross-origin or sandboxing prevents injection — renderer must include the
|
||||
// hook script itself in that case.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useHistory } from '@/store/history';
|
||||
import type { FiberNode, DOMRectLike } from '@originmain/renderer';
|
||||
import type { PropChange } from '@originmain/diff-engine';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SelectionState {
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
rect: DOMRectLike;
|
||||
}
|
||||
|
||||
export interface SelectionOverlayProps {
|
||||
artboardId: string;
|
||||
/** Fiber tree from the live renderer (if connected) */
|
||||
fiberRoot?: FiberNode;
|
||||
/** Width and height must match the artboard frame exactly */
|
||||
width: number;
|
||||
height: number;
|
||||
onSelectionChange?: (selection: SelectionState | null) => void;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function SelectionOverlay({
|
||||
artboardId,
|
||||
fiberRoot,
|
||||
width,
|
||||
height,
|
||||
onSelectionChange,
|
||||
}: SelectionOverlayProps) {
|
||||
const [selected, setSelected] = useState<SelectionState | null>(null);
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
const pushEdit = useHistory(s => s.pushEdit);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
if (!fiberRoot) {
|
||||
setSelected(null);
|
||||
onSelectionChange?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const overlayRect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||
const clickX = e.clientX - overlayRect.left;
|
||||
const clickY = e.clientY - overlayRect.top;
|
||||
|
||||
const hit = hitTestFiber(fiberRoot, clickX, clickY);
|
||||
if (hit) {
|
||||
const sel: SelectionState = { nodeId: hit.id, nodeName: hit.name, rect: hit.domRect! };
|
||||
setSelected(sel);
|
||||
onSelectionChange?.(sel);
|
||||
} else {
|
||||
setSelected(null);
|
||||
onSelectionChange?.(null);
|
||||
}
|
||||
},
|
||||
[fiberRoot, onSelectionChange]
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!fiberRoot) return;
|
||||
const overlayRect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||
const x = e.clientX - overlayRect.left;
|
||||
const y = e.clientY - overlayRect.top;
|
||||
const hit = hitTestFiber(fiberRoot, x, y);
|
||||
setHoveredId(hit?.id ?? null);
|
||||
},
|
||||
[fiberRoot]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setSelected(null);
|
||||
onSelectionChange?.(null);
|
||||
}
|
||||
},
|
||||
[onSelectionChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="presentation"
|
||||
tabIndex={-1}
|
||||
onClick={handleClick}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width,
|
||||
height,
|
||||
zIndex: 5,
|
||||
cursor: 'crosshair',
|
||||
}}
|
||||
>
|
||||
{hoveredId && fiberRoot && (
|
||||
<HoverHighlight fiberRoot={fiberRoot} nodeId={hoveredId} />
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<SelectionHandles
|
||||
artboardId={artboardId}
|
||||
selection={selected}
|
||||
onResizeCommit={(changes) => {
|
||||
pushEdit(artboardId, {
|
||||
componentId: selected.nodeId,
|
||||
componentName: selected.nodeName,
|
||||
changes,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hover highlight ───────────────────────────────────────────────────────────
|
||||
|
||||
function HoverHighlight({ fiberRoot, nodeId }: { fiberRoot: FiberNode; nodeId: string }) {
|
||||
const node = findNode(fiberRoot, nodeId);
|
||||
if (!node?.domRect) return null;
|
||||
const { x, y, width, height } = node.domRect;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x,
|
||||
top: y,
|
||||
width,
|
||||
height,
|
||||
border: '1px solid rgba(51,133,255,0.4)',
|
||||
borderRadius: 2,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Selection handles ─────────────────────────────────────────────────────────
|
||||
|
||||
interface SelectionHandlesProps {
|
||||
artboardId: string;
|
||||
selection: SelectionState;
|
||||
onResizeCommit: (changes: PropChange[]) => void;
|
||||
}
|
||||
|
||||
function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps) {
|
||||
const { rect, nodeName } = selection;
|
||||
const startRect = useRef<DOMRectLike | null>(null);
|
||||
// liveRectRef tracks the running rect without stale-closure issues.
|
||||
// liveRect is the React state for rendering only.
|
||||
const liveRectRef = useRef<DOMRectLike>(rect);
|
||||
const [liveRect, setLiveRect] = useState(rect);
|
||||
// onResizeCommitRef ensures onMouseUp always calls the latest callback even if
|
||||
// the parent re-renders between mousedown and mouseup.
|
||||
const onResizeCommitRef = useRef(onResizeCommit);
|
||||
useEffect(() => { onResizeCommitRef.current = onResizeCommit; });
|
||||
|
||||
// Track registered drag listeners so we can clean them up on unmount.
|
||||
const dragListenersRef = useRef<{
|
||||
move: (e: MouseEvent) => void;
|
||||
up: (e: MouseEvent) => void;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (dragListenersRef.current) {
|
||||
window.removeEventListener('mousemove', dragListenersRef.current.move);
|
||||
window.removeEventListener('mouseup', dragListenersRef.current.up);
|
||||
dragListenersRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const makeResizeHandle = useCallback(
|
||||
(corner: 'tl' | 'tr' | 'bl' | 'br') =>
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
// Capture the rect at drag-start from the ref (always current).
|
||||
startRect.current = { ...liveRectRef.current };
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
if (!startRect.current) return;
|
||||
const dx = ev.clientX - startX;
|
||||
const dy = ev.clientY - startY;
|
||||
// Always apply delta from the START rect, not the previous frame's rect.
|
||||
// Applying to prev causes exponential drift over the course of a drag.
|
||||
const next = adjustRect(startRect.current, corner, dx, dy);
|
||||
liveRectRef.current = next;
|
||||
setLiveRect(next);
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
dragListenersRef.current = null;
|
||||
|
||||
if (!startRect.current) return;
|
||||
// Read final dimensions from the ref — not from the stale liveRect closure.
|
||||
const widthChange = liveRectRef.current.width - startRect.current.width;
|
||||
const heightChange = liveRectRef.current.height - startRect.current.height;
|
||||
|
||||
const changes: PropChange[] = [];
|
||||
if (Math.abs(widthChange) > 0.5) {
|
||||
changes.push({
|
||||
key: 'width',
|
||||
before: startRect.current.width,
|
||||
after: liveRectRef.current.width,
|
||||
changeType: 'modified',
|
||||
});
|
||||
}
|
||||
if (Math.abs(heightChange) > 0.5) {
|
||||
changes.push({
|
||||
key: 'height',
|
||||
before: startRect.current.height,
|
||||
after: liveRectRef.current.height,
|
||||
changeType: 'modified',
|
||||
});
|
||||
}
|
||||
if (changes.length > 0) onResizeCommitRef.current(changes);
|
||||
startRect.current = null;
|
||||
};
|
||||
|
||||
dragListenersRef.current = { move: onMouseMove, up: onMouseUp };
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
},
|
||||
[] // No reactive deps — all values read from refs
|
||||
);
|
||||
|
||||
const { x, y, width, height } = liveRect;
|
||||
const HANDLE = 8;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Selection frame */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x,
|
||||
top: y,
|
||||
width,
|
||||
height,
|
||||
border: '2px solid #3385FF',
|
||||
borderRadius: 2,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
{/* Label */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x,
|
||||
top: y - 20,
|
||||
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||
fontSize: 10,
|
||||
color: '#3385FF',
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{nodeName}
|
||||
</div>
|
||||
{/* Corner handles */}
|
||||
{(['tl', 'tr', 'bl', 'br'] as const).map(corner => {
|
||||
const cx = corner.includes('l') ? x - HANDLE / 2 : x + width - HANDLE / 2;
|
||||
const cy = corner.includes('t') ? y - HANDLE / 2 : y + height - HANDLE / 2;
|
||||
return (
|
||||
<div
|
||||
key={corner}
|
||||
onMouseDown={makeResizeHandle(corner)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: cx,
|
||||
top: cy,
|
||||
width: HANDLE,
|
||||
height: HANDLE,
|
||||
background: '#fff',
|
||||
border: '2px solid #3385FF',
|
||||
borderRadius: 2,
|
||||
cursor: corner === 'tl' || corner === 'br' ? 'nwse-resize' : 'nesw-resize',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function hitTestFiber(node: FiberNode, x: number, y: number): FiberNode | null {
|
||||
for (const child of [...node.children].reverse()) {
|
||||
const hit = hitTestFiber(child, x, y);
|
||||
if (hit) return hit;
|
||||
}
|
||||
if (!node.domRect) return null;
|
||||
const { x: nx, y: ny, width, height } = node.domRect;
|
||||
if (x >= nx && x <= nx + width && y >= ny && y <= ny + height) return node;
|
||||
return null;
|
||||
}
|
||||
|
||||
function findNode(root: FiberNode, id: string): FiberNode | null {
|
||||
if (root.id === id) return root;
|
||||
for (const child of root.children) {
|
||||
const found = findNode(child, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function adjustRect(
|
||||
rect: DOMRectLike,
|
||||
corner: 'tl' | 'tr' | 'bl' | 'br',
|
||||
dx: number,
|
||||
dy: number
|
||||
): DOMRectLike {
|
||||
let { x, y, width, height } = rect;
|
||||
if (corner.includes('l')) { x += dx; width = Math.max(8, width - dx); }
|
||||
else { width = Math.max(8, width + dx); }
|
||||
if (corner.includes('t')) { y += dy; height = Math.max(8, height - dy); }
|
||||
else { height = Math.max(8, height + dy); }
|
||||
return { x, y, width, height };
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { useFileTree, FileTree } from '@pierre/trees/react';
|
||||
import { themeToTreeStyles } from '@pierre/trees';
|
||||
import type { GitStatusEntry } from '@pierre/trees';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CodebaseFileTreeProps {
|
||||
/** Flat list of file paths, relative to repo root */
|
||||
paths: string[];
|
||||
/** Array of git status entries (matches @pierre/trees FileTreeOptions.gitStatus) */
|
||||
gitStatus?: readonly GitStatusEntry[];
|
||||
/** Initial directory expansion depth (default: 1) */
|
||||
initialExpansion?: number;
|
||||
/** Collapse single-child directory chains (default: true) */
|
||||
flattenEmptyDirectories?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
// Dark panel tokens — match ArtboardNavigator palette
|
||||
const treeThemeStyles = themeToTreeStyles({
|
||||
type: 'dark',
|
||||
bg: '#111115',
|
||||
fg: 'rgba(255,255,255,0.42)',
|
||||
colors: {
|
||||
'editor.selectionBackground': 'rgba(51,133,255,0.14)',
|
||||
'list.activeSelectionBackground': 'rgba(51,133,255,0.14)',
|
||||
'list.inactiveSelectionBackground': 'rgba(51,133,255,0.08)',
|
||||
'list.hoverBackground': 'rgba(255,255,255,0.04)',
|
||||
'list.activeSelectionForeground': 'rgba(255,255,255,0.88)',
|
||||
'editorIndentGuide.background': 'rgba(255,255,255,0.04)',
|
||||
},
|
||||
});
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CodebaseFileTree({
|
||||
paths,
|
||||
gitStatus,
|
||||
initialExpansion = 1,
|
||||
flattenEmptyDirectories = true,
|
||||
className,
|
||||
style,
|
||||
}: CodebaseFileTreeProps) {
|
||||
const { model } = useFileTree({
|
||||
paths,
|
||||
...(gitStatus !== undefined ? { gitStatus } : {}),
|
||||
initialExpansion,
|
||||
flattenEmptyDirectories,
|
||||
density: 'compact',
|
||||
icons: 'minimal',
|
||||
fileTreeSearchMode: 'expand-matches',
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
minHeight: 0,
|
||||
background: '#111115',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<FileTree
|
||||
model={model}
|
||||
style={{
|
||||
...treeThemeStyles,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
'--trees-item-height': '26px',
|
||||
'--trees-indent-width': '14px',
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { PatchDiff } from '@pierre/diffs/react';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CodeDiffPanelProps {
|
||||
/** Unified diff string produced by the diff-engine's generatePatch() */
|
||||
patch: string;
|
||||
/** Render mode: 'split' shows before/after columns, 'unified' stacks them */
|
||||
layout?: 'split' | 'unified';
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
// PatchDiff options — dark theme, Shiki syntax highlighting
|
||||
// Note: typed 'as const' to avoid widening to BaseDiffOptions (which includes
|
||||
// 'custom' hunkSeparator that FileDiffOptions excludes).
|
||||
const DARK_OPTIONS = {
|
||||
theme: 'github-dark-dimmed',
|
||||
diffIndicators: 'bars',
|
||||
disableBackground: false,
|
||||
expandUnchanged: false,
|
||||
collapsedContextThreshold: 3,
|
||||
} as const;
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CodeDiffPanel({
|
||||
patch,
|
||||
layout = 'split',
|
||||
className,
|
||||
style,
|
||||
}: CodeDiffPanelProps) {
|
||||
if (!patch) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '24px 16px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||
fontSize: '0.625rem',
|
||||
color: 'rgba(255,255,255,0.22)',
|
||||
letterSpacing: '0.06em',
|
||||
background: '#111115',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
No diff available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const options = {
|
||||
...DARK_OPTIONS,
|
||||
diffStyle: layout,
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
background: '#111115',
|
||||
// Map Fluent 2 neutral surface tokens into @pierre/diffs CSS custom properties
|
||||
'--diffs-background': '#111115',
|
||||
'--diffs-gutter-background': '#0D0D11',
|
||||
'--diffs-addition-background': 'rgba(70,220,120,0.06)',
|
||||
'--diffs-deletion-background': 'rgba(255,70,70,0.06)',
|
||||
'--diffs-addition-gutter': 'rgba(70,220,120,0.15)',
|
||||
'--diffs-deletion-gutter': 'rgba(255,70,70,0.15)',
|
||||
...style,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<PatchDiff patch={patch} options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Tree,
|
||||
TreeItem,
|
||||
TreeItemLayout,
|
||||
type TreeOpenChangeData,
|
||||
type TreeOpenChangeEvent,
|
||||
} from '@fluentui/react-components';
|
||||
import { SquareRegular, FolderRegular, FolderOpenRegular } from '@fluentui/react-icons';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ArtboardNode {
|
||||
id: string;
|
||||
name: string;
|
||||
children?: ArtboardNode[];
|
||||
}
|
||||
|
||||
export interface ArtboardTreeProps {
|
||||
nodes: ArtboardNode[];
|
||||
selectedId?: string | null;
|
||||
onSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ArtboardTree({ nodes, selectedId, onSelect }: ArtboardTreeProps) {
|
||||
const [openItems, setOpenItems] = useState<Set<string>>(new Set());
|
||||
|
||||
function handleOpenChange(_e: TreeOpenChangeEvent, data: TreeOpenChangeData) {
|
||||
setOpenItems(prev => {
|
||||
const next = new Set(prev);
|
||||
if (data.open) next.add(String(data.value));
|
||||
else next.delete(String(data.value));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Tree
|
||||
aria-label="Artboard hierarchy"
|
||||
size="small"
|
||||
openItems={openItems}
|
||||
onOpenChange={handleOpenChange}
|
||||
style={{ background: 'transparent', padding: '2px 0' }}
|
||||
>
|
||||
{nodes.map(node => (
|
||||
<ArtboardTreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedId={selectedId}
|
||||
openItems={openItems}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</Tree>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Recursive node ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ArtboardTreeNode({
|
||||
node,
|
||||
selectedId,
|
||||
openItems,
|
||||
onSelect,
|
||||
}: {
|
||||
node: ArtboardNode;
|
||||
selectedId: string | null | undefined;
|
||||
openItems: Set<string>;
|
||||
onSelect: ((id: string) => void) | undefined;
|
||||
}) {
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const isOpen = openItems.has(node.id);
|
||||
const isSelected = selectedId === node.id;
|
||||
|
||||
const icon = hasChildren ? (
|
||||
isOpen ? (
|
||||
<FolderOpenRegular style={{ fontSize: 13 }} />
|
||||
) : (
|
||||
<FolderRegular style={{ fontSize: 13 }} />
|
||||
)
|
||||
) : (
|
||||
<SquareRegular style={{ fontSize: 11 }} />
|
||||
);
|
||||
|
||||
return (
|
||||
<TreeItem
|
||||
value={node.id}
|
||||
itemType={hasChildren ? 'branch' : 'leaf'}
|
||||
style={{
|
||||
background: isSelected ? 'rgba(51,133,255,0.12)' : undefined,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<TreeItemLayout
|
||||
iconBefore={icon}
|
||||
onClick={() => !hasChildren && onSelect?.(node.id)}
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||
color: isSelected ? 'rgba(255,255,255,0.88)' : 'rgba(255,255,255,0.55)',
|
||||
fontWeight: isSelected ? 500 : 400,
|
||||
cursor: hasChildren ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{node.name}
|
||||
</TreeItemLayout>
|
||||
{hasChildren &&
|
||||
node.children!.map(child => (
|
||||
<ArtboardTreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
selectedId={selectedId}
|
||||
openItems={openItems}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</TreeItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { clerkMiddleware } from '@clerk/nextjs/server';
|
||||
|
||||
export default clerkMiddleware();
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// Skip Next.js internals and all static files
|
||||
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
|
||||
// Always run for API routes
|
||||
'/(api|trpc)(.*)',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { create } from 'zustand';
|
||||
import type { PropChange } from '@originmain/diff-engine';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface EditEntry {
|
||||
/** Which component was changed */
|
||||
componentId: string;
|
||||
componentName: string;
|
||||
/** The prop/style changes in this edit */
|
||||
changes: PropChange[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface ArtboardHistory {
|
||||
past: EditEntry[];
|
||||
future: EditEntry[];
|
||||
}
|
||||
|
||||
interface HistoryStore {
|
||||
/** Per-artboard history stacks */
|
||||
stacks: Record<string, ArtboardHistory>;
|
||||
|
||||
/** Push a new edit onto the artboard's history (clears the future stack) */
|
||||
pushEdit: (artboardId: string, entry: EditEntry) => void;
|
||||
|
||||
/** Undo the most recent edit for an artboard; returns the undone entry */
|
||||
undo: (artboardId: string) => EditEntry | undefined;
|
||||
|
||||
/** Redo the next edit for an artboard; returns the redone entry */
|
||||
redo: (artboardId: string) => EditEntry | undefined;
|
||||
|
||||
canUndo: (artboardId: string) => boolean;
|
||||
canRedo: (artboardId: string) => boolean;
|
||||
|
||||
/** Clear history for a specific artboard */
|
||||
clearHistory: (artboardId: string) => void;
|
||||
}
|
||||
|
||||
// ── Store ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_HISTORY = 100;
|
||||
|
||||
function emptyStack(): ArtboardHistory {
|
||||
return { past: [], future: [] };
|
||||
}
|
||||
|
||||
export const useHistory = create<HistoryStore>((set, get) => ({
|
||||
stacks: {},
|
||||
|
||||
pushEdit(artboardId, entry) {
|
||||
set(state => {
|
||||
const current = state.stacks[artboardId] ?? emptyStack();
|
||||
const past = [...current.past, entry].slice(-MAX_HISTORY);
|
||||
return {
|
||||
stacks: {
|
||||
...state.stacks,
|
||||
[artboardId]: { past, future: [] },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
undo(artboardId) {
|
||||
const stack = get().stacks[artboardId] ?? emptyStack();
|
||||
const last = stack.past[stack.past.length - 1];
|
||||
if (!last) return undefined;
|
||||
|
||||
set(state => {
|
||||
const current = state.stacks[artboardId] ?? emptyStack();
|
||||
return {
|
||||
stacks: {
|
||||
...state.stacks,
|
||||
[artboardId]: {
|
||||
past: current.past.slice(0, -1),
|
||||
future: [last, ...current.future],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return last;
|
||||
},
|
||||
|
||||
redo(artboardId) {
|
||||
const stack = get().stacks[artboardId] ?? emptyStack();
|
||||
const next = stack.future[0];
|
||||
if (!next) return undefined;
|
||||
|
||||
set(state => {
|
||||
const current = state.stacks[artboardId] ?? emptyStack();
|
||||
return {
|
||||
stacks: {
|
||||
...state.stacks,
|
||||
[artboardId]: {
|
||||
past: [...current.past, next],
|
||||
future: current.future.slice(1),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return next;
|
||||
},
|
||||
|
||||
canUndo(artboardId) {
|
||||
return (get().stacks[artboardId]?.past.length ?? 0) > 0;
|
||||
},
|
||||
|
||||
canRedo(artboardId) {
|
||||
return (get().stacks[artboardId]?.future.length ?? 0) > 0;
|
||||
},
|
||||
|
||||
clearHistory(artboardId) {
|
||||
set(state => ({
|
||||
stacks: {
|
||||
...state.stacks,
|
||||
[artboardId]: emptyStack(),
|
||||
},
|
||||
}));
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user