1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-31 10:51:44 +02:00

Add format_text command, and editor.marks state (#3308)

* add format_text command, refactor command extensions

* update onChange to not receive selection

* update docs

* fix tests
This commit is contained in:
Ian Storm Taylor
2019-12-12 15:37:55 -05:00
committed by GitHub
parent ed40c08b80
commit 6552da940a
37 changed files with 350 additions and 647 deletions

View File

@@ -16,24 +16,14 @@ export const HistoryCommand = {
*/
isHistoryCommand(value: any): value is HistoryCommand {
return (
HistoryCommand.isRedoCommand(value) || HistoryCommand.isUndoCommand(value)
)
},
if (Command.isCommand(value)) {
switch (value.type) {
case 'redo':
case 'undo':
return true
}
}
/**
* Check if a value is a `RedoCommand` object.
*/
isRedoCommand(value: any): value is RedoCommand {
return Command.isCommand(value) && value.type === 'redo'
},
/**
* Check if a value is an `UndoCommand` object.
*/
isUndoCommand(value: any): value is UndoCommand {
return Command.isCommand(value) && value.type === 'undo'
return false
},
}

View File

@@ -13,11 +13,14 @@ export const withHistory = (editor: Editor): HistoryEditor => {
editor.history = { undos: [], redos: [] }
editor.exec = (command: Command) => {
if (HistoryEditor.isHistoryEditor(editor)) {
if (
HistoryEditor.isHistoryEditor(editor) &&
HistoryCommand.isHistoryCommand(command)
) {
const { history } = editor
const { undos, redos } = history
if (redos.length > 0 && HistoryCommand.isRedoCommand(command)) {
if (command.type === 'redo' && redos.length > 0) {
const batch = redos[redos.length - 1]
HistoryEditor.withoutSaving(editor, () => {
@@ -33,7 +36,7 @@ export const withHistory = (editor: Editor): HistoryEditor => {
return
}
if (undos.length > 0 && HistoryCommand.isUndoCommand(command)) {
if (command.type === 'undo' && undos.length > 0) {
const batch = undos[undos.length - 1]
HistoryEditor.withoutSaving(editor, () => {

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from 'react'
import React, { useMemo, useState, useCallback } from 'react'
import { Editor, Node, Range } from 'slate'
import { ReactEditor } from '../plugin/react-editor'
@@ -15,19 +15,24 @@ import { EDITOR_TO_ON_CHANGE } from '../utils/weak-maps'
export const Slate = (props: {
editor: Editor
value: Node[]
selection: Range | null
children: React.ReactNode
onChange: (children: Node[], selection: Range | null) => void
onChange: (value: Node[]) => void
[key: string]: any
}) => {
const { editor, children, onChange, value, selection, ...rest } = props
const { editor, children, onChange, value, ...rest } = props
const [key, setKey] = useState(0)
const context: [Editor] = useMemo(() => {
editor.children = value
editor.selection = selection
Object.assign(editor, rest)
return [editor]
}, [value, selection, ...Object.values(rest)])
}, [key, value, ...Object.values(rest)])
EDITOR_TO_ON_CHANGE.set(editor, onChange)
const onContextChange = useCallback(() => {
onChange(editor.children)
setKey(key + 1)
}, [key, onChange])
EDITOR_TO_ON_CHANGE.set(editor, onContextChange)
return (
<SlateContext.Provider value={context}>

View File

@@ -16,23 +16,18 @@ export interface InsertDataCommand {
export type ReactCommand = InsertDataCommand
export const ReactCommand = {
/**
* Check if a value is an `InsertDataCommand` object.
*/
isInsertDataCommand(value: any): value is InsertDataCommand {
return (
Command.isCommand(value) &&
value.type === 'insert_data' &&
value.data instanceof DataTransfer
)
},
/**
* Check if a value is a `ReactCommand` object.
*/
isReactCommand(value: any): value is InsertDataCommand {
return ReactCommand.isInsertDataCommand(value)
if (Command.isCommand(value)) {
switch (value.type) {
case 'insert_data':
return value.data instanceof DataTransfer
}
}
return false
},
}

View File

@@ -57,7 +57,10 @@ export const withReact = (editor: Editor): Editor => {
}
editor.exec = (command: Command) => {
if (ReactCommand.isInsertDataCommand(command)) {
if (
ReactCommand.isReactCommand(command) &&
command.type === 'insert_data'
) {
const { data } = command
const fragment = data.getData('application/x-slate-fragment')
@@ -94,11 +97,10 @@ export const withReact = (editor: Editor): Editor => {
// have to use this unstable API to ensure it batches them. (2019/12/03)
// https://github.com/facebook/react/issues/14259#issuecomment-439702367
ReactDOM.unstable_batchedUpdates(() => {
const contextOnChange = EDITOR_TO_ON_CHANGE.get(editor)
const onContextChange = EDITOR_TO_ON_CHANGE.get(editor)
if (contextOnChange) {
const { children, selection } = editor
contextOnChange(children, selection)
if (onContextChange) {
onContextChange()
}
onChange()

View File

@@ -32,13 +32,10 @@ export const IS_DRAGGING: WeakMap<Editor, boolean> = new WeakMap()
export const IS_CLICKING: WeakMap<Editor, boolean> = new WeakMap()
/**
* Weak map for associating the context `onChange` prop with the plugin.
* Weak map for associating the context `onChange` context with the plugin.
*/
export const EDITOR_TO_ON_CHANGE = new WeakMap<
Editor,
(children: Node[], selection: Range | null) => void
>()
export const EDITOR_TO_ON_CHANGE = new WeakMap<Editor, () => void>()
/**
* Symbols.

View File

@@ -1,5 +1,6 @@
import {
Command,
CoreCommand,
Descendant,
Editor,
Element,
@@ -26,6 +27,7 @@ export const createEditor = (): Editor => {
children: [],
operations: [],
selection: null,
marks: null,
isInline: () => false,
isVoid: () => false,
onChange: () => {},
@@ -73,6 +75,11 @@ export const createEditor = (): Editor => {
editor.operations.push(op)
Editor.normalize(editor)
// Clear any formats applied to the cursor if the selection changes.
if (op.type === 'set_selection') {
editor.marks = null
}
if (!FLUSHING.get(editor)) {
FLUSHING.set(editor, true)
@@ -84,9 +91,9 @@ export const createEditor = (): Editor => {
}
},
exec: (command: Command) => {
const { selection } = editor
if (CoreCommand.isCoreCommand(command)) {
const { selection } = editor
if (Command.isCoreCommand(command)) {
switch (command.type) {
case 'delete_backward': {
if (selection && Range.isCollapsed(selection)) {
@@ -112,6 +119,56 @@ export const createEditor = (): Editor => {
break
}
case 'format_text': {
if (selection) {
const { properties } = command
if (Range.isExpanded(selection)) {
const [match] = Editor.nodes(editor, {
at: selection,
match: n => Text.isText(n) && Text.matches(n, properties),
// TODO: should be `mode: 'universal'`
})
if (match) {
const keys = Object.keys(properties)
Editor.unsetNodes(editor, keys, {
match: 'text',
split: true,
})
} else {
Editor.setNodes(editor, properties, {
match: 'text',
split: true,
})
}
} else {
const marks = { ...(Editor.marks(editor) || {}) }
let match = true
for (const key in properties) {
if (marks[key] !== properties[key]) {
match = false
break
}
}
if (match) {
for (const key in properties) {
delete marks[key]
}
} else {
Object.assign(marks, properties)
}
editor.marks = marks
editor.onChange()
}
}
break
}
case 'insert_break': {
Editor.splitNodes(editor, { always: true })
break
@@ -123,7 +180,7 @@ export const createEditor = (): Editor => {
}
case 'insert_node': {
Editor.insertNodes(editor, [command.node])
Editor.insertNodes(editor, command.node)
break
}
@@ -131,8 +188,8 @@ export const createEditor = (): Editor => {
if (selection) {
const { anchor } = selection
// If the cursor is at the end of an inline, move it outside
// of the inline before inserting
// If the cursor is at the end of an inline, move it outside of
// the inline before inserting
if (Range.isCollapsed(selection)) {
const inline = Editor.match(editor, anchor, 'inline')
@@ -146,7 +203,17 @@ export const createEditor = (): Editor => {
}
}
Editor.insertText(editor, command.text)
const { marks } = editor
const { text } = command
if (marks) {
const node = { text, ...marks }
Editor.insertNodes(editor, node)
} else {
Editor.insertText(editor, text)
}
editor.marks = null
}
break
}

View File

@@ -19,97 +19,6 @@ export const Command = {
isCommand(value: any): value is Command {
return isPlainObject(value) && typeof value.type === 'string'
},
/**
* Check if a value is a `CoreCommand` object.
*/
isCoreCommand(value: any): value is CoreCommand {
return (
Command.isDeleteBackwardCommand(value) ||
Command.isDeleteForwardCommand(value) ||
Command.isDeleteFragmentCommand(value) ||
Command.isInsertTextCommand(value) ||
Command.isInsertFragmentCommand(value) ||
Command.isInsertBreakCommand(value)
)
},
/**
* Check if a value is a `DeleteBackwardCommand` object.
*/
isDeleteBackwardCommand(value: any): value is DeleteBackwardCommand {
return (
Command.isCommand(value) &&
value.type === 'delete_backward' &&
typeof value.unit === 'string'
)
},
/**
* Check if a value is a `DeleteForwardCommand` object.
*/
isDeleteForwardCommand(value: any): value is DeleteForwardCommand {
return (
Command.isCommand(value) &&
value.type === 'delete_forward' &&
typeof value.unit === 'string'
)
},
/**
* Check if a value is a `DeleteFragmentCommand` object.
*/
isDeleteFragmentCommand(value: any): value is DeleteFragmentCommand {
return Command.isCommand(value) && value.type === 'delete_fragment'
},
/**
* Check if a value is an `InsertBreakCommand` object.
*/
isInsertBreakCommand(value: any): value is InsertBreakCommand {
return Command.isCommand(value) && value.type === 'insert_break'
},
/**
* Check if a value is an `InsertFragmentCommand` object.
*/
isInsertFragmentCommand(value: any): value is InsertFragmentCommand {
return (
Command.isCommand(value) &&
value.type === 'insert_fragment' &&
Node.isNodeList(value.fragment)
)
},
/**
* Check if a value is an `InsertNodeCommand` object.
*/
isInsertNodeCommand(value: any): value is InsertNodeCommand {
return (
Command.isCommand(value) &&
value.type === 'insert_node' &&
Node.isNode(value.node)
)
},
/**
* Check if a value is a `InsertTextCommand` object.
*/
isInsertTextCommand(value: any): value is InsertTextCommand {
return (
Command.isCommand(value) &&
value.type === 'insert_text' &&
typeof value.text === 'string'
)
},
}
/**
@@ -140,6 +49,15 @@ export interface DeleteFragmentCommand {
type: 'delete_fragment'
}
/**
* The `FormatTextCommand` adds properties to the text nodes in the selection.
*/
export interface FormatTextCommand {
type: 'format_text'
properties: Record<string, any>
}
/**
* The `InsertBreakCommand` breaks a block in two at the current selection.
*/
@@ -184,7 +102,39 @@ export type CoreCommand =
| DeleteBackwardCommand
| DeleteForwardCommand
| DeleteFragmentCommand
| FormatTextCommand
| InsertBreakCommand
| InsertFragmentCommand
| InsertNodeCommand
| InsertTextCommand
export const CoreCommand = {
/**
* Check if a value is a `CoreCommand` object.
*/
isCoreCommand(value: any): value is CoreCommand {
if (Command.isCommand(value)) {
switch (value.type) {
case 'delete_backward':
return typeof value.unit === 'string'
case 'delete_forward':
return typeof value.unit === 'string'
case 'delete_fragment':
return true
case 'format_text':
return isPlainObject(value.properties)
case 'insert_break':
return true
case 'insert_fragment':
return Node.isNodeList(value.fragment)
case 'insert_node':
return Node.isNode(value.node)
case 'insert_text':
return typeof value.text === 'string'
}
}
return false
},
}

View File

@@ -16,15 +16,16 @@ import { TextTransforms } from './transforms/text'
*/
export interface Editor {
apply: (operation: Operation) => void
children: Node[]
selection: Range | null
operations: Operation[]
marks: Record<string, any> | null
apply: (operation: Operation) => void
exec: (command: Command) => void
isInline: (element: Element) => boolean
isVoid: (element: Element) => boolean
normalizeNode: (entry: NodeEntry) => void
onChange: () => void
operations: Operation[]
selection: Range | null
[key: string]: any
}

View File

@@ -4,12 +4,14 @@ import {
Operation,
Path,
Point,
Text,
PathRef,
PointRef,
Range,
RangeRef,
Node,
} from '../../..'
import { TextEntry } from '../../text'
export const NORMALIZING: WeakMap<Editor, boolean> = new WeakMap()
export const PATH_REFS: WeakMap<Editor, Set<PathRef>> = new WeakMap()
@@ -30,6 +32,7 @@ export const GeneralQueries = {
typeof value.isVoid === 'function' &&
typeof value.normalizeNode === 'function' &&
typeof value.onChange === 'function' &&
(value.marks === null || isPlainObject(value.marks)) &&
(value.selection === null || Range.isRange(value.selection)) &&
Node.isNodeList(value.children) &&
Operation.isOperationList(value.operations)
@@ -45,6 +48,58 @@ export const GeneralQueries = {
return isNormalizing === undefined ? true : isNormalizing
},
/**
* Get the marks that would be added to text at the current selection.
*/
marks(editor: Editor): Record<string, any> | null {
const { marks, selection } = editor
if (!selection) {
return null
}
if (marks) {
return marks
}
if (Range.isExpanded(selection)) {
const [match] = Editor.nodes(editor, {
match: 'text',
mode: 'all',
})
if (match) {
const [node] = match as TextEntry
const { text, ...rest } = node
return rest
} else {
return {}
}
}
const { anchor } = selection
const { path } = anchor
let [node] = Editor.leaf(editor, path)
if (anchor.offset === 0) {
const prev = Editor.previous(editor, path, 'text')
const block = Editor.match(editor, path, 'block')
if (prev && block) {
const [prevNode, prevPath] = prev
const [, blockPath] = block
if (Path.isAncestor(blockPath, prevPath)) {
node = prevNode as Text
}
}
}
const { text, ...rest } = node
return rest
},
/**
* Create a mutable ref for a `Path` object, which will stay in sync as new
* operations are applied to the editor.

View File

@@ -1,14 +1,14 @@
import { Node, Path, Range } from '..'
import isPlainObject from 'is-plain-object'
type InsertNodeOperation = {
export type InsertNodeOperation = {
type: 'insert_node'
path: Path
node: Node
[key: string]: any
}
type InsertTextOperation = {
export type InsertTextOperation = {
type: 'insert_text'
path: Path
offset: number
@@ -16,7 +16,7 @@ type InsertTextOperation = {
[key: string]: any
}
type MergeNodeOperation = {
export type MergeNodeOperation = {
type: 'merge_node'
path: Path
position: number
@@ -25,21 +25,21 @@ type MergeNodeOperation = {
[key: string]: any
}
type MoveNodeOperation = {
export type MoveNodeOperation = {
type: 'move_node'
path: Path
newPath: Path
[key: string]: any
}
type RemoveNodeOperation = {
export type RemoveNodeOperation = {
type: 'remove_node'
path: Path
node: Node
[key: string]: any
}
type RemoveTextOperation = {
export type RemoveTextOperation = {
type: 'remove_text'
path: Path
offset: number
@@ -47,7 +47,7 @@ type RemoveTextOperation = {
[key: string]: any
}
type SetNodeOperation = {
export type SetNodeOperation = {
type: 'set_node'
path: Path
properties: Partial<Node>
@@ -55,7 +55,7 @@ type SetNodeOperation = {
[key: string]: any
}
type SetSelectionOperation =
export type SetSelectionOperation =
| {
type: 'set_selection'
[key: string]: any
@@ -75,7 +75,7 @@ type SetSelectionOperation =
newProperties: null
}
type SplitNodeOperation = {
export type SplitNodeOperation = {
type: 'split_node'
path: Path
position: number
@@ -84,16 +84,7 @@ type SplitNodeOperation = {
[key: string]: any
}
/**
* `Operation` objects define the low-level instructions that Slate editors use
* to apply changes to their internal state. Representing all changes as
* operations is what allows Slate editors to easily implement history,
* collaboration, and other features.
*/
type Operation = NodeOperation | SelectionOperation | TextOperation
type NodeOperation =
export type NodeOperation =
| InsertNodeOperation
| MergeNodeOperation
| MoveNodeOperation
@@ -101,11 +92,20 @@ type NodeOperation =
| SetNodeOperation
| SplitNodeOperation
type SelectionOperation = SetSelectionOperation
export type SelectionOperation = SetSelectionOperation
type TextOperation = InsertTextOperation | RemoveTextOperation
export type TextOperation = InsertTextOperation | RemoveTextOperation
const Operation = {
/**
* `Operation` objects define the low-level instructions that Slate editors use
* to apply changes to their internal state. Representing all changes as
* operations is what allows Slate editors to easily implement history,
* collaboration, and other features.
*/
export type Operation = NodeOperation | SelectionOperation | TextOperation
export const Operation = {
/**
* Check of a value is a `NodeOperation` object.
*/
@@ -124,78 +124,53 @@ const Operation = {
}
switch (value.type) {
case 'insert_node': {
case 'insert_node':
return Path.isPath(value.path) && Node.isNode(value.node)
}
case 'insert_text': {
case 'insert_text':
return (
typeof value.offset === 'number' &&
typeof value.text === 'string' &&
Path.isPath(value.path)
)
}
case 'merge_node': {
case 'merge_node':
return (
typeof value.position === 'number' &&
(typeof value.target === 'number' || value.target === null) &&
Path.isPath(value.path) &&
isPlainObject(value.properties)
)
}
case 'move_node': {
case 'move_node':
return Path.isPath(value.path) && Path.isPath(value.newPath)
}
case 'remove_node': {
case 'remove_node':
return Path.isPath(value.path) && Node.isNode(value.node)
}
case 'remove_text': {
case 'remove_text':
return (
typeof value.offset === 'number' &&
typeof value.text === 'string' &&
Path.isPath(value.path)
)
}
case 'set_node': {
case 'set_node':
return (
Path.isPath(value.path) &&
isPlainObject(value.properties) &&
isPlainObject(value.newProperties)
)
}
case 'set_selection': {
case 'set_selection':
return (
(value.properties === null && Range.isRange(value.newProperties)) ||
(value.newProperties === null && Range.isRange(value.properties)) ||
(isPlainObject(value.properties) &&
isPlainObject(value.newProperties))
)
}
case 'set_value': {
return (
isPlainObject(value.properties) && isPlainObject(value.newProperties)
)
}
case 'split_node': {
case 'split_node':
return (
Path.isPath(value.path) &&
typeof value.position === 'number' &&
(typeof value.target === 'number' || value.target === null) &&
isPlainObject(value.properties)
)
}
default: {
default:
return false
}
}
},
@@ -300,16 +275,3 @@ const Operation = {
}
},
}
export {
InsertNodeOperation,
InsertTextOperation,
MergeNodeOperation,
MoveNodeOperation,
RemoveNodeOperation,
RemoveTextOperation,
SetNodeOperation,
SetSelectionOperation,
SplitNodeOperation,
Operation,
}

View File

@@ -2,8 +2,9 @@ import { Element } from 'slate'
export const input = {
children: [],
selection: null,
operations: [],
selection: null,
marks: null,
apply() {},
exec() {},
isInline() {},

View File

@@ -3,8 +3,9 @@ import { Element } from 'slate'
export const input = [
{
children: [],
selection: null,
operations: [],
selection: null,
marks: null,
apply() {},
exec() {},
isInline() {},

View File

@@ -1,13 +0,0 @@
import { Operation } from 'slate'
export const input = {
type: 'set_value',
properties: {},
newProperties: {},
}
export const test = value => {
return Operation.isOperation(value)
}
export const output = true