basic hashtag autocompletion

This commit is contained in:
Anton Tranelis 2025-06-17 00:23:24 +02:00
parent d14a82f94d
commit 051338246d
12 changed files with 146 additions and 92 deletions

8
lib/package-lock.json generated
View File

@ -15,6 +15,7 @@
"@tiptap/extension-bubble-menu": "^2.14.0",
"@tiptap/extension-bullet-list": "^2.14.0",
"@tiptap/extension-color": "^2.12.0",
"@tiptap/extension-heading": "^2.14.0",
"@tiptap/extension-image": "^2.14.0",
"@tiptap/extension-link": "^2.14.0",
"@tiptap/extension-list-item": "^2.14.0",
@ -39,6 +40,7 @@
"leaflet.locatecontrol": "^0.79.0",
"mdast-util-to-string": "^4.0.0",
"prosemirror-markdown": "^1.13.2",
"prosemirror-state": "^1.4.3",
"radash": "^12.1.0",
"react-colorful": "^5.6.1",
"react-dropzone": "^14.3.8",
@ -2578,9 +2580,9 @@
}
},
"node_modules/@tiptap/extension-heading": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.12.0.tgz",
"integrity": "sha512-9DfES4Wd5TX1foI70N9sAL+35NN1UHrtzDYN2+dTHupnmKir9RaMXyZcbkUb4aDVzYrGxIqxJzHBVkquKIlTrw==",
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.14.0.tgz",
"integrity": "sha512-vM//6G3Ox3mxPv9eilhrDqylELCc8kEP1aQ4xUuOw7vCidjNtGggOa1ERnnpV2dCa2A9E8y4FHtN4Xh29stXQg==",
"license": "MIT",
"funding": {
"type": "github",

View File

@ -103,6 +103,7 @@
"@tiptap/extension-bubble-menu": "^2.14.0",
"@tiptap/extension-bullet-list": "^2.14.0",
"@tiptap/extension-color": "^2.12.0",
"@tiptap/extension-heading": "^2.14.0",
"@tiptap/extension-image": "^2.14.0",
"@tiptap/extension-link": "^2.14.0",
"@tiptap/extension-list-item": "^2.14.0",
@ -127,6 +128,7 @@
"leaflet.locatecontrol": "^0.79.0",
"mdast-util-to-string": "^4.0.0",
"prosemirror-markdown": "^1.13.2",
"prosemirror-state": "^1.4.3",
"radash": "^12.1.0",
"react-colorful": "^5.6.1",
"react-dropzone": "^14.3.8",

View File

@ -0,0 +1,17 @@
/* eslint-disable security/detect-non-literal-regexp */
import { textblockTypeInputRule } from '@tiptap/core'
import { Heading } from '@tiptap/extension-heading'
export const CustomHeading = Heading.extend({
addInputRules() {
return this.options.levels.map((level) => {
return textblockTypeInputRule({
find: new RegExp(`^(#{${Math.min(...this.options.levels)},${level}}) $`),
type: this.type,
getAttributes: {
level,
},
})
})
},
})

View File

@ -0,0 +1,29 @@
import { Image } from '@tiptap/extension-image'
export const CustomImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
style: {
default: null,
parseHTML: (element) => element.getAttribute('style'),
renderHTML: (attributes) => {
if (!attributes.style) {
return {}
}
return { style: attributes.style }
},
},
width: {
default: null,
parseHTML: (element) => element.getAttribute('width'),
renderHTML: (attrs) => (attrs.width ? { width: attrs.width } : {}),
},
height: {
default: null,
parseHTML: (element) => element.getAttribute('height'),
renderHTML: (attrs) => (attrs.height ? { height: attrs.height } : {}),
},
}
},
})

View File

