feat(editor): add base and kanban inserts to the toolbar

This commit is contained in:
Philipinho
2026-06-16 12:37:08 +01:00
parent ad00efe317
commit c43ac7fc8c
4 changed files with 94 additions and 128 deletions
@@ -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<void> {
// @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" });
}
}
@@ -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<Props> = ({ editor, templateMode }) => {
{t("Synced block")}
</Menu.Item>
)}
{!templateMode && (
<Menu.Item
leftSection={<IconTable size={16} />}
onClick={() => insertBaseEmbedBlock(editor)}
>
{t("Base (Inline)")}
</Menu.Item>
)}
{!templateMode && (
<Menu.Item
leftSection={<IconLayoutKanban size={16} />}
onClick={() => insertBaseEmbedBlock(editor, { template: "kanban" })}
>
{t("Kanban")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Label>{t("Diagrams")}</Menu.Label>
@@ -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" });
},
},
{
@@ -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({