[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-text-color",
|
||||
"data-name",
|
||||
"data-id",
|
||||
# callout attributes
|
||||
"data-icon-name",
|
||||
"data-icon-color",
|
||||
|
|
|
|||
|
|
@ -79,6 +79,10 @@ type Props = {
|
|||
* @description Workspace slug, this will be used to get the workspace details
|
||||
*/
|
||||
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,
|
||||
swrDescription,
|
||||
workspaceSlug,
|
||||
issueSequenceId,
|
||||
} = props;
|
||||
// states
|
||||
const [localDescription, setLocalDescription] = useState<TFormData>({
|
||||
|
|
@ -195,6 +200,7 @@ export const DescriptionInput: React.FC<Props> = observer((props) => {
|
|||
editable={!disabled}
|
||||
ref={editorRef}
|
||||
id={entityId}
|
||||
issueSequenceId={issueSequenceId}
|
||||
disabledExtensions={disabledExtensions}
|
||||
initialValue={localDescription.description_html ?? "<p></p>"}
|
||||
value={swrDescription ?? null}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ type RichTextEditorWrapperProps = MakeOptional<
|
|||
workspaceSlug: string;
|
||||
workspaceId: string;
|
||||
projectId?: string;
|
||||
issueSequenceId?: number;
|
||||
} & (
|
||||
| {
|
||||
editable: false;
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
|
|||
<DescriptionInputLoader />
|
||||
) : (
|
||||
<DescriptionInput
|
||||
issueSequenceId={issue.sequence_id}
|
||||
containerClassName="-ml-3 border-none"
|
||||
disabled={!isEditable}
|
||||
editorRef={editorRef}
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
|
|||
/>
|
||||
|
||||
<DescriptionInput
|
||||
issueSequenceId={issue.sequence_id}
|
||||
containerClassName="-ml-3 border-none"
|
||||
disabled={isArchived || !isEditable}
|
||||
editorRef={editorRef}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ export const PeekOverviewIssueDetails: FC<Props> = observer((props) => {
|
|||
/>
|
||||
|
||||
<DescriptionInput
|
||||
issueSequenceId={issue.sequence_id}
|
||||
containerClassName="-ml-3 border-none"
|
||||
disabled={disabled || isArchived}
|
||||
editorRef={editorRef}
|
||||
|
|
|
|||
|
|
@ -1 +1,3 @@
|
|||
export enum ADDITIONAL_EXTENSIONS {}
|
||||
|
||||
export const ADDITIONAL_BLOCK_NODE_TYPES = [];
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
|
|||
fileHandler,
|
||||
flaggedExtensions,
|
||||
extendedEditorProps,
|
||||
workItemIdentifier,
|
||||
} = props;
|
||||
|
||||
const getExtensions = useCallback(() => {
|
||||
|
|
@ -43,7 +44,12 @@ const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
|
|||
{(editor) => (
|
||||
<>
|
||||
{editor && bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
|
||||
<BlockMenu editor={editor} flaggedExtensions={flaggedExtensions} disabledExtensions={disabledExtensions} />
|
||||
<BlockMenu
|
||||
editor={editor}
|
||||
flaggedExtensions={flaggedExtensions}
|
||||
disabledExtensions={disabledExtensions}
|
||||
workItemIdentifier={workItemIdentifier}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</EditorWrapper>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ type Props = {
|
|||
disabledExtensions?: IEditorProps["disabledExtensions"];
|
||||
editor: Editor;
|
||||
flaggedExtensions?: IEditorProps["flaggedExtensions"];
|
||||
workItemIdentifier?: IEditorProps["workItemIdentifier"];
|
||||
};
|
||||
export type BlockMenuOption = {
|
||||
icon: LucideIcon;
|
||||
|
|
@ -34,7 +35,7 @@ export type BlockMenuOption = {
|
|||
};
|
||||
|
||||
export const BlockMenu = (props: Props) => {
|
||||
const { editor } = props;
|
||||
const { editor, workItemIdentifier } = props;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isAnimatedIn, setIsAnimatedIn] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
|
|||
|
|
@ -42,4 +42,30 @@ export enum CORE_EXTENSIONS {
|
|||
UTILITY = "utility",
|
||||
WORK_ITEM_EMBED = "issue-embed-component",
|
||||
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 { CharacterCount } from "@tiptap/extension-character-count";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
|
|
@ -35,6 +36,7 @@ import { CustomImageExtension } from "./custom-image/extension";
|
|||
import { EmojiExtension } from "./emoji/extension";
|
||||
import { CustomPlaceholderExtension } from "./placeholder";
|
||||
import { CustomStarterKitExtension } from "./starter-kit";
|
||||
import { UniqueID } from "./unique-id/extension";
|
||||
|
||||
type TArguments = Pick<
|
||||
IEditorProps,
|
||||
|
|
@ -49,6 +51,7 @@ type TArguments = Pick<
|
|||
> & {
|
||||
enableHistory: boolean;
|
||||
editable: boolean;
|
||||
provider: HocuspocusProvider | undefined;
|
||||
};
|
||||
|
||||
export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
|
|
@ -63,6 +66,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
|||
tabIndex,
|
||||
editable,
|
||||
extendedEditorProps,
|
||||
provider,
|
||||
} = args;
|
||||
|
||||
const extensions = [
|
||||
|
|
@ -119,6 +123,9 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
|||
fileHandler,
|
||||
extendedEditorProps,
|
||||
}),
|
||||
UniqueID.configure({
|
||||
provider,
|
||||
}),
|
||||
];
|
||||
|
||||
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,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
provider,
|
||||
tabIndex,
|
||||
provider,
|
||||
value,
|
||||
} = props;
|
||||
|
||||
|
|
@ -69,6 +69,7 @@ export const useEditor = (props: TEditorHookProps) => {
|
|||
mentionHandler,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
provider,
|
||||
}),
|
||||
...extensions,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ export type IEditorProps = {
|
|||
tabIndex?: number;
|
||||
value?: string | null;
|
||||
extendedEditorProps: IEditorPropsExtended;
|
||||
workItemIdentifier?: string | null;
|
||||
};
|
||||
|
||||
export type ILiteTextEditorProps = IEditorProps;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue