mirror of
https://github.com/docmost/docmost.git
synced 2026-08-29 01:37:05 +08:00
fix(base): show skeleton while inline embed is being created, anchor at slash position
The Database slash command deleted the trigger text and then awaited
the create-base API before inserting the embed. During the wait the
editor sat empty (no visible state), and by the time the embed was
inserted the editor's selection had often drifted — so the new node
landed in the wrong place and the page appeared to "jump up" when the
response arrived.
Insert a placeholder baseEmbed node synchronously at the slash position
with pageId: null and a unique pendingKey. BaseEmbedView renders
BaseTableSkeleton while pendingKey is set — same skeleton BaseTable
shows on its own initial load, so the swap to the real table is a
visual no-op. Once the API responds, look up the placeholder by its
pendingKey and patch in the real pageId via setNodeMarkup. On API
failure, remove the placeholder and surface a toast. The pendingKey
attribute is non-serializing (parseHTML returns null, renderHTML
returns {}) so a placeholder can't survive a page reload as orphan.
This commit is contained in:
@@ -2,6 +2,7 @@ import { NodeViewWrapper, NodeViewProps } from "@tiptap/react";
|
|||||||
import { Box, Text } from "@mantine/core";
|
import { Box, Text } from "@mantine/core";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { BaseTable } from "@/features/base/components/base-table";
|
import { BaseTable } from "@/features/base/components/base-table";
|
||||||
|
import { BaseTableSkeleton } from "@/features/base/components/base-table-skeleton";
|
||||||
import { useBaseQuery } from "@/features/base/queries/base-query";
|
import { useBaseQuery } from "@/features/base/queries/base-query";
|
||||||
|
|
||||||
const SIDE_GUTTER = 8;
|
const SIDE_GUTTER = 8;
|
||||||
@@ -42,8 +43,12 @@ function applyExtension(wrapper: HTMLDivElement) {
|
|||||||
|
|
||||||
export function BaseEmbedView({ node }: NodeViewProps) {
|
export function BaseEmbedView({ node }: NodeViewProps) {
|
||||||
const pageId = node.attrs.pageId as string | null;
|
const pageId = node.attrs.pageId as string | null;
|
||||||
|
const pendingKey = node.attrs.pendingKey as string | null;
|
||||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
const { isLoading, isError } = useBaseQuery(pageId ?? "");
|
// Suppress the query while the slash command is still waiting for the
|
||||||
|
// server to assign a pageId — useBaseQuery would otherwise fire with
|
||||||
|
// an empty key and surface a transient error.
|
||||||
|
const { isLoading, isError } = useBaseQuery(pendingKey ? "" : pageId ?? "");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wrapper = wrapperRef.current;
|
const wrapper = wrapperRef.current;
|
||||||
@@ -67,7 +72,12 @@ export function BaseEmbedView({ node }: NodeViewProps) {
|
|||||||
}, [isLoading, isError, pageId]);
|
}, [isLoading, isError, pageId]);
|
||||||
|
|
||||||
let content: React.ReactNode;
|
let content: React.ReactNode;
|
||||||
if (!pageId) {
|
if (pendingKey) {
|
||||||
|
// Slash command inserted the embed and is awaiting the server's
|
||||||
|
// assigned pageId — render the same skeleton BaseTable shows on
|
||||||
|
// its own initial load so the swap is visually a no-op.
|
||||||
|
content = <BaseTableSkeleton />;
|
||||||
|
} else if (!pageId) {
|
||||||
content = (
|
content = (
|
||||||
<Box p="md">
|
<Box p="md">
|
||||||
<Text c="red">Invalid base embed (missing page id)</Text>
|
<Text c="red">Invalid base embed (missing page id)</Text>
|
||||||
|
|||||||
@@ -53,6 +53,30 @@ import {
|
|||||||
YoutubeIcon,
|
YoutubeIcon,
|
||||||
} from "@/components/icons";
|
} from "@/components/icons";
|
||||||
import api from "@/lib/api-client";
|
import api from "@/lib/api-client";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import type { Editor } from "@tiptap/core";
|
||||||
|
|
||||||
|
// Resolve the position of a baseEmbed placeholder by its pendingKey.
|
||||||
|
// Used by the Database 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 === "baseEmbed" &&
|
||||||
|
node.attrs.pendingKey === pendingKey
|
||||||
|
) {
|
||||||
|
foundPos = pos;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return foundPos;
|
||||||
|
}
|
||||||
|
|
||||||
const CommandGroups: SlashMenuGroupedItemsType = {
|
const CommandGroups: SlashMenuGroupedItemsType = {
|
||||||
basic: [
|
basic: [
|
||||||
@@ -488,13 +512,57 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
const parentPageId = editor.storage?.pageId as string | undefined;
|
const parentPageId = editor.storage?.pageId as string | undefined;
|
||||||
if (!parentPageId) return;
|
if (!parentPageId) return;
|
||||||
|
|
||||||
editor.chain().focus().deleteRange(range).run();
|
// Insert a placeholder embed at the slash position synchronously
|
||||||
|
// so (a) the position is established before any focus/selection
|
||||||
|
// drift during the await, and (b) the user sees a skeleton in
|
||||||
|
// the document instead of an empty gap. The API call then patches
|
||||||
|
// the real pageId into this exact node, identified by pendingKey.
|
||||||
|
const pendingKey =
|
||||||
|
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}-${Math.random()}`;
|
||||||
|
|
||||||
const res = await api.post<{ id: string }>("/bases/inline-embed", {
|
editor
|
||||||
parentPageId,
|
.chain()
|
||||||
});
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.insertBaseEmbed({ pageId: null, pendingKey })
|
||||||
|
.run();
|
||||||
|
|
||||||
editor.commands.insertBaseEmbed({ pageId: res.data.id });
|
try {
|
||||||
|
const res = await api.post<{ id: string }>("/bases/inline-embed", {
|
||||||
|
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 database",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ export interface BaseEmbedOptions {
|
|||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
baseEmbed: {
|
baseEmbed: {
|
||||||
insertBaseEmbed: (attrs: { pageId: string }) => ReturnType;
|
insertBaseEmbed: (attrs: {
|
||||||
|
pageId: string | null;
|
||||||
|
pendingKey?: string | null;
|
||||||
|
}) => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,6 +35,16 @@ export const BaseEmbed = Node.create<BaseEmbedOptions>({
|
|||||||
renderHTML: (attrs) =>
|
renderHTML: (attrs) =>
|
||||||
attrs.pageId ? { 'data-page-id': attrs.pageId } : {},
|
attrs.pageId ? { 'data-page-id': attrs.pageId } : {},
|
||||||
},
|
},
|
||||||
|
// Transient marker set when the slash command inserts the embed
|
||||||
|
// before the server has assigned a pageId. The view renders a
|
||||||
|
// skeleton in this state. Cleared once the API responds and the
|
||||||
|
// real pageId is patched in. Not serialized — embeds saved with
|
||||||
|
// a pendingKey would orphan if the page were closed mid-request.
|
||||||
|
pendingKey: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: () => null,
|
||||||
|
renderHTML: () => ({}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user