Skip to content

Commit

Permalink
feat(portable-text-editor): support for range decorators
Browse files Browse the repository at this point in the history
This will add support for decorating selections inside the Portable Text Editor with custom components.
This can be used for search highlighting, validation etc.
  • Loading branch information
skogsmaskin committed Oct 31, 2023
1 parent 829f541 commit 42b0fad
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 46 deletions.
141 changes: 101 additions & 40 deletions packages/@sanity/portable-text-editor/src/editor/Editable.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
import {BaseRange, Transforms, Text} from 'slate'
import React, {useCallback, useMemo, useEffect, forwardRef, useState, KeyboardEvent} from 'react'
import {BaseRange, Transforms, Text, NodeEntry, Range as SlateRange} from 'slate'
import React, {
forwardRef,
KeyboardEvent,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import {
Editable as SlateEditable,
ReactEditor,
RenderElementProps,
RenderLeafProps,
useSlate,
} from 'slate-react'
import {noop} from 'lodash'
import {flatten, noop} from 'lodash'
import {PortableTextBlock} from '@sanity/types'
import {
EditorChange,
EditorSelection,
OnCopyFn,
OnPasteFn,
OnPasteResult,
PortableTextSlateEditor,
RangeDecoration,
RenderAnnotationFunction,
RenderBlockFunction,
RenderChildFunction,
Expand Down Expand Up @@ -59,6 +68,7 @@ export type PortableTextEditableProps = Omit<
onBeforeInput?: (event: InputEvent) => void
onPaste?: OnPasteFn
onCopy?: OnCopyFn
rangeDecorations?: RangeDecoration[]
renderAnnotation?: RenderAnnotationFunction
renderBlock?: RenderBlockFunction
renderChild?: RenderChildFunction
Expand Down Expand Up @@ -86,6 +96,7 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
onBeforeInput,
onPaste,
onCopy,
rangeDecorations,
renderAnnotation,
renderBlock,
renderChild,
Expand Down Expand Up @@ -149,28 +160,39 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
)

const renderLeaf = useCallback(
(lProps: RenderLeafProps & {leaf: Text & {placeholder?: boolean}}) => {
const rendered = (
<Leaf
{...lProps}
schemaTypes={schemaTypes}
renderAnnotation={renderAnnotation}
renderChild={renderChild}
renderDecorator={renderDecorator}
readOnly={readOnly}
/>
)
if (renderPlaceholder && lProps.leaf.placeholder && lProps.text.text === '') {
return (
<>
<span style={PLACEHOLDER_STYLE} contentEditable={false}>
{renderPlaceholder()}
</span>
{rendered}
</>
(
lProps: RenderLeafProps & {
leaf: Text & {placeholder?: boolean; rangeDecoration?: RangeDecoration}
},
) => {
if (lProps.leaf._type === 'span') {
let rendered = (
<Leaf
{...lProps}
schemaTypes={schemaTypes}
renderAnnotation={renderAnnotation}
renderChild={renderChild}
renderDecorator={renderDecorator}
readOnly={readOnly}
/>
)
if (renderPlaceholder && lProps.leaf.placeholder && lProps.text.text === '') {
return (
<>
<span style={PLACEHOLDER_STYLE} contentEditable={false}>
{renderPlaceholder()}
</span>
{rendered}
</>
)
}
const decoration = lProps.leaf.rangeDecoration
if (decoration) {
rendered = decoration.component({children: rendered})
}
return rendered
}
return rendered
return lProps.children
},
[readOnly, renderAnnotation, renderChild, renderDecorator, renderPlaceholder, schemaTypes],
)
Expand Down Expand Up @@ -393,24 +415,34 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
}
}, [portableTextEditor, scrollSelectionIntoView])

const decorate = useCallback(() => {
if (isEqualToEmptyEditor(slateEditor.children, schemaTypes)) {
return [
{
anchor: {
path: [0, 0],
offset: 0,
const decorate: (entry: NodeEntry) => BaseRange[] = useCallback(
([node, path]) => {
if (isEqualToEmptyEditor(slateEditor.children, schemaTypes)) {
return [
{
anchor: {
path: [0, 0],
offset: 0,
},
focus: {
path: [0, 0],
offset: 0,
},
placeholder: true,
},
focus: {
path: [0, 0],
offset: 0,
},
placeholder: true,
},
]
}
return EMPTY_DECORATORS
}, [schemaTypes, slateEditor])
]
}
return rangeDecorations && rangeDecorations.length
? getChildNodeToRangeDecorations({
slateEditor,
portableTextEditor,
rangeDecorations,
nodeEntry: [node, path],
})
: EMPTY_DECORATORS
},
[slateEditor, schemaTypes, portableTextEditor, rangeDecorations],
)

// Set the forwarded ref to be the Slate editable DOM element
useEffect(() => {
Expand Down Expand Up @@ -442,3 +474,32 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
/>
)
})

const getChildNodeToRangeDecorations = ({
rangeDecorations = [],
nodeEntry,
slateEditor,
portableTextEditor,
}: {
rangeDecorations: RangeDecoration[]
nodeEntry: NodeEntry
slateEditor: PortableTextSlateEditor
portableTextEditor: PortableTextEditor
}): SlateRange[] => {
if (rangeDecorations.length === 0) {
return EMPTY_DECORATORS
}
const [, path] = nodeEntry
return flatten(
rangeDecorations.map((decoration) => {
const slateRange = toSlateRange(decoration.selection, slateEditor)
if (decoration.isRangeInvalid(portableTextEditor)) {
return EMPTY_DECORATORS
}
if (slateRange && SlateRange.includes(slateRange, path) && path.length > 0) {
return {...slateRange, rangeDecoration: decoration}
}
return EMPTY_DECORATORS
}),
)
}
35 changes: 34 additions & 1 deletion packages/@sanity/portable-text-editor/src/types/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export interface EditableAPI {
blur: () => void
delete: (selection: EditorSelection, options?: EditableAPIDeleteOptions) => void
findByPath: (path: Path) => [PortableTextBlock | PortableTextChild | undefined, Path | undefined]
findDOMNode: (element: PortableTextBlock | PortableTextChild) => Node | undefined
findDOMNode: (element: PortableTextBlock | PortableTextChild) => DOMNode | undefined
focus: () => void
focusBlock: () => PortableTextBlock | undefined
focusChild: () => PortableTextChild | undefined
Expand Down Expand Up @@ -96,6 +96,7 @@ export interface PortableTextSlateEditor extends ReactEditor {
isTextSpan: (value: unknown) => value is PortableTextSpan
isListBlock: (value: unknown) => value is PortableTextListBlock
subscriptions: (() => () => void)[]
nodeToRangeDecorations?: Map<Node, Range[]>

/**
* Increments selected list items levels, or decrements them if `reverse` is true.
Expand Down Expand Up @@ -481,6 +482,38 @@ export type ScrollSelectionIntoViewFunction = (
domRange: globalThis.Range,
) => void

/**
* A range decoration is a UI affordance that wraps a given selection range in the editor
* with a custom component. This can be used to highlight search results,
* mark validation errors on specific words, draw user presence and similar.
* @alpha */
export interface RangeDecoration {
/**
* A component for rendering the range decoration.
* This component takes only children, and you could render
* your own component with own props by wrapping those children.
*
* @example
* ```ts
* (rangeComponentProps: PropsWithChildren) => (
* <BlackListHighlighter {...location}>
* {rangeComponentProps.children}
* </BlackListHighlighter>
* )
* ```
*/
component: (props: PropsWithChildren) => ReactElement
/**
* A function that will can tell if the range has become invalid.
* The range will not be rendered when you return `true` from this function.
*/
isRangeInvalid: (editor: PortableTextEditor) => boolean
/**
* The editor content selection range
*/
selection: EditorSelection
}

/** @internal */
export type PortableTextMemberSchemaTypes = {
annotations: ObjectSchemaType[]
Expand Down
21 changes: 17 additions & 4 deletions packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
BlockChildRenderProps as EditorChildRenderProps,
BlockAnnotationRenderProps,
EditorSelection,
RangeDecoration,
} from '@sanity/portable-text-editor'
import {Path, PortableTextBlock, PortableTextTextBlock} from '@sanity/types'
import {Box, Portal, PortalProvider, useBoundaryElement, usePortal} from '@sanity/ui'
Expand Down Expand Up @@ -35,6 +36,7 @@ interface InputProps extends ArrayOfObjectsInputProps<PortableTextBlock> {
onPaste?: OnPasteFn
onToggleFullscreen: () => void
path: Path
rangeDecorations?: RangeDecoration[]
renderBlockActions?: RenderBlockActionsCallback
renderCustomMarkers?: RenderCustomMarkers
}
Expand Down Expand Up @@ -62,6 +64,7 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
onToggleFullscreen,
path,
readOnly,
rangeDecorations,
renderAnnotation,
renderBlock,
renderBlockActions,
Expand Down Expand Up @@ -145,13 +148,12 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
[
_renderBlockActions,
_renderCustomMarkers,
scrollElement,
boundaryElement,
isFullscreen,
onItemClose,
onItemOpen,
onItemRemove,
onPathFocus,
boundaryElement,
path,
readOnly,
renderAnnotation,
Expand Down Expand Up @@ -277,14 +279,14 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
)
},
[
boundaryElement,
scrollElement,
editor.schemaTypes.span.name,
boundaryElement,
onItemClose,
onItemOpen,
onPathFocus,
path,
readOnly,
scrollElement,
renderAnnotation,
renderBlock,
renderCustomMarkers,
Expand Down Expand Up @@ -393,6 +395,7 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
onPaste={onPaste}
onToggleFullscreen={handleToggleFullscreen}
path={path}
rangeDecorations={rangeDecorations}
readOnly={readOnly}
renderAnnotation={editorRenderAnnotation}
renderBlock={editorRenderBlock}
Expand All @@ -407,6 +410,16 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
[
ariaDescribedBy,
editorHotkeys,
editorHotkeys,
isActive,
isFullscreen,
onItemOpen,
onCopy,
onPaste,
handleToggleFullscreen,
path,
rangeDecorations,
readOnly,
editorRenderAnnotation,
editorRenderBlock,
editorRenderChild,
Expand Down
5 changes: 5 additions & 0 deletions packages/sanity/src/core/form/inputs/PortableText/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
EditorSelection,
RenderStyleFunction,
RenderListItemFunction,
RangeDecoration,
} from '@sanity/portable-text-editor'
import {Path} from '@sanity/types'
import {BoundaryElementProvider, useBoundaryElement, useGlobalKeyDown, useLayer} from '@sanity/ui'
Expand Down Expand Up @@ -42,6 +43,7 @@ interface EditorProps {
onToggleFullscreen: () => void
path: Path
readOnly?: boolean
rangeDecorations?: RangeDecoration[]
renderAnnotation: RenderAnnotationFunction
renderBlock: RenderBlockFunction
renderChild: RenderChildFunction
Expand Down Expand Up @@ -77,6 +79,7 @@ export function Editor(props: EditorProps) {
onToggleFullscreen,
path,
readOnly,
rangeDecorations,
renderAnnotation,
renderBlock,
renderChild,
Expand Down Expand Up @@ -120,6 +123,7 @@ export function Editor(props: EditorProps) {
onCopy={onCopy}
onPaste={onPaste}
ref={editableRef}
rangeDecorations={rangeDecorations}
renderAnnotation={renderAnnotation}
renderBlock={renderBlock}
renderChild={renderChild}
Expand All @@ -139,6 +143,7 @@ export function Editor(props: EditorProps) {
initialSelection,
onCopy,
onPaste,
rangeDecorations,
renderAnnotation,
renderBlock,
renderChild,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,14 @@ export function PortableTextInput(props: PortableTextInputProps) {
members,
onChange,
onCopy,
onEditorChange,
onItemRemove,
onInsert,
onPaste,
onPathFocus,
path,
readOnly,
rangeDecorations,
renderBlockActions,
renderCustomMarkers,
schemaType,
Expand Down Expand Up @@ -340,6 +342,7 @@ export function PortableTextInput(props: PortableTextInputProps) {
onInsert={onInsert}
onPaste={onPaste}
onToggleFullscreen={handleToggleFullscreen}
rangeDecorations={rangeDecorations}
renderBlockActions={renderBlockActions}
renderCustomMarkers={renderCustomMarkers}
/>
Expand Down
Loading

0 comments on commit 42b0fad

Please sign in to comment.