Files
goldenChart/frontend/src/components/floatingWidgets/FloatingWidgetWindow.tsx
T
2026-06-15 16:00:41 +09:00

318 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Props> = ({
instance,
focused,
onClose,
onFocus,
onUpdateSlots,
onUpdateSize,
onUpdateGridFr,
nativeShell = false,
onPickerOpenChange,
}) => {
const [pickSlotId, setPickSlotId] = useState<string | null>(null);
const openPicker = useCallback((slotId: string) => {
setPickSlotId(slotId);
onPickerOpenChange?.(true);
}, [onPickerOpenChange]);
const closePicker = useCallback(() => {
setPickSlotId(null);
onPickerOpenChange?.(false);
}, [onPickerOpenChange]);
const bodyWrapRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(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<typeof setTimeout> | 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 = (
<>
<div
ref={panelRef}
className={windowClass}
style={windowStyle}
onMouseDown={nativeShell ? undefined : onFocus}
>
{!nativeShell && (
<div
className="fw-window-head"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
<span className="fw-window-title">{title}</span>
<button
type="button"
className="fw-window-close"
title="위젯 창 닫기"
aria-label="위젯 창 닫기"
onPointerDown={e => e.stopPropagation()}
onClick={onClose}
>
</button>
</div>
)}
<div ref={bodyWrapRef} className="fw-window-body-wrap">
<div
ref={bodyRef}
className="fw-window-body"
style={{
gridTemplateRows: gridTemplateFr(rowFr),
gridTemplateColumns: gridTemplateFr(colFr),
}}
>
{instance.slots.map(slot => (
<div key={slot.id} className="fw-window-cell">
<WidgetSlotView
slot={slot}
zoneId="center"
onPickWidget={openPicker}
onClearWidget={id => clearWidget(id)}
/>
</div>
))}
</div>
{(colSplitPositions.length > 0 || rowSplitPositions.length > 0) && (
<div className="fw-grid-split-layer" aria-hidden="true">
{colSplitPositions.map((left, index) => (
<div
key={`col-${index}`}
className="fw-grid-splitter fw-grid-splitter--v"
style={{ left }}
data-no-chart-pan="true"
role="separator"
aria-orientation="vertical"
aria-label={`열 ${index + 1}·${index + 2} 경계 조절`}
onPointerDown={onColSplitPointerDown(index)}
/>
))}
{rowSplitPositions.map((top, index) => (
<div
key={`row-${index}`}
className="fw-grid-splitter fw-grid-splitter--h"
style={{ top }}
data-no-chart-pan="true"
role="separator"
aria-orientation="horizontal"
aria-label={`행 ${index + 1}·${index + 2} 경계 조절`}
onPointerDown={onRowSplitPointerDown(index)}
/>
))}
</div>
)}
</div>
{!nativeShell && (
<div className="fw-window-resize-layer">
<div
className="fw-window-resize-handle fw-window-resize-handle--sw"
role="separator"
aria-label="위젯 창 크기 조절 (좌하단)"
onPointerDown={onResizeSwPointerDown}
/>
<div
className="fw-window-resize-handle fw-window-resize-handle--se"
role="separator"
aria-label="위젯 창 크기 조절 (우하단)"
onPointerDown={onResizeSePointerDown}
/>
</div>
)}
</div>
<WidgetPickerModal
open={pickSlotId != null}
category="all"
nativeCompact={nativeShell}
onClose={closePicker}
onSelect={applyWidgetType}
/>
</>
);
return nativeShell ? content : createPortal(content, document.body);
};
export default FloatingWidgetWindow;