From 3f77a7e0ae54606b078b16f9fa0a4e0c5d4b15ba Mon Sep 17 00:00:00 2001 From: Joe Anderson Date: Tue, 21 Mar 2023 16:48:14 +0000 Subject: [PATCH] Add `isSelectable` --- .changeset/spicy-phones-nail.md | 7 +++ .changeset/wicked-weeks-kick.md | 2 +- packages/slate-history/test/index.js | 6 ++- packages/slate/src/create-editor.ts | 1 + packages/slate/src/interfaces/editor.ts | 43 ++++++++++++++++--- packages/slate/src/transforms/selection.ts | 2 +- packages/slate/test/index.js | 5 ++- .../Editor/after/non-selectable-inline.tsx | 22 ++++++++++ .../Editor/before/non-selectable-inline.tsx | 22 ++++++++++ .../nodes/ignore-non-selectable/block.tsx | 19 ++++++++ .../nodes/ignore-non-selectable/inline.tsx | 20 +++++++++ .../positions/ignore-non-selectable/block.tsx | 15 +++++++ .../ignore-non-selectable/inline.tsx | 32 ++++++++++++++ .../interfaces/Element/isElement/editor.tsx | 3 +- .../Element/isElementList/full-editor.tsx | 3 +- .../integration/examples/inlines.test.ts | 22 +++++----- site/examples/inlines.tsx | 11 ++++- 17 files changed, 210 insertions(+), 25 deletions(-) create mode 100644 .changeset/spicy-phones-nail.md create mode 100644 packages/slate/test/interfaces/Editor/after/non-selectable-inline.tsx create mode 100644 packages/slate/test/interfaces/Editor/before/non-selectable-inline.tsx create mode 100644 packages/slate/test/interfaces/Editor/nodes/ignore-non-selectable/block.tsx create mode 100644 packages/slate/test/interfaces/Editor/nodes/ignore-non-selectable/inline.tsx create mode 100644 packages/slate/test/interfaces/Editor/positions/ignore-non-selectable/block.tsx create mode 100644 packages/slate/test/interfaces/Editor/positions/ignore-non-selectable/inline.tsx diff --git a/.changeset/spicy-phones-nail.md b/.changeset/spicy-phones-nail.md new file mode 100644 index 00000000000..454e19a34eb --- /dev/null +++ b/.changeset/spicy-phones-nail.md @@ -0,0 +1,7 @@ +--- +'slate': minor +--- + +- Add `isSelectable` to `editor` (default true). A non-selectable element is skipped over when navigating using arrow keys. + - Add `ignoreNonSelectable` to `Editor.nodes`, `Editor.positions`, `Editor.after` and `Editor.before` (default false) + - `Transforms.move` ignores non-selectable elements diff --git a/.changeset/wicked-weeks-kick.md b/.changeset/wicked-weeks-kick.md index 46ec44da446..c74ad7a0111 100644 --- a/.changeset/wicked-weeks-kick.md +++ b/.changeset/wicked-weeks-kick.md @@ -2,4 +2,4 @@ 'slate': minor --- -Adds `isElementReadOnly` to `editor`. A read-only element behaves much like a void with regard to selection and deletion, but renders its `children` the same as any other non-void node. +- Adds `isElementReadOnly` to `editor`. A read-only element behaves much like a void with regard to selection and deletion, but renders its `children` the same as any other non-void node. diff --git a/packages/slate-history/test/index.js b/packages/slate-history/test/index.js index a71a39f6c77..b70d34504e3 100644 --- a/packages/slate-history/test/index.js +++ b/packages/slate-history/test/index.js @@ -30,7 +30,7 @@ export const jsx = createHyperscript({ }) const withTest = editor => { - const { isInline, isVoid, isElementReadOnly } = editor + const { isInline, isVoid, isElementReadOnly, isSelectable } = editor editor.isInline = element => { return element.inline === true ? true : isInline(element) @@ -44,5 +44,9 @@ const withTest = editor => { return element.readOnly === true ? true : isElementReadOnly(element) } + editor.isSelectable = element => { + return element.nonSelectable === true ? false : isSelectable(element) + } + return editor } diff --git a/packages/slate/src/create-editor.ts b/packages/slate/src/create-editor.ts index 87615c1c6fb..d6b09efd773 100644 --- a/packages/slate/src/create-editor.ts +++ b/packages/slate/src/create-editor.ts @@ -27,6 +27,7 @@ export const createEditor = (): Editor => { marks: null, isElementReadOnly: () => false, isInline: () => false, + isSelectable: () => true, isVoid: () => false, markableVoid: () => false, onChange: () => {}, diff --git a/packages/slate/src/interfaces/editor.ts b/packages/slate/src/interfaces/editor.ts index 23616a4f0a2..93b1cafb72c 100644 --- a/packages/slate/src/interfaces/editor.ts +++ b/packages/slate/src/interfaces/editor.ts @@ -61,6 +61,7 @@ export interface BaseEditor { // Schema-specific node behaviors. isElementReadOnly: (element: Element) => boolean isInline: (element: Element) => boolean + isSelectable: (element: Element) => boolean isVoid: (element: Element) => boolean markableVoid: (element: Element) => boolean normalizeNode: (entry: NodeEntry, options?: { operation?: Operation }) => void @@ -158,6 +159,7 @@ export interface EditorNodesOptions { universal?: boolean reverse?: boolean voids?: boolean + ignoreNonSelectable?: boolean } export interface EditorNormalizeOptions { @@ -192,6 +194,7 @@ export interface EditorPositionsOptions { unit?: TextUnitAdjustment reverse?: boolean voids?: boolean + ignoreNonSelectable?: boolean } export interface EditorPreviousOptions { @@ -272,6 +275,7 @@ export interface EditorInterface { isEmpty: (editor: Editor, element: Element) => boolean isInline: (editor: Editor, value: Element) => boolean isNormalizing: (editor: Editor) => boolean + isSelectable: (editor: Editor, element: Element) => boolean isStart: (editor: Editor, point: Point, at: Location) => boolean isVoid: (editor: Editor, value: Element) => boolean last: (editor: Editor, at: Location) => NodeEntry @@ -674,6 +678,7 @@ export const Editor: EditorInterface = { typeof value.insertText === 'function' && typeof value.isElementReadOnly === 'function' && typeof value.isInline === 'function' && + typeof value.isSelectable === 'function' && typeof value.isVoid === 'function' && typeof value.normalizeNode === 'function' && typeof value.onChange === 'function' && @@ -745,6 +750,14 @@ export const Editor: EditorInterface = { return isNormalizing === undefined ? true : isNormalizing }, + /** + * Check if a value is a selectable `Element` object. + */ + + isSelectable(editor: Editor, value: Element): boolean { + return editor.isSelectable(value) + }, + /** * Check if a point is the start point of a location. */ @@ -958,6 +971,7 @@ export const Editor: EditorInterface = { universal = false, reverse = false, voids = false, + ignoreNonSelectable = false, } = options let { match } = options @@ -986,17 +1000,28 @@ export const Editor: EditorInterface = { reverse, from, to, - pass: ([n]) => - voids - ? false - : Element.isElement(n) && - (Editor.isVoid(editor, n) || Editor.isElementReadOnly(editor, n)), + pass: ([node]) => { + if (!Element.isElement(node)) return false + if ( + !voids && + (Editor.isVoid(editor, node) || + Editor.isElementReadOnly(editor, node)) + ) + return true + if (ignoreNonSelectable && !Editor.isSelectable(editor, node)) + return true + return false + }, }) const matches: NodeEntry[] = [] let hit: NodeEntry | undefined for (const [node, path] of nodeEntries) { + if (ignoreNonSelectable && !Editor.isSelectable(editor, node)) { + continue + } + const isLower = hit && Path.compare(path, hit[1]) === 0 // In highest mode any node lower than the last hit is not a match. @@ -1342,6 +1367,7 @@ export const Editor: EditorInterface = { unit = 'offset', reverse = false, voids = false, + ignoreNonSelectable = false, } = options if (!at) { @@ -1381,7 +1407,12 @@ export const Editor: EditorInterface = { // encounter the block node, then all of its text nodes, so when iterating // through the blockText and leafText we just need to remember a window of // one block node and leaf node, respectively. - for (const [node, path] of Editor.nodes(editor, { at, reverse, voids })) { + for (const [node, path] of Editor.nodes(editor, { + at, + reverse, + voids, + ignoreNonSelectable, + })) { /* * ELEMENT NODE - Yield position(s) for voids, collect blockText for blocks */ diff --git a/packages/slate/src/transforms/selection.ts b/packages/slate/src/transforms/selection.ts index 4816f807dac..8acc5b97df4 100644 --- a/packages/slate/src/transforms/selection.ts +++ b/packages/slate/src/transforms/selection.ts @@ -92,7 +92,7 @@ export const SelectionTransforms: SelectionTransforms = { } const { anchor, focus } = selection - const opts = { distance, unit } + const opts = { distance, unit, ignoreNonSelectable: true } const props: Partial = {} if (edge == null || edge === 'anchor') { diff --git a/packages/slate/test/index.js b/packages/slate/test/index.js index 5caacf1cbad..92cf7181f07 100644 --- a/packages/slate/test/index.js +++ b/packages/slate/test/index.js @@ -47,7 +47,7 @@ describe('slate', () => { }) }) const withTest = editor => { - const { isInline, isVoid, isElementReadOnly } = editor + const { isInline, isVoid, isElementReadOnly, isSelectable } = editor editor.isInline = element => { return element.inline === true ? true : isInline(element) } @@ -57,6 +57,9 @@ const withTest = editor => { editor.isElementReadOnly = element => { return element.readOnly === true ? true : isElementReadOnly(element) } + editor.isSelectable = element => { + return element.nonSelectable === true ? false : isSelectable(element) + } return editor } export const jsx = createHyperscript({ diff --git a/packages/slate/test/interfaces/Editor/after/non-selectable-inline.tsx b/packages/slate/test/interfaces/Editor/after/non-selectable-inline.tsx new file mode 100644 index 00000000000..1f24e74148e --- /dev/null +++ b/packages/slate/test/interfaces/Editor/after/non-selectable-inline.tsx @@ -0,0 +1,22 @@ +/** @jsx jsx */ + +import { Editor } from 'slate' +import { jsx } from '../../..' + +export const input = ( + + + onetwothree + + +) + +export const test = editor => { + return Editor.after( + editor, + { path: [0, 0], offset: 3 }, + { ignoreNonSelectable: true } + ) +} + +export const output = { path: [0, 2], offset: 0 } diff --git a/packages/slate/test/interfaces/Editor/before/non-selectable-inline.tsx b/packages/slate/test/interfaces/Editor/before/non-selectable-inline.tsx new file mode 100644 index 00000000000..5403a0c88d3 --- /dev/null +++ b/packages/slate/test/interfaces/Editor/before/non-selectable-inline.tsx @@ -0,0 +1,22 @@ +/** @jsx jsx */ + +import { Editor } from 'slate' +import { jsx } from '../../..' + +export const input = ( + + + onetwothree + + +) + +export const test = editor => { + return Editor.before( + editor, + { path: [0, 2], offset: 0 }, + { ignoreNonSelectable: true } + ) +} + +export const output = { path: [0, 0], offset: 3 } diff --git a/packages/slate/test/interfaces/Editor/nodes/ignore-non-selectable/block.tsx b/packages/slate/test/interfaces/Editor/nodes/ignore-non-selectable/block.tsx new file mode 100644 index 00000000000..abf21606f85 --- /dev/null +++ b/packages/slate/test/interfaces/Editor/nodes/ignore-non-selectable/block.tsx @@ -0,0 +1,19 @@ +/** @jsx jsx */ +import { Editor, Text } from 'slate' +import { jsx } from '../../../..' + +export const input = ( + + one + +) +export const test = editor => { + return Array.from( + Editor.nodes(editor, { + at: [], + match: Text.isText, + ignoreNonSelectable: true, + }) + ) +} +export const output = [] diff --git a/packages/slate/test/interfaces/Editor/nodes/ignore-non-selectable/inline.tsx b/packages/slate/test/interfaces/Editor/nodes/ignore-non-selectable/inline.tsx new file mode 100644 index 00000000000..f81a9d9210f --- /dev/null +++ b/packages/slate/test/interfaces/Editor/nodes/ignore-non-selectable/inline.tsx @@ -0,0 +1,20 @@ +/** @jsx jsx */ +import { Editor, Text } from 'slate' +import { jsx } from '../../../..' + +export const input = ( + + + onetwothree + + +) +export const test = editor => { + return Array.from(Editor.nodes(editor, { at: [], ignoreNonSelectable: true })) +} +export const output = [ + [input, []], + [input.children[0], [0]], + [input.children[0].children[0], [0, 0]], + [input.children[0].children[2], [0, 2]], +] diff --git a/packages/slate/test/interfaces/Editor/positions/ignore-non-selectable/block.tsx b/packages/slate/test/interfaces/Editor/positions/ignore-non-selectable/block.tsx new file mode 100644 index 00000000000..8cbfa9846d3 --- /dev/null +++ b/packages/slate/test/interfaces/Editor/positions/ignore-non-selectable/block.tsx @@ -0,0 +1,15 @@ +/** @jsx jsx */ +import { Editor } from 'slate' +import { jsx } from '../../../..' + +export const input = ( + + one + +) +export const test = editor => { + return Array.from( + Editor.positions(editor, { at: [], ignoreNonSelectable: true }) + ) +} +export const output = [] diff --git a/packages/slate/test/interfaces/Editor/positions/ignore-non-selectable/inline.tsx b/packages/slate/test/interfaces/Editor/positions/ignore-non-selectable/inline.tsx new file mode 100644 index 00000000000..c8ae43dbc90 --- /dev/null +++ b/packages/slate/test/interfaces/Editor/positions/ignore-non-selectable/inline.tsx @@ -0,0 +1,32 @@ +/** @jsx jsx */ +import { Editor } from 'slate' +import { jsx } from '../../../..' + +export const input = ( + + + onetwothree + + +) +export const test = editor => { + return Array.from( + Editor.positions(editor, { at: [], ignoreNonSelectable: true }) + ) +} +export const output = [ + { path: [0, 0], offset: 0 }, + { path: [0, 0], offset: 1 }, + { path: [0, 0], offset: 2 }, + { path: [0, 0], offset: 3 }, + // { path: [0, 1, 0], offset: 0 }, + // { path: [0, 1, 0], offset: 1 }, + // { path: [0, 1, 0], offset: 2 }, + // { path: [0, 1, 0], offset: 3 }, + { path: [0, 2], offset: 0 }, + { path: [0, 2], offset: 1 }, + { path: [0, 2], offset: 2 }, + { path: [0, 2], offset: 3 }, + { path: [0, 2], offset: 4 }, + { path: [0, 2], offset: 5 }, +] diff --git a/packages/slate/test/interfaces/Element/isElement/editor.tsx b/packages/slate/test/interfaces/Element/isElement/editor.tsx index 191bf5dac19..480dda9fc2c 100644 --- a/packages/slate/test/interfaces/Element/isElement/editor.tsx +++ b/packages/slate/test/interfaces/Element/isElement/editor.tsx @@ -15,8 +15,9 @@ export const input = { insertFragment() {}, insertNode() {}, insertText() {}, - isInline() {}, isElementReadOnly() {}, + isInline() {}, + isSelectable() {}, isVoid() {}, normalizeNode() {}, onChange() {}, diff --git a/packages/slate/test/interfaces/Element/isElementList/full-editor.tsx b/packages/slate/test/interfaces/Element/isElementList/full-editor.tsx index 3944517b241..a79498300f1 100644 --- a/packages/slate/test/interfaces/Element/isElementList/full-editor.tsx +++ b/packages/slate/test/interfaces/Element/isElementList/full-editor.tsx @@ -16,8 +16,9 @@ export const input = [ insertFragment() {}, insertNode() {}, insertText() {}, - isInline() {}, isElementReadOnly() {}, + isInline() {}, + isSelectable() {}, isVoid() {}, normalizeNode() {}, onChange() {}, diff --git a/playwright/integration/examples/inlines.test.ts b/playwright/integration/examples/inlines.test.ts index a0b1be538c5..abd6f9d9aa2 100644 --- a/playwright/integration/examples/inlines.test.ts +++ b/playwright/integration/examples/inlines.test.ts @@ -28,20 +28,18 @@ test.describe('Inlines example', () => { selection.addRange(range) }) - const getBadgeIsSelected = async () => { - return ( - (await badge.evaluate(e => e.dataset.playwrightSelected)) === 'true' - ) - } + const getSelectionContainerText = () => + page.evaluate(() => { + const selection = window.getSelection() + return selection.anchorNode.parentNode.innerText + }) - expect(await getBadgeIsSelected()).toBe(false) + expect(await getSelectionContainerText()).toBe('.') await page.keyboard.press('ArrowLeft') - expect(await getBadgeIsSelected()).toBe(true) - await page.keyboard.press('ArrowLeft') - expect(await getBadgeIsSelected()).toBe(false) - await page.keyboard.press('ArrowRight') - expect(await getBadgeIsSelected()).toBe(true) + expect(await getSelectionContainerText()).toBe( + '! Here is a read-only inline: ' + ) await page.keyboard.press('ArrowRight') - expect(await getBadgeIsSelected()).toBe(false) + expect(await getSelectionContainerText()).toBe('.') }) }) diff --git a/site/examples/inlines.tsx b/site/examples/inlines.tsx index a524ccc83ec..2f410c73f00 100644 --- a/site/examples/inlines.tsx +++ b/site/examples/inlines.tsx @@ -115,7 +115,13 @@ const InlinesExample = () => { } const withInlines = editor => { - const { insertData, insertText, isInline, isElementReadOnly } = editor + const { + insertData, + insertText, + isInline, + isElementReadOnly, + isSelectable, + } = editor editor.isInline = element => ['link', 'button', 'badge'].includes(element.type) || isInline(element) @@ -123,6 +129,9 @@ const withInlines = editor => { editor.isElementReadOnly = element => element.type === 'badge' || isElementReadOnly(element) + editor.isSelectable = element => + element.type !== 'badge' && isSelectable(element) + editor.insertText = text => { if (text && isUrl(text)) { wrapLink(editor, text)