// components/template-editor/html-code-editor.tsx "use client" import React from 'react' import { EditorView, basicSetup } from 'codemirror' import { EditorState } from '@codemirror/state' import { html } from '@codemirror/lang-html' import { oneDark } from '@codemirror/theme-one-dark' import { bracketMatching } from '@codemirror/language' import { searchKeymap, highlightSelectionMatches } from '@codemirror/search' import { keymap } from '@codemirror/view' import { indentWithTab } from '@codemirror/commands' interface HtmlCodeEditorProps { value: string onChange: (value: string) => void placeholder?: string height?: string darkMode?: boolean readOnly?: boolean } interface EditorHandle { focus: () => void insertText: (text: string) => void getEditor: () => EditorView | null } export const HtmlCodeEditor = React.forwardRef( ({ value, onChange, placeholder: placeholderText = "HTML 템플릿을 입력하세요...", height = "500px", darkMode = false, readOnly = false }, ref) => { const editorRef = React.useRef(null) const viewRef = React.useRef(null) React.useEffect(() => { if (!editorRef.current) return // 간단한 CSS 기반 Handlebars 하이라이팅 const handlebarsTheme = EditorView.theme({ '.cm-line': { lineHeight: '1.6' }, '.cm-content': { fontSize: '14px', fontFamily: '"Fira Code", "JetBrains Mono", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace' }, '.cm-editor': { borderRadius: '8px', border: darkMode ? '1px solid #374151' : '1px solid #e2e8f0' }, '.cm-focused': { outline: 'none', borderColor: darkMode ? '#60a5fa' : '#3b82f6' }, '.cm-scroller': { fontFamily: 'inherit' }, // CSS 기반 placeholder 구현 '.cm-editor.cm-focused .cm-line': { '&:first-child:only-child:before': { content: value === '' ? `"${placeholderText}"` : '""', color: darkMode ? '#6b7280' : '#9ca3af', fontStyle: 'italic', pointerEvents: 'none', position: 'absolute' } }, '.cm-editor:not(.cm-focused) .cm-line': { '&:first-child:only-child:before': { content: value === '' ? `"${placeholderText}"` : '""', color: darkMode ? '#6b7280' : '#9ca3af', fontStyle: 'italic', pointerEvents: 'none', position: 'absolute' } } }) const startState = EditorState.create({ doc: value, extensions: [ basicSetup, html(), bracketMatching(), highlightSelectionMatches(), keymap.of([ indentWithTab, ...searchKeymap, { key: 'Ctrl-s', preventDefault: true, run: () => true } ]), handlebarsTheme, darkMode ? oneDark : [], EditorView.theme({ '&': { height: height, fontSize: '14px' }, '.cm-content': { minHeight: height, padding: '12px' } }), EditorView.updateListener.of((update) => { if (update.docChanged) { onChange(update.state.doc.toString()) } }), EditorState.readOnly.of(readOnly) ] }) const view = new EditorView({ state: startState, parent: editorRef.current }) viewRef.current = view return () => { view.destroy() } }, [darkMode, height, readOnly, placeholderText, value]) // value 변경 시 에디터 업데이트 (외부에서 변경된 경우) React.useEffect(() => { if (viewRef.current && viewRef.current.state.doc.toString() !== value) { viewRef.current.dispatch({ changes: { from: 0, to: viewRef.current.state.doc.length, insert: value } }) } }, [value]) // ref로 노출할 메서드들 React.useImperativeHandle(ref, () => ({ focus: () => { viewRef.current?.focus() }, insertText: (text: string) => { if (!viewRef.current) return const view = viewRef.current const { from, to } = view.state.selection.main view.dispatch({ changes: { from, to, insert: text }, selection: { anchor: from + text.length } }) view.focus() }, getEditor: () => viewRef.current })) return (
{/* 에디터 하단 정보 바 */}
HTML + Handlebars 라인: {value.split('\n').length} 문자: {value.length} {value.match(/\{\{[^}]+\}\}/g) && ( 변수: {value.match(/\{\{[^}]+\}\}/g)?.length} )}
Ctrl+F: 찾기 Tab: 들여쓰기 Ctrl+/: 주석
) } )