Merge branch 'main' into tiptap3-migration

This commit is contained in:
Philipinho
2025-12-13 00:57:13 +00:00
137 changed files with 6089 additions and 1602 deletions
+3
View File
@@ -20,4 +20,7 @@ export * from "./lib/markdown";
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";
export * from "./lib/tippy-bubble-menu";
@@ -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,
];
},
});
+40
View File
@@ -0,0 +1,40 @@
import {
Highlight as TiptapHighlight,
type HighlightOptions,
} from "@tiptap/extension-highlight";
export const Highlight = TiptapHighlight.extend<HighlightOptions>({
addAttributes() {
return {
...this.parent?.(),
color: {
default: null,
parseHTML: (element) =>
element.getAttribute("data-color") || element.style.backgroundColor,
renderHTML: (attributes) => {
if (!attributes.color) {
return {};
}
return {
"data-color": attributes.color,
style: `background-color: ${attributes.color}; color: inherit`,
};
},
},
colorName: {
default: null,
parseHTML: (element) =>
element.getAttribute("data-highlight-color-name") || null,
renderHTML: (attributes) => {
if (!attributes.colorName) {
return {};
}
return {
"data-highlight-color-name": attributes.colorName.toLowerCase(),
};
},
},
};
},
});
+20
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<
@@ -160,6 +165,7 @@ export const Mention = Node.create<MentionOptions>({
inline: true,
selectable: true,
atom: true,
draggable: true,
addAttributes() {
return {
@@ -246,6 +252,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,
};
},
},
};
},
@@ -28,7 +28,7 @@ export const Subpages = Node.create<SubpagesOptions>({
group: "block",
atom: true,
draggable: false,
draggable: true,
parseHTML() {
return [
-1
View File
@@ -1,6 +1,5 @@
import { TableRow as TiptapTableRow } from "@tiptap/extension-table";
export const TableRow = TiptapTableRow.extend({
allowGapCursor: false,
content: "(tableCell | tableHeader)*",
});
@@ -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,2 @@
export { UniqueID } from "./unique-id";
export * from "./unique-id.util";
@@ -0,0 +1,11 @@
import { generateNodeId } from "../utils";
import { UniqueID as TiptapUniqueID } from "@tiptap/extension-unique-id";
export const UniqueID = TiptapUniqueID.extend({
addOptions() {
return {
...this.parent?.(),
generateID: () => generateNodeId(),
};
},
});
@@ -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 { Node, ResolvedPos } from "@tiptap/pm/model";
import { Table } from "@tiptap/extension-table";
import { sanitizeUrl as braintreeSanitizeUrl } from "@braintree/sanitize-url";
import { EditorView } from '@tiptap/pm/view';
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);