diff --git a/apps/client/src/features/editor/components/base-embed/insert-base-embed.ts b/apps/client/src/features/editor/components/base-embed/insert-base-embed.ts new file mode 100644 index 000000000..8ef1fd500 --- /dev/null +++ b/apps/client/src/features/editor/components/base-embed/insert-base-embed.ts @@ -0,0 +1,67 @@ +import type { Editor, Range } from "@tiptap/core"; +import { v7 as uuid7 } from "uuid"; +import { notifications } from "@mantine/notifications"; +import api from "@/lib/api-client"; + +function findBaseEmbedPlaceholderPos( + editor: Editor, + pendingKey: string, +): number | null { + let foundPos: number | null = null; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === "base" && node.attrs.pendingKey === pendingKey) { + foundPos = pos; + return false; + } + return true; + }); + return foundPos; +} + +export async function insertBaseEmbedBlock( + editor: Editor, + opts: { template?: "kanban"; range?: Range } = {}, +): Promise { + // @ts-ignore + const parentPageId = editor.storage?.pageId as string | undefined; + if (!parentPageId) return; + + const pendingKey = uuid7(); + + const chain = editor.chain().focus(); + if (opts.range) chain.deleteRange(opts.range); + chain.insertBaseEmbed({ pageId: null, pendingKey }).run(); + + try { + const res = await api.post<{ id: string }>("/bases/create", { + parentPageId, + ...(opts.template ? { template: opts.template } : {}), + }); + + const pos = findBaseEmbedPlaceholderPos(editor, pendingKey); + if (pos === null) return; + editor + .chain() + .command(({ tr }) => { + tr.setNodeMarkup(pos, undefined, { + pageId: res.data.id, + pendingKey: null, + }); + return true; + }) + .run(); + } catch { + const pos = findBaseEmbedPlaceholderPos(editor, pendingKey); + if (pos !== null) { + editor + .chain() + .command(({ tr }) => { + const node = tr.doc.nodeAt(pos); + if (node) tr.delete(pos, pos + node.nodeSize); + return true; + }) + .run(); + } + notifications.show({ message: "Failed to create base", color: "red" }); + } +} diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx index 0b762be1c..3b2e62eac 100644 --- a/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx @@ -7,10 +7,12 @@ import { IconCaretRightFilled, IconChevronDown, IconInfoCircle, + IconLayoutKanban, IconMath, IconMathFunction, IconRotate2, IconSitemap, + IconTable, IconTag, } from "@tabler/icons-react"; import IconExcalidraw from "@/components/icons/icon-excalidraw"; @@ -29,6 +31,7 @@ import { YoutubeIcon, } from "@/components/icons"; import { useTranslation } from "react-i18next"; +import { insertBaseEmbedBlock } from "@/features/editor/components/base-embed/insert-base-embed"; interface Props { editor: Editor; @@ -102,6 +105,22 @@ export const MoreInsertsGroup: FC = ({ editor, templateMode }) => { {t("Synced block")} )} + {!templateMode && ( + } + onClick={() => insertBaseEmbedBlock(editor)} + > + {t("Base (Inline)")} + + )} + {!templateMode && ( + } + onClick={() => insertBaseEmbedBlock(editor, { template: "kanban" })} + > + {t("Kanban")} + + )} {t("Diagrams")} diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index daf0fa0e5..877d66403 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -57,32 +57,7 @@ import { VimeoIcon, YoutubeIcon, } from "@/components/icons"; -import api from "@/lib/api-client"; -import { notifications } from "@mantine/notifications"; -import type { Editor } from "@tiptap/core"; -import { v7 as uuid7 } from "uuid"; - -// Resolve the position of a baseEmbed placeholder by its pendingKey. -// Used by the Base slash command to patch in the real pageId once -// the create-base API responds — positions may have shifted in the -// interim from collab edits, undo/redo, or concurrent slash commands. -function findBaseEmbedPlaceholderPos( - editor: Editor, - pendingKey: string, -): number | null { - let foundPos: number | null = null; - editor.state.doc.descendants((node, pos) => { - if ( - node.type.name === "base" && - node.attrs.pendingKey === pendingKey - ) { - foundPos = pos; - return false; - } - return true; - }); - return foundPos; -} +import { insertBaseEmbedBlock } from "@/features/editor/components/base-embed/insert-base-embed"; const CommandGroups: SlashMenuGroupedItemsType = { basic: [ @@ -390,58 +365,8 @@ const CommandGroups: SlashMenuGroupedItemsType = { description: "Insert an inline base on this page", searchTerms: ["base", "database", "table", "grid", "spreadsheet"], icon: IconTable, - command: async ({ editor, range }: CommandProps) => { - // @ts-ignore - const parentPageId = editor.storage?.pageId as string | undefined; - if (!parentPageId) return; - - // Insert a placeholder embed at the slash position synchronously - // so the user sees a skeleton immediately while we wait on the - // create-base API. Once the response lands we look the - // placeholder up by its pendingKey and patch in the real pageId. - const pendingKey = uuid7(); - - editor - .chain() - .focus() - .deleteRange(range) - .insertBaseEmbed({ pageId: null, pendingKey }) - .run(); - - try { - const res = await api.post<{ id: string }>("/bases/create", { - parentPageId, - }); - - const pos = findBaseEmbedPlaceholderPos(editor, pendingKey); - if (pos === null) return; - editor - .chain() - .command(({ tr }) => { - tr.setNodeMarkup(pos, undefined, { - pageId: res.data.id, - pendingKey: null, - }); - return true; - }) - .run(); - } catch { - const pos = findBaseEmbedPlaceholderPos(editor, pendingKey); - if (pos !== null) { - editor - .chain() - .command(({ tr }) => { - const node = tr.doc.nodeAt(pos); - if (node) tr.delete(pos, pos + node.nodeSize); - return true; - }) - .run(); - } - notifications.show({ - message: "Failed to create base", - color: "red", - }); - } + command: ({ editor, range }: CommandProps) => { + insertBaseEmbedBlock(editor, { range }); }, }, { @@ -449,55 +374,8 @@ const CommandGroups: SlashMenuGroupedItemsType = { description: "Insert a kanban board on this page", searchTerms: ["kanban", "board", "cards", "status", "task", "database"], icon: IconLayoutKanban, - command: async ({ editor, range }: CommandProps) => { - // @ts-ignore - const parentPageId = editor.storage?.pageId as string | undefined; - if (!parentPageId) return; - - const pendingKey = uuid7(); - - editor - .chain() - .focus() - .deleteRange(range) - .insertBaseEmbed({ pageId: null, pendingKey }) - .run(); - - try { - const res = await api.post<{ id: string }>("/bases/create", { - parentPageId, - template: "kanban", - }); - - const pos = findBaseEmbedPlaceholderPos(editor, pendingKey); - if (pos === null) return; - editor - .chain() - .command(({ tr }) => { - tr.setNodeMarkup(pos, undefined, { - pageId: res.data.id, - pendingKey: null, - }); - return true; - }) - .run(); - } catch { - const pos = findBaseEmbedPlaceholderPos(editor, pendingKey); - if (pos !== null) { - editor - .chain() - .command(({ tr }) => { - const node = tr.doc.nodeAt(pos); - if (node) tr.delete(pos, pos + node.nodeSize); - return true; - }) - .run(); - } - notifications.show({ - message: "Failed to create base", - color: "red", - }); - } + command: ({ editor, range }: CommandProps) => { + insertBaseEmbedBlock(editor, { range, template: "kanban" }); }, }, { diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 6669486e2..040347d0b 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -428,7 +428,9 @@ const TEMPLATE_EXCLUDED_SLASH_ITEMS = new Set([ "Draw.io (diagrams.net)", "Excalidraw (Whiteboard)", "Audio", - "Synced block" + "Synced block", + "Base (Inline)", + "Kanban" ]); const TemplateSlashCommand = Command.configure({