Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions mamar-wasm-bridge/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "mamar-wasm-bridge",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "pkg/mamar_wasm_bridge.js",
"types": "pkg/mamar_wasm_bridge.d.ts",
"sideEffects": [
"./pkg/snippets/*"
]
}
18 changes: 0 additions & 18 deletions mamar-wasm-bridge/pkg/package.json

This file was deleted.

64 changes: 64 additions & 0 deletions mamar-wasm-bridge/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,67 @@ pub fn bgm_split_variation_at(bgm: &JsValue, variation: usize, time: usize) -> J
bgm.split_variation_at(variation, time);
to_js(&bgm)
}

/// An object with the given properties.
fn object(properties: &[(&str, JsValue)]) -> JsValue {
let object = js_sys::Object::new();
for (key, value) in properties {
js_sys::Reflect::set(&object, &JsValue::from_str(key), value).unwrap();
}
object.into()
}

fn bytes(data: &[u8]) -> JsValue {
js_sys::Uint8Array::from(data).into()
}

/// Makes a song from the MIDI file `data`, named `name`, linked to it so it can be reimported. Returns `{ bgm, base,
/// warnings }`, where `base` is the import to keep while the song is open, or an error message.
#[wasm_bindgen]
pub fn midi_import(data: &[u8], name: &str) -> JsValue {
match reimport::import(data, name) {
Ok(imported) => object(&[
("bgm", to_js(&imported.bgm)),
("base", bytes(&reimport::encode_timeline(&imported.base))),
("warnings", to_js(&imported.warnings)),
]),
Err(e) => e.to_string().into(),
}
}

/// Returns `bgm` with its import link's patch set to what was changed since the last import, `base`. Call it before
/// saving. Returns `bgm` unchanged if it isn't linked or `base` can't be read.
#[wasm_bindgen]
pub fn import_patch(bgm: &JsValue, base: &[u8]) -> JsValue {
let mut bgm: Bgm = from_js(bgm);
if let Some(link) = reimport::decode_timeline(base).and_then(|base| reimport::with_patch(&bgm, &base)) {
bgm.import = Some(link);
}
to_js(&bgm)
}

/// Rebuilds the last import of an opened song from its patch, or returns undefined if it can't be.
#[wasm_bindgen]
pub fn import_base_rebuild(bgm: &JsValue) -> JsValue {
let bgm: Bgm = from_js(bgm);
match reimport::rebuild_base(&bgm) {
Some(base) => bytes(&reimport::encode_timeline(&base)),
None => JsValue::UNDEFINED,
}
}

/// Reimports the MIDI file `data`, named `name`, into `bgm`, given its last import `base` if it has one. Returns
/// `{ bgm, base, report }`, or an error message.
#[wasm_bindgen]
pub fn bgm_reimport(bgm: &JsValue, base: Option<Vec<u8>>, data: &[u8], name: &str) -> JsValue {
let bgm: Bgm = from_js(bgm);
let base = base.as_deref().and_then(reimport::decode_timeline);
match reimport::reimport(&bgm, base.as_ref(), data, name) {
Ok((bgm, base, report)) => object(&[
("bgm", to_js(&bgm)),
("base", bytes(&reimport::encode_timeline(&base))),
("report", to_js(&report)),
]),
Err(e) => e.to_string().into(),
}
}
36 changes: 23 additions & 13 deletions mamar-web/src/app/header/BgmActionGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CSSProperties, useCallback, useEffect } from "react"

import ExportButton from "./ExportButton"
import OpenButton from "./OpenButton"
import ReimportButton from "./ReimportButton"

import Bridge from "../bridge"
import { useDoc, useRoot } from "../store"
Expand Down Expand Up @@ -36,8 +37,11 @@ export default function BgmActionGroup() {
return
}

// A song made from a MIDI file saves what was changed since it was imported, so a reimport can keep it
const bgm = doc.bgm.import && doc.importBase ? Bridge.import_patch(doc.bgm, doc.importBase) : doc.bgm

