import { useCallback, useEffect, useRef, useMemo } from 'react' import { EditorView, keymap, highlightActiveLine, highlightActiveLineGutter, lineNumbers, } from '@codemirror/view' import { EditorState, type Extension } from '@codemirror/state' import { defaultKeymap, indentWithTab, undo, redo } from '@codemirror/commands' import { syntaxHighlighting, indentUnit, HighlightStyle } from '@codemirror/language' import { tags } from '@lezer/highlight' import { json } from '@codemirror/lang-json' import { javascript } from '@codemirror/lang-javascript' import { markdown } from '@codemirror/lang-markdown' import { html } from '@codemirror/lang-html' import { css } from '@codemirror/lang-css' import { rust } from '@codemirror/lang-rust' import { python } from '@codemirror/lang-python' import { useTabsStore } from '@/stores/useTabsStore' import { useContextMenuStore } from '@/stores/useContextMenuStore' import { useSettingsStore } from '@/stores/useSettingsStore' function buildLanguageExtensions(language: string) { switch (language) { case 'typescript': return javascript({ typescript: true }) case 'javascript': return javascript() case 'json': return json() case 'markdown': return markdown() case 'html': return html() case 'css': return css() case 'rust': return rust() case 'python': return python() default: return [] } } const tokyoNightSyntax = HighlightStyle.define([ { tag: tags.keyword, color: '#7aa2f7' }, { tag: [tags.controlKeyword, tags.operatorKeyword], color: '#7aa2f7' }, { tag: tags.modifier, color: '#7aa2f7' }, { tag: tags.definitionKeyword, color: '#7aa2f7' }, { tag: tags.moduleKeyword, color: '#7aa2f7' }, { tag: tags.self, color: '#ff5370' }, { tag: tags.atom, color: '#ff9e64' }, { tag: tags.bool, color: '#ff9e64' }, { tag: tags.null, color: '#ff9e64' }, { tag: tags.number, color: '#ff9e64' }, { tag: tags.string, color: '#9ece6a' }, { tag: tags.character, color: '#9ece6a' }, { tag: tags.regexp, color: '#89ddff' }, { tag: tags.escape, color: '#bb9af7' }, { tag: tags.typeName, color: '#e0af68' }, { tag: tags.className, color: '#e0af68' }, { tag: tags.namespace, color: '#89ddff' }, { tag: tags.macroName, color: '#ff5370' }, { tag: tags.propertyName, color: '#73daca' }, { tag: tags.attributeName, color: '#73daca' }, { tag: tags.variableName, color: '#c0caf5' }, { tag: tags.labelName, color: '#c0caf5' }, { tag: tags.operator, color: '#89ddff' }, { tag: tags.bracket, color: '#a9b1d6' }, { tag: tags.angleBracket, color: '#89ddff' }, { tag: tags.paren, color: '#a9b1d6' }, { tag: tags.brace, color: '#a9b1d6' }, { tag: tags.squareBracket, color: '#a9b1d6' }, { tag: tags.separator, color: '#a9b1d6' }, { tag: tags.punctuation, color: '#a9b1d6' }, { tag: tags.comment, color: '#565f89', fontStyle: 'italic' }, { tag: tags.lineComment, color: '#565f89', fontStyle: 'italic' }, { tag: tags.blockComment, color: '#565f89', fontStyle: 'italic' }, { tag: tags.docComment, color: '#565f89', fontStyle: 'italic' }, { tag: tags.docString, color: '#565f89' }, { tag: tags.meta, color: '#565f89' }, { tag: tags.content, color: '#c0caf5' }, { tag: tags.heading, color: '#7aa2f7', fontWeight: '600' }, { tag: tags.strong, color: '#c0caf5', fontWeight: '700' }, { tag: tags.emphasis, color: '#c0caf5', fontStyle: 'italic' }, { tag: tags.strikethrough, color: '#565f89', textDecoration: 'line-through' }, { tag: tags.link, color: '#73daca', textDecoration: 'underline' }, { tag: tags.url, color: '#73daca', textDecoration: 'underline' }, { tag: tags.list, color: '#7dcfff' }, { tag: tags.quote, color: '#9ece6a' }, { tag: tags.inserted, color: '#9ece6a' }, { tag: tags.deleted, color: '#ff5370' }, { tag: tags.changed, color: '#e0af68' }, { tag: tags.invalid, color: '#ff5370' }, { tag: tags.definition(tags.variableName), color: '#c0caf5' }, { tag: tags.definition(tags.propertyName), color: '#73daca' }, { tag: tags.function(tags.variableName), color: '#7dcfff' }, { tag: tags.definition(tags.function(tags.variableName)), color: '#7dcfff' }, { tag: tags.local(tags.variableName), color: '#c0caf5' }, { tag: tags.special(tags.variableName), color: '#ff5370' }, { tag: tags.special(tags.string), color: '#bb9af7' }, ]) function buildEditorTheme(settings: { fontSize: number fontFamily: string ruler: boolean rulerColumn: number }) { const rulerCSS = settings.ruler ? `linear-gradient(90deg, transparent calc(${settings.rulerColumn}ch + 0.5px), var(--color-ruler) calc(${settings.rulerColumn}ch + 0.5px), var(--color-ruler) calc(${settings.rulerColumn + 0.5}ch + 0.5px), transparent calc(${settings.rulerColumn + 0.5}ch + 0.5px))` : 'none' return EditorView.theme({ '&': { fontSize: `${settings.fontSize}px`, fontFamily: `'${settings.fontFamily}', 'Cascadia Code', 'Fira Code', Consolas, monospace`, height: '100%', backgroundColor: 'var(--color-editor-bg)', }, '.cm-scroller': { fontFamily: `'${settings.fontFamily}', 'Cascadia Code', 'Fira Code', Consolas, monospace`, }, '.cm-content': { padding: '16px 0', caretColor: 'var(--color-accent)', backgroundImage: rulerCSS, backgroundPosition: '0 0', backgroundRepeat: 'no-repeat', }, '.cm-line': { padding: '0 20px', }, '.cm-gutters': { backgroundColor: 'var(--color-editor-surface)', borderRight: '1px solid var(--color-panel-border)', color: 'var(--color-text-muted)', }, '.cm-lineNumbers .cm-gutterElement': { padding: '0 12px 0 16px', fontSize: '12px', fontFamily: `'${settings.fontFamily}', 'Cascadia Code', 'Fira Code', Consolas, monospace`, lineHeight: '1.6', }, '.cm-activeLineGutter': { backgroundColor: 'var(--color-editor-line)', color: 'var(--color-text-primary)', fontWeight: '600', }, '.cm-activeLine': { backgroundColor: 'rgba(12,12,14,0.5)', }, '.cm-cursor': { borderLeftWidth: '2px', borderLeftColor: 'var(--color-accent)', }, '&.cm-focused .cm-cursor': { borderLeftColor: 'var(--color-accent)', }, '.cm-selectionBackground': { backgroundColor: 'rgba(240,160,48,0.2) !important', }, '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': { backgroundColor: 'rgba(240,160,48,0.25) !important', }, '.cm-selectionMatch': { backgroundColor: 'rgba(240,160,48,0.1) !important', }, }) } export default function YaceEditor() { const editorRef = useRef(null) const viewRef = useRef(null) const containerRef = useRef(null) const updateTimeoutRef = useRef | undefined>(undefined) const activeTab = useTabsStore((s) => s.activeTabId ? s.tabs.find((t) => t.id === s.activeTabId) ?? null : null ) const updateContent = useTabsStore((s) => s.updateContent) const setCursorPosition = useTabsStore((s) => s.setCursorPosition) const openContextMenu = useContextMenuStore((s) => s.open) const fontSize = useSettingsStore((s) => s.fontSize) const tabSize = useSettingsStore((s) => s.tabSize) const fontFamily = useSettingsStore((s) => s.fontFamily) const wordWrap = useSettingsStore((s) => s.wordWrap) const lineNumbersEnabled = useSettingsStore((s) => s.lineNumbers) const ruler = useSettingsStore((s) => s.ruler) const rulerColumn = useSettingsStore((s) => s.rulerColumn) const settings = { fontSize, tabSize, fontFamily, wordWrap, lineNumbers: lineNumbersEnabled, ruler, rulerColumn } const editorItems = useMemo(() => [ { id: 'undo', label: 'Undo', shortcut: 'Ctrl+Z', icon: 'undo' as const, action: () => viewRef.current && undo(viewRef.current) }, { id: 'redo', label: 'Redo', shortcut: 'Ctrl+Shift+Z', icon: 'redo' as const, action: () => viewRef.current && redo(viewRef.current) }, { id: 'sep1', label: '', separator: true, action: () => {} }, { id: 'cut', label: 'Cut', shortcut: 'Ctrl+X', icon: 'cut' as const, action: () => { document.execCommand('cut') } }, { id: 'copy', label: 'Copy', shortcut: 'Ctrl+C', icon: 'copy' as const, action: () => { document.execCommand('copy') } }, { id: 'paste', label: 'Paste', shortcut: 'Ctrl+V', icon: 'paste' as const, action: () => { navigator.clipboard.readText().then((t) => viewRef.current?.dispatch({ changes: { from: viewRef.current.state.selection.main.head, insert: t } })) } }, { id: 'sep2', label: '', separator: true, action: () => {} }, { id: 'selectAll', label: 'Select All', shortcut: 'Ctrl+A', icon: 'selectAll' as const, action: () => { viewRef.current?.dispatch({ selection: { anchor: 0, head: viewRef.current.state.doc.length } }) } }, ], []) const handleContextMenu = useCallback((e: MouseEvent) => { e.preventDefault() openContextMenu(e.clientX, e.clientY, editorItems) }, [openContextMenu, editorItems]) const handleChange = useCallback( (content: string) => { if (updateTimeoutRef.current) clearTimeout(updateTimeoutRef.current) updateTimeoutRef.current = setTimeout(() => { const id = useTabsStore.getState().activeTabId if (id) updateContent(id, content) }, 300) }, [updateContent] ) useEffect(() => { if (!editorRef.current) return if (viewRef.current) viewRef.current.destroy() const languageExtensions = activeTab ? buildLanguageExtensions(activeTab.language) : [] const updateListener = EditorView.updateListener.of((update) => { if (update.docChanged) { handleChange(update.state.doc.toString()) } if (update.selectionSet) { const pos = update.state.selection.main.head const line = update.state.doc.lineAt(pos) setCursorPosition(line.number, pos - line.from + 1) } }) const extensions: Extension[] = [ ...(lineNumbersEnabled ? [lineNumbers(), highlightActiveLineGutter()] : [highlightActiveLineGutter()]), highlightActiveLine(), updateListener, keymap.of([...defaultKeymap, indentWithTab]), syntaxHighlighting(tokyoNightSyntax), buildEditorTheme(settings), EditorState.tabSize.of(tabSize), indentUnit.of(' '.repeat(tabSize)), languageExtensions, ] if (wordWrap) { extensions.push(EditorView.lineWrapping) } const state = EditorState.create({ doc: activeTab?.content ?? '', extensions: extensions.flat(), }) viewRef.current = new EditorView({ state, parent: editorRef.current, }) return () => { if (updateTimeoutRef.current) clearTimeout(updateTimeoutRef.current) viewRef.current?.destroy() viewRef.current = null } }, [activeTab?.id, activeTab?.language, fontSize, tabSize, fontFamily, wordWrap, lineNumbersEnabled, ruler, rulerColumn]) useEffect(() => { const el = containerRef.current if (!el) return el.addEventListener('contextmenu', handleContextMenu) return () => el.removeEventListener('contextmenu', handleContextMenu) }, [handleContextMenu]) if (!activeTab) { return null } return (
) }