From 25503eeea35c15c3c080753f106830b150958ed4 Mon Sep 17 00:00:00 2001 From: SIHAN LI Date: Tue, 25 Feb 2025 15:15:59 -0500 Subject: [PATCH 01/21] [Env] Bump version to `0.7.2` --- assets/locale/en.json | 2 +- assets/locale/zh-cn.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- product.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/assets/locale/en.json b/assets/locale/en.json index 8b76a58a9..8e3118af0 100644 --- a/assets/locale/en.json +++ b/assets/locale/en.json @@ -5,7 +5,7 @@ "--------------------------------------------------------------------------------------------", "Do not edit this file. It is machine generated." ], - "version": "0.7.1", + "version": "0.7.2", "contents": { "editor/common/markdown": { "code": "Code", diff --git a/assets/locale/zh-cn.json b/assets/locale/zh-cn.json index 7c651c703..42efc793c 100644 --- a/assets/locale/zh-cn.json +++ b/assets/locale/zh-cn.json @@ -5,7 +5,7 @@ "--------------------------------------------------------------------------------------------", "Do not edit this file. It is machine generated." ], - "version": "0.7.1", + "version": "0.7.2", "contents": { "platform/i18n/browser/i18nService": { "relaunchDisplayLanguageMessage": "重新启动 {name} 以切换到语言:{languageName}?" diff --git a/package-lock.json b/package-lock.json index 4c206d021..27f5c16db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nota", - "version": "0.7.1", + "version": "0.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nota", - "version": "0.7.1", + "version": "0.7.2", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 1ca8b0434..a72101805 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nota", - "version": "0.7.1", + "version": "0.7.2", "description": "A cross-platform markdown note-taking app.", "main": "./dist/main-bundle.js", "type": "commonjs", diff --git a/product.json b/product.json index 083606386..5ab3d2cde 100644 --- a/product.json +++ b/product.json @@ -8,6 +8,6 @@ "projectName": "nota", "applicationName": "nota", "description": "A cross-platform markdown note-taking app.", - "version": "0.7.1", + "version": "0.7.2", "license": "MIT" } \ No newline at end of file From c5f41e02fe9d70a701fe78991f0f5910849979fc Mon Sep 17 00:00:00 2001 From: SIHAN LI Date: Tue, 25 Feb 2025 17:02:00 -0500 Subject: [PATCH 02/21] [Rename] from `editorCommands.ts` to `command.contrib.ts` --- .../contrib/command/{editorCommands.ts => command.contrib.ts} | 0 src/editor/contrib/command/command.ts | 2 +- test/editor/contrib/editorCommands.test.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/editor/contrib/command/{editorCommands.ts => command.contrib.ts} (100%) diff --git a/src/editor/contrib/command/editorCommands.ts b/src/editor/contrib/command/command.contrib.ts similarity index 100% rename from src/editor/contrib/command/editorCommands.ts rename to src/editor/contrib/command/command.contrib.ts diff --git a/src/editor/contrib/command/command.ts b/src/editor/contrib/command/command.ts index 00cfe49db..c2d102039 100644 --- a/src/editor/contrib/command/command.ts +++ b/src/editor/contrib/command/command.ts @@ -6,7 +6,7 @@ import { ILogService } from "src/base/common/logger"; import { EditorExtensionIDs } from "src/editor/contrib/builtInExtensionList"; import { EditorExtension, IEditorExtension } from "src/editor/common/editorExtension"; import { IEditorWidget } from "src/editor/editorWidget"; -import { registerBasicEditorCommands } from "src/editor/contrib/command/editorCommands"; +import { registerBasicEditorCommands } from "src/editor/contrib/command/command.contrib"; import { Command } from "src/platform/command/common/command"; import { ICommandService } from "src/platform/command/common/commandService"; import { RegistrantType } from "src/platform/registrant/common/registrant"; diff --git a/test/editor/contrib/editorCommands.test.ts b/test/editor/contrib/editorCommands.test.ts index c13fd2059..d27d56497 100644 --- a/test/editor/contrib/editorCommands.test.ts +++ b/test/editor/contrib/editorCommands.test.ts @@ -2,7 +2,7 @@ import * as assert from 'assert'; import { ProseUtilsTest } from 'test/editor/view/editorHelpers'; import ist from 'ist'; import { ProseEditorState, ProseNode, ProseNodeSelection, ProseSchema, ProseSelection, ProseTextSelection } from 'src/editor/common/proseMirror'; -import { EditorCommandBase, EditorCommands } from 'src/editor/contrib/command/editorCommands'; +import { EditorCommandBase, EditorCommands } from 'src/editor/contrib/command/command.contrib'; import { nullObject } from 'test/utils/helpers'; const { doc, p, blockquote, hr, ul, li } = ProseUtilsTest.defaultNodes; From d12e445825666bacdea34c17cfa9bd1d6de27511 Mon Sep 17 00:00:00 2001 From: SIHAN LI Date: Tue, 25 Feb 2025 17:12:14 -0500 Subject: [PATCH 03/21] [Chore] abstract out a new file `editorCommand.ts` --- src/editor/contrib/command/command.contrib.ts | 42 +++++++------------ src/editor/contrib/command/editorCommand.ts | 19 +++++++++ 2 files changed, 33 insertions(+), 28 deletions(-) create mode 100644 src/editor/contrib/command/editorCommand.ts diff --git a/src/editor/contrib/command/command.contrib.ts b/src/editor/contrib/command/command.contrib.ts index e55a82843..0ec1d17aa 100644 --- a/src/editor/contrib/command/command.contrib.ts +++ b/src/editor/contrib/command/command.contrib.ts @@ -6,13 +6,14 @@ import { MarkEnum, TokenEnum } from "src/editor/common/markdown"; import { ProseEditorState, ProseTransaction, ProseAllSelection, ProseTextSelection, ProseNodeSelection, ProseEditorView, ProseReplaceStep, ProseSlice, ProseFragment, ProseNode, ProseSelection, ProseContentMatch, ProseMarkType, ProseAttrs, ProseSelectionRange, ProseNodeType, ProseResolvedPos, ProseCursor } from "src/editor/common/proseMirror"; import { ProseTools } from "src/editor/common/proseUtility"; import { EditorSchema } from "src/editor/model/schema"; -import { Command, ICommandSchema, buildChainCommand } from "src/platform/command/common/command"; +import { Command, ICommandSchema } from "src/platform/command/common/command"; import { CreateContextKeyExpr } from "src/platform/context/common/contextKeyExpr"; import { IServiceProvider } from "src/platform/instantiation/common/instantiation"; import { EditorContextKeys } from "src/editor/common/editorContextKeys"; import { IS_MAC } from "src/base/common/platform"; import { redo, undo } from "prosemirror-history"; import { INotificationService } from "src/workbench/services/notification/notification"; +import { buildEditorCommand, EditorCommandBase } from "src/editor/contrib/command/editorCommand"; /** * [FILE OUTLINE] @@ -31,7 +32,7 @@ const whenEditorWritable = CreateContextKeyExpr.And(EditorContextKeys.editorFocu export function registerBasicEditorCommands(extension: IEditorCommandExtension, logService: ILogService): void { __registerToggleMarkCommands(extension, logService); __registerHeadingCommands(extension, logService); - __registerOtherCommands(extension); + __registerBasicCommands(extension); } function getPlatformShortcut(ctrl: string, meta: string): string { @@ -103,8 +104,8 @@ function __registerHeadingCommands(extension: IEditorCommandExtension, logServic } } -function __registerOtherCommands(extension: IEditorCommandExtension): void { - extension.registerCommand(__buildEditorCommand( +function __registerBasicCommands(extension: IEditorCommandExtension): void { + extension.registerCommand(buildEditorCommand( { id: 'editor-esc', when: whenEditorReadonly, @@ -116,7 +117,7 @@ function __registerOtherCommands(extension: IEditorCommandExtension): void { ['Escape'] ); - extension.registerCommand(__buildEditorCommand( + extension.registerCommand(buildEditorCommand( { id: 'editor-enter', when: whenEditorWritable, @@ -131,7 +132,7 @@ function __registerOtherCommands(extension: IEditorCommandExtension): void { ['Enter'] ); - extension.registerCommand(__buildEditorCommand( + extension.registerCommand(buildEditorCommand( { id: 'editor-backspace', when: whenEditorWritable, @@ -145,7 +146,7 @@ function __registerOtherCommands(extension: IEditorCommandExtension): void { ['Backspace'] ); - extension.registerCommand(__buildEditorCommand( + extension.registerCommand(buildEditorCommand( { id: 'editor-delete', when: whenEditorWritable, @@ -159,7 +160,7 @@ function __registerOtherCommands(extension: IEditorCommandExtension): void { ['Delete', getPlatformShortcut('Ctrl+Delete', 'Meta+Delete')] ); - extension.registerCommand(__buildEditorCommand( + extension.registerCommand(buildEditorCommand( { id: 'editor-select-all', when: whenEditorReadonly, @@ -172,7 +173,7 @@ function __registerOtherCommands(extension: IEditorCommandExtension): void { ); // @fix Doesn't work with CM, guess bcz CM is focused but PM is not. - extension.registerCommand(__buildEditorCommand( + extension.registerCommand(buildEditorCommand( { id: 'editor-exit-code-block', when: whenEditorReadonly, @@ -184,7 +185,7 @@ function __registerOtherCommands(extension: IEditorCommandExtension): void { [getPlatformShortcut('Ctrl+Enter', 'Meta+Enter')] ); - extension.registerCommand(__buildEditorCommand( + extension.registerCommand(buildEditorCommand( { id: 'editor-insert-hard-break', when: whenEditorWritable, @@ -197,7 +198,7 @@ function __registerOtherCommands(extension: IEditorCommandExtension): void { ['Shift+Enter', getPlatformShortcut('Ctrl+Enter', 'Meta+Enter')] ); - extension.registerCommand(__buildEditorCommand( + extension.registerCommand(buildEditorCommand( { id: 'editor-save', when: whenEditorWritable, @@ -209,7 +210,7 @@ function __registerOtherCommands(extension: IEditorCommandExtension): void { [getPlatformShortcut('Ctrl+S', 'Meta+S')] ); - extension.registerCommand(__buildEditorCommand( + extension.registerCommand(buildEditorCommand( { id: 'editor-undo', when: whenEditorWritable, @@ -221,7 +222,7 @@ function __registerOtherCommands(extension: IEditorCommandExtension): void { [getPlatformShortcut('Ctrl+Z', 'Meta+Z')] ); - extension.registerCommand(__buildEditorCommand( + extension.registerCommand(buildEditorCommand( { id: 'editor-redo', when: whenEditorWritable, @@ -234,21 +235,6 @@ function __registerOtherCommands(extension: IEditorCommandExtension): void { ); } -function __buildEditorCommand(schema: ICommandSchema, ctors: (typeof Command)[]): Command { - if (ctors.length === 1) { - const command = ctors[0]!; - return new command(schema); - } - return buildChainCommand(schema, ctors); -} - -/** - * @class A base class for every command in the {@link EditorCommandsBasic}. - */ -export abstract class EditorCommandBase extends Command { - public abstract override run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean | Promise; -} - /** * @description Contains a list of commands specific for editor. Every basic * command is responsible for a very specific editor job. Usually these commands diff --git a/src/editor/contrib/command/editorCommand.ts b/src/editor/contrib/command/editorCommand.ts new file mode 100644 index 000000000..c3b05d553 --- /dev/null +++ b/src/editor/contrib/command/editorCommand.ts @@ -0,0 +1,19 @@ +import { ProseEditorState, ProseTransaction, ProseEditorView } from "src/editor/common/proseMirror"; +import { IEditorWidget } from "src/editor/editorWidget"; +import { buildChainCommand, Command, ICommandSchema } from "src/platform/command/common/command"; +import { IServiceProvider } from "src/platform/instantiation/common/instantiation"; + +/** + * @class A base class for every command in the {@link EditorCommandsBasic}. + */ +export abstract class EditorCommandBase extends Command { + public abstract override run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean | Promise; +} + +export function buildEditorCommand(schema: ICommandSchema, ctors: (typeof Command)[]): Command { + if (ctors.length === 1) { + const command = ctors[0]!; + return new command(schema); + } + return buildChainCommand(schema, ctors); +} From c360c27d76e44ff8f4a1f0c5cb2e0400c81e9a56 Mon Sep 17 00:00:00 2001 From: SIHAN LI Date: Tue, 25 Feb 2025 17:46:57 -0500 Subject: [PATCH 04/21] [Chore] --- src/editor/common/proseMirror.ts | 1 + test/editor/contrib/editorCommands.test.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/editor/common/proseMirror.ts b/src/editor/common/proseMirror.ts index 3085758c3..6162c0ddb 100644 --- a/src/editor/common/proseMirror.ts +++ b/src/editor/common/proseMirror.ts @@ -4,6 +4,7 @@ import { Selection } from "prosemirror-state"; export { Step as ProseStep, ReplaceStep as ProseReplaceStep, + ReplaceAroundStep as ProseReplaceAroundStep, Mapping as ProseMapping, StepMap as ProseStepMapping, } from "prosemirror-transform"; diff --git a/test/editor/contrib/editorCommands.test.ts b/test/editor/contrib/editorCommands.test.ts index d27d56497..aba1ac0ff 100644 --- a/test/editor/contrib/editorCommands.test.ts +++ b/test/editor/contrib/editorCommands.test.ts @@ -2,8 +2,9 @@ import * as assert from 'assert'; import { ProseUtilsTest } from 'test/editor/view/editorHelpers'; import ist from 'ist'; import { ProseEditorState, ProseNode, ProseNodeSelection, ProseSchema, ProseSelection, ProseTextSelection } from 'src/editor/common/proseMirror'; -import { EditorCommandBase, EditorCommands } from 'src/editor/contrib/command/command.contrib'; +import { EditorCommands } from 'src/editor/contrib/command/command.contrib'; import { nullObject } from 'test/utils/helpers'; +import { EditorCommandBase } from 'src/editor/contrib/command/editorCommand'; const { doc, p, blockquote, hr, ul, li } = ProseUtilsTest.defaultNodes; // import {schema, eq, doc, blockquote, pre, h1, p, li, ol, ul, em, strong, hr, img} from "prosemirror-test-builder"; From 730c25dfa95c42f46795a7b2ada3701a67853b5b Mon Sep 17 00:00:00 2001 From: SIHAN LI Date: Tue, 25 Feb 2025 20:31:26 -0500 Subject: [PATCH 05/21] [Fix] typo --- src/editor/view/editorView.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/editor/view/editorView.ts b/src/editor/view/editorView.ts index bcd3cc8d4..c605b6a03 100644 --- a/src/editor/view/editorView.ts +++ b/src/editor/view/editorView.ts @@ -40,7 +40,7 @@ export class EditorView extends Disposable implements IEditorView { // [events] - get onDidBlur() { return this._view.onDidFocus; } + get onDidBlur() { return this._view.onDidBlur; } get onDidFocus() { return this._view.onDidFocus; } get onBeforeRender() { return this._view.onBeforeRender; } From 618793fcaeeb346ceb94fbb888a2bf9b11282c2b Mon Sep 17 00:00:00 2001 From: SIHAN LI Date: Tue, 25 Feb 2025 20:32:58 -0500 Subject: [PATCH 06/21] [Feat] list operation (split, lift, sink) --- src/editor/contrib/command/command.contrib.ts | 2 + .../contrib/command/commandList.contrib.ts | 283 ++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 src/editor/contrib/command/commandList.contrib.ts diff --git a/src/editor/contrib/command/command.contrib.ts b/src/editor/contrib/command/command.contrib.ts index 0ec1d17aa..f8acf0d24 100644 --- a/src/editor/contrib/command/command.contrib.ts +++ b/src/editor/contrib/command/command.contrib.ts @@ -14,6 +14,7 @@ import { IS_MAC } from "src/base/common/platform"; import { redo, undo } from "prosemirror-history"; import { INotificationService } from "src/workbench/services/notification/notification"; import { buildEditorCommand, EditorCommandBase } from "src/editor/contrib/command/editorCommand"; +import { registerListCommands } from "src/editor/contrib/command/commandList.contrib"; /** * [FILE OUTLINE] @@ -33,6 +34,7 @@ export function registerBasicEditorCommands(extension: IEditorCommandExtension, __registerToggleMarkCommands(extension, logService); __registerHeadingCommands(extension, logService); __registerBasicCommands(extension); + registerListCommands(extension, logService); } function getPlatformShortcut(ctrl: string, meta: string): string { diff --git a/src/editor/contrib/command/commandList.contrib.ts b/src/editor/contrib/command/commandList.contrib.ts new file mode 100644 index 000000000..99f1fccbc --- /dev/null +++ b/src/editor/contrib/command/commandList.contrib.ts @@ -0,0 +1,283 @@ +import { canJoin, canSplit, liftTarget } from "prosemirror-transform"; +import { ILogService } from "src/base/common/logger"; +import { TokenEnum } from "src/editor/common/markdown"; +import { ProseEditorState, ProseTransaction, ProseEditorView, ProseNodeType, ProseFragment, ProseSlice, ProseReplaceAroundStep, ProseNodeRange, ProseSelection, ProseNodeSelection, ProseAttrs } from "src/editor/common/proseMirror"; +import { IEditorCommandExtension } from "src/editor/contrib/command/command"; +import { EditorCommandBase } from "src/editor/contrib/command/editorCommand"; +import { IEditorWidget } from "src/editor/editorWidget"; +import { Command, ICommandSchema } from "src/platform/command/common/command"; +import { IServiceProvider } from "src/platform/instantiation/common/instantiation"; + +export function registerListCommands(extension: IEditorCommandExtension, logService: ILogService): void { + const schema = extension.getEditorSchema().unwrap(); + + const listItemType = schema.getNodeType(TokenEnum.ListItem); + if (!listItemType) { + logService.warn(extension.id, `Cannot register the editor command (${TokenEnum.ListItem}) because the node type does not exists in the editor schema.`); + return; + } + + extension.registerCommand( + EditorListCommands.splitListItem( + { + id: 'editor-split-list-item', + when: null, + }, + listItemType, + undefined, + ), + ['Enter'] + ); + + extension.registerCommand( + EditorListCommands.sinkListItem( + { + id: 'editor-sink-list-item', + when: null, + }, + listItemType, + ), + ['Tab'] + ); + + extension.registerCommand( + EditorListCommands.liftListItem( + { + id: 'editor-lift-list-item', + when: null, + }, + listItemType, + ), + ['Shift+Tab'] + ); +} + +export namespace EditorListCommands { + + export function splitListItem(schema: ICommandSchema, listItemType: TType, itemAttrs?: ProseAttrs): Command { + return new class extends EditorCommandBase { + public override run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean | Promise { + const { $from, $to, node } = state.selection as ProseNodeSelection; + if ((node && node.isBlock) || $from.depth < 2 || !$from.sameParent($to)) { + return false; + } + + const grandParent = $from.node(-1); + if (grandParent.type !== listItemType) { + return false; + } + + if ($from.parent.content.size === 0 && $from.node(-1).childCount === $from.indexAfter(-1)) { + // In an empty block. If this is a nested list, the wrapping + // list item should be split. Otherwise, bail out and let next + // command handle lifting. + if ($from.depth === 3 || $from.node(-3).type !== listItemType || + $from.index(-2) !== $from.node(-2).childCount - 1 + ) { + return false; + } + + if (dispatch) { + let wrap = ProseFragment.empty; + const depthBefore = $from.index(-1) ? 1 : $from.index(-2) ? 2 : 3; + + // Build a fragment containing empty versions of the structure + // from the outer list item to the parent node of the cursor + for (let d = $from.depth - depthBefore; d >= $from.depth - 3; d--) + wrap = ProseFragment.from($from.node(d).copy(wrap)); + + const depthAfter = $from.indexAfter(-1) < $from.node(-2).childCount + ? 1 + : $from.indexAfter(-2) < $from.node(-3).childCount + ? 2 + : 3; + + // Add a second list item with an empty default start node + wrap = wrap.append(ProseFragment.from(listItemType.createAndFill())); + const start = $from.before($from.depth - (depthBefore - 1)); + const tr = state.tr.replace(start, $from.after(-depthAfter), new ProseSlice(wrap, 4 - depthBefore, 0)); + let sel = -1; + tr.doc.nodesBetween(start, tr.doc.content.size, (node, pos) => { + if (sel > -1) { + return false; + } + if (node.isTextblock && node.content.size === 0) { + sel = pos + 1; + } + }); + if (sel > -1) { + tr.setSelection(ProseSelection.near(tr.doc.resolve(sel))); + } + dispatch(tr.scrollIntoView()); + } + return true; + } + const nextType = $to.pos === $from.end() + ? grandParent.contentMatchAt(0).defaultType + : null; + const tr = state.tr.delete($from.pos, $to.pos); + const types = nextType + ? [itemAttrs + ? { type: listItemType, attrs: itemAttrs } + : null, + { type: nextType }] + : undefined; + + if (!canSplit(tr.doc, $from.pos, 2, types)) { + return false; + } + + if (dispatch) { + dispatch(tr.split($from.pos, 2, types).scrollIntoView()); + } + return true; + } + }(schema); + } + + export function liftListItem(schema: ICommandSchema, listItemType: TType): Command { + return new class extends EditorCommandBase { + public override run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean | Promise { + const { $from, $to } = state.selection; + const range = $from.blockRange($to, node => node.childCount > 0 && node.firstChild!.type === listItemType); + if (!range) { + return false; + } + if (!dispatch) { + return true; + } + + if ($from.node(range.depth - 1).type === listItemType) { + // Inside a parent list + return this.__liftToOuterList(state, dispatch, listItemType, range); + } else { + // Outer list node + return this.__liftOutOfList(state, dispatch, range); + } + } + + private __liftToOuterList(state: ProseEditorState, dispatch: (tr: ProseTransaction) => void, listItemType: ProseNodeType, range: ProseNodeRange) { + const tr = state.tr, end = range.end, endOfList = range.$to.end(range.depth); + if (end < endOfList) { + // There are siblings after the lifted items, which must become + // children of the last item + tr.step(new ProseReplaceAroundStep(end - 1, endOfList, end, endOfList, + new ProseSlice(ProseFragment.from(listItemType.create(null, range.parent.copy())), 1, 0), 1, true)); + range = new ProseNodeRange(tr.doc.resolve(range.$from.pos), tr.doc.resolve(endOfList), range.depth); + } + + const target = liftTarget(range); + if (target === null) { + return false; + } + + tr.lift(range, target); + const $after = tr.doc.resolve(tr.mapping.map(end, -1) - 1); + if (canJoin(tr.doc, $after.pos) && $after.nodeBefore!.type === $after.nodeAfter!.type) { + tr.join($after.pos); + } + + dispatch(tr.scrollIntoView()); + return true; + } + + private __liftOutOfList(state: ProseEditorState, dispatch: (tr: ProseTransaction) => void, range: ProseNodeRange) { + const tr = state.tr, list = range.parent; + + // Merge the list items into a single big item + for (let pos = range.end, i = range.endIndex - 1, e = range.startIndex; i > e; i--) { + pos -= list.child(i).nodeSize; + tr.delete(pos - 1, pos + 1); + } + + const $start = tr.doc.resolve(range.start), item = $start.nodeAfter!; + if (tr.mapping.map(range.end) !== range.start + $start.nodeAfter!.nodeSize) { + return false; + } + + const atStart = range.startIndex === 0, atEnd = range.endIndex === list.childCount; + const parent = $start.node(-1), indexBefore = $start.index(-1); + if (!parent.canReplace(indexBefore + (atStart ? 0 : 1), indexBefore + 1, + item.content.append(atEnd ? ProseFragment.empty : ProseFragment.from(list))) + ) { + return false; + } + + const start = $start.pos, end = start + item.nodeSize; + // Strip off the surrounding list. At the sides where we're not at + // the end of the list, the existing list is closed. At sides where + // this is the end, it is overwritten to its end. + tr.step( + new ProseReplaceAroundStep( + start - (atStart ? 1 : 0), + end + (atEnd ? 1 : 0), + start + 1, + end - 1, + new ProseSlice( + (atStart + ? ProseFragment.empty + : ProseFragment.from(list.copy(ProseFragment.empty))).append( + atEnd + ? ProseFragment.empty + : ProseFragment.from(list.copy(ProseFragment.empty)) + ), + atStart ? 0 : 1, + atEnd ? 0 : 1 + ), + atStart ? 0 : 1 + )); + dispatch(tr.scrollIntoView()); + return true; + } + }(schema); + } + + export function sinkListItem(schema: ICommandSchema, listItemType: TType): Command { + return new class extends EditorCommandBase { + public override run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean | Promise { + const { $from, $to } = state.selection; + const range = $from.blockRange($to, node => node.childCount > 0 && node.firstChild!.type === listItemType); + if (!range) { + return false; + } + const startIndex = range.startIndex; + if (startIndex === 0) { + return false; + } + const parent = range.parent, nodeBefore = parent.child(startIndex - 1); + if (nodeBefore.type !== listItemType) { + return false; + } + + if (dispatch) { + const nestedBefore = nodeBefore.lastChild && nodeBefore.lastChild.type === parent.type; + const inner = ProseFragment.from(nestedBefore ? listItemType.create() : null); + const slice = new ProseSlice( + ProseFragment.from( + listItemType.create( + null, + ProseFragment.from(parent.type.create(null, inner)) + ) + ), + nestedBefore ? 3 : 1, + 0, + ); + const before = range.start, after = range.end; + dispatch(state.tr.step( + new ProseReplaceAroundStep( + before - (nestedBefore ? 3 : 1), + after, + before, + after, + slice, + 1, + true, + )) + .scrollIntoView()); + } + + return true; + } + }(schema); + } +} \ No newline at end of file From e72c3560b582e7f3aa71bf7d608e1849cb3558da Mon Sep 17 00:00:00 2001 From: SIHAN LI Date: Tue, 25 Feb 2025 20:44:57 -0500 Subject: [PATCH 07/21] [Refactor] improve shortcut handling by prioritizing valid candidates instead of only executing one command --- .../services/shortcut/shortcutRegistrant.ts | 2 + .../services/shortcut/shortcutService.ts | 48 +++++++++---------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/workbench/services/shortcut/shortcutRegistrant.ts b/src/workbench/services/shortcut/shortcutRegistrant.ts index fc3063a57..30c2241eb 100644 --- a/src/workbench/services/shortcut/shortcutRegistrant.ts +++ b/src/workbench/services/shortcut/shortcutRegistrant.ts @@ -41,6 +41,8 @@ interface IShortcutBase { /** * When a shortcut is registered with more than one command. The weight will * tell the program which command should choose be execute. + * + * The lower the number, the higher the priority. */ readonly weight: ShortcutWeight; diff --git a/src/workbench/services/shortcut/shortcutService.ts b/src/workbench/services/shortcut/shortcutService.ts index c429a3fa3..e9b12b638 100644 --- a/src/workbench/services/shortcut/shortcutService.ts +++ b/src/workbench/services/shortcut/shortcutService.ts @@ -7,7 +7,7 @@ import { IFileService } from "src/platform/files/common/fileService"; import { IService, createService } from "src/platform/instantiation/common/decorator"; import { ILogService } from "src/base/common/logger"; import { IBrowserLifecycleService, ILifecycleService, LifecyclePhase } from "src/platform/lifecycle/browser/browserLifecycleService"; -import { IShortcutReference, IShortcutRegistrant, ShortcutWeight } from "src/workbench/services/shortcut/shortcutRegistrant"; +import { IShortcutRegistrant, ShortcutWeight } from "src/workbench/services/shortcut/shortcutRegistrant"; import { RegistrantType } from "src/platform/registrant/common/registrant"; import { IBrowserEnvironmentService } from "src/platform/environment/common/environment"; import { Emitter, Register } from "src/base/common/event"; @@ -93,42 +93,42 @@ export class ShortcutService extends Disposable implements IShortcutService { ) { super(); this._shortcutRegistrant = registrantService.getRegistrant(RegistrantType.Shortcut); - this._resource = URI.join(environmentService.appConfigurationPath, SHORTCUT_CONFIG_NAME); // listen to keyboard events this.__register(keyboardService.onKeydown(e => { const pressed = new Shortcut(e.ctrl, e.shift, e.alt, e.meta, e.key); + // filter out only the valid shortcuts const candidates = this._shortcutRegistrant.findShortcut(pressed); - let shortcut: IShortcutReference | undefined; - - for (const candidate of candidates) { - if (this.contextService.contextMatchExpr(candidate.when)) { - if (!shortcut) { - shortcut = candidate; - continue; - } - - // the candidate weight is higher than the current one. - if (candidate.weight < shortcut.weight) { - shortcut = candidate; - } - } - } + const validCandidates = candidates + .filter(each => this.contextService.contextMatchExpr(each.when)) + .sort((candidate1, candidate2) => candidate1.weight - candidate2.weight); // no valid registered shortcuts can be triggered - if (!shortcut) { + if (!validCandidates.length) { return; } - // executing the corresponding command - trySafe( - () => this.commandService.executeCommand(shortcut.commandID, ...(shortcut.commandArgs ?? [])), - { - onError: err => logService.error('[ShortcutService]', `Error encounters. Executing shortcut '${pressed.toString()}' with command '${shortcut?.commandID}'`, err) + // Executing the corresponding commands based on priority + (async () => { + for (const candidate of validCandidates) { + const ret = await trySafe>( + () => this.commandService.executeCommand(candidate.commandID, ...(candidate.commandArgs ?? [])), + { + onError: err => logService.error('[ShortcutService]', `Error encounters. Executing shortcut '${pressed.toString()}' with command '${candidate?.commandID}'`, err) + } + ); + + /** + * Let the client has a chance to return `true` if the + * shortcut is already handled. + */ + if (ret === true) { + break; + } } - ); + })(); })); // When the browser side is ready, we update registrations by reading from disk. From c7d368a34d8d657561559ea6294e17c08f3a91f4 Mon Sep 17 00:00:00 2001 From: SIHAN LI Date: Tue, 25 Feb 2025 20:47:35 -0500 Subject: [PATCH 08/21] [Feat] enhance shortcut command execution by allowing commandArgs to be a function --- src/workbench/services/shortcut/shortcutRegistrant.ts | 3 ++- src/workbench/services/shortcut/shortcutService.ts | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/workbench/services/shortcut/shortcutRegistrant.ts b/src/workbench/services/shortcut/shortcutRegistrant.ts index 30c2241eb..a2857d659 100644 --- a/src/workbench/services/shortcut/shortcutRegistrant.ts +++ b/src/workbench/services/shortcut/shortcutRegistrant.ts @@ -12,6 +12,7 @@ import { rendererWorkbenchShortcutRegister } from "src/workbench/services/workbe import { IS_MAC } from "src/base/common/platform"; import { Arrays } from "src/base/common/utilities/array"; import { Emitter, Register } from "src/base/common/event"; +import { IO } from "src/base/common/utilities/functional"; /** * The less the number is, the higher the priority of the shortcut is. @@ -29,7 +30,7 @@ interface IShortcutBase { /** * The arguments for the command when it is executed. */ - readonly commandArgs: TArgs; + readonly commandArgs: TArgs | IO; /** * The command will only be executed when the expression (precondition) diff --git a/src/workbench/services/shortcut/shortcutService.ts b/src/workbench/services/shortcut/shortcutService.ts index e9b12b638..11e699bf8 100644 --- a/src/workbench/services/shortcut/shortcutService.ts +++ b/src/workbench/services/shortcut/shortcutService.ts @@ -20,6 +20,7 @@ import { FileOperationError } from "src/base/common/files/file"; import { errorToMessage } from "src/base/common/utilities/panic"; import { trySafe } from "src/base/common/error"; import { Strings } from "src/base/common/utilities/string"; +import { isFunction } from "src/base/common/utilities/type"; export const SHORTCUT_CONFIG_NAME = 'shortcut.config.json'; export const IShortcutService = createService('shortcut-service'); @@ -114,8 +115,10 @@ export class ShortcutService extends Disposable implements IShortcutService { (async () => { for (const candidate of validCandidates) { const ret = await trySafe>( - () => this.commandService.executeCommand(candidate.commandID, ...(candidate.commandArgs ?? [])), - { + () => { + const args = isFunction(candidate.commandArgs) ? candidate.commandArgs() : candidate.commandArgs; + this.commandService.executeCommand(candidate.commandID, ...args); + }, { onError: err => logService.error('[ShortcutService]', `Error encounters. Executing shortcut '${pressed.toString()}' with command '${candidate?.commandID}'`, err) } ); From 885b65869acbc5906c5d687668ab8b40cb235256 Mon Sep 17 00:00:00 2001 From: SIHAN LI Date: Wed, 26 Feb 2025 00:17:28 -0500 Subject: [PATCH 09/21] [Fix] disable shortcut saving mechanism --- src/workbench/services/shortcut/shortcutService.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/workbench/services/shortcut/shortcutService.ts b/src/workbench/services/shortcut/shortcutService.ts index 11e699bf8..c44468939 100644 --- a/src/workbench/services/shortcut/shortcutService.ts +++ b/src/workbench/services/shortcut/shortcutService.ts @@ -117,7 +117,7 @@ export class ShortcutService extends Disposable implements IShortcutService { const ret = await trySafe>( () => { const args = isFunction(candidate.commandArgs) ? candidate.commandArgs() : candidate.commandArgs; - this.commandService.executeCommand(candidate.commandID, ...args); + return this.commandService.executeCommand(candidate.commandID, ...args); }, { onError: err => logService.error('[ShortcutService]', `Error encounters. Executing shortcut '${pressed.toString()}' with command '${candidate?.commandID}'`, err) } @@ -135,8 +135,10 @@ export class ShortcutService extends Disposable implements IShortcutService { })); // When the browser side is ready, we update registrations by reading from disk. - lifecycleService.when(LifecyclePhase.Ready).then(() => this.__readConfigurationFromDisk()); - this.__register(lifecycleService.onWillQuit((e) => e.join(this.__onApplicationClose()))); + + // TODO: currently disabled, unable to store shortcut with provided arguments + // lifecycleService.when(LifecyclePhase.Ready).then(() => this.__readConfigurationFromDisk()); + // this.__register(lifecycleService.onWillQuit((e) => e.join(this.__onApplicationClose()))); } // [public methods] From 941260a42e099f03799da67a90a636ffd7818e2a Mon Sep 17 00:00:00 2001 From: SIHAN LI Date: Wed, 26 Feb 2025 00:18:28 -0500 Subject: [PATCH 10/21] [Refactor] update shortcut registration to support multiple shortcuts --- src/platform/command/common/command.ts | 4 +- .../services/shortcut/shortcutRegistrant.ts | 83 ++++++++----------- .../services/shortcutRegistrant.test.ts | 53 ------------ .../services/shortcutService.test.ts | 4 - 4 files changed, 35 insertions(+), 109 deletions(-) diff --git a/src/platform/command/common/command.ts b/src/platform/command/common/command.ts index 013c35ba3..50bdb67bd 100644 --- a/src/platform/command/common/command.ts +++ b/src/platform/command/common/command.ts @@ -3,7 +3,7 @@ import type { ICommandRegistrant, ICommandBasicSchema } from "src/platform/comma import { ContextKeyExpr } from "src/platform/context/common/contextKeyExpr"; import { IContextService } from "src/platform/context/common/contextService"; import { IServiceProvider } from "src/platform/instantiation/common/instantiation"; -import { Callable, Constructor } from "src/base/common/utilities/type"; +import { Callable } from "src/base/common/utilities/type"; /** * A more concrete set of metadata to describe a command specifically used for @@ -22,7 +22,7 @@ export interface ICommandSchema extends Omit, 'commandID'>; + readonly shortcutOptions?: IShortcutRegistration; } export type CommandImplementation = Callable<[provider: IServiceProvider, ...args: TArgs], TReturn>; diff --git a/src/workbench/services/shortcut/shortcutRegistrant.ts b/src/workbench/services/shortcut/shortcutRegistrant.ts index a2857d659..1e59fa57f 100644 --- a/src/workbench/services/shortcut/shortcutRegistrant.ts +++ b/src/workbench/services/shortcut/shortcutRegistrant.ts @@ -62,7 +62,7 @@ export type IShortcutRegistration = IShortcutRegistrationBase /** * The shortcut of the given command. */ - readonly shortcut: Shortcut; + readonly shortcut: Shortcut | Shortcut[]; }; /** @@ -116,10 +116,9 @@ export interface IShortcutRegistrant extends IRegistrant(commandID: ID, registration: IShortcutRegistration): IDisposable; - registerBasic(commandID: ID, registration: IShortcutRegistration2): IDisposable; + register2(commandID: ID, registration: IShortcutRegistration): void; + registerBasic(commandID: ID, registration: IShortcutRegistration2): void; /** * @description Check if the command is already registered with the given @@ -184,7 +183,7 @@ export class ShortcutRegistrant extends Disposable implements IShortcutRegistran rendererWorkbenchShortcutRegister(provider); } - public registerBasic(commandID: ID, registration: IShortcutRegistration2): IDisposable { + public registerBasic(commandID: ID, registration: IShortcutRegistration2): void { const shortcut = (IS_MAC && registration.mac) ? Shortcut.fromString(registration.mac) : Shortcut.fromString(registration.key); @@ -196,52 +195,36 @@ export class ShortcutRegistrant extends Disposable implements IShortcutRegistran return this.register2(commandID, resolved); } - public register2(commandID: ID, registration: IShortcutRegistration): IDisposable { - - const hashcode = registration.shortcut.toHashcode(); - let items = this._shortcuts.get(hashcode); - if (!items) { - items = []; - this._shortcuts.set(hashcode, items); - } - - /** - * Checks if there is a same command with the same shortcut that is - * registered. - */ - if (Arrays.exist2(items, entry => entry.commandID === commandID)) { - panic(`[ShortcutRegistrant] There exists a command with ID '${commandID}' that is already registered`); + public register2(commandID: ID, registration: IShortcutRegistration): void { + const shortcuts = Array.isArray(registration.shortcut) ? registration.shortcut : [registration.shortcut]; + for (const shortcut of shortcuts) { + const hashcode = shortcut.toHashcode(); + let items = this._shortcuts.get(hashcode); + if (!items) { + items = []; + this._shortcuts.set(hashcode, items); + } + + /** + * Checks if there is a same command with the same shortcut that is + * registered. + */ + if (Arrays.exist2(items, entry => entry.commandID === commandID)) { + panic(`[ShortcutRegistrant] There exists a command with ID '${commandID}' that is already registered`); + } + + // register the shortcut + const uuid = ShortcutRegistrant._shortcutUUID++; + items.push({ + uuid: uuid, + commandID: commandID, + commandArgs: registration.commandArgs, + when: registration.when, + weight: registration.weight, + }); + + this._onDidRegister.fire(shortcut); } - - // register the shortcut - const uuid = ShortcutRegistrant._shortcutUUID++; - items.push({ - uuid: uuid, - commandID: commandID, - commandArgs: registration.commandArgs, - when: registration.when, - weight: registration.weight, - }); - - this._onDidRegister.fire(registration.shortcut); - - return safeDisposable( - toDisposable(() => { - if (items) { - const itemIdx = items.findIndex((item) => item.uuid === uuid); - if (itemIdx === -1) { - return; - } - - items.splice(itemIdx, 1); - if (items.length === 0) { - this._shortcuts.delete(hashcode); - } - - this._onDidUnRegister.fire(registration.shortcut); - } - }) - ); } public isRegistered(shortcut: Shortcut | ShortcutHash, commandID: string): boolean { diff --git a/test/code/browser/workbench/services/shortcutRegistrant.test.ts b/test/code/browser/workbench/services/shortcutRegistrant.test.ts index c1a5fd3e1..6eced181d 100644 --- a/test/code/browser/workbench/services/shortcutRegistrant.test.ts +++ b/test/code/browser/workbench/services/shortcutRegistrant.test.ts @@ -1,6 +1,5 @@ import * as assert from 'assert'; import { Shortcut } from "src/base/common/keyboard"; -import { IS_MAC } from 'src/base/common/platform'; import { ShortcutRegistrant, ShortcutWeight } from 'src/workbench/services/shortcut/shortcutRegistrant'; suite('ShortcutRegistrant', () => { @@ -11,58 +10,6 @@ suite('ShortcutRegistrant', () => { registrant = new ShortcutRegistrant(); }); - test('register a shortcut and verify registration', () => { - const shortcut = Shortcut.fromString('Ctrl+A'); - const commandID = 'testCommand'; - const registration = { - shortcut, - commandArgs: [], - when: null, - weight: ShortcutWeight.Core - }; - - const disposable = registrant.register2(commandID, registration); - - assert.strictEqual(registrant.isRegistered(shortcut, commandID), true); - - disposable.dispose(); - assert.strictEqual(registrant.isRegistered(shortcut, commandID), false); - }); - - test('registerBasic with mac-specific shortcut', () => { - if (!IS_MAC) { - return; - } - const commandID = 'macCommand'; - const registration = { key: 'Cmd+Shift+M', mac: 'Cmd+Shift+N', commandArgs: [], when: null, weight: ShortcutWeight.BuiltInExtension }; - - const disposable = registrant.registerBasic(commandID, registration); - const shortcut = Shortcut.fromString('Cmd+Shift+N'); - - assert.strictEqual(registrant.isRegistered(shortcut, commandID), true); - disposable.dispose(); - assert.strictEqual(registrant.isRegistered(shortcut, commandID), false); - }); - - test('register multiple shortcuts with different commands', () => { - const shortcut1 = Shortcut.fromString('Ctrl+X'); - const shortcut2 = Shortcut.fromString('Ctrl+Y'); - const commandID1 = 'command1'; - const commandID2 = 'command2'; - - const disposable1 = registrant.register2(commandID1, { shortcut: shortcut1, commandArgs: [], when: null, weight: ShortcutWeight.Editor }); - const disposable2 = registrant.register2(commandID2, { shortcut: shortcut2, commandArgs: [], when: null, weight: ShortcutWeight.Editor }); - - assert.strictEqual(registrant.isRegistered(shortcut1, commandID1), true); - assert.strictEqual(registrant.isRegistered(shortcut2, commandID2), true); - - disposable1.dispose(); - disposable2.dispose(); - - assert.strictEqual(registrant.isRegistered(shortcut1, commandID1), false); - assert.strictEqual(registrant.isRegistered(shortcut2, commandID2), false); - }); - test('attempt to register duplicate shortcut throws error', () => { const shortcut = Shortcut.fromString('Ctrl+Z'); const commandID = 'duplicateCommand'; diff --git a/test/code/browser/workbench/services/shortcutService.test.ts b/test/code/browser/workbench/services/shortcutService.test.ts index dfbb08296..0339061d6 100644 --- a/test/code/browser/workbench/services/shortcutService.test.ts +++ b/test/code/browser/workbench/services/shortcutService.test.ts @@ -102,9 +102,5 @@ suite('shortcutService-test', () => { contextService.setContext('value', true); keyboardService.fire(createKeyboardEvent(new Shortcut(true, false, false, false, KeyCode.Space))); assert.strictEqual(pressed, 2); - - unregister.dispose(); - keyboardService.fire(createKeyboardEvent(new Shortcut(true, false, false, false, KeyCode.Space))); - assert.strictEqual(pressed, 2); }); }); \ No newline at end of file From 1d1c8c5c34487fc2390b3ddf43588e7855e64ea1 Mon Sep 17 00:00:00 2001 From: SIHAN LI Date: Wed, 26 Feb 2025 00:23:11 -0500 Subject: [PATCH 11/21] [Refactor] editor command are directly regisered in and triggered by `CommandService` and `ShortcutService` --- src/base/common/utilities/type.ts | 5 + src/editor/common/editorContextKeys.ts | 1 + src/editor/contrib/command/command.contrib.ts | 301 +++++++++++------- src/editor/contrib/command/command.ts | 87 +++-- .../contrib/command/commandList.contrib.ts | 38 ++- src/editor/contrib/command/editorCommand.ts | 3 + 6 files changed, 256 insertions(+), 179 deletions(-) diff --git a/src/base/common/utilities/type.ts b/src/base/common/utilities/type.ts index d221f27e0..447c7bf7b 100644 --- a/src/base/common/utilities/type.ts +++ b/src/base/common/utilities/type.ts @@ -280,6 +280,11 @@ export type Push = [...Arr, V]; */ export type Pop = Arr extends [...infer Rest, any] ? Rest : never; +/** + * Remove the first of the array (require non empty). + */ +export type Shift = Arr extends [any, ...infer Rest] ? Rest : never; + /** * Converts a two-dimensional array type to a one-dimensional array type. */ diff --git a/src/editor/common/editorContextKeys.ts b/src/editor/common/editorContextKeys.ts index c250e365c..2e19932a1 100644 --- a/src/editor/common/editorContextKeys.ts +++ b/src/editor/common/editorContextKeys.ts @@ -6,6 +6,7 @@ export namespace EditorContextKeys { export const editorFocusedContext = CreateContextKeyExpr.Equal('isEditorFocused', true); export const isEditorReadonly = CreateContextKeyExpr.Equal('isEditorReadonly', true); export const isEditorWritable = CreateContextKeyExpr.Equal('isEditorWritable', true); + export const isEditorEditable = CreateContextKeyExpr.And(editorFocusedContext, isEditorWritable); export const richtextEditorMode = CreateContextKeyExpr.Equal('editorRenderMode', EditorType.Rich); export const plaintextEditorMode = CreateContextKeyExpr.Equal('editorRenderMode', EditorType.Plain); export const splitViewEditorMode = CreateContextKeyExpr.Equal('editorRenderMode', EditorType.Split); diff --git a/src/editor/contrib/command/command.contrib.ts b/src/editor/contrib/command/command.contrib.ts index f8acf0d24..44ec71f23 100644 --- a/src/editor/contrib/command/command.contrib.ts +++ b/src/editor/contrib/command/command.contrib.ts @@ -13,8 +13,10 @@ import { EditorContextKeys } from "src/editor/common/editorContextKeys"; import { IS_MAC } from "src/base/common/platform"; import { redo, undo } from "prosemirror-history"; import { INotificationService } from "src/workbench/services/notification/notification"; -import { buildEditorCommand, EditorCommandBase } from "src/editor/contrib/command/editorCommand"; +import { buildEditorCommand, EditorCommandArguments, EditorCommandBase } from "src/editor/contrib/command/editorCommand"; import { registerListCommands } from "src/editor/contrib/command/commandList.contrib"; +import { Shortcut } from "src/base/common/keyboard"; +import { ShortcutWeight } from "src/workbench/services/shortcut/shortcutRegistrant"; /** * [FILE OUTLINE] @@ -30,11 +32,11 @@ const whenEditorWritable = CreateContextKeyExpr.And(EditorContextKeys.editorFocu /** * @description A set of default editor command configurations. */ -export function registerBasicEditorCommands(extension: IEditorCommandExtension, logService: ILogService): void { - __registerToggleMarkCommands(extension, logService); - __registerHeadingCommands(extension, logService); - __registerBasicCommands(extension); - registerListCommands(extension, logService); +export function registerBasicEditorCommands(extension: IEditorCommandExtension, logService: ILogService, getArguments: () => EditorCommandArguments): void { + registerListCommands(extension, logService, getArguments); + __registerToggleMarkCommands(extension, logService, getArguments); + __registerHeadingCommands(extension, logService, getArguments); + __registerBasicCommands(extension, getArguments); } function getPlatformShortcut(ctrl: string, meta: string): string { @@ -46,7 +48,7 @@ function getPlatformShortcut(ctrl: string, meta: string): string { * @note These commands need to be constructed after the editor and schema * are initialized. */ -function __registerToggleMarkCommands(extension: IEditorCommandExtension, logService: ILogService): void { +function __registerToggleMarkCommands(extension: IEditorCommandExtension, logService: ILogService, getArguments: () => EditorCommandArguments): void { const schema = extension.getEditorSchema().unwrap(); const toggleMarkConfigs: [string, string, string][] = [ [MarkEnum.Strong, 'Ctrl+B', 'Meta+B'], @@ -64,15 +66,23 @@ function __registerToggleMarkCommands(extension: IEditorCommandExtension, logSer extension.registerCommand( EditorCommands.createToggleMarkCommand( - { id: toggleCmdID, when: whenEditorWritable }, + { + id: toggleCmdID, + when: whenEditorWritable, + shortcutOptions: { + commandArgs: getArguments, + shortcut: Shortcut.fromString(getPlatformShortcut(ctrl, meta)), + weight: ShortcutWeight.Editor, + when: whenEditorWritable, + } + }, markType, null, // attrs { removeWhenPresent: true, enterInlineAtoms: true, } - ), - [getPlatformShortcut(ctrl, meta)] + ) ); } } @@ -83,7 +93,7 @@ function __registerToggleMarkCommands(extension: IEditorCommandExtension, logSer * @note These commands need to be constructed after the editor and schema * are initialized. */ -function __registerHeadingCommands(extension: IEditorCommandExtension, logService: ILogService): void { +function __registerHeadingCommands(extension: IEditorCommandExtension, logService: ILogService, getArguments: () => EditorCommandArguments): void { const schema = extension.getEditorSchema().unwrap(); const headingCmdID = 'editor-toggle-heading'; @@ -97,144 +107,199 @@ function __registerHeadingCommands(extension: IEditorCommandExtension, logServic const cmdID = `${headingCmdID}-${level}`; extension.registerCommand( EditorCommands.createSetBlockCommand( - { id: cmdID, when: whenEditorWritable }, + { + id: cmdID, + when: whenEditorWritable, + shortcutOptions: { + commandArgs: getArguments, + shortcut: Shortcut.fromString(getPlatformShortcut(`Ctrl+${level}`, `Meta+${level}`)), + weight: ShortcutWeight.Editor, + when: whenEditorWritable, + } + }, nodeType, { level: level } - ), - [getPlatformShortcut(`Ctrl+${level}`, `Meta+${level}`)] + ) ); } } -function __registerBasicCommands(extension: IEditorCommandExtension): void { +function __registerBasicCommands(extension: IEditorCommandExtension, getArguments: () => EditorCommandArguments): void { extension.registerCommand(buildEditorCommand( - { - id: 'editor-esc', - when: whenEditorReadonly, - }, - [ - EditorCommands.Unselect - ], - ), - ['Escape'] - ); + { + id: 'editor-enter', + when: whenEditorWritable, + shortcutOptions: { + commandArgs: getArguments, + shortcut: Shortcut.fromString('Enter'), + weight: ShortcutWeight.Editor, + when: whenEditorWritable, + } + }, + [ + EditorCommands.InsertNewLineInCodeBlock, + EditorCommands.InsertEmptyParagraphAdjacentToBlock, + EditorCommands.LiftEmptyTextBlock, + EditorCommands.SplitBlockAtSelection, + ], + )); extension.registerCommand(buildEditorCommand( - { - id: 'editor-enter', - when: whenEditorWritable, - }, - [ - EditorCommands.InsertNewLineInCodeBlock, - EditorCommands.InsertEmptyParagraphAdjacentToBlock, - EditorCommands.LiftEmptyTextBlock, - EditorCommands.SplitBlockAtSelection, - ], - ), - ['Enter'] - ); - + { + id: 'editor-esc', + when: whenEditorReadonly, + shortcutOptions: { + commandArgs: getArguments, + shortcut: Shortcut.fromString('Escape'), + weight: ShortcutWeight.Editor, + when: whenEditorReadonly, + } + }, + [ + EditorCommands.Unselect + ], + )); + extension.registerCommand(buildEditorCommand( - { - id: 'editor-backspace', + { + id: 'editor-backspace', + when: whenEditorWritable, + shortcutOptions: { + commandArgs: getArguments, + shortcut: Shortcut.fromString('Backspace'), + weight: ShortcutWeight.Editor, when: whenEditorWritable, - }, - [ - EditorCommands.DeleteSelection, - EditorCommands.JoinBackward, - EditorCommands.SelectNodeBackward, - ], - ), - ['Backspace'] - ); + } + }, + [ + EditorCommands.DeleteSelection, + EditorCommands.JoinBackward, + EditorCommands.SelectNodeBackward, + ], + )); + extension.registerCommand(buildEditorCommand( - { - id: 'editor-delete', + { + id: 'editor-delete', + when: whenEditorWritable, + shortcutOptions: { + commandArgs: getArguments, + shortcut: [ + Shortcut.fromString('Delete'), + Shortcut.fromString(getPlatformShortcut('Ctrl+Delete', 'Meta+Delete')), + ], + weight: ShortcutWeight.Editor, when: whenEditorWritable, - }, - [ - EditorCommands.DeleteSelection, - EditorCommands.joinForward, - EditorCommands.SelectNodeForward, - ] - ), - ['Delete', getPlatformShortcut('Ctrl+Delete', 'Meta+Delete')] - ); + } + }, + [ + EditorCommands.DeleteSelection, + EditorCommands.JoinForward, + EditorCommands.SelectNodeForward, + ] + )); extension.registerCommand(buildEditorCommand( - { - id: 'editor-select-all', + { + id: 'editor-select-all', + when: whenEditorReadonly, + shortcutOptions: { + commandArgs: getArguments, + shortcut: Shortcut.fromString(getPlatformShortcut('Ctrl+A', 'Meta+A')), + weight: ShortcutWeight.Editor, when: whenEditorReadonly, - }, - [ - EditorCommands.SelectAll - ] - ), - [getPlatformShortcut('Ctrl+A', 'Meta+A')] - ); + } + }, + [ + EditorCommands.SelectAll + ] + )); // @fix Doesn't work with CM, guess bcz CM is focused but PM is not. extension.registerCommand(buildEditorCommand( - { - id: 'editor-exit-code-block', + { + id: 'editor-exit-code-block', + when: whenEditorReadonly, + shortcutOptions: { + commandArgs: getArguments, + shortcut: Shortcut.fromString(getPlatformShortcut('Ctrl+Enter', 'Meta+Enter')), + weight: ShortcutWeight.Editor, when: whenEditorReadonly, - }, - [ - EditorCommands.ExitCodeBlock - ] - ), - [getPlatformShortcut('Ctrl+Enter', 'Meta+Enter')] - ); + } + }, + [ + EditorCommands.ExitCodeBlock + ] + )); extension.registerCommand(buildEditorCommand( - { - id: 'editor-insert-hard-break', + { + id: 'editor-insert-hard-break', + when: whenEditorWritable, + shortcutOptions: { + commandArgs: getArguments, + shortcut: [ + Shortcut.fromString('Shift+Enter'), + Shortcut.fromString(getPlatformShortcut('Ctrl+Enter', 'Meta+Enter')), + ], + weight: ShortcutWeight.Editor, when: whenEditorWritable, - }, - [ - EditorCommands.ExitCodeBlock, - EditorCommands.InsertHardBreak, - ] - ), - ['Shift+Enter', getPlatformShortcut('Ctrl+Enter', 'Meta+Enter')] - ); + } + }, + [ + EditorCommands.ExitCodeBlock, + EditorCommands.InsertHardBreak, + ] + )); extension.registerCommand(buildEditorCommand( - { - id: 'editor-save', + { + id: 'editor-save', + when: whenEditorWritable, + shortcutOptions: { + commandArgs: getArguments, + shortcut: Shortcut.fromString(getPlatformShortcut('Ctrl+S', 'Meta+S')), + weight: ShortcutWeight.Editor, when: whenEditorWritable, - }, - [ - EditorCommands.FileSave, - ] - ), - [getPlatformShortcut('Ctrl+S', 'Meta+S')] - ); + } + }, + [ + EditorCommands.FileSave, + ] + )); extension.registerCommand(buildEditorCommand( - { - id: 'editor-undo', + { + id: 'editor-undo', + when: whenEditorWritable, + shortcutOptions: { + commandArgs: getArguments, + shortcut: Shortcut.fromString(getPlatformShortcut('Ctrl+Z', 'Meta+Z')), + weight: ShortcutWeight.Editor, when: whenEditorWritable, - }, - [ - EditorCommands.FileUndo, - ] - ), - [getPlatformShortcut('Ctrl+Z', 'Meta+Z')] - ); + } + }, + [ + EditorCommands.FileUndo, + ] + )); extension.registerCommand(buildEditorCommand( - { - id: 'editor-redo', + { + id: 'editor-redo', + when: whenEditorWritable, + shortcutOptions: { + commandArgs: getArguments, + shortcut: Shortcut.fromString(getPlatformShortcut('Ctrl+Shift+Z', 'Meta+Shift+Z')), + weight: ShortcutWeight.Editor, when: whenEditorWritable, - }, - [ - EditorCommands.FileRedo, - ] - ), - [getPlatformShortcut('Ctrl+Shift+Z', 'Meta+Shift+Z')] - ); + } + }, + [ + EditorCommands.FileRedo, + ] + )); } /** @@ -597,7 +662,7 @@ export namespace EditorCommands { * other block closer to this one in the tree structure. Will use the * view for accurate start-of-textblock detection if given. */ - export class joinForward extends EditorCommandBase { + export class JoinForward extends EditorCommandBase { public run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean { const $cursor = __atBlockEnd(state, view); @@ -691,7 +756,7 @@ export namespace EditorCommands { * When the selection is empty and at the end of a textblock, select * the node coming after that textblock, if possible. This is intended * to be bound to keys like delete, after - * [`joinForward`](#commands.joinForward) and similar deleting + * [`JoinForward`](#commands.JoinForward) and similar deleting * commands, to provide a fall-back behavior when the schema doesn't * allow deletion at the selected point. */ diff --git a/src/editor/contrib/command/command.ts b/src/editor/contrib/command/command.ts index c2d102039..dd0e3170c 100644 --- a/src/editor/contrib/command/command.ts +++ b/src/editor/contrib/command/command.ts @@ -17,23 +17,16 @@ import { Priority } from "src/base/common/event"; * An interface only for {@link EditorCommandExtension}. */ export interface IEditorCommandExtension extends IEditorExtension { - readonly id: EditorExtensionIDs.Command; - - /** - * @description Register a {@link Command} as editor command that can be - * triggered by any of the given shortcuts. - * - * @note The {@link Command} will also be registered into the global - * {@link CommandService}. - */ - registerCommand(command: Command, shortcuts: string[]): void; + registerCommand(command: Command): void; } /** * @class Extension for handling editor commands with associated keyboard * shortcuts. This class binds commands to specific shortcuts and registers * these commands within the {@link CommandService}. + * + * @deprecated // FIX */ export class EditorCommandExtension extends EditorExtension implements IEditorCommandExtension { @@ -66,32 +59,32 @@ export class EditorCommandExtension extends EditorExtension implements IEditorCo * @note Registered with {@link Priority.Low}. Make other extensions has * possibility to handle the keydown event first. */ - this.__register(this.onKeydown(event => { - const keyEvent = event.event; - const shortcut = new Shortcut(keyEvent.ctrl, keyEvent.shift, keyEvent.alt, keyEvent.meta, keyEvent.key); - const commandID = this._commandKeybinding.get(shortcut.toHashcode()); - if (!commandID) { - return; - } - - /** - * Whenever a command is executed, we need to invoke `preventDefault` - * to tell prosemirror to prevent default behavior of the browser. - * - * @see https://discuss.prosemirror.net/t/question-allselection-weird-behaviours-when-the-document-contains-a-non-text-node-at-the-end/7749/3 - */ - trySafe( - () => commandService.executeCommand(commandID, editorWidget, event.view.state, event.view.dispatch, event.view), - { - onError: () => false, - onThen: (anyExecuted) => { - if (anyExecuted) { - event.preventDefault(); - } - } - } - ); - }, undefined, Priority.Low)); + // this.__register(this.onKeydown(event => { + // const keyEvent = event.event; + // const shortcut = new Shortcut(keyEvent.ctrl, keyEvent.shift, keyEvent.alt, keyEvent.meta, keyEvent.key); + // const commandID = this._commandKeybinding.get(shortcut.toHashcode()); + // if (!commandID) { + // return; + // } + + // /** + // * Whenever a command is executed, we need to invoke `preventDefault` + // * to tell prosemirror to prevent default behavior of the browser. + // * + // * @see https://discuss.prosemirror.net/t/question-allselection-weird-behaviours-when-the-document-contains-a-non-text-node-at-the-end/7749/3 + // */ + // trySafe( + // () => commandService.executeCommand(commandID, editorWidget, event.view.state, event.view.dispatch, event.view), + // { + // onError: () => false, + // onThen: (anyExecuted) => { + // if (anyExecuted) { + // event.preventDefault(); + // } + // } + // } + // ); + // }, undefined, Priority.Low)); } // [protected override methods] @@ -101,7 +94,12 @@ export class EditorCommandExtension extends EditorExtension implements IEditorCo /** * Binds predefined commands to their respective shortcuts. */ - registerBasicEditorCommands(this, this.logService); + + // FIX: + registerBasicEditorCommands(this, this.logService, () => { + const view = this._editorWidget.view.editor.internalView; + return [this._editorWidget, view.state, view.dispatch, view]; + }); } protected override onViewDestroy(view: EditorView): void { @@ -119,7 +117,7 @@ export class EditorCommandExtension extends EditorExtension implements IEditorCo // [public methods] - public registerCommand(command: Command, shortcuts: string[]): void { + public registerCommand(command: Command): void { this._commandSet.add(command.id); /** @@ -127,18 +125,5 @@ export class EditorCommandExtension extends EditorExtension implements IEditorCo */ const registrant = this.registrantService.getRegistrant(RegistrantType.Command); registrant.registerCommand(command); - - /** - * Bind the shortcuts with the command. - */ - for (const str of shortcuts) { - const shortcut = Shortcut.fromString(str); - if (shortcut === Shortcut.None) { - this.logService.warn(this.id, `Editor command (${command.id}) with shortcut registration (${str}) fails.`); - continue; - } - const hash = shortcut.toHashcode(); - this._commandKeybinding.set(hash, command.id); - } } } diff --git a/src/editor/contrib/command/commandList.contrib.ts b/src/editor/contrib/command/commandList.contrib.ts index 99f1fccbc..eb2bd169e 100644 --- a/src/editor/contrib/command/commandList.contrib.ts +++ b/src/editor/contrib/command/commandList.contrib.ts @@ -1,14 +1,17 @@ import { canJoin, canSplit, liftTarget } from "prosemirror-transform"; +import { Shortcut } from "src/base/common/keyboard"; import { ILogService } from "src/base/common/logger"; +import { EditorContextKeys } from "src/editor/common/editorContextKeys"; import { TokenEnum } from "src/editor/common/markdown"; import { ProseEditorState, ProseTransaction, ProseEditorView, ProseNodeType, ProseFragment, ProseSlice, ProseReplaceAroundStep, ProseNodeRange, ProseSelection, ProseNodeSelection, ProseAttrs } from "src/editor/common/proseMirror"; import { IEditorCommandExtension } from "src/editor/contrib/command/command"; -import { EditorCommandBase } from "src/editor/contrib/command/editorCommand"; +import { EditorCommandArguments, EditorCommandBase } from "src/editor/contrib/command/editorCommand"; import { IEditorWidget } from "src/editor/editorWidget"; import { Command, ICommandSchema } from "src/platform/command/common/command"; import { IServiceProvider } from "src/platform/instantiation/common/instantiation"; +import { ShortcutWeight } from "src/workbench/services/shortcut/shortcutRegistrant"; -export function registerListCommands(extension: IEditorCommandExtension, logService: ILogService): void { +export function registerListCommands(extension: IEditorCommandExtension, logService: ILogService, getArguments: () => EditorCommandArguments): void { const schema = extension.getEditorSchema().unwrap(); const listItemType = schema.getNodeType(TokenEnum.ListItem); @@ -21,34 +24,49 @@ export function registerListCommands(extension: IEditorCommandExtension, logServ EditorListCommands.splitListItem( { id: 'editor-split-list-item', - when: null, + when: EditorContextKeys.isEditorEditable, + shortcutOptions: { + when: EditorContextKeys.isEditorEditable, + weight: ShortcutWeight.Editor, + shortcut: Shortcut.fromString('Enter'), + commandArgs: getArguments, + } }, listItemType, undefined, ), - ['Enter'] ); extension.registerCommand( EditorListCommands.sinkListItem( { id: 'editor-sink-list-item', - when: null, + when: EditorContextKeys.isEditorEditable, + shortcutOptions: { + when: EditorContextKeys.isEditorEditable, + weight: ShortcutWeight.Editor, + shortcut: Shortcut.fromString('Tab'), + commandArgs: getArguments, + } }, listItemType, - ), - ['Tab'] + ) ); extension.registerCommand( EditorListCommands.liftListItem( { id: 'editor-lift-list-item', - when: null, + when: EditorContextKeys.isEditorEditable, + shortcutOptions: { + when: EditorContextKeys.isEditorEditable, + weight: ShortcutWeight.Editor, + shortcut: Shortcut.fromString('Shift+Tab'), + commandArgs: getArguments, + } }, listItemType, - ), - ['Shift+Tab'] + ) ); } diff --git a/src/editor/contrib/command/editorCommand.ts b/src/editor/contrib/command/editorCommand.ts index c3b05d553..f5e45e247 100644 --- a/src/editor/contrib/command/editorCommand.ts +++ b/src/editor/contrib/command/editorCommand.ts @@ -1,3 +1,4 @@ +import { Shift } from "src/base/common/utilities/type"; import { ProseEditorState, ProseTransaction, ProseEditorView } from "src/editor/common/proseMirror"; import { IEditorWidget } from "src/editor/editorWidget"; import { buildChainCommand, Command, ICommandSchema } from "src/platform/command/common/command"; @@ -10,6 +11,8 @@ export abstract class EditorCommandBase extends Command { public abstract override run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean | Promise; } +export type EditorCommandArguments = Shift>; + export function buildEditorCommand(schema: ICommandSchema, ctors: (typeof Command)[]): Command { if (ctors.length === 1) { const command = ctors[0]!; From b69117d19bed86ee4cc695591e6f330ffd46300e Mon Sep 17 00:00:00 2001 From: SIHAN LI Date: Wed, 26 Feb 2025 00:35:24 -0500 Subject: [PATCH 12/21] [Refactor] update keyboard event handling to improve command execution flow --- .../services/shortcut/shortcutService.ts | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/workbench/services/shortcut/shortcutService.ts b/src/workbench/services/shortcut/shortcutService.ts index c44468939..63fd3bbc9 100644 --- a/src/workbench/services/shortcut/shortcutService.ts +++ b/src/workbench/services/shortcut/shortcutService.ts @@ -97,7 +97,7 @@ export class ShortcutService extends Disposable implements IShortcutService { this._resource = URI.join(environmentService.appConfigurationPath, SHORTCUT_CONFIG_NAME); // listen to keyboard events - this.__register(keyboardService.onKeydown(e => { + this.__register(keyboardService.onKeydown(async e => { const pressed = new Shortcut(e.ctrl, e.shift, e.alt, e.meta, e.key); // filter out only the valid shortcuts @@ -112,26 +112,25 @@ export class ShortcutService extends Disposable implements IShortcutService { } // Executing the corresponding commands based on priority - (async () => { - for (const candidate of validCandidates) { - const ret = await trySafe>( - () => { - const args = isFunction(candidate.commandArgs) ? candidate.commandArgs() : candidate.commandArgs; - return this.commandService.executeCommand(candidate.commandID, ...args); - }, { - onError: err => logService.error('[ShortcutService]', `Error encounters. Executing shortcut '${pressed.toString()}' with command '${candidate?.commandID}'`, err) - } - ); - - /** - * Let the client has a chance to return `true` if the - * shortcut is already handled. - */ - if (ret === true) { - break; + for (const candidate of validCandidates) { + const ret = await trySafe>( + () => { + const args = isFunction(candidate.commandArgs) ? candidate.commandArgs() : candidate.commandArgs; + return this.commandService.executeCommand(candidate.commandID, ...args); + }, { + onError: err => logService.error('[ShortcutService]', `Error encounters. Executing shortcut '${pressed.toString()}' with command '${candidate?.commandID}'`, err) } + ); + + /** + * Let the client has a chance to return `true` if the + * shortcut is already handled. + */ + if (ret === true) { + e.browserEvent.preventDefault(); + break; } - })(); + } })); // When the browser side is ready, we update registrations by reading from disk. From 75a6003f60c80b9e3e313082ed78966f78859e00 Mon Sep 17 00:00:00 2001 From: SIHAN LI <1015813038@qq.com> Date: Wed, 26 Feb 2025 16:06:39 -0500 Subject: [PATCH 13/21] [Feat] add unique ID for `EditorWidget` --- src/editor/editorWidget.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/editor/editorWidget.ts b/src/editor/editorWidget.ts index 6f8219eb4..5b9946bac 100644 --- a/src/editor/editorWidget.ts +++ b/src/editor/editorWidget.ts @@ -83,6 +83,11 @@ export interface IEditorWidget extends */ readonly onDidRenderModeChange: Register; + /** + * Get a unique ID of the editor. + */ + getID(): string; + /** * @description Opens the source in the editor. * @param source The source in URI form. @@ -132,6 +137,9 @@ export class EditorWidget extends Disposable implements IEditorWidget { // region - [fields] + private static EDITOR_ID = 0; + private readonly _id: number = ++EditorWidget.EDITOR_ID; + /** * The HTML container of the entire editor. */ @@ -316,6 +324,8 @@ export class EditorWidget extends Disposable implements IEditorWidget { get readonly(): boolean { return !this._options.getOptions().writable.value; } get renderMode(): EditorType | null { return null; } // TODO + public getID(): string { return `editor-widget-${this._id}`; } + // region - [public] public async open(source: URI): Promise> { From 2cfef55f758098baf2143fff6e770b4a9cf1e793 Mon Sep 17 00:00:00 2001 From: SIHAN LI <1015813038@qq.com> Date: Wed, 26 Feb 2025 16:52:40 -0500 Subject: [PATCH 14/21] [Feat] `IEditorService` for tracking editor lifecycle and events --- src/code/browser/renderer.desktop.ts | 3 + src/workbench/services/editor/editor.ts | 43 ++++++++++ .../services/editor/editorService.ts | 85 +++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 src/workbench/services/editor/editor.ts create mode 100644 src/workbench/services/editor/editorService.ts diff --git a/src/code/browser/renderer.desktop.ts b/src/code/browser/renderer.desktop.ts index 998c72aed..4867fec4a 100644 --- a/src/code/browser/renderer.desktop.ts +++ b/src/code/browser/renderer.desktop.ts @@ -71,6 +71,8 @@ import { INotificationService } from "src/workbench/services/notification/notifi import { IEncryptionService } from "src/platform/encryption/common/encryptionService"; import { BrowserAITextChannel } from "src/platform/ai/common/aiTextChannel"; import { IAITextService } from "src/platform/ai/common/aiText"; +import { IEditorService } from "src/workbench/services/editor/editor"; +import { EditorService } from "src/workbench/services/editor/editorService"; /** * @class This is the main entry of the renderer process. @@ -280,6 +282,7 @@ const renderer = new class extends class RendererInstance extends Disposable { registerService(IBrowserZoomService , new ServiceDescriptor(BrowserZoomService , [])); registerService(IBrowserInspectorService , new ServiceDescriptor(BrowserInspectorService , [])); registerService(IRecentOpenService , new ServiceDescriptor(RecentOpenService , [])); + registerService(IEditorService , new ServiceDescriptor(EditorService , [])); } // [end] diff --git a/src/workbench/services/editor/editor.ts b/src/workbench/services/editor/editor.ts new file mode 100644 index 000000000..71c939ad9 --- /dev/null +++ b/src/workbench/services/editor/editor.ts @@ -0,0 +1,43 @@ +import { Register } from "src/base/common/event"; +import { IEditorWidget } from "src/editor/editorWidget"; +import { createService, IService, refineDecorator } from "src/platform/instantiation/common/decorator"; + +export const IEditorService = createService('editor-service'); + +/** Do not use this unless you know what you are doing. */ +export const IEditorHostService = refineDecorator(IEditorService); + +/** + * A service that tracks the lifecycle and status of {@link IEditorWidget} + * instances. + * @note It does NOT manage the actual lifecycle of editors, but rather observes + * and emits events related to their creation, focus state, and closure. + */ +export interface IEditorService extends IService { + + readonly onCreateEditor: Register; + readonly onDidCreateEditor: Register; + readonly onCloseEditor: Register; + readonly onDidCloseEditor: Register; + + /** + * Fires the current focused editor. + */ + readonly onDidFocusedEditorChange: Register; + + /** + * The only editor that the user is currently typing with. + */ + getFocusedEditor(): IEditorWidget | undefined; + + getEditors(): readonly IEditorWidget[]; +} + +export interface IEditorHostService extends IEditorService { + onCreate(): void; + create(editor: IEditorWidget): void; + onClose(editor: IEditorWidget): void; + close(): void; + focus(editor: IEditorWidget): void; + blur(editor: IEditorWidget): void; +} \ No newline at end of file diff --git a/src/workbench/services/editor/editorService.ts b/src/workbench/services/editor/editorService.ts new file mode 100644 index 000000000..38d4e74ce --- /dev/null +++ b/src/workbench/services/editor/editorService.ts @@ -0,0 +1,85 @@ +import { Disposable } from "src/base/common/dispose"; +import { Emitter } from "src/base/common/event"; +import { IEditorWidget } from "src/editor/editorWidget"; +import { IEditorHostService } from "src/workbench/services/editor/editor"; + +export class EditorService extends Disposable implements IEditorHostService { + + declare _serviceMarker: undefined; + + // [event] + + private readonly _onCreateEditor = this.__register(new Emitter()); + public readonly onCreateEditor = this._onCreateEditor.registerListener; + + private readonly _onDidCreateEditor = this.__register(new Emitter()); + public readonly onDidCreateEditor = this._onDidCreateEditor.registerListener; + + private readonly _onCloseEditor = this.__register(new Emitter()); + public readonly onCloseEditor = this._onCloseEditor.registerListener; + + private readonly _onDidCloseEditor = this.__register(new Emitter()); + public readonly onDidCloseEditor = this._onDidCloseEditor.registerListener; + + private readonly _onDidFocusedEditorChange = this.__register(new Emitter()); + public readonly onDidFocusedEditorChange = this._onDidFocusedEditorChange.registerListener; + + // [field] + + private readonly _editors: Map; + private _focusedEditor: IEditorWidget | undefined; + + // [constructor] + + constructor() { + super(); + this._editors = new Map(); + this._focusedEditor = undefined; + } + + // [public methods] + + public override dispose(): void { + super.dispose(); + this._editors.clear(); + } + + public onCreate(): void { + this._onCreateEditor.fire(); + } + + public create(editor: IEditorWidget): void { + this._editors.set(editor.getID(), editor); + this._onDidCreateEditor.fire(editor); + } + + public onClose(editor: IEditorWidget): void { + this._editors.delete(editor.getID()); + this._onCloseEditor.fire(editor); + } + + public close(): void { + this._onDidCloseEditor.fire(); + } + + public focus(editor: IEditorWidget): void { + this._focusedEditor = editor; + this._onDidFocusedEditorChange.fire(editor); + } + + public blur(editor: IEditorWidget): void { + this._focusedEditor = undefined; + this._onDidFocusedEditorChange.fire(undefined); + } + + public getFocusedEditor(): IEditorWidget | undefined { + return this._focusedEditor; + } + + public getEditors(): readonly IEditorWidget[] { + return [...this._editors.values()]; + } + + // [private methods] + +} \ No newline at end of file From 2bcb4b94ccfcf2de716de793fb040ceb07e96a7e Mon Sep 17 00:00:00 2001 From: SIHAN LI <1015813038@qq.com> Date: Wed, 26 Feb 2025 16:53:24 -0500 Subject: [PATCH 15/21] [Feat] tracking `EditorWidget` lifecycle --- src/editor/editorWidget.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/editor/editorWidget.ts b/src/editor/editorWidget.ts index 5b9946bac..b6d5b6e01 100644 --- a/src/editor/editorWidget.ts +++ b/src/editor/editorWidget.ts @@ -23,6 +23,7 @@ import { EditorViewModel } from "src/editor/viewModel/editorViewModel"; import { IEditorViewModel, IViewModelBuildData } from "src/editor/common/viewModel"; import { IEditorInputEmulator } from "src/editor/view/inputEmulator"; import { KeyCode } from "src/base/common/keyboard"; +import { IEditorHostService } from "src/workbench/services/editor/editor"; // region - [interface] @@ -295,8 +296,10 @@ export class EditorWidget extends Disposable implements IEditorWidget { @IInstantiationService private readonly instantiationService: IInstantiationService, @ILifecycleService private readonly lifecycleService: IBrowserLifecycleService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IEditorHostService private readonly editorService: IEditorHostService, ) { super(); + this.editorService.onCreate(); this._container = this.__register(new FastElement(container)); this._model = null; @@ -354,6 +357,10 @@ export class EditorWidget extends Disposable implements IEditorWidget { // cache data this._editorData = this.__register(new EditorData(this._model, this._viewModel, this._view, undefined)); + + // track + this.editorService.create(this); + return ok(); }); } @@ -370,10 +377,14 @@ export class EditorWidget extends Disposable implements IEditorWidget { } public override dispose(): void { + this.editorService.onClose(this); + super.dispose(); this.__detachData(); this._extensions.dispose(); this.logService.debug('EditorWidget', 'Editor disposed.'); + + this.editorService.close(); } public updateOptions(newOption: Partial): void { @@ -492,6 +503,9 @@ export class EditorWidget extends Disposable implements IEditorWidget { this._options.updateOptions(newOption); } })); + + this.__register(this.onDidFocus(e => this.editorService.focus(this))); + this.__register(this.onDidBlur(e => this.editorService.blur(this))); } private __registerMVVMListeners(model: IEditorModel, viewModel: IEditorViewModel, view: IEditorView): void { From ec64bd022246852457a38391c10437b8f844d0eb Mon Sep 17 00:00:00 2001 From: SIHAN LI <1015813038@qq.com> Date: Wed, 26 Feb 2025 17:50:45 -0500 Subject: [PATCH 16/21] [Fix] register `IEditorService` as a core service --- src/code/browser/renderer.desktop.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/code/browser/renderer.desktop.ts b/src/code/browser/renderer.desktop.ts index 4867fec4a..738a3626b 100644 --- a/src/code/browser/renderer.desktop.ts +++ b/src/code/browser/renderer.desktop.ts @@ -73,6 +73,7 @@ import { BrowserAITextChannel } from "src/platform/ai/common/aiTextChannel"; import { IAITextService } from "src/platform/ai/common/aiText"; import { IEditorService } from "src/workbench/services/editor/editor"; import { EditorService } from "src/workbench/services/editor/editorService"; +import { rendererEditorCommandRegister } from "src/editor/contrib/command/command.register"; /** * @class This is the main entry of the renderer process. @@ -150,6 +151,10 @@ const renderer = new class extends class RendererInstance extends Disposable { const contextService = new ContextService(); instantiationService.store(IContextService, contextService); + // editor-service + const editorService = new EditorService(); + instantiationService.store(IEditorService, editorService); + // registrant-service const registrantService = instantiationService.createInstance(RegistrantService); instantiationService.store(IRegistrantService, registrantService); @@ -282,7 +287,6 @@ const renderer = new class extends class RendererInstance extends Disposable { registerService(IBrowserZoomService , new ServiceDescriptor(BrowserZoomService , [])); registerService(IBrowserInspectorService , new ServiceDescriptor(BrowserInspectorService , [])); registerService(IRecentOpenService , new ServiceDescriptor(RecentOpenService , [])); - registerService(IEditorService , new ServiceDescriptor(EditorService , [])); } // [end] @@ -324,6 +328,7 @@ const renderer = new class extends class RendererInstance extends Disposable { [ rendererWorkbenchCommandRegister, rendererTitleBarFileCommandRegister, + rendererEditorCommandRegister, ] .forEach(register => register(provider)); } From 3db7e1df4a4dcc445b92e3f270e18f8c5fb0f311 Mon Sep 17 00:00:00 2001 From: SIHAN LI <1015813038@qq.com> Date: Wed, 26 Feb 2025 17:52:43 -0500 Subject: [PATCH 17/21] [Refactor] `EditorCommand` is executed based on the context of the current editor --- src/editor/contrib/command/command.contrib.ts | 374 ++---------------- .../contrib/command/command.register.ts | 349 ++++++++++++++++ src/editor/contrib/command/editorCommand.ts | 23 +- ...List.contrib.ts => listCommand.contrib.ts} | 89 +---- src/platform/registrant/common/registrant.ts | 4 +- test/editor/contrib/editorCommands.test.ts | 219 ---------- 6 files changed, 425 insertions(+), 633 deletions(-) create mode 100644 src/editor/contrib/command/command.register.ts rename src/editor/contrib/command/{commandList.contrib.ts => listCommand.contrib.ts} (75%) delete mode 100644 test/editor/contrib/editorCommands.test.ts diff --git a/src/editor/contrib/command/command.contrib.ts b/src/editor/contrib/command/command.contrib.ts index 44ec71f23..0a021b957 100644 --- a/src/editor/contrib/command/command.contrib.ts +++ b/src/editor/contrib/command/command.contrib.ts @@ -1,306 +1,14 @@ -import type { IEditorCommandExtension } from "src/editor/contrib/command/command"; import type { IEditorWidget } from "src/editor/editorWidget"; import { ReplaceAroundStep, canJoin, canSplit, liftTarget, replaceStep } from "prosemirror-transform"; -import { ILogService } from "src/base/common/logger"; -import { MarkEnum, TokenEnum } from "src/editor/common/markdown"; +import { TokenEnum } from "src/editor/common/markdown"; import { ProseEditorState, ProseTransaction, ProseAllSelection, ProseTextSelection, ProseNodeSelection, ProseEditorView, ProseReplaceStep, ProseSlice, ProseFragment, ProseNode, ProseSelection, ProseContentMatch, ProseMarkType, ProseAttrs, ProseSelectionRange, ProseNodeType, ProseResolvedPos, ProseCursor } from "src/editor/common/proseMirror"; import { ProseTools } from "src/editor/common/proseUtility"; import { EditorSchema } from "src/editor/model/schema"; import { Command, ICommandSchema } from "src/platform/command/common/command"; -import { CreateContextKeyExpr } from "src/platform/context/common/contextKeyExpr"; import { IServiceProvider } from "src/platform/instantiation/common/instantiation"; -import { EditorContextKeys } from "src/editor/common/editorContextKeys"; -import { IS_MAC } from "src/base/common/platform"; import { redo, undo } from "prosemirror-history"; import { INotificationService } from "src/workbench/services/notification/notification"; -import { buildEditorCommand, EditorCommandArguments, EditorCommandBase } from "src/editor/contrib/command/editorCommand"; -import { registerListCommands } from "src/editor/contrib/command/commandList.contrib"; -import { Shortcut } from "src/base/common/keyboard"; -import { ShortcutWeight } from "src/workbench/services/shortcut/shortcutRegistrant"; - -/** - * [FILE OUTLINE] - * - * {@link registerBasicEditorCommands} - * {@link EditorCommandBase} - * {@link EditorCommands} - */ - -const whenEditorReadonly = CreateContextKeyExpr.And(EditorContextKeys.editorFocusedContext, EditorContextKeys.isEditorReadonly); -const whenEditorWritable = CreateContextKeyExpr.And(EditorContextKeys.editorFocusedContext, EditorContextKeys.isEditorWritable); - -/** - * @description A set of default editor command configurations. - */ -export function registerBasicEditorCommands(extension: IEditorCommandExtension, logService: ILogService, getArguments: () => EditorCommandArguments): void { - registerListCommands(extension, logService, getArguments); - __registerToggleMarkCommands(extension, logService, getArguments); - __registerHeadingCommands(extension, logService, getArguments); - __registerBasicCommands(extension, getArguments); -} - -function getPlatformShortcut(ctrl: string, meta: string): string { - return IS_MAC ? meta : ctrl; -} - -/** - * @description Register Toggle Mark Commands. - * @note These commands need to be constructed after the editor and schema - * are initialized. - */ -function __registerToggleMarkCommands(extension: IEditorCommandExtension, logService: ILogService, getArguments: () => EditorCommandArguments): void { - const schema = extension.getEditorSchema().unwrap(); - const toggleMarkConfigs: [string, string, string][] = [ - [MarkEnum.Strong, 'Ctrl+B', 'Meta+B'], - [MarkEnum.Em, 'Ctrl+I', 'Meta+I'], - [MarkEnum.Codespan, 'Ctrl+`', 'Meta+`'], - ]; - for (const [markID, ctrl, meta] of toggleMarkConfigs) { - const toggleCmdID = `editor-toggle-mark-${markID}`; - const markType = schema.getMarkType(markID); - - if (!markType) { - logService.warn(extension.id, `Cannot register the editor command (${toggleCmdID}) because the mark type does not exists in the editor schema.`); - continue; - } - - extension.registerCommand( - EditorCommands.createToggleMarkCommand( - { - id: toggleCmdID, - when: whenEditorWritable, - shortcutOptions: { - commandArgs: getArguments, - shortcut: Shortcut.fromString(getPlatformShortcut(ctrl, meta)), - weight: ShortcutWeight.Editor, - when: whenEditorWritable, - } - }, - markType, - null, // attrs - { - removeWhenPresent: true, - enterInlineAtoms: true, - } - ) - ); - } -} - -/** - * @description Register Toggle Heading Commands. Ctrl+(1-6) will toggle the - * block into Heading block node. - * @note These commands need to be constructed after the editor and schema - * are initialized. - */ -function __registerHeadingCommands(extension: IEditorCommandExtension, logService: ILogService, getArguments: () => EditorCommandArguments): void { - const schema = extension.getEditorSchema().unwrap(); - const headingCmdID = 'editor-toggle-heading'; - - const nodeType = schema.getNodeType(TokenEnum.Heading); - if (!nodeType) { - logService.warn(extension.id, `Cannot register the editor command (${headingCmdID}) because the token type does not exists in the editor schema.`); - return; - } - - for (let level = 1; level <= 6; level++) { - const cmdID = `${headingCmdID}-${level}`; - extension.registerCommand( - EditorCommands.createSetBlockCommand( - { - id: cmdID, - when: whenEditorWritable, - shortcutOptions: { - commandArgs: getArguments, - shortcut: Shortcut.fromString(getPlatformShortcut(`Ctrl+${level}`, `Meta+${level}`)), - weight: ShortcutWeight.Editor, - when: whenEditorWritable, - } - }, - nodeType, - { level: level } - ) - ); - } -} - -function __registerBasicCommands(extension: IEditorCommandExtension, getArguments: () => EditorCommandArguments): void { - extension.registerCommand(buildEditorCommand( - { - id: 'editor-enter', - when: whenEditorWritable, - shortcutOptions: { - commandArgs: getArguments, - shortcut: Shortcut.fromString('Enter'), - weight: ShortcutWeight.Editor, - when: whenEditorWritable, - } - }, - [ - EditorCommands.InsertNewLineInCodeBlock, - EditorCommands.InsertEmptyParagraphAdjacentToBlock, - EditorCommands.LiftEmptyTextBlock, - EditorCommands.SplitBlockAtSelection, - ], - )); - - extension.registerCommand(buildEditorCommand( - { - id: 'editor-esc', - when: whenEditorReadonly, - shortcutOptions: { - commandArgs: getArguments, - shortcut: Shortcut.fromString('Escape'), - weight: ShortcutWeight.Editor, - when: whenEditorReadonly, - } - }, - [ - EditorCommands.Unselect - ], - )); - - extension.registerCommand(buildEditorCommand( - { - id: 'editor-backspace', - when: whenEditorWritable, - shortcutOptions: { - commandArgs: getArguments, - shortcut: Shortcut.fromString('Backspace'), - weight: ShortcutWeight.Editor, - when: whenEditorWritable, - } - }, - [ - EditorCommands.DeleteSelection, - EditorCommands.JoinBackward, - EditorCommands.SelectNodeBackward, - ], - )); - - - extension.registerCommand(buildEditorCommand( - { - id: 'editor-delete', - when: whenEditorWritable, - shortcutOptions: { - commandArgs: getArguments, - shortcut: [ - Shortcut.fromString('Delete'), - Shortcut.fromString(getPlatformShortcut('Ctrl+Delete', 'Meta+Delete')), - ], - weight: ShortcutWeight.Editor, - when: whenEditorWritable, - } - }, - [ - EditorCommands.DeleteSelection, - EditorCommands.JoinForward, - EditorCommands.SelectNodeForward, - ] - )); - - extension.registerCommand(buildEditorCommand( - { - id: 'editor-select-all', - when: whenEditorReadonly, - shortcutOptions: { - commandArgs: getArguments, - shortcut: Shortcut.fromString(getPlatformShortcut('Ctrl+A', 'Meta+A')), - weight: ShortcutWeight.Editor, - when: whenEditorReadonly, - } - }, - [ - EditorCommands.SelectAll - ] - )); - - // @fix Doesn't work with CM, guess bcz CM is focused but PM is not. - extension.registerCommand(buildEditorCommand( - { - id: 'editor-exit-code-block', - when: whenEditorReadonly, - shortcutOptions: { - commandArgs: getArguments, - shortcut: Shortcut.fromString(getPlatformShortcut('Ctrl+Enter', 'Meta+Enter')), - weight: ShortcutWeight.Editor, - when: whenEditorReadonly, - } - }, - [ - EditorCommands.ExitCodeBlock - ] - )); - - extension.registerCommand(buildEditorCommand( - { - id: 'editor-insert-hard-break', - when: whenEditorWritable, - shortcutOptions: { - commandArgs: getArguments, - shortcut: [ - Shortcut.fromString('Shift+Enter'), - Shortcut.fromString(getPlatformShortcut('Ctrl+Enter', 'Meta+Enter')), - ], - weight: ShortcutWeight.Editor, - when: whenEditorWritable, - } - }, - [ - EditorCommands.ExitCodeBlock, - EditorCommands.InsertHardBreak, - ] - )); - - extension.registerCommand(buildEditorCommand( - { - id: 'editor-save', - when: whenEditorWritable, - shortcutOptions: { - commandArgs: getArguments, - shortcut: Shortcut.fromString(getPlatformShortcut('Ctrl+S', 'Meta+S')), - weight: ShortcutWeight.Editor, - when: whenEditorWritable, - } - }, - [ - EditorCommands.FileSave, - ] - )); - - extension.registerCommand(buildEditorCommand( - { - id: 'editor-undo', - when: whenEditorWritable, - shortcutOptions: { - commandArgs: getArguments, - shortcut: Shortcut.fromString(getPlatformShortcut('Ctrl+Z', 'Meta+Z')), - weight: ShortcutWeight.Editor, - when: whenEditorWritable, - } - }, - [ - EditorCommands.FileUndo, - ] - )); - - extension.registerCommand(buildEditorCommand( - { - id: 'editor-redo', - when: whenEditorWritable, - shortcutOptions: { - commandArgs: getArguments, - shortcut: Shortcut.fromString(getPlatformShortcut('Ctrl+Shift+Z', 'Meta+Shift+Z')), - weight: ShortcutWeight.Editor, - when: whenEditorWritable, - } - }, - [ - EditorCommands.FileRedo, - ] - )); -} +import { EditorCommand } from "src/editor/contrib/command/editorCommand"; /** * @description Contains a list of commands specific for editor. Every basic @@ -309,9 +17,9 @@ function __registerBasicCommands(extension: IEditorCommandExtension, getArgument */ export namespace EditorCommands { - export class Unselect extends EditorCommandBase { + export class Unselect extends EditorCommand { - public run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean { + protected __run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean { const { $to } = state.selection; const tr = state.tr.setSelection(ProseTextSelection.create(state.doc, $to.pos)); dispatch?.(tr); @@ -320,9 +28,9 @@ export namespace EditorCommands { } } - export class SelectAll extends EditorCommandBase { + export class SelectAll extends EditorCommand { - public run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void): boolean { + protected __run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void): boolean { if (!dispatch) { return false; } @@ -391,9 +99,9 @@ export namespace EditorCommands { * If the selection meets this criteria, the function replaces the selection * with a newline character. */ - export class InsertNewLineInCodeBlock extends EditorCommandBase { + export class InsertNewLineInCodeBlock extends EditorCommand { - public run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void): boolean { + protected __run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void): boolean { const { $head, $anchor } = state.selection; if (!$head.parent.type.spec.code || !$head.sameParent($anchor)) { return false; @@ -412,9 +120,9 @@ export namespace EditorCommands { * function adds a paragraph before the block node if it is the first child * of its parent, otherwise, it adds a paragraph after the block node. */ - export class InsertEmptyParagraphAdjacentToBlock extends EditorCommandBase { + export class InsertEmptyParagraphAdjacentToBlock extends EditorCommand { - public run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void): boolean { + protected __run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void): boolean { const { $from, $to } = state.selection; // Check if the selection is not suitable for paragraph insertion. @@ -453,9 +161,9 @@ export namespace EditorCommands { * can be "lifted" (meaning moved out of its current context, like out of a * list or a quote), the function performs this action. */ - export class LiftEmptyTextBlock extends EditorCommandBase { + export class LiftEmptyTextBlock extends EditorCommand { - public run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void): boolean { + protected __run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void): boolean { if (!(state.selection instanceof ProseTextSelection)) { return false; } @@ -497,9 +205,9 @@ export namespace EditorCommands { * the selection is within a block, the function divides the block into two * at that point. */ - export class SplitBlockAtSelection extends EditorCommandBase { + export class SplitBlockAtSelection extends EditorCommand { - public run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void): boolean { + protected __run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void): boolean { const { $from, $to } = state.selection; if (state.selection instanceof ProseNodeSelection && state.selection.node.isBlock) { @@ -566,9 +274,9 @@ export namespace EditorCommands { /** * @description Delete the current selection. */ - export class DeleteSelection extends EditorCommandBase { + export class DeleteSelection extends EditorCommand { - public run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void): boolean { + protected __run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void): boolean { if (state.selection.empty) { return false; } @@ -588,9 +296,9 @@ export namespace EditorCommands { * the previous block. Will use the view for accurate (bidi-aware) start-of * -textblock detection if given. */ - export class JoinBackward extends EditorCommandBase { + export class JoinBackward extends EditorCommand { - public run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean { + protected __run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean { const $cursor = __atBlockStart(state, view); if (!$cursor) { return false; @@ -662,9 +370,9 @@ export namespace EditorCommands { * other block closer to this one in the tree structure. Will use the * view for accurate start-of-textblock detection if given. */ - export class JoinForward extends EditorCommandBase { + export class JoinForward extends EditorCommand { - public run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean { + protected __run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean { const $cursor = __atBlockEnd(state, view); if (!$cursor) { return false; @@ -721,9 +429,9 @@ export namespace EditorCommands { * or other deleting commands, as a fall-back behavior when the schema * doesn't allow deletion at the selected point. */ - export class SelectNodeBackward extends EditorCommandBase { + export class SelectNodeBackward extends EditorCommand { - public run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean { + protected __run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean { const $head = state.selection.$head; const ifEmpty = state.selection.empty; @@ -760,9 +468,9 @@ export namespace EditorCommands { * commands, to provide a fall-back behavior when the schema doesn't * allow deletion at the selected point. */ - export class SelectNodeForward extends EditorCommandBase { + export class SelectNodeForward extends EditorCommand { - public run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean { + protected __run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean { const { empty } = state.selection; if (!empty) { return false; @@ -809,9 +517,9 @@ export namespace EditorCommands { * insertion, it creates the new block at the position right after the * code block and moves the cursor into it. */ - export class ExitCodeBlock extends EditorCommandBase { + export class ExitCodeBlock extends EditorCommand { - public run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean { + protected __run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean { const { $head, $anchor } = state.selection; const isInCodeBlock = $head.parent.type.spec.code; const isSelectionCollapsed = $head.sameParent($anchor); @@ -861,7 +569,7 @@ export namespace EditorCommands { */ export function createToggleMarkCommand( schema: ICommandSchema, - markType: TType, + getMarkType: () => TType, attrs: ProseAttrs | null = null, options?: { /** @@ -890,8 +598,9 @@ export namespace EditorCommands { const removeWhenPresent = options?.removeWhenPresent ?? true; const enterAtoms = options?.enterInlineAtoms ?? true; - return new class extends EditorCommandBase { - public run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean { + return new class extends EditorCommand { + protected __run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean { + const markType = getMarkType(); const { empty, $cursor } = state.selection as ProseTextSelection; let ranges = state.selection.ranges; @@ -992,11 +701,12 @@ export namespace EditorCommands { */ export function createSetBlockCommand( schema: ICommandSchema, - nodeType: TType, + getNodeType: () => TType, attrs: ProseAttrs | null = null ): Command { - return new class extends EditorCommandBase { - public run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean { + return new class extends EditorCommand { + protected __run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean { + const nodeType = getNodeType(); let applicable = false; // Step 1: Check if the block type can be applied to any node in the selection ranges @@ -1050,9 +760,9 @@ export namespace EditorCommands { }(schema); } - export class InsertHardBreak extends EditorCommandBase { + export class InsertHardBreak extends EditorCommand { - public run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void): boolean { + protected __run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void): boolean { const br = (state.schema).getNodeType(TokenEnum.LineBreak); if (!br) { return false; @@ -1066,9 +776,9 @@ export namespace EditorCommands { } } - export class FileSave extends EditorCommandBase { + export class FileSave extends EditorCommand { - public run(provider: IServiceProvider, editor: IEditorWidget): boolean { + protected __run(provider: IServiceProvider, editor: IEditorWidget): boolean { editor.save() .match( () => {}, @@ -1084,16 +794,16 @@ export namespace EditorCommands { } } - export class FileUndo extends EditorCommandBase { - public run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean { + export class FileUndo extends EditorCommand { + protected __run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean { // const historyExtension = editor.getExtension(EditorExtensionIDs.History) as IEditorHistoryExtension; // return historyExtension['undo'](state, dispatch); return undo(state, dispatch, view); } } - export class FileRedo extends EditorCommandBase { - public run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean { + export class FileRedo extends EditorCommand { + protected __run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean { // const historyExtension = editor.getExtension(EditorExtensionIDs.History) as IEditorHistoryExtension; // return historyExtension['redo'](state, dispatch); return redo(state, dispatch, view); diff --git a/src/editor/contrib/command/command.register.ts b/src/editor/contrib/command/command.register.ts new file mode 100644 index 000000000..4fce310c2 --- /dev/null +++ b/src/editor/contrib/command/command.register.ts @@ -0,0 +1,349 @@ +import { Shortcut } from "src/base/common/keyboard"; +import { IS_MAC } from "src/base/common/platform"; +import { IO } from "src/base/common/utilities/functional"; +import { panic } from "src/base/common/utilities/panic"; +import { EditorContextKeys } from "src/editor/common/editorContextKeys"; +import { MarkEnum, TokenEnum } from "src/editor/common/markdown"; +import { ProseMarkType, ProseNodeType } from "src/editor/common/proseMirror"; +import { EditorCommands } from "src/editor/contrib/command/command.contrib"; +import { EditorListCommands } from "src/editor/contrib/command/listCommand.contrib"; +import { buildEditorCommand } from "src/editor/contrib/command/editorCommand"; +import { ICommandRegistrant } from "src/platform/command/common/commandRegistrant"; +import { CreateContextKeyExpr } from "src/platform/context/common/contextKeyExpr"; +import { createRegister, RegistrantType } from "src/platform/registrant/common/registrant"; +import { IEditorService } from "src/workbench/services/editor/editor"; +import { ShortcutWeight } from "src/workbench/services/shortcut/shortcutRegistrant"; + +export const rendererEditorCommandRegister = createRegister( + RegistrantType.Command, + 'rendererWorkbench', + (registrant, provider) => { + const editorService = provider.getOrCreateService(IEditorService); + __registerListCommands(registrant, editorService); + __registerToggleMarkCommands(registrant, editorService); + __registerHeadingCommands(registrant, editorService); + __registerBasicCommands(registrant, editorService); + } +); + +const whenEditorReadonly = CreateContextKeyExpr.And(EditorContextKeys.editorFocusedContext, EditorContextKeys.isEditorReadonly); +function getPlatformShortcut(ctrl: string, meta: string): string { + return IS_MAC ? meta : ctrl; +} + +/** + * @description Register Toggle Mark Commands. + * @note These commands need to be constructed after the editor and schema + * are initialized. + */ +function __registerToggleMarkCommands(registrant: ICommandRegistrant, editorService: IEditorService): void { + const toggleMarkConfigs: [string, string, string][] = [ + [MarkEnum.Strong, 'Ctrl+B', 'Meta+B'], + [MarkEnum.Em, 'Ctrl+I', 'Meta+I'], + [MarkEnum.Codespan, 'Ctrl+`', 'Meta+`'], + ]; + for (const [markID, ctrl, meta] of toggleMarkConfigs) { + const toggleCmdID = `editor-toggle-mark-${markID}`; + registrant.registerCommand( + EditorCommands.createToggleMarkCommand( + { + id: toggleCmdID, + when: EditorContextKeys.isEditorEditable, + shortcutOptions: { + commandArgs: [], + shortcut: Shortcut.fromString(getPlatformShortcut(ctrl, meta)), + weight: ShortcutWeight.Editor, + when: EditorContextKeys.isEditorEditable, + } + }, + getSchemaTypeBasedOnCurrEditor(editorService, 'mark', markID), + null, // attrs + { + removeWhenPresent: true, + enterInlineAtoms: true, + } + ) + ); + } +} + +/** + * @description Register Toggle Heading Commands. Ctrl+(1-6) will toggle the + * block into Heading block node. + * @note These commands need to be constructed after the editor and schema + * are initialized. + */ +function __registerHeadingCommands(registrant: ICommandRegistrant, editorService: IEditorService): void { + const headingCmdID = 'editor-toggle-heading'; + + for (let level = 1; level <= 6; level++) { + const cmdID = `${headingCmdID}-${level}`; + registrant.registerCommand( + EditorCommands.createSetBlockCommand( + { + id: cmdID, + when: EditorContextKeys.isEditorEditable, + shortcutOptions: { + commandArgs: [], + shortcut: Shortcut.fromString(getPlatformShortcut(`Ctrl+${level}`, `Meta+${level}`)), + weight: ShortcutWeight.Editor, + when: EditorContextKeys.isEditorEditable, + } + }, + getSchemaTypeBasedOnCurrEditor(editorService, 'node', TokenEnum.Heading), + { level: level } + ) + ); + } +} + +function __registerBasicCommands(registrant: ICommandRegistrant, editorService: IEditorService): void { + registrant.registerCommand(buildEditorCommand( + { + id: 'editor-enter', + when: EditorContextKeys.isEditorEditable, + shortcutOptions: { + commandArgs: [], + shortcut: Shortcut.fromString('Enter'), + weight: ShortcutWeight.Editor, + when: EditorContextKeys.isEditorEditable, + } + }, + [ + EditorCommands.InsertNewLineInCodeBlock, + EditorCommands.InsertEmptyParagraphAdjacentToBlock, + EditorCommands.LiftEmptyTextBlock, + EditorCommands.SplitBlockAtSelection, + ], + )); + + registrant.registerCommand(buildEditorCommand( + { + id: 'editor-esc', + when: whenEditorReadonly, + shortcutOptions: { + commandArgs: [], + shortcut: Shortcut.fromString('Escape'), + weight: ShortcutWeight.Editor, + when: whenEditorReadonly, + } + }, + [ + EditorCommands.Unselect + ], + )); + + registrant.registerCommand(buildEditorCommand( + { + id: 'editor-backspace', + when: EditorContextKeys.isEditorEditable, + shortcutOptions: { + commandArgs: [], + shortcut: Shortcut.fromString('Backspace'), + weight: ShortcutWeight.Editor, + when: EditorContextKeys.isEditorEditable, + } + }, + [ + EditorCommands.DeleteSelection, + EditorCommands.JoinBackward, + EditorCommands.SelectNodeBackward, + ], + )); + + + registrant.registerCommand(buildEditorCommand( + { + id: 'editor-delete', + when: EditorContextKeys.isEditorEditable, + shortcutOptions: { + commandArgs: [], + shortcut: [ + Shortcut.fromString('Delete'), + Shortcut.fromString(getPlatformShortcut('Ctrl+Delete', 'Meta+Delete')), + ], + weight: ShortcutWeight.Editor, + when: EditorContextKeys.isEditorEditable, + } + }, + [ + EditorCommands.DeleteSelection, + EditorCommands.JoinForward, + EditorCommands.SelectNodeForward, + ] + )); + + registrant.registerCommand(buildEditorCommand( + { + id: 'editor-select-all', + when: whenEditorReadonly, + shortcutOptions: { + commandArgs: [], + shortcut: Shortcut.fromString(getPlatformShortcut('Ctrl+A', 'Meta+A')), + weight: ShortcutWeight.Editor, + when: whenEditorReadonly, + } + }, + [ + EditorCommands.SelectAll + ] + )); + + // @fix Doesn't work with CM, guess bcz CM is focused but PM is not. + registrant.registerCommand(buildEditorCommand( + { + id: 'editor-exit-code-block', + when: whenEditorReadonly, + shortcutOptions: { + commandArgs: [], + shortcut: Shortcut.fromString(getPlatformShortcut('Ctrl+Enter', 'Meta+Enter')), + weight: ShortcutWeight.Editor, + when: whenEditorReadonly, + } + }, + [ + EditorCommands.ExitCodeBlock + ] + )); + + registrant.registerCommand(buildEditorCommand( + { + id: 'editor-insert-hard-break', + when: EditorContextKeys.isEditorEditable, + shortcutOptions: { + commandArgs: [], + shortcut: [ + Shortcut.fromString('Shift+Enter'), + Shortcut.fromString(getPlatformShortcut('Ctrl+Enter', 'Meta+Enter')), + ], + weight: ShortcutWeight.Editor, + when: EditorContextKeys.isEditorEditable, + } + }, + [ + EditorCommands.ExitCodeBlock, + EditorCommands.InsertHardBreak, + ] + )); + + registrant.registerCommand(buildEditorCommand( + { + id: 'editor-save', + when: EditorContextKeys.isEditorEditable, + shortcutOptions: { + commandArgs: [], + shortcut: Shortcut.fromString(getPlatformShortcut('Ctrl+S', 'Meta+S')), + weight: ShortcutWeight.Editor, + when: EditorContextKeys.isEditorEditable, + } + }, + [ + EditorCommands.FileSave, + ] + )); + + registrant.registerCommand(buildEditorCommand( + { + id: 'editor-undo', + when: EditorContextKeys.isEditorEditable, + shortcutOptions: { + commandArgs: [], + shortcut: Shortcut.fromString(getPlatformShortcut('Ctrl+Z', 'Meta+Z')), + weight: ShortcutWeight.Editor, + when: EditorContextKeys.isEditorEditable, + } + }, + [ + EditorCommands.FileUndo, + ] + )); + + registrant.registerCommand(buildEditorCommand( + { + id: 'editor-redo', + when: EditorContextKeys.isEditorEditable, + shortcutOptions: { + commandArgs: [], + shortcut: Shortcut.fromString(getPlatformShortcut('Ctrl+Shift+Z', 'Meta+Shift+Z')), + weight: ShortcutWeight.Editor, + when: EditorContextKeys.isEditorEditable, + } + }, + [ + EditorCommands.FileRedo, + ] + )); +} + +function __registerListCommands(registrant: ICommandRegistrant, editorService: IEditorService): void { + const getCurrListItemType = getSchemaTypeBasedOnCurrEditor(editorService, 'node', TokenEnum.ListItem); + + + registrant.registerCommand( + EditorListCommands.splitListItem( + { + id: 'editor-split-list-item', + when: EditorContextKeys.isEditorEditable, + shortcutOptions: { + when: EditorContextKeys.isEditorEditable, + weight: ShortcutWeight.Editor, + shortcut: Shortcut.fromString('Enter'), + commandArgs: [], + } + }, + getCurrListItemType, + undefined, + ), + ); + + registrant.registerCommand( + EditorListCommands.sinkListItem( + { + id: 'editor-sink-list-item', + when: EditorContextKeys.isEditorEditable, + shortcutOptions: { + when: EditorContextKeys.isEditorEditable, + weight: ShortcutWeight.Editor, + shortcut: Shortcut.fromString('Tab'), + commandArgs: [], + } + }, + getCurrListItemType, + ) + ); + + registrant.registerCommand( + EditorListCommands.liftListItem( + { + id: 'editor-lift-list-item', + when: EditorContextKeys.isEditorEditable, + shortcutOptions: { + when: EditorContextKeys.isEditorEditable, + weight: ShortcutWeight.Editor, + shortcut: Shortcut.fromString('Shift+Tab'), + commandArgs: [], + } + }, + getCurrListItemType, + ) + ); +} + +/** + * Since {@link EditorCommand} is executed based the current editor context. So + * the schema must also be determined dynamically. + */ +function getSchemaTypeBasedOnCurrEditor(editorService: IEditorService, type: TType, id: string): IO> { + return function () { + const currEditor = editorService.getFocusedEditor(); + if (!currEditor) { + panic(`Cannot execute the editor command (${id}) because the ${type} type does not exists in the current editor schema.`); + } + const view = currEditor.view.editor.internalView; + return >(type === 'mark' + ? view.state.schema.marks[id]! + : view.state.schema.nodes[id]!); + }; +} + +type GetNodeType = TType extends 'mark' ? ProseMarkType : ProseNodeType; \ No newline at end of file diff --git a/src/editor/contrib/command/editorCommand.ts b/src/editor/contrib/command/editorCommand.ts index f5e45e247..55182067c 100644 --- a/src/editor/contrib/command/editorCommand.ts +++ b/src/editor/contrib/command/editorCommand.ts @@ -1,18 +1,31 @@ -import { Shift } from "src/base/common/utilities/type"; import { ProseEditorState, ProseTransaction, ProseEditorView } from "src/editor/common/proseMirror"; import { IEditorWidget } from "src/editor/editorWidget"; import { buildChainCommand, Command, ICommandSchema } from "src/platform/command/common/command"; import { IServiceProvider } from "src/platform/instantiation/common/instantiation"; +import { IEditorService } from "src/workbench/services/editor/editor"; /** * @class A base class for every command in the {@link EditorCommandsBasic}. */ -export abstract class EditorCommandBase extends Command { - public abstract override run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean | Promise; +export abstract class EditorCommand extends Command { + protected abstract __run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean | Promise; + + /** + * Whenever {@link EditorCommand} executed, it will be executed based on + * the context of the current editor. If no editors is focused, will not be + * executed. + */ + public override run(provider: IServiceProvider): boolean | Promise { + const editorService = provider.getOrCreateService(IEditorService); + const currEditor = editorService.getFocusedEditor(); + if (!currEditor) { + return false; + } + const view = currEditor.view.editor.internalView; + return this.__run(provider, currEditor, view.state, view.dispatch.bind(view), view); + } } -export type EditorCommandArguments = Shift>; - export function buildEditorCommand(schema: ICommandSchema, ctors: (typeof Command)[]): Command { if (ctors.length === 1) { const command = ctors[0]!; diff --git a/src/editor/contrib/command/commandList.contrib.ts b/src/editor/contrib/command/listCommand.contrib.ts similarity index 75% rename from src/editor/contrib/command/commandList.contrib.ts rename to src/editor/contrib/command/listCommand.contrib.ts index eb2bd169e..b62b695c1 100644 --- a/src/editor/contrib/command/commandList.contrib.ts +++ b/src/editor/contrib/command/listCommand.contrib.ts @@ -1,80 +1,17 @@ import { canJoin, canSplit, liftTarget } from "prosemirror-transform"; -import { Shortcut } from "src/base/common/keyboard"; -import { ILogService } from "src/base/common/logger"; -import { EditorContextKeys } from "src/editor/common/editorContextKeys"; -import { TokenEnum } from "src/editor/common/markdown"; import { ProseEditorState, ProseTransaction, ProseEditorView, ProseNodeType, ProseFragment, ProseSlice, ProseReplaceAroundStep, ProseNodeRange, ProseSelection, ProseNodeSelection, ProseAttrs } from "src/editor/common/proseMirror"; -import { IEditorCommandExtension } from "src/editor/contrib/command/command"; -import { EditorCommandArguments, EditorCommandBase } from "src/editor/contrib/command/editorCommand"; +import { EditorCommand } from "src/editor/contrib/command/editorCommand"; import { IEditorWidget } from "src/editor/editorWidget"; import { Command, ICommandSchema } from "src/platform/command/common/command"; import { IServiceProvider } from "src/platform/instantiation/common/instantiation"; -import { ShortcutWeight } from "src/workbench/services/shortcut/shortcutRegistrant"; - -export function registerListCommands(extension: IEditorCommandExtension, logService: ILogService, getArguments: () => EditorCommandArguments): void { - const schema = extension.getEditorSchema().unwrap(); - - const listItemType = schema.getNodeType(TokenEnum.ListItem); - if (!listItemType) { - logService.warn(extension.id, `Cannot register the editor command (${TokenEnum.ListItem}) because the node type does not exists in the editor schema.`); - return; - } - - extension.registerCommand( - EditorListCommands.splitListItem( - { - id: 'editor-split-list-item', - when: EditorContextKeys.isEditorEditable, - shortcutOptions: { - when: EditorContextKeys.isEditorEditable, - weight: ShortcutWeight.Editor, - shortcut: Shortcut.fromString('Enter'), - commandArgs: getArguments, - } - }, - listItemType, - undefined, - ), - ); - - extension.registerCommand( - EditorListCommands.sinkListItem( - { - id: 'editor-sink-list-item', - when: EditorContextKeys.isEditorEditable, - shortcutOptions: { - when: EditorContextKeys.isEditorEditable, - weight: ShortcutWeight.Editor, - shortcut: Shortcut.fromString('Tab'), - commandArgs: getArguments, - } - }, - listItemType, - ) - ); - - extension.registerCommand( - EditorListCommands.liftListItem( - { - id: 'editor-lift-list-item', - when: EditorContextKeys.isEditorEditable, - shortcutOptions: { - when: EditorContextKeys.isEditorEditable, - weight: ShortcutWeight.Editor, - shortcut: Shortcut.fromString('Shift+Tab'), - commandArgs: getArguments, - } - }, - listItemType, - ) - ); -} export namespace EditorListCommands { - export function splitListItem(schema: ICommandSchema, listItemType: TType, itemAttrs?: ProseAttrs): Command { - return new class extends EditorCommandBase { - public override run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean | Promise { + export function splitListItem(schema: ICommandSchema, getListItemType: () => TType, itemAttrs?: ProseAttrs): Command { + return new class extends EditorCommand { + protected __run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean | Promise { + const listItemType = getListItemType(); + const { $from, $to, node } = state.selection as ProseNodeSelection; if ((node && node.isBlock) || $from.depth < 2 || !$from.sameParent($to)) { return false; @@ -153,9 +90,10 @@ export namespace EditorListCommands { }(schema); } - export function liftListItem(schema: ICommandSchema, listItemType: TType): Command { - return new class extends EditorCommandBase { - public override run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean | Promise { + export function liftListItem(schema: ICommandSchema, getListItemType: () => TType): Command { + return new class extends EditorCommand { + protected __run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean | Promise { + const listItemType = getListItemType(); const { $from, $to } = state.selection; const range = $from.blockRange($to, node => node.childCount > 0 && node.firstChild!.type === listItemType); if (!range) { @@ -250,9 +188,10 @@ export namespace EditorListCommands { }(schema); } - export function sinkListItem(schema: ICommandSchema, listItemType: TType): Command { - return new class extends EditorCommandBase { - public override run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean | Promise { + export function sinkListItem(schema: ICommandSchema, getListItemType: () => TType): Command { + return new class extends EditorCommand { + protected __run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean | Promise { + const listItemType = getListItemType(); const { $from, $to } = state.selection; const range = $from.blockRange($to, node => node.childCount > 0 && node.firstChild!.type === listItemType); if (!range) { diff --git a/src/platform/registrant/common/registrant.ts b/src/platform/registrant/common/registrant.ts index 09c202cad..030da0e54 100644 --- a/src/platform/registrant/common/registrant.ts +++ b/src/platform/registrant/common/registrant.ts @@ -100,7 +100,7 @@ export type GetRegistrantByType = T extends (keyof Reg export function createRegister( type: T, description: string, - register: (registrant: GetRegistrantByType) => void, + register: (registrant: GetRegistrantByType, provider: IServiceProvider) => void, ): (provider: IServiceProvider) => void { return (provider: IServiceProvider) => { @@ -110,7 +110,7 @@ export function createRegister( const service = provider.getOrCreateService(IRegistrantService); const registrant = service.getRegistrant(type); try { - register(registrant); + register(registrant, provider); } catch (error: any) { logService.error('createRegister', 'failed registering.', error, { type: type, description: description }); } diff --git a/test/editor/contrib/editorCommands.test.ts b/test/editor/contrib/editorCommands.test.ts deleted file mode 100644 index aba1ac0ff..000000000 --- a/test/editor/contrib/editorCommands.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import * as assert from 'assert'; -import { ProseUtilsTest } from 'test/editor/view/editorHelpers'; -import ist from 'ist'; -import { ProseEditorState, ProseNode, ProseNodeSelection, ProseSchema, ProseSelection, ProseTextSelection } from 'src/editor/common/proseMirror'; -import { EditorCommands } from 'src/editor/contrib/command/command.contrib'; -import { nullObject } from 'test/utils/helpers'; -import { EditorCommandBase } from 'src/editor/contrib/command/editorCommand'; - -const { doc, p, blockquote, hr, ul, li } = ProseUtilsTest.defaultNodes; -// import {schema, eq, doc, blockquote, pre, h1, p, li, ol, ul, em, strong, hr, img} from "prosemirror-test-builder"; - -function getFrom(node: ProseNode): number | undefined { - return (node as any).tag['a']; -} - -function getTo(node: ProseNode): number | undefined { - return (node as any).tag['b']; -} - -/** - * Selection is marked by and during this test environment. - */ -function getSelection(doc: ProseNode): ProseSelection { - const a = getFrom(doc); - - if (a !== undefined) { - const $a = doc.resolve(a); - if ($a.parent.inlineContent) { - const b = getTo(doc); - const $b = b !== undefined ? doc.resolve(b) : undefined; - return new ProseTextSelection($a, $b); - } - - else return new ProseNodeSelection($a); - } - - return ProseSelection.atStart(doc); -} - -function isEqualNode(a: ProseNode, b: ProseNode): boolean { - return a.eq(b); -} - -function createStateBy(doc: ProseNode) { - return ProseEditorState.create({ doc, selection: getSelection(doc) }); -} - -function execCommand(doc: ProseNode, cmd: EditorCommandBase, result: ProseNode | null) { - let state = createStateBy(doc); - - cmd.run(undefined!, undefined!, state, tr => state = state.apply(tr)); - ist(state.doc, result || doc, isEqualNode); - - if (result && getFrom(result) !== undefined) { - ist(state.selection, getSelection(result), isEqualNode); - } -} - -suite.skip('editorCommands-test', () => { - - suite('DeleteSelection', () => { - const cmd = new EditorCommands.DeleteSelection(nullObject()); - - test('deletes part of a text node', () => { - execCommand( - doc(p('foo')), cmd, - doc(p('fo')), - ); - }); - - test('can delete across blocks', () => { - execCommand( - doc(p('foo'), p('bar')), cmd, - doc(p('fr')), - ); - }); - - test('deletes node selections', () => { - execCommand( - doc(p('foo'), '', hr()), cmd, - doc(p('foo')), - ); - }); - - test('moves selection after deleted node', () => { - execCommand( - doc(p('a'), '', p('b'), blockquote(p('c'))), cmd, - doc(p('a'), blockquote(p('c'))) - ); - }); - - test('moves selection before deleted node at end', () => { - execCommand( - doc(p('a'), '', p('b')), cmd, - doc(p('a')), - ); - }); - }); - - suite('JoinBackward', () => { - const cmd = new EditorCommands.JoinBackward(nullObject()); - - test('can join paragraphs', () => - execCommand( - doc(p('hi'), p('there')), cmd, - doc(p('hithere'))), - ); - - test('can join out of a nested node', () => { - execCommand( - doc(p('hi'), blockquote(p('there'))), cmd, - doc(p('hi'), p('there')), - ); - }); - - test('moves a block into an adjacent wrapper', () => { - execCommand( - doc(blockquote(p('hi')), p('there')), cmd, - doc(blockquote(p('hi'), p('there'))), - ); - }); - - test('moves a block into an adjacent wrapper from another wrapper', () => { - execCommand( - doc(blockquote(p('hi')), blockquote(p('there'))), cmd, - doc(blockquote(p('hi'), p('there'))), - ); - }); - - test('joins the wrapper to a subsequent one if applicable', () => { - execCommand( - doc(blockquote(p('hi')), p('there'), blockquote(p('x'))), cmd, - doc(blockquote(p('hi'), p('there'), p('x'))), - ); - }); - - // FIX - test('moves a block into a list item', () => { - execCommand( - doc(ul(li(p('hi'))), p('there')), cmd, - doc(ul(li(p('hi')), li(p('there')))), - ); - }); - - // FIX - test('joins lists', () => { - execCommand( - doc(ul(li(p('hi'))), ul(li(p('there')))), cmd, - doc(ul(li(p('hi')), li(p('there')))), - ); - }); - - // FIX - test('joins list items', () => { - execCommand( - doc(ul(li(p('hi')), li(p('there')))), cmd, - doc(ul(li(p('hi'), p('there')))), - ); - }); - - test('lifts out of a list at the start', () => { - execCommand( - doc(ul(li(p('there')))), cmd, - doc(p('there')), - ); - }); - - // FIX - test('joins lists before and after', () => { - execCommand( - doc(ul(li(p('hi'))), p('there'), ul(li(p('x')))), cmd, - doc(ul(li(p('hi')), li(p('there')), li(p('x')))), - ); - }); - - test('deletes leaf nodes before', () => { - execCommand( - doc(hr, p('there')), cmd, - doc(p('there')), - ); - }); - - test('lifts before it deletes', () => { - execCommand( - doc(hr, blockquote(p('there'))), cmd, - doc(hr, p('there')), - ); - }); - - test('does nothing at start of doc', () => { - execCommand( - doc(p('foo')), cmd, - null, - ); - }); - - test('can join single-textblock-child nodes', () => { - const schema = new ProseSchema({ - nodes: { - text: { inline: true }, - doc: { content: 'block+' }, - block: { content: 'para' }, - para: { content: 'text*' } - } - }); - const doc = schema - .node('doc', null, [ - schema.node('block', null, [schema.node('para', null, [schema.text('a')])]), - schema.node('block', null, [schema.node('para', null, [schema.text('b')])]) - ]); - let state = ProseEditorState.create({ doc, selection: ProseTextSelection.near(doc.resolve(7)) }); - - - - ist(cmd.run(undefined!, undefined!, state, tr => state = state.apply(tr))); - ist(state.doc.toString(), 'doc(block(para("ab")))'); - }); - }); -}); \ No newline at end of file From ff84c13c8ce1e964d071f59bd0599b5fb5181c04 Mon Sep 17 00:00:00 2001 From: SIHAN LI <1015813038@qq.com> Date: Wed, 26 Feb 2025 18:00:03 -0500 Subject: [PATCH 18/21] [Refactor] remove deprecated `EditorCommandExtension` --- src/editor/contrib/builtInExtensionList.ts | 3 - src/editor/contrib/command/command.ts | 129 --------------------- 2 files changed, 132 deletions(-) delete mode 100644 src/editor/contrib/command/command.ts diff --git a/src/editor/contrib/builtInExtensionList.ts b/src/editor/contrib/builtInExtensionList.ts index 1d2830cd4..9c605eb57 100644 --- a/src/editor/contrib/builtInExtensionList.ts +++ b/src/editor/contrib/builtInExtensionList.ts @@ -1,6 +1,5 @@ import { Constructor } from "src/base/common/utilities/type"; import { EditorExtension } from "src/editor/common/editorExtension"; -import { EditorCommandExtension } from "src/editor/contrib/command/command"; import { EditorAutoSaveExtension } from "src/editor/contrib/autoSave"; import { EditorSnippetExtension } from "src/editor/contrib/snippet/snippet"; import { EditorDragAndDropExtension } from "src/editor/contrib/dragAndDrop/dragAndDrop"; @@ -11,7 +10,6 @@ import { EditorAskAIExtension } from "src/editor/contrib/askAI/askAI"; // import { EditorHistoryExtension } from "src/editor/contrib/history/history"; export const enum EditorExtensionIDs { - Command = 'editor-command-extension', AutoSave = 'editor-autosave-extension', Snippet = 'editor-snippet-extension', History = 'editor-history-extension', @@ -29,7 +27,6 @@ export function getBuiltInExtension(): { id: string, ctor: Constructor(); - - /** A set that contains all the editor commands' IDs */ - private readonly _commandSet = new Set(); - - // [constructor] - - constructor( - editorWidget: IEditorWidget, - @IRegistrantService private readonly registrantService: IRegistrantService, - @ICommandService commandService: ICommandService, - @ILogService private readonly logService: ILogService, - ) { - super(editorWidget); - - /** - * Keydown: when key is pressing in the editor: - * 1. we look up for any registered command (from the map), - * 2. if found any, we execute that command from the standard command - * system: {@link CommandService}. - * - * @note Registered with {@link Priority.Low}. Make other extensions has - * possibility to handle the keydown event first. - */ - // this.__register(this.onKeydown(event => { - // const keyEvent = event.event; - // const shortcut = new Shortcut(keyEvent.ctrl, keyEvent.shift, keyEvent.alt, keyEvent.meta, keyEvent.key); - // const commandID = this._commandKeybinding.get(shortcut.toHashcode()); - // if (!commandID) { - // return; - // } - - // /** - // * Whenever a command is executed, we need to invoke `preventDefault` - // * to tell prosemirror to prevent default behavior of the browser. - // * - // * @see https://discuss.prosemirror.net/t/question-allselection-weird-behaviours-when-the-document-contains-a-non-text-node-at-the-end/7749/3 - // */ - // trySafe( - // () => commandService.executeCommand(commandID, editorWidget, event.view.state, event.view.dispatch, event.view), - // { - // onError: () => false, - // onThen: (anyExecuted) => { - // if (anyExecuted) { - // event.preventDefault(); - // } - // } - // } - // ); - // }, undefined, Priority.Low)); - } - - // [protected override methods] - - protected override onViewStateInit(state: EditorState): void { - - /** - * Binds predefined commands to their respective shortcuts. - */ - - // FIX: - registerBasicEditorCommands(this, this.logService, () => { - const view = this._editorWidget.view.editor.internalView; - return [this._editorWidget, view.state, view.dispatch, view]; - }); - } - - protected override onViewDestroy(view: EditorView): void { - const registrant = this.registrantService.getRegistrant(RegistrantType.Command); - - // unregister all the editor commands - for (const registeredID of this._commandSet.values()) { - registrant.unregisterCommand(registeredID); - } - - // cache cleanup - this._commandKeybinding.clear(); - this._commandSet.clear(); - } - - // [public methods] - - public registerCommand(command: Command): void { - this._commandSet.add(command.id); - - /** - * Register the command to the standard {@link CommandService}. - */ - const registrant = this.registrantService.getRegistrant(RegistrantType.Command); - registrant.registerCommand(command); - } -} From b99a346f03dcfd9dc90df6f73266212c23f25751 Mon Sep 17 00:00:00 2001 From: SIHAN LI <1015813038@qq.com> Date: Wed, 26 Feb 2025 22:32:02 -0500 Subject: [PATCH 19/21] [Chore] --- src/base/common/keyboard.ts | 3 +++ test/base/common/keyboard.test.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/base/common/keyboard.ts b/src/base/common/keyboard.ts index af616c712..d92afedcd 100644 --- a/src/base/common/keyboard.ts +++ b/src/base/common/keyboard.ts @@ -534,7 +534,9 @@ export class Shortcut { } else if (lowerPart === 'meta' || lowerPart === 'cmd') { shortcut.meta = true; } else { + // duplicate main key found, we consider it as invalid shortcut. if (shortcut.key !== KeyCode.None) { + console.warn(`Invalid shortcut string (${string})`); return Shortcut.None; } @@ -542,6 +544,7 @@ export class Shortcut { if (key !== KeyCode.None) { shortcut.key = key; } else { + console.warn(`Invalid shortcut string (${string})`); return Shortcut.None; } } diff --git a/test/base/common/keyboard.test.ts b/test/base/common/keyboard.test.ts index bcbdaa10a..d63bf2957 100644 --- a/test/base/common/keyboard.test.ts +++ b/test/base/common/keyboard.test.ts @@ -100,6 +100,9 @@ suite('keyboard-test', () => { assert.strictEqual(Shortcut.fromString('PageDown').equal(new Shortcut(false, false, false, false, KeyCode.PageDown)), true); assert.strictEqual(Shortcut.fromString('CTrL+PageDown').equal(new Shortcut(true, false, false, false, KeyCode.PageDown)), true); assert.strictEqual(Shortcut.fromString('SHiFt+AlT+0').equal(new Shortcut(false, true, true, false, KeyCode.Digit0)), true); + + // more cases + assert.strictEqual(Shortcut.fromString('Ctrl+A').equal(new Shortcut(true, false, false, false, KeyCode.KeyA)), true); }); const enum KeyModifier { From 2e4d5442935d042b31e9961bb1e9be7294339813 Mon Sep 17 00:00:00 2001 From: SIHAN LI Date: Thu, 27 Feb 2025 12:05:40 -0500 Subject: [PATCH 20/21] [Fix] properly editor context key expr --- src/editor/common/editorContextKeys.ts | 6 +++++- src/editor/contrib/command/command.register.ts | 14 ++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/editor/common/editorContextKeys.ts b/src/editor/common/editorContextKeys.ts index 2e19932a1..244fc46d2 100644 --- a/src/editor/common/editorContextKeys.ts +++ b/src/editor/common/editorContextKeys.ts @@ -4,9 +4,13 @@ import { CreateContextKeyExpr } from "src/platform/context/common/contextKeyExpr export namespace EditorContextKeys { export const editorFocusedContext = CreateContextKeyExpr.Equal('isEditorFocused', true); - export const isEditorReadonly = CreateContextKeyExpr.Equal('isEditorReadonly', true); export const isEditorWritable = CreateContextKeyExpr.Equal('isEditorWritable', true); export const isEditorEditable = CreateContextKeyExpr.And(editorFocusedContext, isEditorWritable); + export const isEditorReadonly = CreateContextKeyExpr.Or( + CreateContextKeyExpr.Equal('isEditorReadonly', true), + isEditorWritable, + ); + export const isEditorNotEditable = CreateContextKeyExpr.And(editorFocusedContext, isEditorReadonly); export const richtextEditorMode = CreateContextKeyExpr.Equal('editorRenderMode', EditorType.Rich); export const plaintextEditorMode = CreateContextKeyExpr.Equal('editorRenderMode', EditorType.Plain); export const splitViewEditorMode = CreateContextKeyExpr.Equal('editorRenderMode', EditorType.Split); diff --git a/src/editor/contrib/command/command.register.ts b/src/editor/contrib/command/command.register.ts index 4fce310c2..24e6ca6bd 100644 --- a/src/editor/contrib/command/command.register.ts +++ b/src/editor/contrib/command/command.register.ts @@ -9,7 +9,6 @@ import { EditorCommands } from "src/editor/contrib/command/command.contrib"; import { EditorListCommands } from "src/editor/contrib/command/listCommand.contrib"; import { buildEditorCommand } from "src/editor/contrib/command/editorCommand"; import { ICommandRegistrant } from "src/platform/command/common/commandRegistrant"; -import { CreateContextKeyExpr } from "src/platform/context/common/contextKeyExpr"; import { createRegister, RegistrantType } from "src/platform/registrant/common/registrant"; import { IEditorService } from "src/workbench/services/editor/editor"; import { ShortcutWeight } from "src/workbench/services/shortcut/shortcutRegistrant"; @@ -26,7 +25,6 @@ export const rendererEditorCommandRegister = createRegister( } ); -const whenEditorReadonly = CreateContextKeyExpr.And(EditorContextKeys.editorFocusedContext, EditorContextKeys.isEditorReadonly); function getPlatformShortcut(ctrl: string, meta: string): string { return IS_MAC ? meta : ctrl; } @@ -120,12 +118,12 @@ function __registerBasicCommands(registrant: ICommandRegistrant, editorService: registrant.registerCommand(buildEditorCommand( { id: 'editor-esc', - when: whenEditorReadonly, + when: EditorContextKeys.isEditorNotEditable, shortcutOptions: { commandArgs: [], shortcut: Shortcut.fromString('Escape'), weight: ShortcutWeight.Editor, - when: whenEditorReadonly, + when: EditorContextKeys.isEditorNotEditable, } }, [ @@ -176,12 +174,12 @@ function __registerBasicCommands(registrant: ICommandRegistrant, editorService: registrant.registerCommand(buildEditorCommand( { id: 'editor-select-all', - when: whenEditorReadonly, + when: EditorContextKeys.isEditorNotEditable, shortcutOptions: { commandArgs: [], shortcut: Shortcut.fromString(getPlatformShortcut('Ctrl+A', 'Meta+A')), weight: ShortcutWeight.Editor, - when: whenEditorReadonly, + when: EditorContextKeys.isEditorNotEditable, } }, [ @@ -193,12 +191,12 @@ function __registerBasicCommands(registrant: ICommandRegistrant, editorService: registrant.registerCommand(buildEditorCommand( { id: 'editor-exit-code-block', - when: whenEditorReadonly, + when: EditorContextKeys.isEditorNotEditable, shortcutOptions: { commandArgs: [], shortcut: Shortcut.fromString(getPlatformShortcut('Ctrl+Enter', 'Meta+Enter')), weight: ShortcutWeight.Editor, - when: whenEditorReadonly, + when: EditorContextKeys.isEditorNotEditable, } }, [ From b3622b573600428a7467aacf9bba13acae9b6cc8 Mon Sep 17 00:00:00 2001 From: SIHAN LI Date: Thu, 27 Feb 2025 16:07:47 -0500 Subject: [PATCH 21/21] [Fix] enhance error logging to ensure printed errors are interactive --- src/platform/logger/common/consoleLoggerService.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/platform/logger/common/consoleLoggerService.ts b/src/platform/logger/common/consoleLoggerService.ts index 43072c391..115cbefe8 100644 --- a/src/platform/logger/common/consoleLoggerService.ts +++ b/src/platform/logger/common/consoleLoggerService.ts @@ -51,13 +51,15 @@ export class ConsoleLogger extends AbstractLogger implements ILogger { public error(reporter: string, message: string, error?: any, additional?: Additional): void { if (this.getLevel() <= LogLevel.ERROR) { - console.error(prettyLog(this._ifUseColors, LogLevel.ERROR, this._description, reporter, message, error, additional).slice(0, -1)); + console.error(prettyLog(this._ifUseColors, LogLevel.ERROR, this._description, reporter, message, undefined, additional).slice(0, -1)); + console.error(error); // ensure the printed error is intractive instead of just plain-text } } public fatal(reporter: string, message: string, error?: any, additional?: Additional): void { if (this.getLevel() <= LogLevel.FATAL) { - console.error(prettyLog(this._ifUseColors, LogLevel.FATAL, this._description, reporter, message, error, additional).slice(0, -1)); + console.error(prettyLog(this._ifUseColors, LogLevel.FATAL, this._description, reporter, message, undefined, additional).slice(0, -1)); + console.error(error); // ensure the printed error is intractive instead of just plain-text } }