Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/short-lamps-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@portabletext/editor': patch
---

fix: make event Behavior matching faster
77 changes: 77 additions & 0 deletions packages/editor/src/behaviors/behavior.index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type {Behavior} from './behavior.types.behavior'
import type {BehaviorEvent} from './behavior.types.event'

export type BehaviorIndex = {
global: Array<SortedBehavior>
namespaced: Map<string, Array<SortedBehavior>>
exact: Map<string, Array<SortedBehavior>>
}

type SortedBehavior = {
behavior: Behavior
sortOrder: number
}

/**
* Given an array of Behaviors, build a `BehaviorIndex` where the `sortOrder`
* of each Behavior is preserved.
*/
export function buildBehaviorIndex(behaviors: Array<Behavior>): BehaviorIndex {
const global: Array<SortedBehavior> = []
const namespaced = new Map<string, Array<SortedBehavior>>()
const exact = new Map<string, Array<SortedBehavior>>()

let sortOrder = -1

for (const behavior of behaviors) {
sortOrder++
const sortedBehavior = {behavior, sortOrder}

if (behavior.on === '*') {
global.push(sortedBehavior)
continue
}

if (behavior.on.endsWith('.*')) {
const namespace = behavior.on.slice(0, -2)
const indexedBehaviors = namespaced.get(namespace) ?? []

indexedBehaviors.push(sortedBehavior)

namespaced.set(namespace, indexedBehaviors)

continue
}

const indexedBehaviors = exact.get(behavior.on) ?? []

indexedBehaviors.push(sortedBehavior)

exact.set(behavior.on, indexedBehaviors)
}

return {exact, global, namespaced}
}

export function getEventBehaviors(
behaviorIndex: BehaviorIndex,
type: BehaviorEvent['type'],
): Array<Behavior> {
// Catches all events
const global = behaviorIndex.global

// Handles scenarios like a Behavior listening for `select.*` and the event
// `select.block` is fired.
// OR a Behavior listening for `select.*` and the event `select` is fired.
const namespace = type.includes('.') ? type.split('.').at(0) : type
const namespaced = namespace
? (behaviorIndex.namespaced.get(namespace) ?? [])
: []
const exact = behaviorIndex.exact.get(type) ?? []

const sorted = [...global, ...namespaced, ...exact].sort(
(a, b) => a.sortOrder - b.sortOrder,
)

return sorted.map((sortedBehavior) => sortedBehavior.behavior)
}
74 changes: 22 additions & 52 deletions packages/editor/src/behaviors/behavior.perform-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {debugWithName} from '../internal-utils/debug'
import {performOperation} from '../operations/operation.perform'
import type {PortableTextSlateEditor} from '../types/slate-editor'
import {defaultKeyGenerator} from '../utils/key-generator'
import {abstractBehaviors} from './behavior.abstract'
import {getEventBehaviors, type BehaviorIndex} from './behavior.index'
import type {BehaviorAction} from './behavior.types.action'
import type {Behavior} from './behavior.types.behavior'
import {
Expand All @@ -34,8 +34,9 @@ function eventCategory(event: BehaviorEvent) {

export function performEvent({
mode,
behaviors,
remainingEventBehaviors,
behaviorIndex,
abstractBehaviorIndex,
forwardFromBehaviors,
event,
editor,
keyGenerator,
Expand All @@ -45,8 +46,9 @@ export function performEvent({
sendBack,
}: {
mode: 'send' | 'raise' | 'execute' | 'forward'
behaviors: Array<Behavior>
remainingEventBehaviors: Array<Behavior>
behaviorIndex: BehaviorIndex
abstractBehaviorIndex: BehaviorIndex
forwardFromBehaviors?: Array<Behavior>
event: BehaviorEvent
editor: PortableTextSlateEditor
keyGenerator: () => string
Expand All @@ -70,45 +72,13 @@ export function performEvent({
JSON.stringify(event, null, 2),
)

const eventBehaviors = [
...remainingEventBehaviors,
...abstractBehaviors,
].filter((behavior) => {
// Catches all events
if (behavior.on === '*') {
return true
}

const [listenedNamespace] =
behavior.on.includes('*') && behavior.on.includes('.')
? behavior.on.split('.')
: [undefined]
const [eventNamespace] = event.type.includes('.')
? event.type.split('.')
: [undefined]

// Handles scenarios like a Behavior listening for `select.*` and the event
// `select.block` is fired.
if (
listenedNamespace !== undefined &&
eventNamespace !== undefined &&
listenedNamespace === eventNamespace
) {
return true
}

// Handles scenarios like a Behavior listening for `select.*` and the event
// `select` is fired.
if (
listenedNamespace !== undefined &&
eventNamespace === undefined &&
listenedNamespace === event.type
) {
return true
}

return behavior.on === event.type
})
const eventBehaviors =
mode === 'forward' && forwardFromBehaviors
? forwardFromBehaviors
: getEventBehaviors(
mode === 'execute' ? abstractBehaviorIndex : behaviorIndex,
event.type,
)

if (eventBehaviors.length === 0 && isSyntheticBehaviorEvent(event)) {
nativeEvent?.preventDefault()
Expand Down Expand Up @@ -270,14 +240,15 @@ export function performEvent({
}

if (action.type === 'forward') {
const remainingEventBehaviors = eventBehaviors.slice(
const remainingBehaviors = eventBehaviors.slice(
eventBehaviorIndex + 1,
)

performEvent({
mode: mode === 'execute' ? 'execute' : 'forward',
behaviors,
remainingEventBehaviors: remainingEventBehaviors,
behaviorIndex,
abstractBehaviorIndex,
forwardFromBehaviors: remainingBehaviors,
event: action.event,
editor,
keyGenerator,
Expand All @@ -293,9 +264,8 @@ export function performEvent({
if (action.type === 'raise') {
performEvent({
mode: mode === 'execute' ? 'execute' : 'raise',
behaviors,
remainingEventBehaviors:
mode === 'execute' ? remainingEventBehaviors : behaviors,
behaviorIndex,
abstractBehaviorIndex,
event: action.event,
editor,
keyGenerator,
Expand All @@ -310,8 +280,8 @@ export function performEvent({

performEvent({
mode: 'execute',
behaviors,
remainingEventBehaviors: [],
behaviorIndex,
abstractBehaviorIndex,
event: action.event,
editor,
keyGenerator,
Expand Down
53 changes: 36 additions & 17 deletions packages/editor/src/editor/editor-machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ import {
setup,
type ActorRefFrom,
} from 'xstate'
import {abstractBehaviors} from '../behaviors/behavior.abstract'
import type {BehaviorConfig} from '../behaviors/behavior.config'
import {coreBehaviorsConfig} from '../behaviors/behavior.core'
import {
buildBehaviorIndex,
type BehaviorIndex,
} from '../behaviors/behavior.index'
import {performEvent} from '../behaviors/behavior.perform-event'
import type {
BehaviorEvent,
Expand Down Expand Up @@ -179,7 +184,9 @@ export const editorMachine = setup({
types: {
context: {} as {
behaviors: Set<BehaviorConfig>
behaviorsSorted: boolean
behaviorIndex: BehaviorIndex
abstractBehaviorIndex: BehaviorIndex
behaviorsIndexed: boolean
converters: Set<Converter>
getLegacySchema: () => PortableTextMemberSchemaTypes
keyGenerator: () => string
Expand Down Expand Up @@ -214,7 +221,7 @@ export const editorMachine = setup({

return new Set([...context.behaviors, event.behaviorConfig])
},
behaviorsSorted: false,
behaviorsIndexed: false,
}),
'remove behavior from context': assign({
behaviors: ({context, event}) => {
Expand All @@ -224,6 +231,7 @@ export const editorMachine = setup({

return new Set([...context.behaviors])
},
behaviorsIndexed: false,
}),
'add slate editor to context': assign({
slateEditor: ({context, event}) => {
Expand Down Expand Up @@ -311,14 +319,10 @@ export const editorMachine = setup({
assertEvent(event, ['behavior event'])

try {
const behaviors = [...context.behaviors.values()].map(
(config) => config.behavior,
)

performEvent({
mode: 'send',
behaviors,
remainingEventBehaviors: behaviors,
behaviorIndex: context.behaviorIndex,
abstractBehaviorIndex: context.abstractBehaviorIndex,
event: event.behaviorEvent,
editor: event.editor,
keyGenerator: context.keyGenerator,
Expand Down Expand Up @@ -354,12 +358,22 @@ export const editorMachine = setup({
)
}
},
'sort behaviors': assign({
behaviors: ({context}) =>
!context.behaviorsSorted
? new Set(sortByPriority([...context.behaviors.values()]))
: context.behaviors,
behaviorsSorted: true,
'index behaviors': assign(({context}) => {
if (context.behaviorsIndexed) {
return {}
}

const sortedConfigs = sortByPriority([...context.behaviors.values()])
const allBehaviors = [
...sortedConfigs.map((config) => config.behavior),
...abstractBehaviors,
]

return {
behaviorIndex: buildBehaviorIndex(allBehaviors),
abstractBehaviorIndex: buildBehaviorIndex(abstractBehaviors),
behaviorsIndexed: true,
}
}),
},
guards: {
Expand All @@ -382,7 +396,12 @@ export const editorMachine = setup({
id: 'editor',
context: ({input}) => ({
behaviors: new Set(coreBehaviorsConfig),
behaviorsSorted: false,
behaviorIndex: buildBehaviorIndex([
...coreBehaviorsConfig.map((config) => config.behavior),
...abstractBehaviors,
]),
abstractBehaviorIndex: buildBehaviorIndex(abstractBehaviors),
behaviorsIndexed: false,
converters: new Set(input.converters ?? []),
getLegacySchema: input.getLegacySchema,
keyGenerator: input.keyGenerator,
Expand Down Expand Up @@ -416,7 +435,7 @@ export const editorMachine = setup({
initial: 'determine initial edit mode',
on: {
'behavior event': {
actions: ['sort behaviors', 'handle behavior event'],
actions: ['index behaviors', 'handle behavior event'],
guard: ({event}) =>
event.behaviorEvent.type === 'clipboard.copy' ||
event.behaviorEvent.type === 'mouse.click' ||
Expand Down Expand Up @@ -483,7 +502,7 @@ export const editorMachine = setup({
actions: ['emit read only'],
},
'behavior event': {
actions: ['sort behaviors', 'handle behavior event'],
actions: ['index behaviors', 'handle behavior event'],
},
'blur': {
actions: 'handle blur',
Expand Down
Loading