mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2025-12-13 07:46:10 +00:00
basic mentions
This commit is contained in:
parent
f57db1de46
commit
d14a82f94d
32
lib/package-lock.json
generated
32
lib/package-lock.json
generated
@ -18,6 +18,7 @@
|
||||
"@tiptap/extension-image": "^2.14.0",
|
||||
"@tiptap/extension-link": "^2.14.0",
|
||||
"@tiptap/extension-list-item": "^2.14.0",
|
||||
"@tiptap/extension-mention": "^2.14.0",
|
||||
"@tiptap/extension-placeholder": "^2.14.0",
|
||||
"@tiptap/extension-table": "^2.14.0",
|
||||
"@tiptap/extension-table-cell": "^2.14.0",
|
||||
@ -29,6 +30,7 @@
|
||||
"@tiptap/pm": "^2.12.0",
|
||||
"@tiptap/react": "^2.12.0",
|
||||
"@tiptap/starter-kit": "^2.12.0",
|
||||
"@tiptap/suggestion": "^2.14.0",
|
||||
"axios": "^1.6.5",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"classnames": "^2.5.1",
|
||||
@ -55,6 +57,7 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remove-markdown": "^0.6.2",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
@ -2671,6 +2674,21 @@
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-mention": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-2.14.0.tgz",
|
||||
"integrity": "sha512-mmEv5rBOn9b90hcp0iQg/YWxJPgthfBD6Rp8FRbYauB7laiBUa7rhT5iuY9nj3UFUy8009lEZjc1gvtkC9B9ug==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0",
|
||||
"@tiptap/suggestion": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.12.0.tgz",
|
||||
@ -2929,6 +2947,20 @@
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/suggestion": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-2.14.0.tgz",
|
||||
"integrity": "sha512-AXzEw0KYIyg5id8gz5geIffnBtkZqan5MWe29rGo3gXTfKH+Ik8tWbZdnlMVheycsUCllrymDRei4zw9DqVqkQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@trysound/sax": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
|
||||
|
||||
@ -106,6 +106,7 @@
|
||||
"@tiptap/extension-image": "^2.14.0",
|
||||
"@tiptap/extension-link": "^2.14.0",
|
||||
"@tiptap/extension-list-item": "^2.14.0",
|
||||
"@tiptap/extension-mention": "^2.14.0",
|
||||
"@tiptap/extension-placeholder": "^2.14.0",
|
||||
"@tiptap/extension-table": "^2.14.0",
|
||||
"@tiptap/extension-table-cell": "^2.14.0",
|
||||
@ -117,6 +118,7 @@
|
||||
"@tiptap/pm": "^2.12.0",
|
||||
"@tiptap/react": "^2.12.0",
|
||||
"@tiptap/starter-kit": "^2.12.0",
|
||||
"@tiptap/suggestion": "^2.14.0",
|
||||
"axios": "^1.6.5",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"classnames": "^2.5.1",
|
||||
@ -143,6 +145,7 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remove-markdown": "^0.6.2",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
|
||||
77
lib/src/Components/Input/RichTextEditor/MentionList.tsx
Normal file
77
lib/src/Components/Input/RichTextEditor/MentionList.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { useState, useEffect, useImperativeHandle, forwardRef } from 'react'
|
||||
|
||||
export interface MentionListHandle {
|
||||
onKeyDown: (args: { event: KeyboardEvent }) => boolean
|
||||
}
|
||||
|
||||
interface MentionListProps {
|
||||
items: string[]
|
||||
command: (payload: { id: string }) => void
|
||||
}
|
||||
|
||||
export const MentionList = forwardRef<MentionListHandle, MentionListProps>(function MentionList(
|
||||
{ items, command },
|
||||
ref,
|
||||
) {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
const upHandler = () => {
|
||||
setSelectedIndex((prev) => (items.length > 0 ? (prev + items.length - 1) % items.length : 0))
|
||||
}
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((prev) => (items.length > 0 ? (prev + 1) % items.length : 0))
|
||||
}
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [items])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowUp':
|
||||
upHandler()
|
||||
return true
|
||||
case 'ArrowDown':
|
||||
downHandler()
|
||||
return true
|
||||
case 'Enter':
|
||||
enterHandler()
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className='dropdown-menu'>
|
||||
{items.length > 0 ? (
|
||||
items.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={index === selectedIndex ? 'is-selected' : ''}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className='item'>No result</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@ -4,6 +4,7 @@
|
||||
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'
|
||||
@ -12,18 +13,13 @@ 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,
|
||||
nodePasteRule,
|
||||
nodeInputRule,
|
||||
mergeAttributes,
|
||||
} from '@tiptap/react'
|
||||
import { EditorContent, 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 { TextEditorMenu } from './TextEditorMenu'
|
||||
|
||||
import type { Editor } from '@tiptap/react'
|
||||
@ -76,9 +72,13 @@ export function RichTextEditor({
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
Color.configure({ types: ['textStyle', 'listItem'] }),
|
||||
CustomYoutube.configure({
|
||||
Youtube.configure({
|
||||
nocookie: true,
|
||||
allowFullscreen: true,
|
||||
addPasteHandler: true,
|
||||
height: undefined,
|
||||
width: undefined,
|
||||
modestBranding: true,
|
||||
}),
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
@ -110,6 +110,12 @@ export function RichTextEditor({
|
||||
placeholder,
|
||||
emptyEditorClass: 'is-editor-empty',
|
||||
}),
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'mention',
|
||||
},
|
||||
suggestion,
|
||||
}),
|
||||
],
|
||||
content: defaultValue,
|
||||
onUpdate: handleChange,
|
||||
@ -194,98 +200,13 @@ export function getStyledMarkdown(editor: Editor): string {
|
||||
state.write(tag)
|
||||
}
|
||||
|
||||
const customYoutube: NodeSerializerFn = (state, node) => {
|
||||
const { src } = node.attrs as { src: string }
|
||||
|
||||
const match = src.match(
|
||||
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([A-Za-z0-9_-]{11})/,
|
||||
)
|
||||
const videoId = match?.[1]
|
||||
if (videoId) {
|
||||
const nocookieUrl = `https://www.youtube-nocookie.com/embed/${videoId}`
|
||||
|
||||
let tag = '<div class="tw:w-full tw:aspect-video tw:overflow-hidden">'
|
||||
tag += `<iframe src="${nocookieUrl}" allowfullscreen class="tw-w-full tw-h-full" loading="lazy"></iframe>`
|
||||
tag += '</div>'
|
||||
tag += '\n\n'
|
||||
state.write(tag)
|
||||
}
|
||||
}
|
||||
|
||||
const customSerializer = new MarkdownSerializer(
|
||||
{
|
||||
...baseNodes,
|
||||
image: customImage,
|
||||
youtube: customYoutube,
|
||||
},
|
||||
marks,
|
||||
)
|
||||
|
||||
return customSerializer.serialize(editor.state.doc)
|
||||
}
|
||||
|
||||
const CustomYoutube = Youtube.extend({
|
||||
addPasteRules() {
|
||||
return [
|
||||
nodePasteRule({
|
||||
find: youtubePasteRegex,
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
return { src: `https://www.youtube-nocookie.com/embed/${match[2]}` }
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
addInputRules() {
|
||||
return [
|
||||
nodeInputRule({
|
||||
find: youtubeInputRegex,
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
return { src: `https://www.youtube-nocookie.com/embed/${match[2]}` }
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'iframe[src*="/embed/"]',
|
||||
priority: 1000,
|
||||
getAttrs: (dom) => {
|
||||
const src = (dom as HTMLIFrameElement).getAttribute('src') ?? ''
|
||||
const match = src.match(/\/embed\/([A-Za-z0-9_-]{11})/)
|
||||
if (!match) {
|
||||
return false
|
||||
}
|
||||
const videoId = match[1]
|
||||
return {
|
||||
src: `https://www.youtube-nocookie.com/embed/${videoId}`,
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
// feste Breiten/Höhen raus
|
||||
const { ...attrs } = HTMLAttributes
|
||||
delete attrs.width
|
||||
delete attrs.height
|
||||
const iframeAttrs = mergeAttributes(attrs, {
|
||||
allowfullscreen: '',
|
||||
loading: 'lazy',
|
||||
class: 'tw-w-full tw-h-full',
|
||||
})
|
||||
|
||||
return [
|
||||
'div',
|
||||
{ class: 'tw:w-full tw-aspect-video tw-overflow-hidden' },
|
||||
['iframe', iframeAttrs],
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
const youtubePasteRegex =
|
||||
/(https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/))([A-Za-z0-9_-]{11})(?:\?.*)?/g
|
||||
const youtubeInputRegex =
|
||||
/(https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/))([A-Za-z0-9_-]{11})(?:\?.*)?$/
|
||||
97
lib/src/Components/Input/RichTextEditor/suggestion.ts
Normal file
97
lib/src/Components/Input/RichTextEditor/suggestion.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { ReactRenderer } from '@tiptap/react'
|
||||
import tippy from 'tippy.js'
|
||||
|
||||
import { MentionList } from './MentionList'
|
||||
|
||||
import type { MentionListHandle } from './MentionList'
|
||||
import type { SuggestionProps, SuggestionOptions, SuggestionKeyDownProps } from '@tiptap/suggestion'
|
||||
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[]
|
||||
|
||||
return {
|
||||
onStart: (props: SuggestionProps) => {
|
||||
component = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
})
|
||||
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: () =>
|
||||
props.clientRect ? (props.clientRect() ?? new DOMRect()) : new DOMRect(),
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
})
|
||||
},
|
||||
|
||||
onUpdate(props: SuggestionProps) {
|
||||
component.updateProps(props)
|
||||
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: () =>
|
||||
props.clientRect ? (props.clientRect() ?? new DOMRect()) : new DOMRect(),
|
||||
})
|
||||
},
|
||||
|
||||
onKeyDown(props: SuggestionKeyDownProps): boolean {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup[0].hide()
|
||||
return true
|
||||
}
|
||||
return (component.ref as MentionListHandle | undefined)?.onKeyDown(props) ?? false
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0].destroy()
|
||||
component.destroy()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { RichTextEditor } from '#components/Input/RichTextEditor'
|
||||
import { RichTextEditor } from '#components/Input/RichTextEditor/RichTextEditor'
|
||||
|
||||
import { MarkdownHint } from './MarkdownHint'
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { RichTextEditor } from '#components/Input/RichTextEditor'
|
||||
import { RichTextEditor } from '#components/Input/RichTextEditor/RichTextEditor'
|
||||
import { useUpdateItem } from '#components/Map/hooks/useItems'
|
||||
import { PopupStartEndInput, TextView } from '#components/Map/Subcomponents/ItemPopupComponents'
|
||||
import { ActionButton } from '#components/Profile/Subcomponents/ActionsButton'
|
||||
|
||||
@ -1,4 +1,52 @@
|
||||
/* Basic editor styles */
|
||||
.tiptap {
|
||||
:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.mention {
|
||||
background-color: var(--purple-light);
|
||||
border-radius: 0.4rem;
|
||||
box-decoration-break: clone;
|
||||
color: var(--purple);
|
||||
padding: 0.1rem 0.3rem;
|
||||
&::after {
|
||||
content: "\200B";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Dropdown menu */
|
||||
.dropdown-menu {
|
||||
background: var(--color-base-100);
|
||||
border: 1px solid var(--color-base-200);
|
||||
border-radius: 0.7rem;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
overflow: auto;
|
||||
padding: 0.4rem;
|
||||
position: relative;
|
||||
|
||||
button {
|
||||
align-items: center;
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
|
||||
&:hover,
|
||||
&:hover.is-selected {
|
||||
background-color: var(--color-base-200);
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
background-color: var(--color-base-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor-wrapper div {
|
||||
min-height: 0;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user