chart-0714
Version:
Professional trading chart library with advanced customization for trading journal apps
1 lines • 801 kB
Source Map (JSON)
{"version":3,"file":"index.cjs","sources":["../src/core/DataManager.ts","../src/core/drawing/DrawingSnapManager.ts","../src/core/drawing/DrawingMeasureManager.ts","../src/core/drawing/DrawingObjectManager.ts","../src/core/drawing/DrawingStateManager.ts","../src/core/drawing/DrawingToolHandler.ts","../src/renderers/unified/TextRenderer.ts","../src/components/TextInputModal.ts","../src/core/DrawingManager.ts","../src/core/MarkerManager.ts","../src/core/StopLossManager.ts","../src/core/LineSeriesManager.ts","../src/core/InteractionManager.ts","../src/utils/webgl-utils.ts","../src/core/BufferPool.ts","../src/core/WebGLRenderer.ts","../src/core/ChartPanel.ts","../src/core/UnifiedCoordinateSystem.ts","../src/utils/cursor.ts","../src/panels/chart/ChartEventController.ts","../src/rendering/UnifiedChartRenderer.ts","../src/renderers/GridRenderer.ts","../src/utils/formatters.ts","../src/renderers/LabelRenderer.ts","../src/renderers/CrosshairLabelRenderer.ts","../src/renderers/LineSeriesRenderer.ts","../src/renderers/unified/WebGLLineHelper.ts","../src/renderers/unified/CrosshairRenderer.ts","../src/renderers/unified/DrawingRenderer.ts","../src/renderers/unified/SnapPointRenderer.ts","../src/renderers/unified/MeasureRenderer.ts","../src/renderers/unified/MarkerRenderer.ts","../src/renderers/UnifiedRenderer.ts","../src/panels/chart/ChartRendererManager.ts","../src/panels/chart/ChartUIManager.ts","../src/panels/MainChartPanel.ts","../src/utils/volumeMA.ts","../src/panels/volume/VolumeRendererManager.ts","../src/panels/VolumePanel.ts","../src/ui/AxisRenderer.ts","../src/renderers/XAxisRenderer.ts","../src/core/PanelManager.ts","../src/core/RenderingManager.ts","../src/plugins/IndicatorPlugin.ts","../src/indicators/SMA.ts","../src/plugins/indicators/SMAPlugin.ts","../src/indicators/EMA.ts","../src/plugins/indicators/EMAPlugin.ts","../src/plugins/indicators/index.ts","../src/core/IndicatorManager.ts","../src/core/PluginManager.ts","../src/utils/theme.ts","../src/core/chart/ChartEventHandler.ts","../src/core/chart/ChartLifecycle.ts","../src/core/chart/ChartLineSeriesHelper.ts","../src/core/chart/ChartStopLossHelper.ts","../src/core/chart/ChartStyleHelper.ts","../src/core/chart/ChartViewportHelper.ts","../src/utils/candleResampler.ts","../src/core/chart/ChartDataHelper.ts","../src/core/chart/ChartDrawingHelper.ts","../src/core/chart/ChartMarkerHelper.ts","../src/core/chart/ChartExportHelper.ts","../src/core/chart/ChartThemeHelper.ts","../src/core/chart/ChartUIHelper.ts","../src/utils/timeframeDetector.ts","../src/core/chart/ChartIndicatorHelper.ts","../src/core/Chart.ts","../node_modules/react/cjs/react-jsx-runtime.production.min.js","../node_modules/react/cjs/react-jsx-runtime.development.js","../node_modules/react/jsx-runtime.js","../src/react-integration/useChart.ts","../src/react-integration/Chart.tsx","../src/index.ts"],"sourcesContent":["import type { Candle, ChartData } from '@/types';\n\nexport class DataManager {\n private data: ChartData | null = null;\n \n constructor() {}\n \n /**\n * 캔들 배열을 TypedArray 기반 ChartData로 변환\n */\n setData(candles: Candle[]): void {\n if (!candles || candles.length === 0) {\n this.data = null;\n return;\n }\n \n const length = candles.length;\n \n // TypedArray 할당\n const times = new Float64Array(length);\n const opens = new Float32Array(length);\n const highs = new Float32Array(length);\n const lows = new Float32Array(length);\n const closes = new Float32Array(length);\n const volumes = new Float32Array(length);\n \n let minPrice = Infinity;\n let maxPrice = -Infinity;\n let minTime = Infinity;\n let maxTime = -Infinity;\n \n // 데이터 복사 및 범위 계산\n for (let i = 0; i < length; i++) {\n const candle = candles[i];\n \n times[i] = candle.time;\n opens[i] = candle.open;\n highs[i] = candle.high;\n lows[i] = candle.low;\n closes[i] = candle.close;\n volumes[i] = candle.volume;\n \n minPrice = Math.min(minPrice, candle.low);\n maxPrice = Math.max(maxPrice, candle.high);\n minTime = Math.min(minTime, candle.time);\n maxTime = Math.max(maxTime, candle.time);\n }\n \n this.data = {\n times,\n opens,\n highs,\n lows,\n closes,\n volumes,\n length,\n timeRange: [minTime, maxTime],\n priceRange: [minPrice, maxPrice]\n };\n }\n \n /**\n * 시간 범위에 해당하는 데이터 인덱스 찾기 (이진 탐색)\n */\n findVisibleRange(startTime: number, endTime: number): { start: number; end: number } | null {\n if (!this.data) return null;\n \n const { times, length } = this.data;\n \n // 시작 인덱스 찾기\n let start = 0;\n let end = length - 1;\n let startIndex = 0;\n \n while (start <= end) {\n const mid = Math.floor((start + end) / 2);\n if (times[mid] < startTime) {\n start = mid + 1;\n } else {\n startIndex = mid;\n end = mid - 1;\n }\n }\n \n // 끝 인덱스 찾기\n start = startIndex;\n end = length - 1;\n let endIndex = length - 1;\n \n while (start <= end) {\n const mid = Math.floor((start + end) / 2);\n if (times[mid] <= endTime) {\n endIndex = mid;\n start = mid + 1;\n } else {\n end = mid - 1;\n }\n }\n \n // 여유분 추가 (화면 밖 1-2개 캔들)\n startIndex = Math.max(0, startIndex - 2);\n endIndex = Math.min(length - 1, endIndex + 2);\n \n return { start: startIndex, end: endIndex };\n }\n \n /**\n * 특정 인덱스의 캔들 데이터 가져오기\n */\n getCandle(index: number): Candle | null {\n if (!this.data || index < 0 || index >= this.data.length) {\n return null;\n }\n \n return {\n time: this.data.times[index],\n open: this.data.opens[index],\n high: this.data.highs[index],\n low: this.data.lows[index],\n close: this.data.closes[index],\n volume: this.data.volumes[index]\n };\n }\n \n /**\n * 가격 범위 계산 (보이는 영역)\n */\n getPriceRange(startIndex: number, endIndex: number): [number, number] | null {\n if (!this.data) return null;\n \n let minPrice = Infinity;\n let maxPrice = -Infinity;\n \n for (let i = startIndex; i <= endIndex && i < this.data.length; i++) {\n minPrice = Math.min(minPrice, this.data.lows[i]);\n maxPrice = Math.max(maxPrice, this.data.highs[i]);\n }\n \n // 여유분 추가 (위아래 10%)\n const padding = (maxPrice - minPrice) * 0.1;\n return [minPrice - padding, maxPrice + padding];\n }\n \n /**\n * 시간에 해당하는 정확한 캔들 인덱스 찾기\n */\n getIndexForTime(time: number): number {\n if (!this.data || this.data.length === 0) return -1;\n \n const { times, length } = this.data;\n \n // 이진 탐색으로 정확한 인덱스 찾기\n let left = 0;\n let right = length - 1;\n \n while (left <= right) {\n const mid = Math.floor((left + right) / 2);\n \n if (times[mid] === time) {\n return mid;\n } else if (times[mid] < time) {\n left = mid + 1;\n } else {\n right = mid - 1;\n }\n }\n \n // 정확히 일치하는 시간이 없으면 가장 가까운 인덱스 반환\n return this.findNearestIndex(time);\n }\n \n /**\n * 시간에 가장 가까운 캔들 인덱스 찾기\n */\n findNearestIndex(time: number): number {\n if (!this.data || this.data.length === 0) return -1;\n \n const { times, length } = this.data;\n \n let left = 0;\n let right = length - 1;\n let nearest = 0;\n let minDiff = Math.abs(times[0] - time);\n \n while (left <= right) {\n const mid = Math.floor((left + right) / 2);\n const diff = Math.abs(times[mid] - time);\n \n if (diff < minDiff) {\n minDiff = diff;\n nearest = mid;\n }\n \n if (times[mid] < time) {\n left = mid + 1;\n } else if (times[mid] > time) {\n right = mid - 1;\n } else {\n return mid;\n }\n }\n \n return nearest;\n }\n \n getData(): ChartData | null {\n return this.data;\n }\n \n isEmpty(): boolean {\n return !this.data || this.data.length === 0;\n }\n \n getLength(): number {\n return this.data?.length ?? 0;\n }\n \n /**\n * 평균 캔들 간격 계산 (초 단위)\n */\n getCandleInterval(): number {\n if (!this.data || this.data.length < 2) return 60; // 기본값 1분\n \n // 처음 10개 캔들의 평균 간격 계산\n const sampleSize = Math.min(10, this.data.length - 1);\n let totalInterval = 0;\n \n for (let i = 1; i <= sampleSize; i++) {\n totalInterval += this.data.times[i] - this.data.times[i - 1];\n }\n \n return totalInterval / sampleSize;\n }\n}","import type { Candle } from '@/types';\nimport type { UnifiedCoordinateSystem } from '@/core/UnifiedCoordinateSystem';\n\nexport interface SnapPoint {\n x: number;\n y: number;\n type: 'high' | 'low' | 'open' | 'close';\n price: number;\n time: number;\n index: number;\n}\n\nexport class DrawingSnapManager {\n private isMagneticMode: boolean = true; // 기본값 true로 변경\n private currentSnapPoint: SnapPoint | null = null;\n private onSnapChange?: (snap: SnapPoint | null) => void;\n\n setMagneticMode(enabled: boolean): void {\n this.isMagneticMode = enabled;\n if (!enabled) {\n this.updateSnapPoint(null);\n }\n }\n\n getMagneticMode(): boolean {\n return this.isMagneticMode;\n }\n\n setSnapChangeCallback(callback: (snap: SnapPoint | null) => void): void {\n this.onSnapChange = callback;\n }\n\n private updateSnapPoint(point: SnapPoint | null): void {\n this.currentSnapPoint = point;\n if (this.onSnapChange) {\n this.onSnapChange(point);\n }\n }\n\n findSnapPoint(\n mouseX: number,\n mouseY: number,\n candles: Candle[],\n coordSystem: UnifiedCoordinateSystem,\n threshold: number = 30\n ): SnapPoint | null {\n if (!this.isMagneticMode || !candles || candles.length === 0) return null;\n \n // 마우스 X 좌표를 직접 캔들 인덱스로 변환\n const canvasCoords = coordSystem.cssToCanvas(mouseX, mouseY);\n const centerIndex = Math.round(coordSystem.canvasXToIndex(canvasCoords.x));\n \n \n // 좌우 2개씩 총 5개 캔들 검사\n const searchRange = 2;\n let closestPoint: SnapPoint | null = null;\n let minDistance = Infinity;\n \n for (let i = centerIndex - searchRange; i <= centerIndex + searchRange; i++) {\n if (i < 0 || i >= candles.length) continue;\n \n const candle = candles[i];\n if (!candle) continue;\n \n // OHLC 값들\n const points = [\n { price: candle.high, type: 'high' as const },\n { price: candle.low, type: 'low' as const },\n { price: candle.open, type: 'open' as const },\n { price: candle.close, type: 'close' as const }\n ];\n \n for (const point of points) {\n // 차트 좌표를 CSS 좌표로 변환 - main 패널 지정\n const cssCoords = coordSystem.chartToCSS(i, point.price, 'main');\n \n // 마우스와의 거리 계산\n const dx = cssCoords.x - mouseX;\n const dy = cssCoords.y - mouseY;\n const distance = Math.sqrt(dx * dx + dy * dy);\n \n if (distance < minDistance) {\n minDistance = distance;\n closestPoint = {\n x: cssCoords.x,\n y: cssCoords.y,\n type: point.type,\n price: point.price,\n time: candle.time,\n index: i\n };\n }\n }\n }\n \n // 임계값 내에 있는 경우만 스냅\n if (closestPoint && minDistance < threshold) {\n this.updateSnapPoint(closestPoint);\n return closestPoint;\n }\n \n this.updateSnapPoint(null);\n return null;\n }\n\n getCurrentSnapPoint(): SnapPoint | null {\n return this.currentSnapPoint;\n }\n}","import type { ChartPoint } from '@/types';\n\nexport class DrawingMeasureManager {\n private measureMode: boolean = false;\n private measureStartPoint: ChartPoint | null = null;\n private measureEndPoint: ChartPoint | null = null;\n private measureFixed: boolean = false;\n private onMeasureChange?: (start: ChartPoint | null, end: ChartPoint | null, fixed: boolean) => void;\n\n setMeasureChangeCallback(callback: (start: ChartPoint | null, end: ChartPoint | null, fixed: boolean) => void): void {\n this.onMeasureChange = callback;\n }\n\n startMeasure(point: ChartPoint): void {\n this.measureMode = true;\n this.measureStartPoint = point;\n this.measureEndPoint = null;\n this.measureFixed = false;\n \n if (this.onMeasureChange) {\n this.onMeasureChange(this.measureStartPoint, null, false);\n }\n }\n\n updateMeasure(endPoint: ChartPoint): void {\n if (!this.measureMode || !this.measureStartPoint || this.measureFixed) return;\n \n this.measureEndPoint = endPoint;\n if (this.onMeasureChange) {\n this.onMeasureChange(this.measureStartPoint, this.measureEndPoint, false);\n }\n }\n\n fixMeasure(endPoint: ChartPoint): void {\n if (!this.measureMode || !this.measureStartPoint) return;\n \n this.measureEndPoint = endPoint;\n this.measureFixed = true;\n \n if (this.onMeasureChange) {\n this.onMeasureChange(this.measureStartPoint, this.measureEndPoint, true);\n }\n }\n\n cancelMeasure(): void {\n this.measureMode = false;\n this.measureStartPoint = null;\n this.measureEndPoint = null;\n this.measureFixed = false;\n \n if (this.onMeasureChange) {\n this.onMeasureChange(null, null, false);\n }\n }\n\n isMeasureMode(): boolean {\n return this.measureMode;\n }\n\n getMeasurePoints(): { start: ChartPoint | null; end: ChartPoint | null; fixed: boolean } {\n return {\n start: this.measureStartPoint,\n end: this.measureEndPoint,\n fixed: this.measureFixed\n };\n }\n\n isMeasureFixed(): boolean {\n return this.measureFixed;\n }\n}","import type { DrawingObject, ChartPoint, DrawingToolType } from '@/types';\nimport type { UnifiedCoordinateSystem } from '@/core/UnifiedCoordinateSystem';\n\nexport class DrawingObjectManager {\n private drawings: DrawingObject[] = [];\n private selectedDrawing: DrawingObject | null = null;\n private nextId: number = 1;\n\n addDrawing(drawing: DrawingObject): void {\n this.drawings.push(drawing);\n }\n\n removeDrawing(id: string): void {\n this.drawings = this.drawings.filter(d => d.id !== id);\n if (this.selectedDrawing?.id === id) {\n this.selectedDrawing = null;\n }\n }\n\n getAllDrawings(): DrawingObject[] {\n return [...this.drawings];\n }\n\n getSelectedDrawing(): DrawingObject | null {\n return this.selectedDrawing;\n }\n\n selectDrawing(drawing: DrawingObject | null): void {\n // 이전 선택 해제\n if (this.selectedDrawing) {\n this.selectedDrawing.selected = false;\n }\n \n this.selectedDrawing = drawing;\n \n // 새로운 선택\n if (drawing) {\n drawing.selected = true;\n }\n }\n\n selectDrawingAt(x: number, y: number, coordSystem: UnifiedCoordinateSystem, tolerance: number = 10): DrawingObject | null {\n \n // 클릭 위치를 차트 좌표로 변환 (CSS 좌표 기준) - main 패널 명시\n const chartCoords = coordSystem.cssToChart(x, y, 'main');\n const clickTime = chartCoords.time;\n const clickPrice = chartCoords.price;\n \n // 역순으로 검사 (최근에 그린 것부터)\n for (let i = this.drawings.length - 1; i >= 0; i--) {\n const drawing = this.drawings[i];\n \n if (this.isPointOnDrawing(drawing, clickTime, clickPrice, coordSystem, tolerance)) {\n this.selectDrawing(drawing);\n return drawing;\n }\n }\n \n this.selectDrawing(null);\n return null;\n }\n \n checkDrawingAt(x: number, y: number, coordSystem: UnifiedCoordinateSystem, tolerance: number = 10): DrawingObject | null {\n // 클릭 위치를 차트 좌표로 변환 (CSS 좌표 기준) - main 패널 명시\n const chartCoords = coordSystem.cssToChart(x, y, 'main');\n const clickTime = chartCoords.time;\n const clickPrice = chartCoords.price;\n \n // 역순으로 검사 (최근에 그린 것부터)\n for (let i = this.drawings.length - 1; i >= 0; i--) {\n const drawing = this.drawings[i];\n \n if (this.isPointOnDrawing(drawing, clickTime, clickPrice, coordSystem, tolerance)) {\n return drawing;\n }\n }\n \n return null;\n }\n\n private isPointOnDrawing(drawing: DrawingObject, time: number, price: number, coordSystem: UnifiedCoordinateSystem, tolerance: number): boolean {\n switch (drawing.type) {\n case 'trendline':\n case 'horizontal':\n return this.isPointOnLine(drawing, time, price, coordSystem, tolerance);\n \n case 'text':\n case 'marker':\n return this.isPointOnMarker(drawing, time, price, coordSystem, tolerance);\n \n case 'circle':\n return this.isPointOnCircle(drawing, time, price, coordSystem, tolerance);\n \n case 'rectangle':\n return this.isPointOnRectangle(drawing, time, price, coordSystem, tolerance);\n \n default:\n return false;\n }\n }\n\n private isPointOnLine(drawing: DrawingObject, time: number, price: number, coordSystem: UnifiedCoordinateSystem, tolerance: number): boolean {\n if (drawing.points.length < 2) return false;\n \n const p1 = drawing.points[0];\n const p2 = drawing.points[1];\n \n \n // 수평선의 경우\n if (drawing.type === 'horizontal') {\n const lineCSS = coordSystem.chartToCSS(p1.time, p1.price, 'main');\n const clickCSS = coordSystem.chartToCSS(time, price, 'main');\n return Math.abs(lineCSS.y - clickCSS.y) <= tolerance;\n }\n \n // 트렌드라인의 경우 - 점과 선분 사이의 거리 계산\n const p1CSS = coordSystem.chartToCSS(p1.time, p1.price, 'main');\n const p2CSS = coordSystem.chartToCSS(p2.time, p2.price, 'main');\n const clickCSS = coordSystem.chartToCSS(time, price, 'main');\n \n const x1 = p1CSS.x;\n const y1 = p1CSS.y;\n const x2 = p2CSS.x;\n const y2 = p2CSS.y;\n const px = clickCSS.x;\n const py = clickCSS.y;\n \n // 점과 선분 사이의 거리\n const distance = this.pointToLineDistance(px, py, x1, y1, x2, y2);\n \n return distance <= tolerance;\n }\n\n private isPointOnMarker(drawing: DrawingObject, time: number, price: number, coordSystem: UnifiedCoordinateSystem, tolerance: number): boolean {\n if (drawing.points.length === 0) return false;\n \n const markerPoint = drawing.points[0];\n const markerCSS = coordSystem.chartToCSS(markerPoint.time, markerPoint.price, 'main');\n const clickCSS = coordSystem.chartToCSS(time, price, 'main');\n const markerX = markerCSS.x;\n const markerY = markerCSS.y;\n const clickX = clickCSS.x;\n const clickY = clickCSS.y;\n \n const distance = Math.sqrt(Math.pow(markerX - clickX, 2) + Math.pow(markerY - clickY, 2));\n \n // 마커나 텍스트는 좀 더 넓은 영역 허용\n return distance <= tolerance * 2;\n }\n\n private isPointOnCircle(drawing: DrawingObject, time: number, price: number, coordSystem: UnifiedCoordinateSystem, tolerance: number): boolean {\n if (drawing.points.length === 0 || drawing.radius === undefined) return false;\n \n const center = drawing.points[0];\n const centerCSS = coordSystem.chartToCSS(center.time, center.price, 'main');\n const clickCSS = coordSystem.chartToCSS(time, price, 'main');\n const centerX = centerCSS.x;\n const centerY = centerCSS.y;\n const clickX = clickCSS.x;\n const clickY = clickCSS.y;\n \n const distance = Math.sqrt(Math.pow(centerX - clickX, 2) + Math.pow(centerY - clickY, 2));\n \n \n // 원의 둘레 근처이거나 내부인지 확인\n // 1. 원의 둘레에서 tolerance 범위 내\n // 2. 원의 내부 (distance가 radius보다 작음)\n return Math.abs(distance - drawing.radius) <= tolerance || distance <= drawing.radius;\n }\n\n private isPointOnRectangle(drawing: DrawingObject, time: number, price: number, coordSystem: UnifiedCoordinateSystem, tolerance: number): boolean {\n if (drawing.points.length < 2) return false;\n \n const p1 = drawing.points[0];\n const p2 = drawing.points[1];\n \n const p1CSS = coordSystem.chartToCSS(p1.time, p1.price, 'main');\n const p2CSS = coordSystem.chartToCSS(p2.time, p2.price, 'main');\n const x1 = p1CSS.x;\n const y1 = p1CSS.y;\n const x2 = p2CSS.x;\n const y2 = p2CSS.y;\n const clickCSS = coordSystem.chartToCSS(time, price, 'main');\n const px = clickCSS.x;\n const py = clickCSS.y;\n \n const minX = Math.min(x1, x2);\n const maxX = Math.max(x1, x2);\n const minY = Math.min(y1, y2);\n const maxY = Math.max(y1, y2);\n \n // 사각형 테두리 근처인지 확인\n const nearLeft = Math.abs(px - minX) <= tolerance && py >= minY - tolerance && py <= maxY + tolerance;\n const nearRight = Math.abs(px - maxX) <= tolerance && py >= minY - tolerance && py <= maxY + tolerance;\n const nearTop = Math.abs(py - minY) <= tolerance && px >= minX - tolerance && px <= maxX + tolerance;\n const nearBottom = Math.abs(py - maxY) <= tolerance && px >= minX - tolerance && px <= maxX + tolerance;\n \n return nearLeft || nearRight || nearTop || nearBottom;\n }\n\n private pointToLineDistance(px: number, py: number, x1: number, y1: number, x2: number, y2: number): number {\n const A = px - x1;\n const B = py - y1;\n const C = x2 - x1;\n const D = y2 - y1;\n \n const dot = A * C + B * D;\n const lenSq = C * C + D * D;\n \n let param = -1;\n if (lenSq !== 0) {\n param = dot / lenSq;\n }\n \n let xx, yy;\n \n if (param < 0) {\n xx = x1;\n yy = y1;\n } else if (param > 1) {\n xx = x2;\n yy = y2;\n } else {\n xx = x1 + param * C;\n yy = y1 + param * D;\n }\n \n const dx = px - xx;\n const dy = py - yy;\n \n return Math.sqrt(dx * dx + dy * dy);\n }\n\n deleteSelectedDrawing(): boolean {\n if (this.selectedDrawing) {\n this.removeDrawing(this.selectedDrawing.id);\n return true;\n }\n return false;\n }\n\n createDrawing(type: DrawingToolType, points: ChartPoint[], style?: { color?: string; lineWidth?: number; opacity?: number }, extras?: { radius?: number; width?: number; height?: number }): DrawingObject {\n // 현재 테마에 따라 기본 색상 결정\n // 배경색을 확인해서 라이트/다크 테마 구분\n const bgColor = document.body.style.backgroundColor;\n const isLightTheme = bgColor === 'rgb(255, 255, 255)' || bgColor === '#ffffff';\n const defaultColor = isLightTheme ? '#000000' : '#FFFFFF';\n \n const drawing: DrawingObject = {\n id: `drawing_${this.nextId++}`,\n type,\n points: [...points],\n style: {\n color: style?.color || defaultColor,\n lineWidth: style?.lineWidth || 2,\n opacity: style?.opacity || 1\n },\n selected: false,\n locked: false,\n ...extras\n };\n \n return drawing;\n }\n\n updateDrawing(id: string, updates: Partial<DrawingObject>): void {\n const drawing = this.drawings.find(d => d.id === id);\n if (drawing) {\n Object.assign(drawing, updates);\n }\n }\n\n clear(): void {\n this.drawings = [];\n this.selectedDrawing = null;\n }\n\n getDrawingsCount(): number {\n return this.drawings.length;\n }\n}","import type { DrawingObject } from '@/types';\n\ninterface DrawingState {\n drawings: DrawingObject[];\n timestamp: number;\n}\n\nexport class DrawingStateManager {\n private history: DrawingState[] = [];\n private currentIndex: number = -1;\n private maxHistorySize: number = 50;\n\n saveState(drawings: DrawingObject[]): void {\n // 현재 인덱스 이후의 히스토리 제거 (새로운 분기 생성)\n this.history = this.history.slice(0, this.currentIndex + 1);\n \n // 새로운 상태 추가\n const newState: DrawingState = {\n drawings: this.deepCloneDrawings(drawings),\n timestamp: Date.now()\n };\n \n this.history.push(newState);\n this.currentIndex++;\n \n // 최대 히스토리 크기 유지\n if (this.history.length > this.maxHistorySize) {\n this.history.shift();\n this.currentIndex--;\n }\n }\n\n canUndo(): boolean {\n return this.currentIndex > 0;\n }\n\n canRedo(): boolean {\n return this.currentIndex < this.history.length - 1;\n }\n\n undo(): DrawingObject[] | null {\n if (!this.canUndo()) return null;\n \n this.currentIndex--;\n return this.deepCloneDrawings(this.history[this.currentIndex].drawings);\n }\n\n redo(): DrawingObject[] | null {\n if (!this.canRedo()) return null;\n \n this.currentIndex++;\n return this.deepCloneDrawings(this.history[this.currentIndex].drawings);\n }\n\n private deepCloneDrawings(drawings: DrawingObject[]): DrawingObject[] {\n return drawings.map(drawing => ({\n ...drawing,\n points: drawing.points.map(point => ({ ...point })),\n style: { ...drawing.style }\n }));\n }\n\n clear(): void {\n this.history = [];\n this.currentIndex = -1;\n }\n\n getHistoryInfo(): { current: number; total: number } {\n return {\n current: this.currentIndex + 1,\n total: this.history.length\n };\n }\n\n exportState(): string {\n const currentState = this.history[this.currentIndex];\n if (!currentState) return '[]';\n \n return JSON.stringify(currentState.drawings);\n }\n\n importState(jsonData: string): DrawingObject[] {\n try {\n const drawings = JSON.parse(jsonData) as DrawingObject[];\n // 유효성 검사\n if (!Array.isArray(drawings)) {\n throw new Error('Invalid drawing data: not an array');\n }\n \n return drawings;\n } catch (error) {\n console.error('Failed to import drawings:', error);\n throw error;\n }\n }\n}","import type { DrawingToolType, ChartPoint, DrawingObject, ICoordinateSystem } from '@/types';\n\nexport class DrawingToolHandler {\n private currentTool: DrawingToolType | null = null;\n private isDrawing: boolean = false;\n private firstPoint: ChartPoint | null = null;\n private tempDrawing: DrawingObject | null = null;\n private onTextInput?: (point: ChartPoint, callback: (text: string) => void) => void;\n\n setCurrentTool(tool: DrawingToolType | null): void {\n this.currentTool = tool;\n this.cancelDrawing();\n }\n\n getCurrentTool(): DrawingToolType | null {\n return this.currentTool;\n }\n\n isCurrentlyDrawing(): boolean {\n return this.isDrawing;\n }\n\n getFirstPoint(): ChartPoint | null {\n return this.firstPoint;\n }\n\n getTempDrawing(): DrawingObject | null {\n return this.tempDrawing;\n }\n\n setTextInputCallback(callback: (point: ChartPoint, callback: (text: string) => void) => void): void {\n this.onTextInput = callback;\n }\n\n startDrawing(firstPoint: ChartPoint, style: { color: string; lineWidth: number; opacity: number }): DrawingObject | null {\n if (!this.currentTool) return null;\n \n this.isDrawing = true;\n this.firstPoint = firstPoint;\n \n // 도구별 초기 드로잉 객체 생성\n switch (this.currentTool) {\n case 'trendline':\n this.tempDrawing = {\n id: 'temp',\n type: 'trendline',\n points: [firstPoint, firstPoint], // 두 번째 점은 임시\n style,\n selected: false,\n locked: false\n };\n break;\n \n case 'horizontal':\n this.tempDrawing = {\n id: 'temp',\n type: 'horizontal',\n points: [firstPoint],\n style,\n selected: false,\n locked: false\n };\n break;\n \n case 'circle':\n this.tempDrawing = {\n id: 'temp',\n type: 'circle',\n points: [firstPoint],\n radius: 0,\n style,\n selected: false,\n locked: false\n };\n break;\n \n case 'rectangle':\n this.tempDrawing = {\n id: 'temp',\n type: 'rectangle',\n points: [firstPoint, firstPoint],\n style,\n selected: false,\n locked: false\n };\n break;\n \n case 'text':\n case 'marker':\n // 텍스트와 마커는 즉시 생성\n this.isDrawing = false;\n this.firstPoint = null;\n return null;\n }\n \n return this.tempDrawing;\n }\n\n updateDrawing(currentPoint: ChartPoint, coordSystem?: ICoordinateSystem): void {\n if (!this.isDrawing || !this.tempDrawing || !this.firstPoint) return;\n \n switch (this.tempDrawing.type) {\n case 'trendline':\n this.tempDrawing.points[1] = currentPoint;\n break;\n \n case 'circle':\n // 원의 반지름 계산 (픽셀 단위)\n if (coordSystem && coordSystem.chartToCanvas) {\n const p1 = coordSystem.chartToCanvas(this.firstPoint.time, this.firstPoint.price);\n const p2 = coordSystem.chartToCanvas(currentPoint.time, currentPoint.price);\n const dx = p2.x - p1.x;\n const dy = p2.y - p1.y;\n this.tempDrawing.radius = Math.sqrt(dx * dx + dy * dy);\n } else {\n // Fallback for legacy code\n const dx = currentPoint.time - this.firstPoint.time;\n const dy = currentPoint.price - this.firstPoint.price;\n this.tempDrawing.radius = Math.sqrt(dx * dx + dy * dy) * 20;\n }\n break;\n \n case 'rectangle':\n this.tempDrawing.points[1] = currentPoint;\n break;\n }\n }\n\n finishDrawing(): DrawingObject | null {\n if (!this.isDrawing || !this.tempDrawing) return null;\n \n const finishedDrawing = { ...this.tempDrawing };\n \n this.isDrawing = false;\n this.firstPoint = null;\n this.tempDrawing = null;\n \n return finishedDrawing;\n }\n\n cancelDrawing(): void {\n this.isDrawing = false;\n this.firstPoint = null;\n this.tempDrawing = null;\n }\n\n shouldHandleTextInput(tool: DrawingToolType): boolean {\n return tool === 'text';\n }\n\n requestTextInput(point: ChartPoint, callback: (text: string) => void): void {\n if (this.onTextInput) {\n this.onTextInput(point, callback);\n }\n }\n\n createImmediateDrawing(type: DrawingToolType, point: ChartPoint, style: { color: string; lineWidth: number; opacity: number; markerType?: any; markerPosition?: any }): DrawingObject | null {\n switch (type) {\n case 'horizontal':\n return {\n id: `drawing_${Date.now()}`,\n type: 'horizontal',\n points: [point],\n style,\n selected: false,\n locked: false\n };\n \n case 'marker':\n return {\n id: `drawing_${Date.now()}`,\n type: 'marker',\n points: [point],\n style,\n selected: false,\n locked: false,\n markerType: style.markerType || 'arrowUp',\n markerPosition: style.markerPosition || 'above'\n };\n \n default:\n return null;\n }\n }\n}","import type { DrawingObject, ChartPoint } from '@/types';\nimport type { UnifiedCoordinateSystem } from '@/core/UnifiedCoordinateSystem';\n\nexport interface TextStyle {\n fontSize: number;\n fontFamily: string;\n color: string;\n bold: boolean;\n italic: boolean;\n underline: boolean;\n backgroundColor?: string;\n borderColor?: string;\n borderWidth?: number;\n padding?: number;\n}\n\nexport interface TextObject extends DrawingObject {\n type: 'text';\n text: string;\n textStyle?: TextStyle;\n}\n\nexport class TextRenderer {\n private container: HTMLElement;\n private textElements: Map<string, HTMLDivElement> = new Map();\n private coordSystem: UnifiedCoordinateSystem;\n private debugLogged: boolean = false;\n private onStyleEdit?: (id: string, currentStyle: TextStyle) => void;\n private isEditing: boolean = false;\n \n constructor(container: HTMLElement, coordSystem: UnifiedCoordinateSystem) {\n this.container = container;\n this.coordSystem = coordSystem;\n \n // 텍스트 컨테이너 생성\n this.createTextContainer();\n }\n \n private createTextContainer(): void {\n // 기존 컨테이너가 있으면 재사용\n let textContainer = this.container.querySelector('.chart-text-container') as HTMLDivElement;\n \n if (!textContainer) {\n textContainer = document.createElement('div');\n textContainer.className = 'chart-text-container';\n textContainer.style.cssText = `\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n pointer-events: none;\n overflow: visible;\n z-index: 100;\n `;\n this.container.appendChild(textContainer);\n \n // placeholder 스타일 추가\n const style = document.createElement('style');\n style.textContent = `\n .chart-text[contenteditable=\"true\"].empty:before {\n content: attr(data-placeholder);\n color: #999;\n font-style: italic;\n }\n .chart-text[contenteditable=\"true\"] {\n min-width: 100px !important;\n min-height: 20px !important;\n }\n `;\n document.head.appendChild(style);\n }\n }\n \n /**\n * 텍스트 객체 렌더링\n */\n renderText(textObject: TextObject): void {\n let element = this.textElements.get(textObject.id);\n \n if (!element) {\n if (!this.debugLogged) {\n console.log('[TextRenderer] Creating first text element:', textObject);\n this.debugLogged = true;\n }\n element = this.createTextElement(textObject);\n this.textElements.set(textObject.id, element);\n }\n \n // 위치 업데이트\n this.updateTextPosition(element, textObject);\n \n // 스타일 업데이트\n this.updateTextStyle(element, textObject);\n \n // 텍스트 내용 업데이트\n if (element.textContent !== textObject.text) {\n element.textContent = textObject.text;\n }\n }\n \n /**\n * 새 텍스트 생성 및 즉시 편집 모드\n */\n createAndEditText(point: ChartPoint, style: TextStyle, onComplete: (text: string) => void): void {\n console.log('[TextRenderer] createAndEditText called with:', { point, style });\n \n // 이미 편집 중이면 무시\n if (this.isEditing) {\n console.log('[TextRenderer] Already editing, ignoring');\n return;\n }\n \n this.isEditing = true;\n \n const tempId = `temp-${Date.now()}`;\n const tempObject: TextObject = {\n id: tempId,\n type: 'text',\n points: [point],\n text: '',\n textStyle: style,\n style: { color: style.color, lineWidth: 1, opacity: 1 },\n selected: false,\n locked: false\n };\n \n // 임시 요소 생성\n const element = this.createTextElement(tempObject);\n this.textElements.set(tempId, element);\n \n console.log('[TextRenderer] Created temp element:', element);\n \n // 위치 설정\n this.updateTextPosition(element, tempObject);\n this.updateTextStyle(element, tempObject);\n \n // 편집 모드용 스타일 재설정\n element.style.transform = 'none'; // translate 제거\n element.contentEditable = 'true';\n element.style.cursor = 'text';\n element.style.userSelect = 'text';\n element.style.minWidth = '100px';\n element.style.minHeight = '20px';\n element.style.outline = '2px solid #2196F3';\n element.style.padding = '4px';\n element.style.backgroundColor = 'rgba(255, 255, 255, 0.9)';\n element.style.color = '#000000';\n element.style.zIndex = '1000';\n element.style.pointerEvents = 'auto';\n \n // 빈 텍스트를 위한 placeholder\n element.setAttribute('data-placeholder', 'Type text...');\n if (!element.textContent) {\n element.classList.add('empty');\n }\n \n // 포커스 설정을 즉시 실행\n element.focus();\n console.log('[TextRenderer] Element focused immediately');\n \n const finishEdit = () => {\n const text = element.textContent || '';\n \n // 편집 상태 해제\n this.isEditing = false;\n \n // 임시 요소 제거\n element.remove();\n this.textElements.delete(tempId);\n \n // 텍스트가 있을 때만 콜백 호출\n if (text.trim()) {\n onComplete(text.trim());\n }\n };\n \n // blur 이벤트로 종료 처리\n let isFinishing = false;\n const handleBlur = () => {\n // 이미 종료 중이면 무시\n if (isFinishing) return;\n \n // 약간의 지연을 주어 클릭 이벤트와 충돌 방지\n setTimeout(() => {\n // 포커스가 다시 돌아왔으면 무시\n if (document.activeElement === element) return;\n \n finishEdit();\n }, 200);\n };\n \n const handleKeydown = (e: KeyboardEvent) => {\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n isFinishing = true;\n finishEdit();\n } else if (e.key === 'Escape') {\n e.preventDefault();\n element.textContent = '';\n isFinishing = true;\n finishEdit();\n }\n };\n \n const handleInput = () => {\n if (element.textContent) {\n element.classList.remove('empty');\n } else {\n element.classList.add('empty');\n }\n };\n \n // 마우스 이벤트는 정상적으로 처리되도록 함\n const handleMouseDown = (e: MouseEvent) => {\n // 텍스트 박스 내부 클릭은 포커스 유지\n e.stopPropagation();\n };\n \n element.addEventListener('blur', handleBlur);\n element.addEventListener('keydown', handleKeydown);\n element.addEventListener('input', handleInput);\n element.addEventListener('mousedown', handleMouseDown);\n }\n \n /**\n * 텍스트 요소 생성\n */\n private createTextElement(textObject: TextObject): HTMLDivElement {\n const element = document.createElement('div');\n element.className = 'chart-text';\n element.id = `text-${textObject.id}`;\n \n // 기본 스타일 설정\n element.style.cssText = `\n position: absolute;\n pointer-events: auto;\n cursor: move;\n user-select: none;\n white-space: nowrap;\n transform: translate(-50%, -50%);\n `;\n \n // 텍스트 설정\n element.textContent = textObject.text || '';\n \n // 선택 상태 처리\n if (textObject.selected) {\n element.classList.add('selected');\n element.style.outline = '2px solid #2196F3';\n element.style.outlineOffset = '2px';\n }\n \n // 더블클릭 이벤트 추가\n element.addEventListener('dblclick', (e) => {\n e.stopPropagation();\n this.handleDoubleClick(textObject.id);\n });\n \n // 컨테이너에 추가\n const textContainer = this.container.querySelector('.chart-text-container');\n if (textContainer) {\n textContainer.appendChild(element);\n }\n \n return element;\n }\n \n /**\n * 텍스트 위치 업데이트\n */\n private updateTextPosition(element: HTMLDivElement, textObject: TextObject): void {\n if (!textObject.points || textObject.points.length === 0) {\n console.warn('[TextRenderer] No points in text object:', textObject);\n return;\n }\n \n const point = textObject.points[0];\n const canvasPoint = this.coordSystem.chartToCanvas(point.time, point.price);\n \n // Canvas 픽셀을 CSS 픽셀로 변환\n const pixelRatio = (this.coordSystem as any).getPixelRatio ? (this.coordSystem as any).getPixelRatio() : 1;\n const cssPoint = {\n x: canvasPoint.x / pixelRatio,\n y: canvasPoint.y / pixelRatio\n };\n \n // 첫 번째 텍스트만 로그\n if (!this.debugLogged) {\n console.log('[TextRenderer] Position calculation:', {\n chartPoint: point,\n canvasPoint,\n cssPoint,\n pixelRatio,\n containerRect: this.container.getBoundingClientRect()\n });\n }\n \n // CSS 픽셀 단위로 위치 설정\n element.style.left = `${cssPoint.x}px`;\n element.style.top = `${cssPoint.y}px`;\n \n }\n \n /**\n * 텍스트 스타일 업데이트\n */\n private updateTextStyle(element: HTMLDivElement, textObject: TextObject): void {\n const defaultStyle: TextStyle = {\n fontSize: 14,\n fontFamily: 'Arial, sans-serif',\n color: '#000000',\n bold: false,\n italic: false,\n underline: false,\n padding: 4\n };\n \n const style = { ...defaultStyle, ...textObject.textStyle };\n \n // 폰트 스타일 적용\n element.style.fontSize = `${style.fontSize}px`;\n element.style.fontFamily = style.fontFamily;\n element.style.color = style.color;\n element.style.fontWeight = style.bold ? 'bold' : 'normal';\n element.style.fontStyle = style.italic ? 'italic' : 'normal';\n element.style.textDecoration = style.underline ? 'underline' : 'none';\n \n // 배경 및 테두리\n if (style.backgroundColor) {\n element.style.backgroundColor = style.backgroundColor;\n } else {\n element.style.backgroundColor = 'transparent';\n }\n \n if (style.borderColor && style.borderWidth) {\n element.style.border = `${style.borderWidth}px solid ${style.borderColor}`;\n } else {\n element.style.border = 'none';\n }\n \n // 패딩\n element.style.padding = `${style.padding || 4}px`;\n \n // 선택 상태\n if (textObject.selected) {\n element.style.outline = '2px solid #2196F3';\n element.style.outlineOffset = '2px';\n } else {\n element.style.outline = 'none';\n }\n }\n \n /**\n * 모든 텍스트 재렌더링 (줌/패닝 시)\n */\n updateAllPositions(textObjects: TextObject[]): void {\n // 현재 표시된 텍스트 중 더 이상 없는 것 제거\n const currentIds = new Set(textObjects.map(obj => obj.id));\n \n this.textElements.forEach((element, id) => {\n if (!currentIds.has(id)) {\n element.remove();\n this.textElements.delete(id);\n }\n });\n \n // 모든 텍스트 렌더링\n textObjects.forEach(textObj => {\n this.renderText(textObj);\n });\n }\n \n /**\n * 텍스트 제거\n */\n removeText(id: string): void {\n const element = this.textElements.get(id);\n if (element) {\n element.remove();\n this.textElements.delete(id);\n }\n }\n \n /**\n * 모든 텍스트 제거\n */\n clear(): void {\n this.textElements.forEach(element => element.remove());\n this.textElements.clear();\n }\n \n /**\n * 텍스트 선택 상태 업데이트\n */\n updateSelection(id: string, selected: boolean): void {\n const element = this.textElements.get(id);\n if (element) {\n if (selected) {\n element.classList.add('selected');\n element.style.outline = '2px solid #2196F3';\n element.style.outlineOffset = '2px';\n element.style.pointerEvents = 'auto';\n } else {\n element.classList.remove('selected');\n element.style.outline = 'none';\n element.style.pointerEvents = 'auto';\n }\n }\n }\n \n /**\n * 편집 모드 활성화\n */\n enableEdit(id: string, onComplete: (text: string) => void): void {\n const element = this.textElements.get(id);\n if (!element) return;\n \n // contenteditable로 편집 가능하게\n element.contentEditable = 'true';\n element.style.cursor = 'text';\n element.style.userSelect = 'text';\n element.style.outline = '2px solid #2196F3';\n \n // 포커스 및 전체 선택\n element.focus();\n const range = document.createRange();\n range.selectNodeContents(element);\n const selection = window.getSelection();\n if (selection) {\n selection.removeAllRanges();\n selection.addRange(range);\n }\n \n // 편집 완료 처리\n const finishEdit = () => {\n element.contentEditable = 'false';\n element.style.cursor = 'move';\n element.style.userSelect = 'none';\n \n const newText = element.textContent || '';\n onComplete(newText);\n \n // 이벤트 리스너 제거\n element.removeEventListener('blur', handleBlur);\n element.removeEventListener('keydown', handleKeydown);\n };\n \n const handleBlur = () => finishEdit();\n const handleKeydown = (e: KeyboardEvent) => {\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n finishEdit();\n } else if (e.key === 'Escape') {\n e.preventDefault();\n element.blur();\n }\n };\n \n element.addEventListener('blur', handleBlur);\n element.addEventListener('keydown', handleKeydown);\n }\n \n /**\n * 더블클릭 핸들러\n */\n private handleDoubleClick(id: string): void {\n if (this.onStyleEdit) {\n // DrawingManager에서 설정한 콜백 호출\n // 실제 스타일은 DrawingManager에서 관리\n this.onStyleEdit(id, {} as TextStyle);\n }\n }\n \n /**\n * 스타일 편집 콜백 설정\n */\n setStyleEditCallback(callback: (id: string, currentStyle: TextStyle) => void): void {\n this.onStyleEdit = callback;\n }\n \n /**\n * ID로 텍스트 객체 가져오기 (외부 관리자에서 호출)\n */\n private getTextObjectById(id: string): TextObject | null {\n // DrawingManager에서 관리하므로 여기서는 간단히 null 반환\n // 실제 구현은 DrawingManager에서 처리\n return null;\n }\n \n /**\n * 편집 중인지 확인\n */\n isTextEditing(): boolean {\n return this.isEditing;\n }\n \n /**\n * 컨테이너 정리\n */\n dispose(): void {\n this.clear();\n const textContainer = this.container.querySelector('.chart-text-container');\n if (textContainer) {\n textContainer.remove();\n }\n }\n}","import type { TextStyle } from '@/renderers/unified/TextRenderer';\n\nexport interface TextInputOptions {\n initialText?: string;\n initialStyle?: TextStyle;\n position: { x: number; y: number };\n onConfirm: (text: string, style: TextStyle) => void;\n onCancel: () => void;\n}\n\nexport class TextInputModal {\n private container: HTMLElement;\n private modal: HTMLDivElement | null = null;\n private options: TextInputOptions | null = null;\n \n constructor(container: HTMLElement) {\n this.container = container;\n }\n \n show(options: TextInputOptions): void {\n this.options = options;\n this.createModal();\n }\n \n private createModal(): void {\n // 모달 배경\n const backdrop = document.createElement('div');\n backdrop.className = 'text-input-backdrop';\n backdrop.style.cssText = `\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background: rgba(0, 0, 0, 0.5);\n z-index: 100;\n display: flex;\n align-items: center;\n justify-content: center;\n `;\n \n // 모달 컨테이너\n this.modal = document.createElement('div');\n this.modal.className = 'text-input-modal';\n this.modal.style.cssText = `\n background: white;\n color: #333333;\n border-radius: 8px;\n padding: 20px;\n min-width: 400px;\n box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);\n `;\n \n // 기본 스타일\n const defaultStyle: TextStyle = {\n fontSize: 14,\n fontFamily: 'Arial, sans-serif',\n color: '#000000',\n bold: false,\n italic: false,\n underline: false\n };\n \n const style = { ...defaultStyle, ...this.options?.initialStyle };\n \n // 모달 내용\n this.modal.innerHTML = `\n <div style=\"margin-bottom: 16px;\">\n <h3 style=\"margin: 0 0 16px 0; font-size: 18px; color: #333;\">텍스트 입력</h3>\n \n <textarea \n id=\"text-input\" \n style=\"\n width: 100%;\n min-height: 60px;\n padding: 8px;\n border: 1px solid #ddd;\n border-radius: 4px;\n font-size: 14px;\n color: #333;\n background: white;\n resize: vertical;\n \"\n placeholder=\"텍스트를 입력하세요...\"\n >${this.options?.initialText || ''}</textarea>\n </div>\n \n <div style=\"margin-bottom: 16px;\">\n <h4 style=\"margin: 0 0 8px 0; font-size: 14px; color: #333;\">텍스트 스타일</h4>\n \n <div style=\"display: grid; grid-template-columns: 1fr 1fr; gap: 12px;\">\n <div>\n <label style=\"display: block; margin-bottom: 4px; font-size: 12px; color: #666;\">폰트 크기</label>\n <input \n type=\"number\" \n id=\"font-size\" \n value=\"${style.fontSize}\"\n min=\"8\" \n max=\"72\"\n style=\"width: 100%; padding: 4px; border: 1px solid #ddd; border-radius: 4px; color: #333; background: white;\"\n >\n </div>\n \n <div>\n <label style=\"display: block; margin-bottom: 4px; font-size: 12px; color: #666;\">폰트</label>\n <select \n id=\"font-family\"\n style=\"width: 100%; padding: 4px; border: 1px solid #ddd; border-radius: 4px; color: #333; background: white;\"\n >\n <option value=\"Arial, sans-serif\" ${style.fontFamily === 'Arial, sans-serif' ? 'selected' : ''}>Arial</option>\n <option value=\"'Times New Roman', serif\" ${style.fontFamily === \"'Times New Roman', serif\" ? 'selected' : ''}>Times New Roman</option>\n <option value=\"'Courier New', monospace\" ${style.fontFamily === \"'Courier New', monospace\" ? 'selected' : ''}>Courier New</option>\n <option value=\"Georgia, serif\" ${style.fontFamily === 'Georgia, serif' ? 'selected' : ''}>Georgia</option>\n <option value=\"Verdana, sans-serif\" ${style.fontFamily === 'Verdana, sans-serif' ? 'selected' : ''}>Verdana</option>\n <option value=\"'Trebuchet MS', sans-serif\" ${style.fontFamily === \"'Trebuchet MS', sans-serif\" ? 'selected' : ''}>Trebuchet MS</option>\n </select>\n </div>\n \n <div>\n <label style=\"display: block; margin-bottom: 4px; font-size: 12px; color: #666;\">텍스트 색상</label>\n <input \n type=\"color\" \n id=\"text-color\" \n value=\"${style.color}\"\n style=\"width: 100%; height: 32px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;\"\n >\n </div>\n \n <div>\n <label style=\"display: block; margin-bottom: 4px; font-size: 12px; color: #666;\">배경 색상</label>\n <div style=\"display: flex; gap: 4px;\">\n <input \n type=\"checkbox\" \n id=\"use-background\"\n ${style.backgroundColor ? 'checked' : ''}\n style=\"margin-top: 8px;\"\n >\n <input \n type=\"color\" \n id=\"background-color\" \n value=\"${style.backgroundColor || '#ffffff'}\"\n ${!style.backgroundColor ? 'disabled' : ''}\n style=\"flex: 1; height: 32px; border: 1px solid #ddd; border-radius