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 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/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/code/browser/renderer.desktop.ts b/src/code/browser/renderer.desktop.ts index 998c72aed..738a3626b 100644 --- a/src/code/browser/renderer.desktop.ts +++ b/src/code/browser/renderer.desktop.ts @@ -71,6 +71,9 @@ 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"; +import { rendererEditorCommandRegister } from "src/editor/contrib/command/command.register"; /** * @class This is the main entry of the renderer process. @@ -148,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); @@ -321,6 +328,7 @@ const renderer = new class extends class RendererInstance extends Disposable { [ rendererWorkbenchCommandRegister, rendererTitleBarFileCommandRegister, + rendererEditorCommandRegister, ] .forEach(register => register(provider)); } diff --git a/src/editor/common/editorContextKeys.ts b/src/editor/common/editorContextKeys.ts index c250e365c..244fc46d2 100644 --- a/src/editor/common/editorContextKeys.ts +++ b/src/editor/common/editorContextKeys.ts @@ -4,8 +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/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/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)[]): 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; -} +import { EditorCommand } from "src/editor/contrib/command/editorCommand"; /** * @description Contains a list of commands specific for editor. Every basic @@ -256,9 +17,9 @@ export abstract class EditorCommandBase extends Command { */ 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); @@ -267,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; } @@ -338,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; @@ -359,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. @@ -400,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; } @@ -444,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) { @@ -513,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; } @@ -535,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; @@ -609,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; @@ -668,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; @@ -703,13 +464,13 @@ 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. */ - 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; @@ -756,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); @@ -808,7 +569,7 @@ export namespace EditorCommands { */ export function createToggleMarkCommand( schema: ICommandSchema, - markType: TType, + getMarkType: () => TType, attrs: ProseAttrs | null = null, options?: { /** @@ -837,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; @@ -939,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 @@ -997,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; @@ -1013,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( () => {}, @@ -1031,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..24e6ca6bd --- /dev/null +++ b/src/editor/contrib/command/command.register.ts @@ -0,0 +1,347 @@ +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 { 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); + } +); + +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: EditorContextKeys.isEditorNotEditable, + shortcutOptions: { + commandArgs: [], + shortcut: Shortcut.fromString('Escape'), + weight: ShortcutWeight.Editor, + when: EditorContextKeys.isEditorNotEditable, + } + }, + [ + 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: EditorContextKeys.isEditorNotEditable, + shortcutOptions: { + commandArgs: [], + shortcut: Shortcut.fromString(getPlatformShortcut('Ctrl+A', 'Meta+A')), + weight: ShortcutWeight.Editor, + when: EditorContextKeys.isEditorNotEditable, + } + }, + [ + 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: EditorContextKeys.isEditorNotEditable, + shortcutOptions: { + commandArgs: [], + shortcut: Shortcut.fromString(getPlatformShortcut('Ctrl+Enter', 'Meta+Enter')), + weight: ShortcutWeight.Editor, + when: EditorContextKeys.isEditorNotEditable, + } + }, + [ + 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/command.ts b/src/editor/contrib/command/command.ts deleted file mode 100644 index 00cfe49db..000000000 --- a/src/editor/contrib/command/command.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { EditorState } from "prosemirror-state"; -import { EditorView } from "prosemirror-view"; -import { trySafe } from "src/base/common/error"; -import { Shortcut } from "src/base/common/keyboard"; -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 { Command } from "src/platform/command/common/command"; -import { ICommandService } from "src/platform/command/common/commandService"; -import { RegistrantType } from "src/platform/registrant/common/registrant"; -import { IRegistrantService } from "src/platform/registrant/common/registrantService"; -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; -} - -/** - * @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}. - */ -export class EditorCommandExtension extends EditorExtension implements IEditorCommandExtension { - - // [fields] - - public readonly id = EditorExtensionIDs.Command; - - /** Mapping from {@link Shortcut}-hashed code to command ID. */ - private readonly _commandKeybinding = new Map(); - - /** 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. - */ - registerBasicEditorCommands(this, this.logService); - } - - 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, shortcuts: string[]): void { - this._commandSet.add(command.id); - - /** - * Register the command to the standard {@link CommandService}. - */ - 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/editorCommand.ts b/src/editor/contrib/command/editorCommand.ts new file mode 100644 index 000000000..55182067c --- /dev/null +++ b/src/editor/contrib/command/editorCommand.ts @@ -0,0 +1,35 @@ +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 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 function buildEditorCommand(schema: ICommandSchema, ctors: (typeof Command)[]): Command { + if (ctors.length === 1) { + const command = ctors[0]!; + return new command(schema); + } + return buildChainCommand(schema, ctors); +} diff --git a/src/editor/contrib/command/listCommand.contrib.ts b/src/editor/contrib/command/listCommand.contrib.ts new file mode 100644 index 000000000..b62b695c1 --- /dev/null +++ b/src/editor/contrib/command/listCommand.contrib.ts @@ -0,0 +1,240 @@ +import { canJoin, canSplit, liftTarget } from "prosemirror-transform"; +import { ProseEditorState, ProseTransaction, ProseEditorView, ProseNodeType, ProseFragment, ProseSlice, ProseReplaceAroundStep, ProseNodeRange, ProseSelection, ProseNodeSelection, ProseAttrs } from "src/editor/common/proseMirror"; +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"; + +export namespace EditorListCommands { + + 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; + } + + 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, 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) { + 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, 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) { + 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 diff --git a/src/editor/editorWidget.ts b/src/editor/editorWidget.ts index 6f8219eb4..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] @@ -83,6 +84,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 +138,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. */ @@ -287,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; @@ -316,6 +327,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> { @@ -344,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(); }); } @@ -360,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 { @@ -482,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 { 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; } 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/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 } } 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/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 diff --git a/src/workbench/services/shortcut/shortcutRegistrant.ts b/src/workbench/services/shortcut/shortcutRegistrant.ts index fc3063a57..1e59fa57f 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) @@ -41,6 +42,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; @@ -59,7 +62,7 @@ export type IShortcutRegistration = IShortcutRegistrationBase /** * The shortcut of the given command. */ - readonly shortcut: Shortcut; + readonly shortcut: Shortcut | Shortcut[]; }; /** @@ -113,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 @@ -181,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); @@ -193,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/src/workbench/services/shortcut/shortcutService.ts b/src/workbench/services/shortcut/shortcutService.ts index c429a3fa3..63fd3bbc9 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"; @@ -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'); @@ -93,47 +94,50 @@ 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 => { + 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 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 + 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. - 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] 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 { 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 diff --git a/test/editor/contrib/editorCommands.test.ts b/test/editor/contrib/editorCommands.test.ts deleted file mode 100644 index c13fd2059..000000000 --- a/test/editor/contrib/editorCommands.test.ts +++ /dev/null @@ -1,218 +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 { EditorCommandBase, EditorCommands } from 'src/editor/contrib/command/editorCommands'; -import { nullObject } from 'test/utils/helpers'; - -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