feat: editor in popover

This commit is contained in:
2026-04-05 00:31:47 +02:00
parent 3eec7f316e
commit 914945279f
17 changed files with 755 additions and 266 deletions

View File

@@ -0,0 +1,86 @@
import { useCallback, useEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
export const DITHERED_SHADOW = `
3px 3px 0 0 var(--border), 5px 3px 0 0 transparent, 7px 3px 0 0 var(--border),
4px 4px 0 0 transparent, 6px 4px 0 0 var(--border),
3px 5px 0 0 var(--border), 5px 5px 0 0 transparent, 7px 5px 0 0 var(--border),
4px 6px 0 0 var(--border), 6px 6px 0 0 transparent
`;
export interface FloatingWindowProps {
id: string;
title: string;
x: number;
y: number;
w: number;
h: number;
zIndex: number;
focused: boolean;
onUpdate: (id: string, patch: { x?: number; y?: number; w?: number; h?: number }) => void;
onClose: (id: string) => void;
onFocus: (id: string) => void;
minW?: number;
minH?: number;
addressBar?: ReactNode;
footer?: ReactNode;
containerRef?: React.RefObject<HTMLDivElement | null>;
children: ReactNode;
}
export default function FloatingWindow({
id, title, x, y, w, h, zIndex, focused,
onUpdate, onClose, onFocus,
minW = 320, minH = 200,
addressBar, footer, containerRef, children,
}: FloatingWindowProps) {
const internalRef = useRef<HTMLDivElement>(null);
const ref = containerRef ?? internalRef;
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
useEffect(() => { if (focused) ref.current?.focus(); }, [focused]);
const onDragStart = useCallback((e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest("button")) return;
e.preventDefault(); onFocus(id);
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: x, origY: y };
const onMove = (ev: MouseEvent) => { if (!dragRef.current) return; onUpdate(id, { x: dragRef.current.origX + (ev.clientX - dragRef.current.startX), y: Math.max(0, dragRef.current.origY + (ev.clientY - dragRef.current.startY)) }); };
const onUp = () => { dragRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp);
}, [id, x, y, onUpdate, onFocus]);
const onResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault(); e.stopPropagation(); onFocus(id);
resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: w, origH: h };
const onMove = (ev: MouseEvent) => { if (!resizeRef.current) return; onUpdate(id, { w: Math.max(minW, resizeRef.current.origW + (ev.clientX - resizeRef.current.startX)), h: Math.max(minH, resizeRef.current.origH + (ev.clientY - resizeRef.current.startY)) }); };
const onUp = () => { resizeRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp);
}, [id, w, h, minW, minH, onUpdate, onFocus]);
return createPortal(
<div ref={ref} tabIndex={-1} onKeyDown={(e) => { if (e.key === "Escape") onClose(id); }} onMouseDown={() => onFocus(id)}
className="fixed z-999 flex flex-col bg-popover text-popover-foreground border-2 rounded-lg outline-none transition-[border-color,opacity] duration-150"
style={{ left: x, top: y, width: w, height: h, zIndex: 999 + zIndex, borderColor: focused ? "var(--primary)" : "var(--border)", opacity: focused ? 1 : 0.85, boxShadow: DITHERED_SHADOW }}>
{/* Title bar */}
<div onMouseDown={onDragStart} className="flex items-center gap-2 px-3 py-1.5 border-b-2 border-border cursor-grab active:cursor-grabbing select-none shrink-0 bg-muted/30 rounded-t-lg">
<div className="flex items-center gap-1.5">
<button onClick={() => onClose(id)} className="w-2.5 h-2.5 rounded-full bg-destructive hover:brightness-125 transition-all" />
<span className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30" /><span className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30" />
</div>
<span className="flex-1 text-[10px] font-semibold uppercase tracking-wider truncate text-center">{title}</span>
</div>
{/* Optional address bar */}
{addressBar}
{/* Content */}
<div className="flex-1 min-h-0">
{children}
</div>
{/* Optional footer */}
{footer}
{/* Resize handle */}
<div onMouseDown={onResizeStart} className="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize" style={{ touchAction: "none" }}>
<svg viewBox="0 0 16 16" className="w-full h-full text-muted-foreground/50"><path d="M14 14L8 14L14 8Z" fill="currentColor" /><path d="M14 14L11 14L14 11Z" fill="currentColor" opacity="0.5" /></svg>
</div>
</div>, document.body);
}