Skip to content

Commit d944206

Browse files
committed
feat: add grouping to suggestion menu
1 parent 26dea53 commit d944206

12 files changed

+335
-42
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<div class="bg-background shadow-2xl shadow-neutral-500 rounded p-1">
2+
@for ( group of filteredSlashMenuItemGroups; track group.label){
3+
<div class="px-2 py-1">{{ group.label }}</div>
4+
@for(item of group.items;track item.key+item.title){
5+
<bna-suggestion-menu-item [slashMenuItem]="item" />
6+
} }
7+
</div>
Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,55 @@
11
import { CommonModule } from '@angular/common';
2-
import { Component } from '@angular/core';
2+
import { Component, effect, signal } from '@angular/core';
3+
import {
4+
DefaultSuggestionItem,
5+
filterSuggestionItems,
6+
getDefaultSlashMenuItems,
7+
} from '@blocknote/core';
8+
import { SlashMenuItemsGroups } from '../../interfaces/slash-menu-items-group.type';
9+
import { BlockNoteAngularService } from '../../services';
10+
import { BnaSuggestionMenuItemComponent } from './default-item/bna-suggestion-menu-item.component';
11+
import { getGroupedSlashMenuItems } from './get-grouped-slash-menu-items.util';
312

413
@Component({
514
selector: 'bna-suggestions-menu',
615
standalone: true,
7-
imports: [CommonModule],
16+
imports: [CommonModule, BnaSuggestionMenuItemComponent],
817
templateUrl: './bna-suggestions-menu.component.html',
918
styleUrl: './bna-suggestions-menu.component.css',
1019
})
11-
export class BnaSuggestionsMenuComponent {}
20+
export class BnaSuggestionsMenuComponent {
21+
//TODO: add search
22+
query = signal('');
23+
filteredSlashMenuItemGroups: SlashMenuItemsGroups = [];
24+
25+
constructor(private blockNoteAngularService: BlockNoteAngularService) {
26+
effect(() => {
27+
this.filteredSlashMenuItemGroups = getGroupedSlashMenuItems(
28+
filterSuggestionItems(this.getSlashMenuItems(), this.query())
29+
);
30+
});
31+
}
32+
33+
getSlashMenuItems(): (Omit<DefaultSuggestionItem, 'key'> & {
34+
key: string;
35+
})[] {
36+
const editor = this.blockNoteAngularService.editor();
37+
if (!editor) {
38+
return [];
39+
}
40+
const slashMenuItems =
41+
this.blockNoteAngularService.options().inputSlashMenuItems;
42+
if (slashMenuItems) {
43+
const customSlashMenuItem = slashMenuItems.map((a) =>
44+
a(this.blockNoteAngularService.editor())
45+
);
46+
return [
47+
...getDefaultSlashMenuItems(editor),
48+
...customSlashMenuItem,
49+
//TODO: remove casting
50+
] as any;
51+
}
52+
53+
return [...getDefaultSlashMenuItems(editor)];
54+
}
55+
}

