From a9c8c1d4d104f0e7988884f850da643d4bce137f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nat=C3=A1lia=20Cond=C3=AA?= Date: Thu, 8 Oct 2026 09:46:31 -0300 Subject: [PATCH] Cloud Code editor: reliable select all, copy, paste and undo --- .../B4ACloudCodeView.react.js | 14 ++ .../B4ACodeTree/B4ACodeTree.react.js | 97 ++++++++++-- src/components/B4ACodeTree/B4ATreeActions.js | 2 +- .../CodeEditor/B4aCodeEditorImpl.react.js | 144 +++++++++++++++--- 4 files changed, 225 insertions(+), 32 deletions(-) diff --git a/src/components/B4ACloudCodeView/B4ACloudCodeView.react.js b/src/components/B4ACloudCodeView/B4ACloudCodeView.react.js index fbf01a97d4..3abde93b12 100644 --- a/src/components/B4ACloudCodeView/B4ACloudCodeView.react.js +++ b/src/components/B4ACloudCodeView/B4ACloudCodeView.react.js @@ -3,6 +3,17 @@ import B4aCodeEditor from '../CodeEditor/B4aCodeEditor.react'; import { getExtension } from '../B4ACodeTree/B4ATreeActions'; export default class B4ACloudCodeView extends React.Component { + constructor(props) { + super(props); + this.editorRef = React.createRef(); + } + + // Selects the whole file in the editor. Returns false while the editor is + // still loading. + selectAll() { + return this.editorRef.current ? this.editorRef.current.selectAll() : false; + } + extensionDecoder() { if (this.props.fileName && typeof this.props.fileName === 'string') { return getExtension(this.props.fileName); @@ -14,8 +25,11 @@ export default class B4ACloudCodeView extends React.Component { return (
this.props.onCodeChange(value)} mode={this.extensionDecoder()} diff --git a/src/components/B4ACodeTree/B4ACodeTree.react.js b/src/components/B4ACodeTree/B4ACodeTree.react.js index f51e233fca..8bd0469741 100644 --- a/src/components/B4ACodeTree/B4ACodeTree.react.js +++ b/src/components/B4ACodeTree/B4ACodeTree.react.js @@ -29,6 +29,10 @@ const getCloudFolderPlaceholder = () => const publicFolderPlaceholder = 'Public folder can be used to deploy public static content as html, images, css, etc.\n' +const modifiedFileIcon = require('./icons/file.png'); + +const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || ''); + let cloudFolderPlaceholder; const swalWithBootstrapButtons = Swal.mixin({ @@ -120,6 +124,9 @@ export default class B4ACodeTree extends React.Component { // jstree's `changed.jstree` event in selectNode(). selectedTreePath: '', showUploadMenu: false, + // Bumped every time the user opens a file, so the editor takes focus and + // typing/pasting right after picking a file lands in it. + editorFocusToken: 0, } // Used to track the latest file load request @@ -128,6 +135,8 @@ export default class B4ACodeTree extends React.Component { this.fileInputRef = React.createRef(); this.folderInputRef = React.createRef(); this.uploadMenuRef = React.createRef(); + this.containerRef = React.createRef(); + this.codeViewRef = React.createRef(); } // Read the current tree state from jstree and mirror it into React state so @@ -194,9 +203,53 @@ export default class B4ACodeTree extends React.Component { if (!nodeId) { return; } + if (node.type === 'folder' || node.type === 'new-folder') { + B4ATreeActions.selectFileOnTree(nodeId); + } else { + this.openFileInEditor(nodeId); + } + } + + openFileInEditor(nodeId) { B4ATreeActions.selectFileOnTree(nodeId); + this.focusEditor(); } + focusEditor() { + this.setState(state => ({ editorFocusToken: state.editorFocusToken + 1 })); + } + + // SweetAlert hands focus back to whatever had it before the dialog, and it + // does so after its promise has resolved. The editor can only take focus + // once that is done. + focusEditorAfterDialog = () => { + if (this.focusEditorOnDialogClose) { + this.focusEditorOnDialogClose = false; + this.focusEditor(); + } + }; + + // Outside a text field, Cmd/Ctrl+A selects the whole page. The editor only + // renders the lines on screen, so copying that selection grabs a fragment + // of the file. Send the shortcut to the editor instead. + handleSelectAllShortcut = e => { + const modifier = isMac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey; + if (!modifier || e.altKey || e.shiftKey || (e.key || '').toLowerCase() !== 'a') { + return; + } + const target = e.target; + const container = this.containerRef.current; + if (!container || !target || (target !== document.body && !container.contains(target))) { + return; + } + if (target.closest && target.closest('input, textarea, select, [contenteditable], .monaco-editor')) { + return; + } + if (this.codeViewRef.current && this.codeViewRef.current.selectAll()) { + e.preventDefault(); + } + }; + handleContextAction(action, node, path, newName) { if (this.props.hideControls) { return; @@ -256,13 +309,15 @@ export default class B4ACodeTree extends React.Component { confirmButtonText: 'Create file', buttonsStyling: false, showCloseButton: true, - allowOutsideClick: () => !Swal.isLoading() + allowOutsideClick: () => !Swal.isLoading(), + didClose: this.focusEditorAfterDialog }).then(({ value }) => { if (value) { value = B4ATreeActions.sanitizeHTML(value); const parent = parentNodeId ? [parentNodeId] : B4ATreeActions.getSelectedParent(); const newNodeId = B4ATreeActions.addFileOnSelectedNode(value, parent[0]); B4ATreeActions.selectFileOnTree(newNodeId); + this.focusEditorOnDialogClose = true; this.setState({ files: $('#tree').jstree(true).get_json() }); } }); @@ -594,20 +649,31 @@ export default class B4ACodeTree extends React.Component { return this.props.parentState({ unsavedChanges: true }) } - async updateSelectedFileContent(value) { + // Runs on every keystroke and paste, so it stays synchronous (the node and + // `source` are never behind the editor) and only touches the tree the first + // time a file is modified. + updateSelectedFileContent(value) { if (this.props.hideControls) { return; } - const ecodedValue = await B4ATreeActions.encodeFile(value, 'data:plain/text;base64'); - this.setState({ source: value }); + const tree = $('#tree').jstree(true); + const selectedNode = tree && tree.get_selected(true).pop(); + if (!selectedNode) { return; } - this.state.selectedNodeData?.instance.set_icon(this.state.selectedNodeData.node, require('./icons/file.png')); + selectedNode.data.code = B4ATreeActions.encodeFile(value, 'data:plain/text;base64'); + this.setState({ source: value }); - $('#tree').jstree('get_selected', true).pop().data.code = ecodedValue; - $('#tree').jstree().redraw(true); + if (selectedNode.icon !== modifiedFileIcon) { + tree.set_icon(selectedNode, modifiedFileIcon); + tree.redraw(true); + } // set updated files. - let cloneUpdatedFiles = [...this.props.updatedFiles]; - if(!cloneUpdatedFiles.includes('j1_mainJS') && !cloneUpdatedFiles.includes('j1_indexHTML')){ - this.props.cloudCodeChanges.addFile($('#tree').jstree('get_selected', true).pop().id); + const updatedFiles = this.props.updatedFiles; + if ( + !updatedFiles.includes('j1_mainJS') && + !updatedFiles.includes('j1_indexHTML') && + !this.props.cloudCodeChanges.getFiles().includes(selectedNode.id) + ) { + this.props.cloudCodeChanges.addFile(selectedNode.id); this.props.setUpdatedFile(this.props.cloudCodeChanges.getFiles()); } } @@ -705,6 +771,7 @@ export default class B4ACodeTree extends React.Component { } $('#tree').jstree(config); this.watchSelectedNode(); + document.addEventListener('keydown', this.handleSelectAllShortcut); // Mirror jstree's data into React state on every mutation so // stays in sync with the source of truth. @@ -750,6 +817,7 @@ export default class B4ACodeTree extends React.Component { } componentWillUnmount() { + document.removeEventListener('keydown', this.handleSelectAllShortcut); $('#tree').off( 'changed.jstree create_node.jstree delete_node.jstree rename_node.jstree move_node.jstree set_text.jstree refresh.jstree ready.jstree' ); @@ -806,11 +874,14 @@ export default class B4ACodeTree extends React.Component { )}
this.updateSelectedFileContent(value)} source={this.state.source} extension={this.state.extension} fileName={this.state.selectedFile} + fileId={this.state.nodeId} + focusToken={this.state.editorFocusToken} readOnly={!!this.props.hideControls} /> ; @@ -838,20 +909,22 @@ export default class B4ACodeTree extends React.Component { confirmButtonText: 'Create file', buttonsStyling: false, showCloseButton: true, - allowOutsideClick: () => !Swal.isLoading() + allowOutsideClick: () => !Swal.isLoading(), + didClose: this.focusEditorAfterDialog }).then(({ value }) => { if (value) { value = B4ATreeActions.sanitizeHTML(value); const parent = B4ATreeActions.getSelectedParent(); const newNodeId = B4ATreeActions.addFileOnSelectedNode(value, parent[0]); B4ATreeActions.selectFileOnTree(newNodeId); + this.focusEditorOnDialogClose = true; this.setState({ files: $('#tree').jstree(true).get_json() }); } }); }; return ( -
+
diff --git a/src/components/B4ACodeTree/B4ATreeActions.js b/src/components/B4ACodeTree/B4ATreeActions.js index 8eb8e3e4ab..c4d9320825 100644 --- a/src/components/B4ACodeTree/B4ATreeActions.js +++ b/src/components/B4ACodeTree/B4ATreeActions.js @@ -135,7 +135,7 @@ const decodeFile = (code) => { return Base64.decode(code.split(',')[1]); } -const encodeFile = async (code, extension) => { +const encodeFile = (code, extension) => { return extension + ',' + Base64.encode(code); } diff --git a/src/components/CodeEditor/B4aCodeEditorImpl.react.js b/src/components/CodeEditor/B4aCodeEditorImpl.react.js index 15a71d1a6c..19ca2e58da 100644 --- a/src/components/CodeEditor/B4aCodeEditorImpl.react.js +++ b/src/components/CodeEditor/B4aCodeEditorImpl.react.js @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState, forwardRef, useImperativeHandle } from 'react'; +import React, { useCallback, useLayoutEffect, useMemo, useRef, forwardRef, useImperativeHandle } from 'react'; import Editor from '@monaco-editor/react'; const MONACO_THEME = 'vs-dark'; @@ -22,6 +22,11 @@ const languageMap = { xml: 'xml', }; +// How many emitted values we remember while waiting for the parent to hand +// them back through the `code` prop. A parent that echoes synchronously never +// holds more than one. +const MAX_PENDING_ECHOES = 20; + const loadingFallbackStyle = { color: '#d4d4d4', background: '#1e1e1e', @@ -35,23 +40,86 @@ const loadingFallbackStyle = { }; const B4aCodeEditorImpl = forwardRef( - ({ code: initialCode, onCodeChange, mode, readOnly = false, fontSize = 12 }, ref) => { + ( + { + code: initialCode, + onCodeChange, + mode, + readOnly = false, + fontSize = 12, + documentKey, + focusToken, + }, + ref + ) => { const editorRef = useRef(null); const monacoRef = useRef(null); - const [code, setCode] = useState(initialCode ?? ''); + // Monaco's model is the source of truth while a document is open. The + // `code` prop is only pushed into the editor when it is a genuinely + // external change (another file opened, content loaded), never when it is + // the parent handing back what the user just typed or pasted: re-applying + // an echo replaces the whole document, which moves the cursor to the end + // of the file and can drop edits made in the meantime. + const lastValueRef = useRef(initialCode ?? ''); + const pendingEchoesRef = useRef([]); + const applyingExternalRef = useRef(false); + const documentKeyRef = useRef(documentKey); + const latestRef = useRef(null); + latestRef.current = { code: initialCode ?? '', documentKey, onCodeChange, readOnly, focusToken }; - useEffect(() => { - const next = initialCode ?? ''; - // Avoid re-applying the value when the incoming prop is just an echo of - // what the user typed (parent re-renders with the same source). This - // prevents Monaco from doing a full setValue on every keystroke, which - // is heavy (revalidation, undo reset) and can cause cursor jumps. - const current = editorRef.current ? editorRef.current.getValue() : code; - if (current === next) { + const applyExternalValue = (editor, next, isNewDocument) => { + applyingExternalRef.current = true; + try { + if (isNewDocument || latestRef.current.readOnly) { + // setValue() also clears the undo stack, so undo can never bring the + // previous file's content into the one that was just opened. + editor.setValue(next); + if (isNewDocument) { + editor.setScrollPosition({ scrollTop: 0, scrollLeft: 0 }); + } + } else { + editor.executeEdits('', [ + { range: editor.getModel().getFullModelRange(), text: next, forceMoveMarkers: true }, + ]); + editor.pushUndoStop(); + } + } finally { + applyingExternalRef.current = false; + } + lastValueRef.current = next; + pendingEchoesRef.current = []; + }; + + useLayoutEffect(() => { + const editor = editorRef.current; + if (!editor) { + // Not mounted yet: handleMount picks up the latest code. return; } - setCode(next); - }, [initialCode]); + const next = initialCode ?? ''; + const isNewDocument = documentKeyRef.current !== documentKey; + documentKeyRef.current = documentKey; + if (!isNewDocument) { + if (next === lastValueRef.current) { + pendingEchoesRef.current = []; + return; + } + // A parent that updates asynchronously can hand back a value the user + // has already typed past. It is still an echo, not an external change. + const echoIndex = pendingEchoesRef.current.lastIndexOf(next); + if (echoIndex !== -1) { + pendingEchoesRef.current.splice(0, echoIndex + 1); + return; + } + } + applyExternalValue(editor, next, isNewDocument); + }, [initialCode, documentKey]); + + useLayoutEffect(() => { + if (focusToken && editorRef.current) { + editorRef.current.focus(); + } + }, [focusToken]); useImperativeHandle(ref, () => ({ get editor() { @@ -71,12 +139,34 @@ const B4aCodeEditorImpl = forwardRef( focus() { editorRef.current && editorRef.current.focus(); }, + selectAll() { + const editor = editorRef.current; + const model = editor && editor.getModel(); + if (!model) { + return false; + } + editor.focus(); + editor.setSelection(model.getFullModelRange()); + return true; + }, })); const handleMount = (editor, monaco) => { editorRef.current = editor; monacoRef.current = monaco; + // The `code` prop may have changed between the model being created and + // this callback. + const latest = latestRef.current; + documentKeyRef.current = latest.documentKey; + if (editor.getValue() !== latest.code) { + applyExternalValue(editor, latest.code, true); + } + lastValueRef.current = latest.code; + if (latest.focusToken) { + editor.focus(); + } + const remeasureAndLayout = () => { if (editorRef.current !== editor || monacoRef.current !== monaco) { return; @@ -126,13 +216,23 @@ const B4aCodeEditorImpl = forwardRef( } }; - const handleChange = value => { + const handleChange = useCallback(value => { const next = value ?? ''; - setCode(next); - if (typeof onCodeChange === 'function') { - onCodeChange(next); + lastValueRef.current = next; + if (applyingExternalRef.current) { + return; } - }; + const notifyParent = latestRef.current.onCodeChange; + if (typeof notifyParent !== 'function') { + return; + } + const pendingEchoes = pendingEchoesRef.current; + pendingEchoes.push(next); + if (pendingEchoes.length > MAX_PENDING_ECHOES) { + pendingEchoes.shift(); + } + notifyParent(next); + }, []); const language = languageMap[mode] || 'plaintext'; @@ -156,6 +256,12 @@ const B4aCodeEditorImpl = forwardRef( padding: { top: 8, bottom: 8 }, wordWrap: 'off', fixedOverflowWidgets: true, + // Clipboard: insert exactly what was copied, synchronously. The + // "paste as" pipeline handles the paste asynchronously and gives up if + // the document changes while it is still working. + pasteAs: { enabled: false }, + formatOnPaste: false, + autoIndentOnPaste: false, }), [readOnly, fontSize] ); @@ -170,7 +276,7 @@ const B4aCodeEditorImpl = forwardRef( height="100%" width="100%" language={language} - value={code} + defaultValue={initialCode ?? ''} theme={MONACO_THEME} onChange={handleChange} onMount={handleMount}