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

12
q Normal file
View File

@ -0,0 +1,12 @@
We are currently working on top bar menu items for this web editor. We are working on the "Share" functionality.
When clicking the share button, here is what should happen:
- the code for the currently opened file is getting compressed and generates an URL link that you can send to other people.
- when clicking on this link, the web editor opens with the same file code loaded in it. It acts like an import. It will prompt you to rename the file eventually to avoid name conflicts or accidental overwriting.
- when clicking "Share", it opens up a popup that shows you the generated link with a "Copy to clipboard" button next to it.
Ultrathink and do.
Currently, clicking on the Share button does absolutely nothing. There is no feedback at all, and no errors either in the console of whatever.
Explore.

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);
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);
}
}