mirror of
https://github.com/docmost/docmost.git
synced 2026-08-19 18:44:09 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0501b334b5 |
@@ -387,8 +387,6 @@
|
||||
"Insert horizontal rule divider": "Insert horizontal rule divider",
|
||||
"Page break": "Page break",
|
||||
"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 video from your device.": "Upload any video from your device.",
|
||||
"Upload any audio from your device.": "Upload any audio from your device.",
|
||||
@@ -510,6 +508,11 @@
|
||||
"Allow viewers to comment": "Allow viewers to comment",
|
||||
"Allow viewers to add comments on pages in this space.": "Allow viewers to add comments on pages in this space.",
|
||||
"Toggle viewer comments": "Toggle viewer comments",
|
||||
"Hide comments from viewers": "Hide comments from viewers",
|
||||
"Viewers cannot see or add comments on pages in this space.": "Viewers cannot see or add comments on pages in this space.",
|
||||
"Toggle hide comments from viewers": "Toggle hide comments from viewers",
|
||||
"Turn off 'Allow viewers to comment' first": "Turn off 'Allow viewers to comment' first",
|
||||
"Turn off 'Hide comments from viewers' first": "Turn off 'Hide comments from viewers' first",
|
||||
"Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level",
|
||||
"Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.",
|
||||
"Page permissions": "Page permissions",
|
||||
|
||||
@@ -11,11 +11,13 @@ import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
|
||||
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
|
||||
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
||||
import { useCanViewComments } from "@/features/comment/hooks/use-can-view-comments.ts";
|
||||
|
||||
export default function Aside() {
|
||||
const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom);
|
||||
const { t } = useTranslation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const canViewComments = useCanViewComments();
|
||||
const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false }));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -23,12 +25,18 @@ export default function Aside() {
|
||||
document.getElementById(ASIDE_PANEL_ID)?.focus();
|
||||
}, [isAsideOpen, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAsideOpen && tab === "comments" && !canViewComments) {
|
||||
setAsideState({ tab: "", isAsideOpen: false });
|
||||
}
|
||||
}, [isAsideOpen, tab, canViewComments, setAsideState]);
|
||||
|
||||
let title: string;
|
||||
let component: ReactNode;
|
||||
|
||||
switch (tab) {
|
||||
case "comments":
|
||||
component = <CommentListWithTabs />;
|
||||
component = canViewComments ? <CommentListWithTabs /> : null;
|
||||
title = "Comments";
|
||||
break;
|
||||
case "toc":
|
||||
|
||||
@@ -19,6 +19,7 @@ export const Feature = {
|
||||
SHARING_CONTROLS: 'sharing:controls',
|
||||
TEMPLATES: 'templates',
|
||||
VIEWER_COMMENTS: 'comment:viewer',
|
||||
HIDE_COMMENTS: 'comment:hide',
|
||||
PERSONAL_SPACES: 'spaces:personal',
|
||||
DOCX_EXPORT: 'export:docx',
|
||||
BASES: 'bases',
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Group, Text, Switch, Tooltip } from "@mantine/core";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
|
||||
import { Feature } from "@/ee/features.ts";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
|
||||
|
||||
type SpaceHideCommentsToggleProps = {
|
||||
space: ISpace;
|
||||
};
|
||||
|
||||
export default function SpaceHideCommentsToggle({
|
||||
space,
|
||||
}: SpaceHideCommentsToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasHideComments = useHasFeature(Feature.HIDE_COMMENTS);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
const allowViewerCommentsEnabled =
|
||||
space.settings?.comments?.allowViewerComments === true;
|
||||
const isDisabled = !hasHideComments || allowViewerCommentsEnabled;
|
||||
const tooltipLabel = !hasHideComments
|
||||
? upgradeLabel
|
||||
: t("Turn off 'Allow viewers to comment' first");
|
||||
const [checked, setChecked] = useState(
|
||||
space.settings?.comments?.hideCommentsFromViewers === true,
|
||||
);
|
||||
const updateSpaceMutation = useUpdateSpaceMutation();
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
try {
|
||||
await updateSpaceMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
hideCommentsFromViewers: value,
|
||||
});
|
||||
setChecked(value);
|
||||
} catch {
|
||||
// error handled by mutation
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("Hide comments from viewers")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Viewers cannot see or add comments on pages in this space.")}
|
||||
</Text>
|
||||
</div>
|
||||
<Tooltip label={tooltipLabel} disabled={!isDisabled} refProp="rootRef">
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={isDisabled}
|
||||
aria-label={t("Toggle hide comments from viewers")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,12 @@ export default function SpaceViewerCommentsToggle({
|
||||
const { t } = useTranslation();
|
||||
const hasViewerComments = useHasFeature(Feature.VIEWER_COMMENTS);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
const isDisabled = !hasViewerComments;
|
||||
const hideCommentsEnabled =
|
||||
space.settings?.comments?.hideCommentsFromViewers === true;
|
||||
const isDisabled = !hasViewerComments || hideCommentsEnabled;
|
||||
const tooltipLabel = !hasViewerComments
|
||||
? upgradeLabel
|
||||
: t("Turn off 'Hide comments from viewers' first");
|
||||
const [checked, setChecked] = useState(
|
||||
space.settings?.comments?.allowViewerComments === true,
|
||||
);
|
||||
@@ -45,7 +50,7 @@ export default function SpaceViewerCommentsToggle({
|
||||
</Text>
|
||||
</div>
|
||||
<Tooltip
|
||||
label={upgradeLabel}
|
||||
label={tooltipLabel}
|
||||
disabled={!isDisabled}
|
||||
refProp="rootRef"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
|
||||
export function useCanViewComments(): boolean {
|
||||
const { pageSlug } = useParams();
|
||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
|
||||
const canEdit = !page?.deletedAt && (page?.permissions?.canEdit ?? false);
|
||||
return (
|
||||
canEdit || space?.settings?.comments?.hideCommentsFromViewers !== true
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
IconMathFunction,
|
||||
IconRotate2,
|
||||
IconSitemap,
|
||||
IconSuperscript,
|
||||
IconTable,
|
||||
IconTag,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -271,12 +270,6 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
|
||||
>
|
||||
{t("Math block")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconSuperscript size={16} />}
|
||||
onClick={() => editor.chain().focus().addFootnote().run()}
|
||||
>
|
||||
{t("Footnote")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
IconTag,
|
||||
IconMoodSmile,
|
||||
IconRotate2,
|
||||
IconSuperscript,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
CommandProps,
|
||||
@@ -178,16 +177,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
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",
|
||||
description: "Upload any image from your device.",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { markInputRule } from "@tiptap/core";
|
||||
import { StarterKit } from "@tiptap/starter-kit";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { Code } from "@tiptap/extension-code";
|
||||
import { TextAlign } from "@tiptap/extension-text-align";
|
||||
import { TaskList, TaskItem } from "@tiptap/extension-list";
|
||||
@@ -64,9 +63,6 @@ import {
|
||||
TransclusionReference,
|
||||
TableView,
|
||||
BaseEmbed as BaseEmbedNode,
|
||||
Footnotes,
|
||||
Footnote,
|
||||
FootnoteReference,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -136,7 +132,6 @@ lowlight.register("scala", scala);
|
||||
// @ts-ignore
|
||||
export const mainExtensions = [
|
||||
StarterKit.configure({
|
||||
document: false,
|
||||
heading: false,
|
||||
undoRedo: false,
|
||||
link: false,
|
||||
@@ -148,9 +143,6 @@ export const mainExtensions = [
|
||||
codeBlock: false,
|
||||
code: false,
|
||||
}),
|
||||
Document.extend({
|
||||
content: "block+ footnotes?",
|
||||
}),
|
||||
// Override TipTap's Code extension to fix the inline code input rule.
|
||||
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
|
||||
// before the opening backtick as part of the match, causing markInputRule
|
||||
@@ -211,8 +203,7 @@ export const mainExtensions = [
|
||||
parentName === "tableCell" ||
|
||||
parentName === "tableHeader" ||
|
||||
parentName === "callout" ||
|
||||
parentName === "blockquote" ||
|
||||
parentName === "footnote"
|
||||
parentName === "blockquote"
|
||||
) {
|
||||
return i18n.t("Write...");
|
||||
}
|
||||
@@ -426,9 +417,6 @@ export const mainExtensions = [
|
||||
}).configure(),
|
||||
Columns,
|
||||
Column,
|
||||
Footnotes,
|
||||
Footnote,
|
||||
FootnoteReference,
|
||||
AutoJoiner.configure({
|
||||
elementsToJoin: [],
|
||||
}),
|
||||
|
||||
@@ -82,6 +82,8 @@ import {
|
||||
getCollabSocket,
|
||||
releaseCollabSocket,
|
||||
} from "@/features/editor/collab-socket";
|
||||
import clsx from "clsx";
|
||||
import { useCanViewComments } from "@/features/comment/hooks/use-can-view-comments.ts";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@@ -196,6 +198,7 @@ function CollabPageEditor({
|
||||
const { pageSlug } = useParams();
|
||||
const slugId = extractPageSlugId(pageSlug);
|
||||
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
||||
const canViewComments = useCanViewComments();
|
||||
const canScroll = useCallback(
|
||||
() => Boolean(isComponentMounted.current && editorRef.current),
|
||||
[isComponentMounted],
|
||||
@@ -372,6 +375,7 @@ function CollabPageEditor({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!canViewComments) return;
|
||||
document.addEventListener("ACTIVE_COMMENT_EVENT", handleActiveCommentEvent);
|
||||
return () => {
|
||||
document.removeEventListener(
|
||||
@@ -379,7 +383,7 @@ function CollabPageEditor({
|
||||
handleActiveCommentEvent,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
}, [canViewComments]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveCommentId(null);
|
||||
@@ -430,7 +434,13 @@ function CollabPageEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="editor-container" style={{ position: "relative" }}>
|
||||
<div
|
||||
className={clsx(
|
||||
"editor-container",
|
||||
!canViewComments && "comments-hidden",
|
||||
)}
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
<div ref={menuContainerRef}>
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
@@ -480,17 +490,21 @@ function StaticPageEditor({
|
||||
content: any;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const canViewComments = useCanViewComments();
|
||||
|
||||
return (
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={mainExtensions}
|
||||
content={content}
|
||||
editorProps={{
|
||||
attributes: {
|
||||
"aria-label": ariaLabel,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<div className={clsx(!canViewComments && "comments-hidden")}>
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={mainExtensions}
|
||||
content={content}
|
||||
editorProps={{
|
||||
attributes: {
|
||||
"aria-label": ariaLabel,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -315,3 +315,8 @@
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.comments-hidden .ProseMirror .comment-mark {
|
||||
background: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
.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,4 +18,3 @@
|
||||
@import "./columns.css";
|
||||
@import "./status.css";
|
||||
@import "./base-embed.css";
|
||||
@import "./footnotes.css";
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
useWatchPageMutation,
|
||||
useUnwatchPageMutation,
|
||||
} from "@/features/page/queries/watcher-query";
|
||||
import { useCanViewComments } from "@/features/comment/hooks/use-can-view-comments.ts";
|
||||
|
||||
interface PageHeaderMenuProps {
|
||||
readOnly?: boolean;
|
||||
@@ -65,6 +66,7 @@ interface PageHeaderMenuProps {
|
||||
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const commentsTriggerProps = useAsideTriggerProps("comments");
|
||||
const canViewComments = useCanViewComments();
|
||||
const tocTriggerProps = useAsideTriggerProps("toc");
|
||||
const { pageSlug } = useParams();
|
||||
const { data: page } = usePageQuery({
|
||||
@@ -105,16 +107,18 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
|
||||
<PageShareModal readOnly={readOnly} />
|
||||
|
||||
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
aria-label={t("Comments")}
|
||||
{...commentsTriggerProps}
|
||||
>
|
||||
<IconMessage size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{canViewComments && (
|
||||
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
aria-label={t("Comments")}
|
||||
{...commentsTriggerProps}
|
||||
>
|
||||
<IconMessage size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!page?.isBase && (
|
||||
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
|
||||
import SpaceViewerCommentsToggle from "@/ee/security/components/space-viewer-comments-toggle.tsx";
|
||||
import SpaceHideCommentsToggle from "@/ee/security/components/space-hide-comments-toggle.tsx";
|
||||
|
||||
type SpaceSecuritySettingsProps = {
|
||||
space: ISpace;
|
||||
@@ -29,6 +30,10 @@ export default function SpaceSecuritySettings({
|
||||
<Divider my="lg" />
|
||||
|
||||
<SpaceViewerCommentsToggle space={space} />
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<SpaceHideCommentsToggle space={space} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface ISpaceSharingSettings {
|
||||
|
||||
export interface ISpaceCommentsSettings {
|
||||
allowViewerComments?: boolean;
|
||||
hideCommentsFromViewers?: boolean;
|
||||
}
|
||||
|
||||
export interface ISpaceSettings {
|
||||
@@ -36,6 +37,7 @@ export interface ISpace {
|
||||
// for updates
|
||||
disablePublicSharing?: boolean;
|
||||
allowViewerComments?: boolean;
|
||||
hideCommentsFromViewers?: boolean;
|
||||
}
|
||||
|
||||
interface IMembership {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { StarterKit } from '@tiptap/starter-kit';
|
||||
import { Document } from '@tiptap/extension-document';
|
||||
import { TextAlign } from '@tiptap/extension-text-align';
|
||||
import { Superscript } from '@tiptap/extension-superscript';
|
||||
import SubScript from '@tiptap/extension-subscript';
|
||||
@@ -46,9 +45,6 @@ import {
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
BaseEmbed,
|
||||
Footnotes,
|
||||
Footnote,
|
||||
FootnoteReference,
|
||||
} from '@docmost/editor-ext';
|
||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||
@@ -62,15 +58,11 @@ import { Logger } from '@nestjs/common';
|
||||
|
||||
export const tiptapExtensions = [
|
||||
StarterKit.configure({
|
||||
document: false,
|
||||
codeBlock: false,
|
||||
link: false,
|
||||
trailingNode: false,
|
||||
heading: false,
|
||||
}),
|
||||
Document.extend({
|
||||
content: 'block+ footnotes?',
|
||||
}),
|
||||
Heading,
|
||||
UniqueID.configure({
|
||||
types: ['heading', 'paragraph', 'transclusionSource'],
|
||||
@@ -118,10 +110,7 @@ export const tiptapExtensions = [
|
||||
Status,
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
BaseEmbed,
|
||||
Footnotes,
|
||||
Footnote,
|
||||
FootnoteReference,
|
||||
BaseEmbed
|
||||
] as any;
|
||||
|
||||
export function jsonToHtml(tiptapJson: any) {
|
||||
|
||||
@@ -18,6 +18,7 @@ export const Feature = {
|
||||
RETENTION: 'retention',
|
||||
SHARING_CONTROLS: 'sharing:controls',
|
||||
VIEWER_COMMENTS: 'comment:viewer',
|
||||
HIDE_COMMENTS: 'comment:hide',
|
||||
TEMPLATES: 'templates',
|
||||
PDF_EXPORT: 'export:pdf',
|
||||
PERSONAL_SPACES: 'spaces:personal',
|
||||
|
||||
@@ -89,20 +89,29 @@ export class CommentController {
|
||||
@Body()
|
||||
pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(input.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
await this.pageAccessService.validateCanViewComments(
|
||||
page,
|
||||
user,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
return this.commentService.findByPageId(page.id, pagination);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('info')
|
||||
async findOne(@Body() input: CommentIdDto, @AuthUser() user: User) {
|
||||
async findOne(
|
||||
@Body() input: CommentIdDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const comment = await this.commentRepo.findById(input.commentId);
|
||||
if (!comment) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
@@ -113,7 +122,11 @@ export class CommentController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
await this.pageAccessService.validateCanViewComments(
|
||||
page,
|
||||
user,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { CommentMentionEmail } from '@docmost/transactional/emails/comment-menti
|
||||
import { CommentCreateEmail } from '@docmost/transactional/emails/comment-created-email';
|
||||
import { CommentResolvedEmail } from '@docmost/transactional/emails/comment-resolved-email';
|
||||
import { getPageTitle } from '../../../common/helpers';
|
||||
import { PageAccessService } from '../../page/page-access/page-access.service';
|
||||
|
||||
@Injectable()
|
||||
export class CommentNotificationService {
|
||||
@@ -25,6 +26,7 @@ export class CommentNotificationService {
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly watcherRepo: WatcherRepo,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
) {}
|
||||
|
||||
async processComment(data: ICommentNotificationJob, appUrl: string) {
|
||||
@@ -48,7 +50,7 @@ export class CommentNotificationService {
|
||||
);
|
||||
if (!context) return;
|
||||
|
||||
const { actor, pageTitle, pageUrl } = context;
|
||||
const { actor, pageTitle, pageUrl, spaceSettings } = context;
|
||||
const notifiedUserIds = new Set<string>();
|
||||
notifiedUserIds.add(actorId);
|
||||
|
||||
@@ -72,7 +74,16 @@ export class CommentNotificationService {
|
||||
pageId,
|
||||
[...usersWithSpaceAccess],
|
||||
);
|
||||
const usersWithAccess = new Set(usersWithPageAccess);
|
||||
let accessibleUserIds = usersWithPageAccess;
|
||||
if (spaceSettings?.comments?.hideCommentsFromViewers === true) {
|
||||
accessibleUserIds =
|
||||
await this.pageAccessService.filterUserIdsWithPageEditAccess(
|
||||
spaceId,
|
||||
pageId,
|
||||
accessibleUserIds,
|
||||
);
|
||||
}
|
||||
const usersWithAccess = new Set(accessibleUserIds);
|
||||
|
||||
for (const userId of mentionedUserIds) {
|
||||
if (!usersWithAccess.has(userId)) continue;
|
||||
@@ -145,7 +156,7 @@ export class CommentNotificationService {
|
||||
);
|
||||
if (!context) return;
|
||||
|
||||
const { actor, pageTitle, pageUrl } = context;
|
||||
const { actor, pageTitle, pageUrl, spaceSettings } = context;
|
||||
|
||||
const roles = await this.spaceMemberRepo.getUserSpaceRoles(
|
||||
commentCreatorId,
|
||||
@@ -166,6 +177,16 @@ export class CommentNotificationService {
|
||||
);
|
||||
if (hasPageAccess.length === 0) return;
|
||||
|
||||
if (spaceSettings?.comments?.hideCommentsFromViewers === true) {
|
||||
const editCapable =
|
||||
await this.pageAccessService.filterUserIdsWithPageEditAccess(
|
||||
spaceId,
|
||||
pageId,
|
||||
[commentCreatorId],
|
||||
);
|
||||
if (editCapable.length === 0) return;
|
||||
}
|
||||
|
||||
const notification = await this.notificationService.create({
|
||||
userId: commentCreatorId,
|
||||
workspaceId,
|
||||
@@ -225,7 +246,7 @@ export class CommentNotificationService {
|
||||
.executeTakeFirst(),
|
||||
this.db
|
||||
.selectFrom('spaces')
|
||||
.select(['id', 'slug'])
|
||||
.select(['id', 'slug', 'settings'])
|
||||
.where('id', '=', spaceId)
|
||||
.executeTakeFirst(),
|
||||
]);
|
||||
@@ -236,6 +257,11 @@ export class CommentNotificationService {
|
||||
|
||||
const pageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
|
||||
|
||||
return { actor, pageTitle: getPageTitle(page.title), pageUrl };
|
||||
return {
|
||||
actor,
|
||||
pageTitle: getPageTitle(page.title),
|
||||
pageUrl,
|
||||
spaceSettings: (space.settings ?? null) as Record<string, any> | null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
SpaceCaslSubject,
|
||||
} from '../../casl/interfaces/space-ability.type';
|
||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
|
||||
@Injectable()
|
||||
export class PageAccessService {
|
||||
@@ -14,6 +15,7 @@ export class PageAccessService {
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly spaceRepo: SpaceRepo,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -118,8 +120,68 @@ export class PageAccessService {
|
||||
|
||||
const space = await this.spaceRepo.findById(page.spaceId, workspaceId);
|
||||
const settings = space?.settings as Record<string, any> | null;
|
||||
if (!settings?.comments?.allowViewerComments) {
|
||||
if (
|
||||
!settings?.comments?.allowViewerComments ||
|
||||
settings?.comments?.hideCommentsFromViewers
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
async validateCanViewComments(
|
||||
page: Page,
|
||||
user: User,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const { canEdit } = await this.validateCanViewWithPermissions(page, user);
|
||||
if (canEdit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const space = await this.spaceRepo.findById(page.spaceId, workspaceId);
|
||||
const settings = space?.settings as Record<string, any> | null;
|
||||
if (settings?.comments?.hideCommentsFromViewers) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callers must pass userIds that already have space access (WS room members / pre-filtered notification recipients).
|
||||
*/
|
||||
async filterUserIdsWithPageEditAccess(
|
||||
spaceId: string,
|
||||
pageId: string,
|
||||
userIds: string[],
|
||||
): Promise<string[]> {
|
||||
if (userIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const spaceHasRestrictedPages =
|
||||
await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
|
||||
const hasRestriction =
|
||||
spaceHasRestrictedPages &&
|
||||
(await this.pagePermissionRepo.hasRestrictedAncestor(pageId));
|
||||
|
||||
if (!hasRestriction) {
|
||||
const editCapableIds =
|
||||
await this.spaceMemberRepo.getUserIdsWithSpaceEditAccess(
|
||||
userIds,
|
||||
spaceId,
|
||||
);
|
||||
return userIds.filter((id) => editCapableIds.has(id));
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
userIds.map(async (userId) => {
|
||||
const { canEdit } = await this.pagePermissionRepo.canUserEditPage(
|
||||
userId,
|
||||
pageId,
|
||||
);
|
||||
return canEdit ? userId : null;
|
||||
}),
|
||||
);
|
||||
|
||||
return results.filter((id): id is string => id !== null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,4 +15,8 @@ export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowViewerComments: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
hideCommentsFromViewers: boolean;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,35 @@ import {
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
|
||||
export function validateExclusiveCommentSettings(
|
||||
dto: Partial<
|
||||
Pick<UpdateSpaceDto, 'allowViewerComments' | 'hideCommentsFromViewers'>
|
||||
>,
|
||||
settingsBefore: Record<string, any>,
|
||||
): void {
|
||||
if (
|
||||
dto.allowViewerComments === undefined &&
|
||||
dto.hideCommentsFromViewers === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allowViewerComments =
|
||||
dto.allowViewerComments ??
|
||||
settingsBefore.comments?.allowViewerComments ??
|
||||
false;
|
||||
const hideCommentsFromViewers =
|
||||
dto.hideCommentsFromViewers ??
|
||||
settingsBefore.comments?.hideCommentsFromViewers ??
|
||||
false;
|
||||
|
||||
if (allowViewerComments && hideCommentsFromViewers) {
|
||||
throw new BadRequestException(
|
||||
"'Allow viewers to comment' and 'Hide comments from viewers' cannot both be enabled",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SpaceService {
|
||||
constructor(
|
||||
@@ -141,7 +170,8 @@ export class SpaceService {
|
||||
|
||||
if (
|
||||
typeof updateSpaceDto.disablePublicSharing !== 'undefined' ||
|
||||
typeof updateSpaceDto.allowViewerComments !== 'undefined'
|
||||
typeof updateSpaceDto.allowViewerComments !== 'undefined' ||
|
||||
typeof updateSpaceDto.hideCommentsFromViewers !== 'undefined'
|
||||
) {
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||
withLicenseKey: true,
|
||||
@@ -168,6 +198,17 @@ export class SpaceService {
|
||||
) {
|
||||
throw new ForbiddenException('This feature requires a valid license');
|
||||
}
|
||||
|
||||
if (
|
||||
updateSpaceDto.hideCommentsFromViewers === true &&
|
||||
!this.licenseCheckService.hasFeature(
|
||||
workspace.licenseKey,
|
||||
Feature.HIDE_COMMENTS,
|
||||
workspace.plan,
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException('This feature requires a valid license');
|
||||
}
|
||||
}
|
||||
|
||||
const spaceBefore = await this.spaceRepo.findById(
|
||||
@@ -176,6 +217,8 @@ export class SpaceService {
|
||||
);
|
||||
const settingsBefore = (spaceBefore?.settings ?? {}) as Record<string, any>;
|
||||
|
||||
validateExclusiveCommentSettings(updateSpaceDto, settingsBefore);
|
||||
|
||||
const before: Record<string, any> = {};
|
||||
const after: Record<string, any> = {};
|
||||
|
||||
@@ -218,6 +261,23 @@ export class SpaceService {
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateSpaceDto.hideCommentsFromViewers !== 'undefined') {
|
||||
const prev = settingsBefore?.comments?.hideCommentsFromViewers ?? false;
|
||||
if (prev !== updateSpaceDto.hideCommentsFromViewers) {
|
||||
before.hideCommentsFromViewers = prev;
|
||||
after.hideCommentsFromViewers =
|
||||
updateSpaceDto.hideCommentsFromViewers;
|
||||
}
|
||||
|
||||
await this.spaceRepo.updateCommentSettings(
|
||||
updateSpaceDto.spaceId,
|
||||
workspaceId,
|
||||
'hideCommentsFromViewers',
|
||||
updateSpaceDto.hideCommentsFromViewers,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
updatedSpace = await this.spaceRepo.updateSpace(
|
||||
{
|
||||
name: updateSpaceDto.name,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
CacheKey,
|
||||
PERMISSION_CACHE_TTL_MS,
|
||||
} from '../../../common/helpers/cache-keys';
|
||||
import { SpaceRole } from '../../../common/helpers/types/permission';
|
||||
|
||||
@Injectable()
|
||||
export class SpaceMemberRepo {
|
||||
@@ -278,6 +279,32 @@ export class SpaceMemberRepo {
|
||||
return new Set(rows.map((r) => r.userId));
|
||||
}
|
||||
|
||||
async getUserIdsWithSpaceEditAccess(
|
||||
userIds: string[],
|
||||
spaceId: string,
|
||||
): Promise<Set<string>> {
|
||||
if (userIds.length === 0) return new Set();
|
||||
|
||||
const rows = await this.db
|
||||
.selectFrom('spaceMembers')
|
||||
.select('userId')
|
||||
.where('userId', 'in', userIds)
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where('spaceMembers.role', 'in', [SpaceRole.ADMIN, SpaceRole.WRITER])
|
||||
.unionAll(
|
||||
this.db
|
||||
.selectFrom('spaceMembers')
|
||||
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
|
||||
.select('groupUsers.userId')
|
||||
.where('groupUsers.userId', 'in', userIds)
|
||||
.where('spaceMembers.spaceId', '=', spaceId)
|
||||
.where('spaceMembers.role', 'in', [SpaceRole.ADMIN, SpaceRole.WRITER]),
|
||||
)
|
||||
.execute();
|
||||
|
||||
return new Set(rows.map((r) => r.userId));
|
||||
}
|
||||
|
||||
async getSpaceIdsByGroupId(groupId: string): Promise<string[]> {
|
||||
const rows = await this.db
|
||||
.selectFrom('spaceMembers')
|
||||
|
||||
@@ -149,6 +149,17 @@ export class SpaceRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async getSpaceSettings(
|
||||
spaceId: string,
|
||||
): Promise<Record<string, any> | null> {
|
||||
const row = await this.db
|
||||
.selectFrom('spaces')
|
||||
.select('settings')
|
||||
.where('id', '=', spaceId)
|
||||
.executeTakeFirst();
|
||||
return (row?.settings as Record<string, any> | undefined) ?? null;
|
||||
}
|
||||
|
||||
async insertSpace(
|
||||
insertableSpace: InsertableSpace,
|
||||
trx?: KyselyTransaction,
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: c7b77ffb9e...f396df9bc5
@@ -3,6 +3,8 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Cache } from 'cache-manager';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||
import { PageAccessService } from '../core/page/page-access/page-access.service';
|
||||
import {
|
||||
TREE_EVENTS,
|
||||
WS_SPACE_RESTRICTION_CACHE_PREFIX,
|
||||
@@ -17,6 +19,8 @@ export class WsService {
|
||||
|
||||
constructor(
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly spaceRepo: SpaceRepo,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
|
||||
) {}
|
||||
|
||||
@@ -67,9 +71,24 @@ export class WsService {
|
||||
spaceId: string,
|
||||
pageId: string,
|
||||
data: any,
|
||||
opts?: { bypassVisibilityCheck?: boolean },
|
||||
): Promise<void> {
|
||||
const room = getSpaceRoomName(spaceId);
|
||||
|
||||
if (
|
||||
!opts?.bypassVisibilityCheck &&
|
||||
(await this.spaceHidesCommentsFromViewers(spaceId))
|
||||
) {
|
||||
await this.broadcastToUsersMatching(room, null, data, (candidateIds) =>
|
||||
this.pageAccessService.filterUserIdsWithPageEditAccess(
|
||||
spaceId,
|
||||
pageId,
|
||||
candidateIds,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasRestrictions = await this.spaceHasRestrictions(spaceId);
|
||||
if (!hasRestrictions) {
|
||||
this.server.to(room).emit('message', data);
|
||||
@@ -118,6 +137,17 @@ export class WsService {
|
||||
excludeSocketId: string | null,
|
||||
pageId: string,
|
||||
data: any,
|
||||
): Promise<void> {
|
||||
await this.broadcastToUsersMatching(room, excludeSocketId, data, (ids) =>
|
||||
this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, ids),
|
||||
);
|
||||
}
|
||||
|
||||
private async broadcastToUsersMatching(
|
||||
room: string,
|
||||
excludeSocketId: string | null,
|
||||
data: any,
|
||||
filterUserIds: (candidateUserIds: string[]) => Promise<string[]>,
|
||||
): Promise<void> {
|
||||
const sockets = await this.server.in(room).fetchSockets();
|
||||
|
||||
@@ -144,15 +174,9 @@ export class WsService {
|
||||
const candidateUserIds = Array.from(userSocketMap.keys());
|
||||
if (candidateUserIds.length === 0) return;
|
||||
|
||||
const authorizedUserIds =
|
||||
await this.pagePermissionRepo.getUserIdsWithPageAccess(
|
||||
pageId,
|
||||
candidateUserIds,
|
||||
);
|
||||
|
||||
const authorizedSet = new Set(authorizedUserIds);
|
||||
const allowedSet = new Set(await filterUserIds(candidateUserIds));
|
||||
for (const [userId, userSockets] of userSocketMap) {
|
||||
if (authorizedSet.has(userId)) {
|
||||
if (allowedSet.has(userId)) {
|
||||
for (const socket of userSockets) {
|
||||
socket.emit('message', data);
|
||||
}
|
||||
@@ -176,6 +200,13 @@ export class WsService {
|
||||
return hasRestrictions;
|
||||
}
|
||||
|
||||
private async spaceHidesCommentsFromViewers(
|
||||
spaceId: string,
|
||||
): Promise<boolean> {
|
||||
const settings = await this.spaceRepo.getSpaceSettings(spaceId);
|
||||
return settings?.comments?.hideCommentsFromViewers === true;
|
||||
}
|
||||
|
||||
private extractPageId(data: any): string | null {
|
||||
switch (data.operation) {
|
||||
case 'addTreeNode':
|
||||
|
||||
@@ -32,7 +32,6 @@ export * from "./lib/columns";
|
||||
export * from "./lib/status";
|
||||
export * from "./lib/pdf";
|
||||
export * from "./lib/page-break";
|
||||
export * from "./lib/footnotes";
|
||||
export * from "./lib/resizable-nodeview";
|
||||
export {
|
||||
pageNodeToDocxBuffer,
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
//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;
|
||||
@@ -1,46 +0,0 @@
|
||||
//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;
|
||||
@@ -1,4 +0,0 @@
|
||||
export { default as Footnotes } from "./footnotes";
|
||||
export { default as Footnote } from "./footnote";
|
||||
export type { FootnoteOptions } from "./footnote";
|
||||
export { default as FootnoteReference } from "./reference";
|
||||
@@ -1,221 +0,0 @@
|
||||
//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;
|
||||
@@ -1,90 +0,0 @@
|
||||
//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;
|
||||
@@ -1,123 +0,0 @@
|
||||
//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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
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,12 +2,6 @@ import { marked } from "marked";
|
||||
import { calloutExtension } from "./callout.marked";
|
||||
import { mathBlockExtension } from "./math-block.marked";
|
||||
import { mathInlineExtension } from "./math-inline.marked";
|
||||
import {
|
||||
footnoteDefExtension,
|
||||
footnoteRefExtension,
|
||||
renderFootnotesList,
|
||||
resetFootnotes,
|
||||
} from "./footnotes.marked";
|
||||
|
||||
marked.use({
|
||||
renderer: {
|
||||
@@ -40,13 +34,7 @@ marked.use({
|
||||
});
|
||||
|
||||
marked.use({
|
||||
extensions: [
|
||||
calloutExtension,
|
||||
mathBlockExtension,
|
||||
mathInlineExtension,
|
||||
footnoteDefExtension,
|
||||
footnoteRefExtension,
|
||||
],
|
||||
extensions: [calloutExtension, mathBlockExtension, mathInlineExtension],
|
||||
});
|
||||
|
||||
marked.setOptions({ breaks: true });
|
||||
@@ -60,7 +48,5 @@ export function markdownToHtml(
|
||||
.replace(YAML_FONT_MATTER_REGEX, "")
|
||||
.trimStart();
|
||||
|
||||
resetFootnotes();
|
||||
const html = marked.parse(markdown).toString();
|
||||
return html + renderFootnotesList();
|
||||
return marked.parse(markdown).toString();
|
||||
}
|
||||
|
||||
@@ -34,8 +34,6 @@ export function htmlToMarkdown(html: string): string {
|
||||
iframeEmbed,
|
||||
image,
|
||||
video,
|
||||
footnoteRef,
|
||||
footnotesList,
|
||||
]);
|
||||
return turndownService.turndown(html).replaceAll('<br>', ' ');
|
||||
}
|
||||
@@ -205,56 +203,6 @@ 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) {
|
||||
turndownService.addRule('video', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FootnoteReferenceRun, HeadingLevel, Paragraph, ShadingType } from 'docx';
|
||||
import { HeadingLevel, ShadingType } from 'docx';
|
||||
import { Node } from 'prosemirror-model';
|
||||
import {
|
||||
DocxSerializerAsync,
|
||||
@@ -168,27 +168,6 @@ export const defaultAsyncNodes: NodeSerializerAsync = {
|
||||
pageBreak(state, node) {
|
||||
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.
|
||||
subpages() {},
|
||||
transclusionReference() {},
|
||||
|
||||
@@ -824,29 +824,6 @@ export class DocxSerializerStateAsync {
|
||||
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) {
|
||||
const paragraph = new Paragraph({
|
||||
children: this.current,
|
||||
|
||||
@@ -7,19 +7,9 @@ export interface TrailingNodeExtensionOptions {
|
||||
}
|
||||
|
||||
function nodeEqualsType({ types, node }: { types: any, node: any }) {
|
||||
if (!node) return false
|
||||
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
|
||||
/**
|
||||
* Extension based on:
|
||||
@@ -50,23 +40,19 @@ export const TrailingNode = Extension.create<TrailingNodeExtensionOptions>({
|
||||
appendTransaction: (_, __, state) => {
|
||||
const { doc, tr, schema } = state;
|
||||
const shouldInsertNodeAtEnd = plugin.getState(state);
|
||||
const endPosition = doc.content.size;
|
||||
const type = schema.nodes[this.options.node]
|
||||
|
||||
if (!shouldInsertNodeAtEnd) {
|
||||
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());
|
||||
},
|
||||
state: {
|
||||
init: (_, state) => {
|
||||
try {
|
||||
const lastNode = lastNodeBeforeFootnotes(state.tr.doc)
|
||||
const lastNode = state.tr.doc.lastChild
|
||||
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
|
||||
} catch (err){
|
||||
console.log(err)
|
||||
@@ -84,7 +70,7 @@ export const TrailingNode = Extension.create<TrailingNodeExtensionOptions>({
|
||||
return value
|
||||
}
|
||||
|
||||
const lastNode = lastNodeBeforeFootnotes(tr.doc)
|
||||
const lastNode = tr.doc.lastChild
|
||||
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user