Code editor and widget
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user