From ba4db567cfe78f02c25f63d532fec6acb4378693 Mon Sep 17 00:00:00 2001 From: Chandler Abraham Date: Thu, 29 May 2025 22:27:48 -0700 Subject: [PATCH 1/5] drag n drop --- packages/codemirror/codemirror.mjs | 2 + packages/codemirror/dragdrop.mjs | 142 +++++++++++++++++++++++++++++ website/src/repl/Repl.css | 7 ++ 3 files changed, 151 insertions(+) create mode 100644 packages/codemirror/dragdrop.mjs diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index e96b533ec..dda23acf0 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -24,6 +24,7 @@ import { initTheme, activateTheme, theme } from './themes.mjs'; import { sliderPlugin, updateSliderWidgets } from './slider.mjs'; import { widgetPlugin, updateWidgets } from './widget.mjs'; import { persistentAtom } from '@nanostores/persistent'; +import { dragDropPlugin, dragDropStyles } from './dragdrop.mjs'; const extensions = { isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []), @@ -78,6 +79,7 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo javascript(), sliderPlugin, widgetPlugin, + dragDropPlugin, // indentOnInput(), // works without. already brought with javascript extension? // bracketMatching(), // does not do anything syntaxHighlighting(defaultHighlightStyle), diff --git a/packages/codemirror/dragdrop.mjs b/packages/codemirror/dragdrop.mjs new file mode 100644 index 000000000..05dc3b45d --- /dev/null +++ b/packages/codemirror/dragdrop.mjs @@ -0,0 +1,142 @@ +import { ViewPlugin } from '@codemirror/view'; +import { logger } from '@strudel/core'; + +// Helper function to read file content +async function readFileContent(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => resolve(e.target.result); + reader.onerror = reject; + reader.readAsText(file); + }); +} + +// Helper function to check if file is a code file +function isCodeFile(file) { + const codeExtensions = [ + '.js', '.mjs', '.ts', '.tsx', '.jsx', + '.json', '.txt', '.md', '.tidal', '.strudel', + '.html', '.css', '.scss', '.yaml', '.yml', + '.xml', '.csv', '.log', '.ini', '.conf' + ]; + const fileName = file.name.toLowerCase(); + return codeExtensions.some(ext => fileName.endsWith(ext)) || file.type.startsWith('text/'); +} + +// Create drag and drop extension +export const dragDropPlugin = ViewPlugin.fromClass( + class { + constructor(view) { + this.view = view; + this.handleDrop = this.handleDrop.bind(this); + this.handleDragOver = this.handleDragOver.bind(this); + this.handleDragEnter = this.handleDragEnter.bind(this); + this.handleDragLeave = this.handleDragLeave.bind(this); + + // Add event listeners + view.dom.addEventListener('drop', this.handleDrop); + view.dom.addEventListener('dragover', this.handleDragOver); + view.dom.addEventListener('dragenter', this.handleDragEnter); + view.dom.addEventListener('dragleave', this.handleDragLeave); + } + + handleDragOver(e) { + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'copy'; + } + + handleDragEnter(e) { + e.preventDefault(); + e.stopPropagation(); + this.view.dom.classList.add('cm-drag-over'); + } + + handleDragLeave(e) { + e.preventDefault(); + e.stopPropagation(); + // Only remove the class if we're leaving the editor entirely + if (!this.view.dom.contains(e.relatedTarget)) { + this.view.dom.classList.remove('cm-drag-over'); + } + } + + async handleDrop(e) { + e.preventDefault(); + e.stopPropagation(); + this.view.dom.classList.remove('cm-drag-over'); + + const files = Array.from(e.dataTransfer.files); + + // Filter for code files only + const codeFiles = files.filter(isCodeFile); + + if (codeFiles.length === 0) { + logger('No code files were dropped. Please drop text-based files.', 'warning'); + return; + } + + try { + // Read all files + const fileContents = await Promise.all( + codeFiles.map(async (file) => { + const content = await readFileContent(file); + return `// File: ${file.name}\n${content}`; + }) + ); + + // Combine content + const newContent = fileContents.join('\n\n'); + + // Replace entire editor contents + this.view.dispatch({ + changes: { from: 0, to: this.view.state.doc.length, insert: newContent }, + selection: { anchor: newContent.length } + }); + + // Focus the editor + this.view.focus(); + + // Show success message + const fileNames = codeFiles.map(f => f.name).join(', '); + logger(`Successfully loaded ${codeFiles.length} file(s): ${fileNames}`, 'highlight'); + + // Dispatch custom event for external notification handling + this.view.dom.dispatchEvent(new CustomEvent('files-dropped', { + detail: { + success: true, + fileCount: codeFiles.length, + fileNames: fileNames + } + })); + } catch (error) { + console.error('Error reading dropped files:', error); + logger(`Error loading files: ${error.message}`, 'error'); + + // Dispatch error event + this.view.dom.dispatchEvent(new CustomEvent('files-dropped', { + detail: { + success: false, + error: error.message + } + })); + } + } + + destroy() { + this.view.dom.removeEventListener('drop', this.handleDrop); + this.view.dom.removeEventListener('dragover', this.handleDragOver); + this.view.dom.removeEventListener('dragenter', this.handleDragEnter); + this.view.dom.removeEventListener('dragleave', this.handleDragLeave); + } + } +); + +// CSS for drag over effect +export const dragDropStyles = ` + .cm-editor.cm-drag-over { + outline: 2px dashed #4CAF50; + outline-offset: -2px; + background-color: rgba(76, 175, 80, 0.05); + } +`; \ No newline at end of file diff --git a/website/src/repl/Repl.css b/website/src/repl/Repl.css index 3e13ff5a2..78740551d 100644 --- a/website/src/repl/Repl.css +++ b/website/src/repl/Repl.css @@ -69,3 +69,10 @@ text-decoration: underline 0.18rem; text-underline-offset: 0.22rem; } + +/* Drag and drop styles */ +#code .cm-editor.cm-drag-over { + outline: 2px dashed #4CAF50; + outline-offset: -2px; + background-color: rgba(76, 175, 80, 0.05) !important; +} From b11de530f468e513f968920b580b43ffafd233b8 Mon Sep 17 00:00:00 2001 From: Chandler Abraham Date: Sat, 31 May 2025 14:31:13 -0700 Subject: [PATCH 2/5] First pass at save/upload --- website/src/repl/Repl.css | 30 +++++++++++ website/src/repl/components/Code.jsx | 10 ++-- .../src/repl/components/DownloadButton.jsx | 51 +++++++++++++++++++ website/src/repl/components/Header.jsx | 1 + website/src/repl/components/ReplEditor.jsx | 2 +- 5 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 website/src/repl/components/DownloadButton.jsx diff --git a/website/src/repl/Repl.css b/website/src/repl/Repl.css index 78740551d..2fda68308 100644 --- a/website/src/repl/Repl.css +++ b/website/src/repl/Repl.css @@ -76,3 +76,33 @@ outline-offset: -2px; background-color: rgba(76, 175, 80, 0.05) !important; } + +/* Download button styles */ +.download-button { + position: absolute; + top: 8px; + right: 8px; + z-index: 100; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + padding: 6px; + cursor: pointer; + color: rgba(255, 255, 255, 0.6); + transition: all 0.2s ease; + pointer-events: auto; +} + +.download-button:hover { + background: rgba(0, 0, 0, 0.5); + color: rgba(255, 255, 255, 0.9); + border-color: rgba(255, 255, 255, 0.2); +} + +.download-button:active { + transform: scale(0.95); +} + +.download-button svg { + display: block; +} diff --git a/website/src/repl/components/Code.jsx b/website/src/repl/components/Code.jsx index 8481cc272..e1c235348 100644 --- a/website/src/repl/components/Code.jsx +++ b/website/src/repl/components/Code.jsx @@ -1,14 +1,16 @@ +import DownloadButton from './DownloadButton'; + // type Props = { // containerRef: React.MutableRefObject, // editorRef: React.MutableRefObject, // init: () => void // } export function Code(Props) { - const { editorRef, containerRef, init } = Props; + const { editorRef, containerRef, init, context } = Props; return (
{ containerRef.current = el; @@ -16,6 +18,8 @@ export function Code(Props) { init(); } }} - >
+ > + {context && } + ); } diff --git a/website/src/repl/components/DownloadButton.jsx b/website/src/repl/components/DownloadButton.jsx new file mode 100644 index 000000000..adbcf6947 --- /dev/null +++ b/website/src/repl/components/DownloadButton.jsx @@ -0,0 +1,51 @@ +import { useCallback } from 'react'; + +export default function DownloadButton({ context }) { + const handleDownload = useCallback(() => { + // Get the current code from the editor + const code = context.code; + + // Create a blob with the code + const blob = new Blob([code], { type: 'text/javascript' }); + + // Create a temporary URL for the blob + const url = window.URL.createObjectURL(blob); + + // Create a temporary anchor element and trigger download + const a = document.createElement('a'); + a.href = url; + a.download = `strudel-pattern-${new Date().toISOString().slice(0, 10)}.js`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + + // Clean up the URL + window.URL.revokeObjectURL(url); + }, [context.code]); + + return ( + + ); +} \ No newline at end of file diff --git a/website/src/repl/components/Header.jsx b/website/src/repl/components/Header.jsx index 2963a5f97..2f5e46012 100644 --- a/website/src/repl/components/Header.jsx +++ b/website/src/repl/components/Header.jsx @@ -1,5 +1,6 @@ import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon'; import StopCircleIcon from '@heroicons/react/20/solid/StopCircleIcon'; +import ArrowDownTrayIcon from '@heroicons/react/20/solid/ArrowDownTrayIcon'; import cx from '@src/cx.mjs'; import { useSettings, setIsZen } from '../../settings.mjs'; import '../Repl.css'; diff --git a/website/src/repl/components/ReplEditor.jsx b/website/src/repl/components/ReplEditor.jsx index 23ceb122b..d2f52563f 100644 --- a/website/src/repl/components/ReplEditor.jsx +++ b/website/src/repl/components/ReplEditor.jsx @@ -20,7 +20,7 @@ export default function ReplEditor(Props) {
- + {!isZen && panelPosition === 'right' && }
From e3bc15a8d24c9beced4588055cdb6632fb99499a Mon Sep 17 00:00:00 2001 From: Chandler Abraham Date: Sat, 31 May 2025 14:47:51 -0700 Subject: [PATCH 3/5] code review --- packages/codemirror/codemirror.mjs | 2 +- packages/codemirror/dragdrop.mjs | 34 ++++-------------------------- 2 files changed, 5 insertions(+), 31 deletions(-) diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index dda23acf0..a7158e9f4 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -24,7 +24,7 @@ import { initTheme, activateTheme, theme } from './themes.mjs'; import { sliderPlugin, updateSliderWidgets } from './slider.mjs'; import { widgetPlugin, updateWidgets } from './widget.mjs'; import { persistentAtom } from '@nanostores/persistent'; -import { dragDropPlugin, dragDropStyles } from './dragdrop.mjs'; +import { dragDropPlugin } from './dragdrop.mjs'; const extensions = { isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []), diff --git a/packages/codemirror/dragdrop.mjs b/packages/codemirror/dragdrop.mjs index 05dc3b45d..d2c57d578 100644 --- a/packages/codemirror/dragdrop.mjs +++ b/packages/codemirror/dragdrop.mjs @@ -11,13 +11,11 @@ async function readFileContent(file) { }); } -// Helper function to check if file is a code file +// Check for common text file formats, to avoid +// accidentally loading images or other files function isCodeFile(file) { const codeExtensions = [ - '.js', '.mjs', '.ts', '.tsx', '.jsx', - '.json', '.txt', '.md', '.tidal', '.strudel', - '.html', '.css', '.scss', '.yaml', '.yml', - '.xml', '.csv', '.log', '.ini', '.conf' + '.js', '.strudel', '.str' ]; const fileName = file.name.toLowerCase(); return codeExtensions.some(ext => fileName.endsWith(ext)) || file.type.startsWith('text/'); @@ -101,25 +99,10 @@ export const dragDropPlugin = ViewPlugin.fromClass( const fileNames = codeFiles.map(f => f.name).join(', '); logger(`Successfully loaded ${codeFiles.length} file(s): ${fileNames}`, 'highlight'); - // Dispatch custom event for external notification handling - this.view.dom.dispatchEvent(new CustomEvent('files-dropped', { - detail: { - success: true, - fileCount: codeFiles.length, - fileNames: fileNames - } - })); } catch (error) { console.error('Error reading dropped files:', error); logger(`Error loading files: ${error.message}`, 'error'); - // Dispatch error event - this.view.dom.dispatchEvent(new CustomEvent('files-dropped', { - detail: { - success: false, - error: error.message - } - })); } } @@ -130,13 +113,4 @@ export const dragDropPlugin = ViewPlugin.fromClass( this.view.dom.removeEventListener('dragleave', this.handleDragLeave); } } -); - -// CSS for drag over effect -export const dragDropStyles = ` - .cm-editor.cm-drag-over { - outline: 2px dashed #4CAF50; - outline-offset: -2px; - background-color: rgba(76, 175, 80, 0.05); - } -`; \ No newline at end of file +); \ No newline at end of file From 69244ba445393f8bc477d5c9a0753f0f82f4a427 Mon Sep 17 00:00:00 2001 From: Chandler Abraham Date: Sat, 31 May 2025 15:08:45 -0700 Subject: [PATCH 4/5] fix download button, was returning undefined as file contents --- website/src/repl/components/DownloadButton.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/repl/components/DownloadButton.jsx b/website/src/repl/components/DownloadButton.jsx index adbcf6947..a582a7bb3 100644 --- a/website/src/repl/components/DownloadButton.jsx +++ b/website/src/repl/components/DownloadButton.jsx @@ -3,7 +3,7 @@ import { useCallback } from 'react'; export default function DownloadButton({ context }) { const handleDownload = useCallback(() => { // Get the current code from the editor - const code = context.code; + const code = context.editorRef?.current?.code || ''; // Create a blob with the code const blob = new Blob([code], { type: 'text/javascript' }); @@ -21,7 +21,7 @@ export default function DownloadButton({ context }) { // Clean up the URL window.URL.revokeObjectURL(url); - }, [context.code]); + }, []); return ( ); -} \ No newline at end of file +}