import { Signal } from "@char/aftercare"; import { isBlank, newProject, type Project } from "./model.ts"; import { unpinRedundant } from "./harmony.ts"; import { fromStored, type SavedProject, toStored } from "./persist.ts"; const PREFIX = "chord-pad/"; const INDEX = PREFIX + "index"; const CURRENT = PREFIX + "current"; const storageKey = (id: string) => `${PREFIX}project/${id}`; export type IndexEntry = { id: string; name: string; modified: number }; function read(key: string): T | undefined { try { const json = localStorage.getItem(key); return json ? (JSON.parse(json) as T) : undefined; } catch { return undefined; } } export const projectIndex = new Signal(read(INDEX) ?? []); /** drop a project from storage and the list */ function forget(id: string) { localStorage.removeItem(storageKey(id)); if (localStorage.getItem(CURRENT) === id) localStorage.removeItem(CURRENT); const index = projectIndex.get().filter(e => e.id !== id); if (index.length === projectIndex.get().length) return; localStorage.setItem(INDEX, JSON.stringify(index)); projectIndex.set(index); } /** save a project, unless it's blank: those aren't worth a place in the list */ /** a saved project, as it is in memory */ function load(id: string): Project | undefined { const stored = read(storageKey(id)); return stored && fromStored(stored); } function persist(p: Project) { if (isBlank(p)) return forget(p.id); localStorage.setItem(storageKey(p.id), JSON.stringify(toStored(p))); localStorage.setItem(CURRENT, p.id); const index = [ { id: p.id, name: p.name, modified: p.modified }, ...projectIndex.get().filter(e => e.id !== p.id), ].sort((a, b) => b.modified - a.modified); localStorage.setItem(INDEX, JSON.stringify(index)); projectIndex.set(index); } function initial(): Project { // earlier versions saved every blank project for (const { id } of projectIndex.get()) { const p = load(id); if (!p || isBlank(p)) forget(id); } // the one open last time, or else the latest saved one const id = localStorage.getItem(CURRENT) ?? projectIndex.get()[0]?.id; return (id && load(id)) || newProject(); } export const project = new Signal(initial()); let saveTimer: ReturnType | undefined; export function flushSave(): void { if (saveTimer === undefined) return; clearTimeout(saveTimer); saveTimer = undefined; persist(project.get()); } addEventListener("pagehide", flushSave); /** every edit leaves no chord pinned to what it'd be voiced as anyway */ function replace(edited: Project) { const next = unpinRedundant(edited); project.set(next); clearTimeout(saveTimer); saveTimer = setTimeout(flushSave, 300); } const undoStack: Project[] = []; const redoStack: Project[] = []; export const history = new Signal({ undo: 0, redo: 0 }); const syncHistory = () => history.set({ undo: undoStack.length, redo: redoStack.length }); function record(prev: Project) { undoStack.push(prev); if (undoStack.length > 500) undoStack.shift(); redoStack.length = 0; syncHistory(); } /** apply an edit to a copy of the project. `history: false` is for settings not worth undoing */ export function commit(edit: (draft: Project) => void, { history = true } = {}): void { const prev = project.get(); const next = structuredClone(prev); edit(next); next.modified = Date.now(); if (history) record(prev); replace(next); } /** * for drags: each update re-applies an edit to the project as it was when the gesture began, * and the whole gesture is one undo step */ export function beginGesture(): { update(edit: (draft: Project) => void): void } { const base = project.get(); let recorded = false; return { update(edit) { const next = structuredClone(base); edit(next); next.modified = Date.now(); if (!recorded) record(base); recorded = true; replace(next); }, }; } export function undo(): void { const prev = undoStack.pop(); if (!prev) return; redoStack.push(project.get()); replace(prev); syncHistory(); } export function redo(): void { const next = redoStack.pop(); if (!next) return; undoStack.push(project.get()); replace(next); syncHistory(); } function open(p: Project) { flushSave(); undoStack.length = redoStack.length = 0; syncHistory(); project.set(p); persist(p); } export function openProject(id: string): void { const p = load(id); if (p) open(p); } export const createProject = (): void => open(newProject()); /** switch to a project made elsewhere, like an import */ export const openNewProject = (p: Project): void => open(p); export function deleteProject(id: string): void { if (id === project.get().id) { clearTimeout(saveTimer); saveTimer = undefined; } forget(id); if (id !== project.get().id) return; const [first] = projectIndex.get(); const next = first && load(first.id); open(next ?? newProject()); }