This commit is contained in:
2025-10-15 01:23:05 +02:00
parent 8ac5ac0770
commit e492c03f15
15 changed files with 484 additions and 330 deletions

View File

@ -9,10 +9,13 @@
import ResizablePopup from './lib/ResizablePopup.svelte'; import ResizablePopup from './lib/ResizablePopup.svelte';
import AudioScope from './lib/AudioScope.svelte'; import AudioScope from './lib/AudioScope.svelte';
import Spectrogram from './lib/Spectrogram.svelte'; import Spectrogram from './lib/Spectrogram.svelte';
import { csound, csoundLogs, type LogEntry } from './lib/csound';
import { projectManager, type CsoundProject } from './lib/project-system';
import ConfirmDialog from './lib/ConfirmDialog.svelte'; import ConfirmDialog from './lib/ConfirmDialog.svelte';
import InputDialog from './lib/InputDialog.svelte'; 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 { import {
PanelLeftClose, PanelLeftClose,
PanelLeftOpen, PanelLeftOpen,
@ -26,95 +29,15 @@
Activity Activity
} from 'lucide-svelte'; } from 'lucide-svelte';
let sidePanelVisible = $state(true); const appContext = createAppContext();
let sidePanelPosition = $state<'left' | 'right' | 'bottom'>('right'); setAppContext(appContext);
let popupVisible = $state(false);
let sharePopupVisible = $state(false); const { csound, projectManager, editorSettings, projectEditor } = appContext;
let shareUrl = $state(''); const csoundDerived = createCsoundDerivedStores(csound);
let audioPermissionPopupVisible = $state(true);
let scopePopupVisible = $state(false);
let spectrogramPopupVisible = $state(false);
let analyserNode = $state<AnalyserNode | null>(null); let analyserNode = $state<AnalyserNode | null>(null);
let editorValue = $state(`<CsoundSynthesizer>
<CsOptions>
-odac
</CsOptions>
<CsInstruments>
sr = 44100
ksmps = 32
nchnls = 2
0dbfs = 1
instr 1
iFreq = p4
iAmp = p5
; ADSR envelope
kEnv madsr 0.01, 0.1, 0.6, 0.2
; Sine wave oscillator
aOsc oscili iAmp * kEnv, iFreq
outs aOsc, aOsc
endin
</CsInstruments>
<CsScore>
; Arpeggio: C4 E4 G4 C5
i 1 0.0 0.5 261.63 0.3
i 1 0.5 0.5 329.63 0.3
i 1 1.0 0.5 392.00 0.3
i 1 1.5 0.5 523.25 0.3
</CsScore>
</CsoundSynthesizer>
`);
let interpreterLogs = $state<LogEntry[]>([]); let interpreterLogs = $state<LogEntry[]>([]);
let sidePanelRef: SidePanel;
let editorRef: EditorWithLogs;
let fileBrowserRef: FileBrowser; let fileBrowserRef: FileBrowser;
let currentProjectId = $state<string | null>(null);
let hasUnsavedChanges = $state(false);
let isNewUnsavedBuffer = $state(false);
let pendingProject: CsoundProject | null = null;
let showUnsavedDialog = $state(false);
let showSaveAsDialog = $state(false);
const TEMPLATE_CONTENT = `<CsoundSynthesizer>
<CsOptions>
-odac
</CsOptions>
<CsInstruments>
sr = 44100
ksmps = 32
nchnls = 2
0dbfs = 1
instr 1
iFreq = p4
iAmp = p5
; ADSR envelope
kEnv madsr 0.01, 0.1, 0.6, 0.2
; Sine wave oscillator
aOsc oscili iAmp * kEnv, iFreq
outs aOsc, aOsc
endin
</CsInstruments>
<CsScore>
; Arpeggio: C4 E4 G4 C5
i 1 0.0 0.5 261.63 0.3
i 1 0.5 0.5 329.63 0.3
i 1 1.0 0.5 392.00 0.3
i 1 1.5 0.5 523.25 0.3
</CsScore>
</CsoundSynthesizer>
`;
onMount(async () => { onMount(async () => {
await projectManager.init(); await projectManager.init();
@ -124,12 +47,12 @@ i 1 1.5 0.5 523.25 0.3
await projectManager.createProject({ await projectManager.createProject({
title: 'Template', title: 'Template',
author: 'System', author: 'System',
content: TEMPLATE_CONTENT, content: DEFAULT_CSOUND_TEMPLATE,
tags: [] tags: []
}); });
} }
const unsubscribe = csoundLogs.subscribe(logs => { const unsubscribe = csoundDerived.logs.subscribe(logs => {
interpreterLogs = logs; interpreterLogs = logs;
}); });
@ -138,183 +61,115 @@ i 1 1.5 0.5 523.25 0.3
}; };
}); });
onDestroy(async () => {
await csound.destroy();
});
async function handleEnableAudio() { async function handleEnableAudio() {
try { try {
await csound.init(); await csound.init();
audioPermissionPopupVisible = false; uiState.closeAudioPermission();
} catch (error) { } catch (error) {
console.error('Failed to initialize audio:', error); console.error('Failed to initialize audio:', error);
} }
} }
onDestroy(async () => {
await csound.destroy();
});
function toggleSidePanel() {
sidePanelVisible = !sidePanelVisible;
}
function showPopup() {
popupVisible = true;
}
function handleEditorChange(value: string) { function handleEditorChange(value: string) {
editorValue = value; projectEditor.setContent(value);
hasUnsavedChanges = true;
} }
function handleNewFile() { function handleNewFile() {
if (hasUnsavedChanges) { const needsConfirm = projectEditor.requestSwitch(
pendingProject = null; () => projectEditor.createNew(DEFAULT_CSOUND_TEMPLATE),
showUnsavedDialog = true; handleSwitchConfirm
);
if (needsConfirm) {
uiState.showUnsavedChangesDialog();
}
}
function handleFileSelect(project: CsoundProject | null) {
if (!project) return;
const needsConfirm = projectEditor.requestSwitch(
() => projectEditor.loadProject(project),
handleSwitchConfirm
);
if (needsConfirm) {
uiState.showUnsavedChangesDialog();
}
}
async function handleExecute(code: string) {
try {
await csound.evaluate(code);
} catch (error) {
console.error('Execution error:', error);
}
}
async function handleSave() {
if (projectEditor.isNewUnsavedBuffer) {
uiState.showSaveAsDialog();
return; return;
} }
createNewBuffer(); const success = await projectEditor.save();
} if (success) {
fileBrowserRef?.refresh();
function createNewBuffer() {
currentProjectId = null;
editorValue = TEMPLATE_CONTENT;
hasUnsavedChanges = false;
isNewUnsavedBuffer = true;
if (editorRef) {
editorRef.setValue(TEMPLATE_CONTENT);
}
}
async function handleFileSelect(project: CsoundProject | null) {
if (hasUnsavedChanges) {
pendingProject = project;
showUnsavedDialog = true;
return;
}
if (project) {
loadProject(project);
}
}
function loadProject(project: CsoundProject) {
currentProjectId = project.id;
editorValue = project.content;
hasUnsavedChanges = false;
isNewUnsavedBuffer = false;
if (editorRef) {
editorRef.setValue(project.content);
}
}
async function saveCurrentProject() {
if (isNewUnsavedBuffer) {
showSaveAsDialog = true;
return;
}
if (!currentProjectId) return;
const result = await projectManager.updateProject({
id: currentProjectId,
content: editorValue
});
if (result.success) {
hasUnsavedChanges = false;
if (fileBrowserRef) {
fileBrowserRef.refresh();
}
} }
} }
async function handleSaveAs(title: string) { async function handleSaveAs(title: string) {
const finalTitle = title.trim() || 'Untitled'; const success = await projectEditor.saveAs(title);
if (success) {
const result = await projectManager.createProject({ fileBrowserRef?.refresh();
title: finalTitle, uiState.hideSaveAsDialog();
author: 'Anonymous',
content: editorValue,
tags: []
});
if (result.success) {
currentProjectId = result.data.id;
hasUnsavedChanges = false;
isNewUnsavedBuffer = false;
if (fileBrowserRef) {
fileBrowserRef.refresh();
}
} }
} }
async function handleMetadataUpdate(projectId: string, updates: { title?: string; author?: string }) { async function handleMetadataUpdate(projectId: string, updates: { title?: string; author?: string }) {
const result = await projectManager.updateProject({ const success = await projectEditor.updateMetadata(updates);
id: projectId, if (success) {
...updates fileBrowserRef?.refresh();
});
if (result.success && fileBrowserRef) {
fileBrowserRef.refresh();
} }
} }
function handleSaveAndSwitch() { function handleSwitchConfirm(action: 'save' | 'discard') {
saveCurrentProject().then(() => { if (action === 'save') {
if (pendingProject) { if (projectEditor.isNewUnsavedBuffer) {
loadProject(pendingProject); uiState.hideUnsavedChangesDialog();
uiState.showSaveAsDialog();
} else { } else {
createNewBuffer(); projectEditor.handleSaveAndSwitch().then(() => {
fileBrowserRef?.refresh();
uiState.hideUnsavedChangesDialog();
});
} }
pendingProject = null;
});
}
function handleDiscardAndSwitch() {
if (pendingProject) {
loadProject(pendingProject);
} else { } else {
createNewBuffer(); projectEditor.handleDiscardAndSwitch();
} uiState.hideUnsavedChangesDialog();
pendingProject = null;
}
function cyclePanelPosition() {
if (sidePanelPosition === 'right') {
sidePanelPosition = 'left';
} else if (sidePanelPosition === 'left') {
sidePanelPosition = 'bottom';
} else {
sidePanelPosition = 'right';
} }
} }
async function handleShare() { async function handleShare() {
if (!currentProjectId) return; if (!projectEditor.currentProjectId) return;
const result = await projectManager.exportProjectToUrl(currentProjectId); const result = await projectManager.exportProjectToUrl(projectEditor.currentProjectId);
if (result.success) { if (result.success) {
shareUrl = result.data;
try { try {
await navigator.clipboard.writeText(shareUrl); await navigator.clipboard.writeText(result.data);
} catch (err) { } catch (err) {
console.error('Failed to copy to clipboard:', err); console.error('Failed to copy to clipboard:', err);
} }
uiState.showShare(result.data);
sharePopupVisible = true;
} }
} }
function handleOpenScope() {
scopePopupVisible = true;
}
function handleOpenSpectrogram() {
spectrogramPopupVisible = true;
}
$effect(() => { $effect(() => {
if (scopePopupVisible || spectrogramPopupVisible) { if (uiState.scopePopupVisible || uiState.spectrogramPopupVisible) {
const interval = setInterval(() => { const interval = setInterval(() => {
analyserNode = csound.getAnalyserNode(); analyserNode = csound.getAnalyserNode();
}, 100); }, 100);
@ -344,10 +199,11 @@ i 1 1.5 0.5 523.25 0.3
{#snippet filesTabContent()} {#snippet filesTabContent()}
<FileBrowser <FileBrowser
bind:this={fileBrowserRef} bind:this={fileBrowserRef}
{projectManager}
onFileSelect={handleFileSelect} onFileSelect={handleFileSelect}
onNewFile={handleNewFile} onNewFile={handleNewFile}
onMetadataUpdate={handleMetadataUpdate} onMetadataUpdate={handleMetadataUpdate}
selectedProjectId={currentProjectId} selectedProjectId={projectEditor.currentProjectId}
/> />
{/snippet} {/snippet}
@ -356,53 +212,53 @@ i 1 1.5 0.5 523.25 0.3
{#snippet leftActions()} {#snippet leftActions()}
<button <button
class="icon-button" class="icon-button"
onclick={saveCurrentProject} onclick={handleSave}
disabled={!hasUnsavedChanges} disabled={!projectEditor.hasUnsavedChanges}
title="Save {hasUnsavedChanges ? '(unsaved changes)' : ''}" title="Save {projectEditor.hasUnsavedChanges ? '(unsaved changes)' : ''}"
class:has-changes={hasUnsavedChanges} class:has-changes={projectEditor.hasUnsavedChanges}
> >
<Save size={18} /> <Save size={18} />
</button> </button>
{/snippet} {/snippet}
<button onclick={toggleSidePanel} class="icon-button"> <button onclick={() => uiState.toggleSidePanel()} class="icon-button">
{#if sidePanelVisible} {#if uiState.sidePanelVisible}
{#if sidePanelPosition === 'left'} {#if uiState.sidePanelPosition === 'left'}
<PanelLeftClose size={18} /> <PanelLeftClose size={18} />
{:else if sidePanelPosition === 'right'} {:else if uiState.sidePanelPosition === 'right'}
<PanelRightClose size={18} /> <PanelRightClose size={18} />
{:else} {:else}
<PanelBottomClose size={18} /> <PanelBottomClose size={18} />
{/if} {/if}
{:else} {:else}
{#if sidePanelPosition === 'left'} {#if uiState.sidePanelPosition === 'left'}
<PanelLeftOpen size={18} /> <PanelLeftOpen size={18} />
{:else if sidePanelPosition === 'right'} {:else if uiState.sidePanelPosition === 'right'}
<PanelRightOpen size={18} /> <PanelRightOpen size={18} />
{:else} {:else}
<PanelBottomOpen size={18} /> <PanelBottomOpen size={18} />
{/if} {/if}
{/if} {/if}
</button> </button>
<button onclick={cyclePanelPosition} class="icon-button" title="Change panel position"> <button onclick={() => uiState.cyclePanelPosition()} class="icon-button" title="Change panel position">
<LayoutGrid size={18} /> <LayoutGrid size={18} />
</button> </button>
<button <button
onclick={handleShare} onclick={handleShare}
class="icon-button" class="icon-button"
disabled={!currentProjectId} disabled={!projectEditor.currentProjectId}
title="Share project" title="Share project"
> >
<Share2 size={18} /> <Share2 size={18} />
</button> </button>
<button <button
onclick={handleOpenScope} onclick={() => uiState.openScope()}
class="icon-button" class="icon-button"
title="Open audio scope" title="Open audio scope"
> >
<Activity size={18} /> <Activity size={18} />
</button> </button>
<button <button
onclick={handleOpenSpectrogram} onclick={() => uiState.openSpectrogram()}
class="icon-button" class="icon-button"
title="Open spectrogram" title="Open spectrogram"
> >
@ -416,40 +272,38 @@ i 1 1.5 0.5 523.25 0.3
</button> </button>
</TopBar> </TopBar>
<div class="main-content" class:panel-bottom={sidePanelPosition === 'bottom'}> <div class="main-content" class:panel-bottom={uiState.sidePanelPosition === 'bottom'}>
{#if sidePanelPosition === 'left'} {#if uiState.sidePanelPosition === 'left'}
<SidePanel <SidePanel
bind:this={sidePanelRef} bind:visible={uiState.sidePanelVisible}
bind:visible={sidePanelVisible} bind:position={uiState.sidePanelPosition}
bind:position={sidePanelPosition}
tabs={panelTabs} tabs={panelTabs}
/> />
{/if} {/if}
<div class="editor-area"> <div class="editor-area">
<EditorWithLogs <EditorWithLogs
bind:this={editorRef} value={projectEditor.content}
initialValue={editorValue}
language="javascript" language="javascript"
onChange={handleEditorChange} onChange={handleEditorChange}
onExecute={handleExecute}
logs={interpreterLogs} logs={interpreterLogs}
{editorSettings}
/> />
</div> </div>
{#if sidePanelPosition === 'right'} {#if uiState.sidePanelPosition === 'right'}
<SidePanel <SidePanel
bind:this={sidePanelRef} bind:visible={uiState.sidePanelVisible}
bind:visible={sidePanelVisible} bind:position={uiState.sidePanelPosition}
bind:position={sidePanelPosition}
tabs={panelTabs} tabs={panelTabs}
/> />
{/if} {/if}
{#if sidePanelPosition === 'bottom'} {#if uiState.sidePanelPosition === 'bottom'}
<SidePanel <SidePanel
bind:this={sidePanelRef} bind:visible={uiState.sidePanelVisible}
bind:visible={sidePanelVisible} bind:position={uiState.sidePanelPosition}
bind:position={sidePanelPosition}
initialWidth={200} initialWidth={200}
minWidth={100} minWidth={100}
maxWidth={400} maxWidth={400}
@ -458,21 +312,8 @@ i 1 1.5 0.5 523.25 0.3
{/if} {/if}
</div> </div>
<Popup
bind:visible={popupVisible}
title="Example Popup"
x={200}
y={150}
width={500}
height={400}
>
<h3>This is a popup!</h3>
<p>You can drag it around by the header.</p>
<p>It stays on top of everything else.</p>
</Popup>
<ResizablePopup <ResizablePopup
bind:visible={sharePopupVisible} bind:visible={uiState.sharePopupVisible}
title="Share Project" title="Share Project"
x={(typeof window !== 'undefined' ? window.innerWidth / 2 - 300 : 300)} x={(typeof window !== 'undefined' ? window.innerWidth / 2 - 300 : 300)}
y={(typeof window !== 'undefined' ? window.innerHeight / 2 - 100 : 200)} y={(typeof window !== 'undefined' ? window.innerHeight / 2 - 100 : 200)}
@ -488,7 +329,7 @@ i 1 1.5 0.5 523.25 0.3
<input <input
type="text" type="text"
readonly readonly
value={shareUrl} value={uiState.shareUrl}
class="share-url-input" class="share-url-input"
onclick={(e) => e.currentTarget.select()} onclick={(e) => e.currentTarget.select()}
/> />
@ -499,7 +340,7 @@ i 1 1.5 0.5 523.25 0.3
</ResizablePopup> </ResizablePopup>
<ResizablePopup <ResizablePopup
visible={audioPermissionPopupVisible} visible={uiState.audioPermissionPopupVisible}
title="Audio Permission Required" title="Audio Permission Required"
x={(typeof window !== 'undefined' ? window.innerWidth / 2 - 250 : 250)} x={(typeof window !== 'undefined' ? window.innerWidth / 2 - 250 : 250)}
y={(typeof window !== 'undefined' ? window.innerHeight / 2 - 125 : 200)} y={(typeof window !== 'undefined' ? window.innerHeight / 2 - 125 : 200)}
@ -522,7 +363,7 @@ i 1 1.5 0.5 523.25 0.3
</ResizablePopup> </ResizablePopup>
<ResizablePopup <ResizablePopup
bind:visible={scopePopupVisible} bind:visible={uiState.scopePopupVisible}
title="Audio Scope" title="Audio Scope"
x={(typeof window !== 'undefined' ? window.innerWidth / 2 - 400 : 100)} x={(typeof window !== 'undefined' ? window.innerWidth / 2 - 400 : 100)}
y={(typeof window !== 'undefined' ? window.innerHeight / 2 - 300 : 100)} y={(typeof window !== 'undefined' ? window.innerHeight / 2 - 300 : 100)}
@ -538,7 +379,7 @@ i 1 1.5 0.5 523.25 0.3
</ResizablePopup> </ResizablePopup>
<ResizablePopup <ResizablePopup
bind:visible={spectrogramPopupVisible} bind:visible={uiState.spectrogramPopupVisible}
title="Spectrogram" title="Spectrogram"
x={(typeof window !== 'undefined' ? window.innerWidth / 2 - 400 : 150)} x={(typeof window !== 'undefined' ? window.innerWidth / 2 - 400 : 150)}
y={(typeof window !== 'undefined' ? window.innerHeight / 2 - 300 : 150)} y={(typeof window !== 'undefined' ? window.innerHeight / 2 - 300 : 150)}
@ -554,17 +395,17 @@ i 1 1.5 0.5 523.25 0.3
</ResizablePopup> </ResizablePopup>
<ConfirmDialog <ConfirmDialog
bind:visible={showUnsavedDialog} bind:visible={uiState.unsavedChangesDialogVisible}
title="Unsaved Changes" title="Unsaved Changes"
message="You have unsaved changes. What would you like to do?" message="You have unsaved changes. What would you like to do?"
confirmLabel="Save" confirmLabel="Save"
cancelLabel="Discard" cancelLabel="Discard"
onConfirm={handleSaveAndSwitch} onConfirm={() => handleSwitchConfirm('save')}
onCancel={handleDiscardAndSwitch} onCancel={() => handleSwitchConfirm('discard')}
/> />
<InputDialog <InputDialog
bind:visible={showSaveAsDialog} bind:visible={uiState.saveAsDialogVisible}
title="Save As" title="Save As"
label="File name" label="File name"
placeholder="Untitled" placeholder="Untitled"
@ -703,3 +544,4 @@ i 1 1.5 0.5 523.25 0.3
background-color: #424ab8; background-color: #424ab8;
} }
</style> </style>

View File

@ -13,19 +13,22 @@
import { css } from '@codemirror/lang-css'; import { css } from '@codemirror/lang-css';
import { oneDark } from '@codemirror/theme-one-dark'; import { oneDark } from '@codemirror/theme-one-dark';
import { vim } from '@replit/codemirror-vim'; import { vim } from '@replit/codemirror-vim';
import { editorSettings } from './stores/editorSettings'; import type { EditorSettingsStore } from './stores/editorSettings';
import { csound } from './csound';
interface Props { interface Props {
initialValue?: string; value: string;
language?: 'javascript' | 'html' | 'css'; language?: 'javascript' | 'html' | 'css';
onChange?: (value: string) => void; onChange?: (value: string) => void;
onExecute?: (code: string) => void;
editorSettings: EditorSettingsStore;
} }
let { let {
initialValue = '', value = '',
language = 'javascript', language = 'javascript',
onChange onChange,
onExecute,
editorSettings
}: Props = $props(); }: Props = $props();
let editorContainer: HTMLDivElement; let editorContainer: HTMLDivElement;
@ -45,10 +48,10 @@
{ {
key: 'Mod-e', key: 'Mod-e',
run: (view) => { run: (view) => {
const code = view.state.doc.toString(); if (onExecute) {
csound.evaluate(code).catch(err => { const code = view.state.doc.toString();
console.error('Evaluation error:', err); onExecute(code);
}); }
return true; return true;
} }
} }
@ -84,7 +87,7 @@
]; ];
editorView = new EditorView({ editorView = new EditorView({
doc: initialValue, doc: value,
extensions: [ extensions: [
...baseExtensions, ...baseExtensions,
languageExtensions[language], languageExtensions[language],
@ -134,17 +137,13 @@
} }
}); });
export function getValue(): string { $effect(() => {
return editorView?.state.doc.toString() || ''; if (editorView && value !== editorView.state.doc.toString()) {
}
export function setValue(value: string): void {
if (editorView) {
editorView.dispatch({ editorView.dispatch({
changes: { from: 0, to: editorView.state.doc.length, insert: value } changes: { from: 0, to: editorView.state.doc.length, insert: value }
}); });
} }
} });
</script> </script>
<div class="editor-wrapper"> <div class="editor-wrapper">

View File

@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { editorSettings } from './stores/editorSettings'; import { getAppContext } from './contexts/app-context';
const { editorSettings } = getAppContext();
let settings = $state($editorSettings); let settings = $state($editorSettings);

View File

@ -2,22 +2,26 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Editor from './Editor.svelte'; import Editor from './Editor.svelte';
import LogPanel from './LogPanel.svelte'; import LogPanel from './LogPanel.svelte';
import type { EditorSettingsStore } from './stores/editorSettings';
interface Props { interface Props {
initialValue?: string; value: string;
language?: 'javascript' | 'html' | 'css'; language?: 'javascript' | 'html' | 'css';
onChange?: (value: string) => void; onChange?: (value: string) => void;
onExecute?: (code: string) => void;
logs?: string[]; logs?: string[];
editorSettings: EditorSettingsStore;
} }
let { let {
initialValue = '', value = '',
language = 'javascript', language = 'javascript',
onChange, onChange,
logs = [] onExecute,
logs = [],
editorSettings
}: Props = $props(); }: Props = $props();
let editorRef: Editor;
let logPanelRef: LogPanel; let logPanelRef: LogPanel;
let editorHeight = $state(70); let editorHeight = $state(70);
@ -60,22 +64,16 @@
}; };
}); });
export function getValue(): string {
return editorRef?.getValue() || '';
}
export function setValue(value: string): void {
editorRef?.setValue(value);
}
</script> </script>
<div class="editor-with-logs"> <div class="editor-with-logs">
<div class="editor-section" style="height: {editorHeight}%;"> <div class="editor-section" style="height: {editorHeight}%;">
<Editor <Editor
bind:this={editorRef} {value}
{initialValue}
{language} {language}
{onChange} {onChange}
{onExecute}
{editorSettings}
/> />
</div> </div>

View File

@ -1,18 +1,19 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { File, Plus, Trash2 } from 'lucide-svelte'; import { File, Plus, Trash2 } from 'lucide-svelte';
import { projectManager, type CsoundProject } from './project-system'; import type { CsoundProject, ProjectManager } from './project-system';
import ConfirmDialog from './ConfirmDialog.svelte'; import ConfirmDialog from './ConfirmDialog.svelte';
import InputDialog from './InputDialog.svelte'; import InputDialog from './InputDialog.svelte';
interface Props { interface Props {
projectManager: ProjectManager;
onFileSelect?: (project: CsoundProject | null) => void; onFileSelect?: (project: CsoundProject | null) => void;
onNewFile?: () => void; onNewFile?: () => void;
onMetadataUpdate?: (projectId: string, updates: { title?: string; author?: string }) => void; onMetadataUpdate?: (projectId: string, updates: { title?: string; author?: string }) => void;
selectedProjectId?: string | null; selectedProjectId?: string | null;
} }
let { onFileSelect, onNewFile, onMetadataUpdate, selectedProjectId = null }: Props = $props(); let { projectManager, onFileSelect, onNewFile, onMetadataUpdate, selectedProjectId = null }: Props = $props();
let projects = $state<CsoundProject[]>([]); let projects = $state<CsoundProject[]>([]);
let loading = $state(true); let loading = $state(true);

View File

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Copy, Trash2, Search, Pause, Play } from 'lucide-svelte'; import { Copy, Trash2, Search, Pause, Play } from 'lucide-svelte';
import { csound } from './csound'; import { getAppContext } from './contexts/app-context';
import type { LogEntry } from './csound'; import type { LogEntry } from './csound';
import { onMount, tick } from 'svelte'; import { tick } from 'svelte';
interface Props { interface Props {
logs?: LogEntry[]; logs?: LogEntry[];
@ -10,6 +10,8 @@
let { logs = [] }: Props = $props(); let { logs = [] }: Props = $props();
const { csound } = getAppContext();
let searchQuery = $state(''); let searchQuery = $state('');
let autoFollow = $state(true); let autoFollow = $state(true);
let searchVisible = $state(false); let searchVisible = $state(false);

View File

@ -0,0 +1,33 @@
export const DEFAULT_CSOUND_TEMPLATE = `<CsoundSynthesizer>
<CsOptions>
</CsOptions>
<CsInstruments>
sr = 44100
ksmps = 32
nchnls = 2
0dbfs = 1
instr 1
iFreq = p4
iAmp = p5
; ADSR envelope
kEnv madsr 0.01, 0.1, 0.6, 0.2
; Sine wave oscillator
aOsc oscili iAmp * kEnv, iFreq
outs aOsc, aOsc
endin
</CsInstruments>
<CsScore>
; Arpeggio: C4 E4 G4 C5
i 1 0.0 0.5 261.63 0.3
i 1 0.5 0.5 329.63 0.3
i 1 1.0 0.5 392.00 0.3
i 1 1.5 0.5 523.25 0.3
</CsScore>
</CsoundSynthesizer>
`;

View File

@ -0,0 +1,38 @@
import { getContext, setContext } from 'svelte';
import { ProjectManager } from '../project-system/project-manager';
import { createCsoundStore } from '../csound/store';
import { createEditorSettingsStore } from '../stores/editorSettings';
import { ProjectEditor } from '../stores/projectEditor.svelte';
import type { CsoundStore } from '../csound/store';
import type { EditorSettingsStore } from '../stores/editorSettings';
export interface AppContext {
projectManager: ProjectManager;
csound: CsoundStore;
editorSettings: EditorSettingsStore;
projectEditor: ProjectEditor;
}
const APP_CONTEXT_KEY = Symbol('app-context');
export function createAppContext(): AppContext {
const projectManager = new ProjectManager();
return {
projectManager,
csound: createCsoundStore(),
editorSettings: createEditorSettingsStore(),
projectEditor: new ProjectEditor(projectManager)
};
}
export function setAppContext(context: AppContext): void {
setContext<AppContext>(APP_CONTEXT_KEY, context);
}
export function getAppContext(): AppContext {
const context = getContext<AppContext>(APP_CONTEXT_KEY);
if (!context) {
throw new Error('AppContext not found. Did you forget to call setAppContext?');
}
return context;
}

View File

@ -1,4 +1,4 @@
export { CsoundEngine } from './engine'; export { CsoundEngine } from './engine';
export type { CsoundEngineOptions } from './engine'; export type { CsoundEngineOptions } from './engine';
export { csound, csoundLogs, csoundInitialized, csoundRunning } from './store'; export { createCsoundStore, createCsoundDerivedStores } from './store';
export type { LogEntry } from './store'; export type { LogEntry, CsoundStore } from './store';

View File

@ -13,7 +13,18 @@ interface CsoundState {
logs: LogEntry[]; logs: LogEntry[];
} }
function createCsoundStore() { export interface CsoundStore {
subscribe: (run: (value: CsoundState) => void) => () => void;
init: () => Promise<void>;
evaluate: (code: string) => Promise<void>;
stop: () => Promise<void>;
clearLogs: () => void;
getAudioContext: () => AudioContext | null;
getAnalyserNode: () => AnalyserNode | null;
destroy: () => Promise<void>;
}
export function createCsoundStore(): CsoundStore {
const initialState: CsoundState = { const initialState: CsoundState = {
initialized: false, initialized: false,
running: false, running: false,
@ -116,8 +127,10 @@ function createCsoundStore() {
}; };
} }
export const csound = createCsoundStore(); export function createCsoundDerivedStores(csound: CsoundStore) {
return {
export const csoundLogs = derived(csound, $csound => $csound.logs); logs: derived(csound, $csound => $csound.logs),
export const csoundInitialized = derived(csound, $csound => $csound.initialized); initialized: derived(csound, $csound => $csound.initialized),
export const csoundRunning = derived(csound, $csound => $csound.running); running: derived(csound, $csound => $csound.running)
};
}

View File

@ -41,7 +41,7 @@ export type {
} from './types'; } from './types';
// Export main API // Export main API
export { ProjectManager, projectManager } from './project-manager'; export { ProjectManager } from './project-manager';
// Export database (for advanced usage) // Export database (for advanced usage)
export { projectDb } from './db'; export { projectDb } from './db';

View File

@ -338,6 +338,3 @@ export class ProjectManager {
} }
} }
} }
// Export singleton instance
export const projectManager = new ProjectManager();

