Compare commits

...

5 Commits

Author SHA1 Message Date
Chandler Abraham de436f0b5e run prettier linter 2025-06-01 09:07:49 -07:00
Chandler Abraham 69244ba445 fix download button, was returning undefined as file contents 2025-05-31 15:08:45 -07:00
Chandler Abraham e3bc15a8d2 code review 2025-05-31 14:47:51 -07:00
Chandler Abraham b11de530f4 First pass at save/upload 2025-05-31 14:31:13 -07:00
Chandler Abraham ba4db567cf drag n drop 2025-05-29 22:27:48 -07:00
7 changed files with 199 additions and 4 deletions
+2
View File
@@ -24,6 +24,7 @@ import { initTheme, activateTheme, theme } from './themes.mjs';
import { sliderPlugin, updateSliderWidgets } from './slider.mjs'; import { sliderPlugin, updateSliderWidgets } from './slider.mjs';
import { widgetPlugin, updateWidgets } from './widget.mjs'; import { widgetPlugin, updateWidgets } from './widget.mjs';
import { persistentAtom } from '@nanostores/persistent'; import { persistentAtom } from '@nanostores/persistent';
import { dragDropPlugin } from './dragdrop.mjs';
const extensions = { const extensions = {
isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []), isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []),
@@ -78,6 +79,7 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
javascript(), javascript(),
sliderPlugin, sliderPlugin,
widgetPlugin, widgetPlugin,
dragDropPlugin,
// indentOnInput(), // works without. already brought with javascript extension? // indentOnInput(), // works without. already brought with javascript extension?
// bracketMatching(), // does not do anything // bracketMatching(), // does not do anything
syntaxHighlighting(defaultHighlightStyle), syntaxHighlighting(defaultHighlightStyle),
+112
View File
@@ -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);
}
},
);
+37
View File
@@ -69,3 +69,40 @@
text-decoration: underline 0.18rem; text-decoration: underline 0.18rem;
text-underline-offset: 0.22rem; 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;
}
+7 -3
View File
@@ -1,14 +1,16 @@
import DownloadButton from './DownloadButton';
// type Props = { // type Props = {
// containerRef: React.MutableRefObject<HTMLElement | null>, // containerRef: React.MutableRefObject<HTMLElement | null>,
// editorRef: React.MutableRefObject<HTMLElement | null>, // editorRef: React.MutableRefObject<HTMLElement | null>,
// init: () => void // init: () => void
// } // }
export function Code(Props) { export function Code(Props) {
const { editorRef, containerRef, init } = Props; const { editorRef, containerRef, init, context } = Props;
return ( return (
<section <section
className={'text-gray-100 cursor-text pb-0 overflow-auto grow'} className={'text-gray-100 cursor-text pb-0 overflow-auto grow relative'}
id="code" id="code"
ref={(el) => { ref={(el) => {
containerRef.current = el; containerRef.current = el;
@@ -16,6 +18,8 @@ export function Code(Props) {
init(); init();
} }
}} }}
></section> >
{context && <DownloadButton context={context} />}
</section>
); );
} }
@@ -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 (
<button
onClick={handleDownload}
className="download-button"
title="Download pattern as .js file"
aria-label="Download pattern"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 12L3 7L4.4 5.6L7 8.2V0H9V8.2L11.6 5.6L13 7L8 12Z" fill="currentColor" />
<path d="M14 14V10H16V16H0V10H2V14H14Z" fill="currentColor" />
</svg>
</button>
);
}
+1
View File
@@ -1,5 +1,6 @@
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon'; import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
import StopCircleIcon from '@heroicons/react/20/solid/StopCircleIcon'; import StopCircleIcon from '@heroicons/react/20/solid/StopCircleIcon';
import ArrowDownTrayIcon from '@heroicons/react/20/solid/ArrowDownTrayIcon';
import cx from '@src/cx.mjs'; import cx from '@src/cx.mjs';
import { useSettings, setIsZen } from '../../settings.mjs'; import { useSettings, setIsZen } from '../../settings.mjs';
import '../Repl.css'; import '../Repl.css';
+1 -1
View File
@@ -20,7 +20,7 @@ export default function ReplEditor(Props) {
<Loader active={pending} /> <Loader active={pending} />
<Header context={context} /> <Header context={context} />
<div className="grow flex relative overflow-hidden"> <div className="grow flex relative overflow-hidden">
<Code containerRef={containerRef} editorRef={editorRef} init={init} /> <Code containerRef={containerRef} editorRef={editorRef} init={init} context={context} />
{!isZen && panelPosition === 'right' && <VerticalPanel context={context} />} {!isZen && panelPosition === 'right' && <VerticalPanel context={context} />}
</div> </div>
<UserFacingErrorMessage error={error} /> <UserFacingErrorMessage error={error} />