318 lines
10 KiB
TypeScript
318 lines
10 KiB
TypeScript
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;
|