mirror of
https://github.com/docmost/docmost.git
synced 2026-05-16 05:44:04 +08:00
Merge branch 'main' into tiptap3-migration
This commit is contained in:
@@ -43,18 +43,13 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
||||
const showCommentPopupRef = useRef(showCommentPopup);
|
||||
|
||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
|
||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
showCommentPopupRef.current = showCommentPopup;
|
||||
}, [showCommentPopup]);
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor: props.editor,
|
||||
selector: ctx => {
|
||||
selector: (ctx) => {
|
||||
if (!props.editor) {
|
||||
return null;
|
||||
}
|
||||
@@ -73,31 +68,31 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const items: BubbleMenuItem[] = [
|
||||
{
|
||||
name: "Bold",
|
||||
isActive: () => editorState.isBold,
|
||||
isActive: () => editorState?.isBold,
|
||||
command: () => props.editor.chain().focus().toggleBold().run(),
|
||||
icon: IconBold,
|
||||
},
|
||||
{
|
||||
name: "Italic",
|
||||
isActive: () => editorState.isItalic,
|
||||
isActive: () => editorState?.isItalic,
|
||||
command: () => props.editor.chain().focus().toggleItalic().run(),
|
||||
icon: IconItalic,
|
||||
},
|
||||
{
|
||||
name: "Underline",
|
||||
isActive: () => editorState.isUnderline,
|
||||
isActive: () => editorState?.isUnderline,
|
||||
command: () => props.editor.chain().focus().toggleUnderline().run(),
|
||||
icon: IconUnderline,
|
||||
},
|
||||
{
|
||||
name: "Strike",
|
||||
isActive: () => editorState.isStrike,
|
||||
isActive: () => editorState?.isStrike,
|
||||
command: () => props.editor.chain().focus().toggleStrike().run(),
|
||||
icon: IconStrikethrough,
|
||||
},
|
||||
{
|
||||
name: "Code",
|
||||
isActive: () => editorState.isCode,
|
||||
isActive: () => editorState?.isCode,
|
||||
command: () => props.editor.chain().focus().toggleCode().run(),
|
||||
icon: IconCode,
|
||||
},
|
||||
@@ -105,7 +100,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
|
||||
const commentItem: BubbleMenuItem = {
|
||||
name: "Comment",
|
||||
isActive: () => editorState.isComment,
|
||||
isActive: () => editorState?.isComment,
|
||||
command: () => {
|
||||
const commentId = uuid7();
|
||||
|
||||
@@ -140,12 +135,17 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
onHide: () => {
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsTextAlignmentOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
setIsLinkSelectorOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
|
||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
|
||||
<div className={classes.bubbleMenu}>
|
||||
@@ -155,8 +155,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
setIsOpen={() => {
|
||||
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
||||
setIsTextAlignmentOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
setIsLinkSelectorOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -166,8 +166,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
setIsOpen={() => {
|
||||
setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen);
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
setIsLinkSelectorOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Dispatch, FC, SetStateAction } from "react";
|
||||
import { IconCheck, IconPalette } from "@tabler/icons-react";
|
||||
import React, { Dispatch, FC, SetStateAction } from "react";
|
||||
import { IconCheck, IconChevronDown, IconPalette } from "@tabler/icons-react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
ScrollArea,
|
||||
Text,
|
||||
Tooltip,
|
||||
SimpleGrid,
|
||||
Box,
|
||||
Stack,
|
||||
} from "@mantine/core";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
@@ -61,9 +64,12 @@ const TEXT_COLORS: BubbleColorMenuItem[] = [
|
||||
name: "Gray",
|
||||
color: "#A8A29E",
|
||||
},
|
||||
{
|
||||
name: "Brown",
|
||||
color: "#92400E",
|
||||
},
|
||||
];
|
||||
|
||||
// TODO: handle dark mode
|
||||
const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
|
||||
{
|
||||
name: "Default",
|
||||
@@ -71,35 +77,39 @@ const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
|
||||
},
|
||||
{
|
||||
name: "Blue",
|
||||
color: "#c1ecf9",
|
||||
color: "#98d8f2",
|
||||
},
|
||||
{
|
||||
name: "Green",
|
||||
color: "#acf79f",
|
||||
color: "#7edb6c",
|
||||
},
|
||||
{
|
||||
name: "Purple",
|
||||
color: "#f6f3f8",
|
||||
color: "#e0d6ed",
|
||||
},
|
||||
{
|
||||
name: "Red",
|
||||
color: "#fdebeb",
|
||||
color: "#ffc6c2",
|
||||
},
|
||||
{
|
||||
name: "Yellow",
|
||||
color: "#fbf4a2",
|
||||
color: "#faf594",
|
||||
},
|
||||
{
|
||||
name: "Orange",
|
||||
color: "#faebdd",
|
||||
color: "#f5c8a9",
|
||||
},
|
||||
{
|
||||
name: "Pink",
|
||||
color: "#faf1f5",
|
||||
color: "#f5cfe0",
|
||||
},
|
||||
{
|
||||
name: "Gray",
|
||||
color: "#f1f1ef",
|
||||
color: "#dfdfd7",
|
||||
},
|
||||
{
|
||||
name: "Brown",
|
||||
color: "#d7c4b7",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -109,22 +119,26 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
setIsOpen,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
selector: ctx => {
|
||||
selector: (ctx) => {
|
||||
if (!ctx.editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const activeColors: Record<string, boolean> = {};
|
||||
TEXT_COLORS.forEach(({ color }) => {
|
||||
activeColors[`text_${color}`] = ctx.editor.isActive("textStyle", { color });
|
||||
activeColors[`text_${color}`] = ctx.editor.isActive("textStyle", {
|
||||
color,
|
||||
});
|
||||
});
|
||||
HIGHLIGHT_COLORS.forEach(({ color }) => {
|
||||
activeColors[`highlight_${color}`] = ctx.editor.isActive("highlight", { color });
|
||||
activeColors[`highlight_${color}`] = ctx.editor.isActive("highlight", {
|
||||
color,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
return activeColors;
|
||||
},
|
||||
});
|
||||
@@ -133,67 +147,152 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeColorItem = TEXT_COLORS.find(({ color }) =>
|
||||
editorState[`text_${color}`]
|
||||
const activeColorItem = TEXT_COLORS.find(
|
||||
({ color }) => editorState[`text_${color}`],
|
||||
);
|
||||
|
||||
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
|
||||
editorState[`highlight_${color}`]
|
||||
const activeHighlightItem = HIGHLIGHT_COLORS.find(
|
||||
({ color }) => editorState[`highlight_${color}`],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover width={200} opened={isOpen} withArrow>
|
||||
<Popover width={220} opened={isOpen} withArrow>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Text color")} withArrow>
|
||||
<ActionIcon
|
||||
<Button
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="0"
|
||||
style={{
|
||||
border: "none",
|
||||
color: activeColorItem?.color,
|
||||
}}
|
||||
rightSection={<IconChevronDown size={16} />}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
data-text-color={activeColorItem?.color || ""}
|
||||
data-highlight-color={activeHighlightItem?.color || ""}
|
||||
className="color-selector-trigger"
|
||||
style={{
|
||||
height: "34px",
|
||||
border: "none",
|
||||
fontWeight: 500,
|
||||
fontSize: rem(16),
|
||||
paddingLeft: rem(8),
|
||||
paddingRight: rem(4),
|
||||
}}
|
||||
>
|
||||
<IconPalette size={16} stroke={2} />
|
||||
</ActionIcon>
|
||||
A
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown>
|
||||
{/* make mah responsive */}
|
||||
<ScrollArea.Autosize type="scroll" mah="400">
|
||||
<Text span c="dimmed" tt="uppercase" inherit>
|
||||
{t("Color")}
|
||||
</Text>
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t("Text color")}
|
||||
</Text>
|
||||
<SimpleGrid cols={5} spacing="xs">
|
||||
{TEXT_COLORS.map(({ name, color }, index) => (
|
||||
<Tooltip key={index} label={t(name)} withArrow>
|
||||
<Box
|
||||
onClick={() => {
|
||||
if (name === "Default") {
|
||||
editor.commands.unsetColor();
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setColor(color || "")
|
||||
.run();
|
||||
}
|
||||
setIsOpen(false);
|
||||
}}
|
||||
style={{
|
||||
width: rem(28),
|
||||
height: rem(28),
|
||||
borderRadius: rem(6),
|
||||
border: editorState[`text_${color}`]
|
||||
? "2px solid var(--mantine-color-gray-8)"
|
||||
: "1px solid var(--mantine-color-gray-4)",
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: rem(16),
|
||||
fontWeight: 600,
|
||||
color: color || "var(--mantine-color-gray-8)",
|
||||
}}
|
||||
>
|
||||
A
|
||||
</Box>
|
||||
</Tooltip>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Button.Group orientation="vertical">
|
||||
{TEXT_COLORS.map(({ name, color }, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="default"
|
||||
leftSection={<span style={{ color }}>A</span>}
|
||||
justify="left"
|
||||
fullWidth
|
||||
rightSection={
|
||||
editorState[`text_${color}`] && (
|
||||
<IconCheck style={{ width: rem(16) }} />
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
if (name === "Default") {
|
||||
editor.commands.unsetColor();
|
||||
} else {
|
||||
editor.chain().focus().setColor(color || "").run();
|
||||
}
|
||||
setIsOpen(false);
|
||||
}}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
{t(name)}
|
||||
</Button>
|
||||
))}
|
||||
</Button.Group>
|
||||
<Box>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t("Highlight color")}
|
||||
</Text>
|
||||
<SimpleGrid cols={5} spacing="xs">
|
||||
{HIGHLIGHT_COLORS.map(({ name, color }, index) => (
|
||||
<Tooltip key={index} label={t(name)} withArrow>
|
||||
<Box
|
||||
onClick={() => {
|
||||
if (name === "Default") {
|
||||
editor.commands.unsetHighlight();
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.toggleMark("highlight", {
|
||||
color: color || "",
|
||||
colorName: name.toLowerCase() || "",
|
||||
})
|
||||
.run();
|
||||
}
|
||||
setIsOpen(false);
|
||||
}}
|
||||
style={{
|
||||
width: rem(28),
|
||||
height: rem(28),
|
||||
borderRadius: rem(4),
|
||||
backgroundColor: color || "var(--mantine-color-gray-2)",
|
||||
border: "1px solid var(--mantine-color-gray-4)",
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: rem(16),
|
||||
fontWeight: 600,
|
||||
color: "var(--mantine-color-gray-8)",
|
||||
}}
|
||||
>
|
||||
{editorState[`highlight_${color}`] ? (
|
||||
<IconCheck
|
||||
size={16}
|
||||
color="var(--mantine-color-green-7)"
|
||||
/>
|
||||
) : (
|
||||
"A"
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
editor.commands.unsetColor();
|
||||
editor.commands.unsetHighlight();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("Remove color")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
@@ -39,7 +39,7 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
selector: ctx => {
|
||||
selector: (ctx) => {
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
@@ -65,45 +65,45 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
command: () =>
|
||||
editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
|
||||
isActive: () =>
|
||||
editorState.isParagraph &&
|
||||
!editorState.isBulletList &&
|
||||
!editorState.isOrderedList,
|
||||
editorState?.isParagraph &&
|
||||
!editorState?.isBulletList &&
|
||||
!editorState?.isOrderedList,
|
||||
},
|
||||
{
|
||||
name: "Heading 1",
|
||||
icon: IconH1,
|
||||
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
||||
isActive: () => editorState.isHeading1,
|
||||
isActive: () => editorState?.isHeading1,
|
||||
},
|
||||
{
|
||||
name: "Heading 2",
|
||||
icon: IconH2,
|
||||
command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
||||
isActive: () => editorState.isHeading2,
|
||||
isActive: () => editorState?.isHeading2,
|
||||
},
|
||||
{
|
||||
name: "Heading 3",
|
||||
icon: IconH3,
|
||||
command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||
isActive: () => editorState.isHeading3,
|
||||
isActive: () => editorState?.isHeading3,
|
||||
},
|
||||
{
|
||||
name: "To-do List",
|
||||
icon: IconCheckbox,
|
||||
command: () => editor.chain().focus().toggleTaskList().run(),
|
||||
isActive: () => editorState.isTaskItem,
|
||||
isActive: () => editorState?.isTaskItem,
|
||||
},
|
||||
{
|
||||
name: "Bullet List",
|
||||
icon: IconList,
|
||||
command: () => editor.chain().focus().toggleBulletList().run(),
|
||||
isActive: () => editorState.isBulletList,
|
||||
isActive: () => editorState?.isBulletList,
|
||||
},
|
||||
{
|
||||
name: "Numbered List",
|
||||
icon: IconListNumbers,
|
||||
command: () => editor.chain().focus().toggleOrderedList().run(),
|
||||
isActive: () => editorState.isOrderedList,
|
||||
isActive: () => editorState?.isOrderedList,
|
||||
},
|
||||
{
|
||||
name: "Blockquote",
|
||||
@@ -115,13 +115,13 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
.toggleNode("paragraph", "paragraph")
|
||||
.toggleBlockquote()
|
||||
.run(),
|
||||
isActive: () => editorState.isBlockquote,
|
||||
isActive: () => editorState?.isBlockquote,
|
||||
},
|
||||
{
|
||||
name: "Code",
|
||||
icon: IconCode,
|
||||
command: () => editor.chain().focus().toggleCodeBlock().run(),
|
||||
isActive: () => editorState.isCodeBlock,
|
||||
isActive: () => editorState?.isCodeBlock,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
selector: ctx => {
|
||||
selector: (ctx) => {
|
||||
if (!ctx.editor) {
|
||||
return null;
|
||||
}
|
||||
@@ -55,25 +55,25 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
|
||||
const items: BubbleMenuItem[] = [
|
||||
{
|
||||
name: "Align left",
|
||||
isActive: () => editorState.isAlignLeft,
|
||||
isActive: () => editorState?.isAlignLeft,
|
||||
command: () => editor.chain().focus().setTextAlign("left").run(),
|
||||
icon: IconAlignLeft,
|
||||
},
|
||||
{
|
||||
name: "Align center",
|
||||
isActive: () => editorState.isAlignCenter,
|
||||
isActive: () => editorState?.isAlignCenter,
|
||||
command: () => editor.chain().focus().setTextAlign("center").run(),
|
||||
icon: IconAlignCenter,
|
||||
},
|
||||
{
|
||||
name: "Align right",
|
||||
isActive: () => editorState.isAlignRight,
|
||||
isActive: () => editorState?.isAlignRight,
|
||||
command: () => editor.chain().focus().setTextAlign("right").run(),
|
||||
icon: IconAlignRight,
|
||||
},
|
||||
{
|
||||
name: "Justify",
|
||||
isActive: () => editorState.isAlignJustify,
|
||||
isActive: () => editorState?.isAlignJustify,
|
||||
command: () => editor.chain().focus().setTextAlign("justify").run(),
|
||||
icon: IconAlignJustified,
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
} from "@/features/editor/components/table/types/types.ts";
|
||||
import { ActionIcon, Tooltip, Divider } from "@mantine/core";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangleFilled,
|
||||
IconCircleCheckFilled,
|
||||
@@ -21,6 +21,17 @@ import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||
export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.isActive("callout");
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
@@ -38,17 +49,6 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.isActive("callout");
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const getReferencedVirtualElement = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const { selection } = editor.state;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import classes from "./code-block.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useComputedColorScheme } from "@mantine/core";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
interface MermaidViewProps {
|
||||
props: NodeViewProps;
|
||||
@@ -37,7 +38,7 @@ export default function MermaidView({ props }: MermaidViewProps) {
|
||||
.catch((err) => {
|
||||
if (props.editor.isEditable) {
|
||||
setPreview(
|
||||
`<div class="${classes.error}">${t("Mermaid diagram error:")} ${err}</div>`,
|
||||
`<div class="${classes.error}">${t("Mermaid diagram error:")} ${DOMPurify.sanitize(err)}</div>`,
|
||||
);
|
||||
} else {
|
||||
setPreview(
|
||||
|
||||
@@ -34,7 +34,9 @@ export const handlePaste = (
|
||||
return false;
|
||||
}
|
||||
|
||||
createMentionAction(url, view, pos, creatorId);
|
||||
const anchorId = match[6] ? match[6].split('#')[0] : undefined;
|
||||
const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url;
|
||||
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<Modal.Root opened={opened} onClose={close} fullScreen>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
{embedUrl ? (
|
||||
<ResizableWrapper
|
||||
initialHeight={nodeHeight || 480}
|
||||
|
||||
@@ -119,7 +119,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<ReactClearModal
|
||||
style={{
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function ImageView(props: NodeViewProps) {
|
||||
}, [align]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<Image
|
||||
radius="md"
|
||||
fit="contain"
|
||||
|
||||
@@ -9,6 +9,7 @@ export type LinkFn = (
|
||||
view: EditorView,
|
||||
pos: number,
|
||||
creatorId: string,
|
||||
anchorId?: string,
|
||||
) => void;
|
||||
|
||||
export interface InternalLinkOptions {
|
||||
@@ -18,7 +19,7 @@ export interface InternalLinkOptions {
|
||||
|
||||
export const handleInternalLink =
|
||||
({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn =>
|
||||
async (url: string, view, pos, creatorId) => {
|
||||
async (url: string, view, pos, creatorId, anchorId) => {
|
||||
const validated = validateFn(url, view);
|
||||
if (!validated) return;
|
||||
|
||||
@@ -35,6 +36,7 @@ export const handleInternalLink =
|
||||
entityId: page.id,
|
||||
slugId: page.slugId,
|
||||
creatorId: creatorId,
|
||||
anchorId: anchorId,
|
||||
});
|
||||
|
||||
if (!node) return;
|
||||
|
||||
@@ -19,7 +19,6 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
|
||||
if (!ctx.editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const link = ctx.editor.getAttributes("link");
|
||||
return {
|
||||
href: link.href,
|
||||
@@ -81,13 +80,13 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
|
||||
bg="var(--mantine-color-body)"
|
||||
>
|
||||
<LinkEditorPanel
|
||||
initialUrl={editorState.href}
|
||||
initialUrl={editorState?.href}
|
||||
onSetLink={onSetLink}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<LinkPreviewPanel
|
||||
url={editorState.href}
|
||||
url={editorState?.href}
|
||||
onClear={onUnsetLink}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@ import classes from "./mention.module.css";
|
||||
|
||||
export default function MentionView(props: NodeViewProps) {
|
||||
const { node } = props;
|
||||
const { label, entityType, entityId, slugId } = node.attrs;
|
||||
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
|
||||
const { spaceSlug } = useParams();
|
||||
const { shareId } = useParams();
|
||||
const {
|
||||
@@ -27,10 +27,11 @@ export default function MentionView(props: NodeViewProps) {
|
||||
shareId,
|
||||
pageSlugId: slugId,
|
||||
pageTitle: label,
|
||||
anchorId,
|
||||
});
|
||||
|
||||
return (
|
||||
<NodeViewWrapper style={{ display: "inline" }}>
|
||||
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
||||
{entityType === "user" && (
|
||||
<Text className={classes.userMention} component="span">
|
||||
@{label}
|
||||
@@ -42,7 +43,7 @@ export default function MentionView(props: NodeViewProps) {
|
||||
component={Link}
|
||||
fw={500}
|
||||
to={
|
||||
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label)
|
||||
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId)
|
||||
}
|
||||
underline="never"
|
||||
className={classes.pageMentionLink}
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function SubpagesView(props: NodeViewProps) {
|
||||
|
||||
if (error && !shareId) {
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<Text c="dimmed" size="md" py="md">
|
||||
{t("Failed to load subpages")}
|
||||
</Text>
|
||||
@@ -63,7 +63,7 @@ export default function SubpagesView(props: NodeViewProps) {
|
||||
|
||||
if (subpages.length === 0) {
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<div className={classes.container}>
|
||||
<Text c="dimmed" size="md" py="md">
|
||||
{t("No subpages")}
|
||||
@@ -74,7 +74,7 @@ export default function SubpagesView(props: NodeViewProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<div className={classes.container}>
|
||||
<Stack gap={5}>
|
||||
{subpages.map((page) => (
|
||||
|
||||
@@ -38,14 +38,14 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = React.useState(false);
|
||||
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
selector: ctx => {
|
||||
selector: (ctx) => {
|
||||
if (!ctx.editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
let currentColor = "";
|
||||
if (ctx.editor.isActive("tableCell")) {
|
||||
const attrs = ctx.editor.getAttributes("tableCell");
|
||||
@@ -54,7 +54,7 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
|
||||
const attrs = ctx.editor.getAttributes("tableHeader");
|
||||
currentColor = attrs.backgroundColor || "";
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
currentColor,
|
||||
isTableCell: ctx.editor.isActive("tableCell"),
|
||||
@@ -62,7 +62,7 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
if (!editor || !editorState) {
|
||||
return null;
|
||||
}
|
||||
@@ -71,20 +71,18 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes("tableCell", {
|
||||
.updateAttributes("tableCell", {
|
||||
backgroundColor: color || null,
|
||||
backgroundColorName: color ? colorName : null
|
||||
backgroundColorName: color ? colorName : null,
|
||||
})
|
||||
.updateAttributes("tableHeader", {
|
||||
.updateAttributes("tableHeader", {
|
||||
backgroundColor: color || null,
|
||||
backgroundColorName: color ? colorName : null
|
||||
backgroundColorName: color ? colorName : null,
|
||||
})
|
||||
.run();
|
||||
setOpened(false);
|
||||
};
|
||||
|
||||
const currentColor = editorState.currentColor;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
width={200}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Popover,
|
||||
rem,
|
||||
ScrollArea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
@@ -32,14 +31,14 @@ interface AlignmentItem {
|
||||
export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = React.useState(false);
|
||||
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
selector: ctx => {
|
||||
selector: (ctx) => {
|
||||
if (!ctx.editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
isAlignLeft: ctx.editor.isActive({ textAlign: "left" }),
|
||||
isAlignCenter: ctx.editor.isActive({ textAlign: "center" }),
|
||||
@@ -47,7 +46,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
if (!editor || !editorState) {
|
||||
return null;
|
||||
}
|
||||
@@ -56,21 +55,21 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
|
||||
{
|
||||
name: "Align left",
|
||||
value: "left",
|
||||
isActive: () => editorState.isAlignLeft,
|
||||
isActive: () => editorState?.isAlignLeft,
|
||||
command: () => editor.chain().focus().setTextAlign("left").run(),
|
||||
icon: IconAlignLeft,
|
||||
},
|
||||
{
|
||||
name: "Align center",
|
||||
value: "center",
|
||||
isActive: () => editorState.isAlignCenter,
|
||||
isActive: () => editorState?.isAlignCenter,
|
||||
command: () => editor.chain().focus().setTextAlign("center").run(),
|
||||
icon: IconAlignCenter,
|
||||
},
|
||||
{
|
||||
name: "Align right",
|
||||
value: "right",
|
||||
isActive: () => editorState.isAlignRight,
|
||||
isActive: () => editorState?.isAlignRight,
|
||||
command: () => editor.chain().focus().setTextAlign("right").run(),
|
||||
icon: IconAlignRight,
|
||||
},
|
||||
@@ -84,7 +83,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
|
||||
onChange={setOpened}
|
||||
position="bottom"
|
||||
withArrow
|
||||
transitionProps={{ transition: 'pop' }}
|
||||
transitionProps={{ transition: "pop" }}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Text alignment")} withArrow>
|
||||
@@ -107,9 +106,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
|
||||
key={index}
|
||||
variant="default"
|
||||
leftSection={<item.icon size={16} />}
|
||||
rightSection={
|
||||
item.isActive() && <IconCheck size={16} />
|
||||
}
|
||||
rightSection={item.isActive() && <IconCheck size={16} />}
|
||||
justify="left"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
@@ -126,4 +123,4 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -124,9 +124,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
onClick={alignVideoLeft}
|
||||
size="lg"
|
||||
aria-label={t("Align left")}
|
||||
variant={
|
||||
editorState?.isAlignLeft ? "light" : "default"
|
||||
}
|
||||
variant={editorState?.isAlignLeft ? "light" : "default"}
|
||||
>
|
||||
<IconLayoutAlignLeft size={18} />
|
||||
</ActionIcon>
|
||||
@@ -137,9 +135,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
onClick={alignVideoCenter}
|
||||
size="lg"
|
||||
aria-label={t("Align center")}
|
||||
variant={
|
||||
editorState?.isAlignCenter ? "light" : "default"
|
||||
}
|
||||
variant={editorState?.isAlignCenter ? "light" : "default"}
|
||||
>
|
||||
<IconLayoutAlignCenter size={18} />
|
||||
</ActionIcon>
|
||||
@@ -150,9 +146,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
onClick={alignVideoRight}
|
||||
size="lg"
|
||||
aria-label={t("Align right")}
|
||||
variant={
|
||||
editorState?.isAlignRight ? "light" : "default"
|
||||
}
|
||||
variant={editorState?.isAlignRight ? "light" : "default"}
|
||||
>
|
||||
<IconLayoutAlignRight size={18} />
|
||||
</ActionIcon>
|
||||
@@ -160,10 +154,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
</ActionIcon.Group>
|
||||
|
||||
{editorState?.width && (
|
||||
<NodeWidthResize
|
||||
onChange={onWidthChange}
|
||||
value={editorState.width}
|
||||
/>
|
||||
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
||||
)}
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function VideoView(props: NodeViewProps) {
|
||||
}, [align]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<video
|
||||
preload="metadata"
|
||||
width={width}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { StarterKit } from "@tiptap/starter-kit";
|
||||
import { TextAlign } from "@tiptap/extension-text-align";
|
||||
import { TaskList, TaskItem } from "@tiptap/extension-list";
|
||||
import { TaskList, TaskItem, ListKeymap } from "@tiptap/extension-list";
|
||||
import { Placeholder, CharacterCount } from "@tiptap/extensions";
|
||||
import { Superscript } from "@tiptap/extension-superscript";
|
||||
import SubScript from "@tiptap/extension-subscript";
|
||||
import { Highlight } from "@tiptap/extension-highlight";
|
||||
import { Typography } from "@tiptap/extension-typography";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
|
||||
import { Youtube } from "@tiptap/extension-youtube";
|
||||
import SlashCommand from "@/features/editor/extensions/slash-command";
|
||||
import { Collaboration } from "@tiptap/extension-collaboration";
|
||||
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
|
||||
import { CollaborationCaret } from "@tiptap/extension-collaboration-caret";
|
||||
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import {
|
||||
Comment,
|
||||
@@ -39,6 +39,9 @@ import {
|
||||
Mention,
|
||||
TableDndExtension,
|
||||
Subpages,
|
||||
Heading,
|
||||
Highlight,
|
||||
UniqueID,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -47,17 +50,16 @@ import {
|
||||
import { IUser } from "@/features/user/types/user.types.ts";
|
||||
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
|
||||
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
|
||||
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
|
||||
import { Youtube } from "@tiptap/extension-youtube";
|
||||
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
||||
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import VideoView from "@/features/editor/components/video/video-view.tsx";
|
||||
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
||||
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
||||
import DrawioView from "../components/drawio/drawio-view";
|
||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
import powershell from "highlight.js/lib/languages/powershell";
|
||||
import abap from "highlightjs-sap-abap";
|
||||
@@ -75,7 +77,6 @@ import i18n from "@/i18n.ts";
|
||||
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
|
||||
import EmojiCommand from "./emoji-command";
|
||||
import { countWords } from "alfaaz";
|
||||
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("mermaid", plaintext);
|
||||
@@ -91,6 +92,7 @@ lowlight.register("scala", scala);
|
||||
|
||||
export const mainExtensions = [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
undoRedo: false,
|
||||
link: false,
|
||||
trailingNode: false,
|
||||
@@ -105,6 +107,11 @@ export const mainExtensions = [
|
||||
},
|
||||
},
|
||||
}),
|
||||
Heading,
|
||||
UniqueID.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
filterTransaction: (transaction) => !isChangeOrigin(transaction),
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === "heading") {
|
||||
@@ -248,6 +255,7 @@ type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||
export const collabExtensions: CollabExtensions = (provider, user) => [
|
||||
Collaboration.configure({
|
||||
document: provider.document,
|
||||
provider,
|
||||
}),
|
||||
CollaborationCaret.configure({
|
||||
provider,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
function waitForState(checkFn: () => boolean): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
if (checkFn()) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
}, 800);
|
||||
});
|
||||
}
|
||||
|
||||
export const useEditorScroll = ({
|
||||
canScroll,
|
||||
initialScrollTo,
|
||||
}: {
|
||||
canScroll: () => boolean;
|
||||
initialScrollTo?: string;
|
||||
}) => {
|
||||
const [scrollTo, setScrollTo] = useState<string>(initialScrollTo || "");
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialScrollTo) {
|
||||
setScrollTo(window.location.hash ? window.location.hash.slice(1) : "");
|
||||
}
|
||||
}, [initialScrollTo]);
|
||||
|
||||
const handleScrollTo = useCallback(async (editor: Editor, _scrollTo: string | null = null, tryCount: number = 0) => {
|
||||
await waitForState(() => canScroll());
|
||||
return new Promise((resolve) => {
|
||||
const MAX_TRY_COUNT = 10;
|
||||
if (tryCount >= MAX_TRY_COUNT) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetId = _scrollTo || scrollTo;
|
||||
if (!targetId) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const dom = editor.view.dom.querySelector(`[id="${targetId}"]`);
|
||||
if (dom) {
|
||||
dom.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
resolve(true);
|
||||
} else {
|
||||
setTimeout(async () => {
|
||||
resolve(await handleScrollTo(editor, targetId, tryCount + 1));
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
}, [scrollTo, canScroll]);
|
||||
|
||||
return { scrollTo, handleScrollTo };
|
||||
};
|
||||
@@ -1,5 +1,11 @@
|
||||
import "@/features/editor/styles/index.css";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
import {
|
||||
@@ -8,7 +14,12 @@ import {
|
||||
onAuthenticationFailedParameters,
|
||||
WebSocketStatus,
|
||||
} from "@hocuspocus/provider";
|
||||
import { EditorContent, EditorProvider, useEditor, useEditorState } from "@tiptap/react";
|
||||
import {
|
||||
EditorContent,
|
||||
EditorProvider,
|
||||
useEditor,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
import {
|
||||
collabExtensions,
|
||||
mainExtensions,
|
||||
@@ -51,7 +62,8 @@ import { extractPageSlugId } from "@/lib";
|
||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { searchSpotlight } from '@/features/search/constants.ts';
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@@ -65,6 +77,13 @@ export default function PageEditor({
|
||||
content,
|
||||
}: PageEditorProps) {
|
||||
const collaborationURL = useCollaborationUrl();
|
||||
const isComponentMounted = useRef(false);
|
||||
const editorCreated = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
isComponentMounted.current = true;
|
||||
}, []);
|
||||
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [, setEditor] = useAtom(pageEditorAtom);
|
||||
const [, setAsideState] = useAtom(asideStateAtom);
|
||||
@@ -91,6 +110,11 @@ export default function PageEditor({
|
||||
const userPageEditMode =
|
||||
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||
|
||||
const canScroll = useCallback(
|
||||
() => isComponentMounted.current && editorCreated.current,
|
||||
[isComponentMounted, editorCreated],
|
||||
);
|
||||
const { handleScrollTo } = useEditorScroll({ canScroll });
|
||||
// Providers only created once per pageId
|
||||
const providersRef = useRef<{
|
||||
local: IndexeddbPersistence;
|
||||
@@ -215,7 +239,7 @@ export default function PageEditor({
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyK') {
|
||||
if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
|
||||
searchSpotlight.open();
|
||||
return true;
|
||||
}
|
||||
@@ -252,6 +276,8 @@ export default function PageEditor({
|
||||
setEditor(editor);
|
||||
// @ts-ignore
|
||||
editor.storage.pageId = pageId;
|
||||
handleScrollTo(editor);
|
||||
editorCreated.current = true;
|
||||
}
|
||||
},
|
||||
onUpdate({ editor }) {
|
||||
@@ -263,11 +289,10 @@ export default function PageEditor({
|
||||
},
|
||||
[pageId, editable, remoteProvider],
|
||||
);
|
||||
|
||||
// Track editor's editable state since shouldRerenderOnTransaction is false
|
||||
|
||||
const editorIsEditable = useEditorState({
|
||||
editor,
|
||||
selector: ctx => {
|
||||
selector: (ctx) => {
|
||||
return ctx.editor?.isEditable ?? false;
|
||||
},
|
||||
});
|
||||
@@ -369,20 +394,15 @@ export default function PageEditor({
|
||||
const [showStatic, setShowStatic] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for both connection and sync before switching from static editor
|
||||
if (
|
||||
!hasConnectedOnceRef.current &&
|
||||
remoteProvider?.configuration.websocketProvider.status ===
|
||||
WebSocketStatus.Connected &&
|
||||
isLocalSynced // Also wait for local sync to complete
|
||||
WebSocketStatus.Connected
|
||||
) {
|
||||
hasConnectedOnceRef.current = true;
|
||||
// Small delay to ensure smooth transition
|
||||
setTimeout(() => {
|
||||
setShowStatic(false);
|
||||
}, 100);
|
||||
setShowStatic(false);
|
||||
}
|
||||
}, [remoteProvider?.configuration.websocketProvider.status, isLocalSynced]);
|
||||
}, [remoteProvider?.configuration.websocketProvider.status]);
|
||||
|
||||
if (showStatic) {
|
||||
return (
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import "@/features/editor/styles/index.css";
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { EditorProvider } from "@tiptap/react";
|
||||
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { Heading } from "@tiptap/extension-heading";
|
||||
import { Heading, generateNodeId, UniqueID } from "@docmost/editor-ext";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import { useAtom } from "jotai";
|
||||
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||
|
||||
interface PageEditorProps {
|
||||
title: string;
|
||||
@@ -21,9 +22,34 @@ export default function ReadonlyPageEditor({
|
||||
pageId,
|
||||
}: PageEditorProps) {
|
||||
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
|
||||
const isComponentMounted = useRef(false);
|
||||
const editorCreated = useRef(false);
|
||||
|
||||
const canScroll = useCallback(
|
||||
() => isComponentMounted.current && editorCreated.current,
|
||||
[isComponentMounted, editorCreated],
|
||||
);
|
||||
const initialScrollTo = window.location.hash
|
||||
? window.location.hash.slice(1)
|
||||
: "";
|
||||
const { handleScrollTo } = useEditorScroll({ canScroll, initialScrollTo });
|
||||
|
||||
useEffect(() => {
|
||||
isComponentMounted.current = true;
|
||||
}, []);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
return [...mainExtensions];
|
||||
const filteredExtensions = mainExtensions.filter(
|
||||
(ext) => ext.name !== "uniqueID",
|
||||
);
|
||||
|
||||
return [
|
||||
...filteredExtensions,
|
||||
UniqueID.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
updateDocument: false,
|
||||
}),
|
||||
];
|
||||
}, []);
|
||||
|
||||
const titleExtensions = [
|
||||
@@ -60,6 +86,9 @@ export default function ReadonlyPageEditor({
|
||||
}
|
||||
// @ts-ignore
|
||||
setReadOnlyEditor(editor);
|
||||
|
||||
handleScrollTo(editor);
|
||||
editorCreated.current = true;
|
||||
}
|
||||
}}
|
||||
></EditorProvider>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
);
|
||||
color: light-dark(
|
||||
var(--mantine-color-default-color),
|
||||
var(--mantine-color-dark-0)
|
||||
var(--mantine-color-white)
|
||||
);
|
||||
font-size: var(--mantine-font-size-md);
|
||||
line-height: var(--mantine-line-height-xl);
|
||||
@@ -115,7 +115,7 @@
|
||||
}
|
||||
|
||||
& > .react-renderer {
|
||||
margin-top: var(--mantine-spacing-sm);
|
||||
margin-top: var(--mantine-spacing-sm);
|
||||
margin-bottom: var(--mantine-spacing-sm);
|
||||
|
||||
&:first-child {
|
||||
@@ -141,7 +141,7 @@
|
||||
|
||||
.selection,
|
||||
*::selection {
|
||||
background-color: Highlight;
|
||||
background-color: light-dark(Highlight, var(--mantine-color-gray-7));
|
||||
}
|
||||
|
||||
.comment-mark {
|
||||
@@ -186,6 +186,39 @@
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.ProseMirror > h1,
|
||||
.ProseMirror > h2,
|
||||
.ProseMirror > h3,
|
||||
.ProseMirror > h4,
|
||||
.ProseMirror > h5,
|
||||
.ProseMirror > h6 {
|
||||
|
||||
> .link-btn {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
}
|
||||
|
||||
> .link-btn > .link-btn-content {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
transition: opacity 0.15s ease;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&:hover > .link-btn > .link-btn-content {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
scroll-margin-top: 80px; /* match your header height */
|
||||
}
|
||||
|
||||
.ProseMirror-icon {
|
||||
@@ -209,4 +242,3 @@
|
||||
.actionIconGroup {
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/* Highlight colors with dark mode support */
|
||||
|
||||
.ProseMirror {
|
||||
/* Blue */
|
||||
mark[data-color="#98d8f2"] {
|
||||
background-color: light-dark(
|
||||
rgb(224 242 254),
|
||||
rgba(37, 99, 235, 0.35)
|
||||
) !important;
|
||||
}
|
||||
|
||||
/* Green */
|
||||
mark[data-color="#7edb6c"] {
|
||||
background-color: light-dark(
|
||||
rgb(220 252 231),
|
||||
rgba(0, 138, 0, 0.35)
|
||||
) !important;
|
||||
}
|
||||
|
||||
/* Purple */
|
||||
mark[data-color="#e0d6ed"] {
|
||||
background-color: light-dark(
|
||||
rgb(243 232 255),
|
||||
rgba(147, 51, 234, 0.35)
|
||||
) !important;
|
||||
}
|
||||
|
||||
/* Red */
|
||||
mark[data-color="#ffc6c2"] {
|
||||
background-color: light-dark(
|
||||
rgb(255 228 230),
|
||||
rgba(224, 0, 0, 0.35)
|
||||
) !important;
|
||||
}
|
||||
|
||||
/* Yellow */
|
||||
mark[data-color="#faf594"] {
|
||||
background-color: light-dark(
|
||||
rgb(254 249 195),
|
||||
rgba(234, 179, 8, 0.35)
|
||||
) !important;
|
||||
}
|
||||
|
||||
/* Orange */
|
||||
mark[data-color="#f5c8a9"] {
|
||||
background-color: light-dark(
|
||||
rgb(251, 236, 221),
|
||||
rgba(255, 165, 0, 0.45)
|
||||
) !important;
|
||||
}
|
||||
|
||||
/* Pink */
|
||||
mark[data-color="#f5cfe0"] {
|
||||
background-color: light-dark(
|
||||
rgb(252, 241, 246),
|
||||
rgba(186, 64, 129, 0.35)
|
||||
) !important;
|
||||
}
|
||||
|
||||
/* Gray */
|
||||
mark[data-color="#dfdfd7"] {
|
||||
background-color: light-dark(
|
||||
rgb(238 238 235),
|
||||
rgba(168, 162, 158, 0.35)
|
||||
) !important;
|
||||
}
|
||||
|
||||
/* Brown */
|
||||
mark[data-color="#d7c4b7"] {
|
||||
background-color: light-dark(
|
||||
rgb(215 196 183),
|
||||
rgba(146, 64, 14, 0.35)
|
||||
) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Color selector trigger button styles */
|
||||
.color-selector-trigger[data-text-color="#2563EB"] {
|
||||
color: #2563EB !important;
|
||||
}
|
||||
|
||||
.color-selector-trigger[data-text-color="#008A00"] {
|
||||
color: #008A00 !important;
|
||||
}
|
||||
|
||||
.color-selector-trigger[data-text-color="#9333EA"] {
|
||||
color: #9333EA !important;
|
||||
}
|
||||
|
||||
.color-selector-trigger[data-text-color="#E00000"] {
|
||||
color: #E00000 !important;
|
||||
}
|
||||
|
||||
.color-selector-trigger[data-text-color="#EAB308"] {
|
||||
color: #EAB308 !important;
|
||||
}
|
||||
|
||||
.color-selector-trigger[data-text-color="#FFA500"] {
|
||||
color: #FFA500 !important;
|
||||
}
|
||||
|
||||
.color-selector-trigger[data-text-color="#BA4081"] {
|
||||
color: #BA4081 !important;
|
||||
}
|
||||
|
||||
.color-selector-trigger[data-text-color="#A8A29E"] {
|
||||
color: #A8A29E !important;
|
||||
}
|
||||
|
||||
.color-selector-trigger[data-text-color="#92400E"] {
|
||||
color: #92400E !important;
|
||||
}
|
||||
|
||||
/* Highlight background colors with light-dark support - solid colors for trigger button */
|
||||
.color-selector-trigger[data-highlight-color="#98d8f2"] {
|
||||
background-color: light-dark(
|
||||
rgb(224 242 254),
|
||||
rgb(30 64 175)
|
||||
) !important;
|
||||
}
|
||||
|
||||
.color-selector-trigger[data-highlight-color="#7edb6c"] {
|
||||
background-color: light-dark(
|
||||
rgb(220 252 231),
|
||||
rgb(21 128 61)
|
||||
) !important;
|
||||
}
|
||||
|
||||
.color-selector-trigger[data-highlight-color="#e0d6ed"] {
|
||||
background-color: light-dark(
|
||||
rgb(243 232 255),
|
||||
rgb(107 33 168)
|
||||
) !important;
|
||||
}
|
||||
|
||||
.color-selector-trigger[data-highlight-color="#ffc6c2"] {
|
||||
background-color: light-dark(
|
||||
rgb(255 228 230),
|
||||
rgb(185 28 28)
|
||||
) !important;
|
||||
}
|
||||
|
||||
.color-selector-trigger[data-highlight-color="#faf594"] {
|
||||
background-color: light-dark(
|
||||
rgb(254 249 195),
|
||||
rgb(161 98 7)
|
||||
) !important;
|
||||
}
|
||||
|
||||
.color-selector-trigger[data-highlight-color="#f5c8a9"] {
|
||||
background-color: light-dark(
|
||||
rgb(251 236 221),
|
||||
rgb(194 65 12)
|
||||
) !important;
|
||||
}
|
||||
|
||||
.color-selector-trigger[data-highlight-color="#f5cfe0"] {
|
||||
background-color: light-dark(
|
||||
rgb(252 241 246),
|
||||
rgb(157 23 77)
|
||||
) !important;
|
||||
}
|
||||
|
||||
.color-selector-trigger[data-highlight-color="#dfdfd7"] {
|
||||
background-color: light-dark(
|
||||
rgb(238 238 235),
|
||||
rgb(115 115 115)
|
||||
) !important;
|
||||
}
|
||||
|
||||
.color-selector-trigger[data-highlight-color="#d7c4b7"] {
|
||||
background-color: light-dark(
|
||||
rgb(215 196 183),
|
||||
rgb(120 53 15)
|
||||
) !important;
|
||||
}
|
||||
@@ -12,3 +12,4 @@
|
||||
@import "./find.css";
|
||||
@import "./mention.css";
|
||||
@import "./ordered-list.css";
|
||||
@import "./highlight.css";
|
||||
|
||||
@@ -104,7 +104,10 @@ export function TitleEditor({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const pageSlug = buildPageUrl(spaceSlug, slugId, title);
|
||||
const anchorId = window.location.hash
|
||||
? window.location.hash.substring(1)
|
||||
: undefined;
|
||||
const pageSlug = buildPageUrl(spaceSlug, slugId, title, anchorId);
|
||||
navigate(pageSlug, { replace: true });
|
||||
}, [title]);
|
||||
|
||||
@@ -192,10 +195,43 @@ export function TitleEditor({
|
||||
const { key } = event;
|
||||
const { $head } = titleEditor.state.selection;
|
||||
|
||||
if (key === "Enter") {
|
||||
event.preventDefault();
|
||||
|
||||
const { $from } = titleEditor.state.selection;
|
||||
const titleText = titleEditor.getText();
|
||||
|
||||
// Get the text offset within the heading node (not document position)
|
||||
const textOffset = $from.parentOffset;
|
||||
|
||||
const textAfterCursor = titleText.slice(textOffset);
|
||||
|
||||
// Delete text after cursor from title (this will be in undo history)
|
||||
const endPos = titleEditor.state.doc.content.size;
|
||||
if (textAfterCursor) {
|
||||
titleEditor.commands.deleteRange({ from: $from.pos, to: endPos });
|
||||
}
|
||||
|
||||
// Don't add to history so undo in page editor won't remove this split
|
||||
pageEditor
|
||||
.chain()
|
||||
.command(({ tr }) => {
|
||||
tr.setMeta("addToHistory", false);
|
||||
return true;
|
||||
})
|
||||
.insertContentAt(0, {
|
||||
type: "paragraph",
|
||||
content: textAfterCursor
|
||||
? [{ type: "text", text: textAfterCursor }]
|
||||
: undefined,
|
||||
})
|
||||
.focus("start")
|
||||
.run();
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldFocusEditor =
|
||||
key === "Enter" ||
|
||||
key === "ArrowDown" ||
|
||||
(key === "ArrowRight" && !$head.nodeAfter);
|
||||
key === "ArrowDown" || (key === "ArrowRight" && !$head.nodeAfter);
|
||||
|
||||
if (shouldFocusEditor) {
|
||||
pageEditor.commands.focus("start");
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
|
||||
import React, { useState } from "react";
|
||||
import { useCreateGroupMutation } from "@/features/group/queries/group-query.ts";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { useForm } from "@mantine/form";
|
||||
import * as z from "zod";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { zodResolver } from 'mantine-form-zod-resolver';
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().trim().min(2).max(50),
|
||||
|
||||
@@ -4,10 +4,11 @@ import {
|
||||
useGroupQuery,
|
||||
useUpdateGroupMutation,
|
||||
} from "@/features/group/queries/group-query.ts";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { useForm } from "@mantine/form";
|
||||
import * as z from "zod";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2).max(50),
|
||||
|
||||
@@ -22,6 +22,7 @@ import { IUser } from "@/features/user/types/user.types.ts";
|
||||
import { useEffect } from "react";
|
||||
import { validate as isValidUuid } from "uuid";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function useGetGroupsQuery(
|
||||
params?: QueryParams,
|
||||
@@ -73,11 +74,12 @@ export function useCreateGroupMutation() {
|
||||
|
||||
export function useUpdateGroupMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IGroup, Error, Partial<IGroup>>({
|
||||
mutationFn: (data) => updateGroup(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: "Group updated successfully" });
|
||||
notifications.show({ message: t("Group updated successfully") });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["group", variables.groupId],
|
||||
});
|
||||
@@ -91,11 +93,12 @@ export function useUpdateGroupMutation() {
|
||||
|
||||
export function useDeleteGroupMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (groupId: string) => deleteGroup({ groupId }),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: "Group deleted successfully" });
|
||||
notifications.show({ message: t("Group deleted successfully") });
|
||||
queryClient.refetchQueries({ queryKey: ["groups"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -119,11 +122,12 @@ export function useGroupMembersQuery(
|
||||
|
||||
export function useAddGroupMemberMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, { groupId: string; userIds: string[] }>({
|
||||
mutationFn: (data) => addGroupMember(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: "Added successfully" });
|
||||
notifications.show({ message: t("Added successfully") });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["groupMembers", variables.groupId],
|
||||
});
|
||||
@@ -139,6 +143,7 @@ export function useAddGroupMemberMutation() {
|
||||
|
||||
export function useRemoveGroupMemberMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<
|
||||
void,
|
||||
@@ -150,7 +155,7 @@ export function useRemoveGroupMemberMutation() {
|
||||
>({
|
||||
mutationFn: (data) => removeGroupMember(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: "Removed successfully" });
|
||||
notifications.show({ message: t("Removed successfully") });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["groupMembers", variables.groupId],
|
||||
});
|
||||
|
||||
@@ -15,22 +15,29 @@ export const buildPageUrl = (
|
||||
spaceName: string,
|
||||
pageSlugId: string,
|
||||
pageTitle?: string,
|
||||
anchorId?: string,
|
||||
): string => {
|
||||
let url: string;
|
||||
if (spaceName === undefined) {
|
||||
return `/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
url = `/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
} else {
|
||||
url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
}
|
||||
return `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
return anchorId ? `${url}#${anchorId}` : url;
|
||||
};
|
||||
|
||||
export const buildSharedPageUrl = (opts: {
|
||||
shareId: string;
|
||||
pageSlugId: string;
|
||||
pageTitle?: string;
|
||||
anchorId?: string;
|
||||
}): string => {
|
||||
const { shareId, pageSlugId, pageTitle } = opts;
|
||||
const { shareId, pageSlugId, pageTitle, anchorId } = opts;
|
||||
let url: string;
|
||||
if (!shareId) {
|
||||
return `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
} else {
|
||||
url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
}
|
||||
|
||||
return `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
return anchorId ? `${url}#${anchorId}` : url;
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ScrollArea,
|
||||
Avatar,
|
||||
Group,
|
||||
Switch,
|
||||
getDefaultZIndex,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
IconFileDescription,
|
||||
IconSearch,
|
||||
IconCheck,
|
||||
IconSparkles,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
@@ -24,15 +26,21 @@ import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
import { useLicense } from "@/ee/hooks/use-license";
|
||||
import classes from "./search-spotlight-filters.module.css";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import { useAtom } from "jotai/index";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
|
||||
interface SearchSpotlightFiltersProps {
|
||||
onFiltersChange?: (filters: any) => void;
|
||||
onAskClick?: () => void;
|
||||
spaceId?: string;
|
||||
isAiMode?: boolean;
|
||||
}
|
||||
|
||||
export function SearchSpotlightFilters({
|
||||
onFiltersChange,
|
||||
onAskClick,
|
||||
spaceId,
|
||||
isAiMode = false,
|
||||
}: SearchSpotlightFiltersProps) {
|
||||
const { t } = useTranslation();
|
||||
const { hasLicenseKey } = useLicense();
|
||||
@@ -42,6 +50,7 @@ export function SearchSpotlightFilters({
|
||||
const [spaceSearchQuery, setSpaceSearchQuery] = useState("");
|
||||
const [debouncedSpaceQuery] = useDebouncedValue(spaceSearchQuery, 300);
|
||||
const [contentType, setContentType] = useState<string | null>("page");
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
|
||||
const { data: spacesData } = useGetSpacesQuery({
|
||||
page: 1,
|
||||
@@ -120,6 +129,31 @@ export function SearchSpotlightFilters({
|
||||
|
||||
return (
|
||||
<div className={classes.filtersContainer}>
|
||||
{workspace?.settings?.ai?.search === true && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
height: "32px",
|
||||
paddingLeft: "8px",
|
||||
paddingRight: "8px",
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
checked={isAiMode}
|
||||
onChange={(event) => onAskClick()}
|
||||
label={t("Ask AI")}
|
||||
size="sm"
|
||||
color="blue"
|
||||
labelPosition="left"
|
||||
styles={{
|
||||
root: { display: "flex", alignItems: "center" },
|
||||
label: { paddingRight: "8px", fontSize: "13px", fontWeight: 500 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Menu
|
||||
shadow="md"
|
||||
width={250}
|
||||
@@ -231,7 +265,7 @@ export function SearchSpotlightFilters({
|
||||
contentType !== option.value &&
|
||||
handleFilterChange("contentType", option.value)
|
||||
}
|
||||
disabled={option.disabled}
|
||||
disabled={option.disabled || (isAiMode && option.value === "attachment")}
|
||||
>
|
||||
<Group flex="1" gap="xs">
|
||||
<div>
|
||||
@@ -241,6 +275,11 @@ export function SearchSpotlightFilters({
|
||||
{t("Enterprise")}
|
||||
</Badge>
|
||||
)}
|
||||
{!option.disabled && isAiMode && option.value === "attachment" && (
|
||||
<Text size="xs" mt={4}>
|
||||
{t("Ask AI not available for attachments")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{contentType === option.value && <IconCheck size={20} />}
|
||||
</Group>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Spotlight } from "@mantine/spotlight";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { IconSearch, IconSparkles } from "@tabler/icons-react";
|
||||
import { Group, Button } from "@mantine/core";
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { searchSpotlightStore } from "../constants.ts";
|
||||
import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx";
|
||||
import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
|
||||
import { useAiSearch } from "../../../ee/ai/hooks/use-ai-search.ts";
|
||||
import { SearchResultItem } from "./search-result-item.tsx";
|
||||
import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx";
|
||||
import { useLicense } from "@/ee/hooks/use-license.tsx";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
|
||||
@@ -24,6 +28,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
}>({
|
||||
contentType: "page",
|
||||
});
|
||||
const [isAiMode, setIsAiMode] = useState(false);
|
||||
|
||||
// Build unified search params
|
||||
const searchParams = useMemo(() => {
|
||||
@@ -40,7 +45,42 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
return params;
|
||||
}, [debouncedSearchQuery, filters]);
|
||||
|
||||
const { data: searchResults, isLoading } = useUnifiedSearch(searchParams);
|
||||
const { data: searchResults, isLoading } = useUnifiedSearch(
|
||||
searchParams,
|
||||
!isAiMode // Disable regular search when in AI mode
|
||||
);
|
||||
const {
|
||||
//@ts-ignore
|
||||
data: aiSearchResult,
|
||||
//@ts-ignore
|
||||
isPending: isAiLoading,
|
||||
//@ts-ignore
|
||||
mutate: triggerAiSearchMutation,
|
||||
//@ts-ignore
|
||||
reset: resetAiMutation,
|
||||
//@ts-ignore
|
||||
error: aiSearchError,
|
||||
streamingAnswer,
|
||||
streamingSources,
|
||||
clearStreaming,
|
||||
} = useAiSearch();
|
||||
|
||||
// Clear streaming state and mutation data when query changes (user is typing a new query)
|
||||
useEffect(() => {
|
||||
clearStreaming();
|
||||
resetAiMutation();
|
||||
}, [query, clearStreaming, resetAiMutation]);
|
||||
|
||||
// Show error notification when AI search fails
|
||||
useEffect(() => {
|
||||
if (aiSearchError) {
|
||||
notifications.show({
|
||||
message: aiSearchError.message || t("AI search failed. Please try again."),
|
||||
color: "red",
|
||||
position: "top-center"
|
||||
});
|
||||
}
|
||||
}, [aiSearchError, t]);
|
||||
|
||||
// Determine result type for rendering
|
||||
const isAttachmentSearch =
|
||||
@@ -59,6 +99,16 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
setFilters(newFilters);
|
||||
};
|
||||
|
||||
const handleAskClick = () => {
|
||||
setIsAiMode(!isAiMode);
|
||||
};
|
||||
|
||||
const handleAiSearchTrigger = () => {
|
||||
if (query.trim() && isAiMode) {
|
||||
triggerAiSearchMutation(searchParams);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spotlight.Root
|
||||
@@ -72,10 +122,30 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
backgroundOpacity: 0.55,
|
||||
}}
|
||||
>
|
||||
<Spotlight.Search
|
||||
placeholder={t("Search...")}
|
||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||
/>
|
||||
<Group gap="xs" px="sm" pt="sm" pb="xs">
|
||||
<Spotlight.Search
|
||||
placeholder={isAiMode ? t("Ask a question...") : t("Search...")}
|
||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||
style={{ flex: 1 }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && isAiMode && query.trim() && !isAiLoading) {
|
||||
e.preventDefault();
|
||||
handleAiSearchTrigger();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isAiMode && hasLicenseKey && (
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={handleAiSearchTrigger}
|
||||
disabled={!query.trim()}
|
||||
loading={isAiLoading}
|
||||
>
|
||||
Ask
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<div
|
||||
style={{
|
||||
@@ -84,20 +154,43 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
>
|
||||
<SearchSpotlightFilters
|
||||
onFiltersChange={handleFiltersChange}
|
||||
onAskClick={handleAskClick}
|
||||
spaceId={spaceId}
|
||||
isAiMode={isAiMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Spotlight.ActionsList>
|
||||
{query.length === 0 && resultItems.length === 0 && (
|
||||
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
|
||||
)}
|
||||
{isAiMode ? (
|
||||
<>
|
||||
{query.length === 0 && (
|
||||
<Spotlight.Empty>{t("Ask a question...")}</Spotlight.Empty>
|
||||
)}
|
||||
{query.length > 0 && (isAiLoading || aiSearchResult || streamingAnswer) && (
|
||||
<AiSearchResult
|
||||
result={aiSearchResult}
|
||||
isLoading={isAiLoading}
|
||||
streamingAnswer={streamingAnswer}
|
||||
streamingSources={streamingSources}
|
||||
/>
|
||||
)}
|
||||
{query.length > 0 && !isAiLoading && !aiSearchResult && (
|
||||
<Spotlight.Empty>{t("No answer available")}</Spotlight.Empty>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{query.length === 0 && resultItems.length === 0 && (
|
||||
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
|
||||
)}
|
||||
|
||||
{query.length > 0 && !isLoading && resultItems.length === 0 && (
|
||||
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
|
||||
)}
|
||||
{query.length > 0 && !isLoading && resultItems.length === 0 && (
|
||||
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
|
||||
)}
|
||||
|
||||
{resultItems.length > 0 && <>{resultItems}</>}
|
||||
{resultItems.length > 0 && <>{resultItems}</>}
|
||||
</>
|
||||
)}
|
||||
</Spotlight.ActionsList>
|
||||
</Spotlight.Root>
|
||||
</>
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface UseUnifiedSearchParams extends IPageSearchParams {
|
||||
|
||||
export function useUnifiedSearch(
|
||||
params: UseUnifiedSearchParams,
|
||||
enabled: boolean = true,
|
||||
): UseQueryResult<UnifiedSearchResult[], Error> {
|
||||
const { hasLicenseKey } = useLicense();
|
||||
|
||||
@@ -38,6 +39,6 @@ export function useUnifiedSearch(
|
||||
return await searchPage(backendParams);
|
||||
}
|
||||
},
|
||||
enabled: !!params.query,
|
||||
enabled: !!params.query && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,14 +62,17 @@ export default function SpaceSettingsModal({
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="general">
|
||||
<ScrollArea h={550} scrollbarSize={4} pr={8}>
|
||||
<SpaceDetails
|
||||
spaceId={space?.id}
|
||||
readOnly={spaceAbility.cannot(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Settings,
|
||||
)}
|
||||
/>
|
||||
<ScrollArea h={580} scrollbarSize={5} pr={8}>
|
||||
<div style={{ paddingBottom: "100px"}}>
|
||||
<SpaceDetails
|
||||
spaceId={space?.id}
|
||||
readOnly={spaceAbility.cannot(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Settings,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</ScrollArea>
|
||||
</Tabs.Panel>
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function SpaceMembersList({
|
||||
return (
|
||||
<>
|
||||
<SearchInput onSearch={handleSearch} />
|
||||
<ScrollArea h={400}>
|
||||
<ScrollArea h={450}>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing={8}>
|
||||
<Table.Thead>
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function deleteWorkspaceMember(data: {
|
||||
await api.post("/workspace/members/delete", data);
|
||||
}
|
||||
|
||||
export async function updateWorkspace(data: Partial<IWorkspace>) {
|
||||
export async function updateWorkspace(data: Partial<IWorkspace> & { aiSearch?: boolean }) {
|
||||
const req = await api.post<IWorkspace>("/workspace/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface IWorkspace {
|
||||
defaultSpaceId: string;
|
||||
customDomain: string;
|
||||
enableInvite: boolean;
|
||||
settings: any;
|
||||
settings: IWorkspaceSettings;
|
||||
status: string;
|
||||
enforceSso: boolean;
|
||||
stripeCustomerId: string;
|
||||
@@ -24,6 +24,14 @@ export interface IWorkspace {
|
||||
enforceMfa?: boolean;
|
||||
}
|
||||
|
||||
export interface IWorkspaceSettings {
|
||||
ai?: IWorkspaceAiSettings;
|
||||
}
|
||||
|
||||
export interface IWorkspaceAiSettings {
|
||||
search?: boolean;
|
||||
}
|
||||
|
||||
export interface ICreateInvite {
|
||||
role: string;
|
||||
emails: string[];
|
||||
|
||||
Reference in New Issue
Block a user