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')} +

+
+ handleLoopProtection(true)} + aria-label={t('Preferences.LoopProtectionOnARIA')} + name="loopprotection" + id="loopprotection-on" + className="preference__radio-button" + value="On" + checked={loopProtection} + /> + + handleLoopProtection(false)} + aria-label={t('Preferences.LoopProtectionOffARIA')} + name="loopprotection" + id="loopprotection-off" + className="preference__radio-button" + value="Off" + checked={!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",