feat: anchor links (#1765)

* feat: add heading extension with unique ID support and scroll functionality
* Added unique id for heading
* remove baseUrl heading storage
* move heading to extensions package
* WIP
* support anchors in mentions
* enhance scrolling functionality
* nodeId function
* fix nanoid import
* Bring unique-id extension local
* fixes
* fix internal link scroll in public pages
* add unique id server side
* rename mention anchor to anchorId
* capture first anchorId on paste

---------

Co-authored-by: Romik <40670677+RomikMakavana@users.noreply.github.com>
This commit is contained in:
Philip Okugbe
2025-12-06 14:46:54 +00:00
committed by GitHub
parent 9139d393ef
commit d2629afff2
24 changed files with 802 additions and 27 deletions
@@ -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;
}
@@ -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;
@@ -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}
@@ -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;
@@ -0,0 +1,58 @@
import { Editor } from "@tiptap/react";
import { useCallback, useEffect, useState } from "react";
function waitForState(checkFn: () => boolean): Promise<void> {
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<string>(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 };
};
@@ -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 }) {
@@ -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;
}
}}
></EditorProvider>
@@ -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 {
@@ -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]);
+13 -6
View File
@@ -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;
};
+2 -2
View File
@@ -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;
export const FIVE_MINUTES = 5 * 60 * 1000;
+1
View File
@@ -21,6 +21,7 @@ const MemoizedHistoryModal = React.memo(HistoryModal);
export default function Page() {
const { t } = useTranslation();
const { pageSlug } = useParams();
const {
data: page,
isLoading,
@@ -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) {
+2
View File
@@ -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";
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols Light by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M10.616 16.077H7.077q-1.692 0-2.884-1.192T3 12t1.193-2.885t2.884-1.193h3.539v1H7.077q-1.27 0-2.173.904Q4 10.731 4 12t.904 2.173t2.173.904h3.539zM8.5 12.5v-1h7v1zm4.885 3.577v-1h3.538q1.27 0 2.173-.904Q20 13.269 20 12t-.904-2.173t-2.173-.904h-3.538v-1h3.538q1.692 0 2.885 1.192T21 12t-1.193 2.885t-2.884 1.193z"/></svg>`;
const successIcon = `<svg xmlns="http://www.w3.org/2000/svg" style="color: forestgreen;" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="m10.6 16.6l7.05-7.05l-1.4-1.4l-5.65 5.65l-2.85-2.85l-1.4 1.4zM12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22"/></svg>`;
export const Heading = TiptapHeading.extend<TiptapHeadingOptions>({
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 = "&nbsp;";
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,
];
},
});
+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")
*/
anchorId?: string;
}
export type MentionOptions<
@@ -246,6 +251,20 @@ export const Mention = Node.create<MentionOptions>({
};
},
},
anchorId: {
default: null,
parseHTML: (element) => element.getAttribute("data-anchor-id"),
renderHTML: (attributes) => {
if (!attributes.anchorId) {
return {};
}
return {
"data-anchor-id": attributes.anchorId,
};
},
},
};
},
@@ -64,6 +64,12 @@ export const TrailingNode = Extension.create<TrailingNodeExtensionOptions>({
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 })
},
@@ -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
}
@@ -0,0 +1,15 @@
/**
* Removes duplicated values within an array.
* Supports numbers, strings and objects.
*/
export function removeDuplicates<T>(array: T[], by = JSON.stringify): T[] {
const seen: Record<any, any> = {}
return array.filter(item => {
const key = by(item)
return Object.prototype.hasOwnProperty.call(seen, key)
? false
: (seen[key] = true)
})
}
@@ -0,0 +1,2 @@
export { UniqueID } from "./unique-id";
export * from "./unique-id.util";
@@ -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<UniqueIDOptions>({
name: "uniqueID",
// well 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 doesnt 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;
},
},
// well 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) => {
// dont 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,
);
},
},
}),
];
},
});
@@ -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();
}
+6 -2
View File
@@ -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);
+1
View File
@@ -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==}