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