libs/block-note-angular/src/lib/components/suggestions-menu/default-item/bna-suggestion-menu-item.component.css

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<button
2+
hlmBtn
3+
variant="ghost"
4+
type="button"
5+
class="flex w-full justify-start py-1 px-4 h-full gap-4 text-left font-normal"
6+
(mousedown)="slashMenuItem().onItemClick()"
7+
>
8+
<div
9+
class="border border-border h-[45px] w-[45px] rounded flex justify-center items-center flex-shrink-0"
10+
>
11+
<hlm-icon size="base"[name]="iconName()" class="text-neutral-700"/>
12+
</div>
13+
<div class="w-full">
14+
<p class="text-sm font-normal">{{ slashMenuItem().title }}</p>
15+
<p class="text-xs text-muted-foreground">{{ slashMenuItem().subtext }}</p>
16+
</div>
17+
<div class="flex-grow-0 flex-shrink-0 bg-accent rounded p-1 text-xs">
18+
{{ slashMenuItem().badge }}
19+
</div>
20+
</button>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
import { BnaSuggestionMenuItemComponent } from './bna-suggestion-menu-item.component';
3+
4+
describe('BnaSuggestionMenuItemComponent', () => {
5+
let component: BnaSuggestionMenuItemComponent;
6+
let fixture: ComponentFixture<BnaSuggestionMenuItemComponent>;
7+
8+
beforeEach(async () => {
9+
await TestBed.configureTestingModule({
10+
imports: [BnaSuggestionMenuItemComponent],
11+
}).compileComponents();
12+
13+
fixture = TestBed.createComponent(BnaSuggestionMenuItemComponent);
14+
component = fixture.componentInstance;
15+
fixture.detectChanges();
16+
});
17+
18+
it('should create', () => {
19+
expect(component).toBeTruthy();
20+
});
21+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { CommonModule } from '@angular/common';
2+
import { Component, computed, input } from '@angular/core';
3+
import { provideIcons } from '@ng-icons/core';
4+
import {
5+
lucideFileAudio,
6+
lucideHeading1,
7+
lucideHeading2,
8+
lucideHeading3,
9+
lucideImage,
10+
lucideLayoutPanelTop,
11+
lucideList,
12+
lucideListChecks,
13+
lucideListOrdered,
14+
lucidePilcrow,
15+
lucideSmile,
16+
lucideTable,
17+
lucideVideo,
18+
} from '@ng-icons/lucide';
19+
import { SlashMenuItem } from '../../../interfaces/slash-menu-items-group.type';
20+
import { HlmButtonDirective, HlmIconComponent } from '../../../ui';
21+
22+
const icons: Record<string, string> = {
23+
heading: 'lucideHeading1',
24+
heading_2: 'lucideHeading2',
25+
heading_3: 'lucideHeading3',
26+
check_list: 'lucideListChecks',
27+
numbered_list: 'lucideListOrdered',
28+
bullet_list: 'lucideList',
29+
paragraph: 'lucidePilcrow',
30+
image: 'lucideImage',
31+
video: 'lucideVideo',
32+
audio: 'lucideFileAudio',
33+
table: 'lucideTable',
34+
emoji: 'lucideSmile',
35+
};
36+
37+
@Component({
38+
selector: 'bna-suggestion-menu-item',
39+
standalone: true,
40+
imports: [CommonModule, HlmButtonDirective, HlmIconComponent],
41+
providers: [
42+
provideIcons({
43+
lucideHeading1,
44+
lucideHeading2,
45+
lucideHeading3,
46+
lucideList,
47+
lucideLayoutPanelTop,
48+
lucideListChecks,
49+
lucideTable,
50+
lucideSmile,
51+
lucidePilcrow,
52+
lucideImage,
53+
lucideFileAudio,
54+
lucideVideo,
55+
lucideListOrdered,
56+
}),
57+
],
58+
templateUrl: './bna-suggestion-menu-item.component.html',
59+
styleUrl: './bna-suggestion-menu-item.component.css',
60+
})
61+
export class BnaSuggestionMenuItemComponent {
62+
slashMenuItem = input.required<SlashMenuItem>();
63+
iconName = computed(() => {
64+
const item = this.slashMenuItem();
65+
if (!item) {
66+
return 'lucideLayoutPanelTop';
67+
}
68+
const icon = icons[item?.key];
69+
return icon ? icon : 'lucideLayoutPanelTop';
70+
});
71+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { getGroupedSlashMenuItems } from './get-grouped-slash-menu-items.util';
2+
3+
describe('getGroupedSlashMenuItems', () => {
4+
it('should return empty array, when items are empty', () => {
5+
expect(getGroupedSlashMenuItems([])).toEqual([]);
6+
});
7+
8+
it('should return groups with items', () => {
9+
expect(
10+
getGroupedSlashMenuItems([
11+
{
12+
badge: 'Ctrl-Alt-1',
13+
key: 'heading',
14+
title: 'Heading 1',
15+
subtext: 'Top-level heading',
16+
aliases: ['h', 'heading1', 'h1'],
17+
group: 'Headings',
18+
onItemClick: () => undefined,
19+
},
20+
{
21+
badge: 'Ctrl-Alt-2',
22+
key: 'heading_2',
23+
title: 'Heading 2',
24+
subtext: 'Key section heading',
25+
aliases: ['h2', 'heading2', 'subheading'],
26+
group: 'Headings',
27+
onItemClick: () => undefined,
28+
},
29+
{
30+
badge: 'Ctrl-Alt-3',
31+
key: 'heading_3',
32+
title: 'Heading 3',
33+
subtext: 'Subsection and group heading',
34+
aliases: ['h3', 'heading3', 'subheading'],
35+
group: 'Headings',
36+
onItemClick: () => undefined,
37+
},
38+
{
39+
badge: 'Ctrl-Shift-9',
40+
key: 'check_list',
41+
title: 'Check List',
42+
subtext: 'List with checkboxes',
43+
onItemClick: () => undefined,
44+
aliases: [
45+
'ul',
46+
'li',
47+
'list',
48+
'checklist',
49+
'check list',
50+
'checked list',
51+
'checkbox',
52+
],
53+
group: 'Basic blocks',
54+
},
55+
])
56+
).toEqual([
57+
{
58+
label: 'Headings',
59+
items: [
60+
{
61+
aliases: ['h', 'heading1', 'h1'],
62+
badge: 'Ctrl-Alt-1',
63+
group: 'Headings',
64+
key: 'heading',
65+
onItemClick: expect.any(Function),
66+
subtext: 'Top-level heading',
67+
title: 'Heading 1',
68+
},
69+
{
70+
aliases: ['h2', 'heading2', 'subheading'],
71+
badge: 'Ctrl-Alt-2',
72+
group: 'Headings',
73+
key: 'heading_2',
74+
onItemClick: expect.any(Function),
75+
subtext: 'Key section heading',
76+
title: 'Heading 2',
77+
},
78+
{
79+
aliases: ['h3', 'heading3', 'subheading'],
80+
badge: 'Ctrl-Alt-3',
81+
group: 'Headings',
82+
key: 'heading_3',
83+
onItemClick: expect.any(Function),
84+
subtext: 'Subsection and group heading',
85+
title: 'Heading 3',
86+
},
87+
],
88+
},
89+
{
90+
label: 'Basic blocks',
91+
items: [
92+
{
93+
aliases: [
94+
'ul',
95+
'li',
96+
'list',
97+
'checklist',
98+
'check list',
99+
'checked list',
100+
'checkbox',
101+
],
102+
badge: 'Ctrl-Shift-9',
103+
group: 'Basic blocks',
104+
key: 'check_list',
105+
onItemClick: expect.any(Function),
106+
subtext: 'List with checkboxes',
107+
title: 'Check List',
108+
},
109+
],
110+
},
111+
]);
112+
});
113+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { DefaultSuggestionItem } from '@blocknote/core';
2+
import { SlashMenuItemsGroups } from '../../interfaces/slash-menu-items-group.type';
3+
4+
export function getGroupedSlashMenuItems(
5+
items: (Omit<DefaultSuggestionItem, 'key'> & { key: string })[]
6+
): SlashMenuItemsGroups {
7+
const slashMenuItemsGroups: SlashMenuItemsGroups = [];
8+
9+
for (const item of items) {
10+
const groupIndex = slashMenuItemsGroups.findIndex(
11+
(group) => group.label === item.group
12+
);
13+
if (groupIndex !== -1) {
14+
// Group exists, push this item into it.
15+
slashMenuItemsGroups[groupIndex].items.push(item);
16+
} else {
17+
//TODO: check if group was not found
18+
slashMenuItemsGroups.push({
19+
label: item.group as string,
20+
items: [item],
21+
});
22+
}
23+
}
24+
25+
return slashMenuItemsGroups;
26+
}

libs/block-note-angular/src/lib/editor/bna-editor.component.html

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
<div class="block h-full py-3 bn-container">
2-
@if(isInitialized){
32
<div #formattingToolbar>
43
<ng-content select="bna-formatting-toolbar-controller"></ng-content>
54
</div>
@@ -33,22 +32,7 @@
3332
<bna-file-panel> </bna-file-panel>
3433
</bna-file-panel-controller>
3534
<bna-suggestions-menu-controller class="z-index-3">
36-
<hlm-menu class="shadow-3xl">
37-
<hlm-menu-label>Basic blocks</hlm-menu-label>
38-
<hlm-menu-separator />
39-
<hlm-menu-group>
40-
@for (slashMenuItem of slashMenuItems; track slashMenuItem.title) {
41-
<button hlmMenuItem (mousedown)="slashMenuItem.onItemClick()">
42-
{{ slashMenuItem.title }}
43-
<hlm-menu-shortcut>{{ slashMenuItem.badge }}</hlm-menu-shortcut>
44-
<div class="text-xs">
45-
{{ slashMenuItem.subtext }}
46-
</div>
47-
</button>
48-
<hlm-menu-separator />
49-
}
50-
</hlm-menu-group>
51-
</hlm-menu>
35+
<bna-suggestions-menu
36+
></bna-suggestions-menu>
5237
</bna-suggestions-menu-controller>
53-
}
5438
</div>

0 commit comments

Comments
 (0)