Files
topos/src/main.ts

652 lines
21 KiB
TypeScript

import { OscilloscopeConfig, runOscilloscope } from "./Visuals/Oscilloscope";
import { EditorState, Compartment } from "@codemirror/state";
import { scriptBlinkers } from "./Visuals/Blinkers";
import { javascript } from "@codemirror/lang-javascript";
import { markdown } from "@codemirror/lang-markdown";
import { Extension } from "@codemirror/state";
import { outputSocket } from "./IO/OSC";
import { getCodeMirrorTheme, switchToDebugTheme } from "./EditorSetup";
import {
initializeSelectedUniverse,
AppSettings,
Universe,
loadUniverserFromUrl,
} from "./FileManagement";
import { singleElements, buttonGroups, ElementMap, createDocumentationStyle } from "./DomElements";
import { registerFillKeys, registerOnKeyDown } from "./KeyActions";
import { installEditor } from "./EditorSetup";
import { documentation_factory, documentation_pages, showDocumentation, updateDocumentationContent } from "./Documentation";
import { EditorView } from "codemirror";
import { Clock } from "./Clock";
import { loadSamples, UserAPI } from "./API";
import * as oeis from "jisg";
import * as zpatterns from "zifferjs/src/patterns.ts";
import { makeArrayExtensions } from "./extensions/ArrayExtensions";
import "./style.css";
import { Universes, File } from "./FileManagement";
import { tryEvaluate } from "./Evaluator";
// @ts-ignore
import showdown from "showdown";
import { makeStringExtensions } from "./extensions/StringExtensions";
import { installInterfaceLogic } from "./InterfaceLogic";
import { installWindowBehaviors } from "./WindowBehavior";
import { makeNumberExtensions } from "./extensions/NumberExtensions";
import colors from "./colors.json";
// @ts-ignore
const images = import.meta.glob("./assets/*")
export class Editor {
// Universes and settings
settings: AppSettings = new AppSettings();
universes: Universes = {};
selected_universe: string = "Welcome";
fill: boolean = false;
local_index: number = 1;
// Editor logic
editor_mode: "global" | "local" | "init" | "notes" = "global";
hidden_interface: boolean = false;
fontSize!: Compartment;
withLineNumbers!: Compartment;
themeCompartment!: Compartment;
vimModeCompartment!: Compartment;
hoveringCompartment!: Compartment;
completionsCompartment!: Compartment;
chosenLanguage!: Compartment;
dynamicPlugins!: Compartment;
currentDocumentationPane: string = "introduction";
exampleCounter: number = 0;
exampleIsPlaying: boolean = false;
editorExtensions: Extension[] = [];
userPlugins: Extension[] = [];
state!: EditorState;
view!: EditorView;
selectedExample: string | null = "";
docs: { [key: string]: string } = {};
public _mouseX: number = 0;
public _mouseY: number = 0;
show_error: boolean = false;
currentThemeName: string = "Everblush";
buttonElements: Record<string, HTMLButtonElement[]> = {};
interface: ElementMap = {};
blinkTimeouts: Record<number, number> = {};
osc: OscilloscopeConfig = {
enabled: false,
color: "#fdba74",
thickness: 4,
refresh: 1,
fftSize: 1024,
orientation: "horizontal",
offsetX: 0,
offsetY: 0,
mode: "scope",
size: 1,
};
bindings: any[] = [];
documentationStyle: any = {};
// UserAPI
api: UserAPI;
// Audio stuff
audioContext: AudioContext;
clock: Clock;
dough_nudge: number = 20;
manualPlay: boolean = false;
isPlaying: boolean = false;
// OSC
outputSocket: WebSocket = outputSocket;
// Hydra
public hydra_backend: any;
public hydra: any;
constructor() {
/**
* This is the entry point of the application. The Editor instance is created when the page is loaded.
* It is responsible for:
* - Initializing the user interface
* - Loading the universe from local storage
* - Initializing the audio context and the clock
* - Building the user API
* - Building the documentation
* - Installing event listeners
* - Building the CodeMirror editor
* - Evaluating the init file
*/
// ================================================================================
// Build user interface
// ================================================================================
this.initializeElements();
this.initializeButtonGroups();
this.setCanvas(this.interface.feedback as HTMLCanvasElement);
this.setCanvas(this.interface.scope as HTMLCanvasElement);
this.setCanvas(this.interface.drawings as HTMLCanvasElement);
try {
this.loadHydraSynthAsync();
} catch (error) {
console.log("Couldn't start Hydra: ", error);
}
// ================================================================================
// Loading the universe from local storage
// ================================================================================
this.universes = {
...this.settings.universes,
//...template_universes,
};
initializeSelectedUniverse(this);
// ================================================================================
// Audio context and clock
// ================================================================================
this.audioContext = new AudioContext({ latencyHint: "playback" });
this.clock = new Clock(this, this.audioContext);
// ================================================================================
// User API
// ================================================================================
this.api = new UserAPI(this);
makeArrayExtensions(this.api);
makeStringExtensions(this.api);
makeNumberExtensions(this.api);
// Passing the API to the User
Object.entries(this.api).forEach(([name, value]) => {
(globalThis as Record<string, any>)[name] = value;
});
// Passing OEIS generators to the User
Object.entries(oeis).forEach(([name, value]) => {
(globalThis as Record<string, any>)[name] = value;
});
// Passing ziffers sequences to the User
Object.entries(zpatterns).forEach(([name, value]) => {
(globalThis as Record<string, any>)[name] = value;
});
// ================================================================================
// Building Documentation
// ================================================================================
let pre_loading = async () => {
await loadSamples();
};
pre_loading().then(() => {
this.docs = documentation_factory(this);
});
// ================================================================================
// Application event listeners
// ================================================================================
registerFillKeys(this);
registerOnKeyDown(this);
installInterfaceLogic(this);
scriptBlinkers();
// ================================================================================
// Building CodeMirror Editor
// ================================================================================
installEditor(this);
runOscilloscope(this.interface.scope as HTMLCanvasElement, this);
// First evaluation of the init file
tryEvaluate(this, this.universes[this.selected_universe.toString()].init);
// Changing to global when opening
this.changeModeFromInterface("global");
// Loading universe from URL (if needed)
loadUniverserFromUrl(this);
// Set the color scheme for the application
let available_themes = Object.keys(colors);
if (this.settings.theme in available_themes) {
this.readTheme(this.settings.theme);
} else {
this.settings.theme = "Everblush";
this.readTheme(this.settings.theme);
}
this.documentationStyle = createDocumentationStyle(this);
this.bindings = Object.keys(this.documentationStyle).map((key) => ({
type: "output",
regex: new RegExp(`<${key}([^>]*)>`, "g"),
//@ts-ignore
replace: (match, p1) => `<${key} class="${this.documentationStyle[key]}" ${p1}>`,
}));
// Get documentation id from hash parameter
const document_id = window.location.hash.slice(1);
if (document_id && document_id !== "" && documentation_pages.includes(document_id)) {
this.currentDocumentationPane = document_id
updateDocumentationContent(this, this.bindings);
showDocumentation(this);
}
}
private getBuffer(type: string): any {
/**
* Retrieves the buffer based on the specified type.
* @param type - The type of buffer to retrieve.
* @returns The buffer object.
*/
const universe = this.universes[this.selected_universe.toString()];
return type === "locals"
? universe[type][this.local_index]
: universe[type as keyof Universe];
}
get note_buffer() {
return this.getBuffer("notes");
}
get example_buffer() {
return this.getBuffer("example");
}
get global_buffer() {
return this.getBuffer("global");
}
get init_buffer() {
return this.getBuffer("init");
}
get local_buffer() {
return this.getBuffer("locals");
}
updateKnownUniversesView = () => {
/**
* Updates the known universes view.
* This function generates and populates a list of known universes based on the data stored in the 'universes' property.
* It retrieves the necessary HTML elements and template, creates the list, and attaches event listeners to the generated items.
* If any required elements or templates are missing, warning messages are logged and the function returns early.
*/
let itemTemplate = document.getElementById(
"ui-known-universe-item-template",
) as HTMLTemplateElement;
if (!itemTemplate) {
return;
}
let existing_universes = document.getElementById("existing-universes");
if (!existing_universes) {
return;
}
let list = document.createElement("ul");
list.className =
"lg:h-80 lg:text-normal text-normal h-auto lg:w-80 w-auto lg:pb-2 lg:pt-2 overflow-y-scroll text-brightwhite bg-background lg:mb-4 border rounded-lg";
list.append(
...Object.keys(this.universes).map((it) => {
let item = itemTemplate.content.cloneNode(true) as DocumentFragment;
let api = window as unknown as UserAPI;
item.querySelector(".universe-name")!.textContent = it;
item
.querySelector(".load-universe")
?.addEventListener("click", () => api._loadUniverseFromInterface(it));
item
.querySelector(".delete-universe")
?.addEventListener("click", () =>
api._deleteUniverseFromInterface(it),
);
return item;
}),
);
existing_universes.innerHTML = "";
existing_universes.append(list);
};
changeToLocalBuffer(i: number) {
/**
* Changes the local buffer based on the provided index.
* Updates the CSS accordingly by adding a specific class to the selected tab and removing it from other tabs.
* Updates the local index and updates the editor view.
*
* @param i The index of the tab to change the local buffer to.
*/
const tabs = document.querySelectorAll('[id^="tab-"]');
const tab = tabs[i] as HTMLElement;
tab.classList.add("bg-foreground");
for (let j = 0; j < tabs.length; j++) {
if (j != i) tabs[j].classList.remove("bg-foreground");
}
let tab_id = tab.id.split("-")[1];
this.local_index = parseInt(tab_id);
this.updateEditorView();
}
changeModeFromInterface(mode: "global" | "local" | "init" | "notes") {
/**
* Changes the mode of the interface.
*
* @param mode - The mode to change to. Can be one of "global", "local", "init", or "notes".
*/
const interface_buttons: HTMLElement[] = [
this.interface.local_button,
this.interface.global_button,
this.interface.init_button,
this.interface.note_button,
];
let changeColor = (button: HTMLElement) => {
interface_buttons.forEach((button) => {
let svg = button.children[0] as HTMLElement;
if (svg.classList.contains("text-foreground_selection")) {
svg.classList.remove("text-foreground_selection");
button.classList.remove("text-foreground_selection");
}
});
button.children[0].classList.remove("text-white");
button.children[0].classList.add("text-foreground_selection");
button.classList.add("text-foreground_selection");
button.classList.add("fill-foreground_selection");
};
switch (mode) {
case "local":
if (this.interface.local_script_tabs.classList.contains("hidden")) {
this.interface.local_script_tabs.classList.remove("hidden");
}
this.editor_mode = "local";
this.local_index = 0;
document.getElementById("editor")!.style.height = "calc(100% - 100px)";
this.changeToLocalBuffer(this.local_index);
changeColor(this.interface.local_button);
break;
case "global":
if (!this.interface.local_script_tabs.classList.contains("hidden")) {
this.interface.local_script_tabs.classList.add("hidden");
}
this.editor_mode = "global";
document.getElementById("editor")!.style.height = "100%";
changeColor(this.interface.global_button);
break;
case "init":
if (!this.interface.local_script_tabs.classList.contains("hidden")) {
this.interface.local_script_tabs.classList.add("hidden");
}
this.editor_mode = "init";
changeColor(this.interface.init_button);
break;
case "notes":
if (!this.interface.local_script_tabs.classList.contains("hidden")) {
this.interface.local_script_tabs.classList.add("hidden");
}
this.editor_mode = "notes";
changeColor(this.interface.note_button);
break;
}
// If the editor is in notes mode, we need to update the selectedLanguage
this.view.dispatch({
effects: this.chosenLanguage.reconfigure(
this.editor_mode == "notes" ? [markdown()] : [javascript()],
),
});
this.updateEditorView();
}
setButtonHighlighting(
button: "play" | "pause" | "stop" | "clear",
highlight: boolean,
) {
/**
* Sets the highlighting for a specific button.
*
* @param button - The button to highlight ("play", "pause", "stop", or "clear").
* @param highlight - A boolean indicating whether to highlight the button or not.
*/
document.getElementById("play-label")!.textContent =
button !== "pause" ? "Pause" : "Play";
if (button !== "pause") {
document.getElementById("pause-icon")!.classList.remove("hidden");
document.getElementById("play-icon")!.classList.add("hidden");
} else {
document.getElementById("pause-icon")!.classList.add("hidden");
document.getElementById("play-icon")!.classList.remove("hidden");
}
if (button === "stop") {
this.isPlaying == false;
document.getElementById("play-label")!.textContent = "Play";
document.getElementById("pause-icon")!.classList.add("hidden");
document.getElementById("play-icon")!.classList.remove("hidden");
}
this.flashBackground("#404040", 200);
const possible_selectors = [
'[id^="play-button-"]',
'[id^="clear-button-"]',
'[id^="stop-button-"]',
];
let selector: number;
switch (button) {
case "play":
selector = 0;
break;
case "pause":
selector = 1;
break;
case "clear":
selector = 2;
break;
case "stop":
selector = 3;
break;
}
document
.querySelectorAll(possible_selectors[selector])
.forEach((button) => {
if (highlight) button.children[0].classList.add("animate-pulse");
});
// All other buttons must lose the highlighting
document
.querySelectorAll(
possible_selectors.filter((_, index) => index != selector).join(","),
)
.forEach((button) => {
button.children[0].classList.remove("animate-pulse");
button.children[1].classList.remove("animate-pulse");
});
}
unfocusPlayButtons() {
document.querySelectorAll('[id^="play-button-"]').forEach((button) => {
button.children[0].classList.remove("fill-foreground_selection");
button.children[0].classList.remove("animate-pulse");
});
}
updateEditorView(): void {
this.view.dispatch({
changes: {
from: 0,
to: this.view.state.doc.toString().length,
insert: this.currentFile().candidate,
},
});
}
currentFile(): File {
switch (this.editor_mode) {
case "global":
return this.global_buffer;
case "local":
return this.local_buffer;
case "init":
return this.init_buffer;
case "notes":
return this.note_buffer;
}
}
flashBackground(color: string, duration: number): void {
/**
* Flashes the background of the view and its gutters.
* @param {string} color - The color to set.
* @param {number} duration - Duration in milliseconds to maintain the color.
*/
const domElement = this.view.dom;
const gutters = domElement.getElementsByClassName(
"cm-gutter",
) as HTMLCollectionOf<HTMLElement>;
domElement.classList.add("fluid-bg-transition");
Array.from(gutters).forEach((gutter) =>
gutter.classList.add("fluid-bg-transition"),
);
domElement.style.backgroundColor = color;
Array.from(gutters).forEach(
(gutter) => (gutter.style.backgroundColor = color),
);
setTimeout(() => {
domElement.style.backgroundColor = "";
Array.from(gutters).forEach(
(gutter) => (gutter.style.backgroundColor = ""),
);
domElement.classList.remove("fluid-bg-transition");
Array.from(gutters).forEach((gutter) =>
gutter.classList.remove("fluid-bg-transition"),
);
}, duration);
}
private initializeElements(): void {
for (const [key, value] of Object.entries(singleElements)) {
this.interface[key] = document.getElementById(
value,
) as ElementMap[keyof ElementMap];
}
}
private initializeButtonGroups(): void {
for (const [key, ids] of Object.entries(buttonGroups)) {
this.buttonElements[key] = ids.map(
(id) => document.getElementById(id) as HTMLButtonElement,
);
}
}
private loadHydraSynthAsync(): void {
/**
* Loads the Hydra Synth asynchronously by creating a script element
* and appending it to the document head. * Once the script is
* loaded successfully, it initializes the Hydra Synth. If there
* is an error loading the script, it logs an error message.
*/
var script = document.createElement("script");
script.src = "https://unpkg.com/hydra-synth";
script.async = true;
script.onload = () => {
console.log("Hydra loaded successfully");
this.initializeHydra();
};
script.onerror = function() {
console.error("Error loading Hydra script");
};
document.head.appendChild(script);
}
private initializeHydra(): void {
/**
* Initializes the Hydra backend and sets up the Hydra synth.
*/
// @ts-ignore
this.hydra_backend = new Hydra({
canvas: this.interface.hydra_canvas as HTMLCanvasElement,
detectAudio: false,
enableStreamCapture: false,
});
this.hydra = this.hydra_backend.synth;
this.hydra.setResolution(1280, 768);
(globalThis as any).hydra = this.hydra;
}
private setCanvas(canvas: HTMLCanvasElement): void {
/**
* Sets the canvas element and configures its size and context.
*
* @param canvas - The HTMLCanvasElement to set.
*/
if (!canvas) return;
const ctx = canvas.getContext("2d");
const dpr = window.devicePixelRatio || 1;
// Assuming the canvas takes up the whole window
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
if (ctx) {
ctx.scale(dpr, dpr);
}
}
private updateInterfaceTheme(selected_theme: { [key: string]: string }): void {
function hexToRgb(hex: string): { r: number, g: number, b: number } | null {
let result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
};
for (const [key, value] of Object.entries(selected_theme)) {
let color = hexToRgb(value);
if (color) {
let colorString = `${color.r} ${color.g} ${color.b}`
document.documentElement.style.setProperty("--" + key, colorString);
}
}
}
getColorScheme(theme_name: string): { [key: string]: string } {
// Check if the theme exists in colors.json
let themes: Record<string, { [key: string]: any }> = colors;
return themes[theme_name];
}
readTheme(theme_name: string): void {
// Check if the theme exists in colors.json
if (theme_name == "debug") {
switchToDebugTheme(this);
return
}
let themes: Record<string, { [key: string]: any }> = colors;
let selected_theme = themes[theme_name];
if (selected_theme) {
this.currentThemeName = theme_name;
this.updateInterfaceTheme(selected_theme);
let codeMirrorTheme = getCodeMirrorTheme(selected_theme);
// Reconfigure the view with the new theme
this.view.dispatch({
effects: this.themeCompartment.reconfigure(codeMirrorTheme),
});
}
}
}
let app = new Editor();
installWindowBehaviors(app, window, false);