Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for read-only and non-selectable elements #5374

Merged
merged 3 commits into from
Apr 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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