Merge branch 'develop' into refactorIDEView

This commit is contained in:
Cassie Tarakajian 2020-07-09 12:37:03 -04:00
commit e666b46e7c
32 changed files with 7476 additions and 2557 deletions

View file

@ -4,6 +4,8 @@ import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import { Link } from 'react-router';
import classNames from 'classnames';
import { withTranslation } from 'react-i18next';
import i18next from 'i18next';
import * as IDEActions from '../modules/IDE/actions/ide';
import * as toastActions from '../modules/IDE/actions/toast';
import * as projectActions from '../modules/IDE/actions/project';
@ -55,6 +57,10 @@ class Nav extends React.PureComponent {
this.handleFocusForHelp = this.handleFocus.bind(this, 'help');
this.toggleDropdownForAccount = this.toggleDropdown.bind(this, 'account');
this.handleFocusForAccount = this.handleFocus.bind(this, 'account');
this.toggleDropdownForLang = this.toggleDropdown.bind(this, 'lang');
this.handleFocusForLang = this.handleFocus.bind(this, 'lang');
this.handleLangSelection = this.handleLangSelection.bind(this);
this.closeDropDown = this.closeDropDown.bind(this);
}
@ -163,6 +169,13 @@ class Nav extends React.PureComponent {
this.setDropdown('none');
}
handleLangSelection(event) {
i18next.changeLanguage(event.target.value);
this.props.showToast(1500);
this.props.setToastText('LangChange');
this.setDropdown('none');
}
handleLogout() {
this.props.logoutUser();
this.setDropdown('none');
@ -233,7 +246,7 @@ class Nav extends React.PureComponent {
<Link to="/" className="nav__back-link">
<CaretLeftIcon className="nav__back-icon" focusable="false" aria-hidden="true" />
<span className="nav__item-header">
Back to Editor
{this.props.t('BackEditor')}
</span>
</Link>
</li>
@ -258,7 +271,7 @@ class Nav extends React.PureComponent {
}
}}
>
<span className="nav__item-header">File</span>
<span className="nav__item-header">{this.props.t('File')}</span>
<TriangleIcon className="nav__item-header-triangle" focusable="false" aria-hidden="true" />
</button>
<ul className="nav__dropdown">
@ -268,7 +281,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
>
New
{this.props.t('New')}
</button>
</li>
{ getConfig('LOGIN_ENABLED') && (!this.props.project.owner || this.isUserOwner()) &&
@ -278,7 +291,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
>
Save
{this.props.t('Save')}
<span className="nav__keyboard-shortcut">{metaKeyName}+S</span>
</button>
</li> }
@ -289,7 +302,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
>
Duplicate
{this.props.t('Duplicate')}
</button>
</li> }
{ this.props.project.id &&
@ -299,7 +312,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
>
Share
{this.props.t('Share')}
</button>
</li> }
{ this.props.project.id &&
@ -309,7 +322,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
>
Download
{this.props.t('Download')}
</button>
</li> }
{ this.props.user.authenticated &&
@ -320,7 +333,7 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
Open
{this.props.t('Open')}
</Link>
</li> }
{getConfig('UI_COLLECTIONS_ENABLED') &&
@ -333,7 +346,7 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
Add to Collection
{this.props.t('AddToCollection')}
</Link>
</li>}
{ getConfig('EXAMPLES_ENABLED') &&
@ -344,7 +357,7 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
Examples
{this.props.t('Examples')}
</Link>
</li> }
</ul>
@ -360,7 +373,7 @@ class Nav extends React.PureComponent {
}
}}
>
<span className="nav__item-header">Edit</span>
<span className="nav__item-header">{this.props.t('Edit')}</span>
<TriangleIcon className="nav__item-header-triangle" focusable="false" aria-hidden="true" />
</button>
<ul className="nav__dropdown" >
@ -373,7 +386,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForEdit}
onBlur={this.handleBlur}
>
Tidy Code
{this.props.t('TidyCode')}
<span className="nav__keyboard-shortcut">{'\u21E7'}+Tab</span>
</button>
</li>
@ -383,7 +396,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForEdit}
onBlur={this.handleBlur}
>
Find
{this.props.t('Find')}
<span className="nav__keyboard-shortcut">{metaKeyName}+F</span>
</button>
</li>
@ -393,7 +406,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForEdit}
onBlur={this.handleBlur}
>
Find Next
{this.props.t('FindNext')}
<span className="nav__keyboard-shortcut">{metaKeyName}+G</span>
</button>
</li>
@ -403,7 +416,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForEdit}
onBlur={this.handleBlur}
>
Find Previous
{this.props.t('FindPrevious')}
<span className="nav__keyboard-shortcut">{'\u21E7'}+{metaKeyName}+G</span>
</button>
</li>
@ -420,7 +433,7 @@ class Nav extends React.PureComponent {
}
}}
>
<span className="nav__item-header">Sketch</span>
<span className="nav__item-header">{this.props.t('Sketch')}</span>
<TriangleIcon className="nav__item-header-triangle" focusable="false" aria-hidden="true" />
</button>
<ul className="nav__dropdown">
@ -430,7 +443,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur}
>
Add File
{this.props.t('AddFile')}
</button>
</li>
<li className="nav__dropdown-item">
@ -439,7 +452,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur}
>
Add Folder
{this.props.t('AddFolder')}
</button>
</li>
<li className="nav__dropdown-item">
@ -448,7 +461,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur}
>
Run
{this.props.t('Run')}
<span className="nav__keyboard-shortcut">{metaKeyName}+Enter</span>
</button>
</li>
@ -458,7 +471,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur}
>
Stop
{this.props.t('Stop')}
<span className="nav__keyboard-shortcut">{'\u21E7'}+{metaKeyName}+Enter</span>
</button>
</li>
@ -495,7 +508,7 @@ class Nav extends React.PureComponent {
}
}}
>
<span className="nav__item-header">Help</span>
<span className="nav__item-header">{this.props.t('Help')}</span>
<TriangleIcon className="nav__item-header-triangle" focusable="false" aria-hidden="true" />
</button>
<ul className="nav__dropdown">
@ -505,7 +518,7 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur}
onClick={this.handleKeyboardShortcuts}
>
Keyboard Shortcuts
{this.props.t('KeyboardShortcuts')}
</button>
</li>
<li className="nav__dropdown-item">
@ -516,7 +529,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForHelp}
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>Reference
>{this.props.t('Reference')}
</a>
</li>
<li className="nav__dropdown-item">
@ -526,7 +539,7 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
About
{this.props.t('About')}
</Link>
</li>
</ul>
@ -535,18 +548,73 @@ class Nav extends React.PureComponent {
);
}
renderLanguageMenu(navDropdownState) {
return (
<ul className="nav__items-right" title="user-menu">
<li className={navDropdownState.lang}>
<button
onClick={this.toggleDropdownForLang}
onBlur={this.handleBlur}
onFocus={this.clearHideTimeout}
onMouseOver={() => {
if (this.state.dropdownOpen !== 'none') {
this.setDropdown('lang');
}
}}
>
<span className="nav__item-header"> {this.props.t('Lang')}</span>
<TriangleIcon className="nav__item-header-triangle" focusable="false" aria-hidden="true" />
</button>
<ul className="nav__dropdown">
<li className="nav__dropdown-item">
<button
onFocus={this.handleFocusForLang}
onBlur={this.handleBlur}
value="it"
onClick={e => this.handleLangSelection(e)}
>
Italian (Test Fallback)
</button>
</li>
<li className="nav__dropdown-item">
<button
onFocus={this.handleFocusForLang}
onBlur={this.handleBlur}
value="en-US"
onClick={e => this.handleLangSelection(e)}
>English
</button>
</li>
<li className="nav__dropdown-item">
<button
onFocus={this.handleFocusForLang}
onBlur={this.handleBlur}
value="es-419"
onClick={e => this.handleLangSelection(e)}
>
Español
</button>
</li>
</ul>
</li>
</ul>
);
}
renderUnauthenticatedUserMenu(navDropdownState) {
return (
<ul className="nav__items-right" title="user-menu">
<li className="nav__item">
<Link to="/login" className="nav__auth-button">
<span className="nav__item-header">Log in</span>
<span className="nav__item-header">{this.props.t('Login')}</span>
</Link>
</li>
<span className="nav__item-or">or</span>
<span className="nav__item-or">{this.props.t('LoginOr')}</span>
<li className="nav__item">
<Link to="/signup" className="nav__auth-button">
<span className="nav__item-header">Sign up</span>
<span className="nav__item-header">{this.props.t('SignUp')}</span>
</Link>
</li>
</ul>
@ -557,7 +625,7 @@ class Nav extends React.PureComponent {
return (
<ul className="nav__items-right" title="user-menu">
<li className="nav__item">
<span>Hello, {this.props.user.username}!</span>
<span>{this.props.t('Hello')}, {this.props.user.username}!</span>
</li>
<span className="nav__item-spacer">|</span>
<li className={navDropdownState.account}>
@ -572,7 +640,7 @@ class Nav extends React.PureComponent {
}
}}
>
My Account
{this.props.t('MyAccount')}
<TriangleIcon className="nav__item-header-triangle" focusable="false" aria-hidden="true" />
</button>
<ul className="nav__dropdown">
@ -583,7 +651,7 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
My sketches
{this.props.t('MySketches')}
</Link>
</li>
{getConfig('UI_COLLECTIONS_ENABLED') &&
@ -594,7 +662,7 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
My collections
{this.props.t('MyCollections')}
</Link>
</li>
}
@ -605,7 +673,7 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
My assets
{this.props.t('MyAssets')}
</Link>
</li>
<li className="nav__dropdown-item">
@ -615,7 +683,7 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
Settings
{this.props.t('Settings')}
</Link>
</li>
<li className="nav__dropdown-item">
@ -624,7 +692,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForAccount}
onBlur={this.handleBlur}
>
Log out
{this.props.t('LogOut')}
</button>
</li>
</ul>
@ -677,6 +745,10 @@ class Nav extends React.PureComponent {
account: classNames({
'nav__item': true,
'nav__item--open': this.state.dropdownOpen === 'account'
}),
lang: classNames({
'nav__item': true,
'nav__item--open': this.state.dropdownOpen === 'lang'
})
};
@ -684,6 +756,7 @@ class Nav extends React.PureComponent {
<header>
<nav className="nav" title="main-navigation" ref={(node) => { this.node = node; }}>
{this.renderLeftLayout(navDropdownState)}
{getConfig('TRANSLATIONS_ENABLED') && this.renderLanguageMenu(navDropdownState)}
{this.renderUserMenu(navDropdownState)}
</nav>
</header>
@ -734,7 +807,9 @@ Nav.propTypes = {
}).isRequired,
params: PropTypes.shape({
username: PropTypes.string
})
}),
t: PropTypes.func.isRequired
};
Nav.defaultProps = {
@ -767,5 +842,5 @@ const mapDispatchToProps = {
setAllAccessibleOutput
};
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Nav));
export default withTranslation('WebEditor')(withRouter(connect(mapStateToProps, mapDispatchToProps)(Nav)));
export { Nav as NavComponent };

