Working on menus

This commit is contained in:
2025-11-14 14:45:22 +01:00
parent 93205f3f5b
commit 22e9457779
11 changed files with 459 additions and 15 deletions

View File

@ -3,7 +3,9 @@
import MenuBar from "./lib/components/ui/MenuBar.svelte";
import MenuItem from "./lib/components/ui/MenuItem.svelte";
import MenuAction from "./lib/components/ui/MenuAction.svelte";
import MenuSeparator from "./lib/components/ui/MenuSeparator.svelte";
import AboutDialog from "./lib/components/ui/AboutDialog.svelte";
import AudioVolumeControl from "./lib/components/ui/AudioVolumeControl.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";
@ -26,7 +28,11 @@
Save,
Share2,
Activity,
FileStack,
FilePlus,
Copy,
Download,
Archive,
Upload,
PanelLeftOpen,
PanelRightOpen,
CircleStop,
@ -49,6 +55,7 @@
let analyserNode = $state<AnalyserNode | null>(null);
let interpreterLogs = $state<LogEntry[]>([]);
let editorWithLogsRef: EditorWithLogs;
let fileInputRef: HTMLInputElement;
let logsUnsubscribe: (() => void) | undefined;
@ -56,6 +63,49 @@
await fileManager.init();
await editorState.refreshFileCache();
// Check for shared file in URL
const urlParams = new URLSearchParams(window.location.search);
const sharedData = urlParams.get('d');
const version = urlParams.get('v');
if (sharedData && version) {
try {
const result = await fileManager.importFilesFromUrl(window.location.href);
if (result.success && result.data.length > 0) {
// Open the imported file
await editorState.openFile(result.data[0].id);
// Clean the URL for better UX
window.history.replaceState({}, '', window.location.pathname);
// Setup keyboard shortcuts and exit
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);
};
}
} catch (error) {
console.error('Failed to import shared file:', error);
// Fall through to normal startup
}
}
// Try to restore open tabs
const openTabIds = loadOpenTabs();
const currentFileId = loadCurrentFileId();
@ -100,6 +150,11 @@
e.preventDefault();
handleSave();
}
if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
e.preventDefault();
handleExecuteFile();
}
};
window.addEventListener('keydown', handleKeyDown);
@ -175,6 +230,18 @@
}
}
function triggerEvalBlock() {
editorWithLogsRef?.executeBlock();
}
function triggerEvalSelection() {
editorWithLogsRef?.executeSelection();
}
function triggerEvalFile() {
editorWithLogsRef?.executeFile();
}
async function handleSave() {
await editorState.save();
}
@ -190,6 +257,8 @@
console.error("Failed to copy to clipboard:", err);
}
uiState.showShare(result.data);
} else {
console.error("Failed to generate share URL:", result.error);
}
}
@ -201,6 +270,100 @@
}
}
async function handleDuplicateFile() {
if (!editorState.currentFile) return;
const currentFile = editorState.currentFile;
const newTitle = `${currentFile.title.replace(/\.orc$/, '')} copy.orc`;
const result = await fileManager.createFile({
title: newTitle,
content: currentFile.content
});
if (result.success) {
await editorState.openFile(result.data.id);
}
}
async function handleExportFile() {
if (!editorState.currentFile) return;
const file = editorState.currentFile;
const blob = new Blob([file.content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file.title;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async function handleExportAllFiles() {
const allFilesResult = await fileManager.getAllFiles();
if (!allFilesResult.success || allFilesResult.data.length === 0) return;
const JSZip = (await import('https://cdn.jsdelivr.net/npm/jszip@3.10.1/+esm')).default;
const zip = new JSZip();
for (const file of allFilesResult.data) {
zip.file(file.title, file.content);
}
const blob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'oldboy-files.zip';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function handleImportFiles() {
fileInputRef.click();
}
async function onFileInputChange(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
const supportedExtensions = ['.orc', '.csd', '.sco'];
let firstImportedId: string | null = null;
for (const file of Array.from(input.files)) {
const extension = file.name.substring(file.name.lastIndexOf('.'));
if (!supportedExtensions.includes(extension.toLowerCase())) {
console.warn(`Skipping unsupported file: ${file.name}`);
continue;
}
try {
const content = await file.text();
const result = await fileManager.createFile({
title: file.name,
content: content
});
if (result.success && !firstImportedId) {
firstImportedId = result.data.id;
}
} catch (error) {
console.error(`Failed to import ${file.name}:`, error);
}
}
if (firstImportedId) {
await editorState.openFile(firstImportedId);
}
input.value = '';
}
$effect(() => {
const theme = themes[$editorSettings.theme];
if (theme) {
@ -265,12 +428,22 @@
<div class="app-container">
<MenuBar onLogoClick={() => uiState.showAboutDialog()}>
{#snippet rightControls()}
<AudioVolumeControl {csound} />
{/snippet}
<MenuItem label="File">
<MenuAction
label="New File"
icon={FileStack}
icon={FilePlus}
onclick={handleNewFile}
/>
<MenuAction
label="Duplicate"
icon={Copy}
disabled={!editorState.currentFileId}
onclick={handleDuplicateFile}
/>
<MenuAction
label="Save"
icon={Save}
@ -278,6 +451,24 @@
disabled={!editorState.hasUnsavedChanges}
onclick={handleSave}
/>
<MenuSeparator />
<MenuAction
label="Import File(s)"
icon={Upload}
onclick={handleImportFiles}
/>
<MenuAction
label="Export File"
icon={Download}
disabled={!editorState.currentFileId}
onclick={handleExportFile}
/>
<MenuAction
label="Export All Files"
icon={Archive}
onclick={handleExportAllFiles}
/>
<MenuSeparator />
<MenuAction
label="Share..."
icon={Share2}
@ -301,6 +492,25 @@
disabled={!$running}
onclick={handleStop}
/>
<MenuSeparator />
<MenuAction
label="Eval Block"
shortcut={navigator.platform.includes("Mac") ? "Cmd+E" : "Ctrl+E"}
disabled={!editorState.currentFileId}
onclick={triggerEvalBlock}
/>
<MenuAction
label="Eval Selection"
shortcut="Alt+E"
disabled={!editorState.currentFileId}
onclick={triggerEvalSelection}
/>
<MenuAction
label="Eval File"
shortcut={navigator.platform.includes("Mac") ? "Cmd+Shift+E" : "Ctrl+Shift+E"}
disabled={!editorState.currentFileId}
onclick={triggerEvalFile}
/>
</MenuItem>
<MenuItem label="Visuals">
@ -379,10 +589,10 @@
<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}
x={typeof window !== "undefined" ? window.innerWidth / 2 - 350 : 300}
y={typeof window !== "undefined" ? window.innerHeight / 2 - 125 : 200}
width={700}
height={250}
minWidth={400}
minHeight={150}
>
@ -464,6 +674,15 @@
onClose={() => uiState.hideAboutDialog()}
/>
<input
type="file"
bind:this={fileInputRef}
onchange={onFileInputChange}
accept=".orc,.csd,.sco"
multiple
style="display: none;"
/>
</div>
<style>

View File

@ -157,6 +157,7 @@
height: 12px;
background: var(--accent-color);
cursor: pointer;
border-radius: 0;
}
input[type="range"]::-moz-range-thumb {
@ -165,6 +166,7 @@
background: var(--accent-color);
cursor: pointer;
border: none;
border-radius: 0;
}
input[type="range"]:hover {

View File

@ -46,6 +46,18 @@
editorRef?.handleExecute();
}
export function executeFile() {
editorRef?.handleExecuteFile();
}
export function executeBlock() {
editorRef?.handleExecuteBlock();
}
export function executeSelection() {
editorRef?.handleExecuteSelection();
}
let editorHeight = $state(70);
let isResizing = $state(false);
let startY = $state(0);

View File

@ -0,0 +1,119 @@
<script lang="ts">
import { Volume2 } from 'lucide-svelte';
import type { CsoundStore } from '../../csound';
interface Props {
csound: CsoundStore;
}
let { csound }: Props = $props();
let volume = $state(100);
let previousVolume = $state(100);
function handleVolumeChange(event: Event) {
const target = event.target as HTMLInputElement;
volume = parseInt(target.value, 10);
csound.setVolume(volume / 100);
if (volume > 0) {
previousVolume = volume;
}
}
function toggleMute() {
if (volume > 0) {
previousVolume = volume;
volume = 0;
csound.setVolume(0);
} else {
volume = previousVolume;
csound.setVolume(previousVolume / 100);
}
}
</script>
<div class="volume-control">
<button class="mute-button" onclick={toggleMute} type="button">
<Volume2 size={16} />
</button>
<input
type="range"
min="0"
max="100"
step="1"
value={volume}
oninput={handleVolumeChange}
class="volume-slider"
/>
<span class="volume-percentage">{volume}%</span>
</div>
<style>
.volume-control {
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
height: 100%;
color: var(--text-color);
}
.mute-button {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--text-color);
cursor: pointer;
padding: 4px;
transition: color var(--transition-fast);
}
.mute-button:hover {
color: var(--accent-color);
}
.volume-slider {
width: 100px;
height: 4px;
background: var(--border-color);
outline: none;
-webkit-appearance: none;
appearance: none;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
background: var(--accent-color);
cursor: pointer;
border-radius: 0;
}
.volume-slider::-moz-range-thumb {
width: 12px;
height: 12px;
background: var(--accent-color);
cursor: pointer;
border: none;
border-radius: 0;
}
.volume-slider:hover::-webkit-slider-thumb {
background: var(--accent-hover);
}
.volume-slider:hover::-moz-range-thumb {
background: var(--accent-hover);
}
.volume-percentage {
font-size: 12px;
min-width: 36px;
text-align: right;
user-select: none;
}
</style>

View File

@ -4,10 +4,11 @@
interface Props {
children: Snippet;
rightControls?: Snippet;
onLogoClick?: () => void;
}
let { children, onLogoClick }: Props = $props();
let { children, rightControls, onLogoClick }: Props = $props();
</script>
<div class="menu-bar">
@ -18,6 +19,11 @@
</button>
{@render children()}
</div>
{#if rightControls}
<div class="menu-bar-right">
{@render rightControls()}
</div>
{/if}
</div>
<style>
@ -38,6 +44,12 @@
height: 100%;
}
.menu-bar-right {
display: flex;
align-items: center;
height: 100%;
}
.logo-button {
display: flex;
align-items: center;
@ -51,7 +63,6 @@
cursor: pointer;
transition: background-color var(--transition-fast);
height: 100%;
border-right: 1px solid var(--border-color);
}
.logo-button:hover {

View File

@ -32,7 +32,10 @@
</button>
<MenuDropdown {isOpen} onClose={closeMenu} anchorElement={buttonElement}>
<div onclick={closeMenu}>
<div onclick={(e) => {
// Let the MenuAction handle the click first, then close the menu
setTimeout(closeMenu, 0);
}}>
{@render children()}
</div>
</MenuDropdown>

View File

@ -0,0 +1,9 @@
<div class="menu-separator"></div>
<style>
.menu-separator {
height: 1px;
background-color: var(--border-color);
margin: 4px 0;
}
</style>

View File

@ -32,6 +32,7 @@ export class CsoundEngine {
private scopeNode: AnalyserNode | null = null;
private audioNode: AudioNode | null = null;
private audioContext: AudioContext | null = null;
private gainNode: GainNode | null = null;
private useCsound7: boolean;
constructor(options: CsoundEngineOptions = {}) {
@ -139,6 +140,7 @@ export class CsoundEngine {
this.csound.on('onAudioNodeCreated', (node: AudioNode) => {
this.audioNode = node;
this.audioContext = node.context as AudioContext;
this.setupGainNode();
this.log('Audio node created and captured');
});
@ -510,6 +512,26 @@ export class CsoundEngine {
return this.audioContext;
}
private setupGainNode(): void {
if (!this.audioNode || !this.audioContext || this.gainNode) {
return;
}
try {
this.gainNode = this.audioContext.createGain();
this.gainNode.gain.value = 1.0;
this.audioNode.disconnect();
this.audioNode.connect(this.gainNode);
this.gainNode.connect(this.audioContext.destination);
this.log('Gain node created and connected');
} catch (error) {
console.error('Failed to setup gain node:', error);
this.log('Error setting up gain node: ' + error);
}
}
private setupAnalyser(): void {
if (!this.audioNode || !this.audioContext) {
this.log('Warning: Audio node not available yet');
@ -517,13 +539,19 @@ export class CsoundEngine {
}
try {
if (!this.gainNode) {
this.setupGainNode();
}
this.scopeNode = this.audioContext.createAnalyser();
this.scopeNode.fftSize = 2048;
this.scopeNode.smoothingTimeConstant = 0.3;
this.audioNode.disconnect();
this.audioNode.connect(this.scopeNode);
this.scopeNode.connect(this.audioContext.destination);
if (this.gainNode) {
this.gainNode.disconnect();
this.gainNode.connect(this.scopeNode);
this.scopeNode.connect(this.audioContext.destination);
}
this.log('Analyser node created and connected');
this.options.onAnalyserNodeCreated?.(this.scopeNode);
@ -537,6 +565,20 @@ export class CsoundEngine {
return this.scopeNode;
}
setVolume(value: number): void {
if (!this.gainNode) {
return;
}
this.gainNode.gain.value = Math.max(0, Math.min(1, value));
}
getVolume(): number {
if (!this.gainNode) {
return 1.0;
}
return this.gainNode.gain.value;
}
private log(message: string): void {
this.options.onMessage?.(message);
}

View File

@ -42,6 +42,8 @@ export interface CsoundStore {
getAudioContext: () => AudioContext | null;
getAnalyserNode: () => AnalyserNode | null;
onAnalyserNodeCreated: (callback: (node: AnalyserNode) => void) => () => void;
setVolume: (value: number) => void;
getVolume: () => number;
destroy: () => Promise<void>;
}
@ -357,6 +359,19 @@ export function createCsoundStore(options: CsoundStoreOptions = {}): CsoundStore
return () => analyserNodeListeners.delete(callback);
},
setVolume(value: number): void {
if (engine) {
engine.setVolume(value);
}
},
getVolume(): number {
if (engine) {
return engine.getVolume();
}
return 1.0;
},
async destroy() {
if (engine) {
await engine.destroy();

View File

@ -251,9 +251,9 @@ export class FileManager {
const files: File[] = [];
for (const id of fileIds) {
const file = await this.db.get(id);
if (file) {
files.push(file);
const result = await this.getFile(id);
if (result.success) {
files.push(result.data);
}
}