Files
2026-04-01 10:30:52 +09:00

474 lines
14 KiB
TypeScript

/**
* G7 DevTools 스타일 추적기
*
* 렌더링된 컴포넌트의 DOM 요소를 추적하고 computed styles를 수집합니다.
* 시각적 이상(보이지 않는 요소, z-index 충돌 등)을 자동으로 감지합니다.
*
* @module StyleTracker
*/
import { G7DevToolsCore } from './G7DevToolsCore';
import type { StyleIssue, StyleIssueType } from './types';
/**
* 스타일 추적에 필요한 computed styles 속성
*/
const TRACKED_STYLE_PROPERTIES = [
'display',
'visibility',
'opacity',
'position',
'zIndex',
'overflow',
'width',
'height',
'backgroundColor',
'color',
] as const;
/**
* 컴포넌트 스타일 정보
*/
interface ComponentStyleData {
componentId: string;
componentName: string;
classes: string[];
computedStyles: Record<string, string>;
element: WeakRef<HTMLElement>;
timestamp: number;
}
/**
* 스타일 추적기 클래스
*
* MutationObserver와 ResizeObserver를 사용하여
* DOM 변경 시 자동으로 스타일 이상을 감지합니다.
*/
export class StyleTracker {
private static instance: StyleTracker | null = null;
private enabled: boolean = false;
private trackedComponents: Map<string, ComponentStyleData> = new Map();
private mutationObserver: MutationObserver | null = null;
private scanInterval: ReturnType<typeof setInterval> | null = null;
private devTools: G7DevToolsCore;
private constructor() {
this.devTools = G7DevToolsCore.getInstance();
}
/**
* 싱글톤 인스턴스 반환
*/
static getInstance(): StyleTracker {
if (!StyleTracker.instance) {
StyleTracker.instance = new StyleTracker();
}
return StyleTracker.instance;
}
/**
* 스타일 추적 활성화
*/
enable(): void {
if (this.enabled) return;
this.enabled = true;
// DOM 변경 감지
this.setupMutationObserver();
// 주기적 스캔 (5초마다)
this.scanInterval = setInterval(() => {
this.scanVisibleComponents();
}, 5000);
console.log('[StyleTracker] 스타일 추적 활성화됨');
}
/**
* 스타일 추적 비활성화
*/
disable(): void {
if (!this.enabled) return;
this.enabled = false;
if (this.mutationObserver) {
this.mutationObserver.disconnect();
this.mutationObserver = null;
}
if (this.scanInterval) {
clearInterval(this.scanInterval);
this.scanInterval = null;
}
this.trackedComponents.clear();
console.log('[StyleTracker] 스타일 추적 비활성화됨');
}
/**
* 컴포넌트 스타일 추적 시작
*
* DynamicRenderer에서 컴포넌트 마운트 시 호출됩니다.
*/
trackComponent(
componentId: string,
componentName: string,
element: HTMLElement | null
): void {
if (!this.enabled || !this.devTools.isEnabled() || !element) return;
const classes = this.extractClasses(element);
const computedStyles = this.getComputedStyles(element);
// DevTools에 추적
this.devTools.trackComponentStyle(componentId, componentName, classes, computedStyles);
// 내부 맵에 저장 (WeakRef로 메모리 누수 방지)
this.trackedComponents.set(componentId, {
componentId,
componentName,
classes,
computedStyles,
element: new WeakRef(element),
timestamp: Date.now(),
});
// 즉시 이상 감지
this.detectIssues(componentId, componentName, element, classes, computedStyles);
}
/**
* 컴포넌트 추적 해제
*
* 컴포넌트 언마운트 시 호출됩니다.
*/
untrackComponent(componentId: string): void {
this.trackedComponents.delete(componentId);
}
/**
* 모든 보이는 컴포넌트 스캔
*/
scanVisibleComponents(): void {
if (!this.enabled || !this.devTools.isEnabled()) return;
// 1. DevTools에 마운트된 컴포넌트 목록 가져오기
const lifecycleInfo = this.devTools.getLifecycleInfo();
const mountedComponents = lifecycleInfo.mountedComponents || [];
// 2. 마운트된 컴포넌트 ID로 DOM 요소 찾기
mountedComponents.forEach((comp) => {
const componentId = comp.id;
const componentName = comp.name;
// ID로 DOM 요소 찾기 (여러 방식 시도)
let element: HTMLElement | null = null;
// 1) 정확한 ID로 찾기
if (componentId) {
element = document.getElementById(componentId);
}
// 2) data-component-id 속성으로 찾기
if (!element && componentId) {
element = document.querySelector(`[data-component-id="${componentId}"]`) as HTMLElement;
}
// 3) data-editor-id 속성으로 찾기
if (!element && componentId) {
element = document.querySelector(`[data-editor-id="${componentId}"]`) as HTMLElement;
}
if (!element) return;
const classes = this.extractClasses(element);
const computedStyles = this.getComputedStyles(element);
// DevTools에 추적
this.devTools.trackComponentStyle(componentId, componentName, classes, computedStyles);
// 이슈 감지
this.detectIssues(componentId, componentName, element, classes, computedStyles);
// 맵 업데이트
this.trackedComponents.set(componentId, {
componentId,
componentName,
classes,
computedStyles,
element: new WeakRef(element),
timestamp: Date.now(),
});
});
// 3. 기존 셀렉터로도 스캔 (하위 호환성)
const additionalElements = document.querySelectorAll('[data-editor-id], [id^="comp-"]');
additionalElements.forEach((element) => {
if (!(element instanceof HTMLElement)) return;
const componentId = element.getAttribute('data-editor-id') || element.id;
const componentName = element.getAttribute('data-editor-name') || element.tagName;
if (!componentId || this.trackedComponents.has(componentId)) return;
const classes = this.extractClasses(element);
const computedStyles = this.getComputedStyles(element);
this.devTools.trackComponentStyle(componentId, componentName, classes, computedStyles);
this.detectIssues(componentId, componentName, element, classes, computedStyles);
this.trackedComponents.set(componentId, {
componentId,
componentName,
classes,
computedStyles,
element: new WeakRef(element),
timestamp: Date.now(),
});
});
}
/**
* 특정 요소의 시각적 이상 감지
*/
private detectIssues(
componentId: string,
componentName: string,
element: HTMLElement,
classes: string[],
styles: Record<string, string>
): void {
// 1. 보이지 않는 요소 감지
if (styles.display === 'none') {
this.addIssue({
type: 'invisible-element',
componentId,
componentName,
property: 'display',
currentValue: 'none',
severity: 'info',
description: '요소가 display:none으로 숨겨져 있습니다',
suggestion: '의도적인 숨김이 아니라면 조건부 렌더링(if)을 확인하세요',
});
}
if (styles.visibility === 'hidden') {
this.addIssue({
type: 'invisible-element',
componentId,
componentName,
property: 'visibility',
currentValue: 'hidden',
severity: 'info',
description: '요소가 visibility:hidden으로 숨겨져 있습니다',
suggestion: '공간은 유지되지만 보이지 않습니다. 의도적인지 확인하세요',
});
}
if (styles.opacity === '0') {
this.addIssue({
type: 'invisible-element',
componentId,
componentName,
property: 'opacity',
currentValue: '0',
severity: 'warning',
description: '요소가 opacity:0으로 투명합니다',
suggestion: '애니메이션 상태가 아니라면 스타일을 확인하세요',
});
}
// 2. 크기가 0인 요소
if (styles.width === '0px' || styles.height === '0px') {
this.addIssue({
type: 'invisible-element',
componentId,
componentName,
property: styles.width === '0px' ? 'width' : 'height',
currentValue: '0px',
severity: 'warning',
description: '요소의 크기가 0입니다',
suggestion: '컨텐츠가 있는데 크기가 0이라면 레이아웃 문제일 수 있습니다',
});
}
// 3. overflow:hidden으로 잘린 컨텐츠
if (styles.overflow === 'hidden') {
const hasOverflow = element.scrollWidth > element.clientWidth ||
element.scrollHeight > element.clientHeight;
if (hasOverflow) {
this.addIssue({
type: 'overflow-hidden',
componentId,
componentName,
property: 'overflow',
currentValue: 'hidden',
severity: 'warning',
description: '컨텐츠가 overflow:hidden으로 잘리고 있습니다',
suggestion: 'overflow-auto 또는 컨테이너 크기를 확인하세요',
});
}
}
// 4. 다크 모드 클래스 누락 감지
const hasDarkVariant = classes.some(c => c.startsWith('dark:'));
const hasLightOnlyBg = classes.some(c =>
(c.startsWith('bg-white') || c.startsWith('bg-gray-')) && !c.startsWith('dark:')
);
if (hasLightOnlyBg && !hasDarkVariant) {
this.addIssue({
type: 'dark-mode-missing',
componentId,
componentName,
property: 'className',
currentValue: classes.filter(c => c.startsWith('bg-')).join(' '),
severity: 'info',
description: '다크 모드 변형 클래스가 누락되었을 수 있습니다',
suggestion: 'bg-white 사용 시 dark:bg-gray-800 등을 함께 지정하세요',
});
}
// 5. z-index 충돌 가능성
const zIndex = parseInt(styles.zIndex, 10);
if (!isNaN(zIndex) && zIndex > 50 && styles.position !== 'static') {
this.addIssue({
type: 'z-index-conflict',
componentId,
componentName,
property: 'z-index',
currentValue: styles.zIndex,
severity: 'info',
description: `높은 z-index(${zIndex})가 설정되어 있습니다`,
suggestion: 'z-index 충돌을 피하려면 10, 20, 30, 40, 50 단위를 사용하세요',
});
}
// 6. 반응형 클래스 누락 (모바일 전용 또는 데스크톱 전용)
const hasResponsive = classes.some(c =>
c.startsWith('sm:') || c.startsWith('md:') || c.startsWith('lg:') || c.startsWith('xl:')
);
const hasHiddenClass = classes.includes('hidden');
const hasMobileOnly = classes.includes('portable:block') || classes.includes('portable:flex');
const hasDesktopOnly = classes.includes('desktop:block') || classes.includes('desktop:flex');
if (hasHiddenClass && !hasResponsive && !hasMobileOnly && !hasDesktopOnly) {
// hidden 클래스만 있고 반응형 표시가 없으면
this.addIssue({
type: 'responsive-issue',
componentId,
componentName,
property: 'className',
currentValue: 'hidden',
severity: 'info',
description: '반응형 표시 클래스 없이 hidden이 설정되어 있습니다',
suggestion: '조건에 따라 표시하려면 sm:block, desktop:flex 등을 추가하세요',
});
}
}
/**
* 이슈 추가
*/
private addIssue(issue: Omit<StyleIssue, 'id'>): void {
this.devTools.addStyleIssue(issue);
}
/**
* DOM 요소에서 CSS 클래스 추출
*/
private extractClasses(element: HTMLElement): string[] {
const className = element.getAttribute('class') || '';
return className.split(/\s+/).filter(Boolean);
}
/**
* computed styles 가져오기
*/
private getComputedStyles(element: HTMLElement): Record<string, string> {
const computed = window.getComputedStyle(element);
const result: Record<string, string> = {};
for (const prop of TRACKED_STYLE_PROPERTIES) {
result[prop] = computed.getPropertyValue(
prop.replace(/([A-Z])/g, '-$1').toLowerCase()
);
}
return result;
}
/**
* MutationObserver 설정
*/
private setupMutationObserver(): void {
if (typeof MutationObserver === 'undefined') return;
this.mutationObserver = new MutationObserver((mutations) => {
let shouldScan = false;
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
shouldScan = true;
break;
}
if (mutation.type === 'attributes' &&
(mutation.attributeName === 'class' || mutation.attributeName === 'style')) {
shouldScan = true;
break;
}
}
if (shouldScan) {
// 디바운스
setTimeout(() => this.scanVisibleComponents(), 100);
}
});
this.mutationObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'style'],
});
}
/**
* 수동 전체 스캔
*/
fullScan(): StyleIssue[] {
this.scanVisibleComponents();
return this.devTools.getStyleValidationInfo().issues;
}
/**
* 이슈 목록 조회
*/
getIssues(): StyleIssue[] {
return this.devTools.getStyleValidationInfo().issues;
}
/**
* 특정 컴포넌트 이슈 조회
*/
getIssuesForComponent(componentId: string): StyleIssue[] {
return this.getIssues().filter(i => i.componentId === componentId);
}
/**
* 데이터 초기화
*/
clear(): void {
this.trackedComponents.clear();
this.devTools.clearStyleValidationData();
}
}
/**
* StyleTracker 싱글톤 인스턴스 반환
*/
export function getStyleTracker(): StyleTracker {
return StyleTracker.getInstance();
}