From fde6c9a2e354385ee32fa7705385ce984abd2e79 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Mon, 24 Jun 2024 20:39:12 +0100 Subject: [PATCH] Editor link components * other minor fixes --- apps/client/src/App.tsx | 2 +- .../bubble-menu/bubble-menu.module.css | 17 ---- .../components/bubble-menu/bubble-menu.tsx | 15 +++- .../components/bubble-menu/color-selector.tsx | 27 +++--- .../components/bubble-menu/link-selector.tsx | 61 +++++++++++++ .../components/bubble-menu/node-selector.tsx | 5 +- .../editor/components/image/image-view.tsx | 2 +- .../components/link/link-editor-panel.tsx | 34 ++++++++ .../editor/components/link/link-menu.tsx | 86 +++++++++++++++++++ .../editor/components/link/link-preview.tsx | 61 +++++++++++++ .../features/editor/components/link/types.ts | 4 + .../components/link/use-link-editor-state.tsx | 33 +++++++ .../editor/components/math/math-block.tsx | 2 +- .../editor/components/math/math-inline.tsx | 2 +- .../editor/components/video/video-view.tsx | 2 +- .../features/editor/extensions/extensions.ts | 18 +++- .../src/features/editor/page-editor.tsx | 2 + .../src/features/editor/styles/core.css | 47 ++++++---- .../src/features/editor/styles/media.css | 3 - .../features/editor/styles/placeholder.css | 38 ++++---- .../src/features/group/queries/group-query.ts | 2 +- .../features/page/tree/styles/tree.module.css | 2 +- packages/editor-ext/src/index.ts | 2 + packages/editor-ext/src/lib/link.ts | 47 ++++++++++ packages/editor-ext/src/lib/selection.ts | 36 ++++++++ 25 files changed, 468 insertions(+), 82 deletions(-) create mode 100644 apps/client/src/features/editor/components/bubble-menu/link-selector.tsx create mode 100644 apps/client/src/features/editor/components/link/link-editor-panel.tsx create mode 100644 apps/client/src/features/editor/components/link/link-menu.tsx create mode 100644 apps/client/src/features/editor/components/link/link-preview.tsx create mode 100644 apps/client/src/features/editor/components/link/types.ts create mode 100644 apps/client/src/features/editor/components/link/use-link-editor-state.tsx create mode 100644 packages/editor-ext/src/lib/link.ts create mode 100644 packages/editor-ext/src/lib/selection.ts diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 3045d78b..6c243036 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -61,7 +61,7 @@ export default function App() { } /> } /> - } /> + } /> } /> } /> diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.module.css b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.module.css index 0d3028fe..e43c1714 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.module.css +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.module.css @@ -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) - ); - } } diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx index 9cb16433..eac1cfed 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx @@ -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 = (props) => { }, tippyOptions: { moveTransition: "transform 0.15s ease-out", - onHidden: () => { + onHide: () => { setIsNodeSelectorOpen(false); setIsColorSelectorOpen(false); setIsLinkSelectorOpen(false); @@ -155,6 +156,14 @@ export const EditorBubbleMenu: FC = (props) => { ))} + { + setIsLinkSelectorOpen(!isLinkSelectorOpen); + }} + /> + = (props) => { style={{ border: "none" }} onClick={commentItem.command} > - + diff --git a/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx index 12867794..84025174 100644 --- a/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx @@ -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 = ({ return ( - - + + diff --git a/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx new file mode 100644 index 00000000..a3e2fd83 --- /dev/null +++ b/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx @@ -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; + isOpen: boolean; + setIsOpen: Dispatch>; +} + +export const LinkSelector: FC = ({ + editor, + isOpen, + setIsOpen, +}) => { + const onLink = useCallback( + (url: string) => { + editor.chain().focus().setLink({ href: url }).run(); + setIsOpen(false); + console.log("is p[e "); + }, + [editor], + ); + + return ( + + + + setIsOpen(!isOpen)} + > + + + + + + + + + + ); +}; diff --git a/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx index a1ad1c56..b27970cf 100644 --- a/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx @@ -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 = ({ + + + + ); +}; diff --git a/apps/client/src/features/editor/components/link/link-menu.tsx b/apps/client/src/features/editor/components/link/link-menu.tsx new file mode 100644 index 00000000..7cdd2f0f --- /dev/null +++ b/apps/client/src/features/editor/components/link/link-menu.tsx @@ -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 ( + { + return appendTo?.current; + }, + onHidden: () => { + setShowEdit(false); + }, + placement: "bottom", + offset: [0, 5], + zIndex: 101, + }} + shouldShow={shouldShow} + > + {showEdit ? ( + + + + ) : ( + + )} + + ); +} + +export default LinkMenu; diff --git a/apps/client/src/features/editor/components/link/link-preview.tsx b/apps/client/src/features/editor/components/link/link-preview.tsx new file mode 100644 index 00000000..35cec27d --- /dev/null +++ b/apps/client/src/features/editor/components/link/link-preview.tsx @@ -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 ( + <> + + + + + {url} + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/apps/client/src/features/editor/components/link/types.ts b/apps/client/src/features/editor/components/link/types.ts new file mode 100644 index 00000000..853dcae6 --- /dev/null +++ b/apps/client/src/features/editor/components/link/types.ts @@ -0,0 +1,4 @@ +export type LinkEditorPanelProps = { + initialUrl?: string; + onSetLink: (url: string, openInNewTab?: boolean) => void; +}; diff --git a/apps/client/src/features/editor/components/link/use-link-editor-state.tsx b/apps/client/src/features/editor/components/link/use-link-editor-state.tsx new file mode 100644 index 00000000..778f8da7 --- /dev/null +++ b/apps/client/src/features/editor/components/link/use-link-editor-state.tsx @@ -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) => { + 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, + }; +}; diff --git a/apps/client/src/features/editor/components/math/math-block.tsx b/apps/client/src/features/editor/components/math/math-block.tsx index 456b9f39..5fa83547 100644 --- a/apps/client/src/features/editor/components/math/math-block.tsx +++ b/apps/client/src/features/editor/components/math/math-block.tsx @@ -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); } }; diff --git a/apps/client/src/features/editor/components/math/math-inline.tsx b/apps/client/src/features/editor/components/math/math-inline.tsx index 4904831a..a2ec5a5e 100644 --- a/apps/client/src/features/editor/components/math/math-inline.tsx +++ b/apps/client/src/features/editor/components/math/math-inline.tsx @@ -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); } }; diff --git a/apps/client/src/features/editor/components/video/video-view.tsx b/apps/client/src/features/editor/components/video/video-view.tsx index b5feeb7e..21f25d6e 100644 --- a/apps/client/src/features/editor/components/video/video-view.tsx +++ b/apps/client/src/features/editor/components/video/video-view.tsx @@ -25,7 +25,7 @@ export default function VideoView(props: NodeViewProps) { width={width} controls src={getFileUrl(src)} - className={selected && "ProseMirror-selectednode"} + className={selected ? "ProseMirror-selectednode" : ""} /> ); diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 97aaf70f..00757ba2 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -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[]; diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 0403682e..3eca06a7 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -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) { + )} diff --git a/apps/client/src/features/editor/styles/core.css b/apps/client/src/features/editor/styles/core.css index 6e532626..f4705541 100644 --- a/apps/client/src/features/editor/styles/core.css +++ b/apps/client/src/features/editor/styles/core.css @@ -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 { diff --git a/apps/client/src/features/editor/styles/media.css b/apps/client/src/features/editor/styles/media.css index f288e3b0..ef616d74 100644 --- a/apps/client/src/features/editor/styles/media.css +++ b/apps/client/src/features/editor/styles/media.css @@ -5,9 +5,6 @@ } .node-image, .node-video { - margin-top: 8px; - margin-bottom: 8px; - &.ProseMirror-selectednode { outline: none; } diff --git a/apps/client/src/features/editor/styles/placeholder.css b/apps/client/src/features/editor/styles/placeholder.css index 796ef128..67aec13b 100644 --- a/apps/client/src/features/editor/styles/placeholder.css +++ b/apps/client/src/features/editor/styles/placeholder.css @@ -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: ''; +} diff --git a/apps/client/src/features/group/queries/group-query.ts b/apps/client/src/features/group/queries/group-query.ts index 370b548a..4707b198 100644 --- a/apps/client/src/features/group/queries/group-query.ts +++ b/apps/client/src/features/group/queries/group-query.ts @@ -29,7 +29,7 @@ export function useGetGroupsQuery( export function useGroupQuery(groupId: string): UseQueryResult { return useQuery({ - queryKey: ["group", groupId], + queryKey: ["groups", groupId], queryFn: () => getGroupById(groupId), enabled: !!groupId, }); diff --git a/apps/client/src/features/page/tree/styles/tree.module.css b/apps/client/src/features/page/tree/styles/tree.module.css index 7627e13a..de35ea77 100644 --- a/apps/client/src/features/page/tree/styles/tree.module.css +++ b/apps/client/src/features/page/tree/styles/tree.module.css @@ -4,7 +4,7 @@ .treeContainer { display: flex; - height: 60vh; + height: 68vh; flex: 1; min-width: 0; } diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index dd043912..35900860 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -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"; diff --git a/packages/editor-ext/src/lib/link.ts b/packages/editor-ext/src/lib/link.ts new file mode 100644 index 00000000..7aa4aac7 --- /dev/null +++ b/packages/editor-ext/src/lib/link.ts @@ -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; + }, + }, + }), + ]; + }, +}); diff --git a/packages/editor-ext/src/lib/selection.ts b/packages/editor-ext/src/lib/selection.ts new file mode 100644 index 00000000..6597c14b --- /dev/null +++ b/packages/editor-ext/src/lib/selection.ts @@ -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;