diff --git a/.changeset/hanging-delete-top-level.md b/.changeset/hanging-delete-top-level.md new file mode 100644 index 0000000000..57d6f96113 --- /dev/null +++ b/.changeset/hanging-delete-top-level.md @@ -0,0 +1,6 @@ +--- +'slate': patch +'slate-react': patch +--- + +Limit whole-block deletion of a hanging selection, and native triple-click selections, to top-level blocks. Nested blocks such as table cells and list items keep the previous behavior, so cutting or dragging across cells in editors without `editor.isIsolated` no longer removes cells. After undoing a whole-block deletion, the default `scrollSelectionIntoView` scrolls to the restored text instead of the start of the following block. diff --git a/packages/slate-react/src/components/editable.tsx b/packages/slate-react/src/components/editable.tsx index 77deb0a9a7..edf458d3df 100644 --- a/packages/slate-react/src/components/editable.tsx +++ b/packages/slate-react/src/components/editable.tsx @@ -1390,15 +1390,7 @@ export const Editable = forwardRef( return } - if ( - event.detail === TRIPLE_CLICK && - path.length >= 1 && - Editor.above(editor, { - at: Editor.start(editor, path), - match: n => - Node.isElement(n) && !!editor.isIsolated?.(n), - }) - ) { + if (event.detail === TRIPLE_CLICK && path.length >= 1) { let blockPath = path if ( !( @@ -1414,9 +1406,17 @@ export const Editable = forwardRef( blockPath = block?.[1] ?? path.slice(0, 1) } - const range = Editor.range(editor, blockPath) - Transforms.select(editor, range) - return + const isIsolated = Editor.above(editor, { + at: Editor.start(editor, blockPath), + match: n => + Node.isElement(n) && !!editor.isIsolated?.(n), + }) + + if (blockPath.length > 1 || isIsolated) { + const range = Editor.range(editor, blockPath) + Transforms.select(editor, range) + return + } } if (readOnly) { @@ -2324,8 +2324,16 @@ export const defaultScrollSelectionIntoView = ( domRange: DOMRange ) => { // Scroll to the focus point of the selection, in case the selection is expanded - const isBackward = !!editor.selection && Range.isBackward(editor.selection) - const domFocusPoint = domRange.cloneRange() + const { selection } = editor + const isBackward = !!selection && Range.isBackward(selection) + const unhung = + selection && !isBackward && Range.isExpanded(selection) + ? Editor.unhangRange(editor, selection) + : selection + const domFocusPoint = + unhung && selection && !Range.equals(unhung, selection) + ? ReactEditor.toDOMRange(editor, unhung) + : domRange.cloneRange() domFocusPoint.collapse(isBackward) if (domFocusPoint.getBoundingClientRect) { diff --git a/packages/slate-react/src/utils/move-range.ts b/packages/slate-react/src/utils/move-range.ts index 19814c1722..abaa923a1d 100644 --- a/packages/slate-react/src/utils/move-range.ts +++ b/packages/slate-react/src/utils/move-range.ts @@ -6,7 +6,10 @@ const blockPathAt = (editor: Editor, point: Point): Path | undefined => match: n => Node.isElement(n) && Editor.isBlock(editor, n), })?.[1] -const getWholeBlockPaths = (editor: Editor, range: Range): Path[] | null => { +export const getWholeTopLevelBlockPaths = ( + editor: Editor, + range: Range +): Path[] | null => { const [start, end] = Range.edges(range) const startBlock = blockPathAt(editor, start) const endBlock = blockPathAt(editor, end) @@ -14,40 +17,24 @@ const getWholeBlockPaths = (editor: Editor, range: Range): Path[] | null => { if ( !startBlock || !endBlock || - !Path.isSibling(startBlock, endBlock) || - !Path.isBefore(startBlock, endBlock) || - !!Editor.above(editor, { - at: start, - match: n => Node.isElement(n) && !!editor.isIsolated?.(n), - mode: 'highest', - }) || + startBlock.length !== 1 || + endBlock.length !== 1 || + startBlock[0] >= endBlock[0] || !Editor.isStart(editor, start, startBlock) || !Editor.isStart(editor, end, endBlock) ) { return null } - return Array.from( - { - length: endBlock[endBlock.length - 1] - startBlock[startBlock.length - 1], - }, - (_, offset) => [ - ...Path.parent(startBlock), - startBlock[startBlock.length - 1] + offset, - ] - ) -} - -export const getWholeTopLevelBlockPaths = ( - editor: Editor, - range: Range -): Path[] | null => { - const paths = getWholeBlockPaths(editor, range) - return paths?.[0].length === 1 ? paths : null + return Array.from({ length: endBlock[0] - startBlock[0] }, (_, offset) => [ + startBlock[0] + offset, + ]) } export const getMovableRange = (editor: Editor, range: Range): Range => - getWholeBlockPaths(editor, range) ? range : Editor.unhangRange(editor, range) + getWholeTopLevelBlockPaths(editor, range) + ? range + : Editor.unhangRange(editor, range) export const deleteMovedRange = (editor: Editor, range: Range) => { if (Range.isCollapsed(range)) { diff --git a/packages/slate-react/test/move-range.spec.ts b/packages/slate-react/test/move-range.spec.ts index 2a834af051..804330d4ee 100644 --- a/packages/slate-react/test/move-range.spec.ts +++ b/packages/slate-react/test/move-range.spec.ts @@ -15,8 +15,6 @@ const cell = (text: string) => block('table-cell', text) const editorWith = (children: object[]) => { const editor = createEditor() editor.children = children as Descendant[] - editor.isIsolated = element => - 'type' in element && element.type === 'table-cell' return editor } @@ -188,7 +186,7 @@ describe('moving a dragged or cut range', () => { ).toBeNull() }) - it('removes whole selected list items without merging their neighbors', () => { + it('unhangs instead of merging list items', () => { const list = { type: 'bulleted-list', children: [ @@ -202,10 +200,7 @@ describe('moving a dragged or cut range', () => { expect(result.fragment).toEqual([ { type: 'bulleted-list', - children: [ - { type: 'list-item', children: ['two'] }, - { type: 'list-item', children: [''] }, - ], + children: [{ type: 'list-item', children: ['two'] }], }, ]) expect(result.children).toEqual([ @@ -213,6 +208,7 @@ describe('moving a dragged or cut range', () => { type: 'bulleted-list', children: [ { type: 'list-item', children: ['one'] }, + { type: 'list-item', children: [''] }, { type: 'list-item', children: ['three'] }, ], }, @@ -298,7 +294,7 @@ describe('moving a dragged or cut range', () => { const deleteFragment = jest.fn() editor.deleteFragment = deleteFragment - deleteMovedRange(editor, range([0, 0, 0], 0, [0, 1, 0], 0)) + deleteMovedRange(editor, range([0, 0, 0], 1, [0, 1, 0], 1)) expect(deleteFragment).toHaveBeenCalledTimes(1) expect(Editor.string(editor, [])).toBe('onetwo') diff --git a/packages/slate/src/editor/delete-fragment.ts b/packages/slate/src/editor/delete-fragment.ts index 535cd7ba30..3ee87a7036 100644 --- a/packages/slate/src/editor/delete-fragment.ts +++ b/packages/slate/src/editor/delete-fragment.ts @@ -29,6 +29,7 @@ export const deleteFragment: EditorInterface['deleteFragment'] = ( !isolated && startBlock && endBlock && + startBlock.length === 1 && Path.isSibling(startBlock, endBlock) && Path.isBefore(startBlock, endBlock) && !Editor.isVoid(editor, Node.get(editor, endBlock) as Element) && diff --git a/packages/slate/test/interfaces/Editor/deleteFragment/hanging.spec.ts b/packages/slate/test/interfaces/Editor/deleteFragment/hanging.spec.ts index 620362e9d3..09331438b6 100644 --- a/packages/slate/test/interfaces/Editor/deleteFragment/hanging.spec.ts +++ b/packages/slate/test/interfaces/Editor/deleteFragment/hanging.spec.ts @@ -3,7 +3,7 @@ import { createEditor, Editor, Transforms } from 'slate' for (const nested of [false, true]) { for (const isolated of [false, true]) { - it(`deletes a hanging whole block with nested=${nested}, isolated=${isolated}`, () => { + it(`deletes a hanging whole top-level block with nested=${nested}, isolated=${isolated}`, () => { const editor = createEditor() const blocks = ['one', 'two', 'three'].map(text => ({ children: [{ text }], @@ -16,7 +16,8 @@ for (const nested of [false, true]) { focus: { path: [...prefix, 2, 0], offset: 0 }, }) Editor.deleteFragment(editor, { direction: 'backward' }) - const expected = isolated ? ['one', '', 'three'] : ['one', 'three'] + const expected = + isolated || nested ? ['one', '', 'three'] : ['one', 'three'] assert.deepStrictEqual( expected.map((_, index) => Editor.string(editor, [...prefix, index])), expected