From 160112a372fcc21843cfdbeaf35f1e5a5fa35f97 Mon Sep 17 00:00:00 2001 From: wesleybl Date: Thu, 20 Aug 2026 17:09:13 -0300 Subject: [PATCH] Fixes the cursor position when clicking on a Slate block in edit In edit, clicking on a Slate block would cause the click position to be lost, with the cursor jumping to the beginning of the text. Now, we save the position and set it on the Slate block after it is selected. --- packages/volto-slate/news/8402.bugfix | 1 + .../volto-slate/src/editor/SlateEditor.jsx | 32 ++++++- .../tests/core/blocks/blocks-slate-cursor.js | 94 +++++++++++++++++++ packages/volto/news/8402.bugfix | 1 + 4 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 packages/volto-slate/news/8402.bugfix create mode 100644 packages/volto/cypress/tests/core/blocks/blocks-slate-cursor.js create mode 100644 packages/volto/news/8402.bugfix diff --git a/packages/volto-slate/news/8402.bugfix b/packages/volto-slate/news/8402.bugfix new file mode 100644 index 00000000000..533356d9db0 --- /dev/null +++ b/packages/volto-slate/news/8402.bugfix @@ -0,0 +1 @@ +Fixed cursor position lost when clicking on a Slate block in edit, causing the cursor to jump to the start of the text. @wesleybl diff --git a/packages/volto-slate/src/editor/SlateEditor.jsx b/packages/volto-slate/src/editor/SlateEditor.jsx index 5bd6f001dac..43f214bc7bd 100644 --- a/packages/volto-slate/src/editor/SlateEditor.jsx +++ b/packages/volto-slate/src/editor/SlateEditor.jsx @@ -54,6 +54,7 @@ class SlateEditor extends Component { this.createEditor = this.createEditor.bind(this); this.multiDecorator = this.multiDecorator.bind(this); this.handleChange = this.handleChange.bind(this); + this.handlePointerDown = this.handlePointerDown.bind(this); this.getSavedSelection = this.getSavedSelection.bind(this); this.setSavedSelection = this.setSavedSelection.bind(this); this.scheduleFocus = this.scheduleFocus.bind(this); @@ -73,6 +74,31 @@ class SlateEditor extends Component { this.editor = null; this.selectionTimeout = null; + this.pendingPointerSelection = null; + } + + handlePointerDown(event) { + if (this.props.selected) return; + + const nativeEvent = event.nativeEvent || event; + const domPoint = document.caretPositionFromPoint?.( + nativeEvent.clientX, + nativeEvent.clientY, + ); + if (!domPoint) return; + + try { + const point = ReactEditor.toSlatePoint( + this.state.editor, + [domPoint.offsetNode, domPoint.offset], + { exactMatch: false, suppressThrow: true }, + ); + if (point) { + this.pendingPointerSelection = { anchor: point, focus: point }; + } + } catch { + this.pendingPointerSelection = null; + } } getSavedSelection() { @@ -185,7 +211,10 @@ class SlateEditor extends Component { if (!prevProps.selected && this.props.selected) { // if the SlateEditor becomes selected from unselected - if (window.getSelection().type === 'None') { + if (this.pendingPointerSelection) { + Transforms.select(this.state.editor, this.pendingPointerSelection); + this.pendingPointerSelection = null; + } else if (window.getSelection().type === 'None') { // TODO: why is this condition checked? Transforms.select( this.state.editor, @@ -326,6 +355,7 @@ class SlateEditor extends Component { return null; }} onClick={this.props.onClick} + onPointerDown={this.handlePointerDown} onSelect={(e) => { if (!selected && this.props.onFocus) { // we can't overwrite the onFocus of Editable, as the onFocus diff --git a/packages/volto/cypress/tests/core/blocks/blocks-slate-cursor.js b/packages/volto/cypress/tests/core/blocks/blocks-slate-cursor.js new file mode 100644 index 00000000000..aa37579238f --- /dev/null +++ b/packages/volto/cypress/tests/core/blocks/blocks-slate-cursor.js @@ -0,0 +1,94 @@ +describe('Slate cursor position Tests', () => { + const EDITABLE = '.content-area .slate-editor [contenteditable=true]'; + const BLOCK = '.block.slate'; + const TEXT = 'The quick brown fox jumps over the lazy dog'; + + beforeEach(() => { + cy.intercept('GET', `/**/*?expand*`).as('content'); + cy.intercept('GET', '/**/Document').as('schema'); + // given a logged in editor and a page in edit mode + cy.autologin(); + cy.createContent({ + contentType: 'Document', + contentId: 'my-page', + contentTitle: 'My Page', + bodyModifier: (body) => ({ + ...body, + blocks: { + 'block-1': { + '@type': 'slate', + value: [{ type: 'p', children: [{ text: 'ab' }] }], + }, + 'block-2': { + '@type': 'slate', + value: [{ type: 'p', children: [{ text: 'abc' }] }], + }, + 'block-3': { + '@type': 'slate', + value: [{ type: 'p', children: [{ text: 'ab' }] }], + }, + 'block-4': { + '@type': 'slate', + value: [{ type: 'p', children: [{ text: TEXT }] }], + }, + }, + blocks_layout: { + items: ['block-1', 'block-2', 'block-3', 'block-4'], + }, + }), + }); + cy.visit('/'); + cy.wait('@content'); + + cy.navigate('/my-page/edit'); + cy.wait('@schema'); + }); + + const getEndOfTextCoordinates = (editable) => { + const walker = document.createTreeWalker(editable, NodeFilter.SHOW_TEXT); + let textNode; + while (walker.nextNode()) { + if (walker.currentNode.textContent === TEXT) { + textNode = walker.currentNode; + break; + } + } + expect( + Boolean(textNode), + 'text node of the last block to be found', + ).to.equal(true); + const range = document.createRange(); + range.setStart(textNode, textNode.textContent.length - 1); + range.setEnd(textNode, textNode.textContent.length); + const rect = range.getClientRects()[0]; + const editableRect = editable.getBoundingClientRect(); + return { + x: rect.right - editableRect.left - 1, + y: rect.top - editableRect.top + rect.height / 2, + }; + }; + + it('keeps the cursor at the click position when clicking at the end of a slate block', () => { + // when I click at the very end of the text + cy.get(EDITABLE) + .last() + .scrollIntoView({ block: 'center' }) + .then(($editable) => { + const { x, y } = getEndOfTextCoordinates($editable[0]); + cy.wrap($editable).click(x, y); + }); + + // then the block should be selected and the cursor should stay at the end + cy.get(BLOCK).last().should('have.class', 'selected'); + + cy.window().then((win) => { + const range = win.getSelection().getRangeAt(0); + const anchor = range.startContainer; + const offset = + anchor.nodeType === Node.TEXT_NODE ? range.startOffset : -1; + expect(offset, `cursor offset ${offset} to stay at the end`).to.equal( + TEXT.length, + ); + }); + }); +}); diff --git a/packages/volto/news/8402.bugfix b/packages/volto/news/8402.bugfix new file mode 100644 index 00000000000..533356d9db0 --- /dev/null +++ b/packages/volto/news/8402.bugfix @@ -0,0 +1 @@ +Fixed cursor position lost when clicking on a Slate block in edit, causing the cursor to jump to the start of the text. @wesleybl