diff --git a/src/presentation/components/Code/Ace/AceCodeEditorFactory.ts b/src/presentation/components/Code/Ace/AceCodeEditorFactory.ts new file mode 100644 index 00000000..54fddbf0 --- /dev/null +++ b/src/presentation/components/Code/Ace/AceCodeEditorFactory.ts @@ -0,0 +1,91 @@ +import ace from './ace-importer'; +import type { CodeEditorFactory, SupportedSyntaxLanguage } from '../CodeEditorFactory'; + +const CodeEditorTheme = 'xcode'; + +export const initializeAceEditor: CodeEditorFactory = (options) => { + const editor = ace.edit(options.editorContainerElementId); + const mode = getAceModeName(options.language); + editor.getSession().setMode(`ace/mode/${mode}`); + editor.setTheme(`ace/theme/${CodeEditorTheme}`); + editor.setReadOnly(true); + editor.setAutoScrollEditorIntoView(true); + editor.setShowPrintMargin(false); // Hide the vertical line + editor.getSession().setUseWrapMode(true); // Make code readable on mobile + hideActiveLineAndCursorUntilInteraction(editor); + return { + setContent: (code) => editor.setValue(code, 1), + destroy: () => editor.destroy(), + scrollToLine: (lineNumber) => { + const column = editor.session.getLine(lineNumber).length; + if (column === undefined) { + return; + } + editor.gotoLine(lineNumber, column, true); + }, + updateSize: () => editor?.resize(), + applyStyleToLineRange: (start, end, className) => { + const AceRange = ace.require('ace/range').Range; + const markerId = editor.session.addMarker( + new AceRange(start, 0, end, 0), + className, + 'fullLine', + ); + return { + clearStyle: () => { + editor.session.removeMarker(markerId); + }, + }; + }, + }; +}; + +function getAceModeName(language: SupportedSyntaxLanguage): string { + switch (language) { + case 'batchfile': return 'batchfile'; + case 'shellscript': return 'sh'; + default: + throw new Error(`Language not supported: ${language}`); + } +} + +function hideActiveLineAndCursorUntilInteraction(editor: ace.Ace.Editor) { + hideActiveLineAndCursor(editor); + editor.session.on('change', () => { + editor.session.selection.clearSelection(); + hideActiveLineAndCursor(editor); + }); + editor.session.selection.on('changeSelection', () => { + showActiveLineAndCursor(editor); + }); +} + +function hideActiveLineAndCursor(editor: ace.Ace.Editor): void { + editor.setHighlightGutterLine(false); // Remove highlighting on line number column + editor.setHighlightActiveLine(false); // Remove highlighting throughout the line + setCursorVisibility(false, editor); +} + +function showActiveLineAndCursor(editor: ace.Ace.Editor): void { + editor.setHighlightGutterLine(true); // Show highlighting on line number column + editor.setHighlightActiveLine(true); // Show highlighting throughout the line + setCursorVisibility(true, editor); +} + +// Shows/removes vertical line after focused character +function setCursorVisibility( + isVisible: boolean, + editor: ace.Ace.Editor, +) { + const cursor = editor.renderer.container.querySelector('.ace_cursor-layer') as HTMLElement; + if (!cursor) { + throw new Error('Cannot find Ace cursor, did Ace change its rendering?'); + } + cursor.style.display = isVisible ? '' : 'none'; + // Implementation options for cursor visibility: + // ❌ editor.renderer.showCursor() and hideCursor(): Not functioning as expected + // ❌ editor.renderer.#cursorLayer: No longer part of the public API + // ✅ .ace_hidden-cursors { opacity: 0; }: Hides cursor when not focused + // Pros: Works more automatically + // Cons: Provides less control over visibility toggling +} diff --git a/src/presentation/components/Code/ace-importer.ts b/src/presentation/components/Code/Ace/ace-importer.ts similarity index 93% rename from src/presentation/components/Code/ace-importer.ts rename to src/presentation/components/Code/Ace/ace-importer.ts index 76978582..155b1ddf 100644 --- a/src/presentation/components/Code/ace-importer.ts +++ b/src/presentation/components/Code/Ace/ace-importer.ts @@ -5,7 +5,6 @@ import ace from 'ace-builds'; when built with Vite (`npm run build`). */ -import 'ace-builds/src-noconflict/theme-github'; import 'ace-builds/src-noconflict/theme-xcode'; import 'ace-builds/src-noconflict/mode-batchfile'; import 'ace-builds/src-noconflict/mode-sh'; diff --git a/src/presentation/components/Code/CodeEditorFactory.ts b/src/presentation/components/Code/CodeEditorFactory.ts new file mode 100644 index 00000000..14ed8993 --- /dev/null +++ b/src/presentation/components/Code/CodeEditorFactory.ts @@ -0,0 +1,30 @@ +/** + * Abstraction layer for code editor functionality. + * Allows for flexible integration and easy switching of third-party editor implementations. + */ +export interface CodeEditorFactory { + (options: CodeEditorOptions): CodeEditor; +} + +export interface CodeEditorOptions { + readonly editorContainerElementId: string; + readonly language: SupportedSyntaxLanguage; +} + +export type SupportedSyntaxLanguage = 'batchfile' | 'shellscript'; + +export interface CodeEditor { + destroy(): void; + setContent(content: string): void; + scrollToLine(lineNumber: number): void; + updateSize(): void; + applyStyleToLineRange( + startLineNumber: number, + endLineNumber: number, + className: string, + ): CodeEditorStyleHandle; +} + +export interface CodeEditorStyleHandle { + clearStyle(): void; +} diff --git a/src/presentation/components/Code/TheCodeArea.vue b/src/presentation/components/Code/TheCodeArea.vue index 93edd884..65869690 100644 --- a/src/presentation/components/Code/TheCodeArea.vue +++ b/src/presentation/components/Code/TheCodeArea.vue @@ -25,7 +25,8 @@ import { CodeBuilderFactory } from '@/application/Context/State/Code/Generation/ import SizeObserver from '@/presentation/components/Shared/SizeObserver.vue'; import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective'; import type { ProjectDetails } from '@/domain/Project/ProjectDetails'; -import ace from './ace-importer'; +import { initializeAceEditor } from './Ace/AceCodeEditorFactory'; +import type { SupportedSyntaxLanguage, CodeEditor, CodeEditorStyleHandle } from './CodeEditorFactory'; export default defineComponent({ components: { @@ -34,13 +35,7 @@ export default defineComponent({ directives: { NonCollapsing, }, - props: { - theme: { - type: String, - default: undefined, - }, - }, - setup(props) { + setup() { const { onStateChange, currentState } = injectKey((keys) => keys.useCollectionState); const { projectDetails } = injectKey((keys) => keys.useApplication); const { events } = injectKey((keys) => keys.useAutoUnsubscribedEvents); @@ -48,8 +43,8 @@ export default defineComponent({ const editorId = 'codeEditor'; const highlightedRange = ref(0); - let editor: ace.Ace.Editor | undefined; - let currentMarkerId: number | undefined; + let editor: CodeEditor | undefined; + let currentMarker: CodeEditorStyleHandle | undefined; onUnmounted(() => { destroyEditor(); @@ -63,11 +58,10 @@ export default defineComponent({ function handleNewState(newState: IReadOnlyCategoryCollectionState) { destroyEditor(); - editor = initializeEditor( - props.theme, - editorId, - newState.collection.scripting.language, - ); + editor = initializeAceEditor({ + editorContainerElementId: editorId, + language: getLanguage(newState.collection.scripting.language), + }); const appCode = newState.code; updateCode(appCode.current, newState.collection.scripting.language); events.unsubscribeAllAndRegister([ @@ -77,7 +71,7 @@ export default defineComponent({ function updateCode(code: string, language: ScriptingLanguage) { const innerCode = code || getDefaultCode(language, projectDetails); - editor?.setValue(innerCode, 1); + editor?.setContent(innerCode); } function handleCodeChange(event: ICodeChangedEvent) { @@ -91,7 +85,7 @@ export default defineComponent({ } function sizeChanged() { - editor?.resize(); + editor?.updateSize(); } function destroyEditor() { @@ -100,11 +94,11 @@ export default defineComponent({ } function removeCurrentHighlighting() { - if (!currentMarkerId) { + if (!currentMarker) { return; } - editor?.session.removeMarker(currentMarkerId); - currentMarkerId = undefined; + currentMarker?.clearStyle(); + currentMarker = undefined; highlightedRange.value = 0; } @@ -117,28 +111,15 @@ export default defineComponent({ const end = Math.max( ...positions.map((position) => position.endLine), ); - scrollToLine(end + 2); + editor?.scrollToLine(end + 2); highlight(start, end); } function highlight(startRow: number, endRow: number) { - const AceRange = ace.require('ace/range').Range; - currentMarkerId = editor?.session.addMarker( - new AceRange(startRow, 0, endRow, 0), - 'code-area__highlight', - 'fullLine', - ); + currentMarker = editor?.applyStyleToLineRange(startRow, endRow, 'code-area__highlight'); highlightedRange.value = endRow - startRow; } - function scrollToLine(row: number) { - const column = editor?.session.getLine(row).length; - if (column === undefined) { - return; - } - editor?.gotoLine(row, column, true); - } - return { editorId, highlightedRange, @@ -147,29 +128,12 @@ export default defineComponent({ }, }); -function initializeEditor( - theme: string | undefined, - editorId: string, - language: ScriptingLanguage, -): ace.Ace.Editor { - theme = theme || 'github'; - const editor = ace.edit(editorId); - const lang = getLanguage(language); - editor.getSession().setMode(`ace/mode/${lang}`); - editor.setTheme(`ace/theme/${theme}`); - editor.setReadOnly(true); - editor.setAutoScrollEditorIntoView(true); - editor.setShowPrintMargin(false); // hides vertical line - editor.getSession().setUseWrapMode(true); // So code is readable on mobile - return editor; -} - -function getLanguage(language: ScriptingLanguage) { +function getLanguage(language: ScriptingLanguage): SupportedSyntaxLanguage { switch (language) { case ScriptingLanguage.batchfile: return 'batchfile'; - case ScriptingLanguage.shellscript: return 'sh'; + case ScriptingLanguage.shellscript: return 'shellscript'; default: - throw new Error('unknown language'); + throw new Error(`Unsupported language: ${language}`); } } diff --git a/src/presentation/components/Scripts/TheScriptArea.vue b/src/presentation/components/Scripts/TheScriptArea.vue index 0a88456e..01411f6e 100644 --- a/src/presentation/components/Scripts/TheScriptArea.vue +++ b/src/presentation/components/Scripts/TheScriptArea.vue @@ -11,7 +11,7 @@