diff options
| author | zwlucas <lucas.fariamo08@gmail.com> | 2026-06-05 20:52:54 +0000 |
|---|---|---|
| committer | zwlucas <lucas.fariamo08@gmail.com> | 2026-06-05 20:52:54 +0000 |
| commit | 09f964451d7d92e9891430ec4595c1276d486aab (patch) | |
| tree | 28da4483f5c28924a8c47fceb648b1baebe88224 /src/components/Editor | |
| download | yace-master.tar.gz yace-master.zip | |
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'src/components/Editor')
| -rw-r--r-- | src/components/Editor/YaceEditor.tsx | 291 |
1 files changed, 291 insertions, 0 deletions
diff --git a/src/components/Editor/YaceEditor.tsx b/src/components/Editor/YaceEditor.tsx new file mode 100644 index 0000000..f3152e1 --- /dev/null +++ b/src/components/Editor/YaceEditor.tsx @@ -0,0 +1,291 @@ +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<HTMLDivElement>(null) + const viewRef = useRef<EditorView | null>(null) + const containerRef = useRef<HTMLDivElement>(null) + const updateTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 ( + <div ref={containerRef} className="flex-1 overflow-hidden bg-editor-bg"> + <div ref={editorRef} className="h-full" /> + </div> + ) +} |