@ -0,0 +1,29 @@
import { NodeViewWrapper } from '@tiptap/react'
import { useAddFilterTag } from '#components/Map/hooks/useFilter'
import { useTags } from '#components/Map/hooks/useTags'
import type { NodeViewProps } from '@tiptap/core'
export const Hashtag = ({ node }: NodeViewProps) => {
const { id } = node.attrs as { id: string }
const tags = useTags()
const addFilterTag = useAddFilterTag()
const tag = tags.find((t) => t.name === id)
return (
<NodeViewWrapper className='react-component'>
<a
className='hashtag'
style={{ color: tag?.color ?? 'var(--color-base-content)' }}
onClick={(e) => {
e.stopPropagation()
tag && addFilterTag(tag)
}}
>
#{tag?.name}
</a>
</NodeViewWrapper>
)
}

View File

@ -0,0 +1,11 @@
import { Mention } from '@tiptap/extension-mention'
import { ReactNodeViewRenderer } from '@tiptap/react'
import { Hashtag } from './Hashtag'
export const HashtagMention = Mention.extend({
name: 'mention',
addNodeView() {
return ReactNodeViewRenderer(Hashtag)
},
})

View File

@ -16,7 +16,6 @@ export const MentionList = forwardRef<MentionListHandle, MentionListProps>(funct
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 })

View File

@ -8,38 +8,6 @@ import type { SuggestionProps, SuggestionOptions, SuggestionKeyDownProps } from
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[]

View File

