一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
290 行
11 KiB
TypeScript
290 行
11 KiB
TypeScript
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, ExtendedCommand|null> = {
|
|
[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;
|