Skip to content

Commit

Permalink
Add support for read-only and non-selectable elements (#5374)
Browse files Browse the repository at this point in the history
* Add `isElementReadOnly`

fix delete while selected

fix type while selected

fix failing test

add tests

add e2e test

linter fixes

add changeset

* fix yarn build:next

* Add `isSelectable`
  • Loading branch information
12joan authored Apr 3, 2023
1 parent 26ace0d commit b52e08b
Show file tree
Hide file tree
Showing 22 changed files with 416 additions and 20 deletions.
7 changes: 7 additions & 0 deletions .changeset/spicy-phones-nail.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .changeset/wicked-weeks-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'slate': minor
---

- Add `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.
10 changes: 9 additions & 1 deletion packages/slate-history/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const jsx = createHyperscript({
})

const withTest = editor => {
const { isInline, isVoid } = editor
const { isInline, isVoid, isElementReadOnly, isSelectable } = editor

editor.isInline = element => {
return element.inline === true ? true : isInline(element)
Expand All @@ -40,5 +40,13 @@ const withTest = editor => {
return element.void === true ? true : isVoid(element)
}

editor.isElementReadOnly = element => {
return element.readOnly === true ? true : isElementReadOnly(element)
}

editor.isSelectable = element => {
return element.nonSelectable === true ? false : isSelectable(element)
}

return editor
}
2 changes: 2 additions & 0 deletions packages/slate/src/create-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ export const createEditor = (): Editor => {
operations: [],
selection: null,
marks: null,
isElementReadOnly: () => false,
isInline: () => false,
isSelectable: () => true,
isVoid: () => false,
markableVoid: () => false,
onChange: () => {},
Expand Down
81 changes: 77 additions & 4 deletions packages/slate/src/interfaces/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ export interface BaseEditor {
marks: EditorMarks | null

// 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
Expand Down Expand Up @@ -116,6 +118,12 @@ export interface EditorDirectedDeletionOptions {
unit?: TextUnit
}

export interface EditorElementReadOnlyOptions {
at?: Location
mode?: MaximizeMode
voids?: boolean
}

export interface EditorFragmentDeletionOptions {
direction?: TextDirection
}
Expand Down Expand Up @@ -151,6 +159,7 @@ export interface EditorNodesOptions<T extends Node> {
universal?: boolean
reverse?: boolean
voids?: boolean
ignoreNonSelectable?: boolean
}

export interface EditorNormalizeOptions {
Expand Down Expand Up @@ -185,6 +194,7 @@ export interface EditorPositionsOptions {
unit?: TextUnitAdjustment
reverse?: boolean
voids?: boolean
ignoreNonSelectable?: boolean
}

export interface EditorPreviousOptions<T extends Node> {
Expand Down Expand Up @@ -241,6 +251,10 @@ export interface EditorInterface {
options?: EditorFragmentDeletionOptions
) => void
edges: (editor: Editor, at: Location) => [Point, Point]
elementReadOnly: (
editor: Editor,
options?: EditorElementReadOnlyOptions
) => NodeEntry<Element> | undefined
end: (editor: Editor, at: Location) => Point
first: (editor: Editor, at: Location) => NodeEntry
fragment: (editor: Editor, at: Location) => Descendant[]
Expand All @@ -257,9 +271,11 @@ export interface EditorInterface {
isEditor: (value: any) => value is Editor
isEnd: (editor: Editor, point: Point, at: Location) => boolean
isEdge: (editor: Editor, point: Point, at: Location) => boolean
isElementReadOnly: (editor: Editor, element: Element) => boolean
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
Expand Down Expand Up @@ -509,6 +525,20 @@ export const Editor: EditorInterface = {
return [Editor.start(editor, at), Editor.end(editor, at)]
},

/**
* Match a read-only element in the current branch of the editor.
*/

elementReadOnly(
editor: Editor,
options: EditorElementReadOnlyOptions = {}
): NodeEntry<Element> | undefined {
return Editor.above(editor, {
...options,
match: n => Element.isElement(n) && Editor.isElementReadOnly(editor, n),
})
},

/**
* Get the end point of a location.
*/
Expand Down Expand Up @@ -646,7 +676,9 @@ export const Editor: EditorInterface = {
typeof value.insertFragment === 'function' &&
typeof value.insertNode === 'function' &&
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' &&
Expand Down Expand Up @@ -701,6 +733,14 @@ export const Editor: EditorInterface = {
return editor.isInline(value)
},

/**
* Check if a value is a read-only `Element` object.
*/

isElementReadOnly(editor: Editor, value: Element): boolean {
return editor.isElementReadOnly(value)
},

/**
* Check if the editor is currently normalizing after each operation.
*/
Expand All @@ -710,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.
*/
Expand Down Expand Up @@ -923,6 +971,7 @@ export const Editor: EditorInterface = {
universal = false,
reverse = false,
voids = false,
ignoreNonSelectable = false,
} = options
let { match } = options

Expand Down Expand Up @@ -951,14 +1000,32 @@ export const Editor: EditorInterface = {
reverse,
from,
to,
pass: ([n]) =>
voids ? false : Element.isElement(n) && Editor.isVoid(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<T>[] = []
let hit: NodeEntry<T> | undefined

for (const [node, path] of nodeEntries) {
if (
ignoreNonSelectable &&
Element.isElement(node) &&
!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.
Expand Down Expand Up @@ -1304,6 +1371,7 @@ export const Editor: EditorInterface = {
unit = 'offset',
reverse = false,
voids = false,
ignoreNonSelectable = false,
} = options

if (!at) {
Expand Down Expand Up @@ -1343,15 +1411,20 @@ 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
*/
if (Element.isElement(node)) {
// Void nodes are a special case, so by default we will always
// yield their first point. If the `voids` option is set to true,
// then we will iterate over their content.
if (!voids && editor.isVoid(node)) {
if (!voids && (editor.isVoid(node) || editor.isElementReadOnly(node))) {
yield Editor.start(editor, path)
continue
}
Expand Down
2 changes: 1 addition & 1 deletion packages/slate/src/transforms/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Range> = {}

if (edge == null || edge === 'anchor') {
Expand Down
28 changes: 18 additions & 10 deletions packages/slate/src/transforms/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,17 @@ export const TextTransforms: TextTransforms = {
const isAcrossBlocks =
startBlock && endBlock && !Path.equals(startBlock[1], endBlock[1])
const isSingleText = Path.equals(start.path, end.path)
const startVoid = voids
const startNonEditable = voids
? null
: Editor.void(editor, { at: start, mode: 'highest' })
const endVoid = voids
: Editor.void(editor, { at: start, mode: 'highest' }) ??
Editor.elementReadOnly(editor, { at: start, mode: 'highest' })
const endNonEditable = voids
? null
: Editor.void(editor, { at: end, mode: 'highest' })
: Editor.void(editor, { at: end, mode: 'highest' }) ??
Editor.elementReadOnly(editor, { at: end, mode: 'highest' })

// If the start or end points are inside an inline void, nudge them out.
if (startVoid) {
if (startNonEditable) {
const before = Editor.before(editor, start)

if (
Expand All @@ -140,7 +142,7 @@ export const TextTransforms: TextTransforms = {
}
}

if (endVoid) {
if (endNonEditable) {
const after = Editor.after(editor, end)

if (after && endBlock && Path.isAncestor(endBlock[1], after.path)) {
Expand All @@ -161,7 +163,10 @@ export const TextTransforms: TextTransforms = {
}

if (
(!voids && Element.isElement(node) && Editor.isVoid(editor, node)) ||
(!voids &&
Element.isElement(node) &&
(Editor.isVoid(editor, node) ||
Editor.isElementReadOnly(editor, node))) ||
(!Path.isCommon(path, start.path) && !Path.isCommon(path, end.path))
) {
matches.push(entry)
Expand All @@ -175,7 +180,7 @@ export const TextTransforms: TextTransforms = {

let removedText = ''

if (!isSingleText && !startVoid) {
if (!isSingleText && !startNonEditable) {
const point = startRef.current!
const [node] = Editor.leaf(editor, point)
const { path } = point
Expand All @@ -193,7 +198,7 @@ export const TextTransforms: TextTransforms = {
.filter((r): r is Path => r !== null)
.forEach(p => Transforms.removeNodes(editor, { at: p, voids }))

if (!endVoid) {
if (!endNonEditable) {
const point = endRef.current!
const [node] = Editor.leaf(editor, point)
const { path } = point
Expand Down Expand Up @@ -516,7 +521,10 @@ export const TextTransforms: TextTransforms = {
}
}

if (!voids && Editor.void(editor, { at })) {
if (
(!voids && Editor.void(editor, { at })) ||
Editor.elementReadOnly(editor, { at })
) {
return
}

Expand Down
8 changes: 7 additions & 1 deletion packages/slate/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,19 @@ describe('slate', () => {
})
})
const withTest = editor => {
const { isInline, isVoid } = editor
const { isInline, isVoid, isElementReadOnly, isSelectable } = editor
editor.isInline = element => {
return element.inline === true ? true : isInline(element)
}
editor.isVoid = element => {
return element.void === true ? true : isVoid(element)
}
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({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/** @jsx jsx */

import { Editor } from 'slate'
import { jsx } from '../../..'

export const input = (
<editor>
<block>
one<inline nonSelectable>two</inline>three
</block>
</editor>
)

export const test = editor => {
return Editor.after(
editor,
{ path: [0, 0], offset: 3 },
{ ignoreNonSelectable: true }
)
}

export const output = { path: [0, 2], offset: 0 }
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/** @jsx jsx */

import { Editor } from 'slate'
import { jsx } from '../../..'

export const input = (
<editor>
<block>
one<inline nonSelectable>two</inline>three
</block>
</editor>
)

export const test = editor => {
return Editor.before(
editor,
{ path: [0, 2], offset: 0 },
{ ignoreNonSelectable: true }
)
}

export const output = { path: [0, 0], offset: 3 }
Loading

0 comments on commit b52e08b

Please sign in to comment.