Skip to content

Commit 824c336

Browse files
Slash Menu Commands Customization (#113)
* update v * wip poc * poc * api poc * Implemented basic block retrieval API functions * tiny fixes * Added marks API support, block conversion/insertion functions, and WIP markdown conversion * Changed markdown parsing to use remark * Added API support for serializing blocks to HTML and Markdown * General cleanup & refactor * Implemented PR feedback * Added documentation to API functions * Added basic HTML/Markdown conversion test cases and fixed some bugs * Added more HTML/Markdown conversion test cases * Fixed comments * Added HTML/Markdown conversion test snapshots * Small changes * initial website * Added text content for several sections * Added API functions to update and remove blocks, improved distinction between `Block` and `BlockSpec` types * Added "Accessing Blocks", "BlockSpec Objects" sections and other changes * Finished text content for Introduction to Blocks * Finished text content for Introduction * Finished text content for Quickstart * Finished text content for Manipulating Blocks * Small change to Block Types * update homepage * update site * Changes to most pages' text content and added live demos * Added remaining unit tests * sandpack vertical * add vanilla js docs * comment * temp expose api * Changed `BlockSpec` to `PartialBlock` and improved error messages * add draggable to vanillajs example * Added `replaceBlock` function, updated others. * Added global props * Fixed node conversion tests & snapshots * Fixed format conversion tests & snapshots * Fixed block manipulation tests & snapshots * Commented out failing format conversion tests * Fixed build failures * Fixed list item parsing issue * Changed API file structure * Moved type import to dev dependencies * Added `PartialBlock` and `replaceBlocks()` text * changes to intro * fix lockfile * Updated editor API docs * make api accessible via editor, hide tiptap initialization options (#106) * make api accessible via editor, hide tiptap initialization options * remove comment * Fixed tests * Fixed tests * small renames + expose domElement * improve block types * Small fix to PartialBlock type * Removed logs * add comments about waitForEditor --------- Co-authored-by: Matthew Lipski <[email protected]> * add comment * clean up timeout * small cleanup * rename to InlineContent * Simplified text * doc improvements * Finished block types and other changes * doc updates * fix handlers and padding * Added text cursor and inline content text * General doc fixes * doc suggestions * unset css for heading * Added customizing editor and small changes * Removed generic inline content definition * small doc fixes * Changed styles structure * Updated test screenshots * Added React slash menu items * Added icon to `ReactSlashMenuItem` * Updated suggestion/slash menu item definitions and default commands * small improvements * Merged `EditorFunctions` and `BlockNoteEditor` * Deleted `EditorFunctions` and updated docs * Updated test screenshot * Small changes --------- Co-authored-by: yousefed <[email protected]>
1 parent 1408623 commit 824c336

24 files changed

+553
-567
lines changed

examples/vanilla/src/ui/blockSideMenuFactory.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ export const blockSideMenuFactory: BlockSideMenuFactory = (staticParams) => {
3535
container.style.display = "block";
3636
}
3737

38-
console.log("show blockmenu", params);
3938
container.style.top = params.referenceRect.y + "px";
4039
container.style.left =
4140
params.referenceRect.x - container.offsetWidth + "px";

examples/vanilla/src/ui/hyperlinkToolbarFactory.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ export const hyperlinkToolbarFactory: HyperlinkToolbarFactory = (
4343
container.style.display = "block";
4444
}
4545

46-
console.log("show", params);
4746
container.style.top = params.referenceRect.y + "px";
4847
container.style.left = params.referenceRect.x + "px";
4948
},

examples/vanilla/src/ui/slashMenuFactory.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { SlashMenuItem, SuggestionsMenuFactory } from "@blocknote/core";
1+
import { BaseSlashMenuItem, SuggestionsMenuFactory } from "@blocknote/core";
22
import { createButton } from "./util";
33

