From c8086b33e482d9ed3c1c3214ada0cc09babc724b Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:02:42 +0100 Subject: [PATCH] fix(base): show skeleton while inline embed is being created, anchor at slash position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/base-embed/base-embed-view.tsx | 14 +++- .../components/slash-menu/menu-items.ts | 78 +++++++++++++++++-- .../src/lib/base-embed/base-embed.ts | 15 +++- 3 files changed, 99 insertions(+), 8 deletions(-) diff --git a/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx b/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx index 1a78a0d0d..4cd93fd7c 100644 --- a/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx +++ b/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx @@ -2,6 +2,7 @@ import { NodeViewWrapper, NodeViewProps } from "@tiptap/react"; import { Box, Text } from "@mantine/core"; import { useEffect, useRef } from "react"; 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"; const SIDE_GUTTER = 8; @@ -42,8 +43,12 @@ function applyExtension(wrapper: HTMLDivElement) { export function BaseEmbedView({ node }: NodeViewProps) { const pageId = node.attrs.pageId as string | null; + const pendingKey = node.attrs.pendingKey as string | null; const wrapperRef = useRef(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(() => { const wrapper = wrapperRef.current; @@ -67,7 +72,12 @@ export function BaseEmbedView({ node }: NodeViewProps) { }, [isLoading, isError, pageId]); 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 = ; + } else if (!pageId) { content = ( Invalid base embed (missing page id) 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 d37a63cdb..0e502cf3a 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 @@ -53,6 +53,30 @@ import { YoutubeIcon, } from "@/components/icons"; 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 = { basic: [ @@ -488,13 +512,57 @@ const CommandGroups: SlashMenuGroupedItemsType = { const parentPageId = editor.storage?.pageId as string | undefined; 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", { - parentPageId, - }); + editor + .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", + }); + } }, }, { diff --git a/packages/editor-ext/src/lib/base-embed/base-embed.ts b/packages/editor-ext/src/lib/base-embed/base-embed.ts index 414663418..5c2ab7a07 100644 --- a/packages/editor-ext/src/lib/base-embed/base-embed.ts +++ b/packages/editor-ext/src/lib/base-embed/base-embed.ts @@ -8,7 +8,10 @@ export interface BaseEmbedOptions { declare module '@tiptap/core' { interface Commands { baseEmbed: { - insertBaseEmbed: (attrs: { pageId: string }) => ReturnType; + insertBaseEmbed: (attrs: { + pageId: string | null; + pendingKey?: string | null; + }) => ReturnType; }; } } @@ -32,6 +35,16 @@ export const BaseEmbed = Node.create({ renderHTML: (attrs) => 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: () => ({}), + }, }; },