mirror of
https://codeberg.org/uzu/strudel
synced 2026-07-22 13:13:10 -04:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de436f0b5e | |||
| 69244ba445 | |||
| e3bc15a8d2 | |||
| b11de530f4 | |||
| ba4db567cf |
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
Reference in New Issue
Block a user