diff --git a/cypress/integration/examples/select.ts b/cypress/integration/examples/select.ts new file mode 100644 index 0000000000..648be040d3 --- /dev/null +++ b/cypress/integration/examples/select.ts @@ -0,0 +1,62 @@ +describe('selection', () => { + // Currently, testing color property always yields rgb() value, even when stored + // as hex. + // Hence, we'll overwrite Cypress `should` command, in which we use + // `getComputedStyle` on both the subject element and creates a temp element + // to get the computed color and compares. + // Code by Nicholas Boll at https://github.com/cypress-io/cypress/issues/2186 + // 2021/08/27 + type CssStyleObject = Partial & + Record + const compareColor = (color: string, property: string) => ( + targetElement: NodeListOf + ) => { + const tempElement = document.createElement('div') + tempElement.style.color = color + tempElement.style.display = 'none' // make sure it doesn't actually render + document.body.appendChild(tempElement) // append so that `getComputedStyle` actually works + + const tempColor = getComputedStyle(tempElement).color + // Calling window.getComputedStyle(element) returns `CSSStyleDeclaration` + // object which has numeric index signature with string keys. + // We need to declare a new object which retains the typings of + // CSSStyleDeclaration and yet is relaxed enough to accept an + // arbitrary property name. + + const targetStyle = getComputedStyle(targetElement[0]) as CssStyleObject + const targetColor = targetStyle[property] + + document.body.removeChild(tempElement) // remove it because we're done with it + + expect(tempColor).to.equal(targetColor) + } + Cypress.Commands.overwrite( + 'should', + (originalFn, subject, expectation, ...args) => { + const customMatchers: { [key: string]: any } = { + 'have.backgroundColor': compareColor(args[0], 'backgroundColor'), + 'have.color': compareColor(args[0], 'color'), + } + + // See if the expectation is a string and if it is a member of Jest's expect + if (typeof expectation === 'string' && customMatchers[expectation]) { + return originalFn(subject, customMatchers[expectation]) + } + return originalFn(subject, expectation, ...args) + } + ) + const slateEditor = '[data-slate-node="element"]' + beforeEach(() => cy.visit('examples/richtext')) + it('select the correct block when triple clicking', () => { + // triple clicking the second block (paragraph) shouldn't highlight the + // quote button + for (let i = 0; i < 3; i++) { + cy.get(slateEditor) + .eq(1) + .click() + } + cy.contains('.material-icons', /format_quote/) + .parent() + .should('have.color', '#ccc') + }) +}) diff --git a/packages/slate-react/src/plugin/react-editor.ts b/packages/slate-react/src/plugin/react-editor.ts index fe271c90b6..49635c5936 100644 --- a/packages/slate-react/src/plugin/react-editor.ts +++ b/packages/slate-react/src/plugin/react-editor.ts @@ -568,6 +568,44 @@ export const ReactEditor = { anchorOffset = domRange.anchorOffset focusNode = domRange.focusNode focusOffset = domRange.focusOffset + // When triple clicking a block, Chrome will return a selection object whose + // focus node is the next element sibling and focusOffset is 0. + // This will highlight the corresponding toolbar button for the sibling + // block even though users just want to target the previous block. + // (2021/08/24) + // Within the context of Slate and Chrome, if anchor and focus nodes don't have + // the same nodeValue and focusOffset is 0, then it's definitely a triple click + // behaviour. + if ( + IS_CHROME && + anchorNode?.nodeValue !== focusNode?.nodeValue && + domRange.focusOffset === 0 + ) { + // If an anchorNode is an element node when triple clicked, then the focusNode + // should also be the same as anchorNode when triple clicked. + if (anchorNode!.nodeType === 1) { + focusNode = anchorNode + } else { + // Otherwise, anchorNode is a text node and we need to + // - climb up the DOM tree to get the farthest element node that receives + // triple click. It should have atribute 'data-slate-node' = "element" + // - get the last child of that element node + // - climb down the DOM tree to get the text node of the last child + // - this is also the end of the selection aka the focusNode + const anchorElement = anchorNode!.parentNode as HTMLElement + const tripleClickedBlock = anchorElement.closest( + '[data-slate-node="element"]' + ) + const focusElement = tripleClickedBlock!.lastElementChild + // Get the element node that holds the focus text node + const innermostFocusElement = focusElement!.querySelector( + '[data-slate-string]' + ) + const lastTextNode = innermostFocusElement!.childNodes[0] + focusNode = lastTextNode + } + } + // COMPAT: There's a bug in chrome that always returns `true` for // `isCollapsed` for a Selection that comes from a ShadowRoot. // (2020/08/08)