[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:
M. Palanikannan 2025-04-02 13:42:34 +05:30 committed by GitHub
parent 65a0530cfe
commit a57c37c26c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 458 additions and 330 deletions

3
.gitignore vendored
View file

@ -88,6 +88,7 @@ deploy/selfhost/plane-app/
*storybook.log
output.css
dev-editor
# Redis
*.rdb
*.rdb.gz
*.rdb.gz

View file

@ -1,20 +1,6 @@
import { useCallback, useRef, useState } from "react";
import {
autoUpdate,
computePosition,
flip,
hide,
shift,
useDismiss,
useFloating,
useInteractions,
} from "@floating-ui/react";
import { Node } from "@tiptap/pm/model";
import { EditorView } from "@tiptap/pm/view";
import { Editor, ReactRenderer } from "@tiptap/react";
import { Editor } from "@tiptap/react";
// components
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
import { LinkView, LinkViewProps } from "@/components/links";
import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus";
// types
import { TAIHandler, TDisplayConfig } from "@/types";
@ -31,133 +17,24 @@ type IPageRenderer = {
export const PageRenderer = (props: IPageRenderer) => {
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 (
<>
<div className="frame-renderer flex-grow w-full" onMouseOver={handleLinkHover}>
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
>
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
{editor.isEditable && (
<div>
{bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
<BlockMenu editor={editor} />
<AIFeaturesMenu menu={aiHandler?.menu} />
</div>
)}
</EditorContainer>
</div>
{isOpen && linkViewProps && coordinates && (
<div
style={{ ...floatingStyles, left: `${coordinates.x}px`, top: `${coordinates.y}px` }}
className="absolute"
ref={refs.setFloating}
>
<LinkView {...linkViewProps} style={floatingStyles} {...getFloatingProps()} />
</div>
)}
</>
<div className="frame-renderer flex-grow w-full">
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
>
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
{editor.isEditable && (
<>
{bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
<BlockMenu editor={editor} />
<AIFeaturesMenu menu={aiHandler?.menu} />
</>
)}
</EditorContainer>
</div>
);
};

View file

@ -1,22 +1,25 @@
import { Editor } from "@tiptap/react";
import { FC, ReactNode } from "react";
import { FC, ReactNode, useRef } from "react";
// plane utils
import { cn } from "@plane/utils";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// types
import { TDisplayConfig } from "@/types";
// components
import { LinkViewContainer } from "./link-view-container";
interface EditorContainerProps {
children: ReactNode;
displayConfig: TDisplayConfig;
editor: Editor | null;
editor: Editor;
editorContainerClassName: string;
id: string;
}
export const EditorContainer: FC<EditorContainerProps> = (props) => {
const { children, displayConfig, editor, editorContainerClassName, id } = props;
const containerRef = useRef<HTMLDivElement>(null);
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (event.target !== event.currentTarget) return;
@ -66,22 +69,26 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
};
return (
<div
id={`editor-container-${id}`}
onClick={handleContainerClick}
onMouseLeave={handleContainerMouseLeave}
className={cn(
`editor-container cursor-text relative line-spacing-${displayConfig.lineSpacing ?? DEFAULT_DISPLAY_CONFIG.lineSpacing}`,
{
"active-editor": editor?.isFocused && editor?.isEditable,
"wide-layout": displayConfig.wideLayout,
},
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
editorContainerClassName
)}
>
{children}
</div>
<>
<div
ref={containerRef}
id={`editor-container-${id}`}
onClick={handleContainerClick}
onMouseLeave={handleContainerMouseLeave}
className={cn(
`editor-container cursor-text relative line-spacing-${displayConfig.lineSpacing ?? DEFAULT_DISPLAY_CONFIG.lineSpacing}`,
{
"active-editor": editor?.isFocused && editor?.isEditable,
"wide-layout": displayConfig.wideLayout,
},
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
editorContainerClassName
)}
>
{children}
<LinkViewContainer editor={editor} containerRef={containerRef} />
</div>
</>
);
};

View file

@ -1,5 +1,5 @@
import { FC, ReactNode } from "react";
import { Editor, EditorContent } from "@tiptap/react";
import { FC, ReactNode } from "react";
interface EditorContentProps {
children?: ReactNode;

View file

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

View file

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

View file

@ -1,131 +1,146 @@
import { useEffect, useRef, useState } from "react";
import { Node } from "@tiptap/pm/model";
import { Link2Off } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
// components
import { LinkViewProps } from "@/components/links";
import { LinkViewProps, LinkViews } from "@/components/links";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
const InputView = ({
label,
defaultValue,
placeholder,
onChange,
}: {
interface InputViewProps {
label: string;
defaultValue: string;
value: 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">
<label className="inline-block font-semibold text-xs text-custom-text-400">{label}</label>
<input
placeholder={placeholder}
onClick={(e) => {
e.stopPropagation();
}}
className="w-[280px] outline-none bg-custom-background-90 text-custom-text-900 text-sm"
defaultValue={defaultValue}
onChange={onChange}
onClick={(e) => 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}
onChange={(e) => onChange(e.target.value)}
autoFocus={autoFocus}
/>
</div>
);
export const LinkEditView = ({
viewProps,
}: {
interface LinkEditViewProps {
viewProps: LinkViewProps;
switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void;
}) => {
const { editor, from, to } = viewProps;
switchView: (view: LinkViews) => void;
}
const [positionRef, setPositionRef] = useState({ from: from, to: to });
const [localUrl, setLocalUrl] = useState(viewProps.url);
export const LinkEditView = ({ viewProps }: LinkEditViewProps) => {
const { editor, from, to, url: initialUrl, text: initialText, closeLinkView } = viewProps;
const linkRemoved = useRef<boolean>();
const getText = (from: number, to: number) => {
if (to >= editor.state.doc.content.size) return "";
const text = editor.state.doc.textBetween(from, to, "\n");
return text;
};
const handleUpdateLink = (url: string) => {
setLocalUrl(url);
};
// State
const [positionRef] = useState({ from, to });
const [localUrl, setLocalUrl] = useState(initialUrl);
const [localText, setLocalText] = useState(initialText ?? "");
const [linkRemoved, setLinkRemoved] = useState(false);
const hasSubmitted = useRef(false);
// Effects
useEffect(
() => () => {
if (linkRemoved.current) return;
const url = isValidHttpUrl(localUrl) ? localUrl : viewProps.url;
if (to >= editor.state.doc.content.size) return;
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
editor.view.dispatch(editor.state.tr.addMark(from, to, editor.schema.marks.link.create({ href: url })));
},
[localUrl, editor, from, to, viewProps.url]
() =>
// Cleanup effect: Remove link if not submitted and url is empty
() => {
if (!hasSubmitted.current && !linkRemoved && initialUrl === "") {
try {
removeLink();
} catch (e) {}
}
},
[linkRemoved, initialUrl]
);
const handleUpdateText = (text: string) => {
if (text === "") {
return;
// Sync state with props
useEffect(() => {
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;
if (!node || !node.marks) return false;
editor
.chain()
.setTextSelection(from)
.deleteRange({ from: positionRef.from, to: positionRef.to })
.insertContent(localText)
.setTextSelection({ from, to: from + localText.length })
.run();
//
// Restore marks
node.marks.forEach((mark) => {
editor.chain().setMark(mark.type.name, mark.attrs).run();
});
}
const node = editor.view.state.doc.nodeAt(from) as Node;
if (!node) return;
const marks = node.marks;
if (!marks) return;
return true;
}, [editor, from, to, initialText, localText, localUrl]);
editor.chain().setTextSelection(from).run();
editor.chain().deleteRange({ from: positionRef.from, to: positionRef.to }).run();
editor.chain().insertContent(text).run();
editor
.chain()
.setTextSelection({
from: from,
to: from + text.length,
})
.run();
setPositionRef({ from: from, to: from + text.length });
marks.forEach((mark) => {
editor.chain().setMark(mark.type.name, mark.attrs).run();
});
};
const removeLink = () => {
const removeLink = useCallback(() => {
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
linkRemoved.current = true;
viewProps.closeLinkView();
};
setLinkRemoved(true);
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 (
<div
onKeyDown={(e) => e.key === "Enter" && viewProps.closeLinkView()}
className="shadow-md rounded p-2 flex flex-col gap-3 bg-custom-background-90 border-custom-border-100 border-2"
onKeyDown={handleKeyDown}
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
label={"URL"}
placeholder={"Enter or paste URL"}
defaultValue={localUrl}
onChange={(e) => handleUpdateLink(e.target.value)}
/>
<InputView
label={"Text"}
placeholder={"Enter Text to display"}
defaultValue={getText(from, to)}
onChange={(e) => handleUpdateText(e.target.value)}
/>
<InputView label="URL" placeholder="Enter or paste URL" value={localUrl} onChange={setLocalUrl} autoFocus />
<InputView label="Text" placeholder="Enter Text to display" value={localText} onChange={handleTextChange} />
<div className="mb-1 bg-custom-border-300 h-[1px] w-full gap-2" />
<div className="flex text-sm text-custom-text-800 gap-2 items-center">
<Link2Off size={14} className="inline-block" />
<button onClick={() => removeLink()} className="cursor-pointer">
<button onClick={removeLink} className="cursor-pointer hover:text-custom-text-400 transition-colors">
Remove Link
</button>
</div>

View file

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

View file

@ -1,13 +1,13 @@
import { Copy, GlobeIcon, Link2Off, PencilIcon } from "lucide-react";
// components
import { LinkViewProps } from "@/components/links";
import { LinkViewProps, LinkViews } from "@/components/links";
export const LinkPreview = ({
viewProps,
switchView,
}: {
viewProps: LinkViewProps;
switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void;
switchView: (view: LinkViews) => void;
}) => {
const { editor, from, to, url } = viewProps;
@ -22,20 +22,33 @@ export const LinkPreview = ({
};
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">
<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">
<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" />
</button>
<button onClick={() => switchView("LinkEditView")} className="cursor-pointer">
<PencilIcon size={14} className="inline-block" />
</button>
<button onClick={removeLink} className="cursor-pointer">
<Link2Off size={14} className="inline-block" />
</button>
{editor.isEditable && (
<>
<button
onClick={() => switchView("LinkEditView")}
className="cursor-pointer hover:text-custom-text-100 transition-colors"
>
<PencilIcon size={14} className="inline-block" />
</button>
<button onClick={removeLink} className="cursor-pointer hover:text-custom-text-100 transition-colors">
<Link2Off size={14} className="inline-block" />
</button>
</>
)}
</div>
</div>
</div>

View file

@ -1,22 +1,25 @@
import { CSSProperties, useEffect, useState } from "react";
import { Editor } from "@tiptap/react";
import { CSSProperties, useEffect, useState } from "react";
// components
import { LinkEditView, LinkInputView, LinkPreview } from "@/components/links";
import { LinkEditView, LinkPreview } from "@/components/links";
export type LinkViews = "LinkPreview" | "LinkEditView";
export interface LinkViewProps {
view?: "LinkPreview" | "LinkEditView" | "LinkInputView";
view?: LinkViews;
editor: Editor;
from: number;
to: number;
url: string;
text?: string;
closeLinkView: () => void;
}
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 switchView = (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => {
const switchView = (view: LinkViews) => {
setCurrentView(view);
};
@ -27,16 +30,10 @@ export const LinkView = (props: LinkViewProps & { style: CSSProperties }) => {
}
}, []);
const renderView = () => {
switch (currentView) {
case "LinkPreview":
return <LinkPreview viewProps={props} switchView={switchView} />;
case "LinkEditView":
return <LinkEditView viewProps={props} switchView={switchView} />;
case "LinkInputView":
return <LinkInputView viewProps={props} switchView={switchView} />;
}
};
return renderView();
return (
<>
{currentView === "LinkPreview" && <LinkPreview viewProps={props} switchView={switchView} />}
{currentView === "LinkEditView" && <LinkEditView viewProps={props} switchView={switchView} />}
</>
);
};

View file

@ -23,11 +23,11 @@ export const BubbleMenuLinkSelector: FC<Props> = (props) => {
const handleLinkSubmit = useCallback(() => {
const input = inputRef.current;
if (!input) return;
let url = input.value;
const url = input.value;
if (!url) return;
if (!url.startsWith("http")) url = `http://${url}`;
if (isValidHttpUrl(url)) {
setLinkEditor(editor, url);
const { isValid, url: validatedUrl } = isValidHttpUrl(url);
if (isValid) {
setLinkEditor(editor, validatedUrl);
setIsOpen(false);
setError(false);
} else {

View file

@ -91,6 +91,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
empty ||
!editor.isEditable ||
editor.isActive("image") ||
editor.isActive("imageComponent") ||
isNodeSelection(selection) ||
isCellSelection(selection) ||
isSelecting
@ -102,7 +103,11 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
tippyOptions: {
moveTransition: "transform 0.15s ease-out",
duration: [300, 0],
onShow: () => {
props.editor.storage.link.isBubbleMenuOpen = true;
},
onHidden: () => {
props.editor.storage.link.isBubbleMenuOpen = false;
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);

View file

@ -142,8 +142,8 @@ export const UnderLineItem = (editor: Editor): EditorMenuItem<"underline"> => ({
icon: UnderlineIcon,
});
export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strike"> => ({
key: "strike",
export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough"> => ({
key: "strikethrough",
name: "Strikethrough",
isActive: () => editor?.isActive("strike"),
command: () => toggleStrike(editor),

View file

@ -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",
renderKey: "bold",
@ -113,7 +113,7 @@ const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strik
editors: ["lite", "document"],
},
{
itemKey: "strike",
itemKey: "strikethrough",
renderKey: "strikethrough",
name: "Strikethrough",
icon: Strikethrough,

View file

@ -66,7 +66,7 @@ export const CoreEditorExtensionsWithoutProps = [
autolink: true,
linkOnPaste: true,
protocols: ["http", "https"],
validate: (url: string) => isValidHttpUrl(url),
validate: (url: string) => isValidHttpUrl(url).isValid,
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",

View file

@ -2,6 +2,7 @@ import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useEffect, useRef, useState } from "react";
// extensions
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
import { getExtensionStorage } from "@/helpers/get-extension-storage";
export type CustoBaseImageNodeViewProps = {
getPos: () => number;
@ -76,7 +77,7 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
failedToLoadImage={failedToLoadImage}
getPos={getPos}
loadImageFromFileSystem={setImageFromFileSystem}
maxFileSize={editor.storage.imageComponent?.maxFileSize}
maxFileSize={getExtensionStorage(editor, "imageComponent").maxFileSize}
node={node}
setIsUploaded={setIsUploaded}
selected={selected}

View file

@ -35,6 +35,9 @@ export const getImageComponentImageFileMap = (editor: Editor) =>
export interface UploadImageExtensionStorage {
assetsUploadStatus: TFileHandler["assetsUploadStatus"];
fileMap: Map<string, UploadEntity>;
deletedImageSet: Map<string, boolean>;
uploadInProgress: boolean;
maxFileSize: number;
}
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };

View file

@ -52,6 +52,9 @@ export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
addStorage() {
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
uploadInProgress: false,
maxFileSize: 0,
// escape markdown for images
markdown: {
serialize() {},

View file

@ -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",
priority: 1000,
@ -242,4 +247,12 @@ export const CustomLinkExtension = Mark.create<LinkOptions>({
return plugins;
},
addStorage() {
return {
isPreviewOpen: false,
isBubbleMenuOpen: false,
posToInsert: { from: 0, to: 0 },
};
},
});

View file

@ -102,7 +102,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
autolink: true,
linkOnPaste: true,
protocols: ["http", "https"],
validate: (url: string) => isValidHttpUrl(url),
validate: (url: string) => isValidHttpUrl(url).isValid,
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",

View file

@ -8,7 +8,11 @@ export interface IMarking {
sequence: number;
}
export const HeadingListExtension = Extension.create({
export type HeadingExtensionStorage = {
headings: IMarking[];
};
export const HeadingListExtension = Extension.create<any, HeadingExtensionStorage>({
name: "headingList",
addStorage() {

View file

@ -1,13 +1,13 @@
import ImageExt from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomImageNode } from "@/extensions";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// plugins
import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image";
// types
import { TFileHandler } from "@/types";
// extensions
import { CustomImageNode } from "@/extensions";
export const ImageExtension = (fileHandler: TFileHandler) => {
const {

View file

@ -1,10 +1,10 @@
import { mergeAttributes } from "@tiptap/core";
import { Image } from "@tiptap/extension-image";
// extensions
import { UploadImageExtensionStorage } from "@/extensions";
import { ImageExtensionStorage } from "@/plugins/image";
export const CustomImageComponentWithoutProps = () =>
Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
Image.extend<Record<string, unknown>, ImageExtensionStorage>({
name: "imageComponent",
selectable: true,
group: "block",
@ -48,6 +48,8 @@ export const CustomImageComponentWithoutProps = () =>
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
uploadInProgress: false,
maxFileSize: 0,
assetsUploadStatus: {},
};
},

View file

@ -12,7 +12,11 @@ export type TMentionExtensionOptions = MentionOptions & {
getMentionedEntityDetails: TMentionHandler["getMentionedEntityDetails"];
};
export const CustomMentionExtensionConfig = Mention.extend<TMentionExtensionOptions>({
export type MentionExtensionStorage = {
mentionsOpen: boolean;
};
export const CustomMentionExtensionConfig = Mention.extend<TMentionExtensionOptions, MentionExtensionStorage>({
addAttributes() {
return {
[EMentionComponentAttributeNames.ID]: {

View file

@ -87,7 +87,7 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
autolink: true,
linkOnPaste: true,
protocols: ["http", "https"],
validate: (url: string) => isValidHttpUrl(url),
validate: (url: string) => isValidHttpUrl(url).isValid,
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",

View file

@ -44,16 +44,48 @@ export const getTrimmedHTML = (html: string) => {
return html;
};
export const isValidHttpUrl = (string: string): boolean => {
let url: URL;
export const isValidHttpUrl = (string: string): { isValid: boolean; url: string } => {
// List of potentially dangerous protocols to block
const blockedProtocols = ["javascript:", "data:", "vbscript:", "file:", "about:"];
// First try with the original string
try {
url = new URL(string);
} catch {
return false;
const url = new URL(string);
// Check for potentially dangerous protocols
const protocol = url.protocol.toLowerCase();
if (blockedProtocols.some((p) => protocol === p)) {
return {
isValid: false,
url: string,
};
}
// 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
}
return url.protocol === "http:" || url.protocol === "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) => {

View file

@ -1,10 +1,10 @@
import { Editor, Range } from "@tiptap/core";
// types
import { InsertImageComponentProps } from "@/extensions";
// extensions
import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text";
// helpers
import { findTableAncestor } from "@/helpers/common";
// types
import { InsertImageComponentProps } from "@/extensions";
export const setText = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("paragraph").run();

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

View file

@ -32,7 +32,7 @@ export type TEditorCommands =
| "bold"
| "italic"
| "underline"
| "strike"
| "strikethrough"
| "bulleted-list"
| "numbered-list"
| "to-do-list"
@ -131,13 +131,13 @@ export interface IEditorProps {
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
value?: string | null;
bubbleMenuEnabled?: boolean;
}
export interface ILiteTextEditor extends IEditorProps {
extensions?: Extensions;
}
export interface IRichTextEditor extends IEditorProps {
extensions?: Extensions;
bubbleMenuEnabled?: boolean;
dragDropEnabled?: boolean;
}
@ -196,3 +196,15 @@ export type TRealtimeConfig = {
url: string;
queryParams: TWebhookConnectionQueryParams;
};
export interface EditorEvents {
beforeCreate: never;
create: never;
update: never;
selectionUpdate: never;
transaction: never;
focus: never;
blur: never;
destroy: never;
ready: { height: number };
}

View file

@ -68,7 +68,6 @@ export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
const updateActiveStates = useCallback(() => {
// console.log("Updating status");
const newActiveStates: Record<string, boolean> = {};
Object.values(toolbarItems)
.flat()
@ -81,7 +80,6 @@ export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
});
});
setActiveStates(newActiveStates);
// console.log("newActiveStates", newActiveStates);
}, [editorRef]);
useEffect(() => {

View file

@ -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",
renderKey: "bold",
@ -119,7 +119,7 @@ const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strik
editors: ["lite", "document"],
},
{
itemKey: "strike",
itemKey: "strikethrough",
renderKey: "strikethrough",
name: "Strikethrough",
icon: Strikethrough,

View file

@ -110,7 +110,7 @@
"@babel/generator@^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==
dependencies:
"@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":
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==
dependencies:
"@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":
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==
dependencies:
"@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":
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==
dependencies:
"@babel/helper-string-parser" "^7.25.9"
@ -11074,7 +11074,7 @@ trough@^2.0.0:
ts-api-utils@^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==
ts-dedent@^2.0.0, ts-dedent@^2.2.0: