blob: a167198da6ab432905d4327b6b1c8be8443722c1 [file] [log] [blame]
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());
}
}