Working on menus
This commit is contained in:
231
src/App.svelte
231
src/App.svelte
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
119
src/lib/components/ui/AudioVolumeControl.svelte
Normal file
119
src/lib/components/ui/AudioVolumeControl.svelte
Normal 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>
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
9
src/lib/components/ui/MenuSeparator.svelte
Normal file
9
src/lib/components/ui/MenuSeparator.svelte
Normal file
@ -0,0 +1,9 @@
|
||||
<div class="menu-separator"></div>
|
||||
|
||||
<style>
|
||||
.menu-separator {
|
||||
height: 1px;
|
||||
background-color: var(--border-color);
|
||||
margin: 4px 0;
|
||||
}
|
||||
</style>
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user