622 lines
16 KiB
Svelte
622 lines
16 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from "svelte";
|
|
import TopBar from "./lib/components/ui/TopBar.svelte";
|
|
import EditorWithLogs from "./lib/components/editor/EditorWithLogs.svelte";
|
|
import EditorSettings from "./lib/components/editor/EditorSettings.svelte";
|
|
import FileBrowser from "./lib/components/ui/FileBrowser.svelte";
|
|
import SidePanel from "./lib/components/ui/SidePanel.svelte";
|
|
import ResizablePopup from "./lib/components/ui/ResizablePopup.svelte";
|
|
import AudioScope from "./lib/components/audio/AudioScope.svelte";
|
|
import Spectrogram from "./lib/components/audio/Spectrogram.svelte";
|
|
import ConfirmDialog from "./lib/components/ui/ConfirmDialog.svelte";
|
|
import CsoundReference from "./lib/components/reference/CsoundReference.svelte";
|
|
import {
|
|
createCsoundDerivedStores,
|
|
type LogEntry,
|
|
type EvalSource,
|
|
} from "./lib/csound";
|
|
import { type File } from "./lib/project-system";
|
|
import { loadOpenTabs, loadCurrentFileId } from "./lib/project-system/persistence";
|
|
import { createAppContext, setAppContext } from "./lib/contexts/app-context";
|
|
import { themes, applyTheme } from "./lib/themes";
|
|
import {
|
|
Save,
|
|
Share2,
|
|
Activity,
|
|
FileStack,
|
|
PanelLeftOpen,
|
|
PanelRightOpen,
|
|
CircleStop,
|
|
Play,
|
|
} from "lucide-svelte";
|
|
|
|
const appContext = createAppContext();
|
|
setAppContext(appContext);
|
|
|
|
const {
|
|
csound,
|
|
fileManager,
|
|
editorSettings,
|
|
editorState,
|
|
uiState,
|
|
executionContext,
|
|
} = appContext;
|
|
const { logs: csoundLogs, initialized, compiled, running } = createCsoundDerivedStores(csound);
|
|
|
|
let analyserNode = $state<AnalyserNode | null>(null);
|
|
let interpreterLogs = $state<LogEntry[]>([]);
|
|
let editorWithLogsRef: EditorWithLogs;
|
|
|
|
let logsUnsubscribe: (() => void) | undefined;
|
|
|
|
onMount(async () => {
|
|
await fileManager.init();
|
|
await editorState.refreshFileCache();
|
|
|
|
// Try to restore open tabs
|
|
const openTabIds = loadOpenTabs();
|
|
const currentFileId = loadCurrentFileId();
|
|
|
|
if (openTabIds.length > 0) {
|
|
// Restore tabs
|
|
for (const fileId of openTabIds) {
|
|
await editorState.openFile(fileId);
|
|
}
|
|
|
|
// Switch to last current file
|
|
if (currentFileId && openTabIds.includes(currentFileId)) {
|
|
await editorState.switchToFile(currentFileId);
|
|
}
|
|
} else {
|
|
// No tabs to restore, check if we have any files
|
|
const allFilesResult = await fileManager.getAllFiles();
|
|
if (allFilesResult.success && allFilesResult.data.length > 0) {
|
|
// Open the first file
|
|
await editorState.openFile(allFilesResult.data[0].id);
|
|
} else {
|
|
// No files at all, create a welcome file
|
|
const welcomeContent = '<CsoundSynthesizer>\n<CsOptions>\n-odac\n</CsOptions>\n<CsInstruments>\n\nsr = 44100\nksmps = 32\nnchnls = 2\n0dbfs = 1\n\ninstr 1\n kEnv madsr 0.1, 0.2, 0.7, 0.5\n aOut oscil kEnv * 0.3, 440\n outs aOut, aOut\nendin\n\n</CsInstruments>\n<CsScore>\ni 1 0 2\n</CsScore>\n</CsoundSynthesizer>';
|
|
const result = await fileManager.createFile({ title: 'Welcome.orc', content: welcomeContent });
|
|
if (result.success) {
|
|
await editorState.openFile(result.data.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
logsUnsubscribe = csoundLogs.subscribe((logs) => {
|
|
interpreterLogs = logs;
|
|
});
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === '.') {
|
|
e.preventDefault();
|
|
handleStop();
|
|
}
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
e.preventDefault();
|
|
handleSave();
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
|
|
return () => {
|
|
window.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
});
|
|
|
|
onDestroy(async () => {
|
|
logsUnsubscribe?.();
|
|
await csound.destroy();
|
|
});
|
|
|
|
async function handleEnableAudio() {
|
|
try {
|
|
await csound.init();
|
|
uiState.closeAudioPermission();
|
|
} catch (error) {
|
|
console.error("Failed to initialize audio:", error);
|
|
}
|
|
}
|
|
|
|
function handleEditorChange(value: string) {
|
|
editorState.setContent(value);
|
|
}
|
|
|
|
async function handleNewFile() {
|
|
await editorState.createNewFile();
|
|
}
|
|
|
|
async function handleFileOpen(fileId: string) {
|
|
await editorState.openFile(fileId);
|
|
}
|
|
|
|
async function handleFileDelete(fileId: string) {
|
|
await editorState.deleteFile(fileId);
|
|
}
|
|
|
|
async function handleFileRename(fileId: string, newTitle: string) {
|
|
await editorState.renameFile(fileId, newTitle);
|
|
}
|
|
|
|
async function handleTabClick(fileId: string) {
|
|
await editorState.switchToFile(fileId);
|
|
}
|
|
|
|
async function handleTabClose(fileId: string) {
|
|
await editorState.closeFile(fileId);
|
|
}
|
|
|
|
async function handleExecuteFile() {
|
|
try {
|
|
await executionContext.executeFile();
|
|
} catch (error) {
|
|
console.error("Execution error:", error);
|
|
}
|
|
}
|
|
|
|
async function handleExecuteBlock(code: string) {
|
|
try {
|
|
await executionContext.executeBlock(code);
|
|
} catch (error) {
|
|
console.error("Execution error:", error);
|
|
}
|
|
}
|
|
|
|
async function handleExecuteSelection(code: string) {
|
|
try {
|
|
await executionContext.executeSelection(code);
|
|
} catch (error) {
|
|
console.error("Execution error:", error);
|
|
}
|
|
}
|
|
|
|
async function handleSave() {
|
|
await editorState.save();
|
|
}
|
|
|
|
async function handleShare() {
|
|
if (!editorState.currentFileId) return;
|
|
|
|
const result = await fileManager.exportFilesToUrl([editorState.currentFileId]);
|
|
if (result.success) {
|
|
try {
|
|
await navigator.clipboard.writeText(result.data);
|
|
} catch (err) {
|
|
console.error("Failed to copy to clipboard:", err);
|
|
}
|
|
uiState.showShare(result.data);
|
|
}
|
|
}
|
|
|
|
async function handleStop() {
|
|
try {
|
|
await csound.stop();
|
|
} catch (error) {
|
|
console.error("Failed to stop:", error);
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
const theme = themes[$editorSettings.theme];
|
|
if (theme) {
|
|
applyTheme(theme);
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (uiState.scopePopupVisible || uiState.spectrogramPopupVisible) {
|
|
analyserNode = csound.getAnalyserNode();
|
|
|
|
const unsubscribe = csound.onAnalyserNodeCreated((node) => {
|
|
analyserNode = node;
|
|
});
|
|
|
|
return unsubscribe;
|
|
} else {
|
|
analyserNode = null;
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
executionContext.setContentProvider(() => editorState.content);
|
|
});
|
|
|
|
const panelTabs = [
|
|
{
|
|
id: "editor",
|
|
label: "Editor",
|
|
content: editorTabContent,
|
|
},
|
|
{
|
|
id: "files",
|
|
label: "Files",
|
|
content: filesTabContent,
|
|
},
|
|
{
|
|
id: "reference",
|
|
label: "Reference",
|
|
content: referenceTabContent,
|
|
},
|
|
];
|
|
</script>
|
|
|
|
{#snippet editorTabContent()}
|
|
<EditorSettings />
|
|
{/snippet}
|
|
|
|
{#snippet filesTabContent()}
|
|
<FileBrowser
|
|
{fileManager}
|
|
onFileOpen={handleFileOpen}
|
|
onFileDelete={handleFileDelete}
|
|
onFileRename={handleFileRename}
|
|
onNewFile={handleNewFile}
|
|
/>
|
|
{/snippet}
|
|
|
|
{#snippet referenceTabContent()}
|
|
<CsoundReference />
|
|
{/snippet}
|
|
|
|
<div class="app-container">
|
|
<TopBar title="OldBoy">
|
|
{#snippet leftActions()}
|
|
<button
|
|
onclick={handleStop}
|
|
class="icon-button stop-button"
|
|
disabled={!$running}
|
|
title="Stop audio (Ctrl+.)"
|
|
>
|
|
<CircleStop size={18} />
|
|
</button>
|
|
<button
|
|
class="icon-button"
|
|
onclick={handleNewFile}
|
|
title="New file"
|
|
>
|
|
<FileStack size={18} />
|
|
</button>
|
|
<button
|
|
class="icon-button"
|
|
onclick={handleSave}
|
|
disabled={!editorState.hasUnsavedChanges}
|
|
title="Save (Ctrl+S){editorState.hasUnsavedChanges
|
|
? ' - unsaved changes'
|
|
: ''}"
|
|
class:has-changes={editorState.hasUnsavedChanges}
|
|
>
|
|
<Save size={18} />
|
|
</button>
|
|
<button
|
|
onclick={handleShare}
|
|
class="icon-button"
|
|
disabled={!editorState.currentFileId}
|
|
title="Share current file"
|
|
>
|
|
<Share2 size={18} />
|
|
</button>
|
|
{/snippet}
|
|
<button
|
|
onclick={handleExecuteFile}
|
|
class="icon-button evaluate-button"
|
|
disabled={!$initialized}
|
|
class:is-running={$running}
|
|
title="Run File (Cmd-R)"
|
|
>
|
|
<Play size={18} />
|
|
</button>
|
|
<button
|
|
onclick={() => uiState.toggleScope()}
|
|
class="icon-button"
|
|
title="Toggle audio scope"
|
|
>
|
|
<Activity size={18} />
|
|
</button>
|
|
<button
|
|
onclick={() => uiState.toggleSpectrogram()}
|
|
class="icon-button"
|
|
title="Toggle spectrogram"
|
|
>
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
<path d="M3 9h18" />
|
|
<path d="M3 15h18" />
|
|
<path d="M9 3v18" />
|
|
<path d="M15 3v18" />
|
|
</svg>
|
|
</button>
|
|
{#if !uiState.sidePanelVisible}
|
|
<button
|
|
class="icon-button"
|
|
onclick={() => uiState.toggleSidePanel()}
|
|
title="Open side panel"
|
|
>
|
|
{#if uiState.sidePanelPosition === "left"}
|
|
<PanelLeftOpen size={18} />
|
|
{:else}
|
|
<PanelRightOpen size={18} />
|
|
{/if}
|
|
</button>
|
|
{/if}
|
|
</TopBar>
|
|
|
|
<div class="main-content">
|
|
{#if uiState.sidePanelPosition === "left"}
|
|
<SidePanel
|
|
bind:visible={uiState.sidePanelVisible}
|
|
bind:position={uiState.sidePanelPosition}
|
|
tabs={panelTabs}
|
|
onClose={() => uiState.toggleSidePanel()}
|
|
onCyclePosition={() => uiState.cyclePanelPosition()}
|
|
/>
|
|
{/if}
|
|
|
|
<div class="editor-area">
|
|
<EditorWithLogs
|
|
bind:this={editorWithLogsRef}
|
|
value={editorState.content}
|
|
fileName={editorState.currentFile?.title}
|
|
onChange={handleEditorChange}
|
|
onExecuteFile={handleExecuteFile}
|
|
onExecuteBlock={handleExecuteBlock}
|
|
onExecuteSelection={handleExecuteSelection}
|
|
logs={interpreterLogs}
|
|
{editorSettings}
|
|
openFiles={editorState.openFiles}
|
|
currentFileId={editorState.currentFileId}
|
|
unsavedFileIds={new Set(Array.from(editorState.unsavedChanges.keys()))}
|
|
onTabClick={handleTabClick}
|
|
onTabClose={handleTabClose}
|
|
/>
|
|
</div>
|
|
|
|
{#if uiState.sidePanelPosition === "right"}
|
|
<SidePanel
|
|
bind:visible={uiState.sidePanelVisible}
|
|
bind:position={uiState.sidePanelPosition}
|
|
tabs={panelTabs}
|
|
onClose={() => uiState.toggleSidePanel()}
|
|
onCyclePosition={() => uiState.cyclePanelPosition()}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
|
|
<ResizablePopup
|
|
bind:visible={uiState.sharePopupVisible}
|
|
title="Share Project"
|
|
x={typeof window !== "undefined" ? window.innerWidth / 2 - 300 : 300}
|
|
y={typeof window !== "undefined" ? window.innerHeight / 2 - 100 : 200}
|
|
width={600}
|
|
height={200}
|
|
minWidth={400}
|
|
minHeight={150}
|
|
>
|
|
{#snippet children()}
|
|
<div class="share-content">
|
|
<p>Link copied to clipboard!</p>
|
|
<div class="share-url-container">
|
|
<input
|
|
type="text"
|
|
readonly
|
|
value={uiState.shareUrl}
|
|
class="share-url-input"
|
|
onclick={(e) => e.currentTarget.select()}
|
|
/>
|
|
</div>
|
|
<p class="share-instructions">
|
|
Anyone with this link can import the project.
|
|
</p>
|
|
</div>
|
|
{/snippet}
|
|
</ResizablePopup>
|
|
|
|
<ResizablePopup
|
|
visible={uiState.audioPermissionPopupVisible}
|
|
title="Audio Permission Required"
|
|
x={typeof window !== "undefined" ? window.innerWidth / 2 - 250 : 250}
|
|
y={typeof window !== "undefined" ? window.innerHeight / 2 - 175 : 200}
|
|
width={500}
|
|
height={270}
|
|
minWidth={400}
|
|
minHeight={300}
|
|
closable={false}
|
|
>
|
|
{#snippet children()}
|
|
<div class="audio-permission-content">
|
|
<h3>Enable Audio Context</h3>
|
|
<p>OldBoy needs permission to use audio playback.</p>
|
|
<button class="enable-audio-button" onclick={handleEnableAudio}>
|
|
Enable Audio
|
|
</button>
|
|
</div>
|
|
{/snippet}
|
|
</ResizablePopup>
|
|
|
|
<ResizablePopup
|
|
bind:visible={uiState.scopePopupVisible}
|
|
title="Audio Scope"
|
|
x={typeof window !== "undefined" ? window.innerWidth / 2 - 400 : 100}
|
|
y={typeof window !== "undefined" ? window.innerHeight / 2 - 300 : 100}
|
|
width={800}
|
|
height={600}
|
|
minWidth={400}
|
|
minHeight={300}
|
|
noPadding={true}
|
|
>
|
|
{#snippet children()}
|
|
<AudioScope {analyserNode} />
|
|
{/snippet}
|
|
</ResizablePopup>
|
|
|
|
<ResizablePopup
|
|
bind:visible={uiState.spectrogramPopupVisible}
|
|
title="Spectrogram"
|
|
x={typeof window !== "undefined" ? window.innerWidth / 2 - 400 : 150}
|
|
y={typeof window !== "undefined" ? window.innerHeight / 2 - 300 : 150}
|
|
width={800}
|
|
height={600}
|
|
minWidth={400}
|
|
minHeight={300}
|
|
noPadding={true}
|
|
>
|
|
{#snippet children()}
|
|
<Spectrogram {analyserNode} />
|
|
{/snippet}
|
|
</ResizablePopup>
|
|
|
|
</div>
|
|
|
|
<style>
|
|
.app-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
width: 100vw;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.main-content {
|
|
display: flex;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.editor-area {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.icon-button {
|
|
padding: var(--space-sm);
|
|
background-color: var(--border-color);
|
|
color: var(--text-color);
|
|
border: 1px solid var(--border-color);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: background-color var(--transition-base);
|
|
}
|
|
|
|
.icon-button:hover:not(:disabled) {
|
|
background-color: var(--button-hover);
|
|
border-color: var(--accent-color);
|
|
}
|
|
|
|
.icon-button:disabled {
|
|
opacity: 0.3;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.icon-button.has-changes {
|
|
color: var(--accent-color);
|
|
}
|
|
|
|
.icon-button.stop-button:not(:disabled) {
|
|
color: var(--danger-color);
|
|
}
|
|
|
|
.icon-button.stop-button:hover:not(:disabled) {
|
|
color: var(--danger-hover);
|
|
border-color: var(--danger-hover);
|
|
}
|
|
|
|
.icon-button.evaluate-button.is-running {
|
|
color: var(--accent-color);
|
|
}
|
|
|
|
.icon-button.evaluate-button.is-running:hover:not(:disabled) {
|
|
border-color: var(--accent-color);
|
|
}
|
|
|
|
h3 {
|
|
margin-top: 0;
|
|
color: var(--text-color);
|
|
}
|
|
|
|
p {
|
|
color: var(--text-secondary);
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.share-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-lg);
|
|
}
|
|
|
|
.share-url-container {
|
|
width: 100%;
|
|
}
|
|
|
|
.share-url-input {
|
|
width: 100%;
|
|
padding: var(--space-md);
|
|
background-color: var(--surface-color);
|
|
border: 1px solid var(--border-color);
|
|
color: var(--text-color);
|
|
font-size: var(--font-base);
|
|
font-family: monospace;
|
|
outline: none;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.share-url-input:focus {
|
|
border-color: var(--accent-color);
|
|
}
|
|
|
|
.share-instructions {
|
|
font-size: var(--font-base);
|
|
color: var(--text-secondary);
|
|
margin: 0;
|
|
}
|
|
|
|
.audio-permission-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-lg);
|
|
align-items: center;
|
|
text-align: center;
|
|
padding: var(--space-lg);
|
|
}
|
|
|
|
.audio-permission-content h3 {
|
|
margin: 0;
|
|
font-size: 1.25rem;
|
|
}
|
|
|
|
.audio-permission-content p {
|
|
margin: 0;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.enable-audio-button {
|
|
margin-top: var(--space-lg);
|
|
padding: var(--space-md) var(--space-2xl);
|
|
background-color: var(--accent-color);
|
|
color: white;
|
|
border: none;
|
|
font-size: var(--font-lg);
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background-color var(--transition-base);
|
|
}
|
|
|
|
.enable-audio-button:hover {
|
|
background-color: var(--accent-hover);
|
|
}
|
|
|
|
.enable-audio-button:active {
|
|
background-color: var(--accent-color);
|
|
}
|
|
</style>
|