View file

@ -1,183 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { FileNode } from '../../modules/IDE/components/FileNode';
describe('<FileNode />', () => {
let component;
let props = {};
let input;
let renameTriggerButton;
const changeName = (newFileName) => {
renameTriggerButton.simulate('click');
input.simulate('change', { target: { value: newFileName } });
input.simulate('blur');
};
const getState = () => component.state();
const getUpdatedName = () => getState().updatedName;
describe('with valid props, regardless of filetype', () => {
['folder', 'file'].forEach((fileType) => {
beforeEach(() => {
props = {
...props,
id: '0',
name: 'test.jsx',
fileType,
canEdit: true,
children: [],
authenticated: false,
setSelectedFile: jest.fn(),
deleteFile: jest.fn(),
updateFileName: jest.fn(),
resetSelectedFile: jest.fn(),
newFile: jest.fn(),
newFolder: jest.fn(),
showFolderChildren: jest.fn(),
hideFolderChildren: jest.fn(),
openUploadFileModal: jest.fn(),
setProjectName: jest.fn(),
};
component = shallow(<FileNode {...props} />);
});
describe('when changing name', () => {
beforeEach(() => {
input = component.find('.sidebar__file-item-input');
renameTriggerButton = component
.find('.sidebar__file-item-option')
.first();
component.setState({ isEditing: true });
});
describe('to an empty name', () => {
const newName = '';
beforeEach(() => changeName(newName));
it('should not save', () => expect(props.updateFileName).not.toHaveBeenCalled());
it('should reset name', () => expect(getUpdatedName()).toEqual(props.name));
});
});
});
});
describe('as file with valid props', () => {
beforeEach(() => {
props = {
...props,
id: '0',
name: 'test.jsx',
fileType: 'file',
canEdit: true,
children: [],
authenticated: false,
setSelectedFile: jest.fn(),
deleteFile: jest.fn(),
updateFileName: jest.fn(),
resetSelectedFile: jest.fn(),
newFile: jest.fn(),
newFolder: jest.fn(),
showFolderChildren: jest.fn(),
hideFolderChildren: jest.fn(),
openUploadFileModal: jest.fn()
};
component = shallow(<FileNode {...props} />);
});
describe('when changing name', () => {
beforeEach(() => {
input = component.find('.sidebar__file-item-input');
renameTriggerButton = component
.find('.sidebar__file-item-option')
.first();
component.setState({ isEditing: true });
});
it('should render', () => expect(component).toBeDefined());
// it('should debug', () => console.log(component.debug()));
describe('to a valid filename', () => {
const newName = 'newname.jsx';
beforeEach(() => changeName(newName));
it('should save the name', () => {
expect(props.updateFileName).toBeCalledWith(props.id, newName);
});
});
// Failure Scenarios
describe('to an extensionless filename', () => {
const newName = 'extensionless';
beforeEach(() => changeName(newName));
});
it('should not save', () => expect(props.setProjectName).not.toHaveBeenCalled());
it('should reset name', () => expect(getUpdatedName()).toEqual(props.name));
describe('to different extension', () => {
const newName = 'name.gif';
beforeEach(() => changeName(newName));
it('should not save', () => expect(props.setProjectName).not.toHaveBeenCalled());
it('should reset name', () => expect(getUpdatedName()).toEqual(props.name));
});
describe('to just an extension', () => {
const newName = '.jsx';
beforeEach(() => changeName(newName));
it('should not save', () => expect(props.updateFileName).not.toHaveBeenCalled());
it('should reset name', () => expect(getUpdatedName()).toEqual(props.name));
});
});
});
describe('as folder with valid props', () => {
beforeEach(() => {
props = {
...props,
id: '0',
children: [],
name: 'filename',
fileType: 'folder',
canEdit: true,
authenticated: false,
setSelectedFile: jest.fn(),
deleteFile: jest.fn(),
updateFileName: jest.fn(),
resetSelectedFile: jest.fn(),
newFile: jest.fn(),
newFolder: jest.fn(),
showFolderChildren: jest.fn(),
hideFolderChildren: jest.fn(),
openUploadFileModal: jest.fn()
};
component = shallow(<FileNode {...props} />);
});
describe('when changing name', () => {
beforeEach(() => {
input = component.find('.sidebar__file-item-input');
renameTriggerButton = component
.find('.sidebar__file-item-option')
.first();
component.setState({ isEditing: true });
});
describe('to a foldername', () => {
const newName = 'newfoldername';
beforeEach(() => changeName(newName));
it('should save', () => expect(props.updateFileName).toBeCalledWith(props.id, newName));
it('should update name', () => expect(getUpdatedName()).toEqual(newName));
});
describe('to a filename', () => {
const newName = 'filename.jsx';
beforeEach(() => changeName(newName));
it('should not save', () => expect(props.updateFileName).not.toHaveBeenCalled());
it('should reset name', () => expect(getUpdatedName()).toEqual(props.name));
});
});
});
});

View file

