[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:
Aaryan Khandelwal 2024-06-21 17:37:11 +05:30 committed by GitHub
parent 367ccba17e
commit dcdd1ef065
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
256 changed files with 1027 additions and 2401 deletions

View 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);
}
}

View file

@ -0,0 +1 @@
export * from "./collaboration-provider";

View file

@ -0,0 +1 @@
export * from "./issue-embed";

View 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;
};

View file

@ -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 };

View file

@ -0,0 +1,3 @@
export * from "./editor";
export * from "./page-renderer";
export * from "./read-only-editor";

View file

@ -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>
)}
</>
);
};

View file

@ -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 };

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View 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";

View file

@ -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 };

View file

@ -0,0 +1,2 @@
export * from "./editor";
export * from "./read-only-editor";

View file

@ -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 };

View file

@ -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>
);
};

View file

@ -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 };

View file

@ -0,0 +1,2 @@
export * from "./editor";
export * from "./read-only-editor";

View file

@ -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 };

View file

@ -0,0 +1,4 @@
export * from "./link-edit-view";
export * from "./link-input-view";
export * from "./link-preview";
export * from "./link-view";

View 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>
);
};

View file

@ -0,0 +1,7 @@
// components
import { LinkViewProps } from "@/components/links";
export const LinkInputView = ({}: {
viewProps: LinkViewProps;
switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void;
}) => <p>LinkInputView</p>;

View 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>
);
};

View 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();
};

View 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>
);
};

View file

@ -0,0 +1,3 @@
export * from "./link-selector";
export * from "./node-selector";
export * from "./root";

View file

@ -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>
);
};

View file

@ -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>
);
};

View 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>
);
};

View file

@ -0,0 +1,3 @@
export * from "./bubble-menu";
export * from "./block-menu";
export * from "./menu-items";

View 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;

View 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,
}),
];
},
});

View file

@ -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,
}),
];
},
});

View file

@ -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>
);
};

View 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;
}
},
},
}),
];
},
});

View 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,
});

View 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;
}

View file

@ -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;
}

View 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,
}),
];

View file

@ -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 });
},
});

View 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;
},
});

View file

@ -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 lets 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;
},
});
}

View file

@ -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;
},
},
});
}

View file

@ -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;
},
},
});
}

View file

@ -0,0 +1 @@
export * from "./extension";

View file

@ -0,0 +1 @@
export * from "./list-keymap";

View file

@ -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;
};

View file

@ -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;
},
};
},
});

View 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");
},
},
},
});
}

View 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;
},
},
}),
];
},
});

View 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(),
]),
};
},
});

View 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,
}),
];

View 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 its 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,
}),
];
},
});

View 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,
},
};
},
});

View file

@ -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,
},
};
},
});

View 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;
}}
/>
</>
);
};

View file

@ -0,0 +1,4 @@
export * from "./extension";
export * from "./image-extension-without-props";
export * from "./image-resize";
export * from "./read-only-image";

View file

@ -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,
},
};
},
});

View file

@ -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;
}
};

View file

@ -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;
}
};

View 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";

View file

@ -0,0 +1 @@
export * from "./widget-node";

View file

@ -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)];
},
});

View 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;
},
};
},
});

View 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();
},
};
},
},
});

View file

@ -0,0 +1,4 @@
export * from "./extension";
export * from "./mention-node-view";
export * from "./mentions-list";
export * from "./mentions-without-props";

View file

@ -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>
);
};

View 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";

View file

@ -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",
},
});

View 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;
}
},
};
},
});

View 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,
}),
];

View 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,
},
});

View file

@ -0,0 +1,4 @@
export * from "./table";
export * from "./table-cell";
export * from "./table-header";
export * from "./table-row";

View file

@ -0,0 +1 @@
export { TableCell } from "./table-cell";

View file

@ -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,
];
},
});

View file

@ -0,0 +1 @@
export { TableHeader } from "./table-header";

View file

@ -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,
];
},
});

View file

@ -0,0 +1 @@
export { TableRow } from "./table-row";

View file

@ -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];
},
});

View 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>
`,
};

View file

@ -0,0 +1 @@
export { Table } from "./table";

View file

@ -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;
}
}

View 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));
}
}

View 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)),
};
},
});

View file

@ -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();
}

View file

@ -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);
}

View file

@ -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;
};

View file

@ -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;
}

View file

@ -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;
}
};

View file

@ -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;
}
};

View file

@ -0,0 +1,5 @@
import { CellSelection } from "@tiptap/pm/tables";
export function isCellSelection(value: unknown): value is CellSelection {
return value instanceof CellSelection;
}

View 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;
},
});

View 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 ?? "¾",
});

View 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,
};
};

View 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();
};

View file

@ -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);
}
};

View 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);
}
}

View 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;
};

View 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;
};

View 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