feat(TipTap): add hashtag and item mention suggestions

- Add suggestion system for #hashtags and @item-mentions in RichTextEditor
- New files:
  - HashtagSuggestion.tsx: autocomplete for existing tags, create new tags
  - ItemMentionSuggestion.tsx: autocomplete for items with @syntax
  - ItemMention.tsx: TipTap node for item links with markdown serialization
  - SuggestionList.tsx: shared popup UI component
  - useItemColor.tsx: hook for consistent item color calculation
- Features:
  - Type # to see tag suggestions, space key confirms selection
  - Type @ to see item suggestions with correct colors
  - New tags can be created inline
  - Clickable mentions in view mode (hashtags filter map, @mentions navigate)
  - Bold styling for all mentions and suggestions
  - Disabled clicks in edit mode
- Refactored components to use useItemColor hook for consistent colors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Anton Tranelis 2026-01-14 15:31:17 +01:00
parent 382f284f31
commit c644f182e4
19 changed files with 791 additions and 74 deletions

View File

@ -111,6 +111,7 @@
"@tiptap/pm": "^3.6.5",
"@tiptap/react": "^3.13.0",
"@tiptap/starter-kit": "^3.13.0",
"@tiptap/suggestion": "^3.15.3",
"axios": "^1.13.2",
"browser-image-compression": "^2.0.2",
"classnames": "^2.5.1",
@ -130,6 +131,7 @@
"react-qr-code": "^2.0.16",
"react-toastify": "^9.1.3",
"remark-breaks": "^4.0.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.9.0",
"yet-another-react-lightbox": "^3.28.0"
},

View File

