mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 09:12:36 +10:00
Editor link components
* other minor fixes
This commit is contained in:
@ -61,7 +61,7 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Navigate to="/home" />} />
|
<Route index element={<Navigate to="/home" />} />
|
||||||
<Route path={"/login"} element={<LoginPage />} />
|
<Route path={"/login"} element={<LoginPage />} />
|
||||||
<Route path={"/installation/setup"} element={<SetupWorkspace />} />
|
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
||||||
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
||||||
|
|
||||||
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
||||||
|
|||||||
@ -8,21 +8,4 @@
|
|||||||
.active {
|
.active {
|
||||||
color: light-dark(var(--mantine-color-blue-8), var(--mantine-color-gray-5));
|
color: light-dark(var(--mantine-color-blue-8), var(--mantine-color-gray-5));
|
||||||
}
|
}
|
||||||
|
|
||||||
.colorButton {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.colorButton::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 1px;
|
|
||||||
background-color: light-dark(
|
|
||||||
var(--mantine-color-gray-3),
|
|
||||||
var(--mantine-color-gray-8)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import {
|
|||||||
isNodeSelection,
|
isNodeSelection,
|
||||||
useEditor,
|
useEditor,
|
||||||
} from "@tiptap/react";
|
} from "@tiptap/react";
|
||||||
import { FC, useEffect, useRef, useState } from "react";
|
import { FC, memo, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
IconBold,
|
IconBold,
|
||||||
IconCode,
|
IconCode,
|
||||||
@ -25,6 +25,7 @@ import {
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { isCellSelection } from "@docmost/editor-ext";
|
import { isCellSelection } from "@docmost/editor-ext";
|
||||||
|
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
||||||
|
|
||||||
export interface BubbleMenuItem {
|
export interface BubbleMenuItem {
|
||||||
name: string;
|
name: string;
|
||||||
@ -113,7 +114,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
},
|
},
|
||||||
tippyOptions: {
|
tippyOptions: {
|
||||||
moveTransition: "transform 0.15s ease-out",
|
moveTransition: "transform 0.15s ease-out",
|
||||||
onHidden: () => {
|
onHide: () => {
|
||||||
setIsNodeSelectorOpen(false);
|
setIsNodeSelectorOpen(false);
|
||||||
setIsColorSelectorOpen(false);
|
setIsColorSelectorOpen(false);
|
||||||
setIsLinkSelectorOpen(false);
|
setIsLinkSelectorOpen(false);
|
||||||
@ -155,6 +156,14 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
))}
|
))}
|
||||||
</ActionIcon.Group>
|
</ActionIcon.Group>
|
||||||
|
|
||||||
|
<LinkSelector
|
||||||
|
editor={props.editor}
|
||||||
|
isOpen={isLinkSelectorOpen}
|
||||||
|
setIsOpen={() => {
|
||||||
|
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<ColorSelector
|
<ColorSelector
|
||||||
editor={props.editor}
|
editor={props.editor}
|
||||||
isOpen={isColorSelectorOpen}
|
isOpen={isColorSelectorOpen}
|
||||||
@ -171,7 +180,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
style={{ border: "none" }}
|
style={{ border: "none" }}
|
||||||
onClick={commentItem.command}
|
onClick={commentItem.command}
|
||||||
>
|
>
|
||||||
<IconMessage style={{ width: rem(16) }} stroke={2} />
|
<IconMessage size={16} stroke={2} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</div>
|
</div>
|
||||||
</BubbleMenu>
|
</BubbleMenu>
|
||||||
|
|||||||
@ -1,7 +1,14 @@
|
|||||||
import { Dispatch, FC, SetStateAction } from "react";
|
import { Dispatch, FC, SetStateAction } from "react";
|
||||||
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
|
import { IconCheck, IconPalette } from "@tabler/icons-react";
|
||||||
import { Button, Popover, rem, ScrollArea, Text, Tooltip } from "@mantine/core";
|
import {
|
||||||
import classes from "./bubble-menu.module.css";
|
ActionIcon,
|
||||||
|
Button,
|
||||||
|
Popover,
|
||||||
|
rem,
|
||||||
|
ScrollArea,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useEditor } from "@tiptap/react";
|
import { useEditor } from "@tiptap/react";
|
||||||
|
|
||||||
export interface BubbleColorMenuItem {
|
export interface BubbleColorMenuItem {
|
||||||
@ -110,21 +117,19 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Popover width={200} opened={isOpen} withArrow>
|
<Popover width={200} opened={isOpen} withArrow>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Tooltip label="text color" withArrow>
|
<Tooltip label="Text color" withArrow>
|
||||||
<Button
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
|
size="lg"
|
||||||
radius="0"
|
radius="0"
|
||||||
rightSection={<IconChevronDown size={16} />}
|
|
||||||
className={classes.colorButton}
|
|
||||||
style={{
|
style={{
|
||||||
|
border: "none",
|
||||||
color: activeColorItem?.color,
|
color: activeColorItem?.color,
|
||||||
paddingLeft: "8px",
|
|
||||||
paddingRight: "8px",
|
|
||||||
}}
|
}}
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
>
|
>
|
||||||
A
|
<IconPalette size={16} stroke={2} />
|
||||||
</Button>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,61 @@
|
|||||||
|
import { Dispatch, FC, SetStateAction, useCallback } from "react";
|
||||||
|
import { IconLink } from "@tabler/icons-react";
|
||||||
|
import { ActionIcon, Popover, Tooltip } from "@mantine/core";
|
||||||
|
import { useEditor } from "@tiptap/react";
|
||||||
|
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
||||||
|
|
||||||
|
export interface BubbleColorMenuItem {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkSelectorProps {
|
||||||
|
editor: ReturnType<typeof useEditor>;
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LinkSelector: FC<LinkSelectorProps> = ({
|
||||||
|
editor,
|
||||||
|
isOpen,
|
||||||
|
setIsOpen,
|
||||||
|
}) => {
|
||||||
|
const onLink = useCallback(
|
||||||
|
(url: string) => {
|
||||||
|
editor.chain().focus().setLink({ href: url }).run();
|
||||||
|
setIsOpen(false);
|
||||||
|
console.log("is p[e ");
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
width={300}
|
||||||
|
opened={isOpen}
|
||||||
|
trapFocus
|
||||||
|
offset={{ mainAxis: 35, crossAxis: 0 }}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<Popover.Target>
|
||||||
|
<Tooltip label="Add link" withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
radius="0"
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
}}
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
<IconLink size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Popover.Target>
|
||||||
|
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<LinkEditorPanel onSetLink={onLink} />
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -12,8 +12,7 @@ import {
|
|||||||
IconListNumbers,
|
IconListNumbers,
|
||||||
IconTypography,
|
IconTypography,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Popover, Button, rem, ScrollArea } from "@mantine/core";
|
import { Popover, Button, ScrollArea } from "@mantine/core";
|
||||||
import classes from "@/features/editor/components/bubble-menu/bubble-menu.module.css";
|
|
||||||
import { useEditor } from "@tiptap/react";
|
import { useEditor } from "@tiptap/react";
|
||||||
|
|
||||||
interface NodeSelectorProps {
|
interface NodeSelectorProps {
|
||||||
@ -110,9 +109,9 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
|||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
|
style={{ border: "none", height: "34px" }}
|
||||||
radius="0"
|
radius="0"
|
||||||
rightSection={<IconChevronDown size={16} />}
|
rightSection={<IconChevronDown size={16} />}
|
||||||
className={classes.colorButton}
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
>
|
>
|
||||||
{activeItem?.name}
|
{activeItem?.name}
|
||||||
|
|||||||
@ -26,7 +26,7 @@ export default function ImageView(props: NodeViewProps) {
|
|||||||
fit="contain"
|
fit="contain"
|
||||||
w={width}
|
w={width}
|
||||||
src={getFileUrl(src)}
|
src={getFileUrl(src)}
|
||||||
className={selected && "ProseMirror-selectednode"}
|
className={selected ? "ProseMirror-selectednode" : ""}
|
||||||
/>
|
/>
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,34 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button, Group, TextInput } from "@mantine/core";
|
||||||
|
import { IconLink } from "@tabler/icons-react";
|
||||||
|
import { useLinkEditorState } from "@/features/editor/components/link/use-link-editor-state.tsx";
|
||||||
|
import { LinkEditorPanelProps } from "@/features/editor/components/link/types.ts";
|
||||||
|
|
||||||
|
export const LinkEditorPanel = ({
|
||||||
|
onSetLink,
|
||||||
|
initialUrl,
|
||||||
|
}: LinkEditorPanelProps) => {
|
||||||
|
const state = useLinkEditorState({
|
||||||
|
onSetLink,
|
||||||
|
initialUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<form onSubmit={state.handleSubmit}>
|
||||||
|
<Group gap="xs" style={{ flex: 1 }} wrap="nowrap">
|
||||||
|
<TextInput
|
||||||
|
leftSection={<IconLink size={16} />}
|
||||||
|
variant="filled"
|
||||||
|
placeholder="Paste link"
|
||||||
|
value={state.url}
|
||||||
|
onChange={state.onChange}
|
||||||
|
/>
|
||||||
|
<Button p={"xs"} type="submit" disabled={!state.isValidUrl}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react";
|
||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
||||||
|
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
||||||
|
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
|
||||||
|
import { Card } from "@mantine/core";
|
||||||
|
|
||||||
|
export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
|
||||||
|
const [showEdit, setShowEdit] = useState(false);
|
||||||
|
|
||||||
|
const shouldShow = useCallback(() => {
|
||||||
|
return editor.isActive("link");
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const { href: link } = editor.getAttributes("link");
|
||||||
|
|
||||||
|
const handleEdit = useCallback(() => {
|
||||||
|
setShowEdit(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSetLink = useCallback(
|
||||||
|
(url: string) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.extendMarkRange("link")
|
||||||
|
.setLink({ href: url })
|
||||||
|
.run();
|
||||||
|
setShowEdit(false);
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onUnsetLink = useCallback(() => {
|
||||||
|
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||||
|
setShowEdit(false);
|
||||||
|
return null;
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const onShowEdit = useCallback(() => {
|
||||||
|
setShowEdit(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onHideEdit = useCallback(() => {
|
||||||
|
setShowEdit(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseBubbleMenu
|
||||||
|
editor={editor}
|
||||||
|
pluginKey={`link-menu}`}
|
||||||
|
updateDelay={0}
|
||||||
|
tippyOptions={{
|
||||||
|
appendTo: () => {
|
||||||
|
return appendTo?.current;
|
||||||
|
},
|
||||||
|
onHidden: () => {
|
||||||
|
setShowEdit(false);
|
||||||
|
},
|
||||||
|
placement: "bottom",
|
||||||
|
offset: [0, 5],
|
||||||
|
zIndex: 101,
|
||||||
|
}}
|
||||||
|
shouldShow={shouldShow}
|
||||||
|
>
|
||||||
|
{showEdit ? (
|
||||||
|
<Card
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
padding="xs"
|
||||||
|
bg="var(--mantine-color-body)"
|
||||||
|
>
|
||||||
|
<LinkEditorPanel initialUrl={link} onSetLink={onSetLink} />
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<LinkPreviewPanel
|
||||||
|
url={link}
|
||||||
|
onClear={onUnsetLink}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</BaseBubbleMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LinkMenu;
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
ActionIcon,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
Anchor,
|
||||||
|
Flex,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconLinkOff, IconPencil } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
export type LinkPreviewPanelProps = {
|
||||||
|
url: string;
|
||||||
|
onEdit: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LinkPreviewPanel = ({
|
||||||
|
onClear,
|
||||||
|
onEdit,
|
||||||
|
url,
|
||||||
|
}: LinkPreviewPanelProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card withBorder radius="md" padding="xs" bg="var(--mantine-color-body)">
|
||||||
|
<Flex align="center">
|
||||||
|
<Tooltip label={url}>
|
||||||
|
<Anchor
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
inherit
|
||||||
|
style={{
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</Anchor>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Flex align="center">
|
||||||
|
<Divider mx={4} orientation="vertical" />
|
||||||
|
|
||||||
|
<Tooltip label="Edit link">
|
||||||
|
<ActionIcon onClick={onEdit} variant="subtle" color="gray">
|
||||||
|
<IconPencil size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip label="Remove link">
|
||||||
|
<ActionIcon onClick={onClear} variant="subtle" color="red">
|
||||||
|
<IconLinkOff size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
4
apps/client/src/features/editor/components/link/types.ts
Normal file
4
apps/client/src/features/editor/components/link/types.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export type LinkEditorPanelProps = {
|
||||||
|
initialUrl?: string;
|
||||||
|
onSetLink: (url: string, openInNewTab?: boolean) => void;
|
||||||
|
};
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
|
import { LinkEditorPanelProps } from "@/features/editor/components/link/types.ts";
|
||||||
|
|
||||||
|
export const useLinkEditorState = ({
|
||||||
|
initialUrl,
|
||||||
|
onSetLink,
|
||||||
|
}: LinkEditorPanelProps) => {
|
||||||
|
const [url, setUrl] = useState(initialUrl || "");
|
||||||
|
|
||||||
|
const onChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setUrl(event.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isValidUrl = useMemo(() => /^(\S+):(\/\/)?\S+$/.test(url), [url]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isValidUrl) {
|
||||||
|
onSetLink(url);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[url, isValidUrl, onSetLink],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
setUrl,
|
||||||
|
onChange,
|
||||||
|
handleSubmit,
|
||||||
|
isValidUrl,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -30,7 +30,7 @@ export default function MathBlockView(props: NodeViewProps) {
|
|||||||
});
|
});
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e.message);
|
//console.error(e.message);
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export default function MathInlineView(props: NodeViewProps) {
|
|||||||
katex.render(katexString, container);
|
katex.render(katexString, container);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
//console.error(e);
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export default function VideoView(props: NodeViewProps) {
|
|||||||
width={width}
|
width={width}
|
||||||
controls
|
controls
|
||||||
src={getFileUrl(src)}
|
src={getFileUrl(src)}
|
||||||
className={selected && "ProseMirror-selectednode"}
|
className={selected ? "ProseMirror-selectednode" : ""}
|
||||||
/>
|
/>
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -31,6 +31,8 @@ import {
|
|||||||
TiptapImage,
|
TiptapImage,
|
||||||
Callout,
|
Callout,
|
||||||
TiptapVideo,
|
TiptapVideo,
|
||||||
|
LinkExtension,
|
||||||
|
Selection,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@ -57,7 +59,16 @@ export const mainExtensions = [
|
|||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
}),
|
}),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: 'Enter "/" for commands',
|
placeholder: ({ node }) => {
|
||||||
|
if (node.type.name === "heading") {
|
||||||
|
return `Heading ${node.attrs.level}`;
|
||||||
|
}
|
||||||
|
if (node.type.name === "detailsSummary") {
|
||||||
|
return "Toggle title";
|
||||||
|
}
|
||||||
|
return 'Write anything. Enter "/" for commands';
|
||||||
|
},
|
||||||
|
includeChildren: true,
|
||||||
}),
|
}),
|
||||||
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
||||||
TaskList,
|
TaskList,
|
||||||
@ -65,7 +76,9 @@ export const mainExtensions = [
|
|||||||
nested: true,
|
nested: true,
|
||||||
}),
|
}),
|
||||||
Underline,
|
Underline,
|
||||||
Link,
|
LinkExtension.configure({
|
||||||
|
openOnClick: false,
|
||||||
|
}),
|
||||||
Superscript,
|
Superscript,
|
||||||
SubScript,
|
SubScript,
|
||||||
Highlight.configure({
|
Highlight.configure({
|
||||||
@ -112,6 +125,7 @@ export const mainExtensions = [
|
|||||||
CodeBlockLowlight.configure({
|
CodeBlockLowlight.configure({
|
||||||
lowlight,
|
lowlight,
|
||||||
}),
|
}),
|
||||||
|
Selection,
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import {
|
|||||||
handleFileDrop,
|
handleFileDrop,
|
||||||
handleFilePaste,
|
handleFilePaste,
|
||||||
} from "@/features/editor/components/common/file-upload-handler.tsx";
|
} from "@/features/editor/components/common/file-upload-handler.tsx";
|
||||||
|
import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@ -172,6 +173,7 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
|
|||||||
<ImageMenu editor={editor} />
|
<ImageMenu editor={editor} />
|
||||||
<VideoMenu editor={editor} />
|
<VideoMenu editor={editor} />
|
||||||
<CalloutMenu editor={editor} />
|
<CalloutMenu editor={editor} />
|
||||||
|
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -9,10 +9,10 @@
|
|||||||
);
|
);
|
||||||
font-size: var(--mantine-font-size-md);
|
font-size: var(--mantine-font-size-md);
|
||||||
line-height: var(--mantine-line-height-xl);
|
line-height: var(--mantine-line-height-xl);
|
||||||
font-weight: 400;
|
font-weight: 415;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
{
|
> * + * {
|
||||||
margin-top: 0.75em;
|
margin-top: 0.75em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
a {
|
a {
|
||||||
color: light-dark(#207af1, #587da9);
|
color: light-dark(#207af1, #587da9);
|
||||||
font-weight: bold;
|
/*font-weight: bold;*/
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@ -86,11 +86,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-renderer {
|
& > .react-renderer {
|
||||||
&.node-callout {
|
margin-top: var(--mantine-spacing-xl);
|
||||||
padding-top: var(--mantine-spacing-xs);
|
margin-bottom: var(--mantine-spacing-xl);
|
||||||
padding-bottom: var(--mantine-spacing-xs);
|
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.node-callout {
|
||||||
div[style*="white-space: inherit;"] {
|
div[style*="white-space: inherit;"] {
|
||||||
> :first-child {
|
> :first-child {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -98,16 +106,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.resize-cursor {
|
.selection {
|
||||||
cursor: ew-resize;
|
display: inline;
|
||||||
cursor: col-resize;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.comment-mark {
|
.selection,
|
||||||
background: rgba(255, 215, 0, 0.14);
|
*::selection {
|
||||||
border-bottom: 2px solid rgb(166, 158, 12);
|
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-gray-7));
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-mark {
|
||||||
|
background: rgba(255, 215, 0, 0.14);
|
||||||
|
border-bottom: 2px solid rgb(166, 158, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-cursor {
|
||||||
|
cursor: ew-resize;
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror-icon {
|
.ProseMirror-icon {
|
||||||
|
|||||||
@ -5,9 +5,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.node-image, .node-video {
|
.node-image, .node-video {
|
||||||
margin-top: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
|
|
||||||
&.ProseMirror-selectednode {
|
&.ProseMirror-selectednode {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,24 +1,20 @@
|
|||||||
.ProseMirror p.is-editor-empty:first-child::before {
|
.ProseMirror .is-editor-empty:first-child::before {
|
||||||
content: attr(data-placeholder);
|
|
||||||
float: left;
|
|
||||||
color: #adb5bd;
|
|
||||||
pointer-events: none;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror h1.is-editor-empty:first-child::before {
|
|
||||||
content: attr(data-placeholder);
|
|
||||||
float: left;
|
|
||||||
color: #adb5bd;
|
|
||||||
pointer-events: none;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Placeholder (on every new line) */
|
|
||||||
/*.ProseMirror p.is-empty::before {
|
|
||||||
color: #adb5bd;
|
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
float: left;
|
float: left;
|
||||||
height: 0;
|
color: #adb5bd;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}*/
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror .is-empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
color: #adb5bd;
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table .is-editor-empty:first-child::before,
|
||||||
|
.ProseMirror table .is-empty::before {
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export function useGetGroupsQuery(
|
|||||||
|
|
||||||
export function useGroupQuery(groupId: string): UseQueryResult<IGroup, Error> {
|
export function useGroupQuery(groupId: string): UseQueryResult<IGroup, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["group", groupId],
|
queryKey: ["groups", groupId],
|
||||||
queryFn: () => getGroupById(groupId),
|
queryFn: () => getGroupById(groupId),
|
||||||
enabled: !!groupId,
|
enabled: !!groupId,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
.treeContainer {
|
.treeContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 60vh;
|
height: 68vh;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,3 +8,5 @@ export * from "./lib/image";
|
|||||||
export * from "./lib/video";
|
export * from "./lib/video";
|
||||||
export * from "./lib/callout";
|
export * from "./lib/callout";
|
||||||
export * from "./lib/media-utils";
|
export * from "./lib/media-utils";
|
||||||
|
export * from "./lib/link";
|
||||||
|
export * from "./lib/selection";
|
||||||
|
|||||||
47
packages/editor-ext/src/lib/link.ts
Normal file
47
packages/editor-ext/src/lib/link.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { mergeAttributes } from "@tiptap/core";
|
||||||
|
import TiptapLink from "@tiptap/extension-link";
|
||||||
|
import { Plugin } from "@tiptap/pm/state";
|
||||||
|
import { EditorView } from "@tiptap/pm/view";
|
||||||
|
|
||||||
|
export const LinkExtension = TiptapLink.extend({
|
||||||
|
inclusive: false,
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'a[href]:not([data-type="button"]):not([href *= "javascript:" i])',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
"a",
|
||||||
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||||
|
class: "link",
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
const { editor } = this;
|
||||||
|
|
||||||
|
return [
|
||||||
|
...(this.parent?.() || []),
|
||||||
|
new Plugin({
|
||||||
|
props: {
|
||||||
|
handleKeyDown: (view: EditorView, event: KeyboardEvent) => {
|
||||||
|
const { selection } = editor.state;
|
||||||
|
|
||||||
|
if (event.key === "Escape" && selection.empty !== true) {
|
||||||
|
editor.commands.focus(selection.to, { scrollIntoView: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
36
packages/editor-ext/src/lib/selection.ts
Normal file
36
packages/editor-ext/src/lib/selection.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { Extension } from "@tiptap/core";
|
||||||
|
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||||
|
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||||
|
|
||||||
|
export const Selection = Extension.create({
|
||||||
|
name: "selection",
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
const { editor } = this;
|
||||||
|
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: new PluginKey("selection"),
|
||||||
|
props: {
|
||||||
|
decorations(state) {
|
||||||
|
if (state.selection.empty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editor.isFocused === true) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DecorationSet.create(state.doc, [
|
||||||
|
Decoration.inline(state.selection.from, state.selection.to, {
|
||||||
|
class: "selection",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Selection;
|
||||||
Reference in New Issue
Block a user