Code editor and widget

This commit is contained in:
2026-02-17 23:48:46 +00:00
parent d3f94e45ea
commit 030cf9fbd3
18 changed files with 871 additions and 9 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
import type { CSSProperties, ComponentPropsWithoutRef } from 'react';
import type { CSSProperties, ComponentPropsWithRef } from 'react';
import { cn } from 'helpers/cn';
export const cardStyle = (overrides?: CSSProperties): CSSProperties => ({
@@ -11,7 +11,7 @@ export const cardStyle = (overrides?: CSSProperties): CSSProperties => ({
...overrides,
});
type CardProps = ComponentPropsWithoutRef<'div'>;
type CardProps = ComponentPropsWithRef<'div'>;
export const Card = ({ className, style, ...props }: CardProps) => {
return (
+155
View File
@@ -0,0 +1,155 @@
import type { CSSProperties, ComponentPropsWithoutRef, PointerEvent as ReactPointerEvent, ReactNode } from 'react';
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import type { LucideIcon } from 'lucide-react';
import { ChevronDown, ChevronUp, Minus, Plus } from 'lucide-react';
import { cn } from 'helpers/cn';
import { Card } from './Card';
type WidgetProps = ComponentPropsWithoutRef<'div'> & {
title?: string;
resizable?: boolean;
collapsible?: boolean | { title: string; icon?: LucideIcon };
moveable?: boolean;
};
export const Widget = ({ title, className, style, resizable, collapsible, moveable, children, ...props }: WidgetProps) => {
const [expanded, setExpanded] = useState(true);
const [minimized, setMinimized] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const cardRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{
startX: number;
startY: number;
originX: number;
originY: number;
naturalLeft: number;
naturalTop: number;
cardWidth: number;
cardHeight: number;
} | null>(null);
const preToggleRect = useRef<{ left: number; top: number } | null>(null);
const HEADER_HEIGHT = 64;
const toggleMinimized = useCallback(() => {
if (cardRef.current) {
const rect = cardRef.current.getBoundingClientRect();
preToggleRect.current = { left: rect.left, top: rect.top };
}
setMinimized((v) => !v);
}, []);
useLayoutEffect(() => {
if (!cardRef.current || !preToggleRect.current) return;
const prev = preToggleRect.current;
preToggleRect.current = null;
const newRect = cardRef.current.getBoundingClientRect();
setPosition((p) => ({
x: p.x + (prev.left - newRect.left),
y: p.y + (prev.top - newRect.top),
}));
}, [minimized]);
const onPointerDown = useCallback(
(ev: ReactPointerEvent) => {
if (!moveable) return;
const target = ev.target as HTMLElement;
const card = ev.currentTarget as HTMLElement;
const isCardPadding = target === card;
const isHeader = !isCardPadding && target.closest('[data-widget-header]') && !target.closest('button');
if (!isCardPadding && !isHeader) return;
if (isHeader && ev.detail === 2) {
toggleMinimized();
return;
}
const rect = card.getBoundingClientRect();
dragRef.current = {
startX: ev.clientX,
startY: ev.clientY,
originX: position.x,
originY: position.y,
naturalLeft: rect.left - position.x,
naturalTop: rect.top - position.y,
cardWidth: rect.width,
cardHeight: rect.height,
};
card.setPointerCapture(ev.pointerId);
},
[moveable, position],
);
const onPointerMove = useCallback((ev: ReactPointerEvent) => {
if (!dragRef.current) return;
const d = dragRef.current;
const dx = ev.clientX - d.startX;
const dy = ev.clientY - d.startY;
const newX = d.originX + dx;
const newY = d.originY + dy;
const vw = window.innerWidth;
const vh = window.innerHeight;
setPosition({
x: Math.min(Math.max(newX, -d.naturalLeft), vw - d.naturalLeft - d.cardWidth),
y: Math.min(Math.max(newY, HEADER_HEIGHT - d.naturalTop), vh - d.naturalTop - d.cardHeight),
});
}, []);
const onPointerUp = useCallback(() => {
dragRef.current = null;
}, []);
const resizableStyle: CSSProperties | undefined = resizable ? { resize: 'both', overflow: 'auto' } : undefined;
const moveableStyle: CSSProperties | undefined = moveable
? { position: 'relative', transform: `translate(${position.x}px, ${position.y}px)` }
: undefined;
return (
<Card
ref={cardRef}
className={cn(
'relative p-0',
collapsible && 'pt-0',
moveable && 'cursor-grab [&>*]:cursor-auto',
className,
minimized && '!h-auto !w-auto',
)}
style={{ ...resizableStyle, ...moveableStyle, ...style }}
onPointerDown={moveable ? onPointerDown : undefined}
onPointerMove={moveable ? onPointerMove : undefined}
onPointerUp={moveable ? onPointerUp : undefined}
{...props}
>
<div data-widget-header className="flex h-8 items-center gap-12 px-3 select-none cursor-grab">
{title && <span className="text-sm font-bold text-muted-foreground pointer-events-none">{title}</span>}
<button
type="button"
className="ml-auto p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground"
onClick={() => toggleMinimized()}
>
{minimized ? <Plus size={14} /> : <Minus size={14} />}
</button>
</div>
{!minimized && (
<>
{collapsible && (
<button
type="button"
className="flex w-full items-center gap-2 p-3 cursor-pointer"
onClick={() => setExpanded((v) => !v)}
>
{typeof collapsible === 'object' && collapsible.icon && (
<collapsible.icon size={16} className="text-muted-foreground" />
)}
{typeof collapsible === 'object' && collapsible.title && (
<span className="text-sm text-muted-foreground">{collapsible.title}</span>
)}
<span className="ml-auto text-muted-foreground">
{expanded ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
</span>
</button>
)}
{collapsible ? expanded && <div className="px-5 pb-5">{children}</div> : children}
</>
)}
</Card>
);
};