493 lines
17 KiB
TypeScript
493 lines
17 KiB
TypeScript
import './style.css'
|
|
import { EditorView } from "codemirror";
|
|
import { editorSetup } from './EditorSetup';
|
|
import { EditorState, Compartment } from "@codemirror/state";
|
|
import { javascript } from "@codemirror/lang-javascript";
|
|
import { Clock } from './Clock'
|
|
import { vim } from "@replit/codemirror-vim";
|
|
import { AppSettings } from './AppSettings';
|
|
import { ViewUpdate } from '@codemirror/view';
|
|
import {
|
|
highlightSelection,
|
|
unhighlightSelection,
|
|
rangeHighlighting
|
|
} from "./highlightSelection";
|
|
import { UserAPI } from './API';
|
|
import { Extension } from '@codemirror/state';
|
|
import { Universes, File, template_universe } from './AppSettings';
|
|
import { tryEvaluate } from './Evaluator';
|
|
|
|
|
|
|
|
export class Editor {
|
|
|
|
// Data structures for editor text management
|
|
universes: Universes
|
|
selected_universe: string
|
|
local_index: number = 1
|
|
editor_mode: 'global' | 'local' | 'init' = 'local'
|
|
|
|
settings = new AppSettings()
|
|
editorExtensions: Extension[] = []
|
|
userPlugins: Extension[] = []
|
|
state: EditorState
|
|
api: UserAPI
|
|
|
|
// Audio stuff
|
|
audioContext: AudioContext
|
|
view: EditorView
|
|
clock: Clock
|
|
|
|
// Transport elements
|
|
play_buttons: HTMLButtonElement[] = [
|
|
document.getElementById('play-button-1') as HTMLButtonElement,
|
|
document.getElementById('play-button-2') as HTMLButtonElement
|
|
]
|
|
pause_buttons: HTMLButtonElement[] = [
|
|
document.getElementById('pause-button-1') as HTMLButtonElement,
|
|
document.getElementById('pause-button-2') as HTMLButtonElement
|
|
]
|
|
clear_buttons: HTMLButtonElement[] = [
|
|
document.getElementById('clear-button-1') as HTMLButtonElement,
|
|
document.getElementById('clear-button-2') as HTMLButtonElement
|
|
]
|
|
|
|
// Script selection elements
|
|
local_button: HTMLButtonElement = document.getElementById('local-button') as HTMLButtonElement
|
|
global_button: HTMLButtonElement = document.getElementById('global-button') as HTMLButtonElement
|
|
init_button: HTMLButtonElement = document.getElementById('init-button') as HTMLButtonElement
|
|
universe_viewer: HTMLDivElement = document.getElementById('universe-viewer') as HTMLDivElement
|
|
|
|
// Buffer modal
|
|
buffer_modal: HTMLDivElement = document.getElementById('modal-buffers') as HTMLDivElement
|
|
buffer_search: HTMLInputElement = document.getElementById('buffer-search') as HTMLInputElement
|
|
settings_modal: HTMLDivElement = document.getElementById('modal-settings') as HTMLDivElement
|
|
|
|
// Local script tabs
|
|
local_script_tabs: HTMLDivElement = document.getElementById('local-script-tabs') as HTMLDivElement
|
|
|
|
constructor() {
|
|
|
|
|
|
|
|
// ================================================================================
|
|
// Loading the universe from local storage
|
|
// ================================================================================
|
|
|
|
this.selected_universe = "Default";
|
|
this.universe_viewer.innerHTML = `Topos: ${this.selected_universe}`
|
|
this.universes = this.settings.universes
|
|
|
|
// ================================================================================
|
|
// Audio context and clock
|
|
// ================================================================================
|
|
|
|
this.audioContext = new AudioContext({ sampleRate: 44100, latencyHint: 0.000001});
|
|
this.clock = new Clock(this, this.audioContext);
|
|
|
|
// ================================================================================
|
|
// User API
|
|
// ================================================================================
|
|
|
|
this.api = new UserAPI(this);
|
|
|
|
// ================================================================================
|
|
// CodeMirror Management
|
|
// ================================================================================
|
|
|
|
this.editorExtensions = [
|
|
editorSetup,
|
|
rangeHighlighting(),
|
|
javascript(),
|
|
EditorView.updateListener.of((v:ViewUpdate) => {
|
|
// This is the event listener for the editor
|
|
}),
|
|
...this.userPlugins
|
|
]
|
|
|
|
let dynamicPlugins = new Compartment;
|
|
this.state = EditorState.create({
|
|
extensions: [
|
|
...this.editorExtensions,
|
|
EditorView.lineWrapping,
|
|
dynamicPlugins.of(this.userPlugins)
|
|
],
|
|
doc: this.universes[this.selected_universe].locals[this.local_index].candidate
|
|
})
|
|
|
|
this.view = new EditorView({
|
|
parent: document.getElementById('editor') as HTMLElement,
|
|
state: this.state
|
|
});
|
|
|
|
// ================================================================================
|
|
// Application event listeners
|
|
// ================================================================================
|
|
|
|
document.addEventListener('keydown', (event: KeyboardEvent) => {
|
|
|
|
// TAB should do nothing
|
|
if (event.key === 'Tab') {
|
|
event.preventDefault();
|
|
}
|
|
|
|
// Ctrl + Shift + V: Vim Mode
|
|
if ((event.key === 'v' || event.key === 'V') && event.ctrlKey && event.shiftKey) {
|
|
this.settings.vimMode = !this.settings.vimMode
|
|
event.preventDefault();
|
|
this.userPlugins = this.settings.vimMode ? [] : [vim()]
|
|
this.view.dispatch({
|
|
effects: dynamicPlugins.reconfigure(this.userPlugins)
|
|
})
|
|
}
|
|
|
|
// Ctrl + Enter or Return: Evaluate the hovered code block
|
|
if ((event.key === 'Enter' || event.key === 'Return') && event.ctrlKey) {
|
|
event.preventDefault();
|
|
const code = this.getCodeBlock();
|
|
this.currentFile.candidate = this.view.state.doc.toString()
|
|
tryEvaluate(this, this.currentFile)
|
|
}
|
|
|
|
// Shift + Enter or Ctrl + E: evaluate the line
|
|
if ((event.key === 'Enter' && event.shiftKey) || (event.key === 'e' && event.ctrlKey)) {
|
|
event.preventDefault(); // Prevents the addition of a new line
|
|
this.currentFile.candidate = this.view.state.doc.toString()
|
|
const code = this.getSelectedLines();
|
|
}
|
|
|
|
// This is the modal to switch between universes
|
|
if (event.ctrlKey && event.key === "b") {
|
|
this.openBuffersModal()
|
|
}
|
|
|
|
// This is the modal that opens up the settings
|
|
if (event.shiftKey && event.key === "Escape") {
|
|
this.openSettingsModal()
|
|
}
|
|
|
|
|
|
});
|
|
|
|
// ================================================================================
|
|
// Interface buttons
|
|
// ================================================================================
|
|
|
|
let tabs = document.querySelectorAll('[id^="tab-"]');
|
|
// Iterate over the tabs with an index
|
|
for (let i = 0; i < tabs.length; i++) {
|
|
tabs[i].addEventListener('click', (event) => {
|
|
|
|
// Updating the CSS accordingly
|
|
tabs[i].classList.add('bg-orange-300')
|
|
for (let j = 0; j < tabs.length; j++) {
|
|
if (j != i) tabs[j].classList.remove('bg-orange-300')
|
|
}
|
|
this.currentFile.candidate = this.view.state.doc.toString()
|
|
|
|
let tab = event.target as HTMLElement
|
|
let tab_id = tab.id.split('-')[1]
|
|
this.local_index = parseInt(tab_id)
|
|
this.updateEditorView()
|
|
})
|
|
}
|
|
|
|
this.play_buttons.forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
this.setButtonHighlighting('play', true)
|
|
this.clock.start()
|
|
})
|
|
})
|
|
|
|
this.clear_buttons.forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
this.setButtonHighlighting('clear', true)
|
|
if (confirm('Do you want to reset the current universe?')) {
|
|
this.universes[this.selected_universe] = template_universe
|
|
this.updateEditorView()
|
|
}
|
|
})
|
|
})
|
|
|
|
|
|
this.pause_buttons.forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
this.setButtonHighlighting('pause', true)
|
|
this.clock.pause()
|
|
})
|
|
})
|
|
|
|
this.local_button.addEventListener('click', () => this.changeModeFromInterface('local'))
|
|
this.global_button.addEventListener('click', () => this.changeModeFromInterface('global'))
|
|
this.init_button.addEventListener('click', () => this.changeModeFromInterface('init'))
|
|
|
|
this.buffer_search.addEventListener('keydown', (event) => {
|
|
if (event.key === "Enter") {
|
|
let query = this.buffer_search.value
|
|
if (query.length > 2 && query.length < 20) {
|
|
this.loadUniverse(query)
|
|
this.buffer_search.value = ""
|
|
this.closeBuffersModal()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
get global_buffer() {
|
|
return this.universes[this.selected_universe.toString()].global
|
|
}
|
|
|
|
get init_buffer() {
|
|
return this.universes[this.selected_universe.toString()].init
|
|
}
|
|
|
|
changeModeFromInterface(mode: 'global' | 'local' | 'init') {
|
|
|
|
const interface_buttons = [this.local_button, this.global_button, this.init_button]
|
|
|
|
let changeColor = (button: HTMLElement) => {
|
|
interface_buttons.forEach(button => {
|
|
// Get the child svg element of each button
|
|
let svg = button.children[0] as HTMLElement
|
|
if (svg.classList.contains('text-orange-300')) {
|
|
svg.classList.remove('text-orange-300')
|
|
svg.classList.add('text-white')
|
|
}
|
|
})
|
|
button.children[0].classList.add('text-orange-300')
|
|
}
|
|
|
|
if (mode === this.editor_mode) return
|
|
switch (mode) {
|
|
case 'local':
|
|
if (this.local_script_tabs.classList.contains('hidden')) {
|
|
this.local_script_tabs.classList.remove('hidden')
|
|
}
|
|
this.currentFile.candidate = this.view.state.doc.toString()
|
|
changeColor(this.local_button)
|
|
this.editor_mode = 'local';
|
|
break;
|
|
case 'global':
|
|
if (!this.local_script_tabs.classList.contains('hidden')) {
|
|
this.local_script_tabs.classList.add('hidden')
|
|
}
|
|
this.currentFile.candidate = this.view.state.doc.toString()
|
|
changeColor(this.global_button)
|
|
this.editor_mode = 'global';
|
|
break;
|
|
case 'init':
|
|
if (!this.local_script_tabs.classList.contains('hidden')) {
|
|
this.local_script_tabs.classList.add('hidden')
|
|
}
|
|
this.currentFile.candidate = this.view.state.doc.toString()
|
|
changeColor(this.init_button)
|
|
this.editor_mode = 'init';
|
|
break;
|
|
}
|
|
this.updateEditorView();
|
|
}
|
|
|
|
|
|
setButtonHighlighting(button: 'play' | 'pause' | 'clear', highlight: boolean) {
|
|
const possible_selectors = [
|
|
'[id^="play-button-"]',
|
|
'[id^="pause-button-"]',
|
|
'[id^="clear-button-"'
|
|
]
|
|
let selector: number;
|
|
switch (button) {
|
|
case 'play': selector = 0; break;
|
|
case 'pause': selector = 1; break;
|
|
case 'clear': selector = 2; break;
|
|
}
|
|
document.querySelectorAll(possible_selectors[selector]).forEach(button => {
|
|
if (highlight) button.children[0].classList.add('fill-orange-300')
|
|
});
|
|
// All other buttons must lose the highlighting
|
|
document.querySelectorAll(possible_selectors.filter(
|
|
(_, index) => index != selector).join(',')).forEach(button => {
|
|
button.children[0].classList.remove('fill-orange-300')
|
|
button.children[0].classList.remove('text-orange-300')
|
|
button.children[0].classList.remove('bg-orange-300')
|
|
});
|
|
}
|
|
|
|
unfocusPlayButtons() {
|
|
document.querySelectorAll('[id^="play-button-"]').forEach(button => {
|
|
button.children[0].classList.remove('fill-orange-300')
|
|
})
|
|
}
|
|
|
|
|
|
updateEditorView():void {
|
|
// Remove everything from the editor
|
|
this.view.dispatch({
|
|
changes: {
|
|
from: 0,
|
|
to: this.view.state.doc.toString().length,
|
|
insert:''
|
|
}
|
|
})
|
|
|
|
// Insert something
|
|
this.view.dispatch({
|
|
changes: {
|
|
from: 0,
|
|
insert: this.currentFile.candidate
|
|
}
|
|
});
|
|
}
|
|
|
|
get currentFile(): File {
|
|
switch (this.editor_mode) {
|
|
case 'global': return this.global_buffer;
|
|
case 'local': return this.universes[this.selected_universe].locals[this.local_index];
|
|
case 'init': return this.init_buffer;
|
|
}
|
|
}
|
|
|
|
loadUniverse(universeName: string) {
|
|
this.currentFile.candidate = this.view.state.doc.toString()
|
|
let editor = this;
|
|
|
|
function whichBuffer(editor: Editor): File {
|
|
switch (editor.editor_mode) {
|
|
case 'global': return editor.global_buffer
|
|
case 'local': return editor.universes[
|
|
editor.selected_universe].locals[editor.local_index]
|
|
case 'init': return editor.init_buffer
|
|
}
|
|
}
|
|
|
|
let selectedUniverse = universeName.trim()
|
|
if (this.universes[selectedUniverse] === undefined) {
|
|
this.universes[selectedUniverse] = template_universe
|
|
}
|
|
this.selected_universe = selectedUniverse
|
|
this.universe_viewer.innerHTML = `Topos: ${selectedUniverse}`
|
|
// We should also update the editor accordingly
|
|
this.view.dispatch({
|
|
changes: { from: 0, to: this.view.state.doc.toString().length, insert:'' }
|
|
})
|
|
this.view.dispatch({
|
|
changes: { from: 0, insert: this.currentFile.candidate }
|
|
});
|
|
}
|
|
|
|
getCodeBlock(): string {
|
|
// Capture the position of the cursor
|
|
let cursor = this.view.state.selection.main.head
|
|
const state = this.view.state;
|
|
const { head } = state.selection.main;
|
|
const currentLine = state.doc.lineAt(head);
|
|
let startLine = currentLine;
|
|
while (startLine.number > 1 && !/^\s*$/.test(state.doc.line(startLine.number - 1).text)) {
|
|
startLine = state.doc.line(startLine.number - 1);
|
|
}
|
|
let endLine = currentLine;
|
|
while (
|
|
endLine.number < state.doc.lines && !/^\s*$/.test(state.doc.line(endLine.number + 1).text)) {
|
|
endLine = state.doc.line(endLine.number + 1);
|
|
}
|
|
|
|
this.view.dispatch({selection: {anchor: 0 + startLine.from, head: endLine.to}});
|
|
highlightSelection(this.view);
|
|
|
|
setTimeout(() => {
|
|
unhighlightSelection(this.view)
|
|
this.view.dispatch({selection: {anchor: cursor, head: cursor}});
|
|
}, 200);
|
|
|
|
let result_string = state.doc.sliceString(startLine.from, endLine.to);
|
|
result_string = result_string.split('\n').map((line, index, lines) => {
|
|
const trimmedLine = line.trim();
|
|
if (index === lines.length - 1 || /^\s/.test(lines[index + 1]) || trimmedLine.startsWith('@')) {
|
|
return line;
|
|
} else {
|
|
return line + ';\\';
|
|
}
|
|
}).join('\n');
|
|
return result_string
|
|
}
|
|
|
|
getSelectedLines = (): string => {
|
|
const state = this.view.state;
|
|
const { from, to } = state.selection.main;
|
|
const fromLine = state.doc.lineAt(from);
|
|
const toLine = state.doc.lineAt(to);
|
|
this.view.dispatch({selection: {anchor: 0 + fromLine.from, head: toLine.to}});
|
|
// Release the selection and get the cursor back to its original position
|
|
|
|
// Blink the text!
|
|
highlightSelection(this.view);
|
|
|
|
setTimeout(() => {
|
|
unhighlightSelection(this.view)
|
|
this.view.dispatch({selection: {anchor: from, head: from}});
|
|
}, 200);
|
|
return state.doc.sliceString(fromLine.from, toLine.to);
|
|
}
|
|
|
|
openSettingsModal() {
|
|
if (document.getElementById('modal-settings')!.classList.contains('invisible')) {
|
|
document.getElementById('editor')!.classList.add('invisible')
|
|
document.getElementById('modal-settings')!.classList.remove('invisible')
|
|
} else {
|
|
this.closeSettingsModal();
|
|
}
|
|
}
|
|
|
|
closeSettingsModal() {
|
|
document.getElementById('editor')!.classList.remove('invisible')
|
|
document.getElementById('modal-settings')!.classList.add('invisible')
|
|
}
|
|
|
|
openBuffersModal() {
|
|
// If the modal is hidden, unhide it and hide the editor
|
|
if (document.getElementById('modal-buffers')!.classList.contains('invisible')) {
|
|
document.getElementById('editor')!.classList.add('invisible')
|
|
document.getElementById('modal-buffers')!.classList.remove('invisible')
|
|
document.getElementById('buffer-search')!.focus()
|
|
} else {
|
|
this.closeBuffersModal();
|
|
}
|
|
}
|
|
|
|
closeBuffersModal() {
|
|
// @ts-ignore
|
|
document.getElementById('buffer-search')!.value = ''
|
|
document.getElementById('editor')!.classList.remove('invisible')
|
|
document.getElementById('modal')!.classList.add('invisible')
|
|
document.getElementById('modal-buffers')!.classList.add('invisible')
|
|
}
|
|
}
|
|
|
|
const app = new Editor()
|
|
|
|
function startClock() {
|
|
document.getElementById('editor')!.classList.remove('invisible')
|
|
document.getElementById('modal')!.classList.add('hidden')
|
|
document.getElementById('modal-container')!.classList.remove('motion-safe:animate-pulse')
|
|
document.getElementById('start-button')!.removeEventListener('click', startClock);
|
|
document.removeEventListener('keydown', startOnEnter)
|
|
app.clock.start()
|
|
app.view.focus()
|
|
app.setButtonHighlighting('play', true)
|
|
}
|
|
|
|
function startOnEnter(e: KeyboardEvent) {
|
|
if (e.code === 'Enter' || e.code === "Space") startClock()
|
|
}
|
|
|
|
document.addEventListener('keydown', startOnEnter)
|
|
document.getElementById('start-button')!.addEventListener(
|
|
'click', startClock);
|
|
|
|
// When the user leaves the page, all the universes should be saved in the localStorage
|
|
window.addEventListener('beforeunload', () => {
|
|
// Iterate over all local files and set the candidate to the committed
|
|
app.currentFile.candidate = app.view.state.doc.toString()
|
|
app.currentFile.committed = app.view.state.doc.toString()
|
|
app.settings.saveApplicationToLocalStorage(app.universes)
|
|
return null;
|
|
}); |