mirror of
https://github.com/docmost/docmost.git
synced 2026-05-15 13:14:11 +08:00
d2629afff2
* 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>
387 lines
11 KiB
TypeScript
387 lines
11 KiB
TypeScript
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,
|
||
);
|
||
},
|
||
},
|
||
}),
|
||
];
|
||
},
|
||
});
|