mirror of
https://github.com/docmost/docmost.git
synced 2026-08-28 17:27:06 +08:00
fix(base): keep editor scroll stable when inline-embed creation completes
The placeholder rendered the full 10-row BaseTableSkeleton (~440px)
while waiting for the create response, then BaseTable mounted, ran its
own queries, and rendered the same 10-row skeleton again until rows
loaded. The actual content for a freshly-created empty base is ~112px
— so the swap shrank the doc by ~330px and on a short page the
browser clamped scrollY past the new doc bottom, manifesting as a
"jump to top of editor."
Two changes to keep the height constant end-to-end:
1. BaseTableSkeleton now accepts a `rows` prop (default 10). The
placeholder in BaseEmbedView passes `rows={0}` so the skeleton
matches the height of the eventual empty base shell — header row +
AddRow button, no fake body rows.
2. The Database slash command now seeds `["bases", id]` and the
`["base-rows", id, undefined, undefined, undefined]` infinite-query
cache from the create response (the endpoint already returns the
full base with properties + views; the typed return was just too
narrow). BaseTable mounts with baseLoading/rowsLoading already
false and skips its own skeleton — no transient grow-then-shrink
between placeholder and final content.
End state: placeholder height ≈ rendered-empty-base height, and no
intermediate skeleton appears while BaseTable is "loading." The
scrollY clamp can't fire because the doc never shrinks.
This commit is contained in:
@@ -5,14 +5,24 @@ import classes from "@/features/base/styles/base-table-skeleton.module.css";
|
|||||||
const ROW_NUMBER_WIDTH = 64;
|
const ROW_NUMBER_WIDTH = 64;
|
||||||
const COLUMN_WIDTH = 180;
|
const COLUMN_WIDTH = 180;
|
||||||
const COLUMN_COUNT = 6;
|
const COLUMN_COUNT = 6;
|
||||||
const ROW_COUNT = 10;
|
const DEFAULT_ROW_COUNT = 10;
|
||||||
|
|
||||||
// Deterministic per-cell widths so the skeleton doesn't flicker between
|
// Deterministic per-cell widths so the skeleton doesn't flicker between
|
||||||
// renders. Values are rough normal distribution around 55-85 % of cell.
|
// renders. Values are rough normal distribution around 55-85 % of cell.
|
||||||
const CELL_WIDTH_RATIOS = [0.78, 0.62, 0.84, 0.55, 0.71, 0.66];
|
const CELL_WIDTH_RATIOS = [0.78, 0.62, 0.84, 0.55, 0.71, 0.66];
|
||||||
const HEADER_WIDTH_RATIOS = [0.42, 0.58, 0.5, 0.64, 0.46, 0.54];
|
const HEADER_WIDTH_RATIOS = [0.42, 0.58, 0.5, 0.64, 0.46, 0.54];
|
||||||
|
|
||||||
export function BaseTableSkeleton() {
|
type BaseTableSkeletonProps = {
|
||||||
|
// Override the body row count. Pass 0 when rendering as the
|
||||||
|
// "creating database" placeholder for a freshly-inserted inline embed
|
||||||
|
// — the eventual empty base has no rows, so a 10-row skeleton would
|
||||||
|
// shrink ~330px on swap and trip the browser's scrollY clamp.
|
||||||
|
rows?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BaseTableSkeleton({
|
||||||
|
rows = DEFAULT_ROW_COUNT,
|
||||||
|
}: BaseTableSkeletonProps = {}) {
|
||||||
const gridTemplateColumns = [
|
const gridTemplateColumns = [
|
||||||
`${ROW_NUMBER_WIDTH}px`,
|
`${ROW_NUMBER_WIDTH}px`,
|
||||||
...Array.from({ length: COLUMN_COUNT }, () => `${COLUMN_WIDTH}px`),
|
...Array.from({ length: COLUMN_COUNT }, () => `${COLUMN_WIDTH}px`),
|
||||||
@@ -54,7 +64,7 @@ export function BaseTableSkeleton() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{Array.from({ length: ROW_COUNT }).map((_, rowIndex) => (
|
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||||
<div key={`row-${rowIndex}`} style={{ display: "contents" }}>
|
<div key={`row-${rowIndex}`} style={{ display: "contents" }}>
|
||||||
<div className={gridClasses.cell}>
|
<div className={gridClasses.cell}>
|
||||||
<div className={classes.cellInner}>
|
<div className={classes.cellInner}>
|
||||||
|
|||||||
@@ -74,9 +74,14 @@ export function BaseEmbedView({ node }: NodeViewProps) {
|
|||||||
let content: React.ReactNode;
|
let content: React.ReactNode;
|
||||||
if (pendingKey) {
|
if (pendingKey) {
|
||||||
// Slash command inserted the embed and is awaiting the server's
|
// Slash command inserted the embed and is awaiting the server's
|
||||||
// assigned pageId — render the same skeleton BaseTable shows on
|
// assigned pageId. Render with `rows={0}` so the placeholder
|
||||||
// its own initial load so the swap is visually a no-op.
|
// matches the height of the eventual empty base shell — anything
|
||||||
content = <BaseTableSkeleton />;
|
// taller would shrink hundreds of px on swap, and on a short doc
|
||||||
|
// the browser would clamp scrollY (looks like "page jumps to top
|
||||||
|
// of editor" when the create response lands). The slash command
|
||||||
|
// also prefills the React Query cache so BaseTable mounts with
|
||||||
|
// baseLoading/rowsLoading already false and skips its own skeleton.
|
||||||
|
content = <BaseTableSkeleton rows={0} />;
|
||||||
} else if (!pageId) {
|
} else if (!pageId) {
|
||||||
content = (
|
content = (
|
||||||
<Box p="md">
|
<Box p="md">
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ import {
|
|||||||
import api from "@/lib/api-client";
|
import api from "@/lib/api-client";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import type { Editor } from "@tiptap/core";
|
import type { Editor } from "@tiptap/core";
|
||||||
|
import type { InfiniteData } from "@tanstack/react-query";
|
||||||
|
import { queryClient } from "@/main";
|
||||||
|
import type {
|
||||||
|
IBase,
|
||||||
|
IBaseRow,
|
||||||
|
} from "@/features/base/types/base.types";
|
||||||
|
import type { IPagination } from "@/lib/types";
|
||||||
|
|
||||||
// Resolve the position of a baseEmbed placeholder by its pendingKey.
|
// Resolve the position of a baseEmbed placeholder by its pendingKey.
|
||||||
// Used by the Database slash command to patch in the real pageId once
|
// Used by the Database slash command to patch in the real pageId once
|
||||||
@@ -530,10 +537,40 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
.run();
|
.run();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.post<{ id: string }>("/bases/inline-embed", {
|
// The create endpoint returns the full base (properties +
|
||||||
|
// views), not just an id — see base.service.ts `create`. Type
|
||||||
|
// it as IBase so we can seed the React Query cache below.
|
||||||
|
const res = await api.post<IBase>("/bases/inline-embed", {
|
||||||
parentPageId,
|
parentPageId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Seed the caches BaseTable will read on mount so it doesn't
|
||||||
|
// render its own (10-row) skeleton between our placeholder
|
||||||
|
// and the actual content. Without this seeding the wrapper
|
||||||
|
// would grow to ~436px during BaseTable's load and shrink to
|
||||||
|
// ~112px once the rows resolve — on a short doc the shrink
|
||||||
|
// pushes scrollY past the new doc bottom and the browser
|
||||||
|
// clamps to 0, which is the "jump to top" the user reported.
|
||||||
|
queryClient.setQueryData<IBase>(["bases", res.data.id], res.data);
|
||||||
|
queryClient.setQueryData<InfiniteData<IPagination<IBaseRow>>>(
|
||||||
|
["base-rows", res.data.id, undefined, undefined, undefined],
|
||||||
|
{
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
items: [],
|
||||||
|
meta: {
|
||||||
|
limit: 100,
|
||||||
|
hasNextPage: false,
|
||||||
|
hasPrevPage: false,
|
||||||
|
nextCursor: null,
|
||||||
|
prevCursor: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pageParams: [undefined],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const pos = findBaseEmbedPlaceholderPos(editor, pendingKey);
|
const pos = findBaseEmbedPlaceholderPos(editor, pendingKey);
|
||||||
if (pos === null) return;
|
if (pos === null) return;
|
||||||
editor
|
editor
|
||||||
|
|||||||
Reference in New Issue
Block a user