mirror of
https://github.com/docmost/docmost.git
synced 2026-05-17 23:14:07 +08:00
feat: Tiptap V3 migration (#1854)
* Tiptap3 migration - WIP * fix collaboration * remove unused code * fix flicker * disable duplicate extensions * update tiptap version * Switch to useEditorState - Set shouldRerenderOnTransaction to false * fix editable state * add tippyoptions for reference * merge main * tiptap 3.6.1 * fix bubble menu * fix converter * fix menus * fix collaboration caret css * fix: Set `isInitialized` to force immediate react node view rendering * feat: Migrate tippy.js menus to Floating UI * feat: Update collaboration connection for HocusPocus v3 * fix: Connect/disconnect websocketProvider * cleanup * cleanup * feat: Improved placeholder and upload handling for images * feat: Improved placeholder and upload handling for videos * refactor: Image node and view clean-up * feat: Improved placeholder and upload handling for attachments * fix: Video view styles * fix: Transaction handling on asset upload * fix: Use imageDimensionsFromStream * feat: Multiple file upload, improved placeholders, local previews * fix: Drag & drop, paste upload * fix: Allow media as attachment * * add skeleton pulse animation * add translation strings * fix attachment view responsiveness * fix collab connection status display * Tiptap v3.17.0 * fix suggestion menu exit bug * fix search shortcut * fix history editor css * tiptap 3.17.1 --------- Co-authored-by: Arek Nawo <areknawo@areknawo.com>
This commit is contained in:
@@ -1,11 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
}
|
||||
@@ -1,386 +1,11 @@
|
||||
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";
|
||||
import { UniqueID as TiptapUniqueID } from "@tiptap/extension-unique-id";
|
||||
|
||||
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,
|
||||
|
||||
export const UniqueID = TiptapUniqueID.extend({
|
||||
addOptions() {
|
||||
return {
|
||||
attributeName: "id",
|
||||
types: [],
|
||||
...this.parent?.(),
|
||||
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,
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user