44406b72db
Moteva-style AI design workbench replica. Home prompt creates a project; the project page provides an infinite canvas with node drag, zoom/pan, a design chat panel, history replay, image asset upload, and project save/regenerate. - Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage; sky-valley/pi agent runtime adapter. - Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n, shadcn/ui components, auth (OTP/Turnstile/Google/WeChat). - Deploy: Docker Compose and k3s manifests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
106 lines
3.4 KiB
TypeScript
106 lines
3.4 KiB
TypeScript
"use client";
|
|
|
|
import { useLayoutEffect, useRef, useState, type CSSProperties } from "react";
|
|
|
|
export type FloatingToolbarBounds = {
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
export type FloatingToolbarPlacementOptions = {
|
|
bounds: FloatingToolbarBounds;
|
|
offsetY: number;
|
|
minTop?: number;
|
|
bottomInset?: number;
|
|
margin?: number;
|
|
maxWidth?: number;
|
|
leftInset?: number;
|
|
rightInset?: number;
|
|
};
|
|
|
|
export function useFloatingToolbarPlacement({
|
|
bounds,
|
|
offsetY,
|
|
minTop = 10,
|
|
bottomInset = 0,
|
|
margin = 10,
|
|
maxWidth = 1180,
|
|
leftInset = 0,
|
|
rightInset = 0
|
|
}: FloatingToolbarPlacementOptions) {
|
|
const toolbarRef = useRef<HTMLDivElement | null>(null);
|
|
const center = bounds.left + bounds.width / 2;
|
|
const requestedTop = Math.max(minTop, bounds.top - offsetY);
|
|
const [placement, setPlacement] = useState(() => ({
|
|
left: center,
|
|
top: requestedTop,
|
|
maxWidth
|
|
}));
|
|
|
|
useLayoutEffect(() => {
|
|
const toolbar = toolbarRef.current;
|
|
if (!toolbar) return;
|
|
|
|
let frame = 0;
|
|
const updatePlacement = () => {
|
|
window.cancelAnimationFrame(frame);
|
|
frame = window.requestAnimationFrame(() => {
|
|
const parent = toolbar.offsetParent instanceof HTMLElement ? toolbar.offsetParent : toolbar.parentElement;
|
|
const parentWidth = parent?.clientWidth || window.innerWidth;
|
|
const parentHeight = parent?.clientHeight || window.innerHeight;
|
|
const safeLeft = leftInset + margin;
|
|
const safeRight = Math.max(safeLeft + 1, parentWidth - rightInset - margin);
|
|
const nextMaxWidth = Math.max(120, Math.min(maxWidth, safeRight - safeLeft));
|
|
const rect = toolbar.getBoundingClientRect();
|
|
const contentWidth = Math.ceil(Math.max(toolbar.scrollWidth, rect.width));
|
|
const measuredWidth = Math.min(contentWidth || nextMaxWidth, nextMaxWidth);
|
|
const minLeft = safeLeft + measuredWidth / 2;
|
|
const maxLeft = safeRight - measuredWidth / 2;
|
|
const nextLeft = maxLeft >= minLeft ? clamp(center, minLeft, maxLeft) : (safeLeft + safeRight) / 2;
|
|
const measuredHeight = Math.ceil(rect.height);
|
|
const safeBottom = Math.max(minTop + measuredHeight, parentHeight - bottomInset - margin);
|
|
const maxTop = safeBottom - measuredHeight;
|
|
const nextTop = maxTop >= minTop ? clamp(requestedTop, minTop, maxTop) : minTop;
|
|
|
|
setPlacement((current) => {
|
|
if (Math.abs(current.left - nextLeft) < 0.5 && Math.abs(current.top - nextTop) < 0.5 && Math.abs(current.maxWidth - nextMaxWidth) < 0.5) return current;
|
|
return {
|
|
left: nextLeft,
|
|
top: nextTop,
|
|
maxWidth: nextMaxWidth
|
|
};
|
|
});
|
|
});
|
|
};
|
|
|
|
updatePlacement();
|
|
const observer = new ResizeObserver(updatePlacement);
|
|
observer.observe(toolbar);
|
|
if (toolbar.offsetParent instanceof HTMLElement) observer.observe(toolbar.offsetParent);
|
|
window.addEventListener("resize", updatePlacement);
|
|
|
|
return () => {
|
|
window.cancelAnimationFrame(frame);
|
|
observer.disconnect();
|
|
window.removeEventListener("resize", updatePlacement);
|
|
};
|
|
}, [bottomInset, center, leftInset, margin, maxWidth, minTop, requestedTop, rightInset]);
|
|
|
|
const style: CSSProperties = {
|
|
left: placement.left,
|
|
top: placement.top,
|
|
maxWidth: placement.maxWidth
|
|
};
|
|
|
|
return {
|
|
ref: toolbarRef,
|
|
style
|
|
};
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number) {
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|