mirror of
https://github.com/docmost/docmost.git
synced 2026-08-30 10:26:25 +08:00
feat(ee): bases
Table and kanban UI, formula engine package, and the base-embed editor extension
This commit is contained in:
@@ -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;
|
||||
}
|
||||
+29
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user