feat: Initial clean release of CHM
一些检查失败了
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
一些检查失败了
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
这个提交包含在:
@@ -0,0 +1,164 @@
|
||||
import { EditorView } from 'prosemirror-view';
|
||||
import { EditorCommandType } from '../../types';
|
||||
import commands from './commands';
|
||||
import createTestEditor from '../testing/createTestEditor';
|
||||
import selectDocumentEnd from './selectDocumentEnd';
|
||||
|
||||
const selectAll = (editor: EditorView) => {
|
||||
commands[EditorCommandType.SelectAll](editor.state, editor.dispatch, editor);
|
||||
};
|
||||
|
||||
const moveCursorToEnd = (editor: EditorView) => {
|
||||
selectDocumentEnd(editor.state, editor.dispatch, editor);
|
||||
};
|
||||
|
||||
describe('commands', () => {
|
||||
test('textBold should toggle bold formatting', () => {
|
||||
const editor = createTestEditor({ html: '<p>Test</p>' });
|
||||
|
||||
selectAll(editor);
|
||||
commands[EditorCommandType.ToggleBolded](editor.state, editor.dispatch, editor);
|
||||
|
||||
expect(editor.state.doc.toJSON()).toMatchObject({
|
||||
content: [{
|
||||
type: 'paragraph',
|
||||
content: [{
|
||||
marks: [
|
||||
{ type: 'strong' },
|
||||
],
|
||||
text: 'Test',
|
||||
}],
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
test('toggleHeading should add and remove header formatting', () => {
|
||||
const editor = createTestEditor({ html: '<p>Test</p><p>Test 2</p>' });
|
||||
|
||||
selectAll(editor);
|
||||
commands[EditorCommandType.ToggleHeading](editor.state, editor.dispatch, editor);
|
||||
|
||||
expect(editor.state.doc.toJSON()).toMatchObject({
|
||||
content: [{
|
||||
type: 'heading',
|
||||
content: [{
|
||||
text: 'Test',
|
||||
}],
|
||||
}, {
|
||||
type: 'heading',
|
||||
content: [{
|
||||
text: 'Test 2',
|
||||
}],
|
||||
}],
|
||||
});
|
||||
|
||||
commands[EditorCommandType.ToggleHeading](editor.state, editor.dispatch, editor);
|
||||
|
||||
expect(editor.state.doc.toJSON()).toMatchObject({
|
||||
content: [{
|
||||
type: 'paragraph',
|
||||
content: [{
|
||||
text: 'Test',
|
||||
}],
|
||||
}, {
|
||||
type: 'paragraph',
|
||||
content: [{
|
||||
text: 'Test 2',
|
||||
}],
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
test('jumpToHash should scroll to a heading with a matching hash', () => {
|
||||
const editor = createTestEditor({ html: '<h1>Test heading 1</h1><p>Test 2</p><h2>Test heading 2</h2><p>Test 3</p>' });
|
||||
|
||||
const jumpToHash = (hash: string) => {
|
||||
return commands[EditorCommandType.JumpToHash](editor.state, editor.dispatch, editor, [hash]);
|
||||
};
|
||||
|
||||
expect(jumpToHash('test-heading-1')).toBe(true);
|
||||
expect(editor.state.selection.$anchor.parent.textContent).toBe('Test heading 1');
|
||||
|
||||
expect(jumpToHash('test-heading-2')).toBe(true);
|
||||
expect(editor.state.selection.$anchor.parent.textContent).toBe('Test heading 2');
|
||||
});
|
||||
|
||||
test('textTable should insert a table', () => {
|
||||
const editor = createTestEditor({ html: '<p></p>' });
|
||||
|
||||
commands[EditorCommandType.InsertTable](editor.state, editor.dispatch, editor);
|
||||
|
||||
expect(editor.state.doc.toJSON()).toMatchObject({
|
||||
content: [{
|
||||
content: [
|
||||
{ type: 'table_row' },
|
||||
{ type: 'table_row' },
|
||||
],
|
||||
type: 'table',
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
label: 'should indent the selected paragraph',
|
||||
before: '<p>Test</p>',
|
||||
select: selectAll,
|
||||
expectedDoc: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: ' Test' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'should not throw an error if the selection is at the end of the document (after the last block)',
|
||||
before: '<p>Test</p><p>Test 2</p>',
|
||||
select: moveCursorToEnd,
|
||||
expectedDoc: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'Test' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'Test 2' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: ' ' }] },
|
||||
],
|
||||
},
|
||||
])('indentMore should add spaces to the beginning of the selected lines ($label)', ({ before, select, expectedDoc }) => {
|
||||
const editor = createTestEditor({ html: before });
|
||||
select(editor);
|
||||
|
||||
commands[EditorCommandType.IndentMore](editor.state, editor.dispatch, editor);
|
||||
|
||||
expect(editor.state.doc.toJSON()).toMatchObject({
|
||||
content: expectedDoc,
|
||||
});
|
||||
});
|
||||
|
||||
test('indentLess should remove spaces from the beginning of the line', () => {
|
||||
const editor = createTestEditor({ html: '<p> test</p>' });
|
||||
selectAll(editor);
|
||||
|
||||
commands[EditorCommandType.IndentLess](editor.state, editor.dispatch, editor);
|
||||
|
||||
expect(editor.state.doc.toJSON()).toMatchObject({
|
||||
content: [{
|
||||
content: [
|
||||
{ type: 'text', text: 'test' },
|
||||
],
|
||||
type: 'paragraph',
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
test('goDocEnd should move the cursor to the end of the document', () => {
|
||||
const editor = createTestEditor({ html: '<p>First</p><p>Last</p>' });
|
||||
|
||||
commands[EditorCommandType.GoDocEnd](editor.state, editor.dispatch, editor);
|
||||
|
||||
expect(editor.state.selection.from).toBe(editor.state.doc.content.size - 1);
|
||||
});
|
||||
|
||||
test('goDocStart should move the cursor to the start of the document', () => {
|
||||
const editor = createTestEditor({ html: '<p>First</p><p>Last</p>' });
|
||||
|
||||
moveCursorToEnd(editor);
|
||||
commands[EditorCommandType.GoDocStart](editor.state, editor.dispatch, editor);
|
||||
|
||||
expect(editor.state.selection.from).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
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;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Command } from 'prosemirror-state';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
|
||||
const focusEditor: Command = (_state, _dispatch?, view?) => {
|
||||
if (view) {
|
||||
focus('commands::focus', view);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export default focusEditor;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Command, TextSelection } from 'prosemirror-state';
|
||||
|
||||
const selectDocumentEnd: Command = (state, dispatch) => {
|
||||
// nodeSize is defined to be the length of the node content plus two (one for the start
|
||||
// and one for the end token). However, the main document has no start/end tokens, so subtract two.
|
||||
const position = state.doc.nodeSize - 2;
|
||||
|
||||
const endAlreadySelected = position === state.selection.from && state.selection.to === state.selection.from;
|
||||
if (endAlreadySelected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const transaction = state.tr.setSelection(TextSelection.create(state.doc, position));
|
||||
|
||||
if (dispatch) {
|
||||
dispatch(transaction);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default selectDocumentEnd;
|
||||
在新工单中引用
屏蔽一个用户