From 051338246d57343f658d6bda53af333886515ac1 Mon Sep 17 00:00:00 2001 From: Anton Tranelis Date: Tue, 17 Jun 2025 00:23:24 +0200 Subject: [PATCH] basic hashtag autocompletion --- lib/package-lock.json | 8 +- lib/package.json | 2 + .../Extensions/CustomHeading.ts | 17 ++++ .../RichTextEditor/Extensions/CustomImage.ts | 29 +++++++ .../RichTextEditor/Extensions/Hashtag.tsx | 29 +++++++ .../Extensions/HashtagMention.ts | 11 +++ .../{ => Extensions}/MentionList.tsx | 1 - .../{ => Extensions}/suggestion.ts | 32 -------- .../Input/RichTextEditor/RichTextEditor.tsx | 78 +++++++++---------- .../ItemPopupComponents/TextView.tsx | 8 -- lib/src/Components/Profile/ProfileView.tsx | 2 +- lib/src/assets/css/tiptap.css | 21 +++-- 12 files changed, 146 insertions(+), 92 deletions(-) create mode 100644 lib/src/Components/Input/RichTextEditor/Extensions/CustomHeading.ts create mode 100644 lib/src/Components/Input/RichTextEditor/Extensions/CustomImage.ts create mode 100644 lib/src/Components/Input/RichTextEditor/Extensions/Hashtag.tsx create mode 100644 lib/src/Components/Input/RichTextEditor/Extensions/HashtagMention.ts rename lib/src/Components/Input/RichTextEditor/{ => Extensions}/MentionList.tsx (96%) rename lib/src/Components/Input/RichTextEditor/{ => Extensions}/suggestion.ts (70%) diff --git a/lib/package-lock.json b/lib/package-lock.json index a184dff8..ece02442 100644 --- a/lib/package-lock.json +++ b/lib/package-lock.json @@ -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", diff --git a/lib/package.json b/lib/package.json index 3e22515f..c23126f9 100644 --- a/lib/package.json +++ b/lib/package.json @@ -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", diff --git a/lib/src/Components/Input/RichTextEditor/Extensions/CustomHeading.ts b/lib/src/Components/Input/RichTextEditor/Extensions/CustomHeading.ts new file mode 100644 index 00000000..2f0fdb0f --- /dev/null +++ b/lib/src/Components/Input/RichTextEditor/Extensions/CustomHeading.ts @@ -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, + }, + }) + }) + }, +}) diff --git a/lib/src/Components/Input/RichTextEditor/Extensions/CustomImage.ts b/lib/src/Components/Input/RichTextEditor/Extensions/CustomImage.ts new file mode 100644 index 00000000..0dbc0cfd --- /dev/null +++ b/lib/src/Components/Input/RichTextEditor/Extensions/CustomImage.ts @@ -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 } : {}), + }, + } + }, +}) diff --git a/lib/src/Components/Input/RichTextEditor/Extensions/Hashtag.tsx b/lib/src/Components/Input/RichTextEditor/Extensions/Hashtag.tsx new file mode 100644 index 00000000..2601cad7 --- /dev/null +++ b/lib/src/Components/Input/RichTextEditor/Extensions/Hashtag.tsx @@ -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 ( + + { + e.stopPropagation() + tag && addFilterTag(tag) + }} + > + #{tag?.name} + + + ) +} diff --git a/lib/src/Components/Input/RichTextEditor/Extensions/HashtagMention.ts b/lib/src/Components/Input/RichTextEditor/Extensions/HashtagMention.ts new file mode 100644 index 00000000..4bca554e --- /dev/null +++ b/lib/src/Components/Input/RichTextEditor/Extensions/HashtagMention.ts @@ -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) + }, +}) diff --git a/lib/src/Components/Input/RichTextEditor/MentionList.tsx b/lib/src/Components/Input/RichTextEditor/Extensions/MentionList.tsx similarity index 96% rename from lib/src/Components/Input/RichTextEditor/MentionList.tsx rename to lib/src/Components/Input/RichTextEditor/Extensions/MentionList.tsx index 80bb55c0..1329eb75 100644 --- a/lib/src/Components/Input/RichTextEditor/MentionList.tsx +++ b/lib/src/Components/Input/RichTextEditor/Extensions/MentionList.tsx @@ -16,7 +16,6 @@ export const MentionList = forwardRef(funct const [selectedIndex, setSelectedIndex] = useState(0) const selectItem = (index: number) => { - // eslint-disable-next-line security/detect-object-injection const item = items[index] if (item) { command({ id: item }) diff --git a/lib/src/Components/Input/RichTextEditor/suggestion.ts b/lib/src/Components/Input/RichTextEditor/Extensions/suggestion.ts similarity index 70% rename from lib/src/Components/Input/RichTextEditor/suggestion.ts rename to lib/src/Components/Input/RichTextEditor/Extensions/suggestion.ts index 2ef7d05a..bfcf26b2 100644 --- a/lib/src/Components/Input/RichTextEditor/suggestion.ts +++ b/lib/src/Components/Input/RichTextEditor/Extensions/suggestion.ts @@ -8,38 +8,6 @@ import type { SuggestionProps, SuggestionOptions, SuggestionKeyDownProps } from import type { Instance as TippyInstance } from 'tippy.js' export const suggestion: Partial = { - 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 let popup: TippyInstance[] diff --git a/lib/src/Components/Input/RichTextEditor/RichTextEditor.tsx b/lib/src/Components/Input/RichTextEditor/RichTextEditor.tsx index 97cb720b..6346a02d 100644 --- a/lib/src/Components/Input/RichTextEditor/RichTextEditor.tsx +++ b/lib/src/Components/Input/RichTextEditor/RichTextEditor.tsx @@ -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 diff --git a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextView.tsx b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextView.tsx index a1b24eb5..3788b651 100644 --- a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextView.tsx +++ b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextView.tsx @@ -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', diff --git a/lib/src/Components/Profile/ProfileView.tsx b/lib/src/Components/Profile/ProfileView.tsx index 498f0347..199972f6 100644 --- a/lib/src/Components/Profile/ProfileView.tsx +++ b/lib/src/Components/Profile/ProfileView.tsx @@ -174,7 +174,7 @@ export function ProfileView({ attestationApi }: { attestationApi?: ItemsApi {item && ( <>
diff --git a/lib/src/assets/css/tiptap.css b/lib/src/assets/css/tiptap.css index 02c95455..a407a311 100644 --- a/lib/src/assets/css/tiptap.css +++ b/lib/src/assets/css/tiptap.css @@ -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); } } }