diff --git a/docs/api/preset-commonmark.md b/docs/api/preset-commonmark.md index 21de1a7e57c..5a48f040f05 100644 --- a/docs/api/preset-commonmark.md +++ b/docs/api/preset-commonmark.md @@ -169,6 +169,18 @@ Editor.make() --- +# Utility Commands + +@isMarkSelectedCommand +@isNodeSelectedCommand +@clearTextInCurrentBlockCommand +@setBlockTypeCommand +@wrapInBlockTypeCommand +@addBlockTypeCommand +@selectTextNearPosCommand + +--- + # Prosemirror Plugins @inlineNodesCursorPlugin diff --git a/packages/crepe/src/feature/block-edit/menu/config.ts b/packages/crepe/src/feature/block-edit/menu/config.ts index 7c42ee2a00f..b098d1625aa 100644 --- a/packages/crepe/src/feature/block-edit/menu/config.ts +++ b/packages/crepe/src/feature/block-edit/menu/config.ts @@ -1,19 +1,23 @@ import type { Ctx } from '@milkdown/kit/ctx' import { imageBlockSchema } from '@milkdown/kit/component/image-block' -import { editorViewCtx } from '@milkdown/kit/core' +import { commandsCtx, editorViewCtx } from '@milkdown/kit/core' import { + addBlockTypeCommand, blockquoteSchema, bulletListSchema, + clearTextInCurrentBlockCommand, codeBlockSchema, headingSchema, hrSchema, listItemSchema, orderedListSchema, paragraphSchema, + selectTextNearPosCommand, + setBlockTypeCommand, + wrapInBlockTypeCommand, } from '@milkdown/kit/preset/commonmark' import { createTable } from '@milkdown/kit/preset/gfm' -import { TextSelection } from '@milkdown/kit/prose/state' import type { BlockEditFeatureConfig } from '../index' import type { SlashMenuItem } from './utils' @@ -39,12 +43,6 @@ import { todoListIcon, } from '../../../icons' import { GroupBuilder, type MenuItemGroup } from '../../../utils/group-builder' -import { - clearContentAndAddBlockType, - clearContentAndSetBlockType, - clearContentAndWrapInBlockType, - clearRange, -} from './utils' export function getGroups( filter?: string, @@ -63,113 +61,135 @@ export function getGroups( label: config?.slashMenuTextLabel ?? 'Text', icon: config?.slashMenuTextIcon ?? textIcon, onRun: (ctx) => { - const view = ctx.get(editorViewCtx) - const { dispatch, state } = view + const commands = ctx.get(commandsCtx) + const paragraph = paragraphSchema.type(ctx) - const command = clearContentAndSetBlockType(paragraphSchema.type(ctx)) - command(state, dispatch) + commands.call(clearTextInCurrentBlockCommand.key) + commands.call(setBlockTypeCommand.key, { + nodeType: paragraph, + }) }, }) .addItem('h1', { label: config?.slashMenuH1Label ?? 'Heading 1', icon: config?.slashMenuH1Icon ?? h1Icon, onRun: (ctx) => { - const view = ctx.get(editorViewCtx) - const { dispatch, state } = view - - const command = clearContentAndSetBlockType(headingSchema.type(ctx), { - level: 1, + const commands = ctx.get(commandsCtx) + const heading = headingSchema.type(ctx) + + commands.call(clearTextInCurrentBlockCommand.key) + commands.call(setBlockTypeCommand.key, { + nodeType: heading, + attrs: { + level: 1, + }, }) - command(state, dispatch) }, }) .addItem('h2', { label: config?.slashMenuH2Label ?? 'Heading 2', icon: config?.slashMenuH2Icon ?? h2Icon, onRun: (ctx) => { - const view = ctx.get(editorViewCtx) - const { dispatch, state } = view - - const command = clearContentAndSetBlockType(headingSchema.type(ctx), { - level: 2, + const commands = ctx.get(commandsCtx) + const heading = headingSchema.type(ctx) + + commands.call(clearTextInCurrentBlockCommand.key) + commands.call(setBlockTypeCommand.key, { + nodeType: heading, + attrs: { + level: 2, + }, }) - command(state, dispatch) }, }) .addItem('h3', { label: config?.slashMenuH3Label ?? 'Heading 3', icon: config?.slashMenuH3Icon ?? h3Icon, onRun: (ctx) => { - const view = ctx.get(editorViewCtx) - const { dispatch, state } = view - - const command = clearContentAndSetBlockType(headingSchema.type(ctx), { - level: 3, + const commands = ctx.get(commandsCtx) + const heading = headingSchema.type(ctx) + + commands.call(clearTextInCurrentBlockCommand.key) + commands.call(setBlockTypeCommand.key, { + nodeType: heading, + attrs: { + level: 3, + }, }) - command(state, dispatch) }, }) .addItem('h4', { label: config?.slashMenuH4Label ?? 'Heading 4', icon: config?.slashMenuH4Icon ?? h4Icon, onRun: (ctx) => { - const view = ctx.get(editorViewCtx) - const { dispatch, state } = view - - const command = clearContentAndSetBlockType(headingSchema.type(ctx), { - level: 4, + const commands = ctx.get(commandsCtx) + const heading = headingSchema.type(ctx) + + commands.call(clearTextInCurrentBlockCommand.key) + commands.call(setBlockTypeCommand.key, { + nodeType: heading, + attrs: { + level: 4, + }, }) - command(state, dispatch) }, }) .addItem('h5', { label: config?.slashMenuH5Label ?? 'Heading 5', icon: config?.slashMenuH5Icon ?? h5Icon, onRun: (ctx) => { - const view = ctx.get(editorViewCtx) - const { dispatch, state } = view - - const command = clearContentAndSetBlockType(headingSchema.type(ctx), { - level: 5, + const commands = ctx.get(commandsCtx) + const heading = headingSchema.type(ctx) + + commands.call(clearTextInCurrentBlockCommand.key) + commands.call(setBlockTypeCommand.key, { + nodeType: heading, + attrs: { + level: 5, + }, }) - command(state, dispatch) }, }) .addItem('h6', { label: config?.slashMenuH6Label ?? 'Heading 6', icon: config?.slashMenuH6Icon ?? h6Icon, onRun: (ctx) => { - const view = ctx.get(editorViewCtx) - const { dispatch, state } = view - - const command = clearContentAndSetBlockType(headingSchema.type(ctx), { - level: 6, + const commands = ctx.get(commandsCtx) + const heading = headingSchema.type(ctx) + + commands.call(clearTextInCurrentBlockCommand.key) + commands.call(setBlockTypeCommand.key, { + nodeType: heading, + attrs: { + level: 6, + }, }) - command(state, dispatch) }, }) .addItem('quote', { label: config?.slashMenuQuoteLabel ?? 'Quote', icon: config?.slashMenuQuoteIcon ?? quoteIcon, onRun: (ctx) => { - const view = ctx.get(editorViewCtx) - const { dispatch, state } = view + const commands = ctx.get(commandsCtx) + const blockquote = blockquoteSchema.type(ctx) - const command = clearContentAndWrapInBlockType( - blockquoteSchema.type(ctx) - ) - command(state, dispatch) + commands.call(clearTextInCurrentBlockCommand.key) + commands.call(wrapInBlockTypeCommand.key, { + nodeType: blockquote, + }) }, }) .addItem('divider', { label: config?.slashMenuDividerLabel ?? 'Divider', icon: config?.slashMenuDividerIcon ?? dividerIcon, onRun: (ctx) => { - const view = ctx.get(editorViewCtx) - const { dispatch, state } = view + const commands = ctx.get(commandsCtx) + const hr = hrSchema.type(ctx) - const command = clearContentAndAddBlockType(hrSchema.type(ctx)) - command(state, dispatch) + commands.call(clearTextInCurrentBlockCommand.key) + commands.call(addBlockTypeCommand.key, { + nodeType: hr, + }) }, }) @@ -179,40 +199,40 @@ export function getGroups( label: config?.slashMenuBulletListLabel ?? 'Bullet List', icon: config?.slashMenuBulletListIcon ?? bulletListIcon, onRun: (ctx) => { - const view = ctx.get(editorViewCtx) - const { dispatch, state } = view + const commands = ctx.get(commandsCtx) + const bulletList = bulletListSchema.type(ctx) - const command = clearContentAndWrapInBlockType( - bulletListSchema.type(ctx) - ) - command(state, dispatch) + commands.call(clearTextInCurrentBlockCommand.key) + commands.call(wrapInBlockTypeCommand.key, { + nodeType: bulletList, + }) }, }) .addItem('ordered-list', { label: config?.slashMenuOrderedListLabel ?? 'Ordered List', icon: config?.slashMenuOrderedListIcon ?? orderedListIcon, onRun: (ctx) => { - const view = ctx.get(editorViewCtx) - const { dispatch, state } = view + const commands = ctx.get(commandsCtx) + const orderedList = orderedListSchema.type(ctx) - const command = clearContentAndWrapInBlockType( - orderedListSchema.type(ctx) - ) - command(state, dispatch) + commands.call(clearTextInCurrentBlockCommand.key) + commands.call(wrapInBlockTypeCommand.key, { + nodeType: orderedList, + }) }, }) .addItem('todo-list', { label: config?.slashMenuTaskListLabel ?? 'Todo List', icon: config?.slashMenuTaskListIcon ?? todoListIcon, onRun: (ctx) => { - const view = ctx.get(editorViewCtx) - const { dispatch, state } = view + const commands = ctx.get(commandsCtx) + const listItem = listItemSchema.type(ctx) - const command = clearContentAndWrapInBlockType( - listItemSchema.type(ctx), - { checked: false } - ) - command(state, dispatch) + commands.call(clearTextInCurrentBlockCommand.key) + commands.call(wrapInBlockTypeCommand.key, { + nodeType: listItem, + attrs: { checked: false }, + }) }, }) @@ -226,11 +246,13 @@ export function getGroups( label: config?.slashMenuImageLabel ?? 'Image', icon: config?.slashMenuImageIcon ?? imageIcon, onRun: (ctx) => { - const view = ctx.get(editorViewCtx) - const { dispatch, state } = view + const commands = ctx.get(commandsCtx) + const imageBlock = imageBlockSchema.type(ctx) - const command = clearContentAndAddBlockType(imageBlockSchema.type(ctx)) - command(state, dispatch) + commands.call(clearTextInCurrentBlockCommand.key) + commands.call(addBlockTypeCommand.key, { + nodeType: imageBlock, + }) }, }) } @@ -239,11 +261,13 @@ export function getGroups( label: config?.slashMenuCodeBlockLabel ?? 'Code', icon: config?.slashMenuCodeBlockIcon ?? codeIcon, onRun: (ctx) => { - const view = ctx.get(editorViewCtx) - const { dispatch, state } = view + const commands = ctx.get(commandsCtx) + const codeBlock = codeBlockSchema.type(ctx) - const command = clearContentAndAddBlockType(codeBlockSchema.type(ctx)) - command(state, dispatch) + commands.call(clearTextInCurrentBlockCommand.key) + commands.call(setBlockTypeCommand.key, { + nodeType: codeBlock, + }) }, }) @@ -252,24 +276,19 @@ export function getGroups( label: config?.slashMenuTableLabel ?? 'Table', icon: config?.slashMenuTableIcon ?? tableIcon, onRun: (ctx) => { + const commands = ctx.get(commandsCtx) const view = ctx.get(editorViewCtx) - const { dispatch, state } = view - let { tr } = state - tr = clearRange(tr) - const from = tr.selection.from - const table = createTable(ctx, 3, 3) - tr = tr.replaceSelectionWith(table) - dispatch(tr) - - requestAnimationFrame(() => { - const docSize = view.state.doc.content.size - const $pos = view.state.doc.resolve( - from > docSize ? docSize : from < 0 ? 0 : from - ) - const selection = TextSelection.near($pos) - const tr = view.state.tr - tr.setSelection(selection) - dispatch(tr.scrollIntoView()) + + commands.call(clearTextInCurrentBlockCommand.key) + + // record the position before the table is inserted + const { from } = view.state.selection + commands.call(addBlockTypeCommand.key, { + nodeType: createTable(ctx, 3, 3), + }) + + commands.call(selectTextNearPosCommand.key, { + pos: from, }) }, }) @@ -280,13 +299,14 @@ export function getGroups( label: config?.slashMenuMathLabel ?? 'Math', icon: config?.slashMenuMathIcon ?? functionsIcon, onRun: (ctx) => { - const view = ctx.get(editorViewCtx) - const { dispatch, state } = view + const commands = ctx.get(commandsCtx) + const codeBlock = codeBlockSchema.type(ctx) - const command = clearContentAndAddBlockType(codeBlockSchema.type(ctx), { - language: 'LaTex', + commands.call(clearTextInCurrentBlockCommand.key) + commands.call(addBlockTypeCommand.key, { + nodeType: codeBlock, + attrs: { language: 'LaTex' }, }) - command(state, dispatch) }, }) } diff --git a/packages/crepe/src/feature/block-edit/menu/utils.ts b/packages/crepe/src/feature/block-edit/menu/utils.ts index 43e19d2851a..3ba40d54b11 100644 --- a/packages/crepe/src/feature/block-edit/menu/utils.ts +++ b/packages/crepe/src/feature/block-edit/menu/utils.ts @@ -1,92 +1,4 @@ -import type { Attrs, NodeType } from '@milkdown/kit/prose/model' -import type { Command, Transaction } from '@milkdown/kit/prose/state' - -import { findWrapping } from '@milkdown/kit/prose/transform' - export type SlashMenuItem = { label: string icon: string } - -export function clearRange(tr: Transaction) { - const { $from, $to } = tr.selection - const { pos: from } = $from - const { pos: to } = $to - tr = tr.deleteRange(from - $from.node().content.size, to) - return tr -} - -export function setBlockType( - tr: Transaction, - nodeType: NodeType, - attrs: Attrs | null = null -) { - const { from, to } = tr.selection - return tr.setBlockType(from, to, nodeType, attrs) -} - -export function wrapInBlockType( - tr: Transaction, - nodeType: NodeType, - attrs: Attrs | null = null -) { - const { $from, $to } = tr.selection - - const range = $from.blockRange($to) - const wrapping = range && findWrapping(range, nodeType, attrs) - if (!wrapping) return null - - return tr.wrap(range, wrapping) -} - -export function addBlockType( - tr: Transaction, - nodeType: NodeType, - attrs: Attrs | null = null -) { - const node = nodeType.createAndFill(attrs) - if (!node) return null - - return tr.replaceSelectionWith(node) -} - -export function clearContentAndSetBlockType( - nodeType: NodeType, - attrs: Attrs | null = null -): Command { - return (state, dispatch) => { - if (dispatch) { - const tr = setBlockType(clearRange(state.tr), nodeType, attrs) - dispatch(tr.scrollIntoView()) - } - return true - } -} - -export function clearContentAndWrapInBlockType( - nodeType: NodeType, - attrs: Attrs | null = null -): Command { - return (state, dispatch) => { - const tr = wrapInBlockType(clearRange(state.tr), nodeType, attrs) - if (!tr) return false - - if (dispatch) dispatch(tr.scrollIntoView()) - - return true - } -} - -export function clearContentAndAddBlockType( - nodeType: NodeType, - attrs: Attrs | null = null -): Command { - return (state, dispatch) => { - const tr = addBlockType(clearRange(state.tr), nodeType, attrs) - if (!tr) return false - - if (dispatch) dispatch(tr.scrollIntoView()) - - return true - } -} diff --git a/packages/plugins/preset-commonmark/src/commands/index.ts b/packages/plugins/preset-commonmark/src/commands/index.ts index 9583544c127..22dc54ba5be 100644 --- a/packages/plugins/preset-commonmark/src/commands/index.ts +++ b/packages/plugins/preset-commonmark/src/commands/index.ts @@ -1,23 +1,140 @@ -import type { MarkType, NodeType } from '@milkdown/prose/model' - import { findNodeInSelection } from '@milkdown/prose' +import { + Node, + type Attrs, + type MarkType, + type NodeType, +} from '@milkdown/prose/model' +import { TextSelection } from '@milkdown/prose/state' +import { findWrapping } from '@milkdown/prose/transform' import { $command } from '@milkdown/utils' /// A command to check if a mark is selected. -export const isMarkSelectedCommand = $command('IsMarkSelected', () => { - return (markType?: MarkType) => (state) => { +export const isMarkSelectedCommand = $command( + 'IsMarkSelected', + () => (markType?: MarkType) => (state) => { if (!markType) return false const { doc, selection } = state const hasLink = doc.rangeHasMark(selection.from, selection.to, markType) return hasLink } -}) +) /// A command to check if a node is selected. -export const isNodeSelectedCommand = $command('IsNoteSelected', () => { - return (nodeType?: NodeType) => (state) => { +export const isNodeSelectedCommand = $command( + 'IsNoteSelected', + () => (nodeType?: NodeType) => (state) => { if (!nodeType) return false const result = findNodeInSelection(state, nodeType) return result.hasNode } -}) +) + +/// A command to clear text in the current block. +export const clearTextInCurrentBlockCommand = $command( + 'ClearTextInCurrentBlock', + () => () => (state, dispatch) => { + let tr = state.tr + const { $from, $to } = tr.selection + const { pos: from } = $from + const { pos: right } = $to + const left = from - $from.node().content.size + if (left < 0) return false + + tr = tr.deleteRange(left, right) + dispatch?.(tr) + return true + } +) + +/// Set block type to target block and attribute. +export const setBlockTypeCommand = $command( + 'SetBlockType', + () => + (payload?: { nodeType: NodeType; attrs?: Attrs | null }) => + (state, dispatch) => { + const { nodeType, attrs = null } = payload ?? {} + if (!nodeType) return false + const tr = state.tr + const { from, to } = tr.selection + try { + tr.setBlockType(from, to, nodeType, attrs) + } catch { + return false + } + dispatch?.(tr) + return true + } +) + +/// A command to wrap the current block with a block type. +export const wrapInBlockTypeCommand = $command( + 'WrapInBlockType', + () => + (payload?: { nodeType: NodeType; attrs?: Attrs | null }) => + (state, dispatch) => { + const { nodeType, attrs = null } = payload ?? {} + if (!nodeType) return false + + let tr = state.tr + + try { + const { $from, $to } = tr.selection + const blockRange = $from.blockRange($to) + const wrapping = blockRange && findWrapping(blockRange, nodeType, attrs) + if (!wrapping) return false + tr = tr.wrap(blockRange, wrapping) + } catch { + return false + } + + dispatch?.(tr) + return true + } +) + +/// A command to add a block type to the current selection. +export const addBlockTypeCommand = $command( + 'AddBlockType', + () => + (payload?: { nodeType: NodeType | Node; attrs?: Attrs | null }) => + (state, dispatch) => { + const { nodeType, attrs = null } = payload ?? {} + if (!nodeType) return false + const tr = state.tr + + try { + const node = + nodeType instanceof Node ? nodeType : nodeType.createAndFill(attrs) + if (!node) return false + + tr.replaceSelectionWith(node) + } catch { + return false + } + dispatch?.(tr) + return true + } +) + +/// A command to select text near a position. +export const selectTextNearPosCommand = $command( + 'SelectTextNearPos', + () => (payload?: { pos?: number }) => (state, dispatch) => { + const { pos } = payload ?? {} + if (pos == null) return false + + const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max) + + const tr = state.tr + try { + const $pos = state.doc.resolve(clamp(pos, 0, state.doc.content.size)) + tr.setSelection(TextSelection.near($pos)) + } catch { + return false + } + dispatch?.(tr.scrollIntoView()) + return true + } +) diff --git a/packages/plugins/preset-commonmark/src/composed/commands.ts b/packages/plugins/preset-commonmark/src/composed/commands.ts index dec26aac6c9..9f35b87a0b4 100644 --- a/packages/plugins/preset-commonmark/src/composed/commands.ts +++ b/packages/plugins/preset-commonmark/src/composed/commands.ts @@ -1,6 +1,14 @@ import type { MilkdownPlugin } from '@milkdown/ctx' -import { isMarkSelectedCommand, isNodeSelectedCommand } from '../commands' +import { + addBlockTypeCommand, + clearTextInCurrentBlockCommand, + isMarkSelectedCommand, + isNodeSelectedCommand, + selectTextNearPosCommand, + setBlockTypeCommand, + wrapInBlockTypeCommand, +} from '../commands' import { toggleEmphasisCommand, toggleInlineCodeCommand, @@ -55,4 +63,10 @@ export const commands: MilkdownPlugin[] = [ isMarkSelectedCommand, isNodeSelectedCommand, + + clearTextInCurrentBlockCommand, + setBlockTypeCommand, + wrapInBlockTypeCommand, + addBlockTypeCommand, + selectTextNearPosCommand, ]