diff --git a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx index 3a15e95d..8eee02fc 100644 --- a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx +++ b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx @@ -34,7 +34,9 @@ export const handlePaste = ( return false; } - createMentionAction(url, view, pos, creatorId); + const anchorId = match[6] ? match[6].split('#')[0] : undefined; + const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url; + createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId); return true; } diff --git a/apps/client/src/features/editor/components/link/internal-link-paste.ts b/apps/client/src/features/editor/components/link/internal-link-paste.ts index ac3fc55a..61aa2b69 100644 --- a/apps/client/src/features/editor/components/link/internal-link-paste.ts +++ b/apps/client/src/features/editor/components/link/internal-link-paste.ts @@ -9,6 +9,7 @@ export type LinkFn = ( view: EditorView, pos: number, creatorId: string, + anchorId?: 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, anchorId) => { const validated = validateFn(url, view); if (!validated) return; @@ -35,6 +36,7 @@ export const handleInternalLink = entityId: page.id, slugId: page.slugId, creatorId: creatorId, + anchorId: anchorId, }); if (!node) return; diff --git a/apps/client/src/features/editor/components/mention/mention-view.tsx b/apps/client/src/features/editor/components/mention/mention-view.tsx index d42e4a8c..c71c9e50 100644 --- a/apps/client/src/features/editor/components/mention/mention-view.tsx +++ b/apps/client/src/features/editor/components/mention/mention-view.tsx @@ -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, anchorId } = node.attrs; const { spaceSlug } = useParams(); const { shareId } = useParams(); const { @@ -27,6 +27,7 @@ export default function MentionView(props: NodeViewProps) { shareId, pageSlugId: slugId, pageTitle: label, + anchorId, }); 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, anchorId) } underline="never" className={classes.pageMentionLink} diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index e331e9b9..ecdea2e7 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -14,7 +14,7 @@ import { Color } from "@tiptap/extension-color"; import GlobalDragHandle from "tiptap-extension-global-drag-handle"; import { Youtube } from "@tiptap/extension-youtube"; 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 { @@ -43,7 +43,9 @@ import { Mention, Subpages, TableDndExtension, - Highlight + Heading, + Highlight, + UniqueID, } from "@docmost/editor-ext"; import { randomElement, @@ -94,6 +96,7 @@ lowlight.register("scala", scala); export const mainExtensions = [ StarterKit.configure({ + heading: false, history: false, dropcursor: { width: 3, @@ -106,6 +109,11 @@ export const mainExtensions = [ }, }, }), + Heading, + UniqueID.configure({ + types: ["heading", "paragraph"], + filterTransaction: (transaction) => !isChangeOrigin(transaction), + }), Placeholder.configure({ placeholder: ({ node }) => { if (node.type.name === "heading") { @@ -230,17 +238,17 @@ export const mainExtensions = [ SearchAndReplace.extend({ addKeyboardShortcuts() { return { - 'Mod-f': () => { + "Mod-f": () => { const event = new CustomEvent("openFindDialogFromEditor", {}); document.dispatchEvent(event); return true; }, - 'Escape': () => { + Escape: () => { const event = new CustomEvent("closeFindDialogFromEditor", {}); document.dispatchEvent(event); return true; }, - } + }; }, }).configure(), ] as any; diff --git a/apps/client/src/features/editor/hooks/use-editor-scroll.ts b/apps/client/src/features/editor/hooks/use-editor-scroll.ts new file mode 100644 index 00000000..31c357a0 --- /dev/null +++ b/apps/client/src/features/editor/hooks/use-editor-scroll.ts @@ -0,0 +1,58 @@ +import { Editor } from "@tiptap/react"; +import { useCallback, useEffect, useState } from "react"; + +function waitForState(checkFn: () => boolean): Promise { + return new Promise((resolve) => { + const interval = setInterval(() => { + if (checkFn()) { + clearInterval(interval); + resolve(); + } + }, 800); + }); +} + +export const useEditorScroll = ({ + canScroll, + initialScrollTo, +}: { + canScroll: () => boolean; + initialScrollTo?: string; +}) => { + const [scrollTo, setScrollTo] = useState(initialScrollTo || ""); + + useEffect(() => { + if (!initialScrollTo) { + setScrollTo(window.location.hash ? window.location.hash.slice(1) : ""); + } + }, [initialScrollTo]); + + const handleScrollTo = useCallback(async (editor: Editor, _scrollTo: string | null = null, tryCount: number = 0) => { + await waitForState(() => canScroll()); + return new Promise((resolve) => { + const MAX_TRY_COUNT = 10; + if (tryCount >= MAX_TRY_COUNT) { + resolve(false); + return; + } + + const targetId = _scrollTo || scrollTo; + if (!targetId) { + resolve(false); + return; + } + + const dom = editor.view.dom.querySelector(`[id="${targetId}"]`); + if (dom) { + dom.scrollIntoView({ behavior: 'smooth', block: 'start' }); + resolve(true); + } else { + setTimeout(async () => { + resolve(await handleScrollTo(editor, targetId, tryCount + 1)); + }, 200); + } + }); + }, [scrollTo, canScroll]); + + return { scrollTo, handleScrollTo }; +}; diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index a2dc0d93..b4478920 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -1,5 +1,5 @@ import "@/features/editor/styles/index.css"; -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { IndexeddbPersistence } from "y-indexeddb"; import * as Y from "yjs"; import { @@ -56,6 +56,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 { useEditorScroll } from "./hooks/use-editor-scroll"; interface PageEditorProps { pageId: string; @@ -68,7 +69,16 @@ export default function PageEditor({ editable, content, }: PageEditorProps) { + + const collaborationURL = useCollaborationUrl(); + const isComponentMounted = useRef(false); + const editorCreated = useRef(false); + + useEffect(() => { + isComponentMounted.current = true; + }, []); + const [currentUser] = useAtom(currentUserAtom); const [, setEditor] = useAtom(pageEditorAtom); const [, setAsideState] = useAtom(asideStateAtom); @@ -94,7 +104,9 @@ export default function PageEditor({ const slugId = extractPageSlugId(pageSlug); const userPageEditMode = currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; - + + const canScroll = useCallback(() => isComponentMounted.current && editorCreated.current, [isComponentMounted, editorCreated]); + const { handleScrollTo } = useEditorScroll({ canScroll }); // Providers only created once per pageId const providersRef = useRef<{ local: IndexeddbPersistence; @@ -264,6 +276,8 @@ export default function PageEditor({ // @ts-ignore setEditor(editor); editor.storage.pageId = pageId; + handleScrollTo(editor); + editorCreated.current = true; } }, onUpdate({ editor }) { diff --git a/apps/client/src/features/editor/readonly-page-editor.tsx b/apps/client/src/features/editor/readonly-page-editor.tsx index c1352354..c81e4d19 100644 --- a/apps/client/src/features/editor/readonly-page-editor.tsx +++ b/apps/client/src/features/editor/readonly-page-editor.tsx @@ -1,13 +1,14 @@ import "@/features/editor/styles/index.css"; -import React, { useMemo } from "react"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; import { EditorProvider } from "@tiptap/react"; import { mainExtensions } from "@/features/editor/extensions/extensions"; import { Document } from "@tiptap/extension-document"; -import { Heading } from "@tiptap/extension-heading"; +import { Heading, generateNodeId, UniqueID } from "@docmost/editor-ext"; import { Text } from "@tiptap/extension-text"; import { Placeholder } from "@tiptap/extension-placeholder"; import { useAtom } from "jotai"; import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; +import { useEditorScroll } from "./hooks/use-editor-scroll"; interface PageEditorProps { title: string; @@ -21,9 +22,34 @@ export default function ReadonlyPageEditor({ pageId, }: PageEditorProps) { const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom); + const isComponentMounted = useRef(false); + const editorCreated = useRef(false); + + const canScroll = useCallback( + () => isComponentMounted.current && editorCreated.current, + [isComponentMounted, editorCreated], + ); + const initialScrollTo = window.location.hash + ? window.location.hash.slice(1) + : ""; + const { handleScrollTo } = useEditorScroll({ canScroll, initialScrollTo }); + + useEffect(() => { + isComponentMounted.current = true; + }, []); const extensions = useMemo(() => { - return [...mainExtensions]; + const filteredExtensions = mainExtensions.filter( + (ext) => ext.name !== "uniqueID", + ); + + return [ + ...filteredExtensions, + UniqueID.configure({ + types: ["heading", "paragraph"], + updateDocument: false, + }), + ]; }, []); const titleExtensions = [ @@ -59,6 +85,9 @@ export default function ReadonlyPageEditor({ } // @ts-ignore setReadOnlyEditor(editor); + + handleScrollTo(editor); + editorCreated.current = true; } }} > diff --git a/apps/client/src/features/editor/styles/core.css b/apps/client/src/features/editor/styles/core.css index 10480b8e..0aed878e 100644 --- a/apps/client/src/features/editor/styles/core.css +++ b/apps/client/src/features/editor/styles/core.css @@ -186,6 +186,39 @@ margin-left: auto; margin-right: auto; } + +} + +.ProseMirror > h1, +.ProseMirror > h2, +.ProseMirror > h3, +.ProseMirror > h4, +.ProseMirror > h5, +.ProseMirror > h6 { + + > .link-btn { + cursor: pointer; + position: relative; + + } + + > .link-btn > .link-btn-content { + opacity: 0; + position: absolute; + left: 5px; + top: 0; + height: 100%; + transition: opacity 0.15s ease; + display: inline-flex; + justify-content: center; + flex-direction: column; + } + + &:hover > .link-btn > .link-btn-content { + opacity: 1; + } + + scroll-margin-top: 80px; /* match your header height */ } .ProseMirror-icon { diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx index 73bb48e9..c3610394 100644 --- a/apps/client/src/features/editor/title-editor.tsx +++ b/apps/client/src/features/editor/title-editor.tsx @@ -104,7 +104,10 @@ export function TitleEditor({ }); useEffect(() => { - const pageSlug = buildPageUrl(spaceSlug, slugId, title); + const anchorId = window.location.hash + ? window.location.hash.substring(1) + : undefined; + const pageSlug = buildPageUrl(spaceSlug, slugId, title, anchorId); navigate(pageSlug, { replace: true }); }, [title]); diff --git a/apps/client/src/features/page/page.utils.ts b/apps/client/src/features/page/page.utils.ts index 3ae65aab..06d0ca8d 100644 --- a/apps/client/src/features/page/page.utils.ts +++ b/apps/client/src/features/page/page.utils.ts @@ -15,22 +15,29 @@ export const buildPageUrl = ( spaceName: string, pageSlugId: string, pageTitle?: string, + anchorId?: 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 anchorId ? `${url}#${anchorId}` : url; }; export const buildSharedPageUrl = (opts: { shareId: string; pageSlugId: string; pageTitle?: string; + anchorId?: string; }): string => { - const { shareId, pageSlugId, pageTitle } = opts; + const { shareId, pageSlugId, pageTitle, anchorId } = 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 anchorId ? `${url}#${anchorId}` : url; }; diff --git a/apps/client/src/lib/constants.ts b/apps/client/src/lib/constants.ts index eb8c9105..69ebfd4e 100644 --- a/apps/client/src/lib/constants.ts +++ b/apps/client/src/lib/constants.ts @@ -1,4 +1,4 @@ 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; \ No newline at end of file +export const FIVE_MINUTES = 5 * 60 * 1000; diff --git a/apps/client/src/pages/page/page.tsx b/apps/client/src/pages/page/page.tsx index 65a4f64f..a1dfee6f 100644 --- a/apps/client/src/pages/page/page.tsx +++ b/apps/client/src/pages/page/page.tsx @@ -21,6 +21,7 @@ const MemoizedHistoryModal = React.memo(HistoryModal); export default function Page() { const { t } = useTranslation(); const { pageSlug } = useParams(); + const { data: page, isLoading, diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 355b4bd7..06133c3f 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -10,6 +10,7 @@ import { TextStyle } from '@tiptap/extension-text-style'; import { Color } from '@tiptap/extension-color'; import { Youtube } from '@tiptap/extension-youtube'; import { + Heading, Callout, Comment, CustomCodeBlock, @@ -32,7 +33,9 @@ import { Embed, Mention, Subpages, - Highlight + Highlight, + UniqueID, + addUniqueIdsToDoc, } from '@docmost/editor-ext'; import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; @@ -44,6 +47,11 @@ import { Node } from '@tiptap/pm/model'; export const tiptapExtensions = [ StarterKit.configure({ codeBlock: false, + heading: false, + }), + Heading, + UniqueID.configure({ + types: ['heading', 'paragraph'], }), Comment, TextAlign.configure({ types: ['heading', 'paragraph'] }), @@ -87,7 +95,14 @@ export function jsonToHtml(tiptapJson: any) { } export function htmlToJson(html: string) { - return generateJSON(html, tiptapExtensions); + const pmJson = generateJSON(html, tiptapExtensions); + + try { + return addUniqueIdsToDoc(pmJson, tiptapExtensions); + } catch (error) { + console.warn('failed to add unique ids to doc', error); + return pmJson; + } } export function jsonToText(tiptapJson: JSONContent) { diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index 04b99566..3ff99083 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -21,3 +21,5 @@ export * from "./lib/search-and-replace"; export * from "./lib/embed-provider"; export * from "./lib/subpages"; export * from "./lib/highlight"; +export * from "./lib/heading/heading"; +export * from "./lib/unique-id"; diff --git a/packages/editor-ext/src/lib/heading/heading.ts b/packages/editor-ext/src/lib/heading/heading.ts new file mode 100644 index 00000000..909524fd --- /dev/null +++ b/packages/editor-ext/src/lib/heading/heading.ts @@ -0,0 +1,78 @@ +import TiptapHeading, { + HeadingOptions as TiptapHeadingOptions, +} from "@tiptap/extension-heading"; +import { mergeAttributes } from "@tiptap/react"; +import { Decoration, DecorationSet } from "prosemirror-view"; +import { Plugin } from "prosemirror-state"; + +const copyIcon = ``; +const successIcon = ``; + +export const Heading = TiptapHeading.extend({ + addProseMirrorPlugins() { + return [ + new Plugin({ + props: { + decorations(state) { + const decorations: Decoration[] = []; + const { doc } = state; + + doc.descendants((node, pos) => { + if (node.type.name === "heading" && node.content.size > 0) { + const deco = Decoration.widget( + pos + node.nodeSize - 1, + () => { + const icon = document.createElement("span"); + icon.classList.add("link-btn"); + icon.innerHTML = " "; + icon.contentEditable = "false"; + + const linkBtnContent = document.createElement("span"); + linkBtnContent.classList.add("link-btn-content"); + linkBtnContent.innerHTML = copyIcon; + icon.appendChild(linkBtnContent); + + icon.addEventListener("mousedown", (e) => + e.preventDefault(), + ); + icon.addEventListener("click", (e) => { + e.stopPropagation(); + e.preventDefault(); + const id = node.attrs.id; + const baseUrl = window.location.href.split('#')[0]; + const url = `${baseUrl}#${id}`; + navigator.clipboard.writeText(url); + linkBtnContent.innerHTML = successIcon; + setTimeout( + () => (linkBtnContent.innerHTML = copyIcon), + 2000, + ); + }); + + return icon; + }, + { side: 1 }, // render after node content + ); + decorations.push(deco); + } + }); + + return DecorationSet.create(doc, decorations); + }, + }, + }), + ]; + }, + 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(this.options.HTMLAttributes, HTMLAttributes, { + id: node.attrs.id, + }), + 0, + ]; + }, +}); diff --git a/packages/editor-ext/src/lib/mention.ts b/packages/editor-ext/src/lib/mention.ts index edbf9c11..8d19ebcd 100644 --- a/packages/editor-ext/src/lib/mention.ts +++ b/packages/editor-ext/src/lib/mention.ts @@ -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") + */ + anchorId?: string; } export type MentionOptions< @@ -246,6 +251,20 @@ export const Mention = Node.create({ }; }, }, + + anchorId: { + default: null, + parseHTML: (element) => element.getAttribute("data-anchor-id"), + renderHTML: (attributes) => { + if (!attributes.anchorId) { + return {}; + } + + return { + "data-anchor-id": attributes.anchorId, + }; + }, + }, }; }, diff --git a/packages/editor-ext/src/lib/trailing-node.ts b/packages/editor-ext/src/lib/trailing-node.ts index f3a67cfa..a4d77b3d 100644 --- a/packages/editor-ext/src/lib/trailing-node.ts +++ b/packages/editor-ext/src/lib/trailing-node.ts @@ -64,6 +64,12 @@ export const TrailingNode = Extension.create({ return value } + // Ignore transactions from UniqueID extension to prevent infinite loops + // when UniqueID adds IDs to newly inserted trailing nodes + if (tr.getMeta('__uniqueIDTransaction')) { + return value + } + const lastNode = tr.doc.lastChild return !nodeEqualsType({ node: lastNode, types: disabledNodes }) }, diff --git a/packages/editor-ext/src/lib/unique-id/helpers/findDuplicates.ts b/packages/editor-ext/src/lib/unique-id/helpers/findDuplicates.ts new file mode 100644 index 00000000..d193e8b3 --- /dev/null +++ b/packages/editor-ext/src/lib/unique-id/helpers/findDuplicates.ts @@ -0,0 +1,11 @@ +import { removeDuplicates } from './removeDuplicates.js' + +/** + * Returns a list of duplicated items within an array. + */ +export function findDuplicates(items: any[]): any[] { + const filtered = items.filter((el, index) => items.indexOf(el) !== index) + const duplicates = removeDuplicates(filtered) + + return duplicates +} diff --git a/packages/editor-ext/src/lib/unique-id/helpers/removeDuplicates.ts b/packages/editor-ext/src/lib/unique-id/helpers/removeDuplicates.ts new file mode 100644 index 00000000..2bae38fd --- /dev/null +++ b/packages/editor-ext/src/lib/unique-id/helpers/removeDuplicates.ts @@ -0,0 +1,15 @@ +/** + * Removes duplicated values within an array. + * Supports numbers, strings and objects. + */ +export function removeDuplicates(array: T[], by = JSON.stringify): T[] { + const seen: Record = {} + + return array.filter(item => { + const key = by(item) + + return Object.prototype.hasOwnProperty.call(seen, key) + ? false + : (seen[key] = true) + }) +} diff --git a/packages/editor-ext/src/lib/unique-id/index.ts b/packages/editor-ext/src/lib/unique-id/index.ts new file mode 100644 index 00000000..6bd61fbb --- /dev/null +++ b/packages/editor-ext/src/lib/unique-id/index.ts @@ -0,0 +1,2 @@ +export { UniqueID } from "./unique-id"; +export * from "./unique-id.util"; diff --git a/packages/editor-ext/src/lib/unique-id/unique-id.ts b/packages/editor-ext/src/lib/unique-id/unique-id.ts new file mode 100644 index 00000000..6ecf15f0 --- /dev/null +++ b/packages/editor-ext/src/lib/unique-id/unique-id.ts @@ -0,0 +1,386 @@ +import { + combineTransactionSteps, + Extension, + findChildren, + findChildrenInRange, + getChangedRanges, +} from "@tiptap/core"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Fragment, Slice } from "@tiptap/pm/model"; +import type { Transaction } from "@tiptap/pm/state"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +import { findDuplicates } from "./helpers/findDuplicates.js"; +import { generateNodeId } from "../utils"; + +export type UniqueIDGenerationContext = { + node: ProseMirrorNode; + pos: number; +}; + +export interface UniqueIDOptions { + /** + * The name of the attribute to add the unique ID to. + * @default "id" + */ + attributeName: string; + /** + * The types of nodes to add unique IDs to. + * @default [] + */ + types: string[]; + /** + * The function that generates the unique ID. By default, a UUID v4 is + * generated. However, you can provide your own function to generate the + * unique ID based on the node type and the position. + */ + generateID: (ctx: UniqueIDGenerationContext) => any; + /** + * Ignore some mutations, for example applied from other users through the collaboration plugin. + * + * @default null + */ + filterTransaction: ((transaction: Transaction) => boolean) | null; + /** + * Whether to update the document by adding unique IDs to the nodes. Set this + * property to `false` if the document is in `readonly` mode, is immutable, or + * you don't want it to be modified. + * + * @default true + */ + updateDocument: boolean; +} + +export const UniqueID = Extension.create({ + name: "uniqueID", + + // we’ll set a very high priority to make sure this runs first + // and is compatible with `appendTransaction` hooks of other extensions + priority: 10000, + + addOptions() { + return { + attributeName: "id", + types: [], + generateID: () => generateNodeId(), + filterTransaction: null, + updateDocument: true, + }; + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + [this.options.attributeName]: { + default: null, + parseHTML: (element) => + element.getAttribute(`data-${this.options.attributeName}`), + renderHTML: (attributes) => { + if (!attributes[this.options.attributeName]) { + return {}; + } + + return { + [`data-${this.options.attributeName}`]: + attributes[this.options.attributeName], + }; + }, + }, + }, + }, + ]; + }, + + // check initial content for missing ids + onCreate() { + if (!this.options.updateDocument) { + return; + } + + const collaboration = this.editor.extensionManager.extensions.find( + (ext) => ext.name === "collaboration", + ); + const collaborationCursor = this.editor.extensionManager.extensions.find( + (ext) => ext.name === "collaborationCursor", + ); + + const collabExtensions = [collaboration, collaborationCursor].filter( + Boolean, + ); + const collab = collabExtensions.find((ext) => ext?.options?.provider); + const provider = collab?.options?.provider; + + const createIds = () => { + const { view, state } = this.editor; + const { tr, doc } = state; + const { types, attributeName, generateID } = this.options; + const nodesWithoutId = findChildren(doc, (node) => { + return ( + types.includes(node.type.name) && node.attrs[attributeName] === null + ); + }); + + nodesWithoutId.forEach(({ node, pos }) => { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + [attributeName]: generateID({ node, pos }), + }); + }); + + tr.setMeta("addToHistory", false); + + view.dispatch(tr); + + if (provider) { + provider.off("synced", createIds); + } + }; + + /** + * We need to handle collaboration a bit different here + * because we can't automatically add IDs when the provider is not yet synced + * otherwise we end up with empty paragraphs + */ + if (collab) { + if (!provider) { + return createIds(); + } + + provider.on("synced", createIds); + } else { + return createIds(); + } + }, + + addProseMirrorPlugins() { + if (!this.options.updateDocument) { + return []; + } + + let dragSourceElement: Element | null = null; + let transformPasted = false; + + return [ + new Plugin({ + key: new PluginKey("uniqueID"), + + appendTransaction: (transactions, oldState, newState) => { + const hasDocChanges = + transactions.some((transaction) => transaction.docChanged) && + !oldState.doc.eq(newState.doc); + const filterTransactions = + this.options.filterTransaction && + transactions.some((tr) => !this.options.filterTransaction?.(tr)); + + const isCollabTransaction = transactions.find((tr) => + tr.getMeta("y-sync$"), + ); + + if (isCollabTransaction) { + return; + } + + if (!hasDocChanges || filterTransactions) { + return; + } + + const { tr } = newState; + + const { types, attributeName, generateID } = this.options; + const transform = combineTransactionSteps( + oldState.doc, + transactions as Transaction[], + ); + const { mapping } = transform; + + // get changed ranges based on the old state + const changes = getChangedRanges(transform); + + changes.forEach(({ newRange }) => { + const newNodes = findChildrenInRange( + newState.doc, + newRange, + (node) => { + return types.includes(node.type.name); + }, + ); + + const newIds = newNodes + .map(({ node }) => node.attrs[attributeName]) + .filter((id) => id !== null); + + newNodes.forEach(({ node, pos }, i) => { + // instead of checking `node.attrs[attributeName]` directly + // we look at the current state of the node within `tr.doc`. + // this helps to prevent adding new ids to the same node + // if the node changed multiple times within one transaction + const id = tr.doc.nodeAt(pos)?.attrs[attributeName]; + + if (id === null) { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + [attributeName]: generateID({ node, pos }), + }); + + return; + } + + const nextNode = newNodes[i + 1]; + + if (nextNode && node.content.size === 0) { + tr.setNodeMarkup(nextNode.pos, undefined, { + ...nextNode.node.attrs, + [attributeName]: id, + }); + newIds[i + 1] = id; + + if (nextNode.node.attrs[attributeName]) { + return; + } + + const generatedId = generateID({ node, pos }); + + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + [attributeName]: generatedId, + }); + newIds[i] = generatedId; + + return tr; + } + + const duplicatedNewIds = findDuplicates(newIds); + + // check if the node doesn’t exist in the old state + const { deleted } = mapping.invert().mapResult(pos); + + const newNode = deleted && duplicatedNewIds.includes(id); + + if (newNode) { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + [attributeName]: generateID({ node, pos }), + }); + } + }); + }); + + if (!tr.steps.length) { + return; + } + + // `tr.setNodeMarkup` resets the stored marks + // so we'll restore them if they exist + tr.setStoredMarks(newState.tr.storedMarks); + + // Mark this transaction as coming from UniqueID + // to prevent infinite loops with other extensions (e.g., TrailingNode) + tr.setMeta("__uniqueIDTransaction", true); + + return tr; + }, + + // we register a global drag handler to track the current drag source element + view(view) { + const handleDragstart = (event: DragEvent) => { + dragSourceElement = view.dom.parentElement?.contains( + event.target as Element, + ) + ? view.dom.parentElement + : null; + }; + + window.addEventListener("dragstart", handleDragstart); + + return { + destroy() { + window.removeEventListener("dragstart", handleDragstart); + }, + }; + }, + + props: { + // `handleDOMEvents` is called before `transformPasted` + // so we can do some checks before + handleDOMEvents: { + // only create new ids for dropped content + // or dropped content while holding `alt` + // or content is dragged from another editor + drop: (view, event) => { + if ( + dragSourceElement !== view.dom.parentElement || + event.dataTransfer?.effectAllowed === "copyMove" || + event.dataTransfer?.effectAllowed === "copy" + ) { + dragSourceElement = null; + transformPasted = true; + } + + return false; + }, + // always create new ids on pasted content + paste: () => { + transformPasted = true; + + return false; + }, + }, + + // we’ll remove ids for every pasted node + // so we can create a new one within `appendTransaction` + transformPasted: (slice) => { + if (!transformPasted) { + return slice; + } + + const { types, attributeName } = this.options; + const removeId = (fragment: Fragment): Fragment => { + const list: ProseMirrorNode[] = []; + + fragment.forEach((node) => { + // don’t touch text nodes + if (node.isText) { + list.push(node); + + return; + } + + // check for any other child nodes + if (!types.includes(node.type.name)) { + list.push(node.copy(removeId(node.content))); + + return; + } + + // remove id + const nodeWithoutId = node.type.create( + { + ...node.attrs, + [attributeName]: null, + }, + removeId(node.content), + node.marks, + ); + + list.push(nodeWithoutId); + }); + + return Fragment.from(list); + }; + + // reset check + transformPasted = false; + + return new Slice( + removeId(slice.content), + slice.openStart, + slice.openEnd, + ); + }, + }, + }), + ]; + }, +}); diff --git a/packages/editor-ext/src/lib/unique-id/unique-id.util.ts b/packages/editor-ext/src/lib/unique-id/unique-id.util.ts new file mode 100644 index 00000000..8d1991ed --- /dev/null +++ b/packages/editor-ext/src/lib/unique-id/unique-id.util.ts @@ -0,0 +1,78 @@ +import type { Extensions, JSONContent } from "@tiptap/core"; +import { findChildren, getSchema } from "@tiptap/core"; +import { Node } from "@tiptap/pm/model"; +import { EditorState } from "@tiptap/pm/state"; +import type { UniqueID } from "./unique-id"; + +/** + * Creates a new document with unique IDs added to the nodes. Does the same + * thing as the UniqueID extension, but without the need to create an `Editor` + * instance. This lets you add unique IDs to the document in the server. + * + * When you call it, include the `UniqueID` extension in the `extensions` array. + * The configuration from the `UniqueID` extension will be picked up + * automatically, including its configuration options like `types` and + * `attributeName`. + * + * @see `UniqueID` extension for more information. + * + * @throws {Error} If the `UniqueID` extension is not found in the extensions array. + * + * @example + * const doc = { + * type: 'doc', + * content: [ + * { type: 'paragraph', content: [{ type: 'text', text: 'Hello, world!' }] } + * ] + * } + * const newDoc = addUniqueIds(doc, [StarterKit, UniqueID.configure({ types: ['paragraph', 'heading'] })]) + * console.log(newDoc) + * // Result: + * // { + * // type: 'doc', + * // content: [ + * // { type: 'paragraph', content: [{ type: 'text', text: 'Hello, world!' }], id: '123' } + * // ] + * // } + * + * @param doc - A Tiptap JSON document to add unique IDs to. + * @param extensions - The extensions to use. Must include the `UniqueID` extension. + * @returns The updated Tiptap JSON document, with the unique IDs added to the nodes. + */ +export function addUniqueIdsToDoc( + doc: JSONContent, + extensions: Extensions, +): JSONContent { + // Find the UniqueID extension in the extensions array. If it's not found, throw an error. + const uniqueIDExtension = extensions.find( + (ext) => ext.name === "uniqueID", + ) as typeof UniqueID | undefined; + if (!uniqueIDExtension) { + throw new Error("UniqueID extension not found in the extensions array"); + } + const { types, attributeName, generateID } = uniqueIDExtension.options; + + // Convert the JSON content to a ProseMirror node + const schema = getSchema([ + ...extensions.filter((ext) => ext.name !== "uniqueID"), + uniqueIDExtension, + ]); + const contentNode = Node.fromJSON(schema, doc); + + // Find nodes that don't have a unique ID + const nodesWithoutId = findChildren(contentNode, (node) => { + return !node.attrs[attributeName] && types.includes(node.type.name); + }); + + // Edit the document to add unique IDs to the nodes that don't have a unique ID + let tr = EditorState.create({ + doc: contentNode, + }).tr; + // eslint-disable-next-line no-restricted-syntax + for (const { node, pos } of nodesWithoutId) { + tr = tr.setNodeAttribute(pos, attributeName, generateID({ node, pos })); + } + + // Return the updated document + return tr.doc.toJSON(); +} diff --git a/packages/editor-ext/src/lib/utils.ts b/packages/editor-ext/src/lib/utils.ts index 31cccc64..a581c581 100644 --- a/packages/editor-ext/src/lib/utils.ts +++ b/packages/editor-ext/src/lib/utils.ts @@ -5,6 +5,7 @@ import { CellSelection, TableMap } from "@tiptap/pm/tables"; import { Node, ResolvedPos } from "@tiptap/pm/model"; import Table from "@tiptap/extension-table"; import { sanitizeUrl as braintreeSanitizeUrl } from "@braintree/sanitize-url"; +import { customAlphabet } from "nanoid"; export const isRectSelected = (rect: any) => (selection: CellSelection) => { const map = TableMap.get(selection.$anchorCell.node(-1)); @@ -383,9 +384,12 @@ export function icon(name: string) { export function sanitizeUrl(url: string | undefined): string { if (!url) return ""; - + const sanitized = braintreeSanitizeUrl(url); - + // Return empty string instead of "about:blank" return sanitized === "about:blank" ? "" : sanitized; } + +const alphabet = "abcdefghijklmnopqrstuvwxyz"; +export const generateNodeId = customAlphabet(alphabet, 12); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8bb41b6c..e04b7d17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9396,6 +9396,7 @@ packages: superagent@9.0.2: resolution: {integrity: sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w==} engines: {node: '>=14.18.0'} + deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net supertest@7.0.0: resolution: {integrity: sha512-qlsr7fIC0lSddmA3tzojvzubYxvlGtzumcdHgPwbFWMISQwL22MhM2Y3LNt+6w9Yyx7559VW5ab70dgphm8qQA==}