Files
goldenChart/frontend/src/components/floatingWidgets/FloatingWidgetWindow.tsx
T
Macbook 36b4679c9a fix(desktop): 네이티브 위젯 picker를 창 전체 뷰로 전환
portal/overlay 대신 widget-main에서 picker 전용 화면으로 전환해
macOS·Windows WebView 모두에서 위젯 선택 팝업이 보이도록 합니다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 16:49:25 +09:00

343 lines
11 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;
/** 네이티브 위젯 창 — picker는 부모(widget-main)에서 전환 렌더 */
onNativePickRequest?: (slotId: string) => void;
/** 위젯 선택 팝업 열림/닫힘 (웹 플로팅 레이어용) */
onPickerOpenChange?: (open: boolean) => void | Promise<void>;
}
const FloatingWidgetWindow: React.FC<Props> = ({
instance,
focused,
onClose,
onFocus,
onUpdateSlots,
onUpdateSize,
onUpdateGridFr,
nativeShell = false,
onNativePickRequest,
onPickerOpenChange,
}) => {
const [pickSlotId, setPickSlotId] = useState<string | null>(null);
const nativePickerExternal = nativeShell && Boolean(onNativePickRequest);
const openPicker = useCallback((slotId: string) => {
if (nativePickerExternal) {
onNativePickRequest!(slotId);
return;
}
setPickSlotId(slotId);
}, [nativePickerExternal, onNativePickRequest]);
const closePicker = useCallback(async () => {
setPickSlotId(null);
if (nativeShell && onPickerOpenChange) {
try {
await onPickerOpenChange(false);
} catch (err) {
console.warn('[FloatingWidgetWindow] picker window restore failed', err);
}
}
}, [nativeShell, 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: {} }));
}, [pickSlotId, updateSlot]);
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={id => { openPicker(id); }}
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>
</>
);
const pickerModal = !nativePickerExternal && pickSlotId != null ? (
<WidgetPickerModal
open
category="all"
nativeCompact={nativeShell}
onClose={() => { void closePicker(); }}
onSelect={applyWidgetType}
/>
) : null;
if (nativeShell) {
return content;
}
return createPortal(
<>
{content}
{pickerModal}
</>,
document.body,
);
};
export default FloatingWidgetWindow;