From 8e6b07797c4ce9d8e4b4775153b11337149ebaea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Wed, 15 Oct 2025 01:41:52 +0200 Subject: [PATCH] Refactoring --- src/App.svelte | 73 ++++------ src/lib/Editor.svelte | 41 +++--- src/lib/FileBrowser.svelte | 7 +- src/lib/contexts/app-context.ts | 9 +- src/lib/csound/engine.ts | 2 + src/lib/csound/store.ts | 13 +- src/lib/project-system/db.ts | 3 +- src/lib/project-system/project-manager.ts | 170 +++++++++++----------- src/lib/stores/editorSettings.ts | 10 +- src/lib/stores/projectEditor.svelte.ts | 21 +-- src/lib/stores/uiState.svelte.ts | 4 +- 11 files changed, 180 insertions(+), 173 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index cfe6663..dc2c483 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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(null); let interpreterLogs = $state([]); - 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,42 +112,34 @@ 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); + } + + async function handleSwitchSave() { + const result = await projectEditor.confirmSaveAndSwitch(); + + if (result === 'show-save-as') { + uiState.hideUnsavedChangesDialog(); + uiState.showSaveAsDialog(); + } else { + uiState.hideUnsavedChangesDialog(); } } - function handleSwitchConfirm(action: 'save' | 'discard') { - if (action === 'save') { - if (projectEditor.isNewUnsavedBuffer) { - 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() { @@ -170,11 +158,15 @@ $effect(() => { if (uiState.scopePopupVisible || uiState.spectrogramPopupVisible) { - const interval = setInterval(() => { - analyserNode = csound.getAnalyserNode(); - }, 100); + analyserNode = csound.getAnalyserNode(); - return () => clearInterval(interval); + const unsubscribe = csound.onAnalyserNodeCreated((node) => { + analyserNode = node; + }); + + return unsubscribe; + } else { + analyserNode = null; } }); @@ -198,7 +190,6 @@ {#snippet filesTabContent()} handleSwitchConfirm('save')} - onCancel={() => handleSwitchConfirm('discard')} + onConfirm={handleSwitchSave} + onCancel={handleSwitchDiscard} /> { - 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) => { - if (!editorView) return; + $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() : []) - ] - }); - - editorView.dom.style.fontSize = `${newSettings.fontSize}px`; - editorView.dom.style.fontFamily = newSettings.fontFamily; + editorView.dispatch({ + effects: [ + lineNumbersCompartment.reconfigure(settings.showLineNumbers ? lineNumbers() : []), + lineWrappingCompartment.reconfigure(settings.enableLineWrapping ? EditorView.lineWrapping : []), + vimCompartment.reconfigure(settings.vimMode ? vim() : []) + ] }); - return () => { - unsubscribe(); - }; + editorView.dom.style.fontSize = `${settings.fontSize}px`; + editorView.dom.style.fontFamily = settings.fontFamily; }); onDestroy(() => { diff --git a/src/lib/FileBrowser.svelte b/src/lib/FileBrowser.svelte index 84ddee0..cef8eee 100644 --- a/src/lib/FileBrowser.svelte +++ b/src/lib/FileBrowser.svelte @@ -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) => diff --git a/src/lib/contexts/app-context.ts b/src/lib/contexts/app-context.ts index 9347414..8e11f33 100644 --- a/src/lib/contexts/app-context.ts +++ b/src/lib/contexts/app-context.ts @@ -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() }; } diff --git a/src/lib/csound/engine.ts b/src/lib/csound/engine.ts index 9e98681..2fa0913 100644 --- a/src/lib/csound/engine.ts +++ b/src/lib/csound/engine.ts @@ -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); diff --git a/src/lib/csound/store.ts b/src/lib/csound/store.ts index 1b68e7b..2d7dbac 100644 --- a/src/lib/csound/store.ts +++ b/src/lib/csound/store.ts @@ -21,6 +21,7 @@ export interface CsoundStore { clearLogs: () => void; getAudioContext: () => AudioContext | null; getAnalyserNode: () => AnalyserNode | null; + onAnalyserNodeCreated: (callback: (node: AnalyserNode) => void) => () => void; destroy: () => Promise; } @@ -34,6 +35,7 @@ export function createCsoundStore(): CsoundStore { const { subscribe, set, update } = writable(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); } }; diff --git a/src/lib/project-system/db.ts b/src/lib/project-system/db.ts index 93348a9..d6f6b96 100644 --- a/src/lib/project-system/db.ts +++ b/src/lib/project-system/db.ts @@ -229,5 +229,4 @@ class ProjectDatabase { } } -// Export singleton instance -export const projectDb = new ProjectDatabase(); +export { ProjectDatabase }; diff --git a/src/lib/project-system/project-manager.ts b/src/lib/project-system/project-manager.ts index af4b71a..325da60 100644 --- a/src/lib/project-system/project-manager.ts +++ b/src/lib/project-system/project-manager.ts @@ -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(fn: () => Promise, errorMsg: string): Promise> { + 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 = 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 { - 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> { 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> { - 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> { 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> { - 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> { - 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> { - 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> { 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> { 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> { 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> { 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> { - 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; } } diff --git a/src/lib/stores/editorSettings.ts b/src/lib/stores/editorSettings.ts index 48e5242..f11a6d3 100644 --- a/src/lib/stores/editorSettings.ts +++ b/src/lib/stores/editorSettings.ts @@ -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(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; }); diff --git a/src/lib/stores/projectEditor.svelte.ts b/src/lib/stores/projectEditor.svelte.ts index bae2c89..d7e7fdc 100644 --- a/src/lib/stores/projectEditor.svelte.ts +++ b/src/lib/stores/projectEditor.svelte.ts @@ -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 { + 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; } } diff --git a/src/lib/stores/uiState.svelte.ts b/src/lib/stores/uiState.svelte.ts index cf78eb3..f608509 100644 --- a/src/lib/stores/uiState.svelte.ts +++ b/src/lib/stores/uiState.svelte.ts @@ -1,6 +1,6 @@ type PanelPosition = 'left' | 'right' | 'bottom'; -class UIState { +export class UIState { sidePanelVisible = $state(true); sidePanelPosition = $state('right'); @@ -60,5 +60,3 @@ class UIState { this.saveAsDialogVisible = false; } } - -export const uiState = new UIState();