"use client"; import { DndContext, KeyboardSensor, PointerSensor, useDraggable, useSensor, useSensors, type Modifier } from "@dnd-kit/core"; import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; export const SAVE_LAYOUT_KEY = "buzz-admin-save-layout:v1"; export interface SaveLayout { x: number; y: number; width: number; height: number } type PanelSize = Pick; export function parseSaveLayout(value: string | null): SaveLayout | null { try { const data = JSON.parse(value ?? "null"); if (!data || data.version !== 1 || ![data.x, data.y, data.width, data.height].every((v) => typeof v === "number" && Number.isFinite(v)) || data.width <= 0 || data.height <= 0) return null; return { x: data.x, y: data.y, width: data.width, height: data.height }; } catch { return null; } } export function clampSaveLayout(layout: SaveLayout, width: number, height: number, minimum: PanelSize): SaveLayout { const panelWidth = Math.min(Math.max(minimum.width, layout.width), width); const panelHeight = Math.min(Math.max(minimum.height, layout.height), height); return { x: Math.max(0, Math.min(layout.x, width - panelWidth)), y: Math.max(0, Math.min(layout.y, height - panelHeight)), width: panelWidth, height: panelHeight, }; } export function FloatingSavePanel({ children }: { children: ReactNode }) { const bounds = useRef(null); const panelRef = useRef(null); const dragStart = useRef(null); const [preferred, setPreferred] = useState(null); const [viewport, setViewport] = useState({ width: 0, height: 0 }); const [minimum, setMinimum] = useState({ width: 0, height: 0 }); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor)); useEffect(() => { let active = true; const measure = () => { const rect = bounds.current?.getBoundingClientRect(); if (rect) setViewport({ width: rect.width, height: rect.height }); }; queueMicrotask(() => { if (!active) return; try { setPreferred(parseSaveLayout(window.localStorage.getItem(SAVE_LAYOUT_KEY))); } catch { /* Browser storage is optional. */ } measure(); }); const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null; if (bounds.current) observer?.observe(bounds.current); window.addEventListener("resize", measure); window.visualViewport?.addEventListener("resize", measure); return () => { active = false; observer?.disconnect(); window.removeEventListener("resize", measure); window.visualViewport?.removeEventListener("resize", measure); }; }, []); const constrain = (layout: SaveLayout) => clampSaveLayout(layout, bounds.current?.clientWidth ?? viewport.width, bounds.current?.clientHeight ?? viewport.height, minimum); const currentLayout = (): SaveLayout | null => { const rect = panelRef.current?.getBoundingClientRect(); const area = bounds.current?.getBoundingClientRect(); return rect && area ? { x: rect.left - area.left, y: rect.top - area.top, width: rect.width, height: rect.height } : null; }; const commit = (layout: SaveLayout) => { const next = constrain(layout); setPreferred(next); try { window.localStorage.setItem(SAVE_LAYOUT_KEY, JSON.stringify({ version: 1, ...next })); } catch { /* Keep the session layout even when storage is blocked. */ } }; const modifier: Modifier = ({ transform, draggingNodeRect }) => { const area = bounds.current?.getBoundingClientRect(); if (!area || !draggingNodeRect) return transform; return { ...transform, x: Math.max(area.left - draggingNodeRect.left, Math.min(transform.x, area.right - draggingNodeRect.right)), y: Math.max(area.top - draggingNodeRect.top, Math.min(transform.y, area.bottom - draggingNodeRect.bottom)), }; }; const layout = preferred && viewport.width > 0 && viewport.height > 0 ? clampSaveLayout(preferred, viewport.width, viewport.height, minimum) : null; return (
{ dragStart.current = currentLayout(); }} onDragEnd={({ delta }) => { const start = dragStart.current; if (start) commit({ ...start, x: start.x + delta.x, y: start.y + delta.y }); dragStart.current = null; }} onDragCancel={() => { dragStart.current = null; }}> {children}
); } type ResizeEdges = { left: boolean; right: boolean; top: boolean; bottom: boolean }; function resizeEdges(element: HTMLElement, x: number, y: number): ResizeEdges { const rect = element.getBoundingClientRect(); return { left: x <= rect.left + 8, right: x >= rect.right - 8, top: y <= rect.top + 8, bottom: y >= rect.bottom - 8, }; } function resizeCursor(edges: ResizeEdges) { if ((edges.left && edges.top) || (edges.right && edges.bottom)) return "nwse-resize"; if ((edges.right && edges.top) || (edges.left && edges.bottom)) return "nesw-resize"; if (edges.left || edges.right) return "ew-resize"; if (edges.top || edges.bottom) return "ns-resize"; return "grab"; } function PanelContent({ children, panelRef, layout, minimum, onMeasureMinimum, currentLayout, constrain, preview, commit }: { children: ReactNode; panelRef: React.RefObject; layout: SaveLayout | null; minimum: PanelSize; onMeasureMinimum: (size: PanelSize) => void; currentLayout: () => SaveLayout | null; constrain: (layout: SaveLayout) => SaveLayout; preview: (layout: SaveLayout | null) => void; commit: (layout: SaveLayout) => void; }) { const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform } = useDraggable({ id: "floating-admin-save" }); const resizing = useRef<{ id: number; x: number; y: number; edges: ResizeEdges; start: SaveLayout; previous: SaveLayout | null } | null>(null); const pending = useRef(null); const measuredMinimumRef = useRef(false); const [cursor, setCursor] = useState("grab"); const style: CSSProperties = layout ? { left: layout.x, top: layout.y, width: layout.width, height: layout.height, right: "auto", bottom: "auto" } : {}; style.cursor = transform ? "grabbing" : cursor; if (transform) style.transform = `translate3d(${transform.x}px, ${transform.y}px, 0)`; const cancelResize = () => { if (resizing.current) preview(resizing.current.previous); resizing.current = null; pending.current = null; }; return (
{ panelRef.current = node; setNodeRef(node); setActivatorNodeRef(node); // Capture the automatic starting size before restoring a custom layout. if (node && !measuredMinimumRef.current) { const { width, height } = node.getBoundingClientRect(); if (width > 0 && height > 0) { measuredMinimumRef.current = true; onMeasureMinimum({ width, height }); } } }} {...attributes} role="group" aria-label="ย้ายแผงบันทึก" aria-roledescription="แผงบันทึกที่ย้ายและปรับขนาดได้" title="ลากเพื่อย้าย ลากขอบเพื่อปรับขนาด หรือใช้ Alt + ปุ่มลูกศรเพื่อปรับขนาด" data-save-panel className="pointer-events-auto absolute right-0 bottom-0 flex max-h-full w-fit max-w-full touch-none flex-col items-end gap-2 overflow-hidden rounded-xl border bg-background p-3 shadow-lg sm:right-2" style={style} onPointerDown={(event) => { if (event.button !== 0) return; const edges = resizeEdges(event.currentTarget, event.clientX, event.clientY); if (!edges.left && !edges.right && !edges.top && !edges.bottom) { listeners?.onPointerDown?.(event); return; } const start = currentLayout(); if (!start) return; event.preventDefault(); event.stopPropagation(); event.currentTarget.focus(); event.currentTarget.setPointerCapture(event.pointerId); resizing.current = { id: event.pointerId, x: event.clientX, y: event.clientY, edges, start, previous: layout }; pending.current = start; }} onPointerMove={(event) => { const active = resizing.current; if (!active) { setCursor(resizeCursor(resizeEdges(event.currentTarget, event.clientX, event.clientY))); return; } if (active.id !== event.pointerId) return; const dx = event.clientX - active.x; const dy = event.clientY - active.y; const { edges, start } = active; // Keep the opposite edge anchored while respecting the minimum size and viewport. const x = edges.left ? Math.max(0, Math.min(start.x + dx, start.x + start.width - minimum.width)) : start.x; const y = edges.top ? Math.max(0, Math.min(start.y + dy, start.y + start.height - minimum.height)) : start.y; const next = constrain({ x, y, width: edges.left ? start.width + start.x - x : edges.right ? start.width + dx : start.width, height: edges.top ? start.height + start.y - y : edges.bottom ? start.height + dy : start.height, }); pending.current = next; preview(next); }} onPointerUp={(event) => { if (resizing.current?.id !== event.pointerId) return; if (pending.current) commit(pending.current); resizing.current = null; pending.current = null; event.currentTarget.releasePointerCapture(event.pointerId); }} onPointerCancel={cancelResize} onLostPointerCapture={cancelResize} onKeyDown={(event) => { if (event.target !== event.currentTarget) return; if (!event.altKey || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) { listeners?.onKeyDown?.(event); return; } event.preventDefault(); event.stopPropagation(); const start = currentLayout(); if (!start) return; const step = event.shiftKey ? 32 : 8; commit({ ...start, width: start.width + (event.key === "ArrowRight" ? step : event.key === "ArrowLeft" ? -step : 0), height: start.height + (event.key === "ArrowDown" ? step : event.key === "ArrowUp" ? -step : 0) }); }} > {children}
); }