basic mentions

This commit is contained in:
Anton Tranelis 2025-06-15 20:31:57 +02:00
parent f57db1de46
commit d14a82f94d
9 changed files with 273 additions and 95 deletions

32
lib/package-lock.json generated
View File

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

View File

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

View File

@ -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<MentionListHandle, MentionListProps>(function MentionList(
{ items, command },
ref,
) {
const [selectedIndex, setSelectedIndex] = useState<number>(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 (
<div className='dropdown-menu'>
{items.length > 0 ? (
items.map((item, index) => (
<button
key={index}
className={index === selectedIndex ? 'is-selected' : ''}
onClick={() => selectItem(index)}
>
{item}
</button>
))
) : (
<div className='item'>No result</div>
)}
</div>
)
})

View File

@ -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 = '<div class="tw:w-full tw:aspect-video tw:overflow-hidden">'
tag += `<iframe src="${nocookieUrl}" allowfullscreen class="tw-w-full tw-h-full" loading="lazy"></iframe>`
tag += '</div>'
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})(?:\?.*)?$/

View File

@ -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<SuggestionOptions> = {
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<MentionListHandle>
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()
},
}
},
}

View File

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

View File

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

View File

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