/** * Copyright Aquera Inc 2023 * * This source code is licensed under the BSD-3-Clause license found in the * LICENSE file in the root directory of this source tree. */ import { html, CSSResultArray, TemplateResult, PropertyValueMap, } from 'lit'; import { customElement, query, property } from 'lit/decorators.js'; import { styles } from './nile-code-editor.css'; import { EditorView } from 'codemirror'; import { ViewUpdate, placeholder, tooltips } from '@codemirror/view'; import { Compartment, EditorState, Extension } from '@codemirror/state'; import { StyleSpec } from 'style-mod'; import { syntaxHighlighting, defaultHighlightStyle, } from '@codemirror/language'; import { lineNumbers } from '@codemirror/view'; import { javascript, javascriptLanguage, } from '@codemirror/lang-javascript'; import { sql } from '@codemirror/lang-sql'; import { json } from '@codemirror/lang-json'; import { html as htmlLang } from '@codemirror/lang-html'; import { autocompletion,acceptCompletion, closeCompletion,CompletionContext,CompletionResult, completionStatus } from '@codemirror/autocomplete'; import NileElement from '../internal/nile-element'; import { basicSetup } from './extensionSetup'; import { classMap } from 'lit/directives/class-map.js'; import { Theme as DefaultTheme, customisedThemeCss, fontFamily, readOnlyTheme } from './theme'; import { keymap } from '@codemirror/view'; // Choose the appropriate mode for your use case /** * Nile icon component. * * @tag nile-code-editor * */ @customElement('nile-code-editor') export class NileCodeEditor extends NileElement { @query('.code__editor__container') codeEditor: HTMLInputElement; @property({ type: String, reflect: true , attribute: true }) value = ''; @property({ type: String, reflect: true , attribute: true }) expandIcon = "var(--nile-icon-expand-2, var(--ng-icon-expand-06))"; @property({ type: String, reflect: true , attribute: true }) placeholder = ""; @property({ type: Object, reflect: true , attribute: true }) customAutoCompletions: object | any = {}; @property({ type: Array, reflect: true , attribute: true }) customCompletionsPaths: string[] = []; @property({ type: String, reflect: true , attribute: true}) language: 'javascript' | 'sql' | 'json' | 'html' = 'javascript'; @property({ type: String, reflect: true , attribute: 'error-message' }) errorMessage: string = ''; @property({ type: Boolean, reflect: true , attribute: true }) error: boolean = false; @property({ type: Boolean, reflect: true , attribute: true }) enableSearch: boolean = false; @property({ type: Boolean, reflect: true , attribute: true }) noborder: boolean = false; @property({ type: Boolean, reflect: true , attribute: true }) multiline: boolean = false; @property({ type: Boolean, reflect: true , attribute: true }) enableFoldGutters: boolean = false; @property({ type: Boolean, reflect: true , attribute: true }) allowVariableInCustomSuggestion: boolean = false; @property({ type: Boolean, reflect: true , attribute: true }) lineNumbers: boolean = false; @property({ type: Boolean, reflect: true , attribute: false }) disableSyntaxHighlighting: boolean = false; @property({ type: Object, attribute: false }) customThemeCSS: object | null = customisedThemeCss; @property({ type: Boolean, reflect: true , attribute: true }) lineNumbersMultiline: boolean = true; @property({ type: Boolean, reflect: true , attribute: true }) hasScroller: boolean = true; @property({ type: Boolean, reflect: true , attribute: true }) expandable: boolean = true; @property({ type: Boolean, reflect: true , attribute: true}) readonly: boolean = false; @property({ type: Boolean, reflect: true , attribute: true}) disabled: boolean = false; @property({ type: Boolean, reflect: true , attribute: true}) debounce: boolean = false; @property({ type: Number, reflect: true , attribute: true}) debounceTimeout: number = 200; @property({ type: Boolean, reflect: true , attribute: true}) aboveCursor: boolean = false; @property({ type: Boolean, reflect: true, attribute: true }) tabCompletion: boolean = true; @property({ type: Boolean, reflect: true, attribute: true }) defaultFont: boolean = false; @property({ type: Object, attribute: false }) autoCompleteStyle: { width?: string; multiline?: boolean } | undefined = undefined; @property({ type: Boolean, reflect: true, attribute: true }) hideReadOnlyCursor: boolean = false; public view: EditorView; public viewState:EditorState; private timeOut: any = null; private resizeObserver?: ResizeObserver; // Compartments for initialiazing and switching extensions private lineNumbersComp = new Compartment(); private restrictSingleLineComp = new Compartment(); private readOnlyComp = new Compartment(); private customCompletionComp = new Compartment(); private placeholderComp = new Compartment(); private defaultSyntaxHighlightingComp = new Compartment(); private themeComp = new Compartment(); private autoCompletionComp = new Compartment(); private autoCompleteStyleComp = new Compartment(); private isSpacePressed:boolean = false; /** * The styles for CodeEditor * @remarks If you are extending this class you can extend the base styles with super. Eg `return [super(), myCustomStyles]` */ public static get styles(): CSSResultArray { return [styles]; } connectedCallback(): void { super.connectedCallback(); this.emit('nile-init',undefined,false); } disconnectedCallback(): void { super.disconnectedCallback(); this.resizeObserver?.disconnect(); this.view.destroy() this.emit('nile-destroy',undefined,false); } firstUpdated() { this.createNewView() if (this.autoCompleteStyle?.width) { this.resizeObserver = new ResizeObserver(entries => { for (const entry of entries) { const width = entry.contentRect.width; this.updateAutocompleteWidth(width); } }); this.resizeObserver.observe(this.codeEditor); } this.emit('nile-after-init',{ codeMirrorInstance: this.view, createNewView: this.createNewView, insertAtCursor: this.insertBetweenCode }, false ) } getTabCompletionKeymap() { return this.tabCompletion ? keymap.of([ { key: 'Tab', run: acceptCompletion, } ]) : []; } protected updated(changedProperties: PropertyValueMap | Map): void{ super.updated(changedProperties); if (changedProperties.has('value') && this.view.state.doc.toString()!=this.value) { // Editor has already been initialized, update its state this.singleLineMultiLineToggle(); } if (changedProperties.has('multiline')) { this.view.dispatch({ effects: [ this.lineNumbersComp.reconfigure(this.getLineNumbersExension()), this.restrictSingleLineComp.reconfigure(this.getSingleLineExtension()) ], }) this.singleLineMultiLineToggle(); } if (changedProperties.has('readonly')) { this.view.dispatch({ effects: [ this.readOnlyComp.reconfigure(this.getReadOnlyExtension()), ] }) } if (changedProperties.has('disabled') && this.view) { this.view.dispatch({ effects: [ this.readOnlyComp.reconfigure(this.getReadOnlyExtension()), ] }) } if (changedProperties.has('aboveCursor')) { this.view.dispatch({ effects: [ this.autoCompletionComp.reconfigure( autocompletion({ aboveCursor: this.aboveCursor }) ) ] }); } if (changedProperties.has('placeholder')) { this.view.dispatch({ effects: [ this.placeholderComp.reconfigure(this.getPlaceholderExtension()), ] }) } if (changedProperties.has('lineNumbers') || changedProperties.has('lineNumbersMultiline')) { this.view.dispatch({ effects: [ this.lineNumbersComp.reconfigure(this.getLineNumbersExension()), ] }) } if(changedProperties.has('customAutoCompletions') || changedProperties.has('customCompletionsPaths')){ this.view.dispatch({ effects: [ this.customCompletionComp.reconfigure(javascriptLanguage.data.of({ autocomplete: this.customAutocomplete })) ] }) } if(changedProperties.has('disableSyntaxHighlighting')){ this.view.dispatch({ effects: [ this.defaultSyntaxHighlightingComp.reconfigure(this.getDefaultSyntaxHighlightingExtension()) ] }) } if(changedProperties.has('customThemeCSS')){ this.view.dispatch({ effects: [ this.themeComp.reconfigure(this.getCustomThemeExtension()) ] }) } if(changedProperties.has('autoCompleteStyle') && this.view){ if (this.autoCompleteStyle?.width) { if (!this.resizeObserver) { this.resizeObserver = new ResizeObserver(entries => { for (const entry of entries) { const width = entry.contentRect.width; this.updateAutocompleteWidth(width); } }); this.resizeObserver.observe(this.codeEditor); } } else { this.resizeObserver?.disconnect(); this.resizeObserver = undefined; } this.view.dispatch({ effects: [ this.autoCompleteStyleComp.reconfigure(this.getAutoCompleteStyleExtension()) ] }) } } public render(): TemplateResult { const hasErrorMessage = !!this.errorMessage; const hasError = !!this.error; const noborder = !!this.noborder; const noScrollbar = !this.hasScroller return html`
${this.expandable && !this.disabled ? html`
` : ''}
${hasErrorMessage ? html` ${this.errorMessage} ` : ``}`; } createNewView(emitEvent=true){ if(this.view) this.view.destroy(); this.createState() this.view = new EditorView({ state: this.viewState, parent: this.codeEditor }); this.view.dom.addEventListener('keydown', (e: KeyboardEvent) => { this.checkForSpaceKey(e); this.handleEscapeKey(e); }, true); if (emitEvent) this.emit('nile-after-update', { createNewView: this.createNewView, codeMirrorInstance: this.view, }, false ); } checkForSpaceKey(e: KeyboardEvent) { if (e.code === 'Space' && !e.ctrlKey) { this.isSpacePressed = true; } else { this.isSpacePressed = false; } } public closeCompletion() { if (this.view) { closeCompletion(this.view); } } handleEscapeKey(e: KeyboardEvent) { if (e.key === 'Escape' && this.view && completionStatus(this.view.state) !== null) { e.stopPropagation(); e.preventDefault(); closeCompletion(this.view); } } createState(){ const lineNumbersExtension = this.lineNumbersComp.of(this.getLineNumbersExension()); const readOnlyExtension = this.readOnlyComp.of(this.getReadOnlyExtension()); const restrictSingleLineExtension = this.restrictSingleLineComp.of(this.getSingleLineExtension()) const placeholderExtension = this.placeholderComp.of(this.getPlaceholderExtension()) const customThemeExtension=this.themeComp.of(this.getCustomThemeExtension()); const defaultSyntaxHighlightingExtension = this.defaultSyntaxHighlightingComp.of(this.getDefaultSyntaxHighlightingExtension()); const autoCompleteStyleExtension = this.autoCompleteStyleComp.of(this.getAutoCompleteStyleExtension()); const language = this.getLanguageExtension() const customAutoCompletions = this.customCompletionComp.of(javascriptLanguage.data.of({ autocomplete: this.customAutocomplete })); this.viewState = EditorState.create({ doc: !this.multiline ? convertToSingleLine(this.value) : this.value, extensions: [ basicSetup({ highlightActiveLine: false, foldGutter: this.enableFoldGutters, enableSearch:this.enableSearch }), lineNumbersExtension, readOnlyExtension, restrictSingleLineExtension, customAutoCompletions, placeholderExtension, defaultSyntaxHighlightingExtension, this.autoCompletionComp.of( autocompletion({ aboveCursor: this.aboveCursor }) ), language, customThemeExtension, autoCompleteStyleExtension, this.getTabCompletionKeymap(), EditorView.updateListener.of((v: ViewUpdate) => { if (v.docChanged) { this.debounce ? this.emitAfterTimeout({ value: this.view.state.doc.toString() }) : this.emit('nile-change', { value: this.view.state.doc.toString() }) } }), EditorView.domEventHandlers({ focus: () => this.dispatchEvent(new Event('nile-focus')), blur: () => this.dispatchEvent(new Event('nile-blur')), }), ], }); return this.viewState } /** * Custom autocomplete handler for code editor suggestions * @param context CompletionContext from CodeMirror * @returns CompletionResult with suggestions or null if no suggestions */ customAutocomplete = (context: CompletionContext): CompletionResult | null => { // Getting the valid last line, last text from the code editor const text = context.state.doc.sliceString(0, context.pos); const lastWord = text.split('\n').at(-1)?.split(' ').at(-1) || ''; const [textBeforeCursor, baseTextAfterSeperation] = splitStringAtLastSeparator(lastWord); return this.getNestedSuggestions(context, textBeforeCursor, baseTextAfterSeperation) || this.getTopLevelSuggestions(context, textBeforeCursor); }; /** * Gets nested property suggestions based on the current path * @param context CompletionContext from CodeMirror * @param textBeforeCursor Text before cursor position * @param baseTextAfterSeperation Text after the last separator (. or [) * @returns CompletionResult with nested suggestions or null */ getNestedSuggestions(context: CompletionContext, textBeforeCursor: string, baseTextAfterSeperation: string) { // Return early if not a valid path or not ending with . or [ if (!isValidPath(textBeforeCursor) || !['.', '['].includes(textBeforeCursor.at(-1)!)) { return null; } const path = parsePath(textBeforeCursor); if (!path) return null; const textAfterSeperation = baseTextAfterSeperation.replace(/["'\[]/g, ''); const isInString = textAfterSeperation !== baseTextAfterSeperation; const isBracket = textBeforeCursor.at(-1) === '['; // Return null if we're in a string after a dot if (textBeforeCursor.at(-1) === '.' && isInString) return null; // Get nested properties and filter by text after separation if it exists let resolved = resolveNestedProperties(this.customAutoCompletions, path); if (!resolved || typeof resolved !== 'object') return null; if (textAfterSeperation) { resolved = Object.fromEntries( Object.entries(resolved).filter(([key]) => key.toLowerCase().startsWith(textAfterSeperation.toLowerCase()) ) ); } return { from: context.pos - textAfterSeperation.length, options: Object.keys(resolved).map(key => ({ label: key, type: 'property', info: `Key of ${path[path.length - 1]}`, apply: !this.allowVariableInCustomSuggestion && (isBracket && !isInString) ? `'${key}'` : key, boost: 999 })) }; } /** * Gets top level suggestions based on custom completions and paths * @param context CompletionContext from CodeMirror * @param textBeforeCursor Text before cursor position * @returns CompletionResult with top level suggestions or null */ getTopLevelSuggestions(context: CompletionContext,textBeforeCursor:string){ const baseMatch: any = textBeforeCursor.match(/([a-zA-Z_$][\w$]*)$/); if (!baseMatch) { const trimmedText = textBeforeCursor.trim(); if (trimmedText === '' && !this.isSpacePressed) { const optionsList = Object.keys(this.customAutoCompletions).filter(key => Object.keys(this.customAutoCompletions[key]).length ); const options = optionsList.map((key) => ({ label: key, type: 'property', apply: key, boost: 999 })); if(this.customCompletionsPaths.length){ this.customCompletionsPaths.forEach(path => { options.push({ label: ''+path, type: 'property', apply: ''+path, boost: 998 }); }); } return { from: context.pos, options: options }; } return null; } const optionsList = Object.keys(this.customAutoCompletions).filter(key => Object.keys(this.customAutoCompletions[key]).length && key.toLowerCase().startsWith(textBeforeCursor.toLowerCase()) ); const options=optionsList.map((key) => ({ label: key, type: 'property', apply: key, boost: 999 })) if(this.customCompletionsPaths.length){ this.customCompletionsPaths .filter(path=>path.toLocaleLowerCase().includes(textBeforeCursor.toLocaleLowerCase())) .forEach(path=>{ options.push({ label: ''+path, type: 'property', apply: ''+path, boost: 998 }) }) } return { from: context.pos - baseMatch[1].length, options: options } } emitAfterTimeout(value:any){ if(this.timeOut) clearTimeout(this.timeOut); this.timeOut=setTimeout(()=> this.emit('nile-change', value, false), this.debounceTimeout) } public focusAtPosition(pos: number=this.view.state.doc.toString().length): void { if (this.view) { this.view.dispatch({ selection: { anchor: pos }, }); this.view.focus(); } } public insertBetweenCode=(text: string) => { const transaction = this.view.state.changeByRange(range => { const { from, to } = range; return { changes: { from:from, to, insert: text }, range }; }); this.view.dispatch(transaction); } singleLineMultiLineToggle() { this.view.dispatch({ changes: { from: 0, to: this.view.state.doc.length, insert: !this.multiline ? convertToSingleLine(this.value) : this.value, }, }); } //EXTENSION CONFIGURATIONS getLineNumbersExension() { return (!this.multiline && this.lineNumbers) || (this.multiline && this.lineNumbersMultiline) ? lineNumbers() : []; } getLanguageExtension():Extension{ switch(this.language){ case 'sql': return sql(); case 'json': return json(); case 'html': return htmlLang(); default: return javascript(); } } getReadOnlyExtension() { if (this.disabled) { return [ EditorState.readOnly.of(true), EditorView.editable.of(false), ]; } if (this.readonly) { const extensions = [EditorState.readOnly.of(true)]; if (this.hideReadOnlyCursor) { extensions.push(EditorView.theme(readOnlyTheme)); } return extensions; } return []; } getSingleLineExtension() { return !this.multiline ? EditorState.transactionFilter.of(tr => tr.newDoc.lines > 1 ? [] : tr ) : []; } getPlaceholderExtension(){ return this.placeholder ? placeholder(this.placeholder) : []; } getDefaultSyntaxHighlightingExtension(){ return !this.disableSyntaxHighlighting ? syntaxHighlighting(defaultHighlightStyle, { fallback: true }):[] } getCustomThemeExtension(): Extension { if(this.customThemeCSS) { if(this.defaultFont){ return [EditorView.theme(this.customThemeCSS as { [selector: string]: StyleSpec }), EditorView.theme(customisedThemeCss),EditorView.theme(fontFamily)]; } return [EditorView.theme(this.customThemeCSS as { [selector: string]: StyleSpec }), EditorView.theme(customisedThemeCss)]; } if(this.defaultFont){ return [EditorView.theme(customisedThemeCss), EditorView.theme(fontFamily)]; } return [EditorView.theme(customisedThemeCss)]; } getAutoCompleteStyleExtension(width?: number): Extension { if (this.autoCompleteStyle?.width) { const containerWidth = width || (this.codeEditor ? this.codeEditor.getBoundingClientRect().width : 300); const widthPercentage = parseFloat(this.autoCompleteStyle.width.replace('%', '')); const codeEditorWidth = (containerWidth * widthPercentage) / 100; const multilineStyles: { [key: string]: string } = this.autoCompleteStyle.multiline ? { overflow: "visible", textOverflow: "unset", whiteSpace: "normal", wordWrap: "break-word" } : { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }; return [ tooltips({ position: "fixed", tooltipSpace: () => { const rect = this.codeEditor.getBoundingClientRect(); return { left: rect.left, top: 0, right: rect.right, bottom: 1000 }; } }), EditorView.theme({ ".cm-tooltip.cm-tooltip-autocomplete": { maxWidth: (codeEditorWidth - 15) + "px !important" }, ".cm-tooltip.cm-tooltip-autocomplete > ul > li": { ...multilineStyles, maxWidth: "100%" }, ".cm-tooltip.cm-tooltip-autocomplete > ul": { minWidth: "unset !important" } }) ]; } return []; } private updateAutocompleteWidth(width: number) { if (this.view) { this.view.dispatch({ effects: this.autoCompleteStyleComp.reconfigure(this.getAutoCompleteStyleExtension(width)) }); } } restrictSingleLine() { return EditorState.transactionFilter.of(tr => tr.newDoc.lines > 1 ? [] : tr ); } /* #endregion */ } export default NileCodeEditor; declare global { interface HTMLElementTagNameMap { 'nile-code-editor': NileCodeEditor; } } /** * Parses a string path into an array of keys representing nested object access * @param text The path string to parse (e.g. "foo.bar[0].baz") * @returns Array of keys if valid path, null otherwise * @example * parsePath("foo.bar[0]") // returns ["foo", "bar", "0"] * parsePath("invalid") // returns null */ function parsePath(text: string) { const regex = /([a-zA-Z_$][\w$]*)(\[(?:[^\]]+)\]|\.[a-zA-Z_$][\w$]*)*/g; const matches = [...text.matchAll(regex)]; if (matches.length > 0) { const base = matches[0][1]; // The base object name const keys = [base]; // Extract keys from dot or bracket notation const pathMatches = text.match(/\[(.*?)\]|\.(\w+)/g) || []; for (const match of pathMatches) { if (match.startsWith('[')) { keys.push(match.slice(1, -1).replace(/['"]/g, '')); // Remove brackets and quotes } else if (match.startsWith('.')) { keys.push(match.slice(1)); } } return keys; } return null; }; /** * Splits a path string at the last separator (. or [) * @param input The path string to split * @returns Array containing [path up to last separator, remainder after separator] * @example * splitStringAtLastSeparator("foo.bar[0]") // returns ["foo.bar[", "0"] */ function splitStringAtLastSeparator(input:string) { const lastSeparatorIndex = Math.max(input.lastIndexOf('.'), input.lastIndexOf('[')); if (lastSeparatorIndex === -1) return [input, '']; return [input.slice(0, lastSeparatorIndex + 1), input.slice(lastSeparatorIndex + 1)]; } /** * Traverses an object using an array of keys to access nested properties * @param obj The object to traverse * @param keys Array of keys defining the path to the desired property * @returns The value at the specified path, or null if path is invalid * @example * resolveNestedProperties({foo: {bar: 123}}, ["foo", "bar"]) // returns 123 */ function resolveNestedProperties (obj:any, keys:any[]){ return keys.reduce((acc, key) => { if (acc && typeof acc === 'object') { return acc[key]; } return null; }, obj); }; /** * Validates if a string represents a valid object path format * @param path The path string to validate * @returns Boolean indicating if path format is valid * @example * isValidPath("foo.bar[0]") // returns true * isValidPath("foo..bar") // returns false */ function isValidPath(path: string) { // Regex to validate the format of the string const regex = /^([a-zA-Z_$][\w$]*)(\.[a-zA-Z_$][\w$]*|\[\s*(['"]?[a-zA-Z0-9_$]*['"]?)\s*\])*([\.\[])?$/; // Test the string against the regex return regex.test(path); } /** * Converts multi-line code into a single line by removing line breaks and extra whitespace * @param code The code string to convert * @returns Single line version of the code * @example * convertToSingleLine("foo\n bar") // returns "foo bar" */ function convertToSingleLine(code: string) { if (!code) return ''; // Remove line breaks and unnecessary whitespace return code.replace(/\s+/g, ' ').trim(); }