From 1d41af7c81ce72fdff5aff2fdef1d17c17d58a8b Mon Sep 17 00:00:00 2001 From: Herman Wikner Date: Wed, 28 Feb 2024 13:20:34 +0100 Subject: [PATCH] feat(portable-text-editor): implement `isSelectionOverlapping` method (#5870) * feat(portable-text-editor): implement `isSelectionsOverlapping` method * test(portable-text-editor): add `isSelectionsOverlapping` test --- .../src/editor/PortableTextEditor.tsx | 7 + ...hEditableAPISelectionsOverlapping.test.tsx | 162 ++++++++++++++++++ .../editor/plugins/createWithEditableAPI.ts | 13 ++ .../portable-text-editor/src/types/editor.ts | 1 + 4 files changed, 183 insertions(+) create mode 100644 packages/@sanity/portable-text-editor/src/editor/plugins/__tests__/withEditableAPISelectionsOverlapping.test.tsx diff --git a/packages/@sanity/portable-text-editor/src/editor/PortableTextEditor.tsx b/packages/@sanity/portable-text-editor/src/editor/PortableTextEditor.tsx index 0644a85755c..ef83b7ff6ef 100644 --- a/packages/@sanity/portable-text-editor/src/editor/PortableTextEditor.tsx +++ b/packages/@sanity/portable-text-editor/src/editor/PortableTextEditor.tsx @@ -292,4 +292,11 @@ export class PortableTextEditor extends Component { debug('Host redoing') editor.editable?.redo() } + static isSelectionsOverlapping = ( + editor: PortableTextEditor, + selectionA: EditorSelection, + selectionB: EditorSelection, + ) => { + return editor.editable?.isSelectionsOverlapping(selectionA, selectionB) + } } diff --git a/packages/@sanity/portable-text-editor/src/editor/plugins/__tests__/withEditableAPISelectionsOverlapping.test.tsx b/packages/@sanity/portable-text-editor/src/editor/plugins/__tests__/withEditableAPISelectionsOverlapping.test.tsx new file mode 100644 index 00000000000..e7532fdb3f1 --- /dev/null +++ b/packages/@sanity/portable-text-editor/src/editor/plugins/__tests__/withEditableAPISelectionsOverlapping.test.tsx @@ -0,0 +1,162 @@ +import {describe, expect, it, jest} from '@jest/globals' +import {type PortableTextBlock} from '@sanity/types' +import {render, waitFor} from '@testing-library/react' +import {createRef, type RefObject} from 'react' + +import {PortableTextEditorTester, schemaType} from '../../__tests__/PortableTextEditorTester' +import {PortableTextEditor} from '../../PortableTextEditor' + +const INITIAL_VALUE: PortableTextBlock[] = [ + { + _key: 'a', + _type: 'block', + children: [ + { + _key: 'a1', + _type: 'span', + marks: [], + text: 'This is some text in the block', + }, + ], + markDefs: [], + style: 'normal', + }, +] + +describe('plugin:withEditableAPI: .isSelectionsOverlapping', () => { + it('returns true if the selections are partially overlapping', async () => { + const editorRef: RefObject = createRef() + const onChange = jest.fn() + render( + , + ) + const selectionA = { + focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 4}, + anchor: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 8}, + } + + const selectionB = { + focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 2}, + anchor: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 6}, + } + + await waitFor(() => { + if (editorRef.current) { + const isOverlapping = PortableTextEditor.isSelectionsOverlapping( + editorRef.current, + selectionA, + selectionB, + ) + + expect(isOverlapping).toBe(true) + } + }) + }) + + it('returns true if the selections are fully overlapping', async () => { + const editorRef: RefObject = createRef() + const onChange = jest.fn() + render( + , + ) + const selectionA = { + focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 4}, + anchor: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 8}, + } + + const selectionB = { + focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 4}, + anchor: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 8}, + } + + await waitFor(() => { + if (editorRef.current) { + const isOverlapping = PortableTextEditor.isSelectionsOverlapping( + editorRef.current, + selectionA, + selectionB, + ) + + expect(isOverlapping).toBe(true) + } + }) + }) + + it('return true if selection is fully inside another selection', async () => { + const editorRef: RefObject = createRef() + const onChange = jest.fn() + render( + , + ) + const selectionA = { + focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 2}, + anchor: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 10}, + } + + const selectionB = { + focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 4}, + anchor: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 6}, + } + + await waitFor(() => { + if (editorRef.current) { + const isOverlapping = PortableTextEditor.isSelectionsOverlapping( + editorRef.current, + selectionA, + selectionB, + ) + + expect(isOverlapping).toBe(true) + } + }) + }) + + it('returns false if the selections are not overlapping', async () => { + const editorRef: RefObject = createRef() + const onChange = jest.fn() + render( + , + ) + const selectionA = { + focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 4}, + anchor: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 8}, + } + + const selectionB = { + focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 10}, + anchor: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 12}, + } + + await waitFor(() => { + if (editorRef.current) { + const isOverlapping = PortableTextEditor.isSelectionsOverlapping( + editorRef.current, + selectionA, + selectionB, + ) + + expect(isOverlapping).toBe(false) + } + }) + }) +}) diff --git a/packages/@sanity/portable-text-editor/src/editor/plugins/createWithEditableAPI.ts b/packages/@sanity/portable-text-editor/src/editor/plugins/createWithEditableAPI.ts index b89cd5b4cbb..a17b6542f40 100644 --- a/packages/@sanity/portable-text-editor/src/editor/plugins/createWithEditableAPI.ts +++ b/packages/@sanity/portable-text-editor/src/editor/plugins/createWithEditableAPI.ts @@ -515,6 +515,19 @@ export function createWithEditableAPI( getFragment: () => { return fromSlateValue(editor.getFragment(), types.block.name) }, + isSelectionsOverlapping: (selectionA: EditorSelection, selectionB: EditorSelection) => { + // Convert the selections to Slate ranges + const rangeA = toSlateRange(selectionA, editor) + const rangeB = toSlateRange(selectionB, editor) + + // Make sure the ranges are valid + const isValidRanges = Range.isRange(rangeA) && Range.isRange(rangeB) + + // Check if the ranges are overlapping + const isOverlapping = isValidRanges && Range.includes(rangeA, rangeB) + + return isOverlapping + }, }) return editor } diff --git a/packages/@sanity/portable-text-editor/src/types/editor.ts b/packages/@sanity/portable-text-editor/src/types/editor.ts index 927145c3eb8..aa927bf42b1 100644 --- a/packages/@sanity/portable-text-editor/src/types/editor.ts +++ b/packages/@sanity/portable-text-editor/src/types/editor.ts @@ -59,6 +59,7 @@ export interface EditableAPI { isCollapsedSelection: () => boolean isExpandedSelection: () => boolean isMarkActive: (mark: string) => boolean + isSelectionsOverlapping: (selectionA: EditorSelection, selectionB: EditorSelection) => boolean isVoid: (element: PortableTextBlock | PortableTextChild) => boolean marks: () => string[] redo: () => void