Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Asynchronous slash menu item fetching #506

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions examples/vanilla/src/ui/addSlashMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,42 @@ import {
BaseSlashMenuItem,
BlockNoteEditor,
DefaultBlockSchema,
DefaultInlineContentSchema,
DefaultStyleSchema,
} from "@blocknote/core";
import { createButton } from "./util";

export const addSlashMenu = async (editor: BlockNoteEditor) => {
let element: HTMLElement;

function updateItems(
items: BaseSlashMenuItem<DefaultBlockSchema, any, any>[],
onClick: (item: BaseSlashMenuItem<DefaultBlockSchema, any, any>) => void
async function updateItems(
query: string,
getItems: (
query: string,
token: {
cancel: (() => void) | undefined;
}
) => Promise<
BaseSlashMenuItem<
DefaultBlockSchema,
DefaultInlineContentSchema,
DefaultStyleSchema
>[]
>,
onClick: (
item: BaseSlashMenuItem<
DefaultBlockSchema,
DefaultInlineContentSchema,
DefaultStyleSchema
>
) => void
) {
element.innerHTML = "";
const items = await getItems(query, {
cancel: () => {
return;
},
});
const domItems = items.map((val, i) => {
const element = createButton(val.name, () => {
onClick(val);
Expand All @@ -37,7 +62,11 @@ export const addSlashMenu = async (editor: BlockNoteEditor) => {
}

if (slashMenuState.show) {
updateItems(await slashMenuState.items, editor.slashMenu.executeItem);
await updateItems(
slashMenuState.query,
editor.slashMenu.getItems,
editor.slashMenu.executeItem
);

element.style.display = "block";

Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/editor/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,10 @@ export type BlockNoteEditorOptions<
* @default defaultSlashMenuItems from `./extensions/SlashMenu`
*/
slashMenuItems: (
query: string
query: string,
token: {
cancel: (() => void) | undefined;
}
) => Promise<BaseSlashMenuItem<any, any, any>[]>;

/**
Expand Down
73 changes: 31 additions & 42 deletions packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,33 +8,30 @@ import { SuggestionItem } from "./SuggestionItem";

const findBlock = findParentNode((node) => node.type.name === "blockContainer");

export type SuggestionsMenuState<T extends SuggestionItem> =
BaseUiElementState & {
// The items that should be shown in the menu.
items: Promise<T[]>;
};
export type SuggestionsMenuState = BaseUiElementState & {
query: string;
};

class SuggestionsMenuView<
T extends SuggestionItem,
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema
> {
private suggestionsMenuState?: SuggestionsMenuState<T>;
private suggestionsMenuState?: SuggestionsMenuState;
public updateSuggestionsMenu: () => void;

pluginState: SuggestionPluginState<T>;
pluginState: SuggestionPluginState;

constructor(
private readonly editor: BlockNoteEditor<BSchema, I, S>,
private readonly pluginKey: PluginKey,
updateSuggestionsMenu: (
suggestionsMenuState: SuggestionsMenuState<T>
suggestionsMenuState: SuggestionsMenuState
) => void = () => {
// noop
}
) {
this.pluginState = getDefaultPluginState<T>();
this.pluginState = getDefaultPluginState();

this.updateSuggestionsMenu = () => {
if (!this.suggestionsMenuState) {
Expand Down Expand Up @@ -91,7 +88,7 @@ class SuggestionsMenuView<
this.suggestionsMenuState = {
show: true,
referencePos: decorationNode!.getBoundingClientRect(),
items: this.pluginState.items,
query: this.pluginState.query,
};

this.updateSuggestionsMenu();
Expand All @@ -103,7 +100,7 @@ class SuggestionsMenuView<
}
}

type SuggestionPluginState<T extends SuggestionItem> = {
type SuggestionPluginState = {
// True when the menu is shown, false when hidden.
active: boolean;
// The character that triggered the menu being shown. Allowing the trigger to be different to the default
Expand All @@ -112,19 +109,16 @@ type SuggestionPluginState<T extends SuggestionItem> = {
// The editor position just after the trigger character, i.e. where the user query begins. Used to figure out
// which menu items to show and can also be used to delete the trigger character.
queryStartPos: number | undefined;
// The items that should be shown in the menu.
items: Promise<T[]>;
query: string;
decorationId: string | undefined;
};

function getDefaultPluginState<
T extends SuggestionItem
>(): SuggestionPluginState<T> {
function getDefaultPluginState(): SuggestionPluginState {
return {
active: false,
triggerCharacter: undefined,
queryStartPos: undefined,
items: new Promise<T[]>((resolve) => resolve([])),
query: "",
decorationId: undefined,
};
}
Expand All @@ -146,14 +140,16 @@ export const setupSuggestionsMenu = <
S extends StyleSchema
>(
editor: BlockNoteEditor<BSchema, I, S>,
updateSuggestionsMenu: (
suggestionsMenuState: SuggestionsMenuState<T>
) => void,
updateSuggestionsMenu: (suggestionsMenuState: SuggestionsMenuState) => void,

pluginKey: PluginKey,
defaultTriggerCharacter: string,
getItems: (query: string) => Promise<T[]> = () =>
new Promise((resolve) => resolve([])),
getItems: (
query: string,
token: {
cancel: (() => void) | undefined;
}
) => Promise<T[]> = () => new Promise((resolve) => resolve([])),
onSelectItem: (props: {
item: T;
editor: BlockNoteEditor<BSchema, I, S>;
Expand All @@ -166,7 +162,7 @@ export const setupSuggestionsMenu = <
throw new Error("'char' should be a single character");
}

let suggestionsPluginView: SuggestionsMenuView<T, BSchema, I, S>;
let suggestionsPluginView: SuggestionsMenuView<BSchema, I, S>;

const deactivate = (view: EditorView) => {
view.dispatch(view.state.tr.setMeta(pluginKey, { deactivate: true }));
Expand All @@ -177,7 +173,7 @@ export const setupSuggestionsMenu = <
key: pluginKey,

view: () => {
suggestionsPluginView = new SuggestionsMenuView<T, BSchema, I, S>(
suggestionsPluginView = new SuggestionsMenuView<BSchema, I, S>(
editor,
pluginKey,

Expand All @@ -188,17 +184,12 @@ export const setupSuggestionsMenu = <

state: {
// Initialize the plugin's internal state.
init(): SuggestionPluginState<T> {
return getDefaultPluginState<T>();
init(): SuggestionPluginState {
return getDefaultPluginState();
},

// Apply changes to the plugin state from an editor transaction.
apply(
transaction,
prev,
_oldState,
newState
): SuggestionPluginState<T> {
apply(transaction, prev, _oldState, newState): SuggestionPluginState {
// TODO: More clearly define which transactions should be ignored.
if (transaction.getMeta("orderedListIndexing") !== undefined) {
return prev;
Expand All @@ -211,7 +202,7 @@ export const setupSuggestionsMenu = <
triggerCharacter:
transaction.getMeta(pluginKey)?.triggerCharacter || "",
queryStartPos: newState.selection.from,
items: getItems(""),
query: "",
decorationId: `id_${Math.floor(Math.random() * 0xffffffff)}`,
};
}
Expand All @@ -235,18 +226,15 @@ export const setupSuggestionsMenu = <
// Moving the caret before the character which triggered the menu should hide it.
(prev.active && newState.selection.from < prev.queryStartPos!)
) {
return getDefaultPluginState<T>();
return getDefaultPluginState();
}

const next = { ...prev };

// Updates which menu items to show by checking which items the current query (the text between the trigger
// character and caret) matches with.
next.items = getItems(
newState.doc.textBetween(
prev.queryStartPos!,
newState.selection.from
)
// Updates the current query.
next.query = newState.doc.textBetween(
prev.queryStartPos!,
newState.selection.from
);

return next;
Expand Down Expand Up @@ -318,6 +306,7 @@ export const setupSuggestionsMenu = <
},
},
}),
getItems: getItems,
executeItem: (item: T) => {
onSelectItem({ item, editor });
},
Expand Down
18 changes: 14 additions & 4 deletions packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,24 @@ export class SlashMenuProsemirrorPlugin<
SlashMenuItem extends BaseSlashMenuItem<BSchema, I, S>
> extends EventEmitter<any> {
public readonly plugin: Plugin;
public readonly getItems: (
query: string,
token: {
cancel: (() => void) | undefined;
}
) => Promise<SlashMenuItem[]>;
public readonly executeItem: (item: SlashMenuItem) => void;
public readonly closeMenu: () => void;
public readonly clearQuery: () => void;

constructor(
editor: BlockNoteEditor<BSchema, I, S>,
getItems: (query: string) => Promise<SlashMenuItem[]>
getItems: (
query: string,
token: {
cancel: (() => void) | undefined;
}
) => Promise<SlashMenuItem[]>
) {
super();
const suggestions = setupSuggestionsMenu<SlashMenuItem, BSchema, I, S>(
Expand All @@ -39,14 +50,13 @@ export class SlashMenuProsemirrorPlugin<
);

this.plugin = suggestions.plugin;
this.getItems = getItems;
this.executeItem = suggestions.executeItem;
this.closeMenu = suggestions.closeMenu;
this.clearQuery = suggestions.clearQuery;
}

public onUpdate(
callback: (state: SuggestionsMenuState<SlashMenuItem>) => void
) {
public onUpdate(callback: (state: SuggestionsMenuState) => void) {
return this.on("update", callback);
}
}
Loading
Loading