Merge branch 'main' into confluence-importer

This commit is contained in:
Philipinho
2026-08-12 15:06:08 +01:00
45 changed files with 1896 additions and 129 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ RUN chown -R node:node /app
USER node USER node
RUN pnpm install --frozen-lockfile --prod RUN pnpm install --frozen-lockfile --prod && rm -rf /home/node/.cache/pnpm
RUN mkdir -p /app/data/storage RUN mkdir -p /app/data/storage
@@ -404,6 +404,8 @@
"Insert horizontal rule divider": "Insert horizontal rule divider", "Insert horizontal rule divider": "Insert horizontal rule divider",
"Page break": "Page break", "Page break": "Page break",
"Insert a page break for printing.": "Insert a page break for printing.", "Insert a page break for printing.": "Insert a page break for printing.",
"Footnote": "Footnote",
"Insert a footnote reference.": "Insert a footnote reference.",
"Upload any image from your device.": "Upload any image from your device.", "Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.", "Upload any video from your device.": "Upload any video from your device.",
"Upload any audio from your device.": "Upload any audio from your device.", "Upload any audio from your device.": "Upload any audio from your device.",
@@ -1306,5 +1308,16 @@
"{{count}} rows deleted_one": "1 row deleted", "{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted", "{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected", "{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected" "{{count}} selected_other": "{{count}} selected",
"Compare": "Compare",
"Compare versions": "Compare versions",
"Select version from {{date}}": "Select version from {{date}}",
"Version actions for {{date}}": "Version actions for {{date}}",
"Comparing {{newer}} and {{older}}": "Comparing {{newer}} and {{older}}",
"Exit compare": "Exit compare",
"Search attachments...": "Search attachments...",
"Error loading attachments.": "Error loading attachments.",
"No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{name}}"
} }
@@ -0,0 +1,59 @@
import { ThemeIcon } from "@mantine/core";
import {
IconFile,
IconFileTypeCsv,
IconFileTypeDocx,
IconFileTypePdf,
IconFileTypePpt,
IconFileTypeXls,
IconFileZip,
IconMovie,
IconMusic,
IconPhoto,
type Icon,
} from "@tabler/icons-react";
const EXT_ICONS: Record<string, { icon: Icon; color: string }> = {
".pdf": { icon: IconFileTypePdf, color: "red" },
".doc": { icon: IconFileTypeDocx, color: "blue" },
".docx": { icon: IconFileTypeDocx, color: "blue" },
".xls": { icon: IconFileTypeXls, color: "teal" },
".xlsx": { icon: IconFileTypeXls, color: "teal" },
".csv": { icon: IconFileTypeCsv, color: "teal" },
".ppt": { icon: IconFileTypePpt, color: "orange" },
".pptx": { icon: IconFileTypePpt, color: "orange" },
".zip": { icon: IconFileZip, color: "gray" },
".rar": { icon: IconFileZip, color: "gray" },
".7z": { icon: IconFileZip, color: "gray" },
".tar": { icon: IconFileZip, color: "gray" },
".gz": { icon: IconFileZip, color: "gray" },
};
const MIME_ICONS: Array<{ prefix: string; icon: Icon; color: string }> = [
{ prefix: "image/", icon: IconPhoto, color: "grape" },
{ prefix: "video/", icon: IconMovie, color: "violet" },
{ prefix: "audio/", icon: IconMusic, color: "pink" },
];
interface AttachmentFileIconProps {
fileExt?: string;
mimeType?: string;
}
export function AttachmentFileIcon({
fileExt,
mimeType,
}: AttachmentFileIconProps) {
const byExt = fileExt ? EXT_ICONS[fileExt.toLowerCase()] : undefined;
const byMime = mimeType
? MIME_ICONS.find((entry) => mimeType.startsWith(entry.prefix))
: undefined;
const { icon: FileIcon, color } = byExt ??
byMime ?? { icon: IconFile, color: "gray" };
return (
<ThemeIcon variant="light" color={color} size={40} radius="md">
<FileIcon size={22} stroke={1.5} />
</ThemeIcon>
);
}
@@ -0,0 +1,191 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActionIcon,
Anchor,
Center,
Group,
Loader,
Modal,
ScrollArea,
Text,
Tooltip,
} from "@mantine/core";
import { IconDownload } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { SearchInput } from "@/components/common/search-input.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { usePageAttachmentsQuery } from "@/features/attachments/queries/attachment-query.ts";
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
import { AttachmentFileIcon } from "@/features/attachments/components/attachment-file-icon.tsx";
import { formatBytes } from "@/lib";
import { getFileUrl } from "@/lib/config.ts";
import { formattedDate } from "@/lib/time.ts";
interface PageAttachmentsModalProps {
pageId: string;
open: boolean;
onClose: () => void;
}
export default function PageAttachmentsModal({
pageId,
open,
onClose,
}: PageAttachmentsModalProps) {
const { t } = useTranslation();
return (
<Modal
opened={open}
onClose={onClose}
title={t("Attachments")}
size={800}
closeButtonProps={{ "aria-label": t("Close") }}
>
<PageAttachmentsList pageId={pageId} />
</Modal>
);
}
function PageAttachmentsList({ pageId }: { pageId: string }) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const {
data,
isLoading,
isError,
isFetching,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = usePageAttachmentsQuery(pageId, search);
const attachments = useMemo(
() => data?.pages.flatMap((page) => page.items) ?? [],
[data],
);
const loadMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = loadMoreRef.current;
if (!sentinel || !hasNextPage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isFetching) {
fetchNextPage();
}
},
{ threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetching]);
const handleSearch = useCallback((value: string) => setSearch(value), []);
return (
<>
<SearchInput
onSearch={handleSearch}
placeholder={t("Search attachments...")}
/>
{isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : isError ? (
<Center py="xl">
<Text size="sm" c="dimmed">
{t("Error loading attachments.")}
</Text>
</Center>
) : attachments.length === 0 ? (
<Center py="xl">
<Text size="sm" c="dimmed">
{search
? t("No results found")
: t("No attachments on this page yet.")}
</Text>
</Center>
) : (
<ScrollArea.Autosize mah={480} type="scroll" scrollbarSize={5}>
{attachments.map((attachment) => (
<AttachmentRow key={attachment.id} attachment={attachment} />
))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
{isFetchingNextPage && (
<Center py="sm">
<Loader size="sm" />
</Center>
)}
</ScrollArea.Autosize>
)}
</>
);
}
function AttachmentRow({ attachment }: { attachment: IPageAttachment }) {
const { t } = useTranslation();
const fileUrl = getFileUrl(attachment.url);
return (
<Group wrap="nowrap" gap="md" py="sm" pr="xs">
<AttachmentFileIcon
fileExt={attachment.fileExt}
mimeType={attachment.mimeType}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Anchor
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
size="sm"
fw={500}
c="inherit"
truncate="end"
style={{ display: "block" }}
>
{attachment.fileName}
</Anchor>
<Text size="xs" c="dimmed" mt={2} truncate="end">
{formatBytes(Number(attachment.fileSize))}
{" · "}
{formattedDate(new Date(attachment.createdAt))}
</Text>
</div>
{attachment.creator && (
<Tooltip
label={t("Uploaded by {{name}}", { name: attachment.creator.name })}
withArrow
>
<CustomAvatar
avatarUrl={attachment.creator.avatarUrl}
name={attachment.creator.name}
size="sm"
/>
</Tooltip>
)}
<Tooltip label={t("Download attachment")} withArrow>
<ActionIcon
component="a"
href={fileUrl}
download={attachment.fileName}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
color="gray"
aria-label={t("Download {{name}}", { name: attachment.fileName })}
>
<IconDownload size={18} />
</ActionIcon>
</Tooltip>
</Group>
);
}
@@ -0,0 +1,25 @@
import {
InfiniteData,
keepPreviousData,
useInfiniteQuery,
UseInfiniteQueryResult,
} from "@tanstack/react-query";
import { getPageAttachments } from "@/features/attachments/services/attachment-service.ts";
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
import { IPagination } from "@/lib/types.ts";
export function usePageAttachmentsQuery(
pageId: string,
search?: string,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageAttachment>, unknown>> {
return useInfiniteQuery({
queryKey: ["page-attachments", pageId, search],
queryFn: ({ pageParam }) =>
getPageAttachments(pageId, { cursor: pageParam, query: search }),
enabled: !!pageId,
gcTime: 0,
placeholderData: keepPreviousData,
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
});
}
@@ -3,7 +3,17 @@ import loadImage from "blueimp-load-image";
import { import {
AvatarIconType, AvatarIconType,
IAttachment, IAttachment,
IPageAttachment,
} from "@/features/attachments/types/attachment.types.ts"; } from "@/features/attachments/types/attachment.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
export async function getPageAttachments(
pageId: string,
params?: QueryParams,
): Promise<IPagination<IPageAttachment>> {
const req = await api.post("/pages/attachments", { pageId, ...params });
return req.data;
}
async function compressAndResizeIcon( async function compressAndResizeIcon(
file: File, file: File,
@@ -1,4 +1,5 @@
export { export {
getPageAttachments,
uploadIcon, uploadIcon,
uploadUserAvatar, uploadUserAvatar,
uploadSpaceIcon, uploadSpaceIcon,
@@ -15,6 +15,15 @@ export interface IAttachment {
deletedAt: string | null; deletedAt: string | null;
} }
export interface IPageAttachment extends IAttachment {
url: string;
creator: {
id: string;
name: string;
avatarUrl: string | null;
} | null;
}
export enum AvatarIconType { export enum AvatarIconType {
AVATAR = "avatar", AVATAR = "avatar",
SPACE_ICON = "space-icon", SPACE_ICON = "space-icon",
@@ -12,6 +12,7 @@ import {
IconMathFunction, IconMathFunction,
IconRotate2, IconRotate2,
IconSitemap, IconSitemap,
IconSuperscript,
IconTable, IconTable,
IconTag, IconTag,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@@ -270,6 +271,12 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
> >
{t("Math block")} {t("Math block")}
</Menu.Item> </Menu.Item>
<Menu.Item
leftSection={<IconSuperscript size={16} />}
onClick={() => editor.chain().focus().addFootnote().run()}
>
{t("Footnote")}
</Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
); );
@@ -30,6 +30,7 @@ import {
IconTag, IconTag,
IconMoodSmile, IconMoodSmile,
IconRotate2, IconRotate2,
IconSuperscript,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { import {
CommandProps, CommandProps,
@@ -177,6 +178,16 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setPageBreak().run(), editor.chain().focus().deleteRange(range).setPageBreak().run(),
}, },
{
title: "Footnote",
description: "Insert a footnote reference.",
searchTerms: ["footnote", "reference", "citation", "note"],
icon: IconSuperscript,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).run();
editor.commands.addFootnote();
},
},
{ {
title: "Image", title: "Image",
description: "Upload any image from your device.", description: "Upload any image from your device.",
@@ -1,5 +1,6 @@
import { markInputRule } from "@tiptap/core"; import { markInputRule } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit"; import { StarterKit } from "@tiptap/starter-kit";
import { Document } from "@tiptap/extension-document";
import { Code } from "@tiptap/extension-code"; import { Code } from "@tiptap/extension-code";
import { TextAlign } from "@tiptap/extension-text-align"; import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list"; import { TaskList, TaskItem } from "@tiptap/extension-list";
@@ -63,6 +64,9 @@ import {
TransclusionReference, TransclusionReference,
TableView, TableView,
BaseEmbed as BaseEmbedNode, BaseEmbed as BaseEmbedNode,
Footnotes,
Footnote,
FootnoteReference,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@@ -132,6 +136,7 @@ lowlight.register("scala", scala);
// @ts-ignore // @ts-ignore
export const mainExtensions = [ export const mainExtensions = [
StarterKit.configure({ StarterKit.configure({
document: false,
heading: false, heading: false,
undoRedo: false, undoRedo: false,
link: false, link: false,
@@ -143,6 +148,9 @@ export const mainExtensions = [
codeBlock: false, codeBlock: false,
code: false, code: false,
}), }),
Document.extend({
content: "block+ footnotes?",
}),
// Override TipTap's Code extension to fix the inline code input rule. // Override TipTap's Code extension to fix the inline code input rule.
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character // The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
// before the opening backtick as part of the match, causing markInputRule // before the opening backtick as part of the match, causing markInputRule
@@ -203,7 +211,8 @@ export const mainExtensions = [
parentName === "tableCell" || parentName === "tableCell" ||
parentName === "tableHeader" || parentName === "tableHeader" ||
parentName === "callout" || parentName === "callout" ||
parentName === "blockquote" parentName === "blockquote" ||
parentName === "footnote"
) { ) {
return i18n.t("Write..."); return i18n.t("Write...");
} }
@@ -417,6 +426,9 @@ export const mainExtensions = [
}).configure(), }).configure(),
Columns, Columns,
Column, Column,
Footnotes,
Footnote,
FootnoteReference,
AutoJoiner.configure({ AutoJoiner.configure({
elementsToJoin: [], elementsToJoin: [],
}), }),
@@ -0,0 +1,26 @@
.ProseMirror sup a.footnote-ref {
color: var(--mantine-primary-color-filled);
text-decoration: none;
cursor: pointer;
font-weight: 600;
}
.ProseMirror sup:has(a.footnote-ref) {
padding: 0 1px;
}
.ProseMirror ol.footnotes {
margin-top: 2rem;
padding-top: 0.75rem;
font-size: 0.875rem;
color: var(--mantine-color-dimmed);
list-style-type: decimal;
}
.ProseMirror ol.footnotes:has(li) {
border-top: 1px solid var(--mantine-color-default-border);
}
.ProseMirror ol.footnotes li p {
margin: 0.15rem 0;
}
@@ -18,3 +18,4 @@
@import "./columns.css"; @import "./columns.css";
@import "./status.css"; @import "./status.css";
@import "./base-embed.css"; @import "./base-embed.css";
@import "./footnotes.css";
@@ -6,4 +6,13 @@ export const activeHistoryPrevIdAtom = atom<string>("");
export const highlightChangesAtom = atom<boolean>(true); export const highlightChangesAtom = atom<boolean>(true);
export type DiffCounts = { added: number; deleted: number; total: number }; export type DiffCounts = { added: number; deleted: number; total: number };
export const diffCountsAtom = atom<DiffCounts | null>(null); export const diffCountsAtom = atom<DiffCounts | null>(
null as DiffCounts | null,
);
export type ComparePair = { newerId: string; olderId: string };
export const compareModeAtom = atom<boolean>(false);
export const compareSelectionAtom = atom<string[]>([]);
export const comparePairAtom = atom<ComparePair | null>(
null as ComparePair | null,
);
@@ -1,7 +1,7 @@
.history { .history {
display: block; display: flex;
align-items: center;
width: 100%; width: 100%;
padding: var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0)); color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
@mixin hover { @mixin hover {
@@ -12,6 +12,28 @@
} }
} }
.historyButton {
flex: 1;
min-width: 0;
color: inherit;
}
.compareCheckbox {
padding-left: var(--mantine-spacing-xs);
}
.itemMenu {
opacity: 0;
margin-right: var(--mantine-spacing-xs);
}
.history:hover .itemMenu,
.history:focus-within .itemMenu,
.history.active .itemMenu,
.itemMenu[aria-expanded="true"] {
opacity: 1;
}
.historyEditor { .historyEditor {
:global(.ProseMirror) { :global(.ProseMirror) {
padding: 0 !important; padding: 0 !important;
@@ -77,3 +99,8 @@
flex: 1; flex: 1;
padding: rem(16px) rem(40px); padding: rem(16px) rem(40px);
} }
.compareBanner {
border-bottom: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
@@ -170,7 +170,6 @@ export function HistoryEditor({
} }
const total = addedCount + deletedCount; const total = addedCount + deletedCount;
// @ts-ignore
setDiffCounts({ added: addedCount, deleted: deletedCount, total }); setDiffCounts({ added: addedCount, deleted: deletedCount, total });
editor.setOptions({ editor.setOptions({
@@ -1,10 +1,21 @@
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core"; import {
Text,
Group,
UnstyledButton,
Avatar,
Tooltip,
ActionIcon,
Checkbox,
Menu,
} from "@mantine/core";
import { IconDots } from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { formattedDate } from "@/lib/time"; import { formattedDate } from "@/lib/time";
import classes from "./css/history.module.css"; import classes from "./css/history.module.css";
import clsx from "clsx"; import clsx from "clsx";
import { IPageHistory } from "@/features/page-history/types/page.types"; import { IPageHistory } from "@/features/page-history/types/page.types";
import { memo, useCallback } from "react"; import { memo, useCallback } from "react";
import { useTranslation } from "react-i18next";
const MAX_VISIBLE_AVATARS = 5; const MAX_VISIBLE_AVATARS = 5;
@@ -15,6 +26,13 @@ interface HistoryItemProps {
onHover?: (id: string, index: number) => void; onHover?: (id: string, index: number) => void;
onHoverEnd?: () => void; onHoverEnd?: () => void;
isActive: boolean; isActive: boolean;
compareMode: boolean;
isChecked: boolean;
isCheckboxDisabled: boolean;
canCompare: boolean;
onToggleCompare: (id: string) => void;
onStartCompare: (id: string) => void;
onRestore?: (id: string, index: number) => void;
} }
const HistoryItem = memo(function HistoryItem({ const HistoryItem = memo(function HistoryItem({
@@ -24,10 +42,24 @@ const HistoryItem = memo(function HistoryItem({
onHover, onHover,
onHoverEnd, onHoverEnd,
isActive, isActive,
compareMode,
isChecked,
isCheckboxDisabled,
canCompare,
onToggleCompare,
onStartCompare,
onRestore,
}: HistoryItemProps) { }: HistoryItemProps) {
const { t } = useTranslation();
const date = formattedDate(new Date(historyItem.createdAt));
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
onSelect(historyItem.id, index); if (compareMode) {
}, [onSelect, historyItem.id, index]); onToggleCompare(historyItem.id);
} else {
onSelect(historyItem.id, index);
}
}, [compareMode, onToggleCompare, onSelect, historyItem.id, index]);
const handleMouseEnter = useCallback(() => { const handleMouseEnter = useCallback(() => {
onHover?.(historyItem.id, index); onHover?.(historyItem.id, index);
@@ -37,63 +69,115 @@ const HistoryItem = memo(function HistoryItem({
const hasContributors = contributors && contributors.length > 0; const hasContributors = contributors && contributors.length > 0;
return ( return (
<UnstyledButton <div
p="xs" className={clsx(classes.history, { [classes.active]: isActive })}
onClick={handleClick}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={onHoverEnd} onMouseLeave={onHoverEnd}
className={clsx(classes.history, { [classes.active]: isActive })}
> >
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text> {compareMode && (
<Checkbox
size="xs"
className={classes.compareCheckbox}
checked={isChecked}
disabled={isCheckboxDisabled}
onChange={() => onToggleCompare(historyItem.id)}
aria-label={t("Select version from {{date}}", { date })}
/>
)}
<Group gap={6} wrap="nowrap" mt={4}> <UnstyledButton
{hasContributors ? ( p="xs"
<> onClick={handleClick}
<Tooltip.Group openDelay={300} closeDelay={100}> className={classes.historyButton}
<Avatar.Group spacing={8}> >
{contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => ( <Text size="sm">{date}</Text>
<Tooltip key={contributor.id} label={contributor.name} withArrow>
<CustomAvatar <Group gap={6} wrap="nowrap" mt={4}>
size="sm" {hasContributors ? (
avatarUrl={contributor.avatarUrl} <>
name={contributor.name} <Tooltip.Group openDelay={300} closeDelay={100}>
/> <Avatar.Group spacing={8}>
</Tooltip> {contributors
))} .slice(0, MAX_VISIBLE_AVATARS)
{contributors.length > MAX_VISIBLE_AVATARS && ( .map((contributor) => (
<Tooltip <Tooltip
withArrow key={contributor.id}
label={contributors.slice(MAX_VISIBLE_AVATARS).map((c) => ( label={contributor.name}
<div key={c.id}>{c.name}</div> withArrow
>
<CustomAvatar
size="sm"
avatarUrl={contributor.avatarUrl}
name={contributor.name}
/>
</Tooltip>
))} ))}
> {contributors.length > MAX_VISIBLE_AVATARS && (
<Avatar size="sm" color="gray"> <Tooltip
+{contributors.length - MAX_VISIBLE_AVATARS} withArrow
</Avatar> label={contributors
</Tooltip> .slice(MAX_VISIBLE_AVATARS)
)} .map((c) => (
</Avatar.Group> <div key={c.id}>{c.name}</div>
</Tooltip.Group> ))}
{contributors.length === 1 && ( >
<Avatar size="sm" color="gray">
+{contributors.length - MAX_VISIBLE_AVATARS}
</Avatar>
</Tooltip>
)}
</Avatar.Group>
</Tooltip.Group>
{contributors.length === 1 && (
<Text size="sm" c="dimmed" lineClamp={1}>
{contributors[0].name}
</Text>
)}
</>
) : (
<>
<CustomAvatar
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
<Text size="sm" c="dimmed" lineClamp={1}> <Text size="sm" c="dimmed" lineClamp={1}>
{contributors[0].name} {historyItem.lastUpdatedBy?.name}
</Text> </Text>
</>
)}
</Group>
</UnstyledButton>
{!compareMode && (
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
className={classes.itemMenu}
aria-label={t("Version actions for {{date}}", { date })}
onClick={(e) => e.stopPropagation()}
>
<IconDots size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
disabled={!canCompare}
onClick={() => onStartCompare(historyItem.id)}
>
{t("Compare")}
</Menu.Item>
{onRestore && (
<Menu.Item onClick={() => onRestore(historyItem.id, index)}>
{t("Restore")}
</Menu.Item>
)} )}
</> </Menu.Dropdown>
) : ( </Menu>
<> )}
<CustomAvatar </div>
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
<Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy?.name}
</Text>
</>
)}
</Group>
</UnstyledButton>
); );
}); });
@@ -6,8 +6,12 @@ import HistoryItem from "@/features/page-history/components/history-item";
import { import {
activeHistoryIdAtom, activeHistoryIdAtom,
activeHistoryPrevIdAtom, activeHistoryPrevIdAtom,
compareModeAtom,
comparePairAtom,
compareSelectionAtom,
historyAtoms, historyAtoms,
} from "@/features/page-history/atoms/history-atoms"; } from "@/features/page-history/atoms/history-atoms";
import { resolveComparePair } from "@/features/page-history/utils/resolve-compare-pair";
import { useAtom, useSetAtom } from "jotai"; import { useAtom, useSetAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { import {
@@ -32,6 +36,9 @@ function HistoryList({ pageId }: Props) {
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom); const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms); const setHistoryModalOpen = useSetAtom(historyAtoms);
const [compareMode, setCompareMode] = useAtom(compareModeAtom);
const [compareSelection, setCompareSelection] = useAtom(compareSelectionAtom);
const setComparePair = useSetAtom(comparePairAtom);
const { const {
data: pageHistoryData, data: pageHistoryData,
@@ -79,10 +86,58 @@ function HistoryList({ pageId }: Props) {
const handleSelect = useCallback( const handleSelect = useCallback(
(id: string, index: number) => { (id: string, index: number) => {
setComparePair(null);
setActiveHistoryId(id); setActiveHistoryId(id);
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? ""); setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
}, },
[historyItems, setActiveHistoryId, setActiveHistoryPrevId], [historyItems, setActiveHistoryId, setActiveHistoryPrevId, setComparePair],
);
const handleToggleCompare = useCallback(
(id: string) => {
setCompareSelection((prev) => {
if (prev.includes(id)) return prev.filter((item) => item !== id);
if (prev.length >= 2) return prev;
return [...prev, id];
});
},
[setCompareSelection],
);
const handleStartCompare = useCallback(
(id: string) => {
setComparePair(null);
setCompareMode(true);
setCompareSelection([id]);
},
[setComparePair, setCompareMode, setCompareSelection],
);
const handleCancelCompare = useCallback(() => {
setCompareMode(false);
setCompareSelection([]);
}, [setCompareMode, setCompareSelection]);
const handleConfirmCompare = useCallback(() => {
const pair = resolveComparePair(historyItems, compareSelection);
if (!pair) return;
setComparePair(pair);
setCompareMode(false);
setCompareSelection([]);
}, [
historyItems,
compareSelection,
setComparePair,
setCompareMode,
setCompareSelection,
]);
const handleRestoreItem = useCallback(
(id: string, index: number) => {
handleSelect(id, index);
confirmRestore(id);
},
[handleSelect, confirmRestore],
); );
useEffect(() => { useEffect(() => {
@@ -138,6 +193,16 @@ function HistoryList({ pageId }: Props) {
onHover={handleHover} onHover={handleHover}
onHoverEnd={clearPrefetchTimeout} onHoverEnd={clearPrefetchTimeout}
isActive={historyItem.id === activeHistoryId} isActive={historyItem.id === activeHistoryId}
compareMode={compareMode}
isChecked={compareSelection.includes(historyItem.id)}
isCheckboxDisabled={
!compareSelection.includes(historyItem.id) &&
compareSelection.length >= 2
}
canCompare={historyItems.length >= 2}
onToggleCompare={handleToggleCompare}
onStartCompare={handleStartCompare}
onRestore={canRestore ? handleRestoreItem : undefined}
/> />
))} ))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />} {hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
@@ -148,22 +213,44 @@ function HistoryList({ pageId }: Props) {
)} )}
</ScrollArea> </ScrollArea>
{canRestore && ( {compareMode ? (
<> <>
<Divider /> <Divider />
<Group p="xs" wrap="nowrap"> <Group p="xs" wrap="nowrap">
<Button <Button
variant="default" variant="default"
size="compact-md" size="compact-md"
onClick={() => setHistoryModalOpen(false)} onClick={handleCancelCompare}
> >
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button size="compact-md" onClick={confirmRestore}> <Button
{t("Restore")} size="compact-md"
disabled={compareSelection.length !== 2}
onClick={handleConfirmCompare}
>
{t("Compare")}
</Button> </Button>
</Group> </Group>
</> </>
) : (
canRestore && (
<>
<Divider />
<Group p="xs" wrap="nowrap">
<Button
variant="default"
size="compact-md"
onClick={() => setHistoryModalOpen(false)}
>
{t("Cancel")}
</Button>
<Button size="compact-md" onClick={() => confirmRestore()}>
{t("Restore")}
</Button>
</Group>
</>
)
)} )}
</div> </div>
); );
@@ -1,5 +1,6 @@
import { import {
ActionIcon, ActionIcon,
CloseButton,
Group, Group,
Paper, Paper,
ScrollArea, ScrollArea,
@@ -12,17 +13,20 @@ import { useAtom, useAtomValue } from "jotai";
import { import {
activeHistoryIdAtom, activeHistoryIdAtom,
activeHistoryPrevIdAtom, activeHistoryPrevIdAtom,
comparePairAtom,
diffCountsAtom, diffCountsAtom,
highlightChangesAtom, highlightChangesAtom,
} from "@/features/page-history/atoms/history-atoms"; } from "@/features/page-history/atoms/history-atoms";
import HistoryView from "@/features/page-history/components/history-view"; import HistoryView from "@/features/page-history/components/history-view";
import { useRef } from "react"; import { useMemo, useRef } from "react";
import { IconChevronUp, IconChevronDown } from "@tabler/icons-react"; import { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
useDiffNavigation, useDiffNavigation,
useHistoryReset, useHistoryReset,
} from "@/features/page-history/hooks"; } from "@/features/page-history/hooks";
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query";
import { formattedDate } from "@/lib/time";
interface Props { interface Props {
pageId: string; pageId: string;
@@ -36,6 +40,28 @@ export default function HistoryModalBody({ pageId }: Props) {
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom); const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom); const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
const diffCounts = useAtomValue(diffCountsAtom); const diffCounts = useAtomValue(diffCountsAtom);
const [comparePair, setComparePair] = useAtom(comparePairAtom);
const { data: pageHistoryData } = usePageHistoryListQuery(pageId);
const historyItems = useMemo(
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
[pageHistoryData],
);
const compareLabel = useMemo(() => {
if (!comparePair) return null;
const newerItem = historyItems.find(
(item) => item.id === comparePair.newerId,
);
const olderItem = historyItems.find(
(item) => item.id === comparePair.olderId,
);
if (!newerItem || !olderItem) return null;
return t("Comparing {{newer}} and {{older}}", {
newer: formattedDate(new Date(newerItem.createdAt)),
older: formattedDate(new Date(olderItem.createdAt)),
});
}, [comparePair, historyItems, t]);
useHistoryReset(pageId); useHistoryReset(pageId);
const { currentChangeIndex, handlePrevChange, handleNextChange } = const { currentChangeIndex, handlePrevChange, handleNextChange } =
@@ -50,6 +76,25 @@ export default function HistoryModalBody({ pageId }: Props) {
</nav> </nav>
<div style={{ position: "relative", flex: 1 }}> <div style={{ position: "relative", flex: 1 }}>
{comparePair && (
<Group
justify="space-between"
wrap="nowrap"
px="md"
py={4}
className={classes.compareBanner}
>
<Text size="sm" fw={500} lineClamp={1}>
{compareLabel ?? t("Compare versions")}
</Text>
<CloseButton
size="sm"
aria-label={t("Exit compare")}
onClick={() => setComparePair(null)}
/>
</Group>
)}
<ScrollArea <ScrollArea
h={650} h={650}
w="100%" w="100%"
@@ -57,11 +102,18 @@ export default function HistoryModalBody({ pageId }: Props) {
viewportRef={scrollViewportRef} viewportRef={scrollViewportRef}
> >
<div className={classes.sidebarRightSection}> <div className={classes.sidebarRightSection}>
{activeHistoryId && <HistoryView />} {comparePair ? (
<HistoryView
historyId={comparePair.newerId}
prevHistoryId={comparePair.olderId}
/>
) : (
activeHistoryId && <HistoryView />
)}
</div> </div>
</ScrollArea> </ScrollArea>
{activeHistoryId && activeHistoryPrevId && ( {(comparePair || (activeHistoryId && activeHistoryPrevId)) && (
<Paper <Paper
shadow="md" shadow="md"
radius="xl" radius="xl"
@@ -166,7 +166,7 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
<Button variant="default" onClick={() => setHistoryModalOpen(false)}> <Button variant="default" onClick={() => setHistoryModalOpen(false)}>
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button onClick={confirmRestore}>{t("Restore")}</Button> <Button onClick={() => confirmRestore()}>{t("Restore")}</Button>
</Group> </Group>
)} )}
@@ -7,21 +7,29 @@ import {
activeHistoryPrevIdAtom, activeHistoryPrevIdAtom,
} from "@/features/page-history/atoms/history-atoms"; } from "@/features/page-history/atoms/history-atoms";
function HistoryView() { interface Props {
historyId?: string;
prevHistoryId?: string;
}
function HistoryView({ historyId, prevHistoryId }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const historyId = useAtomValue(activeHistoryIdAtom); const activeId = useAtomValue(activeHistoryIdAtom);
const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom); const activePrevId = useAtomValue(activeHistoryPrevIdAtom);
const resolvedId = historyId ?? activeId;
const resolvedPrevId = prevHistoryId ?? activePrevId;
const { const {
data, data,
isLoading: isLoadingCurrent, isLoading: isLoadingCurrent,
isError: isErrorCurrent, isError: isErrorCurrent,
} = usePageHistoryQuery(historyId); } = usePageHistoryQuery(resolvedId);
const { const {
data: prevData, data: prevData,
isLoading: isLoadingPrev, isLoading: isLoadingPrev,
isError: isErrorPrev, isError: isErrorPrev,
} = usePageHistoryQuery(prevHistoryId); } = usePageHistoryQuery(resolvedPrevId);
if (isLoadingCurrent || isLoadingPrev) { if (isLoadingCurrent || isLoadingPrev) {
return <></>; return <></>;
@@ -3,22 +3,45 @@ import { useEffect } from "react";
import { import {
activeHistoryIdAtom, activeHistoryIdAtom,
activeHistoryPrevIdAtom, activeHistoryPrevIdAtom,
compareModeAtom,
comparePairAtom,
compareSelectionAtom,
diffCountsAtom, diffCountsAtom,
} from "@/features/page-history/atoms/history-atoms"; } from "@/features/page-history/atoms/history-atoms";
/** /**
* Resets history state when pageId changes. * Resets history state when pageId changes.
* Clears active selection and diff counts. * Clears active selection, diff counts, and compare state.
* Compare state also resets on unmount so reopening the modal starts clean.
*/ */
export function useHistoryReset(pageId: string) { export function useHistoryReset(pageId: string) {
const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom); const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom); const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
const [, setDiffCounts] = useAtom(diffCountsAtom); const [, setDiffCounts] = useAtom(diffCountsAtom);
const [, setCompareMode] = useAtom(compareModeAtom);
const [, setCompareSelection] = useAtom(compareSelectionAtom);
const [, setComparePair] = useAtom(comparePairAtom);
useEffect(() => { useEffect(() => {
const resetCompare = () => {
setCompareMode(false);
setCompareSelection([]);
setComparePair(null);
};
setActiveHistoryId(""); setActiveHistoryId("");
setActiveHistoryPrevId(""); setActiveHistoryPrevId("");
// @ts-ignore
setDiffCounts(null); setDiffCounts(null);
}, [pageId, setActiveHistoryId, setActiveHistoryPrevId, setDiffCounts]); resetCompare();
return resetCompare;
}, [
pageId,
setActiveHistoryId,
setActiveHistoryPrevId,
setDiffCounts,
setCompareMode,
setCompareSelection,
setComparePair,
]);
} }
@@ -1,4 +1,4 @@
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { useCallback } from "react"; import { useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Text } from "@mantine/core"; import { Text } from "@mantine/core";
@@ -9,7 +9,8 @@ import {
activeHistoryIdAtom, activeHistoryIdAtom,
historyAtoms, historyAtoms,
} from "@/features/page-history/atoms/history-atoms"; } from "@/features/page-history/atoms/history-atoms";
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query"; import { fetchPageHistory } from "@/features/page-history/queries/page-history-query";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { import {
pageEditorAtom, pageEditorAtom,
titleEditorAtom, titleEditorAtom,
@@ -25,8 +26,6 @@ export function useHistoryRestore() {
const { t } = useTranslation(); const { t } = useTranslation();
const activeHistoryId = useAtomValue(activeHistoryIdAtom); const activeHistoryId = useAtomValue(activeHistoryIdAtom);
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
const mainEditor = useAtomValue(pageEditorAtom); const mainEditor = useAtomValue(pageEditorAtom);
const mainEditorTitle = useAtomValue(titleEditorAtom); const mainEditorTitle = useAtomValue(titleEditorAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms); const setHistoryModalOpen = useSetAtom(historyAtoms);
@@ -40,47 +39,66 @@ export function useHistoryRestore() {
SpaceCaslSubject.Page, SpaceCaslSubject.Page,
); );
const handleRestore = useCallback(() => { const handleRestore = useCallback(
if (!activeHistoryData) return; async (historyId: string) => {
if ( let historyData: IPageHistory;
!mainEditor || try {
mainEditor.isDestroyed || historyData = await fetchPageHistory(historyId);
!mainEditorTitle || } catch {
mainEditorTitle.isDestroyed notifications.show({
) { message: t("Error fetching page data."),
return; color: "red",
} });
return;
}
mainEditorTitle if (
.chain() !mainEditor ||
.clearContent() mainEditor.isDestroyed ||
.setContent(activeHistoryData.title, { emitUpdate: true }) !mainEditorTitle ||
.run(); mainEditorTitle.isDestroyed
) {
return;
}
mainEditor mainEditorTitle
.chain() .chain()
.clearContent() .clearContent()
.setContent(activeHistoryData.content) .setContent(historyData.title, { emitUpdate: true })
.run(); .run();
setHistoryModalOpen(false); mainEditor
notifications.show({ message: t("Successfully restored") }); .chain()
}, [activeHistoryData, mainEditor, mainEditorTitle, setHistoryModalOpen, t]); .clearContent()
.setContent(historyData.content)
.run();
const confirmRestore = useCallback(() => { setHistoryModalOpen(false);
modals.openConfirmModal({ notifications.show({ message: t("Successfully restored") });
title: t("Please confirm your action"), },
children: ( [mainEditor, mainEditorTitle, setHistoryModalOpen, t],
<Text size="sm"> );
{t(
"Are you sure you want to restore this version? Any changes not versioned will be lost.", const confirmRestore = useCallback(
)} (historyId?: string) => {
</Text> const targetId = historyId ?? activeHistoryId;
), if (!targetId) return;
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: handleRestore, modals.openConfirmModal({
}); title: t("Please confirm your action"),
}, [t, handleRestore]); children: (
<Text size="sm">
{t(
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
)}
</Text>
),
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: () => handleRestore(targetId),
});
},
[t, handleRestore, activeHistoryId],
);
return { canRestore, confirmRestore }; return { canRestore, confirmRestore };
} }
@@ -23,6 +23,14 @@ export function prefetchPageHistory(historyId: string) {
}); });
} }
export function fetchPageHistory(historyId: string): Promise<IPageHistory> {
return queryClient.fetchQuery({
queryKey: ["page-history", historyId],
queryFn: () => getPageHistoryById(historyId),
staleTime: HISTORY_STALE_TIME,
});
}
export function usePageHistoryListQuery( export function usePageHistoryListQuery(
pageId: string, pageId: string,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> { ): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> {
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { resolveComparePair } from "./resolve-compare-pair";
// list is newest-first, matching usePageHistoryListQuery order
const items = [{ id: "v3" }, { id: "v2" }, { id: "v1" }];
describe("resolveComparePair", () => {
it("orders newer before older regardless of selection order", () => {
expect(resolveComparePair(items, ["v1", "v3"])).toEqual({
newerId: "v3",
olderId: "v1",
});
expect(resolveComparePair(items, ["v3", "v1"])).toEqual({
newerId: "v3",
olderId: "v1",
});
});
it("returns null unless exactly two versions are selected", () => {
expect(resolveComparePair(items, [])).toBeNull();
expect(resolveComparePair(items, ["v1"])).toBeNull();
expect(resolveComparePair(items, ["v1", "v2", "v3"])).toBeNull();
});
it("returns null when a selected id is not in the list", () => {
expect(resolveComparePair(items, ["v1", "missing"])).toBeNull();
});
it("returns null when the same id is selected twice", () => {
expect(resolveComparePair(items, ["v2", "v2"])).toBeNull();
});
});
@@ -0,0 +1,18 @@
import { ComparePair } from "@/features/page-history/atoms/history-atoms";
/**
* Resolves which of the two selected versions is newer using their position
* in the history list (list is newest-first: lower index = newer).
*/
export function resolveComparePair(
historyItems: { id: string }[],
selection: string[],
): ComparePair | null {
if (selection.length !== 2) return null;
const indexA = historyItems.findIndex((item) => item.id === selection[0]);
const indexB = historyItems.findIndex((item) => item.id === selection[1]);
if (indexA === -1 || indexB === -1 || indexA === indexB) return null;
return indexA < indexB
? { newerId: selection[0], olderId: selection[1] }
: { newerId: selection[1], olderId: selection[0] };
}
@@ -11,6 +11,7 @@ import {
IconList, IconList,
IconMarkdown, IconMarkdown,
IconMessage, IconMessage,
IconPaperclip,
IconPrinter, IconPrinter,
IconStar, IconStar,
IconStarFilled, IconStarFilled,
@@ -42,6 +43,7 @@ import {
import { formattedDate } from "@/lib/time.ts"; import { formattedDate } from "@/lib/time.ts";
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx"; import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import PageAttachmentsModal from "@/features/attachments/components/page-attachments-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { PageShareModal } from "@/ee/page-permission"; import { PageShareModal } from "@/ee/page-permission";
import { import {
@@ -157,6 +159,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
verificationOpened, verificationOpened,
{ open: openVerificationModal, close: closeVerificationModal }, { open: openVerificationModal, close: closeVerificationModal },
] = useDisclosure(false); ] = useDisclosure(false);
const [
attachmentsOpened,
{ open: openAttachmentsModal, close: closeAttachmentsModal },
] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom); const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page?.updatedAt); const pageUpdatedAt = useTimeAgo(page?.updatedAt);
const favoriteIds = useFavoriteIds("page", page?.spaceId); const favoriteIds = useFavoriteIds("page", page?.spaceId);
@@ -293,6 +299,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
</Menu.Item> </Menu.Item>
)} )}
{!page?.isBase && (
<Menu.Item
leftSection={<IconPaperclip size={16} />}
onClick={openAttachmentsModal}
>
{t("Attachments")}
</Menu.Item>
)}
{!readOnly && !page?.isBase && ( {!readOnly && !page?.isBase && (
<PageVerificationMenuItem <PageVerificationMenuItem
pageId={page?.id} pageId={page?.id}
@@ -395,6 +410,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
opened={verificationOpened} opened={verificationOpened}
onClose={closeVerificationModal} onClose={closeVerificationModal}
/> />
<PageAttachmentsModal
pageId={page.id}
open={attachmentsOpened}
onClose={closeAttachmentsModal}
/>
</> </>
); );
} }
@@ -1,4 +1,5 @@
import { StarterKit } from '@tiptap/starter-kit'; import { StarterKit } from '@tiptap/starter-kit';
import { Document } from '@tiptap/extension-document';
import { TextAlign } from '@tiptap/extension-text-align'; import { TextAlign } from '@tiptap/extension-text-align';
import { Superscript } from '@tiptap/extension-superscript'; import { Superscript } from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript'; import SubScript from '@tiptap/extension-subscript';
@@ -45,6 +46,9 @@ import {
TransclusionSource, TransclusionSource,
TransclusionReference, TransclusionReference,
BaseEmbed, BaseEmbed,
Footnotes,
Footnote,
FootnoteReference,
} from '@docmost/editor-ext'; } from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
@@ -58,11 +62,15 @@ import { Logger } from '@nestjs/common';
export const tiptapExtensions = [ export const tiptapExtensions = [
StarterKit.configure({ StarterKit.configure({
document: false,
codeBlock: false, codeBlock: false,
link: false, link: false,
trailingNode: false, trailingNode: false,
heading: false, heading: false,
}), }),
Document.extend({
content: 'block+ footnotes?',
}),
Heading, Heading,
UniqueID.configure({ UniqueID.configure({
types: ['heading', 'paragraph', 'transclusionSource'], types: ['heading', 'paragraph', 'transclusionSource'],
@@ -110,7 +118,10 @@ export const tiptapExtensions = [
Status, Status,
TransclusionSource, TransclusionSource,
TransclusionReference, TransclusionReference,
BaseEmbed BaseEmbed,
Footnotes,
Footnote,
FootnoteReference,
] as any; ] as any;
export function jsonToHtml(tiptapJson: any) { export function jsonToHtml(tiptapJson: any) {
@@ -53,8 +53,14 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
import { TokenService } from '../auth/services/token.service'; import { TokenService } from '../auth/services/token.service';
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload'; import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
import * as path from 'path'; import * as path from 'path';
import { AttachmentInfoDto, RemoveIconDto } from './dto/attachment.dto'; import {
AttachmentInfoDto,
PageIdDto,
RemoveIconDto,
} from './dto/attachment.dto';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PageAccessService } from '../page/page-access/page-access.service'; import { PageAccessService } from '../page/page-access/page-access.service';
import { DomainService } from '../../integrations/environment/domain.service';
import { AuditEvent, AuditResource } from '../../common/events/audit-events'; import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import { import {
AUDIT_SERVICE, AUDIT_SERVICE,
@@ -75,6 +81,7 @@ export class AttachmentController {
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
private readonly tokenService: TokenService, private readonly tokenService: TokenService,
private readonly pageAccessService: PageAccessService, private readonly pageAccessService: PageAccessService,
private readonly domainService: DomainService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {} ) {}
@@ -151,7 +158,10 @@ export class AttachmentController {
}, },
}); });
return res.send(fileResponse); return res.send({
...fileResponse,
url: this.buildFileUrl(workspace, fileResponse),
});
} catch (err: any) { } catch (err: any) {
if (err?.statusCode === 413) { if (err?.statusCode === 413) {
const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`; const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`;
@@ -411,7 +421,37 @@ export class AttachmentController {
await this.pageAccessService.validateCanView(page, user); await this.pageAccessService.validateCanView(page, user);
return attachment; return { ...attachment, url: this.buildFileUrl(workspace, attachment) };
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('pages/attachments')
async getPageAttachments(
@Body() dto: PageIdDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page || page.workspaceId !== workspace.id) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
const result = await this.attachmentRepo.findPageAttachments(
page.id,
pagination,
);
return {
...result,
items: result.items.map((attachment) => ({
...attachment,
url: this.buildFileUrl(workspace, attachment),
})),
};
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -465,6 +505,10 @@ export class AttachmentController {
} }
} }
private buildFileUrl(workspace: Workspace, attachment: Attachment): string {
return `${this.domainService.getUrl(workspace.hostname)}/api/files/${attachment.id}/${encodeURIComponent(attachment.fileName)}`;
}
private async sendFileResponse( private async sendFileResponse(
req: FastifyRequest, req: FastifyRequest,
res: FastifyReply, res: FastifyReply,
@@ -1,4 +1,11 @@
import { IsEnum, IsIn, IsNotEmpty, IsOptional, IsUUID } from 'class-validator'; import {
IsEnum,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { AttachmentType } from '../attachment.constants'; import { AttachmentType } from '../attachment.constants';
export class AttachmentInfoDto { export class AttachmentInfoDto {
@@ -7,6 +14,12 @@ export class AttachmentInfoDto {
attachmentId: string; attachmentId: string;
} }
export class PageIdDto {
@IsString()
@IsNotEmpty()
pageId: string;
}
export class RemoveIconDto { export class RemoveIconDto {
@IsEnum(AttachmentType) @IsEnum(AttachmentType)
@IsIn([ @IsIn([
@@ -1,5 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { ExpressionBuilder, sql } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { DB } from '@docmost/db/types/db';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils'; import { dbOrTx } from '@docmost/db/utils';
import { import {
@@ -8,6 +11,8 @@ import {
UpdatableAttachment, UpdatableAttachment,
} from '@docmost/db/types/entity.types'; } from '@docmost/db/types/entity.types';
import { AttachmentType } from '../../../core/attachment/attachment.constants'; import { AttachmentType } from '../../../core/attachment/attachment.constants';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
@Injectable() @Injectable()
export class AttachmentRepo { export class AttachmentRepo {
@@ -89,6 +94,41 @@ export class AttachmentRepo {
.execute(); .execute();
} }
async findPageAttachments(pageId: string, pagination: PaginationOptions) {
let query = this.db
.selectFrom('attachments')
.select(this.baseFields)
.select((eb) => this.withCreator(eb))
.where('pageId', '=', pageId)
.where('type', '=', AttachmentType.File)
.where('deletedAt', 'is', null);
if (pagination.query) {
query = query.where(
sql`f_unaccent(file_name)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
);
}
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'desc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
}
withCreator(eb: ExpressionBuilder<DB, 'attachments'>) {
return jsonObjectFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef('users.id', '=', 'attachments.creatorId'),
).as('creator');
}
async findByIds( async findByIds(
ids: string[], ids: string[],
opts?: { opts?: {
+1
View File
@@ -32,6 +32,7 @@ export * from "./lib/columns";
export * from "./lib/status"; export * from "./lib/status";
export * from "./lib/pdf"; export * from "./lib/pdf";
export * from "./lib/page-break"; export * from "./lib/page-break";
export * from "./lib/footnotes";
export * from "./lib/resizable-nodeview"; export * from "./lib/resizable-nodeview";
export { export {
pageNodeToDocxBuffer, pageNodeToDocxBuffer,
@@ -0,0 +1,189 @@
//Source MIT - https://github.com/buttondown/tiptap-footnotes
import { mergeAttributes } from "@tiptap/core";
import ListItem, { ListItemOptions } from "@tiptap/extension-list-item";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
footnote: {
/**
* scrolls to & sets the text selection at the end of the footnote with the given id
* @param id the id of the footote (i.e. the `data-id` attribute value of the footnote)
* @example editor.commands.focusFootnote("a43956c1-1ab8-462f-96e4-be3a4b27fd50")
*/
focusFootnote: (id: string) => ReturnType;
};
}
}
export interface FootnoteOptions extends ListItemOptions {
/**
* Content expression for this node
* @default "paragraph+"
*/
content: string;
}
const Footnote = ListItem.extend<FootnoteOptions>({
name: "footnote",
content() {
return this.options.content;
},
isolating: true,
defining: true,
draggable: false,
addOptions() {
return {
HTMLAttributes: {},
bulletListTypeName: 'bulletList',
orderedListTypeName: 'orderedList',
...this.parent?.(),
content: "paragraph+",
};
},
addAttributes() {
return {
id: {
isRequired: true,
},
// the data-id field should match the data-id field of a footnote reference.
// it's used to link footnotes and references together.
"data-id": {
isRequired: true,
},
};
},
parseHTML() {
return [
{
tag: "li",
getAttrs(node) {
const id = node.getAttribute("data-id");
if (id) {
return {
"data-id": node.getAttribute("data-id"),
};
}
return false;
},
priority: 1000,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"li",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
addCommands() {
return {
focusFootnote:
(id: string) =>
({ editor, chain }) => {
const matchedFootnote = editor.$node("footnote", {
"data-id": id,
});
if (matchedFootnote) {
// sets the text selection to the end of the footnote definition and scroll to it.
chain()
.focus()
.setTextSelection(
matchedFootnote.from + matchedFootnote.content.size
)
.run();
matchedFootnote.element.scrollIntoView();
return true;
}
return false;
},
};
},
addKeyboardShortcuts() {
return {
// when inside a footnote, Mod-a should select only the footnote content
"Mod-a": ({ editor }) => {
try {
const { selection } = editor.state;
const { $from } = selection;
for (let depth = $from.depth; depth >= 0; depth--) {
const node = $from.node(depth);
if (node.type.name === "footnote") {
const start = $from.start(depth);
const end = $from.end(depth);
editor.commands.setTextSelection({
from: start + 1,
to: end - 1,
});
return true;
}
}
return false;
} catch (e) {
return false;
}
},
// when the user presses tab, adjust the text selection to be at the end of the next footnote
Tab: ({ editor }) => {
try {
const { selection } = editor.state;
const pos = editor.$pos(selection.anchor);
if (!pos.after) return false;
// if the next node is "footnotes", place the text selection at the end of the first footnote
if (pos.after.node.type.name == "footnotes") {
const firstChild = pos.after.node.child(0);
editor
.chain()
.setTextSelection(pos.after.from + firstChild.content.size)
.scrollIntoView()
.run();
return true;
} else {
const startPos = selection.$from.start(2);
if (Number.isNaN(startPos)) return false;
const parent = editor.$pos(startPos);
if (parent.node.type.name != "footnote" || !parent.after) {
return false;
}
// if the next node is a footnote, place the text selection at the end of it
editor
.chain()
.setTextSelection(parent.after.to - 1)
.scrollIntoView()
.run();
return true;
}
} catch {
return false;
}
},
// inverse of the tab command - place the text selection at the end of the previous footnote
"Shift-Tab": ({ editor }) => {
const { selection } = editor.state;
const startPos = selection.$from.start(2);
if (Number.isNaN(startPos)) return false;
const parent = editor.$pos(startPos);
if (parent.node.type.name != "footnote" || !parent.before) {
return false;
}
editor
.chain()
.setTextSelection(parent.before.to - 1)
.scrollIntoView()
.run();
return true;
},
};
},
});
export default Footnote;
@@ -0,0 +1,46 @@
//Source MIT - https://github.com/buttondown/tiptap-footnotes
import OrderedList from "@tiptap/extension-ordered-list";
import FootnoteRules from "./rules";
const Footnotes = OrderedList.extend({
name: "footnotes",
group: "", // removed the default group of the ordered list extension
isolating: true,
defining: true,
draggable: false,
content() {
return "footnote*";
},
addAttributes() {
return {
class: {
default: "footnotes",
},
};
},
parseHTML() {
return [
{
tag: "ol.footnotes",
priority: 1000,
},
];
},
addKeyboardShortcuts() {
return {};
},
addCommands() {
return {};
},
addInputRules() {
return [];
},
addExtensions() {
return [FootnoteRules];
},
});
export default Footnotes;
@@ -0,0 +1,4 @@
export { default as Footnotes } from "./footnotes";
export { default as Footnote } from "./footnote";
export type { FootnoteOptions } from "./footnote";
export { default as FootnoteReference } from "./reference";
@@ -0,0 +1,221 @@
//Source MIT - https://github.com/buttondown/tiptap-footnotes
import { mergeAttributes, Node } from "@tiptap/core";
import {
Fragment as PMFragment,
Node as PMNode,
Slice,
} from "@tiptap/pm/model";
import { NodeSelection, Plugin, PluginKey } from "@tiptap/pm/state";
import { generateNodeId } from "../utils";
const REFNUM_ATTR = "data-reference-number";
const REF_CLASS = "footnote-ref";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
footnoteReference: {
/**
* add a new footnote reference
* @example editor.commands.addFootnote()
*/
addFootnote: () => ReturnType;
};
}
}
const FootnoteReference = Node.create({
name: "footnoteReference",
inline: true,
content: "text*",
group: "inline",
atom: true,
draggable: true,
parseHTML() {
return [
{
tag: `sup`,
priority: 1000,
getAttrs(node) {
const anchor = node.querySelector<HTMLAnchorElement>(
`a.${REF_CLASS}:first-child`
);
if (!anchor) {
return false;
}
const id = anchor.getAttribute("data-id");
const ref = anchor.getAttribute(REFNUM_ATTR);
return {
"data-id": id ?? generateNodeId(),
referenceNumber: ref ?? anchor.innerText,
};
},
contentElement(node) {
return node.firstChild as HTMLElement;
},
},
];
},
addAttributes() {
return {
class: {
default: REF_CLASS,
},
"data-id": {
renderHTML(attributes) {
return {
"data-id": attributes["data-id"] || generateNodeId(),
};
},
},
referenceNumber: {},
href: {
renderHTML(attributes) {
return {
href: `#fn:${attributes["referenceNumber"]}`,
};
},
},
};
},
renderHTML({ HTMLAttributes }) {
const { referenceNumber, ...attributes } = HTMLAttributes;
const attrs = mergeAttributes(this.options.HTMLAttributes, attributes);
attrs[REFNUM_ATTR] = referenceNumber;
return [
"sup",
{ id: `fnref:${referenceNumber}` },
["a", attrs, HTMLAttributes.referenceNumber],
];
},
addProseMirrorPlugins() {
const { editor } = this;
// Ensures pasted footnote references get unique IDs.
const mapNode = (node: PMNode): PMNode => {
if (node.type.name === this.name) {
const newAttrs = { ...node.attrs, "data-id": generateNodeId() };
return node.type.create(newAttrs, node.content, node.marks);
}
if (node.content && node.content.size > 0) {
const newChildren: PMNode[] = [];
let changed = false;
node.content.forEach((child) => {
const mapped = mapNode(child);
if (mapped !== child) {
changed = true;
}
newChildren.push(mapped);
});
if (changed) {
return node.copy(PMFragment.from(newChildren));
}
}
return node;
};
return [
new Plugin({
key: new PluginKey("footnotePasteHandler"),
props: {
transformPasted(slice) {
const mappedNodes: PMNode[] = [];
let changed = false;
slice.content.forEach((node) => {
const mapped = mapNode(node);
if (mapped !== node) {
changed = true;
}
mappedNodes.push(mapped);
});
if (!changed) {
return slice;
}
return new Slice(
PMFragment.from(mappedNodes),
slice.openStart,
slice.openEnd
);
},
},
}),
new Plugin({
key: new PluginKey("footnoteRefClick"),
props: {
// on double-click, focus on the footnote
handleDoubleClickOn(view, pos, node, nodePos, event) {
if (node.type.name != "footnoteReference") return false;
event.preventDefault();
const id = node.attrs["data-id"];
return editor.commands.focusFootnote(id);
},
// click the footnote reference once to get focus, click twice to scroll to the footnote
handleClickOn(view, pos, node, nodePos, event) {
if (node.type.name != "footnoteReference") return false;
event.preventDefault();
const { selection } = editor.state.tr;
if (selection instanceof NodeSelection && selection.node.eq(node)) {
const id = node.attrs["data-id"];
return editor.commands.focusFootnote(id);
} else {
editor.chain().setNodeSelection(nodePos).run();
return true;
}
},
},
}),
];
},
addCommands() {
return {
addFootnote:
() =>
({ state, tr }) => {
const node = this.type.create({
"data-id": generateNodeId(),
});
tr.insert(state.selection.anchor, node);
return true;
},
};
},
addInputRules() {
// when a user types [^text], add a new footnote
return [
{
find: /\[\^(.*?)\]/,
type: this.type,
undoable: true,
handler({ range, match, chain }) {
const start = range.from;
let end = range.to;
if (match[1]) {
chain().deleteRange({ from: start, to: end }).addFootnote().run();
}
},
},
];
},
});
export default FootnoteReference;
@@ -0,0 +1,90 @@
//Source MIT - https://github.com/buttondown/tiptap-footnotes
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { ReplaceStep } from "@tiptap/pm/transform";
import { Extension } from "@tiptap/core";
import { updateFootnotesList } from "./utils";
const FootnoteRules = Extension.create({
name: "footnoteRules",
priority: 1000,
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("footnoteRules"),
filterTransaction(tr) {
const { from, to } = tr.selection;
// Allow full document selections (Mod-a/Ctrl-a)
if (from === 0 && to === tr.doc.content.size) return true;
let selectedFootnotes = false;
let selectedContent = false;
let footnoteCount = 0;
tr.doc.nodesBetween(from, to, (node, _, parent) => {
if (parent?.type.name == "doc" && node.type.name != "footnotes") {
selectedContent = true;
} else if (node.type.name == "footnote") {
footnoteCount += 1;
} else if (node.type.name == "footnotes") {
selectedFootnotes = true;
}
});
const overSelected = selectedContent && selectedFootnotes;
/*
* Here, we don't allow any transaction that spans between the "content" nodes and the "footnotes" node. This also rejects any transaction that spans between more than 1 footnote.
*/
return !overSelected && footnoteCount <= 1;
},
// if there are some to the footnote references (added/deleted/dragged), append a transaction that updates the footnotes list accordingly
appendTransaction(transactions, oldState, newState) {
let newTr = newState.tr;
let refsChanged = false; // true if the footnote references have been changed, false otherwise
for (let tr of transactions) {
if (!tr.docChanged) continue;
if (refsChanged) break;
for (let step of tr.steps) {
if (!(step instanceof ReplaceStep)) continue;
if (refsChanged) break;
const isDelete = step.from != step.to; // the user deleted items from the document (from != to & the step is a replace step)
const isInsert = step.slice.size > 0;
// check if any footnote references have been inserted
if (isInsert) {
step.slice.content.descendants((node) => {
if (node?.type.name == "footnoteReference") {
refsChanged = true;
return false;
}
});
}
if (isDelete && !refsChanged) {
// check if any footnote references have been deleted
tr.before.nodesBetween(
step.from,
Math.min(tr.before.content.size, step.to), // make sure to not go over the old document's limit
(node) => {
if (node.type.name == "footnoteReference") {
refsChanged = true;
return false;
}
},
);
}
}
}
if (refsChanged) {
updateFootnotesList(newTr, newState);
return newTr;
}
return null;
},
}),
];
},
});
export default FootnoteRules;
@@ -0,0 +1,123 @@
//Source MIT - https://github.com/buttondown/tiptap-footnotes
import { EditorState, Transaction } from "@tiptap/pm/state";
import { Fragment, Node } from "@tiptap/pm/model";
// update the reference number of all the footnote references in the document
export function updateFootnoteReferences(tr: Transaction) {
let count = 1;
const nodes: any[] = [];
tr.doc.descendants((node, pos) => {
if (node.type.name == "footnoteReference") {
tr.setNodeAttribute(pos, "referenceNumber", `${count}`);
nodes.push(node);
count += 1;
}
});
// return the updated footnote references (in the order that they appear in the document)
return nodes;
}
function getFootnotes(tr: Transaction) {
let footnotesRange: { from: number; to: number } | undefined;
const footnotes: Node[] = [];
tr.doc.descendants((node, pos) => {
if (node.type.name == "footnote") {
footnotes.push(node);
} else if (node.type.name == "footnotes") {
footnotesRange = { from: pos, to: pos + node.nodeSize };
} else {
return false;
}
});
return { footnotesRange, footnotes };
}
// update the "footnotes" ordered list based on the footnote references in the document
export function updateFootnotesList(tr: Transaction, state: EditorState) {
const footnoteReferences = updateFootnoteReferences(tr);
const footnoteType = state.schema.nodes.footnote;
const footnotesType = state.schema.nodes.footnotes;
const emptyParagraph = state.schema.nodeFromJSON({
type: "paragraph",
content: [],
});
const { footnotesRange, footnotes } = getFootnotes(tr);
// a mapping of footnote id -> footnote node
const footnoteIds: { [key: string]: Node } = footnotes.reduce(
(obj, footnote) => {
obj[footnote.attrs["data-id"]] = footnote;
return obj;
},
{} as any,
);
const newFootnotes: Node[] = [];
let footnoteRefIds = new Set(
footnoteReferences.map((ref) => ref.attrs["data-id"]),
);
const deleteFootnoteIds: Set<string> = new Set();
for (let footnote of footnotes) {
const id = footnote.attrs["data-id"];
if (!footnoteRefIds.has(id) || deleteFootnoteIds.has(id)) {
deleteFootnoteIds.add(id);
// we traverse through this footnote's content because it may contain footnote references.
// we want to delete the footnotes associated with these references, so we add them to the delete set.
footnote.content.descendants((node) => {
if (node.type.name == "footnoteReference")
deleteFootnoteIds.add(node.attrs["data-id"]);
});
}
}
for (let i = 0; i < footnoteReferences.length; i++) {
let refId = footnoteReferences[i].attrs["data-id"];
if (deleteFootnoteIds.has(refId)) continue;
// if there is a footnote w/ the same id as this `ref`, we preserve its content and update its id attribute
if (refId in footnoteIds) {
let footnote = footnoteIds[refId];
newFootnotes.push(
footnoteType.create(
{ ...footnote.attrs, id: `fn:${i + 1}` },
footnote.content,
),
);
} else {
let newNode = footnoteType.create(
{
"data-id": refId,
id: `fn:${i + 1}`,
},
[emptyParagraph],
);
newFootnotes.push(newNode);
}
}
if (newFootnotes.length == 0) {
// no footnotes in the doc, delete the "footnotes" node
if (footnotesRange) {
tr.delete(footnotesRange.from, footnotesRange.to);
}
} else if (!footnotesRange) {
// there is no footnotes node present in the doc, add it
tr.insert(
tr.doc.content.size,
footnotesType.create(undefined, Fragment.from(newFootnotes)),
);
} else {
tr.replaceWith(
footnotesRange!.from + 1, // add 1 to point at the position after the opening ol tag
footnotesRange!.to - 1, // substract 1 to point to the position before the closing ol tag
Fragment.from(newFootnotes),
);
}
}
@@ -0,0 +1,110 @@
import { Token, marked } from 'marked';
import { generateNodeId } from '../../utils';
interface FootnoteRefToken {
type: 'footnoteRef';
label: string;
raw: string;
}
interface FootnoteDefToken {
type: 'footnoteDef';
label: string;
text: string;
raw: string;
}
// Parse-scoped state: markdownToHtml resets before the top-level parse and
// appends the collected list after it. Nested marked.parse calls (callout,
// footnote definitions) share this state, so hooks cannot be used here.
let footnoteRefs: { label: string; id: string; number: number }[] = [];
let footnoteDefs = new Map<string, string>();
export function resetFootnotes() {
footnoteRefs = [];
footnoteDefs = new Map();
}
export function renderFootnotesList(): string {
if (!footnoteRefs.length) return '';
const items = footnoteRefs.map(({ label, id, number }) => {
const body = footnoteDefs.get(label) || '<p></p>';
return `<li id="fn:${number}" data-id="${id}">${body}</li>`;
});
return `<ol class="footnotes">\n${items.join('\n')}\n</ol>\n`;
}
export const footnoteRefExtension = {
name: 'footnoteRef',
level: 'inline',
start(src: string) {
return src.indexOf('[^');
},
tokenizer(src: string): FootnoteRefToken | undefined {
const match = /^\[\^([^\]\s]+)\]/.exec(src);
if (match) {
return {
type: 'footnoteRef',
raw: match[0],
label: match[1].toLowerCase(),
};
}
},
renderer(token: Token) {
const refToken = token as FootnoteRefToken;
const number = footnoteRefs.length + 1;
const id = generateNodeId();
footnoteRefs.push({ label: refToken.label, id, number });
return `<sup id="fnref:${number}"><a class="footnote-ref" data-id="${id}" data-reference-number="${number}" href="#fn:${number}">${number}</a></sup>`;
},
};
export const footnoteDefExtension = {
name: 'footnoteDef',
level: 'block',
start(src: string) {
return src.match(/^\[\^[^\]\s]+\]:/m)?.index ?? -1;
},
tokenizer(src: string): FootnoteDefToken | undefined {
const firstLine = /^\[\^([^\]\s]+)\]:[ \t]*/.exec(src);
if (!firstLine) return undefined;
const lines = src.split('\n');
const contentLines = [lines[0].slice(firstLine[0].length)];
let consumed = 1;
while (consumed < lines.length) {
const line = lines[consumed];
if (/^[ \t]{2,}\S/.test(line)) {
contentLines.push(line.replace(/^[ \t]{1,4}/, ''));
consumed += 1;
} else if (
/^[ \t]*$/.test(line) &&
consumed + 1 < lines.length &&
/^[ \t]{2,}\S/.test(lines[consumed + 1])
) {
contentLines.push('');
consumed += 1;
} else {
break;
}
}
const raw =
lines.slice(0, consumed).join('\n') +
(consumed < lines.length ? '\n' : '');
return {
type: 'footnoteDef',
raw,
label: firstLine[1].toLowerCase(),
text: contentLines.join('\n').trim(),
};
},
renderer(token: Token) {
const defToken = token as FootnoteDefToken;
const body = defToken.text
? marked.parse(defToken.text).toString()
: '<p></p>';
footnoteDefs.set(defToken.label, body);
return '';
},
};
@@ -2,6 +2,12 @@ import { marked } from "marked";
import { calloutExtension } from "./callout.marked"; import { calloutExtension } from "./callout.marked";
import { mathBlockExtension } from "./math-block.marked"; import { mathBlockExtension } from "./math-block.marked";
import { mathInlineExtension } from "./math-inline.marked"; import { mathInlineExtension } from "./math-inline.marked";
import {
footnoteDefExtension,
footnoteRefExtension,
renderFootnotesList,
resetFootnotes,
} from "./footnotes.marked";
marked.use({ marked.use({
renderer: { renderer: {
@@ -34,7 +40,13 @@ marked.use({
}); });
marked.use({ marked.use({
extensions: [calloutExtension, mathBlockExtension, mathInlineExtension], extensions: [
calloutExtension,
mathBlockExtension,
mathInlineExtension,
footnoteDefExtension,
footnoteRefExtension,
],
}); });
marked.setOptions({ breaks: true }); marked.setOptions({ breaks: true });
@@ -48,5 +60,7 @@ export function markdownToHtml(
.replace(YAML_FONT_MATTER_REGEX, "") .replace(YAML_FONT_MATTER_REGEX, "")
.trimStart(); .trimStart();
return marked.parse(markdown).toString(); resetFootnotes();
const html = marked.parse(markdown).toString();
return html + renderFootnotesList();
} }
@@ -34,6 +34,8 @@ export function htmlToMarkdown(html: string): string {
iframeEmbed, iframeEmbed,
image, image,
video, video,
footnoteRef,
footnotesList,
]); ]);
return turndownService.turndown(html).replaceAll('<br>', ' '); return turndownService.turndown(html).replaceAll('<br>', ' ');
} }
@@ -203,6 +205,56 @@ function image(turndownService: _TurndownService) {
}); });
} }
function getFootnoteAnchor(node: HTMLElement): HTMLElement | null {
const child = node.firstElementChild as HTMLElement | null;
return child?.nodeName === 'A' && child.classList.contains('footnote-ref')
? child
: null;
}
function footnoteRef(turndownService: _TurndownService) {
turndownService.addRule('footnoteRef', {
filter: function (node: HTMLInputElement) {
return node.nodeName === 'SUP' && !!getFootnoteAnchor(node);
},
replacement: function (_content: string, node: HTMLInputElement) {
const anchor = getFootnoteAnchor(node);
const number =
anchor.getAttribute('data-reference-number') || anchor.textContent;
return `[^${number}]`;
},
});
}
function footnotesList(turndownService: _TurndownService) {
turndownService.addRule('footnotesList', {
filter: function (node: HTMLInputElement) {
return node.nodeName === 'OL' && node.classList.contains('footnotes');
},
replacement: function (_content: string, node: HTMLInputElement) {
const items = Array.from(node.children).filter(
(child) => child.nodeName === 'LI',
);
const definitions = items.map((li, index) => {
const number =
(li.getAttribute('id') || '').replace('fn:', '') ||
String(index + 1);
const markdown = turndownService
.turndown((li as HTMLElement).innerHTML)
.trim();
// continuation lines need a 4-space indent to stay in the footnote
const [first, ...rest] = markdown.split('\n');
const body = [
first,
...rest.map((line: string) => (line.trim() ? ` ${line}` : line)),
].join('\n');
return `[^${number}]: ${body}`;
});
return `\n\n${definitions.join('\n')}\n\n`;
},
});
}
function video(turndownService: _TurndownService) { function video(turndownService: _TurndownService) {
turndownService.addRule('video', { turndownService.addRule('video', {
filter: function (node: HTMLInputElement) { filter: function (node: HTMLInputElement) {
@@ -1,4 +1,4 @@
import { HeadingLevel, ShadingType } from 'docx'; import { FootnoteReferenceRun, HeadingLevel, Paragraph, ShadingType } from 'docx';
import { Node } from 'prosemirror-model'; import { Node } from 'prosemirror-model';
import { import {
DocxSerializerAsync, DocxSerializerAsync,
@@ -168,6 +168,27 @@ export const defaultAsyncNodes: NodeSerializerAsync = {
pageBreak(state, node) { pageBreak(state, node) {
state.closeBlock(node, { pageBreakBefore: true }); state.closeBlock(node, { pageBreakBefore: true });
}, },
footnoteReference(state, node) {
const number =
Number(node.attrs?.referenceNumber) || state.$footnoteCounter + 1;
state.$footnoteCounter = Math.max(state.$footnoteCounter, number);
// seed an empty body so the reference stays valid even if the trailing
// footnotes list is missing; the footnotes node overwrites it with content
if (!state.footnotes[number]) {
state.footnotes[number] = { children: [new Paragraph('')] };
}
state.current.push(new FootnoteReferenceRun(number));
},
async footnotes(state, node) {
for (let i = 0; i < node.childCount; i += 1) {
const item = node.child(i);
const number =
Number(String(item.attrs?.id ?? '').replace('fn:', '')) || i + 1;
await state.footnoteDefinition(item, number);
}
},
// items are consumed by the footnotes handler above
footnote() {},
// No usable static export representation: skip without failing. // No usable static export representation: skip without failing.
subpages() {}, subpages() {},
transclusionReference() {}, transclusionReference() {},
@@ -824,6 +824,29 @@ export class DocxSerializerStateAsync {
this.current.push(new FootnoteReferenceRun(this.$footnoteCounter)); this.current.push(new FootnoteReferenceRun(this.$footnoteCounter));
} }
// Fills the footnote body for an already-referenced footnote number from a
// node holding block content (Docmost keeps footnote text in a trailing
// list, separate from the inline reference).
async footnoteDefinition(node: Node, number: number) {
const { current, children, nextRunOpts, nextParentParagraphOpts } = this;
this.current = [];
this.children = [];
delete this.nextRunOpts;
delete this.nextParentParagraphOpts;
await this.renderContent(node);
this.footnotes[number] = {
children: this.children.filter(
(child): child is Paragraph => child instanceof Paragraph,
),
};
this.current = current;
this.children = children;
this.nextRunOpts = nextRunOpts;
this.nextParentParagraphOpts = nextParentParagraphOpts;
}
closeBlock(node: Node, props?: IParagraphOptions) { closeBlock(node: Node, props?: IParagraphOptions) {
const paragraph = new Paragraph({ const paragraph = new Paragraph({
children: this.current, children: this.current,
+17 -3
View File
@@ -7,9 +7,19 @@ export interface TrailingNodeExtensionOptions {
} }
function nodeEqualsType({ types, node }: { types: any, node: any }) { function nodeEqualsType({ types, node }: { types: any, node: any }) {
if (!node) return false
return (Array.isArray(types) && types.includes(node.type)) || node.type === types return (Array.isArray(types) && types.includes(node.type)) || node.type === types
} }
// footnotes must stay the last doc child, so the trailing node goes before it
function lastNodeBeforeFootnotes(doc: any) {
const lastChild = doc.lastChild
if (lastChild?.type.name === 'footnotes') {
return doc.childCount > 1 ? doc.child(doc.childCount - 2) : null
}
return lastChild
}
// @ts-ignore // @ts-ignore
/** /**
* Extension based on: * Extension based on:
@@ -40,19 +50,23 @@ export const TrailingNode = Extension.create<TrailingNodeExtensionOptions>({
appendTransaction: (_, __, state) => { appendTransaction: (_, __, state) => {
const { doc, tr, schema } = state; const { doc, tr, schema } = state;
const shouldInsertNodeAtEnd = plugin.getState(state); const shouldInsertNodeAtEnd = plugin.getState(state);
const endPosition = doc.content.size;
const type = schema.nodes[this.options.node] const type = schema.nodes[this.options.node]
if (!shouldInsertNodeAtEnd) { if (!shouldInsertNodeAtEnd) {
return; return;
} }
const lastChild = doc.lastChild
const endPosition = lastChild?.type.name === 'footnotes'
? doc.content.size - lastChild.nodeSize
: doc.content.size
return tr.insert(endPosition, type.create()); return tr.insert(endPosition, type.create());
}, },
state: { state: {
init: (_, state) => { init: (_, state) => {
try { try {
const lastNode = state.tr.doc.lastChild const lastNode = lastNodeBeforeFootnotes(state.tr.doc)
return !nodeEqualsType({ node: lastNode, types: disabledNodes }) return !nodeEqualsType({ node: lastNode, types: disabledNodes })
} catch (err){ } catch (err){
console.log(err) console.log(err)
@@ -70,7 +84,7 @@ export const TrailingNode = Extension.create<TrailingNodeExtensionOptions>({
return value return value
} }
const lastNode = tr.doc.lastChild const lastNode = lastNodeBeforeFootnotes(tr.doc)
return !nodeEqualsType({ node: lastNode, types: disabledNodes }) return !nodeEqualsType({ node: lastNode, types: disabledNodes })
}, },
}, },