Skip to content

Commit ca406ec

Browse files
Scratchblocks projects site (#1512)
- adds scratchblocks library - uses the scratchblocks helpers from projects-ui to render scratchblocks in instructions panel - adds styling for scratch code block highlighting for instructions panel The scratchblocks helpers from projects-ui are needed to render the blocks within the instruction panel. In projects-ui these are used for non editor scratch projects. The reason this is also needed in editor-ui is because for editor projects, instructions are handled by the InstructionsPanel component in editor-ui. It is possible that we can remove this from editor-ui if/when we move to using Raspberry Flavoured Markdown and client side rendering of instructions down the line. Before: <img width="562" height="266" alt="image" src="https://github.com/user-attachments/assets/12b1a14e-ec69-4c49-a0d8-9b7d8a43b469" /> After: <img width="565" height="377" alt="image" src="https://github.com/user-attachments/assets/4e0ad983-8da4-4bf2-ab27-833886f4f270" /> --------- Co-authored-by: Jo Humphrey <31373245+jamdelion@users.noreply.github.com>
1 parent dbe3efa commit ca406ec

7 files changed

Lines changed: 448 additions & 2 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"redux-oidc": "^4.0.0-beta1",
6969
"scratchReactDomVendor": "npm:react-dom@18.3.1",
7070
"scratchReactVendor": "npm:react@18.3.1",
71+
"scratchblocks": "^3.7.0",
7172
"skulpt": "^1.2.0",
7273
"stream-browserify": "^3.0.0",
7374
"three": "0.169.0",

src/assets/stylesheets/Instructions.scss

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,36 @@
77
.project-instructions {
88
block-size: 100%;
99

10+
code.block3control {
11+
background-color: #ffab19;
12+
}
13+
code.block3events {
14+
background-color: #ffbf00;
15+
}
16+
code.block3extensions {
17+
background-color: #0fbd8c;
18+
}
19+
code.block3looks {
20+
background-color: #9966ff;
21+
}
22+
code.block3motion {
23+
background-color: #4c97ff;
24+
}
25+
code.block3myblocks {
26+
background-color: #ff6680;
27+
}
28+
code.block3operators {
29+
background-color: #59c059;
30+
}
31+
code.block3sensing {
32+
background-color: #5cb1d6;
33+
}
34+
code.block3sound {
35+
background-color: #cf63cf;
36+
}
37+
code.block3variables {
38+
background-color: #ff8c1a;
39+
}
1040
&__content {
1141
padding-block-end: var(--project-instructions-padding, $space-1);
1242
}

src/components/Menus/Sidebar/InstructionsPanel/InstructionsPanel.jsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
11
/* eslint-disable jsx-a11y/anchor-has-content */
22
// This is disabled because the empty anchor tag is used for translation and will have content when rendered.
3+
34
import React, { useEffect, useMemo, useRef, useState } from "react";
45
import { Trans, useTranslation } from "react-i18next";
56
import { useDispatch, useSelector } from "react-redux";
67
import { Tab, TabList, TabPanel, Tabs } from "react-tabs";
78
import SidebarPanel from "../SidebarPanel";
89

910
import Prism from "prismjs";
11+
import PlusIcon from "../../../../assets/icons/plus.svg";
1012
import demoInstructions from "../../../../assets/markdown/demoInstructions.md";
1113
import "../../../../assets/stylesheets/Instructions.scss";
1214
import { quizReadyEvent } from "../../../../events/WebComponentCustomEvents";
1315
import { setProjectInstructions } from "../../../../redux/EditorSlice";
1416
import { setCurrentStepPosition } from "../../../../redux/InstructionsSlice";
1517
import populateMarkdownTemplate from "../../../../utils/populateMarkdownTemplate";
18+
import { scratchblocksInit } from "../../../../utils/scratchblocks";
1619
import DesignSystemButton from "../../../DesignSystemButton/DesignSystemButton";
1720
import RemoveInstructionsModal from "../../../Modals/RemoveInstructionsModal";
1821
import ProgressBar from "./ProgressBar/ProgressBar";
19-
import PlusIcon from "../../../../assets/icons/plus.svg";
2022

2123
const InstructionsPanel = () => {
2224
useEffect(() => {
@@ -48,7 +50,7 @@ const InstructionsPanel = () => {
4850
const currentStepPosition = useSelector(
4951
(state) => state.instructions.currentStepPosition,
5052
);
51-
const { t } = useTranslation();
53+
const { t, i18n } = useTranslation();
5254
const stepContent = useRef();
5355

5456
const [isQuiz, setIsQuiz] = useState(false);
@@ -64,6 +66,7 @@ const InstructionsPanel = () => {
6466

6567
const hasInstructions = steps && steps.length > 0;
6668
const hasMultipleSteps = numberOfSteps > 1;
69+
const isScratchProject = project?.project_type === "code_editor_scratch";
6770

6871
const applySyntaxHighlighting = (container) => {
6972
const codeElements = container.querySelectorAll(
@@ -96,6 +99,9 @@ const InstructionsPanel = () => {
9699
stepContent.current?.parentElement.scrollTo({ top: 0 });
97100
stepContent.current.innerHTML = content;
98101
applySyntaxHighlighting(stepContent.current);
102+
if (isScratchProject) {
103+
scratchblocksInit(i18n.language, stepContent.current);
104+
}
99105
}
100106
};
101107
if (isQuiz && !quizCompleted) {
@@ -112,6 +118,8 @@ const InstructionsPanel = () => {
112118
quizCompleted,
113119
isQuiz,
114120
instructionsTab,
121+
isScratchProject,
122+
i18n.language,
115123
]);
116124

117125
useEffect(() => {

src/components/Menus/Sidebar/InstructionsPanel/InstructionsPanel.test.js

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,30 @@ import { setProjectInstructions } from "../../../../redux/EditorSlice";
66
import { act } from "react";
77
import Modal from "react-modal";
88
import Prism from "prismjs";
9+
import { scratchblocksInit } from "../../../../utils/scratchblocks";
910

1011
window.HTMLElement.prototype.scrollTo = jest.fn();
1112
jest.mock("prismjs", () => ({
1213
...jest.requireActual("prismjs"),
1314
highlightElement: jest.fn(),
1415
}));
16+
jest.mock("../../../../utils/scratchblocks", () => ({
17+
scratchblocksInit: jest.fn(),
18+
}));
19+
20+
// Stand-in for the real (jsdom-unfriendly) scratchblocks SVG rendering: swap
21+
// each .language-blocks element for an svg so we can assert it was processed.
22+
// Set as the implementation per-test because jest `resetMocks: true` clears it.
23+
const fakeScratchblocksInit = (_locale, container) => {
24+
container.querySelectorAll(".language-blocks").forEach((block) => {
25+
const svg = global.document.createElementNS(
26+
"http://www.w3.org/2000/svg",
27+
"svg",
28+
);
29+
svg.setAttribute("data-testid", "scratchblock");
30+
block.parentNode.replaceChild(svg, block);
31+
});
32+
};
1533

1634
describe("When instructionsEditable is true", () => {
1735
describe("When there are instructions", () => {
@@ -357,6 +375,115 @@ describe("When instructions are not editable", () => {
357375
});
358376
});
359377

378+
describe("When the project is a scratch project", () => {
379+
const scratchSteps = [
380+
{
381+
content: "<pre><code class='language-blocks'>say [hello]</code></pre>",
382+
},
383+
{
384+
content:
385+
"<pre><code class='language-blocks'>move (10) steps</code></pre>",
386+
},
387+
];
388+
389+
const renderAtStep = (currentStepPosition) => {
390+
const mockStore = configureStore([]);
391+
const store = mockStore({
392+
editor: {
393+
project: { project_type: "code_editor_scratch" },
394+
instructionsEditable: false,
395+
},
396+
instructions: {
397+
project: { steps: scratchSteps },
398+
quiz: {},
399+
currentStepPosition,
400+
},
401+
});
402+
return render(
403+
<Provider store={store}>
404+
<InstructionsPanel />
405+
</Provider>,
406+
);
407+
};
408+
409+
beforeEach(() => {
410+
scratchblocksInit.mockImplementation(fakeScratchblocksInit);
411+
});
412+
413+
test("Renders the scratch block as an svg", () => {
414+
renderAtStep(0);
415+
expect(screen.getByTestId("scratchblock")).toBeInTheDocument();
416+
});
417+
418+
test("Initialises scratchblocks with the step content container", () => {
419+
renderAtStep(0);
420+
expect(scratchblocksInit).toHaveBeenCalledWith(
421+
expect.any(String),
422+
expect.any(HTMLElement),
423+
);
424+
});
425+
426+
test("Re-renders scratch blocks when navigating to another step", () => {
427+
const { rerender } = renderAtStep(0);
428+
scratchblocksInit.mockClear();
429+
430+
const mockStore = configureStore([]);
431+
const store = mockStore({
432+
editor: {
433+
project: { project_type: "code_editor_scratch" },
434+
instructionsEditable: false,
435+
},
436+
instructions: {
437+
project: { steps: scratchSteps },
438+
quiz: {},
439+
currentStepPosition: 1,
440+
},
441+
});
442+
rerender(
443+
<Provider store={store}>
444+
<InstructionsPanel />
445+
</Provider>,
446+
);
447+
448+
expect(scratchblocksInit).toHaveBeenCalled();
449+
expect(screen.getByTestId("scratchblock")).toBeInTheDocument();
450+
});
451+
});
452+
453+
describe("When the project is not a scratch project", () => {
454+
beforeEach(() => {
455+
scratchblocksInit.mockClear();
456+
const mockStore = configureStore([]);
457+
const store = mockStore({
458+
editor: {
459+
project: { project_type: "python" },
460+
instructionsEditable: false,
461+
},
462+
instructions: {
463+
project: {
464+
steps: [
465+
{
466+
content:
467+
"<pre><code class='language-blocks'>say [hello]</code></pre>",
468+
},
469+
],
470+
},
471+
quiz: {},
472+
currentStepPosition: 0,
473+
},
474+
});
475+
render(
476+
<Provider store={store}>
477+
<InstructionsPanel />
478+
</Provider>,
479+
);
480+
});
481+
482+
test("Does not initialise scratchblocks", () => {
483+
expect(scratchblocksInit).not.toHaveBeenCalled();
484+
});
485+
});
486+
360487
describe("When there is a quiz", () => {
361488
const quizHandler = jest.fn();
362489

0 commit comments

Comments
 (0)