[WEB-1682] refactor: editor code splitting (#4893)
* refactor: lite and rich text editors * refactor: document editor migration * fix: add missing css import * refactor: issue embed widget splitting * chore: remove extensions folder from ee * chore: update web ee folder structure * fix: build errors --------- Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
This commit is contained in:
parent
367ccba17e
commit
dcdd1ef065
256 changed files with 1027 additions and 2401 deletions
70
packages/editor/src/ce/providers/collaboration-provider.ts
Normal file
70
packages/editor/src/ce/providers/collaboration-provider.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import * as Y from "yjs";
|
||||
|
||||
export interface CompleteCollaboratorProviderConfiguration {
|
||||
/**
|
||||
* The identifier/name of your document
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The actual Y.js document
|
||||
*/
|
||||
document: Y.Doc;
|
||||
/**
|
||||
* onChange callback
|
||||
*/
|
||||
onChange: (updates: Uint8Array) => void;
|
||||
/**
|
||||
* Whether connection to the database has been established and all available content has been loaded or not.
|
||||
*/
|
||||
hasIndexedDBSynced: boolean;
|
||||
}
|
||||
|
||||
export type CollaborationProviderConfiguration = Required<Pick<CompleteCollaboratorProviderConfiguration, "name">> &
|
||||
Partial<CompleteCollaboratorProviderConfiguration>;
|
||||
|
||||
export class CollaborationProvider {
|
||||
public configuration: CompleteCollaboratorProviderConfiguration = {
|
||||
name: "",
|
||||
// @ts-expect-error cannot be undefined
|
||||
document: undefined,
|
||||
onChange: () => {},
|
||||
hasIndexedDBSynced: false,
|
||||
};
|
||||
|
||||
constructor(configuration: CollaborationProviderConfiguration) {
|
||||
this.setConfiguration(configuration);
|
||||
|
||||
this.configuration.document = configuration.document ?? new Y.Doc();
|
||||
this.document.on("update", this.documentUpdateHandler.bind(this));
|
||||
this.document.on("destroy", this.documentDestroyHandler.bind(this));
|
||||
}
|
||||
|
||||
public setConfiguration(configuration: Partial<CompleteCollaboratorProviderConfiguration> = {}): void {
|
||||
this.configuration = {
|
||||
...this.configuration,
|
||||
...configuration,
|
||||
};
|
||||
}
|
||||
|
||||
get document() {
|
||||
return this.configuration.document;
|
||||
}
|
||||
|
||||
setHasIndexedDBSynced(hasIndexedDBSynced: boolean) {
|
||||
this.configuration.hasIndexedDBSynced = hasIndexedDBSynced;
|
||||
}
|
||||
|
||||
documentUpdateHandler(update: Uint8Array, origin: any) {
|
||||
if (!this.configuration.hasIndexedDBSynced) return;
|
||||
// return if the update is from the provider itself
|
||||
if (origin === this) return;
|
||||
|
||||
// call onChange with the update
|
||||
this.configuration.onChange?.(update);
|
||||
}
|
||||
|
||||
documentDestroyHandler() {
|
||||
this.document.off("update", this.documentUpdateHandler);
|
||||
this.document.off("destroy", this.documentDestroyHandler);
|
||||
}
|
||||
}
|
||||
1
packages/editor/src/ce/providers/index.ts
Normal file
1
packages/editor/src/ce/providers/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./collaboration-provider";
|
||||
1
packages/editor/src/ce/types/index.ts
Normal file
1
packages/editor/src/ce/types/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./issue-embed";
|
||||
17
packages/editor/src/ce/types/issue-embed.ts
Normal file
17
packages/editor/src/ce/types/issue-embed.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export type TEmbedConfig = {
|
||||
issue?: TIssueEmbedConfig;
|
||||
};
|
||||
|
||||
export type TReadOnlyEmbedConfig = TEmbedConfig;
|
||||
|
||||
export type TIssueEmbedConfig = {
|
||||
widgetCallback: ({
|
||||
issueId,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
}: {
|
||||
issueId: string;
|
||||
projectId: string | undefined;
|
||||
workspaceSlug: string | undefined;
|
||||
}) => React.ReactNode;
|
||||
};
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import React, { useState } from "react";
|
||||
// components
|
||||
import { PageRenderer } from "@/components/editors";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useDocumentEditor } from "@/hooks/use-document-editor";
|
||||
import { TFileHandler } from "@/hooks/use-editor";
|
||||
// plane editor types
|
||||
import { TEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import { EditorRefApi, IMentionHighlight, IMentionSuggestion } from "@/types";
|
||||
|
||||
interface IDocumentEditor {
|
||||
containerClassName?: string;
|
||||
editorClassName?: string;
|
||||
embedHandler: TEmbedConfig;
|
||||
fileHandler: TFileHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id: string;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
suggestions: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
onChange: (updates: Uint8Array) => void;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
value: Uint8Array;
|
||||
}
|
||||
|
||||
const DocumentEditor = (props: IDocumentEditor) => {
|
||||
const {
|
||||
containerClassName,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
value,
|
||||
} = props;
|
||||
// states
|
||||
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {});
|
||||
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
|
||||
// loads such that we can invoke it from react when the cursor leaves the container
|
||||
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
|
||||
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
|
||||
};
|
||||
|
||||
// use document editor
|
||||
const editor = useDocumentEditor({
|
||||
id,
|
||||
editorClassName,
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
value,
|
||||
onChange,
|
||||
handleEditorReady,
|
||||
forwardedRef,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
setHideDragHandleFunction,
|
||||
tabIndex,
|
||||
});
|
||||
|
||||
const editorContainerClassNames = getEditorClassNames({
|
||||
noBorder: true,
|
||||
borderOnFocus: false,
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<PageRenderer
|
||||
tabIndex={tabIndex}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassNames}
|
||||
hideDragHandle={hideDragHandleOnMouseLeave}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentEditorWithRef = React.forwardRef<EditorRefApi, IDocumentEditor>((props, ref) => (
|
||||
<DocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
));
|
||||
|
||||
DocumentEditorWithRef.displayName = "DocumentEditorWithRef";
|
||||
|
||||
export { DocumentEditorWithRef };
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./editor";
|
||||
export * from "./page-renderer";
|
||||
export * from "./read-only-editor";
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import { useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
hide,
|
||||
shift,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
} from "@floating-ui/react";
|
||||
import { Node } from "@tiptap/pm/model";
|
||||
import { EditorView } from "@tiptap/pm/view";
|
||||
import { Editor, ReactRenderer } from "@tiptap/react";
|
||||
// components
|
||||
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
|
||||
import { LinkView, LinkViewProps } from "@/components/links";
|
||||
import { BlockMenu } from "@/components/menus";
|
||||
|
||||
type IPageRenderer = {
|
||||
editor: Editor;
|
||||
editorContainerClassName: string;
|
||||
hideDragHandle?: () => void;
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export const PageRenderer = (props: IPageRenderer) => {
|
||||
const { tabIndex, editor, hideDragHandle, editorContainerClassName } = props;
|
||||
// states
|
||||
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [coordinates, setCoordinates] = useState<{ x: number; y: number }>();
|
||||
const [cleanup, setCleanup] = useState(() => () => {});
|
||||
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
open: isOpen,
|
||||
onOpenChange: setIsOpen,
|
||||
middleware: [flip(), shift(), hide({ strategy: "referenceHidden" })],
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
|
||||
const dismiss = useDismiss(context, {
|
||||
ancestorScroll: true,
|
||||
});
|
||||
|
||||
const { getFloatingProps } = useInteractions([dismiss]);
|
||||
|
||||
const floatingElementRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const closeLinkView = () => setIsOpen(false);
|
||||
|
||||
const handleLinkHover = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
if (!editor) return;
|
||||
const target = event.target as HTMLElement;
|
||||
const view = editor.view as EditorView;
|
||||
|
||||
if (!target || !view) return;
|
||||
const pos = view.posAtDOM(target, 0);
|
||||
if (!pos || pos < 0) return;
|
||||
|
||||
if (target.nodeName !== "A") return;
|
||||
|
||||
const node = view.state.doc.nodeAt(pos) as Node;
|
||||
if (!node || !node.isAtom) return;
|
||||
|
||||
// we need to check if any of the marks are links
|
||||
const marks = node.marks;
|
||||
|
||||
if (!marks) return;
|
||||
|
||||
const linkMark = marks.find((mark) => mark.type.name === "link");
|
||||
|
||||
if (!linkMark) return;
|
||||
|
||||
if (floatingElementRef.current) {
|
||||
floatingElementRef.current?.remove();
|
||||
}
|
||||
|
||||
if (cleanup) cleanup();
|
||||
|
||||
const href = linkMark.attrs.href;
|
||||
const componentLink = new ReactRenderer(LinkView, {
|
||||
props: {
|
||||
view: "LinkPreview",
|
||||
url: href,
|
||||
editor: editor,
|
||||
from: pos,
|
||||
to: pos + node.nodeSize,
|
||||
},
|
||||
editor,
|
||||
});
|
||||
|
||||
const referenceElement = target as HTMLElement;
|
||||
const floatingElement = componentLink.element as HTMLElement;
|
||||
|
||||
floatingElementRef.current = floatingElement;
|
||||
|
||||
const cleanupFunc = autoUpdate(referenceElement, floatingElement, () => {
|
||||
computePosition(referenceElement, floatingElement, {
|
||||
placement: "bottom",
|
||||
middleware: [
|
||||
flip(),
|
||||
shift(),
|
||||
hide({
|
||||
strategy: "referenceHidden",
|
||||
}),
|
||||
],
|
||||
}).then(({ x, y }) => {
|
||||
setCoordinates({ x: x - 300, y: y - 50 });
|
||||
setIsOpen(true);
|
||||
setLinkViewProps({
|
||||
closeLinkView: closeLinkView,
|
||||
view: "LinkPreview",
|
||||
url: href,
|
||||
editor: editor,
|
||||
from: pos,
|
||||
to: pos + node.nodeSize,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
setCleanup(cleanupFunc);
|
||||
},
|
||||
[editor, cleanup]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}>
|
||||
<EditorContainer
|
||||
editor={editor}
|
||||
hideDragHandle={hideDragHandle}
|
||||
editorContainerClassName={editorContainerClassName}
|
||||
>
|
||||
<EditorContentWrapper tabIndex={tabIndex} editor={editor} />
|
||||
{editor && editor.isEditable && <BlockMenu editor={editor} />}
|
||||
</EditorContainer>
|
||||
</div>
|
||||
{isOpen && linkViewProps && coordinates && (
|
||||
<div
|
||||
style={{ ...floatingStyles, left: `${coordinates.x}px`, top: `${coordinates.y}px` }}
|
||||
className="absolute"
|
||||
ref={refs.setFloating}
|
||||
>
|
||||
<LinkView {...linkViewProps} style={floatingStyles} {...getFloatingProps()} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { forwardRef, MutableRefObject } from "react";
|
||||
// components
|
||||
import { PageRenderer } from "@/components/editors";
|
||||
// extensions
|
||||
import { IssueWidget } from "@/extensions";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||
// plane web types
|
||||
import { TEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight } from "@/types";
|
||||
|
||||
interface IDocumentReadOnlyEditor {
|
||||
initialValue: string;
|
||||
containerClassName: string;
|
||||
editorClassName?: string;
|
||||
embedHandler: TEmbedConfig;
|
||||
tabIndex?: number;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
};
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
}
|
||||
|
||||
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
const {
|
||||
containerClassName,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
initialValue,
|
||||
forwardedRef,
|
||||
tabIndex,
|
||||
handleEditorReady,
|
||||
mentionHandler,
|
||||
} = props;
|
||||
const editor = useReadOnlyEditor({
|
||||
initialValue,
|
||||
editorClassName,
|
||||
mentionHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
extensions: [
|
||||
embedHandler?.issue &&
|
||||
IssueWidget({
|
||||
widgetCallback: embedHandler?.issue.widgetCallback,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const editorContainerClassName = getEditorClassNames({
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
return <PageRenderer tabIndex={tabIndex} editor={editor} editorContainerClassName={editorContainerClassName} />;
|
||||
};
|
||||
|
||||
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditor>((props, ref) => (
|
||||
<DocumentReadOnlyEditor {...props} forwardedRef={ref as MutableRefObject<EditorReadOnlyRefApi | null>} />
|
||||
));
|
||||
|
||||
DocumentReadOnlyEditorWithRef.displayName = "DocumentReadOnlyEditorWithRef";
|
||||
|
||||
export { DocumentReadOnlyEditorWithRef };
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { FC, ReactNode } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
|
||||
interface EditorContainerProps {
|
||||
editor: Editor | null;
|
||||
editorContainerClassName: string;
|
||||
children: ReactNode;
|
||||
hideDragHandle?: () => void;
|
||||
}
|
||||
|
||||
export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
const { editor, editorContainerClassName, hideDragHandle, children } = props;
|
||||
|
||||
const handleContainerClick = () => {
|
||||
if (!editor) return;
|
||||
if (!editor.isEditable) return;
|
||||
try {
|
||||
if (editor.isFocused) return; // If editor is already focused, do nothing
|
||||
|
||||
const { selection } = editor.state;
|
||||
const currentNode = selection.$from.node();
|
||||
|
||||
editor?.chain().focus("end", { scrollIntoView: false }).run(); // Focus the editor at the end
|
||||
|
||||
if (
|
||||
currentNode.content.size === 0 && // Check if the current node is empty
|
||||
!(
|
||||
editor.isActive("orderedList") ||
|
||||
editor.isActive("bulletList") ||
|
||||
editor.isActive("taskItem") ||
|
||||
editor.isActive("table") ||
|
||||
editor.isActive("blockquote") ||
|
||||
editor.isActive("codeBlock")
|
||||
) // Check if it's an empty node within an orderedList, bulletList, taskItem, table, quote or code block
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert a new paragraph at the end of the document
|
||||
const endPosition = editor?.state.doc.content.size;
|
||||
editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).run();
|
||||
|
||||
// Focus the newly added paragraph for immediate editing
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(endPosition + 1)
|
||||
.run();
|
||||
} catch (error) {
|
||||
console.error("An error occurred while handling container click to insert new empty node at bottom:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id="editor-container"
|
||||
onClick={handleContainerClick}
|
||||
onMouseLeave={hideDragHandle}
|
||||
className={cn(
|
||||
"cursor-text relative",
|
||||
{
|
||||
"active-editor": editor?.isFocused && editor?.isEditable,
|
||||
},
|
||||
editorContainerClassName
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { Editor, EditorContent } from "@tiptap/react";
|
||||
import { FC, ReactNode } from "react";
|
||||
import { ImageResizer } from "src/core/extensions/image/image-resize";
|
||||
|
||||
interface EditorContentProps {
|
||||
editor: Editor | null;
|
||||
children?: ReactNode;
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
export const EditorContentWrapper: FC<EditorContentProps> = (props) => {
|
||||
const { editor, tabIndex, children } = props;
|
||||
|
||||
return (
|
||||
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
|
||||
<EditorContent editor={editor} />
|
||||
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} />}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { Editor, Extension } from "@tiptap/core";
|
||||
// components
|
||||
import { EditorContainer } from "@/components/editors";
|
||||
// hooks
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
// types
|
||||
import { IEditorProps } from "@/types";
|
||||
import { EditorContentWrapper } from "./editor-content";
|
||||
|
||||
type Props = IEditorProps & {
|
||||
children?: (editor: Editor) => React.ReactNode;
|
||||
extensions: Extension<any, any>[];
|
||||
hideDragHandleOnMouseLeave: () => void;
|
||||
};
|
||||
|
||||
export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
const {
|
||||
children,
|
||||
containerClassName,
|
||||
editorClassName = "",
|
||||
extensions,
|
||||
hideDragHandleOnMouseLeave,
|
||||
id = "",
|
||||
initialValue,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
value,
|
||||
} = props;
|
||||
|
||||
const editor = useEditor({
|
||||
editorClassName,
|
||||
extensions,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
id,
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
value,
|
||||
});
|
||||
|
||||
const editorContainerClassName = getEditorClassNames({
|
||||
noBorder: true,
|
||||
borderOnFocus: false,
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<EditorContainer
|
||||
hideDragHandle={hideDragHandleOnMouseLeave}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassName}
|
||||
>
|
||||
{children?.(editor)}
|
||||
<div className="flex flex-col">
|
||||
<EditorContentWrapper tabIndex={tabIndex} editor={editor} />
|
||||
</div>
|
||||
</EditorContainer>
|
||||
);
|
||||
};
|
||||
7
packages/editor/src/core/components/editors/index.ts
Normal file
7
packages/editor/src/core/components/editors/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export * from "./document";
|
||||
export * from "./lite-text";
|
||||
export * from "./rich-text";
|
||||
export * from "./editor-container";
|
||||
export * from "./editor-content";
|
||||
export * from "./editor-wrapper";
|
||||
export * from "./read-only-editor-wrapper";
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { forwardRef } from "react";
|
||||
// components
|
||||
import { EditorWrapper } from "@/components/editors/editor-wrapper";
|
||||
// extensions
|
||||
import { EnterKeyExtension } from "@/extensions";
|
||||
// types
|
||||
import { EditorRefApi, ILiteTextEditor } from "@/types";
|
||||
|
||||
const LiteTextEditor = (props: ILiteTextEditor) => {
|
||||
const { onEnterKeyPress } = props;
|
||||
|
||||
const extensions = [EnterKeyExtension(onEnterKeyPress)];
|
||||
|
||||
return <EditorWrapper {...props} extensions={extensions} hideDragHandleOnMouseLeave={() => {}} />;
|
||||
};
|
||||
|
||||
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditor>((props, ref) => (
|
||||
<LiteTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
));
|
||||
|
||||
LiteTextEditorWithRef.displayName = "LiteTextEditorWithRef";
|
||||
|
||||
export { LiteTextEditorWithRef };
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./editor";
|
||||
export * from "./read-only-editor";
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { forwardRef } from "react";
|
||||
// components
|
||||
import { ReadOnlyEditorWrapper } from "@/components/editors";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor } from "@/types";
|
||||
|
||||
const LiteTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditor>((props, ref) => (
|
||||
<ReadOnlyEditorWrapper {...props} forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>} />
|
||||
));
|
||||
|
||||
LiteTextReadOnlyEditorWithRef.displayName = "LiteReadOnlyEditorWithRef";
|
||||
|
||||
export { LiteTextReadOnlyEditorWithRef };
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
// components
|
||||
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||
// types
|
||||
import { IReadOnlyEditorProps } from "@/types";
|
||||
|
||||
export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
|
||||
const { containerClassName, editorClassName = "", initialValue, forwardedRef, mentionHandler } = props;
|
||||
|
||||
const editor = useReadOnlyEditor({
|
||||
initialValue,
|
||||
editorClassName,
|
||||
forwardedRef,
|
||||
mentionHandler,
|
||||
});
|
||||
|
||||
const editorContainerClassName = getEditorClassNames({
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<EditorContainer editor={editor} editorContainerClassName={editorContainerClassName}>
|
||||
<div className="flex flex-col">
|
||||
<EditorContentWrapper editor={editor} />
|
||||
</div>
|
||||
</EditorContainer>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { forwardRef, useCallback, useState } from "react";
|
||||
// components
|
||||
import { EditorWrapper } from "@/components/editors";
|
||||
import { EditorBubbleMenu } from "@/components/menus";
|
||||
// extensions
|
||||
import { DragAndDrop, SlashCommand } from "@/extensions";
|
||||
// types
|
||||
import { EditorRefApi, IRichTextEditor } from "@/types";
|
||||
|
||||
const RichTextEditor = (props: IRichTextEditor) => {
|
||||
const { dragDropEnabled, fileHandler } = props;
|
||||
// states
|
||||
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {});
|
||||
|
||||
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
|
||||
// loads such that we can invoke it from react when the cursor leaves the container
|
||||
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
|
||||
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
|
||||
};
|
||||
|
||||
const getExtensions = useCallback(() => {
|
||||
const extensions = [
|
||||
SlashCommand(fileHandler.upload),
|
||||
// TODO; add the extension conditionally for forms that don't require it
|
||||
// EnterKeyExtension(onEnterKeyPress),
|
||||
];
|
||||
|
||||
if (dragDropEnabled) extensions.push(DragAndDrop(setHideDragHandleFunction));
|
||||
|
||||
return extensions;
|
||||
}, [dragDropEnabled, fileHandler.upload]);
|
||||
|
||||
return (
|
||||
<EditorWrapper {...props} extensions={getExtensions()} hideDragHandleOnMouseLeave={hideDragHandleOnMouseLeave}>
|
||||
{(editor) => <>{editor && <EditorBubbleMenu editor={editor} />}</>}
|
||||
</EditorWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const RichTextEditorWithRef = forwardRef<EditorRefApi, IRichTextEditor>((props, ref) => (
|
||||
<RichTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
));
|
||||
|
||||
RichTextEditorWithRef.displayName = "RichTextEditorWithRef";
|
||||
|
||||
export { RichTextEditorWithRef };
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./editor";
|
||||
export * from "./read-only-editor";
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { forwardRef } from "react";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor } from "@/types";
|
||||
import { ReadOnlyEditorWrapper } from "../read-only-editor-wrapper";
|
||||
|
||||
const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditor>((props, ref) => (
|
||||
<ReadOnlyEditorWrapper {...props} forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>} />
|
||||
));
|
||||
|
||||
RichTextReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef";
|
||||
|
||||
export { RichTextReadOnlyEditorWithRef };
|
||||
4
packages/editor/src/core/components/links/index.ts
Normal file
4
packages/editor/src/core/components/links/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./link-edit-view";
|
||||
export * from "./link-input-view";
|
||||
export * from "./link-preview";
|
||||
export * from "./link-view";
|
||||
149
packages/editor/src/core/components/links/link-edit-view.tsx
Normal file
149
packages/editor/src/core/components/links/link-edit-view.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { Node } from "@tiptap/pm/model";
|
||||
import { Link2Off } from "lucide-react";
|
||||
// components
|
||||
import { LinkViewProps } from "@/components/links";
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
|
||||
const InputView = ({
|
||||
label,
|
||||
defaultValue,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
defaultValue: string;
|
||||
placeholder: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="inline-block font-semibold text-xs text-custom-text-400">{label}</label>
|
||||
<input
|
||||
placeholder={placeholder}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="w-[280px] outline-none bg-custom-background-90 text-custom-text-900 text-sm"
|
||||
defaultValue={defaultValue}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const LinkEditView = ({
|
||||
viewProps,
|
||||
}: {
|
||||
viewProps: LinkViewProps;
|
||||
switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void;
|
||||
}) => {
|
||||
const { editor, from, to } = viewProps;
|
||||
|
||||
const [positionRef, setPositionRef] = useState({ from: from, to: to });
|
||||
const [localUrl, setLocalUrl] = useState(viewProps.url);
|
||||
|
||||
const linkRemoved = useRef<boolean>();
|
||||
|
||||
const getText = (from: number, to: number) => {
|
||||
if (to >= editor.state.doc.content.size) return "";
|
||||
|
||||
const text = editor.state.doc.textBetween(from, to, "\n");
|
||||
return text;
|
||||
};
|
||||
|
||||
const isValidUrl = (urlString: string) => {
|
||||
const urlPattern = new RegExp(
|
||||
"^(https?:\\/\\/)?" + // validate protocol
|
||||
"([\\w-]+\\.)+[\\w-]{2,}" + // validate domain name
|
||||
"|((\\d{1,3}\\.){3}\\d{1,3})" + // validate IP (v4) address
|
||||
"(\\:\\d+)?(\\/[-\\w.%]+)*" + // validate port and path
|
||||
"(\\?[;&\\w.%=-]*)?" + // validate query string
|
||||
"(\\#[-\\w]*)?$", // validate fragment locator
|
||||
"i"
|
||||
);
|
||||
const regexTest = urlPattern.test(urlString);
|
||||
const urlTest = isValidHttpUrl(urlString); // Ensure you have defined isValidHttpUrl
|
||||
return regexTest && urlTest;
|
||||
};
|
||||
|
||||
const handleUpdateLink = (url: string) => {
|
||||
setLocalUrl(url);
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (linkRemoved.current) return;
|
||||
|
||||
const url = isValidUrl(localUrl) ? localUrl : viewProps.url;
|
||||
|
||||
if (to >= editor.state.doc.content.size) return;
|
||||
|
||||
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
|
||||
editor.view.dispatch(editor.state.tr.addMark(from, to, editor.schema.marks.link.create({ href: url })));
|
||||
},
|
||||
[localUrl, editor, from, to, viewProps.url]
|
||||
);
|
||||
|
||||
const handleUpdateText = (text: string) => {
|
||||
if (text === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = editor.view.state.doc.nodeAt(from) as Node;
|
||||
if (!node) return;
|
||||
const marks = node.marks;
|
||||
if (!marks) return;
|
||||
|
||||
editor.chain().setTextSelection(from).run();
|
||||
|
||||
editor.chain().deleteRange({ from: positionRef.from, to: positionRef.to }).run();
|
||||
editor.chain().insertContent(text).run();
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection({
|
||||
from: from,
|
||||
to: from + text.length,
|
||||
})
|
||||
.run();
|
||||
|
||||
setPositionRef({ from: from, to: from + text.length });
|
||||
|
||||
marks.forEach((mark) => {
|
||||
editor.chain().setMark(mark.type.name, mark.attrs).run();
|
||||
});
|
||||
};
|
||||
|
||||
const removeLink = () => {
|
||||
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
|
||||
linkRemoved.current = true;
|
||||
viewProps.closeLinkView();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onKeyDown={(e) => e.key === "Enter" && viewProps.closeLinkView()}
|
||||
className="shadow-md rounded p-2 flex flex-col gap-3 bg-custom-background-90 border-custom-border-100 border-2"
|
||||
>
|
||||
<InputView
|
||||
label={"URL"}
|
||||
placeholder={"Enter or paste URL"}
|
||||
defaultValue={localUrl}
|
||||
onChange={(e) => handleUpdateLink(e.target.value)}
|
||||
/>
|
||||
<InputView
|
||||
label={"Text"}
|
||||
placeholder={"Enter Text to display"}
|
||||
defaultValue={getText(from, to)}
|
||||
onChange={(e) => handleUpdateText(e.target.value)}
|
||||
/>
|
||||
<div className="mb-1 bg-custom-border-300 h-[1px] w-full gap-2" />
|
||||
<div className="flex text-sm text-custom-text-800 gap-2 items-center">
|
||||
<Link2Off size={14} className="inline-block" />
|
||||
<button onClick={() => removeLink()} className="cursor-pointer">
|
||||
Remove Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// components
|
||||
import { LinkViewProps } from "@/components/links";
|
||||
|
||||
export const LinkInputView = ({}: {
|
||||
viewProps: LinkViewProps;
|
||||
switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void;
|
||||
}) => <p>LinkInputView</p>;
|
||||
43
packages/editor/src/core/components/links/link-preview.tsx
Normal file
43
packages/editor/src/core/components/links/link-preview.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { Copy, GlobeIcon, Link2Off, PencilIcon } from "lucide-react";
|
||||
// components
|
||||
import { LinkViewProps } from "@/components/links";
|
||||
|
||||
export const LinkPreview = ({
|
||||
viewProps,
|
||||
switchView,
|
||||
}: {
|
||||
viewProps: LinkViewProps;
|
||||
switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void;
|
||||
}) => {
|
||||
const { editor, from, to, url } = viewProps;
|
||||
|
||||
const removeLink = () => {
|
||||
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
|
||||
viewProps.closeLinkView();
|
||||
};
|
||||
|
||||
const copyLinkToClipboard = () => {
|
||||
navigator.clipboard.writeText(url);
|
||||
viewProps.closeLinkView();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 top-0 max-w-max">
|
||||
<div className="shadow-md items-center rounded p-2 flex gap-3 bg-custom-background-90 border-custom-border-100 border-2 text-custom-text-300 text-xs">
|
||||
<GlobeIcon size={14} className="inline-block" />
|
||||
<p>{url.length > 40 ? url.slice(0, 40) + "..." : url}</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={copyLinkToClipboard} className="cursor-pointer">
|
||||
<Copy size={14} className="inline-block" />
|
||||
</button>
|
||||
<button onClick={() => switchView("LinkEditView")} className="cursor-pointer">
|
||||
<PencilIcon size={14} className="inline-block" />
|
||||
</button>
|
||||
<button onClick={removeLink} className="cursor-pointer">
|
||||
<Link2Off size={14} className="inline-block" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
42
packages/editor/src/core/components/links/link-view.tsx
Normal file
42
packages/editor/src/core/components/links/link-view.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { CSSProperties, useEffect, useState } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
// components
|
||||
import { LinkEditView, LinkInputView, LinkPreview } from "@/components/links";
|
||||
|
||||
export interface LinkViewProps {
|
||||
view?: "LinkPreview" | "LinkEditView" | "LinkInputView";
|
||||
editor: Editor;
|
||||
from: number;
|
||||
to: number;
|
||||
url: string;
|
||||
closeLinkView: () => void;
|
||||
}
|
||||
|
||||
export const LinkView = (props: LinkViewProps & { style: CSSProperties }) => {
|
||||
const [currentView, setCurrentView] = useState(props.view ?? "LinkInputView");
|
||||
const [prevFrom, setPrevFrom] = useState(props.from);
|
||||
|
||||
const switchView = (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => {
|
||||
setCurrentView(view);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (props.from !== prevFrom) {
|
||||
setCurrentView("LinkPreview");
|
||||
setPrevFrom(props.from);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderView = () => {
|
||||
switch (currentView) {
|
||||
case "LinkPreview":
|
||||
return <LinkPreview viewProps={props} switchView={switchView} />;
|
||||
case "LinkEditView":
|
||||
return <LinkEditView viewProps={props} switchView={switchView} />;
|
||||
case "LinkInputView":
|
||||
return <LinkInputView viewProps={props} switchView={switchView} />;
|
||||
}
|
||||
};
|
||||
|
||||
return renderView();
|
||||
};
|
||||
174
packages/editor/src/core/components/menus/block-menu.tsx
Normal file
174
packages/editor/src/core/components/menus/block-menu.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Copy, LucideIcon, Trash2 } from "lucide-react";
|
||||
import tippy, { Instance } from "tippy.js";
|
||||
import { Editor } from "@tiptap/react";
|
||||
|
||||
interface BlockMenuProps {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
export const BlockMenu = (props: BlockMenuProps) => {
|
||||
const { editor } = props;
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const popup = useRef<Instance | null>(null);
|
||||
|
||||
const handleClickDragHandle = useCallback((event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.matches(".drag-handle-dots") || target.matches(".drag-handle-dot")) {
|
||||
event.preventDefault();
|
||||
|
||||
popup.current?.setProps({
|
||||
getReferenceClientRect: () => target.getBoundingClientRect(),
|
||||
});
|
||||
|
||||
popup.current?.show();
|
||||
return;
|
||||
}
|
||||
|
||||
popup.current?.hide();
|
||||
return;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (menuRef.current) {
|
||||
menuRef.current.remove();
|
||||
menuRef.current.style.visibility = "visible";
|
||||
|
||||
// @ts-expect-error - tippy types are incorrect
|
||||
popup.current = tippy(document.body, {
|
||||
getReferenceClientRect: null,
|
||||
content: menuRef.current,
|
||||
appendTo: () => document.querySelector(".frame-renderer"),
|
||||
trigger: "manual",
|
||||
interactive: true,
|
||||
arrow: false,
|
||||
placement: "left-start",
|
||||
animation: "shift-away",
|
||||
maxWidth: 500,
|
||||
hideOnClick: true,
|
||||
onShown: () => {
|
||||
menuRef.current?.focus();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
popup.current?.destroy();
|
||||
popup.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = () => {
|
||||
popup.current?.hide();
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
popup.current?.hide();
|
||||
};
|
||||
document.addEventListener("click", handleClickDragHandle);
|
||||
document.addEventListener("contextmenu", handleClickDragHandle);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener("scroll", handleScroll, true); // Using capture phase
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClickDragHandle);
|
||||
document.removeEventListener("contextmenu", handleClickDragHandle);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.removeEventListener("scroll", handleScroll, true);
|
||||
};
|
||||
}, [handleClickDragHandle]);
|
||||
|
||||
const MENU_ITEMS: {
|
||||
icon: LucideIcon;
|
||||
key: string;
|
||||
label: string;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
isDisabled?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
icon: Trash2,
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
onClick: (e) => {
|
||||
editor.chain().deleteSelection().focus().run();
|
||||
popup.current?.hide();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: Copy,
|
||||
key: "duplicate",
|
||||
label: "Duplicate",
|
||||
isDisabled: editor.state.selection.content().content.firstChild?.type.name === "image",
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
try {
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
const firstChild = selection.content().content.firstChild;
|
||||
const docSize = state.doc.content.size;
|
||||
|
||||
if (!firstChild) {
|
||||
throw new Error("No content selected or content is not duplicable.");
|
||||
}
|
||||
|
||||
// Directly use selection.to as the insertion position
|
||||
const insertPos = selection.to;
|
||||
|
||||
// Ensure the insertion position is within the document's bounds
|
||||
if (insertPos < 0 || insertPos > docSize) {
|
||||
throw new Error("The insertion position is invalid or outside the document.");
|
||||
}
|
||||
|
||||
const contentToInsert = firstChild.toJSON();
|
||||
|
||||
// Insert the content at the calculated position
|
||||
editor
|
||||
.chain()
|
||||
.insertContentAt(insertPos, contentToInsert, {
|
||||
updateSelection: true,
|
||||
})
|
||||
.focus(Math.min(insertPos + 1, docSize), { scrollIntoView: false })
|
||||
.run();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
popup.current?.hide();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="z-10 max-h-60 min-w-[7rem] overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg"
|
||||
>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
// Skip rendering the button if it should be disabled
|
||||
if (item.isDisabled && item.key === "duplicate") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 truncate rounded px-1 py-1.5 text-xs text-custom-text-200 hover:bg-custom-background-80"
|
||||
onClick={item.onClick}
|
||||
disabled={item.isDisabled}
|
||||
>
|
||||
<item.icon className="h-3 w-3" />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./link-selector";
|
||||
export * from "./node-selector";
|
||||
export * from "./root";
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Check, Trash } from "lucide-react";
|
||||
// helpers
|
||||
import { cn, isValidHttpUrl } from "@/helpers/common";
|
||||
import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands";
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const BubbleMenuLinkSelector: FC<Props> = ({ editor, isOpen, setIsOpen }) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onLinkSubmit = useCallback(() => {
|
||||
const input = inputRef.current;
|
||||
const url = input?.value;
|
||||
if (url && isValidHttpUrl(url)) {
|
||||
setLinkEditor(editor, url);
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [editor, inputRef, setIsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current && inputRef.current?.focus();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100",
|
||||
{ "bg-custom-background-100": isOpen }
|
||||
)}
|
||||
onClick={(e) => {
|
||||
setIsOpen(!isOpen);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<p className="text-base">↗</p>
|
||||
<p
|
||||
className={cn("underline underline-offset-4", {
|
||||
"text-custom-text-100": editor.isActive("link"),
|
||||
})}
|
||||
>
|
||||
Link
|
||||
</p>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div
|
||||
className="dow-xl fixed top-full z-[99999] mt-1 flex w-60 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 animate-in fade-in slide-in-from-top-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onLinkSubmit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="url"
|
||||
placeholder="Paste a link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="flex-1 border-r border-custom-border-300 bg-custom-background-100 p-1 text-sm outline-none placeholder:text-custom-text-400"
|
||||
defaultValue={editor.getAttributes("link").href || ""}
|
||||
/>
|
||||
{editor.getAttributes("link").href ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
|
||||
onClick={(e) => {
|
||||
unsetLinkEditor(editor);
|
||||
setIsOpen(false);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="flex items-center rounded-sm p-1 text-custom-text-300 transition-all hover:bg-custom-background-90"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
onLinkSubmit();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { Dispatch, FC, SetStateAction } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
// components
|
||||
import {
|
||||
BulletListItem,
|
||||
HeadingOneItem,
|
||||
HeadingThreeItem,
|
||||
HeadingTwoItem,
|
||||
NumberedListItem,
|
||||
QuoteItem,
|
||||
CodeItem,
|
||||
TodoListItem,
|
||||
TextItem,
|
||||
HeadingFourItem,
|
||||
HeadingFiveItem,
|
||||
HeadingSixItem,
|
||||
BubbleMenuItem,
|
||||
} from "@/components/menus";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const BubbleMenuNodeSelector: FC<Props> = ({ editor, isOpen, setIsOpen }) => {
|
||||
const items: BubbleMenuItem[] = [
|
||||
TextItem(editor),
|
||||
HeadingOneItem(editor),
|
||||
HeadingTwoItem(editor),
|
||||
HeadingThreeItem(editor),
|
||||
HeadingFourItem(editor),
|
||||
HeadingFiveItem(editor),
|
||||
HeadingSixItem(editor),
|
||||
BulletListItem(editor),
|
||||
NumberedListItem(editor),
|
||||
TodoListItem(editor),
|
||||
QuoteItem(editor),
|
||||
CodeItem(editor),
|
||||
];
|
||||
|
||||
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
||||
name: "Multiple",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
setIsOpen(!isOpen);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5"
|
||||
>
|
||||
<span>{activeItem?.name}</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
item.command();
|
||||
setIsOpen(false);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80",
|
||||
{
|
||||
"bg-custom-background-80": activeItem.name === item.name,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<item.icon className="size-3 flex-shrink-0" />
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
{activeItem.name === item.name && <Check className="size-3 text-custom-text-300 flex-shrink-0" />}
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
157
packages/editor/src/core/components/menus/bubble-menu/root.tsx
Normal file
157
packages/editor/src/core/components/menus/bubble-menu/root.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { FC, useEffect, useState } from "react";
|
||||
import { BubbleMenu, BubbleMenuProps, isNodeSelection } from "@tiptap/react";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
// components
|
||||
import {
|
||||
BoldItem,
|
||||
BubbleMenuLinkSelector,
|
||||
BubbleMenuNodeSelector,
|
||||
CodeItem,
|
||||
ItalicItem,
|
||||
StrikeThroughItem,
|
||||
UnderLineItem,
|
||||
} from "@/components/menus";
|
||||
// extensions
|
||||
import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
key: string;
|
||||
name: string;
|
||||
isActive: () => boolean;
|
||||
command: () => void;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||
const items: BubbleMenuItem[] = [
|
||||
...(props.editor.isActive("code")
|
||||
? []
|
||||
: [
|
||||
BoldItem(props.editor),
|
||||
ItalicItem(props.editor),
|
||||
UnderLineItem(props.editor),
|
||||
StrikeThroughItem(props.editor),
|
||||
]),
|
||||
CodeItem(props.editor),
|
||||
];
|
||||
|
||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||
...props,
|
||||
shouldShow: ({ state, editor }) => {
|
||||
const { selection } = state;
|
||||
|
||||
const { empty } = selection;
|
||||
|
||||
if (
|
||||
empty ||
|
||||
!editor.isEditable ||
|
||||
editor.isActive("image") ||
|
||||
isNodeSelection(selection) ||
|
||||
isCellSelection(selection) ||
|
||||
isSelecting
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
tippyOptions: {
|
||||
moveTransition: "transform 0.15s ease-out",
|
||||
onHidden: () => {
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsLinkSelectorOpen(false);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseDown() {
|
||||
function handleMouseMove() {
|
||||
if (!props.editor.state.selection.empty) {
|
||||
setIsSelecting(true);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
setIsSelecting(false);
|
||||
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
{...bubbleMenuProps}
|
||||
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
|
||||
>
|
||||
{isSelecting ? null : (
|
||||
<>
|
||||
{!props.editor.isActive("table") && (
|
||||
<BubbleMenuNodeSelector
|
||||
editor={props.editor!}
|
||||
isOpen={isNodeSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
||||
setIsLinkSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!props.editor.isActive("code") && (
|
||||
<BubbleMenuLinkSelector
|
||||
editor={props.editor}
|
||||
isOpen={isLinkSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
||||
setIsNodeSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
item.command();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={cn(
|
||||
"p-2 text-custom-text-300 transition-colors hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5",
|
||||
{
|
||||
"bg-custom-primary-100/5 text-custom-text-100": item.isActive(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-4 w-4", {
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</BubbleMenu>
|
||||
);
|
||||
};
|
||||
3
packages/editor/src/core/components/menus/index.ts
Normal file
3
packages/editor/src/core/components/menus/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./bubble-menu";
|
||||
export * from "./block-menu";
|
||||
export * from "./menu-items";
|
||||
245
packages/editor/src/core/components/menus/menu-items.ts
Normal file
245
packages/editor/src/core/components/menus/menu-items.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import {
|
||||
BoldIcon,
|
||||
Heading1,
|
||||
CheckSquare,
|
||||
Heading2,
|
||||
Heading3,
|
||||
QuoteIcon,
|
||||
ImageIcon,
|
||||
TableIcon,
|
||||
ListIcon,
|
||||
ListOrderedIcon,
|
||||
ItalicIcon,
|
||||
UnderlineIcon,
|
||||
StrikethroughIcon,
|
||||
CodeIcon,
|
||||
Heading4,
|
||||
Heading5,
|
||||
Heading6,
|
||||
CaseSensitive,
|
||||
LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { Editor } from "@tiptap/react";
|
||||
// helpers
|
||||
import {
|
||||
insertImageCommand,
|
||||
insertTableCommand,
|
||||
setText,
|
||||
toggleBlockquote,
|
||||
toggleBold,
|
||||
toggleBulletList,
|
||||
toggleCodeBlock,
|
||||
toggleHeadingFive,
|
||||
toggleHeadingFour,
|
||||
toggleHeadingOne,
|
||||
toggleHeadingSix,
|
||||
toggleHeadingThree,
|
||||
toggleHeadingTwo,
|
||||
toggleItalic,
|
||||
toggleOrderedList,
|
||||
toggleStrike,
|
||||
toggleTaskList,
|
||||
toggleUnderline,
|
||||
} from "@/helpers/editor-commands";
|
||||
// types
|
||||
import { UploadImage } from "@/types";
|
||||
|
||||
export interface EditorMenuItem {
|
||||
key: string;
|
||||
name: string;
|
||||
isActive: () => boolean;
|
||||
command: () => void;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export const TextItem = (editor: Editor) =>
|
||||
({
|
||||
key: "text",
|
||||
name: "Text",
|
||||
isActive: () => editor.isActive("paragraph"),
|
||||
command: () => setText(editor),
|
||||
icon: CaseSensitive,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const HeadingOneItem = (editor: Editor) =>
|
||||
({
|
||||
key: "h1",
|
||||
name: "Heading 1",
|
||||
isActive: () => editor.isActive("heading", { level: 1 }),
|
||||
command: () => toggleHeadingOne(editor),
|
||||
icon: Heading1,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const HeadingTwoItem = (editor: Editor) =>
|
||||
({
|
||||
key: "h2",
|
||||
name: "Heading 2",
|
||||
isActive: () => editor.isActive("heading", { level: 2 }),
|
||||
command: () => toggleHeadingTwo(editor),
|
||||
icon: Heading2,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const HeadingThreeItem = (editor: Editor) =>
|
||||
({
|
||||
key: "h3",
|
||||
name: "Heading 3",
|
||||
isActive: () => editor.isActive("heading", { level: 3 }),
|
||||
command: () => toggleHeadingThree(editor),
|
||||
icon: Heading3,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const HeadingFourItem = (editor: Editor) =>
|
||||
({
|
||||
key: "h4",
|
||||
name: "Heading 4",
|
||||
isActive: () => editor.isActive("heading", { level: 4 }),
|
||||
command: () => toggleHeadingFour(editor),
|
||||
icon: Heading4,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const HeadingFiveItem = (editor: Editor) =>
|
||||
({
|
||||
key: "h5",
|
||||
name: "Heading 5",
|
||||
isActive: () => editor.isActive("heading", { level: 5 }),
|
||||
command: () => toggleHeadingFive(editor),
|
||||
icon: Heading5,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const HeadingSixItem = (editor: Editor) =>
|
||||
({
|
||||
key: "h6",
|
||||
name: "Heading 6",
|
||||
isActive: () => editor.isActive("heading", { level: 6 }),
|
||||
command: () => toggleHeadingSix(editor),
|
||||
icon: Heading6,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const BoldItem = (editor: Editor) =>
|
||||
({
|
||||
key: "bold",
|
||||
name: "Bold",
|
||||
isActive: () => editor?.isActive("bold"),
|
||||
command: () => toggleBold(editor),
|
||||
icon: BoldIcon,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const ItalicItem = (editor: Editor) =>
|
||||
({
|
||||
key: "italic",
|
||||
name: "Italic",
|
||||
isActive: () => editor?.isActive("italic"),
|
||||
command: () => toggleItalic(editor),
|
||||
icon: ItalicIcon,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const UnderLineItem = (editor: Editor) =>
|
||||
({
|
||||
key: "underline",
|
||||
name: "Underline",
|
||||
isActive: () => editor?.isActive("underline"),
|
||||
command: () => toggleUnderline(editor),
|
||||
icon: UnderlineIcon,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const StrikeThroughItem = (editor: Editor) =>
|
||||
({
|
||||
key: "strikethrough",
|
||||
name: "Strikethrough",
|
||||
isActive: () => editor?.isActive("strike"),
|
||||
command: () => toggleStrike(editor),
|
||||
icon: StrikethroughIcon,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const BulletListItem = (editor: Editor) =>
|
||||
({
|
||||
key: "bulleted-list",
|
||||
name: "Bulleted list",
|
||||
isActive: () => editor?.isActive("bulletList"),
|
||||
command: () => toggleBulletList(editor),
|
||||
icon: ListIcon,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const NumberedListItem = (editor: Editor) =>
|
||||
({
|
||||
key: "numbered-list",
|
||||
name: "Numbered list",
|
||||
isActive: () => editor?.isActive("orderedList"),
|
||||
command: () => toggleOrderedList(editor),
|
||||
icon: ListOrderedIcon,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const TodoListItem = (editor: Editor) =>
|
||||
({
|
||||
key: "to-do-list",
|
||||
name: "To-do list",
|
||||
isActive: () => editor.isActive("taskItem"),
|
||||
command: () => toggleTaskList(editor),
|
||||
icon: CheckSquare,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const QuoteItem = (editor: Editor) =>
|
||||
({
|
||||
key: "quote",
|
||||
name: "Quote",
|
||||
isActive: () => editor?.isActive("blockquote"),
|
||||
command: () => toggleBlockquote(editor),
|
||||
icon: QuoteIcon,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const CodeItem = (editor: Editor) =>
|
||||
({
|
||||
key: "code",
|
||||
name: "Code",
|
||||
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
|
||||
command: () => toggleCodeBlock(editor),
|
||||
icon: CodeIcon,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const TableItem = (editor: Editor) =>
|
||||
({
|
||||
key: "table",
|
||||
name: "Table",
|
||||
isActive: () => editor?.isActive("table"),
|
||||
command: () => insertTableCommand(editor),
|
||||
icon: TableIcon,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const ImageItem = (editor: Editor, uploadFile: UploadImage) =>
|
||||
({
|
||||
key: "image",
|
||||
name: "Image",
|
||||
isActive: () => editor?.isActive("image"),
|
||||
command: (savedSelection: Selection | null) => insertImageCommand(editor, uploadFile, savedSelection),
|
||||
icon: ImageIcon,
|
||||
}) as const;
|
||||
|
||||
export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImage) {
|
||||
if (!editor) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
TextItem(editor),
|
||||
HeadingOneItem(editor),
|
||||
HeadingTwoItem(editor),
|
||||
HeadingThreeItem(editor),
|
||||
HeadingFourItem(editor),
|
||||
HeadingFiveItem(editor),
|
||||
HeadingSixItem(editor),
|
||||
BoldItem(editor),
|
||||
ItalicItem(editor),
|
||||
UnderLineItem(editor),
|
||||
StrikeThroughItem(editor),
|
||||
BulletListItem(editor),
|
||||
TodoListItem(editor),
|
||||
CodeItem(editor),
|
||||
NumberedListItem(editor),
|
||||
QuoteItem(editor),
|
||||
TableItem(editor),
|
||||
ImageItem(editor, uploadFile),
|
||||
];
|
||||
}
|
||||
|
||||
export type EditorMenuItemNames =
|
||||
ReturnType<typeof getEditorMenuItems> extends (infer U)[] ? (U extends { key: infer N } ? N : never) : never;
|
||||
96
packages/editor/src/core/extensions/code-inline/index.tsx
Normal file
96
packages/editor/src/core/extensions/code-inline/index.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { Mark, markInputRule, markPasteRule, mergeAttributes } from "@tiptap/core";
|
||||
|
||||
export interface CodeOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
code: {
|
||||
/**
|
||||
* Set a code mark
|
||||
*/
|
||||
setCode: () => ReturnType;
|
||||
/**
|
||||
* Toggle inline code
|
||||
*/
|
||||
toggleCode: () => ReturnType;
|
||||
/**
|
||||
* Unset a code mark
|
||||
*/
|
||||
unsetCode: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const inputRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))$/;
|
||||
const pasteRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))/g;
|
||||
|
||||
export const CustomCodeInlineExtension = Mark.create<CodeOptions>({
|
||||
name: "code",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
"rounded bg-custom-background-80 px-1 py-[2px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200",
|
||||
spellcheck: "false",
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
excludes: "_",
|
||||
|
||||
code: true,
|
||||
|
||||
exitable: true,
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "code" }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["code", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setCode:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.setMark(this.name),
|
||||
toggleCode:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.toggleMark(this.name),
|
||||
unsetCode:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.unsetMark(this.name),
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
"Mod-e": () => this.editor.commands.toggleCode(),
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
markInputRule({
|
||||
find: inputRegex,
|
||||
type: this.type,
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addPasteRules() {
|
||||
return [
|
||||
markPasteRule({
|
||||
find: pasteRegex,
|
||||
type: this.type,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
// import CodeBlock, { CodeBlockOptions } from "@tiptap/extension-code-block";
|
||||
|
||||
import { CodeBlockOptions, CodeBlock } from "./code-block";
|
||||
import { LowlightPlugin } from "./lowlight-plugin";
|
||||
|
||||
export interface CodeBlockLowlightOptions extends CodeBlockOptions {
|
||||
lowlight: any;
|
||||
defaultLanguage: string | null | undefined;
|
||||
}
|
||||
|
||||
export const CodeBlockLowlight = CodeBlock.extend<CodeBlockLowlightOptions>({
|
||||
addOptions() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
lowlight: {},
|
||||
defaultLanguage: null,
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
...(this.parent?.() || []),
|
||||
LowlightPlugin({
|
||||
name: this.name,
|
||||
lowlight: this.options.lowlight,
|
||||
defaultLanguage: this.options.defaultLanguage,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import ts from "highlight.js/lib/languages/typescript";
|
||||
import { CopyIcon, CheckIcon } from "lucide-react";
|
||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
|
||||
// we just have ts support for now
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("ts", ts);
|
||||
|
||||
interface CodeBlockComponentProps {
|
||||
node: ProseMirrorNode;
|
||||
}
|
||||
|
||||
export const CodeBlockComponent: React.FC<CodeBlockComponentProps> = ({ node }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyToClipboard = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(node.textContent);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
} catch (error) {
|
||||
setCopied(false);
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="code-block relative group/code">
|
||||
<Tooltip tooltipContent="Copy code">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group/button hidden group-hover/code:flex items-center justify-center absolute top-2 right-2 z-10 size-8 rounded-md bg-custom-background-80 border border-custom-border-200 transition duration-150 ease-in-out",
|
||||
{
|
||||
"bg-green-500/10 hover:bg-green-500/10 active:bg-green-500/10": copied,
|
||||
}
|
||||
)}
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="h-3 w-3 text-green-500" strokeWidth={3} />
|
||||
) : (
|
||||
<CopyIcon className="h-3 w-3 text-custom-text-300 group-hover/button:text-custom-text-100" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<pre className="bg-custom-background-90 text-custom-text-100 rounded-lg p-8 pl-9 pr-4">
|
||||
<NodeViewContent as="code" className="whitespace-[pre-wrap]" />
|
||||
</pre>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
346
packages/editor/src/core/extensions/code/code-block.ts
Normal file
346
packages/editor/src/core/extensions/code/code-block.ts
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
import { mergeAttributes, Node, textblockTypeInputRule } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
|
||||
export interface CodeBlockOptions {
|
||||
/**
|
||||
* Adds a prefix to language classes that are applied to code tags.
|
||||
* Defaults to `'language-'`.
|
||||
*/
|
||||
languageClassPrefix: string;
|
||||
/**
|
||||
* Define whether the node should be exited on triple enter.
|
||||
* Defaults to `true`.
|
||||
*/
|
||||
exitOnTripleEnter: boolean;
|
||||
/**
|
||||
* Define whether the node should be exited on arrow down if there is no node after it.
|
||||
* Defaults to `true`.
|
||||
*/
|
||||
exitOnArrowDown: boolean;
|
||||
/**
|
||||
* Custom HTML attributes that should be added to the rendered HTML tag.
|
||||
*/
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
codeBlock: {
|
||||
/**
|
||||
* Set a code block
|
||||
*/
|
||||
setCodeBlock: (attributes?: { language: string }) => ReturnType;
|
||||
/**
|
||||
* Toggle a code block
|
||||
*/
|
||||
toggleCodeBlock: (attributes?: { language: string }) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
|
||||
export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
|
||||
|
||||
export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
name: "codeBlock",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
languageClassPrefix: "language-",
|
||||
exitOnTripleEnter: true,
|
||||
exitOnArrowDown: true,
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
content: "text*",
|
||||
|
||||
marks: "",
|
||||
|
||||
group: "block",
|
||||
|
||||
code: true,
|
||||
|
||||
defining: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
language: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const { languageClassPrefix } = this.options;
|
||||
// @ts-expect-error element is a DOM element
|
||||
const classNames = [...(element.firstElementChild?.classList || [])];
|
||||
const languages = classNames
|
||||
.filter((className) => className.startsWith(languageClassPrefix))
|
||||
.map((className) => className.replace(languageClassPrefix, ""));
|
||||
const language = languages[0];
|
||||
|
||||
if (!language) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return language;
|
||||
},
|
||||
rendered: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "pre",
|
||||
preserveWhitespace: "full",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
return [
|
||||
"pre",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
[
|
||||
"code",
|
||||
{
|
||||
class: node.attrs.language ? this.options.languageClassPrefix + node.attrs.language : null,
|
||||
},
|
||||
0,
|
||||
],
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setCodeBlock:
|
||||
(attributes) =>
|
||||
({ commands }) =>
|
||||
commands.setNode(this.name, attributes),
|
||||
toggleCodeBlock:
|
||||
(attributes) =>
|
||||
({ commands }) =>
|
||||
commands.toggleNode(this.name, "paragraph", attributes),
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
"Mod-Alt-c": () => this.editor.commands.toggleCodeBlock(),
|
||||
|
||||
// remove code block when at start of document or code block is empty
|
||||
Backspace: () => {
|
||||
try {
|
||||
const { empty, $anchor } = this.editor.state.selection;
|
||||
const isAtStart = $anchor.pos === 1;
|
||||
|
||||
if (!empty || $anchor.parent.type.name !== this.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAtStart || !$anchor.parent.textContent.length) {
|
||||
return this.editor.commands.clearNodes();
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Error handling Backspace in code block:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// exit node on triple enter
|
||||
Enter: ({ editor }) => {
|
||||
try {
|
||||
if (!this.options.exitOnTripleEnter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
|
||||
const endsWithDoubleNewline = $from.parent.textContent.endsWith("\n\n");
|
||||
|
||||
if (!isAtEnd || !endsWithDoubleNewline) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor
|
||||
.chain()
|
||||
.command(({ tr }) => {
|
||||
tr.delete($from.pos - 2, $from.pos);
|
||||
|
||||
return true;
|
||||
})
|
||||
.exitCode()
|
||||
.run();
|
||||
} catch (error) {
|
||||
console.error("Error handling Enter in code block:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// exit node on arrow down
|
||||
ArrowDown: ({ editor }) => {
|
||||
try {
|
||||
if (!this.options.exitOnArrowDown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { state } = editor;
|
||||
const { selection, doc } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
|
||||
|
||||
if (!isAtEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const after = $from.after();
|
||||
|
||||
if (after === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodeAfter = doc.nodeAt(after);
|
||||
|
||||
if (nodeAfter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.commands.exitCode();
|
||||
} catch (error) {
|
||||
console.error("Error handling ArrowDown in code block:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
textblockTypeInputRule({
|
||||
find: backtickInputRegex,
|
||||
type: this.type,
|
||||
getAttributes: (match) => ({
|
||||
language: match[1],
|
||||
}),
|
||||
}),
|
||||
textblockTypeInputRule({
|
||||
find: tildeInputRegex,
|
||||
type: this.type,
|
||||
getAttributes: (match) => ({
|
||||
language: match[1],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("codeBlockVSCodeHandlerCustom"),
|
||||
props: {
|
||||
handlePaste: (view, event) => {
|
||||
try {
|
||||
if (!event.clipboardData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.editor.isActive(this.type.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.editor.isActive("code")) {
|
||||
// Check if it's an inline code block
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
|
||||
if (!text) {
|
||||
console.error("Pasted text is empty.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const { tr } = view.state;
|
||||
const { $from, $to } = tr.selection;
|
||||
|
||||
if ($from.pos > $to.pos) {
|
||||
console.error("Invalid selection range.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const docSize = tr.doc.content.size;
|
||||
if ($from.pos < 0 || $to.pos > docSize) {
|
||||
console.error("Selection range is out of document bounds.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extend the current selection to replace it with the pasted text
|
||||
// wrapped in an inline code mark
|
||||
const codeMark = view.state.schema.marks.code.create();
|
||||
tr.replaceWith($from.pos, $to.pos, view.state.schema.text(text, [codeMark]));
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
const vscode = event.clipboardData.getData("vscode-editor-data");
|
||||
const vscodeData = vscode ? JSON.parse(vscode) : undefined;
|
||||
const language = vscodeData?.mode;
|
||||
|
||||
if (vscodeData && language) {
|
||||
const { tr } = view.state;
|
||||
const { $from } = tr.selection;
|
||||
|
||||
// Check if the current line is empty
|
||||
const isCurrentLineEmpty = !$from.parent.textContent.trim();
|
||||
|
||||
let insertPos;
|
||||
|
||||
if (isCurrentLineEmpty) {
|
||||
// If the current line is empty, use the current position
|
||||
insertPos = $from.pos - 1;
|
||||
} else {
|
||||
// If the current line is not empty, insert below the current block node
|
||||
insertPos = $from.end($from.depth) + 1;
|
||||
}
|
||||
|
||||
// Ensure insertPos is within document bounds
|
||||
if (insertPos < 0 || insertPos > tr.doc.content.size) {
|
||||
console.error("Invalid insert position.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create a new code block node with the pasted content
|
||||
const textNode = view.state.schema.text(text.replace(/\r\n?/g, "\n"));
|
||||
const codeBlock = this.type.create({ language }, textNode);
|
||||
if (insertPos <= tr.doc.content.size) {
|
||||
tr.insert(insertPos, codeBlock);
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} else {
|
||||
// TODO: complicated paste logic, to be handled later
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling paste in CodeBlock extension:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
121
packages/editor/src/core/extensions/code/index.tsx
Normal file
121
packages/editor/src/core/extensions/code/index.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { common, createLowlight } from "lowlight";
|
||||
import ts from "highlight.js/lib/languages/typescript";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("ts", ts);
|
||||
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { CodeBlockComponent } from "./code-block-node-view";
|
||||
import { CodeBlockLowlight } from "./code-block-lowlight";
|
||||
|
||||
export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CodeBlockComponent);
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: ({ editor }) => {
|
||||
try {
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use ProseMirror's insertText transaction to insert the tab character
|
||||
const tr = state.tr.insertText("\t", $from.pos, $from.pos);
|
||||
editor.view.dispatch(tr);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error handling Tab in CustomCodeBlockExtension:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
ArrowUp: ({ editor }) => {
|
||||
try {
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAtStart = $from.parentOffset === 0;
|
||||
|
||||
if (!isAtStart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if codeBlock is the first node
|
||||
const isFirstNode = $from.depth === 1 && $from.index($from.depth - 1) === 0;
|
||||
|
||||
if (isFirstNode) {
|
||||
// Insert a new paragraph at the start of the document and move the cursor to it
|
||||
return editor.commands.command(({ tr }) => {
|
||||
const node = editor.schema.nodes.paragraph.create();
|
||||
tr.insert(0, node);
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(1)));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Error handling ArrowUp in CustomCodeBlockExtension:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
ArrowDown: ({ editor }) => {
|
||||
try {
|
||||
if (!this.options.exitOnArrowDown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { state } = editor;
|
||||
const { selection, doc } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
|
||||
|
||||
if (!isAtEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const after = $from.after();
|
||||
|
||||
if (after === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodeAfter = doc.nodeAt(after);
|
||||
|
||||
if (nodeAfter) {
|
||||
return editor.commands.command(({ tr }) => {
|
||||
tr.setSelection(Selection.near(doc.resolve(after)));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return editor.commands.exitCode();
|
||||
} catch (error) {
|
||||
console.error("Error handling ArrowDown in CustomCodeBlockExtension:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
}).configure({
|
||||
lowlight,
|
||||
defaultLanguage: "plaintext",
|
||||
exitOnTripleEnter: false,
|
||||
});
|
||||
153
packages/editor/src/core/extensions/code/lowlight-plugin.ts
Normal file
153
packages/editor/src/core/extensions/code/lowlight-plugin.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { findChildren } from "@tiptap/core";
|
||||
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import highlight from "highlight.js/lib/core";
|
||||
|
||||
function parseNodes(nodes: any[], className: string[] = []): { text: string; classes: string[] }[] {
|
||||
return nodes
|
||||
.map((node) => {
|
||||
const classes = [...className, ...(node.properties ? node.properties.className : [])];
|
||||
|
||||
if (node.children) {
|
||||
return parseNodes(node.children, classes);
|
||||
}
|
||||
|
||||
return {
|
||||
text: node.value,
|
||||
classes,
|
||||
};
|
||||
})
|
||||
.flat();
|
||||
}
|
||||
|
||||
function getHighlightNodes(result: any) {
|
||||
// `.value` for lowlight v1, `.children` for lowlight v2
|
||||
return result.value || result.children || [];
|
||||
}
|
||||
|
||||
function registered(aliasOrLanguage: string) {
|
||||
return Boolean(highlight.getLanguage(aliasOrLanguage));
|
||||
}
|
||||
|
||||
function getDecorations({
|
||||
doc,
|
||||
name,
|
||||
lowlight,
|
||||
defaultLanguage,
|
||||
}: {
|
||||
doc: ProsemirrorNode;
|
||||
name: string;
|
||||
lowlight: any;
|
||||
defaultLanguage: string | null | undefined;
|
||||
}) {
|
||||
const decorations: Decoration[] = [];
|
||||
|
||||
findChildren(doc, (node) => node.type.name === name).forEach((block) => {
|
||||
let from = block.pos + 1;
|
||||
const language = block.node.attrs.language || defaultLanguage;
|
||||
const languages = lowlight.listLanguages();
|
||||
|
||||
const nodes =
|
||||
language && (languages.includes(language) || registered(language))
|
||||
? getHighlightNodes(lowlight.highlight(language, block.node.textContent))
|
||||
: getHighlightNodes(lowlight.highlightAuto(block.node.textContent));
|
||||
|
||||
parseNodes(nodes).forEach((node) => {
|
||||
const to = from + node.text.length;
|
||||
|
||||
if (node.classes.length) {
|
||||
const decoration = Decoration.inline(from, to, {
|
||||
class: node.classes.join(" "),
|
||||
});
|
||||
|
||||
decorations.push(decoration);
|
||||
}
|
||||
|
||||
from = to;
|
||||
});
|
||||
});
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
}
|
||||
|
||||
function isFunction(param: () => any) {
|
||||
return typeof param === "function";
|
||||
}
|
||||
|
||||
export function LowlightPlugin({
|
||||
name,
|
||||
lowlight,
|
||||
defaultLanguage,
|
||||
}: {
|
||||
name: string;
|
||||
lowlight: any;
|
||||
defaultLanguage: string | null | undefined;
|
||||
}) {
|
||||
if (!["highlight", "highlightAuto", "listLanguages"].every((api) => isFunction(lowlight[api]))) {
|
||||
throw Error("You should provide an instance of lowlight to use the code-block-lowlight extension");
|
||||
}
|
||||
|
||||
const lowlightPlugin: Plugin<any> = new Plugin({
|
||||
key: new PluginKey("lowlight"),
|
||||
|
||||
state: {
|
||||
init: (_, { doc }) =>
|
||||
getDecorations({
|
||||
doc,
|
||||
name,
|
||||
lowlight,
|
||||
defaultLanguage,
|
||||
}),
|
||||
apply: (transaction, decorationSet, oldState, newState) => {
|
||||
const oldNodeName = oldState.selection.$head.parent.type.name;
|
||||
const newNodeName = newState.selection.$head.parent.type.name;
|
||||
const oldNodes = findChildren(oldState.doc, (node) => node.type.name === name);
|
||||
const newNodes = findChildren(newState.doc, (node) => node.type.name === name);
|
||||
|
||||
if (
|
||||
transaction.docChanged &&
|
||||
// Apply decorations if:
|
||||
// selection includes named node,
|
||||
([oldNodeName, newNodeName].includes(name) ||
|
||||
// OR transaction adds/removes named node,
|
||||
newNodes.length !== oldNodes.length ||
|
||||
// OR transaction has changes that completely encapsulte a node
|
||||
// (for example, a transaction that affects the entire document).
|
||||
// Such transactions can happen during collab syncing via y-prosemirror, for example.
|
||||
transaction.steps.some(
|
||||
(step) =>
|
||||
// @ts-ignore
|
||||
step.from !== undefined &&
|
||||
// @ts-ignore
|
||||
step.to !== undefined &&
|
||||
oldNodes.some(
|
||||
(node) =>
|
||||
// @ts-ignore
|
||||
node.pos >= step.from &&
|
||||
// @ts-ignore
|
||||
node.pos + node.node.nodeSize <= step.to
|
||||
)
|
||||
))
|
||||
) {
|
||||
return getDecorations({
|
||||
doc: transaction.doc,
|
||||
name,
|
||||
lowlight,
|
||||
defaultLanguage,
|
||||
});
|
||||
}
|
||||
|
||||
return decorationSet.map(transaction.mapping, transaction.doc);
|
||||
},
|
||||
},
|
||||
|
||||
props: {
|
||||
decorations(state) {
|
||||
return lowlightPlugin.getState(state);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return lowlightPlugin;
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import { Editor, findParentNode } from "@tiptap/core";
|
||||
|
||||
type ReplaceCodeBlockParams = {
|
||||
editor: Editor;
|
||||
from: number;
|
||||
to: number;
|
||||
textContent: string;
|
||||
cursorPosInsideCodeblock: number;
|
||||
};
|
||||
|
||||
export function replaceCodeWithText(editor: Editor): void {
|
||||
try {
|
||||
const { from, to } = editor.state.selection;
|
||||
const cursorPosInsideCodeblock = from;
|
||||
let replaced = false;
|
||||
|
||||
editor.state.doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (node.type === editor.state.schema.nodes.codeBlock) {
|
||||
const startPos = pos;
|
||||
const endPos = pos + node.nodeSize;
|
||||
const textContent = node.textContent;
|
||||
|
||||
if (textContent.length === 0) {
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
} else {
|
||||
transformCodeBlockToParagraphs({
|
||||
editor,
|
||||
from: startPos,
|
||||
to: endPos,
|
||||
textContent,
|
||||
cursorPosInsideCodeblock,
|
||||
});
|
||||
}
|
||||
|
||||
replaced = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!replaced) {
|
||||
console.log("No code block to replace.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("An error occurred while replacing code block content:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function transformCodeBlockToParagraphs({
|
||||
editor,
|
||||
from,
|
||||
to,
|
||||
textContent,
|
||||
cursorPosInsideCodeblock,
|
||||
}: ReplaceCodeBlockParams): void {
|
||||
const { schema } = editor.state;
|
||||
const { paragraph } = schema.nodes;
|
||||
const docSize = editor.state.doc.content.size;
|
||||
|
||||
if (from < 0 || to > docSize || from > to) {
|
||||
console.error("Invalid range for replacement: ", from, to, "in a document of size", docSize);
|
||||
return;
|
||||
}
|
||||
|
||||
// Split the textContent by new lines to handle each line as a separate paragraph for Windows (\r\n) and Unix (\n)
|
||||
const lines = textContent.split(/\r?\n/);
|
||||
const tr = editor.state.tr;
|
||||
let insertPos = from;
|
||||
|
||||
// Remove the code block first
|
||||
tr.delete(from, to);
|
||||
|
||||
// For each line, create a paragraph node and insert it
|
||||
lines.forEach((line) => {
|
||||
// if the line is empty, create a paragraph node with no content
|
||||
const paragraphNode = line.length === 0 ? paragraph.create({}) : paragraph.create({}, schema.text(line));
|
||||
tr.insert(insertPos, paragraphNode);
|
||||
insertPos += paragraphNode.nodeSize;
|
||||
});
|
||||
|
||||
// Now persist the focus to the converted paragraph
|
||||
const parentNodeOffset = findParentNode((node) => node.type === schema.nodes.codeBlock)(editor.state.selection)?.pos;
|
||||
|
||||
if (parentNodeOffset === undefined) throw new Error("Invalid code block offset");
|
||||
|
||||
const lineNumber = getLineNumber(textContent, cursorPosInsideCodeblock, parentNodeOffset);
|
||||
const cursorPosOutsideCodeblock = cursorPosInsideCodeblock + (lineNumber - 1);
|
||||
|
||||
editor.view.dispatch(tr);
|
||||
editor.chain().focus(cursorPosOutsideCodeblock).run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the line number where the cursor is located inside the code block.
|
||||
* Assumes the indexing of the content inside the code block is like ProseMirror's indexing.
|
||||
*
|
||||
* @param {string} textContent - The content of the code block.
|
||||
* @param {number} cursorPosition - The absolute cursor position in the document.
|
||||
* @param {number} codeBlockNodePos - The starting position of the code block node in the document.
|
||||
* @returns {number} The 1-based line number where the cursor is located.
|
||||
*/
|
||||
function getLineNumber(textContent: string, cursorPosition: number, codeBlockNodePos: number): number {
|
||||
// Split the text content into lines, handling both Unix and Windows newlines
|
||||
const lines = textContent.split(/\r?\n/);
|
||||
const cursorPosInsideCodeblockRelative = cursorPosition - codeBlockNodePos;
|
||||
|
||||
let startPosition = 0;
|
||||
let lineNumber = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
// Calculate the end position of the current line
|
||||
const endPosition = startPosition + lines[i].length + 1; // +1 for the newline character
|
||||
|
||||
// Check if the cursor position is within the current line
|
||||
if (cursorPosInsideCodeblockRelative >= startPosition && cursorPosInsideCodeblockRelative <= endPosition) {
|
||||
lineNumber = i + 1; // Line numbers are 1-based
|
||||
break;
|
||||
}
|
||||
|
||||
// Update the start position for the next line
|
||||
startPosition = endPosition;
|
||||
}
|
||||
|
||||
return lineNumber;
|
||||
}
|
||||
120
packages/editor/src/core/extensions/core-without-props.tsx
Normal file
120
packages/editor/src/core/extensions/core-without-props.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
import TiptapUnderline from "@tiptap/extension-underline";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
// extensions
|
||||
import {
|
||||
CustomCodeBlockExtension,
|
||||
CustomCodeInlineExtension,
|
||||
CustomCodeMarkPlugin,
|
||||
CustomHorizontalRule,
|
||||
CustomKeymap,
|
||||
CustomLinkExtension,
|
||||
CustomMentionWithoutProps,
|
||||
CustomQuoteExtension,
|
||||
CustomTypographyExtension,
|
||||
ImageExtensionWithoutProps,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/extensions";
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
|
||||
export const CoreEditorExtensionsWithoutProps = () => [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-disc pl-7 space-y-2",
|
||||
},
|
||||
},
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-decimal pl-7 space-y-2",
|
||||
},
|
||||
},
|
||||
listItem: {
|
||||
HTMLAttributes: {
|
||||
class: "not-prose space-y-2",
|
||||
},
|
||||
},
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
blockquote: false,
|
||||
dropcursor: {
|
||||
color: "rgba(var(--color-text-100))",
|
||||
width: 1,
|
||||
},
|
||||
}),
|
||||
CustomQuoteExtension,
|
||||
CustomHorizontalRule.configure({
|
||||
HTMLAttributes: {
|
||||
class: "my-4 border-custom-border-400",
|
||||
},
|
||||
}),
|
||||
CustomKeymap,
|
||||
// ListKeymap,
|
||||
CustomLinkExtension.configure({
|
||||
openOnClick: true,
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
protocols: ["http", "https"],
|
||||
validate: (url: string) => isValidHttpUrl(url),
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||
},
|
||||
}),
|
||||
CustomTypographyExtension,
|
||||
ImageExtensionWithoutProps().configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
HTMLAttributes: {
|
||||
class: "not-prose pl-2 space-y-2",
|
||||
},
|
||||
}),
|
||||
TaskItem.configure({
|
||||
HTMLAttributes: {
|
||||
class: "flex",
|
||||
},
|
||||
nested: true,
|
||||
}),
|
||||
CustomCodeBlockExtension.configure({
|
||||
HTMLAttributes: {
|
||||
class: "",
|
||||
},
|
||||
}),
|
||||
CustomCodeMarkPlugin,
|
||||
CustomCodeInlineExtension,
|
||||
Markdown.configure({
|
||||
html: true,
|
||||
transformPastedText: true,
|
||||
}),
|
||||
Table,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TableRow,
|
||||
CustomMentionWithoutProps(),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ editor, node }) => {
|
||||
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
|
||||
|
||||
const shouldHidePlaceholder =
|
||||
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
|
||||
if (shouldHidePlaceholder) return "";
|
||||
|
||||
return "Press '/' for commands...";
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
];
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Extension } from "@tiptap/core";
|
||||
import codemark from "prosemirror-codemark";
|
||||
|
||||
export const CustomCodeMarkPlugin = Extension.create({
|
||||
name: "codemarkPlugin",
|
||||
addProseMirrorPlugins() {
|
||||
return codemark({ markType: this.editor.schema.marks.code });
|
||||
},
|
||||
});
|
||||
245
packages/editor/src/core/extensions/custom-link/extension.tsx
Normal file
245
packages/editor/src/core/extensions/custom-link/extension.tsx
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { Mark, markPasteRule, mergeAttributes, PasteRuleMatch } from "@tiptap/core";
|
||||
import { Plugin } from "@tiptap/pm/state";
|
||||
import { find, registerCustomProtocol, reset } from "linkifyjs";
|
||||
import { autolink } from "./helpers/autolink";
|
||||
import { clickHandler } from "./helpers/clickHandler";
|
||||
import { pasteHandler } from "./helpers/pasteHandler";
|
||||
|
||||
export interface LinkProtocolOptions {
|
||||
scheme: string;
|
||||
optionalSlashes?: boolean;
|
||||
}
|
||||
|
||||
export interface LinkOptions {
|
||||
/**
|
||||
* If enabled, it adds links as you type.
|
||||
*/
|
||||
autolink: boolean;
|
||||
/**
|
||||
* An array of custom protocols to be registered with linkifyjs.
|
||||
*/
|
||||
protocols: Array<LinkProtocolOptions | string>;
|
||||
/**
|
||||
* If enabled, links will be opened on click.
|
||||
*/
|
||||
openOnClick: boolean;
|
||||
/**
|
||||
* If enabled, links will be inclusive i.e. if you move your cursor to the
|
||||
* link text, and start typing, it'll be a part of the link itself.
|
||||
*/
|
||||
inclusive: boolean;
|
||||
/**
|
||||
* Adds a link to the current selection if the pasted content only contains an url.
|
||||
*/
|
||||
linkOnPaste: boolean;
|
||||
/**
|
||||
* A list of HTML attributes to be rendered.
|
||||
*/
|
||||
HTMLAttributes: Record<string, any>;
|
||||
/**
|
||||
* A validation function that modifies link verification for the auto linker.
|
||||
* @param url - The url to be validated.
|
||||
* @returns - True if the url is valid, false otherwise.
|
||||
*/
|
||||
validate?: (url: string) => boolean;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
link: {
|
||||
/**
|
||||
* Set a link mark
|
||||
*/
|
||||
setLink: (attributes: {
|
||||
href: string;
|
||||
target?: string | null;
|
||||
rel?: string | null;
|
||||
class?: string | null;
|
||||
}) => ReturnType;
|
||||
/**
|
||||
* Toggle a link mark
|
||||
*/
|
||||
toggleLink: (attributes: {
|
||||
href: string;
|
||||
target?: string | null;
|
||||
rel?: string | null;
|
||||
class?: string | null;
|
||||
}) => ReturnType;
|
||||
/**
|
||||
* Unset a link mark
|
||||
*/
|
||||
unsetLink: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomLinkExtension = Mark.create<LinkOptions>({
|
||||
name: "link",
|
||||
|
||||
priority: 1000,
|
||||
|
||||
keepOnSplit: false,
|
||||
|
||||
onCreate() {
|
||||
this.options.protocols.forEach((protocol) => {
|
||||
if (typeof protocol === "string") {
|
||||
registerCustomProtocol(protocol);
|
||||
return;
|
||||
}
|
||||
registerCustomProtocol(protocol.scheme, protocol.optionalSlashes);
|
||||
});
|
||||
},
|
||||
|
||||
onDestroy() {
|
||||
reset();
|
||||
},
|
||||
|
||||
inclusive() {
|
||||
return this.options.inclusive;
|
||||
},
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
openOnClick: true,
|
||||
linkOnPaste: true,
|
||||
autolink: true,
|
||||
inclusive: false,
|
||||
protocols: [],
|
||||
HTMLAttributes: {
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer nofollow",
|
||||
class: null,
|
||||
},
|
||||
validate: undefined,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
href: {
|
||||
default: null,
|
||||
},
|
||||
target: {
|
||||
default: this.options.HTMLAttributes.target,
|
||||
},
|
||||
rel: {
|
||||
default: this.options.HTMLAttributes.rel,
|
||||
},
|
||||
class: {
|
||||
default: this.options.HTMLAttributes.class,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "a[href]",
|
||||
getAttrs: (node) => {
|
||||
if (typeof node === "string" || !(node instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
const href = node.getAttribute("href")?.toLowerCase() || "";
|
||||
if (href.startsWith("javascript:") || href.startsWith("data:") || href.startsWith("vbscript:")) {
|
||||
return false;
|
||||
}
|
||||
return {};
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const href = HTMLAttributes.href?.toLowerCase() || "";
|
||||
if (href.startsWith("javascript:") || href.startsWith("data:") || href.startsWith("vbscript:")) {
|
||||
return ["a", mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: "" }), 0];
|
||||
}
|
||||
return ["a", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setLink:
|
||||
(attributes) =>
|
||||
({ chain }) =>
|
||||
chain().setMark(this.name, attributes).setMeta("preventAutolink", true).run(),
|
||||
|
||||
toggleLink:
|
||||
(attributes) =>
|
||||
({ chain }) =>
|
||||
chain()
|
||||
.toggleMark(this.name, attributes, { extendEmptyMarkRange: true })
|
||||
.setMeta("preventAutolink", true)
|
||||
.run(),
|
||||
|
||||
unsetLink:
|
||||
() =>
|
||||
({ chain }) =>
|
||||
chain().unsetMark(this.name, { extendEmptyMarkRange: true }).setMeta("preventAutolink", true).run(),
|
||||
};
|
||||
},
|
||||
|
||||
addPasteRules() {
|
||||
return [
|
||||
markPasteRule({
|
||||
find: (text) => {
|
||||
const foundLinks: PasteRuleMatch[] = [];
|
||||
|
||||
if (text) {
|
||||
const links = find(text).filter((item) => item.isLink);
|
||||
|
||||
if (links.length) {
|
||||
links.forEach((link) =>
|
||||
foundLinks.push({
|
||||
text: link.value,
|
||||
data: {
|
||||
href: link.href,
|
||||
},
|
||||
index: link.start,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return foundLinks;
|
||||
},
|
||||
type: this.type,
|
||||
getAttributes: (match) => ({
|
||||
href: match.data?.href,
|
||||
}),
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const plugins: Plugin[] = [];
|
||||
|
||||
if (this.options.autolink) {
|
||||
plugins.push(
|
||||
autolink({
|
||||
type: this.type,
|
||||
validate: this.options.validate,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (this.options.openOnClick) {
|
||||
plugins.push(
|
||||
clickHandler({
|
||||
type: this.type,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (this.options.linkOnPaste) {
|
||||
plugins.push(
|
||||
pasteHandler({
|
||||
editor: this.editor,
|
||||
type: this.type,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return plugins;
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import {
|
||||
combineTransactionSteps,
|
||||
findChildrenInRange,
|
||||
getChangedRanges,
|
||||
getMarksBetween,
|
||||
NodeWithPos,
|
||||
} from "@tiptap/core";
|
||||
import { MarkType } from "@tiptap/pm/model";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { find } from "linkifyjs";
|
||||
|
||||
type AutolinkOptions = {
|
||||
type: MarkType;
|
||||
validate?: (url: string) => boolean;
|
||||
};
|
||||
|
||||
export function autolink(options: AutolinkOptions): Plugin {
|
||||
return new Plugin({
|
||||
key: new PluginKey("autolink"),
|
||||
appendTransaction: (transactions, oldState, newState) => {
|
||||
const docChanges = transactions.some((transaction) => transaction.docChanged) && !oldState.doc.eq(newState.doc);
|
||||
const preventAutolink = transactions.some((transaction) => transaction.getMeta("preventAutolink"));
|
||||
|
||||
if (!docChanges || preventAutolink) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { tr } = newState;
|
||||
const transform = combineTransactionSteps(oldState.doc, [...transactions]);
|
||||
const changes = getChangedRanges(transform);
|
||||
|
||||
changes.forEach(({ newRange }) => {
|
||||
// Now let’s see if we can add new links.
|
||||
const nodesInChangedRanges = findChildrenInRange(newState.doc, newRange, (node) => node.isTextblock);
|
||||
|
||||
let textBlock: NodeWithPos | undefined;
|
||||
let textBeforeWhitespace: string | undefined;
|
||||
|
||||
if (nodesInChangedRanges.length > 1) {
|
||||
// Grab the first node within the changed ranges (ex. the first of two paragraphs when hitting enter).
|
||||
textBlock = nodesInChangedRanges[0];
|
||||
textBeforeWhitespace = newState.doc.textBetween(
|
||||
textBlock.pos,
|
||||
textBlock.pos + textBlock.node.nodeSize,
|
||||
undefined,
|
||||
" "
|
||||
);
|
||||
} else if (
|
||||
nodesInChangedRanges.length &&
|
||||
// We want to make sure to include the block seperator argument to treat hard breaks like spaces.
|
||||
newState.doc.textBetween(newRange.from, newRange.to, " ", " ").endsWith(" ")
|
||||
) {
|
||||
textBlock = nodesInChangedRanges[0];
|
||||
textBeforeWhitespace = newState.doc.textBetween(textBlock.pos, newRange.to, undefined, " ");
|
||||
}
|
||||
|
||||
if (textBlock && textBeforeWhitespace) {
|
||||
const wordsBeforeWhitespace = textBeforeWhitespace.split(" ").filter((s) => s !== "");
|
||||
|
||||
if (wordsBeforeWhitespace.length <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastWordBeforeSpace = wordsBeforeWhitespace[wordsBeforeWhitespace.length - 1];
|
||||
const lastWordAndBlockOffset = textBlock.pos + textBeforeWhitespace.lastIndexOf(lastWordBeforeSpace);
|
||||
|
||||
if (!lastWordBeforeSpace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
find(lastWordBeforeSpace)
|
||||
.filter((link) => link.isLink)
|
||||
// Calculate link position.
|
||||
.map((link) => ({
|
||||
...link,
|
||||
from: lastWordAndBlockOffset + link.start + 1,
|
||||
to: lastWordAndBlockOffset + link.end + 1,
|
||||
}))
|
||||
// ignore link inside code mark
|
||||
.filter((link) => {
|
||||
if (!newState.schema.marks.code) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !newState.doc.rangeHasMark(link.from, link.to, newState.schema.marks.code);
|
||||
})
|
||||
// validate link
|
||||
.filter((link) => {
|
||||
if (options.validate) {
|
||||
return options.validate(link.value);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
// Add link mark.
|
||||
.forEach((link) => {
|
||||
if (getMarksBetween(link.from, link.to, newState.doc).some((item) => item.mark.type === options.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
tr.addMark(
|
||||
link.from,
|
||||
link.to,
|
||||
options.type.create({
|
||||
href: link.href,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (!tr.steps.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return tr;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { getAttributes } from "@tiptap/core";
|
||||
import { MarkType } from "@tiptap/pm/model";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
|
||||
type ClickHandlerOptions = {
|
||||
type: MarkType;
|
||||
};
|
||||
|
||||
export function clickHandler(options: ClickHandlerOptions): Plugin {
|
||||
return new Plugin({
|
||||
key: new PluginKey("handleClickLink"),
|
||||
props: {
|
||||
handleClick: (view, pos, event) => {
|
||||
if (event.button !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let a = event.target as HTMLElement;
|
||||
const els = [];
|
||||
|
||||
while (a.nodeName !== "DIV") {
|
||||
els.push(a);
|
||||
a = a.parentNode as HTMLElement;
|
||||
}
|
||||
|
||||
if (!els.find((value) => value.nodeName === "A")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const attrs = getAttributes(view.state, options.type.name);
|
||||
const link = event.target as HTMLLinkElement;
|
||||
|
||||
const href = link?.href ?? attrs.href;
|
||||
const target = link?.target ?? attrs.target;
|
||||
|
||||
if (link && href) {
|
||||
window.open(href, target);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { Editor } from "@tiptap/core";
|
||||
import { MarkType } from "@tiptap/pm/model";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { find } from "linkifyjs";
|
||||
|
||||
type PasteHandlerOptions = {
|
||||
editor: Editor;
|
||||
type: MarkType;
|
||||
};
|
||||
|
||||
export function pasteHandler(options: PasteHandlerOptions): Plugin {
|
||||
return new Plugin({
|
||||
key: new PluginKey("handlePasteLink"),
|
||||
props: {
|
||||
handlePaste: (view, event, slice) => {
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
const { empty } = selection;
|
||||
|
||||
if (empty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let textContent = "";
|
||||
|
||||
slice.content.forEach((node) => {
|
||||
textContent += node.textContent;
|
||||
});
|
||||
|
||||
const link = find(textContent).find((item) => item.isLink && item.value === textContent);
|
||||
|
||||
if (!textContent || !link) {
|
||||
return false;
|
||||
}
|
||||
|
||||
options.editor.commands.setMark(options.type, {
|
||||
href: link.href,
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
1
packages/editor/src/core/extensions/custom-link/index.ts
Normal file
1
packages/editor/src/core/extensions/custom-link/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./extension";
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./list-keymap";
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
import { EditorState } from "@tiptap/pm/state";
|
||||
import { Editor, getNodeType, getNodeAtPosition, isAtEndOfNode, isAtStartOfNode, isNodeActive } from "@tiptap/core";
|
||||
import { Node, NodeType } from "@tiptap/pm/model";
|
||||
|
||||
const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => {
|
||||
const { $from } = state.selection;
|
||||
const nodeType = getNodeType(typeOrName, state.schema);
|
||||
|
||||
let currentNode = null;
|
||||
let currentDepth = $from.depth;
|
||||
let currentPos = $from.pos;
|
||||
let targetDepth: number | null = null;
|
||||
|
||||
while (currentDepth > 0 && targetDepth === null) {
|
||||
currentNode = $from.node(currentDepth);
|
||||
|
||||
if (currentNode.type === nodeType) {
|
||||
targetDepth = currentDepth;
|
||||
} else {
|
||||
currentDepth -= 1;
|
||||
currentPos -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetDepth === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { $pos: state.doc.resolve(currentPos), depth: targetDepth };
|
||||
};
|
||||
|
||||
const nextListIsDeeper = (typeOrName: string, state: EditorState) => {
|
||||
const listDepth = getNextListDepth(typeOrName, state);
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos || !listDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listDepth > listItemPos.depth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getNextListDepth = (typeOrName: string, state: EditorState) => {
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [, depth] = getNodeAtPosition(state, typeOrName, listItemPos.$pos.pos + 4);
|
||||
|
||||
return depth;
|
||||
};
|
||||
|
||||
const getPrevListDepth = (typeOrName: string, state: EditorState) => {
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
const pos = listItemPos.$pos;
|
||||
|
||||
// Adjust the position to ensure we're within the list item, especially for edge cases
|
||||
const resolvedPos = state.doc.resolve(Math.max(pos.pos - 1, 0));
|
||||
|
||||
// Traverse up the document structure from the adjusted position
|
||||
for (let d = resolvedPos.depth; d > 0; d--) {
|
||||
const node = resolvedPos.node(d);
|
||||
if (node.type.name === "bulletList" || node.type.name === "orderedList" || node.type.name === "taskList") {
|
||||
// Increment depth for each list ancestor found
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
|
||||
// Subtract 1 from the calculated depth to get the parent list's depth
|
||||
// This adjustment is necessary because the depth calculation includes the current list
|
||||
// By subtracting 1, we aim to get the depth of the parent list, which helps in identifying if the current list is a sublist
|
||||
depth = depth > 0 ? depth - 1 : 0;
|
||||
|
||||
// Double the depth value to get results as 2, 4, 6, 8, etc.
|
||||
depth = depth * 2;
|
||||
|
||||
return depth;
|
||||
};
|
||||
|
||||
export const handleBackspace = (editor: Editor, name: string, parentListTypes: string[]) => {
|
||||
// this is required to still handle the undo handling
|
||||
if (editor.commands.undoInputRule()) {
|
||||
return true;
|
||||
}
|
||||
// Check if a node range is selected, and if so, fall back to default backspace functionality
|
||||
const { from, to } = editor.state.selection;
|
||||
if (from !== to) {
|
||||
// A range is selected, not just a cursor position; fall back to default behavior
|
||||
return false; // Let the editor handle backspace by default
|
||||
}
|
||||
|
||||
// if the current item is NOT inside a list item &
|
||||
// the previous item is a list (orderedList or bulletList)
|
||||
// move the cursor into the list and delete the current item
|
||||
if (!isNodeActive(editor.state, name) && hasListBefore(editor.state, name, parentListTypes)) {
|
||||
const { $anchor } = editor.state.selection;
|
||||
|
||||
const $listPos = editor.state.doc.resolve($anchor.before() - 1);
|
||||
|
||||
const listDescendants: Array<{ node: Node; pos: number }> = [];
|
||||
|
||||
$listPos.node().descendants((node, pos) => {
|
||||
if (node.type.name === name) {
|
||||
listDescendants.push({ node, pos });
|
||||
}
|
||||
});
|
||||
|
||||
const lastItem = listDescendants.at(-1);
|
||||
|
||||
if (!lastItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const $lastItemPos = editor.state.doc.resolve($listPos.start() + lastItem.pos + 1);
|
||||
|
||||
// Check if positions are within the valid range
|
||||
const startPos = $anchor.start() - 1;
|
||||
const endPos = $anchor.end() + 1;
|
||||
if (startPos < 0 || endPos > editor.state.doc.content.size) {
|
||||
return false; // Invalid position, abort operation
|
||||
}
|
||||
|
||||
return editor.chain().cut({ from: startPos, to: endPos }, $lastItemPos.end()).joinForward().run();
|
||||
}
|
||||
|
||||
// if the cursor is not inside the current node type
|
||||
// do nothing and proceed
|
||||
if (!isNodeActive(editor.state, name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if the cursor is not at the start of a node
|
||||
// do nothing and proceed
|
||||
if (!isAtStartOfNode(editor.state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// is the paragraph node inside of the current list item (maybe with a hard break)
|
||||
const isParaSibling = isCurrentParagraphASibling(editor.state);
|
||||
const isCurrentListItemSublist = prevListIsHigher(name, editor.state);
|
||||
const listItemPos = findListItemPos(name, editor.state);
|
||||
const nextListItemIsSibling = nextListIsSibling(name, editor.state);
|
||||
|
||||
if (!listItemPos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentNode = listItemPos.$pos.node(listItemPos.depth);
|
||||
const currentListItemHasSubList = listItemHasSubList(name, editor.state, currentNode);
|
||||
|
||||
if (currentListItemHasSubList && isCurrentListItemSublist && isParaSibling) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentListItemHasSubList && isCurrentListItemSublist) {
|
||||
editor.chain().liftListItem(name).run();
|
||||
return editor.commands.joinItemBackward();
|
||||
}
|
||||
|
||||
if (isCurrentListItemSublist && nextListItemIsSibling) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isCurrentListItemSublist) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentListItemHasSubList) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasListItemBefore(name, editor.state)) {
|
||||
return editor.chain().liftListItem(name).run();
|
||||
}
|
||||
|
||||
if (!currentListItemHasSubList) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// otherwise in the end, a backspace should
|
||||
// always just lift the list item if
|
||||
// joining / merging is not possible
|
||||
return editor.chain().liftListItem(name).run();
|
||||
};
|
||||
|
||||
export const handleDelete = (editor: Editor, name: string) => {
|
||||
// if the cursor is not inside the current node type
|
||||
// do nothing and proceed
|
||||
if (!isNodeActive(editor.state, name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if the cursor is not at the end of a node
|
||||
// do nothing and proceed
|
||||
if (!isAtEndOfNode(editor.state, name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if the next node is a list with a deeper depth
|
||||
if (nextListIsDeeper(name, editor.state)) {
|
||||
return editor
|
||||
.chain()
|
||||
.focus(editor.state.selection.from + 4)
|
||||
.lift(name)
|
||||
.joinBackward()
|
||||
.run();
|
||||
}
|
||||
|
||||
if (nextListIsHigher(name, editor.state)) {
|
||||
return editor.chain().joinForward().joinBackward().run();
|
||||
}
|
||||
|
||||
return editor.commands.joinItemForward();
|
||||
};
|
||||
|
||||
const hasListBefore = (editorState: EditorState, name: string, parentListTypes: string[]) => {
|
||||
const { $anchor } = editorState.selection;
|
||||
|
||||
const previousNodePos = Math.max(0, $anchor.pos - 2);
|
||||
|
||||
const previousNode = editorState.doc.resolve(previousNodePos).node();
|
||||
|
||||
if (!previousNode || !parentListTypes.includes(previousNode.type.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const prevListIsHigher = (typeOrName: string, state: EditorState) => {
|
||||
const listDepth = getPrevListDepth(typeOrName, state);
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos || !listDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listDepth < listItemPos.depth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const nextListIsSibling = (typeOrName: string, state: EditorState) => {
|
||||
const listDepth = getNextListDepth(typeOrName, state);
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos || !listDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listDepth === listItemPos.depth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const nextListIsHigher = (typeOrName: string, state: EditorState) => {
|
||||
const listDepth = getNextListDepth(typeOrName, state);
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos || !listDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listDepth < listItemPos.depth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const listItemHasSubList = (typeOrName: string, state: EditorState, node?: Node) => {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodeType = getNodeType(typeOrName, state.schema);
|
||||
|
||||
let hasSubList = false;
|
||||
|
||||
node.descendants((child) => {
|
||||
if (child.type === nodeType) {
|
||||
hasSubList = true;
|
||||
}
|
||||
});
|
||||
|
||||
return hasSubList;
|
||||
};
|
||||
|
||||
const isCurrentParagraphASibling = (state: EditorState): boolean => {
|
||||
const { $from } = state.selection;
|
||||
const listItemNode = $from.node(-1); // Get the parent node of the current selection, assuming it's a list item.
|
||||
const currentParagraphNode = $from.parent; // Get the current node where the selection is.
|
||||
|
||||
// Ensure we're in a paragraph and the parent is a list item.
|
||||
if (
|
||||
currentParagraphNode.type.name === "paragraph" &&
|
||||
(listItemNode.type.name === "listItem" || listItemNode.type.name === "taskItem")
|
||||
) {
|
||||
let paragraphNodesCount = 0;
|
||||
listItemNode.forEach((child) => {
|
||||
if (child.type.name === "paragraph") {
|
||||
paragraphNodesCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// If there are more than one paragraph nodes, the current paragraph is a sibling.
|
||||
return paragraphNodesCount > 1;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export function isCursorInSubList(editor: Editor) {
|
||||
const { selection } = editor.state;
|
||||
const { $from } = selection;
|
||||
|
||||
// Check if the current node is a list item
|
||||
const listItem = editor.schema.nodes.listItem;
|
||||
const taskItem = editor.schema.nodes.taskItem;
|
||||
|
||||
// Traverse up the document tree from the current position
|
||||
for (let depth = $from.depth; depth > 0; depth--) {
|
||||
const node = $from.node(depth);
|
||||
if (node.type === listItem || node.type === taskItem) {
|
||||
// If the parent of the list item is also a list, it's a sub-list
|
||||
const parent = $from.node(depth - 1);
|
||||
if (
|
||||
parent &&
|
||||
(parent.type === editor.schema.nodes.bulletList ||
|
||||
parent.type === editor.schema.nodes.orderedList ||
|
||||
parent.type === editor.schema.nodes.taskList)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasListItemBefore = (typeOrName: string, state: EditorState): boolean => {
|
||||
const { $anchor } = state.selection;
|
||||
|
||||
const $targetPos = state.doc.resolve($anchor.pos - 2);
|
||||
|
||||
if ($targetPos.index() === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($targetPos.nodeBefore?.type.name !== typeOrName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import { Extension } from "@tiptap/core";
|
||||
|
||||
import { handleBackspace, handleDelete } from "src/core/extensions/custom-list-keymap/list-helpers";
|
||||
|
||||
export type ListKeymapOptions = {
|
||||
listTypes: Array<{
|
||||
itemName: string;
|
||||
wrapperNames: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
|
||||
Extension.create<ListKeymapOptions>({
|
||||
name: "listKeymap",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
listTypes: [
|
||||
{
|
||||
itemName: "listItem",
|
||||
wrapperNames: ["bulletList", "orderedList"],
|
||||
},
|
||||
{
|
||||
itemName: "taskItem",
|
||||
wrapperNames: ["taskList"],
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: () => {
|
||||
if (this.editor.isActive("listItem") || this.editor.isActive("taskItem")) {
|
||||
if (this.editor.commands.sinkListItem("listItem")) {
|
||||
return true;
|
||||
} else if (this.editor.commands.sinkListItem("taskItem")) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// if tabIndex is set, we don't want to handle Tab key
|
||||
if (tabIndex !== undefined && tabIndex !== null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
"Shift-Tab": () => {
|
||||
if (this.editor.commands.liftListItem("listItem")) {
|
||||
return true;
|
||||
} else if (this.editor.commands.liftListItem("taskItem")) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
Delete: ({ editor }) => {
|
||||
try {
|
||||
let handled = false;
|
||||
|
||||
this.options.listTypes.forEach(({ itemName }) => {
|
||||
if (editor.state.schema.nodes[itemName] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleDelete(editor, itemName)) {
|
||||
handled = true;
|
||||
}
|
||||
});
|
||||
|
||||
return handled;
|
||||
} catch (e) {
|
||||
console.log("Error in handling Delete:", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
"Mod-Delete": ({ editor }) => {
|
||||
let handled = false;
|
||||
|
||||
this.options.listTypes.forEach(({ itemName }) => {
|
||||
if (editor.state.schema.nodes[itemName] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleDelete(editor, itemName)) {
|
||||
handled = true;
|
||||
}
|
||||
});
|
||||
|
||||
return handled;
|
||||
},
|
||||
Backspace: ({ editor }) => {
|
||||
try {
|
||||
let handled = false;
|
||||
|
||||
this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
|
||||
if (editor.state.schema.nodes[itemName] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleBackspace(editor, itemName, wrapperNames)) {
|
||||
handled = true;
|
||||
}
|
||||
});
|
||||
|
||||
return handled;
|
||||
} catch (e) {
|
||||
console.log("Error in handling Backspace:", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
"Mod-Backspace": ({ editor }) => {
|
||||
let handled = false;
|
||||
|
||||
this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
|
||||
if (editor.state.schema.nodes[itemName] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleBackspace(editor, itemName, wrapperNames)) {
|
||||
handled = true;
|
||||
}
|
||||
});
|
||||
|
||||
return handled;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
395
packages/editor/src/core/extensions/drag-drop.tsx
Normal file
395
packages/editor/src/core/extensions/drag-drop.tsx
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
import { Extension } from "@tiptap/core";
|
||||
import { Fragment, Slice, Node } from "@tiptap/pm/model";
|
||||
import { NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||
// @ts-expect-error __serializeForClipboard's is not exported
|
||||
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
|
||||
|
||||
export interface DragHandleOptions {
|
||||
dragHandleWidth: number;
|
||||
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
|
||||
scrollThreshold: {
|
||||
up: number;
|
||||
down: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const DragAndDrop = (setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void) =>
|
||||
Extension.create({
|
||||
name: "dragAndDrop",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
DragHandle({
|
||||
dragHandleWidth: 24,
|
||||
scrollThreshold: { up: 300, down: 100 },
|
||||
setHideDragHandle,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
function createDragHandleElement(): HTMLElement {
|
||||
const dragHandleElement = document.createElement("div");
|
||||
dragHandleElement.draggable = true;
|
||||
dragHandleElement.dataset.dragHandle = "";
|
||||
dragHandleElement.classList.add("drag-handle");
|
||||
|
||||
const dragHandleContainer = document.createElement("div");
|
||||
dragHandleContainer.classList.add("drag-handle-container");
|
||||
dragHandleElement.appendChild(dragHandleContainer);
|
||||
|
||||
const dotsContainer = document.createElement("div");
|
||||
dotsContainer.classList.add("drag-handle-dots");
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const spanElement = document.createElement("span");
|
||||
spanElement.classList.add("drag-handle-dot");
|
||||
dotsContainer.appendChild(spanElement);
|
||||
}
|
||||
|
||||
dragHandleContainer.appendChild(dotsContainer);
|
||||
|
||||
return dragHandleElement;
|
||||
}
|
||||
|
||||
function absoluteRect(node: Element) {
|
||||
const data = node.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
top: data.top,
|
||||
left: data.left,
|
||||
width: data.width,
|
||||
};
|
||||
}
|
||||
|
||||
function nodeDOMAtCoords(coords: { x: number; y: number }) {
|
||||
const elements = document.elementsFromPoint(coords.x, coords.y);
|
||||
const generalSelectors = [
|
||||
"li",
|
||||
"p:not(:first-child)",
|
||||
".code-block",
|
||||
"blockquote",
|
||||
"h1, h2, h3",
|
||||
".table-wrapper",
|
||||
"[data-type=horizontalRule]",
|
||||
].join(", ");
|
||||
|
||||
for (const elem of elements) {
|
||||
// if the element is a <p> tag that is the first child of a td or th
|
||||
if (
|
||||
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
|
||||
elem?.textContent?.trim() !== ""
|
||||
) {
|
||||
return elem; // Return only if p tag is not empty
|
||||
}
|
||||
// apply general selector
|
||||
if (elem.matches(generalSelectors)) {
|
||||
return elem;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nodePosAtDOM(node: Element, view: EditorView, options: DragHandleOptions) {
|
||||
const boundingRect = node.getBoundingClientRect();
|
||||
|
||||
return view.posAtCoords({
|
||||
left: boundingRect.left + 50 + options.dragHandleWidth,
|
||||
top: boundingRect.top + 1,
|
||||
})?.inside;
|
||||
}
|
||||
|
||||
function nodePosAtDOMForBlockquotes(node: Element, view: EditorView) {
|
||||
const boundingRect = node.getBoundingClientRect();
|
||||
|
||||
return view.posAtCoords({
|
||||
left: boundingRect.left + 1,
|
||||
top: boundingRect.top + 1,
|
||||
})?.inside;
|
||||
}
|
||||
|
||||
function calcNodePos(pos: number, view: EditorView, node: Element) {
|
||||
const maxPos = view.state.doc.content.size;
|
||||
const safePos = Math.max(0, Math.min(pos, maxPos));
|
||||
const $pos = view.state.doc.resolve(safePos);
|
||||
|
||||
if ($pos.depth > 1) {
|
||||
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
|
||||
// only for nested lists
|
||||
const newPos = $pos.before($pos.depth);
|
||||
return Math.max(0, Math.min(newPos, maxPos));
|
||||
}
|
||||
}
|
||||
|
||||
return safePos;
|
||||
}
|
||||
|
||||
function DragHandle(options: DragHandleOptions) {
|
||||
let listType = "";
|
||||
function handleDragStart(event: DragEvent, view: EditorView) {
|
||||
view.focus();
|
||||
|
||||
if (!event.dataTransfer) return;
|
||||
|
||||
const node = nodeDOMAtCoords({
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
if (!(node instanceof Element)) return;
|
||||
|
||||
let draggedNodePos = nodePosAtDOM(node, view, options);
|
||||
if (draggedNodePos == null || draggedNodePos < 0) return;
|
||||
draggedNodePos = calcNodePos(draggedNodePos, view, node);
|
||||
|
||||
const { from, to } = view.state.selection;
|
||||
const diff = from - to;
|
||||
|
||||
const fromSelectionPos = calcNodePos(from, view, node);
|
||||
let differentNodeSelected = false;
|
||||
|
||||
const nodePos = view.state.doc.resolve(fromSelectionPos);
|
||||
|
||||
// Check if nodePos points to the top level node
|
||||
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
|
||||
else {
|
||||
const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before());
|
||||
// Check if the node where the drag event started is part of the current selection
|
||||
differentNodeSelected = !(
|
||||
draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos
|
||||
);
|
||||
}
|
||||
|
||||
if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) {
|
||||
const endSelection = NodeSelection.create(view.state.doc, to - 1);
|
||||
const multiNodeSelection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos);
|
||||
view.dispatch(view.state.tr.setSelection(multiNodeSelection));
|
||||
} else {
|
||||
const nodeSelection = NodeSelection.create(view.state.doc, draggedNodePos);
|
||||
view.dispatch(view.state.tr.setSelection(nodeSelection));
|
||||
}
|
||||
|
||||
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
|
||||
if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === "listItem") {
|
||||
listType = node.parentElement!.tagName;
|
||||
}
|
||||
|
||||
if (node.matches("blockquote")) {
|
||||
let nodePosForBlockquotes = nodePosAtDOMForBlockquotes(node, view);
|
||||
if (nodePosForBlockquotes === null || nodePosForBlockquotes === undefined) return;
|
||||
|
||||
const docSize = view.state.doc.content.size;
|
||||
nodePosForBlockquotes = Math.max(0, Math.min(nodePosForBlockquotes, docSize));
|
||||
|
||||
if (nodePosForBlockquotes >= 0 && nodePosForBlockquotes <= docSize) {
|
||||
const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockquotes);
|
||||
view.dispatch(view.state.tr.setSelection(nodeSelection));
|
||||
}
|
||||
}
|
||||
|
||||
const slice = view.state.selection.content();
|
||||
const { dom, text } = __serializeForClipboard(view, slice);
|
||||
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData("text/html", dom.innerHTML);
|
||||
event.dataTransfer.setData("text/plain", text);
|
||||
event.dataTransfer.effectAllowed = "copyMove";
|
||||
|
||||
event.dataTransfer.setDragImage(node, 0, 0);
|
||||
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
|
||||
function handleClick(event: MouseEvent, view: EditorView) {
|
||||
view.focus();
|
||||
|
||||
const node = nodeDOMAtCoords({
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
if (!(node instanceof Element)) return;
|
||||
|
||||
if (node.matches("blockquote")) {
|
||||
let nodePosForBlockquotes = nodePosAtDOMForBlockquotes(node, view);
|
||||
if (nodePosForBlockquotes === null || nodePosForBlockquotes === undefined) return;
|
||||
|
||||
const docSize = view.state.doc.content.size;
|
||||
nodePosForBlockquotes = Math.max(0, Math.min(nodePosForBlockquotes, docSize));
|
||||
|
||||
if (nodePosForBlockquotes >= 0 && nodePosForBlockquotes <= docSize) {
|
||||
const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockquotes);
|
||||
view.dispatch(view.state.tr.setSelection(nodeSelection));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let nodePos = nodePosAtDOM(node, view, options);
|
||||
|
||||
if (nodePos === null || nodePos === undefined) return;
|
||||
|
||||
// Adjust the nodePos to point to the start of the node, ensuring NodeSelection can be applied
|
||||
nodePos = calcNodePos(nodePos, view, node);
|
||||
|
||||
// Use NodeSelection to select the node at the calculated position
|
||||
const nodeSelection = NodeSelection.create(view.state.doc, nodePos);
|
||||
|
||||
// Dispatch the transaction to update the selection
|
||||
view.dispatch(view.state.tr.setSelection(nodeSelection));
|
||||
}
|
||||
|
||||
let dragHandleElement: HTMLElement | null = null;
|
||||
|
||||
function hideDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function showDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
options.setHideDragHandle?.(hideDragHandle);
|
||||
|
||||
return new Plugin({
|
||||
key: new PluginKey("dragHandle"),
|
||||
view: (view) => {
|
||||
dragHandleElement = createDragHandleElement();
|
||||
dragHandleElement.addEventListener("dragstart", (e) => {
|
||||
handleDragStart(e, view);
|
||||
});
|
||||
dragHandleElement.addEventListener("click", (e) => {
|
||||
handleClick(e, view);
|
||||
});
|
||||
dragHandleElement.addEventListener("contextmenu", (e) => {
|
||||
handleClick(e, view);
|
||||
});
|
||||
|
||||
dragHandleElement.addEventListener("drag", (e) => {
|
||||
hideDragHandle();
|
||||
const a = document.querySelector(".frame-renderer");
|
||||
if (!a) return;
|
||||
if (e.clientY < options.scrollThreshold.up) {
|
||||
a.scrollBy({ top: -70, behavior: "smooth" });
|
||||
} else if (window.innerHeight - e.clientY < options.scrollThreshold.down) {
|
||||
a.scrollBy({ top: 70, behavior: "smooth" });
|
||||
}
|
||||
});
|
||||
|
||||
hideDragHandle();
|
||||
|
||||
view?.dom?.parentElement?.appendChild(dragHandleElement);
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
dragHandleElement?.remove?.();
|
||||
dragHandleElement = null;
|
||||
},
|
||||
};
|
||||
},
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mousemove: (view, event) => {
|
||||
if (!view.editable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = nodeDOMAtCoords({
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
if (!(node instanceof Element) || node.matches("ul, ol")) {
|
||||
hideDragHandle();
|
||||
return;
|
||||
}
|
||||
|
||||
const compStyle = window.getComputedStyle(node);
|
||||
const lineHeight = parseInt(compStyle.lineHeight, 10);
|
||||
const paddingTop = parseInt(compStyle.paddingTop, 10);
|
||||
|
||||
const rect = absoluteRect(node);
|
||||
|
||||
rect.top += (lineHeight - 20) / 2;
|
||||
rect.top += paddingTop;
|
||||
|
||||
// Li markers
|
||||
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
|
||||
rect.left -= 18;
|
||||
}
|
||||
if (node.matches(".table-wrapper")) {
|
||||
rect.top += 8;
|
||||
}
|
||||
|
||||
rect.width = options.dragHandleWidth;
|
||||
|
||||
if (!dragHandleElement) return;
|
||||
|
||||
dragHandleElement.style.left = `${rect.left - rect.width}px`;
|
||||
dragHandleElement.style.top = `${rect.top}px`;
|
||||
showDragHandle();
|
||||
},
|
||||
keydown: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
mousewheel: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
dragenter: (view) => {
|
||||
view.dom.classList.add("dragging");
|
||||
hideDragHandle();
|
||||
},
|
||||
drop: (view, event) => {
|
||||
view.dom.classList.remove("dragging");
|
||||
hideDragHandle();
|
||||
let droppedNode: Node | null = null;
|
||||
const dropPos = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
if (!dropPos) return;
|
||||
|
||||
if (view.state.selection instanceof NodeSelection) {
|
||||
droppedNode = view.state.selection.node;
|
||||
}
|
||||
if (!droppedNode) return;
|
||||
|
||||
const resolvedPos = view.state.doc.resolve(dropPos.pos);
|
||||
let isDroppedInsideList = false;
|
||||
|
||||
// Traverse up the document tree to find if we're inside a list item
|
||||
for (let i = resolvedPos.depth; i > 0; i--) {
|
||||
if (resolvedPos.node(i).type.name === "listItem") {
|
||||
isDroppedInsideList = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
|
||||
if (
|
||||
view.state.selection instanceof NodeSelection &&
|
||||
view.state.selection.node.type.name === "listItem" &&
|
||||
!isDroppedInsideList &&
|
||||
listType == "OL"
|
||||
) {
|
||||
const text = droppedNode.textContent;
|
||||
if (!text) return;
|
||||
const paragraph = view.state.schema.nodes.paragraph?.createAndFill({}, view.state.schema.text(text));
|
||||
const listItem = view.state.schema.nodes.listItem?.createAndFill({}, paragraph);
|
||||
|
||||
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, listItem);
|
||||
const slice = new Slice(Fragment.from(newList), 0, 0);
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
},
|
||||
dragend: (view) => {
|
||||
view.dom.classList.remove("dragging");
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
47
packages/editor/src/core/extensions/drop.tsx
Normal file
47
packages/editor/src/core/extensions/drop.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
// plugins
|
||||
import { startImageUpload } from "@/plugins/image";
|
||||
// types
|
||||
import { UploadImage } from "@/types";
|
||||
|
||||
export const DropHandlerExtension = (uploadFile: UploadImage) =>
|
||||
Extension.create({
|
||||
name: "dropHandler",
|
||||
priority: 1000,
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("drop-handler-plugin"),
|
||||
props: {
|
||||
handlePaste: (view, event) => {
|
||||
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) {
|
||||
event.preventDefault();
|
||||
const file = event.clipboardData.files[0];
|
||||
const pos = view.state.selection.from;
|
||||
startImageUpload(this.editor, file, view, pos, uploadFile);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
handleDrop: (view, event, _slice, moved) => {
|
||||
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer.files[0];
|
||||
const coordinates = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
if (coordinates) {
|
||||
startImageUpload(this.editor, file, view, coordinates.pos - 1, uploadFile);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
28
packages/editor/src/core/extensions/enter-key-extension.tsx
Normal file
28
packages/editor/src/core/extensions/enter-key-extension.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Extension } from "@tiptap/core";
|
||||
|
||||
export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
|
||||
Extension.create({
|
||||
name: "enterKey",
|
||||
|
||||
addKeyboardShortcuts(this) {
|
||||
return {
|
||||
Enter: () => {
|
||||
if (!this.editor.storage.mentionsOpen) {
|
||||
if (onEnterKeyPress) {
|
||||
onEnterKeyPress();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
"Shift-Enter": ({ editor }) =>
|
||||
editor.commands.first(({ commands }) => [
|
||||
() => commands.newlineInCode(),
|
||||
() => commands.splitListItem("listItem"),
|
||||
() => commands.createParagraphNear(),
|
||||
() => commands.liftEmptyBlock(),
|
||||
() => commands.splitBlock(),
|
||||
]),
|
||||
};
|
||||
},
|
||||
});
|
||||
157
packages/editor/src/core/extensions/extensions.tsx
Normal file
157
packages/editor/src/core/extensions/extensions.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
import TiptapUnderline from "@tiptap/extension-underline";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
// extensions
|
||||
import {
|
||||
CustomCodeBlockExtension,
|
||||
CustomCodeInlineExtension,
|
||||
CustomCodeMarkPlugin,
|
||||
CustomHorizontalRule,
|
||||
CustomKeymap,
|
||||
CustomLinkExtension,
|
||||
CustomMention,
|
||||
CustomQuoteExtension,
|
||||
CustomTypographyExtension,
|
||||
DropHandlerExtension,
|
||||
ImageExtension,
|
||||
ListKeymap,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/extensions";
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
// types
|
||||
import { DeleteImage, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types";
|
||||
|
||||
type TArguments = {
|
||||
mentionConfig: {
|
||||
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
mentionHighlights?: () => Promise<IMentionHighlight[]>;
|
||||
};
|
||||
fileConfig: {
|
||||
deleteFile: DeleteImage;
|
||||
restoreFile: RestoreImage;
|
||||
cancelUploadImage?: () => void;
|
||||
uploadFile: UploadImage;
|
||||
};
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export const CoreEditorExtensions = ({
|
||||
mentionConfig,
|
||||
fileConfig: { deleteFile, restoreFile, cancelUploadImage, uploadFile },
|
||||
placeholder,
|
||||
tabIndex,
|
||||
}: TArguments) => [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-disc pl-7 space-y-2",
|
||||
},
|
||||
},
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-decimal pl-7 space-y-2",
|
||||
},
|
||||
},
|
||||
listItem: {
|
||||
HTMLAttributes: {
|
||||
class: "not-prose space-y-2",
|
||||
},
|
||||
},
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
blockquote: false,
|
||||
dropcursor: {
|
||||
color: "rgba(var(--color-text-100))",
|
||||
width: 1,
|
||||
},
|
||||
}),
|
||||
CustomQuoteExtension,
|
||||
DropHandlerExtension(uploadFile),
|
||||
CustomHorizontalRule.configure({
|
||||
HTMLAttributes: {
|
||||
class: "my-4 border-custom-border-400",
|
||||
},
|
||||
}),
|
||||
CustomKeymap,
|
||||
ListKeymap({ tabIndex }),
|
||||
CustomLinkExtension.configure({
|
||||
openOnClick: true,
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
protocols: ["http", "https"],
|
||||
validate: (url: string) => isValidHttpUrl(url),
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||
},
|
||||
}),
|
||||
CustomTypographyExtension,
|
||||
ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
HTMLAttributes: {
|
||||
class: "not-prose pl-2 space-y-2",
|
||||
},
|
||||
}),
|
||||
TaskItem.configure({
|
||||
HTMLAttributes: {
|
||||
class: "relative",
|
||||
},
|
||||
nested: true,
|
||||
}),
|
||||
CustomCodeBlockExtension.configure({
|
||||
HTMLAttributes: {
|
||||
class: "",
|
||||
},
|
||||
}),
|
||||
CustomCodeMarkPlugin,
|
||||
CustomCodeInlineExtension,
|
||||
Markdown.configure({
|
||||
html: true,
|
||||
transformPastedText: true,
|
||||
}),
|
||||
Table,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TableRow,
|
||||
CustomMention({
|
||||
mentionSuggestions: mentionConfig.mentionSuggestions,
|
||||
mentionHighlights: mentionConfig.mentionHighlights,
|
||||
readonly: false,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ editor, node }) => {
|
||||
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
|
||||
|
||||
if (editor.storage.image.uploadInProgress) return "";
|
||||
|
||||
const shouldHidePlaceholder =
|
||||
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
|
||||
|
||||
if (shouldHidePlaceholder) return "";
|
||||
|
||||
if (placeholder) {
|
||||
if (typeof placeholder === "string") return placeholder;
|
||||
else return placeholder(editor.isFocused, editor.getHTML());
|
||||
}
|
||||
|
||||
return "Press '/' for commands...";
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
];
|
||||
122
packages/editor/src/core/extensions/horizontal-rule.ts
Normal file
122
packages/editor/src/core/extensions/horizontal-rule.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { isNodeSelection, mergeAttributes, Node, nodeInputRule } from "@tiptap/core";
|
||||
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
|
||||
|
||||
export interface HorizontalRuleOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
horizontalRule: {
|
||||
/**
|
||||
* Add a horizontal rule
|
||||
*/
|
||||
setHorizontalRule: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomHorizontalRule = Node.create<HorizontalRuleOptions>({
|
||||
name: "horizontalRule",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
group: "block",
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
},
|
||||
{ tag: "hr" },
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
"data-type": this.name,
|
||||
}),
|
||||
["div", {}],
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setHorizontalRule:
|
||||
() =>
|
||||
({ chain, state }) => {
|
||||
const { selection } = state;
|
||||
const { $from: $originFrom, $to: $originTo } = selection;
|
||||
|
||||
const currentChain = chain();
|
||||
|
||||
if ($originFrom.parentOffset === 0) {
|
||||
currentChain.insertContentAt(
|
||||
{
|
||||
from: Math.max($originFrom.pos - 1, 0),
|
||||
to: $originTo.pos,
|
||||
},
|
||||
{
|
||||
type: this.name,
|
||||
}
|
||||
);
|
||||
} else if (isNodeSelection(selection)) {
|
||||
currentChain.insertContentAt($originTo.pos, {
|
||||
type: this.name,
|
||||
});
|
||||
} else {
|
||||
currentChain.insertContent({ type: this.name });
|
||||
}
|
||||
|
||||
return (
|
||||
currentChain
|
||||
// set cursor after horizontal rule
|
||||
.command(({ tr, dispatch }) => {
|
||||
if (dispatch) {
|
||||
const { $to } = tr.selection;
|
||||
const posAfter = $to.end();
|
||||
|
||||
if ($to.nodeAfter) {
|
||||
if ($to.nodeAfter.isTextblock) {
|
||||
tr.setSelection(TextSelection.create(tr.doc, $to.pos + 1));
|
||||
} else if ($to.nodeAfter.isBlock) {
|
||||
tr.setSelection(NodeSelection.create(tr.doc, $to.pos));
|
||||
} else {
|
||||
tr.setSelection(TextSelection.create(tr.doc, $to.pos));
|
||||
}
|
||||
} else {
|
||||
// add node after horizontal rule if it’s the end of the document
|
||||
const node = $to.parent.type.contentMatch.defaultType?.create();
|
||||
|
||||
if (node) {
|
||||
tr.insert(posAfter, node);
|
||||
tr.setSelection(TextSelection.create(tr.doc, posAfter + 1));
|
||||
}
|
||||
}
|
||||
|
||||
tr.scrollIntoView();
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.run()
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
nodeInputRule({
|
||||
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
|
||||
type: this.type,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
63
packages/editor/src/core/extensions/image/extension.tsx
Normal file
63
packages/editor/src/core/extensions/image/extension.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { UploadImagesPlugin } from "src/core/plugins/image/upload-image";
|
||||
import ImageExt from "@tiptap/extension-image";
|
||||
import { TrackImageDeletionPlugin } from "src/core/plugins/image/delete-image";
|
||||
import { DeleteImage, RestoreImage } from "@/types";
|
||||
import { insertLineBelowImageAction } from "./utilities/insert-line-below-image";
|
||||
import { insertLineAboveImageAction } from "./utilities/insert-line-above-image";
|
||||
import { TrackImageRestorationPlugin } from "src/core/plugins/image/restore-image";
|
||||
import { IMAGE_NODE_TYPE } from "src/core/plugins/image/constants";
|
||||
import { ImageExtensionStorage } from "src/core/plugins/image/types/image-node";
|
||||
|
||||
export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) =>
|
||||
ImageExt.extend<any, ImageExtensionStorage>({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertLineBelowImageAction,
|
||||
ArrowUp: insertLineAboveImageAction,
|
||||
};
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
UploadImagesPlugin(this.editor, cancelUploadImage),
|
||||
TrackImageDeletionPlugin(this.editor, deleteImage),
|
||||
TrackImageRestorationPlugin(this.editor, restoreImage),
|
||||
];
|
||||
},
|
||||
|
||||
onCreate(this) {
|
||||
const imageSources = new Set<string>();
|
||||
this.editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === IMAGE_NODE_TYPE) {
|
||||
imageSources.add(node.attrs.src);
|
||||
}
|
||||
});
|
||||
imageSources.forEach(async (src) => {
|
||||
try {
|
||||
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
||||
await restoreImage(assetUrlWithWorkspaceId);
|
||||
} catch (error) {
|
||||
console.error("Error restoring image: ", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// storage to keep track of image states Map<src, isDeleted>
|
||||
addStorage() {
|
||||
return {
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
uploadInProgress: false,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import ImageExt from "@tiptap/extension-image";
|
||||
import { insertLineBelowImageAction } from "./utilities/insert-line-below-image";
|
||||
import { insertLineAboveImageAction } from "./utilities/insert-line-above-image";
|
||||
|
||||
export const ImageExtensionWithoutProps = () =>
|
||||
ImageExt.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertLineBelowImageAction,
|
||||
ArrowUp: insertLineAboveImageAction,
|
||||
};
|
||||
},
|
||||
|
||||
// storage to keep track of image states Map<src, isDeleted>
|
||||
addStorage() {
|
||||
return {
|
||||
images: new Map<string, boolean>(),
|
||||
uploadInProgress: false,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
76
packages/editor/src/core/extensions/image/image-resize.tsx
Normal file
76
packages/editor/src/core/extensions/image/image-resize.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { Editor } from "@tiptap/react";
|
||||
import { useState } from "react";
|
||||
import Moveable from "react-moveable";
|
||||
|
||||
export const ImageResizer = ({ editor }: { editor: Editor }) => {
|
||||
const updateMediaSize = () => {
|
||||
const imageInfo = document.querySelector(".ProseMirror-selectednode") as HTMLImageElement;
|
||||
if (imageInfo) {
|
||||
const selection = editor.state.selection;
|
||||
|
||||
// Use the style width/height if available, otherwise fall back to the element's natural width/height
|
||||
const width = imageInfo.style.width
|
||||
? Number(imageInfo.style.width.replace("px", ""))
|
||||
: imageInfo.getAttribute("width");
|
||||
const height = imageInfo.style.height
|
||||
? Number(imageInfo.style.height.replace("px", ""))
|
||||
: imageInfo.getAttribute("height");
|
||||
|
||||
editor.commands.setImage({
|
||||
src: imageInfo.src,
|
||||
width: width,
|
||||
height: height,
|
||||
} as any);
|
||||
editor.commands.setNodeSelection(selection.from);
|
||||
}
|
||||
};
|
||||
|
||||
const [aspectRatio, setAspectRatio] = useState(1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Moveable
|
||||
target={document.querySelector(".ProseMirror-selectednode") as HTMLElement}
|
||||
container={null}
|
||||
origin={false}
|
||||
edge={false}
|
||||
throttleDrag={0}
|
||||
keepRatio
|
||||
resizable
|
||||
throttleResize={0}
|
||||
onResizeStart={() => {
|
||||
const imageInfo = document.querySelector(".ProseMirror-selectednode") as HTMLImageElement;
|
||||
if (imageInfo) {
|
||||
const originalWidth = Number(imageInfo.width);
|
||||
const originalHeight = Number(imageInfo.height);
|
||||
setAspectRatio(originalWidth / originalHeight);
|
||||
}
|
||||
}}
|
||||
onResize={({ target, width, height, delta }) => {
|
||||
if (delta[0] || delta[1]) {
|
||||
let newWidth, newHeight;
|
||||
if (delta[0]) {
|
||||
// Width change detected
|
||||
newWidth = Math.max(width, 100);
|
||||
newHeight = newWidth / aspectRatio;
|
||||
} else if (delta[1]) {
|
||||
// Height change detected
|
||||
newHeight = Math.max(height, 100);
|
||||
newWidth = newHeight * aspectRatio;
|
||||
}
|
||||
target.style.width = `${newWidth}px`;
|
||||
target.style.height = `${newHeight}px`;
|
||||
}
|
||||
}}
|
||||
onResizeEnd={() => {
|
||||
updateMediaSize();
|
||||
}}
|
||||
scalable
|
||||
renderDirections={["se"]}
|
||||
onScale={({ target, transform }) => {
|
||||
target.style.transform = transform;
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
4
packages/editor/src/core/extensions/image/index.ts
Normal file
4
packages/editor/src/core/extensions/image/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./extension";
|
||||
export * from "./image-extension-without-props";
|
||||
export * from "./image-resize";
|
||||
export * from "./read-only-image";
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import Image from "@tiptap/extension-image";
|
||||
|
||||
export const ReadOnlyImageExtension = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||
|
||||
export const insertLineAboveImageAction: KeyboardShortcutCommand = ({ editor }) => {
|
||||
try {
|
||||
const { selection, doc } = editor.state;
|
||||
const { $from, $to } = selection;
|
||||
|
||||
let imageNode: ProseMirrorNode | null = null;
|
||||
let imagePos: number | null = null;
|
||||
|
||||
// Check if the selection itself is an image node
|
||||
doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
|
||||
if (node.type.name === "image") {
|
||||
imageNode = node;
|
||||
imagePos = pos;
|
||||
return false; // Stop iterating once an image node is found
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (imageNode === null || imagePos === null) return false;
|
||||
|
||||
// Since we want to insert above the image, we use the imagePos directly
|
||||
const insertPos = imagePos;
|
||||
|
||||
const docSize = editor.state.doc.content.size;
|
||||
|
||||
if (insertPos < 0 || insertPos > docSize) return false;
|
||||
|
||||
// Check for an existing node immediately before the image
|
||||
if (insertPos === 0) {
|
||||
// If the previous node doesn't exist or isn't a paragraph, create and insert a new empty node there
|
||||
editor.chain().insertContentAt(insertPos, { type: "paragraph" }).run();
|
||||
editor.chain().setTextSelection(insertPos).run();
|
||||
} else {
|
||||
const prevNode = doc.nodeAt(insertPos);
|
||||
|
||||
if (prevNode && prevNode.type.name === "paragraph") {
|
||||
// If the previous node is a paragraph, move the cursor there
|
||||
editor.chain().setTextSelection(insertPos).run();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("An error occurred while inserting a line above the image:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||
|
||||
export const insertLineBelowImageAction: KeyboardShortcutCommand = ({ editor }) => {
|
||||
try {
|
||||
const { selection, doc } = editor.state;
|
||||
const { $from, $to } = selection;
|
||||
|
||||
let imageNode: ProseMirrorNode | null = null;
|
||||
let imagePos: number | null = null;
|
||||
|
||||
// Check if the selection itself is an image node
|
||||
doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
|
||||
if (node.type.name === "image") {
|
||||
imageNode = node;
|
||||
imagePos = pos;
|
||||
return false; // Stop iterating once an image node is found
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (imageNode === null || imagePos === null) return false;
|
||||
|
||||
const guaranteedImageNode: ProseMirrorNode = imageNode;
|
||||
const nextNodePos = imagePos + guaranteedImageNode.nodeSize;
|
||||
|
||||
// Check for an existing node immediately after the image
|
||||
const nextNode = doc.nodeAt(nextNodePos);
|
||||
|
||||
if (nextNode && nextNode.type.name === "paragraph") {
|
||||
// If the next node is a paragraph, move the cursor there
|
||||
const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1;
|
||||
editor.chain().setTextSelection(endOfParagraphPos).run();
|
||||
} else if (!nextNode) {
|
||||
// If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there
|
||||
editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run();
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(nextNodePos + 1)
|
||||
.run();
|
||||
} else {
|
||||
// If the next node is not a paragraph, do not proceed
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("An error occurred while inserting a line below the image:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
20
packages/editor/src/core/extensions/index.ts
Normal file
20
packages/editor/src/core/extensions/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export * from "./code";
|
||||
export * from "./code-inline";
|
||||
export * from "./custom-link";
|
||||
export * from "./custom-list-keymap";
|
||||
export * from "./image";
|
||||
export * from "./issue-embed";
|
||||
export * from "./mentions";
|
||||
export * from "./table";
|
||||
export * from "./typography";
|
||||
export * from "./core-without-props";
|
||||
export * from "./custom-code-inline";
|
||||
export * from "./drag-drop";
|
||||
export * from "./drop";
|
||||
export * from "./enter-key-extension";
|
||||
export * from "./extensions";
|
||||
export * from "./horizontal-rule";
|
||||
export * from "./keymap";
|
||||
export * from "./quote";
|
||||
export * from "./read-only-extensions";
|
||||
export * from "./slash-commands";
|
||||
1
packages/editor/src/core/extensions/issue-embed/index.ts
Normal file
1
packages/editor/src/core/extensions/issue-embed/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./widget-node";
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
|
||||
|
||||
type Props = {
|
||||
widgetCallback: ({
|
||||
issueId,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
}: {
|
||||
issueId: string;
|
||||
projectId: string | undefined;
|
||||
workspaceSlug: string | undefined;
|
||||
}) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const IssueWidget = (props: Props) =>
|
||||
Node.create({
|
||||
name: "issue-embed-component",
|
||||
group: "block",
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
entity_identifier: {
|
||||
default: undefined,
|
||||
},
|
||||
project_identifier: {
|
||||
default: undefined,
|
||||
},
|
||||
workspace_identifier: {
|
||||
default: undefined,
|
||||
},
|
||||
id: {
|
||||
default: undefined,
|
||||
},
|
||||
entity_name: {
|
||||
default: undefined,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer((issueProps: any) => (
|
||||
<NodeViewWrapper>
|
||||
{props.widgetCallback({
|
||||
issueId: issueProps.node.attrs.entity_identifier,
|
||||
projectId: issueProps.node.attrs.project_identifier,
|
||||
workspaceSlug: issueProps.node.attrs.workspace_identifier,
|
||||
})}
|
||||
</NodeViewWrapper>
|
||||
));
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "issue-embed-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["issue-embed-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
});
|
||||
126
packages/editor/src/core/extensions/keymap.tsx
Normal file
126
packages/editor/src/core/extensions/keymap.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
||||
import { canJoin } from "@tiptap/pm/transform";
|
||||
import { NodeType } from "@tiptap/pm/model";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
interface Commands<ReturnType> {
|
||||
customkeymap: {
|
||||
/**
|
||||
* Select text between node boundaries
|
||||
*/
|
||||
selectTextWithinNodeBoundaries: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function autoJoin(tr: Transaction, newTr: Transaction, nodeTypes: NodeType[]) {
|
||||
// Find all ranges where we might want to join.
|
||||
const ranges: Array<number> = [];
|
||||
for (let i = 0; i < tr.mapping.maps.length; i++) {
|
||||
const map = tr.mapping.maps[i];
|
||||
for (let j = 0; j < ranges.length; j++) ranges[j] = map.map(ranges[j]);
|
||||
map.forEach((_s, _e, from, to) => ranges.push(from, to));
|
||||
}
|
||||
|
||||
// Figure out which joinable points exist inside those ranges,
|
||||
// by checking all node boundaries in their parent nodes.
|
||||
const joinable: number[] = [];
|
||||
for (let i = 0; i < ranges.length; i += 2) {
|
||||
const from = ranges[i],
|
||||
to = ranges[i + 1];
|
||||
const $from = tr.doc.resolve(from),
|
||||
depth = $from.sharedDepth(to),
|
||||
parent = $from.node(depth);
|
||||
for (let index = $from.indexAfter(depth), pos = $from.after(depth + 1); pos <= to; ++index) {
|
||||
const after = parent.maybeChild(index);
|
||||
if (!after) break;
|
||||
if (index && joinable.indexOf(pos) == -1) {
|
||||
const before = parent.child(index - 1);
|
||||
if (before.type == after.type && nodeTypes.includes(before.type)) joinable.push(pos);
|
||||
}
|
||||
pos += after.nodeSize;
|
||||
}
|
||||
}
|
||||
|
||||
let joined = false;
|
||||
|
||||
// Join the joinable points
|
||||
joinable.sort((a, b) => a - b);
|
||||
for (let i = joinable.length - 1; i >= 0; i--) {
|
||||
if (canJoin(tr.doc, joinable[i])) {
|
||||
newTr.join(joinable[i]);
|
||||
joined = true;
|
||||
}
|
||||
}
|
||||
|
||||
return joined;
|
||||
}
|
||||
|
||||
export const CustomKeymap = Extension.create({
|
||||
name: "CustomKeymap",
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
selectTextWithinNodeBoundaries:
|
||||
() =>
|
||||
({ editor, commands }) => {
|
||||
const { state } = editor;
|
||||
const { tr } = state;
|
||||
const startNodePos = tr.selection.$from.start();
|
||||
const endNodePos = tr.selection.$to.end();
|
||||
return commands.setTextSelection({
|
||||
from: startNodePos,
|
||||
to: endNodePos,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("ordered-list-merging"),
|
||||
appendTransaction(transactions, oldState, newState) {
|
||||
// Create a new transaction.
|
||||
const newTr = newState.tr;
|
||||
|
||||
const joinableNodes = [
|
||||
newState.schema.nodes["orderedList"],
|
||||
newState.schema.nodes["taskList"],
|
||||
newState.schema.nodes["bulletList"],
|
||||
];
|
||||
|
||||
let joined = false;
|
||||
for (const transaction of transactions) {
|
||||
const anotherJoin = autoJoin(transaction, newTr, joinableNodes);
|
||||
joined = anotherJoin || joined;
|
||||
}
|
||||
if (joined) {
|
||||
return newTr;
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
"Mod-a": ({ editor }) => {
|
||||
const { state } = editor;
|
||||
const { tr } = state;
|
||||
const startSelectionPos = tr.selection.from;
|
||||
const endSelectionPos = tr.selection.to;
|
||||
const startNodePos = tr.selection.$from.start();
|
||||
const endNodePos = tr.selection.$to.end();
|
||||
const isCurrentTextSelectionNotExtendedToNodeBoundaries =
|
||||
startSelectionPos > startNodePos || endSelectionPos < endNodePos;
|
||||
if (isCurrentTextSelectionNotExtendedToNodeBoundaries) {
|
||||
editor.chain().selectTextWithinNodeBoundaries().run();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
140
packages/editor/src/core/extensions/mentions/extension.tsx
Normal file
140
packages/editor/src/core/extensions/mentions/extension.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import Mention, { MentionOptions } from "@tiptap/extension-mention";
|
||||
import { ReactNodeViewRenderer, ReactRenderer } from "@tiptap/react";
|
||||
import { Editor, mergeAttributes } from "@tiptap/core";
|
||||
import tippy from "tippy.js";
|
||||
// extensions
|
||||
import { MentionList, MentionNodeView } from "@/extensions";
|
||||
// types
|
||||
import { IMentionHighlight, IMentionSuggestion } from "@/types";
|
||||
|
||||
export interface CustomMentionOptions extends MentionOptions {
|
||||
mentionHighlights: () => Promise<IMentionHighlight[]>;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
export const CustomMention = ({
|
||||
mentionHighlights,
|
||||
mentionSuggestions,
|
||||
readonly,
|
||||
}: {
|
||||
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
mentionHighlights?: () => Promise<IMentionHighlight[]>;
|
||||
readonly: boolean;
|
||||
}) =>
|
||||
Mention.extend<CustomMentionOptions>({
|
||||
addStorage(this) {
|
||||
return {
|
||||
mentionsOpen: false,
|
||||
};
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
},
|
||||
label: {
|
||||
default: null,
|
||||
},
|
||||
target: {
|
||||
default: null,
|
||||
},
|
||||
self: {
|
||||
default: false,
|
||||
},
|
||||
redirect_uri: {
|
||||
default: "/",
|
||||
},
|
||||
entity_identifier: {
|
||||
default: null,
|
||||
},
|
||||
entity_name: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MentionNodeView);
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "mention-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["mention-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
readonly: readonly,
|
||||
mentionHighlights: mentionHighlights,
|
||||
suggestion: {
|
||||
render: () => {
|
||||
if (!mentionSuggestions) return;
|
||||
let component: ReactRenderer | null = null;
|
||||
let popup: any | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
component = new ReactRenderer(MentionList, {
|
||||
props: { ...props, mentionSuggestions },
|
||||
editor: props.editor,
|
||||
});
|
||||
props.editor.storage.mentionsOpen = true;
|
||||
// @ts-expect-error - Tippy types are incorrect
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.querySelector(".active-editor") ?? document.querySelector("#editor-container"),
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component?.updateProps(props);
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
||||
|
||||
if (navigationKeys.includes(props.event.key)) {
|
||||
// @ts-expect-error - Tippy types are incorrect
|
||||
component?.ref?.onKeyDown(props);
|
||||
event?.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
|
||||
props.editor.storage.mentionsOpen = false;
|
||||
popup?.[0].destroy();
|
||||
component?.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
4
packages/editor/src/core/extensions/mentions/index.ts
Normal file
4
packages/editor/src/core/extensions/mentions/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./extension";
|
||||
export * from "./mention-node-view";
|
||||
export * from "./mentions-list";
|
||||
export * from "./mentions-without-props";
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/* eslint-disable react/display-name */
|
||||
// @ts-nocheck
|
||||
import { useEffect, useState } from "react";
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
// types
|
||||
import { IMentionHighlight } from "@/types";
|
||||
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export const MentionNodeView = (props) => {
|
||||
// TODO: move it to web app
|
||||
const [highlightsState, setHighlightsState] = useState<IMentionHighlight[]>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.extension.options.mentionHighlights) return;
|
||||
const hightlights = async () => {
|
||||
const userId = await props.extension.options.mentionHighlights();
|
||||
setHighlightsState(userId);
|
||||
};
|
||||
hightlights();
|
||||
}, [props.extension.options]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="mention-component inline w-fit">
|
||||
<a
|
||||
href={props.node.attrs.redirect_uri}
|
||||
target="_blank"
|
||||
className={cn("mention rounded bg-custom-primary-100/20 px-1 py-0.5 font-medium text-custom-primary-100", {
|
||||
"bg-yellow-500/20 text-yellow-500": highlightsState
|
||||
? highlightsState.includes(props.node.attrs.entity_identifier)
|
||||
: false,
|
||||
"cursor-pointer": !props.extension.options.readonly,
|
||||
})}
|
||||
>
|
||||
@{props.node.attrs.label}
|
||||
</a>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
170
packages/editor/src/core/extensions/mentions/mentions-list.tsx
Normal file
170
packages/editor/src/core/extensions/mentions/mentions-list.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"use client";
|
||||
|
||||
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// ui
|
||||
import { Avatar } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
// types
|
||||
import { IMentionSuggestion } from "@/types";
|
||||
|
||||
interface MentionListProps {
|
||||
command: (item: {
|
||||
id: string;
|
||||
label: string;
|
||||
entity_name: string;
|
||||
entity_identifier: string;
|
||||
target: string;
|
||||
redirect_uri: string;
|
||||
}) => void;
|
||||
query: string;
|
||||
editor: Editor;
|
||||
mentionSuggestions: () => Promise<IMentionSuggestion[]>;
|
||||
}
|
||||
|
||||
export const MentionList = forwardRef((props: MentionListProps, ref) => {
|
||||
const { query, mentionSuggestions } = props;
|
||||
const [items, setItems] = useState<IMentionSuggestion[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSuggestions = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const suggestions = await mentionSuggestions();
|
||||
const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => {
|
||||
const transactionId = uuidv4();
|
||||
return {
|
||||
...suggestion,
|
||||
id: transactionId,
|
||||
};
|
||||
});
|
||||
|
||||
const filteredSuggestions = mappedSuggestions.filter((suggestion) =>
|
||||
suggestion.title.toLowerCase().startsWith(query.toLowerCase())
|
||||
);
|
||||
|
||||
setItems(filteredSuggestions);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch suggestions:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSuggestions();
|
||||
}, [query, mentionSuggestions]);
|
||||
|
||||
const selectItem = (index: number) => {
|
||||
try {
|
||||
const item = items[index];
|
||||
|
||||
if (item) {
|
||||
props.command({
|
||||
id: item.id,
|
||||
label: item.title,
|
||||
entity_identifier: item.entity_identifier,
|
||||
entity_name: item.entity_name,
|
||||
target: "users",
|
||||
redirect_uri: item.redirect_uri,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error selecting item:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = commandListContainer?.current;
|
||||
|
||||
const item = container?.children[selectedIndex] as HTMLElement;
|
||||
|
||||
if (item && container) updateScrollView(container, item);
|
||||
}, [selectedIndex]);
|
||||
|
||||
const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
||||
const containerHeight = container.offsetHeight;
|
||||
const itemHeight = item ? item.offsetHeight : 0;
|
||||
|
||||
const top = item.offsetTop;
|
||||
const bottom = top + itemHeight;
|
||||
|
||||
if (top < container.scrollTop) {
|
||||
container.scrollTop -= container.scrollTop - top + 5;
|
||||
} else if (bottom > containerHeight + container.scrollTop) {
|
||||
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
|
||||
}
|
||||
};
|
||||
const upHandler = () => {
|
||||
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||
};
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||
};
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
upHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
downHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
enterHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={commandListContainer}
|
||||
className="mentions max-h-48 min-w-[12rem] rounded-md bg-custom-background-100 border-[0.5px] border-custom-border-300 px-2 py-2.5 text-xs shadow-custom-shadow-rg overflow-y-scroll"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="text-center text-custom-text-400">Loading...</div>
|
||||
) : items.length ? (
|
||||
items.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded px-1 py-1.5 hover:bg-custom-background-80 text-custom-text-200",
|
||||
{
|
||||
"bg-custom-background-80": index === selectedIndex,
|
||||
}
|
||||
)}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
<Avatar name={item?.title} src={item?.avatar} />
|
||||
<span className="flex-grow truncate">{item.title}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-custom-text-400">No results</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
MentionList.displayName = "MentionList";
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import Mention from "@tiptap/extension-mention";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
// extensions
|
||||
import { CustomMentionOptions, MentionNodeView } from "@/extensions";
|
||||
|
||||
export const CustomMentionWithoutProps = () =>
|
||||
Mention.extend<CustomMentionOptions>({
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
},
|
||||
label: {
|
||||
default: null,
|
||||
},
|
||||
target: {
|
||||
default: null,
|
||||
},
|
||||
self: {
|
||||
default: false,
|
||||
},
|
||||
redirect_uri: {
|
||||
default: "/",
|
||||
},
|
||||
entity_identifier: {
|
||||
default: null,
|
||||
},
|
||||
entity_name: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MentionNodeView);
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "mention-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["mention-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
});
|
||||
30
packages/editor/src/core/extensions/quote.tsx
Normal file
30
packages/editor/src/core/extensions/quote.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import Blockquote from "@tiptap/extension-blockquote";
|
||||
|
||||
export const CustomQuoteExtension = Blockquote.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Enter: () => {
|
||||
try {
|
||||
const { $from, $to, $head } = this.editor.state.selection;
|
||||
const parent = $head.node(-1);
|
||||
|
||||
if (!parent) return false;
|
||||
|
||||
if (parent.type.name !== "blockquote") {
|
||||
return false;
|
||||
}
|
||||
if ($from.pos !== $to.pos) return false;
|
||||
// if ($head.parentOffset < $head.parent.content.size) return false;
|
||||
|
||||
// this.editor.commands.insertContentAt(parent.ne);
|
||||
this.editor.chain().splitBlock().lift(this.name).run();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error handling Enter in blockquote:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
107
packages/editor/src/core/extensions/read-only-extensions.tsx
Normal file
107
packages/editor/src/core/extensions/read-only-extensions.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import StarterKit from "@tiptap/starter-kit";
|
||||
import TiptapUnderline from "@tiptap/extension-underline";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
// extensions
|
||||
import {
|
||||
CustomQuoteExtension,
|
||||
CustomHorizontalRule,
|
||||
CustomLinkExtension,
|
||||
CustomTypographyExtension,
|
||||
ReadOnlyImageExtension,
|
||||
CustomCodeBlockExtension,
|
||||
CustomCodeInlineExtension,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TableRow,
|
||||
Table,
|
||||
CustomMention,
|
||||
} from "@/extensions";
|
||||
// types
|
||||
import { IMentionHighlight } from "@/types";
|
||||
|
||||
export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||
mentionHighlights?: () => Promise<IMentionHighlight[]>;
|
||||
}) => [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-disc pl-7 space-y-2",
|
||||
},
|
||||
},
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-decimal pl-7 space-y-2",
|
||||
},
|
||||
},
|
||||
listItem: {
|
||||
HTMLAttributes: {
|
||||
class: "not-prose space-y-2",
|
||||
},
|
||||
},
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
blockquote: false,
|
||||
dropcursor: false,
|
||||
gapcursor: false,
|
||||
}),
|
||||
CustomQuoteExtension,
|
||||
CustomHorizontalRule.configure({
|
||||
HTMLAttributes: {
|
||||
class: "my-4 border-custom-border-400",
|
||||
},
|
||||
}),
|
||||
CustomLinkExtension.configure({
|
||||
openOnClick: true,
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
protocols: ["http", "https"],
|
||||
validate: (url: string) => isValidHttpUrl(url),
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||
},
|
||||
}),
|
||||
CustomTypographyExtension,
|
||||
ReadOnlyImageExtension.configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
HTMLAttributes: {
|
||||
class: "not-prose pl-2 space-y-2",
|
||||
},
|
||||
}),
|
||||
TaskItem.configure({
|
||||
HTMLAttributes: {
|
||||
class: "relative pointer-events-none",
|
||||
},
|
||||
nested: true,
|
||||
}),
|
||||
CustomCodeBlockExtension.configure({
|
||||
HTMLAttributes: {
|
||||
class: "",
|
||||
},
|
||||
}),
|
||||
CustomCodeInlineExtension,
|
||||
Markdown.configure({
|
||||
html: true,
|
||||
transformCopiedText: true,
|
||||
}),
|
||||
Table,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TableRow,
|
||||
CustomMention({
|
||||
mentionHighlights: mentionConfig.mentionHighlights,
|
||||
readonly: true,
|
||||
}),
|
||||
];
|
||||
387
packages/editor/src/core/extensions/slash-commands.tsx
Normal file
387
packages/editor/src/core/extensions/slash-commands.tsx
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react";
|
||||
import { Editor, Range, Extension } from "@tiptap/core";
|
||||
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import tippy from "tippy.js";
|
||||
import {
|
||||
CaseSensitive,
|
||||
Code2,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
ImageIcon,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListTodo,
|
||||
MinusSquare,
|
||||
Quote,
|
||||
Table,
|
||||
} from "lucide-react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
import {
|
||||
insertTableCommand,
|
||||
toggleBlockquote,
|
||||
toggleBulletList,
|
||||
toggleOrderedList,
|
||||
toggleTaskList,
|
||||
insertImageCommand,
|
||||
toggleHeadingOne,
|
||||
toggleHeadingTwo,
|
||||
toggleHeadingThree,
|
||||
} from "@/helpers/editor-commands";
|
||||
// types
|
||||
import { CommandProps, ISlashCommandItem, UploadImage } from "@/types";
|
||||
|
||||
interface CommandItemProps {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
export type SlashCommandOptions = {
|
||||
suggestion: Omit<SuggestionOptions, "editor">;
|
||||
};
|
||||
|
||||
const Command = Extension.create<SlashCommandOptions>({
|
||||
name: "slash-command",
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: "/",
|
||||
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
|
||||
props.command({ editor, range });
|
||||
},
|
||||
allow({ editor }: { editor: Editor }) {
|
||||
const { selection } = editor.state;
|
||||
|
||||
const parentNode = selection.$from.node(selection.$from.depth);
|
||||
const blockType = parentNode.type.name;
|
||||
|
||||
if (blockType === "codeBlock") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (editor.isActive("table")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const getSuggestionItems =
|
||||
(uploadFile: UploadImage, additionalOptions?: Array<ISlashCommandItem>) =>
|
||||
({ query }: { query: string }) => {
|
||||
let slashCommands: ISlashCommandItem[] = [
|
||||
{
|
||||
key: "text",
|
||||
title: "Text",
|
||||
description: "Just start typing with plain text.",
|
||||
searchTerms: ["p", "paragraph"],
|
||||
icon: <CaseSensitive className="h-3.5 w-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
if (range) {
|
||||
editor.chain().focus().deleteRange(range).clearNodes().run();
|
||||
}
|
||||
editor.chain().focus().clearNodes().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "heading_1",
|
||||
title: "Heading 1",
|
||||
description: "Big section heading.",
|
||||
searchTerms: ["title", "big", "large"],
|
||||
icon: <Heading1 className="h-3.5 w-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingOne(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "heading_2",
|
||||
title: "Heading 2",
|
||||
description: "Medium section heading.",
|
||||
searchTerms: ["subtitle", "medium"],
|
||||
icon: <Heading2 className="h-3.5 w-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingTwo(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "heading_3",
|
||||
title: "Heading 3",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading3 className="h-3.5 w-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingThree(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "todo_list",
|
||||
title: "To do",
|
||||
description: "Track tasks with a to-do list.",
|
||||
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
||||
icon: <ListTodo className="h-3.5 w-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleTaskList(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "bullet_list",
|
||||
title: "Bullet list",
|
||||
description: "Create a simple bullet list.",
|
||||
searchTerms: ["unordered", "point"],
|
||||
icon: <List className="h-3.5 w-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleBulletList(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "numbered_list",
|
||||
title: "Numbered list",
|
||||
description: "Create a list with numbering.",
|
||||
searchTerms: ["ordered"],
|
||||
icon: <ListOrdered className="h-3.5 w-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleOrderedList(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "table",
|
||||
title: "Table",
|
||||
description: "Create a table",
|
||||
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
||||
icon: <Table className="h-3.5 w-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertTableCommand(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "quote_block",
|
||||
title: "Quote",
|
||||
description: "Capture a quote.",
|
||||
searchTerms: ["blockquote"],
|
||||
icon: <Quote className="h-3.5 w-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => toggleBlockquote(editor, range),
|
||||
},
|
||||
{
|
||||
key: "code_block",
|
||||
title: "Code",
|
||||
description: "Capture a code snippet.",
|
||||
searchTerms: ["codeblock"],
|
||||
icon: <Code2 className="h-3.5 w-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
key: "image",
|
||||
title: "Image",
|
||||
description: "Upload an image from your computer.",
|
||||
searchTerms: ["img", "photo", "picture", "media"],
|
||||
icon: <ImageIcon className="h-3.5 w-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertImageCommand(editor, uploadFile, null, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "divider",
|
||||
title: "Divider",
|
||||
description: "Visually divide blocks.",
|
||||
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
||||
icon: <MinusSquare className="h-3.5 w-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (additionalOptions) {
|
||||
additionalOptions.map((item) => {
|
||||
slashCommands.push(item);
|
||||
});
|
||||
}
|
||||
|
||||
slashCommands = slashCommands.filter((item) => {
|
||||
if (typeof query === "string" && query.length > 0) {
|
||||
const search = query.toLowerCase();
|
||||
return (
|
||||
item.title.toLowerCase().includes(search) ||
|
||||
item.description.toLowerCase().includes(search) ||
|
||||
(item.searchTerms && item.searchTerms.some((term: string) => term.includes(search)))
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return slashCommands;
|
||||
};
|
||||
|
||||
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
||||
const containerHeight = container.offsetHeight;
|
||||
const itemHeight = item ? item.offsetHeight : 0;
|
||||
|
||||
const top = item.offsetTop;
|
||||
const bottom = top + itemHeight;
|
||||
|
||||
if (top < container.scrollTop) {
|
||||
container.scrollTop -= container.scrollTop - top + 5;
|
||||
} else if (bottom > containerHeight + container.scrollTop) {
|
||||
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
|
||||
}
|
||||
};
|
||||
|
||||
const CommandList = ({ items, command }: { items: CommandItemProps[]; command: any; editor: any; range: any }) => {
|
||||
// states
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
// refs
|
||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
if (item) command(item);
|
||||
},
|
||||
[command, items]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (navigationKeys.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
if (e.key === "ArrowUp") {
|
||||
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
selectItem(selectedIndex);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [items, selectedIndex, setSelectedIndex, selectItem]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = commandListContainer?.current;
|
||||
|
||||
const item = container?.children[selectedIndex] as HTMLElement;
|
||||
|
||||
if (item && container) updateScrollView(container, item);
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (items.length <= 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
id="slash-command"
|
||||
ref={commandListContainer}
|
||||
className="z-10 max-h-80 min-w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg"
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full rounded px-1 py-1.5 text-sm text-left truncate text-custom-text-200 hover:bg-custom-background-80",
|
||||
{
|
||||
"bg-custom-background-80": index === selectedIndex,
|
||||
}
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
selectItem(index);
|
||||
}}
|
||||
>
|
||||
<span className="grid place-items-center flex-shrink-0">{item.icon}</span>
|
||||
<p className="flex-grow truncate">{item.title}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CommandListInstance {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
||||
}
|
||||
|
||||
const renderItems = () => {
|
||||
let component: ReactRenderer<CommandListInstance, typeof CommandList> | null = null;
|
||||
let popup: any | null = null;
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||
component = new ReactRenderer(CommandList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
const tippyContainer = document.querySelector(".active-editor") ?? document.querySelector("#editor-container");
|
||||
|
||||
// @ts-expect-error Tippy overloads are messed up
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: tippyContainer,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||
component?.updateProps(props);
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (component?.ref?.onKeyDown(props)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onExit: () => {
|
||||
popup?.[0].destroy();
|
||||
component?.destroy();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const SlashCommand = (uploadFile: UploadImage, additionalOptions?: Array<ISlashCommandItem>) =>
|
||||
Command.configure({
|
||||
suggestion: {
|
||||
items: getSuggestionItems(uploadFile, additionalOptions),
|
||||
render: renderItems,
|
||||
},
|
||||
});
|
||||
4
packages/editor/src/core/extensions/table/index.ts
Normal file
4
packages/editor/src/core/extensions/table/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./table";
|
||||
export * from "./table-cell";
|
||||
export * from "./table-header";
|
||||
export * from "./table-row";
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { TableCell } from "./table-cell";
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
|
||||
export interface TableCellOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
export const TableCell = Node.create<TableCellOptions>({
|
||||
name: "tableCell",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
content: "block+",
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
colspan: {
|
||||
default: 1,
|
||||
},
|
||||
rowspan: {
|
||||
default: 1,
|
||||
},
|
||||
colwidth: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const colwidth = element.getAttribute("colwidth");
|
||||
const value = colwidth ? [parseInt(colwidth, 10)] : null;
|
||||
|
||||
return value;
|
||||
},
|
||||
},
|
||||
background: {
|
||||
default: null,
|
||||
},
|
||||
textColor: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
tableRole: "cell",
|
||||
|
||||
isolating: true,
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "td" }];
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
return [
|
||||
"td",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
style: `background-color: ${node.attrs.background}; color: ${node.attrs.textColor}`,
|
||||
}),
|
||||
0,
|
||||
];
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { TableHeader } from "./table-header";
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
|
||||
export interface TableHeaderOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
export const TableHeader = Node.create<TableHeaderOptions>({
|
||||
name: "tableHeader",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
content: "paragraph+",
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
colspan: {
|
||||
default: 1,
|
||||
},
|
||||
rowspan: {
|
||||
default: 1,
|
||||
},
|
||||
colwidth: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const colwidth = element.getAttribute("colwidth");
|
||||
const value = colwidth ? [parseInt(colwidth, 10)] : null;
|
||||
|
||||
return value;
|
||||
},
|
||||
},
|
||||
background: {
|
||||
default: "none",
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
tableRole: "header_cell",
|
||||
|
||||
isolating: true,
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "th" }];
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
return [
|
||||
"th",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
style: `background-color: ${node.attrs.background}`,
|
||||
}),
|
||||
0,
|
||||
];
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { TableRow } from "./table-row";
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
|
||||
export interface TableRowOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
export const TableRow = Node.create<TableRowOptions>({
|
||||
name: "tableRow",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
background: {
|
||||
default: null,
|
||||
},
|
||||
textColor: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
content: "(tableCell | tableHeader)*",
|
||||
|
||||
tableRole: "row",
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "tr" }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const style = HTMLAttributes.background
|
||||
? `background-color: ${HTMLAttributes.background}; color: ${HTMLAttributes.textColor}`
|
||||
: "";
|
||||
|
||||
const attributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { style });
|
||||
|
||||
return ["tr", attributes, 0];
|
||||
},
|
||||
});
|
||||
51
packages/editor/src/core/extensions/table/table/icons.ts
Normal file
51
packages/editor/src/core/extensions/table/table/icons.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
export const icons = {
|
||||
colorPicker: `<svg xmlns="http://www.w3.org/2000/svg" length="24" viewBox="0 0 24 24" style="transform: ;msFilter:;"><path fill="rgb(var(--color-text-300))" d="M20 14c-.092.064-2 2.083-2 3.5 0 1.494.949 2.448 2 2.5.906.044 2-.891 2-2.5 0-1.5-1.908-3.436-2-3.5zM9.586 20c.378.378.88.586 1.414.586s1.036-.208 1.414-.586l7-7-.707-.707L11 4.586 8.707 2.293 7.293 3.707 9.586 6 4 11.586c-.378.378-.586.88-.586 1.414s.208 1.036.586 1.414L9.586 20zM11 7.414 16.586 13H5.414L11 7.414z"></path></svg>`,
|
||||
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>`,
|
||||
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>`,
|
||||
insertLeftTableIcon: `<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
length={12}
|
||||
viewBox="0 -960 960 960"
|
||||
>
|
||||
<path
|
||||
d="M224.617-140.001q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21H360q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H224.617Zm375.383 0q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21h135.383q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H600Zm147.691-607.69q0-4.616-3.846-8.463-3.846-3.846-8.462-3.846H600q-4.616 0-8.462 3.846-3.847 3.847-3.847 8.463v535.382q0 4.616 3.847 8.463Q595.384-200 600-200h135.383q4.616 0 8.462-3.846 3.846-3.847 3.846-8.463v-535.382ZM587.691-200h160-160Z"
|
||||
fill="rgb(var(--color-text-300))"
|
||||
/>
|
||||
</svg>
|
||||
`,
|
||||
insertRightTableIcon: `<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
length={12}
|
||||
viewBox="0 -960 960 960"
|
||||
>
|
||||
<path
|
||||
d="M600-140.001q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21h135.383q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H600Zm-375.383 0q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21H360q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H224.617Zm-12.308-607.69v535.382q0 4.616 3.846 8.463 3.846 3.846 8.462 3.846H360q4.616 0 8.462-3.846 3.847-3.847 3.847-8.463v-535.382q0-4.616-3.847-8.463Q364.616-760 360-760H224.617q-4.616 0-8.462 3.846-3.846 3.847-3.846 8.463Zm160 547.691h-160 160Z"
|
||||
fill="rgb(var(--color-text-300))"
|
||||
/>
|
||||
</svg>
|
||||
`,
|
||||
insertTopTableIcon: `<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
length={24}
|
||||
viewBox="0 -960 960 960"
|
||||
>
|
||||
<path
|
||||
d="M212.309-527.693q-30.308 0-51.308-21t-21-51.307v-135.383q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307V-600q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0 375.383q-30.308 0-51.308-21t-21-51.307V-360q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307v135.383q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0-59.999h535.382q4.616 0 8.463-3.846 3.846-3.846 3.846-8.462V-360q0-4.616-3.846-8.462-3.847-3.847-8.463-3.847H212.309q-4.616 0-8.463 3.847Q200-364.616 200-360v135.383q0 4.616 3.846 8.462 3.847 3.846 8.463 3.846Zm-12.309-160v160-160Z"
|
||||
fill="rgb(var(--color-text-300))"
|
||||
/>
|
||||
</svg>
|
||||
`,
|
||||
toggleColumnHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
|
||||
toggleRowHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
|
||||
insertBottomTableIcon: `<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
length={24}
|
||||
viewBox="0 -960 960 960"
|
||||
>
|
||||
<path
|
||||
d="M212.309-152.31q-30.308 0-51.308-21t-21-51.307V-360q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307v135.383q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0-375.383q-30.308 0-51.308-21t-21-51.307v-135.383q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307V-600q0 30.307-21 51.307-21 21-51.308 21H212.309Zm535.382-219.998H212.309q-4.616 0-8.463 3.846-3.846 3.846-3.846 8.462V-600q0 4.616 3.846 8.462 3.847 3.847 8.463 3.847h535.382q4.616 0 8.463-3.847Q760-595.384 760-600v-135.383q0-4.616-3.846-8.462-3.847-3.846-8.463-3.846ZM200-587.691v-160 160Z"
|
||||
fill="rgb(var(--color-text-300))"
|
||||
/>
|
||||
</svg>
|
||||
`,
|
||||
};
|
||||
1
packages/editor/src/core/extensions/table/table/index.ts
Normal file
1
packages/editor/src/core/extensions/table/table/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { Table } from "./table";
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||
import { findParentNode } from "@tiptap/core";
|
||||
import { DecorationSet, Decoration } from "@tiptap/pm/view";
|
||||
|
||||
const key = new PluginKey("tableControls");
|
||||
|
||||
export function tableControls() {
|
||||
return new Plugin({
|
||||
key,
|
||||
state: {
|
||||
init() {
|
||||
return new TableControlsState();
|
||||
},
|
||||
apply(tr, prev) {
|
||||
return prev.apply(tr);
|
||||
},
|
||||
},
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mousemove: (view, event) => {
|
||||
const pluginState = key.getState(view.state);
|
||||
|
||||
if (!(event.target as HTMLElement).closest(".table-wrapper") && pluginState.values.hoveredTable) {
|
||||
return view.dispatch(
|
||||
view.state.tr.setMeta(key, {
|
||||
setHoveredTable: null,
|
||||
setHoveredCell: null,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const pos = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
if (!pos || pos.pos < 0 || pos.pos > view.state.doc.content.size) return;
|
||||
|
||||
const table = findParentNode((node) => node.type.name === "table")(
|
||||
TextSelection.create(view.state.doc, pos.pos)
|
||||
);
|
||||
const cell = findParentNode((node) => node.type.name === "tableCell" || node.type.name === "tableHeader")(
|
||||
TextSelection.create(view.state.doc, pos.pos)
|
||||
);
|
||||
|
||||
if (!table || !cell) return;
|
||||
|
||||
if (pluginState.values.hoveredCell?.pos !== cell.pos) {
|
||||
return view.dispatch(
|
||||
view.state.tr.setMeta(key, {
|
||||
setHoveredTable: table,
|
||||
setHoveredCell: cell,
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
decorations: (state) => {
|
||||
const pluginState = key.getState(state);
|
||||
if (!pluginState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { hoveredTable, hoveredCell } = pluginState.values;
|
||||
const docSize = state.doc.content.size;
|
||||
if (hoveredTable && hoveredCell && hoveredTable.pos < docSize && hoveredCell.pos < docSize) {
|
||||
const decorations = [
|
||||
Decoration.node(
|
||||
hoveredTable.pos,
|
||||
hoveredTable.pos + hoveredTable.node.nodeSize,
|
||||
{},
|
||||
{
|
||||
hoveredTable,
|
||||
hoveredCell,
|
||||
}
|
||||
),
|
||||
];
|
||||
|
||||
return DecorationSet.create(state.doc, decorations);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
class TableControlsState {
|
||||
values;
|
||||
|
||||
constructor(props = {}) {
|
||||
this.values = {
|
||||
hoveredTable: null,
|
||||
hoveredCell: null,
|
||||
...props,
|
||||
};
|
||||
}
|
||||
|
||||
apply(tr: any) {
|
||||
const actions = tr.getMeta(key);
|
||||
|
||||
if (actions?.setHoveredTable !== undefined) {
|
||||
this.values.hoveredTable = actions.setHoveredTable;
|
||||
}
|
||||
|
||||
if (actions?.setHoveredCell !== undefined) {
|
||||
this.values.hoveredCell = actions.setHoveredCell;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
491
packages/editor/src/core/extensions/table/table/table-view.tsx
Normal file
491
packages/editor/src/core/extensions/table/table/table-view.tsx
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
import { h } from "jsx-dom-cjs";
|
||||
import { Node as ProseMirrorNode, ResolvedPos } from "@tiptap/pm/model";
|
||||
import { Decoration, NodeView } from "@tiptap/pm/view";
|
||||
import tippy, { Instance, Props } from "tippy.js";
|
||||
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { CellSelection, TableMap, updateColumnsOnResize } from "@tiptap/pm/tables";
|
||||
|
||||
import { icons } from "src/core/extensions/table/table/icons";
|
||||
|
||||
type ToolboxItem = {
|
||||
label: string;
|
||||
icon: string;
|
||||
action: (args: any) => void;
|
||||
};
|
||||
|
||||
export function updateColumns(
|
||||
node: ProseMirrorNode,
|
||||
colgroup: HTMLElement,
|
||||
table: HTMLElement,
|
||||
cellMinWidth: number,
|
||||
overrideCol?: number,
|
||||
overrideValue?: any
|
||||
) {
|
||||
let totalWidth = 0;
|
||||
let fixedWidth = true;
|
||||
let nextDOM = colgroup.firstChild as HTMLElement;
|
||||
const row = node.firstChild;
|
||||
|
||||
if (!row) return;
|
||||
|
||||
for (let i = 0, col = 0; i < row.childCount; i += 1) {
|
||||
const { colspan, colwidth } = row.child(i).attrs;
|
||||
|
||||
for (let j = 0; j < colspan; j += 1, col += 1) {
|
||||
const hasWidth = overrideCol === col ? overrideValue : colwidth && colwidth[j];
|
||||
const cssWidth = hasWidth ? `${hasWidth}px` : "";
|
||||
|
||||
totalWidth += hasWidth || cellMinWidth;
|
||||
|
||||
if (!hasWidth) {
|
||||
fixedWidth = false;
|
||||
}
|
||||
|
||||
if (!nextDOM) {
|
||||
colgroup.appendChild(document.createElement("col")).style.width = cssWidth;
|
||||
} else {
|
||||
if (nextDOM.style.width !== cssWidth) {
|
||||
nextDOM.style.width = cssWidth;
|
||||
}
|
||||
|
||||
nextDOM = nextDOM.nextSibling as HTMLElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (nextDOM) {
|
||||
const after = nextDOM.nextSibling;
|
||||
|
||||
nextDOM.parentNode?.removeChild(nextDOM);
|
||||
nextDOM = after as HTMLElement;
|
||||
}
|
||||
|
||||
if (fixedWidth) {
|
||||
table.style.width = `${totalWidth}px`;
|
||||
table.style.minWidth = "";
|
||||
} else {
|
||||
table.style.width = "";
|
||||
table.style.minWidth = `${totalWidth}px`;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultTippyOptions: Partial<Props> = {
|
||||
allowHTML: true,
|
||||
arrow: false,
|
||||
trigger: "click",
|
||||
animation: "scale-subtle",
|
||||
theme: "light-border no-padding",
|
||||
interactive: true,
|
||||
hideOnClick: true,
|
||||
placement: "right",
|
||||
};
|
||||
|
||||
function setCellsBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) {
|
||||
return editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes("tableCell", {
|
||||
background: color.backgroundColor,
|
||||
textColor: color.textColor,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
function setTableRowBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) {
|
||||
const { state, dispatch } = editor.view;
|
||||
const { selection } = state;
|
||||
if (!(selection instanceof CellSelection)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the position of the hovered cell in the selection to determine the row.
|
||||
const hoveredCell = selection.$headCell || selection.$anchorCell;
|
||||
|
||||
// Find the depth of the table row node
|
||||
let rowDepth = hoveredCell.depth;
|
||||
while (rowDepth > 0 && hoveredCell.node(rowDepth).type.name !== "tableRow") {
|
||||
rowDepth--;
|
||||
}
|
||||
|
||||
// If we couldn't find a tableRow node, we can't set the background color
|
||||
if (hoveredCell.node(rowDepth).type.name !== "tableRow") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the position where the table row starts
|
||||
const rowStartPos = hoveredCell.start(rowDepth);
|
||||
|
||||
// Create a transaction that sets the background color on the tableRow node.
|
||||
const tr = state.tr.setNodeMarkup(rowStartPos - 1, null, {
|
||||
...hoveredCell.node(rowDepth).attrs,
|
||||
background: color.backgroundColor,
|
||||
textColor: color.textColor,
|
||||
});
|
||||
|
||||
dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
const columnsToolboxItems: ToolboxItem[] = [
|
||||
{
|
||||
label: "Toggle column header",
|
||||
icon: icons.toggleColumnHeader,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderColumn().run(),
|
||||
},
|
||||
{
|
||||
label: "Add column before",
|
||||
icon: icons.insertLeftTableIcon,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnBefore().run(),
|
||||
},
|
||||
{
|
||||
label: "Add column after",
|
||||
icon: icons.insertRightTableIcon,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnAfter().run(),
|
||||
},
|
||||
{
|
||||
label: "Pick color",
|
||||
icon: "", // No icon needed for color picker
|
||||
action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox`
|
||||
},
|
||||
{
|
||||
label: "Delete column",
|
||||
icon: icons.deleteColumn,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteColumn().run(),
|
||||
},
|
||||
];
|
||||
|
||||
const rowsToolboxItems: ToolboxItem[] = [
|
||||
{
|
||||
label: "Toggle row header",
|
||||
icon: icons.toggleRowHeader,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderRow().run(),
|
||||
},
|
||||
{
|
||||
label: "Add row above",
|
||||
icon: icons.insertTopTableIcon,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowBefore().run(),
|
||||
},
|
||||
{
|
||||
label: "Add row below",
|
||||
icon: icons.insertBottomTableIcon,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowAfter().run(),
|
||||
},
|
||||
{
|
||||
label: "Pick color",
|
||||
icon: "",
|
||||
action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox`
|
||||
},
|
||||
{
|
||||
label: "Delete row",
|
||||
icon: icons.deleteRow,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteRow().run(),
|
||||
},
|
||||
];
|
||||
|
||||
function createToolbox({
|
||||
triggerButton,
|
||||
items,
|
||||
tippyOptions,
|
||||
onSelectColor,
|
||||
onClickItem,
|
||||
colors,
|
||||
}: {
|
||||
triggerButton: Element | null;
|
||||
items: ToolboxItem[];
|
||||
tippyOptions: any;
|
||||
onClickItem: (item: ToolboxItem) => void;
|
||||
onSelectColor: (color: { backgroundColor: string; textColor: string }) => void;
|
||||
colors: { [key: string]: { backgroundColor: string; textColor: string; icon?: string } };
|
||||
}): Instance<Props> {
|
||||
// @ts-expect-error
|
||||
const toolbox = tippy(triggerButton, {
|
||||
content: h(
|
||||
"div",
|
||||
{
|
||||
className:
|
||||
"rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg min-w-[12rem] whitespace-nowrap",
|
||||
},
|
||||
items.map((item) => {
|
||||
if (item.label === "Pick color") {
|
||||
return h("div", { className: "flex flex-col" }, [
|
||||
h("hr", { className: "my-2 border-custom-border-200" }),
|
||||
h("div", { className: "text-custom-text-200 text-sm" }, item.label),
|
||||
h(
|
||||
"div",
|
||||
{ className: "grid grid-cols-6 gap-x-1 gap-y-2.5 mt-2" },
|
||||
Object.entries(colors).map(([colorName, colorValue]) =>
|
||||
h("div", {
|
||||
className: "grid place-items-center size-6 rounded cursor-pointer",
|
||||
style: `background-color: ${colorValue.backgroundColor};color: ${colorValue.textColor || "inherit"};`,
|
||||
innerHTML:
|
||||
colorValue.icon ?? `<span class="text-md" style:"color: ${colorValue.backgroundColor}>A</span>`,
|
||||
onClick: () => onSelectColor(colorValue),
|
||||
})
|
||||
)
|
||||
),
|
||||
h("hr", { className: "my-2 border-custom-border-200" }),
|
||||
]);
|
||||
} else {
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
className:
|
||||
"flex items-center gap-2 px-1 py-1.5 bg-custom-background-100 hover:bg-custom-background-80 text-sm text-custom-text-200 rounded cursor-pointer",
|
||||
itemType: "div",
|
||||
onClick: () => onClickItem(item),
|
||||
},
|
||||
[
|
||||
h("span", {
|
||||
className: "h-3 w-3 flex-shrink-0",
|
||||
innerHTML: item.icon,
|
||||
}),
|
||||
h("div", { className: "label" }, item.label),
|
||||
]
|
||||
);
|
||||
}
|
||||
})
|
||||
),
|
||||
...tippyOptions,
|
||||
});
|
||||
|
||||
return Array.isArray(toolbox) ? toolbox[0] : toolbox;
|
||||
}
|
||||
|
||||
export class TableView implements NodeView {
|
||||
node: ProseMirrorNode;
|
||||
cellMinWidth: number;
|
||||
decorations: Decoration[];
|
||||
editor: Editor;
|
||||
getPos: () => number;
|
||||
hoveredCell: ResolvedPos | null = null;
|
||||
map: TableMap;
|
||||
root: HTMLElement;
|
||||
table: HTMLTableElement;
|
||||
colgroup: HTMLTableColElement;
|
||||
tbody: HTMLElement;
|
||||
rowsControl?: HTMLElement | null;
|
||||
columnsControl?: HTMLElement | null;
|
||||
columnsToolbox?: Instance<Props>;
|
||||
rowsToolbox?: Instance<Props>;
|
||||
controls?: HTMLElement;
|
||||
|
||||
get dom() {
|
||||
return this.root;
|
||||
}
|
||||
|
||||
get contentDOM() {
|
||||
return this.tbody;
|
||||
}
|
||||
|
||||
constructor(
|
||||
node: ProseMirrorNode,
|
||||
cellMinWidth: number,
|
||||
decorations: Decoration[],
|
||||
editor: Editor,
|
||||
getPos: () => number
|
||||
) {
|
||||
this.node = node;
|
||||
this.cellMinWidth = cellMinWidth;
|
||||
this.decorations = decorations;
|
||||
this.editor = editor;
|
||||
this.getPos = getPos;
|
||||
this.hoveredCell = null;
|
||||
this.map = TableMap.get(node);
|
||||
|
||||
if (editor.isEditable) {
|
||||
this.rowsControl = h(
|
||||
"div",
|
||||
{ className: "rows-control" },
|
||||
h("div", {
|
||||
itemType: "button",
|
||||
className: "rows-control-div",
|
||||
onClick: () => this.selectRow(),
|
||||
})
|
||||
);
|
||||
|
||||
this.columnsControl = h(
|
||||
"div",
|
||||
{ className: "columns-control" },
|
||||
h("div", {
|
||||
itemType: "button",
|
||||
className: "columns-control-div",
|
||||
onClick: () => this.selectColumn(),
|
||||
})
|
||||
);
|
||||
|
||||
this.controls = h(
|
||||
"div",
|
||||
{ className: "table-controls", contentEditable: "false" },
|
||||
this.rowsControl,
|
||||
this.columnsControl
|
||||
);
|
||||
const columnColors = {
|
||||
Blue: { backgroundColor: "#D9E4FF", textColor: "#171717" },
|
||||
Orange: { backgroundColor: "#FFEDD5", textColor: "#171717" },
|
||||
Grey: { backgroundColor: "#F1F1F1", textColor: "#171717" },
|
||||
Yellow: { backgroundColor: "#FEF3C7", textColor: "#171717" },
|
||||
Green: { backgroundColor: "#DCFCE7", textColor: "#171717" },
|
||||
Red: { backgroundColor: "#FFDDDD", textColor: "#171717" },
|
||||
Pink: { backgroundColor: "#FFE8FA", textColor: "#171717" },
|
||||
Purple: { backgroundColor: "#E8DAFB", textColor: "#171717" },
|
||||
None: {
|
||||
backgroundColor: "none",
|
||||
textColor: "none",
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="gray" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ban"><circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/></svg>`,
|
||||
},
|
||||
};
|
||||
|
||||
this.columnsToolbox = createToolbox({
|
||||
triggerButton: this.columnsControl.querySelector(".columns-control-div"),
|
||||
items: columnsToolboxItems,
|
||||
colors: columnColors,
|
||||
onSelectColor: (color) => setCellsBackgroundColor(this.editor, color),
|
||||
tippyOptions: {
|
||||
...defaultTippyOptions,
|
||||
appendTo: this.controls,
|
||||
},
|
||||
onClickItem: (item) => {
|
||||
item.action({
|
||||
editor: this.editor,
|
||||
triggerButton: this.columnsControl?.firstElementChild,
|
||||
controlsContainer: this.controls,
|
||||
});
|
||||
this.columnsToolbox?.hide();
|
||||
},
|
||||
});
|
||||
|
||||
this.rowsToolbox = createToolbox({
|
||||
triggerButton: this.rowsControl.firstElementChild,
|
||||
items: rowsToolboxItems,
|
||||
colors: columnColors,
|
||||
tippyOptions: {
|
||||
...defaultTippyOptions,
|
||||
appendTo: this.controls,
|
||||
},
|
||||
onSelectColor: (color) => setTableRowBackgroundColor(editor, color),
|
||||
onClickItem: (item) => {
|
||||
item.action({
|
||||
editor: this.editor,
|
||||
triggerButton: this.rowsControl?.firstElementChild,
|
||||
controlsContainer: this.controls,
|
||||
});
|
||||
this.rowsToolbox?.hide();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.colgroup = h(
|
||||
"colgroup",
|
||||
null,
|
||||
Array.from({ length: this.map.width }, () => 1).map(() => h("col"))
|
||||
);
|
||||
this.tbody = h("tbody");
|
||||
this.table = h("table", null, this.colgroup, this.tbody);
|
||||
|
||||
this.root = h(
|
||||
"div",
|
||||
{
|
||||
className: "table-wrapper horizontal-scrollbar scrollbar-md controls--disabled",
|
||||
},
|
||||
this.controls,
|
||||
this.table
|
||||
);
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
update(node: ProseMirrorNode, decorations: readonly Decoration[]) {
|
||||
if (node.type !== this.node.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.node = node;
|
||||
this.decorations = [...decorations];
|
||||
this.map = TableMap.get(this.node);
|
||||
|
||||
if (this.editor.isEditable) {
|
||||
this.updateControls();
|
||||
}
|
||||
|
||||
this.render();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.colgroup.children.length !== this.map.width) {
|
||||
const cols = Array.from({ length: this.map.width }, () => 1).map(() => h("col"));
|
||||
this.colgroup.replaceChildren(...cols);
|
||||
}
|
||||
|
||||
updateColumnsOnResize(this.node, this.colgroup, this.table, this.cellMinWidth);
|
||||
}
|
||||
|
||||
ignoreMutation() {
|
||||
return true;
|
||||
}
|
||||
|
||||
updateControls() {
|
||||
const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce(
|
||||
(acc, curr) => {
|
||||
if (curr.spec.hoveredCell !== undefined) {
|
||||
acc["hoveredCell"] = curr.spec.hoveredCell;
|
||||
}
|
||||
|
||||
if (curr.spec.hoveredTable !== undefined) {
|
||||
acc["hoveredTable"] = curr.spec.hoveredTable;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, HTMLElement>
|
||||
) as any;
|
||||
|
||||
if (table === undefined || cell === undefined) {
|
||||
return this.root.classList.add("controls--disabled");
|
||||
}
|
||||
|
||||
this.root.classList.remove("controls--disabled");
|
||||
this.hoveredCell = cell;
|
||||
|
||||
const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement;
|
||||
|
||||
if (!this.table || !cellDom) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tableRect = this.table?.getBoundingClientRect();
|
||||
const cellRect = cellDom?.getBoundingClientRect();
|
||||
|
||||
if (this.columnsControl) {
|
||||
this.columnsControl.style.left = `${cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft}px`;
|
||||
this.columnsControl.style.width = `${cellRect.width}px`;
|
||||
}
|
||||
if (this.rowsControl) {
|
||||
this.rowsControl.style.top = `${cellRect.top - tableRect.top}px`;
|
||||
this.rowsControl.style.height = `${cellRect.height}px`;
|
||||
}
|
||||
}
|
||||
|
||||
selectColumn() {
|
||||
if (!this.hoveredCell) return;
|
||||
|
||||
const colIndex = this.map.colCount(this.hoveredCell.pos - (this.getPos() + 1));
|
||||
const anchorCellPos = this.hoveredCell.pos;
|
||||
const headCellPos = this.map.map[colIndex + this.map.width * (this.map.height - 1)] + (this.getPos() + 1);
|
||||
|
||||
const cellSelection = CellSelection.create(this.editor.view.state.doc, anchorCellPos, headCellPos);
|
||||
this.editor.view.dispatch(this.editor.state.tr.setSelection(cellSelection));
|
||||
}
|
||||
|
||||
selectRow() {
|
||||
if (!this.hoveredCell) return;
|
||||
|
||||
const anchorCellPos = this.hoveredCell.pos;
|
||||
const anchorCellIndex = this.map.map.indexOf(anchorCellPos - (this.getPos() + 1));
|
||||
const headCellPos = this.map.map[anchorCellIndex + (this.map.width - 1)] + (this.getPos() + 1);
|
||||
|
||||
const cellSelection = CellSelection.create(this.editor.state.doc, anchorCellPos, headCellPos);
|
||||
this.editor.view.dispatch(this.editor.view.state.tr.setSelection(cellSelection));
|
||||
}
|
||||
}
|
||||
292
packages/editor/src/core/extensions/table/table/table.ts
Normal file
292
packages/editor/src/core/extensions/table/table/table.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
import { TextSelection } from "@tiptap/pm/state";
|
||||
|
||||
import { callOrReturn, getExtensionField, mergeAttributes, Node, ParentConfig } from "@tiptap/core";
|
||||
import {
|
||||
addColumnAfter,
|
||||
addColumnBefore,
|
||||
addRowAfter,
|
||||
addRowBefore,
|
||||
CellSelection,
|
||||
columnResizing,
|
||||
deleteColumn,
|
||||
deleteRow,
|
||||
deleteTable,
|
||||
fixTables,
|
||||
goToNextCell,
|
||||
mergeCells,
|
||||
setCellAttr,
|
||||
splitCell,
|
||||
tableEditing,
|
||||
toggleHeader,
|
||||
toggleHeaderCell,
|
||||
} from "@tiptap/pm/tables";
|
||||
|
||||
import { tableControls } from "src/core/extensions/table/table/table-controls";
|
||||
import { TableView } from "src/core/extensions/table/table/table-view";
|
||||
import { createTable } from "src/core/extensions/table/table/utilities/create-table";
|
||||
import { deleteTableWhenAllCellsSelected } from "src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected";
|
||||
import { insertLineBelowTableAction } from "./utilities/insert-line-below-table-action";
|
||||
import { insertLineAboveTableAction } from "./utilities/insert-line-above-table-action";
|
||||
|
||||
export interface TableOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
resizable: boolean;
|
||||
handleWidth: number;
|
||||
cellMinWidth: number;
|
||||
lastColumnResizable: boolean;
|
||||
allowTableNodeSelection: boolean;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
table: {
|
||||
insertTable: (options?: { rows?: number; cols?: number; withHeaderRow?: boolean }) => ReturnType;
|
||||
addColumnBefore: () => ReturnType;
|
||||
addColumnAfter: () => ReturnType;
|
||||
deleteColumn: () => ReturnType;
|
||||
addRowBefore: () => ReturnType;
|
||||
addRowAfter: () => ReturnType;
|
||||
deleteRow: () => ReturnType;
|
||||
deleteTable: () => ReturnType;
|
||||
mergeCells: () => ReturnType;
|
||||
splitCell: () => ReturnType;
|
||||
toggleHeaderColumn: () => ReturnType;
|
||||
toggleHeaderRow: () => ReturnType;
|
||||
toggleHeaderCell: () => ReturnType;
|
||||
mergeOrSplit: () => ReturnType;
|
||||
setCellAttribute: (name: string, value: any) => ReturnType;
|
||||
goToNextCell: () => ReturnType;
|
||||
goToPreviousCell: () => ReturnType;
|
||||
fixTables: () => ReturnType;
|
||||
setCellSelection: (position: { anchorCell: number; headCell?: number }) => ReturnType;
|
||||
};
|
||||
}
|
||||
|
||||
interface NodeConfig<Options, Storage> {
|
||||
tableRole?:
|
||||
| string
|
||||
| ((this: {
|
||||
name: string;
|
||||
options: Options;
|
||||
storage: Storage;
|
||||
parent: ParentConfig<NodeConfig<Options>>["tableRole"];
|
||||
}) => string);
|
||||
}
|
||||
}
|
||||
|
||||
export const Table = Node.create({
|
||||
name: "table",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
resizable: true,
|
||||
handleWidth: 5,
|
||||
cellMinWidth: 100,
|
||||
lastColumnResizable: true,
|
||||
allowTableNodeSelection: true,
|
||||
};
|
||||
},
|
||||
|
||||
content: "tableRow+",
|
||||
|
||||
tableRole: "table",
|
||||
|
||||
isolating: true,
|
||||
|
||||
group: "block",
|
||||
|
||||
allowGapCursor: false,
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "table" }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["table", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), ["tbody", 0]];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertTable:
|
||||
({ rows = 3, cols = 3, withHeaderRow = false } = {}) =>
|
||||
({ tr, dispatch, editor }) => {
|
||||
const node = createTable(editor.schema, rows, cols, withHeaderRow);
|
||||
if (dispatch) {
|
||||
const offset = tr.selection.anchor + 1;
|
||||
|
||||
tr.replaceSelectionWith(node)
|
||||
.scrollIntoView()
|
||||
.setSelection(TextSelection.near(tr.doc.resolve(offset)));
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
addColumnBefore:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
addColumnBefore(state, dispatch),
|
||||
addColumnAfter:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
addColumnAfter(state, dispatch),
|
||||
deleteColumn:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
deleteColumn(state, dispatch),
|
||||
addRowBefore:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
addRowBefore(state, dispatch),
|
||||
addRowAfter:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
addRowAfter(state, dispatch),
|
||||
deleteRow:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
deleteRow(state, dispatch),
|
||||
deleteTable:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
deleteTable(state, dispatch),
|
||||
mergeCells:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
mergeCells(state, dispatch),
|
||||
splitCell:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
splitCell(state, dispatch),
|
||||
toggleHeaderColumn:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
toggleHeader("column")(state, dispatch),
|
||||
toggleHeaderRow:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
toggleHeader("row")(state, dispatch),
|
||||
toggleHeaderCell:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
toggleHeaderCell(state, dispatch),
|
||||
mergeOrSplit:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
if (mergeCells(state, dispatch)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return splitCell(state, dispatch);
|
||||
},
|
||||
setCellAttribute:
|
||||
(name, value) =>
|
||||
({ state, dispatch }) =>
|
||||
setCellAttr(name, value)(state, dispatch),
|
||||
goToNextCell:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
goToNextCell(1)(state, dispatch),
|
||||
goToPreviousCell:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
goToNextCell(-1)(state, dispatch),
|
||||
fixTables:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
if (dispatch) {
|
||||
fixTables(state);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
setCellSelection:
|
||||
(position) =>
|
||||
({ tr, dispatch }) => {
|
||||
if (dispatch) {
|
||||
const selection = CellSelection.create(tr.doc, position.anchorCell, position.headCell);
|
||||
|
||||
// @ts-ignore
|
||||
tr.setSelection(selection);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: () => {
|
||||
if (this.editor.isActive("table")) {
|
||||
if (this.editor.isActive("listItem") || this.editor.isActive("taskItem")) {
|
||||
return false;
|
||||
}
|
||||
if (this.editor.commands.goToNextCell()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this.editor.can().addRowAfter()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.editor.chain().addRowAfter().goToNextCell().run();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
"Shift-Tab": () => this.editor.commands.goToPreviousCell(),
|
||||
Backspace: deleteTableWhenAllCellsSelected,
|
||||
"Mod-Backspace": deleteTableWhenAllCellsSelected,
|
||||
Delete: deleteTableWhenAllCellsSelected,
|
||||
"Mod-Delete": deleteTableWhenAllCellsSelected,
|
||||
ArrowDown: insertLineBelowTableAction,
|
||||
ArrowUp: insertLineAboveTableAction,
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ editor, getPos, node, decorations }) => {
|
||||
const { cellMinWidth } = this.options;
|
||||
|
||||
return new TableView(node, cellMinWidth, decorations, editor, getPos as () => number);
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const isResizable = this.options.resizable && this.editor.isEditable;
|
||||
|
||||
const plugins = [
|
||||
tableEditing({
|
||||
allowTableNodeSelection: this.options.allowTableNodeSelection,
|
||||
}),
|
||||
tableControls(),
|
||||
];
|
||||
|
||||
if (isResizable) {
|
||||
plugins.unshift(
|
||||
columnResizing({
|
||||
handleWidth: this.options.handleWidth,
|
||||
cellMinWidth: this.options.cellMinWidth,
|
||||
// View: TableView,
|
||||
|
||||
// @ts-ignore
|
||||
lastColumnResizable: this.options.lastColumnResizable,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return plugins;
|
||||
},
|
||||
|
||||
extendNodeSchema(extension) {
|
||||
const context = {
|
||||
name: extension.name,
|
||||
options: extension.options,
|
||||
storage: extension.storage,
|
||||
};
|
||||
|
||||
return {
|
||||
tableRole: callOrReturn(getExtensionField(extension, "tableRole", context)),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Fragment, Node as ProsemirrorNode, NodeType } from "prosemirror-model";
|
||||
|
||||
export function createCell(
|
||||
cellType: NodeType,
|
||||
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
|
||||
): ProsemirrorNode | null | undefined {
|
||||
if (cellContent) {
|
||||
return cellType.createChecked(null, cellContent);
|
||||
}
|
||||
|
||||
return cellType.createAndFill();
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { Fragment, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model";
|
||||
|
||||
import { createCell } from "src/core/extensions/table/table/utilities/create-cell";
|
||||
import { getTableNodeTypes } from "src/core/extensions/table/table/utilities/get-table-node-types";
|
||||
|
||||
export function createTable(
|
||||
schema: Schema,
|
||||
rowsCount: number,
|
||||
colsCount: number,
|
||||
withHeaderRow: boolean,
|
||||
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
|
||||
): ProsemirrorNode {
|
||||
const types = getTableNodeTypes(schema);
|
||||
const headerCells: ProsemirrorNode[] = [];
|
||||
const cells: ProsemirrorNode[] = [];
|
||||
|
||||
for (let index = 0; index < colsCount; index += 1) {
|
||||
const cell = createCell(types.cell, cellContent);
|
||||
|
||||
if (cell) {
|
||||
cells.push(cell);
|
||||
}
|
||||
|
||||
if (withHeaderRow) {
|
||||
const headerCell = createCell(types.header_cell, cellContent);
|
||||
|
||||
if (headerCell) {
|
||||
headerCells.push(headerCell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rows: ProsemirrorNode[] = [];
|
||||
|
||||
for (let index = 0; index < rowsCount; index += 1) {
|
||||
rows.push(types.row.createChecked(null, withHeaderRow && index === 0 ? headerCells : cells));
|
||||
}
|
||||
|
||||
return types.table.createChecked(null, rows);
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { findParentNodeClosestToPos, KeyboardShortcutCommand } from "@tiptap/core";
|
||||
|
||||
import { isCellSelection } from "src/core/extensions/table/table/utilities/is-cell-selection";
|
||||
|
||||
export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ editor }) => {
|
||||
const { selection } = editor.state;
|
||||
|
||||
if (!isCellSelection(selection)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let cellCount = 0;
|
||||
const table = findParentNodeClosestToPos(selection.ranges[0].$from, (node) => node.type.name === "table");
|
||||
|
||||
table?.node.descendants((node) => {
|
||||
if (node.type.name === "table") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (["tableCell", "tableHeader"].includes(node.type.name)) {
|
||||
cellCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
const allCellsSelected = cellCount === selection.ranges.length;
|
||||
|
||||
if (!allCellsSelected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
editor.commands.deleteTable();
|
||||
|
||||
return true;
|
||||
};
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { NodeType, Schema } from "prosemirror-model";
|
||||
|
||||
export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } {
|
||||
if (schema.cached.tableNodeTypes) {
|
||||
return schema.cached.tableNodeTypes;
|
||||
}
|
||||
|
||||
const roles: { [key: string]: NodeType } = {};
|
||||
|
||||
Object.keys(schema.nodes).forEach((type) => {
|
||||
const nodeType = schema.nodes[type];
|
||||
|
||||
if (nodeType.spec.tableRole) {
|
||||
roles[nodeType.spec.tableRole] = nodeType;
|
||||
}
|
||||
});
|
||||
|
||||
schema.cached.tableNodeTypes = roles;
|
||||
|
||||
return roles;
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||
// helpers
|
||||
import { findParentNodeOfType } from "@/helpers/common";
|
||||
|
||||
export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor }) => {
|
||||
// Check if the current selection or the closest node is a table
|
||||
if (!editor.isActive("table")) return false;
|
||||
|
||||
try {
|
||||
// Get the current selection
|
||||
const { selection } = editor.state;
|
||||
|
||||
// Find the table node and its position
|
||||
const tableNode = findParentNodeOfType(selection, "table");
|
||||
if (!tableNode) return false;
|
||||
|
||||
const tablePos = tableNode.pos;
|
||||
|
||||
// Determine if the selection is in the first row of the table
|
||||
const firstRow = tableNode.node.child(0);
|
||||
const selectionPath = (selection.$anchor as any).path;
|
||||
const selectionInFirstRow = selectionPath.includes(firstRow);
|
||||
|
||||
if (!selectionInFirstRow) return false;
|
||||
|
||||
// Check if the table is at the very start of the document or its parent node
|
||||
if (tablePos === 0) {
|
||||
// The table is at the start, so just insert a paragraph at the current position
|
||||
editor.chain().insertContentAt(tablePos, { type: "paragraph" }).run();
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(tablePos + 1)
|
||||
.run();
|
||||
} else {
|
||||
// The table is not at the start, check for the node immediately before the table
|
||||
const prevNodePos = tablePos - 1;
|
||||
|
||||
if (prevNodePos <= 0) return false;
|
||||
|
||||
const prevNode = editor.state.doc.nodeAt(prevNodePos - 1);
|
||||
|
||||
if (prevNode && prevNode.type.name === "paragraph") {
|
||||
// If there's a paragraph before the table, move the cursor to the end of that paragraph
|
||||
const endOfParagraphPos = tablePos - prevNode.nodeSize;
|
||||
editor.chain().setTextSelection(endOfParagraphPos).run();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("failed to insert line above table", e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||
// helpers
|
||||
import { findParentNodeOfType } from "@/helpers/common";
|
||||
|
||||
export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor }) => {
|
||||
// Check if the current selection or the closest node is a table
|
||||
if (!editor.isActive("table")) return false;
|
||||
|
||||
try {
|
||||
// Get the current selection
|
||||
const { selection } = editor.state;
|
||||
|
||||
// Find the table node and its position
|
||||
const tableNode = findParentNodeOfType(selection, "table");
|
||||
if (!tableNode) return false;
|
||||
|
||||
const tablePos = tableNode.pos;
|
||||
const table = tableNode.node;
|
||||
|
||||
// Determine if the selection is in the last row of the table
|
||||
const rowCount = table.childCount;
|
||||
const lastRow = table.child(rowCount - 1);
|
||||
const selectionPath = (selection.$anchor as any).path;
|
||||
const selectionInLastRow = selectionPath.includes(lastRow);
|
||||
|
||||
if (!selectionInLastRow) return false;
|
||||
|
||||
// Calculate the position immediately after the table
|
||||
const nextNodePos = tablePos + table.nodeSize;
|
||||
|
||||
// Check for an existing node immediately after the table
|
||||
const nextNode = editor.state.doc.nodeAt(nextNodePos);
|
||||
|
||||
if (nextNode && nextNode.type.name === "paragraph") {
|
||||
// If the next node is an paragraph, move the cursor there
|
||||
const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1;
|
||||
editor.chain().setTextSelection(endOfParagraphPos).run();
|
||||
} else if (!nextNode) {
|
||||
// If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there
|
||||
editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run();
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(nextNodePos + 1)
|
||||
.run();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("failed to insert line above table", e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { CellSelection } from "@tiptap/pm/tables";
|
||||
|
||||
export function isCellSelection(value: unknown): value is CellSelection {
|
||||
return value instanceof CellSelection;
|
||||
}
|
||||
109
packages/editor/src/core/extensions/typography/index.ts
Normal file
109
packages/editor/src/core/extensions/typography/index.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { Extension } from "@tiptap/core";
|
||||
import {
|
||||
TypographyOptions,
|
||||
emDash,
|
||||
ellipsis,
|
||||
leftArrow,
|
||||
rightArrow,
|
||||
copyright,
|
||||
trademark,
|
||||
servicemark,
|
||||
registeredTrademark,
|
||||
oneHalf,
|
||||
plusMinus,
|
||||
notEqual,
|
||||
laquo,
|
||||
raquo,
|
||||
multiplication,
|
||||
superscriptTwo,
|
||||
superscriptThree,
|
||||
oneQuarter,
|
||||
threeQuarters,
|
||||
impliesArrowRight,
|
||||
} from "src/core/extensions/typography/inputRules";
|
||||
|
||||
export const CustomTypographyExtension = Extension.create<TypographyOptions>({
|
||||
name: "typography",
|
||||
|
||||
addInputRules() {
|
||||
const rules = [];
|
||||
|
||||
if (this.options.emDash !== false) {
|
||||
rules.push(emDash(this.options.emDash));
|
||||
}
|
||||
|
||||
if (this.options.impliesArrowRight !== false) {
|
||||
rules.push(impliesArrowRight(this.options.impliesArrowRight));
|
||||
}
|
||||
|
||||
if (this.options.ellipsis !== false) {
|
||||
rules.push(ellipsis(this.options.ellipsis));
|
||||
}
|
||||
|
||||
if (this.options.leftArrow !== false) {
|
||||
rules.push(leftArrow(this.options.leftArrow));
|
||||
}
|
||||
|
||||
if (this.options.rightArrow !== false) {
|
||||
rules.push(rightArrow(this.options.rightArrow));
|
||||
}
|
||||
|
||||
if (this.options.copyright !== false) {
|
||||
rules.push(copyright(this.options.copyright));
|
||||
}
|
||||
|
||||
if (this.options.trademark !== false) {
|
||||
rules.push(trademark(this.options.trademark));
|
||||
}
|
||||
|
||||
if (this.options.servicemark !== false) {
|
||||
rules.push(servicemark(this.options.servicemark));
|
||||
}
|
||||
|
||||
if (this.options.registeredTrademark !== false) {
|
||||
rules.push(registeredTrademark(this.options.registeredTrademark));
|
||||
}
|
||||
|
||||
if (this.options.oneHalf !== false) {
|
||||
rules.push(oneHalf(this.options.oneHalf));
|
||||
}
|
||||
|
||||
if (this.options.plusMinus !== false) {
|
||||
rules.push(plusMinus(this.options.plusMinus));
|
||||
}
|
||||
|
||||
if (this.options.notEqual !== false) {
|
||||
rules.push(notEqual(this.options.notEqual));
|
||||
}
|
||||
|
||||
if (this.options.laquo !== false) {
|
||||
rules.push(laquo(this.options.laquo));
|
||||
}
|
||||
|
||||
if (this.options.raquo !== false) {
|
||||
rules.push(raquo(this.options.raquo));
|
||||
}
|
||||
|
||||
if (this.options.multiplication !== false) {
|
||||
rules.push(multiplication(this.options.multiplication));
|
||||
}
|
||||
|
||||
if (this.options.superscriptTwo !== false) {
|
||||
rules.push(superscriptTwo(this.options.superscriptTwo));
|
||||
}
|
||||
|
||||
if (this.options.superscriptThree !== false) {
|
||||
rules.push(superscriptThree(this.options.superscriptThree));
|
||||
}
|
||||
|
||||
if (this.options.oneQuarter !== false) {
|
||||
rules.push(oneQuarter(this.options.oneQuarter));
|
||||
}
|
||||
|
||||
if (this.options.threeQuarters !== false) {
|
||||
rules.push(threeQuarters(this.options.threeQuarters));
|
||||
}
|
||||
|
||||
return rules;
|
||||
},
|
||||
});
|
||||
137
packages/editor/src/core/extensions/typography/inputRules.ts
Normal file
137
packages/editor/src/core/extensions/typography/inputRules.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { textInputRule } from "@tiptap/core";
|
||||
|
||||
export interface TypographyOptions {
|
||||
emDash: false | string;
|
||||
ellipsis: false | string;
|
||||
leftArrow: false | string;
|
||||
rightArrow: false | string;
|
||||
copyright: false | string;
|
||||
trademark: false | string;
|
||||
servicemark: false | string;
|
||||
registeredTrademark: false | string;
|
||||
oneHalf: false | string;
|
||||
plusMinus: false | string;
|
||||
notEqual: false | string;
|
||||
laquo: false | string;
|
||||
raquo: false | string;
|
||||
multiplication: false | string;
|
||||
superscriptTwo: false | string;
|
||||
superscriptThree: false | string;
|
||||
oneQuarter: false | string;
|
||||
threeQuarters: false | string;
|
||||
impliesArrowRight: false | string;
|
||||
}
|
||||
|
||||
export const emDash = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /--$/,
|
||||
replace: override ?? "—",
|
||||
});
|
||||
|
||||
export const impliesArrowRight = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /=>$/,
|
||||
replace: override ?? "⇒",
|
||||
});
|
||||
|
||||
export const leftArrow = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /<-$/,
|
||||
replace: override ?? "←",
|
||||
});
|
||||
|
||||
export const rightArrow = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /->$/,
|
||||
replace: override ?? "→",
|
||||
});
|
||||
|
||||
export const ellipsis = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /\.\.\.$/,
|
||||
replace: override ?? "…",
|
||||
});
|
||||
|
||||
export const copyright = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /\(c\)$/,
|
||||
replace: override ?? "©",
|
||||
});
|
||||
|
||||
export const trademark = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /\(tm\)$/,
|
||||
replace: override ?? "™",
|
||||
});
|
||||
|
||||
export const servicemark = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /\(sm\)$/,
|
||||
replace: override ?? "℠",
|
||||
});
|
||||
|
||||
export const registeredTrademark = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /\(r\)$/,
|
||||
replace: override ?? "®",
|
||||
});
|
||||
|
||||
export const oneHalf = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /(?:^|\s)(1\/2)\s$/,
|
||||
replace: override ?? "½",
|
||||
});
|
||||
|
||||
export const plusMinus = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /\+\/-$/,
|
||||
replace: override ?? "±",
|
||||
});
|
||||
|
||||
export const notEqual = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /!=$/,
|
||||
replace: override ?? "≠",
|
||||
});
|
||||
|
||||
export const laquo = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /<<$/,
|
||||
replace: override ?? "«",
|
||||
});
|
||||
|
||||
export const raquo = (override?: string) =>
|
||||
textInputRule({
|
||||
find: />>$/,
|
||||
replace: override ?? "»",
|
||||
});
|
||||
|
||||
export const multiplication = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /\d+\s?([*x])\s?\d+$/,
|
||||
replace: override ?? "×",
|
||||
});
|
||||
|
||||
export const superscriptTwo = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /\^2$/,
|
||||
replace: override ?? "²",
|
||||
});
|
||||
|
||||
export const superscriptThree = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /\^3$/,
|
||||
replace: override ?? "³",
|
||||
});
|
||||
|
||||
export const oneQuarter = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /(?:^|\s)(1\/4)\s$/,
|
||||
replace: override ?? "¼",
|
||||
});
|
||||
|
||||
export const threeQuarters = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /(?:^|\s)(3\/4)\s$/,
|
||||
replace: override ?? "¾",
|
||||
});
|
||||
80
packages/editor/src/core/helpers/common.ts
Normal file
80
packages/editor/src/core/helpers/common.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { Extensions, generateJSON, getSchema } from "@tiptap/core";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { CoreEditorExtensionsWithoutProps } from "src/core/extensions/core-without-props";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface EditorClassNames {
|
||||
noBorder?: boolean;
|
||||
borderOnFocus?: boolean;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
export const getEditorClassNames = ({ noBorder, borderOnFocus, containerClassName }: EditorClassNames) =>
|
||||
cn(
|
||||
"w-full max-w-full sm:rounded-lg focus:outline-none focus:border-0",
|
||||
{
|
||||
"border border-custom-border-200": !noBorder,
|
||||
"focus:border border-custom-border-300": borderOnFocus,
|
||||
},
|
||||
containerClassName
|
||||
);
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// Helper function to find the parent node of a specific type
|
||||
export function findParentNodeOfType(selection: Selection, typeName: string) {
|
||||
let depth = selection.$anchor.depth;
|
||||
while (depth > 0) {
|
||||
const node = selection.$anchor.node(depth);
|
||||
if (node.type.name === typeName) {
|
||||
return { node, pos: selection.$anchor.start(depth) - 1 };
|
||||
}
|
||||
depth--;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const findTableAncestor = (node: Node | null): HTMLTableElement | null => {
|
||||
while (node !== null && node.nodeName !== "TABLE") {
|
||||
node = node.parentNode;
|
||||
}
|
||||
return node as HTMLTableElement;
|
||||
};
|
||||
|
||||
export const getTrimmedHTML = (html: string) => {
|
||||
html = html.replace(/^(<p><\/p>)+/, "");
|
||||
html = html.replace(/(<p><\/p>)+$/, "");
|
||||
return html;
|
||||
};
|
||||
|
||||
export const isValidHttpUrl = (string: string): boolean => {
|
||||
let url: URL;
|
||||
|
||||
try {
|
||||
url = new URL(string);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
};
|
||||
|
||||
/**
|
||||
* @description return an object with contentJSON and editorSchema
|
||||
* @description contentJSON- ProseMirror JSON from HTML content
|
||||
* @description editorSchema- editor schema from extensions
|
||||
* @param {string} html
|
||||
* @returns {object} {contentJSON, editorSchema}
|
||||
*/
|
||||
export const generateJSONfromHTML = (html: string) => {
|
||||
const extensions = CoreEditorExtensionsWithoutProps();
|
||||
const contentJSON = generateJSON(html ?? "<p></p>", extensions as Extensions);
|
||||
const editorSchema = getSchema(extensions as Extensions);
|
||||
return {
|
||||
contentJSON,
|
||||
editorSchema,
|
||||
};
|
||||
};
|
||||
155
packages/editor/src/core/helpers/editor-commands.ts
Normal file
155
packages/editor/src/core/helpers/editor-commands.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { Editor, Range } from "@tiptap/core";
|
||||
import { startImageUpload } from "src/core/plugins/image/image-upload-handler";
|
||||
import { findTableAncestor } from "@/helpers/common";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text";
|
||||
// types
|
||||
import { UploadImage } from "@/types";
|
||||
|
||||
export const setText = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().run();
|
||||
else editor.chain().focus().clearNodes().run();
|
||||
};
|
||||
|
||||
export const toggleHeadingOne = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
|
||||
else editor.chain().focus().toggleHeading({ level: 1 }).run();
|
||||
};
|
||||
|
||||
export const toggleHeadingTwo = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
|
||||
else editor.chain().focus().toggleHeading({ level: 2 }).run();
|
||||
};
|
||||
|
||||
export const toggleHeadingThree = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
|
||||
else editor.chain().focus().toggleHeading({ level: 3 }).run();
|
||||
};
|
||||
|
||||
export const toggleHeadingFour = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 4 }).run();
|
||||
else editor.chain().focus().toggleHeading({ level: 4 }).run();
|
||||
};
|
||||
|
||||
export const toggleHeadingFive = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 5 }).run();
|
||||
else editor.chain().focus().toggleHeading({ level: 5 }).run();
|
||||
};
|
||||
|
||||
export const toggleHeadingSix = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 6 }).run();
|
||||
else editor.chain().focus().toggleHeading({ level: 6 }).run();
|
||||
};
|
||||
|
||||
export const toggleBold = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleBold().run();
|
||||
else editor.chain().focus().toggleBold().run();
|
||||
};
|
||||
|
||||
export const toggleItalic = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleItalic().run();
|
||||
else editor.chain().focus().toggleItalic().run();
|
||||
};
|
||||
|
||||
export const toggleUnderline = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleUnderline().run();
|
||||
else editor.chain().focus().toggleUnderline().run();
|
||||
};
|
||||
|
||||
export const toggleCodeBlock = (editor: Editor, range?: Range) => {
|
||||
try {
|
||||
// if it's a code block, replace it with the code with paragraphs
|
||||
if (editor.isActive("codeBlock")) {
|
||||
replaceCodeWithText(editor);
|
||||
return;
|
||||
}
|
||||
|
||||
const { from, to } = range || editor.state.selection;
|
||||
const text = editor.state.doc.textBetween(from, to, "\n");
|
||||
const isMultiline = text.includes("\n");
|
||||
|
||||
// if the selection is not a range i.e. empty, then simply convert it into a code block
|
||||
if (editor.state.selection.empty) {
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
} else if (isMultiline) {
|
||||
// if the selection is multiline, then also replace the text content with
|
||||
// a code block
|
||||
editor.chain().focus().deleteRange({ from, to }).insertContentAt(from, `\`\`\`\n${text}\n\`\`\``).run();
|
||||
} else {
|
||||
// if the selection is single line, then simply convert it into inline
|
||||
// code
|
||||
editor.chain().focus().toggleCode().run();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("An error occurred while toggling code block:", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleOrderedList = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
||||
else editor.chain().focus().toggleOrderedList().run();
|
||||
};
|
||||
|
||||
export const toggleBulletList = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
||||
else editor.chain().focus().toggleBulletList().run();
|
||||
};
|
||||
|
||||
export const toggleTaskList = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
||||
else editor.chain().focus().toggleTaskList().run();
|
||||
};
|
||||
|
||||
export const toggleStrike = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleStrike().run();
|
||||
else editor.chain().focus().toggleStrike().run();
|
||||
};
|
||||
|
||||
export const toggleBlockquote = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleBlockquote().run();
|
||||
else editor.chain().focus().toggleBlockquote().run();
|
||||
};
|
||||
|
||||
export const insertTableCommand = (editor: Editor, range?: Range) => {
|
||||
if (typeof window !== "undefined") {
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
if (selection.rangeCount !== 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
if (findTableAncestor(range.startContainer)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().insertTable({ rows: 3, cols: 3 }).run();
|
||||
else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3 }).run();
|
||||
};
|
||||
|
||||
export const unsetLinkEditor = (editor: Editor) => {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
};
|
||||
|
||||
export const setLinkEditor = (editor: Editor, url: string) => {
|
||||
editor.chain().focus().setLink({ href: url }).run();
|
||||
};
|
||||
|
||||
export const insertImageCommand = (
|
||||
editor: Editor,
|
||||
uploadFile: UploadImage,
|
||||
savedSelection?: Selection | null,
|
||||
range?: Range
|
||||
) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).run();
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".jpeg, .jpg, .png, .webp, .svg";
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
const file = input.files[0];
|
||||
const pos = savedSelection?.anchor ?? editor.view.state.selection.from;
|
||||
startImageUpload(editor, file, editor.view, pos, uploadFile);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { Selection } from "@tiptap/pm/state";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { MutableRefObject } from "react";
|
||||
|
||||
export const insertContentAtSavedSelection = (
|
||||
editorRef: MutableRefObject<Editor | null>,
|
||||
content: string,
|
||||
savedSelection: Selection
|
||||
) => {
|
||||
if (!editorRef.current || editorRef.current.isDestroyed) {
|
||||
console.error("Editor reference is not available or has been destroyed.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!savedSelection) {
|
||||
console.error("Saved selection is invalid.");
|
||||
return;
|
||||
}
|
||||
|
||||
const docSize = editorRef.current.state.doc.content.size;
|
||||
const safePosition = Math.max(0, Math.min(savedSelection.anchor, docSize));
|
||||
|
||||
try {
|
||||
editorRef.current.chain().focus().insertContentAt(safePosition, content).run();
|
||||
} catch (error) {
|
||||
console.error("An error occurred while inserting content at saved selection:", error);
|
||||
}
|
||||
};
|
||||
40
packages/editor/src/core/helpers/scroll-to-node.ts
Normal file
40
packages/editor/src/core/helpers/scroll-to-node.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Editor } from "@tiptap/react";
|
||||
|
||||
export interface IMarking {
|
||||
type: "heading";
|
||||
level: number;
|
||||
text: string;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
function findNthH1(editor: Editor, n: number, level: number): number {
|
||||
let count = 0;
|
||||
let pos = 0;
|
||||
editor.state.doc.descendants((node, position) => {
|
||||
if (node.type.name === "heading" && node.attrs.level === level) {
|
||||
count++;
|
||||
if (count === n) {
|
||||
pos = position;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
return pos;
|
||||
}
|
||||
|
||||
function scrollToNode(editor: Editor, pos: number): void {
|
||||
const headingNode = editor.state.doc.nodeAt(pos);
|
||||
if (headingNode) {
|
||||
const headingDOM = editor.view.nodeDOM(pos);
|
||||
if (headingDOM instanceof HTMLElement) {
|
||||
headingDOM.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function scrollSummary(editor: Editor, marking: IMarking) {
|
||||
if (editor) {
|
||||
const pos = findNthH1(editor, marking.sequence, marking.level);
|
||||
scrollToNode(editor, pos);
|
||||
}
|
||||
}
|
||||
76
packages/editor/src/core/helpers/yjs.ts
Normal file
76
packages/editor/src/core/helpers/yjs.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { Schema } from "@tiptap/pm/model";
|
||||
import { prosemirrorJSONToYDoc } from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
|
||||
const defaultSchema: Schema = new Schema({
|
||||
nodes: {
|
||||
text: {},
|
||||
doc: { content: "text*" },
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* @description converts ProseMirror JSON to Yjs document
|
||||
* @param document prosemirror JSON
|
||||
* @param fieldName
|
||||
* @param schema
|
||||
* @returns {Y.Doc} Yjs document
|
||||
*/
|
||||
export const proseMirrorJSONToBinaryString = (
|
||||
document: any,
|
||||
fieldName: string | Array<string> = "default",
|
||||
schema?: Schema
|
||||
): string => {
|
||||
if (!document) {
|
||||
throw new Error(
|
||||
`You've passed an empty or invalid document to the Transformer. Make sure to pass ProseMirror-compatible JSON. Actually passed JSON: ${document}`
|
||||
);
|
||||
}
|
||||
|
||||
// allow a single field name
|
||||
if (typeof fieldName === "string") {
|
||||
const yDoc = prosemirrorJSONToYDoc(schema ?? defaultSchema, document, fieldName);
|
||||
const docAsUint8Array = Y.encodeStateAsUpdate(yDoc);
|
||||
const base64Doc = Buffer.from(docAsUint8Array).toString("base64");
|
||||
return base64Doc;
|
||||
}
|
||||
|
||||
const yDoc = new Y.Doc();
|
||||
|
||||
fieldName.forEach((field) => {
|
||||
const update = Y.encodeStateAsUpdate(prosemirrorJSONToYDoc(schema ?? defaultSchema, document, field));
|
||||
|
||||
Y.applyUpdate(yDoc, update);
|
||||
});
|
||||
|
||||
const docAsUint8Array = Y.encodeStateAsUpdate(yDoc);
|
||||
const base64Doc = Buffer.from(docAsUint8Array).toString("base64");
|
||||
|
||||
return base64Doc;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description apply updates to a doc and return the updated doc in base64(binary) format
|
||||
* @param {Uint8Array} document
|
||||
* @param {Uint8Array} updates
|
||||
* @returns {string} base64(binary) form of the updated doc
|
||||
*/
|
||||
export const applyUpdates = (document: Uint8Array, updates: Uint8Array): string => {
|
||||
const yDoc = new Y.Doc();
|
||||
Y.applyUpdate(yDoc, document);
|
||||
Y.applyUpdate(yDoc, updates);
|
||||
|
||||
const encodedDoc = Y.encodeStateAsUpdate(yDoc);
|
||||
const base64Updates = Buffer.from(encodedDoc).toString("base64");
|
||||
return base64Updates;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description merge multiple updates into one single update
|
||||
* @param {Uint8Array[]} updates
|
||||
* @returns {Uint8Array} merged updates
|
||||
*/
|
||||
export const mergeUpdates = (updates: Uint8Array[]): Uint8Array => {
|
||||
const mergedUpdates = Y.mergeUpdates(updates);
|
||||
return mergedUpdates;
|
||||
};
|
||||
103
packages/editor/src/core/hooks/use-document-editor.ts
Normal file
103
packages/editor/src/core/hooks/use-document-editor.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { useEffect, useLayoutEffect, useMemo } from "react";
|
||||
import Collaboration from "@tiptap/extension-collaboration";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
// extensions
|
||||
import { DragAndDrop, IssueWidget, SlashCommand } from "@/extensions";
|
||||
// hooks
|
||||
import { TFileHandler, useEditor } from "@/hooks/use-editor";
|
||||
// plane editor provider
|
||||
import { CollaborationProvider } from "@/plane-editor/providers";
|
||||
// plane editor types
|
||||
import { TEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import { EditorRefApi, IMentionHighlight, IMentionSuggestion } from "@/types";
|
||||
|
||||
type DocumentEditorProps = {
|
||||
editorClassName: string;
|
||||
editorProps?: EditorProps;
|
||||
embedHandler?: TEmbedConfig;
|
||||
fileHandler: TFileHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id: string;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
onChange: (updates: Uint8Array) => void;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
setHideDragHandleFunction: (hideDragHandlerFromDragDrop: () => void) => void;
|
||||
tabIndex?: number;
|
||||
value: Uint8Array;
|
||||
};
|
||||
|
||||
export const useDocumentEditor = (props: DocumentEditorProps) => {
|
||||
const {
|
||||
editorClassName,
|
||||
editorProps = {},
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
placeholder,
|
||||
setHideDragHandleFunction,
|
||||
tabIndex,
|
||||
value,
|
||||
} = props;
|
||||
|
||||
const provider = useMemo(
|
||||
() =>
|
||||
new CollaborationProvider({
|
||||
name: id,
|
||||
onChange,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[id]
|
||||
);
|
||||
|
||||
// update document on value change
|
||||
useEffect(() => {
|
||||
if (value.byteLength > 0) Y.applyUpdate(provider.document, value);
|
||||
}, [value, provider.document]);
|
||||
|
||||
// indexedDB provider
|
||||
useLayoutEffect(() => {
|
||||
const localProvider = new IndexeddbPersistence(id, provider.document);
|
||||
localProvider.on("synced", () => {
|
||||
provider.setHasIndexedDBSynced(true);
|
||||
});
|
||||
return () => {
|
||||
localProvider?.destroy();
|
||||
};
|
||||
}, [provider, id]);
|
||||
|
||||
const editor = useEditor({
|
||||
id,
|
||||
editorProps,
|
||||
editorClassName,
|
||||
fileHandler,
|
||||
handleEditorReady,
|
||||
forwardedRef,
|
||||
mentionHandler,
|
||||
extensions: [
|
||||
SlashCommand(fileHandler.upload),
|
||||
DragAndDrop(setHideDragHandleFunction),
|
||||
embedHandler?.issue &&
|
||||
IssueWidget({
|
||||
widgetCallback: embedHandler.issue.widgetCallback,
|
||||
}),
|
||||
Collaboration.configure({
|
||||
document: provider.document,
|
||||
}),
|
||||
],
|
||||
placeholder,
|
||||
tabIndex,
|
||||
});
|
||||
|
||||
return editor;
|
||||
};
|
||||
39
packages/editor/src/core/hooks/use-editor-markings.tsx
Normal file
39
packages/editor/src/core/hooks/use-editor-markings.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { useCallback, useState } from "react";
|
||||
|
||||
export interface IMarking {
|
||||
type: "heading";
|
||||
level: number;
|
||||
text: string;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
export const useEditorMarkings = () => {
|
||||
const [markings, setMarkings] = useState<IMarking[]>([]);
|
||||
|
||||
const updateMarkings = useCallback((html: string) => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
const headings = doc.querySelectorAll("h1, h2, h3");
|
||||
const tempMarkings: IMarking[] = [];
|
||||
let h1Sequence: number = 0;
|
||||
let h2Sequence: number = 0;
|
||||
let h3Sequence: number = 0;
|
||||
|
||||
headings.forEach((heading) => {
|
||||
const level = parseInt(heading.tagName[1]); // Extract the number from h1, h2, h3
|
||||
tempMarkings.push({
|
||||
type: "heading",
|
||||
level: level,
|
||||
text: heading.textContent || "",
|
||||
sequence: level === 1 ? ++h1Sequence : level === 2 ? ++h2Sequence : ++h3Sequence,
|
||||
});
|
||||
});
|
||||
|
||||
setMarkings(tempMarkings);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
updateMarkings,
|
||||
markings,
|
||||
};
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue