diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index 6014b9b0a..072752420 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -293,6 +293,9 @@ export class StrudelMirror { // Handle global evaluation requests (e.g., from Vim :w) this.onEvaluateRequest = (e) => { try { + if (e.detail.view !== this.editor) { + return; // ignore events from other editors + } // Evaluate current editor on repl-evaluate logger('[repl] evaluate via event'); this.evaluate(); @@ -307,6 +310,9 @@ export class StrudelMirror { // Toggle comments requested from Vim (gc) this.onToggleComment = (e) => { try { + if (e.detail.view !== this.editor) { + return; // ignore events from other editors + } // Honor selections; toggleLineComment handles both selections and // single line toggleLineComment(this.editor); @@ -347,6 +353,9 @@ export class StrudelMirror { // Listen for global stop requests (e.g., from Vim :q) onStopRequest = (e) => { try { + if (e.detail.view !== this.editor) { + return; // ignore events from other editors + } this.stop(); e?.cancelable && e.preventDefault?.(); } catch (err) { diff --git a/packages/codemirror/index.mjs b/packages/codemirror/index.mjs index 2d5b3de21..31de6accb 100644 --- a/packages/codemirror/index.mjs +++ b/packages/codemirror/index.mjs @@ -5,3 +5,4 @@ export * from './slider.mjs'; export * from './themes.mjs'; export * from './widget.mjs'; export { Vim } from './keybindings.mjs'; +export * from './basicSetup.mjs'; diff --git a/packages/codemirror/keybindings.mjs b/packages/codemirror/keybindings.mjs index b8d9a6167..16c7ec99e 100644 --- a/packages/codemirror/keybindings.mjs +++ b/packages/codemirror/keybindings.mjs @@ -96,7 +96,7 @@ try { // internal actions and works with current selections/visual mode. try { Vim.defineAction('strudelToggleComment', (cm) => { - const view = cm?.view || cm; + const view = cm.cm6; try { const ev = new CustomEvent('repl-toggle-comment', { detail: { source: 'vim', view }, cancelable: true }); document.dispatchEvent(ev); @@ -112,15 +112,16 @@ try { // :q to pause/stop Vim.defineEx('quit', 'q', (cm) => { - const view = cm?.view || cm; + const view = cm.cm6; + logger('[vim] :q — stopping repl'); replStop(view); }); // :w to evaluate Vim.defineEx('write', 'w', (cm) => { - const view = cm?.view || cm; // CM6 Vim passes either an object with view or the view itself + const view = cm.cm6; try { - view?.focus?.(); + view.focus?.(); // Let the app know this came from Vim :w try { logger('[vim] :w — evaluating code'); diff --git a/packages/codemirror/package.json b/packages/codemirror/package.json index 268be3061..d2432ef83 100644 --- a/packages/codemirror/package.json +++ b/packages/codemirror/package.json @@ -32,13 +32,13 @@ }, "homepage": "https://codeberg.org/uzu/strudel#readme", "dependencies": { - "@codemirror/autocomplete": "^6.18.4", - "@codemirror/commands": "^6.8.0", - "@codemirror/lang-javascript": "^6.2.2", - "@codemirror/language": "^6.10.8", - "@codemirror/search": "^6.5.8", - "@codemirror/state": "^6.5.1", - "@codemirror/view": "^6.36.2", + "@codemirror/autocomplete": "catalog:", + "@codemirror/commands": "catalog:", + "@codemirror/lang-javascript": "catalog:", + "@codemirror/language": "catalog:", + "@codemirror/search": "catalog:", + "@codemirror/state": "catalog:", + "@codemirror/view": "catalog:", "@lezer/highlight": "^1.2.1", "@nanostores/persistent": "^0.10.2", "@replit/codemirror-emacs": "^6.1.0", diff --git a/packages/transpiler/index.mjs b/packages/transpiler/index.mjs index 484c3e70c..e8f714dd9 100644 --- a/packages/transpiler/index.mjs +++ b/packages/transpiler/index.mjs @@ -2,4 +2,4 @@ import { evaluate as _evaluate } from '@strudel/core'; import { transpiler } from './transpiler.mjs'; export * from './transpiler.mjs'; -export const evaluate = (code) => _evaluate(code, transpiler); +export const evaluate = (code, transpilerOptions) => _evaluate(code, transpiler, transpilerOptions); diff --git a/packages/transpiler/transpiler.mjs b/packages/transpiler/transpiler.mjs index 210d40852..1f783794f 100644 --- a/packages/transpiler/transpiler.mjs +++ b/packages/transpiler/transpiler.mjs @@ -256,12 +256,7 @@ export function transpiler(input, options = {}) { body.push(silenceExpression); } else if (!body?.[body.length - 1]?.expression) { // Last statement is not an expression (e.g., VariableDeclaration, FunctionDeclaration) - if (blockBased) { - // For block-based eval, add silence as the return value when block ends with declaration - body.push(silenceExpression); - } else { - throw new Error('unexpected ast format without body expression'); - } + body.push(silenceExpression); } // For block-based eval, add scope assignments before the return statement diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3cc84b0e3..1d2ff5d5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,30 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +catalogs: + default: + '@codemirror/autocomplete': + specifier: ^6.18.4 + version: 6.18.4 + '@codemirror/commands': + specifier: ^6.8.0 + version: 6.8.0 + '@codemirror/lang-javascript': + specifier: ^6.2.2 + version: 6.2.2 + '@codemirror/language': + specifier: ^6.10.8 + version: 6.10.8 + '@codemirror/search': + specifier: ^6.5.8 + version: 6.5.8 + '@codemirror/state': + specifier: ^6.5.1 + version: 6.5.1 + '@codemirror/view': + specifier: ^6.36.2 + version: 6.36.2 + importers: .: @@ -171,25 +195,25 @@ importers: packages/codemirror: dependencies: '@codemirror/autocomplete': - specifier: ^6.18.4 + specifier: 'catalog:' version: 6.18.4 '@codemirror/commands': - specifier: ^6.8.0 + specifier: 'catalog:' version: 6.8.0 '@codemirror/lang-javascript': - specifier: ^6.2.2 + specifier: 'catalog:' version: 6.2.2 '@codemirror/language': - specifier: ^6.10.8 + specifier: 'catalog:' version: 6.10.8 '@codemirror/search': - specifier: ^6.5.8 + specifier: 'catalog:' version: 6.5.8 '@codemirror/state': - specifier: ^6.5.1 + specifier: 'catalog:' version: 6.5.1 '@codemirror/view': - specifier: ^6.36.2 + specifier: 'catalog:' version: 6.36.2 '@lezer/highlight': specifier: ^1.2.1 @@ -705,6 +729,21 @@ importers: '@astrojs/tailwind': specifier: ^5.1.5 version: 5.1.5(astro@5.1.9(@types/node@22.10.10)(jiti@2.4.2)(lightningcss@1.29.1)(rollup@2.79.2)(terser@5.37.0)(typescript@5.7.3)(yaml@2.7.0))(tailwindcss@3.4.17) + '@codemirror/commands': + specifier: 'catalog:' + version: 6.8.0 + '@codemirror/lang-javascript': + specifier: 'catalog:' + version: 6.2.2 + '@codemirror/language': + specifier: 'catalog:' + version: 6.10.8 + '@codemirror/state': + specifier: 'catalog:' + version: 6.5.1 + '@codemirror/view': + specifier: 'catalog:' + version: 6.36.2 '@docsearch/css': specifier: ^3.8.3 version: 3.8.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a4fca9ff7..c8be1ac16 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,3 +10,12 @@ onlyBuiltDependencies: - sharp - tree-sitter - tree-sitter-haskell + +catalog: + "@codemirror/commands": "^6.8.0" + "@codemirror/language": "^6.10.8" + "@codemirror/lang-javascript": "^6.2.2" + "@codemirror/state": "^6.5.1" + "@codemirror/view": "^6.36.2" + "@codemirror/autocomplete": "^6.18.4" + "@codemirror/search": "^6.5.8" diff --git a/website/package.json b/website/package.json index 6f5539dd6..eb362369b 100644 --- a/website/package.json +++ b/website/package.json @@ -17,6 +17,11 @@ "@astrojs/react": "^4.1.6", "@astrojs/rss": "^4.0.11", "@astrojs/tailwind": "^5.1.5", + "@codemirror/commands": "catalog:", + "@codemirror/language": "catalog:", + "@codemirror/lang-javascript": "catalog:", + "@codemirror/state": "catalog:", + "@codemirror/view": "catalog:", "@docsearch/css": "^3.8.3", "@docsearch/react": "^3.8.3", "@headlessui/react": "^2.2.0", diff --git a/website/src/repl/Repl.css b/website/src/repl/Repl.css index a00600ae6..3907ae248 100644 --- a/website/src/repl/Repl.css +++ b/website/src/repl/Repl.css @@ -10,50 +10,50 @@ opacity: 0.5; } -#code .cm-scroller { +.code-container .cm-scroller { padding-top: 10px !important; height: 100%; font-family: inherit; } -#code .cm-content { +.code-container .cm-content { padding-bottom: 50vh; } -#code .cm-line > * { +.code-container .cm-line > * { background: var(--lineBackground); } -#code .cm-editor { +.code-container .cm-editor { background-color: transparent !important; height: 100%; z-index: 11; } -#code .cm-theme { +.code-container .cm-theme { width: 100%; height: 100%; } -#code .cm-theme-light { +.code-container .cm-theme-light { width: 100%; } -#code .cm-cursorLayer { +.code-container .cm-cursorLayer { animation-name: inherit !important; } -#code .cm-cursor { +.code-container .cm-cursor { border-left: 2px solid currentcolor !important; } -#code .cm-foldGutter { +.code-container .cm-foldGutter { display: none !important; } -#code .cm-focused { +.code-container .cm-focused { outline: none; } -#code .cm-matchingBracket { +.code-container .cm-matchingBracket { text-decoration: underline 0.18rem; text-underline-offset: 0.22rem; } diff --git a/website/src/repl/components/Code.jsx b/website/src/repl/components/Code.jsx index e8cccb86f..161db1020 100644 --- a/website/src/repl/components/Code.jsx +++ b/website/src/repl/components/Code.jsx @@ -8,8 +8,7 @@ export function Code(Props) { return (
{ containerRef.current = el; if (!editorRef.current) { diff --git a/website/src/repl/components/button/action-button.jsx b/website/src/repl/components/button/action-button.jsx index 245cf961e..52ae2f84c 100644 --- a/website/src/repl/components/button/action-button.jsx +++ b/website/src/repl/components/button/action-button.jsx @@ -8,31 +8,3 @@ export function ActionButton({ children, label, labelIsHidden, className, ...but ); } - -export function SpecialActionButton(props) { - const { className, ...buttonProps } = props; - - return ( - - ); -} - -export function ActionInput({ label, className, ...props }) { - return ( - - ); -} - -export function SpecialActionInput({ className, ...props }) { - return ( - {props.label}} - /> - ); -} diff --git a/website/src/repl/components/panel/ImportPrebakeScriptButton.jsx b/website/src/repl/components/panel/ImportPrebakeScriptButton.jsx index 64ce198be..3b22dad3c 100644 --- a/website/src/repl/components/panel/ImportPrebakeScriptButton.jsx +++ b/website/src/repl/components/panel/ImportPrebakeScriptButton.jsx @@ -1,42 +1,50 @@ import { errorLogger } from '@strudel/core'; -import { useSettings, storePrebakeScript } from '../../../settings.mjs'; -import { SpecialActionInput } from '../button/action-button'; -import { confirmDialog, SETTING_CHANGE_RELOAD_MSG } from '@src/repl/util.mjs'; +import { storePrebakeScript } from '../../../settings.mjs'; +import { confirmDialog } from '@src/repl/util.mjs'; -async function importScript(script) { +async function importScript(script, updateEditor) { const reader = new FileReader(); reader.readAsText(script); reader.onload = () => { const text = reader.result; storePrebakeScript(text); + updateEditor && updateEditor(text); }; reader.onerror = () => { errorLogger(new Error('failed to import prebake script'), 'importScript'); }; } -export function ImportPrebakeScriptButton() { - const settings = useSettings(); +export async function exportScript(script) { + const blob = new Blob([script], { type: 'application/javascript' }); + const downloadLink = document.createElement('a'); + downloadLink.href = window.URL.createObjectURL(blob); + const date = new Date().toISOString().split('T')[0]; + downloadLink.download = `prebake_${date}.strudel`; + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); +} + +export function ImportPrebakeScriptButton({ updateEditor }) { + const handleChange = async (e) => { + const file = e.target.files[0]; + const confirmed = await confirmDialog('Warning: This will overwrite the current prebake.\nContinue?'); + if (!confirmed) { + return; + } + try { + await importScript(file, updateEditor); + } catch (e) { + errorLogger(e); + } + }; return ( - { - const file = e.target.files[0]; - const confirmed = await confirmDialog(SETTING_CHANGE_RELOAD_MSG); - if (!confirmed) { - return; - } - try { - await importScript(file); - window.location.reload(); - } catch (e) { - errorLogger(e); - } - }} - /> + ); } diff --git a/website/src/repl/components/panel/SettingsTab.jsx b/website/src/repl/components/panel/SettingsTab.jsx index e20f8420c..a385fd48a 100644 --- a/website/src/repl/components/panel/SettingsTab.jsx +++ b/website/src/repl/components/panel/SettingsTab.jsx @@ -1,18 +1,16 @@ -import { defaultSettings, settingsMap, useSettings } from '../../../settings.mjs'; +import { defaultSettings, settingsMap, useSettings, storePrebakeScript, setSettingsTab } from '../../../settings.mjs'; import { themes } from '@strudel/codemirror'; +import { PrebakeCodeMirror } from '../../../repl/prebakeCodeMirror.mjs'; import { confirmAndReloadPage, isUdels } from '../../util.mjs'; import { ButtonGroup } from './Forms.jsx'; import { AudioDeviceSelector } from './AudioDeviceSelector.jsx'; import { AudioEngineTargetSelector } from './AudioEngineTargetSelector.jsx'; import { confirmDialog } from '../../util.mjs'; import { DEFAULT_MAX_POLYPHONY, setMaxPolyphony, setMultiChannelOrbits } from '@strudel/webaudio'; -import { SpecialActionButton } from '../button/action-button.jsx'; -import { ImportPrebakeScriptButton } from './ImportPrebakeScriptButton.jsx'; - -function cx(...classes) { - // : Array - return classes.filter(Boolean).join(' '); -} +import { ActionButton } from '../button/action-button.jsx'; +import { exportScript, ImportPrebakeScriptButton } from './ImportPrebakeScriptButton.jsx'; +import { useEffect, useRef } from 'react'; +import cx from '@src/cx.mjs'; const inputClass = 'bg-background text-xs h-8 max-h-8 border border-box rounded-0 text-foreground border-muted placeholder-muted focus:outline-none focus:ring-0 focus:border-foreground'; @@ -123,7 +121,7 @@ const fontFamilyOptions = { galactico: 'galactico', }; -export function SettingsTab({ started }) { +function MainSettingsContent({ started }) { const { theme, keybindings, @@ -149,13 +147,12 @@ export function SettingsTab({ started }) { isTabIndentationEnabled, isMultiCursorEnabled, patternAutoStart, - includePrebakeScriptInShare, isBlockBasedEvalEnabled, } = useSettings(); const shouldAlwaysSync = isUdels(); const canChangeAudioDevice = AudioContext.prototype.setSinkId != null; return ( -
+
{canChangeAudioDevice && (
- - - settingsMap.setKey('includePrebakeScriptInShare', cbEvent.target.checked)} - value={includePrebakeScriptInShare} - /> - Try clicking the logo in the top left! - { confirmDialog('Sure?').then((r) => { if (r) { @@ -355,10 +344,70 @@ export function SettingsTab({ started }) { } }); }} + className="bg-background p-2 max-w-[300px] hover:opacity-50" > restore default settings - +
); } + +function PrebakeSettingsContent() { + const { fontFamily, includePrebakeScriptInShare, prebakeScript } = useSettings(); + const editorRef = useRef(); + useEffect(() => { + return () => { + editorRef.current?.cleanup(); + }; + }); + + return ( +
+
+
{ + if (editorRef.current) { + return; + } + editorRef.current = new PrebakeCodeMirror(prebakeScript, (code) => storePrebakeScript(code), el); + }} + >
+
+
+ settingsMap.setKey('includePrebakeScriptInShare', cbEvent.target.checked)} + value={includePrebakeScriptInShare} + /> +
+ editorRef?.current.setCode(code)} /> + exportScript(prebakeScript)}>export + editorRef.current?.savePrebake()}>save +
+
+
+ ); +} +export function SettingsTab({ started }) { + const { settingsTab } = useSettings(); + return ( +
+
+ setSettingsTab(value)} + items={{ + settings: 'settings', + prebake: 'prebake', + }} + > +
+ {settingsTab === 'settings' && } + {settingsTab === 'prebake' && } +
+ ); +} diff --git a/website/src/repl/prebakeCodeMirror.mjs b/website/src/repl/prebakeCodeMirror.mjs new file mode 100644 index 000000000..42b913ae6 --- /dev/null +++ b/website/src/repl/prebakeCodeMirror.mjs @@ -0,0 +1,115 @@ +import { toggleLineComment } from '@codemirror/commands'; +import { javascript, javascriptLanguage } from '@codemirror/lang-javascript'; +import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { Compartment, EditorState, Prec } from '@codemirror/state'; +import { drawSelection, EditorView, keymap } from '@codemirror/view'; +import { logger } from '@strudel/core'; +import { basicSetup, flash, initTheme, extensions, parseBooleans, codemirrorSettings } from '@strudel/codemirror'; +import { evaluate } from '@strudel/transpiler'; + +export class PrebakeCodeMirror { + constructor(initialCode, storePrebake, container) { + const settings = codemirrorSettings.get(); + this.storePrebake = storePrebake; + const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()])); + const initialSettings = Object.keys(compartments).map((key) => + compartments[key].of(extensions[key](parseBooleans(settings[key]))), + ); + initTheme(settings.theme); + let state = EditorState.create({ + doc: initialCode, + extensions: [ + ...initialSettings, + basicSetup, + javascript(), + javascriptLanguage.data.of({ + closeBrackets: { brackets: ['(', '[', '{', "'", '"', '<'] }, + bracketMatching: { brackets: ['(', '[', '{', "'", '"', '<'] }, + }), + syntaxHighlighting(defaultHighlightStyle), + EditorView.updateListener.of((v) => { + if (v.docChanged) { + this.code = v.state.doc.toString(); + } + }), + drawSelection({ cursorBlinkRate: 0 }), + Prec.highest( + keymap.of([ + { + mac: 'Meta-Enter', + run: () => { + this.savePrebake(); + }, + }, + { + key: 'Ctrl-Enter', + run: () => { + this.savePrebake(); + }, + }, + { + key: 'Alt-Enter', + run: () => { + this.savePrebake(); + }, + }, + ]), + ), + ], + }); + + this.code = initialCode; + this.view = new EditorView({ + state, + parent: container, + }); + + const handleSaveEvent = async (e) => { + if (e.detail.view !== this.view) { + return; // ignore events from other editors + } + await this.savePrebake(); + e?.cancelable && e.preventDefault?.(); + }; + const handleToggleComment = (e) => { + if (e.detail.view !== this.view) { + return; // ignore events from other editors + } + this.toggleComment(); + e?.cancelable && e.preventDefault?.(); + }; + + document.addEventListener('repl-evaluate', handleSaveEvent); + document.addEventListener('repl-toggle-comment', handleToggleComment); + this.cleanup = () => { + document.removeEventListener('prebake-evaluate', handleSaveEvent); + document.removeEventListener('prebake-toggle-comment', handleToggleComment); + }; + } + + async savePrebake() { + flash(this.view); + this.storePrebake(this.code); + evaluate(this.code, { addReturn: false }); // run prebake + logger('[prebake] prebake saved'); + } + + toggleComment() { + try { + // Honor selections; toggleLineComment handles both selections and + // single line + toggleLineComment(this.view); + } catch (err) { + console.error('Error handling repl-toggle-comment event', err); + } + } + + setCode(code) { + const changes = { + from: 0, + to: this.view.state.doc.length, + insert: code, + }; + this.view.dispatch({ changes }); + } +} diff --git a/website/src/repl/useReplContext.jsx b/website/src/repl/useReplContext.jsx index 65bd4c2d5..852c63f88 100644 --- a/website/src/repl/useReplContext.jsx +++ b/website/src/repl/useReplContext.jsx @@ -6,7 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th import { code2hash, getPerformanceTimeSeconds, logger, silence } from '@strudel/core'; import { getDrawContext } from '@strudel/draw'; -import { evaluate, transpiler } from '@strudel/transpiler'; +import { transpiler, evaluate } from '@strudel/transpiler'; import { getAudioContextCurrentTime, renderPatternAudio, @@ -87,12 +87,12 @@ export function useReplContext() { pattern: silence, drawTime, drawContext, - prebake: async () => - Promise.all([modulesLoading, presets]).then(() => { - if (prebakeScript?.length) { - return evaluate(prebakeScript ?? ''); - } - }), + prebake: async () => { + await Promise.all([modulesLoading, presets]); + if (prebakeScript) { + return evaluate(prebakeScript, { addReturn: false }); + } + }, onUpdateState: (state) => { setReplState({ ...state }); }, diff --git a/website/src/repl/util.mjs b/website/src/repl/util.mjs index 06dad2467..122a6a763 100644 --- a/website/src/repl/util.mjs +++ b/website/src/repl/util.mjs @@ -108,6 +108,7 @@ export function confirmDialog(msg) { }); } export const SETTING_CHANGE_RELOAD_MSG = 'Changing this setting requires the window to reload itself. OK?'; + export function confirmAndReloadPage(onSuccess) { confirmDialog(SETTING_CHANGE_RELOAD_MSG).then((r) => { if (r == true) { diff --git a/website/src/settings.mjs b/website/src/settings.mjs index 6acf8e2e2..d2f57afc9 100644 --- a/website/src/settings.mjs +++ b/website/src/settings.mjs @@ -18,6 +18,20 @@ export const soundFilterType = { ALL: 'all', }; +const initialPrebakeScript = `// Prebake script +// +// This is code that is loaded before your pattern is run. +// You can use it to define custom functions to use in any pattern. +// +// This is an initial example script. You can edit it to add +// your own funtions. +// +// To use a script shared by some other user you can use +// the import-button or paste the script in this editor. + +const ratchet = register('ratchet', (pat) => pat.sometimes(ply(2))) +`; + export const defaultSettings = { activeFooter: 'intro', keybindings: 'codemirror', @@ -47,13 +61,14 @@ export const defaultSettings = { isPanelPinned: false, isPanelOpen: true, userPatterns: '{}', - prebakeScript: '', + prebakeScript: initialPrebakeScript, audioEngineTarget: audioEngineTargets.webaudio, isButtonRowHidden: false, isCSSAnimationDisabled: false, maxPolyphony: 128, multiChannelOrbits: false, includePrebakeScriptInShare: true, + settingsTab: 'settings', }; let search = null; @@ -115,6 +130,7 @@ export function useSettings() { export const setActiveFooter = (tab) => settingsMap.setKey('activeFooter', tab); export const setPanelPinned = (bool) => settingsMap.setKey('isPanelPinned', bool); export const setIsPanelOpened = (bool) => settingsMap.setKey('isPanelOpen', bool); +export const setSettingsTab = (tab) => settingsMap.setKey('settingsTab', tab); export const storePrebakeScript = (script) => settingsMap.setKey('prebakeScript', script);