From da94e3d439a174fdd6ef3486727cfe7ad6ae2a9a Mon Sep 17 00:00:00 2001 From: Takshit Saini <94343242+takshittt@users.noreply.github.com> Date: Tue, 4 Mar 2025 03:40:30 +0530 Subject: [PATCH] Added support for ES Modules in p5.js-web-editor --- client/constants.js | 1 + client/modules/IDE/actions/project.js | 7 ++ client/modules/IDE/components/Header/Nav.jsx | 4 + .../__snapshots__/Nav.unit.test.jsx.snap | 9 ++ client/modules/IDE/hooks/useSketchActions.js | 12 +++ client/modules/IDE/reducers/files.js | 65 ++++++++++--- client/modules/Preview/EmbedFrame.jsx | 91 ++++++++++++++++--- server/domain-objects/createDefaultFiles.js | 40 ++++++++ translations/locales/en-US/translations.json | 4 +- 9 files changed, 206 insertions(+), 27 deletions(-) diff --git a/client/constants.js b/client/constants.js index fa6010aed1..6591c7637a 100644 --- a/client/constants.js +++ b/client/constants.js @@ -28,6 +28,7 @@ export const RENAME_PROJECT = 'RENAME_PROJECT'; export const PROJECT_SAVE_SUCCESS = 'PROJECT_SAVE_SUCCESS'; export const PROJECT_SAVE_FAIL = 'PROJECT_SAVE_FAIL'; export const NEW_PROJECT = 'NEW_PROJECT'; +export const NEW_MODULE_PROJECT = 'NEW_MODULE_PROJECT'; export const RESET_PROJECT = 'RESET_PROJECT'; export const SET_PROJECT = 'SET_PROJECT'; diff --git a/client/modules/IDE/actions/project.js b/client/modules/IDE/actions/project.js index 1d8943336b..cd0efe7a46 100644 --- a/client/modules/IDE/actions/project.js +++ b/client/modules/IDE/actions/project.js @@ -273,6 +273,13 @@ export function newProject() { return resetProject(); } +export function newModuleProject() { + browserHistory.push('/', { confirmed: true, moduleProject: true }); + return { + type: ActionTypes.NEW_MODULE_PROJECT + }; +} + function generateNewIdsForChildren(file, files) { const newChildren = []; file.children.forEach((childId) => { diff --git a/client/modules/IDE/components/Header/Nav.jsx b/client/modules/IDE/components/Header/Nav.jsx index f40e9137bf..d41e3488b7 100644 --- a/client/modules/IDE/components/Header/Nav.jsx +++ b/client/modules/IDE/components/Header/Nav.jsx @@ -130,6 +130,7 @@ const ProjectMenu = () => { const { t } = useTranslation(); const { newSketch, + newModuleSketch, saveSketch, downloadSketch, shareSketch @@ -165,6 +166,9 @@ const ProjectMenu = () => { {t('Nav.File.New')} + + {t('Nav.File.NewModule')} + +
  • { } } + function newModuleSketch() { + if (!unsavedChanges) { + dispatch(showToast('Toast.OpenedNewModuleSketch')); + dispatch(newModuleProject()); + } else if (window.confirm(t('Nav.WarningUnsavedChanges'))) { + dispatch(showToast('Toast.OpenedNewModuleSketch')); + dispatch(newModuleProject()); + } + } + function saveSketch(cmController) { if (authenticated) { dispatch(saveProject(cmController?.getContent())); @@ -62,6 +73,7 @@ const useSketchActions = () => { return { newSketch, + newModuleSketch, saveSketch, downloadSketch, shareSketch, diff --git a/client/modules/IDE/reducers/files.js b/client/modules/IDE/reducers/files.js index de1b9b1aa7..6d57b0ceec 100644 --- a/client/modules/IDE/reducers/files.js +++ b/client/modules/IDE/reducers/files.js @@ -3,7 +3,10 @@ import * as ActionTypes from '../../../constants'; import { defaultSketch, defaultCSS, - defaultHTML + defaultHTML, + defaultModuleSketch, + defaultModuleHTML, + createDefaultModuleFiles } from '../../../../server/domain-objects/createDefaultFiles'; export const initialState = () => { @@ -51,6 +54,51 @@ export const initialState = () => { ]; }; +export const moduleState = () => { + const a = objectID().toHexString(); + const b = objectID().toHexString(); + const c = objectID().toHexString(); + const r = objectID().toHexString(); + return [ + { + name: 'root', + id: r, + _id: r, + children: [b, a, c], + fileType: 'folder', + content: '' + }, + { + name: 'sketch.js', + content: defaultModuleSketch, + id: a, + _id: a, + isSelectedFile: true, + fileType: 'file', + children: [], + filePath: '' + }, + { + name: 'index.html', + content: defaultModuleHTML, + id: b, + _id: b, + fileType: 'file', + children: [], + filePath: '' + }, + { + name: 'style.css', + content: defaultCSS, + id: c, + _id: c, + fileType: 'file', + children: [], + filePath: '' + } + ]; +}; + function getAllDescendantIds(state, nodeId) { return state .find((file) => file.id === nodeId) @@ -158,17 +206,10 @@ const files = (state, action) => { } return Object.assign({}, file, { blobURL: action.blobURL }); }); - case ActionTypes.NEW_PROJECT: { - const newFiles = action.files.map((file) => { - const corrospondingObj = state.find((obj) => obj.id === file.id); - if (corrospondingObj && corrospondingObj.fileType === 'folder') { - const isFolderClosed = corrospondingObj.isFolderClosed || false; - return { ...file, isFolderClosed }; - } - return file; - }); - return setFilePaths(newFiles); - } + case ActionTypes.NEW_PROJECT: + return initialState(); + case ActionTypes.NEW_MODULE_PROJECT: + return moduleState(); case ActionTypes.SET_PROJECT: { const newFiles = action.files.map((file) => { const corrospondingObj = state.find((obj) => obj.id === file.id); diff --git a/client/modules/Preview/EmbedFrame.jsx b/client/modules/Preview/EmbedFrame.jsx index 4b2ad60d9b..069be4480f 100644 --- a/client/modules/Preview/EmbedFrame.jsx +++ b/client/modules/Preview/EmbedFrame.jsx @@ -69,10 +69,22 @@ function resolveCSSLinksInString(content, files) { return newContent; } -function jsPreprocess(jsText) { +function jsPreprocess(jsText, isModule = false) { let newContent = jsText; - // check the code for js errors before sending it to strip comments - // or loops. + // If this is a module, we need to be careful with transformations + // as they might break import/export statements + if (isModule || /\b(import|export)\b/.test(jsText)) { + // For modules, we still want to check for errors but we'll be more careful with transformations + JSHINT(newContent, { esversion: 11, module: true }); + // For modules, we only decomment but don't apply loop protection + // as it might break module semantics + newContent = decomment(newContent, { + ignore: /\/\/\s*noprotect/g, + space: true + }); + return newContent; + } + // For regular scripts, apply the standard processing JSHINT(newContent); if (JSHINT.errors.length === 0) { @@ -87,6 +99,9 @@ function jsPreprocess(jsText) { function resolveJSLinksInString(content, files) { let newContent = content; + // Check if this is an ES module (contains import/export statements) + const isModule = /\b(import|export)\b/.test(content); + // Handle regular string references to files let jsFileStrings = content.match(STRING_REGEX); jsFileStrings = jsFileStrings || []; jsFileStrings.forEach((jsFileString) => { @@ -101,23 +116,63 @@ function resolveJSLinksInString(content, files) { jsFileString, quoteCharacter + resolvedFile.url + quoteCharacter ); - } else if (resolvedFile.name.match(PLAINTEXT_FILE_REGEX)) { - newContent = newContent.replace( - jsFileString, - quoteCharacter + resolvedFile.blobUrl + quoteCharacter - ); } } } }); - - return jsPreprocess(newContent); + // If this is a module, also handle import statements + if (isModule) { + // Match import statements like: import { x } from './file.js'; + // or import x from './file.js'; + const importRegex = /import\s+(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+)?(['"])([^'"]+)(['"])/g; + let importMatch; + // eslint-disable-next-line no-cond-assign + while ((importMatch = importRegex.exec(content))) { + const [fullMatch, openQuote, importPath, closeQuote] = importMatch; + // Only process relative imports, not package imports + if (importPath.startsWith('./') || importPath.startsWith('../')) { + const resolvedFile = resolvePathToFile(importPath, files); + if (resolvedFile) { + if (resolvedFile.url) { + // Replace the import path with the resolved URL + newContent = newContent.replace( + fullMatch, + fullMatch.replace( + `${openQuote}${importPath}${closeQuote}`, + `${openQuote}${resolvedFile.url}${closeQuote}` + ) + ); + } else { + // Create a blob URL for the imported file + const blobUrl = createBlobUrl(resolvedFile); + const blobPath = blobUrl.split('/').pop(); + objectUrls[ + blobUrl + ] = `${resolvedFile.filePath}/${resolvedFile.name}`; + objectPaths[blobPath] = resolvedFile.name; + // Replace the import path with the blob URL + newContent = newContent.replace( + fullMatch, + fullMatch.replace( + `${openQuote}${importPath}${closeQuote}`, + `${openQuote}${blobUrl}${closeQuote}` + ) + ); + } + } + } + } + } + // Apply preprocessing with module awareness + return jsPreprocess(newContent, isModule); } function resolveScripts(sketchDoc, files) { const scriptsInHTML = sketchDoc.getElementsByTagName('script'); const scriptsInHTMLArray = Array.prototype.slice.call(scriptsInHTML); scriptsInHTMLArray.forEach((script) => { + // Check if this is a module script + const isModule = script.getAttribute('type') === 'module'; if ( script.getAttribute('src') && script.getAttribute('src').match(NOT_EXTERNAL_LINK_REGEX) !== null @@ -137,9 +192,10 @@ function resolveScripts(sketchDoc, files) { // }${resolvedFile.name}`; objectUrls[blobUrl] = `${resolvedFile.filePath}/${resolvedFile.name}`; objectPaths[blobPath] = resolvedFile.name; - // script.setAttribute('data-tag', `${startTag}${resolvedFile.name}`); - // script.removeAttribute('src'); - // script.innerHTML = resolvedFile.content; // eslint-disable-line + // Preserve the module type if it was set + if (isModule) { + script.setAttribute('type', 'module'); + } } } } else if ( @@ -149,7 +205,14 @@ function resolveScripts(sketchDoc, files) { ) !== null ) { script.setAttribute('crossorigin', ''); - script.innerHTML = resolveJSLinksInString(script.innerHTML, files); // eslint-disable-line + // For inline scripts, we need to handle module content differently + // to preserve ES module semantics + if (isModule) { + // For module scripts, we don't apply loop protection as it might break imports + script.innerHTML = resolveJSLinksInString(script.innerHTML, files); // eslint-disable-line + } else { + script.innerHTML = resolveJSLinksInString(script.innerHTML, files); // eslint-disable-line + } } }); } diff --git a/server/domain-objects/createDefaultFiles.js b/server/domain-objects/createDefaultFiles.js index 82a1470123..57725fc2c0 100644 --- a/server/domain-objects/createDefaultFiles.js +++ b/server/domain-objects/createDefaultFiles.js @@ -6,6 +6,15 @@ function draw() { background(220); }`; +export const defaultModuleSketch = `// ES Module version +export function setup() { + createCanvas(400, 400); +} + +export function draw() { + background(220); +}`; + export const defaultHTML = ` @@ -23,6 +32,23 @@ export const defaultHTML = ` `; +export const defaultModuleHTML = ` + + + + + + + + + +
    +
    + + + +`; + export const defaultCSS = `html, body { margin: 0; padding: 0; @@ -45,3 +71,17 @@ export default function createDefaultFiles() { } }; } + +export function createDefaultModuleFiles() { + return { + 'index.html': { + content: defaultModuleHTML + }, + 'style.css': { + content: defaultCSS + }, + 'sketch.js': { + content: defaultModuleSketch + } + }; +} diff --git a/translations/locales/en-US/translations.json b/translations/locales/en-US/translations.json index 0f46c59c14..a2cb95b7ad 100644 --- a/translations/locales/en-US/translations.json +++ b/translations/locales/en-US/translations.json @@ -3,6 +3,7 @@ "File": { "Title": "File", "New": "New", + "NewModule": "New Module", "Share": "Share", "Duplicate": "Duplicate", "Open": "Open", @@ -133,6 +134,7 @@ }, "Toast": { "OpenedNewSketch": "Opened new sketch.", + "OpenedNewModuleSketch": "Opened new module sketch.", "SketchSaved": "Sketch saved.", "SketchFailedSave": "Failed to save sketch.", "AutosaveEnabled": "Autosave enabled.", @@ -362,7 +364,7 @@ "CreateTokenSubmit": "Create", "NoTokens": "You have no existing tokens.", "NewTokenTitle": "Your new access token", - "NewTokenInfo": "Make sure to copy your new personal access token now.\n You won’t be able to see it again!", + "NewTokenInfo": "Make sure to copy your new personal access token now.\n You won't be able to see it again!", "ExistingTokensTitle": "Existing tokens" }, "APIKeyList": {