mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2026-01-20 20:01:18 +00:00
basic hashtag autocompletion
This commit is contained in:
parent
d14a82f94d
commit
051338246d
8
lib/package-lock.json
generated
8
lib/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
@ -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 } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
},
|
||||
})
|
||||
@ -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 })
|
||||
@ -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[]
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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'}>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user