feat(ee): bases

Table and kanban UI, formula engine package, and the base-embed editor extension
This commit is contained in:
Philipinho
2026-06-14 01:29:06 +01:00
parent d86d51c27e
commit 4e5bff6d55
233 changed files with 22278 additions and 141 deletions
@@ -10,6 +10,8 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>("");
export const yjsSyncedAtom = atom<boolean>(false);
export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false);
@@ -0,0 +1,146 @@
import { NodeViewWrapper, NodeViewProps } from "@tiptap/react";
import { Box, Text } from "@mantine/core";
import { useEffect, useRef } from "react";
import { BaseView } from "@/ee/base/components/base-view";
import { BaseTableSkeleton } from "@/ee/base/components/base-table-skeleton";
import { useBaseQuery } from "@/ee/base/queries/base-query";
import { pinOffsetWatcher } from "@docmost/editor-ext";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { IconTable } from "@tabler/icons-react";
import { usePageQuery } from "@/features/page/queries/page-query";
import classes from "./base-embed.module.css";
const SIDE_GUTTER = 8;
// Extend the scroll viewport on both sides (toward AppShell.Main's
// edges), but offset the grid content with padding-left = extendLeft
// so the first cell still lines up with page-content on load.
function applyExtension(wrapper: HTMLDivElement) {
const rect = wrapper.getBoundingClientRect();
if (rect.width === 0) return;
const main = wrapper.closest("main") as HTMLElement | null;
const mainRect = main?.getBoundingClientRect();
const targetLeft = (mainRect?.left ?? 0) + SIDE_GUTTER;
const targetRight = mainRect
? mainRect.right - SIDE_GUTTER
: window.innerWidth - SIDE_GUTTER;
const extendLeft = Math.max(0, rect.left - targetLeft);
const extendRight = Math.max(0, targetRight - rect.right);
wrapper.style.setProperty("--embed-extend-l", `${extendLeft}px`);
wrapper.style.setProperty("--embed-extend-r", `${extendRight}px`);
wrapper.style.setProperty("--embed-grid-pad-left", `${extendLeft}px`);
// Symmetric right-side padding so the user can pan past the last
// column into empty space.
// This gives the table breathing room on the right when scrolled fully right.
wrapper.style.setProperty("--embed-grid-pad-right", `${extendRight}px`);
// Inline sticky band clears whatever fixed surface sits above the editor —
// the page header AND the fixed formatting toolbar. `--editor-pin-offset`
// is the same offset the default ProseMirror table header-pin uses
// (published by pinOffsetWatcher); fall back to the page-header height.
// Standalone leaves --sticky-band-top unset (resolves to the rule default
// of 0).
wrapper.style.setProperty(
"--sticky-band-top",
"var(--editor-pin-offset, var(--page-header-height))",
);
}
export function BaseEmbedView({ node, editor }: NodeViewProps) {
const pageId = node.attrs.pageId as string | null;
const pendingKey = node.attrs.pendingKey as string | null;
const wrapperRef = useRef<HTMLDivElement | null>(null);
const hasBases = useHasFeature(Feature.BASES);
// Suppress the query while the slash command awaits the server-assigned
// pageId; useBaseQuery would otherwise fire with an empty key.
const { data: base, isLoading, isError } = useBaseQuery(
pendingKey ? "" : pageId ?? "",
);
const { data: page } = usePageQuery({ pageId: pageId ?? undefined });
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const update = () => applyExtension(wrapper);
update();
const ro = new ResizeObserver(update);
ro.observe(wrapper);
// Sidebar collapse changes <main>'s left/width without resizing
// the wrapper itself, so observe <main> too.
const main = wrapper.closest("main");
if (main) ro.observe(main);
window.addEventListener("resize", update);
return () => {
ro.disconnect();
window.removeEventListener("resize", update);
};
}, [isLoading, isError, pageId]);
// Keep --editor-pin-offset published while the embed is mounted, so the
// sticky column header clears the fixed toolbar even when this document
// has no default ProseMirror table holding the watcher open.
useEffect(() => {
pinOffsetWatcher.acquire();
return () => pinOffsetWatcher.release();
}, []);
// Error/invalid states render a compact message, not a tall reserved box.
// The 200px min-height (which avoids a layout jump when the real table
// mounts) is reserved only for the skeleton/loading/table states.
const isCompact = !pendingKey && (!pageId || isError);
let content: React.ReactNode;
if (pendingKey) {
// Slash command inserted the embed and is awaiting the server's
// assigned pageId. Match the shape the create endpoint will
// return for an inline-embed (Title + Text 1 + Text 2, one
// empty row — see BaseService.create's `defaults`) so the swap
// to the real table doesn't visibly collapse a large fake table
// down to a small empty one.
content = <BaseTableSkeleton rows={1} columns={3} />;
} else if (!pageId) {
content = (
<Box p="md">
<Text c="red">Invalid base embed (missing page id)</Text>
</Box>
);
} else if (isLoading) {
content = (
<Box p="md">
<Text c="dimmed">Loading...</Text>
</Box>
);
} else if (isError) {
content = (
<Box p="md" bg="gray.0" style={{ borderRadius: 8 }}>
<Text c="dimmed">You don't have access to this database.</Text>
</Box>
);
} else {
content = (
<BaseView
pageId={pageId}
embedded
editable={hasBases && editor.isEditable && (base?.canEdit ?? false)}
/>
);
}
return (
<NodeViewWrapper className={classes.handleGutter}>
<div data-drag-preview hidden className={classes.dragPreview}>
<IconTable size={16} />
<span>{page?.title?.trim() || "Untitled base"}</span>
</div>
<div ref={wrapperRef} style={{ minHeight: isCompact ? undefined : 200 }}>
{content}
</div>
</NodeViewWrapper>
);
}
@@ -0,0 +1,44 @@
.handleGutter {
margin-left: -1.5rem;
padding-left: 1.5rem;
}
@media (max-width: 48em) {
.handleGutter {
margin-left: -1rem;
padding-left: 1rem;
}
}
.dragPreview {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 260px;
padding: 4px 10px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
background-color: light-dark(
var(--mantine-color-white),
var(--mantine-color-dark-6)
);
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.18);
}
.dragPreview[hidden] {
display: none;
}
.dragPreview svg {
flex-shrink: 0;
}
.dragPreview span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -0,0 +1,29 @@
.wrapper {
display: flex;
flex-direction: column;
gap: 8px;
/* Push the bar to the bottom of the full-height editor container. */
margin-top: auto;
padding-top: 24px;
padding-bottom: 8px;
/* Match the content indent used by .byline and .ProseMirror so the bar
lines up with the title and paragraph rather than the container edge. */
padding-left: 3rem;
padding-right: 3rem;
@media (max-width: $mantine-breakpoint-sm) {
padding-left: 1rem;
padding-right: 1rem;
}
}
.label {
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
}
.chipRow {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
@@ -0,0 +1,82 @@
import { useEffect, useState } from "react";
import { Button } from "@mantine/core";
import { IconTable, IconLayoutKanban } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import { useConvertPageToBaseMutation } from "@/ee/base/queries/base-query";
import {
pageEditorAtom,
yjsSyncedAtom,
} from "@/features/editor/atoms/editor-atoms";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import classes from "./empty-page-get-started.module.css";
type EmptyPageGetStartedProps = {
pageId: string;
editable: boolean;
};
export function EmptyPageGetStarted({
pageId,
editable,
}: EmptyPageGetStartedProps) {
const { t } = useTranslation();
const editor = useAtomValue(pageEditorAtom);
const isSynced = useAtomValue(yjsSyncedAtom);
const hasBases = useHasFeature(Feature.BASES);
const convertMutation = useConvertPageToBaseMutation();
const [isEmpty, setIsEmpty] = useState(false);
useEffect(() => {
if (!editor) return;
const sync = () => setIsEmpty(editor.isEmpty);
sync();
editor.on("update", sync);
editor.on("create", sync);
return () => {
editor.off("update", sync);
editor.off("create", sync);
};
}, [editor]);
if (!editable || !hasBases || !editor || !isSynced || !isEmpty) return null;
const chips = [
{
key: "database",
label: t("Base"),
icon: IconTable,
onClick: () => convertMutation.mutate({ pageId }),
disabled: convertMutation.isPending,
},
{
key: "kanban",
label: t("Kanban"),
icon: IconLayoutKanban,
onClick: () => convertMutation.mutate({ pageId, template: "kanban" }),
disabled: convertMutation.isPending,
},
];
return (
<div className={classes.wrapper} contentEditable={false}>
<span className={classes.label}>{t("Get started with")}</span>
<div className={classes.chipRow}>
{chips.map((chip) => (
<Button
key={chip.key}
variant="default"
size="xs"
radius="xl"
leftSection={<chip.icon size={16} />}
onClick={chip.onClick}
disabled={chip.disabled}
>
{chip.label}
</Button>
))}
</div>
</div>
);
}
@@ -13,7 +13,7 @@ import { useTranslation } from "react-i18next";
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useParams } from "react-router-dom";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
import clsx from "clsx";
@@ -186,7 +186,7 @@ export const LinkEditorPanel = ({
<div style={{ flex: 1, minWidth: 0 }}>
<AutoTooltipText size="sm" fw={500} truncate lh={1.3}>
{page.title || t("Untitled")}
{getPageTitle(page.title, page.isBase, t)}
</AutoTooltipText>
{page.space?.name && (
<AutoTooltipText size="xs" c="dimmed" truncate lh={1.4}>
@@ -32,6 +32,7 @@ import {
MentionSuggestionItem,
} from "@/features/editor/components/mention/mention.type.ts";
import { IPage } from "@/features/page/types/page.types";
import { getPageTitle } from "@/features/page/page.utils";
import {
useCreatePageMutation,
usePageQuery,
@@ -103,7 +104,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
items = items.concat(
suggestion.pages.map((page) => ({
id: uuid7(),
label: page.title || t("Untitled"),
label: getPageTitle(page.title, page.isBase, t),
entityType: "page",
entityId: page.id,
slugId: page.slugId,
@@ -278,7 +279,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
props.command({
id: uuid7(),
label: createdPage.title || "Untitled",
label: getPageTitle(createdPage.title, createdPage.isBase, t),
entityType: "page",
entityId: createdPage.id,
slugId: createdPage.slugId,
@@ -5,6 +5,7 @@ import {
} from "@/features/editor/components/slash-menu/types";
import {
ActionIcon,
Badge,
Group,
Paper,
ScrollArea,
@@ -15,6 +16,8 @@ import {
import classes from "./slash-menu.module.css";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
const CommandList = ({
items,
@@ -33,6 +36,13 @@ const CommandList = ({
const [countAnnouncement, setCountAnnouncement] = useState("");
const [selectionAnnouncement, setSelectionAnnouncement] = useState("");
const hasBases = useHasFeature(Feature.BASES);
// Title must match the "Base (Inline)" item in menu-items.ts. Without the
// bases entitlement the item stays visible but disabled; an expired license
// the client can't detect falls through to a handled create failure.
const isItemDisabled = (item: SlashMenuItemType) =>
!hasBases && item.title === "Base (Inline)";
const flatItems = useMemo(() => {
return Object.values(items).flat();
}, [items]);
@@ -40,11 +50,11 @@ const CommandList = ({
const selectItem = useCallback(
(index: number) => {
const item = flatItems[index];
if (item) {
if (item && !isItemDisabled(item)) {
command(item);
}
},
[command, flatItems],
[command, flatItems, hasBases],
);
useEffect(() => {
@@ -140,6 +150,7 @@ const CommandList = ({
{categoryItems.map((item: SlashMenuItemType) => {
flatIndex += 1;
const itemIndex = flatIndex;
const disabled = isItemDisabled(item);
return (
<UnstyledButton
data-item-index={itemIndex}
@@ -147,12 +158,15 @@ const CommandList = ({
id={`slash-command-option-${itemIndex}`}
role="option"
aria-selected={itemIndex === selectedIndex}
aria-disabled={disabled}
disabled={disabled}
onClick={() => selectItem(itemIndex)}
className={clsx(classes.menuBtn, {
[classes.selectedItem]: itemIndex === selectedIndex,
[classes.disabledItem]: disabled,
})}
>
<Group>
<Group wrap="nowrap">
<ActionIcon variant="default" component="div" aria-hidden="true">
<item.icon size={18} />
</ActionIcon>
@@ -166,6 +180,12 @@ const CommandList = ({
{t(item.description)}
</Text>
</div>
{disabled && (
<Badge size="xs" variant="light" color="gray">
{t("Upgrade")}
</Badge>
)}
</Group>
</UnstyledButton>
);
@@ -7,6 +7,7 @@ import {
IconH2,
IconH3,
IconInfoCircle,
IconLayoutKanban,
IconList,
IconListNumbers,
IconMath,
@@ -56,6 +57,32 @@ 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 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 === "base" &&
node.attrs.pendingKey === pendingKey
) {
foundPos = pos;
return false;
}
return true;
});
return foundPos;
}
const CommandGroups: SlashMenuGroupedItemsType = {
basic: [
@@ -358,6 +385,121 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run(),
},
{
title: "Base (Inline)",
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",
});
}
},
},
{
title: "Kanban",
description: "Insert a kanban board on this page",
searchTerms: ["kanban", "board", "cards", "status", "task"],
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",
});
}
},
},
{
title: "Toggle block",
description: "Insert collapsible block.",
@@ -25,3 +25,8 @@
background: var(--mantine-color-gray-light);
}
}
.disabledItem {
opacity: 0.45;
cursor: not-allowed;
}
@@ -34,6 +34,8 @@ export interface GlobalDragHandleOptions {
* Custom nodes to be included for drag handle
*/
customNodes: string[];
atomNodes: string[];
}
function absoluteRect(node: Element) {
const data = node.getBoundingClientRect();
@@ -76,6 +78,10 @@ function nodeDOMAtCoords(
`[data-type=${node}] p`,
`.node-${node} p`,
]);
const atomSelectors = options.atomNodes.flatMap((node) => [
`[data-type=${node}]`,
`.node-${node}`,
]);
const selectors = [
"li",
@@ -95,8 +101,9 @@ function nodeDOMAtCoords(
".tableWrapper",
...customParagraphSelectors,
...customSelectors,
...atomSelectors,
].join(", ");
return document
const found = document
.elementsFromPoint(coords.x, coords.y)
.find((elem: Element) => {
// Skip elements that belong to a nested editor (e.g. transclusion
@@ -108,6 +115,11 @@ function nodeDOMAtCoords(
elem.matches(selectors)
);
});
if (found && atomSelectors.length > 0) {
const atomWrapper = found.closest(atomSelectors.join(", "));
if (atomWrapper) return atomWrapper;
}
return found;
}
function nodePosAtDOM(
node: Element,
@@ -127,7 +139,7 @@ function isCustomNodeDOM(
options: GlobalDragHandleOptions,
): boolean {
if (!elem) return false;
for (const name of options.customNodes) {
for (const name of [...options.customNodes, ...options.atomNodes]) {
if (
elem.getAttribute("data-type") === name ||
elem.classList.contains(`node-${name}`)
@@ -210,7 +222,10 @@ export function DragHandlePlugin(
// The drag landed on a custom-node container (transclusion etc.).
// Walk up to the matching node so the drag moves the whole
// container, not whatever inner element the click landed on.
const customTypes = new Set(options.customNodes);
const customTypes = new Set([
...options.customNodes,
...options.atomNodes,
]);
for (let d = $sel.depth; d > 0; d--) {
if (customTypes.has($sel.node(d).type.name)) {
selection = NodeSelection.create(
@@ -264,7 +279,23 @@ export function DragHandlePlugin(
event.dataTransfer.setData("text/plain", text);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setDragImage(node, 0, 0);
const previewTemplate =
node.querySelector<HTMLElement>("[data-drag-preview]");
if (previewTemplate) {
const preview = previewTemplate.cloneNode(true) as HTMLElement;
preview.removeAttribute("hidden");
preview.style.position = "fixed";
preview.style.top = "0";
preview.style.left = "-10000px";
preview.style.pointerEvents = "none";
document.body.appendChild(preview);
event.dataTransfer.setDragImage(preview, 0, 0);
document.addEventListener("dragend", () => preview.remove(), {
once: true,
});
} else {
event.dataTransfer.setDragImage(node, 0, 0);
}
view.dragging = { slice, move: event.ctrlKey };
}
@@ -497,6 +528,7 @@ const GlobalDragHandle = Extension.create({
scrollThreshold: 100,
excludedTags: [],
customNodes: [],
atomNodes: [],
};
},
@@ -509,6 +541,7 @@ const GlobalDragHandle = Extension.create({
dragHandleSelector: this.options.dragHandleSelector,
excludedTags: this.options.excludedTags,
customNodes: this.options.customNodes,
atomNodes: this.options.atomNodes,
}),
];
},
@@ -61,6 +61,7 @@ import {
TransclusionSource,
TransclusionReference,
TableView,
BaseEmbed as BaseEmbedNode,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -91,6 +92,7 @@ import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
import { BaseEmbedView } from "@/features/editor/components/base-embed/base-embed-view.tsx";
import { common, createLowlight } from "lowlight";
import plaintext from "highlight.js/lib/languages/plaintext";
import powershell from "highlight.js/lib/languages/powershell";
@@ -231,6 +233,7 @@ export const mainExtensions = [
TrailingNode,
GlobalDragHandle.configure({
customNodes: ["transclusionSource", "transclusionReference"],
atomNodes: ["base"],
}),
TextStyle,
Color,
@@ -381,6 +384,11 @@ export const mainExtensions = [
TransclusionReference.configure({
view: TransclusionReferenceView,
}),
BaseEmbedNode.extend({
addNodeView() {
return ReactNodeViewRenderer(BaseEmbedView);
},
}),
MarkdownClipboard.configure({
transformPastedText: true,
}),
@@ -26,6 +26,7 @@ import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx";
import clsx from "clsx";
import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { EmptyPageGetStarted } from "@/features/editor/components/empty-page/empty-page-get-started";
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor);
@@ -90,6 +91,7 @@ export function FullEditor({
fluid={fullPageWidth}
size={!fullPageWidth && 900}
className={classes.editor}
style={{ display: "flex", flexDirection: "column" }}
>
{editorToolbarEnabled && editable && isEditMode && (
<MemoizedFixedToolbar />
@@ -113,6 +115,7 @@ export function FullEditor({
content={content}
canComment={canComment}
/>
<EmptyPageGetStarted pageId={pageId} editable={editable} />
</Container>
);
}
@@ -34,6 +34,7 @@ import {
currentPageEditModeAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
yjsSyncedAtom,
} from "@/features/editor/atoms/editor-atoms";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import {
@@ -109,6 +110,7 @@ export default function PageEditor({
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom,
);
const [, setYjsSynced] = useAtom(yjsSyncedAtom);
const menuContainerRef = useRef(null);
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
@@ -378,6 +380,14 @@ export default function PageEditor({
const isSynced = isLocalSynced && isRemoteSynced;
useEffect(() => {
setYjsSynced(isSynced);
}, [isSynced, setYjsSynced]);
useEffect(() => {
return () => setYjsSynced(false);
}, [setYjsSynced]);
useEffect(() => {
const timeout = setTimeout(() => {
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
@@ -0,0 +1,17 @@
.ProseMirror {
.node-base {
/* Suppress the default ProseMirror atom-node selection outline —
* the embed reads as a document block, not a focused widget. */
&.ProseMirror-selectednode {
outline: none;
}
/* Page-reference pills are self-contained chips. The editor's `a` rule
* would otherwise add a link underline + bold weight on top of the chip,
* making it look different from a standalone base. Keep it a plain chip. */
a.pagePill {
border-bottom: none;
font-weight: 400;
}
}
}
@@ -1,3 +1,11 @@
:root {
/* Height of the fixed PageHeader at the top of every page. Used by
* standalone base layout (paddingTop) and by sticky bands inside
* inline base embeds (top offset). One source of truth; if the
* page header ever changes height, edit only this. */
--page-header-height: 45px;
}
.ProseMirror {
background-color: light-dark(
var(--mantine-color-white),
@@ -286,3 +294,24 @@
margin-top: 0;
margin-bottom: 0.75em;
}
/* The full-page base view positions its title with its own outer
* wrapper padding (so it can align with the table below). The global
* 3rem .ProseMirror padding-x would push the title further in than
* the table — drop it inside the base title wrapper only. */
.base-page-title .ProseMirror {
padding-left: 0;
padding-right: 0;
}
/* Definite height so the inner .tableScrollport scrolls and the sticky table
* header pins. Flow on print, else it emits a trailing blank page. */
.base-page-root {
height: calc(100dvh - var(--app-shell-header-height, 45px));
}
@media print {
.base-page-root {
height: 100%;
}
}
@@ -1,11 +1,13 @@
.editor {
height: 100%;
min-height: calc(100dvh - var(--app-shell-header-height, 45px) - 96px);
padding: 8px 0;
margin: 48px auto;
@media print {
padding: 0;
margin: 0;
min-height: 0;
}
}
@@ -17,3 +17,4 @@
@import "./indent.css";
@import "./columns.css";
@import "./status.css";
@import "./base-embed.css";
@@ -35,6 +35,7 @@ export interface TitleEditorProps {
title: string;
spaceSlug: string;
editable: boolean;
isBase?: boolean;
}
export function TitleEditor({
@@ -43,6 +44,7 @@ export function TitleEditor({
title,
spaceSlug,
editable,
isBase,
}: TitleEditorProps) {
const { t } = useTranslation();
const { mutateAsync: updateTitlePageMutationAsync } =
@@ -64,7 +66,7 @@ export function TitleEditor({
}),
Text,
Placeholder.configure({
placeholder: t("Untitled"),
placeholder: isBase ? t("Untitled base") : t("Untitled"),
showOnlyWhenEditable: false,
}),
History.configure({
@@ -106,11 +108,17 @@ export function TitleEditor({
});
useEffect(() => {
const anchorId = window.location.hash
? window.location.hash.substring(1)
: undefined;
const pageSlug = buildPageUrl(spaceSlug, slugId, title, anchorId);
navigate(pageSlug, { replace: true });
// Canonicalize only the path slug; keep query params (?row=, ?view=
// deep links) and the hash anchor intact.
const pageSlug = buildPageUrl(spaceSlug, slugId, title);
navigate(
{
pathname: pageSlug,
search: window.location.search,
hash: window.location.hash,
},
{ replace: true },
);
}, [title]);
const saveTitle = useCallback(() => {
@@ -14,6 +14,7 @@ export type IFavorite = {
slugId: string;
title: string;
icon: string | null;
isBase: boolean;
spaceId: string;
};
space?: {
@@ -4,15 +4,15 @@ import {
UnstyledButton,
Badge,
Table,
ThemeIcon,
Button,
} from "@mantine/core";
import { Link } from "react-router-dom";
import PageListSkeleton from "@/components/ui/page-list-skeleton";
import { buildPageUrl } from "@/features/page/page.utils";
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
import { formattedDate } from "@/lib/time";
import { useCreatedByQuery } from "@/features/page/queries/page-query";
import { IconFileDescription, IconFiles } from "@tabler/icons-react";
import { PageListIcon } from "@/components/common/page-list-icon";
import { IconFiles } from "@tabler/icons-react";
import { EmptyState } from "@/components/ui/empty-state";
import { getSpaceUrl } from "@/lib/config";
import { useTranslation } from "react-i18next";
@@ -62,17 +62,9 @@ export default function CreatedByMe({ spaceId }: Props) {
)}
>
<Group wrap="nowrap">
{page.icon || (
<ThemeIcon
variant="transparent"
color="gray"
size={18}
>
<IconFileDescription size={18} />
</ThemeIcon>
)}
<PageListIcon icon={page.icon} isBase={page.isBase} />
<Text fw={500} size="md" lineClamp={1}>
{page.title || t("Untitled")}
{getPageTitle(page.title, page.isBase, t)}
</Text>
</Group>
</UnstyledButton>
@@ -4,15 +4,15 @@ import {
UnstyledButton,
Badge,
Table,
ThemeIcon,
Button,
} from "@mantine/core";
import { Link } from "react-router-dom";
import PageListSkeleton from "@/components/ui/page-list-skeleton";
import { buildPageUrl } from "@/features/page/page.utils";
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
import { formattedDate } from "@/lib/time";
import { useFavoritesQuery } from "@/features/favorite/queries/favorite-query";
import { IconFileDescription, IconStar } from "@tabler/icons-react";
import { PageListIcon } from "@/components/common/page-list-icon";
import { IconStar } from "@tabler/icons-react";
import { EmptyState } from "@/components/ui/empty-state";
import { getSpaceUrl } from "@/lib/config";
import { useTranslation } from "react-i18next";
@@ -63,17 +63,12 @@ export default function FavoritesPages({ spaceId }: Props) {
)}
>
<Group wrap="nowrap">
{fav.page.icon || (
<ThemeIcon
variant="transparent"
color="gray"
size={18}
>
<IconFileDescription size={18} />
</ThemeIcon>
)}
<PageListIcon
icon={fav.page.icon}
isBase={fav.page.isBase}
/>
<Text fw={500} size="md" lineClamp={1}>
{fav.page.title || t("Untitled")}
{getPageTitle(fav.page.title, fav.page.isBase, t)}
</Text>
</Group>
</UnstyledButton>
@@ -18,7 +18,7 @@ import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useState } from "react";
import { useMarkReadMutation } from "../queries/notification-query";
import { buildPageUrl } from "@/features/page/page.utils";
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
import { formatRelativeTime } from "../notification.utils";
import classes from "../notification.module.css";
@@ -143,7 +143,7 @@ export function NotificationItem({
/>
)}
<Text size="xs" c="dimmed" lineClamp={1}>
{notification.page.title || t("Untitled")}
{getPageTitle(notification.page.title, undefined, t)}
</Text>
</Group>
)}
@@ -14,7 +14,7 @@ import {
BacklinkDirection,
IBacklinkPageItem,
} from "@/features/page-details/types/backlink.types.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts";
import { getPageIcon } from "@/lib";
interface BacklinksListProps {
@@ -86,7 +86,7 @@ export function BacklinksList({
{getPageIcon(item.icon ?? "")}
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} lineClamp={1}>
{item.title || t("Untitled")}
{getPageTitle(item.title, undefined, t)}
</Text>
{item.space?.name && (
<Text size="xs" c="dimmed" lineClamp={1}>
@@ -15,15 +15,17 @@ import { IconCornerDownRightDouble, IconDots } from "@tabler/icons-react";
import { Link, useParams } from "react-router-dom";
import classes from "./breadcrumb.module.css";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts";
import type { TFunction } from "i18next";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { extractPageSlugId } from "@/lib";
import { useMediaQuery } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
function getTitle(name: string, icon: string) {
if (icon) {
return `${icon} ${name}`;
function getTitle(node: SpaceTreeNode, t: TFunction) {
const name = getPageTitle(node.name, node.isBase, t);
if (node.icon) {
return `${node.icon} ${name}`;
}
return name;
}
@@ -58,7 +60,7 @@ export default function Breadcrumb() {
style={{ border: "none" }}
>
<Text fz={"sm"} className={classes.truncatedText}>
{getTitle(node.name, node.icon)}
{getTitle(node, t)}
</Text>
</Button>
</Button.Group>
@@ -75,7 +77,7 @@ export default function Breadcrumb() {
style={{ border: "none" }}
>
<Text fz={"sm"} className={classes.truncatedText}>
{getTitle(node.name, node.icon)}
{getTitle(node, t)}
</Text>
</Button>
</Button.Group>
@@ -83,7 +85,7 @@ export default function Breadcrumb() {
const renderAnchor = useCallback(
(node: SpaceTreeNode, isCurrent = false) => (
<Tooltip label={node.name} key={node.id}>
<Tooltip label={getPageTitle(node.name, node.isBase, t)} key={node.id}>
<Anchor
component={Link}
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
@@ -93,11 +95,11 @@ export default function Breadcrumb() {
className={classes.truncatedText}
aria-current={isCurrent ? "page" : undefined}
>
{getTitle(node.name, node.icon)}
{getTitle(node, t)}
</Anchor>
</Tooltip>
),
[spaceSlug],
[spaceSlug, t],
);
const getBreadcrumbItems = () => {
@@ -101,7 +101,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
<>
<ConnectionWarning />
{!readOnly && <PageEditModeToggle size="xs" />}
{!readOnly && !page?.isBase && <PageEditModeToggle size="xs" />}
<PageShareModal readOnly={readOnly} />
@@ -116,16 +116,18 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
</ActionIcon>
</Tooltip>
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
<ActionIcon
variant="subtle"
color="dark"
aria-label={t("Table of contents")}
{...tocTriggerProps}
>
<IconList size={20} stroke={2} />
</ActionIcon>
</Tooltip>
{!page?.isBase && (
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
<ActionIcon
variant="subtle"
color="dark"
aria-label={t("Table of contents")}
{...tocTriggerProps}
>
<IconList size={20} stroke={2} />
</ActionIcon>
</Tooltip>
)}
<PageActionMenu readOnly={readOnly} />
</>
@@ -234,12 +236,14 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
{t("Copy link")}
</Menu.Item>
<Menu.Item
leftSection={<IconMarkdown size={16} />}
onClick={handleCopyAsMarkdown}
>
{t("Copy as Markdown")}
</Menu.Item>
{!page?.isBase && (
<Menu.Item
leftSection={<IconMarkdown size={16} />}
onClick={handleCopyAsMarkdown}
>
{t("Copy as Markdown")}
</Menu.Item>
)}
<Menu.Item
leftSection={
@@ -270,22 +274,26 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
</Menu.Item>
)}
<Menu.Divider />
{!page?.isBase && <Menu.Divider />}
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
<Group wrap="nowrap">
<PageWidthToggle label={t("Full width")} />
</Group>
</Menu.Item>
{!page?.isBase && (
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
<Group wrap="nowrap">
<PageWidthToggle label={t("Full width")} />
</Group>
</Menu.Item>
)}
<Menu.Item
leftSection={<IconHistory size={16} />}
onClick={openHistoryModal}
>
{t("Page history")}
</Menu.Item>
{!page?.isBase && (
<Menu.Item
leftSection={<IconHistory size={16} />}
onClick={openHistoryModal}
>
{t("Page history")}
</Menu.Item>
)}
{!readOnly && (
{!readOnly && !page?.isBase && (
<PageVerificationMenuItem
pageId={page?.id}
onClick={openVerificationModal}
@@ -1,4 +1,18 @@
import slugify from "@sindresorhus/slugify";
import type { TFunction } from "i18next";
/**
* Display title for a page, with a base-aware empty-title fallback: bases
* fall back to "Untitled base", normal pages to "Untitled". Single chokepoint
* so the fallback stays consistent across the UI.
*/
export function getPageTitle(
title: string | null | undefined,
isBase: boolean | undefined,
t: TFunction,
): string {
return title || (isBase ? t("Untitled base") : t("Untitled"));
}
const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => {
const titleSlug = slugify(pageTitle?.substring(0, 70) || "untitled", {
@@ -6,6 +6,7 @@ import {
useState,
type ReactNode,
} from 'react';
import { flushSync } from 'react-dom';
import { createRoot } from 'react-dom/client';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
@@ -145,7 +146,15 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
getOffset: pointerOutsideOfPreview({ x: '16px', y: '8px' }),
render: ({ container }) => {
const root = createRoot(container);
root.render(<DocTreeDragPreview label={getDragLabel(node)} />);
// flushSync forces the preview to paint into `container`
// synchronously, before pragmatic-dnd snapshots it for the
// native drag image. Without it, createRoot's async render
// leaves the container empty at snapshot time, so the browser
// falls back to a default snapshot of the source row (and the
// stale image can linger on screen).
flushSync(() => {
root.render(<DocTreeDragPreview label={getDragLabel(node)} />);
});
return () => root.unmount();
},
});
@@ -20,6 +20,7 @@ import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import CopyPageModal from "@/features/page/components/copy-page-modal.tsx";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { getPageTitle } from "@/features/page/page.utils";
import { duplicatePage } from "@/features/page/services/page-service.ts";
import { useClipboard } from "@/hooks/use-clipboard";
import { getAppUrl } from "@/lib/config.ts";
@@ -125,7 +126,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
<ActionIcon
variant="transparent"
c="gray"
aria-label={t("Page menu for {{name}}", { name: node.name || t("untitled") })}
aria-label={t("Page menu for {{name}}", { name: getPageTitle(node.name, node.isBase, t) })}
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
@@ -9,11 +9,13 @@ import {
IconFileDescription,
IconPlus,
IconPointFilled,
IconTable,
} from "@tabler/icons-react";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
import { queryClient } from "@/main.tsx";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { getPageTitle } from "@/features/page/page.utils";
import { getPageById } from "@/features/page/services/page-service.ts";
import {
useUpdatePageMutation,
@@ -161,7 +163,13 @@ export function SpaceTreeRow({
<EmojiPicker
onEmojiSelect={handleEmojiSelect}
icon={
node.icon ? node.icon : <IconFileDescription size="18" />
node.icon ? (
node.icon
) : node.isBase ? (
<IconTable size={18} />
) : (
<IconFileDescription size="18" />
)
}
readOnly={!canEdit}
removeEmojiAction={handleRemoveEmoji}
@@ -169,7 +177,7 @@ export function SpaceTreeRow({
/>
</div>
<span className={classes.text}>{node.name || t("untitled")}</span>
<span className={classes.text}>{getPageTitle(node.name, node.isBase, t)}</span>
<div className={classes.actions}>
<NodeMenu node={node} canEdit={canEdit} />
@@ -18,6 +18,7 @@ import {
mergeRootTrees,
} from "@/features/page/tree/utils/utils.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { getPageTitle } from "@/features/page/page.utils";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { getPageBreadcrumbs } from "@/features/page/services/page-service.ts";
import { IPage } from "@/features/page/types/page.types.ts";
@@ -200,7 +201,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
[],
);
const getDragLabel = useCallback(
(n: SpaceTreeNode) => n.name || t("untitled"),
(n: SpaceTreeNode) => getPageTitle(n.name, n.isBase, t),
[t],
);
@@ -87,8 +87,6 @@
padding: 0;
}
/* ---- pragmatic-tree additions ---- */
.rowWrapper {
position: relative;
display: flex;
@@ -7,6 +7,7 @@ export type SpaceTreeNode = {
spaceId: string;
parentPageId: string;
hasChildren: boolean;
isBase?: boolean;
canEdit?: boolean;
children: SpaceTreeNode[];
};
@@ -24,6 +24,7 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
hasChildren: page.hasChildren,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
isBase: page.isBase,
canEdit: page.canEdit ?? page.permissions?.canEdit,
children: [],
};
@@ -42,10 +43,6 @@ export function findBreadcrumbPath(
path: SpaceTreeNode[] = [],
): SpaceTreeNode[] | null {
for (const node of tree) {
if (!node.name || node.name.trim() === "") {
node.name = "untitled";
}
if (node.id === pageId) {
return [...path, node];
}
@@ -12,6 +12,7 @@ export interface IPage {
spaceId: string;
workspaceId: string;
isLocked: boolean;
isBase: boolean;
lastUpdatedById: string;
createdAt: Date;
updatedAt: Date;
@@ -11,6 +11,9 @@ export enum SpaceCaslSubject {
Page = "page",
}
// Bases are pages and inherit Page permissions — a separate Base
// subject was redundant and has been dropped from the server's casl
// rules too. Anything that used to check Base now checks Page.
export type SpaceAbility =
| [SpaceCaslAction, SpaceCaslSubject.Settings]
| [SpaceCaslAction, SpaceCaslSubject.Member]
@@ -48,6 +48,11 @@ export const useTreeSocket = () => {
icon: event.payload.icon,
} as Partial<SpaceTreeNode>);
}
if (event.payload?.isBase !== undefined) {
next = treeModel.update(next, event.id, {
isBase: event.payload.isBase,
} as Partial<SpaceTreeNode>);
}
return next;
});
}