44
/**
55
* This menu is drawn when the cursor is moved to a hyperlink (using the keyboard),
66
* or when the mouse is hovering over a hyperlink
77
*/
8-
export const slashMenuFactory: SuggestionsMenuFactory<SlashMenuItem> = (
8+
export const slashMenuFactory: SuggestionsMenuFactory<BaseSlashMenuItem> = (
99
staticParams
1010
) => {
1111
const container = document.createElement("div");
@@ -17,8 +17,8 @@ export const slashMenuFactory: SuggestionsMenuFactory<SlashMenuItem> = (
1717
document.body.appendChild(container);
1818

1919
function updateItems(
20-
items: SlashMenuItem[],
21-
onClick: (item: SlashMenuItem) => void,
20+
items: BaseSlashMenuItem[],
21+
onClick: (item: BaseSlashMenuItem) => void,
2222
selected: number
2323
) {
2424
container.innerHTML = "";
@@ -49,7 +49,6 @@ export const slashMenuFactory: SuggestionsMenuFactory<SlashMenuItem> = (
4949
container.style.display = "block";
5050
}
5151

52-
console.log("show", params);
5352
container.style.top = params.referenceRect.y + "px";
5453
container.style.left = params.referenceRect.x + "px";
5554
},

packages/core/src/BlockNoteEditor.ts

Lines changed: 232 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,37 @@
11
import { Editor, EditorOptions } from "@tiptap/core";
2-
2+
import { Node } from "prosemirror-model";
33
// import "./blocknote.css";
4-
import { Editor as EditorAPI } from "./api/Editor";
4+
import { Block, PartialBlock } from "./extensions/Blocks/api/blockTypes";
55
import { getBlockNoteExtensions, UiFactories } from "./BlockNoteExtensions";
66
import styles from "./editor.module.css";
7-
import { defaultSlashCommands, SlashCommand } from "./extensions/SlashMenu";
7+
import {
8+
defaultSlashMenuItems,
9+
BaseSlashMenuItem,
10+
} from "./extensions/SlashMenu";
11+
import { Editor as TiptapEditor } from "@tiptap/core/dist/packages/core/src/Editor";
12+
import { nodeToBlock } from "./api/nodeConversions/nodeConversions";
13+
import { TextCursorPosition } from "./extensions/Blocks/api/cursorPositionTypes";
14+
import { getBlockInfoFromPos } from "./extensions/Blocks/helpers/getBlockInfoFromPos";
15+
import { getNodeById } from "./api/util/nodeUtil";
16+
import {
17+
insertBlocks,
18+
updateBlock,
19+
removeBlocks,
20+
replaceBlocks,
21+
} from "./api/blockManipulation/blockManipulation";
22+
import {
23+
blocksToHTML,
24+
HTMLToBlocks,
25+
blocksToMarkdown,
26+
markdownToBlocks,
27+
} from "./api/formatConversions/formatConversions";
828

929
export type BlockNoteEditorOptions = {
1030
// TODO: Figure out if enableBlockNoteExtensions/disableHistoryExtension are needed and document them.
1131
enableBlockNoteExtensions: boolean;
1232
disableHistoryExtension: boolean;
1333
uiFactories: UiFactories;
14-
slashCommands: SlashCommand[];
34+
slashCommands: BaseSlashMenuItem[];
1535
parentElement: HTMLElement;
1636
editorDOMAttributes: Record<string, string>;
1737
onUpdate: (editor: BlockNoteEditor) => void;
@@ -27,17 +47,19 @@ const blockNoteTipTapOptions = {
2747
enableCoreExtensions: false,
2848
};
2949

30-
export class BlockNoteEditor extends EditorAPI {
31-
public readonly _tiptapEditor: Editor & { contentComponent: any };
50+
export class BlockNoteEditor {
51+
public readonly _tiptapEditor: TiptapEditor & { contentComponent: any };
52+
private blockCache = new WeakMap<Node, Block>();
3253

3354
public get domElement() {
3455
return this._tiptapEditor.view.dom as HTMLDivElement;
3556
}
3657

3758
constructor(options: Partial<BlockNoteEditorOptions> = {}) {
3859
const blockNoteExtensions = getBlockNoteExtensions({
60+
editor: this,
3961
uiFactories: options.uiFactories || {},
40-
slashCommands: options.slashCommands || defaultSlashCommands,
62+
slashCommands: options.slashCommands || defaultSlashMenuItems,
4163
});
4264

4365
let extensions = options.disableHistoryExtension
@@ -69,10 +91,210 @@ export class BlockNoteEditor extends EditorAPI {
6991
},
7092
};
7193

72-
const _tiptapEditor = new Editor(tiptapOptions) as Editor & {
94+
this._tiptapEditor = new Editor(tiptapOptions) as Editor & {
7395
contentComponent: any;
7496
};
75-
super(_tiptapEditor);
76-
this._tiptapEditor = _tiptapEditor;
97+
}
98+
99+
/**
100+
* Gets a list of all top-level blocks that are in the editor.
101+
*/
102+
public get topLevelBlocks(): Block[] {
103+
const blocks: Block[] = [];
104+
105+
this._tiptapEditor.state.doc.firstChild!.descendants((node) => {
106+
blocks.push(nodeToBlock(node, this.blockCache));
107+
108+
return false;
109+
});
110+
111+
return blocks;
112+
}
113+
114+
/**
115+
* Traverses all blocks in the editor, including all nested blocks, and executes a callback for each. The traversal is
116+
* depth-first, which is the same order as blocks appear in the editor by y-coordinate.
117+
* @param callback The callback to execute for each block.
118+
* @param reverse Whether the blocks should be traversed in reverse order.
119+
*/
120+
public allBlocks(
121+
callback: (block: Block) => void,
122+
reverse: boolean = false
123+
): void {
124+
function helper(blocks: Block[]) {
125+
if (reverse) {
126+
for (const block of blocks.reverse()) {
127+
helper(block.children);
128+
callback(block);
129+
}
130+
} else {
131+
for (const block of blocks) {
132+
callback(block);
133+
helper(block.children);
134+
}
135+
}
136+
}
137+
138+
helper(this.topLevelBlocks);
139+
}
140+
141+
/**
142+
* Gets information regarding the position of the text cursor in the editor.
143+
*/
144+
public getTextCursorPosition(): TextCursorPosition {
145+
const { node, depth, startPos, endPos } = getBlockInfoFromPos(
146+
this._tiptapEditor.state.doc,
147+
this._tiptapEditor.state.selection.from
148+
)!;
149+
150+
// Index of the current blockContainer node relative to its parent blockGroup.
151+
const nodeIndex = this._tiptapEditor.state.doc
152+
.resolve(endPos)
153+
.index(depth - 1);
154+
// Number of the parent blockGroup's child blockContainer nodes.
155+
const numNodes = this._tiptapEditor.state.doc
156+
.resolve(endPos + 1)
157+
.node().childCount;
158+
159+
// Gets previous blockContainer node at the same nesting level, if the current node isn't the first child.
160+
let prevNode: Node | undefined = undefined;
161+
if (nodeIndex > 0) {
162+
prevNode = this._tiptapEditor.state.doc.resolve(startPos - 2).node();
163+
}
164+
165+
// Gets next blockContainer node at the same nesting level, if the current node isn't the last child.
166+
let nextNode: Node | undefined = undefined;
167+
if (nodeIndex < numNodes - 1) {
168+
nextNode = this._tiptapEditor.state.doc.resolve(endPos + 2).node();
169+
}
170+
171+
return {
172+
block: nodeToBlock(node, this.blockCache),
173+
prevBlock:
174+
prevNode === undefined
175+
? undefined
176+
: nodeToBlock(prevNode, this.blockCache),
177+
nextBlock:
178+
nextNode === undefined
179+
? undefined
180+
: nodeToBlock(nextNode, this.blockCache),
181+
};
182+
}
183+
184+
public setTextCursorPosition(
185+
block: Block,
186+
placement: "start" | "end" = "start"
187+
) {
188+
const { posBeforeNode } = getNodeById(
189+
block.id,
190+
this._tiptapEditor.state.doc
191+
);
192+
const { startPos, contentNode } = getBlockInfoFromPos(
193+
this._tiptapEditor.state.doc,
194+
posBeforeNode + 2
195+
)!;
196+
197+
if (placement === "start") {
198+
this._tiptapEditor.commands.setTextSelection(startPos + 1);
199+
} else {
200+
this._tiptapEditor.commands.setTextSelection(
201+
startPos + contentNode.nodeSize - 1
202+
);
203+
}
204+
}
205+
206+
/**
207+
* Inserts multiple blocks before, after, or nested inside an existing block in the editor.
208+
* @param blocksToInsert An array of blocks to insert.
209+
* @param blockToInsertAt An existing block, marking where the new blocks should be inserted at.
210+
* @param placement Determines whether the blocks should be inserted just before, just after, or nested inside the
211+
* existing block.
212+
*/
213+
public insertBlocks(
214+
blocksToInsert: PartialBlock[],
215+
blockToInsertAt: Block,
216+
placement: "before" | "after" | "nested" = "before"
217+
): void {
218+
insertBlocks(
219+
blocksToInsert,
220+
blockToInsertAt,
221+
placement,
222+
this._tiptapEditor
223+
);
224+
}
225+
226+
/**
227+
* Updates a block in the editor to the given specification.
228+
* @param blockToUpdate The block that should be updated.
229+
* @param updatedBlock The specification that the block should be updated to.
230+
*/
231+
public updateBlock(blockToUpdate: Block, updatedBlock: PartialBlock) {
232+
updateBlock(blockToUpdate, updatedBlock, this._tiptapEditor);
233+
}
234+
235+
/**
236+
* Removes multiple blocks from the editor. Throws an error if any of the blocks could not be found.
237+
* @param blocksToRemove An array of blocks that should be removed.
238+
*/
239+
public removeBlocks(blocksToRemove: Block[]) {
240+
removeBlocks(blocksToRemove, this._tiptapEditor);
241+
}
242+
243+
/**
244+
* Replaces multiple blocks in the editor with several other blocks. If the provided blocks to remove are not adjacent
245+
* to each other, the new blocks are inserted at the position of the first block in the array. Throws an error if any
246+
* of the blocks could not be found.
247+
* @param blocksToRemove An array of blocks that should be replaced.
248+
* @param blocksToInsert An array of blocks to replace the old ones with.
249+
*/
250+
public replaceBlocks(
251+
blocksToRemove: Block[],
252+
blocksToInsert: PartialBlock[]
253+
) {
254+
replaceBlocks(blocksToRemove, blocksToInsert, this._tiptapEditor);
255+
}
256+
257+
/**
258+
* Executes a callback function whenever the editor's content changes.
259+
* @param callback The callback function to execute.
260+
*/
261+
public onContentChange(callback: () => void) {
262+
this._tiptapEditor.on("update", callback);
263+
}
264+
265+
/**
266+
* Serializes a list of blocks into an HTML string. The output is not the same as what's rendered by the editor, and
267+
* is simplified in order to better conform to HTML standards. Block structuring elements are removed, children of
268+
* blocks which aren't list items are lifted out of them, and list items blocks are wrapped in `ul`/`ol` tags.
269+
* @param blocks The list of blocks to serialize into HTML.
270+
*/
271+
public async blocksToHTML(blocks: Block[]): Promise<string> {
272+
return blocksToHTML(blocks, this._tiptapEditor.schema);
273+
}
274+
275+
/**
276+
* Creates a list of blocks from an HTML string.
277+
* @param htmlString The HTML string to create a list of blocks from.
278+
*/
279+
public async HTMLToBlocks(htmlString: string): Promise<Block[]> {
280+
return HTMLToBlocks(htmlString, this._tiptapEditor.schema);
281+
}
282+
283+
/**
284+
* Serializes a list of blocks into a Markdown string. The output is simplified as Markdown does not support all
285+
* features of BlockNote. Block structuring elements are removed, children of blocks which aren't list items are
286+
* lifted out of them, and certain styles are removed.
287+
* @param blocks The list of blocks to serialize into Markdown.
288+
*/
289+
public async blocksToMarkdown(blocks: Block[]): Promise<string> {
290+
return blocksToMarkdown(blocks, this._tiptapEditor.schema);
291+
}
292+
293+
/**
294+
* Creates a list of blocks from a Markdown string.
295+
* @param markdownString The Markdown string to create a list of blocks from.
296+
*/
297+
public async markdownToBlocks(markdownString: string): Promise<Block[]> {
298+
return markdownToBlocks(markdownString, this._tiptapEditor.schema);
77299
}
78300
}

packages/core/src/BlockNoteExtensions.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Extensions, extensions } from "@tiptap/core";
22

3+
import { BlockNoteEditor } from "./BlockNoteEditor";
4+
35
import { Bold } from "@tiptap/extension-bold";
46
import { Code } from "@tiptap/extension-code";
57
import { Dropcursor } from "@tiptap/extension-dropcursor";
@@ -22,8 +24,8 @@ import { FormattingToolbarFactory } from "./extensions/FormattingToolbar/Formatt
2224
import HyperlinkMark from "./extensions/HyperlinkToolbar/HyperlinkMark";
2325
import { HyperlinkToolbarFactory } from "./extensions/HyperlinkToolbar/HyperlinkToolbarFactoryTypes";
2426
import { Placeholder } from "./extensions/Placeholder/PlaceholderExtension";
25-
import { SlashCommand, SlashMenuExtension } from "./extensions/SlashMenu";
26-
import { SlashMenuItem } from "./extensions/SlashMenu/SlashMenuItem";
27+
import { SlashMenuExtension } from "./extensions/SlashMenu";
28+
import { BaseSlashMenuItem } from "./extensions/SlashMenu";
2729
import { TextAlignmentExtension } from "./extensions/TextAlignment/TextAlignmentExtension";
2830
import { TextColorExtension } from "./extensions/TextColor/TextColorExtension";
2931
import { TextColorMark } from "./extensions/TextColor/TextColorMark";
@@ -34,16 +36,17 @@ import { SuggestionsMenuFactory } from "./shared/plugins/suggestion/SuggestionsM
3436
export type UiFactories = Partial<{
3537
formattingToolbarFactory: FormattingToolbarFactory;
3638
hyperlinkToolbarFactory: HyperlinkToolbarFactory;
37-
slashMenuFactory: SuggestionsMenuFactory<SlashMenuItem>;
39+
slashMenuFactory: SuggestionsMenuFactory<BaseSlashMenuItem>;
3840
blockSideMenuFactory: BlockSideMenuFactory;
3941
}>;
4042

4143
/**
4244
* Get all the Tiptap extensions BlockNote is configured with by default
4345
*/
4446
export const getBlockNoteExtensions = (opts: {
47+
editor: BlockNoteEditor;
4548
uiFactories: UiFactories;
46-
slashCommands: SlashCommand[];
49+
slashCommands: BaseSlashMenuItem[];
4750
}) => {
4851
const ret: Extensions = [
4952
extensions.ClipboardTextSerializer,
@@ -123,6 +126,7 @@ export const getBlockNoteExtensions = (opts: {
123126
if (opts.uiFactories.slashMenuFactory) {
124127
ret.push(
125128
SlashMenuExtension.configure({
129+
editor: opts.editor,
126130
commands: opts.slashCommands,
127131
slashMenuFactory: opts.uiFactories.slashMenuFactory,
128132
})

0 commit comments

Comments
 (0)