diff options
Diffstat (limited to 'components/template-editor/html-code-editor.tsx')
| -rw-r--r-- | components/template-editor/html-code-editor.tsx | 194 |
1 files changed, 194 insertions, 0 deletions
diff --git a/components/template-editor/html-code-editor.tsx b/components/template-editor/html-code-editor.tsx new file mode 100644 index 00000000..de34ab96 --- /dev/null +++ b/components/template-editor/html-code-editor.tsx @@ -0,0 +1,194 @@ +// 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<EditorHandle, HtmlCodeEditorProps>( + ({ + value, + onChange, + placeholder: placeholderText = "HTML 템플릿을 입력하세요...", + height = "500px", + darkMode = false, + readOnly = false + }, ref) => { + const editorRef = React.useRef<HTMLDivElement>(null) + const viewRef = React.useRef<EditorView | null>(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 ( + <div className="relative"> + <div ref={editorRef} className="overflow-hidden" /> + + {/* 에디터 하단 정보 바 */} + <div className="flex items-center justify-between mt-2 px-2 py-1 bg-gray-50 dark:bg-gray-800 rounded text-xs text-gray-500 dark:text-gray-400"> + <div className="flex items-center gap-4"> + <span className="flex items-center gap-1"> + <span className="w-2 h-2 bg-green-500 rounded-full"></span> + HTML + Handlebars + </span> + <span>라인: {value.split('\n').length}</span> + <span>문자: {value.length}</span> + {value.match(/\{\{[^}]+\}\}/g) && ( + <span>변수: {value.match(/\{\{[^}]+\}\}/g)?.length}</span> + )} + </div> + <div className="flex items-center gap-3"> + <span>Ctrl+F: 찾기</span> + <span>Tab: 들여쓰기</span> + <span>Ctrl+/: 주석</span> + </div> + </div> + </div> + ) + } +)
\ No newline at end of file |