@ -1,9 +1,9 @@
import React from 'react';
import { shallow } from 'enzyme';
import renderer from 'react-test-renderer';
import { render } from '@testing-library/react';
import { NavComponent } from './../Nav';
import { NavComponent } from '../Nav';
describe('Nav', () => {
const props = {
@ -44,19 +44,12 @@ describe('Nav', () => {
setToastText: jest.fn(),
rootFile: {
id: 'root-file'
}
},
t: jest.fn()
};
const getWrapper = () => shallow(<NavComponent {...props} />);
test('it renders main navigation', () => {
const nav = getWrapper();
expect(nav.exists('.nav')).toEqual(true);
});
it('renders correctly', () => {
const tree = renderer
.create(<NavComponent {...props} />)
.toJSON();
expect(tree).toMatchSnapshot();
const { asFragment } = render(<NavComponent {...props} />);
expect(asFragment()).toMatchSnapshot();
});
});

View file

@ -1,105 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { ToolbarComponent } from '../../modules/IDE/components/Toolbar';
const initialProps = {
isPlaying: false,
preferencesIsVisible: false,
stopSketch: jest.fn(),
setProjectName: jest.fn(),
openPreferences: jest.fn(),
showEditProjectName: jest.fn(),
hideEditProjectName: jest.fn(),
infiniteLoop: false,
autorefresh: false,
setAutorefresh: jest.fn(),
setTextOutput: jest.fn(),
setGridOutput: jest.fn(),
startSketch: jest.fn(),
startAccessibleSketch: jest.fn(),
saveProject: jest.fn(),
currentUser: 'me',
originalProjectName: 'testname',
owner: {
username: 'me'
},
project: {
name: 'testname',
isEditingName: false,
id: 'id',
},
};
describe('<ToolbarComponent />', () => {
let component;
let props = initialProps;
let input;
let renameTriggerButton;
const changeName = (newFileName) => {
component.find('.toolbar__project-name').simulate('click', { preventDefault: jest.fn() });
input = component.find('.toolbar__project-name-input');
renameTriggerButton = component.find('.toolbar__edit-name-button');
renameTriggerButton.simulate('click');
input.simulate('change', { target: { value: newFileName } });
input.simulate('blur');
};
const setProps = (additionalProps) => {
props = {
...props,
...additionalProps,
project: {
...props.project,
...(additionalProps || {}).project
},
};
};
// Test Cases
describe('with valid props', () => {
beforeEach(() => {
setProps();
component = shallow(<ToolbarComponent {...props} />);
});
it('renders', () => expect(component).toBeDefined());
describe('when use owns sketch', () => {
beforeEach(() => setProps({ currentUser: props.owner.username }));
describe('when changing sketch name', () => {
beforeEach(() => {
setProps({
project: { isEditingName: true, name: 'testname' },
setProjectName: jest.fn(name => component.setProps({ project: { name } })),
});
component = shallow(<ToolbarComponent {...props} />);
});
describe('to a valid name', () => {
beforeEach(() => changeName('hello'));
it('should save', () => expect(props.setProjectName).toBeCalledWith('hello'));
});
describe('to an empty name', () => {
beforeEach(() => changeName(''));
it('should set name to empty', () => expect(props.setProjectName).toBeCalledWith(''));
it(
'should detect empty name and revert to original',
() => expect(props.setProjectName).toHaveBeenLastCalledWith(initialProps.project.name)
);
});
});
});
describe('when user does not own sketch', () => {
beforeEach(() => setProps({ currentUser: 'not-the-owner' }));
it('should disable edition', () => expect(component.find('.toolbar__edit-name-button')).toEqual({}));
});
});
});

View file

@ -1,346 +1,219 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Nav renders correctly 1`] = `
<DocumentFragment>
<header>
<nav
className="nav"
class="nav"
title="main-navigation"
>
<ul
className="nav__items-left"
class="nav__items-left"
>
<li
className="nav__item-logo"
class="nav__item-logo"
>
<test-file-stub
aria-label="p5.js Logo"
className="svg__logo"
classname="svg__logo"
focusable="false"
role="img"
/>
</li>
<li
className="nav__item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
class="nav__item"
>
<button>
<span
className="nav__item-header"
>
File
</span>
class="nav__item-header"
/>
<test-file-stub
aria-hidden="true"
className="nav__item-header-triangle"
classname="nav__item-header-triangle"
focusable="false"
/>
</button>
<ul
className="nav__dropdown"
class="nav__dropdown"
>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
New
</button>
<button />
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Duplicate
</button>
<button />
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Share
</button>
<button />
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Download
</button>
<button />
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<a
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
style={Object {}}
>
Open
</a>
<a />
</li>
</ul>
</li>
<li
className="nav__item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
class="nav__item"
>
<button>
<span
className="nav__item-header"
>
Edit
</span>
class="nav__item-header"
/>
<test-file-stub
aria-hidden="true"
className="nav__item-header-triangle"
classname="nav__item-header-triangle"
focusable="false"
/>
</button>
<ul
className="nav__dropdown"
class="nav__dropdown"
>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Tidy Code
<button>
<span
className="nav__keyboard-shortcut"
class="nav__keyboard-shortcut"
>
+Tab
⇧+Tab
</span>
</button>
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Find
<button>
<span
className="nav__keyboard-shortcut"
class="nav__keyboard-shortcut"
>
+F
⌃+F
</span>
</button>
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Find Next
<button>
<span
className="nav__keyboard-shortcut"
class="nav__keyboard-shortcut"
>
+G
⌃+G
</span>
</button>
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Find Previous
<button>
<span
className="nav__keyboard-shortcut"
class="nav__keyboard-shortcut"
>
+
+G
⇧+⌃+G
</span>
</button>
</li>
</ul>
</li>
<li
className="nav__item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
class="nav__item"
>
<button>
<span
className="nav__item-header"
>
Sketch
</span>
class="nav__item-header"
/>
<test-file-stub
aria-hidden="true"
className="nav__item-header-triangle"
classname="nav__item-header-triangle"
focusable="false"
/>
</button>
<ul
className="nav__dropdown"
class="nav__dropdown"
>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Add File
</button>
<button />
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Add Folder
</button>
<button />
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Run
<button>
<span
className="nav__keyboard-shortcut"
class="nav__keyboard-shortcut"
>
+Enter
⌃+Enter
</span>
</button>
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Stop
<button>
<span
className="nav__keyboard-shortcut"
class="nav__keyboard-shortcut"
>
+
+Enter
⇧+⌃+Enter
</span>
</button>
</li>
</ul>
</li>
<li
className="nav__item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
class="nav__item"
>
<button>
<span
className="nav__item-header"
>
Help
</span>
class="nav__item-header"
/>
<test-file-stub
aria-hidden="true"
className="nav__item-header-triangle"
classname="nav__item-header-triangle"
focusable="false"
/>
</button>
<ul
className="nav__dropdown"
class="nav__dropdown"
>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Keyboard Shortcuts
</button>
<button />
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<a
href="https://p5js.org/reference/"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
rel="noopener noreferrer"
target="_blank"
>
Reference
</a>
/>
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<a
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
style={Object {}}
>
About
</a>
<a />
</li>
</ul>
</li>
</ul>
</nav>
</header>
</DocumentFragment>
`;

38
client/i18n.js Normal file
View file

@ -0,0 +1,38 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
const fallbackLng = ['en-US'];
const availableLanguages = ['en-US', 'es-419'];
const options = {
loadPath: '/translations/{{lng}}/translations.json',
requestOptions: { // used for fetch, can also be a function (payload) => ({ method: 'GET' })
mode: 'no-cors'
},
allowMultiLoading: false, // set loadPath: '/locales/resources.json?lng={{lng}}&ns={{ns}}' to adapt to multiLoading
};
i18n
.use(initReactI18next) // pass the i18n instance to react-i18next.
.use(LanguageDetector)// to detect the language from currentBrowser
.use(Backend) // to fetch the data from server
.init({
lng: 'en-US',
defaultNS: 'WebEditor',
fallbackLng, // if user computer language is not on the list of available languages, than we will be using the fallback language specified earlier
debug: false,
backend: options,
getAsync: false,
initImmediate: false,
useSuspense: true,
whitelist: availableLanguages,
interpolation: {
escapeValue: false, // react already safes from xss
},
saveMissing: false, // if a key is not found AND this flag is set to true, i18next will call the handler missingKeyHandler
missingKeyHandler: false // function(lng, ns, key, fallbackValue) { } custom logic about how to handle the missing keys
});
export default i18n;

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { Suspense } from 'react';
import { render } from 'react-dom';
import { hot } from 'react-hot-loader/root';
import { Provider } from 'react-redux';
@ -7,6 +7,8 @@ import { Router, browserHistory } from 'react-router';
import configureStore from './store';
import routes from './routes';
import ThemeProvider from './modules/App/components/ThemeProvider';
import Loader from './modules/App/components/loader';
import i18n from './i18n';
require('./styles/main.scss');
@ -29,6 +31,8 @@ const App = () => (
const HotApp = hot(App);
render(
<HotApp />,
<Suspense fallback={(<Loader />)}>
<HotApp />
</Suspense>,
document.getElementById('root')
);

5
client/jest.setup.js Normal file
View file

@ -0,0 +1,5 @@
import '@babel/polyfill';
// See: https://github.com/testing-library/jest-dom
// eslint-disable-next-line import/no-extraneous-dependencies
import '@testing-library/jest-dom';

View file

@ -1,11 +1,12 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { useTranslation } from 'react-i18next';
import SquareLogoIcon from '../../../images/p5js-square-logo.svg';
// import PlayIcon from '../../../images/play.svg';
import AsteriskIcon from '../../../images/p5-asterisk.svg';
function About(props) {
const { t } = useTranslation();
return (
<div className="about__content">
<Helmet>
@ -25,7 +26,7 @@ function About(props) {
</p> */}
</div>
<div className="about__content-column">
<h3 className="about__content-column-title">New to p5.js?</h3>
<h3 className="about__content-column-title">{t('NewP5')}</h3>
<p className="about__content-column-list">
<a
href="https://p5js.org/examples/"
@ -33,7 +34,7 @@ function About(props) {
rel="noopener noreferrer"
>
<AsteriskIcon className="about__content-column-asterisk" aria-hidden="true" focusable="false" />
Examples
{t('Examples')}
</a>
</p>
<p className="about__content-column-list">
@ -43,12 +44,12 @@ function About(props) {
rel="noopener noreferrer"
>
<AsteriskIcon className="about__content-column-asterisk" aria-hidden="true" focusable="false" />
Learn
{t('Learn')}
</a>
</p>
</div>
<div className="about__content-column">
<h3 className="about__content-column-title">Resources</h3>
<h3 className="about__content-column-title">{t('Resources')}</h3>
<p className="about__content-column-list">
<a
href="https://p5js.org/libraries/"
@ -56,7 +57,7 @@ function About(props) {
rel="noopener noreferrer"
>
<AsteriskIcon className="about__content-column-asterisk" aria-hidden="true" focusable="false" />
Libraries
{t('Libraries')}
</a>
</p>
<p className="about__content-column-list">
@ -66,7 +67,7 @@ function About(props) {
rel="noopener noreferrer"
>
<AsteriskIcon className="about__content-column-asterisk" aria-hidden="true" focusable="false" />
Reference
{t('Reference')}
</a>
</p>
<p className="about__content-column-list">
@ -76,7 +77,7 @@ function About(props) {
rel="noopener noreferrer"
>
<AsteriskIcon className="about__content-column-asterisk" aria-hidden="true" focusable="false" />
Forum
{t('Forum')}
</a>
</p>
</div>
@ -86,7 +87,7 @@ function About(props) {
href="https://github.com/processing/p5.js-web-editor"
target="_blank"
rel="noopener noreferrer"
>Contribute
>{t('Contribute')}
</a>
</p>
<p className="about__footer-list">
@ -94,7 +95,7 @@ function About(props) {
href="https://github.com/processing/p5.js-web-editor/issues/new"
target="_blank"
rel="noopener noreferrer"
>Report a bug
>{t('Report')}
</a>
</p>
<p className="about__footer-list">

View file

@ -206,12 +206,14 @@ export class FileNode extends React.Component {
</div>
}
<button
aria-label="Name"
className="sidebar__file-item-name"
onClick={this.handleFileClick}
>
{this.state.updatedName}
</button>
<input
data-testid="input"
type="text"
className="sidebar__file-item-input"
value={this.state.updatedName}

View file

@ -0,0 +1,31 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import { FileNode } from './FileNode';
export default {
title: 'IDE/FileNode',
component: FileNode
};
export const Show = () => (
<FileNode
id="nodeId"
parantId="parentId"
name="File name"
fileType="jpeg"
isSelectedFile
isFolderClosed={false}
setSelectedFile={action('setSelectedFile')}
deleteFile={action('deleteFile')}
updateFileName={action('updateFileName')}
resetSelectedFile={action('resetSelectedFile')}
newFile={action('newFile')}
newFolder={action('newFolder')}
showFolderChildren={action('showFolderChildren')}
hideFolderChildren={action('hideFolderChildren')}
openUploadFileModal={action('openUploadFileModal')}
canEdit
authenticated
/>
);

View file

@ -0,0 +1,127 @@
import React from 'react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { FileNode } from './FileNode';
describe('<FileNode />', () => {
const changeName = (newFileName) => {
const renameButton = screen.getByText(/Rename/i);
fireEvent.click(renameButton);
const input = screen.getByTestId('input');
fireEvent.change(input, { target: { value: newFileName } });
fireEvent.blur(input);
};
const expectFileNameToBe = async (expectedName) => {
const name = screen.getByLabelText(/Name/i);
await waitFor(() => within(name).queryByText(expectedName));
};
const renderFileNode = (fileType, extraProps = {}) => {
const props = {
...extraProps,
id: '0',
name: fileType === 'folder' ? 'afolder' : 'test.jsx',
fileType,
canEdit: true,
children: [],
authenticated: false,
setSelectedFile: jest.fn(),
deleteFile: jest.fn(),
updateFileName: jest.fn(),
resetSelectedFile: jest.fn(),
newFile: jest.fn(),
newFolder: jest.fn(),
showFolderChildren: jest.fn(),
hideFolderChildren: jest.fn(),
openUploadFileModal: jest.fn(),
setProjectName: jest.fn(),
};
render(<FileNode {...props} />);
return props;
};
describe('fileType: file', () => {
it('cannot change to an empty name', async () => {
const props = renderFileNode('file');
changeName('');
await waitFor(() => expect(props.updateFileName).not.toHaveBeenCalled());
await expectFileNameToBe(props.name);
});
it('can change to a valid filename', async () => {
const newName = 'newname.jsx';
const props = renderFileNode('file');
changeName(newName);
await waitFor(() => expect(props.updateFileName).toHaveBeenCalledWith(props.id, newName));
await expectFileNameToBe(newName);
});
it('must have an extension', async () => {
const newName = 'newname';
const props = renderFileNode('file');
changeName(newName);
await waitFor(() => expect(props.updateFileName).not.toHaveBeenCalled());
await expectFileNameToBe(props.name);
});
it('can change to a different extension', async () => {
const newName = 'newname.gif';
const props = renderFileNode('file');
changeName(newName);
await waitFor(() => expect(props.updateFileName).not.toHaveBeenCalled());
await expectFileNameToBe(props.name);
});
it('cannot be just an extension', async () => {
const newName = '.jsx';
const props = renderFileNode('file');
changeName(newName);
await waitFor(() => expect(props.updateFileName).not.toHaveBeenCalled());
await expectFileNameToBe(props.name);
});
});
describe('fileType: folder', () => {
it('cannot change to an empty name', async () => {
const props = renderFileNode('folder');
changeName('');
await waitFor(() => expect(props.updateFileName).not.toHaveBeenCalled());
await expectFileNameToBe(props.name);
});
it('can change to another name', async () => {
const newName = 'foldername';
const props = renderFileNode('folder');
changeName(newName);
await waitFor(() => expect(props.updateFileName).toHaveBeenCalledWith(props.id, newName));
await expectFileNameToBe(newName);
});
it('cannot have a file extension', async () => {
const newName = 'foldername.jsx';
const props = renderFileNode('folder');
changeName(newName);
await waitFor(() => expect(props.updateFileName).not.toHaveBeenCalled());
await expectFileNameToBe(props.name);
});
});
});

View file

@ -1,53 +1,55 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { metaKeyName, } from '../../../utils/metaKey';
function KeyboardShortcutModal() {
const { t } = useTranslation();
return (
<div className="keyboard-shortcuts">
<h3 className="keyboard-shortcuts__title">Code Editing</h3>
<h3 className="keyboard-shortcuts__title">{t('CodeEditing')}</h3>
<p className="keyboard-shortcuts__description">
Code editing keyboard shortcuts follow <a href="https://shortcuts.design/toolspage-sublimetext.html" target="_blank" rel="noopener noreferrer">Sublime Text shortcuts</a>.
{t('Code editing keyboard shortcuts follow')} <a href="https://shortcuts.design/toolspage-sublimetext.html" target="_blank" rel="noopener noreferrer">{t('Sublime Text shortcuts')}</a>.
</p>
<ul className="keyboard-shortcuts__list">
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">{'\u21E7'} + Tab</span>
<span>Tidy</span>
<span>{t('Tidy')}</span>
</li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
{metaKeyName} + F
</span>
<span>Find Text</span>
<span>{t('FindText')}</span>
</li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
{metaKeyName} + G
</span>
<span>Find Next Text Match</span>
<span>{t('FindNextTextMatch')}</span>
</li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
{metaKeyName} + {'\u21E7'} + G
</span>
<span>Find Previous Text Match</span>
<span>{t('FindPreviousTextMatch')}</span>
</li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
{metaKeyName} + [
</span>
<span>Indent Code Left</span>
<span>{t('IndentCodeLeft')}</span>
</li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
{metaKeyName} + ]
</span>
<span>Indent Code Right</span>
<span>{t('IndentCodeRight')}</span>
</li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
{metaKeyName} + /
</span>
<span>Comment Line</span>
<span>{t('CommentLine')}</span>
</li>
</ul>
<h3 className="keyboard-shortcuts__title">General</h3>
@ -56,31 +58,31 @@ function KeyboardShortcutModal() {
<span className="keyboard-shortcut__command">
{metaKeyName} + S
</span>
<span>Save</span>
<span>{t('Save')}</span>
</li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
{metaKeyName} + Enter
</span>
<span>Start Sketch</span>
<span>{t('StartSketch')}</span>
</li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
{metaKeyName} + {'\u21E7'} + Enter
</span>
<span>Stop Sketch</span>
<span>{t('StopSketch')}</span>
</li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
{metaKeyName} + {'\u21E7'} + 1
</span>
<span>Turn on Accessible Output</span>
<span>{t('TurnOnAccessibleOutput')}</span>
</li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
{metaKeyName} + {'\u21E7'} + 2
</span>
<span>Turn off Accessible Output</span>
<span>{t('TurnOffAccessibleOutput')}</span>
</li>
</ul>
</div>

View file

@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react';
import { Helmet } from 'react-helmet';
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';
@ -98,13 +99,13 @@ class Preferences extends React.Component {
<Tabs>
<TabList>
<div className="tabs__titles">
<Tab><h4 className="tabs__title">General Settings</h4></Tab>
<Tab><h4 className="tabs__title">Accessibility</h4></Tab>
<Tab><h4 className="tabs__title">{this.props.t('GeneralSettings')}</h4></Tab>
<Tab><h4 className="tabs__title">{this.props.t('Accessibility')}</h4></Tab>
</div>
</TabList>
<TabPanel>
<div className="preference">
<h4 className="preference__title">Theme</h4>
<h4 className="preference__title">{this.props.t('Theme')}</h4>
<div className="preference__options">
<input
type="radio"
@ -116,7 +117,7 @@ class Preferences extends React.Component {
value="light"
checked={this.props.theme === 'light'}
/>
<label htmlFor="light-theme-on" className="preference__option">Light</label>
<label htmlFor="light-theme-on" className="preference__option">{this.props.t('Light')}</label>
<input
type="radio"
onChange={() => this.props.setTheme('dark')}
@ -127,7 +128,7 @@ class Preferences extends React.Component {
value="dark"
checked={this.props.theme === 'dark'}
/>
<label htmlFor="dark-theme-on" className="preference__option">Dark</label>
<label htmlFor="dark-theme-on" className="preference__option">{this.props.t('Dark')}</label>
<input
type="radio"
onChange={() => this.props.setTheme('contrast')}
@ -138,11 +139,11 @@ class Preferences extends React.Component {
value="contrast"
checked={this.props.theme === 'contrast'}
/>
<label htmlFor="high-contrast-theme-on" className="preference__option">High Contrast</label>
<label htmlFor="high-contrast-theme-on" className="preference__option">{this.props.t('HighContrast')}</label>
</div>
</div>
<div className="preference">
<h4 className="preference__title">Text size</h4>
<h4 className="preference__title">{this.props.t('TextSize')}</h4>
<button
className="preference__minus-button"
onClick={this.decreaseFontSize}
@ -150,7 +151,7 @@ class Preferences extends React.Component {
disabled={this.state.fontSize <= 8}
>
<MinusIcon focusable="false" aria-hidden="true" />
<h6 className="preference__label">Decrease</h6>
<h6 className="preference__label">{this.props.t('Decrease')}</h6>
</button>
<form onSubmit={this.onFontInputSubmit}>
<input
@ -171,11 +172,11 @@ class Preferences extends React.Component {
disabled={this.state.fontSize >= 36}
>
<PlusIcon focusable="false" aria-hidden="true" />
<h6 className="preference__label">Increase</h6>
<h6 className="preference__label">{this.props.t('Increase')}</h6>
</button>
</div>
<div className="preference">
<h4 className="preference__title">Autosave</h4>
<h4 className="preference__title">{this.props.t('Autosave')}</h4>
<div className="preference__options">
<input
type="radio"
@ -187,7 +188,7 @@ class Preferences extends React.Component {
value="On"
checked={this.props.autosave}
/>
<label htmlFor="autosave-on" className="preference__option">On</label>
<label htmlFor="autosave-on" className="preference__option">{this.props.t('On')}</label>
<input
type="radio"
onChange={() => this.props.setAutosave(false)}
@ -198,11 +199,11 @@ class Preferences extends React.Component {
value="Off"
checked={!this.props.autosave}
/>
<label htmlFor="autosave-off" className="preference__option">Off</label>
<label htmlFor="autosave-off" className="preference__option">{this.props.t('Off')}</label>
</div>
</div>
<div className="preference">
<h4 className="preference__title">Word Wrap</h4>
<h4 className="preference__title">{this.props.t('WordWrap')}</h4>
<div className="preference__options">
<input
type="radio"
@ -214,7 +215,7 @@ class Preferences extends React.Component {
value="On"
checked={this.props.linewrap}
/>
<label htmlFor="linewrap-on" className="preference__option">On</label>
<label htmlFor="linewrap-on" className="preference__option">{this.props.t('On')}</label>
<input
type="radio"
onChange={() => this.props.setLinewrap(false)}
@ -225,13 +226,13 @@ class Preferences extends React.Component {
value="Off"
checked={!this.props.linewrap}
/>
<label htmlFor="linewrap-off" className="preference__option">Off</label>
<label htmlFor="linewrap-off" className="preference__option">{this.props.t('Off')}</label>
</div>
</div>
</TabPanel>
<TabPanel>
<div className="preference">
<h4 className="preference__title">Line numbers</h4>
<h4 className="preference__title">{this.props.t('LineNumbers')}</h4>
<div className="preference__options">
<input
type="radio"
@ -243,7 +244,7 @@ class Preferences extends React.Component {
value="On"
checked={this.props.lineNumbers}
/>
<label htmlFor="line-numbers-on" className="preference__option">On</label>
<label htmlFor="line-numbers-on" className="preference__option">{this.props.t('On')}</label>
<input
type="radio"
onChange={() => this.props.setLineNumbers(false)}
@ -254,11 +255,11 @@ class Preferences extends React.Component {
value="Off"
checked={!this.props.lineNumbers}
/>
<label htmlFor="line-numbers-off" className="preference__option">Off</label>
<label htmlFor="line-numbers-off" className="preference__option">{this.props.t('Off')}</label>
</div>
</div>
<div className="preference">
<h4 className="preference__title">Lint warning sound</h4>
<h4 className="preference__title">{this.props.t('LintWarningSound')}</h4>
<div className="preference__options">
<input
type="radio"
@ -270,7 +271,7 @@ class Preferences extends React.Component {
value="On"
checked={this.props.lintWarning}
/>
<label htmlFor="lint-warning-on" className="preference__option">On</label>
<label htmlFor="lint-warning-on" className="preference__option">{this.props.t('On')}</label>
<input
type="radio"
onChange={() => this.props.setLintWarning(false)}
@ -281,19 +282,19 @@ class Preferences extends React.Component {
value="Off"
checked={!this.props.lintWarning}
/>
<label htmlFor="lint-warning-off" className="preference__option">Off</label>
<label htmlFor="lint-warning-off" className="preference__option">{this.props.t('Off')}</label>
<button
className="preference__preview-button"
onClick={() => beep.play()}
aria-label="preview sound"
>
Preview sound
{this.props.t('PreviewSound')}
</button>
</div>
</div>
<div className="preference">
<h4 className="preference__title">Accessible text-based canvas</h4>
<h6 className="preference__subtitle">Used with screen reader</h6>
<h4 className="preference__title">{this.props.t('AccessibleTextBasedCanvas')}</h4>
<h6 className="preference__subtitle">{this.props.t('UsedScreenReader')}</h6>
<div className="preference__options">
<input
@ -307,7 +308,7 @@ class Preferences extends React.Component {
value="On"
checked={(this.props.textOutput)}
/>
<label htmlFor="text-output-on" className="preference__option preference__canvas">Plain-text</label>
<label htmlFor="text-output-on" className="preference__option preference__canvas">{this.props.t('PlainText')}</label>
<input
type="checkbox"
onChange={(event) => {
@ -319,7 +320,7 @@ class Preferences extends React.Component {
value="On"
checked={(this.props.gridOutput)}
/>
<label htmlFor="table-output-on" className="preference__option preference__canvas">Table-text</label>
<label htmlFor="table-output-on" className="preference__option preference__canvas">{this.props.t('TableText')}</label>
<input
type="checkbox"
onChange={(event) => {
@ -331,7 +332,7 @@ class Preferences extends React.Component {
value="On"
checked={(this.props.soundOutput)}
/>
<label htmlFor="sound-output-on" className="preference__option preference__canvas">Sound</label>
<label htmlFor="sound-output-on" className="preference__option preference__canvas">{this.props.t('Sound')}</label>
</div>
</div>
</TabPanel>
@ -360,6 +361,7 @@ Preferences.propTypes = {
setLintWarning: PropTypes.func.isRequired,
theme: PropTypes.string.isRequired,
setTheme: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
};
export default Preferences;
export default withTranslation('WebEditor')(Preferences);

View file

@ -64,7 +64,8 @@ class PreviewFrame extends React.Component {
componentWillUnmount() {
window.removeEventListener('message', this.handleConsoleEvent);
ReactDOM.unmountComponentAtNode(this.iframeElement.contentDocument.body);
const iframeBody = this.iframeElement.contentDocument.body;
if (iframeBody) { ReactDOM.unmountComponentAtNode(iframeBody); }
}
handleConsoleEvent(messageEvent) {

View file

@ -2,15 +2,17 @@ import PropTypes from 'prop-types';
import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { useTranslation } from 'react-i18next';
import * as ToastActions from '../actions/toast';
import ExitIcon from '../../../images/exit.svg';
function Toast(props) {
const { t } = useTranslation('WebEditor');
return (
<section className="toast">
<p>
{props.text}
{t(props.text)}
</p>
<button className="toast__close" onClick={props.hideToast} aria-label="Close Alert" >
<ExitIcon focusable="false" aria-hidden="true" />

View file

@ -17,21 +17,36 @@ class Toolbar extends React.Component {
super(props);
this.handleKeyPress = this.handleKeyPress.bind(this);
this.handleProjectNameChange = this.handleProjectNameChange.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.props.setProjectName(event.target.value);
this.setState({ projectNameInputValue: event.target.value });
}
validateProjectName() {
if ((this.props.project.name.trim()).length === 0) {
this.props.setProjectName(this.originalProjectName);
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();
}
}
}
@ -108,7 +123,6 @@ class Toolbar extends React.Component {
className="toolbar__project-name"
onClick={() => {
if (canEditProjectName) {
this.originalProjectName = this.props.project.name;
this.props.showEditProjectName();
setTimeout(() => this.projectNameInput.focus(), 0);
}
@ -130,16 +144,11 @@ class Toolbar extends React.Component {
type="text"
maxLength="128"
className="toolbar__project-name-input"
value={this.props.project.name}
aria-label="New sketch name"
value={this.state.projectNameInputValue}
onChange={this.handleProjectNameChange}
ref={(element) => { this.projectNameInput = element; }}
onBlur={() => {
this.validateProjectName();
this.props.hideEditProjectName();
if (this.props.project.id) {
this.props.saveProject();
}
}}
onBlur={this.handleProjectNameSave}
onKeyPress={this.handleKeyPress}
/>
{(() => { // eslint-disable-line

View file

@ -0,0 +1,84 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import lodash from 'lodash';
import { ToolbarComponent } from './Toolbar';
const renderComponent = (extraProps = {}) => {
const props = lodash.merge({
isPlaying: false,
preferencesIsVisible: false,
stopSketch: jest.fn(),
setProjectName: jest.fn(),
openPreferences: jest.fn(),
showEditProjectName: jest.fn(),
hideEditProjectName: jest.fn(),
infiniteLoop: false,
autorefresh: false,
setAutorefresh: jest.fn(),
setTextOutput: jest.fn(),
setGridOutput: jest.fn(),
startSketch: jest.fn(),
startAccessibleSketch: jest.fn(),
saveProject: jest.fn(),
currentUser: 'me',
originalProjectName: 'testname',
owner: {
username: 'me'
},
project: {
name: 'testname',
isEditingName: false,
id: 'id',
},
}, extraProps);
render(<ToolbarComponent {...props} />);
return props;
};
describe('<ToolbarComponent />', () => {
it('sketch owner can switch to sketch name editing mode', async () => {
const props = renderComponent();
const sketchName = screen.getByLabelText('Edit sketch name');
fireEvent.click(sketchName);
await waitFor(() => expect(props.showEditProjectName).toHaveBeenCalled());
});
it('non-owner can\t switch to sketch editing mode', async () => {
const props = renderComponent({ currentUser: 'not-me' });
const sketchName = screen.getByLabelText('Edit sketch name');
fireEvent.click(sketchName);
expect(sketchName).toBeDisabled();
await waitFor(() => expect(props.showEditProjectName).not.toHaveBeenCalled());
});
it('sketch owner can change name', async () => {
const props = renderComponent({ project: { isEditingName: true } });
const sketchNameInput = screen.getByLabelText('New sketch name');
fireEvent.change(sketchNameInput, { target: { value: 'my new sketch name' } });
fireEvent.blur(sketchNameInput);
await waitFor(() => expect(props.setProjectName).toHaveBeenCalledWith('my new sketch name'));
await waitFor(() => expect(props.saveProject).toHaveBeenCalled());
});
it('sketch owner can\'t change to empty name', async () => {
const props = renderComponent({ project: { isEditingName: true } });
const sketchNameInput = screen.getByLabelText('New sketch name');
fireEvent.change(sketchNameInput, { target: { value: '' } });
fireEvent.blur(sketchNameInput);
await waitFor(() => expect(props.setProjectName).not.toHaveBeenCalled());
await waitFor(() => expect(props.saveProject).not.toHaveBeenCalled());
});
});

View file

@ -3,6 +3,7 @@ import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import { withTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet';
import SplitPane from 'react-split-pane';
import Editor from '../components/Editor';
@ -54,7 +55,7 @@ function warnIfUnsavedChanges(props) { // eslint-disable-line
props.persistState();
window.onbeforeunload = null;
} else if (props.ide.unsavedChanges) {
if (!window.confirm('Are you sure you want to leave this page? You have unsaved changes.')) {
if (!window.confirm(this.props.t('WarningUnsavedChanges'))) {
return false;
}
props.setUnsavedChanges(false);
@ -219,10 +220,10 @@ class IDEView extends React.Component {
warnIfUnsavedChanges={this.warnIfUnsavedChangesCaller.bind(this, this.props)}
cmController={this.cmController}
/>
<Toolbar />
<Toolbar key={this.props.project.id} />
{this.props.ide.preferencesIsVisible &&
<Overlay
title="Settings"
title={this.props.t('Settings')}
ariaLabel="settings"
closeOverlay={this.props.closePreferences}
>
@ -338,7 +339,7 @@ class IDEView extends React.Component {
</SplitPane>
<section className="preview-frame-holder">
<header className="preview-frame__header">
<h2 className="preview-frame__title">Preview</h2>
<h2 className="preview-frame__title">{this.props.t('Preview')}</h2>
</header>
<div className="preview-frame__content">
<div className="preview-frame-overlay" ref={(element) => { this.overlay = element; }}>
@ -399,7 +400,7 @@ class IDEView extends React.Component {
}
{ this.props.location.pathname === '/about' &&
<Overlay
title="About"
title={this.props.t('About')}
previousPath={this.props.ide.previousPath}
ariaLabel="about"
>
@ -445,7 +446,7 @@ class IDEView extends React.Component {
}
{this.props.ide.keyboardShortcutVisible &&
<Overlay
title="Keyboard Shortcuts"
title={this.props.t('KeyboardShortcuts')}
ariaLabel="keyboard shortcuts"
closeOverlay={this.props.closeKeyboardShortcutModal}
>
@ -612,7 +613,8 @@ IDEView.propTypes = {
hideRuntimeErrorWarning: PropTypes.func.isRequired,
startSketch: PropTypes.func.isRequired,
openUploadFileModal: PropTypes.func.isRequired,
closeUploadFileModal: PropTypes.func.isRequired
closeUploadFileModal: PropTypes.func.isRequired,
t: PropTypes.func.isRequired
};
function mapStateToProps(state) {
@ -649,4 +651,6 @@ function mapDispatchToProps(dispatch) {
);
}
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(IDEView));
export default withTranslation('WebEditor')(withRouter(connect(mapStateToProps, mapDispatchToProps)(IDEView)));

View file

@ -71,41 +71,50 @@ ShareURL.propTypes = {
value: PropTypes.string.isRequired,
};
class CollectionItemRowBase extends React.Component {
handleSketchRemove = () => {
if (window.confirm(`Are you sure you want to remove "${this.props.item.project.name}" from this collection?`)) {
this.props.removeFromCollection(this.props.collection.id, this.props.item.project.id);
}
}
const CollectionItemRowBase = ({
collection, item, isOwner, removeFromCollection
}) => {
const projectIsDeleted = item.isDeleted;
render() {
const { item } = this.props;
const sketchOwnerUsername = item.project.user.username;
const sketchUrl = `/${item.project.user.username}/sketches/${item.project.id}`;
const handleSketchRemove = () => {
const name = projectIsDeleted ? 'deleted sketch' : item.project.name;
if (window.confirm(`Are you sure you want to remove "${name}" from this collection?`)) {
removeFromCollection(collection.id, item.projectId);
}
};
const name = projectIsDeleted ? <span>Sketch was deleted</span> : (
<Link to={`/${item.project.user.username}/sketches/${item.projectId}`}>
{item.project.name}
</Link>
);
const sketchOwnerUsername = projectIsDeleted ? null : item.project.user.username;
return (
<tr
className="sketches-table__row"
className={`sketches-table__row ${projectIsDeleted ? 'is-deleted' : ''}`}
>
<th scope="row">
<Link to={sketchUrl}>
{item.project.name}
</Link>
{name}
</th>
<td>{format(new Date(item.createdAt), 'MMM D, YYYY h:mm A')}</td>
<td>{sketchOwnerUsername}</td>
<td className="collection-row__action-column ">
{isOwner &&
<button
className="collection-row__remove-button"
onClick={this.handleSketchRemove}
onClick={handleSketchRemove}
aria-label="Remove sketch from collection"
>
<RemoveIcon focusable="false" aria-hidden="true" />
</button>
}
</td>
</tr>);
}
}
};
CollectionItemRowBase.propTypes = {
collection: PropTypes.shape({
@ -114,14 +123,17 @@ CollectionItemRowBase.propTypes = {
}).isRequired,
item: PropTypes.shape({
createdAt: PropTypes.string.isRequired,
projectId: PropTypes.string.isRequired,
isDeleted: PropTypes.bool.isRequired,
project: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
user: PropTypes.shape({
username: PropTypes.string.isRequired
})
}),
}).isRequired,
}).isRequired,
isOwner: PropTypes.bool.isRequired,
user: PropTypes.shape({
username: PropTypes.string,
authenticated: PropTypes.bool.isRequired
@ -342,6 +354,7 @@ class Collection extends React.Component {
render() {
const title = this.hasCollection() ? this.getCollectionName() : null;
const isOwner = this.isOwner();
return (
<main className="collection-container" data-has-items={this.hasCollectionItems() ? 'true' : 'false'}>
@ -372,6 +385,7 @@ class Collection extends React.Component {
user={this.props.user}
username={this.getUsername()}
collection={this.props.collection}
isOwner={isOwner}
/>))}
</tbody>
</table>

View file

@ -85,6 +85,10 @@
}
}
.sketches-table__row.is-deleted > * {
font-style: italic;
}
.sketches-table thead {
font-size: #{12 / $base-font-size}rem;
@include themify() {

View file

@ -1,8 +0,0 @@
import { configure } from 'enzyme'
import Adapter from 'enzyme-adapter-react-16'
import '@babel/polyfill'
import mongoose from 'mongoose'
mongoose.Promise = global.Promise;
configure({ adapter: new Adapter() })

8242
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -36,12 +36,30 @@
]
},
"jest": {
"setupFiles": [
"<rootDir>/jest.setup.js"
"projects": [
{
"displayName": "server",
"testEnvironment": "node",
"setupFilesAfterEnv": [
"<rootDir>/server/jest.setup.js"
],
"testMatch": [
"<rootDir>/server/**/*.test.(js|jsx)"
]
},
{
"displayName": "client",
"setupFilesAfterEnv": [
"<rootDir>/client/jest.setup.js"
],
"moduleNameMapper": {
"^.+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/client/__test__/mocks/fileMock.js"
},
"testMatch": [
"<rootDir>/client/**/*.test.(js|jsx)"
]
}
]
},
"main": "index.js",
"author": "Cassie Tarakajian",
@ -78,15 +96,14 @@
"@storybook/addons": "^5.3.6",
"@storybook/react": "^5.3.6",
"@svgr/webpack": "^5.4.0",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.2.1",
"babel-core": "^7.0.0-bridge.0",
"babel-eslint": "^9.0.0",
"babel-jest": "^24.9.0",
"babel-loader": "^8.0.0",
"babel-plugin-transform-react-remove-prop-types": "^0.2.12",
"css-loader": "^3.4.2",
"cssnano": "^4.1.10",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2",
"eslint": "^4.19.1",
"eslint-config-airbnb": "^16.1.0",
"eslint-plugin-import": "^2.20.2",
@ -94,10 +111,10 @@
"eslint-plugin-react": "^7.18.3",
"file-loader": "^2.0.0",
"husky": "^4.2.5",
"jest": "^24.9.0",
"jest": "^26.0.1",
"lint-staged": "^10.1.3",
"mini-css-extract-plugin": "^0.8.2",
"node-sass": "^4.13.1",
"node-sass": "^4.14.1",
"nodemon": "^1.19.4",
"optimize-css-assets-webpack-plugin": "^5.0.3",
"postcss-cssnext": "^3.1.0",
@ -151,6 +168,9 @@
"express-session": "^1.17.0",
"friendly-words": "^1.1.10",
"htmlhint": "^0.10.1",
"i18next": "^19.4.5",
"i18next-browser-languagedetector": "^4.2.0",
"i18next-http-backend": "^1.0.15",
"is-url": "^1.2.4",
"jest-express": "^1.11.0",
"js-beautify": "^1.10.3",
@ -178,6 +198,7 @@
"react-dom": "^16.12.0",
"react-helmet": "^5.1.3",
"react-hot-loader": "^4.12.19",
"react-i18next": "^11.5.0",
"react-redux": "^5.1.2",
"react-router": "^3.2.5",
"react-split-pane": "^0.1.89",

View file

@ -32,7 +32,7 @@ export default function addProjectToCollection(req, res) {
return null;
}
const projectInCollection = collection.items.find(p => p.project._id === project._id);
const projectInCollection = collection.items.find(p => p.projectId === project._id);
if (projectInCollection) {
sendFailure(404, 'Project already in collection');

View file

@ -23,7 +23,7 @@ export default function addProjectToCollection(req, res) {
return null;
}
const project = collection.items.find(p => p.project._id === projectId);
const project = collection.items.find(p => p.projectId === projectId);
if (project != null) {
project.remove();

4
server/jest.setup.js Normal file
View file

@ -0,0 +1,4 @@
import '@babel/polyfill';
import mongoose from 'mongoose';
mongoose.Promise = global.Promise;

View file

@ -15,6 +15,14 @@ collectedProjectSchema.virtual('id').get(function getId() {
return this._id.toHexString();
});
collectedProjectSchema.virtual('projectId').get(function projectId() {
return this.populated('project');
});
collectedProjectSchema.virtual('isDeleted').get(function isDeleted() {
return this.project == null;
});
collectedProjectSchema.set('toJSON', {
virtuals: true
});

View file

@ -79,6 +79,7 @@ app.options('*', corsMiddleware);
app.use(Express.static(path.resolve(__dirname, '../dist/static'), {
maxAge: process.env.STATIC_MAX_AGE || (process.env.NODE_ENV === 'production' ? '1d' : '0')
}));
app.use('/translations', Express.static('translations/locales/'));
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
app.use(bodyParser.json({ limit: '50mb' }));
app.use(cookieParser());

View file

@ -33,6 +33,8 @@ export function renderIndex() {
window.process.env.UI_COLLECTIONS_ENABLED = ${process.env.UI_COLLECTIONS_ENABLED === 'false' ? false : true};
window.process.env.UPLOAD_LIMIT = ${process.env.UPLOAD_LIMIT ? `${process.env.UPLOAD_LIMIT}` : undefined};
window.process.env.MOBILE_ENABLED = ${process.env.MOBILE_ENABLED ? `${process.env.MOBILE_ENABLED}` : undefined};
window.process.env.TRANSLATIONS_ENABLED = ${process.env.TRANSLATIONS_ENABLED === 'true' ? true :false};
</script>
</head>
<body>

View file

@ -0,0 +1,115 @@
{
"Contribute": "Contribute",
"NewP5": "New to p5.js?",
"Report": "Report a bug",
"Learn": "Learn",
"About": "About",
"Resources": "Resources",
"Libraries": "Libraries",
"Forum": "Forum",
"File": "File",
"New": "New",
"Save": "Save",
"Share": "Share",
"Duplicate": "Duplicate",
"Examples": "Examples",
"Edit": "Edit",
"TidyCode": "Tidy Code",
"Find": "Find",
"AddToCollection": "Add to Collection",
"FindNext": "Find Next",
"FindPrevious": "Find Previous",
"Sketch": "Sketch",
"AddFile": "Add File",
"AddFolder": "Add Folder",
"Run": "Run",
"Stop": "Stop",
"Help": "Help",
"KeyboardShortcuts": "Keyboard Shortcuts",
"Reference": "Reference",
"Tidy": "Tidy",
"Lang": "Language",
"FindNextMatch": "Find Next Match",
"FindPrevMatch": "Find Previous Match",
"IndentCodeLeft": "Indent Code Left",
"IndentCodeRight": "Indent Code Right",
"CommentLine": "Comment Line",
"StartSketch": "Start Sketch",
"StopSketch": "StopSketch",
"TurnOnAccessibleOutput": "Turn On Accessible Output",
"TurnOffAccessibleOutput": "Turn Off Accessible Output",
"ToogleSidebar": "Toogle Sidebar",
"ToogleConsole": "Toogle Console",
"Preview": "Preview",
"Auto-refresh": "Auto-refresh",
"Console": "Console",
"Settings": "Settings",
"GeneralSettings": "General settings",
"Theme": "Theme",
"Light": "Light",
"Dark": "Dark",
"HighContrast": "High Contrast",
"TextSize": "Text Size",
"Decrease": "Decrease",
"Increase": "Increase",
"IndentationAmount": "Indentation amount",
"Autosave": "Autosave",
"On": "On",
"Off": "Off",
"SketchSettings": "Sketch Settings",
"SecurityProtocol": "Security Protocol",
"ServeOverHTTPS": "Serve over HTTPS",
"Accessibility": "Accessibility",
"LintWarningSound": "Lint warning sound",
"PreviewSound": "Preview sound",
"AccessibleTextBasedCanvas": "Accessible text-based canvas",
"UsedScreenReader": "Used with screen reader",
"PlainText": "Plain-text",
"TableText": "Table-text",
"Sound": "Sound",
"WordWrap": "Word Wrap",
"LineNumbers": "Line numbers",
"LangChange": "Language changed",
"Welcome": "Welcome",
"Login": "Log in",
"LoginOr": "or",
"SignUp": "Sign up",
"Email": "email",
"Username": "username",
"LoginGithub": "Login with Github",
"LoginGoogle": "Login with Google",
"DontHaveAccount": "Don't have an account?",
"ForgotPassword": "Forgot your password?",
"ResetPassword": "Reset your password",
"BackEditor": "Back to Editor",
"UsernameSplit": "User Name",
"Password": "Password",
"ConfirmPassword": "Confirm Password",
"OpenedNewSketch": "Opened new sketch.",
"Hello": "Hello",
"MyAccount": "My Account",
"My":"My",
"Sketches": "My sketches",
"Collections": "My collections",
"Asset": "Asset",
"MyAssets": "My assets",
"TitleAbout": "p5.js Web Editor | About",
"CodeEditing": "Code Editing",
"Error": "Error",
"In order to save": "In order to save",
"you must be logged in": "you must be logged in",
"Please": "please",
"Find in files": "Find in files",
"Create": "Create",
"enter a name": "enter a name",
"Add": "Add",
"Folder": "Folder",
"FindText": "Find Text",
"FindNextTextMatch": "Find Next Text Match",
"FindPreviousTextMatch": "Find Previous Text Match",
"Code editing keyboard shortcuts follow": "Code editing keyboard shortcuts follow",
"Sublime Text shortcuts": "Sublime Text shortcuts",
"WarningUnsavedChanges": "Are you sure you want to leave this page? You have unsaved changes."
}

View file

@ -0,0 +1,113 @@
{
"Contribute": "Contribuir",
"NewP5": "¿Empezando con p5.js?",
"Report": "Reporta un error",
"Learn": "Aprende",
"About": "Acerca de",
"Resources": "Recursos",
"Libraries": "Bibliotecas",
"Forum": "Foro",
"File": "Archivo",
"New": "Nuevo",
"Save": "Guardar",
"Share": "Compartir",
"Duplicate": "Duplicar",
"Examples": "Ejemplos",
"Edit": "Editar",
"TidyCode": "Ordenar código",
"Find": "Buscar",
"AddToCollection": "Agregar a colección",
"FindNext": "Buscar siguiente",
"FindPrevious": "Buscar anterior",
"Sketch": "Bosquejo",
"AddFile": "Agregar archivo",
"AddFolder": "Agregar directorio",
"Run": "Ejecutar",
"Stop": "Detener",
"Help": "Ayuda",
"KeyboardShortcuts": "Atajos",
"Reference": "Referencia",
"Tidy": "Ordenar",
"Lang": "Lenguaje",
"FindNextMatch": "Encontrar siguiente ocurrencia",
"FindPrevMatch": "Encontrar ocurrencia previa",
"IndentCodeLeft": "Indentar código a la izquierda",
"IndentCodeRight": "Indentar código a la derecha",
"CommentLine": "Comentar línea de código",
"StartSketch": "Iniciar bosquejo",
"StopSketch": "Detener bosquejo",
"TurnOnAccessibleOutput": "Activar salida accesible",
"TurnOffAccessibleOutput": "Desactivar salida accesible",
"ToogleSidebar": "Alternar barra de deslizamiento",
"ToogleConsole": "Alternar consola",
"Preview": "Vista previa",
"Auto-refresh": "Auto-refrescar",
"Console": "Consola",
"Settings": "Configuración",
"GeneralSettings": "Configuración general",
"Theme": "Modo de visualización",
"Light": "Claro",
"Dark": "Oscuro",
"HighContrast": "Alto contraste",
"TextSize": "Tamaño del texto",
"Decrease": "Disminuir",
"Increase": "Aumentar",
"IndentationAmount": "Cantidad de indentación",
"Autosave": "Grabar automáticamente",
"On": "Activar",
"Off": "Desactivar",
"SketchSettings": "Configuración del bosquejo",
"SecurityProtocol": "Protocolo de seguridad",
"ServeOverHTTPS": "Usar HTTPS",
"Accessibility": "Accesibilidad",
"LintWarningSound": "Sonido de alarma Lint",
"PreviewSound": "Probar sonido",
"AccessibleTextBasedCanvas": "Lienzo accesible por texto",
"UsedScreenReader": "Uso con screen reader",
"PlainText": "Texto sin formato",
"TableText": "Tablero de texto",
"Sound": "Sonido",
"WordWrap": "Ajuste automático de línea",
"LineNumbers": "Número de línea",
"LangChange": "Lenguaje cambiado",
"Welcome": "Bienvenida",
"Login": "Ingresa",
"LoginOr": "o",
"SignUp": "registráte",
"email": "correo electrónico",
"username": "nombre de usuario",
"LoginGithub": "Ingresa con Github",
"LoginGoogle": "Ingresa con Google",
"DontHaveAccount": "No tienes cuenta?",
"ForgotPassword": "¿Olvidaste tu contraseña?",
"ResetPassword": "Regenera tu contraseña",
"BackEditor": "Regresa al editor",
"UsernameSplit": "Nombre de usuario",
"Password": "Contraseña",
"ConfirmPassword": "Confirma la contraseña",
"OpenedNewSketch": "Creaste nuevo bosquejo.",
"Hello": "Hola",
"MyAccount": "Mi Cuenta",
"My": "Mi",
"MySketches": "Mis bosquejos",
"MyCollections":"Mis colecciones",
"Asset": "Asset",
"MyAssets": "Mis assets",
"TitleAbout": "Editor Web p5.js | Acerca de",
"CodeEditing": "Editando Código",
"Error": "Error",
"In order to save": "Para guardar",
"you must be logged in": "debes ingresar a tu cuenta",
"Please": "Por favor",
"Find in files": "Encontrar en archivos",
"Create": "Create",
"enter a name": "enter a name",
"Add": "Add",
"Folder": "Directorio",
"FindText": "Encontrar texto",
"FindNextTextMatch": "Encontrar la siguiente ocurrencia de texto",
"FindPreviousTextMatch": "Encontrar la ocurrencia previa de texto",
"Code editing keyboard shortcuts follow": "Los atajos para edición son como",
"Sublime Text shortcuts": "los atajos de Sublime Text ",
"WarningUnsavedChanges": "¿Estás seguro de que quieres salir de la página? Tienes cambios sin guardar."
}