UNPKG

@blocknote/core

Version:

A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.

331 lines (301 loc) 9.18 kB
import { Block, PartialBlock } from "../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { checkDefaultBlockTypeInSchema } from "../../blocks/defaultBlockTypeGuards.js"; import { BlockSchema, InlineContentSchema, StyleSchema, isStyledTextInlineContent, } from "../../schema/index.js"; import { formatKeyboardShortcut } from "../../util/browser.js"; import { DefaultSuggestionItem } from "./DefaultSuggestionItem.js"; // Sets the editor's text cursor position to the next content editable block, // so either a block with inline content or a table. The last block is always a // paragraph, so this function won't try to set the cursor position past the // last block. function setSelectionToNextContentEditableBlock< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, >(editor: BlockNoteEditor<BSchema, I, S>) { let block: Block<BSchema, I, S> | undefined = editor.getTextCursorPosition().block; let contentType = editor.schema.blockSchema[block.type].content; while (contentType === "none") { block = editor.getTextCursorPosition().nextBlock; if (block === undefined) { return; } contentType = editor.schema.blockSchema[block.type].content as | "inline" | "table" | "none"; editor.setTextCursorPosition(block, "end"); } } // Checks if the current block is empty or only contains a slash, and if so, // updates the current block instead of inserting a new one below. If the new // block doesn't contain editable content, the cursor is moved to the next block // that does. export function insertOrUpdateBlock< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, >( editor: BlockNoteEditor<BSchema, I, S>, block: PartialBlock<BSchema, I, S>, ): Block<BSchema, I, S> { const currentBlock = editor.getTextCursorPosition().block; if (currentBlock.content === undefined) { throw new Error("Slash Menu open in a block that doesn't contain content."); } let newBlock: Block<BSchema, I, S>; if ( Array.isArray(currentBlock.content) && ((currentBlock.content.length === 1 && isStyledTextInlineContent(currentBlock.content[0]) && currentBlock.content[0].type === "text" && currentBlock.content[0].text === "/") || currentBlock.content.length === 0) ) { newBlock = editor.updateBlock(currentBlock, block); // We make sure to reset the cursor position to the new block as calling // `updateBlock` may move it out. This generally happens when the content // changes, or the update makes the block multi-column. editor.setTextCursorPosition(newBlock); } else { newBlock = editor.insertBlocks([block], currentBlock, "after")[0]; editor.setTextCursorPosition(editor.getTextCursorPosition().nextBlock!); } setSelectionToNextContentEditableBlock(editor); return newBlock; } export function getDefaultSlashMenuItems< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, >(editor: BlockNoteEditor<BSchema, I, S>) { const items: DefaultSuggestionItem[] = []; if (checkDefaultBlockTypeInSchema("heading", editor)) { items.push( { onItemClick: () => { insertOrUpdateBlock(editor, { type: "heading", props: { level: 1 }, }); }, badge: formatKeyboardShortcut("Mod-Alt-1"), key: "heading", ...editor.dictionary.slash_menu.heading, }, { onItemClick: () => { insertOrUpdateBlock(editor, { type: "heading", props: { level: 2 }, }); }, badge: formatKeyboardShortcut("Mod-Alt-2"), key: "heading_2", ...editor.dictionary.slash_menu.heading_2, }, { onItemClick: () => { insertOrUpdateBlock(editor, { type: "heading", props: { level: 3 }, }); }, badge: formatKeyboardShortcut("Mod-Alt-3"), key: "heading_3", ...editor.dictionary.slash_menu.heading_3, }, ); } if (checkDefaultBlockTypeInSchema("quote", editor)) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { type: "quote", }); }, key: "quote", ...editor.dictionary.slash_menu.quote, }); } if (checkDefaultBlockTypeInSchema("numberedListItem", editor)) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { type: "numberedListItem", }); }, badge: formatKeyboardShortcut("Mod-Shift-7"), key: "numbered_list", ...editor.dictionary.slash_menu.numbered_list, }); } if (checkDefaultBlockTypeInSchema("bulletListItem", editor)) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { type: "bulletListItem", }); }, badge: formatKeyboardShortcut("Mod-Shift-8"), key: "bullet_list", ...editor.dictionary.slash_menu.bullet_list, }); } if (checkDefaultBlockTypeInSchema("checkListItem", editor)) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { type: "checkListItem", }); }, badge: formatKeyboardShortcut("Mod-Shift-9"), key: "check_list", ...editor.dictionary.slash_menu.check_list, }); } if (checkDefaultBlockTypeInSchema("paragraph", editor)) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { type: "paragraph", }); }, badge: formatKeyboardShortcut("Mod-Alt-0"), key: "paragraph", ...editor.dictionary.slash_menu.paragraph, }); } if (checkDefaultBlockTypeInSchema("codeBlock", editor)) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { type: "codeBlock", }); }, badge: formatKeyboardShortcut("Mod-Alt-c"), key: "code_block", ...editor.dictionary.slash_menu.code_block, }); } if (checkDefaultBlockTypeInSchema("table", editor)) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { type: "table", content: { type: "tableContent", rows: [ { cells: ["", "", ""], }, { cells: ["", "", ""], }, ], }, }); }, badge: undefined, key: "table", ...editor.dictionary.slash_menu.table, }); } if (checkDefaultBlockTypeInSchema("image", editor)) { items.push({ onItemClick: () => { const insertedBlock = insertOrUpdateBlock(editor, { type: "image", }); // Immediately open the file toolbar editor.transact((tr) => tr.setMeta(editor.filePanel!.plugins[0], { block: insertedBlock, }), ); }, key: "image", ...editor.dictionary.slash_menu.image, }); } if (checkDefaultBlockTypeInSchema("video", editor)) { items.push({ onItemClick: () => { const insertedBlock = insertOrUpdateBlock(editor, { type: "video", }); // Immediately open the file toolbar editor.transact((tr) => tr.setMeta(editor.filePanel!.plugins[0], { block: insertedBlock, }), ); }, key: "video", ...editor.dictionary.slash_menu.video, }); } if (checkDefaultBlockTypeInSchema("audio", editor)) { items.push({ onItemClick: () => { const insertedBlock = insertOrUpdateBlock(editor, { type: "audio", }); // Immediately open the file toolbar editor.transact((tr) => tr.setMeta(editor.filePanel!.plugins[0], { block: insertedBlock, }), ); }, key: "audio", ...editor.dictionary.slash_menu.audio, }); } if (checkDefaultBlockTypeInSchema("file", editor)) { items.push({ onItemClick: () => { const insertedBlock = insertOrUpdateBlock(editor, { type: "file", }); // Immediately open the file toolbar editor.transact((tr) => tr.setMeta(editor.filePanel!.plugins[0], { block: insertedBlock, }), ); }, key: "file", ...editor.dictionary.slash_menu.file, }); } items.push({ onItemClick: () => { editor.openSuggestionMenu(":", { deleteTriggerCharacter: true, ignoreQueryLength: true, }); }, key: "emoji", ...editor.dictionary.slash_menu.emoji, }); return items; } export function filterSuggestionItems< T extends { title: string; aliases?: readonly string[] }, >(items: T[], query: string) { return items.filter( ({ title, aliases }) => title.toLowerCase().includes(query.toLowerCase()) || (aliases && aliases.filter((alias) => alias.toLowerCase().includes(query.toLowerCase()), ).length !== 0), ); }