Compare commits

..

9 Commits

Author SHA1 Message Date
Philipinho a7f90d6474 Merge branch 'main' into anchor-link 2025-09-21 01:58:39 +01:00
Philipinho 29d3a8cee2 Merge branch 'main' into anchor-link 2025-09-17 02:44:38 +01:00
Philipinho 66099f4657 use prosemirror decorations 2025-08-11 22:10:30 -07:00
Philipinho cefabc8683 Merge branch 'main' into anchor-link 2025-08-11 13:49:57 -07:00
Philipinho 703bfad424 Support anchor links in page mentions 2025-07-08 21:06:33 -07:00
fuscodev b82171c24c fix: uid in shared pages 2025-06-28 13:07:54 +02:00
fuscodev 1baff07e4e add nanoid by Vito0912 2025-06-27 20:27:54 +02:00
fuscodev f689291a99 Merge branch 'main' into anchor-link 2025-06-18 23:41:03 +02:00
fuscodev 0e4af65935 anchor link init 2025-06-04 20:43:47 +02:00
31 changed files with 433 additions and 144 deletions
@@ -234,6 +234,7 @@
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
"Invite link": "Invite link",
"Copy": "Copy",
"Copy anchor link": "Copy anchor link",
"Copy to space": "Copy to space",
"Copied": "Copied",
"Duplicate": "Duplicate",
@@ -404,6 +405,7 @@
"Copy page": "Copy page",
"Copy page to a different space.": "Copy page to a different space.",
"Page copied successfully": "Page copied successfully",
"Anchor link copied": "Anchor link copied",
"Page duplicated successfully": "Page duplicated successfully",
"Find": "Find",
"Not found": "Not found",
@@ -3,7 +3,6 @@ import { uploadImageAction } from "@/features/editor/components/image/upload-ima
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { Slice } from "@tiptap/pm/model";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
export const handlePaste = (
@@ -34,7 +33,9 @@ export const handlePaste = (
return false;
}
createMentionAction(url, view, pos, creatorId);
const anchor = match[6]; // Extract anchor from the regex match
const urlWithoutAnchor = anchor ? url.substring(0, url.indexOf("#")) : url;
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchor);
return true;
}
@@ -0,0 +1,3 @@
.anchorScrollMargin {
scroll-margin-top: 95px;
}
@@ -0,0 +1,56 @@
import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
export function useAnchorScroll(offset = 95, maxRetries = 10, retryDelay = 500) {
const location = useLocation();
const lastHash = useRef("");
useEffect(() => {
let retries = maxRetries;
const tryScroll = () => {
let el = document.getElementById(lastHash.current);
if (!el) {
const hash = lastHash.current;
if (hash.includes('-')) {
const parts = hash.split('-');
const possibleUid = parts[parts.length - 1];
const elements = document.querySelectorAll('[id]');
for (const element of elements) {
if (element.id.endsWith(`-${possibleUid}`)) {
el = element as HTMLElement;
break;
}
}
}
if (!el) {
const elements = document.querySelectorAll('[id]');
for (const element of elements) {
if (element.id.endsWith(`-${hash}`) || element.id === hash) {
el = element as HTMLElement;
break;
}
}
}
}
if (el) {
const y = el.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top: y, behavior: "smooth" });
window.history.replaceState(null, "", `#${el.id}`);
} else if (retries > 0) {
retries--;
setTimeout(tryScroll, retryDelay);
}
};
if (location.hash) {
lastHash.current = location.hash.slice(1);
tryScroll();
}
}, [location, offset, maxRetries, retryDelay]);
}
@@ -9,6 +9,7 @@ export type LinkFn = (
view: EditorView,
pos: number,
creatorId: string,
anchor?: string,
) => void;
export interface InternalLinkOptions {
@@ -18,7 +19,7 @@ export interface InternalLinkOptions {
export const handleInternalLink =
({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn =>
async (url: string, view, pos, creatorId) => {
async (url: string, view, pos, creatorId, anchor) => {
const validated = validateFn(url, view);
if (!validated) return;
@@ -35,6 +36,7 @@ export const handleInternalLink =
entityId: page.id,
slugId: page.slugId,
creatorId: creatorId,
anchor: anchor,
});
if (!node) return;
@@ -11,7 +11,7 @@ import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) {
const { node } = props;
const { label, entityType, entityId, slugId } = node.attrs;
const { label, entityType, entityId, slugId, anchor } = node.attrs;
const { spaceSlug } = useParams();
const { shareId } = useParams();
const {
@@ -27,6 +27,7 @@ export default function MentionView(props: NodeViewProps) {
shareId,
pageSlugId: slugId,
pageTitle: label,
anchor,
});
return (
@@ -42,7 +43,7 @@ export default function MentionView(props: NodeViewProps) {
component={Link}
fw={500}
to={
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label)
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchor)
}
underline="never"
className={classes.pageMentionLink}
@@ -11,7 +11,7 @@ import { Typography } from "@tiptap/extension-typography";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import SlashCommand from "@/features/editor/extensions/slash-command";
import { Collaboration } from "@tiptap/extension-collaboration";
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
import { HocuspocusProvider } from "@hocuspocus/provider";
import {
@@ -40,6 +40,7 @@ import {
Mention,
Subpages,
TableDndExtension,
HeadingAnchors,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -78,6 +79,8 @@ import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboa
import EmojiCommand from "./emoji-command";
import { CharacterCount } from "@tiptap/extension-character-count";
import { countWords } from "alfaaz";
import UniqueID from "@tiptap/extension-unique-id";
import { generateEditorNodeId } from "../utils/nanoid";
const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext);
@@ -94,6 +97,7 @@ lowlight.register("scala", scala);
export const mainExtensions = [
StarterKit.configure({
history: false,
heading: false,
dropcursor: {
width: 3,
color: "#70CFF8",
@@ -105,6 +109,7 @@ export const mainExtensions = [
},
},
}),
HeadingAnchors,
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
@@ -241,6 +246,12 @@ export const mainExtensions = [
}
},
}).configure(),
UniqueID.configure({
types: ["heading"],
attributeName: "nodeId",
generateID: () => generateEditorNodeId(),
filterTransaction: (transaction) => !isChangeOrigin(transaction),
}),
] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
@@ -51,6 +51,7 @@ import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from '@/features/search/constants.ts';
import { useAnchorScroll } from "./components/heading/use-anchor-scroll";
interface PageEditorProps {
pageId: string;
@@ -87,6 +88,7 @@ export default function PageEditor({
const [isCollabReady, setIsCollabReady] = useState(false);
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
// useAnchorScroll();
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
@@ -0,0 +1,79 @@
.heading-block {
position: relative;
scroll-margin-top: 80px;
}
.has-anchor {
position: relative;
}
.heading-anchor-wrapper {
display: inline-block;
margin-left: 8px;
vertical-align: middle;
}
.heading-anchor-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
background: transparent;
color: var(--mantine-color-gray-5);
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease, color 0.2s ease;
outline: none;
}
.has-anchor:hover .heading-anchor-button {
opacity: 1;
}
.heading-anchor-button:hover {
color: var(--mantine-color-blue-6);
}
.heading-anchor-button.copied {
color: var(--mantine-color-green-6);
opacity: 1;
}
.heading-anchor-button svg {
width: 16px;
height: 16px;
}
@media (max-width: 768px) {
.heading-anchor-button {
opacity: 0.3;
}
.has-anchor:hover .heading-anchor-button {
opacity: 0.7;
}
}
@media print {
.heading-anchor-wrapper {
display: none !important;
}
}
.ProseMirror .heading-anchor-button {
pointer-events: all;
}
/* Hide button when cursor is in the same heading */
.ProseMirror-focused .has-anchor.ProseMirror-selectednode .heading-anchor-button {
opacity: 0;
}
/* Always show on hover, regardless of focus state */
.has-anchor:hover .heading-anchor-button {
opacity: 1;
pointer-events: all;
}
@@ -12,3 +12,4 @@
@import "./find.css";
@import "./mention.css";
@import "./ordered-list.css";
@import "./heading-anchors.css";
@@ -0,0 +1,5 @@
import { customAlphabet } from "nanoid";
const slugIdAlphabet =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
export const generateEditorNodeId = customAlphabet(slugIdAlphabet, 12);
+13 -6
View File
@@ -15,22 +15,29 @@ export const buildPageUrl = (
spaceName: string,
pageSlugId: string,
pageTitle?: string,
anchor?: string,
): string => {
let url: string;
if (spaceName === undefined) {
return `/p/${buildPageSlug(pageSlugId, pageTitle)}`;
url = `/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} else {
url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
}
return `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
return anchor ? `${url}#${anchor}` : url;
};
export const buildSharedPageUrl = (opts: {
shareId: string;
pageSlugId: string;
pageTitle?: string;
anchor?: string;
}): string => {
const { shareId, pageSlugId, pageTitle } = opts;
const { shareId, pageSlugId, pageTitle, anchor } = opts;
let url: string;
if (!shareId) {
return `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} else {
url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
}
return `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
return anchor ? `${url}#${anchor}` : url;
};
@@ -8,20 +8,11 @@ import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { useAtom } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { UserRole } from "@/lib/types.ts";
import { useIsEEOnly } from "@/hooks/use-is-cloud-ee.tsx";
export default function SpaceList() {
const { t } = useTranslation();
const [page, setPage] = useState(1);
const [user] = useAtom(userAtom);
const isEEOnly = useIsEEOnly();
const { data, isLoading } = useGetSpacesQuery({
page,
...(isEEOnly && user.role === UserRole.OWNER && { includeAllSpaces: true }),
});
const { data, isLoading } = useGetSpacesQuery({ page });
const [opened, { open, close }] = useDisclosure(false);
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
+1 -10
View File
@@ -1,16 +1,7 @@
import { isCloud } from "@/lib/config";
import { useLicense } from "@/ee/hooks/use-license";
import { useAtom } from "jotai/index";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import usePlan from "@/ee/hooks/use-plan";
export const useIsCloudEE = () => {
const { hasLicenseKey } = useLicense();
return isCloud() || !!hasLicenseKey;
};
export const useIsEEOnly = () => {
const { hasLicenseKey } = useLicense();
const { isBusiness } = usePlan();
return (isCloud() && isBusiness) || !!hasLicenseKey;
};
};
+4 -2
View File
@@ -1,4 +1,6 @@
export const INTERNAL_LINK_REGEX =
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?(?:#(.*))?$/;
export const FIVE_MINUTES = 5 * 60 * 1000;
export const FIVE_MINUTES = 5 * 60 * 1000;
//export const INTERNAL_LINK_REGEX =
// /^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
-1
View File
@@ -2,7 +2,6 @@ export interface QueryParams {
query?: string;
page?: number;
limit?: number;
includeAllSpaces?: boolean;
}
export enum UserRole {
@@ -8,12 +8,14 @@ import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
import { extractPageSlugId } from "@/lib";
import { Error404 } from "@/components/ui/error-404.tsx";
import ShareBranding from "@/features/share/components/share-branding.tsx";
import { useAnchorScroll } from "@/features/editor/components/heading/use-anchor-scroll";
export default function SharedPage() {
const { t } = useTranslation();
const { pageSlug } = useParams();
const { shareId } = useParams();
const navigate = useNavigate();
useAnchorScroll();
const { data, isLoading, isError, error } = useSharePageQuery({
pageId: extractPageSlugId(pageSlug),
@@ -10,6 +10,7 @@ import { Typography } from '@tiptap/extension-typography';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
import { Youtube } from '@tiptap/extension-youtube';
import UniqueID from '@tiptap/extension-unique-id';
import {
Callout,
Comment,
@@ -33,6 +34,7 @@ import {
Embed,
Mention,
Subpages,
HeadingAnchors
} from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML } from '../common/helpers/prosemirror/html';
@@ -45,7 +47,9 @@ import { Node } from '@tiptap/pm/model';
export const tiptapExtensions = [
StarterKit.configure({
codeBlock: false,
heading: false,
}),
HeadingAnchors,
Comment,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
TaskList,
@@ -81,6 +85,10 @@ export const tiptapExtensions = [
Embed,
Mention,
Subpages,
UniqueID.configure({
types: ['heading'],
attributeName: 'nodeId',
}),
] as any;
export function jsonToHtml(tiptapJson: any) {
@@ -10,7 +10,7 @@ import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
import { SpaceRole, UserRole } from '../../common/helpers/types/permission';
import { SpaceRole } from '../../common/helpers/types/permission';
import { getPageId } from '../collaboration.util';
import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload';
@@ -63,10 +63,7 @@ export class AuthenticationExtension implements Extension {
const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles);
// if role not found but user is a workspace owner, grant them readonly permission
if (!userSpaceRole && user.role === UserRole.OWNER) {
data.connection.readOnly = true;
} else if (!userSpaceRole) {
if (!userSpaceRole) {
this.logger.warn(`User not authorized to access page: ${pageId}`);
throw new UnauthorizedException();
}
@@ -4,7 +4,7 @@ import {
createMongoAbility,
MongoAbility,
} from '@casl/ability';
import { SpaceRole, UserRole } from '../../../common/helpers/types/permission';
import { SpaceRole } from '../../../common/helpers/types/permission';
import { User } from '@docmost/db/types/entity.types';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import {
@@ -25,17 +25,13 @@ export default class SpaceAbilityFactory {
const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles);
if (!userSpaceRole && user.role === UserRole.OWNER) {
return buildWorkspaceOwnerAbility();
}
switch (userSpaceRole) {
case SpaceRole.ADMIN:
return buildSpaceAdminAbility();
case SpaceRole.WRITER:
return buildSpaceWriterAbility(user.role);
return buildSpaceWriterAbility();
case SpaceRole.READER:
return buildSpaceReaderAbility(user.role);
return buildSpaceReaderAbility();
default:
throw new NotFoundException('Space permissions not found');
}
@@ -53,50 +49,23 @@ function buildSpaceAdminAbility() {
return build();
}
function buildSpaceWriterAbility(workspaceRole?: string) {
function buildSpaceWriterAbility() {
const { can, build } = new AbilityBuilder<MongoAbility<ISpaceAbility>>(
createMongoAbility,
);
if (workspaceRole === UserRole.OWNER) {
// Workspace owners get manage permissions even with writer space role
can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Member);
} else {
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
}
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Share);
return build();
}
function buildSpaceReaderAbility(workspaceRole?: string) {
function buildSpaceReaderAbility() {
const { can, build } = new AbilityBuilder<MongoAbility<ISpaceAbility>>(
createMongoAbility,
);
if (workspaceRole === UserRole.OWNER) {
// Workspace owners get manage permissions even with reader space role
can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Member);
} else {
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
}
can(SpaceCaslAction.Read, SpaceCaslSubject.Page);
can(SpaceCaslAction.Read, SpaceCaslSubject.Share);
return build();
}
function buildWorkspaceOwnerAbility() {
const { can, build } = new AbilityBuilder<MongoAbility<ISpaceAbility>>(
createMongoAbility,
);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Member);
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
can(SpaceCaslAction.Read, SpaceCaslSubject.Page);
can(SpaceCaslAction.Read, SpaceCaslSubject.Share);
return build();
@@ -279,14 +279,4 @@ export class SpaceMemberService {
): Promise<PaginationResult<Space>> {
return await this.spaceMemberRepo.getUserSpaces(userId, pagination);
}
async getAllWorkspaceSpaces(
workspaceId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Space>> {
return await this.spaceMemberRepo.getAllWorkspaceSpaces(
workspaceId,
pagination,
);
}
}
+1 -15
View File
@@ -34,7 +34,6 @@ import {
} from '../casl/interfaces/workspace-ability.type';
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
import { CreateSpaceDto } from './dto/create-space.dto';
import { SpaceRole, UserRole } from '../../common/helpers/types/permission';
@UseGuards(JwtAuthGuard)
@Controller('spaces')
@@ -53,17 +52,7 @@ export class SpaceController {
@Body()
pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
if (pagination.includeAllSpaces) {
if (user.role !== UserRole.OWNER) {
throw new ForbiddenException('Only workspace owners view all spaces');
}
return this.spaceMemberService.getAllWorkspaceSpaces(
workspace.id,
pagination,
);
}
return this.spaceMemberService.getUserSpaces(user.id, pagination);
}
@@ -93,10 +82,7 @@ export class SpaceController {
space.id,
);
let userSpaceRole = findHighestUserSpaceRole(userSpaceRoles);
if (!userSpaceRole && user.role === UserRole.OWNER) {
userSpaceRole = SpaceRole.READER;
}
const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles);
const membership = {
userId: user.id,
@@ -1,5 +1,4 @@
import {
IsBoolean,
IsNumber,
IsOptional,
IsPositive,
@@ -24,9 +23,4 @@ export class PaginationOptions {
@IsOptional()
@IsString()
query: string;
//for space endpoint workspace owners
@IsOptional()
@IsBoolean()
includeAllSpaces?: boolean;
}
@@ -263,37 +263,4 @@ export class SpaceMemberRepo {
return result;
}
async getAllWorkspaceSpaces(
workspaceId: string,
pagination: PaginationOptions,
) {
let query = this.db
.selectFrom('spaces')
.selectAll()
.select((eb) => [this.spaceRepo.withMemberCount(eb)])
.where('workspaceId', '=', workspaceId)
.orderBy('createdAt', 'asc');
if (pagination.query) {
query = query.where((eb) =>
eb(
sql`f_unaccent(name)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
).or(
sql`f_unaccent(description)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
),
);
}
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
return result;
}
}
+1
View File
@@ -56,6 +56,7 @@
"@tiptap/extension-text-style": "^2.10.3",
"@tiptap/extension-typography": "^2.10.3",
"@tiptap/extension-underline": "^2.10.3",
"@tiptap/extension-unique-id": "^2.23.0",
"@tiptap/extension-youtube": "^2.10.3",
"@tiptap/html": "^2.10.3",
"@tiptap/pm": "^2.10.3",
+1
View File
@@ -20,3 +20,4 @@ export * from "./lib/markdown";
export * from "./lib/search-and-replace";
export * from "./lib/embed-provider";
export * from "./lib/subpages";
export * from "./lib/heading";
@@ -0,0 +1,80 @@
import Heading from "@tiptap/extension-heading";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { mergeAttributes } from "@tiptap/core";
import { buildAnchorDecorations } from './utils';
const HEADING_ANCHORS_PLUGIN_KEY = new PluginKey("heading-anchors");
export const HeadingAnchors = Heading.extend({
renderHTML({ node, HTMLAttributes }) {
const hasLevel = this.options.levels.includes(node.attrs.level);
const level = hasLevel ? node.attrs.level : this.options.levels[0];
return [
`h${level}`,
mergeAttributes(HTMLAttributes, {
class: "heading-block",
}),
0,
];
},
addProseMirrorPlugins() {
return [
...(this.parent?.() || []),
new Plugin({
key: HEADING_ANCHORS_PLUGIN_KEY,
state: {
init(_, { doc }) {
return buildAnchorDecorations(doc);
},
apply(tr, oldState, _, newState) {
if (!tr.docChanged) {
return oldState.map(tr.mapping, tr.doc);
}
let headingsChanged = false;
tr.steps.forEach((step) => {
step.getMap().forEach((oldStart, oldEnd, newStart, newEnd) => {
// Check both old and new document ranges for headings
const checkRange = (
doc: ProseMirrorNode,
from: number,
to: number,
) => {
doc.nodesBetween(from, to, (node) => {
if (node.type.name === 'heading') {
headingsChanged = true;
return false;
}
});
};
if (tr.docs[0]) {
checkRange(tr.docs[0], oldStart, oldEnd);
}
checkRange(newState.doc, newStart, newEnd);
});
});
if (headingsChanged) {
return buildAnchorDecorations(newState.doc);
}
return oldState.map(tr.mapping, tr.doc);
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
}),
];
},
});
export default HeadingAnchors;
@@ -0,0 +1 @@
export { HeadingAnchors } from "./heading-anchors";
@@ -0,0 +1,100 @@
import { Node as ProseMirrorNode } from "prosemirror-model";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import slugify from "@sindresorhus/slugify";
const textToSlug = (text: string): string => {
return slugify(text?.substring(0, 20));
};
function buildAnchorId(node: ProseMirrorNode): string {
const text = node.textContent;
const nodeId = node.attrs.nodeId;
if (!text) return "";
if (nodeId) {
const slug = textToSlug(text);
return slug ? `${slug}-${nodeId}` : nodeId;
}
return textToSlug(text);
}
function createAnchorLink(id: string): HTMLElement {
const wrapper = document.createElement("span");
wrapper.className = "heading-anchor-wrapper";
const button = document.createElement("button");
button.className = "heading-anchor-button";
button.setAttribute("aria-label", "Copy link to this section");
button.setAttribute("contenteditable", "false");
button.innerHTML = `
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"/>
</svg>
`;
button.addEventListener("mousedown", (e) => {
e.preventDefault();
e.stopPropagation();
});
button.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const url = new URL(window.location.href);
url.hash = id;
navigator.clipboard.writeText(url.toString()).then(() => {
const originalHTML = button.innerHTML;
button.innerHTML = `
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
</svg>
`;
button.classList.add("copied");
setTimeout(() => {
button.innerHTML = originalHTML;
button.classList.remove("copied");
}, 2000);
});
});
wrapper.appendChild(button);
return wrapper;
}
export function buildAnchorDecorations(doc: ProseMirrorNode): DecorationSet {
const decorations: Decoration[] = [];
doc.descendants((node, pos) => {
if (node.type.name !== "heading" || !node.textContent) {
return;
}
const anchorId = buildAnchorId(node);
if (!anchorId) return;
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
id: anchorId,
class: "has-anchor",
"data-anchor-id": anchorId,
}),
);
if (node.content.size > 0) {
const lastChildEnd = pos + 1 + node.content.size;
decorations.push(
Decoration.widget(lastChildEnd, createAnchorLink(anchorId), {
side: 0,
key: `anchor-${anchorId}`,
}),
);
}
});
return DecorationSet.create(doc, decorations);
}
+19
View File
@@ -33,6 +33,11 @@ export interface MentionNodeAttrs {
* the id of the user who initiated the mention
*/
creatorId?: string;
/**
* the anchor hash for page mentions (e.g., "heading-1")
*/
anchor?: string;
}
export type MentionOptions<
@@ -246,6 +251,20 @@ export const Mention = Node.create<MentionOptions>({
};
},
},
anchor: {
default: null,
parseHTML: (element) => element.getAttribute("data-anchor"),
renderHTML: (attributes) => {
if (!attributes.anchor) {
return {};
}
return {
"data-anchor": attributes.anchor,
};
},
},
};
},
+21
View File
@@ -130,6 +130,9 @@ importers:
'@tiptap/extension-underline':
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-unique-id':
specifier: ^2.23.0
version: 2.25.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
'@tiptap/extension-youtube':
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
@@ -4257,6 +4260,12 @@ packages:
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-unique-id@2.25.0':
resolution: {integrity: sha512-D45xSQ6H4v5agVCnv6l/TGQt4coDSo+Xbg2/CrP8UNYomVbPNFDmtDHL4Tyoq5HAa9HpMskVpWmJAmNJUH6f9A==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/extension-youtube@2.14.0':
resolution: {integrity: sha512-kryHjsjlIV2B6rS0Mnv9AqAyCCaeNWE1XDAWyYfhWQSmQkfaxSZU3rMnh3BMvSsVsdv5mtyxyBqBTrQA2sBSaw==}
peerDependencies:
@@ -9581,6 +9590,10 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
hasBin: true
uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
@@ -14287,6 +14300,12 @@ snapshots:
dependencies:
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
'@tiptap/extension-unique-id@2.25.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)':
dependencies:
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
'@tiptap/pm': 2.14.0
uuid: 10.0.0
'@tiptap/extension-youtube@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))':
dependencies:
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
@@ -20591,6 +20610,8 @@ snapshots:
utils-merge@1.0.1: {}
uuid@10.0.0: {}
uuid@11.1.0: {}
uuid@9.0.1: {}