Editor link components

* other minor fixes
This commit is contained in:
Philipinho
2024-06-24 20:39:12 +01:00
parent f2a193ac8d
commit fde6c9a2e3
25 changed files with 468 additions and 82 deletions

View File

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

View File

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

View File

@ -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>

View File

@ -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>

View File

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

View File

@ -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}

View File

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

View File

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

View File

@ -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;

View File

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

View File

@ -0,0 +1,4 @@
export type LinkEditorPanelProps = {
initialUrl?: string;
onSetLink: (url: string, openInNewTab?: boolean) => void;
};

View File

@ -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,
};
};

View File

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

View File

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

View File

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

View File

@ -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[];

View File

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

View File

@ -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 {

View File

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

View File

@ -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: '';
}

View File

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

View File

@ -4,7 +4,7 @@
.treeContainer { .treeContainer {
display: flex; display: flex;
height: 60vh; height: 68vh;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }

View File

@ -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";

View 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;
},
},
}),
];
},
});

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