Skip to content

Commit

Permalink
[Refactor] encapsulate into BlockInsertPalette for quick document b…
Browse files Browse the repository at this point in the history
…lock insertion
  • Loading branch information
Bistard committed Feb 17, 2025
1 parent 79fc50e commit 2100bff
Show file tree
Hide file tree
Showing 2 changed files with 293 additions and 210 deletions.
235 changes: 25 additions & 210 deletions src/editor/contrib/slashCommandExtension/slashCommandExtension.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,16 @@
import "src/editor/contrib/slashCommandExtension/slashCommand.scss";
import { AnchorPrimaryAxisAlignment, AnchorVerticalPosition } from "src/base/browser/basic/contextMenu/contextMenu";
import { MenuAction, MenuItemType, SimpleMenuAction, SubmenuAction } from "src/base/browser/basic/menu/menuItem";
import { IPosition } from "src/base/common/utilities/size";
import { MenuItemType } from "src/base/browser/basic/menu/menuItem";
import { EditorExtension, IEditorExtension } from "src/editor/common/editorExtension";
import { ProseTools } from "src/editor/common/proseUtility";
import { EditorExtensionIDs } from "src/editor/contrib/builtInExtensionList";
import { IEditorWidget } from "src/editor/editorWidget";
import { IOnTextInputEvent } from "src/editor/view/proseEventBroadcaster";
import { IContextMenuService } from "src/workbench/services/contextMenu/contextMenuService";
import { Disposable, DisposableBucket, IDisposable, safeDisposable } from "src/base/common/dispose";
import { DisposableBucket, IDisposable, safeDisposable } from "src/base/common/dispose";
import { KeyCode } from "src/base/common/keyboard";
import { ProseAttrs, ProseEditorView, ProseTextSelection } from "src/editor/common/proseMirror";
import { Emitter, Priority } from "src/base/common/event";
import { getTokenReadableName, Markdown, TokenEnum } from "src/editor/common/markdown";
import { II18nService } from "src/platform/i18n/browser/i18nService";
import { Arrays } from "src/base/common/utilities/array";
import { ErrorHandler } from "src/base/common/error";
import { ProseEditorView } from "src/editor/common/proseMirror";
import { Priority } from "src/base/common/event";
import { BlockInsertPalette } from "src/editor/view/widget/blockInsertPalette/blockInsertPlette";
import { IInstantiationService } from "src/platform/instantiation/common/instantiation";

interface IEditorSlashCommandExtension extends IEditorExtension {

Expand All @@ -29,35 +24,25 @@ export class EditorSlashCommandExtension extends EditorExtension implements IEdi
// [fields]

public override readonly id = EditorExtensionIDs.SlashCommand;
private readonly _menuRenderer: SlashMenuRenderer;
private readonly _menuController: SlashMenuController;

private readonly _palette: BlockInsertPalette;
private readonly _keyboardController: SlashKeyboardController;

constructor(
editorWidget: IEditorWidget,
@IContextMenuService contextMenuService: IContextMenuService,
@II18nService i18nService: II18nService,
@IInstantiationService instantiationService: IInstantiationService,
) {
super(editorWidget);
this._keyboardController = this.__register(new SlashKeyboardController(this, contextMenuService));
this._menuController = new SlashMenuController(editorWidget);
this._menuRenderer = this.__register(new SlashMenuRenderer(editorWidget, contextMenuService, i18nService));
this._palette = this.__register(instantiationService.createInstance(BlockInsertPalette, editorWidget));
this._keyboardController = this.__register(new SlashKeyboardController(this, this._palette));

// slash-command rendering
this.__register(this.onTextInput(e => {
this.__tryShowSlashCommand(e);
}));

// always back to normal
this.__register(this._menuRenderer.onMenuDestroy(() => {
this.__register(this._palette.onMenuDestroy(() => {
this._keyboardController.unlisten();
editorWidget.view.editor.focus();
}));

// menu click logic
this.__register(this._menuRenderer.onClick(e => {
contextMenuService.contextMenu.destroy();
this._menuController.onClick(e);
}));
}

Expand All @@ -80,7 +65,7 @@ export class EditorSlashCommandExtension extends EditorExtension implements IEdi

// show slash command
const position = view.coordsAtPos(selection.$from.pos);
this._menuRenderer.show(position);
this._palette.render(position);

// re-focus back to editor, not the slash command.
view.focus();
Expand All @@ -104,7 +89,7 @@ class SlashKeyboardController implements IDisposable {

constructor(
private readonly extension: EditorSlashCommandExtension,
private readonly contextMenuService: IContextMenuService,
private readonly palette: BlockInsertPalette,
) {
this._triggeredNode = undefined;
}
Expand Down Expand Up @@ -151,37 +136,37 @@ class SlashKeyboardController implements IDisposable {

// escape: destroy the slash command
if (pressed === KeyCode.Escape) {
this.contextMenuService.contextMenu.destroy();
this.palette.destroy();
}
// handle up/down arrows
else if (pressed === KeyCode.UpArrow) {
this.contextMenuService.contextMenu.focusPrev();
this.palette.focusPrev();
} else if (pressed === KeyCode.DownArrow) {
this.contextMenuService.contextMenu.focusNext();
this.palette.focusNext();
}
// handle right/left arrows
else if (pressed === KeyCode.RightArrow || pressed === KeyCode.LeftArrow) {
const index = this.contextMenuService.contextMenu.getFocus();
const currAction = this.contextMenuService.contextMenu.getAction(index);
const index = this.palette.getFocus();
const currAction = this.palette.getAction(index);
if (!currAction || currAction.type !== MenuItemType.Submenu) {
return false;
}

if (pressed === KeyCode.RightArrow) {
const opened = this.contextMenuService.contextMenu.tryOpenSubmenu();
const opened = this.palette.tryOpenSubmenu();
return opened;
} else {
return false;
}
}
// enter
else if (pressed === KeyCode.Enter) {
const hasFocus = this.contextMenuService.contextMenu.hasFocus();
const hasFocus = this.palette.hasFocus();
if (!hasFocus) {
this.contextMenuService.contextMenu.destroy();
this.palette.destroy();
return false;
}
this.contextMenuService.contextMenu.runFocus();
this.palette.runFocus();
}

// make sure to re-focus back to editor
Expand All @@ -201,15 +186,15 @@ class SlashKeyboardController implements IDisposable {
const { $from } = view.state.selection;
const isEmptyBlock = ProseTools.Node.isEmptyTextBlock($from.parent);
if (isEmptyBlock) {
this.contextMenuService.contextMenu.destroy();
this.palette.destroy();
}
}));

/**
* Destroy slash command whenever the selection changes to other blocks.
*/
bucket.register(this.extension.onDidSelectionChange(e => {
const menu = this.contextMenuService.contextMenu;
const menu = this.palette;
const triggeredNode = this._triggeredNode;
if (!triggeredNode) {
return;
Expand Down Expand Up @@ -241,174 +226,4 @@ class SlashKeyboardController implements IDisposable {
nodeType: $pos.parent.type.name,
};
}
}

// region - SlashMenuRenderer

type SlashOnClickEvent = {
readonly type: string;
readonly name: string;
readonly attr: ProseAttrs;
};

class SlashMenuRenderer extends Disposable {

private readonly _onMenuDestroy = this.__register(new Emitter<void>());
public readonly onMenuDestroy = this._onMenuDestroy.registerListener;

private readonly _onClick = this.__register(new Emitter<SlashOnClickEvent>());
public readonly onClick = this._onClick.registerListener;

constructor(
private readonly editorWidget: IEditorWidget,
private readonly contextMenuService: IContextMenuService,
private readonly i18nService: II18nService,
) {
super();
}

public show(position?: IPosition): void {
if (!position) {
return;
}
const { overlayContainer: parentElement } = this.editorWidget.view.editor;

this.contextMenuService.showContextMenuCustom({
getActions: () => this.__obtainSlashCommandContent(),
getContext: () => undefined,
getAnchor: () => ({ x: position.left, y: position.top, height: 24 }),
getExtraContextMenuClassName: () => 'editor-slash-command',
primaryAlignment: AnchorPrimaryAxisAlignment.Vertical,
verticalPosition: AnchorVerticalPosition.Below,

/**
* We need to capture the blur event to prevent auto destroy. We
* will handle destruction by ourselves.
*/
onBeforeDestroy: (cause) => {
const shouldPrevent = cause === 'blur';
return shouldPrevent;
},
// clean up
onDestroy: () => {
this._onMenuDestroy.fire();
},
}, parentElement);
}

private __obtainSlashCommandContent(): MenuAction[] {
const nodes = this.__obtainValidContent();

// convert each node into menu action
return nodes.map(nodeName => {
// heading: submenu
if (nodeName === TokenEnum.Heading) {
return this.__getHeadingActions();
}
// general case
const resolvedName = getTokenReadableName(this.i18nService, nodeName);
return new SimpleMenuAction({
enabled: true,
id: resolvedName,
callback: () => this._onClick.fire({
type: nodeName,
name: resolvedName,
attr: {},
}),
});
});
}

private __obtainValidContent(): string[] {
const blocks = this.editorWidget.model.getRegisteredDocumentNodes();
const ordered = this.__filterContent(blocks, CONTENT_FILTER);
return ordered;
}

private __filterContent(unordered: string[], expectOrder: string[]): string[] {
const ordered: string[] = [];
const unorderedSet = new Set(unordered);
for (const name of expectOrder) {
if (unorderedSet.has(name)) {
ordered.push(name);
unorderedSet.delete(name);
} else {
console.warn(`[SlashCommandExtension] missing node: ${name}`);
}
}
return ordered;
}

private __getHeadingActions(): SubmenuAction {
const heading = getTokenReadableName(this.i18nService, TokenEnum.Heading);
return new SubmenuAction(
Arrays.range(1, 7).map(level => this.__getHeadingAction(heading, level)),
{ enabled: true, id: heading }
);
}

private __getHeadingAction(name: string, level: number): MenuAction {
return new SimpleMenuAction({
enabled: true,
id: `${name} ${level}`,
callback: () => this._onClick.fire({
type: TokenEnum.Heading,
name: name,
attr: { level: level },
}),
});
}
}

const CONTENT_FILTER = [
TokenEnum.Paragraph,
TokenEnum.Blockquote,
TokenEnum.Heading,
TokenEnum.Image,
TokenEnum.List,
TokenEnum.Table,
TokenEnum.CodeBlock,
TokenEnum.MathBlock,
TokenEnum.HTML,
TokenEnum.HorizontalRule,
];

// region - SlashMenuController

class SlashMenuController {

constructor(
private readonly editorWidget: IEditorWidget,
) {}

public onClick(event: SlashOnClickEvent): void {
const { type, name, attr } = event;
const view = this.editorWidget.view.editor.internalView;
const state = view.state;
let tr = state.tr;

const prevStart = tr.selection.$from.start();
const $pos = state.doc.resolve(prevStart);

// create an empy node with given type
const node = Markdown.Create.empty(state, type, attr);
if (!node) {
ErrorHandler.onUnexpectedError(new Error(`Cannot create node (${name})`));
return;
}

// replace the current node with the new node
tr = ProseTools.Position.replaceWithNode(state, $pos, node);

// find next selectable text
const newStart = tr.mapping.map(prevStart);
const $newStart = tr.doc.resolve(newStart + 1);
const selection = ProseTextSelection.findFrom($newStart, 1, true);
if (selection) {
tr = tr.setSelection(selection);
}

// update
view.dispatch(tr);
}
}
Loading

0 comments on commit 2100bff

Please sign in to comment.