1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-21 22:45:18 +02:00

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

* 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`
This commit is contained in:
Joe Anderson
2023-04-03 19:02:26 +01:00
committed by GitHub
parent 26ace0db28
commit b52e08b0ea
22 changed files with 416 additions and 20 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -30,7 +30,7 @@ export const jsx = createHyperscript({
}) })
const withTest = editor => { const withTest = editor => {
const { isInline, isVoid } = editor const { isInline, isVoid, isElementReadOnly, isSelectable } = editor
editor.isInline = element => { editor.isInline = element => {
return element.inline === true ? true : isInline(element) return element.inline === true ? true : isInline(element)
@@ -40,5 +40,13 @@ const withTest = editor => {
return element.void === true ? true : 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 return editor
} }

View File

@@ -25,7 +25,9 @@ export const createEditor = (): Editor => {
operations: [], operations: [],
selection: null, selection: null,
marks: null, marks: null,
isElementReadOnly: () => false,
isInline: () => false, isInline: () => false,
isSelectable: () => true,
isVoid: () => false, isVoid: () => false,
markableVoid: () => false, markableVoid: () => false,
onChange: () => {}, onChange: () => {},

View File

@@ -59,7 +59,9 @@ export interface BaseEditor {
marks: EditorMarks | null marks: EditorMarks | null
// Schema-specific node behaviors. // Schema-specific node behaviors.
isElementReadOnly: (element: Element) => boolean
isInline: (element: Element) => boolean isInline: (element: Element) => boolean
isSelectable: (element: Element) => boolean
isVoid: (element: Element) => boolean isVoid: (element: Element) => boolean
markableVoid: (element: Element) => boolean markableVoid: (element: Element) => boolean
normalizeNode: (entry: NodeEntry, options?: { operation?: Operation }) => void normalizeNode: (entry: NodeEntry, options?: { operation?: Operation }) => void
@@ -116,6 +118,12 @@ export interface EditorDirectedDeletionOptions {
unit?: TextUnit unit?: TextUnit
} }
export interface EditorElementReadOnlyOptions {
at?: Location
mode?: MaximizeMode
voids?: boolean
}
export interface EditorFragmentDeletionOptions { export interface EditorFragmentDeletionOptions {
direction?: TextDirection direction?: TextDirection
} }
@@ -151,6 +159,7 @@ export interface EditorNodesOptions<T extends Node> {
universal?: boolean universal?: boolean
reverse?: boolean reverse?: boolean
voids?: boolean voids?: boolean
ignoreNonSelectable?: boolean
} }
export interface EditorNormalizeOptions { export interface EditorNormalizeOptions {
@@ -185,6 +194,7 @@ export interface EditorPositionsOptions {
unit?: TextUnitAdjustment unit?: TextUnitAdjustment
reverse?: boolean reverse?: boolean
voids?: boolean voids?: boolean
ignoreNonSelectable?: boolean
} }
export interface EditorPreviousOptions<T extends Node> { export interface EditorPreviousOptions<T extends Node> {
@@ -241,6 +251,10 @@ export interface EditorInterface {
options?: EditorFragmentDeletionOptions options?: EditorFragmentDeletionOptions
) => void ) => void
edges: (editor: Editor, at: Location) => [Point, Point] edges: (editor: Editor, at: Location) => [Point, Point]
elementReadOnly: (
editor: Editor,
options?: EditorElementReadOnlyOptions
) => NodeEntry<Element> | undefined
end: (editor: Editor, at: Location) => Point end: (editor: Editor, at: Location) => Point
first: (editor: Editor, at: Location) => NodeEntry first: (editor: Editor, at: Location) => NodeEntry
fragment: (editor: Editor, at: Location) => Descendant[] fragment: (editor: Editor, at: Location) => Descendant[]
@@ -257,9 +271,11 @@ export interface EditorInterface {
isEditor: (value: any) => value is Editor isEditor: (value: any) => value is Editor
isEnd: (editor: Editor, point: Point, at: Location) => boolean isEnd: (editor: Editor, point: Point, at: Location) => boolean
isEdge: (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 isEmpty: (editor: Editor, element: Element) => boolean
isInline: (editor: Editor, value: Element) => boolean isInline: (editor: Editor, value: Element) => boolean
isNormalizing: (editor: Editor) => boolean isNormalizing: (editor: Editor) => boolean
isSelectable: (editor: Editor, element: Element) => boolean
isStart: (editor: Editor, point: Point, at: Location) => boolean isStart: (editor: Editor, point: Point, at: Location) => boolean
isVoid: (editor: Editor, value: Element) => boolean isVoid: (editor: Editor, value: Element) => boolean
last: (editor: Editor, at: Location) => NodeEntry last: (editor: Editor, at: Location) => NodeEntry
@@ -509,6 +525,20 @@ export const Editor: EditorInterface = {
return [Editor.start(editor, at), Editor.end(editor, at)] 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. * Get the end point of a location.
*/ */
@@ -646,7 +676,9 @@ export const Editor: EditorInterface = {
typeof value.insertFragment === 'function' && typeof value.insertFragment === 'function' &&
typeof value.insertNode === 'function' && typeof value.insertNode === 'function' &&
typeof value.insertText === 'function' && typeof value.insertText === 'function' &&
typeof value.isElementReadOnly === 'function' &&
typeof value.isInline === 'function' && typeof value.isInline === 'function' &&
typeof value.isSelectable === 'function' &&
typeof value.isVoid === 'function' && typeof value.isVoid === 'function' &&
typeof value.normalizeNode === 'function' && typeof value.normalizeNode === 'function' &&
typeof value.onChange === 'function' && typeof value.onChange === 'function' &&
@@ -701,6 +733,14 @@ export const Editor: EditorInterface = {
return editor.isInline(value) 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. * Check if the editor is currently normalizing after each operation.
*/ */
@@ -710,6 +750,14 @@ export const Editor: EditorInterface = {
return isNormalizing === undefined ? true : isNormalizing 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. * Check if a point is the start point of a location.
*/ */
@@ -923,6 +971,7 @@ export const Editor: EditorInterface = {
universal = false, universal = false,
reverse = false, reverse = false,
voids = false, voids = false,
ignoreNonSelectable = false,
} = options } = options
let { match } = options let { match } = options
@@ -951,14 +1000,32 @@ export const Editor: EditorInterface = {
reverse, reverse,
from, from,
to, to,
pass: ([n]) => pass: ([node]) => {
voids ? false : Element.isElement(n) && Editor.isVoid(editor, n), 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>[] = [] const matches: NodeEntry<T>[] = []
let hit: NodeEntry<T> | undefined let hit: NodeEntry<T> | undefined
for (const [node, path] of nodeEntries) { 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 const isLower = hit && Path.compare(path, hit[1]) === 0
// In highest mode any node lower than the last hit is not a match. // In highest mode any node lower than the last hit is not a match.
@@ -1304,6 +1371,7 @@ export const Editor: EditorInterface = {
unit = 'offset', unit = 'offset',
reverse = false, reverse = false,
voids = false, voids = false,
ignoreNonSelectable = false,
} = options } = options
if (!at) { if (!at) {
@@ -1343,7 +1411,12 @@ export const Editor: EditorInterface = {
// encounter the block node, then all of its text nodes, so when iterating // 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 // through the blockText and leafText we just need to remember a window of
// one block node and leaf node, respectively. // 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 * ELEMENT NODE - Yield position(s) for voids, collect blockText for blocks
*/ */
@@ -1351,7 +1424,7 @@ export const Editor: EditorInterface = {
// Void nodes are a special case, so by default we will always // Void nodes are a special case, so by default we will always
// yield their first point. If the `voids` option is set to true, // yield their first point. If the `voids` option is set to true,
// then we will iterate over their content. // 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) yield Editor.start(editor, path)
continue continue
} }

View File

@@ -92,7 +92,7 @@ export const SelectionTransforms: SelectionTransforms = {
} }
const { anchor, focus } = selection const { anchor, focus } = selection
const opts = { distance, unit } const opts = { distance, unit, ignoreNonSelectable: true }
const props: Partial<Range> = {} const props: Partial<Range> = {}
if (edge == null || edge === 'anchor') { if (edge == null || edge === 'anchor') {

View File

@@ -120,15 +120,17 @@ export const TextTransforms: TextTransforms = {
const isAcrossBlocks = const isAcrossBlocks =
startBlock && endBlock && !Path.equals(startBlock[1], endBlock[1]) startBlock && endBlock && !Path.equals(startBlock[1], endBlock[1])
const isSingleText = Path.equals(start.path, end.path) const isSingleText = Path.equals(start.path, end.path)
const startVoid = voids const startNonEditable = voids
? null ? null
: Editor.void(editor, { at: start, mode: 'highest' }) : Editor.void(editor, { at: start, mode: 'highest' }) ??
const endVoid = voids Editor.elementReadOnly(editor, { at: start, mode: 'highest' })
const endNonEditable = voids
? null ? 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 the start or end points are inside an inline void, nudge them out.
if (startVoid) { if (startNonEditable) {
const before = Editor.before(editor, start) const before = Editor.before(editor, start)
if ( if (
@@ -140,7 +142,7 @@ export const TextTransforms: TextTransforms = {
} }
} }
if (endVoid) { if (endNonEditable) {
const after = Editor.after(editor, end) const after = Editor.after(editor, end)
if (after && endBlock && Path.isAncestor(endBlock[1], after.path)) { if (after && endBlock && Path.isAncestor(endBlock[1], after.path)) {
@@ -161,7 +163,10 @@ export const TextTransforms: TextTransforms = {
} }
if ( 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)) (!Path.isCommon(path, start.path) && !Path.isCommon(path, end.path))
) { ) {
matches.push(entry) matches.push(entry)
@@ -175,7 +180,7 @@ export const TextTransforms: TextTransforms = {
let removedText = '' let removedText = ''
if (!isSingleText && !startVoid) { if (!isSingleText && !startNonEditable) {
const point = startRef.current! const point = startRef.current!
const [node] = Editor.leaf(editor, point) const [node] = Editor.leaf(editor, point)
const { path } = point const { path } = point
@@ -193,7 +198,7 @@ export const TextTransforms: TextTransforms = {
.filter((r): r is Path => r !== null) .filter((r): r is Path => r !== null)
.forEach(p => Transforms.removeNodes(editor, { at: p, voids })) .forEach(p => Transforms.removeNodes(editor, { at: p, voids }))
if (!endVoid) { if (!endNonEditable) {
const point = endRef.current! const point = endRef.current!
const [node] = Editor.leaf(editor, point) const [node] = Editor.leaf(editor, point)
const { path } = point const { path } = point
@@ -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 return
} }

View File

@@ -47,13 +47,19 @@ describe('slate', () => {
}) })
}) })
const withTest = editor => { const withTest = editor => {
const { isInline, isVoid } = editor const { isInline, isVoid, isElementReadOnly, isSelectable } = editor
editor.isInline = element => { editor.isInline = element => {
return element.inline === true ? true : isInline(element) return element.inline === true ? true : isInline(element)
} }
editor.isVoid = element => { editor.isVoid = element => {
return element.void === true ? true : 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 return editor
} }
export const jsx = createHyperscript({ export const jsx = createHyperscript({

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -0,0 +1,19 @@
/** @jsx jsx */
import { Editor, Text } from 'slate'
import { jsx } from '../../../..'
export const input = (
<editor>
<block nonSelectable>one</block>
</editor>
)
export const test = editor => {
return Array.from(
Editor.nodes(editor, {
at: [],
match: Text.isText,
ignoreNonSelectable: true,
})
)
}
export const output = []

View File

@@ -0,0 +1,20 @@
/** @jsx jsx */
import { Editor, Text } from 'slate'
import { jsx } from '../../../..'
export const input = (
<editor>
<block>
one<inline nonSelectable>two</inline>three
</block>
</editor>
)
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]],
]

View File

@@ -0,0 +1,15 @@
/** @jsx jsx */
import { Editor } from 'slate'
import { jsx } from '../../../..'
export const input = (
<editor>
<block nonSelectable>one</block>
</editor>
)
export const test = editor => {
return Array.from(
Editor.positions(editor, { at: [], ignoreNonSelectable: true })
)
}
export const output = []

View File

@@ -0,0 +1,28 @@
/** @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 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, 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 },
]

View File

@@ -15,7 +15,9 @@ export const input = {
insertFragment() {}, insertFragment() {},
insertNode() {}, insertNode() {},
insertText() {}, insertText() {},
isElementReadOnly() {},
isInline() {}, isInline() {},
isSelectable() {},
isVoid() {}, isVoid() {},
normalizeNode() {}, normalizeNode() {},
onChange() {}, onChange() {},

View File

@@ -16,7 +16,9 @@ export const input = [
insertFragment() {}, insertFragment() {},
insertNode() {}, insertNode() {},
insertText() {}, insertText() {},
isElementReadOnly() {},
isInline() {}, isInline() {},
isSelectable() {},
isVoid() {}, isVoid() {},
normalizeNode() {}, normalizeNode() {},
onChange() {}, onChange() {},

View File

@@ -0,0 +1,25 @@
/** @jsx jsx */
import { Transforms } from 'slate'
import { jsx } from '../../..'
export const run = editor => {
Transforms.delete(editor, { reverse: true })
}
export const input = (
<editor>
<block>
<text />
<inline readOnly>read-only inline</inline>
<cursor />
</block>
</editor>
)
export const output = (
<editor>
<block>
<text>
<cursor />
</text>
</block>
</editor>
)

View File

@@ -0,0 +1,27 @@
/** @jsx jsx */
import { Transforms } from 'slate'
import { jsx } from '../../..'
export const run = editor => {
Transforms.delete(editor, { reverse: true })
}
export const input = (
<editor>
<block>
<text />
<inline readOnly>
read-only <cursor />
inline
</inline>
</block>
</editor>
)
export const output = (
<editor>
<block>
<text>
<cursor />
</text>
</block>
</editor>
)

View File

@@ -0,0 +1,29 @@
/** @jsx jsx */
import { Transforms } from 'slate'
import { jsx } from '../../..'
export const input = (
<editor>
<block>
<text />
<inline readOnly>
read-only <cursor />
inline
</inline>
</block>
</editor>
)
export const run = editor => {
Transforms.insertText(editor, 'x')
}
export const output = (
<editor>
<block>
<text />
<inline readOnly>
read-only <cursor />
inline
</inline>
</block>
</editor>
)

View File

@@ -14,4 +14,32 @@ test.describe('Inlines example', () => {
.innerText() .innerText()
).toContain('hyperlink') ).toContain('hyperlink')
}) })
test('arrow keys skip over read-only inline', async ({ page }) => {
const badge = await page.locator('text=Approved >> xpath=../../..')
// Put cursor after the badge
await badge.evaluate(badgeElement => {
const range = document.createRange()
range.setStartAfter(badgeElement, 0)
range.setEndAfter(badgeElement, 0)
const selection = window.getSelection()
selection.removeAllRanges()
selection.addRange(range)
})
const getSelectionContainerText = () =>
page.evaluate(() => {
const selection = window.getSelection()
return selection.anchorNode.parentNode.innerText
})
expect(await getSelectionContainerText()).toBe('.')
await page.keyboard.press('ArrowLeft')
expect(await getSelectionContainerText()).toBe(
'! Here is a read-only inline: '
)
await page.keyboard.press('ArrowRight')
expect(await getSelectionContainerText()).toBe('.')
})
}) })

View File

@@ -47,6 +47,8 @@ export type LinkElement = { type: 'link'; url: string; children: Descendant[] }
export type ButtonElement = { type: 'button'; children: Descendant[] } export type ButtonElement = { type: 'button'; children: Descendant[] }
export type BadgeElement = { type: 'badge'; children: Descendant[] }
export type ListItemElement = { type: 'list-item'; children: Descendant[] } export type ListItemElement = { type: 'list-item'; children: Descendant[] }
export type MentionElement = { export type MentionElement = {
@@ -92,6 +94,7 @@ type CustomElement =
| ImageElement | ImageElement
| LinkElement | LinkElement
| ButtonElement | ButtonElement
| BadgeElement
| ListItemElement | ListItemElement
| MentionElement | MentionElement
| ParagraphElement | ParagraphElement

View File

@@ -38,7 +38,14 @@ const initialValue: Descendant[] = [
children: [{ text: 'editable button' }], children: [{ text: 'editable button' }],
}, },
{ {
text: '!', text: '! Here is a read-only inline: ',
},
{
type: 'badge',
children: [{ text: 'Approved' }],
},
{
text: '.',
}, },
], ],
}, },
@@ -108,10 +115,22 @@ const InlinesExample = () => {
} }
const withInlines = editor => { const withInlines = editor => {
const { insertData, insertText, isInline } = editor const {
insertData,
insertText,
isInline,
isElementReadOnly,
isSelectable,
} = editor
editor.isInline = element => editor.isInline = element =>
['link', 'button'].includes(element.type) || isInline(element) ['link', 'button', 'badge'].includes(element.type) || isInline(element)
editor.isElementReadOnly = element =>
element.type === 'badge' || isElementReadOnly(element)
editor.isSelectable = element =>
element.type !== 'badge' && isSelectable(element)
editor.insertText = text => { editor.insertText = text => {
if (text && isUrl(text)) { if (text && isUrl(text)) {
@@ -283,6 +302,30 @@ const EditableButtonComponent = ({ attributes, children }) => {
) )
} }
const BadgeComponent = ({ attributes, children, element }) => {
const selected = useSelected()
return (
<span
{...attributes}
contentEditable={false}
className={css`
background-color: green;
color: white;
padding: 2px 6px;
border-radius: 2px;
font-size: 0.9em;
${selected && 'box-shadow: 0 0 0 3px #ddd;'}
`}
data-playwright-selected={selected}
>
<InlineChromiumBugfix />
{children}
<InlineChromiumBugfix />
</span>
)
}
const Element = props => { const Element = props => {
const { attributes, children, element } = props const { attributes, children, element } = props
switch (element.type) { switch (element.type) {
@@ -290,6 +333,8 @@ const Element = props => {
return <LinkComponent {...props} /> return <LinkComponent {...props} />
case 'button': case 'button':
return <EditableButtonComponent {...props} /> return <EditableButtonComponent {...props} />
case 'badge':
return <BadgeComponent {...props} />
default: default:
return <p {...attributes}>{children}</p> return <p {...attributes}>{children}</p>
} }