[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
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -88,6 +88,7 @@ deploy/selfhost/plane-app/
|
|||
*storybook.log
|
||||
output.css
|
||||
|
||||
dev-editor
|
||||
# Redis
|
||||
*.rdb
|
||||
*.rdb.gz
|
||||
*.rdb.gz
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { FC, ReactNode } from "react";
|
||||
import { Editor, EditorContent } from "@tiptap/react";
|
||||
import { FC, ReactNode } from "react";
|
||||
|
||||
interface EditorContentProps {
|
||||
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-input-view";
|
||||
export * from "./link-preview";
|
||||
export * from "./link-view";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
// 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>
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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() {},
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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]: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
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"
|
||||
| "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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
10
yarn.lock
10
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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue