import type { Ctx, MilkdownPlugin } from '@milkdown/ctx' import type { Node as ProseNode } from '@milkdown/prose/model' import type { Selection, Transaction } from '@milkdown/prose/state' import { EditorViewReady, InitReady, SerializerReady, prosePluginsCtx, serializerCtx, } from '@milkdown/core' import { createSlice } from '@milkdown/ctx' import { Plugin, PluginKey } from '@milkdown/prose/state' import { debounce } from 'lodash-es' /// The dictionary of subscribers of each event. export interface Subscribers { beforeMount: ((ctx: Ctx) => void)[] mounted: ((ctx: Ctx) => void)[] updated: ((ctx: Ctx, doc: ProseNode, prevDoc: ProseNode) => void)[] markdownUpdated: (( ctx: Ctx, markdown: string, prevMarkdown: string ) => void)[] blur: ((ctx: Ctx) => void)[] focus: ((ctx: Ctx) => void)[] destroy: ((ctx: Ctx) => void)[] selectionUpdated: (( ctx: Ctx, selection: Selection, prevSelection: Selection | null ) => void)[] } /// The manager of listeners. It provides methods to subscribe to events. export class ListenerManager { private beforeMountedListeners: Array<(ctx: Ctx) => void> = [] private mountedListeners: Array<(ctx: Ctx) => void> = [] private updatedListeners: Array< (ctx: Ctx, doc: ProseNode, prevDoc: ProseNode) => void > = [] private selectionUpdatedListeners: Array< (ctx: Ctx, selection: Selection, prevDoc: Selection | null) => void > = [] private markdownUpdatedListeners: Array< (ctx: Ctx, markdown: string, prevMarkdown: string) => void > = [] private blurListeners: Array<(ctx: Ctx) => void> = [] private focusListeners: Array<(ctx: Ctx) => void> = [] private destroyListeners: Array<(ctx: Ctx) => void> = [] /// A getter to get all [subscribers](#interface-subscribers). You should not use this method directly. get listeners(): Subscribers { return { beforeMount: this.beforeMountedListeners, mounted: this.mountedListeners, updated: this.updatedListeners, markdownUpdated: this.markdownUpdatedListeners, blur: this.blurListeners, focus: this.focusListeners, destroy: this.destroyListeners, selectionUpdated: this.selectionUpdatedListeners, } } /// Subscribe to the beforeMount event. /// This event will be triggered before the editor is mounted. beforeMount = (fn: (ctx: Ctx) => void) => { this.beforeMountedListeners.push(fn) return this } /// Subscribe to the mounted event. /// This event will be triggered after the editor is mounted. mounted = (fn: (ctx: Ctx) => void) => { this.mountedListeners.push(fn) return this } /// Subscribe to the updated event. /// This event will be triggered after the editor state is updated and **the document is changed**. /// The second parameter is the current document and the third parameter is the previous document. updated = ( fn: (ctx: Ctx, doc: ProseNode, prevDoc: ProseNode | null) => void ) => { this.updatedListeners.push(fn) return this } /// Subscribe to the markdownUpdated event. /// This event will be triggered after the editor state is updated and **the document is changed**. /// The second parameter is the current markdown and the third parameter is the previous markdown. markdownUpdated( fn: (ctx: Ctx, markdown: string, prevMarkdown: string) => void ) { this.markdownUpdatedListeners.push(fn) return this } /// Subscribe to the blur event. /// This event will be triggered when the editor is blurred. blur(fn: (ctx: Ctx) => void) { this.blurListeners.push(fn) return this } /// Subscribe to the focus event. /// This event will be triggered when the editor is focused. focus(fn: (ctx: Ctx) => void) { this.focusListeners.push(fn) return this } /// Subscribe to the destroy event. /// This event will be triggered before the editor is destroyed. destroy(fn: (ctx: Ctx) => void) { this.destroyListeners.push(fn) return this } /// Subscribe to the selectionUpdated event. /// This event will be triggered when the editor selection is updated. selectionUpdated( fn: ( ctx: Ctx, selection: Selection, prevSelection: Selection | null ) => void ) { this.selectionUpdatedListeners.push(fn) return this } } /// The ctx key of the listener manager. /// You can use `ctx.get(listenerCtx)` to get the [listener manager](#class-listenermanager). export const listenerCtx = createSlice( new ListenerManager(), 'listener' ) /// The plugin key of the listener prosemirror plugin. export const key = new PluginKey('MILKDOWN_LISTENER') /// The listener plugin. export const listener: MilkdownPlugin = (ctx) => { ctx.inject(listenerCtx, new ListenerManager()) return async () => { await ctx.wait(InitReady) const listener = ctx.get(listenerCtx) const { listeners } = listener listeners.beforeMount.forEach((fn) => fn(ctx)) await ctx.wait(SerializerReady) const serializer = ctx.get(serializerCtx) let prevDoc: ProseNode | null = null let prevMarkdown: string | null = null let prevSelection: Selection | null = null let latestTr: Transaction | null = null const debouncedHandler = debounce(() => { if (!latestTr) return const { doc } = latestTr if (listeners.updated.length > 0 && prevDoc && !prevDoc.eq(doc)) { listeners.updated.forEach((fn) => { fn(ctx, doc, prevDoc!) }) } if (listeners.markdownUpdated.length > 0 && prevDoc && !prevDoc.eq(doc)) { const markdown = serializer(doc) listeners.markdownUpdated.forEach((fn) => { fn(ctx, markdown, prevMarkdown!) }) prevMarkdown = markdown } prevDoc = doc latestTr = null }, 200) const plugin = new Plugin({ key, view: () => { return { destroy: () => { listeners.destroy.forEach((fn) => fn(ctx)) }, } }, props: { handleDOMEvents: { focus: () => { listeners.focus.forEach((fn) => fn(ctx)) return false }, blur: () => { listeners.blur.forEach((fn) => fn(ctx)) return false }, }, }, state: { init: (_, instance) => { prevDoc = instance.doc prevMarkdown = serializer(instance.doc) }, apply: (tr) => { const currentSelection = tr.selection if ( (!prevSelection && currentSelection) || (prevSelection && !currentSelection.eq(prevSelection)) ) { listeners.selectionUpdated.forEach((fn) => { fn(ctx, currentSelection, prevSelection) }) prevSelection = currentSelection } if ( !(tr.docChanged || tr.storedMarksSet) || tr.getMeta('addToHistory') === false ) return latestTr = tr debouncedHandler() }, }, }) ctx.update(prosePluginsCtx, (x) => x.concat(plugin)) await ctx.wait(EditorViewReady) listeners.mounted.forEach((fn) => fn(ctx)) } } listener.meta = { package: '@milkdown/plugin-listener', displayName: 'Listener', }