diff --git a/.changeset/bumpy-spoons-check.md b/.changeset/bumpy-spoons-check.md new file mode 100644 index 000000000..0712b745b --- /dev/null +++ b/.changeset/bumpy-spoons-check.md @@ -0,0 +1,5 @@ +--- +'@portabletext/editor': patch +--- + +fix: issues with expanding selection down using Shift+ArrowDown diff --git a/.changeset/short-turtles-fetch.md b/.changeset/short-turtles-fetch.md new file mode 100644 index 000000000..87062e85d --- /dev/null +++ b/.changeset/short-turtles-fetch.md @@ -0,0 +1,5 @@ +--- +'@portabletext/editor': patch +--- + +fix(dom): respect snapshot selection in `.getSelectionRect` diff --git a/packages/editor/gherkin-spec/selection.feature b/packages/editor/gherkin-spec/selection.feature index e15aef67c..2c3abd63e 100644 --- a/packages/editor/gherkin-spec/selection.feature +++ b/packages/editor/gherkin-spec/selection.feature @@ -3,6 +3,41 @@ Feature: Selection Background: Given one editor + Scenario Outline: Expanding selection down + Given the text + When the caret is put + And "{Shift>}{ArrowDown}{/Shift}" is pressed + And "{Shift>}{ArrowDown}{/Shift}" is pressed + Then is selected + + Examples: + | text | position | selection | + | "foo\|bar\|baz" | before "foo" | "foo\|bar\|" | + | "foo\|>#:bar\|baz" | before "foo" | "foo\|>#:bar\|" | + | "foo\|>#:bar\|{image}" | before "foo" | "foo\|>#:bar" | + + Scenario: Expanding selection down into block object + Given the text "foo|{image}|bar" + When the caret is put before "foo" + And "{Shift>}{ArrowDown}{/Shift}" is pressed + And "{Shift>}{ArrowDown}{/Shift}" is pressed + Then "foo|{image}" is selected + + Scenario Outline: Expanding selection down through block objects + Given the text + When the caret is put before "foo" + And "{Shift>}{ArrowDown}{/Shift}" is pressed + And "{Shift>}{ArrowDown}{/Shift}" is pressed + And "{Shift>}{ArrowDown}{/Shift}" is pressed + Then is selected + + Examples: + | text | selection | + | "foo\|{image}\|bar" | "foo\|{image}\|bar" | + | "foo\|{image}\|bar\|baz" | "foo\|{image}\|bar\|" | + | "foo\|{image}\|bar\|{image}" | "foo\|{image}\|bar" | + | "foo\|{image}\|{image}\|bar" | "foo\|{image}\|{image}" | + Scenario: Expanding collapsed selection backwards from empty line Given the text "foo|" When the editor is focused diff --git a/packages/editor/src/behaviors/behavior.abstract.keyboard.ts b/packages/editor/src/behaviors/behavior.abstract.keyboard.ts index c669de6cc..be27a87be 100644 --- a/packages/editor/src/behaviors/behavior.abstract.keyboard.ts +++ b/packages/editor/src/behaviors/behavior.abstract.keyboard.ts @@ -1,28 +1,20 @@ -import {createKeyboardShortcut} from '@portabletext/keyboard-shortcuts' import {isTextBlock} from '@portabletext/schema' import {defaultKeyboardShortcuts} from '../editor/default-keyboard-shortcuts' +import {getFocusBlockObject} from '../selectors' import {getFocusBlock} from '../selectors/selector.get-focus-block' import {getFocusInlineObject} from '../selectors/selector.get-focus-inline-object' +import {getFocusTextBlock} from '../selectors/selector.get-focus-text-block' +import {getNextBlock} from '../selectors/selector.get-next-block' import {getPreviousBlock} from '../selectors/selector.get-previous-block' import {isSelectionCollapsed} from '../selectors/selector.is-selection-collapsed' import {isSelectionExpanded} from '../selectors/selector.is-selection-expanded' +import {isEqualSelectionPoints} from '../utils' import {getBlockEndPoint} from '../utils/util.get-block-end-point' +import {getBlockStartPoint} from '../utils/util.get-block-start-point' import {isEmptyTextBlock} from '../utils/util.is-empty-text-block' import {raise} from './behavior.types.action' import {defineBehavior} from './behavior.types.behavior' -const shiftLeft = createKeyboardShortcut({ - default: [ - { - key: 'ArrowLeft', - shift: true, - meta: false, - ctrl: false, - alt: false, - }, - ], -}) - export const abstractKeyboardBehaviors = [ /** * When Backspace is pressed on an inline object, Slate will raise a @@ -130,7 +122,10 @@ export const abstractKeyboardBehaviors = [ defineBehavior({ on: 'keyboard.keydown', guard: ({snapshot, event}) => { - if (!snapshot.context.selection || !shiftLeft.guard(event.originEvent)) { + if ( + !snapshot.context.selection || + !defaultKeyboardShortcuts.shiftLeft.guard(event.originEvent) + ) { return false } @@ -186,4 +181,269 @@ export const abstractKeyboardBehaviors = [ ], ], }), + + defineBehavior({ + on: 'keyboard.keydown', + guard: ({snapshot, event}) => { + if ( + !snapshot.context.selection || + !defaultKeyboardShortcuts.shiftDown.guard(event.originEvent) + ) { + return false + } + + const focusBlockObject = getFocusBlockObject(snapshot) + + if (!focusBlockObject) { + return false + } + + const nextBlock = getNextBlock(snapshot) + + if (!nextBlock) { + return false + } + + if (!isTextBlock(snapshot.context, nextBlock.node)) { + return { + nextBlockEndPoint: getBlockEndPoint({ + context: snapshot.context, + block: nextBlock, + }), + selection: snapshot.context.selection, + } + } + + const nextNextBlock = getNextBlock({ + ...snapshot, + context: { + ...snapshot.context, + selection: { + anchor: { + path: nextBlock.path, + offset: 0, + }, + focus: { + path: nextBlock.path, + offset: 0, + }, + }, + }, + }) + + const nextBlockEndPoint = + nextNextBlock && isTextBlock(snapshot.context, nextNextBlock.node) + ? getBlockStartPoint({ + context: snapshot.context, + block: nextNextBlock, + }) + : getBlockEndPoint({ + context: snapshot.context, + block: nextBlock, + }) + + return {nextBlockEndPoint, selection: snapshot.context.selection} + }, + actions: [ + (_, {nextBlockEndPoint, selection}) => [ + raise({ + type: 'select', + at: {anchor: selection.anchor, focus: nextBlockEndPoint}, + }), + ], + ], + }), + + defineBehavior({ + on: 'keyboard.keydown', + guard: ({snapshot, event, dom}) => { + if ( + !snapshot.context.selection || + !defaultKeyboardShortcuts.shiftDown.guard(event.originEvent) + ) { + return false + } + + const focusTextBlock = getFocusTextBlock(snapshot) + + if (!focusTextBlock) { + return false + } + + const nextBlock = getNextBlock(snapshot) + + if (!nextBlock) { + return false + } + + if (isTextBlock(snapshot.context, nextBlock.node)) { + return false + } + + const focusBlockEndPoint = getBlockEndPoint({ + context: snapshot.context, + block: focusTextBlock, + }) + + if ( + isEqualSelectionPoints( + snapshot.context.selection.focus, + focusBlockEndPoint, + ) + ) { + return false + } + + // Find the DOM position of the current focus point + const focusRect = dom.getSelectionRect({ + ...snapshot, + context: { + ...snapshot.context, + selection: { + anchor: snapshot.context.selection.focus, + focus: snapshot.context.selection.focus, + }, + }, + }) + // Find the DOM position of the focus block end point + const endPointRect = dom.getSelectionRect({ + ...snapshot, + context: { + ...snapshot.context, + selection: { + anchor: focusBlockEndPoint, + focus: focusBlockEndPoint, + }, + }, + }) + + if (!focusRect || !endPointRect) { + return false + } + + if (endPointRect.top > focusRect.top) { + // If the end point is positioned further from the top than the current + // focus point, then we can deduce that the end point is on the next + // line. In this case, we don't want to interfere since the browser + // does right thing and expands the selection to the end of the current + // line. + return false + } + + // If the end point is positioned at the same level as the current focus + // point, then we can deduce that the end point is on the same line. In + // this case, we want to expand the selection to the end point. + // This mitigates a Firefox bug where Shift+ArrowDown can expand + // further into the next block. + return {focusBlockEndPoint, selection: snapshot.context.selection} + }, + actions: [ + (_, {focusBlockEndPoint, selection}) => [ + raise({ + type: 'select', + at: { + anchor: selection.anchor, + focus: focusBlockEndPoint, + }, + }), + ], + ], + }), + + defineBehavior({ + on: 'keyboard.keydown', + guard: ({snapshot, event, dom}) => { + if ( + !snapshot.context.selection || + !defaultKeyboardShortcuts.shiftDown.guard(event.originEvent) + ) { + return false + } + + const focusTextBlock = getFocusTextBlock(snapshot) + + if (!focusTextBlock) { + return false + } + + const nextBlock = getNextBlock(snapshot) + + if (!nextBlock) { + return false + } + + const focusBlockEndPoint = getBlockEndPoint({ + context: snapshot.context, + block: focusTextBlock, + }) + + if ( + isEqualSelectionPoints( + snapshot.context.selection.focus, + focusBlockEndPoint, + ) + ) { + return false + } + + // Find the DOM position of the current focus point + const focusRect = dom.getSelectionRect({ + ...snapshot, + context: { + ...snapshot.context, + selection: { + anchor: snapshot.context.selection.focus, + focus: snapshot.context.selection.focus, + }, + }, + }) + // Find the DOM position of the focus block end point + const endPointRect = dom.getSelectionRect({ + ...snapshot, + context: { + ...snapshot.context, + selection: { + anchor: focusBlockEndPoint, + focus: focusBlockEndPoint, + }, + }, + }) + + if (!focusRect || !endPointRect) { + return false + } + + if (endPointRect.top > focusRect.top) { + // If the end point is positioned further from the top than the current + // focus point, then we can deduce that the end point is on the next + // line. In this case, we don't want to interfere since the browser + // does right thing and expands the selection to the end of the current + // line. + return false + } + + // If the end point is positioned at the same level as the current focus + // point, then we can deduce that the end point is on the same line. In + // this case, we want to expand the selection to the end of the start + // block. This mitigates a Chromium bug where Shift+ArrowDown can expand + // further into the next block. + const nextBlockStartPoint = getBlockStartPoint({ + context: snapshot.context, + block: nextBlock, + }) + + return {nextBlockStartPoint, selection: snapshot.context.selection} + }, + actions: [ + (_, {nextBlockStartPoint, selection}) => [ + raise({ + type: 'select', + at: { + anchor: selection.anchor, + focus: nextBlockStartPoint, + }, + }), + ], + ], + }), ] diff --git a/packages/editor/src/editor/default-keyboard-shortcuts.ts b/packages/editor/src/editor/default-keyboard-shortcuts.ts index bb481a683..9d478cb17 100644 --- a/packages/editor/src/editor/default-keyboard-shortcuts.ts +++ b/packages/editor/src/editor/default-keyboard-shortcuts.ts @@ -132,6 +132,28 @@ export const defaultKeyboardShortcuts = { }, ], }), + shiftDown: createKeyboardShortcut({ + default: [ + { + key: 'ArrowDown', + shift: true, + meta: false, + ctrl: false, + alt: false, + }, + ], + }), + shiftLeft: createKeyboardShortcut({ + default: [ + { + key: 'ArrowLeft', + shift: true, + meta: false, + ctrl: false, + alt: false, + }, + ], + }), shiftTab: createKeyboardShortcut({ default: [ { diff --git a/packages/editor/src/editor/editor-dom.ts b/packages/editor/src/editor/editor-dom.ts index 1473f37cb..3b847f583 100644 --- a/packages/editor/src/editor/editor-dom.ts +++ b/packages/editor/src/editor/editor-dom.ts @@ -39,7 +39,7 @@ export function createEditorDom( getBlockNodes: (snapshot) => getBlockNodes(slateEditor, snapshot), getChildNodes: (snapshot) => getChildNodes(slateEditor, snapshot), getEditorElement: () => getEditorElement(slateEditor), - getSelectionRect: (snapshot) => getSelectionRect(snapshot), + getSelectionRect: (snapshot) => getSelectionRect(slateEditor, snapshot), getStartBlockElement: (snapshot) => getStartBlockElement(slateEditor, snapshot), getEndBlockElement: (snapshot) => getEndBlockElement(slateEditor, snapshot), @@ -117,20 +117,20 @@ function getEditorElement(slateEditor: PortableTextSlateEditor) { } } -function getSelectionRect(snapshot: EditorSnapshot) { - if (!snapshot.context.selection) { +function getSelectionRect( + slateEditor: PortableTextSlateEditor, + snapshot: EditorSnapshot, +) { + const slateRange = toSlateRange(snapshot) + + if (!slateRange) { return null } try { - const selection = window.getSelection() - - if (!selection) { - return null - } + const domRange = DOMEditor.toDOMRange(slateEditor, slateRange) - const range = selection.getRangeAt(0) - return range.getBoundingClientRect() + return domRange.getBoundingClientRect() } catch { return null } diff --git a/packages/editor/src/globals.d.ts b/packages/editor/src/globals.d.ts index 404c55f1e..6fb9e2ab2 100644 --- a/packages/editor/src/globals.d.ts +++ b/packages/editor/src/globals.d.ts @@ -1 +1,3 @@ declare const __DEV__: boolean + +declare module '*.css' {} diff --git a/packages/editor/src/test/vitest/test-editor.css b/packages/editor/src/test/vitest/test-editor.css new file mode 100644 index 000000000..831a9b0c3 --- /dev/null +++ b/packages/editor/src/test/vitest/test-editor.css @@ -0,0 +1,188 @@ +[role='textbox'] { + --list-padding: 1em; + /** + * First, we initialize a counter for each block level. + */ + counter-reset: level-1 level-2 level-3 level-4 level-5 level-6 level-7 level-8 + level-9 level-10; +} + +[data-list-item] { + display: grid; + grid-template-columns: 1.5rem 1fr; + gap: 0.5rem; +} +[data-list-item]::before { + text-align: right; + grid-column: 1; +} + +[data-list-item='number'] { + align-items: baseline; +} +[data-list-item='number']::before { + font-size: 0.9rem; + font-variant-numeric: tabular-nums; +} + +[data-list-item='bullet'] { + align-items: center; +} +[data-list-item='bullet']::before { + font-size: 0.4rem; +} + +/** + * Then, the counter for each level is manually set to 1 whenever a list item + * with index 1 is encountered. + */ +[data-level='1'][data-list-index='1'] { + counter-set: level-1 1; +} +[data-level='2'][data-list-index='1'] { + counter-set: level-2 1; +} +[data-level='3'][data-list-index='1'] { + counter-set: level-3 1; +} +[data-level='4'][data-list-index='1'] { + counter-set: level-4 1; +} +[data-level='5'][data-list-index='1'] { + counter-set: level-5 1; +} +[data-level='6'][data-list-index='1'] { + counter-set: level-6 1; +} +[data-level='7'][data-list-index='1'] { + counter-set: level-7 1; +} +[data-level='8'][data-list-index='1'] { + counter-set: level-8 1; +} +[data-level='9'][data-list-index='1'] { + counter-set: level-9 1; +} +[data-level='10'][data-list-index='1'] { + counter-set: level-10 1; +} + +/** + * Thereafter, the count for each list level is incremented for each index + * greater than 1. + */ +[data-level='1']:not([data-list-index='1']) { + counter-increment: level-1; +} +[data-level='2']:not([data-list-index='1']) { + counter-increment: level-2; +} +[data-level='3']:not([data-list-index='1']) { + counter-increment: level-3; +} +[data-level='4']:not([data-list-index='1']) { + counter-increment: level-4; +} +[data-level='5']:not([data-list-index='1']) { + counter-increment: level-5; +} +[data-level='6']:not([data-list-index='1']) { + counter-increment: level-6; +} +[data-level='7']:not([data-list-index='1']) { + counter-increment: level-7; +} +[data-level='8']:not([data-list-index='1']) { + counter-increment: level-8; +} +[data-level='9']:not([data-list-index='1']) { + counter-increment: level-9; +} +[data-level='10']:not([data-list-index='1']) { + counter-increment: level-10; +} + +/** + * Finally, the calculated count is displayed in the list item. + */ +[data-list-item='number'][data-level='1']::before { + content: counter(level-1, decimal) '.'; +} +[data-list-item='number'][data-level='2']::before { + content: counter(level-2, lower-alpha) '.'; +} +[data-list-item='number'][data-level='3']::before { + content: counter(level-3, lower-roman) '.'; +} +[data-list-item='number'][data-level='4']::before { + content: counter(level-4, decimal) '.'; +} +[data-list-item='number'][data-level='5']::before { + content: counter(level-5, lower-alpha) '.'; +} +[data-list-item='number'][data-level='6']::before { + content: counter(level-6, lower-roman) '.'; +} +[data-list-item='number'][data-level='7']::before { + content: counter(level-7, decimal) '.'; +} +[data-list-item='number'][data-level='8']::before { + content: counter(level-8, lower-alpha) '.'; +} +[data-list-item='number'][data-level='9']::before { + content: counter(level-9, lower-roman) '.'; +} +[data-list-item='number'][data-level='10']::before { + content: counter(level-10, decimal) '.'; +} + +/** + * Visual display of bulleted list items + */ +[data-list-item='bullet'][data-level='1']::before, +[data-list-item='bullet'][data-level='4']::before, +[data-list-item='bullet'][data-level='7']::before, +[data-list-item='bullet'][data-level='10']::before { + content: '●'; +} +[data-list-item='bullet'][data-level='2']::before, +[data-list-item='bullet'][data-level='5']::before, +[data-list-item='bullet'][data-level='8']::before { + content: '○'; +} +[data-list-item='bullet'][data-level='3']::before, +[data-list-item='bullet'][data-level='6']::before, +[data-list-item='bullet'][data-level='9']::before { + content: '■'; +} + +/** + * Padding for each level of list item + */ +[data-level='2'] { + padding-left: calc(var(--list-padding)); +} +[data-level='3'] { + padding-left: calc(var(--list-padding) * 2); +} +[data-level='4'] { + padding-left: calc(var(--list-padding) * 3); +} +[data-level='5'] { + padding-left: calc(var(--list-padding) * 4); +} +[data-level='6'] { + padding-left: calc(var(--list-padding) * 5); +} +[data-level='7'] { + padding-left: calc(var(--list-padding) * 6); +} +[data-level='8'] { + padding-left: calc(var(--list-padding) * 7); +} +[data-level='9'] { + padding-left: calc(var(--list-padding) * 8); +} +[data-level='10'] { + padding-left: calc(var(--list-padding) * 9); +} diff --git a/packages/editor/src/test/vitest/test-editor.tsx b/packages/editor/src/test/vitest/test-editor.tsx index 88f1ce418..233949016 100644 --- a/packages/editor/src/test/vitest/test-editor.tsx +++ b/packages/editor/src/test/vitest/test-editor.tsx @@ -18,6 +18,7 @@ import type {EditorEmittedEvent} from '../../editor/relay-machine' import {EventListenerPlugin} from '../../plugins' import {EditorRefPlugin} from '../../plugins/plugin.editor-ref' import type {Context} from './step-context' +import './test-editor.css' type CreateTestEditorOptions = { initialValue?: Array