@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
332 lines (319 loc) • 11.4 kB
text/typescript
import {
combineTransactionSteps,
Extension,
findChildrenInRange,
getChangedRanges,
} from "@tiptap/core";
import { Fragment, Slice } from "prosemirror-model";
import { Plugin, PluginKey } from "prosemirror-state";
import { v4 } from "uuid";
/**
* Code from Tiptap UniqueID extension (https://tiptap.dev/api/extensions/unique-id)
* This extension is licensed under MIT (even though it's part of Tiptap pro).
*
* If you're a user of BlockNote, we still recommend to support their awesome work and become a sponsor!
* https://tiptap.dev/pro
*/
/**
* Removes duplicated values within an array.
* Supports numbers, strings and objects.
*/
function removeDuplicates(array: any, by = JSON.stringify) {
const seen: any = {};
return array.filter((item: any) => {
const key = by(item);
return Object.prototype.hasOwnProperty.call(seen, key)
? false
: (seen[key] = true);
});
}
/**
* Returns a list of duplicated items within an array.
*/
function findDuplicates(items: any) {
const filtered = items.filter(
(el: any, index: number) => items.indexOf(el) !== index,
);
const duplicates = removeDuplicates(filtered);
return duplicates;
}
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: [],
setIdAttribute: false,
generateID: () => {
// Use mock ID if tests are running.
if (typeof window !== "undefined" && (window as any).__TEST_OPTIONS) {
const testOptions = (window as any).__TEST_OPTIONS;
if (testOptions.mockID === undefined) {
testOptions.mockID = 0;
} else {
testOptions.mockID++;
}
return testOptions.mockID.toString() as string;
}
return v4();
},
filterTransaction: null,
};
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
[this.options.attributeName]: {
default: null,
parseHTML: (element) =>
element.getAttribute(`data-${this.options.attributeName}`),
renderHTML: (attributes) => {
const defaultIdAttributes = {
[`data-${this.options.attributeName}`]:
attributes[this.options.attributeName],
};
if (this.options.setIdAttribute) {
return {
...defaultIdAttributes,
id: attributes[this.options.attributeName],
};
} else {
return defaultIdAttributes;
}
},
},
},
},
];
},
// check initial content for missing ids
// onCreate() {
// // Don’t do this when the collaboration extension is active
// // because this may update the content, so Y.js tries to merge these changes.
// // This leads to empty block nodes.
// // See: https://github.com/ueberdosis/tiptap/issues/2400
// if (
// this.editor.extensionManager.extensions.find(
// (extension) => extension.name === "collaboration"
// )
// ) {
// return;
// }
// 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(),
// });
// });
// tr.setMeta("addToHistory", false);
// view.dispatch(tr);
// },
addProseMirrorPlugins() {
let dragSourceElement: any = null;
let transformPasted = false;
return [
new Plugin({
key: new PluginKey("uniqueID"),
appendTransaction: (transactions, oldState, newState) => {
// console.log("appendTransaction");
const docChanges =
transactions.some((transaction) => transaction.docChanged) &&
!oldState.doc.eq(newState.doc);
const filterTransactions =
this.options.filterTransaction &&
transactions.some((tr) => {
let _a, _b;
return !((_b = (_a = this.options).filterTransaction) === null ||
_b === void 0
? void 0
: _b.call(_a, tr));
});
if (!docChanges || filterTransactions) {
return;
}
const { tr } = newState;
const { types, attributeName, generateID } = this.options;
const transform = combineTransactionSteps(
oldState.doc,
transactions as any,
);
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);
const duplicatedNewIds = findDuplicates(newIds);
newNodes.forEach(({ node, pos }) => {
let _a;
// 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 =
(_a = tr.doc.nodeAt(pos)) === null || _a === void 0
? void 0
: _a.attrs[attributeName];
if (id === null) {
// edge case, when using collaboration, yjs will set the id to null in `_forceRerender`
// when loading the editor
// this checks for this case and keeps it at initialBlockId so there will be no change
const initialDoc = oldState.doc.type.createAndFill()!.content;
const wasInitial =
oldState.doc.content.findDiffStart(initialDoc) === null;
if (wasInitial) {
// the old state was the "initial content"
const jsonNode = JSON.parse(
JSON.stringify(newState.doc.toJSON()),
);
jsonNode.content[0].content[0].attrs.id = "initialBlockId";
// would the new state with the fix also be the "initial content"?
if (
JSON.stringify(jsonNode.content) ===
JSON.stringify(initialDoc.toJSON())
) {
// yes, apply the fix
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
[attributeName]: "initialBlockId",
});
return;
}
}
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
[attributeName]: generateID(),
});
return;
}
// 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(),
});
}
});
});
if (!tr.steps.length) {
return;
}
return tr;
},
// we register a global drag handler to track the current drag source element
view(view) {
const handleDragstart = (event: any) => {
let _a;
dragSourceElement = (
(_a = view.dom.parentElement) === null || _a === void 0
? void 0
: _a.contains(event.target)
)
? 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. However, `transformPasted` only runs when
// editor content is pasted - not external content.
handleDOMEvents: {
// only create new ids for dropped content while holding `alt`
// or content is dragged from another editor
drop: (view, event: any) => {
let _a;
if (
dragSourceElement !== view.dom.parentElement ||
((_a = event.dataTransfer) === null || _a === void 0
? void 0
: _a.effectAllowed) === "copy"
) {
transformPasted = true;
} else {
transformPasted = false;
}
dragSourceElement = null;
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: any) => {
const list: any[] = [];
fragment.forEach((node: any) => {
// 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,
);
},
},
}),
];
},
});
export { UniqueID as default, UniqueID };