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>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
"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);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { toUserMessage } from "@/infrastructure/userMessages";
|
||||
import { useI18n } from "@/i18n/i18n";
|
||||
|
||||
export function useUserMessage() {
|
||||
const { t } = useI18n();
|
||||
return useCallback((value: unknown, fallbackKey?: string) => toUserMessage(value, t, fallbackKey), [t]);
|
||||
}
|
||||
Reference in New Issue
Block a user