char-slop/chordpad.vibed.software

git clone https://git.t4t.associates/char-slop/chordpad.vibed.software

Charlotte Somcbor project file formata5e999d

main
4.9 KiB168 linesraw
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 {
14  try {
15    const json = localStorage.getItem(key);
16    return json ? (JSON.parse(json) as T) : undefined;
17  } catch {
18    return 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) {
26  localStorage.removeItem(storageKey(id));
27  if (localStorage.getItem(CURRENT) === id) localStorage.removeItem(CURRENT);
28  const index = projectIndex.get().filter(e => e.id !== id);
29  if (index.length === projectIndex.get().length) return;
30  localStorage.setItem(INDEX, JSON.stringify(index));
31  projectIndex.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 {
37  const stored = read<SavedProject>(storageKey(id));
38  return stored && fromStored(stored);
39}
40
41function persist(p: Project) {
42  if (isBlank(p)) return forget(p.id);
43  localStorage.setItem(storageKey(p.id), JSON.stringify(toStored(p)));
44  localStorage.setItem(CURRENT, p.id);
45  const 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);
49  localStorage.setItem(INDEX, JSON.stringify(index));
50  projectIndex.set(index);
51}
52
53function initial(): Project {
54  // earlier versions saved every blank project
55  for (const { id } of projectIndex.get()) {
56    const p = load(id);
57    if (!p || isBlank(p)) forget(id);
58  }
59  // the one open last time, or else the latest saved one
60  const id = localStorage.getItem(CURRENT) ?? projectIndex.get()[0]?.id;
61  return (id && load(id)) || newProject();
62}
63
64export const project = new Signal<Project>(initial());
65
66let saveTimer: ReturnType<typeof setTimeout> | undefined;
67export function flushSave(): void {
68  if (saveTimer === undefined) return;
69  clearTimeout(saveTimer);
70  saveTimer = undefined;
71  persist(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) {
77  const next = unpinRedundant(edited);
78  project.set(next);
79  clearTimeout(saveTimer);
80  saveTimer = 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) {
89  undoStack.push(prev);
90  if (undoStack.length > 500) undoStack.shift();
91  redoStack.length = 0;
92  syncHistory();
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 {
97  const prev = project.get();
98  const next = structuredClone(prev);
99  edit(next);
100  next.modified = Date.now();
101  if (history) record(prev);
102  replace(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 } {
110  const base = project.get();
111  let recorded = false;
112  return {
113    update(edit) {
114      const next = structuredClone(base);
115      edit(next);
116      next.modified = Date.now();
117      if (!recorded) record(base);
118      recorded = true;
119      replace(next);
120    },
121  };
122}
123
124export function undo(): void {
125  const prev = undoStack.pop();
126  if (!prev) return;
127  redoStack.push(project.get());
128  replace(prev);
129  syncHistory();
130}
131
132export function redo(): void {
133  const next = redoStack.pop();
134  if (!next) return;
135  undoStack.push(project.get());
136  replace(next);
137  syncHistory();
138}
139
140function open(p: Project) {
141  flushSave();
142  undoStack.length = redoStack.length = 0;
143  syncHistory();
144  project.set(p);
145  persist(p);
146}
147
148export function openProject(id: string): void {
149  const p = load(id);
150  if (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 {
159  if (id === project.get().id) {
160    clearTimeout(saveTimer);
161    saveTimer = undefined;
162  }
163  forget(id);
164  if (id !== project.get().id) return;
165  const [first] = projectIndex.get();
166  const next = first && load(first.id);
167  open(next ?? newProject());
168}