[PE-304] feat: make floating link generic and use it for all editors (#6552)
* fix: make floating link generic and use it for all editors * fix: link component behaviour with selected text fixed and storage is now typed * chore: link view seperated * fix: editor link edit view across multiple links resets now * fix: link view container * fix: cleaning up * fix: url validation
This commit is contained in:
parent
65a0530cfe
commit
a57c37c26c
32 changed files with 458 additions and 330 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -88,6 +88,7 @@ deploy/selfhost/plane-app/
|
||||||
*storybook.log
|
*storybook.log
|
||||||
output.css
|
output.css
|
||||||
|
|
||||||
|
dev-editor
|
||||||
# Redis
|
# Redis
|
||||||
*.rdb
|
*.rdb
|
||||||
*.rdb.gz
|
*.rdb.gz
|
||||||
|
|
@ -1,20 +1,6 @@
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { Editor } from "@tiptap/react";
|
||||||
import {
|
|
||||||
autoUpdate,
|
|
||||||
computePosition,
|
|
||||||
flip,
|
|
||||||
hide,
|
|
||||||
shift,
|
|
||||||
useDismiss,
|
|
||||||
useFloating,
|
|
||||||
useInteractions,
|
|
||||||
} from "@floating-ui/react";
|
|
||||||
import { Node } from "@tiptap/pm/model";
|
|
||||||
import { EditorView } from "@tiptap/pm/view";
|
|
||||||
import { Editor, ReactRenderer } from "@tiptap/react";
|
|
||||||
// components
|
// components
|
||||||
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
|
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
|
||||||
import { LinkView, LinkViewProps } from "@/components/links";
|
|
||||||
import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus";
|
import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus";
|
||||||
// types
|
// types
|
||||||
import { TAIHandler, TDisplayConfig } from "@/types";
|
import { TAIHandler, TDisplayConfig } from "@/types";
|
||||||
|
|
@ -31,108 +17,9 @@ type IPageRenderer = {
|
||||||
|
|
||||||
export const PageRenderer = (props: IPageRenderer) => {
|
export const PageRenderer = (props: IPageRenderer) => {
|
||||||
const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
|
const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
|
||||||
// states
|
|
||||||
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [coordinates, setCoordinates] = useState<{ x: number; y: number }>();
|
|
||||||
const [cleanup, setCleanup] = useState(() => () => {});
|
|
||||||
|
|
||||||
const { refs, floatingStyles, context } = useFloating({
|
|
||||||
open: isOpen,
|
|
||||||
onOpenChange: setIsOpen,
|
|
||||||
middleware: [flip(), shift(), hide({ strategy: "referenceHidden" })],
|
|
||||||
whileElementsMounted: autoUpdate,
|
|
||||||
});
|
|
||||||
|
|
||||||
const dismiss = useDismiss(context, {
|
|
||||||
ancestorScroll: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { getFloatingProps } = useInteractions([dismiss]);
|
|
||||||
|
|
||||||
const floatingElementRef = useRef<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const closeLinkView = () => setIsOpen(false);
|
|
||||||
|
|
||||||
const handleLinkHover = useCallback(
|
|
||||||
(event: React.MouseEvent) => {
|
|
||||||
if (!editor) return;
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
const view = editor.view as EditorView;
|
|
||||||
|
|
||||||
if (!target || !view) return;
|
|
||||||
const pos = view.posAtDOM(target, 0);
|
|
||||||
if (!pos || pos < 0) return;
|
|
||||||
|
|
||||||
if (target.nodeName !== "A") return;
|
|
||||||
|
|
||||||
const node = view.state.doc.nodeAt(pos) as Node;
|
|
||||||
if (!node || !node.isAtom) return;
|
|
||||||
|
|
||||||
// we need to check if any of the marks are links
|
|
||||||
const marks = node.marks;
|
|
||||||
|
|
||||||
if (!marks) return;
|
|
||||||
|
|
||||||
const linkMark = marks.find((mark) => mark.type.name === "link");
|
|
||||||
|
|
||||||
if (!linkMark) return;
|
|
||||||
|
|
||||||
if (floatingElementRef.current) {
|
|
||||||
floatingElementRef.current?.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cleanup) cleanup();
|
|
||||||
|
|
||||||
const href = linkMark.attrs.href;
|
|
||||||
const componentLink = new ReactRenderer(LinkView, {
|
|
||||||
props: {
|
|
||||||
view: "LinkPreview",
|
|
||||||
url: href,
|
|
||||||
editor: editor,
|
|
||||||
from: pos,
|
|
||||||
to: pos + node.nodeSize,
|
|
||||||
},
|
|
||||||
editor,
|
|
||||||
});
|
|
||||||
|
|
||||||
const referenceElement = target as HTMLElement;
|
|
||||||
const floatingElement = componentLink.element as HTMLElement;
|
|
||||||
|
|
||||||
floatingElementRef.current = floatingElement;
|
|
||||||
|
|
||||||
const cleanupFunc = autoUpdate(referenceElement, floatingElement, () => {
|
|
||||||
computePosition(referenceElement, floatingElement, {
|
|
||||||
placement: "bottom",
|
|
||||||
middleware: [
|
|
||||||
flip(),
|
|
||||||
shift(),
|
|
||||||
hide({
|
|
||||||
strategy: "referenceHidden",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}).then(({ x, y }) => {
|
|
||||||
setCoordinates({ x: x - 300, y: y - 50 });
|
|
||||||
setIsOpen(true);
|
|
||||||
setLinkViewProps({
|
|
||||||
closeLinkView: closeLinkView,
|
|
||||||
view: "LinkPreview",
|
|
||||||
url: href,
|
|
||||||
editor: editor,
|
|
||||||
from: pos,
|
|
||||||
to: pos + node.nodeSize,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
setCleanup(cleanupFunc);
|
|
||||||
},
|
|
||||||
[editor, cleanup]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="frame-renderer flex-grow w-full">
|
||||||
<div className="frame-renderer flex-grow w-full" onMouseOver={handleLinkHover}>
|
|
||||||
<EditorContainer
|
<EditorContainer
|
||||||
displayConfig={displayConfig}
|
displayConfig={displayConfig}
|
||||||
editor={editor}
|
editor={editor}
|
||||||
|
|
@ -141,23 +28,13 @@ export const PageRenderer = (props: IPageRenderer) => {
|
||||||
>
|
>
|
||||||
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
|
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
|
||||||
{editor.isEditable && (
|
{editor.isEditable && (
|
||||||
<div>
|
<>
|
||||||
{bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
|
{bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
|
||||||
<BlockMenu editor={editor} />
|
<BlockMenu editor={editor} />
|
||||||
<AIFeaturesMenu menu={aiHandler?.menu} />
|
<AIFeaturesMenu menu={aiHandler?.menu} />
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</EditorContainer>
|
</EditorContainer>
|
||||||
</div>
|
</div>
|
||||||
{isOpen && linkViewProps && coordinates && (
|
|
||||||
<div
|
|
||||||
style={{ ...floatingStyles, left: `${coordinates.x}px`, top: `${coordinates.y}px` }}
|
|
||||||
className="absolute"
|
|
||||||
ref={refs.setFloating}
|
|
||||||
>
|
|
||||||
<LinkView {...linkViewProps} style={floatingStyles} {...getFloatingProps()} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,25 @@
|
||||||
import { Editor } from "@tiptap/react";
|
import { Editor } from "@tiptap/react";
|
||||||
import { FC, ReactNode } from "react";
|
import { FC, ReactNode, useRef } from "react";
|
||||||
// plane utils
|
// plane utils
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
// constants
|
// constants
|
||||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||||
// types
|
// types
|
||||||
import { TDisplayConfig } from "@/types";
|
import { TDisplayConfig } from "@/types";
|
||||||
|
// components
|
||||||
|
import { LinkViewContainer } from "./link-view-container";
|
||||||
|
|
||||||
interface EditorContainerProps {
|
interface EditorContainerProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
displayConfig: TDisplayConfig;
|
displayConfig: TDisplayConfig;
|
||||||
editor: Editor | null;
|
editor: Editor;
|
||||||
editorContainerClassName: string;
|
editorContainerClassName: string;
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||||
const { children, displayConfig, editor, editorContainerClassName, id } = props;
|
const { children, displayConfig, editor, editorContainerClassName, id } = props;
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||||
if (event.target !== event.currentTarget) return;
|
if (event.target !== event.currentTarget) return;
|
||||||
|
|
@ -66,7 +69,9 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
|
ref={containerRef}
|
||||||
id={`editor-container-${id}`}
|
id={`editor-container-${id}`}
|
||||||
onClick={handleContainerClick}
|
onClick={handleContainerClick}
|
||||||
onMouseLeave={handleContainerMouseLeave}
|
onMouseLeave={handleContainerMouseLeave}
|
||||||
|
|
@ -82,6 +87,8 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
<LinkViewContainer editor={editor} containerRef={containerRef} />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { FC, ReactNode } from "react";
|
|
||||||
import { Editor, EditorContent } from "@tiptap/react";
|
import { Editor, EditorContent } from "@tiptap/react";
|
||||||
|
import { FC, ReactNode } from "react";
|
||||||
|
|
||||||
interface EditorContentProps {
|
interface EditorContentProps {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
import { autoUpdate, flip, hide, shift, useDismiss, useFloating, useInteractions } from "@floating-ui/react";
|
||||||
|
import { Editor, useEditorState } from "@tiptap/react";
|
||||||
|
import { FC, useCallback, useEffect, useState } from "react";
|
||||||
|
// components
|
||||||
|
import { LinkView, LinkViewProps } from "@/components/links";
|
||||||
|
|
||||||
|
interface LinkViewContainerProps {
|
||||||
|
editor: Editor;
|
||||||
|
containerRef: React.RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LinkViewContainer: FC<LinkViewContainerProps> = ({ editor, containerRef }) => {
|
||||||
|
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [virtualElement, setVirtualElement] = useState<any>(null);
|
||||||
|
|
||||||
|
const editorState = useEditorState({
|
||||||
|
editor,
|
||||||
|
selector: ({ editor }: { editor: Editor }) => ({
|
||||||
|
linkExtensionStorage: editor.storage.link,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { refs, floatingStyles, context } = useFloating({
|
||||||
|
open: isOpen,
|
||||||
|
onOpenChange: setIsOpen,
|
||||||
|
elements: {
|
||||||
|
reference: virtualElement,
|
||||||
|
},
|
||||||
|
middleware: [
|
||||||
|
flip({
|
||||||
|
fallbackPlacements: ["top", "bottom"],
|
||||||
|
}),
|
||||||
|
shift({
|
||||||
|
padding: 5,
|
||||||
|
}),
|
||||||
|
hide(),
|
||||||
|
],
|
||||||
|
whileElementsMounted: autoUpdate,
|
||||||
|
placement: "bottom-start",
|
||||||
|
});
|
||||||
|
|
||||||
|
const dismiss = useDismiss(context);
|
||||||
|
|
||||||
|
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]);
|
||||||
|
|
||||||
|
const handleLinkHover = useCallback(
|
||||||
|
(event: MouseEvent) => {
|
||||||
|
if (!editor || editorState.linkExtensionStorage.isBubbleMenuOpen) return;
|
||||||
|
|
||||||
|
// Find the closest anchor tag from the event target
|
||||||
|
const target = (event.target as HTMLElement)?.closest("a");
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
const referenceProps = getReferenceProps();
|
||||||
|
Object.entries(referenceProps).forEach(([key, value]) => {
|
||||||
|
target.setAttribute(key, value as string);
|
||||||
|
});
|
||||||
|
|
||||||
|
const view = editor.view;
|
||||||
|
if (!view) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pos = view.posAtDOM(target, 0);
|
||||||
|
if (pos === undefined || pos < 0) return;
|
||||||
|
|
||||||
|
const node = view.state.doc.nodeAt(pos);
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
const linkMark = node.marks?.find((mark) => mark.type.name === "link");
|
||||||
|
if (!linkMark) return;
|
||||||
|
|
||||||
|
setVirtualElement(target);
|
||||||
|
|
||||||
|
// Only update if not already open or if hovering over a different link
|
||||||
|
if (!isOpen || (linkViewProps && (linkViewProps.from !== pos || linkViewProps.to !== pos + node.nodeSize))) {
|
||||||
|
setLinkViewProps({
|
||||||
|
view: "LinkPreview", // Always start with preview for new links
|
||||||
|
url: linkMark.attrs.href,
|
||||||
|
text: node.text || "",
|
||||||
|
editor: editor,
|
||||||
|
from: pos,
|
||||||
|
to: pos + node.nodeSize,
|
||||||
|
closeLinkView: () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
editorState.linkExtensionStorage.isPreviewOpen = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error handling link hover:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editor, editorState.linkExtensionStorage, getReferenceProps, isOpen, linkViewProps]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set up event listeners
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.addEventListener("mouseover", handleLinkHover);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener("mouseover", handleLinkHover);
|
||||||
|
};
|
||||||
|
}, [handleLinkHover]);
|
||||||
|
|
||||||
|
// Close link view when bubble menu opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (editorState.linkExtensionStorage.isBubbleMenuOpen && isOpen) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}, [editorState.linkExtensionStorage, isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isOpen && linkViewProps && virtualElement && (
|
||||||
|
<div ref={refs.setFloating} style={{ ...floatingStyles, zIndex: 100 }} {...getFloatingProps()}>
|
||||||
|
<LinkView {...linkViewProps} style={floatingStyles} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
export * from "./link-edit-view";
|
export * from "./link-edit-view";
|
||||||
export * from "./link-input-view";
|
|
||||||
export * from "./link-preview";
|
export * from "./link-preview";
|
||||||
export * from "./link-view";
|
export * from "./link-view";
|
||||||
|
|
|
||||||
|
|
@ -1,131 +1,146 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { Node } from "@tiptap/pm/model";
|
import { Node } from "@tiptap/pm/model";
|
||||||
import { Link2Off } from "lucide-react";
|
import { Link2Off } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
// components
|
// components
|
||||||
import { LinkViewProps } from "@/components/links";
|
import { LinkViewProps, LinkViews } from "@/components/links";
|
||||||
// helpers
|
// helpers
|
||||||
import { isValidHttpUrl } from "@/helpers/common";
|
import { isValidHttpUrl } from "@/helpers/common";
|
||||||
|
|
||||||
const InputView = ({
|
interface InputViewProps {
|
||||||
label,
|
|
||||||
defaultValue,
|
|
||||||
placeholder,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
label: string;
|
||||||
defaultValue: string;
|
value: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
onChange: (value: string) => void;
|
||||||
}) => (
|
autoFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InputView = ({ label, value, placeholder, onChange, autoFocus }: InputViewProps) => (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="inline-block font-semibold text-xs text-custom-text-400">{label}</label>
|
<label className="inline-block font-semibold text-xs text-custom-text-400">{label}</label>
|
||||||
<input
|
<input
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onClick={(e) => {
|
onClick={(e) => e.stopPropagation()}
|
||||||
e.stopPropagation();
|
className="w-[280px] outline-none bg-custom-background-90 text-custom-text-900 text-sm border border-custom-border-300 rounded-md p-2"
|
||||||
}}
|
value={value}
|
||||||
className="w-[280px] outline-none bg-custom-background-90 text-custom-text-900 text-sm"
|
onChange={(e) => onChange(e.target.value)}
|
||||||
defaultValue={defaultValue}
|
autoFocus={autoFocus}
|
||||||
onChange={onChange}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const LinkEditView = ({
|
interface LinkEditViewProps {
|
||||||
viewProps,
|
|
||||||
}: {
|
|
||||||
viewProps: LinkViewProps;
|
viewProps: LinkViewProps;
|
||||||
switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void;
|
switchView: (view: LinkViews) => void;
|
||||||
}) => {
|
}
|
||||||
const { editor, from, to } = viewProps;
|
|
||||||
|
|
||||||
const [positionRef, setPositionRef] = useState({ from: from, to: to });
|
export const LinkEditView = ({ viewProps }: LinkEditViewProps) => {
|
||||||
const [localUrl, setLocalUrl] = useState(viewProps.url);
|
const { editor, from, to, url: initialUrl, text: initialText, closeLinkView } = viewProps;
|
||||||
|
|
||||||
const linkRemoved = useRef<boolean>();
|
// State
|
||||||
|
const [positionRef] = useState({ from, to });
|
||||||
const getText = (from: number, to: number) => {
|
const [localUrl, setLocalUrl] = useState(initialUrl);
|
||||||
if (to >= editor.state.doc.content.size) return "";
|
const [localText, setLocalText] = useState(initialText ?? "");
|
||||||
|
const [linkRemoved, setLinkRemoved] = useState(false);
|
||||||
const text = editor.state.doc.textBetween(from, to, "\n");
|
const hasSubmitted = useRef(false);
|
||||||
return text;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateLink = (url: string) => {
|
|
||||||
setLocalUrl(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Effects
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() =>
|
||||||
if (linkRemoved.current) return;
|
// Cleanup effect: Remove link if not submitted and url is empty
|
||||||
|
() => {
|
||||||
const url = isValidHttpUrl(localUrl) ? localUrl : viewProps.url;
|
if (!hasSubmitted.current && !linkRemoved && initialUrl === "") {
|
||||||
|
try {
|
||||||
if (to >= editor.state.doc.content.size) return;
|
removeLink();
|
||||||
|
} catch (e) {}
|
||||||
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
|
}
|
||||||
editor.view.dispatch(editor.state.tr.addMark(from, to, editor.schema.marks.link.create({ href: url })));
|
|
||||||
},
|
},
|
||||||
[localUrl, editor, from, to, viewProps.url]
|
[linkRemoved, initialUrl]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUpdateText = (text: string) => {
|
// Sync state with props
|
||||||
if (text === "") {
|
useEffect(() => {
|
||||||
return;
|
setLocalUrl(initialUrl);
|
||||||
}
|
}, [initialUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialText) setLocalText(initialText);
|
||||||
|
}, [initialText]);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleTextChange = useCallback((value: string) => {
|
||||||
|
if (value.trim() !== "") setLocalText(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyChanges = useCallback((): boolean => {
|
||||||
|
if (linkRemoved) return false;
|
||||||
|
hasSubmitted.current = true;
|
||||||
|
|
||||||
|
const { url, isValid } = isValidHttpUrl(localUrl);
|
||||||
|
if (to >= editor.state.doc.content.size || !isValid) return false;
|
||||||
|
|
||||||
|
// Apply URL change
|
||||||
|
const tr = editor.state.tr;
|
||||||
|
tr.removeMark(from, to, editor.schema.marks.link).addMark(from, to, editor.schema.marks.link.create({ href: url }));
|
||||||
|
editor.view.dispatch(tr);
|
||||||
|
|
||||||
|
// Apply text change if different
|
||||||
|
if (localText !== initialText) {
|
||||||
const node = editor.view.state.doc.nodeAt(from) as Node;
|
const node = editor.view.state.doc.nodeAt(from) as Node;
|
||||||
if (!node) return;
|
if (!node || !node.marks) return false;
|
||||||
const marks = node.marks;
|
|
||||||
if (!marks) return;
|
|
||||||
|
|
||||||
editor.chain().setTextSelection(from).run();
|
|
||||||
|
|
||||||
editor.chain().deleteRange({ from: positionRef.from, to: positionRef.to }).run();
|
|
||||||
editor.chain().insertContent(text).run();
|
|
||||||
|
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.setTextSelection({
|
.setTextSelection(from)
|
||||||
from: from,
|
.deleteRange({ from: positionRef.from, to: positionRef.to })
|
||||||
to: from + text.length,
|
.insertContent(localText)
|
||||||
})
|
.setTextSelection({ from, to: from + localText.length })
|
||||||
.run();
|
.run();
|
||||||
|
//
|
||||||
setPositionRef({ from: from, to: from + text.length });
|
// Restore marks
|
||||||
|
node.marks.forEach((mark) => {
|
||||||
marks.forEach((mark) => {
|
|
||||||
editor.chain().setMark(mark.type.name, mark.attrs).run();
|
editor.chain().setMark(mark.type.name, mark.attrs).run();
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
const removeLink = () => {
|
return true;
|
||||||
|
}, [editor, from, to, initialText, localText, localUrl]);
|
||||||
|
|
||||||
|
const removeLink = useCallback(() => {
|
||||||
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
|
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
|
||||||
linkRemoved.current = true;
|
setLinkRemoved(true);
|
||||||
viewProps.closeLinkView();
|
closeLinkView();
|
||||||
};
|
}, [editor, from, to, closeLinkView]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (applyChanges()) {
|
||||||
|
closeLinkView();
|
||||||
|
setLocalUrl("");
|
||||||
|
setLocalText("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[applyChanges, closeLinkView]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onKeyDown={(e) => e.key === "Enter" && viewProps.closeLinkView()}
|
onKeyDown={handleKeyDown}
|
||||||
className="shadow-md rounded p-2 flex flex-col gap-3 bg-custom-background-90 border-custom-border-100 border-2"
|
className="shadow-md rounded p-2 flex flex-col gap-3 bg-custom-background-90 border-custom-border-100 border-2 animate-in fade-in translate-y-1 duration-200 origin-center"
|
||||||
|
style={{
|
||||||
|
animationTimingFunction: "cubic-bezier(.55, .085, .68, .53)",
|
||||||
|
transformOrigin: "center",
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<InputView
|
<InputView label="URL" placeholder="Enter or paste URL" value={localUrl} onChange={setLocalUrl} autoFocus />
|
||||||
label={"URL"}
|
<InputView label="Text" placeholder="Enter Text to display" value={localText} onChange={handleTextChange} />
|
||||||
placeholder={"Enter or paste URL"}
|
|
||||||
defaultValue={localUrl}
|
|
||||||
onChange={(e) => handleUpdateLink(e.target.value)}
|
|
||||||
/>
|
|
||||||
<InputView
|
|
||||||
label={"Text"}
|
|
||||||
placeholder={"Enter Text to display"}
|
|
||||||
defaultValue={getText(from, to)}
|
|
||||||
onChange={(e) => handleUpdateText(e.target.value)}
|
|
||||||
/>
|
|
||||||
<div className="mb-1 bg-custom-border-300 h-[1px] w-full gap-2" />
|
<div className="mb-1 bg-custom-border-300 h-[1px] w-full gap-2" />
|
||||||
<div className="flex text-sm text-custom-text-800 gap-2 items-center">
|
<div className="flex text-sm text-custom-text-800 gap-2 items-center">
|
||||||
<Link2Off size={14} className="inline-block" />
|
<Link2Off size={14} className="inline-block" />
|
||||||
<button onClick={() => removeLink()} className="cursor-pointer">
|
<button onClick={removeLink} className="cursor-pointer hover:text-custom-text-400 transition-colors">
|
||||||
Remove Link
|
Remove Link
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
// components
|
|
||||||
import { LinkViewProps } from "@/components/links";
|
|
||||||
|
|
||||||
export const LinkInputView = ({}: {
|
|
||||||
viewProps: LinkViewProps;
|
|
||||||
switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void;
|
|
||||||
}) => <p>LinkInputView</p>;
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { Copy, GlobeIcon, Link2Off, PencilIcon } from "lucide-react";
|
import { Copy, GlobeIcon, Link2Off, PencilIcon } from "lucide-react";
|
||||||
// components
|
// components
|
||||||
import { LinkViewProps } from "@/components/links";
|
import { LinkViewProps, LinkViews } from "@/components/links";
|
||||||
|
|
||||||
export const LinkPreview = ({
|
export const LinkPreview = ({
|
||||||
viewProps,
|
viewProps,
|
||||||
switchView,
|
switchView,
|
||||||
}: {
|
}: {
|
||||||
viewProps: LinkViewProps;
|
viewProps: LinkViewProps;
|
||||||
switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void;
|
switchView: (view: LinkViews) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { editor, from, to, url } = viewProps;
|
const { editor, from, to, url } = viewProps;
|
||||||
|
|
||||||
|
|
@ -22,20 +22,33 @@ export const LinkPreview = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute left-0 top-0 max-w-max">
|
<div
|
||||||
|
className="absolute left-0 top-0 max-w-max animate-in fade-in translate-y-1 duration-300 origin-center"
|
||||||
|
style={{
|
||||||
|
animationTimingFunction: "cubic-bezier(.55, .085, .68, .53)",
|
||||||
|
transformOrigin: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="shadow-md items-center rounded p-2 flex gap-3 bg-custom-background-90 border-custom-border-100 border-2 text-custom-text-300 text-xs">
|
<div className="shadow-md items-center rounded p-2 flex gap-3 bg-custom-background-90 border-custom-border-100 border-2 text-custom-text-300 text-xs">
|
||||||
<GlobeIcon size={14} className="inline-block" />
|
<GlobeIcon size={14} className="inline-block" />
|
||||||
<p>{url.length > 40 ? url.slice(0, 40) + "..." : url}</p>
|
<p>{url?.length > 40 ? url.slice(0, 40) + "..." : url}</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={copyLinkToClipboard} className="cursor-pointer">
|
<button onClick={copyLinkToClipboard} className="cursor-pointer hover:text-custom-text-100 transition-colors">
|
||||||
<Copy size={14} className="inline-block" />
|
<Copy size={14} className="inline-block" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => switchView("LinkEditView")} className="cursor-pointer">
|
{editor.isEditable && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => switchView("LinkEditView")}
|
||||||
|
className="cursor-pointer hover:text-custom-text-100 transition-colors"
|
||||||
|
>
|
||||||
<PencilIcon size={14} className="inline-block" />
|
<PencilIcon size={14} className="inline-block" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={removeLink} className="cursor-pointer">
|
<button onClick={removeLink} className="cursor-pointer hover:text-custom-text-100 transition-colors">
|
||||||
<Link2Off size={14} className="inline-block" />
|
<Link2Off size={14} className="inline-block" />
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,25 @@
|
||||||
import { CSSProperties, useEffect, useState } from "react";
|
|
||||||
import { Editor } from "@tiptap/react";
|
import { Editor } from "@tiptap/react";
|
||||||
|
import { CSSProperties, useEffect, useState } from "react";
|
||||||
// components
|
// components
|
||||||
import { LinkEditView, LinkInputView, LinkPreview } from "@/components/links";
|
import { LinkEditView, LinkPreview } from "@/components/links";
|
||||||
|
|
||||||
|
export type LinkViews = "LinkPreview" | "LinkEditView";
|
||||||
|
|
||||||
export interface LinkViewProps {
|
export interface LinkViewProps {
|
||||||
view?: "LinkPreview" | "LinkEditView" | "LinkInputView";
|
view?: LinkViews;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
from: number;
|
from: number;
|
||||||
to: number;
|
to: number;
|
||||||
url: string;
|
url: string;
|
||||||
|
text?: string;
|
||||||
closeLinkView: () => void;
|
closeLinkView: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LinkView = (props: LinkViewProps & { style: CSSProperties }) => {
|
export const LinkView = (props: LinkViewProps & { style: CSSProperties }) => {
|
||||||
const [currentView, setCurrentView] = useState(props.view ?? "LinkInputView");
|
const [currentView, setCurrentView] = useState<LinkViews>(props.view ?? "LinkPreview");
|
||||||
const [prevFrom, setPrevFrom] = useState(props.from);
|
const [prevFrom, setPrevFrom] = useState(props.from);
|
||||||
|
|
||||||
const switchView = (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => {
|
const switchView = (view: LinkViews) => {
|
||||||
setCurrentView(view);
|
setCurrentView(view);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -27,16 +30,10 @@ export const LinkView = (props: LinkViewProps & { style: CSSProperties }) => {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const renderView = () => {
|
return (
|
||||||
switch (currentView) {
|
<>
|
||||||
case "LinkPreview":
|
{currentView === "LinkPreview" && <LinkPreview viewProps={props} switchView={switchView} />}
|
||||||
return <LinkPreview viewProps={props} switchView={switchView} />;
|
{currentView === "LinkEditView" && <LinkEditView viewProps={props} switchView={switchView} />}
|
||||||
case "LinkEditView":
|
</>
|
||||||
return <LinkEditView viewProps={props} switchView={switchView} />;
|
);
|
||||||
case "LinkInputView":
|
|
||||||
return <LinkInputView viewProps={props} switchView={switchView} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return renderView();
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,11 @@ export const BubbleMenuLinkSelector: FC<Props> = (props) => {
|
||||||
const handleLinkSubmit = useCallback(() => {
|
const handleLinkSubmit = useCallback(() => {
|
||||||
const input = inputRef.current;
|
const input = inputRef.current;
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
let url = input.value;
|
const url = input.value;
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
if (!url.startsWith("http")) url = `http://${url}`;
|
const { isValid, url: validatedUrl } = isValidHttpUrl(url);
|
||||||
if (isValidHttpUrl(url)) {
|
if (isValid) {
|
||||||
setLinkEditor(editor, url);
|
setLinkEditor(editor, validatedUrl);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setError(false);
|
setError(false);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
|
||||||
empty ||
|
empty ||
|
||||||
!editor.isEditable ||
|
!editor.isEditable ||
|
||||||
editor.isActive("image") ||
|
editor.isActive("image") ||
|
||||||
|
editor.isActive("imageComponent") ||
|
||||||
isNodeSelection(selection) ||
|
isNodeSelection(selection) ||
|
||||||
isCellSelection(selection) ||
|
isCellSelection(selection) ||
|
||||||
isSelecting
|
isSelecting
|
||||||
|
|
@ -102,7 +103,11 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
|
||||||
tippyOptions: {
|
tippyOptions: {
|
||||||
moveTransition: "transform 0.15s ease-out",
|
moveTransition: "transform 0.15s ease-out",
|
||||||
duration: [300, 0],
|
duration: [300, 0],
|
||||||
|
onShow: () => {
|
||||||
|
props.editor.storage.link.isBubbleMenuOpen = true;
|
||||||
|
},
|
||||||
onHidden: () => {
|
onHidden: () => {
|
||||||
|
props.editor.storage.link.isBubbleMenuOpen = false;
|
||||||
setIsNodeSelectorOpen(false);
|
setIsNodeSelectorOpen(false);
|
||||||
setIsLinkSelectorOpen(false);
|
setIsLinkSelectorOpen(false);
|
||||||
setIsColorSelectorOpen(false);
|
setIsColorSelectorOpen(false);
|
||||||
|
|
|
||||||
|
|
@ -142,8 +142,8 @@ export const UnderLineItem = (editor: Editor): EditorMenuItem<"underline"> => ({
|
||||||
icon: UnderlineIcon,
|
icon: UnderlineIcon,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strike"> => ({
|
export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough"> => ({
|
||||||
key: "strike",
|
key: "strikethrough",
|
||||||
name: "Strikethrough",
|
name: "Strikethrough",
|
||||||
isActive: () => editor?.isActive("strike"),
|
isActive: () => editor?.isActive("strike"),
|
||||||
command: () => toggleStrike(editor),
|
command: () => toggleStrike(editor),
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ export const TEXT_ALIGNMENT_ITEMS: ToolbarMenuItem<"text-align">[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strike">[] = [
|
const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strikethrough">[] = [
|
||||||
{
|
{
|
||||||
itemKey: "bold",
|
itemKey: "bold",
|
||||||
renderKey: "bold",
|
renderKey: "bold",
|
||||||
|
|
@ -113,7 +113,7 @@ const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strik
|
||||||
editors: ["lite", "document"],
|
editors: ["lite", "document"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
itemKey: "strike",
|
itemKey: "strikethrough",
|
||||||
renderKey: "strikethrough",
|
renderKey: "strikethrough",
|
||||||
name: "Strikethrough",
|
name: "Strikethrough",
|
||||||
icon: Strikethrough,
|
icon: Strikethrough,
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||||
autolink: true,
|
autolink: true,
|
||||||
linkOnPaste: true,
|
linkOnPaste: true,
|
||||||
protocols: ["http", "https"],
|
protocols: ["http", "https"],
|
||||||
validate: (url: string) => isValidHttpUrl(url),
|
validate: (url: string) => isValidHttpUrl(url).isValid,
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class:
|
class:
|
||||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
// extensions
|
// extensions
|
||||||
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
|
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
|
||||||
|
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||||
|
|
||||||
export type CustoBaseImageNodeViewProps = {
|
export type CustoBaseImageNodeViewProps = {
|
||||||
getPos: () => number;
|
getPos: () => number;
|
||||||
|
|
@ -76,7 +77,7 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
|
||||||
failedToLoadImage={failedToLoadImage}
|
failedToLoadImage={failedToLoadImage}
|
||||||
getPos={getPos}
|
getPos={getPos}
|
||||||
loadImageFromFileSystem={setImageFromFileSystem}
|
loadImageFromFileSystem={setImageFromFileSystem}
|
||||||
maxFileSize={editor.storage.imageComponent?.maxFileSize}
|
maxFileSize={getExtensionStorage(editor, "imageComponent").maxFileSize}
|
||||||
node={node}
|
node={node}
|
||||||
setIsUploaded={setIsUploaded}
|
setIsUploaded={setIsUploaded}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ export const getImageComponentImageFileMap = (editor: Editor) =>
|
||||||
export interface UploadImageExtensionStorage {
|
export interface UploadImageExtensionStorage {
|
||||||
assetsUploadStatus: TFileHandler["assetsUploadStatus"];
|
assetsUploadStatus: TFileHandler["assetsUploadStatus"];
|
||||||
fileMap: Map<string, UploadEntity>;
|
fileMap: Map<string, UploadEntity>;
|
||||||
|
deletedImageSet: Map<string, boolean>;
|
||||||
|
uploadInProgress: boolean;
|
||||||
|
maxFileSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
|
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,9 @@ export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
|
||||||
addStorage() {
|
addStorage() {
|
||||||
return {
|
return {
|
||||||
fileMap: new Map(),
|
fileMap: new Map(),
|
||||||
|
deletedImageSet: new Map<string, boolean>(),
|
||||||
|
uploadInProgress: false,
|
||||||
|
maxFileSize: 0,
|
||||||
// escape markdown for images
|
// escape markdown for images
|
||||||
markdown: {
|
markdown: {
|
||||||
serialize() {},
|
serialize() {},
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,12 @@ declare module "@tiptap/core" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CustomLinkExtension = Mark.create<LinkOptions>({
|
export type CustomLinkStorage = {
|
||||||
|
isPreviewOpen: boolean;
|
||||||
|
posToInsert: { from: number; to: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomLinkExtension = Mark.create<LinkOptions, CustomLinkStorage>({
|
||||||
name: "link",
|
name: "link",
|
||||||
|
|
||||||
priority: 1000,
|
priority: 1000,
|
||||||
|
|
@ -242,4 +247,12 @@ export const CustomLinkExtension = Mark.create<LinkOptions>({
|
||||||
|
|
||||||
return plugins;
|
return plugins;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addStorage() {
|
||||||
|
return {
|
||||||
|
isPreviewOpen: false,
|
||||||
|
isBubbleMenuOpen: false,
|
||||||
|
posToInsert: { from: 0, to: 0 },
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||||
autolink: true,
|
autolink: true,
|
||||||
linkOnPaste: true,
|
linkOnPaste: true,
|
||||||
protocols: ["http", "https"],
|
protocols: ["http", "https"],
|
||||||
validate: (url: string) => isValidHttpUrl(url),
|
validate: (url: string) => isValidHttpUrl(url).isValid,
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class:
|
class:
|
||||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,11 @@ export interface IMarking {
|
||||||
sequence: number;
|
sequence: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HeadingListExtension = Extension.create({
|
export type HeadingExtensionStorage = {
|
||||||
|
headings: IMarking[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HeadingListExtension = Extension.create<any, HeadingExtensionStorage>({
|
||||||
name: "headingList",
|
name: "headingList",
|
||||||
|
|
||||||
addStorage() {
|
addStorage() {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import ImageExt from "@tiptap/extension-image";
|
import ImageExt from "@tiptap/extension-image";
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
// extensions
|
||||||
|
import { CustomImageNode } from "@/extensions";
|
||||||
// helpers
|
// helpers
|
||||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||||
// plugins
|
// plugins
|
||||||
import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image";
|
import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image";
|
||||||
// types
|
// types
|
||||||
import { TFileHandler } from "@/types";
|
import { TFileHandler } from "@/types";
|
||||||
// extensions
|
|
||||||
import { CustomImageNode } from "@/extensions";
|
|
||||||
|
|
||||||
export const ImageExtension = (fileHandler: TFileHandler) => {
|
export const ImageExtension = (fileHandler: TFileHandler) => {
|
||||||
const {
|
const {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { mergeAttributes } from "@tiptap/core";
|
import { mergeAttributes } from "@tiptap/core";
|
||||||
import { Image } from "@tiptap/extension-image";
|
import { Image } from "@tiptap/extension-image";
|
||||||
// extensions
|
// extensions
|
||||||
import { UploadImageExtensionStorage } from "@/extensions";
|
import { ImageExtensionStorage } from "@/plugins/image";
|
||||||
|
|
||||||
export const CustomImageComponentWithoutProps = () =>
|
export const CustomImageComponentWithoutProps = () =>
|
||||||
Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
|
Image.extend<Record<string, unknown>, ImageExtensionStorage>({
|
||||||
name: "imageComponent",
|
name: "imageComponent",
|
||||||
selectable: true,
|
selectable: true,
|
||||||
group: "block",
|
group: "block",
|
||||||
|
|
@ -48,6 +48,8 @@ export const CustomImageComponentWithoutProps = () =>
|
||||||
return {
|
return {
|
||||||
fileMap: new Map(),
|
fileMap: new Map(),
|
||||||
deletedImageSet: new Map<string, boolean>(),
|
deletedImageSet: new Map<string, boolean>(),
|
||||||
|
uploadInProgress: false,
|
||||||
|
maxFileSize: 0,
|
||||||
assetsUploadStatus: {},
|
assetsUploadStatus: {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,11 @@ export type TMentionExtensionOptions = MentionOptions & {
|
||||||
getMentionedEntityDetails: TMentionHandler["getMentionedEntityDetails"];
|
getMentionedEntityDetails: TMentionHandler["getMentionedEntityDetails"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CustomMentionExtensionConfig = Mention.extend<TMentionExtensionOptions>({
|
export type MentionExtensionStorage = {
|
||||||
|
mentionsOpen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomMentionExtensionConfig = Mention.extend<TMentionExtensionOptions, MentionExtensionStorage>({
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
[EMentionComponentAttributeNames.ID]: {
|
[EMentionComponentAttributeNames.ID]: {
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
||||||
autolink: true,
|
autolink: true,
|
||||||
linkOnPaste: true,
|
linkOnPaste: true,
|
||||||
protocols: ["http", "https"],
|
protocols: ["http", "https"],
|
||||||
validate: (url: string) => isValidHttpUrl(url),
|
validate: (url: string) => isValidHttpUrl(url).isValid,
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class:
|
class:
|
||||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||||
|
|
|
||||||
|
|
@ -44,16 +44,48 @@ export const getTrimmedHTML = (html: string) => {
|
||||||
return html;
|
return html;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isValidHttpUrl = (string: string): boolean => {
|
export const isValidHttpUrl = (string: string): { isValid: boolean; url: string } => {
|
||||||
let url: URL;
|
// List of potentially dangerous protocols to block
|
||||||
|
const blockedProtocols = ["javascript:", "data:", "vbscript:", "file:", "about:"];
|
||||||
|
|
||||||
|
// First try with the original string
|
||||||
try {
|
try {
|
||||||
url = new URL(string);
|
const url = new URL(string);
|
||||||
} catch {
|
|
||||||
return false;
|
// Check for potentially dangerous protocols
|
||||||
|
const protocol = url.protocol.toLowerCase();
|
||||||
|
if (blockedProtocols.some((p) => protocol === p)) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
url: string,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return url.protocol === "http:" || url.protocol === "https:";
|
// If URL has any valid protocol, return as is
|
||||||
|
if (url.protocol && url.protocol !== "") {
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
url: string,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Original string wasn't a valid URL - that's okay, we'll try with https
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try again with https:// prefix
|
||||||
|
try {
|
||||||
|
const urlWithHttps = `https://${string}`;
|
||||||
|
new URL(urlWithHttps);
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
url: urlWithHttps,
|
||||||
|
};
|
||||||
|
} catch (_) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
url: string,
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getParagraphCount = (editorState: EditorState | undefined) => {
|
export const getParagraphCount = (editorState: EditorState | undefined) => {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { Editor, Range } from "@tiptap/core";
|
import { Editor, Range } from "@tiptap/core";
|
||||||
|
// types
|
||||||
|
import { InsertImageComponentProps } from "@/extensions";
|
||||||
// extensions
|
// extensions
|
||||||
import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text";
|
import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text";
|
||||||
// helpers
|
// helpers
|
||||||
import { findTableAncestor } from "@/helpers/common";
|
import { findTableAncestor } from "@/helpers/common";
|
||||||
// types
|
|
||||||
import { InsertImageComponentProps } from "@/extensions";
|
|
||||||
|
|
||||||
export const setText = (editor: Editor, range?: Range) => {
|
export const setText = (editor: Editor, range?: Range) => {
|
||||||
if (range) editor.chain().focus().deleteRange(range).setNode("paragraph").run();
|
if (range) editor.chain().focus().deleteRange(range).setNode("paragraph").run();
|
||||||
|
|
|
||||||
23
packages/editor/src/core/helpers/get-extension-storage.ts
Normal file
23
packages/editor/src/core/helpers/get-extension-storage.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import {
|
||||||
|
CustomLinkStorage,
|
||||||
|
HeadingExtensionStorage,
|
||||||
|
MentionExtensionStorage,
|
||||||
|
UploadImageExtensionStorage,
|
||||||
|
} from "@/extensions";
|
||||||
|
import { ImageExtensionStorage } from "@/plugins/image";
|
||||||
|
|
||||||
|
type ExtensionNames = "imageComponent" | "image" | "link" | "headingList" | "mention";
|
||||||
|
|
||||||
|
interface ExtensionStorageMap {
|
||||||
|
imageComponent: UploadImageExtensionStorage;
|
||||||
|
image: ImageExtensionStorage;
|
||||||
|
link: CustomLinkStorage;
|
||||||
|
headingList: HeadingExtensionStorage;
|
||||||
|
mention: MentionExtensionStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getExtensionStorage = <K extends ExtensionNames>(
|
||||||
|
editor: Editor,
|
||||||
|
extensionName: K
|
||||||
|
): ExtensionStorageMap[K] => editor.storage[extensionName];
|
||||||
|
|
@ -32,7 +32,7 @@ export type TEditorCommands =
|
||||||
| "bold"
|
| "bold"
|
||||||
| "italic"
|
| "italic"
|
||||||
| "underline"
|
| "underline"
|
||||||
| "strike"
|
| "strikethrough"
|
||||||
| "bulleted-list"
|
| "bulleted-list"
|
||||||
| "numbered-list"
|
| "numbered-list"
|
||||||
| "to-do-list"
|
| "to-do-list"
|
||||||
|
|
@ -131,13 +131,13 @@ export interface IEditorProps {
|
||||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
value?: string | null;
|
value?: string | null;
|
||||||
|
bubbleMenuEnabled?: boolean;
|
||||||
}
|
}
|
||||||
export interface ILiteTextEditor extends IEditorProps {
|
export interface ILiteTextEditor extends IEditorProps {
|
||||||
extensions?: Extensions;
|
extensions?: Extensions;
|
||||||
}
|
}
|
||||||
export interface IRichTextEditor extends IEditorProps {
|
export interface IRichTextEditor extends IEditorProps {
|
||||||
extensions?: Extensions;
|
extensions?: Extensions;
|
||||||
bubbleMenuEnabled?: boolean;
|
|
||||||
dragDropEnabled?: boolean;
|
dragDropEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,3 +196,15 @@ export type TRealtimeConfig = {
|
||||||
url: string;
|
url: string;
|
||||||
queryParams: TWebhookConnectionQueryParams;
|
queryParams: TWebhookConnectionQueryParams;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface EditorEvents {
|
||||||
|
beforeCreate: never;
|
||||||
|
create: never;
|
||||||
|
update: never;
|
||||||
|
selectionUpdate: never;
|
||||||
|
transaction: never;
|
||||||
|
focus: never;
|
||||||
|
blur: never;
|
||||||
|
destroy: never;
|
||||||
|
ready: { height: number };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,6 @@ export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
|
||||||
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
|
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const updateActiveStates = useCallback(() => {
|
const updateActiveStates = useCallback(() => {
|
||||||
// console.log("Updating status");
|
|
||||||
const newActiveStates: Record<string, boolean> = {};
|
const newActiveStates: Record<string, boolean> = {};
|
||||||
Object.values(toolbarItems)
|
Object.values(toolbarItems)
|
||||||
.flat()
|
.flat()
|
||||||
|
|
@ -81,7 +80,6 @@ export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
setActiveStates(newActiveStates);
|
setActiveStates(newActiveStates);
|
||||||
// console.log("newActiveStates", newActiveStates);
|
|
||||||
}, [editorRef]);
|
}, [editorRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ export const TEXT_ALIGNMENT_ITEMS: ToolbarMenuItem<"text-align">[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strike">[] = [
|
const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strikethrough">[] = [
|
||||||
{
|
{
|
||||||
itemKey: "bold",
|
itemKey: "bold",
|
||||||
renderKey: "bold",
|
renderKey: "bold",
|
||||||
|
|
@ -119,7 +119,7 @@ const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strik
|
||||||
editors: ["lite", "document"],
|
editors: ["lite", "document"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
itemKey: "strike",
|
itemKey: "strikethrough",
|
||||||
renderKey: "strikethrough",
|
renderKey: "strikethrough",
|
||||||
name: "Strikethrough",
|
name: "Strikethrough",
|
||||||
icon: Strikethrough,
|
icon: Strikethrough,
|
||||||
|
|
|
||||||
10
yarn.lock
10
yarn.lock
|
|
@ -110,7 +110,7 @@
|
||||||
|
|
||||||
"@babel/generator@^7.26.8":
|
"@babel/generator@^7.26.8":
|
||||||
version "7.26.8"
|
version "7.26.8"
|
||||||
resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.26.8.tgz#f9c5e770309e12e3099ad8271e52f6caa15442ab"
|
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.26.8.tgz#f9c5e770309e12e3099ad8271e52f6caa15442ab"
|
||||||
integrity sha512-ef383X5++iZHWAXX0SXQR6ZyQhw/0KtTkrTz61WXRhFM6dhpHulO/RJz79L8S6ugZHJkOOkUrUdxgdF2YiPFnA==
|
integrity sha512-ef383X5++iZHWAXX0SXQR6ZyQhw/0KtTkrTz61WXRhFM6dhpHulO/RJz79L8S6ugZHJkOOkUrUdxgdF2YiPFnA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/parser" "^7.26.8"
|
"@babel/parser" "^7.26.8"
|
||||||
|
|
@ -128,7 +128,7 @@
|
||||||
|
|
||||||
"@babel/helper-compilation-targets@^7.22.6", "@babel/helper-compilation-targets@^7.25.9", "@babel/helper-compilation-targets@^7.26.5":
|
"@babel/helper-compilation-targets@^7.22.6", "@babel/helper-compilation-targets@^7.25.9", "@babel/helper-compilation-targets@^7.26.5":
|
||||||
version "7.26.5"
|
version "7.26.5"
|
||||||
resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz#75d92bb8d8d51301c0d49e52a65c9a7fe94514d8"
|
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz#75d92bb8d8d51301c0d49e52a65c9a7fe94514d8"
|
||||||
integrity sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==
|
integrity sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/compat-data" "^7.26.5"
|
"@babel/compat-data" "^7.26.5"
|
||||||
|
|
@ -267,7 +267,7 @@
|
||||||
|
|
||||||
"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.26.8":
|
"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.26.8":
|
||||||
version "7.26.8"
|
version "7.26.8"
|
||||||
resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.26.8.tgz#deca2b4d99e5e1b1553843b99823f118da6107c2"
|
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.8.tgz#deca2b4d99e5e1b1553843b99823f118da6107c2"
|
||||||
integrity sha512-TZIQ25pkSoaKEYYaHbbxkfL36GNsQ6iFiBbeuzAkLnXayKR1yP1zFe+NxuZWWsUyvt8icPU9CCq0sgWGXR1GEw==
|
integrity sha512-TZIQ25pkSoaKEYYaHbbxkfL36GNsQ6iFiBbeuzAkLnXayKR1yP1zFe+NxuZWWsUyvt8icPU9CCq0sgWGXR1GEw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/types" "^7.26.8"
|
"@babel/types" "^7.26.8"
|
||||||
|
|
@ -892,7 +892,7 @@
|
||||||
|
|
||||||
"@babel/types@^7.0.0", "@babel/types@^7.18.9", "@babel/types@^7.20.7", "@babel/types@^7.25.9", "@babel/types@^7.26.8", "@babel/types@^7.4.4":
|
"@babel/types@^7.0.0", "@babel/types@^7.18.9", "@babel/types@^7.20.7", "@babel/types@^7.25.9", "@babel/types@^7.26.8", "@babel/types@^7.4.4":
|
||||||
version "7.26.8"
|
version "7.26.8"
|
||||||
resolved "https://registry.npmjs.org/@babel/types/-/types-7.26.8.tgz#97dcdc190fab45be7f3dc073e3c11160d677c127"
|
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.8.tgz#97dcdc190fab45be7f3dc073e3c11160d677c127"
|
||||||
integrity sha512-eUuWapzEGWFEpHFxgEaBG8e3n6S8L3MSu0oda755rOfabWPnh0Our1AozNFVUxGFIhbKgd1ksprsoDGMinTOTA==
|
integrity sha512-eUuWapzEGWFEpHFxgEaBG8e3n6S8L3MSu0oda755rOfabWPnh0Our1AozNFVUxGFIhbKgd1ksprsoDGMinTOTA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/helper-string-parser" "^7.25.9"
|
"@babel/helper-string-parser" "^7.25.9"
|
||||||
|
|
@ -11074,7 +11074,7 @@ trough@^2.0.0:
|
||||||
|
|
||||||
ts-api-utils@^2.0.1:
|
ts-api-utils@^2.0.1:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz#660729385b625b939aaa58054f45c058f33f10cd"
|
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.0.1.tgz#660729385b625b939aaa58054f45c058f33f10cd"
|
||||||
integrity sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==
|
integrity sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==
|
||||||
|
|
||||||
ts-dedent@^2.0.0, ts-dedent@^2.2.0:
|
ts-dedent@^2.0.0, ts-dedent@^2.2.0:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue