[
  {
    "path": ".dockerignore",
    "content": "/.git\n/target\npkg\n/node_modules\n/dist\n*.local\n\nDockerfile\n.dockerignore\n.env\nREADME.md\n"
  },
  {
    "path": ".editorconfig",
    "content": "[*]\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n[*.rs]\ntab_width = 4\n\n[*.{js,jsx,ts,tsx,html,css}]\ntab_width = 2\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches:\n      - main\n  pull_request:\n    branches:\n      - main\n\njobs:\n  format:\n    name: Prettier\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v2\n\n      - uses: actions/setup-node@v2\n        with:\n          node-version: \"14\"\n\n      - run: npm ci --only=dev\n\n      - run: npx prettier --check .\n\n  rustfmt:\n    name: Rustfmt\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v2\n\n      - uses: actions-rs/toolchain@v1\n        with:\n          toolchain: stable\n\n      - run: cargo fmt -- --check\n\n  deploy:\n    name: Deploy\n    runs-on: ubuntu-latest\n    if: github.event_name == 'push'\n\n    concurrency:\n      group: deploy\n      cancel-in-progress: false\n\n    steps:\n      - uses: actions/checkout@v3\n\n      - uses: superfly/flyctl-actions@master\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n        with:\n          args: deploy --remote-only\n"
  },
  {
    "path": ".gitignore",
    "content": "/target\npkg\n\nnode_modules\n.DS_Store\ndist\n*.local\n\n.vscode\n"
  },
  {
    "path": ".prettierignore",
    "content": "/target\npkg\n\nnode_modules\n.DS_Store\ndist\n*.local\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"proseWrap\": \"always\",\n  \"endOfLine\": \"auto\"\n}\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[workspace]\n\nmembers = [\n  \"cstudio-server\",\n  \"cstudio-wasm\",\n]\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM rust:alpine as backend\nWORKDIR /home/rust/src\nRUN apk --no-cache add musl-dev openssl-dev\nCOPY . .\nRUN cargo test --release\nRUN cargo build --release\n\nFROM rust:alpine as wasm\nWORKDIR /home/rust/src\nRUN apk --no-cache add curl musl-dev\nRUN curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh\nCOPY . .\nRUN wasm-pack build --target web cstudio-wasm\n\nFROM node:lts-alpine as frontend\nWORKDIR /usr/src/app\nCOPY package.json package-lock.json ./\nCOPY --from=wasm /home/rust/src/cstudio-wasm/pkg cstudio-wasm/pkg\nRUN npm ci\nCOPY . .\nRUN npm run build\n\nFROM scratch\nCOPY --from=frontend /usr/src/app/dist dist\nCOPY --from=backend /home/rust/src/target/release/cstudio-server .\nUSER 1000:1000\nCMD [ \"./cstudio-server\" ]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2021 Eric Zhang\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# composing.studio\n\nThis is the code for [composing.studio](https://composing.studio/), a web\napplication that aims to make music composition collaborative and accessible to\neveryone.\n\n<p align=\"center\">\n<a href=\"https://composing.studio/\">\n<img src=\"https://i.imgur.com/6L56aKA.png\" width=\"800\"><br>\n<strong>composing.studio</strong>\n</a>\n</p>\n\n## Rationale\n\nRight now it’s possible to write music in a textual format called ABC, which\nworks for transcribing simple songs + guitar chords, as well as some other\npieces like chorales and folk music. It has some limitations, but not too many\n(someone has encoded a Beethoven symphony in ABC before!).\n\nHere’s an example of an interactive editor built with ABC:\nhttps://editor.drawthedots.com/. The editor is not super user-friendly, but it\nshowcases the utility and interest in this space.\n\nWe provide a friendly and intuitive web-based interface for editing ABC music\nnotation, with syntax highlighting, live preview, audio playback, and real-time\ncollaboration. Anyone can create a new collaborative session by entering our\nwebsite, and they can share the link with fellow composers to work together and\ncome up with new ideas.\n\n## Getting Started\n\nThis application is built using a backend operational transformation control\nserver written in Rust, based on [Rustpad](https://github.com/ekzhang/rustpad),\nas well as a frontend written in TypeScript using [React](https://reactjs.org/).\n\nThe backend server has support for transient collaborative editing sessions, and\nthe frontend offers a collaborative text editor with custom Monarch ABC syntax\nhighlighting, cursors, and live presence tracking. These parts of the\napplication are connected by WebSocket communication.\n\nOn the frontend, we use the [abcjs](https://www.abcjs.net/) library to\ndynamically render sheet music from ABC notation and generate interactive\nplayback controls through web audio synthesis.\n\nTo run this application, you need to install Rust, `wasm-pack`, and Node.js.\nThen, build the WebAssembly portion of the app:\n\n```\nwasm-pack build --target web cstudio-wasm\n```\n\nWhen that is complete, you can install dependencies for the frontend React\napplication:\n\n```\nnpm install\n```\n\nNext, compile and run the backend web server:\n\n```\ncargo run\n```\n\nWhile the backend is running, open another shell and run the following command\nto start the frontend portion.\n\n```\nnpm run dev\n```\n\nThis command will open a browser window to `http://localhost:3000`, with hot\nreloading on changes.\n\n## Contributing\n\nThis project is still in a **very experimental** phase. We're exploring\ndifferent ways of allowing musicians to collaborate with each other in a global\ncommunity. If you're interested in adding features or helping fix bugs, please\nreach out to us first by creating a GitHub issue!\n\nWe have continuous integration for this repository, which checks things like\ncode style (Prettier, Rustfmt) and successful build (Docker). The current state\nof the `main` branch is continuously deployed to the production web server at\n[composing.studio](https://composing.studio/).\n\n<br>\n\n<sup>\nAll code is licensed under the <a href=\"LICENSE\">MIT license</a>.\n</sup>\n"
  },
  {
    "path": "cstudio-server/Cargo.toml",
    "content": "[package]\nname = \"cstudio-server\"\nversion = \"0.1.0\"\nauthors = [\"Eric Zhang <ekzhang1@gmail.com>\"]\nedition = \"2021\"\n\n[dependencies]\nanyhow = \"1.0.40\"\nbytecount = \"0.6\"\ndashmap = \"4.0.2\"\ndotenv = \"0.15.0\"\nfutures = \"0.3.15\"\nlog = \"0.4.14\"\noperational-transform = { version = \"0.6.0\", features = [\"serde\"] }\nparking_lot = \"0.11.1\"\npretty_env_logger = \"0.4.0\"\nserde = { version = \"1.0.126\", features = [\"derive\"] }\nserde_json = \"1.0.64\"\ntokio = { version = \"1.6.1\", features = [\"full\", \"test-util\"] }\ntokio-stream = \"0.1.6\"\nwarp = \"0.3.1\"\n"
  },
  {
    "path": "cstudio-server/src/lib.rs",
    "content": "//! Server backend for the Rustpad collaborative text editor.\n\n#![forbid(unsafe_code)]\n#![warn(missing_docs)]\n\nuse std::sync::Arc;\nuse std::time::{Duration, SystemTime};\n\nuse dashmap::DashMap;\nuse log::info;\nuse serde::Serialize;\nuse tokio::time::{self, Instant};\nuse warp::{filters::BoxedFilter, ws::Ws, Filter, Reply};\n\nuse rustpad::Rustpad;\n\nmod ot;\nmod rustpad;\n\n/// An entry stored in the global server map.\n///\n/// Each entry corresponds to a single document. This is garbage collected by a\n/// background task after one day of inactivity, to avoid server memory usage\n/// growing without bound.\nstruct Document {\n    last_accessed: Instant,\n    rustpad: Arc<Rustpad>,\n}\n\nimpl Default for Document {\n    fn default() -> Self {\n        Self {\n            last_accessed: Instant::now(),\n            rustpad: Default::default(),\n        }\n    }\n}\n\n/// Statistics about the server, returned from an API endpoint.\n#[derive(Serialize)]\nstruct Stats {\n    /// System time when the server started, in seconds since Unix epoch.\n    start_time: u64,\n    /// Number of documents currently tracked by the server.\n    num_documents: usize,\n}\n\n/// Server configuration.\n#[derive(Debug)]\npub struct ServerConfig {\n    /// Number of days to clean up documents after inactivity.\n    pub expiry_days: u32,\n}\n\nimpl Default for ServerConfig {\n    fn default() -> Self {\n        Self { expiry_days: 1 }\n    }\n}\n\n/// A combined filter handling all server routes.\npub fn server(config: ServerConfig) -> BoxedFilter<(impl Reply,)> {\n    warp::path(\"api\")\n        .and(backend(config))\n        .or(frontend())\n        .boxed()\n}\n\n/// Construct routes for static files from React.\nfn frontend() -> BoxedFilter<(impl Reply,)> {\n    warp::fs::dir(\"dist\")\n        .or(warp::fs::file(\"dist/index.html\"))\n        .boxed()\n}\n\n/// Construct backend routes, including WebSocket handlers.\nfn backend(config: ServerConfig) -> BoxedFilter<(impl Reply,)> {\n    let state: Arc<DashMap<String, Document>> = Default::default();\n    tokio::spawn(cleaner(Arc::clone(&state), config.expiry_days));\n\n    let state_filter = warp::any().map(move || Arc::clone(&state));\n\n    let socket = warp::path(\"socket\")\n        .and(warp::path::param())\n        .and(warp::path::end())\n        .and(warp::ws())\n        .and(state_filter.clone())\n        .map(\n            |id: String, ws: Ws, state: Arc<DashMap<String, Document>>| {\n                let mut entry = state.entry(id).or_default();\n                let value = entry.value_mut();\n                value.last_accessed = Instant::now();\n                let rustpad = Arc::clone(&value.rustpad);\n                ws.on_upgrade(|socket| async move { rustpad.on_connection(socket).await })\n            },\n        );\n\n    let text = warp::path(\"text\")\n        .and(warp::path::param())\n        .and(warp::path::end())\n        .and(state_filter.clone())\n        .map(|id: String, state: Arc<DashMap<String, Document>>| {\n            state\n                .get(&id)\n                .map(|value| value.rustpad.text())\n                .unwrap_or_default()\n        });\n\n    let start_time = SystemTime::now()\n        .duration_since(SystemTime::UNIX_EPOCH)\n        .expect(\"SystemTime returned before UNIX_EPOCH\")\n        .as_secs();\n    let stats = warp::path(\"stats\")\n        .and(warp::path::end())\n        .and(state_filter)\n        .map(move |state: Arc<DashMap<String, Document>>| {\n            let num_documents = state.len();\n            warp::reply::json(&Stats {\n                start_time,\n                num_documents,\n            })\n        });\n\n    socket.or(text).or(stats).boxed()\n}\n\nconst HOUR: Duration = Duration::from_secs(3600);\n\n// Reclaims memory for documents.\nasync fn cleaner(state: Arc<DashMap<String, Document>>, expiry_days: u32) {\n    loop {\n        time::sleep(HOUR).await;\n        let mut keys = Vec::new();\n        for entry in &*state {\n            if entry.last_accessed.elapsed() > HOUR * 24 * expiry_days {\n                keys.push(entry.key().clone());\n            }\n        }\n        info!(\"cleaner removing keys: {:?}\", keys);\n        for key in keys {\n            state.remove(&key);\n        }\n    }\n}\n"
  },
  {
    "path": "cstudio-server/src/main.rs",
    "content": "use cstudio_server::{server, ServerConfig};\n\n#[tokio::main]\nasync fn main() {\n    dotenv::dotenv().ok();\n    pretty_env_logger::init();\n\n    let port = std::env::var(\"PORT\")\n        .unwrap_or_else(|_| String::from(\"3030\"))\n        .parse()\n        .expect(\"Unable to parse PORT\");\n\n    let config = ServerConfig {\n        expiry_days: std::env::var(\"EXPIRY_DAYS\")\n            .unwrap_or_else(|_| String::from(\"1\"))\n            .parse()\n            .expect(\"Unable to parse EXPIRY_DAYS\"),\n    };\n\n    warp::serve(server(config)).run(([0, 0, 0, 0], port)).await;\n}\n"
  },
  {
    "path": "cstudio-server/src/ot.rs",
    "content": "//! Helper methods for working with operational transformation.\n\nuse operational_transform::{Operation, OperationSeq};\n\n/// Return the new index of a position in the string.\npub fn transform_index(operation: &OperationSeq, position: u32) -> u32 {\n    let mut index = position as i32;\n    let mut new_index = index;\n    for op in operation.ops() {\n        match op {\n            &Operation::Retain(n) => index -= n as i32,\n            Operation::Insert(s) => new_index += bytecount::num_chars(s.as_bytes()) as i32,\n            &Operation::Delete(n) => {\n                new_index -= std::cmp::min(index, n as i32);\n                index -= n as i32;\n            }\n        }\n        if index < 0 {\n            break;\n        }\n    }\n    new_index as u32\n}\n"
  },
  {
    "path": "cstudio-server/src/rustpad.rs",
    "content": "//! Eventually consistent server-side logic for Rustpad.\n\nuse std::collections::HashMap;\nuse std::sync::atomic::{AtomicU64, Ordering};\n\nuse anyhow::{bail, Context, Result};\nuse futures::prelude::*;\nuse log::{info, warn};\nuse operational_transform::OperationSeq;\nuse parking_lot::{RwLock, RwLockUpgradableReadGuard};\nuse serde::{Deserialize, Serialize};\nuse tokio::sync::{broadcast, Notify};\nuse warp::ws::{Message, WebSocket};\n\nuse crate::ot::transform_index;\n\n/// The main object representing a collaborative session.\npub struct Rustpad {\n    /// State modified by critical sections of the code.\n    state: RwLock<State>,\n    /// Incremented to obtain unique user IDs.\n    count: AtomicU64,\n    /// Used to notify clients of new text operations.\n    notify: Notify,\n    /// Used to inform all clients of metadata updates.\n    update: broadcast::Sender<ServerMsg>,\n}\n\n/// Shared state involving multiple users, protected by a lock.\n#[derive(Default)]\nstruct State {\n    operations: Vec<UserOperation>,\n    text: String,\n    language: Option<String>,\n    users: HashMap<u64, UserInfo>,\n    cursors: HashMap<u64, CursorData>,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\nstruct UserOperation {\n    id: u64,\n    operation: OperationSeq,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\nstruct UserInfo {\n    name: String,\n    hue: u32,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\nstruct CursorData {\n    cursors: Vec<u32>,\n    selections: Vec<(u32, u32)>,\n}\n\n/// A message received from the client over WebSocket.\n#[derive(Clone, Debug, Serialize, Deserialize)]\nenum ClientMsg {\n    /// Represents a sequence of local edits from the user.\n    Edit {\n        revision: usize,\n        operation: OperationSeq,\n    },\n    /// Sets the language of the editor.\n    SetLanguage(String),\n    /// Sets the user's current information.\n    ClientInfo(UserInfo),\n    /// Sets the user's cursor and selection positions.\n    CursorData(CursorData),\n}\n\n/// A message sent to the client over WebSocket.\n#[derive(Clone, Debug, Serialize, Deserialize)]\nenum ServerMsg {\n    /// Informs the client of their unique socket ID.\n    Identity(u64),\n    /// Broadcasts text operations to all clients.\n    History {\n        start: usize,\n        operations: Vec<UserOperation>,\n    },\n    /// Broadcasts the current language, last writer wins.\n    Language(String),\n    /// Broadcasts a user's information, or `None` on disconnect.\n    UserInfo { id: u64, info: Option<UserInfo> },\n    /// Broadcasts a user's cursor position.\n    UserCursor { id: u64, data: CursorData },\n}\n\nimpl From<ServerMsg> for Message {\n    fn from(msg: ServerMsg) -> Self {\n        let serialized = serde_json::to_string(&msg).expect(\"failed serialize\");\n        Message::text(serialized)\n    }\n}\n\nimpl Default for Rustpad {\n    fn default() -> Self {\n        let (tx, _) = broadcast::channel(16);\n        Self {\n            state: Default::default(),\n            count: Default::default(),\n            notify: Default::default(),\n            update: tx,\n        }\n    }\n}\n\nimpl Rustpad {\n    /// Handle a connection from a WebSocket.\n    pub async fn on_connection(&self, socket: WebSocket) {\n        let id = self.count.fetch_add(1, Ordering::Relaxed);\n        info!(\"connection! id = {}\", id);\n        if let Err(e) = self.handle_connection(id, socket).await {\n            warn!(\"connection terminated early: {}\", e);\n        }\n        info!(\"disconnection, id = {}\", id);\n        self.state.write().users.remove(&id);\n        self.state.write().cursors.remove(&id);\n        self.update\n            .send(ServerMsg::UserInfo { id, info: None })\n            .ok();\n    }\n\n    /// Returns a snapshot of the latest text.\n    pub fn text(&self) -> String {\n        let state = self.state.read();\n        state.text.clone()\n    }\n\n    /// Returns the current revision.\n    pub fn revision(&self) -> usize {\n        let state = self.state.read();\n        state.operations.len()\n    }\n\n    async fn handle_connection(&self, id: u64, mut socket: WebSocket) -> Result<()> {\n        let mut update_rx = self.update.subscribe();\n\n        let mut revision: usize = self.send_initial(id, &mut socket).await?;\n\n        loop {\n            // In order to avoid the \"lost wakeup\" problem, we first request a\n            // notification, **then** check the current state for new revisions.\n            // This is the same approach that `tokio::sync::watch` takes.\n            let notified = self.notify.notified();\n            if self.revision() > revision {\n                revision = self.send_history(revision, &mut socket).await?\n            }\n\n            tokio::select! {\n                _ = notified => {}\n                update = update_rx.recv() => {\n                    socket.send(update?.into()).await?;\n                }\n                result = socket.next() => {\n                    match result {\n                        None => break,\n                        Some(message) => {\n                            self.handle_message(id, message?).await?;\n                        }\n                    }\n                }\n            }\n        }\n\n        Ok(())\n    }\n\n    async fn send_initial(&self, id: u64, socket: &mut WebSocket) -> Result<usize> {\n        socket.send(ServerMsg::Identity(id).into()).await?;\n        let mut messages = Vec::new();\n        let revision = {\n            let state = self.state.read();\n            if !state.operations.is_empty() {\n                messages.push(ServerMsg::History {\n                    start: 0,\n                    operations: state.operations.clone(),\n                });\n            }\n            if let Some(language) = &state.language {\n                messages.push(ServerMsg::Language(language.clone()));\n            }\n            for (&id, info) in &state.users {\n                messages.push(ServerMsg::UserInfo {\n                    id,\n                    info: Some(info.clone()),\n                });\n            }\n            for (&id, data) in &state.cursors {\n                messages.push(ServerMsg::UserCursor {\n                    id,\n                    data: data.clone(),\n                });\n            }\n            state.operations.len()\n        };\n        for msg in messages {\n            socket.send(msg.into()).await?;\n        }\n        Ok(revision)\n    }\n\n    async fn send_history(&self, start: usize, socket: &mut WebSocket) -> Result<usize> {\n        let operations = {\n            let state = self.state.read();\n            let len = state.operations.len();\n            if start < len {\n                state.operations[start..].to_owned()\n            } else {\n                Vec::new()\n            }\n        };\n        let num_ops = operations.len();\n        if num_ops > 0 {\n            let msg = ServerMsg::History { start, operations };\n            socket.send(msg.into()).await?;\n        }\n        Ok(start + num_ops)\n    }\n\n    async fn handle_message(&self, id: u64, message: Message) -> Result<()> {\n        let msg: ClientMsg = match message.to_str() {\n            Ok(text) => serde_json::from_str(text).context(\"failed to deserialize message\")?,\n            Err(()) => return Ok(()), // Ignore non-text messages\n        };\n        match msg {\n            ClientMsg::Edit {\n                revision,\n                operation,\n            } => {\n                self.apply_edit(id, revision, operation)\n                    .context(\"invalid edit operation\")?;\n                self.notify.notify_waiters();\n            }\n            ClientMsg::SetLanguage(language) => {\n                self.state.write().language = Some(language.clone());\n                self.update.send(ServerMsg::Language(language)).ok();\n            }\n            ClientMsg::ClientInfo(info) => {\n                self.state.write().users.insert(id, info.clone());\n                let msg = ServerMsg::UserInfo {\n                    id,\n                    info: Some(info),\n                };\n                self.update.send(msg).ok();\n            }\n            ClientMsg::CursorData(data) => {\n                self.state.write().cursors.insert(id, data.clone());\n                let msg = ServerMsg::UserCursor { id, data };\n                self.update.send(msg).ok();\n            }\n        }\n        Ok(())\n    }\n\n    fn apply_edit(&self, id: u64, revision: usize, mut operation: OperationSeq) -> Result<()> {\n        info!(\n            \"edit: id = {}, revision = {}, base_len = {}, target_len = {}\",\n            id,\n            revision,\n            operation.base_len(),\n            operation.target_len()\n        );\n        let state = self.state.upgradable_read();\n        let len = state.operations.len();\n        if revision > len {\n            bail!(\"got revision {}, but current is {}\", revision, len);\n        }\n        for history_op in &state.operations[revision..] {\n            operation = operation.transform(&history_op.operation)?.0;\n        }\n        if operation.target_len() > 100000 {\n            bail!(\n                \"target length {} is greater than 100 KB maximum\",\n                operation.target_len()\n            );\n        }\n        let new_text = operation.apply(&state.text)?;\n        let mut state = RwLockUpgradableReadGuard::upgrade(state);\n        for (_, data) in state.cursors.iter_mut() {\n            for cursor in data.cursors.iter_mut() {\n                *cursor = transform_index(&operation, *cursor);\n            }\n            for (start, end) in data.selections.iter_mut() {\n                *start = transform_index(&operation, *start);\n                *end = transform_index(&operation, *end);\n            }\n        }\n        state.operations.push(UserOperation { id, operation });\n        state.text = new_text;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "cstudio-wasm/Cargo.toml",
    "content": "[package]\nname = \"cstudio-wasm\"\nversion = \"0.1.0\"\nauthors = [\"Eric Zhang <ekzhang1@gmail.com>\"]\nedition = \"2021\"\n\n[lib]\ncrate-type = [\"cdylib\", \"rlib\"]\n\n[features]\ndefault = [\"console_error_panic_hook\"]\n\n[dependencies]\nbytecount = \"0.6\"\nconsole_error_panic_hook = { version = \"0.1\", optional = true }\noperational-transform = { version = \"0.6.0\", features = [\"serde\"] }\nserde = { version = \"1.0.126\", features = [\"derive\"] }\nserde_json = \"1.0.64\"\nwasm-bindgen = \"0.2\"\njs-sys = \"0.3.51\"\n\n[dev-dependencies]\nwasm-bindgen-test = \"0.3\"\n\n[package.metadata.wasm-pack.profile.release]\nwasm-opt = false\n"
  },
  {
    "path": "cstudio-wasm/src/lib.rs",
    "content": "//! Core logic for Rustpad, shared with the client through WebAssembly.\n\n#![warn(missing_docs)]\n\nuse operational_transform::OperationSeq;\nuse serde::{Deserialize, Serialize};\nuse wasm_bindgen::prelude::*;\n\npub mod utils;\n\n/// This is an wrapper around `operational_transform::OperationSeq`, which is\n/// necessary for Wasm compatibility through `wasm-bindgen`.\n#[wasm_bindgen]\n#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)]\npub struct OpSeq(OperationSeq);\n\n/// This is a pair of `OpSeq` structs, which is needed to handle some return\n/// values from `wasm-bindgen`.\n#[wasm_bindgen]\n#[derive(Default, Clone, Debug, PartialEq)]\npub struct OpSeqPair(OpSeq, OpSeq);\n\nimpl OpSeq {\n    /// Transforms two operations A and B that happened concurrently and produces\n    /// two operations A' and B' (in an array) such that\n    ///     `apply(apply(S, A), B') = apply(apply(S, B), A')`.\n    /// This function is the heart of OT.\n    ///\n    /// Unlike `OpSeq::transform`, this function returns a raw tuple, which is\n    /// more efficient but cannot be exported by `wasm-bindgen`.\n    ///\n    /// # Error\n    ///\n    /// Returns `None` if the operations cannot be transformed due to\n    /// length conflicts.\n    pub fn transform_raw(&self, other: &OpSeq) -> Option<(OpSeq, OpSeq)> {\n        let (a, b) = self.0.transform(&other.0).ok()?;\n        Some((Self(a), Self(b)))\n    }\n}\n\n#[wasm_bindgen]\nimpl OpSeq {\n    /// Creates a default empty `OpSeq`.\n    pub fn new() -> Self {\n        Self::default()\n    }\n\n    /// Creates a store for operatations which does not need to allocate  until\n    /// `capacity` operations have been stored inside.\n    pub fn with_capacity(capacity: usize) -> Self {\n        Self(OperationSeq::with_capacity(capacity))\n    }\n\n    /// Merges the operation with `other` into one operation while preserving\n    /// the changes of both. Or, in other words, for each input string S and a\n    /// pair of consecutive operations A and B.\n    ///     `apply(apply(S, A), B) = apply(S, compose(A, B))`\n    /// must hold.\n    ///\n    /// # Error\n    ///\n    /// Returns `None` if the operations are not composable due to length\n    /// conflicts.\n    pub fn compose(&self, other: &OpSeq) -> Option<OpSeq> {\n        self.0.compose(&other.0).ok().map(Self)\n    }\n\n    /// Deletes `n` characters at the current cursor position.\n    pub fn delete(&mut self, n: u32) {\n        self.0.delete(n as u64)\n    }\n\n    /// Inserts a `s` at the current cursor position.\n    pub fn insert(&mut self, s: &str) {\n        self.0.insert(s)\n    }\n\n    /// Moves the cursor `n` characters forwards.\n    pub fn retain(&mut self, n: u32) {\n        self.0.retain(n as u64)\n    }\n\n    /// Transforms two operations A and B that happened concurrently and produces\n    /// two operations A' and B' (in an array) such that\n    ///     `apply(apply(S, A), B') = apply(apply(S, B), A')`.\n    /// This function is the heart of OT.\n    ///\n    /// # Error\n    ///\n    /// Returns `None` if the operations cannot be transformed due to\n    /// length conflicts.\n    pub fn transform(&self, other: &OpSeq) -> Option<OpSeqPair> {\n        let (a, b) = self.0.transform(&other.0).ok()?;\n        Some(OpSeqPair(Self(a), Self(b)))\n    }\n\n    /// Applies an operation to a string, returning a new string.\n    ///\n    /// # Error\n    ///\n    /// Returns an error if the operation cannot be applied due to length\n    /// conflicts.\n    pub fn apply(&self, s: &str) -> Option<String> {\n        self.0.apply(s).ok()\n    }\n\n    /// Computes the inverse of an operation. The inverse of an operation is the\n    /// operation that reverts the effects of the operation, e.g. when you have\n    /// an operation 'insert(\"hello \"); skip(6);' then the inverse is\n    /// 'delete(\"hello \"); skip(6);'. The inverse should be used for\n    /// implementing undo.\n    pub fn invert(&self, s: &str) -> Self {\n        Self(self.0.invert(s))\n    }\n\n    /// Checks if this operation has no effect.\n    #[inline]\n    pub fn is_noop(&self) -> bool {\n        self.0.is_noop()\n    }\n\n    /// Returns the length of a string these operations can be applied to\n    #[inline]\n    pub fn base_len(&self) -> usize {\n        self.0.base_len()\n    }\n\n    /// Returns the length of the resulting string after the operations have\n    /// been applied.\n    #[inline]\n    pub fn target_len(&self) -> usize {\n        self.0.target_len()\n    }\n\n    /// Return the new index of a position in the string.\n    pub fn transform_index(&self, position: u32) -> u32 {\n        let mut index = position as i32;\n        let mut new_index = index;\n        for op in self.0.ops() {\n            use operational_transform::Operation::*;\n            match op {\n                &Retain(n) => index -= n as i32,\n                Insert(s) => new_index += bytecount::num_chars(s.as_bytes()) as i32,\n                &Delete(n) => {\n                    new_index -= std::cmp::min(index, n as i32);\n                    index -= n as i32;\n                }\n            }\n            if index < 0 {\n                break;\n            }\n        }\n        new_index as u32\n    }\n\n    /// Attempts to deserialize an `OpSeq` from a JSON string.\n    #[allow(clippy::should_implement_trait)]\n    pub fn from_str(s: &str) -> Option<OpSeq> {\n        serde_json::from_str(s).ok()\n    }\n\n    /// Converts this object to a JSON string.\n    #[allow(clippy::inherent_to_string)]\n    pub fn to_string(&self) -> String {\n        serde_json::to_string(self).expect(\"json serialization failure\")\n    }\n}\n\n#[wasm_bindgen]\nimpl OpSeqPair {\n    /// Returns the first element of the pair.\n    pub fn first(&self) -> OpSeq {\n        self.0.clone()\n    }\n\n    /// Returns the second element of the pair.\n    pub fn second(&self) -> OpSeq {\n        self.1.clone()\n    }\n}\n"
  },
  {
    "path": "cstudio-wasm/src/utils.rs",
    "content": "//! Utility functions.\n\nuse wasm_bindgen::prelude::*;\n\n/// Set a panic listener to display better error messages.\n#[wasm_bindgen]\npub fn set_panic_hook() {\n    // When the `console_error_panic_hook` feature is enabled, we can call the\n    // `set_panic_hook` function at least once during initialization, and then\n    // we will get better error messages if our code ever panics.\n    //\n    // For more details see\n    // https://github.com/rustwasm/console_error_panic_hook#readme\n    #[cfg(feature = \"console_error_panic_hook\")]\n    console_error_panic_hook::set_once();\n}\n"
  },
  {
    "path": "fly.toml",
    "content": "app = \"composing-studio\"\nprimary_region = \"dfw\"\nkill_signal = \"SIGINT\"\nkill_timeout = 5\n\n[experimental]\n  auto_rollback = true\n\n[[services]]\n  protocol = \"tcp\"\n  internal_port = 3030\n  processes = [\"app\"]\n\n  [services.concurrency]\n    type = \"connections\"\n    hard_limit = 65536\n    soft_limit = 1024\n\n  [[services.ports]]\n    port = 80\n    handlers = [\"http\"]\n    force_https = true\n\n  [[services.ports]]\n    port = 443\n    handlers = [\"tls\"]\n    tls_options = { alpn = [\"h2\", \"http/1.1\"] }\n\n  [[services.tcp_checks]]\n    interval = \"15s\"\n    timeout = \"2s\"\n    grace_period = \"1s\"\n    restart_limit = 0\n"
  },
  {
    "path": "index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Composing Studio</title>\n\n    <meta\n      property=\"og:title\"\n      content=\"Composing Studio • Make Music Together\"\n    />\n    <meta\n      property=\"og:image\"\n      content=\"https://composing.studio/static/social.png\"\n    />\n    <meta\n      name=\"description\"\n      property=\"og:description\"\n      content=\"The easiest way to collaboratively compose music with others on the Internet, supporting ABC music notation and playback.\"\n    />\n    <meta name=\"twitter:card\" content=\"summary_large_image\" />\n    <meta\n      name=\"keywords\"\n      content=\"compose music,music notation,abc notation,music with friends,music collaboration, write music, share music, create music, make music\"\n    />\n\n    <link\n      href=\"https://fonts.googleapis.com/css?family=Raleway:400,300,600\"\n      rel=\"stylesheet\"\n      type=\"text/css\"\n    />\n\n    <!-- Global site tag (gtag.js) - Google Analytics -->\n    <script\n      async\n      src=\"https://www.googletagmanager.com/gtag/js?id=G-Q5JBE0TK39\"\n    ></script>\n    <script>\n      window.dataLayer = window.dataLayer || [];\n      function gtag() {\n        dataLayer.push(arguments);\n      }\n      gtag(\"js\", new Date());\n\n      gtag(\"config\", \"G-Q5JBE0TK39\");\n    </script>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/index.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"composing.studio\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"serve\": \"vite preview\",\n    \"format\": \"prettier --write .\"\n  },\n  \"dependencies\": {\n    \"@chakra-ui/react\": \"^1.7.0\",\n    \"@emotion/react\": \"^11.5.0\",\n    \"@emotion/styled\": \"^11.3.0\",\n    \"@monaco-editor/react\": \"^4.3.1\",\n    \"abcjs\": \"^5.12.0\",\n    \"cstudio-wasm\": \"file:./cstudio-wasm/pkg\",\n    \"framer-motion\": \"^4.1.17\",\n    \"lodash.debounce\": \"^4.0.8\",\n    \"nanoid\": \"^3.1.30\",\n    \"project-name-generator\": \"^2.1.9\",\n    \"react\": \"^17.0.2\",\n    \"react-dom\": \"^17.0.2\",\n    \"react-full-page\": \"^0.1.12\",\n    \"react-icons\": \"^4.3.1\",\n    \"react-router-dom\": \"^6.0.2\",\n    \"react-scroll\": \"^1.8.4\",\n    \"react-split\": \"^2.0.13\",\n    \"use-debounce\": \"^7.0.1\",\n    \"use-local-storage-state\": \"^11.0.0\"\n  },\n  \"devDependencies\": {\n    \"@types/lodash.debounce\": \"^4.0.6\",\n    \"@types/project-name-generator\": \"^2.1.1\",\n    \"@types/react\": \"^17.0.34\",\n    \"@types/react-dom\": \"^17.0.11\",\n    \"@types/react-router-dom\": \"^5.3.2\",\n    \"@types/react-scroll\": \"^1.8.3\",\n    \"@vitejs/plugin-react\": \"^1.0.8\",\n    \"monaco-editor\": \"^0.30.1\",\n    \"prettier\": \"2.4.1\",\n    \"typescript\": \"~4.4.4\",\n    \"vite\": \"^2.6.14\"\n  }\n}\n"
  },
  {
    "path": "src/App.tsx",
    "content": "import { loader } from \"@monaco-editor/react\";\nimport { BrowserRouter, Route, Routes } from \"react-router-dom\";\nimport LandingPage from \"./pages/LandingPage\";\nimport EditorPage from \"./pages/EditorPage\";\n\ninitAbc();\n\nasync function initAbc() {\n  const monaco = await loader.init();\n  monaco.languages.register({ id: \"abc\" });\n  monaco.languages.setLanguageConfiguration(\"abc\", {\n    comments: {\n      lineComment: \"%\",\n    },\n    brackets: [\n      [\"{\", \"}\"],\n      [\"[\", \"]\"],\n      [\"(\", \")\"],\n    ],\n    autoClosingPairs: [\n      { open: \"{\", close: \"}\" },\n      { open: \"[\", close: \"]\" },\n      { open: \"(\", close: \")\" },\n      { open: '\"', close: '\"' },\n    ],\n    surroundingPairs: [\n      { open: \"{\", close: \"}\" },\n      { open: \"(\", close: \")\" },\n      { open: \"[\", close: \"]\" },\n      { open: '\"', close: '\"' },\n    ],\n  });\n  monaco.languages.setMonarchTokensProvider(\"abc\", {\n    tokenPostfix: \".abc\",\n\n    tokenizer: {\n      root: [\n        // V: Voice\n        [/^V:[^\\n]*/, \"strong\"],\n        // m: message comment\n        [/^m:[^\\n]*/, \"comment\"],\n        // X: Annotations\n        [/^[A-Za-z]:[^\\n]*/, \"emphasis\"],\n        // % Comments\n        [/%.*$/, \"comment\"],\n        // Chords like \"A\"\n        [/\"[^\"]*\"/, \"string\"],\n        // Syntax for bar lines\n        [/:?\\|[:\\]]?/, \"keyword.control\"],\n        // Syntax for bar lines\n        [/:?\\|[:\\]]?/, \"keyword.control\"],\n        // Notes\n        [/[a-gA-G][,']*[0-9]*\\/*[0-9]*/, \"variable.value\"],\n      ],\n    },\n  });\n}\n\nfunction App() {\n  return (\n    <BrowserRouter>\n      <Routes>\n        <Route path=\"/\" element={<LandingPage />} />\n        <Route path=\"/:id\" element={<EditorPage />} />\n      </Routes>\n    </BrowserRouter>\n  );\n}\n\nexport default App;\n"
  },
  {
    "path": "src/abcjs.d.ts",
    "content": "declare module \"abcjs\";\n"
  },
  {
    "path": "src/components/ConnectionStatus.tsx",
    "content": "import { HStack, Icon, Text } from \"@chakra-ui/react\";\nimport { VscCircleFilled } from \"react-icons/vsc\";\n\ntype ConnectionStatusProps = {\n  connection: \"connected\" | \"disconnected\" | \"desynchronized\";\n  darkMode: boolean;\n};\n\nfunction ConnectionStatus({ connection, darkMode }: ConnectionStatusProps) {\n  return (\n    <HStack spacing={1}>\n      <Icon\n        as={VscCircleFilled}\n        color={\n          {\n            connected: \"green.500\",\n            disconnected: \"orange.500\",\n            desynchronized: \"red.500\",\n          }[connection]\n        }\n      />\n      <Text\n        fontSize=\"sm\"\n        fontStyle=\"italic\"\n        color={darkMode ? \"gray.300\" : \"gray.600\"}\n      >\n        {\n          {\n            connected: \"You are connected!\",\n            disconnected: \"Connecting to the server...\",\n            desynchronized: \"Disconnected, please refresh.\",\n          }[connection]\n        }\n      </Text>\n    </HStack>\n  );\n}\n\nexport default ConnectionStatus;\n"
  },
  {
    "path": "src/components/Footer.tsx",
    "content": "import { Flex, Icon, Text } from \"@chakra-ui/react\";\nimport { VscRemote } from \"react-icons/vsc\";\n\nfunction Footer() {\n  return (\n    <Flex h=\"22px\" bgColor=\"#0071c3\" color=\"white\">\n      <Flex\n        h=\"100%\"\n        bgColor=\"#09835c\"\n        pl={2.5}\n        pr={4}\n        fontSize=\"sm\"\n        align=\"center\"\n      >\n        <Icon as={VscRemote} mb={-0.5} mr={1} />\n        <Text fontSize=\"xs\">Composing Studio</Text>\n      </Flex>\n    </Flex>\n  );\n}\n\nexport default Footer;\n"
  },
  {
    "path": "src/components/LandingFeature.tsx",
    "content": "import { Heading, Image, Stack } from \"@chakra-ui/react\";\n\ntype LandingFeatureProps = {\n  title: string;\n  image: string;\n};\n\nfunction LandingFeature({ title, image }: LandingFeatureProps) {\n  return (\n    <Stack align=\"center\">\n      <Heading size=\"xl\" mb={1}>\n        {title}\n      </Heading>\n      <Image maxW=\"sm\" minW={0} rounded=\"md\" shadow=\"md\" src={image} />\n    </Stack>\n  );\n}\n\nexport default LandingFeature;\n"
  },
  {
    "path": "src/components/Score.tsx",
    "content": "import { useEffect, useRef } from \"react\";\nimport { Box, Stack } from \"@chakra-ui/react\";\nimport abcjs from \"abcjs\";\nimport { nanoid } from \"nanoid\";\nimport \"abcjs/abcjs-audio.css\";\n\nclass CursorControl {\n  // This demonstrates two methods of indicating where the music is.\n  // 1) An element is created that is moved along for each note.\n  // 2) The currently being played note is given a class so that it can be transformed.\n  private cursor: SVGLineElement | null = null; // This is the svg element that will move with the music.\n\n  constructor(private readonly rootSelector: string) {}\n\n  onStart() {\n    // This is called when the timer starts so we know the svg has been drawn by now.\n    // Create the cursor and add it to the sheet music's svg.\n    const svg = document.querySelector(this.rootSelector + \" svg\");\n    this.cursor = document.createElementNS(\n      \"http://www.w3.org/2000/svg\",\n      \"line\"\n    );\n    this.cursor.setAttribute(\"class\", \"abcjs-cursor\");\n    this.cursor.setAttributeNS(null, \"x1\", \"0\");\n    this.cursor.setAttributeNS(null, \"y1\", \"0\");\n    this.cursor.setAttributeNS(null, \"x2\", \"0\");\n    this.cursor.setAttributeNS(null, \"y2\", \"0\");\n    svg?.appendChild(this.cursor);\n  }\n\n  removeSelection() {\n    // Unselect any previously selected notes.\n    var lastSelection = document.querySelectorAll(\n      this.rootSelector + \" .abcjs-highlight\"\n    );\n    for (var k = 0; k < lastSelection.length; k++)\n      lastSelection[k].classList.remove(\"abcjs-highlight\");\n  }\n\n  onEvent(ev: any) {\n    // This is called every time a note or a rest is reached and contains the coordinates of it.\n    if (ev.measureStart && ev.left === null) return; // this was the second part of a tie across a measure line. Just ignore it.\n\n    this.removeSelection();\n\n    // Select the currently selected notes.\n    for (var i = 0; i < ev.elements.length; i++) {\n      var note = ev.elements[i];\n      for (var j = 0; j < note.length; j++) {\n        note[j].classList.add(\"abcjs-highlight\");\n      }\n    }\n\n    // Move the cursor to the location of the current note.\n    if (this.cursor) {\n      this.cursor.setAttribute(\"x1\", String(ev.left - 2));\n      this.cursor.setAttribute(\"x2\", String(ev.left - 2));\n      this.cursor.setAttribute(\"y1\", ev.top);\n      this.cursor.setAttribute(\"y2\", ev.top + ev.height);\n    }\n  }\n\n  onFinished() {\n    this.removeSelection();\n\n    if (this.cursor) {\n      this.cursor.setAttribute(\"x1\", \"0\");\n      this.cursor.setAttribute(\"x2\", \"0\");\n      this.cursor.setAttribute(\"y1\", \"0\");\n      this.cursor.setAttribute(\"y2\", \"0\");\n    }\n  }\n}\n\ntype ScoreProps = {\n  notes: string;\n  darkMode: boolean;\n};\n\nfunction Score({ notes, darkMode }: ScoreProps) {\n  const ref = useRef<any>(null);\n  if (ref.current === null) {\n    const id = nanoid();\n    ref.current = {\n      id,\n      synth: new abcjs.synth.CreateSynth(),\n      synthControl: new abcjs.synth.SynthController(),\n      cursorControl: new CursorControl(`#paper-${id}`),\n    };\n  }\n\n  useEffect(() => {\n    ref.current.synthControl.load(\n      `#audio-${ref.current.id}`,\n      ref.current.cursorControl,\n      {\n        displayLoop: true,\n        displayRestart: true,\n        displayPlay: true,\n        displayProgress: true,\n        displayWarp: true,\n      }\n    );\n  }, []);\n\n  useEffect(() => {\n    try {\n      let visualObj = abcjs.renderAbc(`paper-${ref.current.id}`, notes, {\n        responsive: \"resize\",\n        add_classes: true,\n      });\n\n      ref.current.synth\n        .init({ visualObj: visualObj[0] })\n        .then(function () {\n          ref.current.synthControl\n            .setTune(visualObj[0], false, { chordsOff: false })\n            .then(function () {\n              console.log(\"Audio successfully loaded.\");\n            })\n            .catch(function (error: any) {\n              console.warn(\"Audio problem:\", error);\n            });\n        })\n        .catch(function (error: any) {\n          console.warn(\"Audio problem:\", error);\n        });\n    } catch (error) {\n      console.warn(\"Error when running Abcjs:\", error);\n    }\n  }, [notes]);\n\n  return (\n    <Stack p={3}>\n      <Box\n        id={`paper-${ref.current.id}`}\n        borderWidth=\"1px\"\n        borderColor=\"gray.500\"\n        rounded=\"sm\"\n        bgColor={darkMode ? \"whiteAlpha.900\" : \"initial\"}\n      />\n      <Box id={`audio-${ref.current.id}`} />\n    </Stack>\n  );\n}\n\nexport default Score;\n"
  },
  {
    "path": "src/components/User.tsx",
    "content": "import {\n  Button,\n  ButtonGroup,\n  HStack,\n  Icon,\n  Input,\n  Popover,\n  PopoverArrow,\n  PopoverBody,\n  PopoverCloseButton,\n  PopoverContent,\n  PopoverFooter,\n  PopoverHeader,\n  PopoverTrigger,\n  Text,\n  useDisclosure,\n} from \"@chakra-ui/react\";\nimport { useRef } from \"react\";\nimport { FaPalette } from \"react-icons/fa\";\nimport { VscAccount } from \"react-icons/vsc\";\nimport { UserInfo } from \"../lib/rustpad\";\n\ntype UserProps = {\n  info: UserInfo;\n  isMe?: boolean;\n  onChangeName?: (name: string) => unknown;\n  onChangeColor?: () => unknown;\n  darkMode: boolean;\n};\n\nfunction User({\n  info,\n  isMe = false,\n  onChangeName,\n  onChangeColor,\n  darkMode,\n}: UserProps) {\n  const inputRef = useRef<HTMLInputElement>(null);\n  const { isOpen, onOpen, onClose } = useDisclosure();\n\n  const nameColor = `hsl(${info.hue}, 90%, ${darkMode ? \"70%\" : \"25%\"})`;\n  return (\n    <Popover\n      placement=\"right\"\n      isOpen={isOpen}\n      onClose={onClose}\n      initialFocusRef={inputRef}\n    >\n      <PopoverTrigger>\n        <HStack\n          p={2}\n          rounded=\"md\"\n          _hover={{\n            bgColor: darkMode ? \"#464647\" : \"gray.200\",\n            cursor: \"pointer\",\n          }}\n          onClick={() => isMe && onOpen()}\n        >\n          <Icon as={VscAccount} />\n          <Text fontWeight=\"medium\" color={nameColor}>\n            {info.name}\n          </Text>\n          {isMe && <Text>(you)</Text>}\n        </HStack>\n      </PopoverTrigger>\n      <PopoverContent\n        bgColor={darkMode ? \"#333333\" : \"white\"}\n        borderColor={darkMode ? \"#464647\" : \"gray.200\"}\n      >\n        <PopoverHeader\n          fontWeight=\"semibold\"\n          borderColor={darkMode ? \"#464647\" : \"gray.200\"}\n        >\n          Update Info\n        </PopoverHeader>\n        <PopoverArrow bgColor={darkMode ? \"#333333\" : \"white\"} />\n        <PopoverCloseButton />\n        <PopoverBody borderColor={darkMode ? \"#464647\" : \"gray.200\"}>\n          <Input\n            ref={inputRef}\n            mb={2}\n            value={info.name}\n            maxLength={25}\n            onChange={(event) => onChangeName?.(event.target.value)}\n          />\n          <Button\n            size=\"sm\"\n            w=\"100%\"\n            leftIcon={<FaPalette />}\n            colorScheme={darkMode ? \"whiteAlpha\" : \"gray\"}\n            onClick={onChangeColor}\n          >\n            Change Color\n          </Button>\n        </PopoverBody>\n        <PopoverFooter\n          d=\"flex\"\n          justifyContent=\"flex-end\"\n          borderColor={darkMode ? \"#464647\" : \"gray.200\"}\n        >\n          <ButtonGroup size=\"sm\">\n            <Button colorScheme=\"blue\" onClick={onClose}>\n              Done\n            </Button>\n          </ButtonGroup>\n        </PopoverFooter>\n      </PopoverContent>\n    </Popover>\n  );\n}\n\nexport default User;\n"
  },
  {
    "path": "src/index.css",
    "content": "body {\n  overscroll-behavior: none;\n  overflow: hidden;\n}\n\n/* Hack: Fix number rendering in abcjs audio controller. */\n.abcjs-midi-tempo {\n  color: black;\n}\n\n.abcjs-highlight {\n  fill: #0071c3;\n}\n\n.abcjs-cursor {\n  stroke: rgba(75, 159, 162, 0.4);\n  stroke-width: 28px;\n  transform: translateX(6px);\n}\n"
  },
  {
    "path": "src/index.tsx",
    "content": "import { StrictMode } from \"react\";\nimport ReactDOM from \"react-dom\";\nimport { ChakraProvider } from \"@chakra-ui/react\";\nimport init, { set_panic_hook } from \"cstudio-wasm\";\nimport App from \"./App\";\nimport \"./index.css\";\n\ninit().then(() => {\n  set_panic_hook();\n  ReactDOM.render(\n    <StrictMode>\n      <ChakraProvider>\n        <App />\n      </ChakraProvider>\n    </StrictMode>,\n    document.getElementById(\"root\")\n  );\n});\n"
  },
  {
    "path": "src/lib/animals.json",
    "content": "[\n  \"Alligator\",\n  \"Ant\",\n  \"Anteater\",\n  \"Antelope\",\n  \"Arctic Fox\",\n  \"Armadillo\",\n  \"Badger\",\n  \"Bat\",\n  \"Beaver\",\n  \"Bee\",\n  \"Beetle\",\n  \"Black Bear\",\n  \"Buffalo\",\n  \"Butterfly\",\n  \"Camel\",\n  \"Cat\",\n  \"Chameleon\",\n  \"Cheetah\",\n  \"Chicken\",\n  \"Cicada\",\n  \"Clam\",\n  \"Cockatoo\",\n  \"Cockroach\",\n  \"Cow\",\n  \"Coyote\",\n  \"Crab\",\n  \"Cricket\",\n  \"Crow\",\n  \"Deer\",\n  \"Dog\",\n  \"Dolphin\",\n  \"Donkey\",\n  \"Dove\",\n  \"Dragonfly\",\n  \"Duck\",\n  \"Eagle\",\n  \"Eel\",\n  \"Elephant\",\n  \"Ferret\",\n  \"Fish\",\n  \"Fly\",\n  \"Fox\",\n  \"Frog\",\n  \"Gazelle\",\n  \"Goat\",\n  \"Grasshopper\",\n  \"Grizzly Bear\",\n  \"Groundhog\",\n  \"Guinea Pig\",\n  \"Hedgehog\",\n  \"Hen\",\n  \"Hippopotamus\",\n  \"Horse\",\n  \"Hummingbird\",\n  \"Hyena\",\n  \"Koala\",\n  \"Leopard\",\n  \"Lion\",\n  \"Llama\",\n  \"Lobster\",\n  \"Lynx\",\n  \"Meerkat\",\n  \"Mole\",\n  \"Moose\",\n  \"Moth\",\n  \"Mouse\",\n  \"Octopus\",\n  \"Orangutan\",\n  \"Orca\",\n  \"Ostrich\",\n  \"Owl\",\n  \"Panda Bear\",\n  \"Panther\",\n  \"Parrot\",\n  \"Penguin\",\n  \"Pig\",\n  \"Pigeon\",\n  \"Polar Bear\",\n  \"Rabbit\",\n  \"Raccoon\",\n  \"Reindeer\",\n  \"Robin\",\n  \"Sea Lion\",\n  \"Sea Otter\",\n  \"Seagull\",\n  \"Seahorse\",\n  \"Seal\",\n  \"Shark\",\n  \"Sheep\",\n  \"Shrimp\",\n  \"Slug\",\n  \"Snail\",\n  \"Snake\",\n  \"Sparrow\",\n  \"Squid\",\n  \"Squirrel\",\n  \"Starfish\",\n  \"Swan\",\n  \"Tiger\",\n  \"Turkey\",\n  \"Turtle\",\n  \"Wallaby\",\n  \"Walrus\",\n  \"Wasp\",\n  \"Water Buffalo\",\n  \"Weasel\",\n  \"Weaver\",\n  \"Whale\",\n  \"Wildcat\",\n  \"Wilddog\",\n  \"Wolf\",\n  \"Wolverine\",\n  \"Wombat\",\n  \"Woodpecker\"\n]\n"
  },
  {
    "path": "src/lib/rustpad.ts",
    "content": "import { OpSeq } from \"cstudio-wasm\";\nimport type {\n  editor,\n  IDisposable,\n  IPosition,\n} from \"monaco-editor/esm/vs/editor/editor.api\";\nimport debounce from \"lodash.debounce\";\n\n/** Options passed in to the Rustpad constructor. */\nexport type RustpadOptions = {\n  readonly uri: string;\n  readonly editor: editor.IStandaloneCodeEditor;\n  readonly onConnected?: () => unknown;\n  readonly onDisconnected?: () => unknown;\n  readonly onDesynchronized?: () => unknown;\n  readonly onChangeLanguage?: (language: string) => unknown;\n  readonly onChangeUsers?: (users: Record<number, UserInfo>) => unknown;\n  readonly reconnectInterval?: number;\n};\n\n/** A user currently editing the document. */\nexport type UserInfo = {\n  readonly name: string;\n  readonly hue: number;\n};\n\n/** Browser client for Rustpad. */\nclass Rustpad {\n  private ws?: WebSocket;\n  private connecting?: boolean;\n  private recentFailures: number = 0;\n  private readonly model: editor.ITextModel;\n  private readonly onChangeHandle: IDisposable;\n  private readonly onCursorHandle: IDisposable;\n  private readonly onSelectionHandle: IDisposable;\n  private readonly beforeUnload: (event: BeforeUnloadEvent) => void;\n  private readonly tryConnectId: number;\n  private readonly resetFailuresId: number;\n\n  // Client-server state\n  private me: number = -1;\n  private revision: number = 0;\n  private outstanding?: OpSeq;\n  private buffer?: OpSeq;\n  private users: Record<number, UserInfo> = {};\n  private userCursors: Record<number, CursorData> = {};\n  private myInfo?: UserInfo;\n  private cursorData: CursorData = { cursors: [], selections: [] };\n\n  // Intermittent local editor state\n  private lastValue: string = \"\";\n  private ignoreChanges: boolean = false;\n  private oldDecorations: string[] = [];\n\n  constructor(readonly options: RustpadOptions) {\n    this.model = options.editor.getModel()!;\n    this.onChangeHandle = options.editor.onDidChangeModelContent((e) =>\n      this.onChange(e)\n    );\n    const cursorUpdate = debounce(() => this.sendCursorData(), 20);\n    this.onCursorHandle = options.editor.onDidChangeCursorPosition((e) => {\n      this.onCursor(e);\n      cursorUpdate();\n    });\n    this.onSelectionHandle = options.editor.onDidChangeCursorSelection((e) => {\n      this.onSelection(e);\n      cursorUpdate();\n    });\n    this.beforeUnload = (event: BeforeUnloadEvent) => {\n      if (this.outstanding) {\n        event.preventDefault();\n        event.returnValue = \"\";\n      } else {\n        delete event.returnValue;\n      }\n    };\n    window.addEventListener(\"beforeunload\", this.beforeUnload);\n\n    const interval = options.reconnectInterval ?? 1000;\n    this.tryConnect();\n    this.tryConnectId = window.setInterval(() => this.tryConnect(), interval);\n    this.resetFailuresId = window.setInterval(\n      () => (this.recentFailures = 0),\n      15 * interval\n    );\n  }\n\n  /** Destroy this Rustpad instance and close any sockets. */\n  dispose() {\n    window.clearInterval(this.tryConnectId);\n    window.clearInterval(this.resetFailuresId);\n    this.onSelectionHandle.dispose();\n    this.onCursorHandle.dispose();\n    this.onChangeHandle.dispose();\n    window.removeEventListener(\"beforeunload\", this.beforeUnload);\n    this.ws?.close();\n  }\n\n  /** Try to set the language of the editor, if connected. */\n  setLanguage(language: string): boolean {\n    this.ws?.send(`{\"SetLanguage\":${JSON.stringify(language)}}`);\n    return this.ws !== undefined;\n  }\n\n  /** Set the user's information. */\n  setInfo(info: UserInfo) {\n    this.myInfo = info;\n    this.sendInfo();\n  }\n\n  /**\n   * Attempts a WebSocket connection.\n   *\n   * Safety Invariant: Until this WebSocket connection is closed, no other\n   * connections will be attempted because either `this.ws` or\n   * `this.connecting` will be set to a truthy value.\n   *\n   * Liveness Invariant: After this WebSocket connection closes, either through\n   * error or successful end, both `this.connecting` and `this.ws` will be set\n   * to falsy values.\n   */\n  private tryConnect() {\n    if (this.connecting || this.ws) return;\n    this.connecting = true;\n    const ws = new WebSocket(this.options.uri);\n    ws.onopen = () => {\n      this.connecting = false;\n      this.ws = ws;\n      this.options.onConnected?.();\n      this.users = {};\n      this.options.onChangeUsers?.(this.users);\n      this.sendInfo();\n      this.sendCursorData();\n      if (this.outstanding) {\n        this.sendOperation(this.outstanding);\n      }\n    };\n    ws.onclose = () => {\n      if (this.ws) {\n        this.ws = undefined;\n        this.options.onDisconnected?.();\n        if (++this.recentFailures >= 5) {\n          // If we disconnect 5 times within 15 reconnection intervals, then the\n          // client is likely desynchronized and needs to refresh.\n          this.dispose();\n          this.options.onDesynchronized?.();\n        }\n      } else {\n        this.connecting = false;\n      }\n    };\n    ws.onmessage = ({ data }) => {\n      if (typeof data === \"string\") {\n        this.handleMessage(JSON.parse(data));\n      }\n    };\n  }\n\n  private handleMessage(msg: ServerMsg) {\n    if (msg.Identity !== undefined) {\n      this.me = msg.Identity;\n    } else if (msg.History !== undefined) {\n      const { start, operations } = msg.History;\n      if (start > this.revision) {\n        console.warn(\"History message has start greater than last operation.\");\n        this.ws?.close();\n        return;\n      }\n      for (let i = this.revision - start; i < operations.length; i++) {\n        let { id, operation } = operations[i];\n        this.revision++;\n        if (id === this.me) {\n          this.serverAck();\n        } else {\n          operation = OpSeq.from_str(JSON.stringify(operation));\n          this.applyServer(operation);\n        }\n      }\n    } else if (msg.Language !== undefined) {\n      this.options.onChangeLanguage?.(msg.Language);\n    } else if (msg.UserInfo !== undefined) {\n      const { id, info } = msg.UserInfo;\n      if (id !== this.me) {\n        this.users = { ...this.users };\n        if (info) {\n          this.users[id] = info;\n        } else {\n          delete this.users[id];\n          delete this.userCursors[id];\n        }\n        this.updateCursors();\n        this.options.onChangeUsers?.(this.users);\n      }\n    } else if (msg.UserCursor !== undefined) {\n      const { id, data } = msg.UserCursor;\n      if (id !== this.me) {\n        this.userCursors[id] = data;\n        this.updateCursors();\n      }\n    }\n  }\n\n  private serverAck() {\n    if (!this.outstanding) {\n      console.warn(\"Received serverAck with no outstanding operation.\");\n      return;\n    }\n    this.outstanding = this.buffer;\n    this.buffer = undefined;\n    if (this.outstanding) {\n      this.sendOperation(this.outstanding);\n    }\n  }\n\n  private applyServer(operation: OpSeq) {\n    if (this.outstanding) {\n      const pair = this.outstanding.transform(operation)!;\n      this.outstanding = pair.first();\n      operation = pair.second();\n      if (this.buffer) {\n        const pair = this.buffer.transform(operation)!;\n        this.buffer = pair.first();\n        operation = pair.second();\n      }\n    }\n    this.applyOperation(operation);\n  }\n\n  private applyClient(operation: OpSeq) {\n    if (!this.outstanding) {\n      this.sendOperation(operation);\n      this.outstanding = operation;\n    } else if (!this.buffer) {\n      this.buffer = operation;\n    } else {\n      this.buffer = this.buffer.compose(operation);\n    }\n    this.transformCursors(operation);\n  }\n\n  private sendOperation(operation: OpSeq) {\n    const op = operation.to_string();\n    this.ws?.send(`{\"Edit\":{\"revision\":${this.revision},\"operation\":${op}}}`);\n  }\n\n  private sendInfo() {\n    if (this.myInfo) {\n      this.ws?.send(`{\"ClientInfo\":${JSON.stringify(this.myInfo)}}`);\n    }\n  }\n\n  private sendCursorData() {\n    if (!this.buffer) {\n      this.ws?.send(`{\"CursorData\":${JSON.stringify(this.cursorData)}}`);\n    }\n  }\n\n  private applyOperation(operation: OpSeq) {\n    if (operation.is_noop()) return;\n\n    this.ignoreChanges = true;\n    const ops: (string | number)[] = JSON.parse(operation.to_string());\n    let index = 0;\n\n    for (const op of ops) {\n      if (typeof op === \"string\") {\n        // Insert\n        const pos = unicodePosition(this.model, index);\n        index += unicodeLength(op);\n        this.model.pushEditOperations(\n          this.options.editor.getSelections(),\n          [\n            {\n              range: {\n                startLineNumber: pos.lineNumber,\n                startColumn: pos.column,\n                endLineNumber: pos.lineNumber,\n                endColumn: pos.column,\n              },\n              text: op,\n              forceMoveMarkers: true,\n            },\n          ],\n          () => null\n        );\n      } else if (op >= 0) {\n        // Retain\n        index += op;\n      } else {\n        // Delete\n        const chars = -op;\n        var from = unicodePosition(this.model, index);\n        var to = unicodePosition(this.model, index + chars);\n        this.model.pushEditOperations(\n          this.options.editor.getSelections(),\n          [\n            {\n              range: {\n                startLineNumber: from.lineNumber,\n                startColumn: from.column,\n                endLineNumber: to.lineNumber,\n                endColumn: to.column,\n              },\n              text: \"\",\n              forceMoveMarkers: true,\n            },\n          ],\n          () => null\n        );\n      }\n    }\n\n    this.lastValue = this.model.getValue();\n    this.ignoreChanges = false;\n\n    this.transformCursors(operation);\n  }\n\n  private transformCursors(operation: OpSeq) {\n    for (const data of Object.values(this.userCursors)) {\n      data.cursors = data.cursors.map((c) => operation.transform_index(c));\n      data.selections = data.selections.map(([s, e]) => [\n        operation.transform_index(s),\n        operation.transform_index(e),\n      ]);\n    }\n    this.updateCursors();\n  }\n\n  private updateCursors() {\n    const decorations: editor.IModelDeltaDecoration[] = [];\n\n    for (const [id, data] of Object.entries(this.userCursors)) {\n      if (id in this.users) {\n        const { hue, name } = this.users[id as any];\n        generateCssStyles(hue);\n\n        for (const cursor of data.cursors) {\n          const position = unicodePosition(this.model, cursor);\n          decorations.push({\n            options: {\n              className: `remote-cursor-${hue}`,\n              stickiness: 1,\n              zIndex: 2,\n            },\n            range: {\n              startLineNumber: position.lineNumber,\n              startColumn: position.column,\n              endLineNumber: position.lineNumber,\n              endColumn: position.column,\n            },\n          });\n        }\n        for (const selection of data.selections) {\n          const position = unicodePosition(this.model, selection[0]);\n          const positionEnd = unicodePosition(this.model, selection[1]);\n          decorations.push({\n            options: {\n              className: `remote-selection-${hue}`,\n              hoverMessage: {\n                value: name,\n              },\n              stickiness: 1,\n              zIndex: 1,\n            },\n            range: {\n              startLineNumber: position.lineNumber,\n              startColumn: position.column,\n              endLineNumber: positionEnd.lineNumber,\n              endColumn: positionEnd.column,\n            },\n          });\n        }\n      }\n    }\n\n    this.oldDecorations = this.model.deltaDecorations(\n      this.oldDecorations,\n      decorations\n    );\n  }\n\n  private onChange(event: editor.IModelContentChangedEvent) {\n    if (!this.ignoreChanges) {\n      const content = this.lastValue;\n      const contentLength = unicodeLength(content);\n      let offset = 0;\n\n      let operation = OpSeq.new();\n      operation.retain(contentLength);\n      event.changes.sort((a, b) => b.rangeOffset - a.rangeOffset);\n      for (const change of event.changes) {\n        // The following dance is necessary to convert from UTF-16 indices (evil\n        // encoding-dependent JavaScript representation) to portable Unicode\n        // codepoint indices.\n        const { text, rangeOffset, rangeLength } = change;\n        const initialLength = unicodeLength(content.slice(0, rangeOffset));\n        const deletedLength = unicodeLength(\n          content.slice(rangeOffset, rangeOffset + rangeLength)\n        );\n        const restLength =\n          contentLength + offset - initialLength - deletedLength;\n        const changeOp = OpSeq.new();\n        changeOp.retain(initialLength);\n        changeOp.delete(deletedLength);\n        changeOp.insert(text);\n        changeOp.retain(restLength);\n        operation = operation.compose(changeOp)!;\n        offset += changeOp.target_len() - changeOp.base_len();\n      }\n      this.applyClient(operation);\n      this.lastValue = this.model.getValue();\n    }\n  }\n\n  private onCursor(event: editor.ICursorPositionChangedEvent) {\n    const cursors = [event.position, ...event.secondaryPositions];\n    this.cursorData.cursors = cursors.map((p) => unicodeOffset(this.model, p));\n  }\n\n  private onSelection(event: editor.ICursorSelectionChangedEvent) {\n    const selections = [event.selection, ...event.secondarySelections];\n    this.cursorData.selections = selections.map((s) => [\n      unicodeOffset(this.model, s.getStartPosition()),\n      unicodeOffset(this.model, s.getEndPosition()),\n    ]);\n  }\n}\n\ntype UserOperation = {\n  id: number;\n  operation: any;\n};\n\ntype CursorData = {\n  cursors: number[];\n  selections: [number, number][];\n};\n\ntype ServerMsg = {\n  Identity?: number;\n  History?: {\n    start: number;\n    operations: UserOperation[];\n  };\n  Language?: string;\n  UserInfo?: {\n    id: number;\n    info: UserInfo | null;\n  };\n  UserCursor?: {\n    id: number;\n    data: CursorData;\n  };\n};\n\n/** Returns the number of Unicode codepoints in a string. */\nfunction unicodeLength(str: string): number {\n  let length = 0;\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  for (const c of str) ++length;\n  return length;\n}\n\n/** Returns the number of Unicode codepoints before a position in the model. */\nfunction unicodeOffset(model: editor.ITextModel, pos: IPosition): number {\n  const value = model.getValue();\n  const offsetUTF16 = model.getOffsetAt(pos);\n  return unicodeLength(value.slice(0, offsetUTF16));\n}\n\n/** Returns the position after a certain number of Unicode codepoints. */\nfunction unicodePosition(model: editor.ITextModel, offset: number): IPosition {\n  const value = model.getValue();\n  let offsetUTF16 = 0;\n  for (const c of value) {\n    // Iterate over Unicode codepoints\n    if (offset <= 0) break;\n    offsetUTF16 += c.length;\n    offset -= 1;\n  }\n  return model.getPositionAt(offsetUTF16);\n}\n\n/** Cache for private use by `generateCssStyles()`. */\nconst generatedStyles = new Set<number>();\n\n/** Add CSS styles for a remote user's cursor and selection. */\nfunction generateCssStyles(hue: number) {\n  if (!generatedStyles.has(hue)) {\n    generatedStyles.add(hue);\n    const css = `\n      .monaco-editor .remote-selection-${hue} {\n        background-color: hsla(${hue}, 90%, 80%, 0.5);\n      }\n      .monaco-editor .remote-cursor-${hue} {\n        border-left: 2px solid hsl(${hue}, 90%, 25%);\n      }\n    `;\n    const element = document.createElement(\"style\");\n    const text = document.createTextNode(css);\n    element.appendChild(text);\n    document.head.appendChild(element);\n  }\n}\n\nexport default Rustpad;\n"
  },
  {
    "path": "src/music/bartok.abc",
    "content": "T: Excerpt from Viola Concerto\nA: Béla Bartók\nL: 1/4\nQ: 80\nV: Viola nm=\"Viola\"\nK:C clef=alto\n!mf! (Cc) (B3/2 G/) | (c//B//G/) (B/c/) (B3/2 G/) | (_B/4 G/4 F/) (G/2 B/2) ^F (_E/ =F/) | (_E/C/_B,) C2 |]\nV: Piano nm=\"Piano\"\nK:C clef=bass\n!p! (C,4 | (C,4) | (C,4) | C,4) |]\n"
  },
  {
    "path": "src/music/fluteDuet.abc",
    "content": "T:Duet in G\nT:1. Allegro\nC:Fran\\ccois Devienne\nZ:Transcribed by Frank Nordberg - http://www.musicaviva.com\nM:C\nL:1/8\nQ:136\nK:G\nV:Flute_1\ng4 (fd)(ef)|g2g2a2a2|b4 (ag)(ab)|g2g2d4|\nV:Flute_2\n(BG)(AB) c4|(Bd)(BG) (FA)(FD)|(GA)(Bc) d2d2|B4 (BA)(GA)|\n%\nV:Flute_1\ng4 (ag)(fe)|(fe)(fg) (ab)(^c'd')|b2g2e2^c2|(e4d4):|\nV:Flute_2\n(BG)(AB) ^c4|(d^c)(de) (fg)(af)|g2B2G2E2|(G4F4):|\n"
  },
  {
    "path": "src/music/fugue.abc",
    "content": "T:Little Fugue in G Minor\nM:4/4\nC:J. S. Bach\nZ:Jeff Bigler\nQ:1/4=72\nL:1/16\nK:Gm\nV:1 nm=\"Violin 1\"\n!f! G4 d4 B6 A2 |\\\nG2B2A2G2 (^F2A2) D4 |\\\nG2D2A2D2 B2AG A2D2 |\\\nG2DG A2DA B2AG ADdc |\nBAGB AG^FA GDGA Bcd=e |\\\n!mf! =f=edf ed^ce d2A2d2e2 |\\\n(vfg)ufvg (uTg3f/2g/2) agab agf=e |\nfaga ^caga daga caga |\\\nfd^cd gdcd adcd gdcd |\\\n!mp! A2f2G2=e2 F2A2d2f2 |\n!p! _e2a2 z2 e2 d2g2 z2 d2 \"A\"|\\\n!mf! cBcd caga Bg^fg Af=ef |\\\n[dg]16|]\nV:2 nm=\"Violin 2\"\nz16 |\\\nz16 |\\\nz16 |\\\nz16 |\nz16 |\\\n!f! d4 a4 f6 =e2 |\\\nd2f2=e2d2 (^c2e2) A4 |\nd2A2=e2A2 f2ed e2A2 |\\\nd2Ad =e2Ae f2ed eAag |\\\nf=edf ed^ce dAde fga=b |\n!mf! c'_bc'd' c'bac' babc' bagb |\\\na2g2^f2d2 !f! G4 d4 |\\\n[G_B]16|]\n"
  },
  {
    "path": "src/music/twinkle.abc",
    "content": "T:Twinkle, Twinkle Little Star\nM:4/4\nL:1/4\nK:C\nQ:96\nC C G G | A A G2 | F F E E | D D C2 |\nw:Twin-kle twin-kle lit-tle star, How I won-der what you are.\nG G F F | E E D2 | G G F F | E E D2 |\nw:Up a-bove the world so high, like a dia-mond in the sky.\nC C G G | A A G2 | F F E E | D D C2 |]\nw:Twin-kle twin-kle lit-tle star, How I won-der what you are!\n"
  },
  {
    "path": "src/pages/EditorPage.tsx",
    "content": "import { useEffect, useRef, useState } from \"react\";\nimport { useParams } from \"react-router-dom\";\nimport {\n  Box,\n  Button,\n  Container,\n  Flex,\n  Heading,\n  HStack,\n  Icon,\n  Input,\n  InputGroup,\n  InputRightElement,\n  Link,\n  Stack,\n  Switch,\n  Text,\n  useToast,\n} from \"@chakra-ui/react\";\nimport {\n  VscChevronRight,\n  VscFolderOpened,\n  VscGist,\n  VscRepoPull,\n} from \"react-icons/vsc\";\nimport { useDebounce } from \"use-debounce\";\nimport useStorage from \"use-local-storage-state\";\nimport Editor from \"@monaco-editor/react\";\nimport type { editor } from \"monaco-editor/esm/vs/editor/editor.api\";\nimport animals from \"../lib/animals.json\";\nimport Rustpad, { UserInfo } from \"../lib/rustpad\";\nimport ConnectionStatus from \"../components/ConnectionStatus\";\nimport Footer from \"../components/Footer\";\nimport User from \"../components/User\";\nimport Score from \"../components/Score\";\nimport fluteDuetAbc from \"../music/fluteDuet.abc?raw\";\nimport fugueAbc from \"../music/fugue.abc?raw\";\nimport bartokAbc from \"../music/bartok.abc?raw\";\nimport twinkleAbc from \"../music/twinkle.abc?raw\";\nimport Split from \"react-split\";\nimport \"./Split.css\";\n\nfunction getWsUri(id: string) {\n  return (\n    (window.location.origin.startsWith(\"https\") ? \"wss://\" : \"ws://\") +\n    window.location.host +\n    `/api/socket/${id}`\n  );\n}\n\nfunction generateName() {\n  return \"Anonymous \" + animals[Math.floor(Math.random() * animals.length)];\n}\n\nfunction generateHue() {\n  return Math.floor(Math.random() * 360);\n}\n\nfunction EditorPage() {\n  const toast = useToast();\n  const [connection, setConnection] = useState<\n    \"connected\" | \"disconnected\" | \"desynchronized\"\n  >(\"disconnected\");\n  const [users, setUsers] = useState<Record<number, UserInfo>>({});\n  const [name, setName] = useStorage(\"name\", generateName);\n  const [hue, setHue] = useStorage(\"hue\", generateHue);\n  const [editor, setEditor] = useState<editor.IStandaloneCodeEditor>();\n  const [darkMode, setDarkMode] = useStorage(\"darkMode\", () => false);\n  const rustpad = useRef<Rustpad>();\n  const { id } = useParams<string>();\n\n  useEffect(() => {\n    if (editor?.getModel()) {\n      const model = editor.getModel()!;\n      model.setValue(\"\");\n      model.setEOL(0); // LF\n      rustpad.current = new Rustpad({\n        uri: getWsUri(id!),\n        editor,\n        onConnected: () => setConnection(\"connected\"),\n        onDisconnected: () => setConnection(\"disconnected\"),\n        onDesynchronized: () => {\n          setConnection(\"desynchronized\");\n          toast({\n            title: \"Desynchronized with server\",\n            description: \"Please save your work and refresh the page.\",\n            status: \"error\",\n            duration: null,\n          });\n        },\n        onChangeUsers: setUsers,\n      });\n      return () => {\n        rustpad.current?.dispose();\n        rustpad.current = undefined;\n      };\n    }\n  }, [id, editor, toast, setUsers]);\n\n  useEffect(() => {\n    if (connection === \"connected\") {\n      rustpad.current?.setInfo({ name, hue });\n    }\n  }, [connection, name, hue]);\n\n  async function handleCopy() {\n    await navigator.clipboard.writeText(`${window.location.origin}/${id}`);\n    toast({\n      title: \"Copied!\",\n      description: \"Link copied to clipboard\",\n      status: \"success\",\n      duration: 2000,\n      isClosable: true,\n    });\n  }\n\n  function handleDarkMode() {\n    setDarkMode(!darkMode);\n  }\n\n  function handleLoadSample() {\n    const samples = [fluteDuetAbc, fugueAbc, bartokAbc, twinkleAbc];\n\n    if (editor?.getModel()) {\n      const model = editor.getModel()!;\n      model.pushEditOperations(\n        editor.getSelections(),\n        [\n          {\n            range: model.getFullModelRange(),\n            text: samples[Math.floor(Math.random() * samples.length)],\n          },\n        ],\n        () => null\n      );\n      editor.setPosition({ column: 0, lineNumber: 0 });\n    }\n  }\n\n  const [text, setText] = useState(\"\");\n  const [abcString] = useDebounce(text, 100, { maxWait: 1000 });\n\n  return (\n    <Flex\n      direction=\"column\"\n      h=\"100vh\"\n      overflow=\"hidden\"\n      bgColor={darkMode ? \"#1e1e1e\" : \"white\"}\n      color={darkMode ? \"#cbcaca\" : \"inherit\"}\n      className={darkMode ? \"dark-mode\" : undefined}\n    >\n      <Box\n        flexShrink={0}\n        bgColor={darkMode ? \"#333333\" : \"#e8e8e8\"}\n        color={darkMode ? \"#cccccc\" : \"#383838\"}\n        textAlign=\"center\"\n        fontSize=\"sm\"\n        py={0.5}\n      >\n        Composing Studio\n      </Box>\n      <Flex flex=\"1 0\" minH={0}>\n        <Container\n          w=\"xs\"\n          bgColor={darkMode ? \"#252526\" : \"#f3f3f3\"}\n          overflowY=\"auto\"\n          maxW=\"full\"\n          lineHeight={1.4}\n          py={4}\n        >\n          <ConnectionStatus darkMode={darkMode} connection={connection} />\n\n          <Flex justifyContent=\"space-between\" mt={4} mb={1.5} w=\"full\">\n            <Heading size=\"sm\">Dark Mode</Heading>\n            <Switch isChecked={darkMode} onChange={handleDarkMode} />\n          </Flex>\n\n          <Heading mt={4} mb={1.5} size=\"sm\">\n            Share Link\n          </Heading>\n          <InputGroup size=\"sm\">\n            <Input\n              readOnly\n              pr=\"3.5rem\"\n              variant=\"outline\"\n              bgColor={darkMode ? \"#3c3c3c\" : \"white\"}\n              borderColor={darkMode ? \"#3c3c3c\" : \"white\"}\n              value={`${window.location.origin}/${id}`}\n            />\n            <InputRightElement width=\"3.5rem\">\n              <Button\n                h=\"1.4rem\"\n                size=\"xs\"\n                onClick={handleCopy}\n                _hover={{ bg: darkMode ? \"#575759\" : \"gray.200\" }}\n                bgColor={darkMode ? \"#575759\" : \"gray.200\"}\n              >\n                Copy\n              </Button>\n            </InputRightElement>\n          </InputGroup>\n\n          <Heading mt={4} mb={1.5} size=\"sm\">\n            Active Users\n          </Heading>\n          <Stack spacing={0} mb={1.5} fontSize=\"sm\">\n            <User\n              info={{ name, hue }}\n              isMe\n              onChangeName={(name) => name.length > 0 && setName(name)}\n              onChangeColor={() => setHue(generateHue())}\n              darkMode={darkMode}\n            />\n            {Object.entries(users).map(([id, info]) => (\n              <User key={id} info={info} darkMode={darkMode} />\n            ))}\n          </Stack>\n\n          <Heading mt={4} mb={1.5} size=\"sm\">\n            About\n          </Heading>\n          <Text fontSize=\"sm\" mb={1.5}>\n            <strong>Composing Studio</strong> is an open-source collaborative\n            web application that lets people write and engrave music together\n            using{\" \"}\n            <Link\n              color=\"blue.600\"\n              fontWeight=\"semibold\"\n              href=\"https://abcnotation.com/\"\n              isExternal\n            >\n              ABC notation\n            </Link>\n            .\n          </Text>\n          <Text fontSize=\"sm\" mb={1.5}>\n            Share a link to this studio with others, and they'll be able to edit\n            from their browser while seeing your changes in real time.\n          </Text>\n          <Text fontSize=\"sm\" mb={1.5}>\n            Built using Rust and TypeScript. See the{\" \"}\n            <Link\n              color=\"blue.600\"\n              fontWeight=\"semibold\"\n              href=\"https://github.com/ekzhang/composing.studio\"\n              isExternal\n            >\n              GitHub repository\n            </Link>{\" \"}\n            for details.\n          </Text>\n\n          <Button\n            size=\"sm\"\n            colorScheme={darkMode ? \"whiteAlpha\" : \"blackAlpha\"}\n            borderColor={darkMode ? \"purple.400\" : \"purple.600\"}\n            color={darkMode ? \"purple.400\" : \"purple.600\"}\n            variant=\"outline\"\n            leftIcon={<VscRepoPull />}\n            mt={1}\n            onClick={handleLoadSample}\n          >\n            Load an example\n          </Button>\n        </Container>\n        <Flex flex={1} minW={0} h=\"100%\" direction=\"column\" overflow=\"hidden\">\n          <HStack\n            h={6}\n            spacing={1}\n            color=\"#888888\"\n            fontWeight=\"medium\"\n            fontSize=\"13px\"\n            px={3.5}\n            flexShrink={0}\n          >\n            <Icon as={VscFolderOpened} fontSize=\"md\" color=\"blue.500\" />\n            <Text>documents</Text>\n            <Icon as={VscChevronRight} fontSize=\"md\" />\n            <Icon as={VscGist} fontSize=\"md\" color=\"purple.500\" />\n            <Text>{id}</Text>\n          </HStack>\n          <Box flex={1} minH={0} h=\"100%\" overflow=\"hidden\">\n            <Split className=\"split\" minSize={50}>\n              <Box>\n                <Editor\n                  theme={darkMode ? \"vs-dark\" : \"vs\"}\n                  language=\"abc\"\n                  options={{\n                    automaticLayout: true,\n                    fontSize: 13,\n                    wordWrap: \"on\",\n                  }}\n                  onMount={(editor) => setEditor(editor)}\n                  onChange={(text) => {\n                    if (text !== undefined) {\n                      setText(text);\n                    }\n                  }}\n                />\n              </Box>\n\n              <Box overflowX=\"auto\">\n                <Score notes={abcString} darkMode={darkMode} />\n              </Box>\n            </Split>\n          </Box>\n        </Flex>\n      </Flex>\n      <Footer />\n    </Flex>\n  );\n}\n\nexport default EditorPage;\n"
  },
  {
    "path": "src/pages/LandingPage.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport {\n  Box,\n  Button,\n  Flex,\n  Icon,\n  Image,\n  SimpleGrid,\n  Stack,\n  Text,\n} from \"@chakra-ui/react\";\nimport { HiChevronDoubleDown } from \"react-icons/hi\";\nimport { VscArrowRight } from \"react-icons/vsc\";\nimport { useNavigate } from \"react-router-dom\";\nimport generate from \"project-name-generator\";\nimport { FullPage, Slide } from \"react-full-page\";\nimport { Link } from \"react-scroll\";\nimport LandingFeature from \"../components/LandingFeature\";\n\nfunction getRandomId() {\n  return generate({ number: true }).dashed;\n}\n\nfunction LandingPage() {\n  const navigate = useNavigate();\n  const [loading, setLoading] = useState(false);\n\n  const [showLogo, setShowLogo] = useState(false);\n  const [showButton, setShowButton] = useState(false);\n  const [showInfo, setShowInfo] = useState(false);\n\n  useEffect(() => {\n    // Animated landing page entrance sequence.\n    setTimeout(() => setShowLogo(true), 500);\n    setTimeout(() => setShowButton(true), 1500);\n    setTimeout(() => setShowInfo(true), 2500);\n  }, []);\n\n  function handleClick() {\n    setLoading(true);\n    // Arbitrary delay for suspense reasons.\n    setTimeout(() => {\n      const id = getRandomId();\n      navigate(`/${id}`);\n    }, 500);\n  }\n\n  return (\n    <FullPage>\n      <Slide>\n        <Flex w=\"100%\" h=\"100vh\" align=\"center\" justify=\"center\">\n          <Stack>\n            <Image\n              src=\"/static/logo.png\"\n              w=\"md\"\n              opacity={showLogo ? 1 : 0}\n              transition=\"opacity 0.5s\"\n            />\n            <Button\n              size=\"lg\"\n              variant=\"ghost\"\n              textTransform=\"uppercase\"\n              fontSize=\"2xl\"\n              h={12}\n              mb={6}\n              rightIcon={<VscArrowRight />}\n              _hover={{ transform: \"scale(1.1)\", bgColor: \"gray.50\" }}\n              onClick={handleClick}\n              isLoading={loading}\n              opacity={showButton ? 1 : 0}\n            >\n              Enter\n            </Button>\n            <Box h={12} />\n            <Link to=\"info\" smooth={true}>\n              <Stack\n                position=\"absolute\"\n                w=\"md\"\n                spacing={1}\n                p={3}\n                color=\"gray.600\"\n                opacity={showInfo ? 1 : 0}\n                transition=\"opacity 0.5s\"\n                bottom={3}\n                align=\"center\"\n                fontWeight=\"semibold\"\n                _hover={{ bgColor: \"gray.50\", cursor: \"pointer\" }}\n              >\n                <Box fontSize=\"1em\" textTransform=\"uppercase\">\n                  Scroll to learn more\n                </Box>\n                <Icon as={HiChevronDoubleDown} fontSize=\"lg\" />\n              </Stack>\n            </Link>\n          </Stack>\n        </Flex>\n      </Slide>\n      <Slide id=\"info\">\n        <Flex\n          h=\"100vh\"\n          direction=\"column\"\n          align=\"center\"\n          justify=\"center\"\n          bgColor=\"gray.50\"\n        >\n          <SimpleGrid columns={3} spacing={8} my={6}>\n            <LandingFeature title=\"Compose\" image=\"/static/left.png\" />\n            <LandingFeature title=\"Preview\" image=\"/static/center.png\" />\n            <LandingFeature title=\"Collaborate\" image=\"/static/right.png\" />\n          </SimpleGrid>\n          <Stack w=\"md\">\n            <Text\n              align=\"center\"\n              fontSize=\"lg\"\n              mt={8}\n              mb={3}\n              fontWeight=\"semibold\"\n              textTransform=\"uppercase\"\n            >\n              Make music together.\n            </Text>\n            <Button\n              size=\"lg\"\n              colorScheme=\"blue\"\n              fontSize=\"2xl\"\n              textTransform=\"uppercase\"\n              h={14}\n              rightIcon={<VscArrowRight />}\n              onClick={handleClick}\n              isLoading={loading}\n              opacity={showButton ? 1 : 0}\n            >\n              Start creating\n            </Button>\n          </Stack>\n        </Flex>\n      </Slide>\n    </FullPage>\n  );\n}\n\nexport default LandingPage;\n"
  },
  {
    "path": "src/pages/Split.css",
    "content": ".split {\n  display: flex;\n  flex-direction: row;\n  height: 100%;\n}\n\n.gutter {\n  background-color: #eee;\n  background-repeat: no-repeat;\n  background-position: 50%;\n}\n\n.gutter.gutter-horizontal {\n  background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==\");\n  cursor: col-resize;\n}\n\n.dark-mode .gutter {\n  filter: invert(90%);\n}\n"
  },
  {
    "path": "src/react-full-page.d.ts",
    "content": "declare module \"react-full-page\";\n"
  },
  {
    "path": "src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"es5\",\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"esModuleInterop\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"strict\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"node\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\"\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "vite.config.js",
    "content": "import { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\n\nexport default defineConfig({\n  build: {\n    chunkSizeWarningLimit: 1000,\n  },\n  plugins: [react()],\n  server: {\n    proxy: {\n      \"/api\": {\n        target: \"http://localhost:3030\",\n        changeOrigin: true,\n        secure: false,\n        ws: true,\n      },\n    },\n  },\n});\n"
  }
]