| // Copyright 2022 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import * as ace from './ace'; |
| import {DEFAULT_INPUT, getAceMode, InputMode, Mode, OutputMode} from './mode'; |
| import {entries, values, wordWrap} from './util'; |
| |
| // Ace editor options that only apply to the output editor. |
| const OUTPUT_EDITOR_OPTIONS: ace.Options = { |
| readOnly: true, |
| // These highlights are just distracting and usually stay on the first line. |
| highlightActiveLine: false, |
| highlightGutterLine: false, |
| // For guides to be useful, we would have to infer the indentation size. |
| displayIndentGuides: false, |
| }; |
| |
| // Ace session options that only apply to output sessions that contain errors. |
| const ERROR_SESSION_OPTIONS: ace.Options = { |
| // Error messages are often printed on one long line. |
| wrap: true, |
| }; |
| |
| // Number of characters to wrap text at for annotation tooltips. |
| const ANNOTATION_TEXT_WRAP = 66; |
| |
| // The main data structure used by the Editors class. |
| type SessionMap = Record<InputMode, InputRecord>; |
| |
| // A map from input modes to serialized input sessions. The key type is string |
| // rather than InputMode because the set of input modes might have changed since |
| // the user last had the session map saved to local storage. |
| type SavedSessionMap = { |
| [inputMode: string]: ace.SavedSession; |
| }; |
| |
| interface InputRecord { |
| session: ace.Session; |
| // Partial because the output records are set as needed, not up front. |
| outputs: Partial<Record<OutputMode, OutputRecord>>; |
| } |
| |
| interface OutputRecord { |
| session: ace.Session; |
| options: Options; |
| inputAnnotations: ace.Annotation[]; |
| } |
| |
| // Options sent to the server. This corresonds to the Options type in server.go. |
| export type Options = { |
| [key: string]: any; |
| }; |
| |
| // An annotation received from the server. This corresponds to the Annotation |
| // type in server.go, and is different from the ace.Annotation type. |
| export interface Annotation { |
| line: number; // one-based |
| column: number; // one-based |
| kind: 'info' | 'warning' | 'error'; |
| text: string; |
| } |
| |
| // Editors manages state for the input and output Ace editors. |
| export class Editors { |
| readonly inputEditor: ace.Editor; |
| readonly outputEditor: ace.Editor; |
| readonly editorOptions: ace.Options; |
| readonly sessionOptions: ace.Options; |
| readonly sessions: SessionMap; |
| |
| constructor(sessions: SessionMap) { |
| // These HTML IDs are set in elm/Editors.elm. |
| this.inputEditor = Editors.newEditor('InputEditor'); |
| this.outputEditor = Editors.newEditor('OutputEditor'); |
| this.outputEditor.setOptions(OUTPUT_EDITOR_OPTIONS); |
| this.editorOptions = {}; |
| this.sessionOptions = {}; |
| this.sessions = sessions; |
| } |
| |
| static newEditor(htmlID: string): ace.Editor { |
| const editor = ace.edit(htmlID); |
| // We have our own settings menu in the app. |
| editor.commands.removeCommand('showSettingsMenu'); |
| // We use Ctrl-[ and Ctrl-] in Editors.elm to navigate editor tabs. |
| editor.commands.removeCommand('blockindent'); |
| editor.commands.removeCommand('blockoutdent'); |
| // Ctrl/Cmd-L interferes with the browser's address bar shortcut. |
| const gotoline = editor.commands.byName.gotoline; |
| gotoline.bindKey = {win: 'Ctrl-G', mac: 'Ctrl-G'}; |
| editor.commands.addCommand(gotoline); |
| // editor.commands.bindKey(ctrlG, gotoline); |
| // For accessiblity, allow users to "escape" the editor with a keyboard |
| // shortcut that makes Tab and Shift+Tab regain their normal behavior. |
| // https://www.w3.org/TR/2009/WD-wai-aria-practices-20090224/#richtext |
| const indentCommand = editor.commands.byName.indent; |
| const outdentCommand = editor.commands.byName.outdent; |
| const setTabMode = (indent: boolean): void => { |
| if (indent) { |
| editor.commands.addCommand(indentCommand); |
| editor.commands.addCommand(outdentCommand); |
| } else { |
| editor.commands.removeCommand('indent'); |
| editor.commands.removeCommand('outdent'); |
| } |
| }; |
| let currentTabMode = false; |
| setTabMode(currentTabMode); |
| editor.on('focus', () => { |
| setTabMode((currentTabMode = true)); |
| }); |
| editor.commands.addCommand({ |
| name: 'toggleTabMode', |
| bindKey: {win: 'Ctrl-M', mac: 'Ctrl-M'}, |
| exec: () => { |
| setTabMode((currentTabMode = !currentTabMode)); |
| }, |
| }); |
| return editor; |
| } |
| |
| newSession(mode: Mode): ace.Session { |
| const session = ace.createEditSession('', getAceMode(mode)); |
| session.setOptions(this.sessionOptions); |
| return session; |
| } |
| |
| toJSON(): SavedSessionMap { |
| const json: SavedSessionMap = {}; |
| for (const [inputMode, {session}] of entries(this.sessions)) { |
| json[inputMode] = ace.sessionToJSON(session); |
| } |
| return json; |
| } |
| |
| static fromJSON(json?: SavedSessionMap): Editors { |
| const sessions: Partial<SessionMap> = {}; |
| for (const [inputMode, defaultValue] of entries(DEFAULT_INPUT)) { |
| const savedSession = json?.[inputMode]; |
| // Load the saved session only if it is present and nonempty. |
| if (savedSession?.value) { |
| const session = ace.sessionFromJSON( |
| savedSession, |
| getAceMode(inputMode) |
| ); |
| sessions[inputMode] = {session, outputs: {}}; |
| } else { |
| const session = ace.createEditSession( |
| defaultValue, |
| getAceMode(inputMode) |
| ); |
| sessions[inputMode] = {session, outputs: {}}; |
| } |
| } |
| return new Editors(sessions as SessionMap); |
| } |
| |
| editorsDo(editorFn: (e: ace.Editor) => void) { |
| editorFn(this.inputEditor); |
| editorFn(this.outputEditor); |
| } |
| |
| sessionsDo(sessionFn: (s: ace.Session) => void) { |
| for (const {session, outputs} of values(this.sessions)) { |
| sessionFn(session); |
| for (const {session} of values(outputs)) { |
| sessionFn(session); |
| } |
| } |
| } |
| |
| showSessions(inputMode: InputMode, outputMode: OutputMode) { |
| const inputRecord = this.sessions[inputMode]; |
| const outputRecord = |
| inputRecord.outputs[outputMode] ?? |
| (inputRecord.outputs[outputMode] = { |
| session: this.newSession(outputMode), |
| options: {}, |
| inputAnnotations: [], |
| }); |
| inputRecord.session.setAnnotations(outputRecord.inputAnnotations); |
| this.inputEditor.setSession(inputRecord.session); |
| this.outputEditor.setSession(outputRecord.session); |
| } |
| |
| setOutput( |
| inputMode: InputMode, |
| outputMode: OutputMode, |
| content: string, |
| annotations: Annotation[], |
| errorTitle?: string |
| ) { |
| const inputRecord = this.sessions[inputMode]; |
| const outputRecord = inputRecord.outputs[outputMode]; |
| if (outputRecord == undefined) { |
| return; |
| } |
| const session = outputRecord.session; |
| if (errorTitle == undefined) { |
| session.setMode(getAceMode(outputMode)); |
| session.setOptions(this.sessionOptions); |
| session.setValue(content); |
| session.fidlboltErrorSession = false; |
| } else { |
| session.setMode('ace/mode/error'); |
| session.setOptions(ERROR_SESSION_OPTIONS); |
| session.setValue(errorTitle + ':\n\n' + content); |
| session.fidlboltErrorSession = true; |
| } |
| outputRecord.inputAnnotations = annotations.map( |
| ({line, column, kind, text}) => ({ |
| // Convert one-based to zero-based. |
| row: line - 1, |
| column: column - 1, |
| type: kind, |
| text: wordWrap(text, ANNOTATION_TEXT_WRAP), |
| }) |
| ); |
| inputRecord.session.setAnnotations(outputRecord.inputAnnotations); |
| } |
| |
| applySettings(settings: ace.Options) { |
| this.editorOptions.theme = 'ace/theme/' + settings.theme; |
| this.editorOptions.showPrintMargin = |
| (settings.printMarginColumn as number) > 0; |
| this.editorOptions.highlightGutterLine = settings.highlightActiveLine; |
| this.editorOptions.fontSize = |
| Math.max(1, settings.fontSize as number) + 'px'; |
| this.sessionOptions.tabSize = Math.max(1, settings.tabSize as number); |
| this.sessionOptions.navigateWithinSoftTabs = !settings.atomicSoftTabs; |
| if (settings.keyboardHandler === 'ace') { |
| this.editorOptions.keyboardHandler = null; |
| } else { |
| this.editorOptions.keyboardHandler = |
| 'ace/keyboard/' + settings.keyboardHandler; |
| } |
| for (const field of [ |
| 'displayIndentGuides', |
| 'foldStyle', |
| 'highlightActiveLine', |
| 'printMarginColumn', |
| 'relativeLineNumbers', |
| 'scrollPastEnd', |
| 'showGutter', |
| 'showInvisibles', |
| 'showLineNumbers', |
| 'showPrintMargin', |
| ]) { |
| this.editorOptions[field] = settings[field]; |
| } |
| for (const field of ['indentedSoftWrap', 'useSoftTabs', 'wrap']) { |
| this.sessionOptions[field] = settings[field]; |
| } |
| |
| this.inputEditor.setOptions(this.editorOptions); |
| this.outputEditor.setOptions({ |
| ...this.editorOptions, |
| ...OUTPUT_EDITOR_OPTIONS, |
| }); |
| this.sessionsDo(session => { |
| session.setOptions(this.sessionOptions); |
| if (session.fidlboltErrorSession) { |
| session.setOptions(ERROR_SESSION_OPTIONS); |
| } |
| }); |
| } |
| |
| resize() { |
| this.editorsDo(e => e.resize()); |
| } |
| } |