Files
topos/src/main.ts

524 lines
16 KiB
TypeScript

import { OscilloscopeConfig, runOscilloscope, scriptBlinkers } from "./AudioVisualisation";
import { EditorState, Compartment } from "@codemirror/state";
import { javascript } from "@codemirror/lang-javascript";
import { markdown } from "@codemirror/lang-markdown";
import { Extension } from "@codemirror/state";
import { socket } from "./IO/OSC";
import {
initializeSelectedUniverse,
AppSettings,
Universe,
loadUniverserFromUrl,
} from "./FileManagement";
import { singleElements, buttonGroups, ElementMap } from "./DomElements";
import { registerFillKeys, registerOnKeyDown } from "./KeyActions";
import { installEditor } from "./EditorSetup";
import { documentation_factory } 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";
// @ts-ignore
import { registerSW } from "virtual:pwa-register";
if ("serviceWorker" in navigator) {
registerSW();
}
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";
fontSize!: Compartment;
withLineNumbers!: 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;
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,
};
// UserAPI
api: UserAPI;
// Audio stuff
audioContext: AudioContext;
clock: Clock;
dough_nudge: number = 20;
manualPlay: boolean = false;
isPlaying: boolean = false;
// OSC
socket: WebSocket = socket
// Hydra
public hydra_backend: any;
public hydra: any;
constructor() {
// ================================================================================
// Build user interface
// ================================================================================
this.initializeElements();
this.initializeButtonGroups();
this.setCanvas(this.interface.feedback as HTMLCanvasElement);
this.setCanvas(this.interface.scope 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);
}
private getBuffer(type: string): any {
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 = () => {
let itemTemplate = document.getElementById(
"ui-known-universe-item-template"
) as HTMLTemplateElement;
if (!itemTemplate) {
console.warn("Missing template #ui-known-universe-item-template");
return;
}
let existing_universes = document.getElementById("existing-universes");
if (!existing_universes) {
console.warn("Missing element #existing-universes");
return;
}
let list = document.createElement("ul");
list.className =
"lg:h-80 lg:text-normal text-sm h-auto lg:w-80 w-auto lg:pb-2 lg:pt-2 overflow-y-scroll text-white lg:mb-4 border rounded-lg bg-neutral-800";
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) {
// Updating the CSS accordingly
const tabs = document.querySelectorAll('[id^="tab-"]');
const tab = tabs[i] as HTMLElement;
tab.classList.add("bg-orange-300");
for (let j = 0; j < tabs.length; j++) {
if (j != i) tabs[j].classList.remove("bg-orange-300");
}
let tab_id = tab.id.split("-")[1];
this.local_index = parseInt(tab_id);
this.updateEditorView();
}
changeModeFromInterface(mode: "global" | "local" | "init" | "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-orange-300")) {
svg.classList.remove("text-orange-300");
button.classList.remove("text-orange-300");
}
});
button.children[0].classList.remove("text-white");
button.children[0].classList.add("text-orange-300");
button.classList.add("text-orange-300");
button.classList.add("fill-orange-300");
};
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
) {
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-orange-300");
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;
}
}
/**
* 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.
*/
flashBackground(color: string, duration: number): void {
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 {
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 {
// @ts-ignore
this.hydra_backend = new Hydra({
canvas: this.interface.hydra_canvas as HTMLCanvasElement,
detectAudio: false,
enableStreamCapture: false,
});
this.hydra = this.hydra_backend.synth;
}
private setCanvas(canvas: HTMLCanvasElement): void {
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);
}
}
}
let app = new Editor();
installWindowBehaviors(app, window, false);