mirror of
https://github.com/docmost/docmost.git
synced 2026-07-26 15:24:50 +10:00
feat(editor): add base and kanban inserts to the toolbar
This commit is contained in:
@@ -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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -7,10 +7,12 @@ import {
|
|||||||
IconCaretRightFilled,
|
IconCaretRightFilled,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
|
IconLayoutKanban,
|
||||||
IconMath,
|
IconMath,
|
||||||
IconMathFunction,
|
IconMathFunction,
|
||||||
IconRotate2,
|
IconRotate2,
|
||||||
IconSitemap,
|
IconSitemap,
|
||||||
|
IconTable,
|
||||||
IconTag,
|
IconTag,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
||||||
@@ -29,6 +31,7 @@ import {
|
|||||||
YoutubeIcon,
|
YoutubeIcon,
|
||||||
} from "@/components/icons";
|
} from "@/components/icons";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { insertBaseEmbedBlock } from "@/features/editor/components/base-embed/insert-base-embed";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
@@ -102,6 +105,22 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
|
|||||||
{t("Synced block")}
|
{t("Synced block")}
|
||||||
</Menu.Item>
|
</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.Divider />
|
||||||
<Menu.Label>{t("Diagrams")}</Menu.Label>
|
<Menu.Label>{t("Diagrams")}</Menu.Label>
|
||||||
|
|||||||
@@ -57,32 +57,7 @@ import {
|
|||||||
VimeoIcon,
|
VimeoIcon,
|
||||||
YoutubeIcon,
|
YoutubeIcon,
|
||||||
} from "@/components/icons";
|
} from "@/components/icons";
|
||||||
import api from "@/lib/api-client";
|
import { insertBaseEmbedBlock } from "@/features/editor/components/base-embed/insert-base-embed";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CommandGroups: SlashMenuGroupedItemsType = {
|
const CommandGroups: SlashMenuGroupedItemsType = {
|
||||||
basic: [
|
basic: [
|
||||||
@@ -390,58 +365,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
description: "Insert an inline base on this page",
|
description: "Insert an inline base on this page",
|
||||||
searchTerms: ["base", "database", "table", "grid", "spreadsheet"],
|
searchTerms: ["base", "database", "table", "grid", "spreadsheet"],
|
||||||
icon: IconTable,
|
icon: IconTable,
|
||||||
command: async ({ editor, range }: CommandProps) => {
|
command: ({ editor, range }: CommandProps) => {
|
||||||
// @ts-ignore
|
insertBaseEmbedBlock(editor, { range });
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -449,55 +374,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
description: "Insert a kanban board on this page",
|
description: "Insert a kanban board on this page",
|
||||||
searchTerms: ["kanban", "board", "cards", "status", "task", "database"],
|
searchTerms: ["kanban", "board", "cards", "status", "task", "database"],
|
||||||
icon: IconLayoutKanban,
|
icon: IconLayoutKanban,
|
||||||
command: async ({ editor, range }: CommandProps) => {
|
command: ({ editor, range }: CommandProps) => {
|
||||||
// @ts-ignore
|
insertBaseEmbedBlock(editor, { range, template: "kanban" });
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -428,7 +428,9 @@ const TEMPLATE_EXCLUDED_SLASH_ITEMS = new Set([
|
|||||||
"Draw.io (diagrams.net)",
|
"Draw.io (diagrams.net)",
|
||||||
"Excalidraw (Whiteboard)",
|
"Excalidraw (Whiteboard)",
|
||||||
"Audio",
|
"Audio",
|
||||||
"Synced block"
|
"Synced block",
|
||||||
|
"Base (Inline)",
|
||||||
|
"Kanban"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const TemplateSlashCommand = Command.configure({
|
const TemplateSlashCommand = Command.configure({
|
||||||
|
|||||||
Reference in New Issue
Block a user