// TODO: surface an error encoding it in a dialog
const bgmBin = encodeForGame(doc.bgm, sbn) as Uint8Array<ArrayBuffer>
const bgmBin = encodeForGame(bgm, sbn) as Uint8Array<ArrayBuffer>

const fileHandle = await fileSave(new Blob([bgmBin]), {
fileName: createBgmFileName(doc.name),
Expand All @@ -48,7 +52,7 @@ export default function BgmActionGroup() {
// If it was saved as .ron, overwrite the file contents (currently BGM) with the RON
if (fileHandle?.name.endsWith(".ron")) {
const writable = await fileHandle.createWritable({ keepExistingData: false })
await writable.write(Bridge.ron_encode(doc.bgm))
await writable.write(Bridge.ron_encode(bgm))
await writable.close()
}

Expand All @@ -60,11 +64,26 @@ export default function BgmActionGroup() {
if (evt.ctrlKey && evt.key === "s") {
evt.preventDefault()
save(evt.shiftKey)
return
}

// Text fields keep their own undo
const target = evt.target as HTMLElement
if (!(evt.ctrlKey || evt.metaKey) || target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
return
}
const key = evt.key.toLowerCase()
if (key === "z" && !evt.shiftKey) {
evt.preventDefault()
dispatch.undo()
} else if ((key === "z" && evt.shiftKey) || key === "y") {
evt.preventDefault()
dispatch.redo()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [save])
}, [save, dispatch])

const props = {
isQuiet: true,
Expand All @@ -91,16 +110,7 @@ export default function BgmActionGroup() {
</ActionButton>
<Tooltip>Hold Shift to <i>Save As</i></Tooltip>
</TooltipTrigger>
<ReimportButton />
<ExportButton />
<ActionButton
onPress={() => dispatch.undo()}
isDisabled={!dispatch.canUndo}
{...props}
>Undo</ActionButton>
<ActionButton
onPress={() => dispatch.redo()}
isDisabled={!dispatch.canRedo}
{...props}
>Redo</ActionButton>
</View>
}
2 changes: 2 additions & 0 deletions mamar-web/src/app/header/OpenButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export default function OpenButton() {

for (const handle of launchParams.files) {
const file = await handle.getFile()
// Kept so a song made from a MIDI file can be reimported from it
file.handle = handle
const action = await openFile(file, sbn)
actions.push(action)
}
Expand Down
95 changes: 95 additions & 0 deletions mamar-web/src/app/header/ReimportButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { ActionButton, AlertDialog, Content, DialogContainer, Text, Tooltip, TooltipTrigger } from "@adobe/react-spectrum"
import { Report } from "pm64-typegen"
import { useEffect, useState } from "react"

import Bridge from "../bridge"
import { useDoc } from "../store"
import { pickMidi, readRememberedMidi, rememberMidi } from "../util/midiHandles"

/** What went wrong in a reimport, shown in a dialog. A reimport that went fine says so in the tooltip for a moment. */
interface Problems {
title: string
lines: string[]
}

/**
* Reimports the MIDI file the song was made from, keeping what was changed in Mamar. Only shown for songs made from a
* MIDI file. Reads the remembered file in one click; Shift-click, or a file that can't be read, picks one instead.
*/
export default function ReimportButton() {
const [doc, docDispatch] = useDoc()
const [done, setDone] = useState<"Reimported" | "No changes" | null>(null)
const [isHovered, setHovered] = useState(false)
const [problems, setProblems] = useState<Problems | null>(null)

useEffect(() => {
if (done) {
const timeout = setTimeout(() => setDone(null), 2000)
return () => clearTimeout(timeout)
}
}, [done])

const link = doc?.bgm.import
if (!doc || !link) {
return null
}

const reimport = async (pick: boolean) => {
let file: File | null = pick ? null : await readRememberedMidi(link.id)
let handle: FileSystemFileHandle | undefined
if (!file) {
try {
const picked = await pickMidi()
file = picked
handle = picked.handle
} catch {
// Cancelled
return
}
}

const data = new Uint8Array(await file.arrayBuffer())
const reimported: { bgm: typeof doc.bgm, base: Uint8Array, report: Report } | string =
Bridge.bgm_reimport(doc.bgm, doc.importBase, data, file.name)
if (typeof reimported === "string") {
setProblems({ title: "Couldn't reimport", lines: [reimported] })
return
}

if (handle) {
await rememberMidi(link.id, handle)
}
const { report } = reimported
if (report.unchanged) {
setDone("No changes")
return
}
docDispatch({ type: "reimport", bgm: reimported.bgm, importBase: reimported.base })
if (report.problems.length > 0) {
setProblems({ title: `Reimported ${file.name}`, lines: report.problems })
} else {
setDone("Reimported")
}
}

return <>
<TooltipTrigger isOpen={done !== null || isHovered} onOpenChange={setHovered}>
<ActionButton onPress={evt => reimport(evt.shiftKey)} isQuiet>
Reimport
</ActionButton>
{done
? <Tooltip variant={done === "Reimported" ? "positive" : "neutral"}>{done}</Tooltip>
: <Tooltip>Update from {link.source_name || "the MIDI file"}. Shift-click to choose another file.</Tooltip>}
</TooltipTrigger>

<DialogContainer onDismiss={() => setProblems(null)}>
{problems && <AlertDialog title={problems.title} variant="warning" primaryActionLabel="OK">
<Content>
{problems.lines.length === 1
? <Text>{problems.lines[0]}</Text>
: <ul>{problems.lines.map((line, i) => <li key={i}>{line}</li>)}</ul>}
</Content>
</AlertDialog>}
</DialogContainer>
</>
}
6 changes: 1 addition & 5 deletions mamar-web/src/app/store/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ interface Dispatch {
(...actions: RootAction[]): void
undo: () => void
redo: () => void
canUndo: boolean
canRedo: boolean
}

function shouldActionCommitToHistory(action: RootAction): boolean {
Expand Down Expand Up @@ -40,7 +38,7 @@ const {
Provider,
useTracked,
} = createContainer(() => {
const [state, setState, { undo, redo, canUndo, canRedo }] = useUndoable<Root>({
const [state, setState, { undo, redo }] = useUndoable<Root>({
docs: {},
}, {
behavior: "destroyFuture", // "mergePastReversed",
Expand Down Expand Up @@ -71,8 +69,6 @@ const {
}
dispatch.undo = undo
dispatch.redo = redo
dispatch.canUndo = canUndo
dispatch.canRedo = canRedo

return [state, dispatch]
})
Expand Down
18 changes: 18 additions & 0 deletions mamar-web/src/app/store/doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ export interface Doc {
zoom?: number
/** The part of the active variation that playback repeats, if one has been marked. */
cycle?: Cycle | null
/**
* The MIDI file the song was last imported from, as the WASM bridge encodes it, if the song came from one. A
* reimport merges it with the song and the new file, and saving stores what was changed since. See pm64's reimport.
*/
importBase?: Uint8Array
}

/** A part of the timeline that playback can repeat, as in a DAW. */
Expand Down Expand Up @@ -112,6 +117,10 @@ export type DocAction = {
} | {
type: "set_cycle"
cycle: Cycle | null
} | {
type: "reimport"
bgm: Bgm
importBase: Uint8Array
}

export function docReducer(state: Doc, action: DocAction): Doc {
Expand Down Expand Up @@ -177,6 +186,15 @@ export function docReducer(state: Doc, action: DocAction): Doc {
...state,
location: { ...(state.location ?? DEFAULT_LOCATION), ...action.location },
}
case "reimport":
return {
...state,
bgm: action.bgm,
importBase: action.importBase,
isSaved: false,
// Events may have moved or gone
selection: null,
}
}
}

Expand Down
Loading
Loading