mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2026-02-06 09:55:47 +00:00
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:
parent
382f284f31
commit
c644f182e4
@ -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"
|
||||
},
|
||||
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'}
|
||||
|
||||
@ -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'
|
||||
>
|
||||
|
||||
@ -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,
|
||||
|
||||
42
lib/src/Components/Map/hooks/useItemColor.tsx
Normal file
42
lib/src/Components/Map/hooks/useItemColor.tsx
Normal 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)
|
||||
}
|
||||
@ -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',
|
||||
}}
|
||||
>
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
|
||||
@ -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}`)}
|
||||
|
||||
166
lib/src/Components/TipTap/extensions/ItemMention.tsx
Normal file
166
lib/src/Components/TipTap/extensions/ItemMention.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
@ -0,0 +1,5 @@
|
||||
export { SuggestionList } from './SuggestionList'
|
||||
export { createHashtagSuggestion } from './HashtagSuggestion'
|
||||
export { createItemMentionSuggestion } from './ItemMentionSuggestion'
|
||||
|
||||
export type { SuggestionListRef } from './SuggestionList'
|
||||
@ -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).
|
||||
*/
|
||||
|
||||
@ -23,6 +23,7 @@ export function simpleMarkdownToHtml(text: string, tags: Tag[]): string {
|
||||
.replace(/<video-embed/g, '<video-embed')
|
||||
.replace(/<\/video-embed>/g, '</video-embed>')
|
||||
.replace(/<span data-hashtag/g, '<span data-hashtag')
|
||||
.replace(/<span data-item-mention/g, '<span data-item-mention')
|
||||
.replace(/<\/span>/g, '</span>')
|
||||
.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 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
49
package-lock.json
generated
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user