import { Command, EditorState, TextSelection, Transaction } from 'prosemirror-state'; import { EditorCommandType } from '../../types'; import { redo, undo } from 'prosemirror-history'; import { autoJoin, selectAll, setBlockType, toggleMark } from 'prosemirror-commands'; import schema from '../schema'; import { liftListItem, sinkListItem, wrapRangeInList } from 'prosemirror-schema-list'; import { NodeType, Slice } from 'prosemirror-model'; import { getSearchVisible, setSearchVisible } from '../plugins/searchPlugin'; import { findNext, findPrev, replaceAll, replaceNext } from 'prosemirror-search'; import { getEditorApi } from '../plugins/joplinEditorApiPlugin'; import { EditorEventType } from '../../events'; import extractSelectedLinesTo from '../utils/extractSelectedLinesTo'; import { EditorView } from 'prosemirror-view'; import jumpToHash from '../utils/jumpToHash'; import focusEditor from './focusEditor'; import canReplaceSelectionWith from '../utils/canReplaceSelectionWith'; import showCreateEditablePrompt from '../plugins/joplinEditablePlugin/showCreateEditablePrompt'; import getTextBetween from '../utils/getTextBetween'; type Dispatch = (tr: Transaction)=> void; type ExtendedCommand = (state: EditorState, dispatch: Dispatch, view?: EditorView, options?: string[])=> boolean; const toggleHeading = (level: number): Command => { const enableCommand: Command = (state, dispatch) => { const result = extractSelectedLinesTo({ type: schema.nodes.heading, attrs: { level }, }, state.tr, state.selection); if (!result) return false; if (dispatch) { dispatch(result.transaction); } return true; }; const resetCommand = setBlockType(schema.nodes.paragraph); return (state, dispatch, view) => { if (enableCommand(state, dispatch, view)) { return true; } return resetCommand(state, dispatch, view); }; }; const toggleList = (type: NodeType): Command => { const enableCommand: Command = autoJoin((state, dispatch) => { const extractionResult = extractSelectedLinesTo({ type: schema.nodes.paragraph, attrs: {}, }, state.tr, state.selection); let transaction = extractionResult?.transaction; if (!transaction) { transaction = state.tr; } const selection = extractionResult?.finalSelection ?? state.selection; const range = selection.$from.blockRange(selection.$to); const result = wrapRangeInList(transaction, range, type); if (dispatch && result) { dispatch(transaction); } return result; }, [type.name]); const liftCommand = liftListItem(schema.nodes.list_item); return (state, dispatch, view) => { return enableCommand(state, dispatch, view) || liftCommand(state, dispatch, view); }; }; const toggleCode: Command = (state, dispatch, view) => { return toggleMark(schema.marks.code)(state, dispatch, view) || setBlockType(schema.nodes.paragraph)(state, dispatch, view); }; const getSelectedBlock = (state: EditorState) => { const blockRange = state.selection.$from.blockRange(state.selection.$to); // blockRange can be null in an empty document, or when the selection is after the last // block (e.g. the very end of the document). Handle this: const contentStart = blockRange ? blockRange.start + 1 : state.selection.from; return { blockRange, contentStart }; }; const addTextAtLineStart = (text: string): Command => (state, dispatch) => { const { contentStart } = getSelectedBlock(state); let transaction = state.tr; transaction = transaction.insertText(text, contentStart); if (dispatch) dispatch(transaction); return true; }; const removeTextAtLineStart = (pattern: RegExp): Command => (state, dispatch) => { const { contentStart, blockRange } = getSelectedBlock(state); const text = state.doc.textBetween(contentStart, blockRange.end); const match = text.match(pattern); if (!match || match.index !== 0) return false; const contentEnd = contentStart + match[0].length; // Verify that the indexes are correct. This also helps verify that there aren't any // non-text nodes (e.g checkboxes) included in the range: const actualText = state.doc.textBetween(contentStart, contentEnd); if (actualText) { const transaction = state.tr.replaceRange(contentStart, contentEnd, Slice.empty); if (dispatch) dispatch(transaction); return true; } return false; }; const listItemTypes = [schema.nodes.list_item, schema.nodes.task_list_item]; const commands: Record = { [EditorCommandType.Undo]: undo, [EditorCommandType.Redo]: redo, [EditorCommandType.SelectAll]: selectAll, [EditorCommandType.Focus]: focusEditor, [EditorCommandType.ToggleBolded]: toggleMark(schema.marks.strong), [EditorCommandType.ToggleItalicized]: toggleMark(schema.marks.emphasis), [EditorCommandType.ToggleCode]: toggleCode, [EditorCommandType.ToggleMath]: (state, dispatch, view) => { const inlineNodeType = schema.nodes.joplinEditableInline; const blockNodeType = schema.nodes.joplinEditableBlock; // If multiple paragraphs are selected, it usually isn't possible to replace them // to inline math. Fall back to block math: const block = !canReplaceSelectionWith(state.selection, inlineNodeType); const nodeType = block ? blockNodeType : inlineNodeType; if (canReplaceSelectionWith(state.selection, nodeType)) { if (view) { const selectedText = getTextBetween(state.doc, state.selection.from, state.selection.to); const content = selectedText || '...'; const blockStart = block ? '$$\n\t' : '$'; return showCreateEditablePrompt({ source: block ? `${blockStart}${content}\n$$` : `${blockStart}${content}$`, inline: !block, cursor: blockStart.length, })(state, dispatch, view); } return true; } return false; }, [EditorCommandType.ToggleComment]: null, [EditorCommandType.DuplicateLine]: null, [EditorCommandType.SortSelectedLines]: null, [EditorCommandType.ToggleNumberedList]: toggleList(schema.nodes.ordered_list), [EditorCommandType.ToggleBulletedList]: toggleList(schema.nodes.bullet_list), [EditorCommandType.ToggleCheckList]: toggleList(schema.nodes.task_list), [EditorCommandType.ToggleBlockQuote]: null, [EditorCommandType.ToggleHeading]: toggleHeading(2), [EditorCommandType.ToggleHeading1]: toggleHeading(1), [EditorCommandType.ToggleHeading2]: toggleHeading(2), [EditorCommandType.ToggleHeading3]: toggleHeading(3), [EditorCommandType.ToggleHeading4]: toggleHeading(4), [EditorCommandType.ToggleHeading5]: toggleHeading(5), [EditorCommandType.InsertHorizontalRule]: null, [EditorCommandType.InsertTable]: (state, dispatch, view) => { if (view) { // See https://github.com/ProseMirror/prosemirror-tables/issues/91 const tr = state.tr.replaceSelectionWith( schema.nodes.table.create(null, [ schema.nodes.table_row.create(null, [ schema.nodes.table_header.createAndFill(), schema.nodes.table_header.createAndFill(), ]), schema.nodes.table_row.create(null, [ schema.nodes.table_cell.createAndFill(), schema.nodes.table_cell.createAndFill(), ]), ]), ); if (dispatch) { dispatch(tr); } } return true; }, [EditorCommandType.InsertCodeBlock]: (state, dispatch, view) => { const sourceBlockStart = '```\n'; const selectedText = getTextBetween(state.doc, state.selection.from, state.selection.to); return showCreateEditablePrompt({ source: `${sourceBlockStart}${selectedText}\n\`\`\``, inline: false, cursor: sourceBlockStart.length, })(state, dispatch, view); }, [EditorCommandType.ToggleSearch]: (state, dispatch, view) => { const command = setSearchVisible(!getSearchVisible(state)); return command(state, dispatch, view); }, [EditorCommandType.ShowSearch]: setSearchVisible(true), [EditorCommandType.HideSearch]: setSearchVisible(false), [EditorCommandType.FindNext]: findNext, [EditorCommandType.FindPrevious]: findPrev, [EditorCommandType.ReplaceNext]: replaceNext, [EditorCommandType.ReplaceAll]: replaceAll, [EditorCommandType.EditLink]: (state: EditorState, dispatch) => { if (dispatch) { const onEvent = getEditorApi(state).onEvent; onEvent({ kind: EditorEventType.EditLink, }); } return true; }, [EditorCommandType.ScrollSelectionIntoView]: (state, dispatch) => { if (dispatch) { dispatch(state.tr.scrollIntoView()); } return true; }, [EditorCommandType.DeleteLine]: (state, dispatch) => { const anchor = state.selection.$anchor; const transaction = state.tr; for (let i = anchor.depth; i > 0; i--) { if (anchor.node(i).isBlock) { const deleteFrom = anchor.before(i); const deleteTo = anchor.after(i); if (dispatch) { dispatch(transaction.deleteRange(deleteFrom, deleteTo)); } return true; } } return false; }, [EditorCommandType.DeleteToLineEnd]: null, [EditorCommandType.DeleteToLineStart]: null, [EditorCommandType.IndentMore]: (state, dispatch, view) => { return listItemTypes.some(type => sinkListItem(type)(state, dispatch, view)) || addTextAtLineStart(' ')(state, dispatch, view); }, [EditorCommandType.IndentLess]: (state, dispatch, view) => { return removeTextAtLineStart(/\s{1,4}/)(state, dispatch, view) || listItemTypes.some(type => liftListItem(type)(state, dispatch, view)); }, [EditorCommandType.IndentAuto]: null, [EditorCommandType.InsertNewlineAndIndent]: null, [EditorCommandType.SwapLineUp]: null, [EditorCommandType.SwapLineDown]: null, [EditorCommandType.GoDocEnd]: (state, dispatch) => { dispatch(state.tr.setSelection(TextSelection.atEnd(state.doc)).scrollIntoView()); return true; }, [EditorCommandType.GoDocStart]: (state, dispatch) => { dispatch(state.tr.setSelection(TextSelection.atStart(state.doc)).scrollIntoView()); return true; }, [EditorCommandType.GoLineStart]: null, [EditorCommandType.GoLineEnd]: null, [EditorCommandType.GoLineUp]: null, [EditorCommandType.GoLineDown]: null, [EditorCommandType.GoPageUp]: null, [EditorCommandType.GoPageDown]: null, [EditorCommandType.GoCharLeft]: null, [EditorCommandType.GoCharRight]: null, [EditorCommandType.UndoSelection]: null, [EditorCommandType.RedoSelection]: null, [EditorCommandType.SelectedText]: null, [EditorCommandType.InsertText]: (state, dispatch, _view, [text]) => { if (dispatch) { dispatch(state.tr.insertText(text)); } return true; }, [EditorCommandType.ReplaceSelection]: null, [EditorCommandType.SetText]: null, [EditorCommandType.JumpToHash]: (state, dispatch, view, [targetHash]) => { return jumpToHash(targetHash)(state, dispatch, view); }, // Table editing commands (handled by prosemirror-tables plugin, not here) [EditorCommandType.TableAddRow]: null, [EditorCommandType.TableAddColumn]: null, [EditorCommandType.TableDeleteRow]: null, [EditorCommandType.TableDeleteColumn]: null, }; export default commands;