[WIKI-480] feat: unique id extension (#8098)
This commit is contained in:
parent
ebab5e209f
commit
bd0361de92
16 changed files with 433 additions and 3 deletions
|
|
@ -99,6 +99,7 @@ ATTRIBUTES = {
|
||||||
"data-background-color",
|
"data-background-color",
|
||||||
"data-text-color",
|
"data-text-color",
|
||||||
"data-name",
|
"data-name",
|
||||||
|
"data-id",
|
||||||
# callout attributes
|
# callout attributes
|
||||||
"data-icon-name",
|
"data-icon-name",
|
||||||
"data-icon-color",
|
"data-icon-color",
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,10 @@ type Props = {
|
||||||
* @description Workspace slug, this will be used to get the workspace details
|
* @description Workspace slug, this will be used to get the workspace details
|
||||||
*/
|
*/
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
|
/**
|
||||||
|
* @description Issue sequence id, this will be used to get the issue sequence id
|
||||||
|
*/
|
||||||
|
issueSequenceId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -100,6 +104,7 @@ export const DescriptionInput: React.FC<Props> = observer((props) => {
|
||||||
setIsSubmitting,
|
setIsSubmitting,
|
||||||
swrDescription,
|
swrDescription,
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
|
issueSequenceId,
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
// states
|
||||||
const [localDescription, setLocalDescription] = useState<TFormData>({
|
const [localDescription, setLocalDescription] = useState<TFormData>({
|
||||||
|
|
@ -195,6 +200,7 @@ export const DescriptionInput: React.FC<Props> = observer((props) => {
|
||||||
editable={!disabled}
|
editable={!disabled}
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
id={entityId}
|
id={entityId}
|
||||||
|
issueSequenceId={issueSequenceId}
|
||||||
disabledExtensions={disabledExtensions}
|
disabledExtensions={disabledExtensions}
|
||||||
initialValue={localDescription.description_html ?? "<p></p>"}
|
initialValue={localDescription.description_html ?? "<p></p>"}
|
||||||
value={swrDescription ?? null}
|
value={swrDescription ?? null}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ type RichTextEditorWrapperProps = MakeOptional<
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
issueSequenceId?: number;
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
editable: false;
|
editable: false;
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
|
||||||
<DescriptionInputLoader />
|
<DescriptionInputLoader />
|
||||||
) : (
|
) : (
|
||||||
<DescriptionInput
|
<DescriptionInput
|
||||||
|
issueSequenceId={issue.sequence_id}
|
||||||
containerClassName="-ml-3 border-none"
|
containerClassName="-ml-3 border-none"
|
||||||
disabled={!isEditable}
|
disabled={!isEditable}
|
||||||
editorRef={editorRef}
|
editorRef={editorRef}
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DescriptionInput
|
<DescriptionInput
|
||||||
|
issueSequenceId={issue.sequence_id}
|
||||||
containerClassName="-ml-3 border-none"
|
containerClassName="-ml-3 border-none"
|
||||||
disabled={isArchived || !isEditable}
|
disabled={isArchived || !isEditable}
|
||||||
editorRef={editorRef}
|
editorRef={editorRef}
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ export const PeekOverviewIssueDetails: FC<Props> = observer((props) => {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DescriptionInput
|
<DescriptionInput
|
||||||
|
issueSequenceId={issue.sequence_id}
|
||||||
containerClassName="-ml-3 border-none"
|
containerClassName="-ml-3 border-none"
|
||||||
disabled={disabled || isArchived}
|
disabled={disabled || isArchived}
|
||||||
editorRef={editorRef}
|
editorRef={editorRef}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,3 @@
|
||||||
export enum ADDITIONAL_EXTENSIONS {}
|
export enum ADDITIONAL_EXTENSIONS {}
|
||||||
|
|
||||||
|
export const ADDITIONAL_BLOCK_NODE_TYPES = [];
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
|
||||||
fileHandler,
|
fileHandler,
|
||||||
flaggedExtensions,
|
flaggedExtensions,
|
||||||
extendedEditorProps,
|
extendedEditorProps,
|
||||||
|
workItemIdentifier,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const getExtensions = useCallback(() => {
|
const getExtensions = useCallback(() => {
|
||||||
|
|
@ -43,7 +44,12 @@ const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
|
||||||
{(editor) => (
|
{(editor) => (
|
||||||
<>
|
<>
|
||||||
{editor && bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
|
{editor && bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
|
||||||
<BlockMenu editor={editor} flaggedExtensions={flaggedExtensions} disabledExtensions={disabledExtensions} />
|
<BlockMenu
|
||||||
|
editor={editor}
|
||||||
|
flaggedExtensions={flaggedExtensions}
|
||||||
|
disabledExtensions={disabledExtensions}
|
||||||
|
workItemIdentifier={workItemIdentifier}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</EditorWrapper>
|
</EditorWrapper>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ type Props = {
|
||||||
disabledExtensions?: IEditorProps["disabledExtensions"];
|
disabledExtensions?: IEditorProps["disabledExtensions"];
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
flaggedExtensions?: IEditorProps["flaggedExtensions"];
|
flaggedExtensions?: IEditorProps["flaggedExtensions"];
|
||||||
|
workItemIdentifier?: IEditorProps["workItemIdentifier"];
|
||||||
};
|
};
|
||||||
export type BlockMenuOption = {
|
export type BlockMenuOption = {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
|
@ -34,7 +35,7 @@ export type BlockMenuOption = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BlockMenu = (props: Props) => {
|
export const BlockMenu = (props: Props) => {
|
||||||
const { editor } = props;
|
const { editor, workItemIdentifier } = props;
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isAnimatedIn, setIsAnimatedIn] = useState(false);
|
const [isAnimatedIn, setIsAnimatedIn] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -42,4 +42,30 @@ export enum CORE_EXTENSIONS {
|
||||||
UTILITY = "utility",
|
UTILITY = "utility",
|
||||||
WORK_ITEM_EMBED = "issue-embed-component",
|
WORK_ITEM_EMBED = "issue-embed-component",
|
||||||
EMOJI = "emoji",
|
EMOJI = "emoji",
|
||||||
|
UNIQUE_ID = "uniqueID",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const BLOCK_NODE_TYPES = [
|
||||||
|
// Basic block nodes
|
||||||
|
CORE_EXTENSIONS.PARAGRAPH,
|
||||||
|
CORE_EXTENSIONS.HEADING,
|
||||||
|
CORE_EXTENSIONS.BLOCKQUOTE,
|
||||||
|
CORE_EXTENSIONS.CODE_BLOCK,
|
||||||
|
CORE_EXTENSIONS.HORIZONTAL_RULE,
|
||||||
|
|
||||||
|
// List nodes
|
||||||
|
CORE_EXTENSIONS.BULLET_LIST,
|
||||||
|
CORE_EXTENSIONS.ORDERED_LIST,
|
||||||
|
CORE_EXTENSIONS.LIST_ITEM,
|
||||||
|
CORE_EXTENSIONS.TASK_LIST,
|
||||||
|
CORE_EXTENSIONS.TASK_ITEM,
|
||||||
|
|
||||||
|
// Table nodes
|
||||||
|
CORE_EXTENSIONS.TABLE,
|
||||||
|
|
||||||
|
// Media and embed nodes
|
||||||
|
CORE_EXTENSIONS.IMAGE,
|
||||||
|
CORE_EXTENSIONS.CUSTOM_IMAGE,
|
||||||
|
CORE_EXTENSIONS.CALLOUT,
|
||||||
|
CORE_EXTENSIONS.WORK_ITEM_EMBED,
|
||||||
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
import type { Extensions } from "@tiptap/core";
|
import type { Extensions } from "@tiptap/core";
|
||||||
import { CharacterCount } from "@tiptap/extension-character-count";
|
import { CharacterCount } from "@tiptap/extension-character-count";
|
||||||
import TaskItem from "@tiptap/extension-task-item";
|
import TaskItem from "@tiptap/extension-task-item";
|
||||||
|
|
@ -35,6 +36,7 @@ import { CustomImageExtension } from "./custom-image/extension";
|
||||||
import { EmojiExtension } from "./emoji/extension";
|
import { EmojiExtension } from "./emoji/extension";
|
||||||
import { CustomPlaceholderExtension } from "./placeholder";
|
import { CustomPlaceholderExtension } from "./placeholder";
|
||||||
import { CustomStarterKitExtension } from "./starter-kit";
|
import { CustomStarterKitExtension } from "./starter-kit";
|
||||||
|
import { UniqueID } from "./unique-id/extension";
|
||||||
|
|
||||||
type TArguments = Pick<
|
type TArguments = Pick<
|
||||||
IEditorProps,
|
IEditorProps,
|
||||||
|
|
@ -49,6 +51,7 @@ type TArguments = Pick<
|
||||||
> & {
|
> & {
|
||||||
enableHistory: boolean;
|
enableHistory: boolean;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
|
provider: HocuspocusProvider | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||||
|
|
@ -63,6 +66,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||||
tabIndex,
|
tabIndex,
|
||||||
editable,
|
editable,
|
||||||
extendedEditorProps,
|
extendedEditorProps,
|
||||||
|
provider,
|
||||||
} = args;
|
} = args;
|
||||||
|
|
||||||
const extensions = [
|
const extensions = [
|
||||||
|
|
@ -119,6 +123,9 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||||
fileHandler,
|
fileHandler,
|
||||||
extendedEditorProps,
|
extendedEditorProps,
|
||||||
}),
|
}),
|
||||||
|
UniqueID.configure({
|
||||||
|
provider,
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!disabledExtensions.includes("image")) {
|
if (!disabledExtensions.includes("image")) {
|
||||||
|
|
|
||||||
136
packages/editor/src/core/extensions/unique-id/extension.ts
Normal file
136
packages/editor/src/core/extensions/unique-id/extension.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
|
import { Extension } from "@tiptap/core";
|
||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import type { Transaction } from "@tiptap/pm/state";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
// constants
|
||||||
|
import { CORE_EXTENSIONS, BLOCK_NODE_TYPES } from "@/constants/extension";
|
||||||
|
import { ADDITIONAL_BLOCK_NODE_TYPES } from "@/plane-editor/constants/extensions";
|
||||||
|
import { createUniqueIDPlugin } from "./plugin";
|
||||||
|
import { createIdsForView } from "./utils";
|
||||||
|
// plane imports
|
||||||
|
|
||||||
|
const COMBINED_BLOCK_NODE_TYPES = [...BLOCK_NODE_TYPES, ...ADDITIONAL_BLOCK_NODE_TYPES];
|
||||||
|
export type UniqueIDGenerationContext = {
|
||||||
|
node: ProseMirrorNode;
|
||||||
|
pos: number;
|
||||||
|
};
|
||||||
|
export const UniqueIDAttribute = "id";
|
||||||
|
export const generateUniqueID = () => uuidv4();
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
generateUniqueID: (ctx: UniqueIDGenerationContext) => string;
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
/**
|
||||||
|
* The provider to use for the unique ID generation.
|
||||||
|
* @default null
|
||||||
|
*/
|
||||||
|
provider: HocuspocusProvider | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UniqueID = Extension.create<UniqueIDOptions>({
|
||||||
|
name: CORE_EXTENSIONS.UNIQUE_ID,
|
||||||
|
|
||||||
|
// 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: COMBINED_BLOCK_NODE_TYPES,
|
||||||
|
generateUniqueID: () => uuidv4(),
|
||||||
|
filterTransaction: null,
|
||||||
|
updateDocument: true,
|
||||||
|
provider: undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
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.editor.isEditable) {
|
||||||
|
this.options.updateDocument = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.options.updateDocument) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = this.options.provider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (provider) {
|
||||||
|
// Check if provider is already synced
|
||||||
|
if (provider.isSynced) {
|
||||||
|
createIdsForView(this.editor.view, this.options);
|
||||||
|
}
|
||||||
|
// If not synced, the listener will be registered in the plugin
|
||||||
|
// and handled there with proper cleanup
|
||||||
|
} else {
|
||||||
|
return createIdsForView(this.editor.view, this.options);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
if (!this.options.updateDocument) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [createUniqueIDPlugin(this.options)];
|
||||||
|
},
|
||||||
|
});
|
||||||
211
packages/editor/src/core/extensions/unique-id/plugin.ts
Normal file
211
packages/editor/src/core/extensions/unique-id/plugin.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
import { combineTransactionSteps, findChildrenInRange, findDuplicates, getChangedRanges } from "@tiptap/core";
|
||||||
|
import { Fragment, Slice } from "@tiptap/pm/model";
|
||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||||
|
import type { Transaction } from "@tiptap/pm/state";
|
||||||
|
// types
|
||||||
|
import type { UniqueIDOptions } from "./extension";
|
||||||
|
// utils
|
||||||
|
import { createIdsForView } from "./utils";
|
||||||
|
|
||||||
|
export const createUniqueIDPlugin = (options: UniqueIDOptions) => {
|
||||||
|
let dragSourceElement: Element | null = null;
|
||||||
|
let transformPasted = false;
|
||||||
|
let syncHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
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 =
|
||||||
|
options.filterTransaction && transactions.some((tr) => !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, generateUniqueID } = options;
|
||||||
|
const transform = combineTransactionSteps(oldState.doc, transactions as Transaction[]);
|
||||||
|
|
||||||
|
const { mapping } = transform;
|
||||||
|
|
||||||
|
// get changed ranges based on the old state
|
||||||
|
const changes = getChangedRanges(transform);
|
||||||
|
|
||||||
|
// Get all IDs from the entire document to check for duplicates globally
|
||||||
|
const allNodesInDoc: Array<{ node: ProseMirrorNode; pos: number }> = [];
|
||||||
|
newState.doc.descendants((node, pos) => {
|
||||||
|
if (types.includes(node.type.name)) {
|
||||||
|
allNodesInDoc.push({ node, pos });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const allIds = allNodesInDoc.map(({ node }) => node.attrs[attributeName]).filter((id) => id !== null);
|
||||||
|
const duplicatedIds = findDuplicates(allIds);
|
||||||
|
|
||||||
|
changes.forEach(({ newRange }) => {
|
||||||
|
const newNodes = findChildrenInRange(newState.doc, newRange, (node) => types.includes(node.type.name));
|
||||||
|
|
||||||
|
newNodes.forEach(({ node, pos }) => {
|
||||||
|
// 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]: generateUniqueID({ node, pos }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the node doesn't exist in the old state
|
||||||
|
const { deleted } = mapping.invert().mapResult(pos);
|
||||||
|
|
||||||
|
// If this is a new node (didn't exist in old state) and its ID is duplicated in the entire document
|
||||||
|
const newNode = deleted && duplicatedIds.includes(id);
|
||||||
|
|
||||||
|
if (newNode) {
|
||||||
|
tr.setNodeMarkup(pos, undefined, {
|
||||||
|
...node.attrs,
|
||||||
|
[attributeName]: generateUniqueID({ 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);
|
||||||
|
|
||||||
|
// Don't add ID generation to undo history
|
||||||
|
tr.setMeta("addToHistory", false);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Handle provider sync listener for creating IDs when collaboration provider syncs
|
||||||
|
const provider = options.provider;
|
||||||
|
if (provider && !provider.isSynced) {
|
||||||
|
syncHandler = () => {
|
||||||
|
createIdsForView(view, options);
|
||||||
|
|
||||||
|
// Clean up the listener after it runs
|
||||||
|
if (provider && syncHandler) {
|
||||||
|
provider.off("synced", syncHandler);
|
||||||
|
syncHandler = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
provider.on("synced", syncHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
window.removeEventListener("dragstart", handleDragstart);
|
||||||
|
|
||||||
|
// Clean up provider sync listener if it exists
|
||||||
|
if (provider && syncHandler) {
|
||||||
|
provider.off("synced", syncHandler);
|
||||||
|
syncHandler = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
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 === "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 } = 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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
28
packages/editor/src/core/extensions/unique-id/utils.ts
Normal file
28
packages/editor/src/core/extensions/unique-id/utils.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { findChildren } from "@tiptap/core";
|
||||||
|
import type { EditorView } from "@tiptap/pm/view";
|
||||||
|
// types
|
||||||
|
import type { UniqueIDOptions } from "./extension";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to create IDs for nodes that don't have them
|
||||||
|
*/
|
||||||
|
export const createIdsForView = (view: EditorView, options: UniqueIDOptions) => {
|
||||||
|
const { state } = view;
|
||||||
|
const { tr, doc } = state;
|
||||||
|
const { types, attributeName, generateUniqueID } = options;
|
||||||
|
const nodesWithoutId = findChildren(
|
||||||
|
doc,
|
||||||
|
(node) => types.includes(node.type.name) && node.attrs[attributeName] === null
|
||||||
|
);
|
||||||
|
|
||||||
|
nodesWithoutId.forEach(({ node, pos }) => {
|
||||||
|
tr.setNodeMarkup(pos, undefined, {
|
||||||
|
...node.attrs,
|
||||||
|
[attributeName]: generateUniqueID({ node, pos }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tr.setMeta("addToHistory", false);
|
||||||
|
|
||||||
|
view.dispatch(tr);
|
||||||
|
};
|
||||||
|
|
@ -39,8 +39,8 @@ export const useEditor = (props: TEditorHookProps) => {
|
||||||
onEditorFocus,
|
onEditorFocus,
|
||||||
onTransaction,
|
onTransaction,
|
||||||
placeholder,
|
placeholder,
|
||||||
provider,
|
|
||||||
tabIndex,
|
tabIndex,
|
||||||
|
provider,
|
||||||
value,
|
value,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
|
@ -69,6 +69,7 @@ export const useEditor = (props: TEditorHookProps) => {
|
||||||
mentionHandler,
|
mentionHandler,
|
||||||
placeholder,
|
placeholder,
|
||||||
tabIndex,
|
tabIndex,
|
||||||
|
provider,
|
||||||
}),
|
}),
|
||||||
...extensions,
|
...extensions,
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,7 @@ export type IEditorProps = {
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
value?: string | null;
|
value?: string | null;
|
||||||
extendedEditorProps: IEditorPropsExtended;
|
extendedEditorProps: IEditorPropsExtended;
|
||||||
|
workItemIdentifier?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ILiteTextEditorProps = IEditorProps;
|
export type ILiteTextEditorProps = IEditorProps;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue