Skip to content

list-related commands to the editor#274

Merged
Bistard merged 10 commits intodevfrom
dev-list
Feb 26, 2025
Merged

list-related commands to the editor#274
Bistard merged 10 commits intodevfrom
dev-list

Conversation

@Bistard
Copy link
Owner

@Bistard Bistard commented Feb 26, 2025

This pull request introduces list-related operations to the editor, providing commands and shortcuts for splitting, sinking, and lifting list items. It also refactors the command registration process to support shortcut options.

New Features:

  • Introduces list-related commands to the editor, including splitting, sinking, and lifting list items.
  • Adds keyboard shortcuts for list operations, such as Enter to split, Tab to sink, and Shift+Tab to lift list items.

@Bistard Bistard added enhancement Improvements on existing features refactor editor labels Feb 26, 2025
@Bistard Bistard self-assigned this Feb 26, 2025
@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Feb 26, 2025

Reviewer's Guide by Sourcery

This pull request introduces a new system for registering editor commands with shortcuts, including context-aware shortcuts and weighted command execution. It also adds list-related commands with default shortcuts for improved list manipulation. The changes involve refactoring the command registration process, introducing new command implementations, and updating the shortcut handling mechanism.

Sequence diagram for command execution with shortcut

sequenceDiagram
    participant KeyboardService
    participant ShortcutService
    participant ShortcutRegistrant
    participant CommandService
    participant Command

    KeyboardService->>ShortcutService: onKeydown(event)
    ShortcutService->>ShortcutRegistrant: findShortcut(shortcut)
    alt Valid shortcut found
        ShortcutRegistrant-->>ShortcutService: Return candidate shortcuts
        loop For each candidate shortcut
            ShortcutService->>ContextService: contextMatchExpr(candidate.when)
            alt Context matches
                ShortcutService->>CommandService: executeCommand(commandID, ...args)
                CommandService->>Command: run(provider, ...args)
                Command-->>CommandService: Return result
                alt Result is true
                    CommandService-->>ShortcutService: Break loop
                end
            end
        end
    else No valid shortcut
        ShortcutRegistrant-->>ShortcutService: Return empty list
    end
    ShortcutService-->>KeyboardService: (No command executed)
Loading

Updated class diagram for EditorCommandExtension

classDiagram
    class EditorCommandExtension {
        - _commandSet: Set<string>
        - _commandKeybinding: Map<number, string>
        + registerCommand(command: Command): void
        + registerBasicEditorCommands(extension: IEditorCommandExtension, logService: ILogService, getArguments: () => EditorCommandArguments): void
    }

    class Command {
        + id: string
        + run(provider: IServiceProvider, editor: IEditorWidget, state: ProseEditorState, dispatch?: (tr: ProseTransaction) => void, view?: ProseEditorView): boolean | Promise<boolean>
    }

    EditorCommandExtension -- Command : Registers
Loading

File-Level Changes

Change Details Files
Refactor command registration to use shortcutOptions for defining shortcuts and their associated properties.
  • Introduce shortcutOptions in ICommandSchema to encapsulate shortcut-related configurations.
  • Modify command registration to utilize shortcutOptions for defining shortcuts, weights, and conditions.
  • Remove direct shortcut binding during command registration in EditorCommandExtension.
  • Update command registration in registerBasicEditorCommands to use shortcutOptions.
  • Update ShortcutRegistrant to handle multiple shortcuts per command.
  • Update ShortcutService to execute commands based on shortcut weight and context.
  • Remove disposable return from ShortcutRegistrant.register2.
src/editor/contrib/command/command.contrib.ts
src/editor/contrib/command/command.ts
src/workbench/services/shortcut/shortcutRegistrant.ts
src/workbench/services/shortcut/shortcutService.ts
test/code/browser/workbench/services/shortcutRegistrant.test.ts
test/code/browser/workbench/services/shortcutService.test.ts
src/platform/command/common/command.ts
Introduce list-related commands (split, sink, lift) with corresponding shortcuts.
  • Implement splitListItem, sinkListItem, and liftListItem commands for list manipulation.
  • Register list commands in registerListCommands.
  • Add default shortcuts for list commands (Enter, Tab, Shift+Tab).
src/editor/contrib/command/commandList.contrib.ts
Introduce EditorCommandArguments type and buildEditorCommand function.
  • Introduce EditorCommandArguments type to represent the arguments for editor commands.
  • Introduce buildEditorCommand function to build editor commands.
  • Use buildEditorCommand function to build editor commands.
src/editor/contrib/command/command.contrib.ts
src/editor/contrib/command/editorCommand.ts
Fixes and minor changes.
  • Fix typo in EditorCommands namespace.
  • Fix: onDidBlur and onDidFocus events are swapped.
  • Add isEditorEditable context key.
src/editor/contrib/command/editorCommands.ts
src/editor/common/editorContextKeys.ts
src/editor/view/editorView.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!
  • Generate a plan of action for an issue: Comment @sourcery-ai plan on
    an issue to generate a plan of action for it.

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@Bistard Bistard changed the title List-related operations in editor list-related commands to the editor Feb 26, 2025
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @Bistard - I've reviewed your changes - here's some feedback:

Overall Comments:

  • Consider renaming __registerOtherCommands to registerBasicCommands for clarity and consistency.
  • The registerCommand function in EditorCommandExtension should not directly handle shortcut registration; this logic should be moved to the ShortcutRegistrant.
Here's what I looked at during the review
  • 🟢 General issues: all looks good
  • 🟢 Security: all looks good
  • 🟢 Testing: all looks good
  • 🟢 Complexity: all looks good
  • 🟢 Documentation: all looks good

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +75 to +154
export function splitListItem<TType extends ProseNodeType>(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<boolean> {
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);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (code-quality): Avoid function declarations, favouring function assignment expressions, inside blocks. (avoid-function-declarations-in-blocks)

ExplanationFunction declarations may be hoisted in Javascript, but the behaviour is inconsistent between browsers. Hoisting is generally confusing and should be avoided. Rather than using function declarations inside blocks, you should use function expressions, which create functions in-scope.

Comment on lines +156 to +251
export function liftListItem<TType extends ProseNodeType>(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<boolean> {
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);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (code-quality): Avoid function declarations, favouring function assignment expressions, inside blocks. (avoid-function-declarations-in-blocks)

ExplanationFunction declarations may be hoisted in Javascript, but the behaviour is inconsistent between browsers. Hoisting is generally confusing and should be avoided. Rather than using function declarations inside blocks, you should use function expressions, which create functions in-scope.

Comment on lines +253 to +300
export function sinkListItem<TType extends ProseNodeType>(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<boolean> {
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);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (code-quality): Avoid function declarations, favouring function assignment expressions, inside blocks. (avoid-function-declarations-in-blocks)

ExplanationFunction declarations may be hoisted in Javascript, but the behaviour is inconsistent between browsers. Hoisting is generally confusing and should be avoided. Rather than using function declarations inside blocks, you should use function expressions, which create functions in-scope.

@Bistard Bistard merged commit 6c5c813 into dev Feb 26, 2025
5 checks passed
@Bistard Bistard deleted the dev-list branch February 26, 2025 05:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

editor enhancement Improvements on existing features refactor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant