diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index e96b533ec..a7158e9f4 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 } 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..9db58b90f --- /dev/null +++ b/packages/codemirror/dragdrop.mjs @@ -0,0 +1,112 @@ +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); + }); +} + +// Check for common text file formats, to avoid +// accidentally loading images or other files +function isCodeFile(file) { + const codeExtensions = ['.js', '.strudel', '.str']; + 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'); + } catch (error) { + console.error('Error reading dropped files:', error); + logger(`Error loading files: ${error.message}`, 'error'); + } + } + + 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); + } + }, +); diff --git a/website/src/repl/Repl.css b/website/src/repl/Repl.css index 3e13ff5a2..7e5d89066 100644 --- a/website/src/repl/Repl.css +++ b/website/src/repl/Repl.css @@ -69,3 +69,40 @@ 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; +} + +/* 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..554414a8b --- /dev/null +++ b/website/src/repl/components/DownloadButton.jsx @@ -0,0 +1,39 @@ +import { useCallback } from 'react'; + +export default function DownloadButton({ context }) { + const handleDownload = useCallback(() => { + // Get the current code from the editor + const code = context.editorRef?.current?.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); + }, []); + + return ( + + ); +} 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' && }