Skip to content

Commit 1fa2082

Browse files
authored
Merge branch 'develop' into fix-reloading
2 parents 2ea1861 + 135f400 commit 1fa2082

28 files changed

Lines changed: 800 additions & 223 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
.env.staging
55
.vscode/
66
node_modules/
7+
minio/
78
npm-debug.log
89
dump.rdb
910
static/dist/

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ RUN npm run build
2525

2626
FROM base AS production
2727
ENV NODE_ENV=production
28-
COPY package.json package-lock.json index.js ./
28+
COPY package.json package-lock.json index.js .eslintrc ./
2929
RUN npm install --production
3030
COPY --from=build $APP_HOME/dist ./dist
3131
CMD ["npm", "run", "start:prod"]

client/i18n.js

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@ import {
1818
uk,
1919
sv,
2020
tr,
21-
enIN,
22-
ne
21+
enIN
2322
} from 'date-fns/locale';
2423

2524
import { getPreferredLanguage } from './utils/language-utils';
@@ -101,8 +100,7 @@ export function languageKeyToDateLocale(lang) {
101100
'zh-CN': zhCN,
102101
'zh-TW': zhTW,
103102
tr,
104-
ur: enIN,
105-
ne
103+
ur: enIN
106104
};
107105
return languageMap[lang];
108106
}

client/modules/IDE/actions/project.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
showErrorModal,
1414
setPreviousPath
1515
} from './ide';
16+
import { clearLocalBackup } from '../utils/localBackup';
1617
import { clearState, saveState } from '../../../persistState';
1718

