diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index e8f310d6f..4c660045d 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -39,8 +39,6 @@
"vscode": {
"extensions": [
"ms-azuretools.vscode-docker",
- "ninoseki.vscode-gem-lens",
- "rebornix.ruby",
"eamodio.gitlens",
"github.vscode-pull-request-github",
"wmaurer.change-case",
@@ -53,7 +51,6 @@
"pkosta2005.heroku-command",
"yzhang.markdown-all-in-one",
"mikestead.dotenv",
- "wingrunr21.vscode-ruby",
"ms-vscode.remote-repositories",
"github.remotehub",
"circleci.circleci",
@@ -64,14 +61,13 @@
"avraammavridis.vsc-react-documentation",
"ofhumanbondage.react-proptypes-intellisense",
"syler.sass-indented",
- "codezombiech.gitignore"
+ "codezombiech.gitignore",
+ "github.vscode-github-actions",
+ "humao.rest-client"
],
"settings": {
"terminal.integrated.defaultProfile.linux": "zsh",
- "jest.autoRun": {
- "watch": false,
- "onSave": "test-src-file"
- }
+ "jest.jestCommandLine": "yarn test"
}
}
},
diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml
index 82567be5c..6f34a5ca5 100644
--- a/.github/workflows/changelog.yml
+++ b/.github/workflows/changelog.yml
@@ -3,26 +3,26 @@ name: Changelog
on:
pull_request:
branches:
- - '**'
+ - "**"
jobs:
changelog-updated:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
- - name: Check if changelog updated
- id: changed-files-specific
- uses: tj-actions/changed-files@v29.0.4
- with:
- base_sha: ${{ github.event.pull_request.base.sha }}
- files: |
- CHANGELOG.md
- env:
- GITHUB_TOKEN: ${{ github.token }}
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Check if changelog updated
+ id: changed-files-specific
+ uses: tj-actions/changed-files@v29.0.4
+ with:
+ base_sha: ${{ github.event.pull_request.base.sha }}
+ files: |
+ CHANGELOG.md
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
- - name: Fail job if changelog not updated
- if: steps.changed-files-specific.outputs.any_changed == 'false'
- run: |
- exit 1
+ - name: Fail job if changelog not updated
+ if: steps.changed-files-specific.outputs.any_changed == 'false'
+ run: |
+ exit 1
diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml
index bb741e939..66d19cbdc 100644
--- a/.github/workflows/ci-cd.yml
+++ b/.github/workflows/ci-cd.yml
@@ -104,7 +104,7 @@ jobs:
REACT_APP_PLAUSIBLE_SOURCE: ""
- name: Archive cypress artifacts
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4.6.0
if: failure()
with:
name: cypress-artifacts
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index de3b9fe93..22c40ef0f 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -123,6 +123,12 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Install AWS CLI v2.22.35
+ run: |
+ curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64-2.22.35.zip" -o "awscliv2.zip"
+ unzip awscliv2.zip
+ sudo ./aws/install --update
+
- name: Build WC bundle
run: |
# TODO: Reinitialise when storybook build is fixed
@@ -145,8 +151,18 @@ jobs:
REACT_APP_SENTRY_ENV: ${{ inputs.react_app_sentry_env }}
- name: Deploy site to S3 bucket
- run: aws s3 sync ./build/ s3://${{ secrets.AWS_S3_BUCKET }}/${{ needs.setup-environment.outputs.deploy_dir }} --endpoint ${{ secrets.AWS_ENDPOINT }}
+ run: aws s3 sync ./build/ s3://${{ secrets.AWS_S3_BUCKET }}/${{ needs.setup-environment.outputs.deploy_dir }} --endpoint ${{ secrets.AWS_ENDPOINT }}
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: ${{ secrets.AWS_REGION }}
+
+ - name: Purge Cloudflare cache
+ run: |
+ curl -X POST "https://api.cloudflare.com/client/v4/zones/$%7B%7B%20secrets.CLOUDFLARE_ZONE_ID%20%7D%7D/purge_cache" \
+ -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
+ -H "Content-Type: application/json" \
+ --data '{"files":["${{ needs.setup-environment.outputs.public_url }}/web-component.html", "${{ needs.setup-environment.outputs.public_url }}/web-component.js"]}'
+ env:
+ CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec1db3ed5..b239d5e53 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,40 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## Unreleased
+## [0.29.0] - 2025-02-06
+
+### Added
+
+- Autosave instructions (#1163)
+- Editable instructions (#1161)
+- Ability to write to files in `python` (#1146)
+- Support for the `outputPanels` attribute in the `PyodideRunner` (#1157)
+- Downloading project instructions (#1160)
+- Support for audio and video files in HTML projects (#1179)
+- Show instructions option in sidebar if instructions are editable (#1164)
+- Open instructions panel by default if instructions are editable (#1164)
+- Instructions empty state to show when instructions are editable (#1165, #1168)
+- Allow `instructions` attribute to override instructions attached to the project (#1169)
+- Instructions tabs for edit and viewing (#1167)
+- Add remove instructions button modal (#1176, #1191)
+- Dark mode colours (#1182)
+- Dark mode for instuctions code block (#1187)
+- Change markdown links to open in new tab (#1188)
+- Update demo instructions text (#1189)
+- Syntax highlighting for custom instructions in Code Editor for Education (#1190)
+
+### Changed
+
+- Made `INSTRUCTIONS.md` a reserved file name (#1160)
+- Clear the redux store when the component unmounts (#1169)
+- Login to save now logs in and automatically saves (#1162)
+- Instructions panel heading (#1183)
+- Added cache purge on deployment (#1186)
+
+### Fixed
+
+- Fix AWS CLI in deploy script to 2.22.35 to workaround cloudflare issue (See https://developers.cloudflare.com/r2/examples/aws/aws-cli/) (#1178)
+- Padding on instructions code block (#1184, 1190)
## [0.28.14] - 2025-01-06
@@ -1027,7 +1060,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Events in Web Component indicating whether Mission Zero criteria have been met (#113)
-[unreleased]: https://github.com/RaspberryPiFoundation/editor-ui/compare/v0.28.14...HEAD
+[unreleased]: https://github.com/RaspberryPiFoundation/editor-ui/compare/v0.29.0...HEAD
+[0.29.0]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.29.0
[0.28.14]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.28.14
[0.28.13]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.28.13
[0.28.12]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.28.12
diff --git a/README.md b/README.md
index 54281f396..652313d25 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,7 @@ The `editor-wc` tag accepts the following attributes, which must be provided as
- `assets_identifier`: Load assets (not code) from this project identifier
- `auth_key`: Authenticate the user to allow them to make API requests such as saving their work
- `code`: A preset blob of code to show in the editor pane (overrides content of `main.py`/`index.html`)
+- `editable_instructions`: Boolean whether to show edit panel for instructions
- `embedded`: Enable embedded mode which hides some functionality (defaults to `false`)
- `host_styles`: Styles passed into the web component from the host page
- `identifier`: Load the project with this identifier from the database
diff --git a/cypress/e2e/spec-wc-pyodide.cy.js b/cypress/e2e/spec-wc-pyodide.cy.js
index 858c308a9..73bcbd314 100644
--- a/cypress/e2e/spec-wc-pyodide.cy.js
+++ b/cypress/e2e/spec-wc-pyodide.cy.js
@@ -91,6 +91,58 @@ describe("Running the code with pyodide", () => {
.should("contain", "Hello Lois");
});
+ it("runs a simple program to write to a file", () => {
+ runCode('with open("output.txt", "w") as f:\n\tf.write("Hello world")');
+ cy.get("editor-wc")
+ .shadow()
+ .contains(".files-list-item", "output.txt")
+ .click();
+ cy.get("editor-wc")
+ .shadow()
+ .find(".cm-editor")
+ .should("contain", "Hello world");
+ });
+
+ it("errors when trying to write to an existing file in 'x' mode", () => {
+ runCode('with open("output.txt", "w") as f:\n\tf.write("Hello world")');
+ cy.get("editor-wc")
+ .shadow()
+ .find(".files-list-item")
+ .should("contain", "output.txt");
+ runCode('with open("output.txt", "x") as f:\n\tf.write("Something else")');
+ cy.get("editor-wc")
+ .shadow()
+ .find(".error-message__content")
+ .should(
+ "contain",
+ "FileExistsError: File 'output.txt' already exists on line 1 of main.py",
+ );
+ });
+
+ it("updates the file in the editor when the content is updated programatically", () => {
+ runCode('with open("output.txt", "w") as f:\n\tf.write("Hello world")');
+ cy.get("editor-wc")
+ .shadow()
+ .find("div[class=cm-content]")
+ .invoke(
+ "text",
+ 'with open("output.txt", "a") as f:\n\tf.write("Hello again world")',
+ );
+ cy.get("editor-wc")
+ .shadow()
+ .contains(".files-list-item", "output.txt")
+ .click();
+ cy.get("editor-wc")
+ .shadow()
+ .find(".btn--run")
+ .should("not.be.disabled")
+ .click();
+ cy.get("editor-wc")
+ .shadow()
+ .find(".cm-editor")
+ .should("contain", "Hello again world");
+ });
+
it("runs a simple program with a built-in python module", () => {
runCode("from math import floor, pi\nprint(floor(pi))");
cy.get("editor-wc")
diff --git a/package.json b/package.json
index bbda89f88..af4712906 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@raspberrypifoundation/editor-ui",
- "version": "0.28.14",
+ "version": "0.29.0",
"private": true,
"dependencies": {
"@apollo/client": "^3.7.8",
@@ -45,6 +45,7 @@
"js-convert-case": "^4.2.0",
"jszip": "^3.10.1",
"jszip-utils": "^0.1.0",
+ "marked": "^15.0.6",
"material-symbols": "^0.27.0",
"mime-types": "^2.1.35",
"node-html-parser": "^6.1.5",
@@ -53,6 +54,7 @@
"prismjs": "^1.29.0",
"prompts": "2.4.0",
"prop-types": "^15.8.1",
+ "raw-loader": "^4.0.2",
"rc-resize-observer": "^1.3.1",
"re-resizable": "6.9.9",
"react": "^18.1.0",
@@ -111,7 +113,7 @@
"@react-three/test-renderer": "8.2.1",
"@svgr/webpack": "5.5.0",
"@testing-library/jest-dom": "^5.16.5",
- "@testing-library/react": "^13.2.0",
+ "@testing-library/react": "14.3.1",
"@testing-library/user-event": "^12.1.10",
"@typescript-eslint/eslint-plugin": "^4.5.0",
"@typescript-eslint/parser": "^4.5.0",
diff --git a/src/PyodideWorker.js b/src/PyodideWorker.js
index b50981742..200c742b0 100644
--- a/src/PyodideWorker.js
+++ b/src/PyodideWorker.js
@@ -60,12 +60,6 @@ const PyodideWorker = () => {
const runPython = async (python) => {
stopped = false;
- await pyodide.loadPackage("pyodide_http");
-
- await pyodide.runPythonAsync(`
- import pyodide_http
- pyodide_http.patch_all()
- `);
try {
await withSupportForPackages(python, async () => {
@@ -98,6 +92,52 @@ const PyodideWorker = () => {
await pyodide.loadPackagesFromImports(python);
checkIfStopped();
+ await pyodide.runPythonAsync(
+ `
+ import basthon
+ import builtins
+ import os
+
+ MAX_FILES = 100
+ MAX_FILE_SIZE = 8500000
+
+ def _custom_open(filename, mode="r", *args, **kwargs):
+ if "x" in mode and os.path.exists(filename):
+ raise FileExistsError(f"File '{filename}' already exists")
+ if ("w" in mode or "a" in mode or "x" in mode) and "b" not in mode:
+ if len(os.listdir()) > MAX_FILES and not os.path.exists(filename):
+ raise OSError(f"File system limit reached, no more than {MAX_FILES} files allowed")
+ class CustomFile:
+ def __init__(self, filename):
+ self.filename = filename
+ self.content = ""
+
+ def write(self, content):
+ self.content += content
+ if len(self.content) > MAX_FILE_SIZE:
+ raise OSError(f"File '{self.filename}' exceeds maximum file size of {MAX_FILE_SIZE} bytes")
+ with _original_open(self.filename, "w") as f:
+ f.write(self.content)
+ basthon.kernel.write_file({ "filename": self.filename, "content": self.content, "mode": mode })
+
+ def close(self):
+ pass
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.close()
+
+ return CustomFile(filename)
+ else:
+ return _original_open(filename, mode, *args, **kwargs)
+
+ # Override the built-in open function
+ builtins.open = _custom_open
+ `,
+ { filename: "__custom_open__.py" },
+ );
await runPythonFn();
for (let name of imports) {
@@ -337,6 +377,12 @@ const PyodideWorker = () => {
postMessage({ method: "handleVisual", origin, content });
},
+ write_file: (event) => {
+ const filename = event.toJs().get("filename");
+ const content = event.toJs().get("content");
+ const mode = event.toJs().get("mode");
+ postMessage({ method: "handleFileWrite", filename, content, mode });
+ },
locals: () => pyodide.runPython("globals()"),
},
};
@@ -346,7 +392,7 @@ const PyodideWorker = () => {
await pyodide.runPythonAsync(`
# Clear all user-defined variables and modules
for name in dir():
- if not name.startswith('_'):
+ if not name.startswith('_') and not name=='basthon':
del globals()[name]
`);
postMessage({ method: "handleLoaded", stdinBuffer, interruptBuffer });
@@ -364,6 +410,8 @@ const PyodideWorker = () => {
pyodide = await pyodidePromise;
+ pyodide.registerJsModule("basthon", fakeBasthonPackage);
+
await pyodide.runPythonAsync(`
__old_input__ = input
def __patched_input__(prompt=False):
@@ -373,6 +421,18 @@ const PyodideWorker = () => {
__builtins__.input = __patched_input__
`);
+ await pyodide.runPythonAsync(`
+ import builtins
+ # Save the original open function
+ _original_open = builtins.open
+ `);
+
+ await pyodide.loadPackage("pyodide-http");
+ await pyodide.runPythonAsync(`
+ import pyodide_http
+ pyodide_http.patch_all()
+ `);
+
if (supportsAllFeatures) {
stdinBuffer =
stdinBuffer || new Int32Array(new SharedArrayBuffer(1024 * 1024)); // 1 MiB
@@ -416,6 +476,14 @@ const PyodideWorker = () => {
const lines = trace.split("\n");
+ // if the third from last line matches /File "__custom_open__\.py", line (\d+)/g then strip off the last three lines
+ if (
+ lines.length > 3 &&
+ /File "__custom_open__\.py", line (\d+)/g.test(lines[lines.length - 3])
+ ) {
+ lines.splice(-3, 3);
+ }
+
const snippetLine = lines[lines.length - 2]; // print("hi")invalid
const caretLine = lines[lines.length - 1]; // ^^^^^^^
@@ -424,7 +492,9 @@ const PyodideWorker = () => {
? [snippetLine.slice(4), caretLine.slice(4)].join("\n")
: "";
- const matches = [...trace.matchAll(/File "(.*)", line (\d+)/g)];
+ const matches = [
+ ...trace.matchAll(/File "(?!__custom_open__\.py)(.*)", line (\d+)/g),
+ ];
const match = matches[matches.length - 1];
const path = match ? match[1] : "";
diff --git a/src/assets/markdown/demoInstructions.md b/src/assets/markdown/demoInstructions.md
new file mode 100644
index 000000000..df1b5a6cb
--- /dev/null
+++ b/src/assets/markdown/demoInstructions.md
@@ -0,0 +1,18 @@
+# How instructions work
+
+## Enabling instructions
+
+Any text written here will be visible to students in the sidebar
+
+## Writing instructions
+
+Write your instructions using [Markdown](https://www.markdownguide.org/)
+### What you can do
+
+Lists:
+
+- Bullet points
+- Bullet points
+
+1. numbered steps
+2. numbered steps
diff --git a/src/assets/stylesheets/Instructions.scss b/src/assets/stylesheets/Instructions.scss
index e32b73253..82d7b99bf 100644
--- a/src/assets/stylesheets/Instructions.scss
+++ b/src/assets/stylesheets/Instructions.scss
@@ -1,15 +1,23 @@
-@use "../../../node_modules/@raspberrypifoundation/design-system-core/scss/components/squiggle.scss" as *;
+@use "../../../node_modules/@raspberrypifoundation/design-system-core/scss/components/squiggle.scss"
+ as *;
+@use "../../../node_modules/@raspberrypifoundation/design-system-core/scss/mixins/typography";
@use "./rpf_design_system/colours" as *;
@use "./rpf_design_system/spacing" as *;
-@use './rpf_design_system/font-size' as *;
+@use "./rpf_design_system/font-size" as *;
@use "./rpf_design_system/font-weight" as *;
.project-instructions {
+ block-size: 100%;
+
h2 {
@include font-size-1-25(bold);
margin: 0;
}
+ a {
+ color: var(--editor-color-theme-secondary);
+ }
+
strong {
font-weight: $font-weight-bold;
}
@@ -32,27 +40,33 @@
}
}
- pre {
+ code {
+ color: $rpf-white;
background-color: $rpf-grey-700;
border-radius: 8px;
- padding: $space-0-5;
- overflow: auto;
- margin: $space-1 0;
+ padding: calc(0.75 * $space-0-125) $space-0-5;
}
- code {
- color: $rpf-white;
+ pre {
background-color: $rpf-grey-700;
+ border: 1px solid $rpf-grey-600;
border-radius: 8px;
- padding: calc(0.75 * $space-0-125) $space-0-5;
+ padding: $space-0-5 $space-1;
+ overflow: auto;
+ margin: $space-1 0;
+
+ code {
+ padding-inline: 0;
+ }
}
.c-project-code {
- margin: $space-1 0 $space-1-5 0;
- border-radius: 8px;
background-color: $rpf-grey-700;
+ border-radius: 8px;
+ margin: $space-1 0 $space-1-5 0;
pre {
+ border: none;
margin: 0;
}
@@ -72,14 +86,11 @@
border-block-end: 1px solid $rpf-grey-600;
}
-
-
.line-numbers {
padding-inline-start: $space-3;
padding-inline-end: $space-1;
}
-
.line-numbers-rows {
border-color: $rpf-text-secondary-dark;
@@ -97,13 +108,37 @@
}
.language-python {
- .number, .boolean, .function {
+ .number,
+ .boolean,
+ .function {
+ color: $rpf-syntax-1;
+ }
+ .keyword {
+ color: $rpf-syntax-4;
+ }
+ .string,
+ .char {
+ color: $rpf-syntax-2;
+ }
+ .comment {
+ color: $rpf-syntax-3;
+ }
+
+ .keyword-print {
+ color: $rpf-white;
+ }
+ }
+
+ .language-javascript {
+ .number,
+ .boolean {
color: $rpf-syntax-1;
}
.keyword {
color: $rpf-syntax-4;
}
- .string, .char {
+ .string,
+ .char {
color: $rpf-syntax-2;
}
.comment {
@@ -129,7 +164,8 @@
color: $rpf-syntax-4;
}
- .property, .punctuation {
+ .property,
+ .punctuation {
color: $rpf-white;
}
}
@@ -137,7 +173,8 @@
.language-html {
.tag {
color: $rpf-syntax-4;
- .punctuation, .attr-name {
+ .punctuation,
+ .attr-name {
color: $rpf-white;
}
@@ -255,4 +292,69 @@
white-space: pre-wrap;
}
}
+
+ .project-instructions__empty {
+ background-color: var(--editor-color-layer-1);
+ border-radius: $space-0-5;
+ display: flex;
+ flex-direction: column;
+ gap: $space-1-5;
+ padding: $space-1;
+ }
+
+ .project-instructions__empty-text {
+ margin: 0;
+ }
+}
+
+.--dark .project-instructions {
+ pre {
+ background-color: $rpf-grey-850;
+ }
+ code {
+ background-color: $rpf-grey-850;
+ }
+}
+
+#app,
+#wc {
+ .c-instruction-tabs {
+ display: flex;
+ flex-direction: column;
+ block-size: 100%;
+
+ .react-tabs {
+ border: 1px solid var(--editor-color-outline);
+
+ .react-tabs__tab-list {
+ border-block-end: 1px solid var(--editor-color-outline);
+ }
+ }
+
+ .react-tabs__tab {
+ background: var(--editor-color-tab-background);
+ padding-inline: var(--space-1-5);
+
+ &--selected {
+ background: var(--editor-color-layer-3);
+ }
+ }
+
+ .react-tabs__tab-panel {
+ .project-instructions {
+ padding-inline: var(--space-1);
+ }
+ }
+
+ textarea {
+ @include typography.style-1();
+
+ background: var(--editor-color-layer-3);
+ color: var(--editor-color-text);
+ border: none;
+ block-size: 100%;
+ overflow-block: scroll;
+ padding: var(--space-1);
+ }
+ }
}
diff --git a/src/assets/stylesheets/InternalStyles.scss b/src/assets/stylesheets/InternalStyles.scss
index 2f24fccd6..189405bf0 100644
--- a/src/assets/stylesheets/InternalStyles.scss
+++ b/src/assets/stylesheets/InternalStyles.scss
@@ -103,6 +103,7 @@ button:focus-visible {
--editor-color-theme-tertiary: #{$rpf-teal-100};
--editor-color-text: #{$rpf-text-primary};
--editor-color-text-secondary: #{$rpf-text-secondary};
+ --editor-color-tab-background: #{$rpf-off-white};
}
.--dark {
@@ -116,6 +117,7 @@ button:focus-visible {
--editor-color-text: #{$rpf-white};
--editor-color-text-secondary: #{$rpf-text-secondary-darkmode};
--rpf-button-secondary-text-color: #{$rpf-white};
+ --editor-color-tab-background: #{$rpf-grey-700};
.rpf-button--secondary {
border-color: $rpf-navy-800;}
diff --git a/src/components/DownloadButton/DownloadButton.jsx b/src/components/DownloadButton/DownloadButton.jsx
index eb9c15b8d..b37342633 100644
--- a/src/components/DownloadButton/DownloadButton.jsx
+++ b/src/components/DownloadButton/DownloadButton.jsx
@@ -39,6 +39,10 @@ const DownloadButton = (props) => {
const zip = new JSZip();
+ if (project.instructions) {
+ zip.file("INSTRUCTIONS.md", project.instructions);
+ }
+
project.components.forEach((file) => {
zip.file(`${file.name}.${file.extension}`, file.content);
});
diff --git a/src/components/DownloadButton/DownloadButton.test.js b/src/components/DownloadButton/DownloadButton.test.js
index ef9e38253..1d8a5a655 100644
--- a/src/components/DownloadButton/DownloadButton.test.js
+++ b/src/components/DownloadButton/DownloadButton.test.js
@@ -25,6 +25,7 @@ describe("Downloading project with name set", () => {
project: {
name: "My epic project",
identifier: "hello-world-project",
+ instructions: "print hello world to the console",
components: [
{
name: "main",
@@ -53,6 +54,18 @@ describe("Downloading project with name set", () => {
expect(downloadButton).toBeInTheDocument();
});
+ test("Clicking download zips instructions", async () => {
+ fireEvent.click(downloadButton);
+ const JSZipInstance = JSZip.mock.instances[0];
+ const mockFile = JSZipInstance.file;
+ await waitFor(() =>
+ expect(mockFile).toHaveBeenCalledWith(
+ "INSTRUCTIONS.md",
+ "print hello world to the console",
+ ),
+ );
+ });
+
test("Clicking download zips project file content", async () => {
fireEvent.click(downloadButton);
const JSZipInstance = JSZip.mock.instances[0];
@@ -123,3 +136,48 @@ describe("Downloading project with no name set", () => {
);
});
});
+
+describe("Downloading project with no instructions set", () => {
+ let downloadButton;
+
+ beforeEach(() => {
+ JSZip.mockClear();
+ const middlewares = [];
+ const mockStore = configureStore(middlewares);
+ const initialState = {
+ editor: {
+ project: {
+ name: "My epic project",
+ identifier: "hello-world-project",
+ components: [
+ {
+ name: "main",
+ extension: "py",
+ content: "",
+ },
+ ],
+ image_list: [],
+ },
+ },
+ };
+ const store = mockStore(initialState);
+ render(
+
+ {}} />
+ ,
+ );
+ downloadButton = screen.queryByText("Download").parentElement;
+ });
+
+ test("Clicking download button does not zip instructions", async () => {
+ fireEvent.click(downloadButton);
+ const JSZipInstance = JSZip.mock.instances[0];
+ const mockFile = JSZipInstance.file;
+ await waitFor(() =>
+ expect(mockFile).not.toHaveBeenCalledWith(
+ "INSTRUCTIONS.md",
+ expect.anything(),
+ ),
+ );
+ });
+});
diff --git a/src/components/Editor/EditorPanel/EditorPanel.jsx b/src/components/Editor/EditorPanel/EditorPanel.jsx
index 951cfb70b..21a930736 100644
--- a/src/components/Editor/EditorPanel/EditorPanel.jsx
+++ b/src/components/Editor/EditorPanel/EditorPanel.jsx
@@ -2,7 +2,10 @@
import "../../../assets/stylesheets/EditorPanel.scss";
import React, { useRef, useEffect, useContext, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
-import { updateProjectComponent } from "../../../redux/EditorSlice";
+import {
+ setCascadeUpdate,
+ updateProjectComponent,
+} from "../../../redux/EditorSlice";
import { useCookies } from "react-cookie";
import { useTranslation } from "react-i18next";
import { basicSetup } from "codemirror";
@@ -27,8 +30,10 @@ const MAX_CHARACTERS = 8500000;
const EditorPanel = ({ extension = "html", fileName = "index" }) => {
const editor = useRef();
+ const editorViewRef = useRef();
const project = useSelector((state) => state.editor.project);
const readOnly = useSelector((state) => state.editor.readOnly);
+ const cascadeUpdate = useSelector((state) => state.editor.cascadeUpdate);
const [cookies] = useCookies(["theme", "fontSize"]);
const dispatch = useDispatch();
const { t } = useTranslation();
@@ -40,7 +45,8 @@ const EditorPanel = ({ extension = "html", fileName = "index" }) => {
updateProjectComponent({
extension: extension,
name: fileName,
- code: content,
+ content,
+ cascadeUpdate: false,
}),
);
};
@@ -74,11 +80,11 @@ const EditorPanel = ({ extension = "html", fileName = "index" }) => {
window.matchMedia("(prefers-color-scheme:dark)").matches);
const editorTheme = isDarkMode ? editorDarkTheme : editorLightTheme;
- useEffect(() => {
- const file = project.components.find(
- (item) => item.extension === extension && item.name === fileName,
- );
+ const file = project.components.find(
+ (item) => item.extension === extension && item.name === fileName,
+ );
+ useEffect(() => {
if (!file) {
return;
}
@@ -123,6 +129,8 @@ const EditorPanel = ({ extension = "html", fileName = "index" }) => {
parent: editor.current,
});
+ editorViewRef.current = view;
+
// 'aria-hidden' to fix keyboard access accessibility error
view.scrollDOM.setAttribute("aria-hidden", "true");
@@ -138,6 +146,23 @@ const EditorPanel = ({ extension = "html", fileName = "index" }) => {
};
}, [cookies]);
+ useEffect(() => {
+ if (
+ cascadeUpdate &&
+ editorViewRef.current &&
+ file.content !== editorViewRef.current.state.doc.toString()
+ ) {
+ editorViewRef.current.dispatch({
+ changes: {
+ from: 0,
+ to: editorViewRef.current.state.doc.length,
+ insert: file.content,
+ },
+ });
+ dispatch(setCascadeUpdate(false));
+ }
+ }, [file, cascadeUpdate, editorViewRef]);
+
return (
<>
diff --git a/src/components/Editor/Output/Output.test.js b/src/components/Editor/Output/Output.test.js
index 0c23a4c37..2b736ac1f 100644
--- a/src/components/Editor/Output/Output.test.js
+++ b/src/components/Editor/Output/Output.test.js
@@ -26,6 +26,8 @@ describe("Output component", () => {
project: {
components: [],
},
+ focussedFileIndices: [0],
+ openFiles: [["main.py"]],
},
auth: {
user,
diff --git a/src/components/Editor/Runners/HtmlRunner/HtmlRunner.jsx b/src/components/Editor/Runners/HtmlRunner/HtmlRunner.jsx
index f5bcbd13e..ae8a47720 100644
--- a/src/components/Editor/Runners/HtmlRunner/HtmlRunner.jsx
+++ b/src/components/Editor/Runners/HtmlRunner/HtmlRunner.jsx
@@ -31,7 +31,11 @@ import { MOBILE_MEDIA_QUERY } from "../../../../utils/mediaQueryBreakpoints";
function HtmlRunner() {
const project = useSelector((state) => state.editor.project);
const projectCode = project.components;
- const projectImages = project.image_list;
+ const projectMedia = [
+ ...(project.image_list || []),
+ ...(project.audio || []),
+ ...(project.videos || []),
+ ];
const firstPanelIndex = 0;
const focussedFileIndex = useSelector(
@@ -114,9 +118,9 @@ function HtmlRunner() {
const cssProjectImgs = (projectFile) => {
var updatedProjectFile = { ...projectFile };
if (projectFile.extension === "css") {
- projectImages.forEach((image) => {
- const find = new RegExp(`['"]${image.filename}['"]`, "g"); // prevent substring matches
- const replace = `"${image.url}"`;
+ projectMedia.forEach((media_file) => {
+ const find = new RegExp(`['"]${media_file.filename}['"]`, "g"); // prevent substring matches
+ const replace = `"${media_file.url}"`;
updatedProjectFile.content = updatedProjectFile.content.replaceAll(
find,
replace,
@@ -264,27 +268,29 @@ function HtmlRunner() {
const replaceSrcNodes = (
indexPage,
- projectImages,
+ projectMedia,
projectCode,
attr = "src",
) => {
const srcNodes = indexPage.querySelectorAll(`[${attr}]`);
srcNodes.forEach((srcNode) => {
- const projectImage = projectImages.find(
+ const projectMediaFile = projectMedia.find(
(component) => component.filename === srcNode.attrs[attr],
);
- const projectFile = projectCode.find(
+ const projectTextFile = projectCode.find(
(file) => `${file.name}.${file.extension}` === srcNode.attrs[attr],
);
let src = "";
- if (!!projectImage) {
- src = projectImage.url;
- } else if (!!projectFile) {
+ if (!!projectMediaFile) {
+ src = projectMediaFile.url;
+ } else if (!!projectTextFile) {
src = getBlobURL(
- projectFile.content,
- mimeTypes.lookup(`${projectFile.name}.${projectFile.extension}`),
+ projectTextFile.content,
+ mimeTypes.lookup(
+ `${projectTextFile.name}.${projectTextFile.extension}`,
+ ),
);
} else if (matchingRegexes(allowedExternalLinks, srcNode.attrs[attr])) {
src = srcNode.attrs[attr];
@@ -351,8 +357,8 @@ function HtmlRunner() {
body.insertAdjacentHTML("afterbegin", disableLocalStorageScript);
replaceHrefNodes(indexPage, projectCode);
- replaceSrcNodes(indexPage, projectImages, projectCode);
- replaceSrcNodes(indexPage, projectImages, projectCode, "data-src");
+ replaceSrcNodes(indexPage, projectMedia, projectCode);
+ replaceSrcNodes(indexPage, projectMedia, projectCode, "data-src");
body.appendChild(parse(``));
diff --git a/src/components/Editor/Runners/HtmlRunner/HtmlRunner.test.js b/src/components/Editor/Runners/HtmlRunner/HtmlRunner.test.js
index 98fac8be5..40ad19de9 100644
--- a/src/components/Editor/Runners/HtmlRunner/HtmlRunner.test.js
+++ b/src/components/Editor/Runners/HtmlRunner/HtmlRunner.test.js
@@ -499,6 +499,77 @@ describe("When an allowed external link is rendered", () => {
});
});
+describe("When media is rendered", () => {
+ const mediaHTML =
+ '
';
+ let generatedHtml;
+ beforeEach(() => {
+ const middlewares = [];
+ const mockStore = configureStore(middlewares);
+ const initialState = {
+ editor: {
+ project: {
+ components: [
+ { name: "index", extension: "html", content: mediaHTML },
+ ],
+ image_list: [
+ {
+ filename: "image.jpeg",
+ url: "https://example.com/image.jpeg",
+ },
+ ],
+ videos: [
+ {
+ filename: "video.mp4",
+ url: "https://example.com/video.mp4",
+ },
+ ],
+ audio: [
+ {
+ filename: "audio.mp3",
+ url: "https://example.com/audio.mp3",
+ },
+ ],
+ },
+ focussedFileIndices: [0],
+ openFiles: [["index.html"]],
+ codeRunTriggered: true,
+ codeHasBeenRun: true,
+ errorModalShowing: false,
+ },
+ };
+ const store = mockStore(initialState);
+ render(
+
+
+
+
+
+
+ ,
+ );
+ [generatedHtml] = Blob.mock.calls[0][0];
+ });
+
+ test("Transforms image sources", () => {
+ expect(generatedHtml).toContain(
+ '
{
+ expect(generatedHtml).toContain(
+ '