mirror of
https://github.com/makeplane/plane.git
synced 2026-09-03 12:50:29 +02:00
Merge branch 'preview' into chore/upgrade-editor
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import { SlashCommand } from "@/extensions";
|
||||
import { SlashCommands } from "@/extensions";
|
||||
// plane editor types
|
||||
import { TIssueEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
@@ -14,7 +14,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
|
||||
const extensions: Extensions = [SlashCommand()];
|
||||
const extensions: Extensions = [SlashCommands()];
|
||||
|
||||
return extensions;
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
@@ -38,6 +39,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn
|
||||
const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({
|
||||
editorClassName,
|
||||
extensions,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig } from "@/types";
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TFileHandler } from "@/types";
|
||||
|
||||
interface IDocumentReadOnlyEditor {
|
||||
id: string;
|
||||
@@ -19,6 +19,7 @@ interface IDocumentReadOnlyEditor {
|
||||
displayConfig?: TDisplayConfig;
|
||||
editorClassName?: string;
|
||||
embedHandler: any;
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
tabIndex?: number;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
mentionHandler: {
|
||||
@@ -33,6 +34,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
id,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
@@ -51,6 +53,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
const editor = useReadOnlyEditor({
|
||||
editorClassName,
|
||||
extensions,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
initialValue,
|
||||
|
||||
@@ -14,14 +14,16 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
|
||||
containerClassName,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editorClassName = "",
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
id,
|
||||
initialValue,
|
||||
forwardedRef,
|
||||
mentionHandler,
|
||||
} = props;
|
||||
|
||||
const editor = useReadOnlyEditor({
|
||||
editorClassName,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { forwardRef, useCallback } from "react";
|
||||
import { EditorWrapper } from "@/components/editors";
|
||||
import { EditorBubbleMenu } from "@/components/menus";
|
||||
// extensions
|
||||
import { SideMenuExtension, SlashCommand } from "@/extensions";
|
||||
import { SideMenuExtension, SlashCommands } from "@/extensions";
|
||||
// types
|
||||
import { EditorRefApi, IRichTextEditor } from "@/types";
|
||||
|
||||
@@ -11,7 +11,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
||||
const { dragDropEnabled } = props;
|
||||
|
||||
const getExtensions = useCallback(() => {
|
||||
const extensions = [SlashCommand()];
|
||||
const extensions = [SlashCommands()];
|
||||
|
||||
extensions.push(
|
||||
SideMenuExtension({
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Dispatch, FC, SetStateAction } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { ALargeSmall, Ban } from "lucide-react";
|
||||
// constants
|
||||
import { COLORS_LIST } from "@/constants/common";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
import { BackgroundColorItem, TextColorItem } from "../menu-items";
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const BubbleMenuColorSelector: FC<Props> = (props) => {
|
||||
const { editor, isOpen, setIsOpen } = props;
|
||||
|
||||
const activeTextColor = COLORS_LIST.find((c) => TextColorItem(editor).isActive(c.key));
|
||||
const activeBackgroundColor = COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive(c.key));
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
setIsOpen(!isOpen);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="flex items-center gap-1 h-full whitespace-nowrap px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors"
|
||||
>
|
||||
<span>Color</span>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-shrink-0 size-6 grid place-items-center rounded border-[0.5px] border-custom-border-300",
|
||||
{
|
||||
"bg-custom-background-100": !activeBackgroundColor,
|
||||
}
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: activeBackgroundColor ? activeBackgroundColor.backgroundColor : "transparent",
|
||||
}}
|
||||
>
|
||||
<ALargeSmall
|
||||
className={cn("size-3.5", {
|
||||
"text-custom-text-100": !activeTextColor,
|
||||
})}
|
||||
style={{
|
||||
color: activeTextColor ? activeTextColor.textColor : "inherit",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<section className="fixed top-full z-[99999] mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 p-2 space-y-2 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-custom-text-300 font-semibold">Text colors</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{COLORS_LIST.map((color) => (
|
||||
<button
|
||||
key={color.key}
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
|
||||
style={{
|
||||
backgroundColor: color.textColor,
|
||||
}}
|
||||
onClick={() => TextColorItem(editor).command(color.key)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
|
||||
onClick={() => TextColorItem(editor).command(undefined)}
|
||||
>
|
||||
<Ban className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-custom-text-300 font-semibold">Background colors</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{COLORS_LIST.map((color) => (
|
||||
<button
|
||||
key={color.key}
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
|
||||
style={{
|
||||
backgroundColor: color.backgroundColor,
|
||||
}}
|
||||
onClick={() => BackgroundColorItem(editor).command(color.key)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
|
||||
onClick={() => BackgroundColorItem(editor).command(undefined)}
|
||||
>
|
||||
<Ban className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./color-selector";
|
||||
export * from "./link-selector";
|
||||
export * from "./node-selector";
|
||||
export * from "./root";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Check, Trash } from "lucide-react";
|
||||
import { Check, Link, Trash } from "lucide-react";
|
||||
// helpers
|
||||
import { cn, isValidHttpUrl } from "@/helpers/common";
|
||||
import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands";
|
||||
@@ -11,7 +11,9 @@ type Props = {
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const BubbleMenuLinkSelector: FC<Props> = ({ editor, isOpen, setIsOpen }) => {
|
||||
export const BubbleMenuLinkSelector: FC<Props> = (props) => {
|
||||
const { editor, isOpen, setIsOpen } = props;
|
||||
// refs
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onLinkSubmit = useCallback(() => {
|
||||
@@ -28,26 +30,23 @@ export const BubbleMenuLinkSelector: FC<Props> = ({ editor, isOpen, setIsOpen })
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative h-full">
|
||||
<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 }
|
||||
"h-full flex items-center gap-1 px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors",
|
||||
{
|
||||
"bg-custom-background-80": isOpen,
|
||||
"text-custom-text-100": editor.isActive("link"),
|
||||
}
|
||||
)}
|
||||
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>
|
||||
<span>Link</span>
|
||||
<Link className="flex-shrink-0 size-3" />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
HeadingFourItem,
|
||||
HeadingFiveItem,
|
||||
HeadingSixItem,
|
||||
BubbleMenuItem,
|
||||
EditorMenuItem,
|
||||
} from "@/components/menus";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
@@ -26,8 +26,10 @@ type Props = {
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const BubbleMenuNodeSelector: FC<Props> = ({ editor, isOpen, setIsOpen }) => {
|
||||
const items: BubbleMenuItem[] = [
|
||||
export const BubbleMenuNodeSelector: FC<Props> = (props) => {
|
||||
const { editor, isOpen, setIsOpen } = props;
|
||||
|
||||
const items: EditorMenuItem[] = [
|
||||
TextItem(editor),
|
||||
HeadingOneItem(editor),
|
||||
HeadingTwoItem(editor),
|
||||
@@ -42,7 +44,7 @@ export const BubbleMenuNodeSelector: FC<Props> = ({ editor, isOpen, setIsOpen })
|
||||
CodeItem(editor),
|
||||
];
|
||||
|
||||
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
||||
const activeItem = items.filter((item) => item.isActive("")).pop() ?? {
|
||||
name: "Multiple",
|
||||
};
|
||||
|
||||
@@ -54,12 +56,11 @@ export const BubbleMenuNodeSelector: FC<Props> = ({ editor, isOpen, setIsOpen })
|
||||
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"
|
||||
className="flex items-center gap-1 h-full whitespace-nowrap px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors"
|
||||
>
|
||||
<span>{activeItem?.name}</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<ChevronDown className="flex-shrink-0 size-3" />
|
||||
</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) => (
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { BubbleMenu, BubbleMenuProps, isNodeSelection } from "@tiptap/react";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
// components
|
||||
import {
|
||||
BoldItem,
|
||||
BubbleMenuColorSelector,
|
||||
BubbleMenuLinkSelector,
|
||||
BubbleMenuNodeSelector,
|
||||
CodeItem,
|
||||
EditorMenuItem,
|
||||
ItalicItem,
|
||||
StrikeThroughItem,
|
||||
UnderLineItem,
|
||||
@@ -16,34 +17,23 @@ import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-sele
|
||||
// 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),
|
||||
];
|
||||
// states
|
||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
|
||||
const items: EditorMenuItem[] = props.editor.isActive("code")
|
||||
? [CodeItem(props.editor)]
|
||||
: [BoldItem(props.editor), ItalicItem(props.editor), UnderLineItem(props.editor), StrikeThroughItem(props.editor)];
|
||||
|
||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||
...props,
|
||||
shouldShow: ({ state, editor }) => {
|
||||
const { selection } = state;
|
||||
|
||||
const { empty } = selection;
|
||||
|
||||
if (
|
||||
@@ -63,15 +53,11 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||
onHidden: () => {
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsLinkSelectorOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseDown() {
|
||||
function handleMouseMove() {
|
||||
@@ -102,51 +88,66 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||
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"
|
||||
className="flex py-2 divide-x divide-custom-border-200 rounded-lg border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg"
|
||||
>
|
||||
{isSelecting ? null : (
|
||||
{!isSelecting && (
|
||||
<>
|
||||
{!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">
|
||||
<div className="px-2">
|
||||
{!props.editor.isActive("table") && (
|
||||
<BubbleMenuNodeSelector
|
||||
editor={props.editor!}
|
||||
isOpen={isNodeSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsNodeSelectorOpen((prev) => !prev);
|
||||
setIsLinkSelectorOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2">
|
||||
{!props.editor.isActive("code") && (
|
||||
<BubbleMenuLinkSelector
|
||||
editor={props.editor}
|
||||
isOpen={isLinkSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsLinkSelectorOpen((prev) => !prev);
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2">
|
||||
{!props.editor.isActive("code") && (
|
||||
<BubbleMenuColorSelector
|
||||
editor={props.editor}
|
||||
isOpen={isColorSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsColorSelectorOpen((prev) => !prev);
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsLinkSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-0.5 px-2">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
key={item.key}
|
||||
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",
|
||||
"size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-colors",
|
||||
{
|
||||
"bg-custom-primary-100/5 text-custom-text-100": item.isActive(),
|
||||
"bg-custom-background-80 text-custom-text-100": item.isActive(""),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-4 w-4", {
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
/>
|
||||
<item.icon className="size-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -20,12 +20,14 @@ import {
|
||||
Heading6,
|
||||
CaseSensitive,
|
||||
LucideIcon,
|
||||
Palette,
|
||||
} from "lucide-react";
|
||||
// helpers
|
||||
import {
|
||||
insertImage,
|
||||
insertTableCommand,
|
||||
setText,
|
||||
toggleBackgroundColor,
|
||||
toggleBlockquote,
|
||||
toggleBold,
|
||||
toggleBulletList,
|
||||
@@ -40,18 +42,26 @@ import {
|
||||
toggleOrderedList,
|
||||
toggleStrike,
|
||||
toggleTaskList,
|
||||
toggleTextColor,
|
||||
toggleUnderline,
|
||||
} from "@/helpers/editor-commands";
|
||||
// types
|
||||
import { TEditorCommands } from "@/types";
|
||||
import { TColorEditorCommands, TNonColorEditorCommands } from "@/types";
|
||||
|
||||
export interface EditorMenuItem {
|
||||
key: TEditorCommands;
|
||||
export type EditorMenuItem = {
|
||||
name: string;
|
||||
isActive: () => boolean;
|
||||
command: () => void;
|
||||
command: (...args: any) => void;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
} & (
|
||||
| {
|
||||
key: TNonColorEditorCommands;
|
||||
isActive: () => boolean;
|
||||
}
|
||||
| {
|
||||
key: TColorEditorCommands;
|
||||
isActive: (color: string | undefined) => boolean;
|
||||
}
|
||||
);
|
||||
|
||||
export const TextItem = (editor: Editor): EditorMenuItem => ({
|
||||
key: "text",
|
||||
@@ -198,10 +208,25 @@ export const ImageItem = (editor: Editor) =>
|
||||
icon: ImageIcon,
|
||||
}) as const;
|
||||
|
||||
export function getEditorMenuItems(editor: Editor | null) {
|
||||
if (!editor) {
|
||||
return [];
|
||||
}
|
||||
export const TextColorItem = (editor: Editor): EditorMenuItem => ({
|
||||
key: "text-color",
|
||||
name: "Color",
|
||||
isActive: (color) => editor.isActive("customColor", { color }),
|
||||
command: (color: string) => toggleTextColor(color, editor),
|
||||
icon: Palette,
|
||||
});
|
||||
|
||||
export const BackgroundColorItem = (editor: Editor): EditorMenuItem => ({
|
||||
key: "background-color",
|
||||
name: "Background color",
|
||||
isActive: (color) => editor.isActive("customColor", { backgroundColor: color }),
|
||||
command: (color: string) => toggleBackgroundColor(color, editor),
|
||||
icon: Palette,
|
||||
});
|
||||
|
||||
export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem[] => {
|
||||
if (!editor) return [];
|
||||
|
||||
return [
|
||||
TextItem(editor),
|
||||
HeadingOneItem(editor),
|
||||
@@ -221,5 +246,7 @@ export function getEditorMenuItems(editor: Editor | null) {
|
||||
QuoteItem(editor),
|
||||
TableItem(editor),
|
||||
ImageItem(editor),
|
||||
TextColorItem(editor),
|
||||
BackgroundColorItem(editor),
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
61
packages/editor/src/core/constants/common.ts
Normal file
61
packages/editor/src/core/constants/common.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export const COLORS_LIST: {
|
||||
key: string;
|
||||
label: string;
|
||||
textColor: string;
|
||||
backgroundColor: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "gray",
|
||||
label: "Gray",
|
||||
textColor: "var(--editor-colors-gray-text)",
|
||||
backgroundColor: "var(--editor-colors-gray-background)",
|
||||
},
|
||||
{
|
||||
key: "peach",
|
||||
label: "Peach",
|
||||
textColor: "var(--editor-colors-peach-text)",
|
||||
backgroundColor: "var(--editor-colors-peach-background)",
|
||||
},
|
||||
{
|
||||
key: "pink",
|
||||
label: "Pink",
|
||||
textColor: "var(--editor-colors-pink-text)",
|
||||
backgroundColor: "var(--editor-colors-pink-background)",
|
||||
},
|
||||
{
|
||||
key: "orange",
|
||||
label: "Orange",
|
||||
textColor: "var(--editor-colors-orange-text)",
|
||||
backgroundColor: "var(--editor-colors-orange-background)",
|
||||
},
|
||||
{
|
||||
key: "green",
|
||||
label: "Green",
|
||||
textColor: "var(--editor-colors-green-text)",
|
||||
backgroundColor: "var(--editor-colors-green-background)",
|
||||
},
|
||||
{
|
||||
key: "light-blue",
|
||||
label: "Light blue",
|
||||
textColor: "var(--editor-colors-light-blue-text)",
|
||||
backgroundColor: "var(--editor-colors-light-blue-background)",
|
||||
},
|
||||
{
|
||||
key: "dark-blue",
|
||||
label: "Dark blue",
|
||||
textColor: "var(--editor-colors-dark-blue-text)",
|
||||
backgroundColor: "var(--editor-colors-dark-blue-background)",
|
||||
},
|
||||
{
|
||||
key: "purple",
|
||||
label: "Purple",
|
||||
textColor: "var(--editor-colors-purple-text)",
|
||||
backgroundColor: "var(--editor-colors-purple-background)",
|
||||
},
|
||||
// {
|
||||
// key: "pink-blue-gradient",
|
||||
// label: "Pink blue gradient",
|
||||
// textColor: "var(--editor-colors-pink-blue-gradient-text)",
|
||||
// backgroundColor: "var(--editor-colors-pink-blue-gradient-background)",
|
||||
// },
|
||||
];
|
||||
@@ -16,6 +16,7 @@ import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props
|
||||
import { CustomMentionWithoutProps } from "./mentions/mentions-without-props";
|
||||
import { CustomQuoteExtension } from "./quote";
|
||||
import { TableHeader, TableCell, TableRow, Table } from "./table";
|
||||
import { CustomColorExtension } from "./custom-color";
|
||||
|
||||
export const CoreEditorExtensionsWithoutProps = [
|
||||
StarterKit.configure({
|
||||
@@ -83,6 +84,7 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||
TableCell,
|
||||
TableRow,
|
||||
CustomMentionWithoutProps(),
|
||||
CustomColorExtension,
|
||||
];
|
||||
|
||||
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];
|
||||
|
||||
133
packages/editor/src/core/extensions/custom-color.ts
Normal file
133
packages/editor/src/core/extensions/custom-color.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Mark, mergeAttributes } from "@tiptap/core";
|
||||
// constants
|
||||
import { COLORS_LIST } from "@/constants/common";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
color: {
|
||||
/**
|
||||
* Set the text color
|
||||
* @param {string} color The color to set
|
||||
* @example editor.commands.setTextColor('red')
|
||||
*/
|
||||
setTextColor: (color: string) => ReturnType;
|
||||
|
||||
/**
|
||||
* Unset the text color
|
||||
* @example editor.commands.unsetTextColor()
|
||||
*/
|
||||
unsetTextColor: () => ReturnType;
|
||||
/**
|
||||
* Set the background color
|
||||
* @param {string} backgroundColor The color to set
|
||||
* @example editor.commands.setBackgroundColor('red')
|
||||
*/
|
||||
setBackgroundColor: (backgroundColor: string) => ReturnType;
|
||||
|
||||
/**
|
||||
* Unset the background color
|
||||
* @example editor.commands.unsetBackgroundColorColor()
|
||||
*/
|
||||
unsetBackgroundColor: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomColorExtension = Mark.create({
|
||||
name: "customColor",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
color: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.getAttribute("data-text-color"),
|
||||
renderHTML: (attributes: { color: string }) => {
|
||||
const { color } = attributes;
|
||||
if (!color) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let elementAttributes: Record<string, string> = {
|
||||
"data-text-color": color,
|
||||
};
|
||||
|
||||
if (!COLORS_LIST.find((c) => c.key === color)) {
|
||||
elementAttributes = {
|
||||
...elementAttributes,
|
||||
style: `color: ${color}`,
|
||||
};
|
||||
}
|
||||
|
||||
return elementAttributes;
|
||||
},
|
||||
},
|
||||
backgroundColor: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.getAttribute("data-background-color"),
|
||||
renderHTML: (attributes: { backgroundColor: string }) => {
|
||||
const { backgroundColor } = attributes;
|
||||
if (!backgroundColor) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let elementAttributes: Record<string, string> = {
|
||||
"data-background-color": backgroundColor,
|
||||
};
|
||||
|
||||
if (!COLORS_LIST.find((c) => c.key === backgroundColor)) {
|
||||
elementAttributes = {
|
||||
...elementAttributes,
|
||||
style: `background-color: ${backgroundColor}`,
|
||||
};
|
||||
}
|
||||
|
||||
return elementAttributes;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "span",
|
||||
getAttrs: (node) => node.getAttribute("data-text-color") && null,
|
||||
},
|
||||
{
|
||||
tag: "span",
|
||||
getAttrs: (node) => node.getAttribute("data-background-color") && null,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["span", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setTextColor:
|
||||
(color: string) =>
|
||||
({ chain }) =>
|
||||
chain().setMark(this.name, { color }).run(),
|
||||
unsetTextColor:
|
||||
() =>
|
||||
({ chain }) =>
|
||||
chain().setMark(this.name, { color: null }).run(),
|
||||
setBackgroundColor:
|
||||
(backgroundColor: string) =>
|
||||
({ chain }) =>
|
||||
chain().setMark(this.name, { backgroundColor }).run(),
|
||||
unsetBackgroundColor:
|
||||
() =>
|
||||
({ chain }) =>
|
||||
chain().setMark(this.name, { backgroundColor: null }).run(),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -42,6 +42,7 @@ type CustomImageBlockProps = CustomImageNodeViewProps & {
|
||||
setFailedToLoadImage: (isError: boolean) => void;
|
||||
editorContainer: HTMLDivElement | null;
|
||||
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
|
||||
src: string;
|
||||
};
|
||||
|
||||
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
@@ -55,14 +56,15 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
getPos,
|
||||
editor,
|
||||
editorContainer,
|
||||
src: remoteImageSrc,
|
||||
setEditorContainer,
|
||||
} = props;
|
||||
const { src: remoteImageSrc, width, height, aspectRatio } = node.attrs;
|
||||
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio } = node.attrs;
|
||||
// states
|
||||
const [size, setSize] = useState<Size>({
|
||||
width: ensurePixelString(width, "35%"),
|
||||
height: ensurePixelString(height, "auto"),
|
||||
aspectRatio: aspectRatio || 1,
|
||||
width: ensurePixelString(nodeWidth, "35%"),
|
||||
height: ensurePixelString(nodeHeight, "auto"),
|
||||
aspectRatio: nodeAspectRatio || null,
|
||||
});
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [initialResizeComplete, setInitialResizeComplete] = useState(false);
|
||||
@@ -70,6 +72,19 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const containerRect = useRef<DOMRect | null>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const [hasErroredOnFirstLoad, setHasErroredOnFirstLoad] = useState(false);
|
||||
const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);
|
||||
|
||||
const updateAttributesSafely = useCallback(
|
||||
(attributes: Partial<ImageAttributes>, errorMessage: string) => {
|
||||
try {
|
||||
updateAttributes(attributes);
|
||||
} catch (error) {
|
||||
console.error(`${errorMessage}:`, error);
|
||||
}
|
||||
},
|
||||
[updateAttributes]
|
||||
);
|
||||
|
||||
const handleImageLoad = useCallback(() => {
|
||||
const img = imageRef.current;
|
||||
@@ -91,40 +106,50 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
}
|
||||
|
||||
setEditorContainer(closestEditorContainer);
|
||||
const aspectRatio = img.naturalWidth / img.naturalHeight;
|
||||
const aspectRatioCalculated = img.naturalWidth / img.naturalHeight;
|
||||
|
||||
if (width === "35%") {
|
||||
if (nodeWidth === "35%") {
|
||||
const editorWidth = closestEditorContainer.clientWidth;
|
||||
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
|
||||
const initialHeight = initialWidth / aspectRatio;
|
||||
const initialHeight = initialWidth / aspectRatioCalculated;
|
||||
|
||||
const initialComputedSize = {
|
||||
width: `${Math.round(initialWidth)}px` satisfies Pixel,
|
||||
height: `${Math.round(initialHeight)}px` satisfies Pixel,
|
||||
aspectRatio: aspectRatio,
|
||||
aspectRatio: aspectRatioCalculated,
|
||||
};
|
||||
|
||||
setSize(initialComputedSize);
|
||||
updateAttributes(initialComputedSize);
|
||||
updateAttributesSafely(
|
||||
initialComputedSize,
|
||||
"Failed to update attributes while initializing an image for the first time:"
|
||||
);
|
||||
} else {
|
||||
// as the aspect ratio in not stored for old images, we need to update the attrs
|
||||
setSize((prevSize) => {
|
||||
const newSize = { ...prevSize, aspectRatio };
|
||||
updateAttributes(newSize);
|
||||
return newSize;
|
||||
});
|
||||
// or if aspectRatioCalculated from the image's width and height doesn't match stored aspectRatio then also we'll update the attrs
|
||||
if (!nodeAspectRatio || nodeAspectRatio !== aspectRatioCalculated) {
|
||||
setSize((prevSize) => {
|
||||
const newSize = { ...prevSize, aspectRatio: aspectRatioCalculated };
|
||||
updateAttributesSafely(
|
||||
newSize,
|
||||
"Failed to update attributes while initializing images with width but no aspect ratio:"
|
||||
);
|
||||
return newSize;
|
||||
});
|
||||
}
|
||||
}
|
||||
setInitialResizeComplete(true);
|
||||
}, [width, updateAttributes, editorContainer]);
|
||||
}, [nodeWidth, updateAttributes, editorContainer, nodeAspectRatio]);
|
||||
|
||||
// for real time resizing
|
||||
useLayoutEffect(() => {
|
||||
setSize((prevSize) => ({
|
||||
...prevSize,
|
||||
width: ensurePixelString(width),
|
||||
height: ensurePixelString(height),
|
||||
width: ensurePixelString(nodeWidth),
|
||||
height: ensurePixelString(nodeHeight),
|
||||
aspectRatio: nodeAspectRatio,
|
||||
}));
|
||||
}, [width, height]);
|
||||
}, [nodeWidth, nodeHeight, nodeAspectRatio]);
|
||||
|
||||
const handleResize = useCallback(
|
||||
(e: MouseEvent | TouchEvent) => {
|
||||
@@ -137,12 +162,12 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
|
||||
setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` }));
|
||||
},
|
||||
[size]
|
||||
[size.aspectRatio]
|
||||
);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
setIsResizing(false);
|
||||
updateAttributes(size);
|
||||
updateAttributesSafely(size, "Failed to update attributes at the end of resizing:");
|
||||
}, [size, updateAttributes]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
@@ -160,11 +185,15 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
window.addEventListener("mousemove", handleResize);
|
||||
window.addEventListener("mouseup", handleResizeEnd);
|
||||
window.addEventListener("mouseleave", handleResizeEnd);
|
||||
window.addEventListener("touchmove", handleResize);
|
||||
window.addEventListener("touchend", handleResizeEnd);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleResize);
|
||||
window.removeEventListener("mouseup", handleResizeEnd);
|
||||
window.removeEventListener("mouseleave", handleResizeEnd);
|
||||
window.removeEventListener("touchmove", handleResize);
|
||||
window.removeEventListener("touchend", handleResizeEnd);
|
||||
};
|
||||
}
|
||||
}, [isResizing, handleResize, handleResizeEnd]);
|
||||
@@ -181,11 +210,13 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
|
||||
// show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or)
|
||||
// if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete
|
||||
const showImageLoader = !(remoteImageSrc || imageFromFileSystem) || !initialResizeComplete;
|
||||
// show the image utils only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
|
||||
const showImageUtils = editor.isEditable && remoteImageSrc && initialResizeComplete;
|
||||
const showImageLoader = !(remoteImageSrc || imageFromFileSystem) || !initialResizeComplete || hasErroredOnFirstLoad;
|
||||
// show the image utils only if the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
|
||||
const showImageUtils = remoteImageSrc && initialResizeComplete;
|
||||
// show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
|
||||
const showImageResizer = editor.isEditable && remoteImageSrc && initialResizeComplete;
|
||||
// show the preview image from the file system if the remote image's src is not set
|
||||
const displayedImageSrc = remoteImageSrc ?? imageFromFileSystem;
|
||||
const displayedImageSrc = remoteImageSrc || imageFromFileSystem;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -194,7 +225,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
onMouseDown={handleImageMouseDown}
|
||||
style={{
|
||||
width: size.width,
|
||||
aspectRatio: size.aspectRatio,
|
||||
...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
|
||||
}}
|
||||
>
|
||||
{showImageLoader && (
|
||||
@@ -207,9 +238,26 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
ref={imageRef}
|
||||
src={displayedImageSrc}
|
||||
onLoad={handleImageLoad}
|
||||
onError={(e) => {
|
||||
console.error("Error loading image", e);
|
||||
setFailedToLoadImage(true);
|
||||
onError={async (e) => {
|
||||
// for old image extension this command doesn't exist or if the image failed to load for the first time
|
||||
if (!editor?.commands.restoreImage || hasTriedRestoringImageOnce) {
|
||||
setFailedToLoadImage(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setHasErroredOnFirstLoad(true);
|
||||
// this is a type error from tiptap, don't remove await until it's fixed
|
||||
await editor?.commands.restoreImage?.(node.attrs.src);
|
||||
imageRef.current.src = remoteImageSrc;
|
||||
} catch {
|
||||
// if the image failed to even restore, then show the error state
|
||||
setFailedToLoadImage(true);
|
||||
console.error("Error while loading image", e);
|
||||
} finally {
|
||||
setHasErroredOnFirstLoad(false);
|
||||
setHasTriedRestoringImageOnce(true);
|
||||
}
|
||||
}}
|
||||
width={size.width}
|
||||
className={cn("image-component block rounded-md", {
|
||||
@@ -220,7 +268,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
})}
|
||||
style={{
|
||||
width: size.width,
|
||||
aspectRatio: size.aspectRatio,
|
||||
...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
|
||||
}}
|
||||
/>
|
||||
{showImageUtils && (
|
||||
@@ -239,7 +287,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
{selected && displayedImageSrc === remoteImageSrc && (
|
||||
<div className="absolute inset-0 size-full bg-custom-primary-500/30" />
|
||||
)}
|
||||
{showImageUtils && (
|
||||
{showImageResizer && (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -260,6 +308,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
}
|
||||
)}
|
||||
onMouseDown={handleResizeStart}
|
||||
onTouchStart={handleResizeStart}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
|
||||
import { Editor, NodeViewWrapper } from "@tiptap/react";
|
||||
import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
// extensions
|
||||
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
|
||||
|
||||
export type CustomImageNodeViewProps = {
|
||||
export type CustomImageComponentProps = {
|
||||
getPos: () => number;
|
||||
editor: Editor;
|
||||
node: ProsemirrorNode & {
|
||||
node: NodeViewProps["node"] & {
|
||||
attrs: ImageAttributes;
|
||||
};
|
||||
updateAttributes: (attrs: Record<string, any>) => void;
|
||||
updateAttributes: (attrs: ImageAttributes) => void;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export type CustomImageNodeViewProps = NodeViewProps & CustomImageComponentProps;
|
||||
|
||||
export const CustomImageNode = (props: CustomImageNodeViewProps) => {
|
||||
const { getPos, editor, node, updateAttributes, selected } = props;
|
||||
const { src: remoteImageSrc } = node.attrs;
|
||||
|
||||
const [isUploaded, setIsUploaded] = useState(false);
|
||||
const [imageFromFileSystem, setImageFromFileSystem] = useState<string | undefined>(undefined);
|
||||
@@ -37,14 +39,13 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => {
|
||||
// the image is already uploaded if the image-component node has src attribute
|
||||
// and we need to remove the blob from our file system
|
||||
useEffect(() => {
|
||||
const remoteImageSrc = node.attrs.src;
|
||||
if (remoteImageSrc) {
|
||||
setIsUploaded(true);
|
||||
setImageFromFileSystem(undefined);
|
||||
} else {
|
||||
setIsUploaded(false);
|
||||
}
|
||||
}, [node.attrs.src]);
|
||||
}, [remoteImageSrc]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
@@ -54,6 +55,8 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => {
|
||||
imageFromFileSystem={imageFromFileSystem}
|
||||
editorContainer={editorContainer}
|
||||
editor={editor}
|
||||
// @ts-expect-error function not expected here, but will still work
|
||||
src={editor?.commands?.getImageSource?.(remoteImageSrc)}
|
||||
getPos={getPos}
|
||||
node={node}
|
||||
setEditorContainer={setEditorContainer}
|
||||
@@ -67,6 +70,7 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => {
|
||||
failedToLoadImage={failedToLoadImage}
|
||||
getPos={getPos}
|
||||
loadImageFromFileSystem={setImageFromFileSystem}
|
||||
maxFileSize={editor.storage.imageComponent.maxFileSize}
|
||||
node={node}
|
||||
setIsUploaded={setIsUploaded}
|
||||
selected={selected}
|
||||
|
||||
@@ -1,44 +1,36 @@
|
||||
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useUploader, useDropZone } from "@/hooks/use-file-upload";
|
||||
// plugins
|
||||
import { isFileValid } from "@/plugins/image";
|
||||
import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload";
|
||||
// extensions
|
||||
import { getImageComponentImageFileMap, ImageAttributes } from "@/extensions/custom-image";
|
||||
import { type CustomImageComponentProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
|
||||
|
||||
export const CustomImageUploader = (props: {
|
||||
failedToLoadImage: boolean;
|
||||
editor: Editor;
|
||||
selected: boolean;
|
||||
type CustomImageUploaderProps = CustomImageComponentProps & {
|
||||
maxFileSize: number;
|
||||
loadImageFromFileSystem: (file: string) => void;
|
||||
failedToLoadImage: boolean;
|
||||
setIsUploaded: (isUploaded: boolean) => void;
|
||||
node: ProsemirrorNode & {
|
||||
attrs: ImageAttributes;
|
||||
};
|
||||
updateAttributes: (attrs: Record<string, any>) => void;
|
||||
getPos: () => number;
|
||||
}) => {
|
||||
};
|
||||
|
||||
export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
const {
|
||||
selected,
|
||||
failedToLoadImage,
|
||||
editor,
|
||||
failedToLoadImage,
|
||||
getPos,
|
||||
loadImageFromFileSystem,
|
||||
maxFileSize,
|
||||
node,
|
||||
selected,
|
||||
setIsUploaded,
|
||||
updateAttributes,
|
||||
getPos,
|
||||
} = props;
|
||||
// ref
|
||||
// refs
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const hasTriggeredFilePickerRef = useRef(false);
|
||||
const imageEntityId = node.attrs.id;
|
||||
|
||||
const { id: imageEntityId } = node.attrs;
|
||||
// derived values
|
||||
const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]);
|
||||
|
||||
const onUpload = useCallback(
|
||||
@@ -73,8 +65,18 @@ export const CustomImageUploader = (props: {
|
||||
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
|
||||
);
|
||||
// hooks
|
||||
const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ onUpload, editor, loadImageFromFileSystem });
|
||||
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ uploader: uploadFile });
|
||||
const { uploading: isImageBeingUploaded, uploadFile } = useUploader({
|
||||
editor,
|
||||
loadImageFromFileSystem,
|
||||
maxFileSize,
|
||||
onUpload,
|
||||
});
|
||||
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
|
||||
editor,
|
||||
maxFileSize,
|
||||
pos: getPos(),
|
||||
uploader: uploadFile,
|
||||
});
|
||||
|
||||
// the meta data of the image component
|
||||
const meta = useMemo(
|
||||
@@ -82,9 +84,6 @@ export const CustomImageUploader = (props: {
|
||||
[imageComponentImageFileMap, imageEntityId]
|
||||
);
|
||||
|
||||
// if the image component is dropped, we check if it has an existing file
|
||||
const existingFile = useMemo(() => (meta && meta.event === "drop" ? meta.file : undefined), [meta]);
|
||||
|
||||
// after the image component is mounted we start the upload process based on
|
||||
// it's uploaded
|
||||
useEffect(() => {
|
||||
@@ -100,27 +99,26 @@ export const CustomImageUploader = (props: {
|
||||
}
|
||||
}, [meta, uploadFile, imageComponentImageFileMap]);
|
||||
|
||||
// check if the image is dropped and set the local image as the existing file
|
||||
useEffect(() => {
|
||||
if (existingFile) {
|
||||
uploadFile(existingFile);
|
||||
}
|
||||
}, [existingFile, uploadFile]);
|
||||
|
||||
const onFileChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (isFileValid(file)) {
|
||||
uploadFile(file);
|
||||
}
|
||||
async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
const filesList = e.target.files;
|
||||
if (!filesList) {
|
||||
return;
|
||||
}
|
||||
await uploadFirstImageAndInsertRemaining({
|
||||
editor,
|
||||
filesList,
|
||||
maxFileSize,
|
||||
pos: getPos(),
|
||||
uploader: uploadFile,
|
||||
});
|
||||
},
|
||||
[uploadFile]
|
||||
[uploadFile, editor, getPos]
|
||||
);
|
||||
|
||||
const getDisplayMessage = useCallback(() => {
|
||||
const isUploading = isImageBeingUploaded || existingFile;
|
||||
const isUploading = isImageBeingUploaded;
|
||||
if (failedToLoadImage) {
|
||||
return "Error loading image";
|
||||
}
|
||||
@@ -134,13 +132,14 @@ export const CustomImageUploader = (props: {
|
||||
}
|
||||
|
||||
return "Add an image";
|
||||
}, [draggedInside, failedToLoadImage, existingFile, isImageBeingUploaded]);
|
||||
}, [draggedInside, failedToLoadImage, isImageBeingUploaded]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 cursor-pointer transition-all duration-200 ease-in-out",
|
||||
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 transition-all duration-200 ease-in-out cursor-default",
|
||||
{
|
||||
"hover:text-custom-text-200 cursor-pointer": editor.isEditable,
|
||||
"bg-custom-background-80 text-custom-text-200": draggedInside,
|
||||
"text-custom-primary-200 bg-custom-primary-100/10 hover:bg-custom-primary-100/10 hover:text-custom-primary-200 border-custom-primary-200/10":
|
||||
selected,
|
||||
@@ -153,7 +152,7 @@ export const CustomImageUploader = (props: {
|
||||
onDragLeave={onDragLeave}
|
||||
contentEditable={false}
|
||||
onClick={() => {
|
||||
if (!failedToLoadImage) {
|
||||
if (!failedToLoadImage && editor.isEditable) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
@@ -167,6 +166,7 @@ export const CustomImageUploader = (props: {
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.webp"
|
||||
onChange={onFileChange}
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,8 @@ declare module "@tiptap/core" {
|
||||
imageComponent: {
|
||||
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
|
||||
uploadImage: (file: File) => () => Promise<string> | undefined;
|
||||
restoreImage: (src: string) => () => Promise<void>;
|
||||
getImageSource?: (path: string) => () => string;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -36,7 +38,13 @@ export interface UploadImageExtensionStorage {
|
||||
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
|
||||
|
||||
export const CustomImageExtension = (props: TFileHandler) => {
|
||||
const { upload, delete: deleteImage, restore: restoreImage } = props;
|
||||
const {
|
||||
getAssetSrc,
|
||||
upload,
|
||||
delete: deleteImageFn,
|
||||
restore: restoreImageFn,
|
||||
validation: { maxFileSize },
|
||||
} = props;
|
||||
|
||||
return Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
|
||||
name: "imageComponent",
|
||||
@@ -78,23 +86,6 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
|
||||
onCreate(this) {
|
||||
const imageSources = new Set<string>();
|
||||
this.editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === this.name) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
|
||||
@@ -104,16 +95,35 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
TrackImageDeletionPlugin(this.editor, deleteImage, this.name),
|
||||
TrackImageRestorationPlugin(this.editor, restoreImage, this.name),
|
||||
TrackImageDeletionPlugin(this.editor, deleteImageFn, this.name),
|
||||
TrackImageRestorationPlugin(this.editor, restoreImageFn, this.name),
|
||||
];
|
||||
},
|
||||
|
||||
onCreate(this) {
|
||||
const imageSources = new Set<string>();
|
||||
this.editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === this.name) {
|
||||
if (!node.attrs.src?.startsWith("http")) return;
|
||||
|
||||
imageSources.add(node.attrs.src);
|
||||
}
|
||||
});
|
||||
imageSources.forEach(async (src) => {
|
||||
try {
|
||||
await restoreImageFn(src);
|
||||
} catch (error) {
|
||||
console.error("Error restoring image: ", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
uploadInProgress: false,
|
||||
maxFileSize,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -123,7 +133,13 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||
(props: { file?: File; pos?: number; event: "insert" | "drop" }) =>
|
||||
({ commands }) => {
|
||||
// Early return if there's an invalid file being dropped
|
||||
if (props?.file && !isFileValid(props.file)) {
|
||||
if (
|
||||
props?.file &&
|
||||
!isFileValid({
|
||||
file: props.file,
|
||||
maxFileSize,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -166,6 +182,10 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||
const fileUrl = await upload(file);
|
||||
return fileUrl;
|
||||
},
|
||||
restoreImage: (src: string) => async () => {
|
||||
await restoreImageFn(src);
|
||||
},
|
||||
getImageSource: (path: string) => () => getAssetSrc(path),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -3,9 +3,13 @@ import { Image } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// components
|
||||
import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custom-image";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
|
||||
export const CustomReadOnlyImageExtension = () =>
|
||||
Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
|
||||
export const CustomReadOnlyImageExtension = (props: Pick<TFileHandler, "getAssetSrc">) => {
|
||||
const { getAssetSrc } = props;
|
||||
|
||||
return Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
|
||||
name: "imageComponent",
|
||||
selectable: false,
|
||||
group: "block",
|
||||
@@ -51,7 +55,14 @@ export const CustomReadOnlyImageExtension = () =>
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
getImageSource: (path: string) => () => getAssetSrc(path),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ export const DropHandlerExtension = () =>
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
const pos = view.state.selection.from;
|
||||
insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" });
|
||||
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export const DropHandlerExtension = () =>
|
||||
|
||||
if (coordinates) {
|
||||
const pos = coordinates.pos;
|
||||
insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" });
|
||||
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export const DropHandlerExtension = () =>
|
||||
},
|
||||
});
|
||||
|
||||
const insertImages = async ({
|
||||
export const insertImagesSafely = async ({
|
||||
editor,
|
||||
files,
|
||||
initialPos,
|
||||
@@ -72,13 +72,6 @@ const insertImages = async ({
|
||||
const docSize = editor.state.doc.content.size;
|
||||
pos = Math.min(pos, docSize);
|
||||
|
||||
// Check if the position has a non-empty node
|
||||
const nodeAtPos = editor.state.doc.nodeAt(pos);
|
||||
if (nodeAtPos && nodeAtPos.content.size > 0) {
|
||||
// Move to the end of the current node
|
||||
pos += nodeAtPos.nodeSize;
|
||||
}
|
||||
|
||||
try {
|
||||
// Insert the image at the current position
|
||||
editor.commands.insertImageComponent({ file, pos, event });
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CustomCodeBlockExtension,
|
||||
CustomCodeInlineExtension,
|
||||
CustomCodeMarkPlugin,
|
||||
CustomColorExtension,
|
||||
CustomHorizontalRule,
|
||||
CustomImageExtension,
|
||||
CustomKeymap,
|
||||
@@ -30,16 +31,11 @@ import {
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
// types
|
||||
import { DeleteImage, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types";
|
||||
import { IMentionHighlight, IMentionSuggestion, TFileHandler } from "@/types";
|
||||
|
||||
type TArguments = {
|
||||
enableHistory: boolean;
|
||||
fileConfig: {
|
||||
deleteFile: DeleteImage;
|
||||
restoreFile: RestoreImage;
|
||||
cancelUploadImage?: () => void;
|
||||
uploadFile: UploadImage;
|
||||
};
|
||||
fileHandler: TFileHandler;
|
||||
mentionConfig: {
|
||||
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
mentionHighlights?: () => Promise<IMentionHighlight[]>;
|
||||
@@ -48,123 +44,120 @@ type TArguments = {
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export const CoreEditorExtensions = ({
|
||||
enableHistory,
|
||||
fileConfig: { deleteFile, restoreFile, cancelUploadImage, uploadFile },
|
||||
mentionConfig,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
}: TArguments) => [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-disc pl-7 space-y-2",
|
||||
export const CoreEditorExtensions = (args: TArguments) => {
|
||||
const { enableHistory, fileHandler, mentionConfig, placeholder, tabIndex } = args;
|
||||
|
||||
return [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-disc pl-7 space-y-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-decimal pl-7 space-y-2",
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-decimal pl-7 space-y-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
listItem: {
|
||||
HTMLAttributes: {
|
||||
class: "not-prose space-y-2",
|
||||
listItem: {
|
||||
HTMLAttributes: {
|
||||
class: "not-prose space-y-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
blockquote: false,
|
||||
dropcursor: {
|
||||
class: "text-custom-text-300",
|
||||
},
|
||||
...(enableHistory ? {} : { history: false }),
|
||||
}),
|
||||
CustomQuoteExtension,
|
||||
DropHandlerExtension(),
|
||||
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",
|
||||
},
|
||||
}),
|
||||
CustomImageExtension({
|
||||
delete: deleteFile,
|
||||
restore: restoreFile,
|
||||
upload: uploadFile,
|
||||
cancel: cancelUploadImage ?? (() => {}),
|
||||
}),
|
||||
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,
|
||||
breaks: 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}`;
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
blockquote: false,
|
||||
dropcursor: {
|
||||
class: "text-custom-text-300",
|
||||
},
|
||||
...(enableHistory ? {} : { history: false }),
|
||||
}),
|
||||
CustomQuoteExtension,
|
||||
DropHandlerExtension(),
|
||||
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(fileHandler).configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
CustomImageExtension(fileHandler),
|
||||
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,
|
||||
breaks: 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.imageComponent.uploadInProgress) return "";
|
||||
if (editor.storage.imageComponent.uploadInProgress) return "";
|
||||
|
||||
const shouldHidePlaceholder =
|
||||
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
|
||||
const shouldHidePlaceholder =
|
||||
editor.isActive("table") ||
|
||||
editor.isActive("codeBlock") ||
|
||||
editor.isActive("image") ||
|
||||
editor.isActive("imageComponent");
|
||||
|
||||
if (shouldHidePlaceholder) return "";
|
||||
if (shouldHidePlaceholder) return "";
|
||||
|
||||
if (placeholder) {
|
||||
if (typeof placeholder === "string") return placeholder;
|
||||
else return placeholder(editor.isFocused, editor.getHTML());
|
||||
}
|
||||
if (placeholder) {
|
||||
if (typeof placeholder === "string") return placeholder;
|
||||
else return placeholder(editor.isFocused, editor.getHTML());
|
||||
}
|
||||
|
||||
return "Press '/' for commands...";
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
CharacterCount,
|
||||
];
|
||||
return "Press '/' for commands...";
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
CharacterCount,
|
||||
CustomColorExtension,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -5,22 +5,30 @@ import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-par
|
||||
// plugins
|
||||
import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image";
|
||||
// types
|
||||
import { DeleteImage, RestoreImage } from "@/types";
|
||||
import { TFileHandler } from "@/types";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions";
|
||||
|
||||
export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) =>
|
||||
ImageExt.extend<any, ImageExtensionStorage>({
|
||||
export const ImageExtension = (fileHandler: TFileHandler) => {
|
||||
const {
|
||||
getAssetSrc,
|
||||
delete: deleteImageFn,
|
||||
restore: restoreImageFn,
|
||||
validation: { maxFileSize },
|
||||
} = fileHandler;
|
||||
|
||||
return ImageExt.extend<any, ImageExtensionStorage>({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
|
||||
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
TrackImageDeletionPlugin(this.editor, deleteImage, this.name),
|
||||
TrackImageRestorationPlugin(this.editor, restoreImage, this.name),
|
||||
TrackImageDeletionPlugin(this.editor, deleteImageFn, this.name),
|
||||
TrackImageRestorationPlugin(this.editor, restoreImageFn, this.name),
|
||||
];
|
||||
},
|
||||
|
||||
@@ -28,13 +36,14 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm
|
||||
const imageSources = new Set<string>();
|
||||
this.editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === this.name) {
|
||||
if (!node.attrs.src?.startsWith("http")) return;
|
||||
|
||||
imageSources.add(node.attrs.src);
|
||||
}
|
||||
});
|
||||
imageSources.forEach(async (src) => {
|
||||
try {
|
||||
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
||||
await restoreImage(assetUrlWithWorkspaceId);
|
||||
await restoreImageFn(src);
|
||||
} catch (error) {
|
||||
console.error("Error restoring image: ", error);
|
||||
}
|
||||
@@ -46,6 +55,7 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm
|
||||
return {
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
uploadInProgress: false,
|
||||
maxFileSize,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -58,6 +68,15 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm
|
||||
height: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
getImageSource: (path: string) => () => getAssetSrc(path),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -66,3 +85,4 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -14,6 +14,9 @@ export const ImageExtensionWithoutProps = () =>
|
||||
height: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -2,20 +2,36 @@ import Image from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
|
||||
export const ReadOnlyImageExtension = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
export const ReadOnlyImageExtension = (props: Pick<TFileHandler, "getAssetSrc">) => {
|
||||
const { getAssetSrc } = props;
|
||||
|
||||
return Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
getImageSource: (path: string) => () => getAssetSrc(path),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -6,10 +6,12 @@ export * from "./custom-list-keymap";
|
||||
export * from "./image";
|
||||
export * from "./issue-embed";
|
||||
export * from "./mentions";
|
||||
export * from "./slash-commands";
|
||||
export * from "./table";
|
||||
export * from "./typography";
|
||||
export * from "./core-without-props";
|
||||
export * from "./custom-code-inline";
|
||||
export * from "./custom-color";
|
||||
export * from "./drop";
|
||||
export * from "./enter-key-extension";
|
||||
export * from "./extensions";
|
||||
|
||||
@@ -21,93 +21,108 @@ import {
|
||||
CustomMention,
|
||||
HeadingListExtension,
|
||||
CustomReadOnlyImageExtension,
|
||||
CustomColorExtension,
|
||||
} from "@/extensions";
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
// types
|
||||
import { IMentionHighlight } from "@/types";
|
||||
import { IMentionHighlight, TFileHandler } from "@/types";
|
||||
|
||||
export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||
mentionHighlights?: () => Promise<IMentionHighlight[]>;
|
||||
}) => [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-disc pl-7 space-y-2",
|
||||
type Props = {
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
mentionConfig: {
|
||||
mentionHighlights?: () => Promise<IMentionHighlight[]>;
|
||||
};
|
||||
};
|
||||
|
||||
export const CoreReadOnlyEditorExtensions = (props: Props) => {
|
||||
const { fileHandler, mentionConfig } = props;
|
||||
|
||||
return [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-disc pl-7 space-y-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-decimal pl-7 space-y-2",
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-decimal pl-7 space-y-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
listItem: {
|
||||
HTMLAttributes: {
|
||||
class: "not-prose 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",
|
||||
},
|
||||
}),
|
||||
CustomReadOnlyImageExtension(),
|
||||
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,
|
||||
}),
|
||||
CharacterCount,
|
||||
HeadingListExtension,
|
||||
];
|
||||
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({
|
||||
getAssetSrc: fileHandler.getAssetSrc,
|
||||
}).configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
CustomReadOnlyImageExtension({
|
||||
getAssetSrc: fileHandler.getAssetSrc,
|
||||
}),
|
||||
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,
|
||||
}),
|
||||
CharacterCount,
|
||||
CustomColorExtension,
|
||||
HeadingListExtension,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -42,7 +42,7 @@ export const SideMenuExtension = (props: Props) => {
|
||||
ai: aiEnabled,
|
||||
dragDrop: dragDropEnabled,
|
||||
},
|
||||
scrollThreshold: { up: 300, down: 100 },
|
||||
scrollThreshold: { up: 200, down: 100 },
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react";
|
||||
import { Editor, Range, Extension } from "@tiptap/core";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import tippy from "tippy.js";
|
||||
import {
|
||||
CaseSensitive,
|
||||
Code2,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Heading4,
|
||||
Heading5,
|
||||
Heading6,
|
||||
ImageIcon,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListTodo,
|
||||
MinusSquare,
|
||||
Quote,
|
||||
Table,
|
||||
} from "lucide-react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
import {
|
||||
insertTableCommand,
|
||||
toggleBlockquote,
|
||||
toggleBulletList,
|
||||
toggleOrderedList,
|
||||
toggleTaskList,
|
||||
toggleHeadingOne,
|
||||
toggleHeadingTwo,
|
||||
toggleHeadingThree,
|
||||
toggleHeadingFour,
|
||||
toggleHeadingFive,
|
||||
toggleHeadingSix,
|
||||
insertImage,
|
||||
} from "@/helpers/editor-commands";
|
||||
// types
|
||||
import { CommandProps, ISlashCommandItem } 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 =
|
||||
(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="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
if (range) {
|
||||
editor.chain().focus().deleteRange(range).clearNodes().run();
|
||||
}
|
||||
editor.chain().focus().clearNodes().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "h1",
|
||||
title: "Heading 1",
|
||||
description: "Big section heading.",
|
||||
searchTerms: ["title", "big", "large"],
|
||||
icon: <Heading1 className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingOne(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "h2",
|
||||
title: "Heading 2",
|
||||
description: "Medium section heading.",
|
||||
searchTerms: ["subtitle", "medium"],
|
||||
icon: <Heading2 className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingTwo(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "h3",
|
||||
title: "Heading 3",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading3 className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingThree(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "h4",
|
||||
title: "Heading 4",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading4 className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingFour(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "h5",
|
||||
title: "Heading 5",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading5 className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingFive(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "h6",
|
||||
title: "Heading 6",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading6 className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingSix(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "to-do-list",
|
||||
title: "To do",
|
||||
description: "Track tasks with a to-do list.",
|
||||
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
||||
icon: <ListTodo className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleTaskList(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "bulleted-list",
|
||||
title: "Bullet list",
|
||||
description: "Create a simple bullet list.",
|
||||
searchTerms: ["unordered", "point"],
|
||||
icon: <List className="size-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="size-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="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertTableCommand(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "quote",
|
||||
title: "Quote",
|
||||
description: "Capture a quote.",
|
||||
searchTerms: ["blockquote"],
|
||||
icon: <Quote className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => toggleBlockquote(editor, range),
|
||||
},
|
||||
{
|
||||
key: "code",
|
||||
title: "Code",
|
||||
description: "Capture a code snippet.",
|
||||
searchTerms: ["codeblock"],
|
||||
icon: <Code2 className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
key: "image",
|
||||
title: "Image",
|
||||
icon: <ImageIcon className="size-3.5" />,
|
||||
description: "Insert an image",
|
||||
searchTerms: ["img", "photo", "picture", "media", "upload"],
|
||||
command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
|
||||
},
|
||||
{
|
||||
key: "divider",
|
||||
title: "Divider",
|
||||
description: "Visually divide blocks.",
|
||||
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
||||
icon: <MinusSquare className="size-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('[id^="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 = (additionalOptions?: Array<ISlashCommandItem>) =>
|
||||
Command.configure({
|
||||
suggestion: {
|
||||
items: getSuggestionItems(additionalOptions),
|
||||
render: renderItems,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
ALargeSmall,
|
||||
CaseSensitive,
|
||||
Code2,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Heading4,
|
||||
Heading5,
|
||||
Heading6,
|
||||
ImageIcon,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListTodo,
|
||||
MinusSquare,
|
||||
Quote,
|
||||
Table,
|
||||
} from "lucide-react";
|
||||
// constants
|
||||
import { COLORS_LIST } from "@/constants/common";
|
||||
// helpers
|
||||
import {
|
||||
insertTableCommand,
|
||||
toggleBlockquote,
|
||||
toggleBulletList,
|
||||
toggleOrderedList,
|
||||
toggleTaskList,
|
||||
toggleHeadingOne,
|
||||
toggleHeadingTwo,
|
||||
toggleHeadingThree,
|
||||
toggleHeadingFour,
|
||||
toggleHeadingFive,
|
||||
toggleHeadingSix,
|
||||
toggleTextColor,
|
||||
toggleBackgroundColor,
|
||||
insertImage,
|
||||
} from "@/helpers/editor-commands";
|
||||
// types
|
||||
import { CommandProps, ISlashCommandItem } from "@/types";
|
||||
|
||||
export type TSlashCommandSection = {
|
||||
key: string;
|
||||
title?: string;
|
||||
items: ISlashCommandItem[];
|
||||
};
|
||||
|
||||
export const getSlashCommandFilteredSections =
|
||||
(additionalOptions?: ISlashCommandItem[]) =>
|
||||
({ query }: { query: string }): TSlashCommandSection[] => {
|
||||
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
|
||||
{
|
||||
key: "general",
|
||||
items: [
|
||||
{
|
||||
commandKey: "text",
|
||||
key: "text",
|
||||
title: "Text",
|
||||
description: "Just start typing with plain text.",
|
||||
searchTerms: ["p", "paragraph"],
|
||||
icon: <CaseSensitive className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
if (range) {
|
||||
editor.chain().focus().deleteRange(range).clearNodes().run();
|
||||
}
|
||||
editor.chain().focus().clearNodes().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
commandKey: "h1",
|
||||
key: "h1",
|
||||
title: "Heading 1",
|
||||
description: "Big section heading.",
|
||||
searchTerms: ["title", "big", "large"],
|
||||
icon: <Heading1 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingOne(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "h2",
|
||||
key: "h2",
|
||||
title: "Heading 2",
|
||||
description: "Medium section heading.",
|
||||
searchTerms: ["subtitle", "medium"],
|
||||
icon: <Heading2 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingTwo(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "h3",
|
||||
key: "h3",
|
||||
title: "Heading 3",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading3 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingThree(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "h4",
|
||||
key: "h4",
|
||||
title: "Heading 4",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading4 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingFour(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "h5",
|
||||
key: "h5",
|
||||
title: "Heading 5",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading5 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingFive(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "h6",
|
||||
key: "h6",
|
||||
title: "Heading 6",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading6 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingSix(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "to-do-list",
|
||||
key: "to-do-list",
|
||||
title: "To do",
|
||||
description: "Track tasks with a to-do list.",
|
||||
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
||||
icon: <ListTodo className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleTaskList(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "bulleted-list",
|
||||
key: "bulleted-list",
|
||||
title: "Bullet list",
|
||||
description: "Create a simple bullet list.",
|
||||
searchTerms: ["unordered", "point"],
|
||||
icon: <List className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleBulletList(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "numbered-list",
|
||||
key: "numbered-list",
|
||||
title: "Numbered list",
|
||||
description: "Create a list with numbering.",
|
||||
searchTerms: ["ordered"],
|
||||
icon: <ListOrdered className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleOrderedList(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "table",
|
||||
key: "table",
|
||||
title: "Table",
|
||||
description: "Create a table",
|
||||
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
||||
icon: <Table className="size-3.5" />,
|
||||
command: ({ editor, range }) => insertTableCommand(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "quote",
|
||||
key: "quote",
|
||||
title: "Quote",
|
||||
description: "Capture a quote.",
|
||||
searchTerms: ["blockquote"],
|
||||
icon: <Quote className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleBlockquote(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "code",
|
||||
key: "code",
|
||||
title: "Code",
|
||||
description: "Capture a code snippet.",
|
||||
searchTerms: ["codeblock"],
|
||||
icon: <Code2 className="size-3.5" />,
|
||||
command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
commandKey: "image",
|
||||
key: "image",
|
||||
title: "Image",
|
||||
icon: <ImageIcon className="size-3.5" />,
|
||||
description: "Insert an image",
|
||||
searchTerms: ["img", "photo", "picture", "media", "upload"],
|
||||
command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
|
||||
},
|
||||
{
|
||||
commandKey: "divider",
|
||||
key: "divider",
|
||||
title: "Divider",
|
||||
description: "Visually divide blocks.",
|
||||
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
||||
icon: <MinusSquare className="size-3.5" />,
|
||||
command: ({ editor, range }) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "text-color",
|
||||
title: "Colors",
|
||||
items: [
|
||||
{
|
||||
commandKey: "text-color",
|
||||
key: "text-color-default",
|
||||
title: "Default",
|
||||
description: "Change text color",
|
||||
searchTerms: ["color", "text", "default"],
|
||||
icon: (
|
||||
<ALargeSmall
|
||||
className="size-3.5"
|
||||
style={{
|
||||
color: "rgba(var(--color-text-100))",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
command: ({ editor, range }) => toggleTextColor(undefined, editor, range),
|
||||
},
|
||||
...COLORS_LIST.map(
|
||||
(color) =>
|
||||
({
|
||||
commandKey: "text-color",
|
||||
key: `text-color-${color.key}`,
|
||||
title: color.label,
|
||||
description: "Change text color",
|
||||
searchTerms: ["color", "text", color.label],
|
||||
icon: (
|
||||
<ALargeSmall
|
||||
className="size-3.5"
|
||||
style={{
|
||||
color: color.textColor,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
command: ({ editor, range }) => toggleTextColor(color.key, editor, range),
|
||||
}) as ISlashCommandItem
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "background-color",
|
||||
title: "Background colors",
|
||||
items: [
|
||||
{
|
||||
commandKey: "background-color",
|
||||
key: "background-color-default",
|
||||
title: "Default background",
|
||||
description: "Change background color",
|
||||
searchTerms: ["color", "bg", "background", "default"],
|
||||
icon: <ALargeSmall className="size-3.5" />,
|
||||
iconContainerStyle: {
|
||||
borderRadius: "4px",
|
||||
backgroundColor: "rgba(var(--color-background-100))",
|
||||
border: "1px solid rgba(var(--color-border-300))",
|
||||
},
|
||||
command: ({ editor, range }) => toggleTextColor(undefined, editor, range),
|
||||
},
|
||||
...COLORS_LIST.map(
|
||||
(color) =>
|
||||
({
|
||||
commandKey: "background-color",
|
||||
key: `background-color-${color.key}`,
|
||||
title: color.label,
|
||||
description: "Change background color",
|
||||
searchTerms: ["color", "bg", "background", color.label],
|
||||
icon: <ALargeSmall className="size-3.5" />,
|
||||
iconContainerStyle: {
|
||||
borderRadius: "4px",
|
||||
backgroundColor: color.backgroundColor,
|
||||
},
|
||||
command: ({ editor, range }) => toggleBackgroundColor(color.key, editor, range),
|
||||
}) as ISlashCommandItem
|
||||
),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
additionalOptions?.map((item) => {
|
||||
SLASH_COMMAND_SECTIONS?.[0]?.items.push(item);
|
||||
});
|
||||
|
||||
const filteredSlashSections = SLASH_COMMAND_SECTIONS.map((section) => ({
|
||||
...section,
|
||||
items: section.items.filter((item) => {
|
||||
if (typeof query !== "string") return;
|
||||
|
||||
const lowercaseQuery = query.toLowerCase();
|
||||
return (
|
||||
item.title.toLowerCase().includes(lowercaseQuery) ||
|
||||
item.description.toLowerCase().includes(lowercaseQuery) ||
|
||||
item.searchTerms.some((t) => t.includes(lowercaseQuery))
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
return filteredSlashSections.filter((s) => s.items.length !== 0);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
// types
|
||||
import { ISlashCommandItem } from "@/types";
|
||||
|
||||
type Props = {
|
||||
isSelected: boolean;
|
||||
item: ISlashCommandItem;
|
||||
itemIndex: number;
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
onMouseEnter: () => void;
|
||||
sectionIndex: number;
|
||||
};
|
||||
|
||||
export const CommandMenuItem: React.FC<Props> = (props) => {
|
||||
const { isSelected, item, itemIndex, onClick, onMouseEnter, sectionIndex } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
id={`item-${sectionIndex}-${itemIndex}`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full rounded px-1 py-1.5 text-sm text-left truncate text-custom-text-200",
|
||||
{
|
||||
"bg-custom-background-80": isSelected,
|
||||
}
|
||||
)}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
<span className="size-5 grid place-items-center flex-shrink-0" style={item.iconContainerStyle}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<p className="flex-grow truncate">{item.title}</p>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
// components
|
||||
import { TSlashCommandSection } from "./command-items-list";
|
||||
import { CommandMenuItem } from "./command-menu-item";
|
||||
|
||||
type Props = {
|
||||
items: TSlashCommandSection[];
|
||||
command: any;
|
||||
};
|
||||
|
||||
export const SlashCommandsMenu = (props: Props) => {
|
||||
const { items: sections, command } = props;
|
||||
// states
|
||||
const [selectedIndex, setSelectedIndex] = useState({
|
||||
section: 0,
|
||||
item: 0,
|
||||
});
|
||||
// refs
|
||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(sectionIndex: number, itemIndex: number) => {
|
||||
const item = sections[sectionIndex]?.items?.[itemIndex];
|
||||
if (item) command(item);
|
||||
},
|
||||
[command, sections]
|
||||
);
|
||||
// handle arrow key navigation
|
||||
useEffect(() => {
|
||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (navigationKeys.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
const currentSection = selectedIndex.section;
|
||||
const currentItem = selectedIndex.item;
|
||||
let nextSection = currentSection;
|
||||
let nextItem = currentItem;
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
nextItem = currentItem - 1;
|
||||
if (nextItem < 0) {
|
||||
nextSection = currentSection - 1;
|
||||
if (nextSection < 0) nextSection = sections.length - 1;
|
||||
nextItem = sections[nextSection].items.length - 1;
|
||||
}
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
nextItem = currentItem + 1;
|
||||
if (nextItem >= sections[currentSection].items.length) {
|
||||
nextSection = currentSection + 1;
|
||||
if (nextSection >= sections.length) nextSection = 0;
|
||||
nextItem = 0;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
selectItem(currentSection, currentItem);
|
||||
}
|
||||
setSelectedIndex({
|
||||
section: nextSection,
|
||||
item: nextItem,
|
||||
});
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [sections, selectedIndex, setSelectedIndex, selectItem]);
|
||||
// initialize the select index to 0 by default
|
||||
useEffect(() => {
|
||||
setSelectedIndex({
|
||||
section: 0,
|
||||
item: 0,
|
||||
});
|
||||
}, [sections]);
|
||||
// scroll to the dropdown item when navigating via keyboard
|
||||
useLayoutEffect(() => {
|
||||
const container = commandListContainer?.current;
|
||||
if (!container) return;
|
||||
|
||||
const item = container.querySelector(`#item-${selectedIndex.section}-${selectedIndex.item}`) as HTMLElement;
|
||||
|
||||
// use scroll into view to bring the item in view if it is not in view
|
||||
item?.scrollIntoView({ block: "nearest" });
|
||||
}, [sections, selectedIndex]);
|
||||
|
||||
const areSearchResultsEmpty = sections.map((s) => s.items.length).reduce((acc, curr) => acc + curr, 0) === 0;
|
||||
|
||||
if (areSearchResultsEmpty) 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 space-y-2"
|
||||
>
|
||||
{sections.map((section, sectionIndex) => (
|
||||
<div key={section.key} className="space-y-2">
|
||||
{section.title && <h6 className="text-xs font-semibold text-custom-text-300">{section.title}</h6>}
|
||||
<div>
|
||||
{section.items.map((item, itemIndex) => (
|
||||
<CommandMenuItem
|
||||
key={item.key}
|
||||
isSelected={sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item}
|
||||
item={item}
|
||||
itemIndex={itemIndex}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
selectItem(sectionIndex, itemIndex);
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setSelectedIndex({
|
||||
section: sectionIndex,
|
||||
item: itemIndex,
|
||||
})
|
||||
}
|
||||
sectionIndex={sectionIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
111
packages/editor/src/core/extensions/slash-commands/root.tsx
Normal file
111
packages/editor/src/core/extensions/slash-commands/root.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Editor, Range, Extension } from "@tiptap/core";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import tippy from "tippy.js";
|
||||
// types
|
||||
import { ISlashCommandItem } from "@/types";
|
||||
// components
|
||||
import { getSlashCommandFilteredSections } from "./command-items-list";
|
||||
import { SlashCommandsMenu } from "./command-menu";
|
||||
|
||||
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,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
interface CommandListInstance {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
||||
}
|
||||
|
||||
const renderItems = () => {
|
||||
let component: ReactRenderer<CommandListInstance, typeof SlashCommandsMenu> | null = null;
|
||||
let popup: any | null = null;
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||
component = new ReactRenderer(SlashCommandsMenu, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
const tippyContainer =
|
||||
document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]');
|
||||
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?.[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 SlashCommands = (additionalOptions?: ISlashCommandItem[]) =>
|
||||
Command.configure({
|
||||
suggestion: {
|
||||
items: getSlashCommandFilteredSections(additionalOptions),
|
||||
render: renderItems,
|
||||
},
|
||||
});
|
||||
@@ -154,3 +154,29 @@ export const unsetLinkEditor = (editor: Editor) => {
|
||||
export const setLinkEditor = (editor: Editor, url: string) => {
|
||||
editor.chain().focus().setLink({ href: url }).run();
|
||||
};
|
||||
|
||||
export const toggleTextColor = (color: string | undefined, editor: Editor, range?: Range) => {
|
||||
if (color) {
|
||||
if (range) editor.chain().focus().deleteRange(range).setTextColor(color).run();
|
||||
else editor.chain().focus().setTextColor(color).run();
|
||||
} else {
|
||||
if (range) editor.chain().focus().deleteRange(range).unsetTextColor().run();
|
||||
else editor.chain().focus().unsetTextColor().run();
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleBackgroundColor = (color: string | undefined, editor: Editor, range?: Range) => {
|
||||
if (color) {
|
||||
if (range) {
|
||||
editor.chain().focus().deleteRange(range).setBackgroundColor(color).run();
|
||||
} else {
|
||||
editor.chain().focus().setBackgroundColor(color).run();
|
||||
}
|
||||
} else {
|
||||
if (range) {
|
||||
editor.chain().focus().deleteRange(range).unsetBackgroundColor().run();
|
||||
} else {
|
||||
editor.chain().focus().unsetBackgroundColor().run();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -90,12 +90,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
extensions: [
|
||||
...CoreEditorExtensions({
|
||||
enableHistory,
|
||||
fileConfig: {
|
||||
uploadFile: fileHandler.upload,
|
||||
deleteFile: fileHandler.delete,
|
||||
restoreFile: fileHandler.restore,
|
||||
cancelUploadImage: fileHandler.cancel,
|
||||
},
|
||||
fileHandler,
|
||||
mentionConfig: {
|
||||
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
|
||||
mentionHighlights: mentionHandler.highlights,
|
||||
@@ -141,7 +136,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
forwardedRef,
|
||||
() => ({
|
||||
clearEditor: (emitUpdate = false) => {
|
||||
editorRef.current?.commands.clearContent(emitUpdate);
|
||||
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
|
||||
},
|
||||
setEditorValue: (content: string) => {
|
||||
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||
@@ -151,7 +146,8 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
insertContentAtSavedSelection(editorRef, content, savedSelection);
|
||||
}
|
||||
},
|
||||
executeMenuItemCommand: (itemKey: TEditorCommands) => {
|
||||
executeMenuItemCommand: (props) => {
|
||||
const { itemKey } = props;
|
||||
const editorItems = getEditorMenuItems(editorRef.current);
|
||||
|
||||
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
||||
@@ -160,6 +156,8 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
if (item) {
|
||||
if (item.key === "image") {
|
||||
item.command(savedSelectionRef.current);
|
||||
} else if (itemKey === "text-color" || itemKey === "background-color") {
|
||||
item.command(props.color);
|
||||
} else {
|
||||
item.command();
|
||||
}
|
||||
@@ -167,12 +165,19 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
console.warn(`No command found for item: ${itemKey}`);
|
||||
}
|
||||
},
|
||||
isMenuItemActive: (itemName: TEditorCommands): boolean => {
|
||||
isMenuItemActive: (props) => {
|
||||
const { itemKey } = props;
|
||||
const editorItems = getEditorMenuItems(editorRef.current);
|
||||
|
||||
const getEditorMenuItem = (itemName: TEditorCommands) => editorItems.find((item) => item.key === itemName);
|
||||
const item = getEditorMenuItem(itemName);
|
||||
return item ? item.isActive() : false;
|
||||
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
||||
const item = getEditorMenuItem(itemKey);
|
||||
if (!item) return false;
|
||||
|
||||
if (itemKey === "text-color" || itemKey === "background-color") {
|
||||
return item.isActive(props.color);
|
||||
} else {
|
||||
return item.isActive("");
|
||||
}
|
||||
},
|
||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
|
||||
// Subscribe to update event emitted from headers extension
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { DragEvent, useCallback, useEffect, useState } from "react";
|
||||
import { Editor } from "@tiptap/core";
|
||||
// extensions
|
||||
import { insertImagesSafely } from "@/extensions/drop";
|
||||
// plugins
|
||||
import { isFileValid } from "@/plugins/image";
|
||||
|
||||
export const useUploader = ({
|
||||
onUpload,
|
||||
editor,
|
||||
loadImageFromFileSystem,
|
||||
}: {
|
||||
onUpload: (url: string) => void;
|
||||
type TUploaderArgs = {
|
||||
editor: Editor;
|
||||
loadImageFromFileSystem: (file: string) => void;
|
||||
}) => {
|
||||
maxFileSize: number;
|
||||
onUpload: (url: string) => void;
|
||||
};
|
||||
|
||||
export const useUploader = (args: TUploaderArgs) => {
|
||||
const { editor, loadImageFromFileSystem, maxFileSize, onUpload } = args;
|
||||
// states
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const uploadFile = useCallback(
|
||||
@@ -22,7 +26,10 @@ export const useUploader = ({
|
||||
setUploading(true);
|
||||
const fileNameTrimmed = trimFileName(file.name);
|
||||
const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type });
|
||||
const isValid = isFileValid(fileWithTrimmedName);
|
||||
const isValid = isFileValid({
|
||||
file: fileWithTrimmedName,
|
||||
maxFileSize,
|
||||
});
|
||||
if (!isValid) {
|
||||
setImageUploadInProgress(false);
|
||||
return;
|
||||
@@ -63,7 +70,16 @@ export const useUploader = ({
|
||||
return { uploading, uploadFile };
|
||||
};
|
||||
|
||||
export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) => {
|
||||
type TDropzoneArgs = {
|
||||
editor: Editor;
|
||||
maxFileSize: number;
|
||||
pos: number;
|
||||
uploader: (file: File) => Promise<void>;
|
||||
};
|
||||
|
||||
export const useDropZone = (args: TDropzoneArgs) => {
|
||||
const { editor, maxFileSize, pos, uploader } = args;
|
||||
// states
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
const [draggedInside, setDraggedInside] = useState<boolean>(false);
|
||||
|
||||
@@ -86,40 +102,22 @@ export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) =>
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: DragEvent<HTMLDivElement>) => {
|
||||
async (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDraggedInside(false);
|
||||
if (e.dataTransfer.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileList = e.dataTransfer.files;
|
||||
|
||||
const files: File[] = [];
|
||||
|
||||
for (let i = 0; i < fileList.length; i += 1) {
|
||||
const item = fileList.item(i);
|
||||
if (item) {
|
||||
files.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (files.some((file) => file.type.indexOf("image") === -1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const filteredFiles = files.filter((f) => f.type.indexOf("image") !== -1);
|
||||
|
||||
const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined;
|
||||
|
||||
if (file) {
|
||||
uploader(file);
|
||||
} else {
|
||||
console.error("No file found");
|
||||
}
|
||||
const filesList = e.dataTransfer.files;
|
||||
await uploadFirstImageAndInsertRemaining({
|
||||
editor,
|
||||
filesList,
|
||||
maxFileSize,
|
||||
pos,
|
||||
uploader,
|
||||
});
|
||||
},
|
||||
[uploader]
|
||||
[uploader, editor, pos]
|
||||
);
|
||||
|
||||
const onDragEnter = () => {
|
||||
@@ -143,3 +141,51 @@ function trimFileName(fileName: string, maxLength = 100) {
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
type TMultipleImagesArgs = {
|
||||
editor: Editor;
|
||||
filesList: FileList;
|
||||
maxFileSize: number;
|
||||
pos: number;
|
||||
uploader: (file: File) => Promise<void>;
|
||||
};
|
||||
|
||||
// Upload the first image and insert the remaining images for uploading multiple image
|
||||
// post insertion of image-component
|
||||
export async function uploadFirstImageAndInsertRemaining(args: TMultipleImagesArgs) {
|
||||
const { editor, filesList, maxFileSize, pos, uploader } = args;
|
||||
const filteredFiles: File[] = [];
|
||||
for (let i = 0; i < filesList.length; i += 1) {
|
||||
const item = filesList.item(i);
|
||||
if (
|
||||
item &&
|
||||
item.type.indexOf("image") !== -1 &&
|
||||
isFileValid({
|
||||
file: item,
|
||||
maxFileSize,
|
||||
})
|
||||
) {
|
||||
filteredFiles.push(item);
|
||||
}
|
||||
}
|
||||
if (filteredFiles.length !== filesList.length) {
|
||||
console.warn("Some files were not images and have been ignored.");
|
||||
}
|
||||
if (filteredFiles.length === 0) {
|
||||
console.error("No image files found to upload");
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload the first image
|
||||
const firstFile = filteredFiles[0];
|
||||
uploader(firstFile);
|
||||
|
||||
// Insert the remaining images
|
||||
const remainingFiles = filteredFiles.slice(1);
|
||||
|
||||
if (remainingFiles.length > 0) {
|
||||
const docSize = editor.state.doc.content.size;
|
||||
const posOfNextImageToBeInserted = Math.min(pos + 1, docSize);
|
||||
insertImagesSafely({ editor, files: remainingFiles, initialPos: posOfNextImageToBeInserted, event: "drop" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit
|
||||
editorClassName,
|
||||
editorProps = {},
|
||||
extensions,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
@@ -74,6 +75,7 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit
|
||||
document: provider.document,
|
||||
}),
|
||||
],
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
mentionHandler,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
|
||||
// props
|
||||
import { CoreReadOnlyEditorProps } from "@/props";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight } from "@/types";
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight, TFileHandler } from "@/types";
|
||||
|
||||
interface CustomReadOnlyEditorProps {
|
||||
initialValue?: string;
|
||||
@@ -19,6 +19,7 @@ interface CustomReadOnlyEditorProps {
|
||||
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
extensions?: any;
|
||||
editorProps?: EditorProps;
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
@@ -33,6 +34,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||
forwardedRef,
|
||||
extensions = [],
|
||||
editorProps = {},
|
||||
fileHandler,
|
||||
handleEditorReady,
|
||||
mentionHandler,
|
||||
provider,
|
||||
@@ -52,7 +54,10 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||
},
|
||||
extensions: [
|
||||
...CoreReadOnlyEditorExtensions({
|
||||
mentionHighlights: mentionHandler.highlights,
|
||||
mentionConfig: {
|
||||
mentionHighlights: mentionHandler.highlights,
|
||||
},
|
||||
fileHandler,
|
||||
}),
|
||||
...extensions,
|
||||
],
|
||||
@@ -70,8 +75,8 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
clearEditor: () => {
|
||||
editorRef.current?.commands.clearContent();
|
||||
clearEditor: (emitUpdate = false) => {
|
||||
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
|
||||
},
|
||||
setEditorValue: (content: string) => {
|
||||
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||
|
||||
@@ -253,14 +253,46 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
|
||||
dragHandleElement.addEventListener("click", (e) => handleClick(e, view));
|
||||
dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view));
|
||||
|
||||
const isScrollable = (node: HTMLElement | SVGElement) => {
|
||||
if (!(node instanceof HTMLElement || node instanceof SVGElement)) {
|
||||
return false;
|
||||
}
|
||||
const style = getComputedStyle(node);
|
||||
return ["overflow", "overflow-y"].some((propertyName) => {
|
||||
const value = style.getPropertyValue(propertyName);
|
||||
return value === "auto" || value === "scroll";
|
||||
});
|
||||
};
|
||||
|
||||
const getScrollParent = (node: HTMLElement | SVGElement) => {
|
||||
let currentParent = node.parentElement;
|
||||
while (currentParent) {
|
||||
if (isScrollable(currentParent)) {
|
||||
return currentParent;
|
||||
}
|
||||
currentParent = currentParent.parentElement;
|
||||
}
|
||||
return document.scrollingElement || document.documentElement;
|
||||
};
|
||||
|
||||
const maxScrollSpeed = 100;
|
||||
|
||||
dragHandleElement.addEventListener("drag", (e) => {
|
||||
hideDragHandle();
|
||||
const frameRenderer = document.querySelector(".frame-renderer");
|
||||
if (!frameRenderer) return;
|
||||
if (e.clientY < options.scrollThreshold.up) {
|
||||
frameRenderer.scrollBy({ top: -70, behavior: "smooth" });
|
||||
} else if (window.innerHeight - e.clientY < options.scrollThreshold.down) {
|
||||
frameRenderer.scrollBy({ top: 70, behavior: "smooth" });
|
||||
const scrollableParent = getScrollParent(dragHandleElement);
|
||||
if (!scrollableParent) return;
|
||||
const scrollThreshold = options.scrollThreshold;
|
||||
|
||||
if (e.clientY < scrollThreshold.up) {
|
||||
const overflow = scrollThreshold.up - e.clientY;
|
||||
const ratio = Math.min(overflow / scrollThreshold.up, 1);
|
||||
const scrollAmount = -maxScrollSpeed * ratio;
|
||||
scrollableParent.scrollBy({ top: scrollAmount });
|
||||
} else if (window.innerHeight - e.clientY < scrollThreshold.down) {
|
||||
const overflow = e.clientY - (window.innerHeight - scrollThreshold.down);
|
||||
const ratio = Math.min(overflow / scrollThreshold.down, 1);
|
||||
const scrollAmount = maxScrollSpeed * ratio;
|
||||
scrollableParent.scrollBy({ top: scrollAmount });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
|
||||
});
|
||||
|
||||
transactions.forEach((transaction) => {
|
||||
// if the transaction has meta of skipImageDeletion get to true, then return (like while clearing the editor content programatically)
|
||||
if (transaction.getMeta("skipImageDeletion")) return;
|
||||
// transaction could be a selection
|
||||
if (!transaction.docChanged) return;
|
||||
|
||||
@@ -45,10 +47,9 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
|
||||
});
|
||||
|
||||
async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> {
|
||||
if (!src) return;
|
||||
try {
|
||||
if (!src) return;
|
||||
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
||||
await deleteImage(assetUrlWithWorkspaceId);
|
||||
await deleteImage(src);
|
||||
} catch (error) {
|
||||
console.error("Error deleting image: ", error);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor
|
||||
if (node.type.name !== nodeType) return;
|
||||
if (pos < 0 || pos > newState.doc.content.size) return;
|
||||
if (oldImageSources.has(node.attrs.src)) return;
|
||||
// if the src is just a id (private bucket), then we don't need to handle restore from here but
|
||||
// only while it fails to load
|
||||
if (!node.attrs.src?.startsWith("http")) return;
|
||||
addedImages.push(node as ImageNode);
|
||||
});
|
||||
|
||||
@@ -48,10 +51,9 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor
|
||||
});
|
||||
|
||||
async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise<void> {
|
||||
if (!src) return;
|
||||
try {
|
||||
if (!src) return;
|
||||
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
||||
await restoreImage(assetUrlWithWorkspaceId);
|
||||
await restoreImage(src);
|
||||
} catch (error) {
|
||||
console.error("Error restoring image: ", error);
|
||||
throw error;
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
export function isFileValid(file: File, showAlert = true): boolean {
|
||||
type TArgs = {
|
||||
file: File;
|
||||
maxFileSize: number;
|
||||
};
|
||||
|
||||
export const isFileValid = (args: TArgs): boolean => {
|
||||
const { file, maxFileSize } = args;
|
||||
|
||||
if (!file) {
|
||||
if (showAlert) {
|
||||
alert("No file selected. Please select a file to upload.");
|
||||
}
|
||||
alert("No file selected. Please select a file to upload.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
if (showAlert) {
|
||||
alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file.");
|
||||
}
|
||||
alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
if (showAlert) {
|
||||
alert("File size too large. Please select a file smaller than 5MB.");
|
||||
}
|
||||
if (file.size > maxFileSize) {
|
||||
alert(`File size too large. Please select a file smaller than ${maxFileSize / 1024 / 1024}MB.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -44,5 +44,6 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
||||
};
|
||||
|
||||
export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { DeleteImage, RestoreImage, UploadImage } from "@/types";
|
||||
|
||||
export type TFileHandler = {
|
||||
getAssetSrc: (path: string) => string;
|
||||
cancel: () => void;
|
||||
delete: DeleteImage;
|
||||
upload: UploadImage;
|
||||
restore: RestoreImage;
|
||||
validation: {
|
||||
/**
|
||||
* @description max file size in bytes
|
||||
* @example enter 5242880( 5* 1024 * 1024) for 5MB
|
||||
*/
|
||||
maxFileSize: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type TEditorFontStyle = "sans-serif" | "serif" | "monospace";
|
||||
|
||||
@@ -6,14 +6,15 @@ import {
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
TAIHandler,
|
||||
TColorEditorCommands,
|
||||
TDisplayConfig,
|
||||
TEditorCommands,
|
||||
TEmbedConfig,
|
||||
TExtensions,
|
||||
TFileHandler,
|
||||
TNonColorEditorCommands,
|
||||
TServerHandler,
|
||||
} from "@/types";
|
||||
|
||||
// editor refs
|
||||
export type EditorReadOnlyRefApi = {
|
||||
getMarkDown: () => string;
|
||||
@@ -36,8 +37,26 @@ export type EditorReadOnlyRefApi = {
|
||||
|
||||
export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||
setEditorValueAtCursorPosition: (content: string) => void;
|
||||
executeMenuItemCommand: (itemKey: TEditorCommands) => void;
|
||||
isMenuItemActive: (itemKey: TEditorCommands) => boolean;
|
||||
executeMenuItemCommand: (
|
||||
props:
|
||||
| {
|
||||
itemKey: TNonColorEditorCommands;
|
||||
}
|
||||
| {
|
||||
itemKey: TColorEditorCommands;
|
||||
color: string | undefined;
|
||||
}
|
||||
) => void;
|
||||
isMenuItemActive: (
|
||||
props:
|
||||
| {
|
||||
itemKey: TNonColorEditorCommands;
|
||||
}
|
||||
| {
|
||||
itemKey: TColorEditorCommands;
|
||||
color: string | undefined;
|
||||
}
|
||||
) => boolean;
|
||||
onStateChange: (callback: () => void) => () => void;
|
||||
setFocusAtPosition: (position: number) => void;
|
||||
isEditorReadyToDiscard: () => boolean;
|
||||
@@ -89,6 +108,7 @@ export interface IReadOnlyEditorProps {
|
||||
containerClassName?: string;
|
||||
displayConfig?: TDisplayConfig;
|
||||
editorClassName?: string;
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
id: string;
|
||||
initialValue: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
|
||||
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<void>;
|
||||
|
||||
export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
|
||||
export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise<void>;
|
||||
|
||||
export type UploadImage = (file: File) => Promise<string>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode } from "react";
|
||||
import { CSSProperties } from "react";
|
||||
import { Editor, Range } from "@tiptap/core";
|
||||
|
||||
export type TEditorCommands =
|
||||
@@ -21,7 +21,12 @@ export type TEditorCommands =
|
||||
| "table"
|
||||
| "image"
|
||||
| "divider"
|
||||
| "issue-embed";
|
||||
| "issue-embed"
|
||||
| "text-color"
|
||||
| "background-color";
|
||||
|
||||
export type TColorEditorCommands = Extract<TEditorCommands, "text-color" | "background-color">;
|
||||
export type TNonColorEditorCommands = Exclude<TEditorCommands, "text-color" | "background-color">;
|
||||
|
||||
export type CommandProps = {
|
||||
editor: Editor;
|
||||
@@ -29,10 +34,12 @@ export type CommandProps = {
|
||||
};
|
||||
|
||||
export type ISlashCommandItem = {
|
||||
key: TEditorCommands;
|
||||
commandKey: TEditorCommands;
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
searchTerms: string[];
|
||||
icon: ReactNode;
|
||||
icon: React.ReactNode;
|
||||
iconContainerStyle?: CSSProperties;
|
||||
command: ({ editor, range }: CommandProps) => void;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// styles
|
||||
// import "./styles/tailwind.css";
|
||||
import "src/styles/variables.css";
|
||||
import "src/styles/editor.css";
|
||||
import "src/styles/table.css";
|
||||
import "src/styles/github-dark.css";
|
||||
@@ -18,6 +19,9 @@ export {
|
||||
|
||||
export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
|
||||
|
||||
// constants
|
||||
export * from "@/constants/common";
|
||||
|
||||
// helpers
|
||||
export * from "@/helpers/common";
|
||||
export * from "@/helpers/editor-commands";
|
||||
|
||||
@@ -1,61 +1,3 @@
|
||||
.editor-container {
|
||||
&.large-font {
|
||||
--font-size-h1: 1.75rem;
|
||||
--font-size-h2: 1.5rem;
|
||||
--font-size-h3: 1.375rem;
|
||||
--font-size-h4: 1.25rem;
|
||||
--font-size-h5: 1.125rem;
|
||||
--font-size-h6: 1rem;
|
||||
--font-size-regular: 1rem;
|
||||
--font-size-list: var(--font-size-regular);
|
||||
--font-size-code: var(--font-size-regular);
|
||||
|
||||
--line-height-h1: 2.25rem;
|
||||
--line-height-h2: 2rem;
|
||||
--line-height-h3: 1.75rem;
|
||||
--line-height-h4: 1.5rem;
|
||||
--line-height-h5: 1.5rem;
|
||||
--line-height-h6: 1.5rem;
|
||||
--line-height-regular: 1.5rem;
|
||||
--line-height-list: var(--line-height-regular);
|
||||
--line-height-code: var(--line-height-regular);
|
||||
}
|
||||
|
||||
&.small-font {
|
||||
--font-size-h1: 1.4rem;
|
||||
--font-size-h2: 1.2rem;
|
||||
--font-size-h3: 1.1rem;
|
||||
--font-size-h4: 1rem;
|
||||
--font-size-h5: 0.9rem;
|
||||
--font-size-h6: 0.8rem;
|
||||
--font-size-regular: 0.8rem;
|
||||
--font-size-list: var(--font-size-regular);
|
||||
--font-size-code: var(--font-size-regular);
|
||||
|
||||
--line-height-h1: 1.8rem;
|
||||
--line-height-h2: 1.6rem;
|
||||
--line-height-h3: 1.4rem;
|
||||
--line-height-h4: 1.2rem;
|
||||
--line-height-h5: 1.2rem;
|
||||
--line-height-h6: 1.2rem;
|
||||
--line-height-regular: 1.2rem;
|
||||
--line-height-list: var(--line-height-regular);
|
||||
--line-height-code: var(--line-height-regular);
|
||||
}
|
||||
|
||||
&.sans-serif {
|
||||
--font-style: sans-serif;
|
||||
}
|
||||
|
||||
&.serif {
|
||||
--font-style: serif;
|
||||
}
|
||||
|
||||
&.monospace {
|
||||
--font-style: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
position: relative;
|
||||
word-wrap: break-word;
|
||||
@@ -439,3 +381,62 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
|
||||
margin-top: 0;
|
||||
}
|
||||
/* end tailwind typography */
|
||||
|
||||
/* text colors */
|
||||
[data-text-color="gray"] {
|
||||
color: var(--editor-colors-gray-text);
|
||||
}
|
||||
[data-text-color="peach"] {
|
||||
color: var(--editor-colors-peach-text);
|
||||
}
|
||||
[data-text-color="pink"] {
|
||||
color: var(--editor-colors-pink-text);
|
||||
}
|
||||
[data-text-color="orange"] {
|
||||
color: var(--editor-colors-orange-text);
|
||||
}
|
||||
[data-text-color="green"] {
|
||||
color: var(--editor-colors-green-text);
|
||||
}
|
||||
[data-text-color="light-blue"] {
|
||||
color: var(--editor-colors-light-blue-text);
|
||||
}
|
||||
[data-text-color="dark-blue"] {
|
||||
color: var(--editor-colors-dark-blue-text);
|
||||
}
|
||||
[data-text-color="purple"] {
|
||||
color: var(--editor-colors-purple-text);
|
||||
}
|
||||
/* [data-text-color="pink-blue-gradient"] {
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
background-image: linear-gradient(90deg, #a961cd 50%, #e75962 100%);
|
||||
} */
|
||||
/* end text colors */
|
||||
|
||||
/* background colors */
|
||||
[data-background-color="gray"] {
|
||||
background-color: var(--editor-colors-gray-background);
|
||||
}
|
||||
[data-background-color="peach"] {
|
||||
background-color: var(--editor-colors-peach-background);
|
||||
}
|
||||
[data-background-color="pink"] {
|
||||
background-color: var(--editor-colors-pink-background);
|
||||
}
|
||||
[data-background-color="orange"] {
|
||||
background-color: var(--editor-colors-orange-background);
|
||||
}
|
||||
[data-background-color="green"] {
|
||||
background-color: var(--editor-colors-green-background);
|
||||
}
|
||||
[data-background-color="light-blue"] {
|
||||
background-color: var(--editor-colors-light-blue-background);
|
||||
}
|
||||
[data-background-color="dark-blue"] {
|
||||
background-color: var(--editor-colors-dark-blue-background);
|
||||
}
|
||||
[data-background-color="purple"] {
|
||||
background-color: var(--editor-colors-purple-background);
|
||||
}
|
||||
/* end background colors */
|
||||
|
||||
96
packages/editor/src/styles/variables.css
Normal file
96
packages/editor/src/styles/variables.css
Normal file
@@ -0,0 +1,96 @@
|
||||
:root {
|
||||
/* text colors */
|
||||
--editor-colors-gray-text: #5c5e63;
|
||||
--editor-colors-peach-text: #ff5b59;
|
||||
--editor-colors-pink-text: #f65385;
|
||||
--editor-colors-orange-text: #fd9038;
|
||||
--editor-colors-green-text: #0fc27b;
|
||||
--editor-colors-light-blue-text: #17bee9;
|
||||
--editor-colors-dark-blue-text: #266df0;
|
||||
--editor-colors-purple-text: #9162f9;
|
||||
/* end text colors */
|
||||
}
|
||||
|
||||
/* text background colors */
|
||||
[data-theme="light"],
|
||||
[data-theme="light-contrast"] {
|
||||
--editor-colors-gray-background: #d6d6d8;
|
||||
--editor-colors-peach-background: #ffd5d7;
|
||||
--editor-colors-pink-background: #fdd4e3;
|
||||
--editor-colors-orange-background: #ffe3cd;
|
||||
--editor-colors-green-background: #c3f0de;
|
||||
--editor-colors-light-blue-background: #c5eff9;
|
||||
--editor-colors-dark-blue-background: #c9dafb;
|
||||
--editor-colors-purple-background: #e3d8fd;
|
||||
}
|
||||
[data-theme="dark"],
|
||||
[data-theme="dark-contrast"] {
|
||||
--editor-colors-gray-background: #404144;
|
||||
--editor-colors-peach-background: #593032;
|
||||
--editor-colors-pink-background: #562e3d;
|
||||
--editor-colors-orange-background: #583e2a;
|
||||
--editor-colors-green-background: #1d4a3b;
|
||||
--editor-colors-light-blue-background: #1f495c;
|
||||
--editor-colors-dark-blue-background: #223558;
|
||||
--editor-colors-purple-background: #3d325a;
|
||||
}
|
||||
/* end text background colors */
|
||||
|
||||
.editor-container {
|
||||
/* font sizes and line heights */
|
||||
&.large-font {
|
||||
--font-size-h1: 1.75rem;
|
||||
--font-size-h2: 1.5rem;
|
||||
--font-size-h3: 1.375rem;
|
||||
--font-size-h4: 1.25rem;
|
||||
--font-size-h5: 1.125rem;
|
||||
--font-size-h6: 1rem;
|
||||
--font-size-regular: 1rem;
|
||||
--font-size-list: var(--font-size-regular);
|
||||
--font-size-code: var(--font-size-regular);
|
||||
|
||||
--line-height-h1: 2.25rem;
|
||||
--line-height-h2: 2rem;
|
||||
--line-height-h3: 1.75rem;
|
||||
--line-height-h4: 1.5rem;
|
||||
--line-height-h5: 1.5rem;
|
||||
--line-height-h6: 1.5rem;
|
||||
--line-height-regular: 1.5rem;
|
||||
--line-height-list: var(--line-height-regular);
|
||||
--line-height-code: var(--line-height-regular);
|
||||
}
|
||||
&.small-font {
|
||||
--font-size-h1: 1.4rem;
|
||||
--font-size-h2: 1.2rem;
|
||||
--font-size-h3: 1.1rem;
|
||||
--font-size-h4: 1rem;
|
||||
--font-size-h5: 0.9rem;
|
||||
--font-size-h6: 0.8rem;
|
||||
--font-size-regular: 0.8rem;
|
||||
--font-size-list: var(--font-size-regular);
|
||||
--font-size-code: var(--font-size-regular);
|
||||
|
||||
--line-height-h1: 1.8rem;
|
||||
--line-height-h2: 1.6rem;
|
||||
--line-height-h3: 1.4rem;
|
||||
--line-height-h4: 1.2rem;
|
||||
--line-height-h5: 1.2rem;
|
||||
--line-height-h6: 1.2rem;
|
||||
--line-height-regular: 1.2rem;
|
||||
--line-height-list: var(--line-height-regular);
|
||||
--line-height-code: var(--line-height-regular);
|
||||
}
|
||||
/* end font sizes and line heights */
|
||||
|
||||
/* font styles */
|
||||
&.sans-serif {
|
||||
--font-style: "Inter", sans-serif;
|
||||
}
|
||||
&.serif {
|
||||
--font-style: serif;
|
||||
}
|
||||
&.monospace {
|
||||
--font-style: monospace;
|
||||
}
|
||||
/* end font styles */
|
||||
}
|
||||
Reference in New Issue
Block a user