diff --git a/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx b/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx
index fe414ffb58..552d2795cf 100644
--- a/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx
+++ b/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx
@@ -1,63 +1,22 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
-import { fireEvent, render, screen } from '../../../../test-utils';
+import { fireEvent, reduxRender, screen } from '../../../../test-utils';
import Preferences from './index';
-
-/* props to pass in:
- * - this.props.fontSize : number
- * - this.props.autosave : bool
- * - this.props.autocloseBracketsQuotes : bool
- * - this.props.autocompleteHinter : bool
- * - this.props.linewrap : bool
- * - this.props.lineNumbers : bool
- * - this.props.theme : string
- * - this.props.lintWarning : bool
- * - this.props.textOutput : bool
- * - this.props.gridOutput : bool
- * - this.props.soundOutput : bool
- * - t from internationalization
- *
- * - this.props.setFontSize(fontsize : number)
- * - this.props.setAutosave(value : bool)
- * - this.props.setAutocloseBracketsQuotes(value: bool)
- * - this.props.setLinewrap(value : bool)
- * - this.props.setLineNumbers(value : bool)
- * - this.props.setTheme(color : string) -> can be {"light", "dark", "contrast"}
- * - this.props.setLintWarning(value : bool)
- * - this.props.setTextOutput(value : bool)
- * - this.props.setGridOutput(value : bool)
- * - this.props.setSoundOutput(value : bool)
- * -
- */
+import * as PreferencesActions from '../../actions/preferences';
describe('
', () => {
- let props = {
- t: jest.fn(),
- fontSize: 12,
- autosave: false,
- autocloseBracketsQuotes: false,
- autocompleteHinter: false,
- linewrap: false,
- lineNumbers: false,
- theme: 'contrast',
- lintWarning: false,
- textOutput: false,
- gridOutput: false,
- soundOutput: false,
- setFontSize: jest.fn(),
- setAutosave: jest.fn(),
- setAutocloseBracketsQuotes: jest.fn(),
- setAutocompleteHinter: jest.fn(),
- setLinewrap: jest.fn(),
- setLineNumbers: jest.fn(),
- setTheme: jest.fn(),
- setLintWarning: jest.fn(),
- setTextOutput: jest.fn(),
- setGridOutput: jest.fn(),
- setSoundOutput: jest.fn()
- };
+ // For backwards compatibility, spy on each action creator to see when it was dispatched.
+ const props = Object.fromEntries(
+ Object.keys(PreferencesActions).map((name) => {
+ const spied = jest.spyOn(PreferencesActions, name);
+ return [name, spied];
+ })
+ );
- const subject = () => render(
);
+ const subject = (initialPreferences = {}) =>
+ reduxRender(
, {
+ initialState: { preferences: initialPreferences }
+ });
afterEach(() => {
jest.clearAllMocks();
@@ -81,9 +40,7 @@ describe('
', () => {
it('increase font size by 2 when clicking plus button', () => {
// render the component with font size set to 12
- act(() => {
- subject();
- });
+ const { store } = subject({ fontSize: 12 });
// get ahold of the button for increasing text size
const fontPlusButton = screen.getByRole('button', {
@@ -95,13 +52,16 @@ describe('
', () => {
fireEvent.click(fontPlusButton);
});
- // expect that setFontSize has been called once with the argument 14
- expect(props.setFontSize).toHaveBeenCalledTimes(1);
- expect(props.setFontSize.mock.calls[0][0]).toBe(14);
+ const fontSizeInput = screen.getByLabelText('Font Size');
+
+ // expect that the font size text input says "14"
+ expect(fontSizeInput.value).toBe('14');
+ // expect that the stored value is a number 14
+ expect(store.getState().preferences.fontSize).toBe(14);
});
it('font size decrease button says decrease', () => {
- // render the component with font size set to 12
+ // render the component
act(() => {
subject();
});
@@ -117,9 +77,7 @@ describe('
', () => {
it('decrease font size by 2 when clicking minus button', () => {
// render the component with font size set to 12
- act(() => {
- subject();
- });
+ const { store } = subject({ fontSize: 12 });
// get ahold of the button for decreasing text size
const fontMinusButton = screen.getByRole('button', {
@@ -131,19 +89,20 @@ describe('
', () => {
fireEvent.click(fontMinusButton);
});
- // expect that setFontSize would have been called once with argument 10
- expect(props.setFontSize).toHaveBeenCalledTimes(1);
- expect(props.setFontSize.mock.calls[0][0]).toBe(10);
+ const fontSizeInput = screen.getByLabelText('Font Size');
+
+ // expect that the font size text input says "10"
+ expect(fontSizeInput.value).toBe('10');
+ // expect that the stored value is a number 10
+ expect(store.getState().preferences.fontSize).toBe(10);
});
it('font text field changes on manual text input', () => {
// render the component with font size set to 12
- act(() => {
- subject();
- });
+ const { store } = subject({ fontSize: 12 });
// get ahold of the text field
- const input = screen.getByRole('textbox', { name: /font size/i });
+ const input = screen.getByLabelText('Font Size');
// change input to 24
act(() => {
@@ -159,19 +118,16 @@ describe('
', () => {
);
});
- // expect that setFontSize was called once with 24
- expect(props.setFontSize).toHaveBeenCalledTimes(1);
- expect(props.setFontSize.mock.calls[0][0]).toBe(24);
+ // expect that the font size is now 24
+ expect(store.getState().preferences.fontSize).toBe(24);
});
it('font size CAN NOT go over 36', () => {
// render the component
- act(() => {
- subject();
- });
+ const { store } = subject();
// get ahold of the text field
- const input = screen.getByRole('textbox', { name: /font size/i });
+ const input = screen.getByLabelText('Font Size');
act(() => {
fireEvent.change(input, { target: { value: '100' } });
@@ -187,18 +143,15 @@ describe('
', () => {
);
});
- expect(props.setFontSize).toHaveBeenCalledTimes(1);
- expect(props.setFontSize.mock.calls[0][0]).toBe(36);
+ expect(store.getState().preferences.fontSize).toBe(36);
});
it('font size CAN NOT go under 8', () => {
// render the component
- act(() => {
- subject();
- });
+ const { store } = subject();
// get ahold of the text field
- const input = screen.getByRole('textbox', { name: /font size/i });
+ const input = screen.getByLabelText('Font Size');
act(() => {
fireEvent.change(input, { target: { value: '0' } });
@@ -214,20 +167,17 @@ describe('
', () => {
);
});
- expect(props.setFontSize).toHaveBeenCalledTimes(1);
- expect(props.setFontSize.mock.calls[0][0]).toBe(8);
+ expect(store.getState().preferences.fontSize).toBe(8);
});
// this case is a bit synthetic because we wouldn't be able to type
// h and then i, but it tests the same idea
it('font size input field does NOT take non-integers', () => {
// render the component
- act(() => {
- subject();
- });
+ const { store } = subject({ fontSize: 12 });
// get ahold of the text field
- const input = screen.getByRole('textbox', { name: /font size/i });
+ const input = screen.getByLabelText('Font Size');
act(() => {
fireEvent.change(input, { target: { value: 'hi' } });
@@ -246,18 +196,15 @@ describe('
', () => {
});
// it still sets the font size but it's still 12
- expect(props.setFontSize).toHaveBeenCalledTimes(1);
- expect(props.setFontSize.mock.calls[0][0]).toBe(12);
+ expect(store.getState().preferences.fontSize).toBe(12);
});
it('font size input field does NOT take "-"', () => {
// render the component
- act(() => {
- subject();
- });
+ const { store } = subject({ fontSize: 12 });
// get ahold of the text field
- const input = screen.getByRole('textbox', { name: /font size/i });
+ const input = screen.getByLabelText('Font Size');
act(() => {
fireEvent.change(input, { target: { value: '-' } });
@@ -273,8 +220,7 @@ describe('
', () => {
);
});
- expect(props.setFontSize).toHaveBeenCalledTimes(1);
- expect(props.setFontSize.mock.calls[0][0]).toBe(12);
+ expect(store.getState().preferences.fontSize).toBe(12);
});
});
@@ -311,13 +257,9 @@ describe('
', () => {
describe('testing theme switching', () => {
describe('dark mode', () => {
- beforeAll(() => {
- props.theme = 'dark';
- });
-
it('switch to light', () => {
act(() => {
- subject();
+ subject({ theme: 'dark' });
});
const themeRadioCurrent = screen.getByRole('radio', {
@@ -338,13 +280,9 @@ describe('
', () => {
});
describe('light mode', () => {
- beforeAll(() => {
- props.theme = 'light';
- });
-
it('switch to dark', () => {
act(() => {
- subject();
+ subject({ theme: 'light' });
});
const themeRadioCurrent = screen.getByRole('radio', {
@@ -365,7 +303,7 @@ describe('
', () => {
it('switch to contrast', () => {
act(() => {
- subject();
+ subject({ theme: 'light' });
});
const themeRadioCurrent = screen.getByRole('radio', {
name: /light theme on/i
@@ -388,7 +326,7 @@ describe('
', () => {
describe('testing toggle UI elements on starting tab', () => {
it('autosave toggle, starting at false', () => {
act(() => {
- subject();
+ subject({ autosave: false });
});
// get ahold of the radio buttons for toggling autosave
@@ -410,7 +348,7 @@ describe('
', () => {
it('autocloseBracketsQuotes toggle, starting at false', () => {
// render the component with autocloseBracketsQuotes prop set to false
act(() => {
- subject();
+ subject({ autocloseBracketsQuotes: false });
});
// get ahold of the radio buttons for toggling autocloseBracketsQuotes
@@ -452,14 +390,10 @@ describe('
', () => {
});
describe('start autosave value at true', () => {
- beforeAll(() => {
- props.autosave = true;
- });
-
it('autosave toggle, starting at true', () => {
// render the component with autosave prop set to true
act(() => {
- subject();
+ subject({ autosave: true });
});
// get ahold of the radio buttons for toggling autosave
@@ -480,13 +414,9 @@ describe('
', () => {
});
describe('start autoclose brackets value at true', () => {
- beforeAll(() => {
- props.autocloseBracketsQuotes = true;
- });
-
it('autocloseBracketsQuotes toggle, starting at true', () => {
act(() => {
- subject();
+ subject({ autocloseBracketsQuotes: true });
});
// get ahold of the radio buttons for toggling autocloseBracketsQuotes
@@ -535,14 +465,10 @@ describe('
', () => {
});
describe('start linewrap at false', () => {
- beforeAll(() => {
- props.linewrap = false;
- });
-
it('linewrap toggle, starting at false', () => {
// render the component with linewrap prop set to false
act(() => {
- subject();
+ subject({ linewrap: false });
});
// get ahold of the radio buttons for toggling linewrap
@@ -563,14 +489,10 @@ describe('
', () => {
});
describe('start linewrap at true', () => {
- beforeAll(() => {
- props.linewrap = true;
- });
-
it('linewrap toggle, starting at true', () => {
// render the component with linewrap prop set to false
act(() => {
- subject();
+ subject({ linewrap: true });
});
// get ahold of the radio buttons for toggling linewrap
@@ -626,14 +548,10 @@ describe('
', () => {
describe('testing toggle UI elements on accessibility tab', () => {
describe('starting linenumbers at false', () => {
- beforeAll(() => {
- props.lineNumbers = false;
- });
-
it('lineNumbers toggle, starting at false', () => {
// render the component with lineNumbers prop set to false
act(() => {
- subject();
+ subject({ lineNumbers: false });
});
// switch tabs
@@ -661,14 +579,10 @@ describe('
', () => {
});
describe('starting linenumbers at true', () => {
- beforeAll(() => {
- props.lineNumbers = true;
- });
-
it('lineNumbers toggle, starting at true', () => {
// render the component with lineNumbers prop set to false
act(() => {
- subject();
+ subject({ lineNumbers: true });
});
// switch tabs
@@ -696,14 +610,10 @@ describe('
', () => {
});
describe('starting lintWarning at false', () => {
- beforeAll(() => {
- props.lintWarning = false;
- });
-
it('lintWarning toggle, starting at false', () => {
// render the component with lintWarning prop set to false
act(() => {
- subject();
+ subject({ lintWarning: false });
});
// switch tabs
@@ -731,14 +641,10 @@ describe('
', () => {
});
describe('starting lintWarning at true', () => {
- beforeAll(() => {
- props.lintWarning = true;
- });
-
it('lintWarning toggle, starting at true', () => {
// render the component with lintWarning prop set to false
act(() => {
- subject();
+ subject({ lintWarning: true });
});
// switch tabs
@@ -766,15 +672,12 @@ describe('
', () => {
});
const testCheckbox = (arialabel, startState, setter) => {
- props = {
- ...props,
- textOutput: startState && arialabel === 'text output on',
- soundOutput: startState && arialabel === 'sound output on',
- gridOutput: startState && arialabel === 'table output on'
- };
-
act(() => {
- subject();
+ subject({
+ textOutput: startState && arialabel === 'text output on',
+ soundOutput: startState && arialabel === 'sound output on',
+ gridOutput: startState && arialabel === 'table output on'
+ });
});
// switch tabs
@@ -817,17 +720,12 @@ describe('
', () => {
});
describe('multiple checkboxes can be selected', () => {
- beforeAll(() => {
- props = {
- ...props,
- textOutput: true,
- gridOutput: true
- };
- });
-
it('multiple checkboxes can be selected', () => {
act(() => {
- subject();
+ subject({
+ textOutput: true,
+ gridOutput: true
+ });
});
// switch tabs
@@ -850,16 +748,12 @@ describe('
', () => {
});
describe('none of the checkboxes can be selected', () => {
- beforeAll(() => {
- props = {
- ...props,
- textOutput: false,
- gridOutput: false
- };
- });
it('none of the checkboxes can be selected', () => {
act(() => {
- subject();
+ subject({
+ textOutput: false,
+ gridOutput: false
+ });
});
// switch tabs
diff --git a/client/modules/IDE/components/Preferences/index.jsx b/client/modules/IDE/components/Preferences/index.jsx
index b72b108cde..fb52c6f075 100644
--- a/client/modules/IDE/components/Preferences/index.jsx
+++ b/client/modules/IDE/components/Preferences/index.jsx
@@ -1,523 +1,479 @@
+import classNames from 'classnames';
+import clamp from 'lodash/clamp';
import PropTypes from 'prop-types';
-import React from 'react';
+import React, { useEffect, useRef, useState } from 'react';
import { Helmet } from 'react-helmet';
+import { useDispatch, useSelector } from 'react-redux';
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
-import { withTranslation } from 'react-i18next';
-// import { bindActionCreators } from 'redux';
-// import { connect } from 'react-redux';
-// import * as PreferencesActions from '../actions/preferences';
-
+import { useTranslation } from 'react-i18next';
import PlusIcon from '../../../../images/plus.svg';
import MinusIcon from '../../../../images/minus.svg';
import beepUrl from '../../../../sounds/audioAlert.mp3';
+import {
+ setTheme,
+ setAutosave,
+ setTextOutput,
+ setGridOutput,
+ setFontSize,
+ setLineNumbers,
+ setLintWarning,
+ setAutocloseBracketsQuotes,
+ setLinewrap,
+ setAutocompleteHinter
+} from '../../actions/preferences';
-class Preferences extends React.Component {
- constructor(props) {
- super(props);
- this.handleUpdateAutosave = this.handleUpdateAutosave.bind(this);
- this.handleUpdateLinewrap = this.handleUpdateLinewrap.bind(this);
- this.handleLintWarning = this.handleLintWarning.bind(this);
- this.handleLineNumbers = this.handleLineNumbers.bind(this);
- this.onFontInputChange = this.onFontInputChange.bind(this);
- this.onFontInputSubmit = this.onFontInputSubmit.bind(this);
- this.increaseFontSize = this.increaseFontSize.bind(this);
- this.decreaseFontSize = this.decreaseFontSize.bind(this);
- this.setFontSize = this.setFontSize.bind(this);
-
- this.state = {
- fontSize: props.fontSize
- };
- }
-
- onFontInputChange(event) {
- const INTEGER_REGEX = /^[0-9\b]+$/;
- if (event.target.value === '' || INTEGER_REGEX.test(event.target.value)) {
- this.setState({
- fontSize: event.target.value
- });
- }
- }
-
- onFontInputSubmit(event) {
- event.preventDefault();
- let value = parseInt(this.state.fontSize, 10);
- if (Number.isNaN(value)) {
- value = 16;
- }
- if (value > 36) {
- value = 36;
- }
- if (value < 8) {
- value = 8;
- }
- this.setFontSize(value);
- }
+export default function Preferences({ className }) {
+ const { t } = useTranslation();
- setFontSize(value) {
- this.setState({ fontSize: value });
- this.props.setFontSize(value);
- }
+ const dispatch = useDispatch();
- decreaseFontSize() {
- const newValue = Number(this.state.fontSize) - 2;
- this.setFontSize(newValue);
- }
+ const {
+ fontSize,
+ autosave,
+ linewrap,
+ lineNumbers,
+ lintWarning,
+ textOutput,
+ gridOutput,
+ theme,
+ autocloseBracketsQuotes,
+ autocompleteHinter
+ } = useSelector((state) => state.preferences);
- increaseFontSize() {
- const newValue = Number(this.state.fontSize) + 2;
- this.setFontSize(newValue);
- }
+ const validateFontSize = (number) => {
+ if (Number.isNaN(number)) return 16;
+ return clamp(number, 8, 36);
+ };
- handleUpdateAutosave(event) {
- const value = event.target.value === 'true';
- this.props.setAutosave(value);
- }
+ // The current contents of the input, which may be invalid. String or number.
+ const [fontSizeString, setFontSizeString] = useState(fontSize);
+ const fontSizeNumber = parseInt(fontSizeString, 10);
- handleUpdateLinewrap(event) {
- const value = event.target.value === 'true';
- this.props.setLinewrap(value);
- }
+ // Immediately submit any valid fontSize input
+ useEffect(() => {
+ if (validateFontSize(fontSizeNumber) === fontSizeNumber) {
+ dispatch(setFontSize(fontSizeNumber));
+ }
+ }, [fontSizeNumber]);
- handleLintWarning(event) {
- const value = event.target.value === 'true';
- this.props.setLintWarning(value);
- }
+ // Handler for increase and decrease.
+ const updateFontSize = (value) => {
+ const valid = validateFontSize(value);
+ setFontSizeString(valid);
+ dispatch(setFontSize(valid));
+ };
- handleLineNumbers(event) {
- const value = event.target.value === 'true';
- this.props.setLineNumbers(value);
- }
+ const onFontInputSubmit = (event) => {
+ event.preventDefault();
+ const valid = validateFontSize(fontSizeNumber);
+ dispatch(setFontSize(valid));
+ };
- render() {
- const beep = new Audio(beepUrl);
+ const fontSizeInputRef = useRef();
- return (
-
-
- p5.js Web Editor | Preferences
-
-
-
-
-
-
- {this.props.t('Preferences.GeneralSettings')}
-
-
-
-
- {this.props.t('Preferences.Accessibility')}
-
-
-
-
-
-
-
- {this.props.t('Preferences.Theme')}
+ return (
+
+
+ p5.js Web Editor | Preferences
+
+
+
+
-
-
- {this.props.t('Preferences.TextSize')}
-
-
+
+
+
+
{t('Preferences.Theme')}
+
-
+
+
{t('Preferences.TextSize')}
+
+
+
+
+
+
{t('Preferences.Autosave')}
+
+ dispatch(setAutosave(true))}
+ aria-label={t('Preferences.AutosaveOnARIA')}
+ name="autosave"
+ id="autosave-on"
+ className="preference__radio-button"
+ value="On"
+ checked={autosave}
+ />
+
+ dispatch(setAutosave(false))}
+ aria-label={t('Preferences.AutosaveOffARIA')}
+ name="autosave"
+ id="autosave-off"
+ className="preference__radio-button"
+ value="Off"
+ checked={!autosave}
+ />
+
-
+
+
+ {t('Preferences.AutocloseBracketsQuotes')}
+
+
+ dispatch(setAutocloseBracketsQuotes(true))}
+ aria-label={t('Preferences.AutocloseBracketsQuotesOnARIA')}
+ name="autoclosebracketsquotes"
+ id="autoclosebracketsquotes-on"
+ className="preference__radio-button"
+ value="On"
+ checked={autocloseBracketsQuotes}
+ />
+
+ dispatch(setAutocloseBracketsQuotes(false))}
+ aria-label={t('Preferences.AutocloseBracketsQuotesOffARIA')}
+ name="autoclosebracketsquotes"
+ id="autoclosebracketsquotes-off"
+ className="preference__radio-button"
+ value="Off"
+ checked={!autocloseBracketsQuotes}
+ />
+
-
+
+
+ {t('Preferences.AutocompleteHinter')}
+
+
+ dispatch(setAutocompleteHinter(true))}
+ aria-label={t('Preferences.AutocompleteHinterOnARIA')}
+ name="autocompletehinter"
+ id="autocompletehinter-on"
+ className="preference__radio-button"
+ value="On"
+ checked={autocompleteHinter}
+ />
+
+ dispatch(setAutocompleteHinter(false))}
+ aria-label={t('Preferences.AutocompleteHinterOffARIA')}
+ name="autocompletehinter"
+ id="autocompletehinter-off"
+ className="preference__radio-button"
+ value="Off"
+ checked={!autocompleteHinter}
+ />
+
-
+
+
{t('Preferences.WordWrap')}
+
+ dispatch(setLinewrap(true))}
+ aria-label={t('Preferences.LineWrapOnARIA')}
+ name="linewrap"
+ id="linewrap-on"
+ className="preference__radio-button"
+ value="On"
+ checked={linewrap}
+ />
+
+ dispatch(setLinewrap(false))}
+ aria-label={t('Preferences.LineWrapOffARIA')}
+ name="linewrap"
+ id="linewrap-off"
+ className="preference__radio-button"
+ value="Off"
+ checked={!linewrap}
+ />
+
-
-
-
+
+
+
+
+ {t('Preferences.LineNumbers')}
+
+
+ dispatch(setLineNumbers(true))}
+ aria-label={t('Preferences.LineNumbersOnARIA')}
+ name="line numbers"
+ id="line-numbers-on"
+ className="preference__radio-button"
+ value="On"
+ checked={lineNumbers}
+ />
+
+ dispatch(setLineNumbers(false))}
+ aria-label={t('Preferences.LineNumbersOffARIA')}
+ name="line numbers"
+ id="line-numbers-off"
+ className="preference__radio-button"
+ value="Off"
+ checked={!lineNumbers}
+ />
+
-
+
+
+ {t('Preferences.LintWarningSound')}
+
+
+ dispatch(setLintWarning(true))}
+ aria-label={t('Preferences.LintWarningOnARIA')}
+ name="lint warning"
+ id="lint-warning-on"
+ className="preference__radio-button"
+ value="On"
+ checked={lintWarning}
+ />
+
+ dispatch(setLintWarning(false))}
+ aria-label={t('Preferences.LintWarningOffARIA')}
+ name="lint warning"
+ id="lint-warning-off"
+ className="preference__radio-button"
+ value="Off"
+ checked={!lintWarning}
+ />
+
+
-
-
- {this.props.t('Preferences.AccessibleTextBasedCanvas')}
-
-
- {this.props.t('Preferences.UsedScreenReader')}
-
+
+
+
+
+
+ );
}
Preferences.propTypes = {
- fontSize: PropTypes.number.isRequired,
- lineNumbers: PropTypes.bool.isRequired,
- setFontSize: PropTypes.func.isRequired,
- autosave: PropTypes.bool.isRequired,
- linewrap: PropTypes.bool.isRequired,
- setLineNumbers: PropTypes.func.isRequired,
- setAutosave: PropTypes.func.isRequired,
- setLinewrap: PropTypes.func.isRequired,
- textOutput: PropTypes.bool.isRequired,
- gridOutput: PropTypes.bool.isRequired,
- setTextOutput: PropTypes.func.isRequired,
- setGridOutput: PropTypes.func.isRequired,
- lintWarning: PropTypes.bool.isRequired,
- setLintWarning: PropTypes.func.isRequired,
- theme: PropTypes.string.isRequired,
- setTheme: PropTypes.func.isRequired,
- autocloseBracketsQuotes: PropTypes.bool.isRequired,
- setAutocloseBracketsQuotes: PropTypes.func.isRequired,
- autocompleteHinter: PropTypes.bool.isRequired,
- setAutocompleteHinter: PropTypes.func.isRequired,
- t: PropTypes.func.isRequired
+ // fontSize: PropTypes.number.isRequired,
+ // lineNumbers: PropTypes.bool.isRequired,
+ // setFontSize: PropTypes.func.isRequired,
+ // autosave: PropTypes.bool.isRequired,
+ // linewrap: PropTypes.bool.isRequired,
+ // setLineNumbers: PropTypes.func.isRequired,
+ // setAutosave: PropTypes.func.isRequired,
+ // setLinewrap: PropTypes.func.isRequired,
+ // textOutput: PropTypes.bool.isRequired,
+ // gridOutput: PropTypes.bool.isRequired,
+ // setTextOutput: PropTypes.func.isRequired,
+ // setGridOutput: PropTypes.func.isRequired,
+ // lintWarning: PropTypes.bool.isRequired,
+ // setLintWarning: PropTypes.func.isRequired,
+ // theme: PropTypes.string.isRequired,
+ // setTheme: PropTypes.func.isRequired,
+ // autocloseBracketsQuotes: PropTypes.bool.isRequired,
+ // setAutocloseBracketsQuotes: PropTypes.func.isRequired,
+ // autocompleteHinter: PropTypes.bool.isRequired,
+ // setAutocompleteHinter: PropTypes.func.isRequired,
+ // t: PropTypes.func.isRequired,
+ className: PropTypes.string
};
-export default withTranslation()(Preferences);
+Preferences.defaultProps = {
+ className: ''
+};
diff --git a/client/modules/IDE/components/PreviewFrame.jsx b/client/modules/IDE/components/PreviewFrame.jsx
index f14d29c871..a635c5f71a 100644
--- a/client/modules/IDE/components/PreviewFrame.jsx
+++ b/client/modules/IDE/components/PreviewFrame.jsx
@@ -9,9 +9,10 @@ const Frame = styled.iframe`
min-width: 100%;
position: ${(props) => (props.fullView ? 'relative' : 'absolute')};
border-width: 0;
+ display: ${(props) => (props.hide ? 'none' : 'block')};
`;
-function PreviewFrame({ fullView }) {
+function PreviewFrame({ fullView, hide }) {
const iframe = useRef();
const previewUrl = getConfig('PREVIEW_URL');
useEffect(() => {
@@ -40,16 +41,19 @@ function PreviewFrame({ fullView }) {
frameBorder="0"
ref={iframe}
fullView={fullView}
+ hide={hide}
/>
);
}
PreviewFrame.propTypes = {
- fullView: PropTypes.bool
+ fullView: PropTypes.bool,
+ hide: PropTypes.bool
};
PreviewFrame.defaultProps = {
- fullView: false
+ fullView: false,
+ hide: false
};
export default PreviewFrame;
diff --git a/client/modules/IDE/components/QuickAddList/QuickAddList.jsx b/client/modules/IDE/components/QuickAddList/QuickAddList.jsx
index e3bbb6be5d..e276538bad 100644
--- a/client/modules/IDE/components/QuickAddList/QuickAddList.jsx
+++ b/client/modules/IDE/components/QuickAddList/QuickAddList.jsx
@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
-import { Link } from 'react-router';
+import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import Icons from './Icons';
@@ -11,7 +11,8 @@ const Item = ({ isAdded, onSelect, name, url }) => {
? t('QuickAddList.ButtonRemoveARIA')
: t('QuickAddList.ButtonAddToCollectionARIA');
return (
-
{ /* eslint-disable-line */ }
+ // eslint-disable-next-line
+
);
-};
-
-ShareModal.propTypes = {
- projectId: PropTypes.string.isRequired,
- ownerUsername: PropTypes.string.isRequired,
- projectName: PropTypes.string.isRequired
-};
-
-export default ShareModal;
+}
diff --git a/client/modules/IDE/components/Sidebar.jsx b/client/modules/IDE/components/Sidebar.jsx
index 1553c40361..5307e21b43 100644
--- a/client/modules/IDE/components/Sidebar.jsx
+++ b/client/modules/IDE/components/Sidebar.jsx
@@ -1,178 +1,131 @@
-import PropTypes from 'prop-types';
-import React from 'react';
+import React, { useRef, useState } from 'react';
import classNames from 'classnames';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
+import { useDispatch, useSelector } from 'react-redux';
+import {
+ closeProjectOptions,
+ newFile,
+ newFolder,
+ openProjectOptions,
+ openUploadFileModal
+} from '../actions/ide';
+import { getAuthenticated, selectCanEditSketch } from '../selectors/users';
+import { selectRootFile } from '../selectors/files';
import ConnectedFileNode from './FileNode';
import DownArrowIcon from '../../../images/down-filled-triangle.svg';
-class Sidebar extends React.Component {
- constructor(props) {
- super(props);
- this.resetSelectedFile = this.resetSelectedFile.bind(this);
- this.toggleProjectOptions = this.toggleProjectOptions.bind(this);
- this.onBlurComponent = this.onBlurComponent.bind(this);
- this.onFocusComponent = this.onFocusComponent.bind(this);
+// TODO: use a generic Dropdown UI component
- this.state = {
- isFocused: false
- };
- }
+export default function SideBar() {
+ const { t } = useTranslation();
- onBlurComponent() {
- this.setState({ isFocused: false });
+ const dispatch = useDispatch();
+ const [isFocused, setIsFocused] = useState(false);
+
+ const rootFile = useSelector(selectRootFile);
+ const projectOptionsVisible = useSelector(
+ (state) => state.ide.projectOptionsVisible
+ );
+ const isExpanded = useSelector((state) => state.ide.sidebarIsExpanded);
+ const canEditProject = useSelector(selectCanEditSketch);
+ const isAuthenticated = useSelector(getAuthenticated);
+
+ const toggleRef = useRef(null);
+
+ const onBlurComponent = () => {
+ setIsFocused(false);
setTimeout(() => {
- if (!this.state.isFocused) {
- this.props.closeProjectOptions();
+ if (!isFocused) {
+ dispatch(closeProjectOptions());
}
}, 200);
- }
+ };
- onFocusComponent() {
- this.setState({ isFocused: true });
- }
+ const onFocusComponent = () => setIsFocused(true);
- resetSelectedFile() {
- this.props.setSelectedFile(this.props.files[1].id);
- }
-
- toggleProjectOptions(e) {
+ const toggleProjectOptions = (e) => {
e.preventDefault();
- if (this.props.projectOptionsVisible) {
- this.props.closeProjectOptions();
+ if (projectOptionsVisible) {
+ dispatch(closeProjectOptions());
} else {
- this.sidebarOptions.focus();
- this.props.openProjectOptions();
+ toggleRef.current?.focus();
+ dispatch(openProjectOptions());
}
- }
+ };
- userCanEditProject() {
- let canEdit;
- if (!this.props.owner) {
- canEdit = true;
- } else if (
- this.props.user.authenticated &&
- this.props.owner.id === this.props.user.id
- ) {
- canEdit = true;
- } else {
- canEdit = false;
- }
- return canEdit;
- }
-
- render() {
- const canEditProject = this.userCanEditProject();
- const sidebarClass = classNames({
- sidebar: true,
- 'sidebar--contracted': !this.props.isExpanded,
- 'sidebar--project-options': this.props.projectOptionsVisible,
- 'sidebar--cant-edit': !canEditProject
- });
- const rootFile = this.props.files.filter((file) => file.name === 'root')[0];
+ const sidebarClass = classNames({
+ sidebar: true,
+ 'sidebar--contracted': !isExpanded,
+ 'sidebar--project-options': projectOptionsVisible,
+ 'sidebar--cant-edit': !canEditProject
+ });
- return (
-
-
-
- {this.props.t('Sidebar.Title')}
-
-
-
-
+ return (
+
+
+
+ {t('Sidebar.Title')}
+
+
+
+
+ -
+
+
+ -
+
+
+ {isAuthenticated && (
-
- -
-
-
- {this.props.user.authenticated && (
- -
-
-
- )}
-
-
-
-
-
- );
- }
+ )}
+
+
+
+
+
+ );
}
-
-Sidebar.propTypes = {
- files: PropTypes.arrayOf(
- PropTypes.shape({
- name: PropTypes.string.isRequired,
- id: PropTypes.string.isRequired
- })
- ).isRequired,
- setSelectedFile: PropTypes.func.isRequired,
- isExpanded: PropTypes.bool.isRequired,
- projectOptionsVisible: PropTypes.bool.isRequired,
- newFile: PropTypes.func.isRequired,
- openProjectOptions: PropTypes.func.isRequired,
- closeProjectOptions: PropTypes.func.isRequired,
- newFolder: PropTypes.func.isRequired,
- openUploadFileModal: PropTypes.func.isRequired,
- owner: PropTypes.shape({
- id: PropTypes.string
- }),
- user: PropTypes.shape({
- id: PropTypes.string,
- authenticated: PropTypes.bool.isRequired
- }).isRequired,
- t: PropTypes.func.isRequired
-};
-
-Sidebar.defaultProps = {
- owner: undefined
-};
-
-export default withTranslation()(Sidebar);
diff --git a/client/modules/IDE/components/SketchList.jsx b/client/modules/IDE/components/SketchList.jsx
index 757bacd904..04fc8b5d5c 100644
--- a/client/modules/IDE/components/SketchList.jsx
+++ b/client/modules/IDE/components/SketchList.jsx
@@ -3,7 +3,7 @@ import React from 'react';
import { Helmet } from 'react-helmet';
import { withTranslation } from 'react-i18next';
import { connect } from 'react-redux';
-import { Link } from 'react-router';
+import { Link } from 'react-router-dom';
import { bindActionCreators } from 'redux';
import classNames from 'classnames';
import slugify from 'slugify';
@@ -11,7 +11,6 @@ import dates from '../../../utils/formatDate';
import * as ProjectActions from '../actions/project';
import * as ProjectsActions from '../actions/projects';
import * as CollectionsActions from '../actions/collections';
-import * as ToastActions from '../actions/toast';
import * as SortingActions from '../actions/sorting';
import * as IdeActions from '../actions/ide';
import getSortedSketches from '../selectors/projects';
@@ -295,24 +294,22 @@ class SketchListRowBase extends React.Component {
);
return (
-
-
- {name} |
-
- {mobile && 'Created: '}
- {formatDateCell(sketch.createdAt, mobile)}
- |
-
- {mobile && 'Updated: '}
- {formatDateCell(sketch.updatedAt, mobile)}
- |
- {this.renderDropdown()}
-
-
+
+ {name} |
+
+ {mobile && 'Created: '}
+ {formatDateCell(sketch.createdAt, mobile)}
+ |
+
+ {mobile && 'Updated: '}
+ {formatDateCell(sketch.updatedAt, mobile)}
+ |
+ {this.renderDropdown()}
+
);
}
}
@@ -591,13 +588,7 @@ function mapStateToProps(state) {
function mapDispatchToProps(dispatch) {
return bindActionCreators(
- Object.assign(
- {},
- ProjectsActions,
- CollectionsActions,
- ToastActions,
- SortingActions
- ),
+ Object.assign({}, ProjectsActions, CollectionsActions, SortingActions),
dispatch
);
}
diff --git a/client/modules/IDE/components/Toolbar.jsx b/client/modules/IDE/components/Toolbar.jsx
deleted file mode 100644
index 92d6bcb5d0..0000000000
--- a/client/modules/IDE/components/Toolbar.jsx
+++ /dev/null
@@ -1,250 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { connect } from 'react-redux';
-import { Link } from 'react-router';
-import classNames from 'classnames';
-import { withTranslation } from 'react-i18next';
-import * as IDEActions from '../actions/ide';
-import * as preferenceActions from '../actions/preferences';
-import * as projectActions from '../actions/project';
-
-import PlayIcon from '../../../images/play.svg';
-import StopIcon from '../../../images/stop.svg';
-import PreferencesIcon from '../../../images/preferences.svg';
-import EditProjectNameIcon from '../../../images/pencil.svg';
-
-class Toolbar extends React.Component {
- constructor(props) {
- super(props);
- this.handleKeyPress = this.handleKeyPress.bind(this);
- this.handleProjectNameChange = this.handleProjectNameChange.bind(this);
- this.handleProjectNameClick = this.handleProjectNameClick.bind(this);
- this.handleProjectNameSave = this.handleProjectNameSave.bind(this);
-
- this.state = {
- projectNameInputValue: props.project.name
- };
- }
-
- handleKeyPress(event) {
- if (event.key === 'Enter') {
- this.props.hideEditProjectName();
- this.projectNameInput.blur();
- }
- }
-
- handleProjectNameChange(event) {
- this.setState({ projectNameInputValue: event.target.value });
- }
-
- handleProjectNameClick() {
- if (this.canEditProjectName) {
- this.props.showEditProjectName();
- setTimeout(() => {
- this.projectNameInput?.focus();
- }, 140);
- }
- }
-
- handleProjectNameSave() {
- const newProjectName = this.state.projectNameInputValue.trim();
- if (newProjectName.length === 0) {
- this.setState({
- projectNameInputValue: this.props.project.name
- });
- } else {
- this.props.setProjectName(newProjectName);
- this.props.hideEditProjectName();
- if (this.props.project.id) {
- this.props.saveProject();
- }
- }
- }
-
- canEditProjectName() {
- return (
- (this.props.owner &&
- this.props.owner.username &&
- this.props.owner.username === this.props.currentUser) ||
- !this.props.owner ||
- !this.props.owner.username
- );
- }
-
- render() {
- const playButtonClass = classNames({
- 'toolbar__play-button': true,
- 'toolbar__play-button--selected': this.props.isPlaying
- });
- const stopButtonClass = classNames({
- 'toolbar__stop-button': true,
- 'toolbar__stop-button--selected': !this.props.isPlaying
- });
- const preferencesButtonClass = classNames({
- 'toolbar__preferences-button': true,
- 'toolbar__preferences-button--selected': this.props.preferencesIsVisible
- });
- const nameContainerClass = classNames({
- 'toolbar__project-name-container': true,
- 'toolbar__project-name-container--editing': this.props.project
- .isEditingName
- });
-
- const canEditProjectName = this.canEditProjectName();
-
- return (
-
-
-
-
-
- {
- this.props.setAutorefresh(event.target.checked);
- }}
- />
-
-
-
-
-
{
- this.projectNameInput = element;
- }}
- onBlur={this.handleProjectNameSave}
- onKeyPress={this.handleKeyPress}
- />
- {(() => {
- if (this.props.owner) {
- return (
-
- {this.props.t('Toolbar.By')}{' '}
-
- {this.props.owner.username}
-
-
- );
- }
- return null;
- })()}
-
-
-
- );
- }
-}
-
-Toolbar.propTypes = {
- isPlaying: PropTypes.bool.isRequired,
- preferencesIsVisible: PropTypes.bool.isRequired,
- stopSketch: PropTypes.func.isRequired,
- setProjectName: PropTypes.func.isRequired,
- openPreferences: PropTypes.func.isRequired,
- owner: PropTypes.shape({
- username: PropTypes.string
- }),
- project: PropTypes.shape({
- name: PropTypes.string.isRequired,
- isEditingName: PropTypes.bool,
- id: PropTypes.string
- }).isRequired,
- showEditProjectName: PropTypes.func.isRequired,
- hideEditProjectName: PropTypes.func.isRequired,
- infiniteLoop: PropTypes.bool.isRequired,
- autorefresh: PropTypes.bool.isRequired,
- setAutorefresh: PropTypes.func.isRequired,
- setTextOutput: PropTypes.func.isRequired,
- setGridOutput: PropTypes.func.isRequired,
- startSketch: PropTypes.func.isRequired,
- startAccessibleSketch: PropTypes.func.isRequired,
- saveProject: PropTypes.func.isRequired,
- currentUser: PropTypes.string,
- t: PropTypes.func.isRequired,
- syncFileContent: PropTypes.func.isRequired
-};
-
-Toolbar.defaultProps = {
- owner: undefined,
- currentUser: undefined
-};
-
-function mapStateToProps(state) {
- return {
- autorefresh: state.preferences.autorefresh,
- currentUser: state.user.username,
- infiniteLoop: state.ide.infiniteLoop,
- isPlaying: state.ide.isPlaying,
- owner: state.project.owner,
- preferencesIsVisible: state.ide.preferencesIsVisible,
- project: state.project
- };
-}
-
-const mapDispatchToProps = {
- ...IDEActions,
- ...preferenceActions,
- ...projectActions
-};
-
-export const ToolbarComponent = withTranslation()(Toolbar);
-export default connect(mapStateToProps, mapDispatchToProps)(ToolbarComponent);
diff --git a/client/modules/IDE/components/UnsavedChangesIndicator.jsx b/client/modules/IDE/components/UnsavedChangesIndicator.jsx
new file mode 100644
index 0000000000..6bd0c11486
--- /dev/null
+++ b/client/modules/IDE/components/UnsavedChangesIndicator.jsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useSelector } from 'react-redux';
+import UnsavedChangesDotIcon from '../../../images/unsaved-changes-dot.svg';
+
+export default function UnsavedChangesIndicator() {
+ const { t } = useTranslation();
+ const hasUnsavedChanges = useSelector((state) => state.ide.unsavedChanges);
+
+ if (!hasUnsavedChanges) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/client/modules/IDE/components/UploadFileModal.jsx b/client/modules/IDE/components/UploadFileModal.jsx
index 20aefc960b..6ec0268c99 100644
--- a/client/modules/IDE/components/UploadFileModal.jsx
+++ b/client/modules/IDE/components/UploadFileModal.jsx
@@ -1,6 +1,6 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
-import { Link } from 'react-router';
+import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import prettyBytes from 'pretty-bytes';
import getConfig from '../../../utils/getConfig';
diff --git a/client/modules/IDE/hooks/index.js b/client/modules/IDE/hooks/index.js
new file mode 100644
index 0000000000..c590fb38cd
--- /dev/null
+++ b/client/modules/IDE/hooks/index.js
@@ -0,0 +1,78 @@
+import { useDispatch, useSelector } from 'react-redux';
+import {
+ autosaveProject,
+ exportProjectAsZip,
+ hideEditProjectName,
+ newProject,
+ saveProject,
+ setProjectName
+} from '../actions/project';
+import { useTranslation } from 'react-i18next';
+import { showToast } from '../actions/toast';
+import { showErrorModal, showShareModal } from '../actions/ide';
+import { useParams } from 'react-router';
+
+export const useSketchActions = () => {
+ const unsavedChanges = useSelector((state) => state.ide.unsavedChanges);
+ const authenticated = useSelector((state) => state.user.authenticated);
+ const project = useSelector((state) => state.project);
+ const currentUser = useSelector((state) => state.user.username);
+ const dispatch = useDispatch();
+ const { t } = useTranslation();
+ const params = useParams();
+
+ function newSketch() {
+ if (!unsavedChanges) {
+ dispatch(showToast('Toast.OpenedNewSketch'));
+ dispatch(newProject());
+ } else if (window.confirm(t('Nav.WarningUnsavedChanges'))) {
+ dispatch(showToast('Toast.OpenedNewSketch'));
+ dispatch(newProject());
+ }
+ }
+
+ function saveSketch(cmController) {
+ if (authenticated) {
+ dispatch(saveProject(cmController?.getContent()));
+ } else {
+ dispatch(showErrorModal('forceAuthentication'));
+ }
+ }
+
+ function downloadSketch() {
+ dispatch(autosaveProject());
+ dispatch(exportProjectAsZip(project.id));
+ }
+
+ function shareSketch() {
+ const { username } = params;
+ dispatch(showShareModal(project.id, project.name, username));
+ }
+
+ function changeSketchName(name) {
+ const newProjectName = name.trim();
+ if (newProjectName.length > 0) {
+ dispatch(setProjectName(newProjectName));
+ if (project.id) dispatch(saveProject());
+ }
+ }
+
+ function canEditProjectName() {
+ return (
+ (project.owner &&
+ project.owner.username &&
+ project.owner.username === currentUser) ||
+ !project.owner ||
+ !project.owner.username
+ );
+ }
+
+ return {
+ newSketch,
+ saveSketch,
+ downloadSketch,
+ shareSketch,
+ changeSketchName,
+ canEditProjectName
+ };
+};
diff --git a/client/modules/IDE/hooks/useKeyDownHandlers.js b/client/modules/IDE/hooks/useKeyDownHandlers.js
new file mode 100644
index 0000000000..3bf9ccf8ac
--- /dev/null
+++ b/client/modules/IDE/hooks/useKeyDownHandlers.js
@@ -0,0 +1,61 @@
+import mapKeys from 'lodash/mapKeys';
+import PropTypes from 'prop-types';
+import { useCallback, useEffect, useRef } from 'react';
+
+/**
+ * Attaches keydown handlers to the global document.
+ *
+ * Handles Mac/PC switching of Ctrl to Cmd.
+ *
+ * @param {Record
void>} keyHandlers - an object
+ * which maps from the key to its event handler. The object keys are a combination
+ * of the key and prefixes `ctrl-` `shift-` (ie. 'ctrl-f', 'ctrl-shift-f')
+ * and the values are the function to call when that specific key is pressed.
+ */
+export default function useKeyDownHandlers(keyHandlers) {
+ /**
+ * Instead of memoizing the handlers, use a ref and call the current
+ * handler at the time of the event.
+ */
+ const handlers = useRef(keyHandlers);
+
+ useEffect(() => {
+ handlers.current = mapKeys(keyHandlers, (value, key) => key?.toLowerCase());
+ }, [keyHandlers]);
+
+ /**
+ * Will call all matching handlers, starting with the most specific: 'ctrl-shift-f' => 'ctrl-f' => 'f'.
+ * Can use e.stopPropagation() to prevent subsequent handlers.
+ * @type {(function(KeyboardEvent): void)}
+ */
+ const handleEvent = useCallback((e) => {
+ const isMac = navigator.userAgent?.toLowerCase().indexOf('mac') !== -1;
+ const isCtrl = isMac ? e.metaKey : e.ctrlKey;
+ if (e.shiftKey && isCtrl) {
+ handlers.current[`ctrl-shift-${e.key?.toLowerCase()}`]?.(e);
+ }
+ if (isCtrl) {
+ handlers.current[`ctrl-${e.key?.toLowerCase()}`]?.(e);
+ }
+ handlers.current[e.key?.toLowerCase()]?.(e);
+ }, []);
+
+ useEffect(() => {
+ document.addEventListener('keydown', handleEvent);
+
+ return () => document.removeEventListener('keydown', handleEvent);
+ }, [handleEvent]);
+}
+
+/**
+ * Component version can be used in class components where hooks can't be used.
+ *
+ * @param {Record void>} handlers
+ */
+export const DocumentKeyDown = ({ handlers }) => {
+ useKeyDownHandlers(handlers);
+ return null;
+};
+DocumentKeyDown.propTypes = {
+ handlers: PropTypes.objectOf(PropTypes.func).isRequired
+};
diff --git a/client/modules/IDE/pages/FullView.jsx b/client/modules/IDE/pages/FullView.jsx
index e2ddd20b62..ce981edec6 100644
--- a/client/modules/IDE/pages/FullView.jsx
+++ b/client/modules/IDE/pages/FullView.jsx
@@ -1,7 +1,7 @@
-import PropTypes from 'prop-types';
import React, { useEffect, useState } from 'react';
import Helmet from 'react-helmet';
import { useDispatch, useSelector } from 'react-redux';
+import { useParams } from 'react-router-dom';
import PreviewFrame from '../components/PreviewFrame';
import PreviewNav from '../../../components/PreviewNav';
import { getProject } from '../actions/project';
@@ -14,13 +14,14 @@ import {
import useInterval from '../hooks/useInterval';
import RootPage from '../../../components/RootPage';
-function FullView(props) {
+function FullView() {
+ const params = useParams();
const dispatch = useDispatch();
const project = useSelector((state) => state.project);
const [isRendered, setIsRendered] = useState(false);
useEffect(() => {
- dispatch(getProject(props.params.project_id, props.params.username));
+ dispatch(getProject(params.project_id, params.username));
}, []);
useEffect(() => {
@@ -64,7 +65,7 @@ function FullView(props) {
}}
project={{
name: project.name,
- id: props.params.project_id
+ id: params.project_id
}}
/>
@@ -74,11 +75,4 @@ function FullView(props) {
);
}
-FullView.propTypes = {
- params: PropTypes.shape({
- project_id: PropTypes.string,
- username: PropTypes.string
- }).isRequired
-};
-
export default FullView;
diff --git a/client/modules/IDE/pages/IDEOverlays.jsx b/client/modules/IDE/pages/IDEOverlays.jsx
new file mode 100644
index 0000000000..9f2f3730d9
--- /dev/null
+++ b/client/modules/IDE/pages/IDEOverlays.jsx
@@ -0,0 +1,119 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useDispatch, useSelector } from 'react-redux';
+import { useLocation, useParams } from 'react-router-dom';
+import Overlay from '../../App/components/Overlay';
+import {
+ closeKeyboardShortcutModal,
+ closePreferences,
+ closeShareModal,
+ hideErrorModal
+} from '../actions/ide';
+import About from '../components/About';
+import AddToCollectionList from '../components/AddToCollectionList';
+import ErrorModal from '../components/ErrorModal';
+import Feedback from '../components/Feedback';
+import KeyboardShortcutModal from '../components/KeyboardShortcutModal';
+import NewFileModal from '../components/NewFileModal';
+import NewFolderModal from '../components/NewFolderModal';
+import Preferences from '../components/Preferences';
+import { CollectionSearchbar } from '../components/Searchbar';
+import ShareModal from '../components/ShareModal';
+import UploadFileModal from '../components/UploadFileModal';
+
+export default function IDEOverlays() {
+ const { t } = useTranslation();
+ const dispatch = useDispatch();
+ const location = useLocation();
+ const params = useParams();
+
+ const {
+ modalIsVisible,
+ newFolderModalVisible,
+ uploadFileModalVisible,
+ preferencesIsVisible,
+ shareModalVisible,
+ keyboardShortcutVisible,
+ errorType,
+ previousPath
+ } = useSelector((state) => state.ide);
+
+ return (
+ <>
+ {preferencesIsVisible && (
+ dispatch(closePreferences())}
+ >
+
+
+ )}
+ {location.pathname === '/about' && (
+
+
+
+ )}
+ {location.pathname === '/feedback' && (
+
+
+
+ )}
+ {location.pathname.match(/add-to-collection$/) && (
+ }
+ isFixedHeight
+ >
+
+
+ )}
+ {shareModalVisible && (
+ dispatch(closeShareModal())}
+ >
+
+
+ )}
+ {keyboardShortcutVisible && (
+ dispatch(closeKeyboardShortcutModal())}
+ >
+
+
+ )}
+ {errorType && (
+ dispatch(hideErrorModal())}
+ >
+ dispatch(hideErrorModal())}
+ />
+
+ )}
+ {modalIsVisible && }
+ {newFolderModalVisible && }
+ {uploadFileModalVisible && }
+ >
+ );
+}
diff --git a/client/modules/IDE/pages/IDEView.jsx b/client/modules/IDE/pages/IDEView.jsx
index 5158e33c44..e947b09716 100644
--- a/client/modules/IDE/pages/IDEView.jsx
+++ b/client/modules/IDE/pages/IDEView.jsx
@@ -1,458 +1,363 @@
import PropTypes from 'prop-types';
-import React from 'react';
-import { bindActionCreators } from 'redux';
-import { connect } from 'react-redux';
-import { withRouter } from 'react-router';
-import { withTranslation } from 'react-i18next';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import {
+ useBeforeUnload,
+ unstable_useBlocker as useBlocker,
+ useLocation
+} from 'react-router-dom';
+import { useDispatch, useSelector } from 'react-redux';
+import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet';
import SplitPane from 'react-split-pane';
-import Editor from '../components/Editor';
+import MediaQuery from 'react-responsive';
+import IDEKeyHandlers from '../components/IDEKeyHandlers';
import Sidebar from '../components/Sidebar';
import PreviewFrame from '../components/PreviewFrame';
-import Toolbar from '../components/Toolbar';
-import Preferences from '../components/Preferences/index';
-import NewFileModal from '../components/NewFileModal';
-import NewFolderModal from '../components/NewFolderModal';
-import UploadFileModal from '../components/UploadFileModal';
-import ShareModal from '../components/ShareModal';
-import KeyboardShortcutModal from '../components/KeyboardShortcutModal';
-import ErrorModal from '../components/ErrorModal';
-import Nav from '../../../components/Nav';
import Console from '../components/Console';
import Toast from '../components/Toast';
-import * as FileActions from '../actions/files';
-import * as IDEActions from '../actions/ide';
-import * as ProjectActions from '../actions/project';
-import * as EditorAccessibilityActions from '../actions/editorAccessibility';
-import * as PreferencesActions from '../actions/preferences';
-import * as UserActions from '../../User/actions';
-import * as ConsoleActions from '../actions/console';
-import { getHTMLFile } from '../reducers/files';
-import Overlay from '../../App/components/Overlay';
-import About from '../components/About';
-import AddToCollectionList from '../components/AddToCollectionList';
-import Feedback from '../components/Feedback';
-import { CollectionSearchbar } from '../components/Searchbar';
-import { getIsUserOwner } from '../selectors/users';
+import { updateFileContent } from '../actions/files';
+import {
+ setPreviousPath,
+ stopSketch,
+ collapseSidebar,
+ newFile
+} from '../actions/ide';
+import {
+ autosaveProject,
+ clearPersistedState,
+ getProject
+} from '../actions/project';
+import { selectActiveFile, selectRootFile } from '../selectors/files';
+import { getIsUserOwner, selectCanEditSketch } from '../selectors/users';
import RootPage from '../../../components/RootPage';
+import IDEOverlays from './IDEOverlays';
+import Header from '../components/Header';
+import FloatingActionButton from '../components/FloatingActionButton';
+import EditorV2 from '../components/EditorV2';
+import {
+ EditorSidebarWrapper,
+ FileDrawer,
+ PreviewWrapper
+} from '../components/EditorV2/MobileEditor';
+import IconButton from '../../../components/mobile/IconButton';
+import { PlusIcon } from '../../../common/icons';
+import ConnectedFileNode from '../components/FileNode';
-function getTitle(props) {
- const { id } = props.project;
- return id ? `p5.js Web Editor | ${props.project.name}` : 'p5.js Web Editor';
+function getTitle(project) {
+ const { id } = project;
+ return id ? `p5.js Web Editor | ${project.name}` : 'p5.js Web Editor';
}
-function warnIfUnsavedChanges(props, nextLocation) {
- const toAuth =
- nextLocation &&
- nextLocation.action === 'PUSH' &&
- (nextLocation.pathname === '/login' || nextLocation.pathname === '/signup');
- const onAuth =
- nextLocation &&
- (props.location.pathname === '/login' ||
- props.location.pathname === '/signup');
- if (props.ide.unsavedChanges && !toAuth && !onAuth) {
- if (!window.confirm(props.t('Nav.WarningUnsavedChanges'))) {
- return false;
- }
- return true;
- }
- return true;
+function isAuth(pathname) {
+ return pathname === '/login' || pathname === '/signup';
}
-class IDEView extends React.Component {
- constructor(props) {
- super(props);
- this.handleGlobalKeydown = this.handleGlobalKeydown.bind(this);
+function isOverlay(pathname) {
+ return pathname === '/about' || pathname === '/feedback';
+}
- this.state = {
- consoleSize: props.ide.consoleIsExpanded ? 150 : 29,
- sidebarSize: props.ide.sidebarIsExpanded ? 160 : 20
- };
- }
+function WarnIfUnsavedChanges() {
+ const hasUnsavedChanges = useSelector((state) => state.ide.unsavedChanges);
+ const { t } = useTranslation();
- componentDidMount() {
- // If page doesn't reload after Sign In then we need
- // to force cleared state to be cleared
- this.props.clearPersistedState();
+ const currentLocation = useLocation();
- this.props.stopSketch();
- if (this.props.params.project_id) {
- const { project_id: id, username } = this.props.params;
- if (id !== this.props.project.id) {
- this.props.getProject(id, username);
+ // blocker handles internal navigation between pages.
+ const blocker = useBlocker(hasUnsavedChanges);
+
+ useEffect(() => {
+ if (blocker.state === 'blocked') {
+ const nextLocation = blocker.location;
+ if (
+ isAuth(nextLocation.pathname) ||
+ isAuth(currentLocation.pathname) ||
+ isOverlay(nextLocation.pathname) ||
+ isOverlay(currentLocation.pathname)
+ ) {
+ blocker.proceed();
+ } else {
+ const didConfirm = window.confirm(t('Nav.WarningUnsavedChanges'));
+ if (didConfirm) {
+ blocker.proceed();
+ } else {
+ blocker.reset();
+ }
}
}
+ }, [blocker, currentLocation.pathname, t, hasUnsavedChanges]);
- this.isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1;
- document.addEventListener('keydown', this.handleGlobalKeydown, false);
+ // beforeunload handles closing or refreshing the window.
+ const handleUnload = useCallback(
+ (e) => {
+ if (hasUnsavedChanges) {
+ // See: https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#browser_compatibility
+ e.preventDefault();
+ const confirmationMessage = t('Nav.WarningUnsavedChanges');
+ e.returnValue = confirmationMessage;
+ return confirmationMessage;
+ }
+ return null;
+ },
+ [t, hasUnsavedChanges]
+ );
- this.props.router.setRouteLeaveHook(
- this.props.route,
- this.handleUnsavedChanges
- );
+ useBeforeUnload(handleUnload);
- // window.onbeforeunload = this.handleUnsavedChanges;
- window.addEventListener('beforeunload', this.handleBeforeUnload);
+ return null;
+}
- this.autosaveInterval = null;
- }
+export const CmControllerContext = React.createContext({});
- componentWillReceiveProps(nextProps) {
- if (nextProps.location !== this.props.location) {
- this.props.setPreviousPath(this.props.location.pathname);
- }
- if (this.props.ide.sidebarIsExpanded !== nextProps.ide.sidebarIsExpanded) {
- this.setState({
- sidebarSize: nextProps.ide.sidebarIsExpanded ? 160 : 20
- });
- }
- }
+const IDEView = (props) => {
+ const ide = useSelector((state) => state.ide);
+ const selectedFile = useSelector(selectActiveFile);
+ const preferences = useSelector((state) => state.preferences);
+ const project = useSelector((state) => state.project);
+ const isUserOwner = useSelector(getIsUserOwner);
+ const dispatch = useDispatch();
+ const { t } = useTranslation();
- componentWillUpdate(nextProps) {
- if (nextProps.params.project_id && !this.props.params.project_id) {
- if (nextProps.params.project_id !== nextProps.project.id) {
- this.props.getProject(nextProps.params.project_id);
+ const [consoleSize, setConsoleSize] = useState(
+ ide.consoleIsExpanded ? 150 : 29
+ );
+ const [sidebarSize, setSidebarSize] = useState(
+ ide.sidebarIsExpanded ? 160 : 20
+ );
+ const rootFile = useSelector(selectRootFile);
+ const canEditProject = useSelector(selectCanEditSketch);
+
+ const cmRef = useRef({});
+ let overlay = null;
+
+ const autosaveIntervalRef = useRef(null);
+ const prevPropsRef = useRef({
+ selectedFileName: selectedFile.name,
+ selectedFileContent: selectedFile.content,
+ location: props.location,
+ sidebarIsExpanded: ide.sidebarSize,
+ project_id: props.params.project_id
+ });
+
+ const syncFileContent = () => {
+ const file = cmRef.current.getContent();
+ dispatch(updateFileContent(file.id, file.content));
+ };
+
+ useEffect(() => {
+ dispatch(clearPersistedState());
+
+ dispatch(stopSketch());
+ if (props.params.project_id) {
+ const { project_id: id, username } = props.params;
+ if (id !== project.id) {
+ dispatch(getProject(id, username));
}
}
- }
+ }, []);
- componentDidUpdate(prevProps) {
- if (this.props.isUserOwner && this.props.project.id) {
- if (
- this.props.preferences.autosave &&
- this.props.ide.unsavedChanges &&
- !this.props.ide.justOpenedProject
- ) {
- if (
- this.props.selectedFile.name === prevProps.selectedFile.name &&
- this.props.selectedFile.content !== prevProps.selectedFile.content
- ) {
- if (this.autosaveInterval) {
- clearTimeout(this.autosaveInterval);
- }
- this.autosaveInterval = setTimeout(this.props.autosaveProject, 20000);
- }
- } else if (this.autosaveInterval && !this.props.preferences.autosave) {
- clearTimeout(this.autosaveInterval);
- this.autosaveInterval = null;
- }
- } else if (this.autosaveInterval) {
- clearTimeout(this.autosaveInterval);
- this.autosaveInterval = null;
+ // for setting previous location
+ useEffect(() => {
+ if (props.location !== prevPropsRef.current.location) {
+ dispatch(setPreviousPath(prevPropsRef.current.location.pathname));
}
- if (this.props.route.path !== prevProps.route.path) {
- this.props.router.setRouteLeaveHook(this.props.route, () =>
- warnIfUnsavedChanges(this.props)
+ prevPropsRef.current.location = props.location;
+ }, [props.location]);
+
+ // for the sidebar size behaviour
+ useEffect(() => {
+ if (!ide.sidebarIsExpanded) {
+ prevPropsRef.current.sidebarSize = sidebarSize;
+ setSidebarSize(20);
+ }
+ if (ide.sidebarIsExpanded) {
+ setSidebarSize(
+ prevPropsRef.current.sidebarSize > 160
+ ? prevPropsRef.current.sidebarSize
+ : 160
);
}
- }
- componentWillUnmount() {
- document.removeEventListener('keydown', this.handleGlobalKeydown, false);
- clearTimeout(this.autosaveInterval);
- this.autosaveInterval = null;
- }
- handleGlobalKeydown(e) {
- // 83 === s
+ }, [ide.sidebarIsExpanded]);
+
+ // For autosave
+ useEffect(() => {
if (
- e.keyCode === 83 &&
- ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac))
+ isUserOwner &&
+ project.id &&
+ preferences.autosave &&
+ ide.unsavedChanges &&
+ !ide.justOpenedProject
) {
- e.preventDefault();
- e.stopPropagation();
if (
- this.props.isUserOwner ||
- (this.props.user.authenticated && !this.props.project.owner)
+ selectedFile.name === prevPropsRef.current.selectedFileName &&
+ selectedFile.content !== prevPropsRef.current.selectedFileContent
) {
- this.props.saveProject(this.cmController.getContent());
- } else if (this.props.user.authenticated) {
- this.props.cloneProject();
- } else {
- this.props.showErrorModal('forceAuthentication');
- }
- // 13 === enter
- } else if (
- e.keyCode === 13 &&
- e.shiftKey &&
- ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac))
- ) {
- e.preventDefault();
- e.stopPropagation();
- this.props.stopSketch();
- } else if (
- e.keyCode === 13 &&
- ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac))
- ) {
- e.preventDefault();
- e.stopPropagation();
- this.syncFileContent();
- this.props.startSketch();
- // 50 === 2
- } else if (
- e.keyCode === 50 &&
- ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) &&
- e.shiftKey
- ) {
- e.preventDefault();
- this.props.setAllAccessibleOutput(false);
- // 49 === 1
- } else if (
- e.keyCode === 49 &&
- ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) &&
- e.shiftKey
- ) {
- e.preventDefault();
- this.props.setAllAccessibleOutput(true);
- } else if (
- e.keyCode === 66 &&
- ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac))
- ) {
- e.preventDefault();
- if (!this.props.ide.sidebarIsExpanded) {
- this.props.expandSidebar();
- } else {
- this.props.collapseSidebar();
- }
- } else if (e.keyCode === 192 && e.ctrlKey) {
- e.preventDefault();
- if (this.props.ide.consoleIsExpanded) {
- this.props.collapseConsole();
- } else {
- this.props.expandConsole();
- }
- } else if (e.keyCode === 27) {
- if (this.props.ide.newFolderModalVisible) {
- this.props.closeNewFolderModal();
- } else if (this.props.ide.uploadFileModalVisible) {
- this.props.closeUploadFileModal();
- } else if (this.props.ide.modalIsVisible) {
- this.props.closeNewFileModal();
+ if (autosaveIntervalRef.current) {
+ clearTimeout(autosaveIntervalRef.current);
+ }
+ autosaveIntervalRef.current = setTimeout(
+ dispatch(autosaveProject()),
+ 20000
+ );
}
+ } else if (autosaveIntervalRef.current && !preferences.autosave) {
+ clearTimeout(autosaveIntervalRef.current);
+ autosaveIntervalRef.current = null;
}
- }
- handleUnsavedChanges = (nextLocation) =>
- warnIfUnsavedChanges(this.props, nextLocation);
+ prevPropsRef.current.selectedFileName = selectedFile.name;
+ prevPropsRef.current.selectedFileContent = selectedFile.content;
- handleBeforeUnload = (e) => {
- const confirmationMessage = this.props.t('Nav.WarningUnsavedChanges');
- if (this.props.ide.unsavedChanges) {
- (e || window.event).returnValue = confirmationMessage;
- return confirmationMessage;
- }
- return null;
- };
-
- syncFileContent = () => {
- const file = this.cmController.getContent();
- this.props.updateFileContent(file.id, file.content);
- };
+ return () => {
+ if (autosaveIntervalRef.current) {
+ clearTimeout(autosaveIntervalRef.current);
+ autosaveIntervalRef.current = null;
+ }
+ };
+ }, [
+ isUserOwner,
+ project.id,
+ preferences.autosave,
+ ide.unsavedChanges,
+ ide.justOpenedProject,
+ selectedFile.name,
+ selectedFile.content
+ ]);
- render() {
- return (
-
-
- {getTitle(this.props)}
-
-
-
-
- {this.props.ide.preferencesIsVisible && (
-
-
-
- )}
-
- this.setState({ sidebarSize: size })}
- onDragFinished={this._handleSidebarPaneOnDragFinished}
- allowResize={this.props.ide.sidebarIsExpanded}
- minSize={125}
- >
-
- {
- this.overlay.style.display = 'block';
- }}
- onDragFinished={() => {
- this.overlay.style.display = 'none';
- }}
- resizerStyle={{
- borderLeftWidth: '2px',
- borderRightWidth: '2px',
- width: '2px',
- margin: '0px 0px'
- }}
- >
+ return (
+
+
+ {getTitle(project)}
+
+ cmRef.current?.getContent()} />
+
+
+
+
+
+
+ {(matches) =>
+ matches ? (
+
this.setState({ consoleSize: size })}
- allowResize={this.props.ide.consoleIsExpanded}
- className="editor-preview-subpanel"
+ split="vertical"
+ size={sidebarSize}
+ onChange={(size) => {
+ setSidebarSize(size);
+ }}
+ allowResize={ide.sidebarIsExpanded}
+ minSize={125}
>
- {
- this.cmController = ctl;
+
+ {
+ overlay.style.display = 'block';
}}
- />
-
+ onDragFinished={() => {
+ overlay.style.display = 'none';
+ }}
+ resizerStyle={{
+ borderLeftWidth: '2px',
+ borderRightWidth: '2px',
+ width: '2px',
+ margin: '0px 0px'
+ }}
+ >
+ setConsoleSize(size)}
+ allowResize={ide.consoleIsExpanded}
+ className="editor-preview-subpanel"
+ >
+ {
+ cmRef.current = ctl;
+ }}
+ />
+
+
+
+
+
+ {t('Toolbar.Preview')}
+
+
+
+
{
+ overlay = element;
+ }}
+ />
+
+ {((preferences.textOutput || preferences.gridOutput) &&
+ ide.isPlaying) ||
+ ide.isAccessibleOutputPlaying}
+
+
+
+
+
-
-
-
- {this.props.t('Toolbar.Preview')}
-
-
-
-
{
- this.overlay = element;
- }}
+
+ ) : (
+ <>
+
+
+
+
-
- {((this.props.preferences.textOutput ||
- this.props.preferences.gridOutput) &&
- this.props.ide.isPlaying) ||
- this.props.ide.isAccessibleOutputPlaying}
-
-
-
-
-
-
-
- {this.props.ide.modalIsVisible &&
}
- {this.props.ide.newFolderModalVisible &&
}
- {this.props.ide.uploadFileModalVisible &&
}
- {this.props.location.pathname === '/about' && (
-
-
-
- )}
- {this.props.location.pathname === '/feedback' && (
-
-
-
- )}
- {this.props.location.pathname.match(/add-to-collection$/) && (
-
}
- isFixedHeight
- >
-
-
- )}
- {this.props.ide.shareModalVisible && (
-
-
-
- )}
- {this.props.ide.keyboardShortcutVisible && (
-
-
-
- )}
- {this.props.ide.errorType && (
-
-
-
- )}
-
- );
- }
-}
+
+
+
+
+
+
+
+
+
+ {
+ cmRef.current = ctl;
+ }}
+ />
+
+ >
+ )
+ }
+
+
+
+ );
+};
IDEView.propTypes = {
params: PropTypes.shape({
@@ -462,155 +367,7 @@ IDEView.propTypes = {
}).isRequired,
location: PropTypes.shape({
pathname: PropTypes.string
- }).isRequired,
- getProject: PropTypes.func.isRequired,
- user: PropTypes.shape({
- authenticated: PropTypes.bool.isRequired,
- id: PropTypes.string,
- username: PropTypes.string
- }).isRequired,
- saveProject: PropTypes.func.isRequired,
- ide: PropTypes.shape({
- errorType: PropTypes.string,
- keyboardShortcutVisible: PropTypes.bool.isRequired,
- shareModalVisible: PropTypes.bool.isRequired,
- shareModalProjectId: PropTypes.string.isRequired,
- shareModalProjectName: PropTypes.string.isRequired,
- shareModalProjectUsername: PropTypes.string.isRequired,
- previousPath: PropTypes.string.isRequired,
- previewIsRefreshing: PropTypes.bool.isRequired,
- isPlaying: PropTypes.bool.isRequired,
- isAccessibleOutputPlaying: PropTypes.bool.isRequired,
- projectOptionsVisible: PropTypes.bool.isRequired,
- preferencesIsVisible: PropTypes.bool.isRequired,
- modalIsVisible: PropTypes.bool.isRequired,
- uploadFileModalVisible: PropTypes.bool.isRequired,
- newFolderModalVisible: PropTypes.bool.isRequired,
- justOpenedProject: PropTypes.bool.isRequired,
- sidebarIsExpanded: PropTypes.bool.isRequired,
- consoleIsExpanded: PropTypes.bool.isRequired,
- unsavedChanges: PropTypes.bool.isRequired
- }).isRequired,
- stopSketch: PropTypes.func.isRequired,
- project: PropTypes.shape({
- id: PropTypes.string,
- name: PropTypes.string.isRequired,
- owner: PropTypes.shape({
- username: PropTypes.string,
- id: PropTypes.string
- }),
- updatedAt: PropTypes.string
- }).isRequired,
- preferences: PropTypes.shape({
- autosave: PropTypes.bool.isRequired,
- fontSize: PropTypes.number.isRequired,
- linewrap: PropTypes.bool.isRequired,
- lineNumbers: PropTypes.bool.isRequired,
- lintWarning: PropTypes.bool.isRequired,
- textOutput: PropTypes.bool.isRequired,
- gridOutput: PropTypes.bool.isRequired,
- theme: PropTypes.string.isRequired,
- autorefresh: PropTypes.bool.isRequired,
- language: PropTypes.string.isRequired,
- autocloseBracketsQuotes: PropTypes.bool.isRequired,
- autocompleteHinter: PropTypes.bool.isRequired
- }).isRequired,
- closePreferences: PropTypes.func.isRequired,
- setAutocloseBracketsQuotes: PropTypes.func.isRequired,
- setAutocompleteHinter: PropTypes.func.isRequired,
- setFontSize: PropTypes.func.isRequired,
- setAutosave: PropTypes.func.isRequired,
- setLineNumbers: PropTypes.func.isRequired,
- setLinewrap: PropTypes.func.isRequired,
- setLintWarning: PropTypes.func.isRequired,
- setTextOutput: PropTypes.func.isRequired,
- setGridOutput: PropTypes.func.isRequired,
- setAllAccessibleOutput: PropTypes.func.isRequired,
- files: PropTypes.arrayOf(
- PropTypes.shape({
- id: PropTypes.string.isRequired,
- name: PropTypes.string.isRequired,
- content: PropTypes.string.isRequired
- })
- ).isRequired,
- selectedFile: PropTypes.shape({
- id: PropTypes.string.isRequired,
- content: PropTypes.string.isRequired,
- name: PropTypes.string.isRequired
- }).isRequired,
- setSelectedFile: PropTypes.func.isRequired,
- htmlFile: PropTypes.shape({
- id: PropTypes.string.isRequired,
- name: PropTypes.string.isRequired,
- content: PropTypes.string.isRequired
- }).isRequired,
- newFile: PropTypes.func.isRequired,
- expandSidebar: PropTypes.func.isRequired,
- collapseSidebar: PropTypes.func.isRequired,
- cloneProject: PropTypes.func.isRequired,
- expandConsole: PropTypes.func.isRequired,
- collapseConsole: PropTypes.func.isRequired,
- deleteFile: PropTypes.func.isRequired,
- updateFileName: PropTypes.func.isRequired,
- updateFileContent: PropTypes.func.isRequired,
- openProjectOptions: PropTypes.func.isRequired,
- closeProjectOptions: PropTypes.func.isRequired,
- newFolder: PropTypes.func.isRequired,
- closeNewFolderModal: PropTypes.func.isRequired,
- closeNewFileModal: PropTypes.func.isRequired,
- closeShareModal: PropTypes.func.isRequired,
- closeKeyboardShortcutModal: PropTypes.func.isRequired,
- autosaveProject: PropTypes.func.isRequired,
- router: PropTypes.shape({
- setRouteLeaveHook: PropTypes.func
- }).isRequired,
- route: PropTypes.oneOfType([PropTypes.object, PropTypes.element]).isRequired,
- setTheme: PropTypes.func.isRequired,
- setPreviousPath: PropTypes.func.isRequired,
- showErrorModal: PropTypes.func.isRequired,
- hideErrorModal: PropTypes.func.isRequired,
- clearPersistedState: PropTypes.func.isRequired,
- startSketch: PropTypes.func.isRequired,
- openUploadFileModal: PropTypes.func.isRequired,
- closeUploadFileModal: PropTypes.func.isRequired,
- t: PropTypes.func.isRequired,
- isUserOwner: PropTypes.bool.isRequired
+ }).isRequired
};
-function mapStateToProps(state) {
- return {
- files: state.files,
- selectedFile:
- state.files.find((file) => file.isSelectedFile) ||
- state.files.find((file) => file.name === 'sketch.js') ||
- state.files.find((file) => file.name !== 'root'),
- htmlFile: getHTMLFile(state.files),
- ide: state.ide,
- preferences: state.preferences,
- editorAccessibility: state.editorAccessibility,
- user: state.user,
- project: state.project,
- console: state.console,
- isUserOwner: getIsUserOwner(state)
- };
-}
-
-function mapDispatchToProps(dispatch) {
- return bindActionCreators(
- Object.assign(
- {},
- EditorAccessibilityActions,
- FileActions,
- ProjectActions,
- IDEActions,
- PreferencesActions,
- UserActions,
- ConsoleActions
- ),
- dispatch
- );
-}
-
-export default withTranslation()(
- withRouter(connect(mapStateToProps, mapDispatchToProps)(IDEView))
-);
+export default IDEView;
diff --git a/client/modules/IDE/pages/Legal.jsx b/client/modules/IDE/pages/Legal.jsx
index b7849aae0e..623d409ba6 100644
--- a/client/modules/IDE/pages/Legal.jsx
+++ b/client/modules/IDE/pages/Legal.jsx
@@ -1,15 +1,14 @@
import React, { useState, useEffect } from 'react';
-import PropTypes from 'prop-types';
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
-import { browserHistory } from 'react-router';
+import { useLocation, useNavigate } from 'react-router-dom';
import styled from 'styled-components';
import { useTranslation } from 'react-i18next';
import PrivacyPolicy from './PrivacyPolicy';
import TermsOfUse from './TermsOfUse';
import CodeOfConduct from './CodeOfConduct';
import RootPage from '../../../components/RootPage';
-import Nav from '../../../components/Nav';
import { remSize, prop } from '../../../theme';
+import Nav from '../components/Header/Nav';
const StyledTabList = styled(TabList)`
display: flex;
@@ -29,7 +28,8 @@ const TabTitle = styled.p`
}
`;
-function Legal({ location }) {
+function Legal() {
+ const location = useLocation();
const [selectedIndex, setSelectedIndex] = useState(0);
const { t } = useTranslation();
useEffect(() => {
@@ -42,17 +42,19 @@ function Legal({ location }) {
}
}, [location]);
+ const navigate = useNavigate();
+
function onSelect(index, lastIndex, event) {
if (index === lastIndex) return;
if (index === 0) {
setSelectedIndex(0);
- browserHistory.push('/privacy-policy');
+ navigate('/privacy-policy');
} else if (index === 1) {
setSelectedIndex(1);
- browserHistory.push('/terms-of-use');
+ navigate('/terms-of-use');
} else if (index === 2) {
setSelectedIndex(2);
- browserHistory.push('/code-of-conduct');
+ navigate('/code-of-conduct');
}
}
@@ -85,10 +87,4 @@ function Legal({ location }) {
);
}
-Legal.propTypes = {
- location: PropTypes.shape({
- pathname: PropTypes.string
- }).isRequired
-};
-
export default Legal;
diff --git a/client/modules/IDE/pages/MobileIDEView.jsx b/client/modules/IDE/pages/MobileIDEView.jsx
index 6442a0adba..60104940fe 100644
--- a/client/modules/IDE/pages/MobileIDEView.jsx
+++ b/client/modules/IDE/pages/MobileIDEView.jsx
@@ -1,73 +1,48 @@
-import React, { useEffect } from 'react';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
-import { withRouter } from 'react-router';
-import { useState } from 'react';
+import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
+import { useDispatch, useSelector } from 'react-redux';
+import { useParams } from 'react-router-dom';
import styled from 'styled-components';
-
-// Imports to be Refactored
-import { bindActionCreators } from 'redux';
-
-import * as IDEActions from '../actions/ide';
-import * as ProjectActions from '../actions/project';
-import * as ConsoleActions from '../actions/console';
-import * as PreferencesActions from '../actions/preferences';
-import * as EditorAccessibilityActions from '../actions/editorAccessibility';
-
-// Local Imports
-import Editor from '../components/Editor';
-
import {
- PlayIcon,
- MoreIcon,
FolderIcon,
+ MoreIcon,
+ PlayIcon,
PreferencesIcon,
- TerminalIcon,
- SaveIcon
+ SaveIcon,
+ TerminalIcon
} from '../../../common/icons';
-import UnsavedChangesDotIcon from '../../../images/unsaved-changes-dot.svg';
-
-import IconButton from '../../../components/mobile/IconButton';
-import Header from '../../../components/mobile/Header';
-import Toast from '../components/Toast';
-import Screen from '../../../components/mobile/MobileScreen';
+import Dropdown from '../../../components/Dropdown';
+import ActionStrip from '../../../components/mobile/ActionStrip';
+import MobileExplorer from '../../../components/mobile/Explorer';
import Footer from '../../../components/mobile/Footer';
+import MobileHeader from '../../../components/mobile/MobileHeader';
+import IconButton from '../../../components/mobile/IconButton';
import IDEWrapper from '../../../components/mobile/IDEWrapper';
-import MobileExplorer from '../../../components/mobile/Explorer';
-import Console from '../components/Console';
-import { remSize } from '../../../theme';
-
-import ActionStrip from '../../../components/mobile/ActionStrip';
+import Screen from '../../../components/mobile/MobileScreen';
import useAsModal from '../../../components/useAsModal';
-import Dropdown from '../../../components/Dropdown';
-import { getIsUserOwner } from '../selectors/users';
-
+import { remSize } from '../../../theme';
+import { logoutUser } from '../../User/actions';
+import { toggleForceDesktop } from '../actions/editorAccessibility';
import {
- useEffectWithComparison,
- useEventListener
-} from '../hooks/custom-hooks';
-
-import * as device from '../../../utils/device';
+ collapseConsole,
+ startSketch,
+ stopSketch,
+ toggleConsole
+} from '../actions/ide';
+import {
+ clearPersistedState,
+ getProject,
+ saveProject
+} from '../actions/project';
+import AutosaveHandler from '../components/AutosaveHandler';
+import Console from '../components/Console';
+import Editor from '../components/Editor';
+import { useIDEKeyHandlers } from '../components/IDEKeyHandlers';
+import Toast from '../components/Toast';
+import UnsavedChangesIndicator from '../components/UnsavedChangesIndicator';
-const withChangeDot = (title, unsavedChanges = false) => (
-
- {title}
-
- {unsavedChanges && (
-
- )}
-
-
-);
-const getRootFile = (files) =>
- files && files.filter((file) => file.name === 'root')[0];
-const getRootFileID = (files) =>
- ((root) => root && root.id)(getRootFile(files));
+import { selectActiveFile } from '../selectors/files';
+import { selectUsername } from '../selectors/users';
const Expander = styled.div`
height: ${(props) => (props.expanded ? remSize(160) : remSize(27))};
@@ -77,46 +52,28 @@ const NavItem = styled.li`
position: relative;
`;
-const getNavOptions = (
- username = undefined,
- logoutUser = () => {},
- toggleForceDesktop = () => {}
-) => {
+const NavMenu = () => {
const { t } = useTranslation();
- return username
- ? [
- {
- icon: PreferencesIcon,
- title: t('MobileIDEView.Preferences'),
- href: '/preferences'
- },
- {
- icon: PreferencesIcon,
- title: t('MobileIDEView.MyStuff'),
- href: `/${username}/sketches`
- },
- {
- icon: PreferencesIcon,
- title: t('MobileIDEView.Examples'),
- href: '/p5/sketches'
- },
- {
- icon: PreferencesIcon,
- title: t('MobileIDEView.OriginalEditor'),
- action: toggleForceDesktop
- },
- {
- icon: PreferencesIcon,
- title: t('MobileIDEView.Logout'),
- action: logoutUser
- }
- ]
- : [
+ const dispatch = useDispatch();
+
+ const username = useSelector(selectUsername);
+
+ return (
+
dispatch(toggleForceDesktop())
},
- {
- icon: PreferencesIcon,
- title: t('MobileIDEView.Login'),
- href: '/login'
- }
- ];
-};
-
-const canSaveProject = (isUserOwner, project, user) =>
- isUserOwner || (user.authenticated && !project.owner);
-
-// TODO: This could go into
-const handleGlobalKeydown = (props, cmController) => (e) => {
- const {
- user,
- project,
- ide,
- setAllAccessibleOutput,
- saveProject,
- cloneProject,
- showErrorModal,
- startSketch,
- stopSketch,
- expandSidebar,
- collapseSidebar,
- expandConsole,
- collapseConsole,
- closeNewFolderModal,
- closeUploadFileModal,
- closeNewFileModal,
- isUserOwner
- } = props;
-
- const isMac = device.isMac();
-
- // const ctrlDown = (e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac);
- const ctrlDown = isMac ? e.metaKey : e.ctrlKey;
-
- if (ctrlDown) {
- if (e.shiftKey) {
- if (e.keyCode === 13) {
- e.preventDefault();
- e.stopPropagation();
- stopSketch();
- } else if (e.keyCode === 13) {
- e.preventDefault();
- e.stopPropagation();
- startSketch();
- // 50 === 2
- } else if (e.keyCode === 50) {
- e.preventDefault();
- setAllAccessibleOutput(false);
- // 49 === 1
- } else if (e.keyCode === 49) {
- e.preventDefault();
- setAllAccessibleOutput(true);
- }
- } else if (e.keyCode === 83) {
- // 83 === s
- e.preventDefault();
- e.stopPropagation();
- if (canSaveProject(isUserOwner, project, user))
- saveProject(cmController.getContent(), false, true);
- else if (user.authenticated) cloneProject();
- else showErrorModal('forceAuthentication');
-
- // 13 === enter
- } else if (e.keyCode === 66) {
- e.preventDefault();
- if (!ide.sidebarIsExpanded) expandSidebar();
- else collapseSidebar();
- }
- } else if (e.keyCode === 192 && e.ctrlKey) {
- e.preventDefault();
- if (ide.consoleIsExpanded) collapseConsole();
- else expandConsole();
- } else if (e.keyCode === 27) {
- if (ide.newFolderModalVisible) closeNewFolderModal();
- else if (ide.uploadFileModalVisible) closeUploadFileModal();
- else if (ide.modalIsVisible) closeNewFileModal();
- }
-};
-
-const autosave = (autosaveInterval, setAutosaveInterval) => (
- props,
- prevProps
-) => {
- const {
- autosaveProject,
- preferences,
- ide,
- selectedFile: file,
- project,
- isUserOwner
- } = props;
-
- const { selectedFile: oldFile } = prevProps;
-
- const doAutosave = () => autosaveProject(true);
-
- if (isUserOwner && project.id) {
- if (preferences.autosave && ide.unsavedChanges && !ide.justOpenedProject) {
- if (file.name === oldFile.name && file.content !== oldFile.content) {
- if (autosaveInterval) {
- clearTimeout(autosaveInterval);
- }
- console.log('will save project in 20 seconds');
- setAutosaveInterval(setTimeout(doAutosave, 20000));
- }
- } else if (autosaveInterval && !preferences.autosave) {
- clearTimeout(autosaveInterval);
- setAutosaveInterval(null);
- }
- } else if (autosaveInterval) {
- clearTimeout(autosaveInterval);
- setAutosaveInterval(null);
- }
+ username
+ ? {
+ icon: PreferencesIcon,
+ title: t('MobileIDEView.Logout'),
+ action: () => dispatch(logoutUser())
+ }
+ : {
+ icon: PreferencesIcon,
+ title: t('MobileIDEView.Login'),
+ href: '/login'
+ }
+ ].filter(Boolean)}
+ />
+ );
};
-// ide, preferences, project, selectedFile, user, params, unsavedChanges, expandConsole, collapseConsole,
-// stopSketch, startSketch, getProject, clearPersistedState, autosaveProject, saveProject, files
+const MobileIDEView = () => {
+ const params = useParams();
+ const { t } = useTranslation();
+ const dispatch = useDispatch();
-const MobileIDEView = (props) => {
- // const {
- // preferences, ide, editorAccessibility, project, updateLintMessage, clearLintMessage,
- // selectedFile, updateFileContent, files, user, params,
- // closeEditorOptions, showEditorOptions, logoutUser,
- // startRefreshSketch, stopSketch, expandSidebar, collapseSidebar, clearConsole, console,
- // showRuntimeErrorWarning, hideRuntimeErrorWarning, startSketch, getProject, clearPersistedState, setUnsavedChanges,
- // toggleForceDesktop
- // } = props;
+ const project = useSelector((state) => state.project);
- const {
- ide,
- preferences,
- project,
- selectedFile,
- user,
- params,
- unsavedChanges,
- expandConsole,
- collapseConsole,
- stopSketch,
- startSketch,
- getProject,
- clearPersistedState,
- autosaveProject,
- saveProject,
- files,
- toggleForceDesktop,
- logoutUser,
- isUserOwner
- } = props;
+ const [cmController, setCmController] = useState(null);
- const [cmController, setCmController] = useState(null); // eslint-disable-line
+ const consoleIsExpanded = useSelector((state) => state.ide.consoleIsExpanded);
- const { username } = user;
- const { consoleIsExpanded } = ide;
+ const selectedFile = useSelector(selectActiveFile);
const { name: filename } = selectedFile;
// Force state reset
- useEffect(clearPersistedState, []);
useEffect(() => {
- stopSketch();
- collapseConsole();
+ dispatch(clearPersistedState());
+ dispatch(stopSketch());
+ dispatch(collapseConsole());
}, []);
// Load Project
- const [currentProjectID, setCurrentProjectID] = useState(null);
useEffect(() => {
- if (!username) return;
- if (params.project_id && !currentProjectID) {
- if (params.project_id !== project.id) {
- getProject(params.project_id, params.username);
- }
+ if (
+ params.project_id &&
+ params.username &&
+ params.project_id !== project.id
+ ) {
+ dispatch(getProject(params.project_id, params.username));
}
- setCurrentProjectID(params.project_id);
- }, [params, project, username]);
+ }, [dispatch, params, project.id]);
// Screen Modals
- const [toggleNavDropdown, NavDropDown] = useAsModal(
-
- );
+ const [toggleNavDropdown, NavDropDown] = useAsModal();
const [toggleExplorer, Explorer] = useAsModal(
- (toggle) => (
-
- ),
+ (toggle) => ,
true
);
- // TODO: This behavior could move to
- const [autosaveInterval, setAutosaveInterval] = useState(null);
- useEffectWithComparison(autosave(autosaveInterval, setAutosaveInterval), {
- autosaveProject,
- preferences,
- ide,
- selectedFile,
- project,
- user,
- isUserOwner
+ useIDEKeyHandlers({
+ getContent: () => cmController.getContent()
});
- useEventListener('keydown', handleGlobalKeydown(props, cmController), false, [
- props
- ]);
-
- const projectActions = [
- {
- icon: TerminalIcon,
- aria: 'Toggle console open/closed',
- action: consoleIsExpanded ? collapseConsole : expandConsole,
- inverted: true
- },
- {
- icon: SaveIcon,
- aria: 'Save project',
- action: () => saveProject(cmController.getContent(), false, true)
- },
- { icon: FolderIcon, aria: 'Open files explorer', action: toggleExplorer }
- ];
-
return (
-
+
+
-
+ {project.name}
+
+
+ }
subtitle={filename}
>
@@ -375,13 +169,13 @@ const MobileIDEView = (props) => {
{
- startSketch();
+ dispatch(startSketch());
}}
icon={PlayIcon}
- aria-label="Run sketch"
+ aria-label={t('Toolbar.PlaySketchARIA')}
/>
-
+
@@ -394,114 +188,32 @@ const MobileIDEView = (props) => {
)}
-
+ dispatch(toggleConsole()),
+ inverted: true
+ },
+ {
+ icon: SaveIcon,
+ aria: t('Common.Save'), // TODO: translation for 'Save project'?
+ action: () =>
+ dispatch(saveProject(cmController.getContent(), false, true))
+ },
+ {
+ icon: FolderIcon,
+ aria: t('Editor.OpenSketchARIA'),
+ action: toggleExplorer
+ }
+ ]}
+ />
);
};
-const handleGlobalKeydownProps = {
- expandConsole: PropTypes.func.isRequired,
- collapseConsole: PropTypes.func.isRequired,
- expandSidebar: PropTypes.func.isRequired,
- collapseSidebar: PropTypes.func.isRequired,
-
- setAllAccessibleOutput: PropTypes.func.isRequired,
- saveProject: PropTypes.func.isRequired,
- cloneProject: PropTypes.func.isRequired,
- showErrorModal: PropTypes.func.isRequired,
-
- closeNewFolderModal: PropTypes.func.isRequired,
- closeUploadFileModal: PropTypes.func.isRequired,
- closeNewFileModal: PropTypes.func.isRequired,
- isUserOwner: PropTypes.bool.isRequired
-};
-
-MobileIDEView.propTypes = {
- ide: PropTypes.shape({
- consoleIsExpanded: PropTypes.bool.isRequired
- }).isRequired,
-
- preferences: PropTypes.shape({}).isRequired,
-
- project: PropTypes.shape({
- id: PropTypes.string,
- name: PropTypes.string.isRequired,
- owner: PropTypes.shape({
- username: PropTypes.string,
- id: PropTypes.string
- })
- }).isRequired,
-
- selectedFile: PropTypes.shape({
- id: PropTypes.string.isRequired,
- content: PropTypes.string.isRequired,
- name: PropTypes.string.isRequired
- }).isRequired,
-
- files: PropTypes.arrayOf(
- PropTypes.shape({
- id: PropTypes.string.isRequired,
- name: PropTypes.string.isRequired,
- content: PropTypes.string.isRequired
- })
- ).isRequired,
-
- toggleForceDesktop: PropTypes.func.isRequired,
-
- user: PropTypes.shape({
- authenticated: PropTypes.bool.isRequired,
- id: PropTypes.string,
- username: PropTypes.string
- }).isRequired,
-
- logoutUser: PropTypes.func.isRequired,
-
- getProject: PropTypes.func.isRequired,
- clearPersistedState: PropTypes.func.isRequired,
- params: PropTypes.shape({
- project_id: PropTypes.string,
- username: PropTypes.string
- }).isRequired,
-
- startSketch: PropTypes.func.isRequired,
-
- unsavedChanges: PropTypes.bool.isRequired,
- autosaveProject: PropTypes.func.isRequired,
- isUserOwner: PropTypes.bool.isRequired,
-
- ...handleGlobalKeydownProps
-};
-
-function mapStateToProps(state) {
- return {
- selectedFile:
- state.files.find((file) => file.isSelectedFile) ||
- state.files.find((file) => file.name === 'sketch.js') ||
- state.files.find((file) => file.name !== 'root'),
- ide: state.ide,
- files: state.files,
- unsavedChanges: state.ide.unsavedChanges,
- preferences: state.preferences,
- user: state.user,
- project: state.project,
- console: state.console,
- isUserOwner: getIsUserOwner(state)
- };
-}
-
-const mapDispatchToProps = (dispatch) =>
- bindActionCreators(
- {
- ...ProjectActions,
- ...IDEActions,
- ...ConsoleActions,
- ...PreferencesActions,
- ...EditorAccessibilityActions
- },
- dispatch
- );
-
-export default withRouter(
- connect(mapStateToProps, mapDispatchToProps)(MobileIDEView)
-);
+export default MobileIDEView;
diff --git a/client/modules/IDE/reducers/ide.js b/client/modules/IDE/reducers/ide.js
index 8d45b8b50a..7d8fc85fdc 100644
--- a/client/modules/IDE/reducers/ide.js
+++ b/client/modules/IDE/reducers/ide.js
@@ -1,8 +1,10 @@
-import * as ActionTypes from '../../../constants';
+import { createSlice } from '@reduxjs/toolkit';
const initialState = {
isPlaying: false,
+ // TODO: this doesn't do anything.
isAccessibleOutputPlaying: false,
+ // TODO: rename ambiguous property
modalIsVisible: false,
sidebarIsExpanded: false,
consoleIsExpanded: true,
@@ -10,12 +12,14 @@ const initialState = {
projectOptionsVisible: false,
newFolderModalVisible: false,
uploadFileModalVisible: false,
+ // TODO: nested properties instead of all at top-level
shareModalVisible: false,
shareModalProjectId: 'abcd',
shareModalProjectName: 'My Cute Sketch',
shareModalProjectUsername: 'p5_user',
keyboardShortcutVisible: false,
unsavedChanges: false,
+ // TODO: remove dead code, see: PR #849 and issue #698
infiniteLoop: false,
previewIsRefreshing: false,
infiniteLoopMessage: '',
@@ -26,105 +30,139 @@ const initialState = {
parentId: undefined
};
-const ide = (state = initialState, action) => {
- switch (action.type) {
- case ActionTypes.START_SKETCH:
- return Object.assign({}, state, { isPlaying: true });
- case ActionTypes.STOP_SKETCH:
- return Object.assign({}, state, { isPlaying: false });
- case ActionTypes.START_ACCESSIBLE_OUTPUT:
- return Object.assign({}, state, { isAccessibleOutputPlaying: true });
- case ActionTypes.STOP_ACCESSIBLE_OUTPUT:
- return Object.assign({}, state, { isAccessibleOutputPlaying: false });
- case ActionTypes.CONSOLE_EVENT:
- return Object.assign({}, state, { consoleEvent: action.event });
- case ActionTypes.SHOW_MODAL:
- return Object.assign({}, state, {
- modalIsVisible: true,
- parentId: action.parentId,
- newFolderModalVisible: false
- });
- case ActionTypes.HIDE_MODAL:
- return Object.assign({}, state, { modalIsVisible: false });
- case ActionTypes.COLLAPSE_SIDEBAR:
- return Object.assign({}, state, { sidebarIsExpanded: false });
- case ActionTypes.EXPAND_SIDEBAR:
- return Object.assign({}, state, { sidebarIsExpanded: true });
- case ActionTypes.COLLAPSE_CONSOLE:
- return Object.assign({}, state, { consoleIsExpanded: false });
- case ActionTypes.EXPAND_CONSOLE:
- return Object.assign({}, state, { consoleIsExpanded: true });
- case ActionTypes.OPEN_PREFERENCES:
- return Object.assign({}, state, { preferencesIsVisible: true });
- case ActionTypes.CLOSE_PREFERENCES:
- return Object.assign({}, state, { preferencesIsVisible: false });
- case ActionTypes.RESET_PROJECT:
- return initialState;
- case ActionTypes.OPEN_PROJECT_OPTIONS:
- return Object.assign({}, state, { projectOptionsVisible: true });
- case ActionTypes.CLOSE_PROJECT_OPTIONS:
- return Object.assign({}, state, { projectOptionsVisible: false });
- case ActionTypes.SHOW_NEW_FOLDER_MODAL:
- return Object.assign({}, state, {
- newFolderModalVisible: true,
- parentId: action.parentId,
- modalIsVisible: false
- });
- case ActionTypes.CLOSE_NEW_FOLDER_MODAL:
- return Object.assign({}, state, { newFolderModalVisible: false });
- case ActionTypes.SHOW_SHARE_MODAL:
- return Object.assign({}, state, {
- shareModalVisible: true,
- shareModalProjectId: action.payload.shareModalProjectId,
- shareModalProjectName: action.payload.shareModalProjectName,
- shareModalProjectUsername: action.payload.shareModalProjectUsername
- });
- case ActionTypes.CLOSE_SHARE_MODAL:
- return Object.assign({}, state, { shareModalVisible: false });
- case ActionTypes.SHOW_KEYBOARD_SHORTCUT_MODAL:
- return Object.assign({}, state, { keyboardShortcutVisible: true });
- case ActionTypes.CLOSE_KEYBOARD_SHORTCUT_MODAL:
- return Object.assign({}, state, { keyboardShortcutVisible: false });
- case ActionTypes.SET_UNSAVED_CHANGES:
- return Object.assign({}, state, { unsavedChanges: action.value });
- case ActionTypes.DETECT_INFINITE_LOOPS:
- return Object.assign({}, state, {
- infiniteLoop: true,
- infiniteLoopMessage: action.message
- });
- case ActionTypes.RESET_INFINITE_LOOPS:
- return Object.assign({}, state, {
- infiniteLoop: false,
- infiniteLoopMessage: ''
- });
- case ActionTypes.START_SKETCH_REFRESH:
- return Object.assign({}, state, { previewIsRefreshing: true });
- case ActionTypes.END_SKETCH_REFRESH:
- return Object.assign({}, state, { previewIsRefreshing: false });
- case ActionTypes.JUST_OPENED_PROJECT:
- return Object.assign({}, state, { justOpenedProject: true });
- case ActionTypes.RESET_JUST_OPENED_PROJECT:
- return Object.assign({}, state, { justOpenedProject: false });
- case ActionTypes.SET_PREVIOUS_PATH:
- return Object.assign({}, state, { previousPath: action.path });
- case ActionTypes.SHOW_ERROR_MODAL:
- return Object.assign({}, state, { errorType: action.modalType });
- case ActionTypes.HIDE_ERROR_MODAL:
- return Object.assign({}, state, { errorType: undefined });
- case ActionTypes.HIDE_RUNTIME_ERROR_WARNING:
- return Object.assign({}, state, { runtimeErrorWarningVisible: false });
- case ActionTypes.SHOW_RUNTIME_ERROR_WARNING:
- return Object.assign({}, state, { runtimeErrorWarningVisible: true });
- case ActionTypes.OPEN_UPLOAD_FILE_MODAL:
- return Object.assign({}, state, {
- uploadFileModalVisible: true,
- parentId: action.parentId
- });
- case ActionTypes.CLOSE_UPLOAD_FILE_MODAL:
- return Object.assign({}, state, { uploadFileModalVisible: false });
- default:
- return state;
+const ideSlice = createSlice({
+ name: 'ide',
+ initialState,
+ reducers: {
+ startVisualSketch: (state) => {
+ state.isPlaying = true;
+ },
+ stopVisualSketch: (state) => {
+ state.isPlaying = false;
+ },
+ startAccessibleOutput: (state) => {
+ state.isAccessibleOutputPlaying = true;
+ },
+ stopAccessibleOutput: (state) => {
+ state.isAccessibleOutputPlaying = false;
+ },
+ consoleEvent: (state, action) => {
+ state.consoleEvent = action.payload;
+ },
+ collapseSidebar: (state) => {
+ state.sidebarIsExpanded = false;
+ },
+ expandSidebar: (state) => {
+ state.sidebarIsExpanded = true;
+ },
+ toggleSidebar: (state) => {
+ state.sidebarIsExpanded = !state.sidebarIsExpanded;
+ },
+ collapseConsole: (state) => {
+ state.consoleIsExpanded = false;
+ },
+ expandConsole: (state) => {
+ state.consoleIsExpanded = true;
+ },
+ toggleConsole: (state) => {
+ state.consoleIsExpanded = !state.consoleIsExpanded;
+ },
+ openPreferences: (state) => {
+ state.preferencesIsVisible = true;
+ },
+ closePreferences: (state) => {
+ state.preferencesIsVisible = false;
+ },
+ openProjectOptions: (state) => {
+ state.projectOptionsVisible = true;
+ },
+ closeProjectOptions: (state) => {
+ state.projectOptionsVisible = false;
+ },
+ resetProject: () => initialState,
+ // TODO: rename to openNewFileModal or showNewFileModal
+ newFile: (state, action) => {
+ state.modalIsVisible = true;
+ // TODO: nested properties
+ state.parentId = action.payload;
+ state.newFolderModalVisible = false;
+ },
+ closeNewFileModal: (state) => {
+ state.modalIsVisible = false;
+ },
+ // TODO: rename to openNewFolderModal or showNewFolderModal
+ newFolder: (state, action) => {
+ state.newFolderModalVisible = true;
+ state.parentId = action.payload;
+ state.modalIsVisible = false;
+ },
+ closeNewFolderModal: (state) => {
+ state.newFolderModalVisible = false;
+ },
+ openUploadFileModal: (state, action) => {
+ state.uploadFileModalVisible = true;
+ state.parentId = action.payload;
+ },
+ closeUploadFileModal: (state) => {
+ state.uploadFileModalVisible = false;
+ },
+ showShareModal: (state, action) => {
+ state.shareModalVisible = true;
+ state.shareModalProjectId = action.payload.shareModalProjectId;
+ state.shareModalProjectName = action.payload.shareModalProjectName;
+ state.shareModalProjectUsername =
+ action.payload.shareModalProjectUsername;
+ },
+ closeShareModal: (state) => {
+ state.shareModalVisible = false;
+ },
+ showKeyboardShortcutModal: (state) => {
+ state.keyboardShortcutVisible = true;
+ },
+ closeKeyboardShortcutModal: (state) => {
+ state.keyboardShortcutVisible = false;
+ },
+ showErrorModal: (state, action) => {
+ state.errorType = action.payload;
+ },
+ hideErrorModal: (state) => {
+ state.errorType = undefined;
+ },
+ setUnsavedChanges: (state, action) => {
+ state.unsavedChanges = action.payload;
+ },
+ detectInfiniteLoops: (state, action) => {
+ state.infiniteLoop = true;
+ state.infiniteLoopMessage = action.payload;
+ },
+ resetInfiniteLoops: (state) => {
+ state.infiniteLoop = false;
+ state.infiniteLoopMessage = '';
+ },
+ startRefreshSketch: (state) => {
+ state.previewIsRefreshing = true;
+ },
+ endSketchRefresh: (state) => {
+ state.previewIsRefreshing = false;
+ },
+ justOpenedProject: (state) => {
+ state.justOpenedProject = true;
+ },
+ resetJustOpenedProject: (state) => {
+ state.justOpenedProject = false;
+ },
+ setPreviousPath: (state, action) => {
+ state.previousPath = action.payload;
+ },
+ showRuntimeErrorWarning: (state) => {
+ state.runtimeErrorWarningVisible = true;
+ },
+ hideRuntimeErrorWarning: (state) => {
+ state.runtimeErrorWarningVisible = false;
+ }
}
-};
+});
+
+export const ideActions = ideSlice.actions;
-export default ide;
+export default ideSlice.reducer;
diff --git a/client/modules/IDE/reducers/toast.js b/client/modules/IDE/reducers/toast.js
index b680c7cfef..9c092862a6 100644
--- a/client/modules/IDE/reducers/toast.js
+++ b/client/modules/IDE/reducers/toast.js
@@ -1,21 +1,24 @@
-import * as ActionTypes from '../../../constants';
+import { createSlice } from '@reduxjs/toolkit';
const initialState = {
isVisible: false,
text: ''
};
-const toast = (state = initialState, action) => {
- switch (action.type) {
- case ActionTypes.SHOW_TOAST:
- return Object.assign({}, state, { isVisible: true });
- case ActionTypes.HIDE_TOAST:
- return Object.assign({}, state, { isVisible: false });
- case ActionTypes.SET_TOAST_TEXT:
- return Object.assign({}, state, { text: action.text });
- default:
- return state;
+const toastSlice = createSlice({
+ name: 'toast',
+ initialState,
+ reducers: {
+ setToast: (state, action) => {
+ state.isVisible = true;
+ state.text = action.payload;
+ },
+ hideToast: (state) => {
+ state.isVisible = false;
+ }
}
-};
+});
+
+export const { setToast, hideToast } = toastSlice.actions;
-export default toast;
+export default toastSlice.reducer;
diff --git a/client/modules/IDE/selectors/collections.js b/client/modules/IDE/selectors/collections.js
index 207dce39a9..136a0a7920 100644
--- a/client/modules/IDE/selectors/collections.js
+++ b/client/modules/IDE/selectors/collections.js
@@ -1,4 +1,4 @@
-import { createSelector } from 'reselect';
+import { createSelector } from '@reduxjs/toolkit';
import differenceInMilliseconds from 'date-fns/differenceInMilliseconds';
import find from 'lodash/find';
import orderBy from 'lodash/orderBy';
diff --git a/client/modules/IDE/selectors/files.js b/client/modules/IDE/selectors/files.js
new file mode 100644
index 0000000000..2e2699740e
--- /dev/null
+++ b/client/modules/IDE/selectors/files.js
@@ -0,0 +1,15 @@
+import { createSelector } from '@reduxjs/toolkit';
+
+const selectFiles = (state) => state.files;
+
+export const selectRootFile = createSelector(selectFiles, (files) =>
+ files.find((file) => file.name === 'root')
+);
+
+export const selectActiveFile = createSelector(
+ selectFiles,
+ (files) =>
+ files.find((file) => file.isSelectedFile) ||
+ files.find((file) => file.name === 'sketch.js') ||
+ files.find((file) => file.name !== 'root')
+);
diff --git a/client/modules/IDE/selectors/project.js b/client/modules/IDE/selectors/project.js
index 26197f3b00..cdf7d61826 100644
--- a/client/modules/IDE/selectors/project.js
+++ b/client/modules/IDE/selectors/project.js
@@ -1,4 +1,4 @@
-import { createSelector } from 'reselect';
+import { createSelector } from '@reduxjs/toolkit';
export const selectProjectOwner = (state) => state.project.owner;
export const selectProjectId = (state) => state.project.id;
diff --git a/client/modules/IDE/selectors/projects.js b/client/modules/IDE/selectors/projects.js
index 08701d211c..d5ba29a268 100644
--- a/client/modules/IDE/selectors/projects.js
+++ b/client/modules/IDE/selectors/projects.js
@@ -1,4 +1,4 @@
-import { createSelector } from 'reselect';
+import { createSelector } from '@reduxjs/toolkit';
import differenceInMilliseconds from 'date-fns/differenceInMilliseconds';
import orderBy from 'lodash/orderBy';
import { DIRECTION } from '../actions/sorting';
diff --git a/client/modules/IDE/selectors/users.js b/client/modules/IDE/selectors/users.js
index 4086348041..4923c78e68 100644
--- a/client/modules/IDE/selectors/users.js
+++ b/client/modules/IDE/selectors/users.js
@@ -1,11 +1,12 @@
-import { createSelector } from 'reselect';
+import { createSelector } from '@reduxjs/toolkit';
import getConfig from '../../../utils/getConfig';
-const getAuthenticated = (state) => state.user.authenticated;
+export const getAuthenticated = (state) => state.user.authenticated;
const getTotalSize = (state) => state.user.totalSize;
const getAssetsTotalSize = (state) => state.assets.totalSize;
-const getSketchOwner = (state) => state.project.owner;
+export const getSketchOwner = (state) => state.project.owner;
const getUserId = (state) => state.user.id;
+export const selectUsername = (state) => state.user.username;
const limit = getConfig('UPLOAD_LIMIT') || 250000000;
export const getCanUploadMedia = createSelector(
@@ -39,3 +40,9 @@ export const getIsUserOwner = createSelector(
return sketchOwner.id === userId;
}
);
+
+export const selectCanEditSketch = createSelector(
+ getSketchOwner,
+ getIsUserOwner,
+ (sketchOwner, isOwner) => !sketchOwner || isOwner
+);
diff --git a/client/modules/Mobile/MobileDashboardView.jsx b/client/modules/Mobile/MobileDashboardView.jsx
index f5a6beb770..c484f6a0ac 100644
--- a/client/modules/Mobile/MobileDashboardView.jsx
+++ b/client/modules/Mobile/MobileDashboardView.jsx
@@ -1,12 +1,11 @@
import React from 'react';
-import PropTypes from 'prop-types';
import styled from 'styled-components';
import { useSelector } from 'react-redux';
-import { withRouter } from 'react-router';
+import { useLocation, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import Screen from '../../components/mobile/MobileScreen';
-import Header from '../../components/mobile/Header';
+import MobileHeader from '../../components/mobile/MobileHeader';
import IconButton from '../../components/mobile/IconButton';
import { ExitIcon, MoreIcon } from '../../common/icons';
import Footer from '../../components/mobile/Footer';
@@ -178,8 +177,10 @@ const NavItem = styled.li`
const renderPanel = (name, props) =>
((Component) => Component && )(Panels[name]);
-const MobileDashboard = ({ params, location }) => {
+const MobileDashboard = () => {
const user = useSelector((state) => state.user);
+ const location = useLocation();
+ const params = useParams();
const { username: paramsUsername } = params;
const { pathname } = location;
const { t } = useTranslation();
@@ -198,8 +199,8 @@ const MobileDashboard = ({ params, location }) => {
);
return (
-
-
+
{
);
};
-MobileDashboard.propTypes = {
- location: PropTypes.shape({
- pathname: PropTypes.string.isRequired
- }).isRequired,
- params: PropTypes.shape({
- username: PropTypes.string.isRequired
- })
-};
-MobileDashboard.defaultProps = { params: {} };
-
-export default withRouter(MobileDashboard);
+export default MobileDashboard;
diff --git a/client/modules/Mobile/MobilePreferences.jsx b/client/modules/Mobile/MobilePreferences.jsx
index 5dcb115092..87e18da22b 100644
--- a/client/modules/Mobile/MobilePreferences.jsx
+++ b/client/modules/Mobile/MobilePreferences.jsx
@@ -1,165 +1,36 @@
import React from 'react';
-import { bindActionCreators } from 'redux';
-import { useSelector, useDispatch } from 'react-redux';
-import { withRouter } from 'react-router';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
-
-import * as PreferencesActions from '../IDE/actions/preferences';
-import * as IdeActions from '../IDE/actions/ide';
+import { ExitIcon } from '../../common/icons';
+import MobileHeader from '../../components/mobile/MobileHeader';
import IconButton from '../../components/mobile/IconButton';
import Screen from '../../components/mobile/MobileScreen';
-import Header from '../../components/mobile/Header';
-import PreferencePicker from '../../components/mobile/PreferencePicker';
-import { ExitIcon } from '../../common/icons';
-import { remSize, prop } from '../../theme';
-import {
- optionsPickOne,
- preferenceOnOff
-} from '../IDE/components/Preferences/PreferenceCreators';
-
-const Content = styled.div`
- z-index: 0;
- margin-top: ${remSize(68)};
-`;
-
-const SectionHeader = styled.h2`
- color: ${prop('primaryTextColor')};
- padding-top: ${remSize(32)};
-`;
-
-const SectionSubeader = styled.h3`
- color: ${prop('primaryTextColor')};
+import { remSize } from '../../theme';
+import Preferences from '../IDE/components/Preferences';
+
+const PreferencesContent = styled(Preferences)`
+ margin-left: auto;
+ margin-right: auto;
+ padding-top: ${remSize(84)};
+ height: 100vh;
+ max-height: 100vh;
`;
const MobilePreferences = () => {
- // Props
- const {
- theme,
- autosave,
- linewrap,
- autocompleteHinter,
- textOutput,
- gridOutput,
- lineNumbers,
- lintWarning
- } = useSelector((state) => state.preferences);
-
- // Actions
- const {
- setTheme,
- setAutosave,
- setLinewrap,
- setAutocompleteHinter,
- setTextOutput,
- setGridOutput,
- setLineNumbers,
- setLintWarning
- } = bindActionCreators(
- { ...PreferencesActions, ...IdeActions },
- useDispatch()
- );
-
const { t } = useTranslation();
- const generalSettings = [
- {
- title: t('MobilePreferences.Theme'),
- value: theme,
- options: optionsPickOne(
- t('MobilePreferences.Theme'),
- t('MobilePreferences.LightTheme'),
- t('MobilePreferences.DarkTheme'),
- t('MobilePreferences.HighContrastTheme')
- ),
- onSelect: (x) => setTheme(x) // setTheme
- },
- preferenceOnOff(
- t('MobilePreferences.Autosave'),
- autosave,
- setAutosave,
- 'autosave'
- ),
- preferenceOnOff(
- t('MobilePreferences.AutocompleteHinter'),
- autocompleteHinter,
- setAutocompleteHinter,
- 'autocompleteHinter'
- ),
- preferenceOnOff(
- t('MobilePreferences.WordWrap'),
- linewrap,
- setLinewrap,
- 'linewrap'
- )
- ];
-
- const outputSettings = [
- preferenceOnOff(
- t('MobilePreferences.PlainText'),
- textOutput,
- setTextOutput,
- 'text output'
- ),
- preferenceOnOff(
- t('MobilePreferences.TableText'),
- gridOutput,
- setGridOutput,
- 'table output'
- )
- ];
-
- const accessibilitySettings = [
- preferenceOnOff(
- t('MobilePreferences.LineNumbers'),
- lineNumbers,
- setLineNumbers
- ),
- preferenceOnOff(
- t('MobilePreferences.LintWarningSound'),
- lintWarning,
- setLintWarning
- )
- ];
-
return (
-
+
-
-
-
-
- {t('MobilePreferences.GeneralSettings')}
-
- {generalSettings.map((option) => (
-
- ))}
-
-
- {t('MobilePreferences.Accessibility')}
-
- {accessibilitySettings.map((option) => (
-
- ))}
-
-
- {t('MobilePreferences.AccessibleOutput')}
-
-
- {t('MobilePreferences.UsedScreenReader')}
-
- {outputSettings.map((option) => (
-
- ))}
-
-
+
+
+
+
);
};
-export default withRouter(MobilePreferences);
+export default MobilePreferences;
diff --git a/client/modules/Mobile/MobileSketchView.jsx b/client/modules/Mobile/MobileSketchView.jsx
index dc69550f6f..27832a8ddd 100644
--- a/client/modules/Mobile/MobileSketchView.jsx
+++ b/client/modules/Mobile/MobileSketchView.jsx
@@ -1,90 +1,39 @@
-import React from 'react';
-import { bindActionCreators } from 'redux';
-import { useSelector, useDispatch } from 'react-redux';
-import Header from '../../components/mobile/Header';
+import React, { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useDispatch, useSelector } from 'react-redux';
+import { ExitIcon } from '../../common/icons';
+import Footer from '../../components/mobile/Footer';
+import MobileHeader from '../../components/mobile/MobileHeader';
import IconButton from '../../components/mobile/IconButton';
-import PreviewFrame from '../IDE/components/PreviewFrame';
import Screen from '../../components/mobile/MobileScreen';
+import { startSketch, stopSketch } from '../IDE/actions/ide';
import Console from '../IDE/components/Console';
-import * as ProjectActions from '../IDE/actions/project';
-import * as IDEActions from '../IDE/actions/ide';
-import * as PreferencesActions from '../IDE/actions/preferences';
-import * as ConsoleActions from '../IDE/actions/console';
-import * as FilesActions from '../IDE/actions/files';
-
-import { getHTMLFile } from '../IDE/reducers/files';
-
-import { ExitIcon } from '../../common/icons';
-import Footer from '../../components/mobile/Footer';
+import PreviewFrame from '../IDE/components/PreviewFrame';
import Content from './MobileViewContent';
const MobileSketchView = () => {
- const { files, ide, preferences } = useSelector((state) => state);
+ const { t } = useTranslation();
+ const dispatch = useDispatch();
- const htmlFile = useSelector((state) => getHTMLFile(state.files));
- const projectName = useSelector((state) => state.project.name);
- const selectedFile = useSelector(
- (state) =>
- state.files.find((file) => file.isSelectedFile) ||
- state.files.find((file) => file.name === 'sketch.js') ||
- state.files.find((file) => file.name !== 'root')
- );
+ useEffect(() => {
+ dispatch(startSketch());
+ return () => {
+ dispatch(stopSketch());
+ };
+ }, [dispatch]);
- const {
- setTextOutput,
- setGridOutput,
- dispatchConsoleEvent,
- endSketchRefresh,
- stopSketch,
- setBlobUrl,
- expandConsole,
- clearConsole
- } = bindActionCreators(
- {
- ...ProjectActions,
- ...IDEActions,
- ...PreferencesActions,
- ...ConsoleActions,
- ...FilesActions
- },
- useDispatch()
- );
+ const projectName = useSelector((state) => state.project.name);
return (
-
-