refactor(lib): tiptap markdown migration with mentions and video support#670
refactor(lib): tiptap markdown migration with mentions and video support#670antontranelis wants to merge 36 commits intomainfrom
Conversation
Replace react-markdown with TipTap read-only editor for TextView component. Changes: - Create TipTap extensions folder with custom Hashtag and VideoEmbed nodes - Hashtag extension: clickable hashtags with tag colors and filter integration - VideoEmbed extension: YouTube and Rumble iframe embeds - Add preprocessMarkdown utility for URL, email, video link, and hashtag conversion - Migrate TextView to use TipTap with StarterKit, Markdown, Link extensions - Remove unused itemId prop from TextView and all callers Known issue: Popup buttons may not work correctly when TextView has content due to Leaflet's handling of contenteditable elements. To be fixed in follow-up. Co-Authored-By: Claude Opus 4.5 <[email protected]>
Add TextViewStatic component for Leaflet popups and card previews: - Uses simple HTML rendering instead of TipTap EditorContent - No contenteditable attribute = no Leaflet click blocking issues - Better performance for rendering many items Changes: - Add TextViewStatic.tsx for popups/cards - Add simpleMarkdownToHtml.tsx utility for lightweight markdown conversion - Update ItemViewPopup to use TextViewStatic - Update ItemCard to use TextViewStatic TextView (full TipTap) remains for profile pages with all features. Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Export TextViewStatic from ItemPopupComponents and Item modules - Update MapContainer to use TextViewStatic instead of TextView for popups Co-Authored-By: Claude Opus 4.5 <[email protected]>
Support markdown autolinks <https://youtube.com/...> in addition to standard markdown links [text](url) for YouTube and Rumble videos. Co-Authored-By: Claude Opus 4.5 <[email protected]>
…ialization - Add ReactNodeViewRenderer for video preview in RichTextEditor - Add ProseMirror plugin for paste detection of video URLs - Add markdown serialization to output autolink format <url> - Integrate VideoEmbed extension in RichTextEditor - Preprocess video links when loading content Co-Authored-By: Claude Opus 4.5 <[email protected]>
- 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 <[email protected]>
- Fix import order issues - Fix TypeScript type annotations - Use .at() for array access to satisfy lint rules - Cast props to correct types in command handlers - Fix template literal type issues Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Replace deprecated frameBorder with style={{ border: 'none' }}
- Fix unsafe regex patterns to prevent ReDoS vulnerabilities
- Anchor patterns with ^ for exact matching
- Use fixed-length YouTube video IDs (11 chars)
- Add proper terminators for URL parsing
Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Pass items and getItemColor to simpleMarkdownToHtml - Item mentions now display with correct colors (item.color → tag color → layer default) - Add font-weight: bold to hashtags and item mentions for consistency Co-Authored-By: Claude Opus 4.5 <[email protected]>
UUIDs can contain both uppercase and lowercase hex characters (A-F, a-f). The regex was only matching lowercase, causing @mentions to not be converted to links in TextViewStatic. Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Replace community `tiptap-markdown` with official `@tiptap/markdown` - Use new API: `editor.getMarkdown()` instead of `editor.storage.markdown.getMarkdown()` - Add `contentType: 'markdown'` for direct markdown loading - Remove unused `@tiptap/extension-color` (no UI was using it) - Remove custom MarkdownStorage type declaration Co-Authored-By: Claude Opus 4.5 <[email protected]>
Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Remove old addStorage.markdown.serialize (community tiptap-markdown) - Keep only renderMarkdown (official @tiptap/markdown) - Update TextView.tsx to use @tiptap/markdown with contentType Co-Authored-By: Claude Opus 4.5 <[email protected]>
The @tiptap/markdown extension doesn't automatically parse custom markdown syntax like [@Label](/item/id). We need to preprocess the markdown before loading it into the editor to convert these patterns to HTML spans that the extensions' parseHTML handlers can recognize. - RichTextEditor: preprocess defaultValue before loading - TextView: preprocess innerText before loading Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Add convertNakedUrls() to skip URLs already inside markdown links - Rewrite truncateMarkdown() with token-aware truncation - Add @tiptap/markdown support to VideoEmbed, ItemMention, Hashtag - Fix double-conversion of URLs in existing links - Fix truncation cutting tokens in the middle - Fix eslint warnings with proper types Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Replace addStorage() with markdownTokenizer/parseMarkdown/renderMarkdown - Remove tiptap-markdown from rollup commonjs config - These changes fix serialization of hashtags and mentions to markdown
There was a problem hiding this comment.
Pull request overview
This PR migrates from react-markdown to TipTap editor for rich text rendering with markdown support via @tiptap/markdown. The migration adds three custom extensions (Hashtag, ItemMention, VideoEmbed) and introduces utility functions for markdown preprocessing, truncation, and static HTML rendering.
Changes:
- Replaced
react-markdownwith TipTap editor using@tiptap/markdownfor native markdown serialization - Created custom TipTap extensions for hashtags, video embeds, and item mentions with suggestion dropdowns
- Introduced
TextViewStaticcomponent for lightweight rendering in popups/cards without TipTap overhead - Refactored item color calculation into a reusable hook
useGetItemColor
Reviewed changes
Copilot reviewed 33 out of 34 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| package.json / package-lock.json | Removed tiptap-markdown, added @tiptap/markdown and @tiptap/suggestion, downgraded several dependencies |
| TextView.tsx | Migrated to TipTap editor with Markdown extension |
| TextViewStatic.tsx | New static renderer for popups using simpleMarkdownToHtml |
| preprocessMarkdown.ts | New utilities for markdown preprocessing, truncation, and token handling |
| simpleMarkdownToHtml.tsx | New lightweight HTML converter for static views |
| Hashtag.tsx / ItemMention.tsx / VideoEmbed.tsx | New TipTap extensions with markdown tokenizers |
| RichTextEditor.tsx | Updated to use new Markdown extension and suggestion system |
| useItemColor.tsx | New hook for centralized item color calculation |
| Various profile/map components | Updated to use TextViewStatic or removed unused itemId prop |
Comments suppressed due to low confidence (1)
lib/src/Components/TipTap/utils/preprocessMarkdown.ts:1
- The truncateMarkdown function is being called with the result of removeMarkdownSyntax(innerText), but looking at line 236, truncateMarkdown internally calls removeMarkdownSyntax again on line 237. This results in double processing: the markdown syntax is removed once before calling truncateMarkdown, then removed again inside it. The call should be
truncateMarkdown(innerText, 100)without the removeMarkdownSyntax wrapper.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| }, | ||
| }, | ||
| }, | ||
| [innerText, tags], |
There was a problem hiding this comment.
The dependency array for useEditor is incomplete. It includes innerText and tags but should also include items and getItemColor since these are used in the extension configuration. Missing these dependencies could cause stale closures where the extensions reference outdated values.
| [innerText, tags], | |
| [innerText, tags, items, getItemColor], |
| // Update content when text changes | ||
| useEffect(() => { | ||
| editor.commands.setContent(innerText, { contentType: 'markdown' }) | ||
| }, [editor, innerText]) | ||
|
|
There was a problem hiding this comment.
This useEffect updates the editor content on every innerText change, but since innerText is already provided in the useEditor initialization (line 80), this creates redundant updates. This could cause performance issues and potential race conditions. Consider removing this useEffect or only using it when the editor needs to be updated after initialization with different content.
| // Update content when text changes | |
| useEffect(() => { | |
| editor.commands.setContent(innerText, { contentType: 'markdown' }) | |
| }, [editor, innerText]) |
| const YOUTUBE_REGEX = /^https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})(?:&|$)/ | ||
| const YOUTUBE_SHORT_REGEX = /^https?:\/\/youtu\.be\/([a-zA-Z0-9_-]{11})(?:\?|$)/ |
There was a problem hiding this comment.
The YouTube video ID pattern [a-zA-Z0-9_-]{11} is hardcoded to exactly 11 characters, but YouTube video IDs can vary in length. While most are 11 characters, this rigid pattern could fail for edge cases. Consider using {10,12} or + for more flexibility.
| const YOUTUBE_REGEX = /^https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})(?:&|$)/ | |
| const YOUTUBE_SHORT_REGEX = /^https?:\/\/youtu\.be\/([a-zA-Z0-9_-]{11})(?:\?|$)/ | |
| const YOUTUBE_REGEX = /^https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_-]{10,12})(?:&|$)/ | |
| const YOUTUBE_SHORT_REGEX = /^https?:\/\/youtu\.be\/([a-zA-Z0-9_-]{10,12})(?:\?|$)/ |
| useEffect(() => { | ||
| if (editor.storage.markdown.getMarkdown() === '' || !editor.storage.markdown.getMarkdown()) { | ||
| editor.commands.setContent(defaultValue) | ||
| if (editor.getMarkdown() === '' || !editor.getMarkdown()) { | ||
| editor.commands.setContent(defaultValue, { contentType: 'markdown' }) | ||
| } | ||
| }, [defaultValue, editor]) |
There was a problem hiding this comment.
This useEffect has a missing dependency. The condition editor.getMarkdown() is called but editor is not guaranteed to be initialized when this effect runs. The code should check if editor exists before calling methods on it: if (editor && (editor.getMarkdown() === '' || !editor.getMarkdown())).
- Add missing dependencies to useEditor in TextView.tsx (items, getItemColor, addFilterTag) - Remove redundant useEffect that duplicated editor initialization - Update all TipTap packages to v3.15.3 for version consistency - Make YouTube video ID pattern more flexible (10-12 chars instead of exactly 11) Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Add strict regex patterns for tag restoration that only match exact expected format - Add sanitizeUrl() to block javascript:, data:, vbscript: URLs in markdown links - Add containsDangerousAttributes() to detect event handlers in restored content - Prevents onclick/onload injection via malformed preprocessed tags
…ntion - preprocessMarkdown.spec.ts: 76 tests covering URL conversion, video links, hashtags, item mentions, markdown removal, and truncation - simpleMarkdownToHtml.spec.ts: 21 tests for HTML conversion and formatting - xss.spec.ts: 22 security tests covering script injection, event handlers, javascript URLs, tag restoration bypass, and attribute injection
…ent below" This reverts commit df62f39.
- Add null checks for editor in RichTextEditor (handleChange, useEffect) - Extract video URL patterns to shared videoPatterns.ts module - Refactor VideoEmbed.tsx and preprocessMarkdown.ts to use shared patterns - Add helper functions: getVideoEmbedUrl, getVideoCanonicalUrl, parseVideoUrl - Remove unused markdownToTiptapJson function Co-Authored-By: Claude Opus 4.5 <[email protected]>
…wStatic Reorder processing steps: truncate raw markdown first, then preprocess. Previously, preprocessMarkdown converted mentions/hashtags to HTML tags before truncation, but truncateMarkdown only recognizes markdown syntax ([@Label](/item/id)), not HTML tags (<span data-item-mention...>), causing tags to be cut in half. Co-Authored-By: Claude Opus 4.5 <[email protected]>
This reverts commit 64b6e60.
- Add TextViewStatic.spec.tsx (16 tests): static HTML renderer tests - Add TextView.spec.tsx (10 tests): TipTap read-only viewer tests - Add RichTextEditor.spec.tsx (9 tests): editable editor tests - Update setupTest.ts with TipTap DOM mocks (Range, Document APIs) Tests cover rendering, truncation, hashtag/mention styling and clicks, link navigation, and video embed rendering.
- Add HashtagSuggestion.cy.tsx (16 tests): popup trigger, filtering, keyboard/click selection, new tag creation, escape to close - Add ItemMentionSuggestion.cy.tsx (16 tests): popup trigger, filtering, keyboard/click selection, markdown serialization, edge cases - Add SuggestionList.cy.tsx (14 tests): rendering, keyboard navigation, click selection, empty state - Update TestEditor.tsx to support enableSuggestions prop for testing These tests validate user-facing autocomplete behaviors for # and @ mentions in the rich text editor.
|
@antontranelis |
|
augment review |
🤖 Augment PR SummarySummary: This PR migrates markdown rendering/editing from Changes:
Technical Notes: Static rendering uses 🤖 Was this summary useful? React with 👍 or 👎 |
| * Sanitizes a URL for safe use in href attributes. | ||
| * Returns '#' for dangerous URLs like javascript:, data:, vbscript: | ||
| */ | ||
| function sanitizeUrl(url: string): string { |
There was a problem hiding this comment.
sanitizeUrl() only blocks literal javascript:/data: prefixes, but entity-encoded schemes (e.g. javascript:alert(1)) can still become javascript: once the browser decodes the attribute, which is risky given dangerouslySetInnerHTML. This looks like an XSS bypass path for markdown links that should be explicitly handled.
Severity: high
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
| } | ||
| const editor = useEditor( | ||
| { | ||
| extensions: [ |
There was a problem hiding this comment.
TextView relies on finding <a> tags for navigation and even notes external links are handled by the Link extension, but no Link extension is registered in the extensions list. That can result in markdown links rendering as plain text and the click handler never firing.
Severity: medium
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
| name = 'places', | ||
| menuText = 'add new place', | ||
| menuColor = '#2E7D32', | ||
| name, |
There was a problem hiding this comment.
Defaults for name/menuText/menuColor/markerDefaultColor were removed, so any call site omitting them will now propagate undefined into LayerContext and stored layer data. This is a behavior change that could regress existing usage (including examples) if those props weren’t always provided.
Severity: medium
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
| useImperativeHandle(ref, () => ({ | ||
| onKeyDown: (event: KeyboardEvent) => { | ||
| if (event.key === 'ArrowUp') { | ||
| setSelectedIndex((prev) => (prev + items.length - 1) % items.length) |
There was a problem hiding this comment.
onKeyDown uses modulo arithmetic with items.length, but when items is empty this becomes modulo-by-zero and can set selectedIndex to NaN. Since TipTap can still route key events while the empty-state UI is shown, this can lead to inconsistent selection behavior.
Severity: medium
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
| }, | ||
|
|
||
| onExit: () => { | ||
| popup[0].destroy() |
There was a problem hiding this comment.
popup is only initialized when props.clientRect exists, but onUpdate/onKeyDown/onExit unconditionally access popup[0]; if clientRect is missing this will throw. The same risk exists in the item mention suggestion renderer.
Severity: medium
Other Locations
lib/src/Components/TipTap/extensions/suggestions/ItemMentionSuggestion.tsx:76lib/src/Components/TipTap/extensions/suggestions/ItemMentionSuggestion.tsx:83lib/src/Components/TipTap/extensions/suggestions/ItemMentionSuggestion.tsx:90
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
| cy.get('.tippy-content button').should('have.length', 5) | ||
| }) | ||
|
|
||
| it('does not show popup when # is inside a word', () => { |
There was a problem hiding this comment.
This test name says the popup should not show when # is inside a word, but the assertion expects .tippy-content to be visible. That mismatch can mask the intended behavior and make failures harder to interpret.
Severity: low
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
| }) | ||
|
|
||
| describe('Content Editing', () => { | ||
| it('calls updateFormValue when content changes', async () => { |
There was a problem hiding this comment.
This test triggers an input event but never asserts that updateFormValue was called, so it can pass even if the editor stops emitting updates. It may be worth asserting the callback invocation (or resulting markdown) to validate the behavior.
Severity: low
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
…Tap editor commands, add missing assertions, fix ESLint errors
Summary
This PR migrates from
react-markdownto TipTap for rich text editing and viewing, with full markdown support via@tiptap/markdown.Key Changes
TipTap Migration:
react-markdownwith TipTap editor in TextView component@tiptap/markdownfor native markdown serializationTextViewStaticcomponent for lightweight popup/card previewsCustom Extensions:
#tag) - Clickable tags with colors, filters map on click<url>autolink syntax@mention) - Links to items with[@Label](/item/id)syntaxUtilities:
preprocessMarkdown- Converts naked URLs, emails, videos, hashtags to proper syntaxconvertNakedUrls- Smart URL conversion that respects existing linkstruncateMarkdown- Token-aware truncation preserving @mentions, #hashtags, and linkssimpleMarkdownToHtml- Lightweight HTML conversion for static viewsBug Fixes
Test plan
🤖 Generated with Claude Code