summaryrefslogtreecommitdiff
path: root/components/template-editor/html-code-editor.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/template-editor/html-code-editor.tsx')
-rw-r--r--components/template-editor/html-code-editor.tsx194
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