import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useDraggablePanel } from '../../hooks/useDraggablePanel'; import { useFloatingWidgetGridSplit } from '../../hooks/useFloatingWidgetGridSplit'; import { useFloatingWidgetResize } from '../../hooks/useFloatingWidgetResize'; import { defaultGridFr, gridSplitPositions, gridTemplateFr, minSizeForGrid, type FloatingWidgetInstance, } from '../../types/floatingWidget'; import type { WidgetSlot } from '../../types/widgetDashboard'; import WidgetSlotView from '../widgetDashboard/WidgetSlotView'; import WidgetPickerModal from '../widgetDashboard/WidgetPickerModal'; interface Props { instance: FloatingWidgetInstance; focused: boolean; onClose: () => void; onFocus: () => void; onUpdateSlots: (slots: WidgetSlot[]) => void; onUpdateSize: (width: number, height: number) => void; onUpdateGridFr: (rowFr: number[], colFr: number[]) => void; /** Tauri OS 창 — 네이티브 타이틀·리사이즈 사용, 내부 플로팅 크롬 숨김 */ nativeShell?: boolean; /** 위젯 선택 팝업 열림/닫힘 (네이티브 창 크기 조절용) */ onPickerOpenChange?: (open: boolean) => void; } const FloatingWidgetWindow: React.FC = ({ instance, focused, onClose, onFocus, onUpdateSlots, onUpdateSize, onUpdateGridFr, nativeShell = false, onPickerOpenChange, }) => { const [pickSlotId, setPickSlotId] = useState(null); const openPicker = useCallback((slotId: string) => { setPickSlotId(slotId); onPickerOpenChange?.(true); }, [onPickerOpenChange]); const closePicker = useCallback(() => { setPickSlotId(null); onPickerOpenChange?.(false); }, [onPickerOpenChange]); const bodyWrapRef = useRef(null); const bodyRef = useRef(null); const [bodySize, setBodySize] = useState({ width: 0, height: 0 }); const resolveBodySize = useCallback(() => { const wrap = bodyWrapRef.current; const body = bodyRef.current; const width = wrap?.clientWidth ?? body?.clientWidth ?? bodySize.width; const height = wrap?.clientHeight ?? body?.clientHeight ?? bodySize.height; if (width > 0 && height > 0) { return { width, height }; } if (nativeShell) { return { width: width > 0 ? width : instance.width, height: height > 0 ? height : instance.height, }; } return { width, height }; }, [bodySize.height, bodySize.width, instance.height, instance.width, nativeShell]); const rowFr = instance.rowFr?.length === instance.rows ? instance.rowFr : defaultGridFr(instance.rows); const colFr = instance.colFr?.length === instance.cols ? instance.colFr : defaultGridFr(instance.cols); const rowFrRef = useRef(rowFr); const colFrRef = useRef(colFr); rowFrRef.current = rowFr; colFrRef.current = colFr; const { panelRef, setPos, dragging, onHeaderPointerDown, headerTouchStyle, panelStyle, headerCursor, } = useDraggablePanel({ centerOnMount: false, initialPosition: instance.position, }); const minSize = useMemo( () => minSizeForGrid(instance.rows, instance.cols), [instance.rows, instance.cols], ); const handleResize = useCallback((result: { width: number; height: number; x?: number }) => { if (result.x != null) { setPos(prev => ({ ...prev, x: result.x! })); } onUpdateSize(result.width, result.height); }, [onUpdateSize, setPos]); const { onResizeSePointerDown, onResizeSwPointerDown } = useFloatingWidgetResize( panelRef, { width: instance.width, height: instance.height }, minSize, handleResize, ); const handleColFrChange = useCallback((next: number[]) => { onUpdateGridFr(rowFrRef.current, next); }, [onUpdateGridFr]); const handleRowFrChange = useCallback((next: number[]) => { onUpdateGridFr(next, colFrRef.current); }, [onUpdateGridFr]); const notifyLayoutSync = useCallback(() => { window.dispatchEvent(new CustomEvent('gc-widget-layout-sync')); }, []); const { onColSplitPointerDown, onRowSplitPointerDown } = useFloatingWidgetGridSplit({ onColFrChange: handleColFrChange, onRowFrChange: handleRowFrChange, getColFr: () => colFrRef.current, getRowFr: () => rowFrRef.current, getBodySize: resolveBodySize, onSplitEnd: notifyLayoutSync, }); const updateSlot = useCallback((slotId: string, updater: (s: WidgetSlot) => WidgetSlot) => { onUpdateSlots(instance.slots.map(s => (s.id === slotId ? updater(s) : s))); }, [instance.slots, onUpdateSlots]); const applyWidgetType = useCallback((widgetType: string) => { if (!pickSlotId) return; updateSlot(pickSlotId, s => ({ ...s, widgetType, config: {} })); closePicker(); }, [pickSlotId, updateSlot, closePicker]); const clearWidget = useCallback((slotId: string) => { updateSlot(slotId, s => ({ ...s, widgetType: null, config: {} })); }, [updateSlot]); /* 창·그리드 크기 변경 시 내부 차트·위젯 레이아웃 재동기화 */ useEffect(() => { const el = bodyWrapRef.current ?? bodyRef.current; if (!el) return undefined; let timer: ReturnType | null = null; const scheduleLayoutSync = () => { const wrap = bodyWrapRef.current; const body = bodyRef.current; setBodySize({ width: wrap?.clientWidth ?? body?.clientWidth ?? 0, height: wrap?.clientHeight ?? body?.clientHeight ?? 0, }); if (timer) clearTimeout(timer); timer = setTimeout(notifyLayoutSync, 80); }; scheduleLayoutSync(); const ro = new ResizeObserver(() => scheduleLayoutSync()); ro.observe(el); if (bodyRef.current && bodyRef.current !== el) { ro.observe(bodyRef.current); } return () => { if (timer) clearTimeout(timer); ro.disconnect(); }; }, [instance.rows, instance.cols, notifyLayoutSync]); const colSplitPositions = useMemo( () => gridSplitPositions(colFr, bodySize.width, 1), [colFr, bodySize.width], ); const rowSplitPositions = useMemo( () => gridSplitPositions(rowFr, bodySize.height, 1), [rowFr, bodySize.height], ); const title = `위젯 ${instance.rows}×${instance.cols}`; const windowClass = [ 'fw-window', focused ? 'fw-window--focused' : '', nativeShell ? 'fw-window--native' : '', ].filter(Boolean).join(' '); const windowStyle = nativeShell ? undefined : { ...panelStyle, width: instance.width, height: instance.height, zIndex: instance.zIndex, cursor: dragging ? 'grabbing' : undefined, }; const content = ( <>
{!nativeShell && (
{title}
)}
{instance.slots.map(slot => (
clearWidget(id)} />
))}
{(colSplitPositions.length > 0 || rowSplitPositions.length > 0) && (