@ -4,11 +4,15 @@ import { Link } from '@tiptap/extension-link'
import { Placeholder } from '@tiptap/extension-placeholder'
import { EditorContent, useEditor } from '@tiptap/react'
import { StarterKit } from '@tiptap/starter-kit'
import { useEffect } from 'react'
import { useEffect, useMemo } from 'react'
import { Markdown } from 'tiptap-markdown'
import { VideoEmbed } from '#components/TipTap/extensions/VideoEmbed'
import { preprocessVideoLinks } from '#components/TipTap/utils/preprocessMarkdown'
import { useGetItemColor } from '#components/Map/hooks/useItemColor'
import { useItems } from '#components/Map/hooks/useItems'
import { useAddTag, useTags } from '#components/Map/hooks/useTags'
import { Hashtag, ItemMention, VideoEmbed } from '#components/TipTap/extensions'
import { createHashtagSuggestion, createItemMentionSuggestion } from '#components/TipTap/extensions'
import { preprocessMarkdown } from '#components/TipTap/utils/preprocessMarkdown'
import { InputLabel } from './InputLabel'
import { TextEditorMenu } from './TextEditorMenu'
@ -42,6 +46,18 @@ export function RichTextEditor({
showMenu = true,
updateFormValue,
}: RichTextEditorProps) {
const tags = useTags()
const addTag = useAddTag()
const items = useItems()
const getItemColor = useGetItemColor()
// Memoize suggestion configurations to prevent unnecessary re-renders
const hashtagSuggestion = useMemo(() => createHashtagSuggestion(tags, addTag), [tags, addTag])
const itemMentionSuggestion = useMemo(
() => createItemMentionSuggestion(items, getItemColor),
[items, getItemColor],
)
const handleChange = () => {
let newValue: string | undefined = editor.storage.markdown.getMarkdown()
@ -77,8 +93,17 @@ export function RichTextEditor({
emptyEditorClass: 'is-editor-empty',
}),
VideoEmbed,
Hashtag.configure({
tags,
suggestion: hashtagSuggestion,
}),
ItemMention.configure({
suggestion: itemMentionSuggestion,
items,
getItemColor,
}),
],
content: preprocessVideoLinks(defaultValue),
content: preprocessMarkdown(defaultValue),
onUpdate: handleChange,
editorProps: {
attributes: {
@ -89,7 +114,7 @@ export function RichTextEditor({
useEffect(() => {
if (editor.storage.markdown.getMarkdown() === '' || !editor.storage.markdown.getMarkdown()) {
editor.commands.setContent(preprocessVideoLinks(defaultValue))
editor.commands.setContent(preprocessMarkdown(defaultValue))
}
}, [defaultValue, editor])

View File

@ -23,6 +23,7 @@ import { useNavigate } from 'react-router-dom'
import { useAppState } from '#components/AppShell/hooks/useAppState'
import { useDebounce } from '#components/Map/hooks/useDebounce'
import { useAddFilterTag } from '#components/Map/hooks/useFilter'
import { useGetItemColor } from '#components/Map/hooks/useItemColor'
import { useItems } from '#components/Map/hooks/useItems'
import { useLeafletRefs } from '#components/Map/hooks/useLeafletRefs'
import { useTags } from '#components/Map/hooks/useTags'
@ -48,6 +49,7 @@ export const SearchControl = () => {
const map = useMap()
const tags = useTags()
const items = useItems()
const getItemColor = useGetItemColor()
const leafletRefs = useLeafletRefs()
const addFilterTag = useAddFilterTag()
const appState = useAppState()
@ -173,21 +175,7 @@ export const SearchControl = () => {
<hr className='tw:opacity-50'></hr>
)}
{itemsResults.slice(0, 5).map((item) => {
// Calculate color using the same logic as PopupView
const itemTags =
item.text
?.match(/#[^\s#]+/g)
?.map((tag) =>
tags.find((t) => t.name.toLowerCase() === tag.slice(1).toLowerCase()),
)
.filter(Boolean) ?? []
let color1 = item.layer?.markerDefaultColor ?? '#777'
if (item.color) {
color1 = item.color
} else if (itemTags[0]) {
color1 = itemTags[0].color
}
const color1 = getItemColor(item, '#777')
return (
<div

View File

@ -1,7 +1,7 @@
import { FaPlus } from 'react-icons/fa6'
import { useGetItemColor } from '#components/Map/hooks/useItemColor'
import { useMyProfile } from '#components/Map/hooks/useMyProfile'
import { useGetItemTags } from '#components/Map/hooks/useTags'
import type { Item } from '#types/Item'
@ -11,7 +11,7 @@ interface ConnectionStatusProps {
export function ConnectionStatus({ item }: ConnectionStatusProps) {
const myProfile = useMyProfile()
const getItemTags = useGetItemTags()
const getItemColor = useGetItemColor()
if (myProfile.myProfile?.id === item.id) {
return null
@ -31,12 +31,10 @@ export function ConnectionStatus({ item }: ConnectionStatusProps) {
return <p className='tw:flex tw:items-center tw:mr-2'> Connected</p>
}
const tags = getItemTags(item)
return (
<button
style={{
backgroundColor:
item.color ?? (tags[0]?.color ? tags[0].color : item.layer.markerDefaultColor || '#000'),
backgroundColor: getItemColor(item),
}}
className='tw:btn tw:text-white tw:mr-2 tw:tooltip tw:tooltip-top '
data-tip={'Connect'}

View File

@ -1,12 +1,7 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-base-to-string */
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
/* eslint-disable @typescript-eslint/prefer-optional-chain */
import { get } from 'radash'
import { Link } from 'react-router-dom'
import { useGetItemTags } from '#components/Map/hooks/useTags'
import { useGetItemColor } from '#components/Map/hooks/useItemColor'
import type { Item } from '#types/Item'
@ -27,14 +22,15 @@ export const PopupButton = ({
target?: string
}) => {
const params = new URLSearchParams(window.location.search)
const getItemTags = useGetItemTags()
const parameter = get(item, parameterField ?? 'id')
const getItemColor = useGetItemColor()
const parameter = parameterField ? get(item, parameterField) : undefined
const itemId = typeof parameter === 'string' ? parameter : (item?.id ?? '')
return (
<Link to={`${url}/${parameter || item?.id}?${params}`} target={target ?? '_self'}>
<Link to={`${url}/${itemId}?${params}`} target={target ?? '_self'}>
<button
style={{
backgroundColor: `${item?.color ?? (item && (getItemTags(item) && getItemTags(item)[0] && getItemTags(item)[0].color ? getItemTags(item)[0].color : (item?.layer?.markerDefaultColor ?? '#000')))}`,
backgroundColor: getItemColor(item),
}}
className='tw:btn tw:text-white tw:btn-sm tw:float-right tw:mt-1'
>

View File

@ -1,4 +1,3 @@
import { Link } from '@tiptap/extension-link'
import { EditorContent, useEditor } from '@tiptap/react'
import { StarterKit } from '@tiptap/starter-kit'
import { useEffect, useRef } from 'react'
@ -6,9 +5,10 @@ import { useNavigate } from 'react-router-dom'
import { Markdown } from 'tiptap-markdown'
import { useAddFilterTag } from '#components/Map/hooks/useFilter'
import { useGetItemColor } from '#components/Map/hooks/useItemColor'
import { useItems } from '#components/Map/hooks/useItems'
import { useTags } from '#components/Map/hooks/useTags'
import { Hashtag } from '#components/TipTap/extensions/Hashtag'
import { VideoEmbed } from '#components/TipTap/extensions/VideoEmbed'
import { Hashtag, ItemMention, VideoEmbed } from '#components/TipTap/extensions'
import {
preprocessMarkdown,
removeMarkdownSyntax,
@ -39,6 +39,8 @@ export const TextView = ({
const addFilterTag = useAddFilterTag()
const navigate = useNavigate()
const containerRef = useRef<HTMLDivElement>(null)
const items = useItems()
const getItemColor = useGetItemColor()
// Prepare the text content
let innerText = ''
@ -71,13 +73,7 @@ export const TextView = ({
Markdown.configure({
html: true, // Allow HTML in markdown (for our preprocessed tags)
transformPastedText: true,
}),
Link.configure({
openOnClick: false, // We handle clicks ourselves
HTMLAttributes: {
target: '_blank',
rel: 'noopener noreferrer',
},
linkify: true,
}),
Hashtag.configure({
tags,
@ -85,6 +81,10 @@ export const TextView = ({
addFilterTag(tag)
},
}),
ItemMention.configure({
items,
getItemColor,
}),
VideoEmbed,
],
content: processedText,

View File

@ -0,0 +1,42 @@
import { useCallback } from 'react'
import { useGetItemTags } from './useTags'
import type { Item } from '#types/Item'
/**
* Returns a function that calculates the color for an item.
* Priority: item.color > first tag color > layer default color > fallback
*/
export const useGetItemColor = (): ((item: Item | undefined, fallback?: string) => string) => {
const getItemTags = useGetItemTags()
return useCallback(
(item: Item | undefined, fallback: string = '#000') => {
if (!item) return fallback
// 1. Item's own color takes highest priority
if (item.color) return item.color
// 2. First tag's color
const itemTags = getItemTags(item)
if (itemTags[0]?.color) return itemTags[0].color
// 3. Layer's default marker color
if (item.layer?.markerDefaultColor) return item.layer.markerDefaultColor
// 4. Fallback
return fallback
},
[getItemTags],
)
}
/**
* Hook that returns the calculated color for a specific item.
* Priority: item.color > first tag color > layer default color > fallback
*/
export const useItemColor = (item: Item | undefined, fallback: string = '#000'): string => {
const getItemColor = useGetItemColor()
return getItemColor(item, fallback)
}

View File

@ -10,7 +10,8 @@ import { useAuth } from '#components/Auth/useAuth'
import { useItems, useUpdateItem, useAddItem } from '#components/Map/hooks/useItems'
import { useLayers } from '#components/Map/hooks/useLayers'
import { useHasUserPermission } from '#components/Map/hooks/usePermissions'
import { useAddTag, useGetItemTags, useTags } from '#components/Map/hooks/useTags'
import { useGetItemColor } from '#components/Map/hooks/useItemColor'
import { useAddTag, useTags } from '#components/Map/hooks/useTags'
import { MapOverlayPage } from '#components/Templates'
import { linkItem, onUpdateItem, unlinkItem } from './itemFunctions'
@ -64,7 +65,7 @@ export function ProfileForm() {
const addTag = useAddTag()
const navigate = useNavigate()
const hasUserPermission = useHasUserPermission()
const getItemTags = useGetItemTags()
const getItemColor = useGetItemColor()
const items = useItems()
const [urlParams, setUrlParams] = useState(new URLSearchParams(location.search))
@ -92,11 +93,7 @@ export function ProfileForm() {
useEffect(() => {
if (!item) return
const newColor =
item.color ??
(getItemTags(item) && getItemTags(item)[0]?.color
? getItemTags(item)[0].color
: item.layer?.markerDefaultColor)
const newColor = getItemColor(item, '')
const offers = (item.offers ?? []).reduce((acc: Tag[], o) => {
const offer = tags.find((t) => t.id === o.tags_id)
@ -216,8 +213,7 @@ export function ProfileForm() {
)}
type='submit'
style={{
// We could refactor this, it is used several times at different locations
backgroundColor: `${item.color ?? (getItemTags(item) && getItemTags(item)[0] && getItemTags(item)[0].color ? getItemTags(item)[0].color : item?.layer?.markerDefaultColor)}`,
backgroundColor: getItemColor(item),
color: '#fff',
}}
>

View File

@ -1,16 +1,13 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/prefer-optional-chain */
import LinkIcon from '@heroicons/react/24/outline/LinkIcon'
import PlusIcon from '@heroicons/react/24/outline/PlusIcon'
import { useState } from 'react'
import { TextInput } from '#components/Input'
import { useGetItemColor } from '#components/Map/hooks/useItemColor'
import { useItems } from '#components/Map/hooks/useItems'
import { useHasUserPermission } from '#components/Map/hooks/usePermissions'
import { useGetItemTags } from '#components/Map/hooks/useTags'
import { HeaderView } from '#components/Map/Subcomponents/ItemPopupComponents/HeaderView'
import DialogModal from '#components/Templates/DialogModal'
@ -36,7 +33,7 @@ export function ActionButton({
const hasUserPermission = useHasUserPermission()
const [modalOpen, setModalOpen] = useState<boolean>(false)
const [search, setSearch] = useState<string>('')
const getItemTags = useGetItemTags()
const getItemColor = useGetItemColor()
const items = useItems()
@ -45,11 +42,7 @@ export function ActionButton({
.filter((i) => !existingRelations.some((s) => s.id === i.id))
.filter((i) => i.id !== item.id)
const backgroundColor =
item.color ??
(getItemTags(item) && getItemTags(item)[0] && getItemTags(item)[0].color
? getItemTags(item)[0].color
: item.layer?.markerDefaultColor)
const backgroundColor = getItemColor(item)
return (
<>

View File

@ -1,15 +1,21 @@
import { mergeAttributes, Node } from '@tiptap/core'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import Suggestion from '@tiptap/suggestion'
import { decodeTag } from '#utils/FormatTags'
import type { Tag } from '#types/Tag'
import type { NodeViewProps } from '@tiptap/react'
import type { SuggestionOptions } from '@tiptap/suggestion'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnySuggestionOptions = Partial<SuggestionOptions<any>>
export interface HashtagOptions {
tags: Tag[]
onTagClick?: (tag: Tag) => void
HTMLAttributes: Record<string, unknown>
suggestion?: AnySuggestionOptions
}
declare module '@tiptap/core' {
@ -31,6 +37,7 @@ export const Hashtag = Node.create<HashtagOptions>({
tags: [],
onTagClick: undefined,
HTMLAttributes: {},
suggestion: undefined,
}
},
@ -83,17 +90,52 @@ export const Hashtag = Node.create<HashtagOptions>({
}
},
addStorage() {
return {
markdown: {
serialize(
state: { write: (text: string) => void },
node: { attrs: { label: string } },
) {
// Write as plain hashtag
state.write(`#${node.attrs.label}`)
},
parse: {
// Parsing is handled by preprocessHashtags
},
},
}
},
addNodeView() {
return ReactNodeViewRenderer(HashtagComponent)
},
addProseMirrorPlugins() {
// Only add suggestion plugin if suggestion options are provided
if (!this.options.suggestion) {
return []
}
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
]
},
})
function HashtagComponent({ node, extension }: NodeViewProps) {
function HashtagComponent({ node, extension, editor }: NodeViewProps) {
const options = extension.options as HashtagOptions
const tagName = node.attrs.label as string
const tag = options.tags.find((t) => t.name.toLowerCase() === tagName.toLowerCase())
const isEditable = editor.isEditable
const handleClick = (e: React.MouseEvent) => {
// Don't navigate when in edit mode
if (isEditable) return
e.preventDefault()
e.stopPropagation()
if (tag && options.onTagClick) {
@ -104,8 +146,11 @@ function HashtagComponent({ node, extension }: NodeViewProps) {
return (
<NodeViewWrapper as='span' className='hashtag-wrapper'>
<span
className='hashtag'
style={tag ? { color: tag.color, cursor: 'pointer' } : { cursor: 'default' }}
className='hashtag tw:font-bold'
style={{
color: tag?.color ?? 'inherit',
cursor: isEditable ? 'text' : 'pointer',
}}
onClick={handleClick}
>
{decodeTag(`#${tagName}`)}

View File

@ -0,0 +1,166 @@
import { mergeAttributes, Node } from '@tiptap/core'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import Suggestion from '@tiptap/suggestion'
import { useNavigate } from 'react-router-dom'
import type { Item } from '#types/Item'
import type { NodeViewProps } from '@tiptap/react'
import type { SuggestionOptions } from '@tiptap/suggestion'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnySuggestionOptions = Partial<SuggestionOptions<any>>
export interface ItemMentionOptions {
HTMLAttributes: Record<string, unknown>
suggestion?: AnySuggestionOptions
items?: Item[]
getItemColor?: (item: Item | undefined, fallback?: string) => string
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
itemMention: {
insertItemMention: (attributes: { id: string; label: string }) => ReturnType
}
}
}
export const ItemMention = Node.create<ItemMentionOptions>({
name: 'itemMention',
group: 'inline',
inline: true,
atom: true,
addOptions() {
return {
HTMLAttributes: {},
suggestion: undefined,
items: [],
getItemColor: undefined,
}
},
addAttributes() {
return {
id: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('data-id'),
renderHTML: (attributes: Record<string, unknown>) => {
if (!attributes.id) return {}
return { 'data-id': attributes.id }
},
},
label: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('data-label'),
renderHTML: (attributes: Record<string, unknown>) => {
if (!attributes.label) return {}
return { 'data-label': attributes.label }
},
},
}
},
parseHTML() {
return [{ tag: 'span[data-item-mention]' }]
},
renderHTML({ node, HTMLAttributes }) {
return [
'span',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-item-mention': '',
class: 'item-mention',
}),
`@${node.attrs.label as string}`,
]
},
addCommands() {
return {
insertItemMention:
(attributes) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: attributes,
})
},
}
},
addStorage() {
return {
markdown: {
serialize(
state: { write: (text: string) => void },
node: { attrs: { id: string; label: string } },
) {
// Write as markdown link: [@Label](/item/id)
const { id, label } = node.attrs
state.write(`[@${label}](/item/${id})`)
},
parse: {
// Parsing is handled by preprocessItemMentions
},
},
}
},
addNodeView() {
return ReactNodeViewRenderer(ItemMentionComponent)
},
addProseMirrorPlugins() {
// Only add suggestion plugin if suggestion options are provided
if (!this.options.suggestion) {
return []
}
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
]
},
})
function ItemMentionComponent({ node, editor, extension }: NodeViewProps) {
const navigate = useNavigate()
const options = extension.options as ItemMentionOptions
const label = node.attrs.label as string
const id = node.attrs.id as string
const isEditable = editor.isEditable
// Find the item to get its color
const item = options.items?.find((i) => i.id === id)
const color = options.getItemColor
? options.getItemColor(item, 'var(--color-primary)')
: item?.color ?? 'var(--color-primary)'
const handleClick = (e: React.MouseEvent) => {
// Don't navigate when in edit mode
if (isEditable) return
e.preventDefault()
e.stopPropagation()
// Navigate to /item/[uuid] - use absolute path to avoid double /item/item/
void navigate(`/item/${id}`, { replace: false })
}
return (
<NodeViewWrapper as='span' className='item-mention-wrapper'>
<span
className='item-mention tw:font-bold'
style={{
color,
cursor: isEditable ? 'text' : 'pointer',
}}
onClick={handleClick}
>
@{label}
</span>
</NodeViewWrapper>
)
}

View File

@ -1,2 +1,11 @@
export { Hashtag } from './Hashtag'
export { ItemMention } from './ItemMention'
export { VideoEmbed } from './VideoEmbed'
export {
createHashtagSuggestion,
createItemMentionSuggestion,
SuggestionList,
} from './suggestions'
export type { SuggestionListRef } from './suggestions'

View File

@ -0,0 +1,159 @@
import { PluginKey } from '@tiptap/pm/state'
import { ReactRenderer } from '@tiptap/react'
import tippy from 'tippy.js'
import { SuggestionList } from './SuggestionList'
import type { Instance as TippyInstance } from 'tippy.js'
import type { SuggestionOptions } from '@tiptap/suggestion'
import type { Tag } from '#types/Tag'
import type { SuggestionListRef } from './SuggestionList'
export const HashtagSuggestionPluginKey = new PluginKey('hashtagSuggestion')
type HashtagSuggestionItem = Tag | { isNew: true; name: string }
/**
* Creates a hashtag suggestion configuration for TipTap.
* Supports creating new tags if they don't exist.
*/
export function createHashtagSuggestion(
tags: Tag[],
addTag?: (tag: Tag) => void,
): Partial<SuggestionOptions<HashtagSuggestionItem>> {
return {
pluginKey: HashtagSuggestionPluginKey,
char: '#',
allowedPrefixes: null, // null = any prefix allowed (including start of line)
items: ({ query }): HashtagSuggestionItem[] => {
if (!query) {
return tags.slice(0, 8)
}
const filtered = tags
.filter((tag) => tag.name.toLowerCase().startsWith(query.toLowerCase()))
.slice(0, 7)
// Check if there's an exact match
const exactMatch = tags.some((tag) => tag.name.toLowerCase() === query.toLowerCase())
// If no exact match and addTag is provided, offer to create new tag
if (!exactMatch && addTag && query.length > 0) {
return [...filtered, { isNew: true, name: query }]
}
return filtered
},
render: () => {
let component: ReactRenderer<SuggestionListRef>
let popup: TippyInstance[]
let currentItems: HashtagSuggestionItem[] = []
let currentCommand: ((item: HashtagSuggestionItem) => void) | null = null
return {
onStart: (props) => {
currentItems = props.items
currentCommand = props.command
component = new ReactRenderer(SuggestionList, {
props: {
items: props.items,
command: props.command,
type: 'hashtag',
},
editor: props.editor,
})
if (!props.clientRect) return
popup = tippy('body', {
getReferenceClientRect: props.clientRect as () => DOMRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
})
},
onUpdate: (props) => {
currentItems = props.items
currentCommand = props.command
component.updateProps({
items: props.items,
command: props.command,
type: 'hashtag',
})
if (!props.clientRect) return
popup[0].setProps({
getReferenceClientRect: props.clientRect as () => DOMRect,
})
},
onKeyDown: (props) => {
if (props.event.key === 'Escape') {
popup[0].hide()
return true
}
// Space key triggers selection of the first item (or creates new tag)
if (props.event.key === ' ') {
const firstItem = currentItems[0]
if (firstItem && currentCommand) {
currentCommand(firstItem)
return true
}
}
return component.ref?.onKeyDown(props.event) ?? false
},
onExit: () => {
popup[0].destroy()
component.destroy()
},
}
},
command: ({ editor, range, props }) => {
let tagToInsert: Tag
// Create new tag if needed
if ('isNew' in props && props.isNew) {
const newTag: Tag = {
id: crypto.randomUUID(),
name: props.name,
color: '#888888', // Default color
}
if (addTag) {
addTag(newTag)
}
tagToInsert = newTag
} else {
tagToInsert = props as Tag
}
// Insert hashtag node and a space after it
// Using a single insertContent call with an array ensures atomic insertion
editor
.chain()
.focus()
.deleteRange(range)
.insertContent([
{
type: 'hashtag',
attrs: { id: tagToInsert.id, label: tagToInsert.name },
},
{
type: 'text',
text: ' ',
},
])
.run()
},
}
}

View File

@ -0,0 +1,119 @@
import { PluginKey } from '@tiptap/pm/state'
import { ReactRenderer } from '@tiptap/react'
import tippy from 'tippy.js'
import { SuggestionList } from './SuggestionList'
import type { Instance as TippyInstance } from 'tippy.js'
import type { SuggestionOptions } from '@tiptap/suggestion'
import type { Item } from '#types/Item'
import type { SuggestionListRef } from './SuggestionList'
export const ItemMentionSuggestionPluginKey = new PluginKey('itemMentionSuggestion')
/**
* Creates an item mention suggestion configuration for TipTap.
* Allows users to mention items with @ syntax.
*/
export function createItemMentionSuggestion(
items: Item[],
getItemColor?: (item: Item | undefined, fallback?: string) => string,
): Partial<SuggestionOptions<Item>> {
return {
pluginKey: ItemMentionSuggestionPluginKey,
char: '@',
allowedPrefixes: null, // null = any prefix allowed (including start of line)
items: ({ query }): Item[] => {
if (!query) {
return items.filter((item) => item.name).slice(0, 8)
}
return items
.filter((item) => item.name?.toLowerCase().includes(query.toLowerCase()))
.slice(0, 8)
},
render: () => {
let component: ReactRenderer<SuggestionListRef>
let popup: TippyInstance[]
return {
onStart: (props) => {
component = new ReactRenderer(SuggestionList, {
props: {
items: props.items,
command: props.command,
type: 'item',
getItemColor,
},
editor: props.editor,
})
if (!props.clientRect) return
popup = tippy('body', {
getReferenceClientRect: props.clientRect as () => DOMRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
})
},
onUpdate: (props) => {
component.updateProps({
items: props.items,
command: props.command,
type: 'item',
getItemColor,
})
if (!props.clientRect) return
popup[0].setProps({
getReferenceClientRect: props.clientRect as () => DOMRect,
})
},
onKeyDown: (props) => {
if (props.event.key === 'Escape') {
popup[0].hide()
return true
}
return component.ref?.onKeyDown(props.event) ?? false
},
onExit: () => {
popup[0].destroy()
component.destroy()
},
}
},
command: ({ editor, range, props }) => {
// Insert item mention and a space after it
// Using a single insertContent call with an array ensures atomic insertion
editor
.chain()
.focus()
.deleteRange(range)
.insertContent([
{
type: 'itemMention',
attrs: {
id: props.id,
label: props.name,
},
},
{
type: 'text',
text: ' ',
},
])
.run()
},
}
}

View File

@ -0,0 +1,100 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import type { Item } from '#types/Item'
import type { Tag } from '#types/Tag'
type SuggestionItem = Tag | Item | { isNew: true; name: string }
export interface SuggestionListRef {
onKeyDown: (event: KeyboardEvent) => boolean
}
interface SuggestionListProps {
items: SuggestionItem[]
command: (item: SuggestionItem) => void
type: 'hashtag' | 'item'
getItemColor?: (item: Item | undefined, fallback?: string) => string
}
export const SuggestionList = forwardRef<SuggestionListRef, SuggestionListProps>(
({ items, command, type, getItemColor }, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0)
useEffect(() => {
setSelectedIndex(0)
}, [items])
useImperativeHandle(ref, () => ({
onKeyDown: (event: KeyboardEvent) => {
if (event.key === 'ArrowUp') {
setSelectedIndex((prev) => (prev + items.length - 1) % items.length)
return true
}
if (event.key === 'ArrowDown') {
setSelectedIndex((prev) => (prev + 1) % items.length)
return true
}
if (event.key === 'Enter') {
const item = items[selectedIndex]
if (item) command(item)
return true
}
return false
},
}))
if (items.length === 0) {
return (
<div className='tw:dropdown-content tw:menu tw:bg-base-100 tw:rounded-box tw:shadow-lg tw:p-2 tw:z-50'>
<div className='tw:text-base-content/50 tw:px-2 tw:py-1 tw:text-sm'>Keine Ergebnisse</div>
</div>
)
}
return (
<div className='tw:dropdown-content tw:menu tw:bg-base-100 tw:rounded-box tw:shadow-lg tw:max-h-64 tw:overflow-y-auto tw:z-50 tw:p-1'>
{items.map((item, index) => {
const isNewTag = 'isNew' in item && item.isNew
const isTag = type === 'hashtag' && !isNewTag
const isItem = type === 'item'
const label = isNewTag ? item.name : 'name' in item ? item.name : ''
// Calculate color based on type
let color: string | undefined
if (isTag && 'color' in item) {
color = (item as Tag).color
} else if (isItem && getItemColor) {
color = getItemColor(item as Item)
}
const key = isNewTag ? `new-${item.name}` : 'id' in item ? item.id : `item-${index}`
return (
<button
key={key}
className={`tw:btn tw:btn-ghost tw:btn-sm tw:justify-start tw:font-bold ${
index === selectedIndex ? 'tw:bg-base-200' : ''
}`}
style={color ? { color } : undefined}
onClick={() => command(item)}
>
{isNewTag ? (
<span className='tw:flex tw:items-center tw:gap-1'>
<span className='tw:text-base-content/50'>Neu:</span>
<span>#{label}</span>
</span>
) : (
<>
{type === 'hashtag' ? '#' : '@'}
{label}
</>
)}
</button>
)
})}
</div>
)
},
)
SuggestionList.displayName = 'SuggestionList'

View File

@ -0,0 +1,5 @@
export { SuggestionList } from './SuggestionList'
export { createHashtagSuggestion } from './HashtagSuggestion'
export { createItemMentionSuggestion } from './ItemMentionSuggestion'
export type { SuggestionListRef } from './SuggestionList'

View File

@ -56,6 +56,9 @@ export function preprocessMarkdown(text: string): string {
// 5. Convert hashtags to hashtag tags
result = preprocessHashtags(result)
// 6. Convert item mentions to item-mention tags
result = preprocessItemMentions(result)
return result
}
@ -118,6 +121,33 @@ export function preprocessHashtags(text: string): string {
)
}
/**
* Converts [@Label](/item/id) to item-mention HTML tags.
* Supports multiple formats:
* - [@Label](/item/id) - absolute path
* - [@Label](item/id) - relative path (legacy)
* - [@Label](/item/layer/id) - with layer (legacy)
*/
export function preprocessItemMentions(text: string): string {
let result = text
// Format with layer: [@Label](/item/layer/id) or [@Label](item/layer/id)
// Use non-greedy matching for label to handle consecutive mentions
result = result.replace(
/\[@([^\]]+?)\]\(\/?item\/[^/]+\/([a-f0-9-]+)\)/g,
'<span data-item-mention data-label="$1" data-id="$2">@$1</span>',
)
// Format without layer: [@Label](/item/id) or [@Label](item/id)
// UUID pattern: hex characters with dashes
result = result.replace(
/\[@([^\]]+?)\]\(\/?item\/([a-f0-9-]+)\)/g,
'<span data-item-mention data-label="$1" data-id="$2">@$1</span>',
)
return result
}
/**
* Removes markdown syntax for plain text display (used for truncation calculation).
*/

View File

@ -23,6 +23,7 @@ export function simpleMarkdownToHtml(text: string, tags: Tag[]): string {
.replace(/&lt;video-embed/g, '<video-embed')
.replace(/&lt;\/video-embed&gt;/g, '</video-embed>')
.replace(/&lt;span data-hashtag/g, '<span data-hashtag')
.replace(/&lt;span data-item-mention/g, '<span data-item-mention')
.replace(/&lt;\/span&gt;/g, '</span>')
.replace(/&gt;&lt;/g, '><')
.replace(/"&gt;/g, '">')
@ -50,6 +51,14 @@ export function simpleMarkdownToHtml(text: string, tags: Tag[]): string {
},
)
// Convert item-mention spans to styled spans
html = html.replace(
/<span data-item-mention data-label="([^"]+)" data-id="([^"]+)">@([^<]+)<\/span>/g,
(_, label: string, id: string) => {
return `<a href="/item/${id}" class="item-mention" style="color: var(--color-primary, #3b82f6); cursor: pointer;">@${label}</a>`
},
)
// Bold: **text** or __text__
html = html.replace(/(\*\*|__)(.*?)\1/g, '<strong>$2</strong>')

49
package-lock.json generated
View File

@ -154,6 +154,7 @@
"@tiptap/pm": "^3.6.5",
"@tiptap/react": "^3.13.0",
"@tiptap/starter-kit": "^3.13.0",
"@tiptap/suggestion": "^3.15.3",
"axios": "^1.13.2",
"browser-image-compression": "^2.0.2",
"classnames": "^2.5.1",
@ -173,6 +174,7 @@
"react-qr-code": "^2.0.16",
"react-toastify": "^9.1.3",
"remark-breaks": "^4.0.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.9.0",
"yet-another-react-lightbox": "^3.28.0"
},
@ -3050,6 +3052,16 @@
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@react-leaflet/core": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
@ -4218,16 +4230,16 @@
}
},
"node_modules/@tiptap/core": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.13.0.tgz",
"integrity": "sha512-iUelgiTMgPVMpY5ZqASUpk8mC8HuR9FWKaDzK27w9oWip9tuB54Z8mePTxNcQaSPb6ErzEaC8x8egrRt7OsdGQ==",
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.15.3.tgz",
"integrity": "sha512-bmXydIHfm2rEtGju39FiQNfzkFx9CDvJe+xem1dgEZ2P6Dj7nQX9LnA1ZscW7TuzbBRkL5p3dwuBIi3f62A66A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^3.13.0"
"@tiptap/pm": "^3.15.3"
}
},
"node_modules/@tiptap/extension-blockquote": {
@ -4624,9 +4636,9 @@
}
},
"node_modules/@tiptap/pm": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.13.0.tgz",
"integrity": "sha512-WKR4ucALq+lwx0WJZW17CspeTpXorbIOpvKv5mulZica6QxqfMhn8n1IXCkDws/mCoLRx4Drk5d377tIjFNsvQ==",
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.15.3.tgz",
"integrity": "sha512-Zm1BaU1TwFi3CQiisxjgnzzIus+q40bBKWLqXf6WEaus8Z6+vo1MT2pU52dBCMIRaW9XNDq3E5cmGtMc1AlveA==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
@ -4716,6 +4728,20 @@
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/suggestion": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.15.3.tgz",
"integrity": "sha512-+CbaHhPfKUe+fNpUIQaOPhh6xI+xL5jbK1zw++U+CZIRrVAAmHRhO+D0O2HdiE1RK7596y8bRqMiB2CRHF7emA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.15.3",
"@tiptap/pm": "^3.15.3"
}
},
"node_modules/@trysound/sax": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
@ -15011,6 +15037,15 @@
"node": ">=14.0.0"
}
},
"node_modules/tippy.js": {
"version": "6.3.7",
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
"integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==",
"license": "MIT",
"dependencies": {
"@popperjs/core": "^2.9.0"
}
},
"node_modules/tiptap-markdown": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz",