Files
moteva/frontend/src/ui/hooks/useFloatingToolbarPlacement.ts
T
root 44406b72db Initial commit: img-infinite-canvas AI design workbench MVP
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>
2026-07-07 23:15:37 +08:00

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);
}