char-slop/chordpad.vibed.software
git clone https://git.t4t.associates/char-slop/chordpad.vibed.software
a5e999d
main
1import { Signal } from "@char/aftercare" ; 2import { isBlank , newProject , type Project } from "./model.ts" ; 3import { unpinRedundant } from "./harmony.ts" ; 4import { fromStored , type SavedProject , toStored } from "./persist.ts" ; 5 6const PREFIX = "chord-pad/" ; 7const INDEX = PREFIX + "index" ; 8const CURRENT = PREFIX + "current" ; 9const storageKey = ( id :string ) => ` ${ PREFIX } project/ ${ id } ` ; 10 11export type IndexEntry = { id :string ; name :string ; modified :number }; 12 13function read < T > ( key :string ) :T | undefined { 14try { 15const json = localStorage . getItem ( key ); 16return json ?( JSON . parse ( json ) as T ) :undefined ; 17} catch { 18return undefined ; 19} 20} 21 22export const projectIndex = new Signal < IndexEntry []>( read < IndexEntry []>( INDEX ) ?? []); 23 24/** drop a project from storage and the list */ 25function forget ( id :string ) { 26localStorage . removeItem ( storageKey ( id )); 27if ( localStorage . getItem ( CURRENT ) === id ) localStorage . removeItem ( CURRENT ); 28const index = projectIndex . get (). filter ( e => e . id !== id ); 29if ( index . length === projectIndex . get (). length ) return ; 30localStorage . setItem ( INDEX , JSON . stringify ( index )); 31projectIndex . set ( index ); 32} 33 34/** save a project, unless it's blank: those aren't worth a place in the list */ 35/** a saved project, as it is in memory */ 36function load ( id :string ) :Project | undefined { 37const stored = read < SavedProject >( storageKey ( id )); 38return stored && fromStored ( stored ); 39} 40 41function persist ( p :Project ) { 42if ( isBlank ( p )) return forget ( p . id ); 43localStorage . setItem ( storageKey ( p . id ), JSON . stringify ( toStored ( p ))); 44localStorage . setItem ( CURRENT , p . id ); 45const index = [ 46{ id :p . id , name :p . name , modified :p . modified }, 47 ...projectIndex . get (). filter ( e => e . id !== p . id ), 48]. sort (( a , b ) => b . modified - a . modified ); 49localStorage . setItem ( INDEX , JSON . stringify ( index )); 50projectIndex . set ( index ); 51} 52 53function initial () :Project { 54// earlier versions saved every blank project 55for ( const { id} of projectIndex . get ()) { 56const p = load ( id ); 57if ( ! p || isBlank ( p )) forget ( id ); 58} 59// the one open last time, or else the latest saved one 60const id = localStorage . getItem ( CURRENT ) ?? projectIndex . get ()[ 0 ]?. id ; 61return ( id && load ( id )) || newProject (); 62} 63 64export const project = new Signal < Project >( initial ()); 65 66let saveTimer :ReturnType < typeof setTimeout > | undefined ; 67export function flushSave () :void { 68if ( saveTimer === undefined ) return ; 69clearTimeout ( saveTimer ); 70saveTimer = undefined ; 71persist ( project . get ()); 72} 73addEventListener ( "pagehide" , flushSave ); 74 75/** every edit leaves no chord pinned to what it'd be voiced as anyway */ 76function replace ( edited :Project ) { 77const next = unpinRedundant ( edited ); 78project . set ( next ); 79clearTimeout ( saveTimer ); 80saveTimer = setTimeout ( flushSave , 300 ); 81} 82 83const undoStack :Project [] = []; 84const redoStack :Project [] = []; 85export const history = new Signal ({ undo :0 , redo :0 }); 86const syncHistory = () => history . set ({ undo :undoStack . length , redo :redoStack . length }); 87 88function record ( prev :Project ) { 89undoStack . push ( prev ); 90if ( undoStack . length > 500 ) undoStack . shift (); 91redoStack . length = 0 ; 92syncHistory (); 93} 94 95/** apply an edit to a copy of the project. `history: false` is for settings not worth undoing */ 96export function commit ( edit :( draft :Project ) => void , { history= true } = {}) :void { 97const prev = project . get (); 98const next = structuredClone ( prev ); 99edit ( next ); 100next . modified = Date . now (); 101if ( history ) record ( prev ); 102replace ( next ); 103} 104 105/** 106* for drags: each update re-applies an edit to the project as it was when the gesture began, 107* and the whole gesture is one undo step 108*/ 109export function beginGesture () :{ update ( edit :( draft :Project ) => void ) :void } { 110const base = project . get (); 111let recorded = false ; 112return { 113update ( edit ) { 114const next = structuredClone ( base ); 115edit ( next ); 116next . modified = Date . now (); 117if ( ! recorded ) record ( base ); 118recorded = true ; 119replace ( next ); 120}, 121}; 122} 123 124export function undo () :void { 125const prev = undoStack . pop (); 126if ( ! prev ) return ; 127redoStack . push ( project . get ()); 128replace ( prev ); 129syncHistory (); 130} 131 132export function redo () :void { 133const next = redoStack . pop (); 134if ( ! next ) return ; 135undoStack . push ( project . get ()); 136replace ( next ); 137syncHistory (); 138} 139 140function open ( p :Project ) { 141flushSave (); 142undoStack . length = redoStack . length = 0 ; 143syncHistory (); 144project . set ( p ); 145persist ( p ); 146} 147 148export function openProject ( id :string ) :void { 149const p = load ( id ); 150if ( p ) open ( p ); 151} 152 153export const createProject = () :void => open ( newProject ()); 154 155/** switch to a project made elsewhere, like an import */ 156export const openNewProject = ( p :Project ) :void => open ( p ); 157 158export function deleteProject ( id :string ) :void { 159if ( id === project . get (). id ) { 160clearTimeout ( saveTimer ); 161saveTimer = undefined ; 162} 163forget ( id ); 164if ( id !== project . get (). id ) return ; 165const [ first ] = projectIndex . get (); 166const next = first && load ( first . id ); 167open ( next ?? newProject ()); 168}