1819
const ROOT_URL = getConfig('API_URL');
@@ -164,6 +165,8 @@ export function saveProject(
164165
.then((response) => {
165166
dispatch(endSavingProject());
166167
dispatch(setUnsavedChanges(false));
168+
// Clear the localStorage backup after successful server save (#3891)
169+
clearLocalBackup(state.project.id);
167170
const { hasChanges, synchedProject } = getSynchedProject(
168171
getState(),
169172
response.data

client/modules/IDE/actions/uploader.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { apiClient } from '../../../utils/apiClient';
33
import { getConfig } from '../../../utils/getConfig';
44
import { isTestEnvironment } from '../../../utils/checkTestEnv';
55
import { handleCreateFile } from './files';
6+
import { showErrorModal } from './ide';
67

78
const s3BucketUrlBase = getConfig('S3_BUCKET_URL_BASE');
89
const awsRegion = getConfig('AWS_REGION');
@@ -22,7 +23,7 @@ function isS3Upload(file) {
2223
return !TEXT_FILE_REGEX.test(file.name) || file.size >= MAX_LOCAL_FILE_SIZE;
2324
}
2425

25-
export async function dropzoneAcceptCallback(userId, file, done) {
26+
export async function dropzoneAcceptCallback(userId, file, done, dispatch) {
2627
// if a user would want to edit this file as text, local interceptor
2728
if (!isS3Upload(file)) {
2829
try {
@@ -51,6 +52,13 @@ export async function dropzoneAcceptCallback(userId, file, done) {
5152
file.postData = response.data;
5253
done();
5354
} catch (error) {
55+
if (error?.response?.status === 403) {
56+
if (dispatch) {
57+
dispatch(showErrorModal('uploadLimit'));
58+
}
59+
done('Upload limit reached.');
60+
return;
61+
}
5462
done(
5563
error?.response?.data?.responseText?.message ||
5664
error?.message ||

client/modules/IDE/components/Banner.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { CrossIcon } from '../../../common/icons';
77
* Banner displays a dismissible announcement bar with a link and a close icon.
88
* It's typically used to highlight opportunities, but use and design can be flexible.
99
*
10-
* This component is **presentational only** — visibility logic (open/close state) should be
10+
* This component is presentational only — visibility logic (open/close state) should be
1111
* controlled by the parent via the `onClose` handler.
1212
*
1313
* @param {Object} props
@@ -26,6 +26,7 @@ const Banner = ({ onClose }) => {
2626
// URL can be updated depending on the opportunity or announcement.
2727
const bannerURL = 'https://processingfoundation.org/donate';
2828

29+
// currently holds donation copy, will switch back when temp maintenance is done
2930
const bannerCopy = (
3031
<>
3132
<Trans i18nKey="Banner.Copy" components={{ bold: <strong /> }} />

client/modules/IDE/components/Editor/index.jsx

Lines changed: 92 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import UnsavedChangesIndicator from '../UnsavedChangesIndicator';
7272
import { EditorContainer, EditorHolder } from './MobileEditor';
7373
import { FolderIcon } from '../../../../common/icons';
7474
import { IconButton } from '../../../../common/IconButton';
75+
import { saveLocalBackup } from '../../utils/localBackup';
7576

7677
import contextAwareHinter from '../../../../utils/contextAwareHinter';
7778
import showRenameDialog from '../../../../utils/showRenameDialog';
@@ -217,7 +218,14 @@ class Editor extends React.Component {
217218
this.props.setUnsavedChanges(true);
218219
this.props.hideRuntimeErrorWarning();
219220
this.props.updateFileContent(this.props.file.id, this._cm.getValue());
220-
if (this.props.autorefresh && this.props.isPlaying) {
221+
222+
// Save a local backup to localStorage for crash recovery (#3891).
223+
// This ensures work is recoverable even if the tab crashes
224+
// (e.g. from an infinite loop) before the server autosave fires.
225+
const projectId = this.props.project?.id || 'unsaved';
226+
saveLocalBackup(projectId, this.props.files);
227+
228+
if (this.props.autorefresh) {
221229
this.props.clearConsole();
222230
this.props.startSketch();
223231
}
@@ -228,21 +236,73 @@ class Editor extends React.Component {
228236
this._cm.on('keyup', this.handleKeyUp);
229237
}
230238

231-
this._cm.on('keydown', (_cm, e) => {
232-
// Skip hinting if the user is pasting (Ctrl/Cmd+V) or using modifier keys (Ctrl/Alt)
233-
if (
234-
((e.ctrlKey || e.metaKey) && e.key === 'v') ||
235-
e.ctrlKey ||
236-
e.altKey
237-
) {
238-
return;
239+
// Mobile autocomplete support (CM5 IME + contenteditable input)
240+
const triggerHint = (cm) => {
241+
const mode = cm.getOption('mode');
242+
if (mode !== 'css' && mode !== 'javascript') return;
243+
244+
const cursor = cm.getCursor();
245+
const token = cm.getTokenAt(cursor);
246+
247+
// Android keyboards often append a trailing space after each word.
248+
// When that happens, stripping the space so the hinter sees the word.
249+
if (token.string === ' ' && cursor.ch > 0 && cursor.ch === token.end) {
250+
const prevToken = cm.getTokenAt({
251+
line: cursor.line,
252+
ch: cursor.ch - 1
253+
});
254+
if (prevToken.string && /[a-z]/i.test(prevToken.string)) {
255+
cm.replaceRange(
256+
'',
257+
{ line: cursor.line, ch: cursor.ch - 1 },
258+
cursor,
259+
'+trimHint'
260+
);
261+
this.showHint(cm);
262+
return;
263+
}
239264
}
240-
const mode = this._cm.getOption('mode');
241-
if (/^[a-z]$/i.test(e.key) && (mode === 'css' || mode === 'javascript')) {
242-
this.showHint(_cm);
265+
if (token.string && /[a-z]/i.test(token.string)) {
266+
this.showHint(cm);
267+
}
268+
};
269+
270+
// Desktop: fires on each keystroke via CM5's textarea input path.
271+
this._cm.on('change', (_cm, changeObj) => {
272+
if (changeObj.origin !== '+input') return;
273+
if (/[a-z]/i.test(changeObj.text.join(''))) {
274+
triggerHint(_cm);
243275
}
244276
});
245277

278+
// Mobile (word commit): fires when a composed word is accepted.
279+
this._compositionEndHandler = () => {
280+
setTimeout(() => {
281+
if (this._cm) triggerHint(this._cm);
282+
}, 150);
283+
};
284+
this._cm
285+
.getInputField()
286+
.addEventListener('compositionend', this._compositionEndHandler);
287+
288+
// Mobile (per-character): forces CM5 to process composing text
289+
// during typing so autocomplete appears before keyboard dismissal.
290+
this._compositionFlushTimer = null;
291+
this._compositionUpdateHandler = (e) => {
292+
if (!e.data || !/[a-z]/i.test(e.data)) return;
293+
clearTimeout(this._compositionFlushTimer);
294+
this._compositionFlushTimer = setTimeout(() => {
295+
const display = this._cm && this._cm.display;
296+
if (display && display.input && display.input.composing) {
297+
display.input.composing.done = true;
298+
display.input.readFromDOMSoon();
299+
}
300+
}, 200);
301+
};
302+
this._cm
303+
.getInputField()
304+
.addEventListener('compositionupdate', this._compositionUpdateHandler);
305+
246306
this._cm.getWrapperElement().style[
247307
'font-size'
248308
] = `${this.props.fontSize}px`;
@@ -372,6 +432,20 @@ class Editor extends React.Component {
372432
componentWillUnmount() {
373433
if (this._cm) {
374434
this._cm.off('keyup', this.handleKeyUp);
435+
const inputField = this._cm.getInputField();
436+
if (this._compositionEndHandler) {
437+
inputField.removeEventListener(
438+
'compositionend',
439+
this._compositionEndHandler
440+
);
441+
}
442+
if (this._compositionUpdateHandler) {
443+
inputField.removeEventListener(
444+
'compositionupdate',
445+
this._compositionUpdateHandler
446+
);
447+
}
448+
clearTimeout(this._compositionFlushTimer);
375449
}
376450
this.props.provideController(null);
377451
}
@@ -733,7 +807,6 @@ Editor.propTypes = {
733807
setUnsavedChanges: PropTypes.func.isRequired,
734808
startSketch: PropTypes.func.isRequired,
735809
autorefresh: PropTypes.bool.isRequired,
736-
isPlaying: PropTypes.bool.isRequired,
737810
theme: PropTypes.string.isRequired,
738811
unsavedChanges: PropTypes.bool.isRequired,
739812
files: PropTypes.arrayOf(
@@ -756,11 +829,15 @@ Editor.propTypes = {
756829
provideController: PropTypes.func.isRequired,
757830
t: PropTypes.func.isRequired,
758831
setSelectedFile: PropTypes.func.isRequired,
759-
expandConsole: PropTypes.func.isRequired
832+
expandConsole: PropTypes.func.isRequired,
833+
project: PropTypes.shape({
834+
id: PropTypes.string
835+
})
760836
};
761837

762838
Editor.defaultProps = {
763-
htmlFile: null
839+
htmlFile: null,
840+
project: {}
764841
};
765842

766843
function mapStateToProps(state) {

client/modules/IDE/components/ErrorModal.jsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import PropTypes from 'prop-types';
22
import React from 'react';
33
import { Link } from 'react-router-dom';
44
import { useTranslation } from 'react-i18next';
5+
import prettyBytes from 'pretty-bytes';
6+
import { getConfig } from '../../../utils/getConfig';
7+
import { parseNumber } from '../../../utils/parseStringToType';
8+
9+
const uploadLimit = parseNumber(getConfig('UPLOAD_LIMIT')) || 250000000;
10+
const uploadLimitText = prettyBytes(uploadLimit);
511

612
const ErrorModal = ({ type, service, closeModal }) => {
713
const { t } = useTranslation();
@@ -51,6 +57,18 @@ const ErrorModal = ({ type, service, closeModal }) => {
5157
return <p>{t('ErrorModal.SavedDifferentWindow')}</p>;
5258
}
5359

60+
function uploadLimitReached() {
61+
return (
62+
<p>
63+
{t('UploadFileModal.SizeLimitError', { sizeLimit: uploadLimitText })}
64+
<Link to="/assets" onClick={closeModal}>
65+
assets
66+
</Link>
67+
.
68+
</p>
69+
);
70+
}
71+
5472
return (
5573
<div className="error-modal__content">
5674
{(() => { // eslint-disable-line
@@ -60,6 +78,8 @@ const ErrorModal = ({ type, service, closeModal }) => {
6078
return staleSession();
6179
} else if (type === 'staleProject') {
6280
return staleProject();
81+
} else if (type === 'uploadLimit') {
82+
return uploadLimitReached();
6383
} else if (type === 'oauthError') {
6484
return oauthError();
6585
}
@@ -73,7 +93,8 @@ ErrorModal.propTypes = {
7393
'forceAuthentication',
7494
'staleSession',
7595
'staleProject',
76-
'oauthError'
96+
'oauthError',
97+
'uploadLimit'
7798
]).isRequired,
7899
closeModal: PropTypes.func.isRequired,
79100
service: PropTypes.oneOf(['google', 'github'])

client/modules/IDE/components/FileUploader.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ function FileUploader() {
6969
acceptedFiles: fileExtensionsAndMimeTypes,
7070
dictDefaultMessage: t('FileUploader.DictDefaultMessage'),
7171
accept: (file, done) => {
72-
dropzoneAcceptCallback(userId, file, done);
72+
dropzoneAcceptCallback(userId, file, done, dispatch);
7373
},
7474
sending: dropzoneSendingCallback
7575
});

client/modules/IDE/hooks/useHandleMessageEvent.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export default function useHandleMessageEvent() {
5959
if (hasInfiniteLoop) {
6060
dispatch(stopSketch());
6161
dispatch(expandConsole());
62+
dispatch(dispatchConsoleEvent(decodedMessages));
6263
return;
6364
}
6465

0 commit comments

Comments
 (0)