diff --git a/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx b/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx
index 13e4c88881..d9b03cf4a1 100644
--- a/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx
+++ b/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx
@@ -3,6 +3,9 @@ import { act, fireEvent, reduxRender, screen } from '../../../../test-utils';
import { initialState } from '../../reducers/preferences';
import Preferences from './index';
import * as PreferencesActions from '../../actions/preferences';
+import { initialState as filesInitialState } from '../../reducers/files';
+import { P5VersionProvider } from '../../hooks/useP5Version';
+import { NO_PROTECT_REGEX } from '../../utils/loopProtection';
describe('', () => {
// For backwards compatibility, spy on each action creator to see when it was dispatched.
@@ -13,15 +16,27 @@ describe('', () => {
})
);
- const subject = (initialPreferences = {}) =>
- reduxRender(, {
- initialState: {
- preferences: {
- ...initialState,
- ...initialPreferences
+ const subject = (initialPreferences = {}, indexContent) =>
+ reduxRender(
+
+
+ ,
+ {
+ initialState: {
+ preferences: {
+ ...initialState,
+ ...initialPreferences
+ },
+ files: filesInitialState().map((file) => ({
+ ...file,
+ ...(indexContent &&
+ file.fileType === 'file' &&
+ file.name === 'index.html' &&
+ file.filePath === '' && { content: indexContent })
+ }))
}
}
- });
+ );
afterEach(() => {
jest.clearAllMocks();
@@ -485,6 +500,111 @@ describe('', () => {
);
});
});
+ describe('loop protection toggle', () => {
+ const getIndexFile = (store) =>
+ store
+ .getState()
+ .files.find((f) => f.fileType === 'file' && f.name === 'index.html');
+
+ const getOnRadio = () =>
+ screen.getByRole('radio', { name: /loop protection on/i });
+
+ const getOffRadio = () =>
+ screen.getByRole('radio', { name: /loop protection off/i });
+
+ it('is ON by default when no noprotect comment exists', () => {
+ subject();
+
+ expect(getOnRadio().checked).toBe(true);
+ expect(getOffRadio().checked).toBe(false);
+ });
+
+ it('is OFF when noprotect comment exists', () => {
+ subject(
+ {},
+ `
+
+
+
+
+`
+ );
+
+ expect(getOnRadio().checked).toBe(false);
+ expect(getOffRadio().checked).toBe(true);
+ });
+
+ it('adds noprotect comment when turning OFF', () => {
+ const { store } = subject();
+ const initialIndexFile = getIndexFile(store);
+ expect(initialIndexFile.content).not.toMatch(NO_PROTECT_REGEX);
+
+ act(() => {
+ fireEvent.click(getOffRadio());
+ });
+
+ const updatedIndexFile = getIndexFile(store);
+ expect(updatedIndexFile.content).toMatch(NO_PROTECT_REGEX);
+ expect(
+ updatedIndexFile.content.match(//g)?.length
+ ).toBe(1);
+ });
+
+ it('removes noprotect comment when turning ON', () => {
+ const { store } = subject(
+ {},
+ `
+
+
+
+
+`
+ );
+ const initialIndexFile = getIndexFile(store);
+ expect(initialIndexFile.content).toMatch(NO_PROTECT_REGEX);
+
+ act(() => {
+ fireEvent.click(getOnRadio());
+ });
+
+ const updatedIndexFile = getIndexFile(store);
+ expect(updatedIndexFile.content).not.toMatch(NO_PROTECT_REGEX);
+ });
+
+ it('does not change index.html when clicking already selected ON', () => {
+ const { store } = subject();
+ const initialIndexFile = getIndexFile(store);
+ const initialContent = initialIndexFile.content;
+
+ act(() => {
+ fireEvent.click(getOnRadio());
+ });
+
+ const updatedIndexFile = getIndexFile(store);
+ expect(updatedIndexFile.content).toBe(initialContent);
+ });
+
+ it('does not change index.html when clicking already selected OFF', () => {
+ const { store } = subject(
+ {},
+ `
+
+
+
+
+`
+ );
+ const initialIndexFile = getIndexFile(store);
+ const initialContent = initialIndexFile.content;
+
+ act(() => {
+ fireEvent.click(getOffRadio());
+ });
+
+ const updatedIndexFile = getIndexFile(store);
+ expect(updatedIndexFile.content).toBe(initialContent);
+ });
+ });
});
describe('can toggle between general settings and accessibility tabs successfully', () => {
diff --git a/client/modules/IDE/components/Preferences/index.jsx b/client/modules/IDE/components/Preferences/index.jsx
index a2fa59496d..5eb55cbbc6 100644
--- a/client/modules/IDE/components/Preferences/index.jsx
+++ b/client/modules/IDE/components/Preferences/index.jsx
@@ -29,6 +29,7 @@ import { CmControllerContext } from '../../pages/IDEView';
import Stars from '../Stars';
import Admonition from '../Admonition';
import TextArea from '../TextArea';
+import { hasNoProtect, toggleLoopProtection } from '../../utils/loopProtection';
export default function Preferences() {
const { t } = useTranslation();
@@ -53,6 +54,15 @@ export default function Preferences() {
const { versionInfo, indexID } = useP5Version();
const cmRef = useContext(CmControllerContext);
const [showStars, setShowStars] = useState(null);
+ const files = useSelector((s) => s.files);
+ const indexFile = files.find(
+ (file) =>
+ file.fileType === 'file' &&
+ file.name === 'index.html' &&
+ file.filePath === ''
+ );
+ const indexSrc = indexFile?.content;
+ const loopProtection = useMemo(() => !hasNoProtect(indexSrc), [indexSrc]);
const timerRef = useRef(null);
const pickerRef = useRef(null);
const onChangeVersion = (version) => {
@@ -115,6 +125,15 @@ export default function Preferences() {
cmRef.current?.updateFileContent(indexID, src);
};
+ function handleLoopProtection(enabled) {
+ if (!indexID || !indexSrc) return;
+
+ const next = toggleLoopProtection(indexSrc, enabled);
+ if (next === indexSrc) return;
+
+ updateHTML(next);
+ }
+
const markdownComponents = useMemo(() => {
// eslint-disable-next-line react/no-unstable-nested-components
const ExternalLink = ({ children, ...props }) => (
@@ -414,6 +433,42 @@ export default function Preferences() {
+
+
+ {t('Preferences.LoopProtection')}
+
+
+
diff --git a/client/modules/IDE/utils/loopProtection.js b/client/modules/IDE/utils/loopProtection.js
new file mode 100644
index 0000000000..49e591496a
--- /dev/null
+++ b/client/modules/IDE/utils/loopProtection.js
@@ -0,0 +1,18 @@
+export const NO_PROTECT_REGEX = /^\s*\s*\n?/m;
+
+export function hasNoProtect(src = '') {
+ return NO_PROTECT_REGEX.test(src);
+}
+
+export function addNoProtect(src = '') {
+ if (hasNoProtect(src)) return src;
+ return `\n${src}`;
+}
+
+export function removeNoProtect(src = '') {
+ return src.replace(NO_PROTECT_REGEX, '');
+}
+
+export function toggleLoopProtection(src = '', enabled) {
+ return enabled ? removeNoProtect(src) : addNoProtect(src);
+}
diff --git a/client/modules/Preview/EmbedFrame.jsx b/client/modules/Preview/EmbedFrame.jsx
index 02c766bd47..2b1a00ca1b 100644
--- a/client/modules/Preview/EmbedFrame.jsx
+++ b/client/modules/Preview/EmbedFrame.jsx
@@ -32,6 +32,10 @@ const Frame = styled.iframe`
`}
`;
+function getHtmlFile(files) {
+ return files.filter((file) => file.name.match(/.*\.html$/i))[0];
+}
+
function resolveCSSLinksInString(content, files) {
let newContent = content;
let cssFileStrings = content.match(STRING_REGEX);
@@ -55,6 +59,8 @@ function resolveCSSLinksInString(content, files) {
}
function resolveJSLinksInString(content, files) {
+ const indexFile = getHtmlFile(files);
+ const indexSrc = indexFile?.content;
let newContent = content;
let jsFileStrings = content.match(STRING_REGEX);
jsFileStrings = jsFileStrings || [];
@@ -80,7 +86,7 @@ function resolveJSLinksInString(content, files) {
}
});
- return jsPreprocess(newContent);
+ return jsPreprocess(newContent, indexSrc);
}
function resolveScripts(sketchDoc, files) {
@@ -166,11 +172,11 @@ function resolveJSAndCSSLinks(files) {
return newFiles;
}
-function addLoopProtect(sketchDoc) {
+function addLoopProtect(sketchDoc, indexSrc) {
const scriptsInHTML = sketchDoc.getElementsByTagName('script');
const scriptsInHTMLArray = Array.prototype.slice.call(scriptsInHTML);
scriptsInHTMLArray.forEach((script) => {
- script.innerHTML = jsPreprocess(script.innerHTML); // eslint-disable-line
+ script.innerHTML = jsPreprocess(script.innerHTML, indexSrc); // eslint-disable-line
});
}
@@ -182,6 +188,8 @@ function injectLocalFiles(files, htmlFile, options) {
const resolvedFiles = resolveJSAndCSSLinks(files);
const parser = new DOMParser();
const sketchDoc = parser.parseFromString(htmlFile.content, 'text/html');
+ const indexFile = getHtmlFile(files);
+ const indexSrc = indexFile?.content;
const base = sketchDoc.createElement('base');
base.href = `${window.origin}${basePath}${basePath.length > 1 && '/'}`;
@@ -229,16 +237,12 @@ p5.prototype.registerMethod('afterSetup', p5.prototype.ensureAccessibleCanvas);`
window.objectPaths = ${JSON.stringify(objectPaths)};
window.editorOrigin = '${getConfig('EDITOR_URL')}';
`;
- addLoopProtect(sketchDoc);
+ addLoopProtect(sketchDoc, indexSrc);
sketchDoc.head.prepend(consoleErrorsScript);
return `\n${sketchDoc.documentElement.outerHTML}`;
}
-function getHtmlFile(files) {
- return files.filter((file) => file.name.match(/.*\.html$/i))[0];
-}
-
function EmbedFrame({ files, isPlaying, basePath, gridOutput, textOutput }) {
const iframe = useRef();
const htmlFile = useMemo(() => getHtmlFile(files), [files]);
diff --git a/client/modules/Preview/jsPreprocess.js b/client/modules/Preview/jsPreprocess.js
index 6acd46e6e8..e417285655 100644
--- a/client/modules/Preview/jsPreprocess.js
+++ b/client/modules/Preview/jsPreprocess.js
@@ -1,6 +1,7 @@
import * as acorn from 'acorn';
import * as walk from 'acorn-walk';
import escodegen from 'escodegen';
+import { hasNoProtect } from '../IDE/utils/loopProtection';
const LOOP_TIMEOUT_MS = 100;
@@ -201,8 +202,8 @@ function parseJs(jsText) {
}
}
-export function jsPreprocess(jsText) {
- if (/\/\/\s*noprotect/.test(jsText)) {
+export function jsPreprocess(jsText, indexSrc) {
+ if (hasNoProtect(indexSrc)) {
return jsText;
}
diff --git a/client/styles/components/_overlay.scss b/client/styles/components/_overlay.scss
index 61828b68dd..ccedadc914 100644
--- a/client/styles/components/_overlay.scss
+++ b/client/styles/components/_overlay.scss
@@ -8,7 +8,6 @@
bottom: 0;
z-index: 9999;
background-color: rgba(0, 0, 0, 0.5);
- overflow-y: hidden;
}
.overlay__content {
diff --git a/translations/locales/en-US/translations.json b/translations/locales/en-US/translations.json
index a1c17007c8..1144c18671 100644
--- a/translations/locales/en-US/translations.json
+++ b/translations/locales/en-US/translations.json
@@ -219,6 +219,9 @@
"WordWrap": "Word Wrap",
"WordWrapOnARIA": "wordwrap on",
"WordWrapOffARIA": "wordwrap off",
+ "LoopProtection": "Loop Protection",
+ "LoopProtectionOnARIA": "loop protection on",
+ "LoopProtectionOffARIA": "loop protection off",
"LineNumbers": "Line numbers",
"LineNumbersOnARIA": "line numbers on",
"LineNumbersOffARIA": "line numbers off",