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 = {}; interface: ElementMap = {}; blinkTimeouts: Record = {}; 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)[name] = value; }); // Passing OEIS generators to the User Object.entries(oeis).forEach(([name, value]) => { (globalThis as Record)[name] = value; }); // Passing ziffers sequences to the User Object.entries(zpatterns).forEach(([name, value]) => { (globalThis as Record)[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; 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 = 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 = 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);