Refactoring

This commit is contained in:
2025-10-15 01:41:52 +02:00
parent e492c03f15
commit 8e6b07797c
11 changed files with 180 additions and 173 deletions

View File

@ -13,7 +13,6 @@
import InputDialog from './lib/InputDialog.svelte';
import { createCsoundDerivedStores, type LogEntry } from './lib/csound';
import { type CsoundProject } from './lib/project-system';
import { uiState } from './lib/stores/uiState.svelte';
import { DEFAULT_CSOUND_TEMPLATE } from './lib/config/templates';
import { createAppContext, setAppContext } from './lib/contexts/app-context';
import {
@ -32,12 +31,11 @@
const appContext = createAppContext();
setAppContext(appContext);
const { csound, projectManager, editorSettings, projectEditor } = appContext;
const { csound, projectManager, editorSettings, projectEditor, uiState } = appContext;
const csoundDerived = createCsoundDerivedStores(csound);
let analyserNode = $state<AnalyserNode | null>(null);
let interpreterLogs = $state<LogEntry[]>([]);
let fileBrowserRef: FileBrowser;
onMount(async () => {
await projectManager.init();
@ -79,12 +77,11 @@
}
function handleNewFile() {
const needsConfirm = projectEditor.requestSwitch(
() => projectEditor.createNew(DEFAULT_CSOUND_TEMPLATE),
handleSwitchConfirm
const result = projectEditor.requestSwitch(
() => projectEditor.createNew(DEFAULT_CSOUND_TEMPLATE)
);
if (needsConfirm) {
if (result === 'confirm-unsaved') {
uiState.showUnsavedChangesDialog();
}
}
@ -92,12 +89,11 @@
function handleFileSelect(project: CsoundProject | null) {
if (!project) return;
const needsConfirm = projectEditor.requestSwitch(
() => projectEditor.loadProject(project),
handleSwitchConfirm
const result = projectEditor.requestSwitch(
() => projectEditor.loadProject(project)
);
if (needsConfirm) {
if (result === 'confirm-unsaved') {
uiState.showUnsavedChangesDialog();
}
}
@ -116,44 +112,36 @@
return;
}
const success = await projectEditor.save();
if (success) {
fileBrowserRef?.refresh();
}
await projectEditor.save();
}
async function handleSaveAs(title: string) {
const success = await projectEditor.saveAs(title);
if (success) {
fileBrowserRef?.refresh();
uiState.hideSaveAsDialog();
}
}
async function handleMetadataUpdate(projectId: string, updates: { title?: string; author?: string }) {
const success = await projectEditor.updateMetadata(updates);
if (success) {
fileBrowserRef?.refresh();
}
await projectEditor.updateMetadata(updates);
}
function handleSwitchConfirm(action: 'save' | 'discard') {
if (action === 'save') {
if (projectEditor.isNewUnsavedBuffer) {
async function handleSwitchSave() {
const result = await projectEditor.confirmSaveAndSwitch();
if (result === 'show-save-as') {
uiState.hideUnsavedChangesDialog();
uiState.showSaveAsDialog();
} else {
projectEditor.handleSaveAndSwitch().then(() => {
fileBrowserRef?.refresh();
uiState.hideUnsavedChangesDialog();
});
}
} else {
projectEditor.handleDiscardAndSwitch();
uiState.hideUnsavedChangesDialog();
}
}
function handleSwitchDiscard() {
projectEditor.confirmDiscardAndSwitch();
uiState.hideUnsavedChangesDialog();
}
async function handleShare() {
if (!projectEditor.currentProjectId) return;
@ -170,11 +158,15 @@
$effect(() => {
if (uiState.scopePopupVisible || uiState.spectrogramPopupVisible) {
const interval = setInterval(() => {
analyserNode = csound.getAnalyserNode();
}, 100);
return () => clearInterval(interval);
const unsubscribe = csound.onAnalyserNodeCreated((node) => {
analyserNode = node;
});
return unsubscribe;
} else {
analyserNode = null;
}
});
@ -198,7 +190,6 @@
{#snippet filesTabContent()}
<FileBrowser
bind:this={fileBrowserRef}
{projectManager}
onFileSelect={handleFileSelect}
onNewFile={handleNewFile}
@ -400,8 +391,8 @@
message="You have unsaved changes. What would you like to do?"
confirmLabel="Save"
cancelLabel="Discard"
onConfirm={() => handleSwitchConfirm('save')}
onCancel={() => handleSwitchConfirm('discard')}
onConfirm={handleSwitchSave}
onCancel={handleSwitchDiscard}
/>
<InputDialog

View File

@ -58,8 +58,6 @@
]);
onMount(() => {
const settings = $editorSettings;
const baseExtensions = [
highlightActiveLineGutter(),
highlightSpecialChars(),
@ -86,6 +84,8 @@
])
];
const initSettings = $editorSettings;
editorView = new EditorView({
doc: value,
extensions: [
@ -93,9 +93,9 @@
languageExtensions[language],
oneDark,
evaluateKeymap,
lineNumbersCompartment.of(settings.showLineNumbers ? lineNumbers() : []),
lineWrappingCompartment.of(settings.enableLineWrapping ? EditorView.lineWrapping : []),
vimCompartment.of(settings.vimMode ? vim() : []),
lineNumbersCompartment.of(initSettings.showLineNumbers ? lineNumbers() : []),
lineWrappingCompartment.of(initSettings.enableLineWrapping ? EditorView.lineWrapping : []),
vimCompartment.of(initSettings.vimMode ? vim() : []),
EditorView.updateListener.of((update) => {
if (update.docChanged && onChange) {
onChange(update.state.doc.toString());
@ -103,32 +103,29 @@
}),
EditorView.theme({
'&': {
fontSize: `${settings.fontSize}px`,
fontFamily: settings.fontFamily
fontSize: `${initSettings.fontSize}px`,
fontFamily: initSettings.fontFamily
}
})
],
parent: editorContainer
});
});
const unsubscribe = editorSettings.subscribe((newSettings) => {
$effect(() => {
const settings = $editorSettings;
if (!editorView) return;
editorView.dispatch({
effects: [
lineNumbersCompartment.reconfigure(newSettings.showLineNumbers ? lineNumbers() : []),
lineWrappingCompartment.reconfigure(newSettings.enableLineWrapping ? EditorView.lineWrapping : []),
vimCompartment.reconfigure(newSettings.vimMode ? vim() : [])
lineNumbersCompartment.reconfigure(settings.showLineNumbers ? lineNumbers() : []),
lineWrappingCompartment.reconfigure(settings.enableLineWrapping ? EditorView.lineWrapping : []),
vimCompartment.reconfigure(settings.vimMode ? vim() : [])
]
});
editorView.dom.style.fontSize = `${newSettings.fontSize}px`;
editorView.dom.style.fontFamily = newSettings.fontFamily;
});
return () => {
unsubscribe();
};
editorView.dom.style.fontSize = `${settings.fontSize}px`;
editorView.dom.style.fontFamily = settings.fontFamily;
});
onDestroy(() => {

View File

@ -40,12 +40,17 @@
onMount(async () => {
await loadProjects();
const unsubscribe = projectManager.onProjectsChanged(() => {
loadProjects();
});
return unsubscribe;
});
async function loadProjects() {
loading = true;
try {
await projectManager.init();
const result = await projectManager.getAllProjects();
if (result.success) {
projects = result.data.sort((a, b) =>

View File

@ -1,8 +1,10 @@
import { getContext, setContext } from 'svelte';
import { ProjectManager } from '../project-system/project-manager';
import { ProjectDatabase } from '../project-system/db';
import { createCsoundStore } from '../csound/store';
import { createEditorSettingsStore } from '../stores/editorSettings';
import { ProjectEditor } from '../stores/projectEditor.svelte';
import { UIState } from '../stores/uiState.svelte';
import type { CsoundStore } from '../csound/store';
import type { EditorSettingsStore } from '../stores/editorSettings';
@ -11,17 +13,20 @@ export interface AppContext {
csound: CsoundStore;
editorSettings: EditorSettingsStore;
projectEditor: ProjectEditor;
uiState: UIState;
}
const APP_CONTEXT_KEY = Symbol('app-context');
export function createAppContext(): AppContext {
const projectManager = new ProjectManager();
const db = new ProjectDatabase();
const projectManager = new ProjectManager(db);
return {
projectManager,
csound: createCsoundStore(),
editorSettings: createEditorSettingsStore(),
projectEditor: new ProjectEditor(projectManager)
projectEditor: new ProjectEditor(projectManager),
uiState: new UIState()
};
}

View File

@ -4,6 +4,7 @@ export interface CsoundEngineOptions {
onMessage?: (message: string) => void;
onError?: (error: string) => void;
onPerformanceEnd?: () => void;
onAnalyserNodeCreated?: (node: AnalyserNode) => void;
}
export class CsoundEngine {
@ -145,6 +146,7 @@ export class CsoundEngine {
this.scopeNode.connect(this.audioContext.destination);
this.log('Analyser node created and connected');
this.options.onAnalyserNodeCreated?.(this.scopeNode);
} catch (error) {
console.error('Failed to setup analyser:', error);
this.log('Error setting up analyser: ' + error);

View File

@ -21,6 +21,7 @@ export interface CsoundStore {
clearLogs: () => void;
getAudioContext: () => AudioContext | null;
getAnalyserNode: () => AnalyserNode | null;
onAnalyserNodeCreated: (callback: (node: AnalyserNode) => void) => () => void;
destroy: () => Promise<void>;
}
@ -34,6 +35,7 @@ export function createCsoundStore(): CsoundStore {
const { subscribe, set, update } = writable<CsoundState>(initialState);
let engine: CsoundEngine | null = null;
const analyserNodeListeners: Set<(node: AnalyserNode) => void> = new Set();
function addLog(message: string, type: 'info' | 'error' = 'info') {
update(state => ({
@ -51,7 +53,10 @@ export function createCsoundStore(): CsoundStore {
try {
engine = new CsoundEngine({
onMessage: (msg) => addLog(msg, 'info'),
onError: (err) => addLog(err, 'error')
onError: (err) => addLog(err, 'error'),
onAnalyserNodeCreated: (node) => {
analyserNodeListeners.forEach(listener => listener(node));
}
});
await engine.init();
@ -117,11 +122,17 @@ export function createCsoundStore(): CsoundStore {
return null;
},
onAnalyserNodeCreated(callback: (node: AnalyserNode) => void): () => void {
analyserNodeListeners.add(callback);
return () => analyserNodeListeners.delete(callback);
},
async destroy() {
if (engine) {
await engine.destroy();
engine = null;
}
analyserNodeListeners.clear();
set(initialState);
}
};

View File

@ -229,5 +229,4 @@ class ProjectDatabase {
}
}
// Export singleton instance
export const projectDb = new ProjectDatabase();
export { ProjectDatabase };

View File

@ -1,8 +1,20 @@
import type { CsoundProject, CreateProjectData, UpdateProjectData, Result } from './types';
import { projectDb } from './db';
import type { ProjectDatabase } from './db';
import { compressProject, decompressProject, projectToShareUrl, projectFromShareUrl } from './compression';
const CSOUND_VERSION = '7.0.0'; // This should be detected from @csound/browser
const CSOUND_VERSION = '7.0.0';
async function wrapResult<T>(fn: () => Promise<T>, errorMsg: string): Promise<Result<T>> {
try {
const data = await fn();
return { success: true, data };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error(errorMsg),
};
}
}
/**
* Generate a unique ID for a project
@ -18,15 +30,54 @@ function getCurrentTimestamp(): string {
return new Date().toISOString();
}
/**
* Create an imported/duplicated project with new ID and timestamps
*/
function createDerivedProject(baseProject: CsoundProject, titleSuffix: string): CsoundProject {
const now = getCurrentTimestamp();
return {
...baseProject,
id: generateId(),
title: `${baseProject.title} ${titleSuffix}`,
dateCreated: now,
dateModified: now,
saveCount: 0,
};
}
type ProjectChangeListener = () => void;
/**
* Project Manager - Main API for managing Csound projects
*/
export class ProjectManager {
private db: ProjectDatabase;
private changeListeners: Set<ProjectChangeListener> = new Set();
constructor(db: ProjectDatabase) {
this.db = db;
}
/**
* Subscribe to project changes
*/
onProjectsChanged(listener: ProjectChangeListener): () => void {
this.changeListeners.add(listener);
return () => this.changeListeners.delete(listener);
}
/**
* Notify all listeners that projects have changed
*/
private notifyChange(): void {
this.changeListeners.forEach(listener => listener());
}
/**
* Initialize the project manager (initializes database)
*/
async init(): Promise<void> {
await projectDb.init();
await this.db.init();
}
/**
@ -48,7 +99,8 @@ export class ProjectManager {
csoundVersion: CSOUND_VERSION,
};
await projectDb.put(project);
await this.db.put(project);
this.notifyChange();
return { success: true, data: project };
} catch (error) {
@ -64,7 +116,7 @@ export class ProjectManager {
*/
async getProject(id: string): Promise<Result<CsoundProject>> {
try {
const project = await projectDb.get(id);
const project = await this.db.get(id);
if (!project) {
return {
@ -86,15 +138,7 @@ export class ProjectManager {
* Get all projects
*/
async getAllProjects(): Promise<Result<CsoundProject[]>> {
try {
const projects = await projectDb.getAll();
return { success: true, data: projects };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error('Failed to get projects'),
};
}
return wrapResult(() => this.db.getAll(), 'Failed to get projects');
}
/**
@ -102,7 +146,7 @@ export class ProjectManager {
*/
async updateProject(data: UpdateProjectData): Promise<Result<CsoundProject>> {
try {
const existingProject = await projectDb.get(data.id);
const existingProject = await this.db.get(data.id);
if (!existingProject) {
return {
@ -121,7 +165,8 @@ export class ProjectManager {
saveCount: existingProject.saveCount + 1,
};
await projectDb.put(updatedProject);
await this.db.put(updatedProject);
this.notifyChange();
return { success: true, data: updatedProject };
} catch (error) {
@ -136,45 +181,25 @@ export class ProjectManager {
* Delete a project
*/
async deleteProject(id: string): Promise<Result<void>> {
try {
await projectDb.delete(id);
return { success: true, data: undefined };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error('Failed to delete project'),
};
const result = await wrapResult(() => this.db.delete(id), 'Failed to delete project');
if (result.success) {
this.notifyChange();
}
return result;
}
/**
* Search projects by tag
*/
async getProjectsByTag(tag: string): Promise<Result<CsoundProject[]>> {
try {
const projects = await projectDb.getByTag(tag);
return { success: true, data: projects };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error('Failed to search projects by tag'),
};
}
return wrapResult(() => this.db.getByTag(tag), 'Failed to search projects by tag');
}
/**
* Search projects by author
*/
async getProjectsByAuthor(author: string): Promise<Result<CsoundProject[]>> {
try {
const projects = await projectDb.getByAuthor(author);
return { success: true, data: projects };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error('Failed to search projects by author'),
};
}
return wrapResult(() => this.db.getByAuthor(author), 'Failed to search projects by author');
}
/**
@ -182,7 +207,7 @@ export class ProjectManager {
*/
async exportProjectToUrl(id: string, baseUrl?: string): Promise<Result<string>> {
try {
const project = await projectDb.get(id);
const project = await this.db.get(id);
if (!project) {
return {
@ -207,7 +232,7 @@ export class ProjectManager {
*/
async exportProjectToCompressed(id: string): Promise<Result<string>> {
try {
const project = await projectDb.get(id);
const project = await this.db.get(id);
if (!project) {
return {
@ -233,19 +258,10 @@ export class ProjectManager {
async importProjectFromUrl(url: string): Promise<Result<CsoundProject>> {
try {
const project = projectFromShareUrl(url);
const importedProject = createDerivedProject(project, '(imported)');
// Generate a new ID and reset timestamps
const now = getCurrentTimestamp();
const importedProject: CsoundProject = {
...project,
id: generateId(),
dateCreated: now,
dateModified: now,
saveCount: 0,
title: `${project.title} (imported)`,
};
await projectDb.put(importedProject);
await this.db.put(importedProject);
this.notifyChange();
return { success: true, data: importedProject };
} catch (error) {
@ -265,19 +281,10 @@ export class ProjectManager {
data: compressedData,
version: 1,
});
const importedProject = createDerivedProject(project, '(imported)');
// Generate a new ID and reset timestamps
const now = getCurrentTimestamp();
const importedProject: CsoundProject = {
...project,
id: generateId(),
dateCreated: now,
dateModified: now,
saveCount: 0,
title: `${project.title} (imported)`,
};
await projectDb.put(importedProject);
await this.db.put(importedProject);
this.notifyChange();
return { success: true, data: importedProject };
} catch (error) {
@ -293,7 +300,7 @@ export class ProjectManager {
*/
async duplicateProject(id: string): Promise<Result<CsoundProject>> {
try {
const originalProject = await projectDb.get(id);
const originalProject = await this.db.get(id);
if (!originalProject) {
return {
@ -302,17 +309,10 @@ export class ProjectManager {
};
}
const now = getCurrentTimestamp();
const duplicatedProject: CsoundProject = {
...originalProject,
id: generateId(),
title: `${originalProject.title} (copy)`,
dateCreated: now,
dateModified: now,
saveCount: 0,
};
const duplicatedProject = createDerivedProject(originalProject, '(copy)');
await projectDb.put(duplicatedProject);
await this.db.put(duplicatedProject);
this.notifyChange();
return { success: true, data: duplicatedProject };
} catch (error) {
@ -327,14 +327,10 @@ export class ProjectManager {
* Clear all projects (use with caution!)
*/
async clearAllProjects(): Promise<Result<void>> {
try {
await projectDb.clear();
return { success: true, data: undefined };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error('Failed to clear projects'),
};
}
const result = await wrapResult(() => this.db.clear(), 'Failed to clear projects');
if (result.success) {
this.notifyChange();
}
return result;
}
}

View File

@ -1,5 +1,7 @@
import { writable } from 'svelte/store';
const STORAGE_KEY = 'editorSettings';
export interface EditorSettings {
fontSize: number;
fontFamily: string;
@ -26,7 +28,7 @@ export interface EditorSettingsStore {
}
export function createEditorSettingsStore(): EditorSettingsStore {
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('editorSettings') : null;
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null;
const initial = stored ? { ...defaultSettings, ...JSON.parse(stored) } : defaultSettings;
const { subscribe, set, update } = writable<EditorSettings>(initial);
@ -35,7 +37,7 @@ export function createEditorSettingsStore(): EditorSettingsStore {
subscribe,
set: (value: EditorSettings) => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('editorSettings', JSON.stringify(value));
localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
}
set(value);
},
@ -43,7 +45,7 @@ export function createEditorSettingsStore(): EditorSettingsStore {
update((current) => {
const newValue = updater(current);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('editorSettings', JSON.stringify(newValue));
localStorage.setItem(STORAGE_KEY, JSON.stringify(newValue));
}
return newValue;
});
@ -52,7 +54,7 @@ export function createEditorSettingsStore(): EditorSettingsStore {
update((current) => {
const newValue = { ...current, ...partial };
if (typeof localStorage !== 'undefined') {
localStorage.setItem('editorSettings', JSON.stringify(newValue));
localStorage.setItem(STORAGE_KEY, JSON.stringify(newValue));
}
return newValue;
});

View File

@ -123,32 +123,33 @@ export class ProjectEditor {
return false;
}
requestSwitch(action: () => void, onConfirm: (action: 'save' | 'discard') => void) {
requestSwitch(action: () => void): 'proceed' | 'confirm-unsaved' {
if (!this.state.hasUnsavedChanges) {
action();
return false;
return 'proceed';
}
this.pendingAction = action;
this.confirmCallback = onConfirm;
return true;
return 'confirm-unsaved';
}
async handleSaveAndSwitch(): Promise<void> {
async confirmSaveAndSwitch(): Promise<'show-save-as' | 'done'> {
if (this.state.isNewUnsavedBuffer) {
this.confirmCallback?.('save');
return;
return 'show-save-as';
}
await this.save();
this.pendingAction?.();
this.pendingAction = null;
this.confirmCallback = null;
return 'done';
}
handleDiscardAndSwitch(): void {
confirmDiscardAndSwitch(): void {
this.pendingAction?.();
this.pendingAction = null;
this.confirmCallback = null;
}
cancelSwitch(): void {
this.pendingAction = null;
}
}

View File

@ -1,6 +1,6 @@
type PanelPosition = 'left' | 'right' | 'bottom';
class UIState {
export class UIState {
sidePanelVisible = $state(true);
sidePanelPosition = $state<PanelPosition>('right');
@ -60,5 +60,3 @@ class UIState {
this.saveAsDialogVisible = false;
}
}
export const uiState = new UIState();