위젯영역 리사이즈
This commit is contained in:
@@ -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(() => {
|
||||
onInstancesChange?.(instances.length);
|
||||
}, [instances.length, onInstancesChange]);
|
||||
@@ -154,6 +160,7 @@ const FloatingWidgetLayer: React.FC<Props> = ({
|
||||
onFocus={() => bringToFront(inst.id)}
|
||||
onUpdateSlots={slots => handleUpdateSlots(inst.id, slots)}
|
||||
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 { createPortal } from 'react-dom';
|
||||
import { useDraggablePanel } from '../../hooks/useDraggablePanel';
|
||||
import { useFloatingWidgetGridSplit } from '../../hooks/useFloatingWidgetGridSplit';
|
||||
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 WidgetSlotView from '../widgetDashboard/WidgetSlotView';
|
||||
import WidgetPickerModal from '../widgetDashboard/WidgetPickerModal';
|
||||
@@ -14,6 +21,7 @@ interface Props {
|
||||
onFocus: () => void;
|
||||
onUpdateSlots: (slots: WidgetSlot[]) => void;
|
||||
onUpdateSize: (width: number, height: number) => void;
|
||||
onUpdateGridFr: (rowFr: number[], colFr: number[]) => void;
|
||||
}
|
||||
|
||||
const FloatingWidgetWindow: React.FC<Props> = ({
|
||||
@@ -23,9 +31,23 @@ const FloatingWidgetWindow: React.FC<Props> = ({
|
||||
onFocus,
|
||||
onUpdateSlots,
|
||||
onUpdateSize,
|
||||
onUpdateGridFr,
|
||||
}) => {
|
||||
const [pickSlotId, setPickSlotId] = useState<string | null>(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 {
|
||||
panelRef,
|
||||
@@ -59,6 +81,25 @@ const FloatingWidgetWindow: React.FC<Props> = ({
|
||||
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) => {
|
||||
onUpdateSlots(instance.slots.map(s => (s.id === slotId ? updater(s) : s)));
|
||||
}, [instance.slots, onUpdateSlots]);
|
||||
@@ -73,23 +114,35 @@ const FloatingWidgetWindow: React.FC<Props> = ({
|
||||
updateSlot(slotId, s => ({ ...s, widgetType: null, config: {} }));
|
||||
}, [updateSlot]);
|
||||
|
||||
/* 창 크기 변경 시 내부 차트·위젯 레이아웃 재동기화 */
|
||||
/* 창·그리드 크기 변경 시 내부 차트·위젯 레이아웃 재동기화 */
|
||||
useEffect(() => {
|
||||
const el = bodyRef.current;
|
||||
if (!el) return undefined;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const ro = new ResizeObserver(() => {
|
||||
const scheduleLayoutSync = () => {
|
||||
setBodySize({ width: el.clientWidth, height: el.clientHeight });
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent('gc-widget-layout-sync'));
|
||||
}, 80);
|
||||
});
|
||||
};
|
||||
scheduleLayoutSync();
|
||||
const ro = new ResizeObserver(() => scheduleLayoutSync());
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
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}`;
|
||||
|
||||
@@ -125,24 +178,53 @@ const FloatingWidgetWindow: React.FC<Props> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={bodyRef}
|
||||
className="fw-window-body"
|
||||
style={{
|
||||
gridTemplateRows: `repeat(${instance.rows}, minmax(0, 1fr))`,
|
||||
gridTemplateColumns: `repeat(${instance.cols}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{instance.slots.map(slot => (
|
||||
<div key={slot.id} className="fw-window-cell">
|
||||
<WidgetSlotView
|
||||
slot={slot}
|
||||
zoneId="center"
|
||||
onPickWidget={id => setPickSlotId(id)}
|
||||
onClearWidget={id => clearWidget(id)}
|
||||
/>
|
||||
<div 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 => setPickSlotId(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 }}
|
||||
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">
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -104,6 +104,14 @@
|
||||
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 {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -156,6 +164,48 @@
|
||||
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 {
|
||||
position: absolute;
|
||||
|
||||
@@ -21,17 +21,78 @@ export const FLOATING_WIDGET_LAYOUTS: FloatingWidgetLayoutPreset[] = [
|
||||
{ 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 {
|
||||
id: string;
|
||||
rows: number;
|
||||
cols: number;
|
||||
slots: WidgetSlot[];
|
||||
/** 행·열 비율 (fr) — 그리드 칸 리사이즈 */
|
||||
rowFr: number[];
|
||||
colFr: number[];
|
||||
position: { x: number; y: number };
|
||||
width: number;
|
||||
height: 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 } {
|
||||
const cellW = 280;
|
||||
const cellH = 220;
|
||||
@@ -69,6 +130,8 @@ export function createFloatingWidgetInstance(
|
||||
rows,
|
||||
cols,
|
||||
slots: Array.from({ length: count }, () => newWidgetSlot()),
|
||||
rowFr: defaultGridFr(rows),
|
||||
colFr: defaultGridFr(cols),
|
||||
position: {
|
||||
x: Math.min(80 + offset, Math.max(24, window.innerWidth - width - 24)),
|
||||
y: Math.min(72 + offset, Math.max(24, window.innerHeight - height - 24)),
|
||||
|
||||
Reference in New Issue
Block a user