mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
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:
@@ -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 = " ";
|
||||
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,
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
// 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,
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user