From c644f182e4c32f18d50a65e81c9608bb909ec1c0 Mon Sep 17 00:00:00 2001 From: Anton Tranelis Date: Wed, 14 Jan 2026 15:31:17 +0100 Subject: [PATCH] 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 --- lib/package.json | 2 + lib/src/Components/Input/RichTextEditor.tsx | 35 +++- .../Subcomponents/Controls/SearchControl.tsx | 18 +- .../HeaderView/ConnectionStatus.tsx | 8 +- .../ItemPopupComponents/PopupButton.tsx | 16 +- .../ItemPopupComponents/TextView.tsx | 20 +-- lib/src/Components/Map/hooks/useItemColor.tsx | 42 +++++ lib/src/Components/Profile/ProfileForm.tsx | 14 +- .../Profile/Subcomponents/ActionsButton.tsx | 13 +- .../Components/TipTap/extensions/Hashtag.tsx | 51 +++++- .../TipTap/extensions/ItemMention.tsx | 166 ++++++++++++++++++ lib/src/Components/TipTap/extensions/index.ts | 9 + .../suggestions/HashtagSuggestion.tsx | 159 +++++++++++++++++ .../suggestions/ItemMentionSuggestion.tsx | 119 +++++++++++++ .../extensions/suggestions/SuggestionList.tsx | 100 +++++++++++ .../TipTap/extensions/suggestions/index.ts | 5 + .../TipTap/utils/preprocessMarkdown.ts | 30 ++++ .../TipTap/utils/simpleMarkdownToHtml.tsx | 9 + package-lock.json | 49 +++++- 19 files changed, 791 insertions(+), 74 deletions(-) create mode 100644 lib/src/Components/Map/hooks/useItemColor.tsx create mode 100644 lib/src/Components/TipTap/extensions/ItemMention.tsx create mode 100644 lib/src/Components/TipTap/extensions/suggestions/HashtagSuggestion.tsx create mode 100644 lib/src/Components/TipTap/extensions/suggestions/ItemMentionSuggestion.tsx create mode 100644 lib/src/Components/TipTap/extensions/suggestions/SuggestionList.tsx create mode 100644 lib/src/Components/TipTap/extensions/suggestions/index.ts diff --git a/lib/package.json b/lib/package.json index b57b78f9..ca7ad432 100644 --- a/lib/package.json +++ b/lib/package.json @@ -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" }, diff --git a/lib/src/Components/Input/RichTextEditor.tsx b/lib/src/Components/Input/RichTextEditor.tsx index c2dba635..fcf68233 100644 --- a/lib/src/Components/Input/RichTextEditor.tsx +++ b/lib/src/Components/Input/RichTextEditor.tsx @@ -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]) diff --git a/lib/src/Components/Map/Subcomponents/Controls/SearchControl.tsx b/lib/src/Components/Map/Subcomponents/Controls/SearchControl.tsx index f7370e84..bfcd40db 100644 --- a/lib/src/Components/Map/Subcomponents/Controls/SearchControl.tsx +++ b/lib/src/Components/Map/Subcomponents/Controls/SearchControl.tsx @@ -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 = () => {
)} {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 (
✅ Connected

} - const tags = getItemTags(item) return ( + ) + })} +
+ ) + }, +) + +SuggestionList.displayName = 'SuggestionList' diff --git a/lib/src/Components/TipTap/extensions/suggestions/index.ts b/lib/src/Components/TipTap/extensions/suggestions/index.ts new file mode 100644 index 00000000..50cb2a7f --- /dev/null +++ b/lib/src/Components/TipTap/extensions/suggestions/index.ts @@ -0,0 +1,5 @@ +export { SuggestionList } from './SuggestionList' +export { createHashtagSuggestion } from './HashtagSuggestion' +export { createItemMentionSuggestion } from './ItemMentionSuggestion' + +export type { SuggestionListRef } from './SuggestionList' diff --git a/lib/src/Components/TipTap/utils/preprocessMarkdown.ts b/lib/src/Components/TipTap/utils/preprocessMarkdown.ts index 2a87bee2..cb3c81b1 100644 --- a/lib/src/Components/TipTap/utils/preprocessMarkdown.ts +++ b/lib/src/Components/TipTap/utils/preprocessMarkdown.ts @@ -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, + '@$1', + ) + + // Format without layer: [@Label](/item/id) or [@Label](item/id) + // UUID pattern: hex characters with dashes + result = result.replace( + /\[@([^\]]+?)\]\(\/?item\/([a-f0-9-]+)\)/g, + '@$1', + ) + + return result +} + /** * Removes markdown syntax for plain text display (used for truncation calculation). */ diff --git a/lib/src/Components/TipTap/utils/simpleMarkdownToHtml.tsx b/lib/src/Components/TipTap/utils/simpleMarkdownToHtml.tsx index 00ce7a55..b6fe5c2c 100644 --- a/lib/src/Components/TipTap/utils/simpleMarkdownToHtml.tsx +++ b/lib/src/Components/TipTap/utils/simpleMarkdownToHtml.tsx @@ -23,6 +23,7 @@ export function simpleMarkdownToHtml(text: string, tags: Tag[]): string { .replace(/<video-embed/g, '') .replace(/<span data-hashtag/g, '') .replace(/></g, '><') .replace(/">/g, '">') @@ -50,6 +51,14 @@ export function simpleMarkdownToHtml(text: string, tags: Tag[]): string { }, ) + // Convert item-mention spans to styled spans + html = html.replace( + /@([^<]+)<\/span>/g, + (_, label: string, id: string) => { + return `@${label}` + }, + ) + // Bold: **text** or __text__ html = html.replace(/(\*\*|__)(.*?)\1/g, '$2') diff --git a/package-lock.json b/package-lock.json index 15e7c5a1..5f7f4670 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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",