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>
<Route index element={<Navigate to="/home" />} />
<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={"/p/:pageSlug"} element={<PageRedirect />} />

View File

@ -8,21 +8,4 @@
.active {
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,
useEditor,
} from "@tiptap/react";
import { FC, useEffect, useRef, useState } from "react";
import { FC, memo, useEffect, useRef, useState } from "react";
import {
IconBold,
IconCode,
@ -25,6 +25,7 @@ import {
import { useAtom } from "jotai";
import { v4 as uuidv4 } from "uuid";
import { isCellSelection } from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
export interface BubbleMenuItem {
name: string;
@ -113,7 +114,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
},
tippyOptions: {
moveTransition: "transform 0.15s ease-out",
onHidden: () => {
onHide: () => {
setIsNodeSelectorOpen(false);
setIsColorSelectorOpen(false);
setIsLinkSelectorOpen(false);
@ -155,6 +156,14 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
))}
</ActionIcon.Group>
<LinkSelector
editor={props.editor}
isOpen={isLinkSelectorOpen}
setIsOpen={() => {
setIsLinkSelectorOpen(!isLinkSelectorOpen);
}}
/>
<ColorSelector
editor={props.editor}
isOpen={isColorSelectorOpen}
@ -171,7 +180,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
style={{ border: "none" }}
onClick={commentItem.command}
>
<IconMessage style={{ width: rem(16) }} stroke={2} />
<IconMessage size={16} stroke={2} />
</ActionIcon>
</div>
</BubbleMenu>

View File

@ -1,7 +1,14 @@
import { Dispatch, FC, SetStateAction } from "react";
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
import { Button, Popover, rem, ScrollArea, Text, Tooltip } from "@mantine/core";
import classes from "./bubble-menu.module.css";
import { IconCheck, IconPalette } from "@tabler/icons-react";
import {
ActionIcon,
Button,
Popover,
rem,
ScrollArea,
Text,
Tooltip,
} from "@mantine/core";
import { useEditor } from "@tiptap/react";
export interface BubbleColorMenuItem {
@ -110,21 +117,19 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
return (
<Popover width={200} opened={isOpen} withArrow>
<Popover.Target>
<Tooltip label="text color" withArrow>
<Button
<Tooltip label="Text color" withArrow>
<ActionIcon
variant="default"
size="lg"
radius="0"
rightSection={<IconChevronDown size={16} />}
className={classes.colorButton}
style={{
border: "none",
color: activeColorItem?.color,
paddingLeft: "8px",
paddingRight: "8px",
}}
onClick={() => setIsOpen(!isOpen)}
>
A
</Button>
<IconPalette size={16} stroke={2} />
</ActionIcon>
</Tooltip>
</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,
IconTypography,
} from "@tabler/icons-react";
import { Popover, Button, rem, ScrollArea } from "@mantine/core";
import classes from "@/features/editor/components/bubble-menu/bubble-menu.module.css";
import { Popover, Button, ScrollArea } from "@mantine/core";
import { useEditor } from "@tiptap/react";
interface NodeSelectorProps {
@ -110,9 +109,9 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
<Popover.Target>
<Button
variant="default"
style={{ border: "none", height: "34px" }}
radius="0"
rightSection={<IconChevronDown size={16} />}
className={classes.colorButton}
onClick={() => setIsOpen(!isOpen)}
>
{activeItem?.name}

View File

@ -26,7 +26,7 @@ export default function ImageView(props: NodeViewProps) {
fit="contain"
w={width}
src={getFileUrl(src)}
className={selected && "ProseMirror-selectednode"}
className={selected ? "ProseMirror-selectednode" : ""}
/>
</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);
} catch (e) {
console.error(e.message);
//console.error(e.message);
setError(e.message);
}
};

View File

@ -24,7 +24,7 @@ export default function MathInlineView(props: NodeViewProps) {
katex.render(katexString, container);
setError(null);
} catch (e) {
console.error(e);
//console.error(e);
setError(e.message);
}
};

View File

@ -25,7 +25,7 @@ export default function VideoView(props: NodeViewProps) {
width={width}
controls
src={getFileUrl(src)}
className={selected && "ProseMirror-selectednode"}
className={selected ? "ProseMirror-selectednode" : ""}
/>
</NodeViewWrapper>
);

View File

@ -31,6 +31,8 @@ import {
TiptapImage,
Callout,
TiptapVideo,
LinkExtension,
Selection,
} from "@docmost/editor-ext";
import {
randomElement,
@ -57,7 +59,16 @@ export const mainExtensions = [
codeBlock: false,
}),
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"] }),
TaskList,
@ -65,7 +76,9 @@ export const mainExtensions = [
nested: true,
}),
Underline,
Link,
LinkExtension.configure({
openOnClick: false,
}),
Superscript,
SubScript,
Highlight.configure({
@ -112,6 +125,7 @@ export const mainExtensions = [
CodeBlockLowlight.configure({
lowlight,
}),
Selection,
] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];

View File

@ -38,6 +38,7 @@ import {
handleFileDrop,
handleFilePaste,
} from "@/features/editor/components/common/file-upload-handler.tsx";
import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
interface PageEditorProps {
pageId: string;
@ -172,6 +173,7 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
<ImageMenu editor={editor} />
<VideoMenu editor={editor} />
<CalloutMenu editor={editor} />
<LinkMenu editor={editor} appendTo={menuContainerRef} />
</div>
)}

View File

@ -9,10 +9,10 @@
);
font-size: var(--mantine-font-size-md);
line-height: var(--mantine-line-height-xl);
font-weight: 400;
font-weight: 415;
width: 100%;
{
> * + * {
margin-top: 0.75em;
}
@ -46,7 +46,7 @@
a {
color: light-dark(#207af1, #587da9);
font-weight: bold;
/*font-weight: bold;*/
text-decoration: none;
cursor: pointer;
}
@ -86,11 +86,19 @@
}
}
.react-renderer {
&.node-callout {
padding-top: var(--mantine-spacing-xs);
padding-bottom: var(--mantine-spacing-xs);
& > .react-renderer {
margin-top: var(--mantine-spacing-xl);
margin-bottom: var(--mantine-spacing-xl);
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
&.node-callout {
div[style*="white-space: inherit;"] {
> :first-child {
margin: 0;
@ -98,16 +106,25 @@
}
}
}
}
.resize-cursor {
cursor: ew-resize;
cursor: col-resize;
}
.selection {
display: inline;
}
.comment-mark {
background: rgba(255, 215, 0, 0.14);
border-bottom: 2px solid rgb(166, 158, 12);
.selection,
*::selection {
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 {

View File

@ -5,9 +5,6 @@
}
.node-image, .node-video {
margin-top: 8px;
margin-bottom: 8px;
&.ProseMirror-selectednode {
outline: none;
}

View File

@ -1,24 +1,20 @@
.ProseMirror p.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;
.ProseMirror .is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
height: 0;
color: #adb5bd;
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> {
return useQuery({
queryKey: ["group", groupId],
queryKey: ["groups", groupId],
queryFn: () => getGroupById(groupId),
enabled: !!groupId,
});

View File

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

View File

@ -8,3 +8,5 @@ export * from "./lib/image";
export * from "./lib/video";
export * from "./lib/callout";
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;