diff --git a/.gitignore b/.gitignore index 78f7ad3905d..36f85dc78e2 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,7 @@ deploy/selfhost/plane-app/ *storybook.log output.css +dev-editor # Redis *.rdb -*.rdb.gz \ No newline at end of file +*.rdb.gz diff --git a/packages/editor/src/core/components/editors/document/page-renderer.tsx b/packages/editor/src/core/components/editors/document/page-renderer.tsx index 2b4c094c59f..5a95fe5cabb 100644 --- a/packages/editor/src/core/components/editors/document/page-renderer.tsx +++ b/packages/editor/src/core/components/editors/document/page-renderer.tsx @@ -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(); - 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(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 ( - <> -
- - - {editor.isEditable && ( -
- {bubbleMenuEnabled && } - - -
- )} -
-
- {isOpen && linkViewProps && coordinates && ( -
- -
- )} - +
+ + + {editor.isEditable && ( + <> + {bubbleMenuEnabled && } + + + + )} + +
); }; diff --git a/packages/editor/src/core/components/editors/editor-container.tsx b/packages/editor/src/core/components/editors/editor-container.tsx index 02f0a021711..2dfc6425bc3 100644 --- a/packages/editor/src/core/components/editors/editor-container.tsx +++ b/packages/editor/src/core/components/editors/editor-container.tsx @@ -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 = (props) => { const { children, displayConfig, editor, editorContainerClassName, id } = props; + const containerRef = useRef(null); const handleContainerClick = (event: React.MouseEvent) => { if (event.target !== event.currentTarget) return; @@ -66,22 +69,26 @@ export const EditorContainer: FC = (props) => { }; return ( -
- {children} -
+ <> +
+ {children} + +
+ ); }; diff --git a/packages/editor/src/core/components/editors/editor-content.tsx b/packages/editor/src/core/components/editors/editor-content.tsx index b05457f2e63..8171d06d9d2 100644 --- a/packages/editor/src/core/components/editors/editor-content.tsx +++ b/packages/editor/src/core/components/editors/editor-content.tsx @@ -1,5 +1,5 @@ -import { FC, ReactNode } from "react"; import { Editor, EditorContent } from "@tiptap/react"; +import { FC, ReactNode } from "react"; interface EditorContentProps { children?: ReactNode; diff --git a/packages/editor/src/core/components/editors/link-view-container.tsx b/packages/editor/src/core/components/editors/link-view-container.tsx new file mode 100644 index 00000000000..41263a9962e --- /dev/null +++ b/packages/editor/src/core/components/editors/link-view-container.tsx @@ -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; +} + +export const LinkViewContainer: FC = ({ editor, containerRef }) => { + const [linkViewProps, setLinkViewProps] = useState(); + const [isOpen, setIsOpen] = useState(false); + const [virtualElement, setVirtualElement] = useState(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 && ( +
+ +
+ )} + + ); +}; diff --git a/packages/editor/src/core/components/links/index.ts b/packages/editor/src/core/components/links/index.ts index 8e123098e8f..4bd24e373d2 100644 --- a/packages/editor/src/core/components/links/index.ts +++ b/packages/editor/src/core/components/links/index.ts @@ -1,4 +1,3 @@ export * from "./link-edit-view"; -export * from "./link-input-view"; export * from "./link-preview"; export * from "./link-view"; diff --git a/packages/editor/src/core/components/links/link-edit-view.tsx b/packages/editor/src/core/components/links/link-edit-view.tsx index 665e7500a7a..2e23e2bca40 100644 --- a/packages/editor/src/core/components/links/link-edit-view.tsx +++ b/packages/editor/src/core/components/links/link-edit-view.tsx @@ -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) => void; -}) => ( + onChange: (value: string) => void; + autoFocus?: boolean; +} + +const InputView = ({ label, value, placeholder, onChange, autoFocus }: InputViewProps) => (
{ - 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} />
); -export const LinkEditView = ({ - viewProps, -}: { +interface LinkEditViewProps { viewProps: LinkViewProps; - switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void; -}) => { - const { editor, from, to } = viewProps; - - const [positionRef, setPositionRef] = useState({ from: from, to: to }); - const [localUrl, setLocalUrl] = useState(viewProps.url); - - const linkRemoved = useRef(); + switchView: (view: LinkViews) => void; +} - const getText = (from: number, to: number) => { - if (to >= editor.state.doc.content.size) return ""; +export const LinkEditView = ({ viewProps }: LinkEditViewProps) => { + const { editor, from, to, url: initialUrl, text: initialText, closeLinkView } = viewProps; - 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 (
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} > - handleUpdateLink(e.target.value)} - /> - handleUpdateText(e.target.value)} - /> + +
-
diff --git a/packages/editor/src/core/components/links/link-input-view.tsx b/packages/editor/src/core/components/links/link-input-view.tsx deleted file mode 100644 index a66d80e6d2e..00000000000 --- a/packages/editor/src/core/components/links/link-input-view.tsx +++ /dev/null @@ -1,7 +0,0 @@ -// components -import { LinkViewProps } from "@/components/links"; - -export const LinkInputView = ({}: { - viewProps: LinkViewProps; - switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void; -}) =>

LinkInputView

; diff --git a/packages/editor/src/core/components/links/link-preview.tsx b/packages/editor/src/core/components/links/link-preview.tsx index 1237c7c9804..30205005dea 100644 --- a/packages/editor/src/core/components/links/link-preview.tsx +++ b/packages/editor/src/core/components/links/link-preview.tsx @@ -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 ( -
+
-

{url.length > 40 ? url.slice(0, 40) + "..." : url}

+

{url?.length > 40 ? url.slice(0, 40) + "..." : url}

- - - + {editor.isEditable && ( + <> + + + + )}
diff --git a/packages/editor/src/core/components/links/link-view.tsx b/packages/editor/src/core/components/links/link-view.tsx index 988250387fd..699f94e400d 100644 --- a/packages/editor/src/core/components/links/link-view.tsx +++ b/packages/editor/src/core/components/links/link-view.tsx @@ -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(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 ; - case "LinkEditView": - return ; - case "LinkInputView": - return ; - } - }; - - return renderView(); + return ( + <> + {currentView === "LinkPreview" && } + {currentView === "LinkEditView" && } + + ); }; diff --git a/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx index 333edf78a1d..1dd47c5bb33 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx @@ -23,11 +23,11 @@ export const BubbleMenuLinkSelector: FC = (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 { diff --git a/packages/editor/src/core/components/menus/bubble-menu/root.tsx b/packages/editor/src/core/components/menus/bubble-menu/root.tsx index 149c6f6c24b..59e795cbb0a 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/root.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/root.tsx @@ -91,6 +91,7 @@ export const EditorBubbleMenu: FC = (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 = (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); diff --git a/packages/editor/src/core/components/menus/menu-items.ts b/packages/editor/src/core/components/menus/menu-items.ts index 8cb4accc5fa..4268ccb6c48 100644 --- a/packages/editor/src/core/components/menus/menu-items.ts +++ b/packages/editor/src/core/components/menus/menu-items.ts @@ -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), diff --git a/packages/editor/src/core/constants/common.ts b/packages/editor/src/core/constants/common.ts index bae06d3031e..8961bcd915b 100644 --- a/packages/editor/src/core/constants/common.ts +++ b/packages/editor/src/core/constants/common.ts @@ -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, diff --git a/packages/editor/src/core/extensions/core-without-props.ts b/packages/editor/src/core/extensions/core-without-props.ts index 9bad7a5c744..ed9f5c1a4b5 100644 --- a/packages/editor/src/core/extensions/core-without-props.ts +++ b/packages/editor/src/core/extensions/core-without-props.ts @@ -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", diff --git a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx index de2a20ab965..e525bc6da4b 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx @@ -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} diff --git a/packages/editor/src/core/extensions/custom-image/custom-image.ts b/packages/editor/src/core/extensions/custom-image/custom-image.ts index 3e35fd0e38c..4f1b3c8dbd6 100644 --- a/packages/editor/src/core/extensions/custom-image/custom-image.ts +++ b/packages/editor/src/core/extensions/custom-image/custom-image.ts @@ -35,6 +35,9 @@ export const getImageComponentImageFileMap = (editor: Editor) => export interface UploadImageExtensionStorage { assetsUploadStatus: TFileHandler["assetsUploadStatus"]; fileMap: Map; + deletedImageSet: Map; + uploadInProgress: boolean; + maxFileSize: number; } export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean }; diff --git a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts index 78237d67835..0f77ff9e57c 100644 --- a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts +++ b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts @@ -52,6 +52,9 @@ export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => { addStorage() { return { fileMap: new Map(), + deletedImageSet: new Map(), + uploadInProgress: false, + maxFileSize: 0, // escape markdown for images markdown: { serialize() {}, diff --git a/packages/editor/src/core/extensions/custom-link/extension.tsx b/packages/editor/src/core/extensions/custom-link/extension.tsx index ee065f512b9..27c1bb598da 100644 --- a/packages/editor/src/core/extensions/custom-link/extension.tsx +++ b/packages/editor/src/core/extensions/custom-link/extension.tsx @@ -73,7 +73,12 @@ declare module "@tiptap/core" { } } -export const CustomLinkExtension = Mark.create({ +export type CustomLinkStorage = { + isPreviewOpen: boolean; + posToInsert: { from: number; to: number }; +}; + +export const CustomLinkExtension = Mark.create({ name: "link", priority: 1000, @@ -242,4 +247,12 @@ export const CustomLinkExtension = Mark.create({ return plugins; }, + + addStorage() { + return { + isPreviewOpen: false, + isBubbleMenuOpen: false, + posToInsert: { from: 0, to: 0 }, + }; + }, }); diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx index 002dce9454f..ff200cd323b 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -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", diff --git a/packages/editor/src/core/extensions/headers.ts b/packages/editor/src/core/extensions/headers.ts index 3960d5f039c..958cf6ca32b 100644 --- a/packages/editor/src/core/extensions/headers.ts +++ b/packages/editor/src/core/extensions/headers.ts @@ -8,7 +8,11 @@ export interface IMarking { sequence: number; } -export const HeadingListExtension = Extension.create({ +export type HeadingExtensionStorage = { + headings: IMarking[]; +}; + +export const HeadingListExtension = Extension.create({ name: "headingList", addStorage() { diff --git a/packages/editor/src/core/extensions/image/extension.tsx b/packages/editor/src/core/extensions/image/extension.tsx index f549719f26e..6766b4d0c03 100644 --- a/packages/editor/src/core/extensions/image/extension.tsx +++ b/packages/editor/src/core/extensions/image/extension.tsx @@ -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 { diff --git a/packages/editor/src/core/extensions/image/image-component-without-props.tsx b/packages/editor/src/core/extensions/image/image-component-without-props.tsx index 7167e622c32..c17bcc5598e 100644 --- a/packages/editor/src/core/extensions/image/image-component-without-props.tsx +++ b/packages/editor/src/core/extensions/image/image-component-without-props.tsx @@ -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, UploadImageExtensionStorage>({ + Image.extend, ImageExtensionStorage>({ name: "imageComponent", selectable: true, group: "block", @@ -48,6 +48,8 @@ export const CustomImageComponentWithoutProps = () => return { fileMap: new Map(), deletedImageSet: new Map(), + uploadInProgress: false, + maxFileSize: 0, assetsUploadStatus: {}, }; }, diff --git a/packages/editor/src/core/extensions/mentions/extension-config.ts b/packages/editor/src/core/extensions/mentions/extension-config.ts index cf192507f42..e75fc9156f5 100644 --- a/packages/editor/src/core/extensions/mentions/extension-config.ts +++ b/packages/editor/src/core/extensions/mentions/extension-config.ts @@ -12,7 +12,11 @@ export type TMentionExtensionOptions = MentionOptions & { getMentionedEntityDetails: TMentionHandler["getMentionedEntityDetails"]; }; -export const CustomMentionExtensionConfig = Mention.extend({ +export type MentionExtensionStorage = { + mentionsOpen: boolean; +}; + +export const CustomMentionExtensionConfig = Mention.extend({ addAttributes() { return { [EMentionComponentAttributeNames.ID]: { diff --git a/packages/editor/src/core/extensions/read-only-extensions.tsx b/packages/editor/src/core/extensions/read-only-extensions.tsx index 6f09cb68393..3881c548b3f 100644 --- a/packages/editor/src/core/extensions/read-only-extensions.tsx +++ b/packages/editor/src/core/extensions/read-only-extensions.tsx @@ -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", diff --git a/packages/editor/src/core/helpers/common.ts b/packages/editor/src/core/helpers/common.ts index 04f827ece53..36075caf23d 100644 --- a/packages/editor/src/core/helpers/common.ts +++ b/packages/editor/src/core/helpers/common.ts @@ -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) => { diff --git a/packages/editor/src/core/helpers/editor-commands.ts b/packages/editor/src/core/helpers/editor-commands.ts index 71072f097ac..39796ac245a 100644 --- a/packages/editor/src/core/helpers/editor-commands.ts +++ b/packages/editor/src/core/helpers/editor-commands.ts @@ -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(); diff --git a/packages/editor/src/core/helpers/get-extension-storage.ts b/packages/editor/src/core/helpers/get-extension-storage.ts new file mode 100644 index 00000000000..0107f8425c9 --- /dev/null +++ b/packages/editor/src/core/helpers/get-extension-storage.ts @@ -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 = ( + editor: Editor, + extensionName: K +): ExtensionStorageMap[K] => editor.storage[extensionName]; diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index edf696ab8d8..a55a1a84aa1 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -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 }; +} diff --git a/web/core/components/pages/editor/header/toolbar.tsx b/web/core/components/pages/editor/header/toolbar.tsx index 6e4ffdd5f85..8d378acdb13 100644 --- a/web/core/components/pages/editor/header/toolbar.tsx +++ b/web/core/components/pages/editor/header/toolbar.tsx @@ -68,7 +68,6 @@ export const PageToolbar: React.FC = ({ editorRef }) => { const [activeStates, setActiveStates] = useState>({}); const updateActiveStates = useCallback(() => { - // console.log("Updating status"); const newActiveStates: Record = {}; Object.values(toolbarItems) .flat() @@ -81,7 +80,6 @@ export const PageToolbar: React.FC = ({ editorRef }) => { }); }); setActiveStates(newActiveStates); - // console.log("newActiveStates", newActiveStates); }, [editorRef]); useEffect(() => { diff --git a/web/core/constants/editor.ts b/web/core/constants/editor.ts index e3cb487b6b0..5e8c723d71d 100644 --- a/web/core/constants/editor.ts +++ b/web/core/constants/editor.ts @@ -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, diff --git a/yarn.lock b/yarn.lock index bc8e4d354a1..c4c8fad076e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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: