위젯영역 리사이즈

This commit is contained in:
Macbook
2026-06-14 01:18:10 +09:00
parent 57677ef39c
commit a3dcfe5cb2
5 changed files with 336 additions and 22 deletions
@@ -120,6 +120,12 @@ const FloatingWidgetLayer: React.FC<Props> = ({
))); )));
}, []); }, []);
const handleUpdateGridFr = useCallback((id: string, rowFr: number[], colFr: number[]) => {
setInstances(prev => prev.map(inst => (
inst.id === id ? { ...inst, rowFr, colFr } : inst
)));
}, []);
useEffect(() => { useEffect(() => {
onInstancesChange?.(instances.length); onInstancesChange?.(instances.length);
}, [instances.length, onInstancesChange]); }, [instances.length, onInstancesChange]);
@@ -154,6 +160,7 @@ const FloatingWidgetLayer: React.FC<Props> = ({
onFocus={() => bringToFront(inst.id)} onFocus={() => bringToFront(inst.id)}
onUpdateSlots={slots => handleUpdateSlots(inst.id, slots)} onUpdateSlots={slots => handleUpdateSlots(inst.id, slots)}
onUpdateSize={(w, h) => handleUpdateSize(inst.id, w, h)} onUpdateSize={(w, h) => handleUpdateSize(inst.id, w, h)}
onUpdateGridFr={(rowFr, colFr) => handleUpdateGridFr(inst.id, rowFr, colFr)}
/> />
))} ))}
@@ -1,8 +1,15 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useDraggablePanel } from '../../hooks/useDraggablePanel'; import { useDraggablePanel } from '../../hooks/useDraggablePanel';
import { useFloatingWidgetGridSplit } from '../../hooks/useFloatingWidgetGridSplit';
import { useFloatingWidgetResize } from '../../hooks/useFloatingWidgetResize'; import { useFloatingWidgetResize } from '../../hooks/useFloatingWidgetResize';
import { minSizeForGrid, type FloatingWidgetInstance } from '../../types/floatingWidget'; import {
defaultGridFr,
gridSplitPositions,
gridTemplateFr,
minSizeForGrid,
type FloatingWidgetInstance,
} from '../../types/floatingWidget';
import type { WidgetSlot } from '../../types/widgetDashboard'; import type { WidgetSlot } from '../../types/widgetDashboard';
import WidgetSlotView from '../widgetDashboard/WidgetSlotView'; import WidgetSlotView from '../widgetDashboard/WidgetSlotView';
import WidgetPickerModal from '../widgetDashboard/WidgetPickerModal'; import WidgetPickerModal from '../widgetDashboard/WidgetPickerModal';
@@ -14,6 +21,7 @@ interface Props {
onFocus: () => void; onFocus: () => void;
onUpdateSlots: (slots: WidgetSlot[]) => void; onUpdateSlots: (slots: WidgetSlot[]) => void;
onUpdateSize: (width: number, height: number) => void; onUpdateSize: (width: number, height: number) => void;
onUpdateGridFr: (rowFr: number[], colFr: number[]) => void;
} }
const FloatingWidgetWindow: React.FC<Props> = ({ const FloatingWidgetWindow: React.FC<Props> = ({
@@ -23,9 +31,23 @@ const FloatingWidgetWindow: React.FC<Props> = ({
onFocus, onFocus,
onUpdateSlots, onUpdateSlots,
onUpdateSize, onUpdateSize,
onUpdateGridFr,
}) => { }) => {
const [pickSlotId, setPickSlotId] = useState<string | null>(null); const [pickSlotId, setPickSlotId] = useState<string | null>(null);
const bodyRef = useRef<HTMLDivElement>(null); const bodyRef = useRef<HTMLDivElement>(null);
const [bodySize, setBodySize] = useState({ width: 0, height: 0 });
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 { const {
panelRef, panelRef,
@@ -59,6 +81,25 @@ const FloatingWidgetWindow: React.FC<Props> = ({
handleResize, handleResize,
); );
const handleColFrChange = useCallback((next: number[]) => {
onUpdateGridFr(rowFrRef.current, next);
}, [onUpdateGridFr]);
const handleRowFrChange = useCallback((next: number[]) => {
onUpdateGridFr(next, colFrRef.current);
}, [onUpdateGridFr]);
const { onColSplitPointerDown, onRowSplitPointerDown } = useFloatingWidgetGridSplit({
onColFrChange: handleColFrChange,
onRowFrChange: handleRowFrChange,
getColFr: () => colFrRef.current,
getRowFr: () => rowFrRef.current,
getBodySize: () => ({
width: bodyRef.current?.clientWidth ?? bodySize.width,
height: bodyRef.current?.clientHeight ?? bodySize.height,
}),
});
const updateSlot = useCallback((slotId: string, updater: (s: WidgetSlot) => WidgetSlot) => { const updateSlot = useCallback((slotId: string, updater: (s: WidgetSlot) => WidgetSlot) => {
onUpdateSlots(instance.slots.map(s => (s.id === slotId ? updater(s) : s))); onUpdateSlots(instance.slots.map(s => (s.id === slotId ? updater(s) : s)));
}, [instance.slots, onUpdateSlots]); }, [instance.slots, onUpdateSlots]);
@@ -73,23 +114,35 @@ const FloatingWidgetWindow: React.FC<Props> = ({
updateSlot(slotId, s => ({ ...s, widgetType: null, config: {} })); updateSlot(slotId, s => ({ ...s, widgetType: null, config: {} }));
}, [updateSlot]); }, [updateSlot]);
/* 창 크기 변경 시 내부 차트·위젯 레이아웃 재동기화 */ /* 창·그리드 크기 변경 시 내부 차트·위젯 레이아웃 재동기화 */
useEffect(() => { useEffect(() => {
const el = bodyRef.current; const el = bodyRef.current;
if (!el) return undefined; if (!el) return undefined;
let timer: ReturnType<typeof setTimeout> | null = null; let timer: ReturnType<typeof setTimeout> | null = null;
const ro = new ResizeObserver(() => { const scheduleLayoutSync = () => {
setBodySize({ width: el.clientWidth, height: el.clientHeight });
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
timer = setTimeout(() => { timer = setTimeout(() => {
window.dispatchEvent(new CustomEvent('gc-widget-layout-sync')); window.dispatchEvent(new CustomEvent('gc-widget-layout-sync'));
}, 80); }, 80);
}); };
scheduleLayoutSync();
const ro = new ResizeObserver(() => scheduleLayoutSync());
ro.observe(el); ro.observe(el);
return () => { return () => {
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
ro.disconnect(); ro.disconnect();
}; };
}, []); }, [instance.rows, instance.cols]);
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 title = `위젯 ${instance.rows}×${instance.cols}`;
@@ -125,12 +178,13 @@ const FloatingWidgetWindow: React.FC<Props> = ({
</button> </button>
</div> </div>
<div className="fw-window-body-wrap">
<div <div
ref={bodyRef} ref={bodyRef}
className="fw-window-body" className="fw-window-body"
style={{ style={{
gridTemplateRows: `repeat(${instance.rows}, minmax(0, 1fr))`, gridTemplateRows: gridTemplateFr(rowFr),
gridTemplateColumns: `repeat(${instance.cols}, minmax(0, 1fr))`, gridTemplateColumns: gridTemplateFr(colFr),
}} }}
> >
{instance.slots.map(slot => ( {instance.slots.map(slot => (
@@ -145,6 +199,34 @@ const FloatingWidgetWindow: React.FC<Props> = ({
))} ))}
</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 }}
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 }}
role="separator"
aria-orientation="horizontal"
aria-label={`${index + 1}·${index + 2} 경계 조절`}
onPointerDown={onRowSplitPointerDown(index)}
/>
))}
</div>
)}
</div>
<div className="fw-window-resize-layer"> <div className="fw-window-resize-layer">
<div <div
className="fw-window-resize-handle fw-window-resize-handle--sw" className="fw-window-resize-handle fw-window-resize-handle--sw"
@@ -0,0 +1,112 @@
import { useCallback, useEffect, useRef } from 'react';
import {
adjustAdjacentGridFr,
FLOATING_WIDGET_CELL_MIN_H,
FLOATING_WIDGET_CELL_MIN_W,
FLOATING_WIDGET_GRID_GAP,
} from '../types/floatingWidget';
import { bindWindowDrag, isPrimaryPointerButton } from '../utils/pointerDrag';
type SplitAxis = 'col' | 'row';
interface Options {
onColFrChange: (next: number[]) => void;
onRowFrChange: (next: number[]) => void;
getColFr: () => number[];
getRowFr: () => number[];
getBodySize: () => { width: number; height: number };
}
export function useFloatingWidgetGridSplit({
onColFrChange,
onRowFrChange,
getColFr,
getRowFr,
getBodySize,
}: Options) {
const unbindDragRef = useRef<(() => void) | null>(null);
const endDrag = useCallback(() => {
unbindDragRef.current?.();
unbindDragRef.current = null;
document.body.style.cursor = '';
document.body.style.userSelect = '';
}, []);
useEffect(() => () => {
unbindDragRef.current?.();
document.body.style.cursor = '';
document.body.style.userSelect = '';
}, []);
const startSplit = useCallback((
e: React.PointerEvent,
axis: SplitAxis,
index: number,
) => {
if (!isPrimaryPointerButton(e)) return;
e.preventDefault();
e.stopPropagation();
const handle = e.currentTarget as HTMLElement;
try {
handle.setPointerCapture(e.pointerId);
} catch {
/* ignore */
}
handle.classList.add('fw-grid-splitter--active');
const startX = e.clientX;
const startY = e.clientY;
const startColFr = [...getColFr()];
const startRowFr = [...getRowFr()];
const startSize = getBodySize();
document.body.style.cursor = axis === 'col' ? 'col-resize' : 'row-resize';
document.body.style.userSelect = 'none';
unbindDragRef.current?.();
unbindDragRef.current = bindWindowDrag(
(xy) => {
if (axis === 'col') {
const delta = xy.x - startX;
onColFrChange(adjustAdjacentGridFr(
startColFr,
index,
delta,
startSize.width,
FLOATING_WIDGET_GRID_GAP,
FLOATING_WIDGET_CELL_MIN_W,
));
return;
}
const delta = xy.y - startY;
onRowFrChange(adjustAdjacentGridFr(
startRowFr,
index,
delta,
startSize.height,
FLOATING_WIDGET_GRID_GAP,
FLOATING_WIDGET_CELL_MIN_H,
));
},
() => {
handle.classList.remove('fw-grid-splitter--active');
endDrag();
},
{ preventTouchScroll: true },
);
}, [endDrag, getBodySize, getColFr, getRowFr, onColFrChange, onRowFrChange]);
const onColSplitPointerDown = useCallback(
(index: number) => (e: React.PointerEvent) => startSplit(e, 'col', index),
[startSplit],
);
const onRowSplitPointerDown = useCallback(
(index: number) => (e: React.PointerEvent) => startSplit(e, 'row', index),
[startSplit],
);
return { onColSplitPointerDown, onRowSplitPointerDown };
}
+50
View File
@@ -104,6 +104,14 @@
background: color-mix(in srgb, #e74c3c 12%, transparent); background: color-mix(in srgb, #e74c3c 12%, transparent);
} }
.fw-window-body-wrap {
position: relative;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.fw-window-body { .fw-window-body {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -156,6 +164,48 @@
height: 100%; height: 100%;
} }
/* 그리드 칸 경계 리사이즈 */
.fw-grid-split-layer {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
}
.fw-grid-splitter {
position: absolute;
pointer-events: auto;
touch-action: none;
background: transparent;
transition: background 0.12s;
}
.fw-grid-splitter--v {
top: 0;
bottom: 0;
width: 7px;
margin-left: -3px;
cursor: col-resize;
}
.fw-grid-splitter--h {
left: 0;
right: 0;
height: 7px;
margin-top: -3px;
cursor: row-resize;
}
.fw-grid-splitter:hover,
.fw-grid-splitter--active,
.fw-window--focused .fw-grid-splitter {
background: color-mix(in srgb, var(--se-accent, var(--accent)) 35%, transparent);
}
.fw-grid-splitter--active {
background: color-mix(in srgb, var(--se-accent, var(--accent)) 55%, transparent);
}
/* 리사이즈 핸들 — 좌·우 하단 (차트 툴바 위에 표시) */ /* 리사이즈 핸들 — 좌·우 하단 (차트 툴바 위에 표시) */
.fw-window-resize-layer { .fw-window-resize-layer {
position: absolute; position: absolute;
+63
View File
@@ -21,17 +21,78 @@ export const FLOATING_WIDGET_LAYOUTS: FloatingWidgetLayoutPreset[] = [
{ id: '3x3', rows: 3, cols: 3, label: '3×3' }, { id: '3x3', rows: 3, cols: 3, label: '3×3' },
]; ];
export const FLOATING_WIDGET_CELL_MIN_W = 140;
export const FLOATING_WIDGET_CELL_MIN_H = 100;
export const FLOATING_WIDGET_GRID_GAP = 1;
export interface FloatingWidgetInstance { export interface FloatingWidgetInstance {
id: string; id: string;
rows: number; rows: number;
cols: number; cols: number;
slots: WidgetSlot[]; slots: WidgetSlot[];
/** 행·열 비율 (fr) — 그리드 칸 리사이즈 */
rowFr: number[];
colFr: number[];
position: { x: number; y: number }; position: { x: number; y: number };
width: number; width: number;
height: number; height: number;
zIndex: number; zIndex: number;
} }
export function defaultGridFr(count: number): number[] {
return Array.from({ length: count }, () => 1);
}
export function gridTemplateFr(fr: number[]): string {
return fr.map(f => `${Math.max(f, 0.05)}fr`).join(' ');
}
/** 인접 두 트랙의 fr 비율을 픽셀 delta 만큼 조정 */
export function adjustAdjacentGridFr(
fr: number[],
index: number,
deltaPx: number,
containerPx: number,
gapPx: number,
minCellPx: number,
): number[] {
if (index < 0 || index >= fr.length - 1) return fr;
const next = [...fr];
const n = fr.length;
const totalFr = fr.reduce((a, b) => a + b, 0);
const contentPx = Math.max(0, containerPx - (n - 1) * gapPx);
const pairFr = fr[index] + fr[index + 1];
const leftPx = contentPx * fr[index] / totalFr;
const rightPx = contentPx * fr[index + 1] / totalFr;
const pairPx = leftPx + rightPx;
const minLeft = Math.min(minCellPx, pairPx / 2);
const maxLeft = Math.max(pairPx - minCellPx, pairPx / 2);
const newLeftPx = Math.max(minLeft, Math.min(maxLeft, leftPx + deltaPx));
const newRightPx = pairPx - newLeftPx;
next[index] = pairFr * (newLeftPx / pairPx);
next[index + 1] = pairFr * (newRightPx / pairPx);
return next;
}
/** 스플리터 위치(px) — gap 포함 */
export function gridSplitPositions(
fr: number[],
containerPx: number,
gapPx: number,
): number[] {
const n = fr.length;
if (n <= 1 || containerPx <= 0) return [];
const totalFr = fr.reduce((a, b) => a + b, 0);
const contentPx = Math.max(0, containerPx - (n - 1) * gapPx);
const positions: number[] = [];
let accPx = 0;
for (let i = 0; i < n - 1; i++) {
accPx += contentPx * fr[i] / totalFr;
positions.push(accPx + i * gapPx + gapPx / 2);
}
return positions;
}
export function defaultSizeForGrid(rows: number, cols: number): { width: number; height: number } { export function defaultSizeForGrid(rows: number, cols: number): { width: number; height: number } {
const cellW = 280; const cellW = 280;
const cellH = 220; const cellH = 220;
@@ -69,6 +130,8 @@ export function createFloatingWidgetInstance(
rows, rows,
cols, cols,
slots: Array.from({ length: count }, () => newWidgetSlot()), slots: Array.from({ length: count }, () => newWidgetSlot()),
rowFr: defaultGridFr(rows),
colFr: defaultGridFr(cols),
position: { position: {
x: Math.min(80 + offset, Math.max(24, window.innerWidth - width - 24)), x: Math.min(80 + offset, Math.max(24, window.innerWidth - width - 24)),
y: Math.min(72 + offset, Math.max(24, window.innerHeight - height - 24)), y: Math.min(72 + offset, Math.max(24, window.innerHeight - height - 24)),