View File

@ -18,8 +18,15 @@ const defaultSettings: EditorSettings = {
vimMode: false vimMode: false
}; };
function createEditorSettings() { export interface EditorSettingsStore {
const stored = localStorage.getItem('editorSettings'); subscribe: (run: (value: EditorSettings) => void) => () => void;
set: (value: EditorSettings) => void;
update: (updater: (value: EditorSettings) => EditorSettings) => void;
updatePartial: (partial: Partial<EditorSettings>) => void;
}
export function createEditorSettingsStore(): EditorSettingsStore {
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('editorSettings') : null;
const initial = stored ? { ...defaultSettings, ...JSON.parse(stored) } : defaultSettings; const initial = stored ? { ...defaultSettings, ...JSON.parse(stored) } : defaultSettings;
const { subscribe, set, update } = writable<EditorSettings>(initial); const { subscribe, set, update } = writable<EditorSettings>(initial);
@ -27,24 +34,28 @@ function createEditorSettings() {
return { return {
subscribe, subscribe,
set: (value: EditorSettings) => { set: (value: EditorSettings) => {
localStorage.setItem('editorSettings', JSON.stringify(value)); if (typeof localStorage !== 'undefined') {
localStorage.setItem('editorSettings', JSON.stringify(value));
}
set(value); set(value);
}, },
update: (updater: (value: EditorSettings) => EditorSettings) => { update: (updater: (value: EditorSettings) => EditorSettings) => {
update((current) => { update((current) => {
const newValue = updater(current); const newValue = updater(current);
localStorage.setItem('editorSettings', JSON.stringify(newValue)); if (typeof localStorage !== 'undefined') {
localStorage.setItem('editorSettings', JSON.stringify(newValue));
}
return newValue; return newValue;
}); });
}, },
updatePartial: (partial: Partial<EditorSettings>) => { updatePartial: (partial: Partial<EditorSettings>) => {
update((current) => { update((current) => {
const newValue = { ...current, ...partial }; const newValue = { ...current, ...partial };
localStorage.setItem('editorSettings', JSON.stringify(newValue)); if (typeof localStorage !== 'undefined') {
localStorage.setItem('editorSettings', JSON.stringify(newValue));
}
return newValue; return newValue;
}); });
} }
}; };
} }
export const editorSettings = createEditorSettings();

View File

@ -0,0 +1,154 @@
import type { CsoundProject, ProjectManager } from '../project-system';
interface ProjectEditorState {
currentProject: CsoundProject | null;
content: string;
hasUnsavedChanges: boolean;
isNewUnsavedBuffer: boolean;
}
export class ProjectEditor {
private projectManager: ProjectManager;
private state = $state<ProjectEditorState>({
currentProject: null,
content: '',
hasUnsavedChanges: false,
isNewUnsavedBuffer: false
});
private pendingAction: (() => void) | null = null;
private confirmCallback: ((action: 'save' | 'discard') => void) | null = null;
constructor(projectManager: ProjectManager) {
this.projectManager = projectManager;
}
get currentProject() {
return this.state.currentProject;
}
get content() {
return this.state.content;
}
get hasUnsavedChanges() {
return this.state.hasUnsavedChanges;
}
get isNewUnsavedBuffer() {
return this.state.isNewUnsavedBuffer;
}
get currentProjectId() {
return this.state.currentProject?.id ?? null;
}
setContent(content: string) {
this.state.content = content;
this.state.hasUnsavedChanges = true;
}
loadProject(project: CsoundProject) {
this.state.currentProject = project;
this.state.content = project.content;
this.state.hasUnsavedChanges = false;
this.state.isNewUnsavedBuffer = false;
}
createNew(template: string) {
this.state.currentProject = null;
this.state.content = template;
this.state.hasUnsavedChanges = false;
this.state.isNewUnsavedBuffer = true;
}
async save(): Promise<boolean> {
if (this.state.isNewUnsavedBuffer) {
throw new Error('Cannot save new buffer without title. Use saveAs instead.');
}
if (!this.state.currentProject) {
return false;
}
const result = await this.projectManager.updateProject({
id: this.state.currentProject.id,
content: this.state.content
});
if (result.success) {
this.state.hasUnsavedChanges = false;
return true;
}
return false;
}
async saveAs(title: string, author: string = 'Anonymous'): Promise<boolean> {
const finalTitle = title.trim() || 'Untitled';
const result = await this.projectManager.createProject({
title: finalTitle,
author,
content: this.state.content,
tags: []
});
if (result.success) {
this.state.currentProject = result.data;
this.state.hasUnsavedChanges = false;
this.state.isNewUnsavedBuffer = false;
return true;
}
return false;
}
async updateMetadata(updates: { title?: string; author?: string }): Promise<boolean> {
if (!this.state.currentProject) {
return false;
}
const result = await this.projectManager.updateProject({
id: this.state.currentProject.id,
...updates
});
if (result.success && result.data) {
this.state.currentProject = result.data;
return true;
}
return false;
}
requestSwitch(action: () => void, onConfirm: (action: 'save' | 'discard') => void) {
if (!this.state.hasUnsavedChanges) {
action();
return false;
}
this.pendingAction = action;
this.confirmCallback = onConfirm;
return true;
}
async handleSaveAndSwitch(): Promise<void> {
if (this.state.isNewUnsavedBuffer) {
this.confirmCallback?.('save');
return;
}
await this.save();
this.pendingAction?.();
this.pendingAction = null;
this.confirmCallback = null;
}
handleDiscardAndSwitch(): void {
this.pendingAction?.();
this.pendingAction = null;
this.confirmCallback = null;
}
}

View File

@ -0,0 +1,64 @@
type PanelPosition = 'left' | 'right' | 'bottom';
class UIState {
sidePanelVisible = $state(true);
sidePanelPosition = $state<PanelPosition>('right');
scopePopupVisible = $state(false);
spectrogramPopupVisible = $state(false);
sharePopupVisible = $state(false);
audioPermissionPopupVisible = $state(true);
unsavedChangesDialogVisible = $state(false);
saveAsDialogVisible = $state(false);
shareUrl = $state('');
toggleSidePanel() {
this.sidePanelVisible = !this.sidePanelVisible;
}
cyclePanelPosition() {
if (this.sidePanelPosition === 'right') {
this.sidePanelPosition = 'left';
} else if (this.sidePanelPosition === 'left') {
this.sidePanelPosition = 'bottom';
} else {
this.sidePanelPosition = 'right';
}
}
openScope() {
this.scopePopupVisible = true;
}
openSpectrogram() {
this.spectrogramPopupVisible = true;
}
showShare(url: string) {
this.shareUrl = url;
this.sharePopupVisible = true;
}
closeAudioPermission() {
this.audioPermissionPopupVisible = false;
}
showUnsavedChangesDialog() {
this.unsavedChangesDialogVisible = true;
}
hideUnsavedChangesDialog() {
this.unsavedChangesDialogVisible = false;
}
showSaveAsDialog() {
this.saveAsDialogVisible = true;
}
hideSaveAsDialog() {
this.saveAsDialogVisible = false;
}
}
export const uiState = new UIState();