aboutsummaryrefslogtreecommitdiff
path: root/src/components/Editor/YaceEditor.tsx
diff options
context:
space:
mode:
authorzwlucas <lucas.fariamo08@gmail.com>2026-06-05 20:52:54 +0000
committerzwlucas <lucas.fariamo08@gmail.com>2026-06-05 20:52:54 +0000
commit09f964451d7d92e9891430ec4595c1276d486aab (patch)
tree28da4483f5c28924a8c47fceb648b1baebe88224 /src/components/Editor/YaceEditor.tsx
downloadyace-09f964451d7d92e9891430ec4595c1276d486aab.tar.gz
yace-09f964451d7d92e9891430ec4595c1276d486aab.zip
feat: uploadHEADmaster
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'src/components/Editor/YaceEditor.tsx')
-rw-r--r--src/components/Editor/YaceEditor.tsx291
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>
+ )
+}