@ -1,10 +1,9 @@
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
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'
@ -13,13 +12,18 @@ 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 } from '@tiptap/react'
import { EditorContent, mergeAttributes, 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 { useTags } from '#components/Map/hooks/useTags'
import { CustomHeading } from './Extensions/CustomHeading'
import { CustomImage } from './Extensions/CustomImage'
import { HashtagMention } from './Extensions/HashtagMention'
import { suggestion } from './Extensions/suggestion'
import { TextEditorMenu } from './TextEditorMenu'
import type { Editor } from '@tiptap/react'
@ -63,12 +67,16 @@ export function RichTextEditor({
updateFormValue,
}: RichTextEditorProps) {
const handleChange = () => {
if (!editor) return
if (updateFormValue) {
updateFormValue(getStyledMarkdown(editor))
if (editor) {
console.log(getStyledMarkdown(editor))
updateFormValue(getStyledMarkdown(editor))
}
}
}
const tags = useTags()
const editor = useEditor({
extensions: [
Color.configure({ types: ['textStyle', 'listItem'] }),
@ -89,6 +97,29 @@ export function RichTextEditor({
keepMarks: true,
keepAttributes: false,
},
heading: false,
}),
HashtagMention.configure({
HTMLAttributes: { class: 'mention' },
renderHTML: ({ node, options }) => {
return [
'span',
mergeAttributes(options.HTMLAttributes, {
'data-id': node.attrs.id,
}),
`#${node.attrs.id}`,
]
},
suggestion: {
char: '#',
items: ({ query }) => {
return tags
.map((tag) => tag.name)
.filter((tag) => tag.toLowerCase().startsWith(query.toLowerCase()))
.slice(0, 5)
},
...suggestion,
},
}),
Markdown.configure({
html: true,
@ -106,16 +137,11 @@ export function RichTextEditor({
TaskItem,
CustomImage,
Link,
CustomHeading,
Placeholder.configure({
placeholder,
emptyEditorClass: 'is-editor-empty',
}),
Mention.configure({
HTMLAttributes: {
class: 'mention',
},
suggestion,
}),
],
content: defaultValue,
onUpdate: handleChange,
@ -157,34 +183,6 @@ export function RichTextEditor({
)
}
const CustomImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
style: {
default: null,
parseHTML: (element) => element.getAttribute('style'),
renderHTML: (attributes) => {
if (!attributes.style) {
return {}
}
return { style: attributes.style }
},
},
width: {
default: null,
parseHTML: (element) => element.getAttribute('width'),
renderHTML: (attrs) => (attrs.width ? { width: attrs.width } : {}),
},
height: {
default: null,
parseHTML: (element) => element.getAttribute('height'),
renderHTML: (attrs) => (attrs.height ? { height: attrs.height } : {}),
},
}
},
})
export function getStyledMarkdown(editor: Editor): string {
const { serializer } = editor.storage.markdown as { serializer: MarkdownSerializer }
const baseNodes = serializer.nodes as Record<string, NodeSerializerFn>

View File

@ -165,13 +165,6 @@ function removeMarkdownKeepParagraphs(text: string): string {
// 4) Code-Fences und Inline-Code entfernen
.replace(/```[\s\S]*?```/g, '')
.replace(/`([^`]+)`/g, '$1')
// 5) Fett/Italic löschen
.replace(/(\*\*|__)(.*?)\1/g, '$2')
.replace(/(\*|_)(.*?)\1/g, '$2')
// 6) Überschriften-Hashes entfernen
.replace(/^#{1,6}\s+(.*)$/gm, '$1')
// 7) Listen-Marker entfernen (-, *, +, 1., 2., …)
.replace(/^\s*([-+*]|\d+\.)\s+/gm, '')
// 8) Tabellen-Pipes entfernen
.replace(/^\|(.+)\|$/gm, '$1')
.replace(/^\s*\|[-\s|]+\|$/gm, '')
@ -230,7 +223,6 @@ export const sanitizeSchema = {
'enableiframeapi',
'endtime',
'ivloadpolicy',
'loop',
'modestbranding',
'origin',
'playlist',

View File

@ -174,7 +174,7 @@ export function ProfileView({ attestationApi }: { attestationApi?: ItemsApi<any>
{item && (
<MapOverlayPage
key={item.id}
className={`tw:p-0! tw:overflow-scroll tw:m-4! tw:md:w-[calc(50%-32px)] tw:w-[calc(100%-32px)] tw:min-w-80 tw:max-w-3xl tw:left-0! tw:sm:left-auto! tw:top-0 tw:bottom-0 tw:transition-opacity tw:duration-500 ${!selectPosition ? 'tw:opacity-100 tw:pointer-events-auto' : 'tw:opacity-0 tw:pointer-events-none'} tw:max-h-[1000px]`}
className={`tw:p-0! tw:overflow-hidden tw:m-4! tw:md:w-[calc(50%-32px)] tw:w-[calc(100%-32px)] tw:min-w-80 tw:max-w-3xl tw:left-0! tw:sm:left-auto! tw:top-0 tw:bottom-0 tw:transition-opacity tw:duration-500 ${!selectPosition ? 'tw:opacity-100 tw:pointer-events-auto' : 'tw:opacity-0 tw:pointer-events-none'} tw:max-h-[1000px]`}
>
<>
<div className={'tw:px-6 tw:pt-6'}>

View File

@ -5,22 +5,26 @@
}
.mention {
background-color: var(--purple-light);
border-radius: 0.4rem;
background-color: var(--color-primary) 20%;
border-radius: var(--radius-box);
box-decoration-break: clone;
color: var(--purple);
padding: 0.1rem 0.3rem;
color: var(--color-primary);
padding: 0;
&::after {
content: "\200B";
}
}
}
.react-renderer .react-component[data-node-view-wrapper] {
display: inline-block;
}
/* Dropdown menu */
.dropdown-menu {
background: var(--color-base-100);
border: 1px solid var(--color-base-200);
border-radius: 0.7rem;
border-radius: var(--radius-box);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
@ -36,14 +40,17 @@
gap: 0.25rem;
text-align: left;
width: 100%;
padding-left: var(--tw-spacing);
padding-right: var(--tw-spacing);
border-radius: var(--radius-box);
&:hover,
&:hover.is-selected {
background-color: var(--color-base-200);
background-color: var(--color-base-300);
}
&.is-selected {
background-color: var(--color-base-200);
background-color: var(--color-base-300);
}
}
}