diff --git a/lib/package-lock.json b/lib/package-lock.json index 8a24bbca..a184dff8 100644 --- a/lib/package-lock.json +++ b/lib/package-lock.json @@ -18,6 +18,7 @@ "@tiptap/extension-image": "^2.14.0", "@tiptap/extension-link": "^2.14.0", "@tiptap/extension-list-item": "^2.14.0", + "@tiptap/extension-mention": "^2.14.0", "@tiptap/extension-placeholder": "^2.14.0", "@tiptap/extension-table": "^2.14.0", "@tiptap/extension-table-cell": "^2.14.0", @@ -29,6 +30,7 @@ "@tiptap/pm": "^2.12.0", "@tiptap/react": "^2.12.0", "@tiptap/starter-kit": "^2.12.0", + "@tiptap/suggestion": "^2.14.0", "axios": "^1.6.5", "browser-image-compression": "^2.0.2", "classnames": "^2.5.1", @@ -55,6 +57,7 @@ "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remove-markdown": "^0.6.2", + "tippy.js": "^6.3.7", "tiptap-markdown": "^0.8.10", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", @@ -2671,6 +2674,21 @@ "@tiptap/core": "^2.7.0" } }, + "node_modules/@tiptap/extension-mention": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-2.14.0.tgz", + "integrity": "sha512-mmEv5rBOn9b90hcp0iQg/YWxJPgthfBD6Rp8FRbYauB7laiBUa7rhT5iuY9nj3UFUy8009lEZjc1gvtkC9B9ug==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0", + "@tiptap/suggestion": "^2.7.0" + } + }, "node_modules/@tiptap/extension-ordered-list": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.12.0.tgz", @@ -2929,6 +2947,20 @@ "url": "https://github.com/sponsors/ueberdosis" } }, + "node_modules/@tiptap/suggestion": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-2.14.0.tgz", + "integrity": "sha512-AXzEw0KYIyg5id8gz5geIffnBtkZqan5MWe29rGo3gXTfKH+Ik8tWbZdnlMVheycsUCllrymDRei4zw9DqVqkQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", diff --git a/lib/package.json b/lib/package.json index ea9e7fbf..3e22515f 100644 --- a/lib/package.json +++ b/lib/package.json @@ -106,6 +106,7 @@ "@tiptap/extension-image": "^2.14.0", "@tiptap/extension-link": "^2.14.0", "@tiptap/extension-list-item": "^2.14.0", + "@tiptap/extension-mention": "^2.14.0", "@tiptap/extension-placeholder": "^2.14.0", "@tiptap/extension-table": "^2.14.0", "@tiptap/extension-table-cell": "^2.14.0", @@ -117,6 +118,7 @@ "@tiptap/pm": "^2.12.0", "@tiptap/react": "^2.12.0", "@tiptap/starter-kit": "^2.12.0", + "@tiptap/suggestion": "^2.14.0", "axios": "^1.6.5", "browser-image-compression": "^2.0.2", "classnames": "^2.5.1", @@ -143,6 +145,7 @@ "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remove-markdown": "^0.6.2", + "tippy.js": "^6.3.7", "tiptap-markdown": "^0.8.10", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", diff --git a/lib/src/Components/Input/RichTextEditor/MentionList.tsx b/lib/src/Components/Input/RichTextEditor/MentionList.tsx new file mode 100644 index 00000000..80bb55c0 --- /dev/null +++ b/lib/src/Components/Input/RichTextEditor/MentionList.tsx @@ -0,0 +1,77 @@ +import { useState, useEffect, useImperativeHandle, forwardRef } from 'react' + +export interface MentionListHandle { + onKeyDown: (args: { event: KeyboardEvent }) => boolean +} + +interface MentionListProps { + items: string[] + command: (payload: { id: string }) => void +} + +export const MentionList = forwardRef(function MentionList( + { items, command }, + ref, +) { + const [selectedIndex, setSelectedIndex] = useState(0) + + const selectItem = (index: number) => { + // eslint-disable-next-line security/detect-object-injection + const item = items[index] + if (item) { + command({ id: item }) + } + } + + const upHandler = () => { + setSelectedIndex((prev) => (items.length > 0 ? (prev + items.length - 1) % items.length : 0)) + } + + const downHandler = () => { + setSelectedIndex((prev) => (items.length > 0 ? (prev + 1) % items.length : 0)) + } + + const enterHandler = () => { + selectItem(selectedIndex) + } + + useEffect(() => { + setSelectedIndex(0) + }, [items]) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }) => { + switch (event.key) { + case 'ArrowUp': + upHandler() + return true + case 'ArrowDown': + downHandler() + return true + case 'Enter': + enterHandler() + return true + default: + return false + } + }, + })) + + return ( +
+ {items.length > 0 ? ( + items.map((item, index) => ( + + )) + ) : ( +
No result
+ )} +
+ ) +}) diff --git a/lib/src/Components/Input/RichTextEditor.tsx b/lib/src/Components/Input/RichTextEditor/RichTextEditor.tsx similarity index 68% rename from lib/src/Components/Input/RichTextEditor.tsx rename to lib/src/Components/Input/RichTextEditor/RichTextEditor.tsx index 4fb3e1a5..97cb720b 100644 --- a/lib/src/Components/Input/RichTextEditor.tsx +++ b/lib/src/Components/Input/RichTextEditor/RichTextEditor.tsx @@ -4,6 +4,7 @@ import { Color } from '@tiptap/extension-color' import { Image } from '@tiptap/extension-image' import { Link } from '@tiptap/extension-link' +import { Mention } from '@tiptap/extension-mention' import { Placeholder } from '@tiptap/extension-placeholder' import { Table } from '@tiptap/extension-table' import { TableCell } from '@tiptap/extension-table-cell' @@ -12,18 +13,13 @@ import { TableRow } from '@tiptap/extension-table-row' import { TaskItem } from '@tiptap/extension-task-item' import { TaskList } from '@tiptap/extension-task-list' import { Youtube } from '@tiptap/extension-youtube' -import { - EditorContent, - useEditor, - nodePasteRule, - nodeInputRule, - mergeAttributes, -} from '@tiptap/react' +import { EditorContent, useEditor } from '@tiptap/react' import { StarterKit } from '@tiptap/starter-kit' import { MarkdownSerializer } from 'prosemirror-markdown' import { useEffect } from 'react' import { Markdown } from 'tiptap-markdown' +import { suggestion } from './suggestion' import { TextEditorMenu } from './TextEditorMenu' import type { Editor } from '@tiptap/react' @@ -76,9 +72,13 @@ export function RichTextEditor({ const editor = useEditor({ extensions: [ Color.configure({ types: ['textStyle', 'listItem'] }), - CustomYoutube.configure({ + Youtube.configure({ nocookie: true, allowFullscreen: true, + addPasteHandler: true, + height: undefined, + width: undefined, + modestBranding: true, }), StarterKit.configure({ bulletList: { @@ -110,6 +110,12 @@ export function RichTextEditor({ placeholder, emptyEditorClass: 'is-editor-empty', }), + Mention.configure({ + HTMLAttributes: { + class: 'mention', + }, + suggestion, + }), ], content: defaultValue, onUpdate: handleChange, @@ -194,98 +200,13 @@ export function getStyledMarkdown(editor: Editor): string { state.write(tag) } - const customYoutube: NodeSerializerFn = (state, node) => { - const { src } = node.attrs as { src: string } - - const match = src.match( - /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([A-Za-z0-9_-]{11})/, - ) - const videoId = match?.[1] - if (videoId) { - const nocookieUrl = `https://www.youtube-nocookie.com/embed/${videoId}` - - let tag = '
' - tag += `` - tag += '
' - tag += '\n\n' - state.write(tag) - } - } - const customSerializer = new MarkdownSerializer( { ...baseNodes, image: customImage, - youtube: customYoutube, }, marks, ) return customSerializer.serialize(editor.state.doc) } - -const CustomYoutube = Youtube.extend({ - addPasteRules() { - return [ - nodePasteRule({ - find: youtubePasteRegex, - type: this.type, - getAttributes: (match) => { - return { src: `https://www.youtube-nocookie.com/embed/${match[2]}` } - }, - }), - ] - }, - addInputRules() { - return [ - nodeInputRule({ - find: youtubeInputRegex, - type: this.type, - getAttributes: (match) => { - return { src: `https://www.youtube-nocookie.com/embed/${match[2]}` } - }, - }), - ] - }, - parseHTML() { - return [ - { - tag: 'iframe[src*="/embed/"]', - priority: 1000, - getAttrs: (dom) => { - const src = (dom as HTMLIFrameElement).getAttribute('src') ?? '' - const match = src.match(/\/embed\/([A-Za-z0-9_-]{11})/) - if (!match) { - return false - } - const videoId = match[1] - return { - src: `https://www.youtube-nocookie.com/embed/${videoId}`, - } - }, - }, - ] - }, - renderHTML({ HTMLAttributes }) { - // feste Breiten/Höhen raus - const { ...attrs } = HTMLAttributes - delete attrs.width - delete attrs.height - const iframeAttrs = mergeAttributes(attrs, { - allowfullscreen: '', - loading: 'lazy', - class: 'tw-w-full tw-h-full', - }) - - return [ - 'div', - { class: 'tw:w-full tw-aspect-video tw-overflow-hidden' }, - ['iframe', iframeAttrs], - ] - }, -}) - -const youtubePasteRegex = -/(https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/))([A-Za-z0-9_-]{11})(?:\?.*)?/g -const youtubeInputRegex = - /(https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/))([A-Za-z0-9_-]{11})(?:\?.*)?$/ \ No newline at end of file diff --git a/lib/src/Components/Input/TextEditorMenu.tsx b/lib/src/Components/Input/RichTextEditor/TextEditorMenu.tsx similarity index 100% rename from lib/src/Components/Input/TextEditorMenu.tsx rename to lib/src/Components/Input/RichTextEditor/TextEditorMenu.tsx diff --git a/lib/src/Components/Input/RichTextEditor/suggestion.ts b/lib/src/Components/Input/RichTextEditor/suggestion.ts new file mode 100644 index 00000000..2ef7d05a --- /dev/null +++ b/lib/src/Components/Input/RichTextEditor/suggestion.ts @@ -0,0 +1,97 @@ +import { ReactRenderer } from '@tiptap/react' +import tippy from 'tippy.js' + +import { MentionList } from './MentionList' + +import type { MentionListHandle } from './MentionList' +import type { SuggestionProps, SuggestionOptions, SuggestionKeyDownProps } from '@tiptap/suggestion' +import type { Instance as TippyInstance } from 'tippy.js' + +export const suggestion: Partial = { + items: ({ query }: { query: string }): string[] => { + return [ + 'Lea Thompson', + 'Cyndi Lauper', + 'Tom Cruise', + 'Madonna', + 'Jerry Hall', + 'Joan Collins', + 'Winona Ryder', + 'Christina Applegate', + 'Alyssa Milano', + 'Molly Ringwald', + 'Ally Sheedy', + 'Debbie Harry', + 'Olivia Newton-John', + 'Elton John', + 'Michael J. Fox', + 'Axl Rose', + 'Emilio Estevez', + 'Ralph Macchio', + 'Rob Lowe', + 'Jennifer Grey', + 'Mickey Rourke', + 'John Cusack', + 'Matthew Broderick', + 'Justine Bateman', + 'Lisa Bonet', + ] + .filter((item) => item.toLowerCase().startsWith(query.toLowerCase())) + .slice(0, 5) + }, + + render() { + let component: ReactRenderer + let popup: TippyInstance[] + + return { + onStart: (props: SuggestionProps) => { + component = new ReactRenderer(MentionList, { + props, + editor: props.editor, + }) + + if (!props.clientRect) { + return + } + + popup = tippy('body', { + getReferenceClientRect: () => + props.clientRect ? (props.clientRect() ?? new DOMRect()) : new DOMRect(), + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }) + }, + + onUpdate(props: SuggestionProps) { + component.updateProps(props) + + if (!props.clientRect) { + return + } + + popup[0].setProps({ + getReferenceClientRect: () => + props.clientRect ? (props.clientRect() ?? new DOMRect()) : new DOMRect(), + }) + }, + + onKeyDown(props: SuggestionKeyDownProps): boolean { + if (props.event.key === 'Escape') { + popup[0].hide() + return true + } + return (component.ref as MentionListHandle | undefined)?.onKeyDown(props) ?? false + }, + + onExit() { + popup[0].destroy() + component.destroy() + }, + } + }, +} diff --git a/lib/src/Components/Profile/Subcomponents/ProfileTextForm.tsx b/lib/src/Components/Profile/Subcomponents/ProfileTextForm.tsx index f98b5c39..9b257152 100644 --- a/lib/src/Components/Profile/Subcomponents/ProfileTextForm.tsx +++ b/lib/src/Components/Profile/Subcomponents/ProfileTextForm.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react' -import { RichTextEditor } from '#components/Input/RichTextEditor' +import { RichTextEditor } from '#components/Input/RichTextEditor/RichTextEditor' import { MarkdownHint } from './MarkdownHint' diff --git a/lib/src/Components/Profile/Templates/TabsForm.tsx b/lib/src/Components/Profile/Templates/TabsForm.tsx index 272db754..07076a89 100644 --- a/lib/src/Components/Profile/Templates/TabsForm.tsx +++ b/lib/src/Components/Profile/Templates/TabsForm.tsx @@ -8,7 +8,7 @@ /* eslint-disable react/prop-types */ import { useNavigate } from 'react-router-dom' -import { RichTextEditor } from '#components/Input/RichTextEditor' +import { RichTextEditor } from '#components/Input/RichTextEditor/RichTextEditor' import { useUpdateItem } from '#components/Map/hooks/useItems' import { PopupStartEndInput, TextView } from '#components/Map/Subcomponents/ItemPopupComponents' import { ActionButton } from '#components/Profile/Subcomponents/ActionsButton' diff --git a/lib/src/assets/css/tiptap.css b/lib/src/assets/css/tiptap.css index abd6d22e..02c95455 100644 --- a/lib/src/assets/css/tiptap.css +++ b/lib/src/assets/css/tiptap.css @@ -1,4 +1,52 @@ +/* Basic editor styles */ +.tiptap { + :first-child { + margin-top: 0; + } + .mention { + background-color: var(--purple-light); + border-radius: 0.4rem; + box-decoration-break: clone; + color: var(--purple); + padding: 0.1rem 0.3rem; + &::after { + content: "\200B"; + } + } +} + +/* Dropdown menu */ +.dropdown-menu { + background: var(--color-base-100); + border: 1px solid var(--color-base-200); + border-radius: 0.7rem; + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + gap: 0.1rem; + overflow: auto; + padding: 0.4rem; + position: relative; + + button { + align-items: center; + background-color: transparent; + display: flex; + gap: 0.25rem; + text-align: left; + width: 100%; + + &:hover, + &:hover.is-selected { + background-color: var(--color-base-200); + } + + &.is-selected { + background-color: var(--color-base-200); + } + } +} .editor-wrapper div { min-height: 0;