Files
invyone/frontend/components/dash/DashboardCanvas.tsx
T
gbpark 2c0a97f2ba Phase 1: INVYONE 카드 엔진 토대 정리
- components/builder/* 폐기 (12-grid 미완성 빌더 14개 파일)
- components/template-builder/TemplateBuilder.tsx 신규
  (자유배치 + 3뷰 + Zustand 스토어 + 드래그/리사이즈/히스토리/격자)
- admin/builder/page.tsx 진입점 전환 (BuilderLayout → TemplateBuilder)
- 타입 정리: FreePosition / TemplateComponent / ViewConfig / Card /
  Dashboard / CardConnection 추가, 레거시(GridPosition/TemplateKind/
  DEFAULT_COMPONENT_LAYOUTS/CANVAS_KEYWORDS) @deprecated 표기
- v2-* 마이그레이션 1차:
  · 완전: v2-table-list (ResizeObserver), v2-table-search-widget (@container)
  · 경량: button/input/select/date/text-display/card-display/aggregation-widget
    (withContainerQuery HOC)
- 다크 모드 대응: Tailwind dark: variant 21패턴 71곳 치환
- /test-card-responsive PoC 검증 페이지

세션 후반 버그 픽스 (phase1-log §7):
- test-card-responsive (main) 그룹 밖 이동 (AppLayout 탭 시스템 회피)
- useRegistryPalette default_size {width,height}/{w,h} 포맷 정규화
- dark: variant 중복 체인 정리

검증: (A) 반응형 메커니즘, (B) TemplateBuilder UI 통과
(C) 기존 VEX 화면은 마이그레이션 미완 상태라 Phase 2 이후 개별 진행

스펙: notes/gbpark/2026-04-10-card-engine-final-spec.md
로그: notes/gbpark/2026-04-10-card-engine-phase1-log.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 03:08:06 +09:00

194 lines
6.4 KiB
TypeScript

'use client';
import { useRef, useCallback, useEffect, forwardRef } from 'react';
import { useDashboardStore } from '@/stores/dashboardStore';
import { DashboardCard } from './DashboardCard';
import { DashboardEmpty } from './DashboardEmpty';
interface DashboardCanvasProps {
dashboardName: string;
onOpenLibrary: () => void;
onOpenSettings?: (cardId: string) => void;
controlMode?: boolean;
}
export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(function DashboardCanvas({
dashboardName,
onOpenLibrary,
onOpenSettings,
controlMode: controlActive,
}, externalRef) {
const cards = useDashboardStore((s) => s.cards);
const editMode = useDashboardStore((s) => s.editMode);
const updateCard = useDashboardStore((s) => s.updateCard);
const removeCard = useDashboardStore((s) => s.removeCard);
const internalRef = useRef<HTMLDivElement>(null);
const canvasRef = (externalRef as React.RefObject<HTMLDivElement | null>) ?? internalRef;
const dragRef = useRef<{
cardId: string;
startX: number; startY: number;
origLeft: number; origTop: number;
origW: number; origH: number;
mode: 'drag' | 'resize';
el: HTMLElement;
} | null>(null);
// 캔버스 경계 clamp
const clamp = useCallback((l: number, t: number, w: number, h: number) => {
const cv = canvasRef.current;
if (!cv) return { l, t, w, h };
const cw = cv.clientWidth;
const ch = cv.clientHeight;
w = Math.min(w, cw);
h = Math.min(h, ch);
l = Math.max(0, Math.min(l, cw - w));
t = Math.max(0, Math.min(t, ch - h));
return { l, t, w, h };
}, []);
// 마우스 다운 → 드래그/리사이즈 시작
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (!editMode) return;
const target = e.target as HTMLElement;
// 버튼/입력/select 클릭은 무시 (단, 리사이즈 핸들은 통과)
const isResize = target.closest('[data-resize]') !== null;
if (!isResize) {
if (target.closest('button') || target.closest('input') || target.closest('select') || target.closest('textarea')) return;
}
// ★ wrapper div(data-card-id 가진 것)를 찾아야 함 — .dash-card는 그 안의 div
const wrapperEl = target.closest('[data-card-id]') as HTMLElement;
if (!wrapperEl) return;
const cardId = wrapperEl.dataset.cardId;
if (!cardId) return;
e.preventDefault();
dragRef.current = {
cardId,
startX: e.clientX,
startY: e.clientY,
origLeft: wrapperEl.offsetLeft,
origTop: wrapperEl.offsetTop,
origW: wrapperEl.offsetWidth,
origH: wrapperEl.offsetHeight,
mode: isResize ? 'resize' : 'drag',
el: wrapperEl,
};
// 시각 피드백은 내부 .dash-card에 적용
const cardEl = wrapperEl.querySelector('.dash-card') as HTMLElement | null;
if (cardEl) cardEl.classList.add(isResize ? 'resizing' : 'dragging');
document.body.style.cursor = isResize ? 'nwse-resize' : 'grabbing';
document.body.style.userSelect = 'none';
}, [editMode]);
// 마우스 이동
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
const d = dragRef.current;
if (!d) return;
const dx = e.clientX - d.startX;
const dy = e.clientY - d.startY;
if (d.mode === 'drag') {
const c = clamp(d.origLeft + dx, d.origTop + dy, d.origW, d.origH);
d.el.style.left = c.l + 'px';
d.el.style.top = c.t + 'px';
} else {
const nw = Math.max(220, d.origW + dx);
const nh = Math.max(140, d.origH + dy);
const c = clamp(d.origLeft, d.origTop, nw, nh);
d.el.style.width = c.w + 'px';
d.el.style.height = c.h + 'px';
}
};
const handleMouseUp = () => {
const d = dragRef.current;
if (!d) return;
// 시각 피드백 제거 (.dash-card)
const cardEl = d.el.querySelector('.dash-card') as HTMLElement | null;
if (cardEl) cardEl.classList.remove('dragging', 'resizing');
document.body.style.cursor = '';
document.body.style.userSelect = '';
// 최종 위치를 store에 반영
updateCard(d.cardId, {
position_x: d.el.offsetLeft,
position_y: d.el.offsetTop,
width: d.el.offsetWidth,
height: d.el.offsetHeight,
});
dragRef.current = null;
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [clamp, updateCard]);
const handleToggleCollapse = useCallback((cardId: string) => {
const card = cards.find((c) => (c.card_id ?? c.CARD_ID) === cardId);
if (!card) return;
const wasCollapsed = card.is_collapsed ?? card.IS_COLLAPSED ?? false;
updateCard(cardId, { is_collapsed: !wasCollapsed });
}, [cards, updateCard]);
const handleRemove = useCallback((cardId: string) => {
if (!confirm('이 카드를 삭제하시겠습니까?')) return;
removeCard(cardId);
}, [removeCard]);
return (
<div
ref={canvasRef}
className={`dash-canvas${editMode ? ' edit-mode' : ''}${controlActive ? ' control-mode' : ''}`}
onMouseDown={controlActive ? undefined : handleMouseDown}
>
{cards.length === 0 ? (
<DashboardEmpty dashboardName={dashboardName} onOpenLibrary={onOpenLibrary} />
) : (
cards.map((card) => {
const id = card.card_id ?? card.CARD_ID;
const x = Number(card.position_x ?? card.POSITION_X ?? 50);
const y = Number(card.position_y ?? card.POSITION_Y ?? 50);
const w = Number(card.width ?? card.WIDTH ?? 600);
const h = Number(card.height ?? card.HEIGHT ?? 400);
return (
<div
key={id}
data-card-id={id}
style={{
position: 'absolute',
left: x + 'px',
top: y + 'px',
width: w + 'px',
height: h + 'px',
zIndex: 10,
}}
>
<DashboardCard
card={card}
editMode={editMode}
onRemove={handleRemove}
onToggleCollapse={handleToggleCollapse}
onOpenSettings={onOpenSettings}
/>
</div>
);
})
)}
</div>
);
});