feat: emoji support in document text editor#62
Conversation
|
@Praashh is attempting to deploy a commit to the william Team on Vercel. A member of the Team first needs to authorize it. |
|
Important Review skippedReview was skipped due to path filters ⛔ Files ignored due to path filters (1)
CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including You can disable this status message by setting the WalkthroughAdds a ProseMirror emoji plugin with colon-triggered suggestions and inline emoji rendering; registers the plugin; introduces a React EmojiOverlay component and related styles (duplicated CSS present); updates package.json dependencies; minor synonym-overlay styling changes. Changes
Sequence Diagram(s)sequenceDiagram
participant U as User
participant TE as TextEditor (ProseMirror)
participant EP as EmojiPlugin
participant OL as EmojiOverlay (React)
participant WIN as window
U->>TE: Type ":" or ":name"
TE->>EP: Transaction processed -> detect colon/token
EP->>OL: showSuggestions(query, coords) -> mount/render overlay
U->>OL: Navigate/select (keys/click)
OL->>WIN: call window.insertEmojiSuggestion(code)
WIN->>EP: insertEmojiAtCursor(code)
EP->>TE: Replace token with emoji decoration and refocus
TE->>U: Emoji rendered inline
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~30 minutes Assessment against linked issues
Assessment against linked issues: Out-of-scope changes
Possibly related PRs
Poem
✨ Finishing Touches🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Actionable comments posted: 9
🧹 Nitpick comments (1)
apps/snow-leopard/lib/editor/emoji-plugin.ts (1)
96-115: NIT: Inline styles on the panel should be minimal; rely on CSS classes defined in text-editor.tsxMost of these can be expressed via the .emoji-suggestion-panel styles you added. This reduces JS churn and keeps visual changes in one place.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
apps/snow-leopard/components/document/text-editor.tsx(1 hunks)apps/snow-leopard/lib/editor/editor-plugins.ts(2 hunks)apps/snow-leopard/lib/editor/emoji-plugin.ts(1 hunks)apps/snow-leopard/package.json(4 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (3)
apps/snow-leopard/lib/editor/editor-plugins.ts (1)
apps/snow-leopard/lib/editor/emoji-plugin.ts (1)
emojiPlugin(30-590)
apps/snow-leopard/components/document/text-editor.tsx (1)
apps/snow-leopard/lib/editor/placeholder-plugin.ts (1)
view(30-59)
apps/snow-leopard/package.json (1)
apps/snow-leopard/lib/editor/diff.js (2)
ch(244-244)node(367-388)
🪛 ast-grep (0.38.6)
apps/snow-leopard/lib/editor/emoji-plugin.ts
[warning] 241-294: Direct modification of innerHTML or outerHTML properties detected. Modifying these properties with unsanitized user input can lead to XSS vulnerabilities. Use safe alternatives or sanitize content first.
Context: pluginState.suggestionElement.innerHTML = <div class="emoji-suggestion-header" style=" color: rgb(255 255 255); font-size: 12px; margin-bottom: 8px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; ">EMOJI MATCHING ${pluginState.currentQuery.toUpperCase()}</div> <div class="emoji-suggestion-shortcuts" style=" color: rgb(156 163 175); font-size: 10px; margin-bottom: 8px; text-align: center; font-family: monospace; ">↑↓←→ Navigate • Enter/Space Select • Esc Close • Tab Next</div> <div class="emoji-suggestion-list" style=" display: flex; gap: 8px; flex-wrap: nowrap; overflow-x: auto; "> ${pluginState.suggestions.map((suggestion: EmojiSuggestion, index: number) =>
${suggestion.emoji}
${suggestion.code}
).join('')} </div> Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://owasp.org/www-community/xss-filter-evasion-cheatsheet
- https://cwe.mitre.org/data/definitions/79.html
(dom-content-modification)
[warning] 241-294: Direct HTML content assignment detected. Modifying innerHTML, outerHTML, or using document.write with unsanitized content can lead to XSS vulnerabilities. Use secure alternatives like textContent or sanitize HTML with libraries like DOMPurify.
Context: pluginState.suggestionElement.innerHTML = <div class="emoji-suggestion-header" style=" color: rgb(255 255 255); font-size: 12px; margin-bottom: 8px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; ">EMOJI MATCHING ${pluginState.currentQuery.toUpperCase()}</div> <div class="emoji-suggestion-shortcuts" style=" color: rgb(156 163 175); font-size: 10px; margin-bottom: 8px; text-align: center; font-family: monospace; ">↑↓←→ Navigate • Enter/Space Select • Esc Close • Tab Next</div> <div class="emoji-suggestion-list" style=" display: flex; gap: 8px; flex-wrap: nowrap; overflow-x: auto; "> ${pluginState.suggestions.map((suggestion: EmojiSuggestion, index: number) =>
${suggestion.emoji}
${suggestion.code}
).join('')} </div> Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://www.dhairyashah.dev/posts/why-innerhtml-is-a-bad-idea-and-how-to-avoid-it/
- https://cwe.mitre.org/data/definitions/79.html
(unsafe-html-content-assignment)
🔇 Additional comments (4)
apps/snow-leopard/components/document/text-editor.tsx (1)
389-457: Centralize emoji suggestion panel styles — unable to locate inline-style source (please verify)Short: The CSS in apps/snow-leopard/components/document/text-editor.tsx (emoji rules at ~389–457) defines global classes for the emoji UI, but I couldn't find the reported plugin file that adds inline styles — ripgrep returned only node_modules/pnpm-lock entries for “emoji”. Maintaining both inline styles and these classes will cause style drift; prefer a single source (global classes or Tailwind utilities) and remove inline styles from the plugin.
Please check these locations:
- apps/snow-leopard/components/document/text-editor.tsx — lines ~389–457: .emoji-widget, .emoji-hidden, .ProseMirror font-family, .emoji-suggestion-panel and its children (the global styles to keep).
- No repo file named emoji-plugin.ts was found by search; matches were only in node_modules / pnpm-lock files. If the plugin is present under a different path or generated at runtime, point me to it.
Actionable suggestions (pick one):
- If emoji-plugin.ts is in this repo: remove its inline style object and apply the classes defined in text-editor.tsx (or convert to Tailwind).
- If the plugin is third‑party: either scope/override styling via these classes (add !important only as last resort) or wrap the suggestion panel in a class and apply the global rules there.
- Keep theming (dark/light) by using CSS variables or theme-specific class overrides instead of duplicating inline values.
Would you like me to prepare a patch that removes inline styles (if you provide the plugin file path) and reuses the classes above?
apps/snow-leopard/package.json (2)
60-63: Confirm RC/canary compatibility (Next 15 canary + React 19 RC).Using next 15.2.2-canary.1 with react/react-dom 19 RC is fine if the app has been validated against breaking changes and the rest of the toolchain supports them. Please confirm CI/e2e coverage and that ProseMirror editor behavior remains stable (focus, input events, IME).
If instability is observed, consider pinning to stable versions and upgrading in a dedicated PR.
62-62: node-emoji dependency is appropriate for the pluginnode-emoji fits the use case and is lightweight. Ensure tree-shaking is effective and there’s no SSR/bundling quirk with CJS interop in your Next setup. The current import style in the plugin (
import * as emoji from 'node-emoji') is correct for CJS default export behavior without esModuleInterop.apps/snow-leopard/lib/editor/editor-plugins.ts (1)
38-39: LGTM: emoji plugin registration is correctly placedemojiPlugin() is added after formatPlugin and before savePlugin, which is a sensible order for UI interaction and doc mutations.
| (window as any).insertEmojiSuggestion = (emojiCode: string): void => { | ||
| // console.log('Emoji plugin: Inserting emoji:', emojiCode); | ||
|
|
||
| if (pluginState.suggestionElement) { | ||
| hideSuggestions(); | ||
| } | ||
|
|
||
| const success = insertEmojiAtCursor(emojiCode); | ||
|
|
||
| if (!success) { | ||
| console.warn('Emoji plugin: Failed to insert emoji, could not find colon'); | ||
| } | ||
|
|
||
| setTimeout(() => { | ||
| if (pluginState.editorView && pluginState.editorView.dom) { | ||
| pluginState.editorView.focus(); | ||
| } | ||
| }, 0); | ||
| }; |
There was a problem hiding this comment.
🛠️ Refactor suggestion
Avoid global window.insertEmojiSuggestion; attach listeners per item instead
Leaking a global mutator is unsafe and can be hijacked. When you render items, bind click/mousedown listeners directly to each element with closures capturing the suggestion code.
🤖 Prompt for AI Agents
In apps/snow-leopard/lib/editor/emoji-plugin.ts around lines 323 to 341, remove
the global assignment to window.insertEmojiSuggestion and instead, when
rendering each suggestion item attach a click (and/or mousedown) listener
directly to that DOM element that closes over the emojiCode and calls the
existing logic (hideSuggestions(), insertEmojiAtCursor(emojiCode), re-focus
editor); ensure the handler prevents default/stopPropagation as needed, call
hideSuggestions before insertion, handle failure logging locally, and remove or
clean up event listeners when suggestions are torn down to avoid leaks.
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (5)
apps/snow-leopard/lib/editor/emoji-plugin.ts (5)
385-492: Remove inert keymap block or move logic to props.handleKeyDownProseMirror ignores a keymap property on PluginSpec. This block won’t run. Either delete it (overlay handles keys globally) or implement the handlers under props.handleKeyDown.
Minimal option: delete dead keymap to avoid confusion.
Apply this diff:
- }, - - keymap: { - ':': (state: EditorState, dispatch: ((tr: Transaction) => void) | null, view: EditorView): boolean => { - const { from } = view.state.selection; - if (from >= 0 && from <= view.state.doc.content.size) { - const coords = view.coordsAtPos(from); - if (coords) { - showSuggestions(':', coords); - } - } else { - console.warn('Emoji plugin: Invalid cursor position for colon key:', from); - } - return false; - }, - 'Backspace': (state: EditorState, dispatch: ((tr: Transaction) => void) | null, view: EditorView): boolean => { - const { from } = view.state.selection; - const textBefore = view.state.doc.textBetween(Math.max(0, from - 10), from); - if (!textBefore.includes(':')) { - hideSuggestions(); - } - return false; - }, - 'Escape': (): boolean => { - hideSuggestions(); - return false; - }, - 'ArrowRight': (): boolean => { - if (pluginState.suggestions.length > 0) { - pluginState.selectedIndex = Math.min(pluginState.selectedIndex + 1, pluginState.suggestions.length - 1); - renderOverlay(); - return true; - } - return false; - }, - 'ArrowLeft': (): boolean => { - if (pluginState.suggestions.length > 0) { - pluginState.selectedIndex = Math.max(pluginState.selectedIndex - 1, 0); - renderOverlay(); - return true; - } - return false; - }, - 'ArrowDown': (): boolean => { - if (pluginState.suggestions.length > 0) { - pluginState.selectedIndex = Math.min(pluginState.selectedIndex + 1, pluginState.suggestions.length - 1); - renderOverlay(); - return true; - } - return false; - }, - 'ArrowUp': (): boolean => { - if (pluginState.suggestions.length > 0) { - pluginState.selectedIndex = Math.max(pluginState.selectedIndex - 1, 0); - renderOverlay(); - return true; - } - return false; - }, - 'Tab': (): boolean => { - if (pluginState.suggestions.length > 0) { - pluginState.selectedIndex = Math.min(pluginState.selectedIndex + 1, pluginState.suggestions.length - 1); - renderOverlay(); - return true; - } - return false; - }, - 'Shift-Tab': (): boolean => { - if (pluginState.suggestions.length > 0) { - pluginState.selectedIndex = Math.max(pluginState.selectedIndex - 1, 0); - renderOverlay(); - return true; - } - return false; - }, - 'Home': (): boolean => { - if (pluginState.suggestions.length > 0) { - pluginState.selectedIndex = 0; - renderOverlay(); - return true; - } - return false; - }, - 'End': (): boolean => { - if (pluginState.suggestions.length > 0) { - pluginState.selectedIndex = pluginState.suggestions.length - 1; - renderOverlay(); - return true; - } - return false; - }, - 'Enter': (): boolean => { - if (pluginState.suggestions.length > 0 && pluginState.selectedIndex < pluginState.suggestions.length) { - const selected = pluginState.suggestions[pluginState.selectedIndex]; - insertEmojiAtCursor(selected.code); - hideSuggestions(); - return true; - } - return false; - }, - 'Space': (): boolean => { - if (pluginState.suggestions.length > 0 && pluginState.selectedIndex < pluginState.suggestions.length) { - const selected = pluginState.suggestions[pluginState.selectedIndex]; - insertEmojiAtCursor(selected.code); - hideSuggestions(); - return true; - } - return false; - }, - } + }
20-23: Use node-emoji’s key field (not name) to build codesnode-emoji.search() returns { emoji, key }. Using name produces :undefined: codes.
Apply this diff:
interface EmojiSearchResult { emoji: string; - name: string; + key: string; // node-emoji uses `key` for the short name } @@ return allEmojis .map((item: EmojiSearchResult, index: number) => ({ emoji: item.emoji, - code: `:${item.name}:`, + code: `:${item.key}:`, score: allEmojis.length - index }))Also applies to: 171-182
51-101: Insert the emoji glyph immediately instead of the placeholderYou currently insert the code (e.g., 😄) and rely on appendTransaction to convert, causing flicker and extra work. Insert the glyph directly.
Apply this diff:
function insertEmojiAtCursor(emojiCode: string): boolean { @@ - const tr = pluginState.editorView.state.tr.replaceWith(start, end, pluginState.editorView.state.schema.text(emojiCode)); - - const newPos = start + emojiCode.length; + const toInsert = emoji.emojify(emojiCode); + const tr = pluginState.editorView.state.tr.replaceWith( + start, + end, + pluginState.editorView.state.schema.text(toInsert) + ); + + const newPos = start + toInsert.length;
261-265: Clean up overlay on editor destroy to prevent leaksImplement view.destroy() to remove the container/root and clear references.
Apply this diff:
return new Plugin({ view: (editorView: EditorView) => { setEditorView(editorView); - return {}; + return { + destroy() { + hideSuggestions(); + pluginState.editorView = null; + }, + }; },
271-301: Handle multiple matches per text node and correct positions in decorationsindexOf() returns the first occurrence, so multiple codes in the same node render incorrectly. Iterate with a global regex.
Apply this diff:
- const emojiMatches = text.match(/:[\w+-]+:/g); - - if (emojiMatches) { - emojiMatches.forEach((match: string) => { - const emojiChar = emoji.emojify(match); - if (emojiChar !== match) { - const start = pos + text.indexOf(match); - const end = start + match.length; - - decorations.push( - Decoration.widget(start, () => { - const span = document.createElement('span'); - span.textContent = emojiChar; - span.className = 'emoji-widget'; - span.setAttribute('data-emoji-code', match); - span.title = `Emoji: ${match}`; - return span; - }, { side: -1 }) - ); - - decorations.push( - Decoration.inline(start, end, { - class: 'emoji-hidden' - }) - ); - } - }); - } + const regexAll = /:[\w+-]+:/g; + let m: RegExpExecArray | null; + while ((m = regexAll.exec(text)) !== null) { + const match = m[0]; + const emojiChar = emoji.emojify(match); + if (emojiChar !== match) { + const start = pos + m.index; + const end = start + match.length; + decorations.push( + Decoration.widget(start, () => { + const span = document.createElement('span'); + span.textContent = emojiChar; + span.className = 'emoji-widget'; + span.setAttribute('data-emoji-code', match); + span.title = `Emoji: ${match}`; + return span; + }, { side: -1 }), + ); + decorations.push(Decoration.inline(start, end, { class: 'emoji-hidden' })); + } + }
🧹 Nitpick comments (4)
apps/snow-leopard/components/emoji-overlay.tsx (3)
54-60: Handle Shift+Tab to navigate backwardsTab always advances; Shift+Tab should move selection backwards for consistency with common UI patterns.
Apply this diff:
- case "ArrowRight": - case "ArrowDown": - case "Tab": + case "ArrowRight": + case "ArrowDown": e.preventDefault(); onSelectedIndexChange(Math.min(selectedIndex + 1, suggestions.length - 1)); break; + case "Tab": + e.preventDefault(); + onSelectedIndexChange( + e.shiftKey + ? Math.max(selectedIndex - 1, 0) + : Math.min(selectedIndex + 1, suggestions.length - 1) + ); + break;Also applies to: 66-76
97-107: Use pointerdown for outside-click detection (covers touch and pen input)mousedown misses touch devices. pointerdown is more robust across input types; keep cleanup symmetrical.
Apply this diff:
- if (isOpen) { - window.addEventListener("keydown", handleKeyDown); - document.addEventListener("mousedown", handleClickOutside); - } + if (isOpen) { + window.addEventListener("keydown", handleKeyDown); + document.addEventListener("pointerdown", handleClickOutside); + } @@ - window.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("mousedown", handleClickOutside); + window.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("pointerdown", handleClickOutside);
166-183: Improve accessibility: add listbox/option roles and aria-selectedConvey selection to assistive tech by marking the container as a listbox and items as options with aria-selected.
Apply this diff:
- <div + <div ref={listRef} - className="flex gap-1 overflow-x-auto p-1 bg-background scrollbar-thin" + className="flex gap-1 overflow-x-auto p-1 bg-background scrollbar-thin" + role="listbox" > {suggestions.map((suggestion, index) => ( <button key={`${suggestion.code}-${index}`} data-index={index} onClick={() => onSelectEmoji(suggestion.code)} + role="option" + aria-selected={index === selectedIndex} + aria-label={`${suggestion.code} ${suggestion.emoji}`} className={cn( "p-1 rounded-md text-xl transition-colors", index === selectedIndex ? "bg-muted" : "hover:bg-muted/60" )} > {suggestion.emoji} </button> ))} </div>apps/snow-leopard/lib/editor/emoji-plugin.ts (1)
9-13: Consider de-duplicating EmojiSuggestion typeEmojiSuggestion is defined here and in the React component. Move it to a shared types module (e.g., lib/editor/emoji-types.ts) and import in both places to avoid drift.
Would you like me to extract a shared type file and update imports across the two modules?
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (3)
apps/snow-leopard/components/emoji-overlay.tsx(1 hunks)apps/snow-leopard/lib/editor/emoji-plugin.ts(1 hunks)apps/snow-leopard/lib/editor/synonym-plugin.ts(1 hunks)
✅ Files skipped from review due to trivial changes (1)
- apps/snow-leopard/lib/editor/synonym-plugin.ts
🧰 Additional context used
🧬 Code Graph Analysis (1)
apps/snow-leopard/components/emoji-overlay.tsx (3)
apps/snow-leopard/lib/editor/inline-suggestion-plugin.ts (1)
handleKeyDown(228-263)apps/snow-leopard/lib/utils.ts (1)
cn(15-17)apps/snow-leopard/components/suggestion-overlay.tsx (1)
SuggestionOverlay(45-565)
🪛 ast-grep (0.38.6)
apps/snow-leopard/lib/editor/emoji-plugin.ts
[warning] 186-219: Direct HTML content assignment detected. Modifying innerHTML, outerHTML, or using document.write with unsanitized content can lead to XSS vulnerabilities. Use secure alternatives like textContent or sanitize HTML with libraries like DOMPurify.
Context: pluginState.suggestionElement.innerHTML = `
Emoji matching "${pluginState.currentQuery}"
<!-- Keyboard shortcuts hint -->
<div class="text-xs text-muted-foreground text-center font-mono">
↑↓←→ Navigate • Enter/Space Select • Esc Close
</div>
<!-- Emoji suggestions list -->
<div class="border rounded-lg overflow-hidden bg-muted/30">
<div class="p-2 max-h-[200px] overflow-y-auto">
<div class="flex gap-2 overflow-x-auto">
${pluginState.suggestions.map((suggestion: EmojiSuggestion, index: number) => `
<button class="emoji-suggestion-item flex flex-col items-center gap-1 p-2 rounded-md transition-colors min-w-fit whitespace-nowrap hover:bg-muted border border-transparent ${index === pluginState.selectedIndex ? 'bg-muted border-border' : ''}"
data-index="${index}"
data-emoji="${suggestion.emoji}"
data-code="${suggestion.code}"
onclick="window.insertEmojiSuggestion('${suggestion.code}')"
onmousedown="event.preventDefault(); window.insertEmojiSuggestion('${suggestion.code}')">
<span class="text-xl">${suggestion.emoji}</span>
<span class="text-xs font-mono text-muted-foreground">${suggestion.code}</span>
</button>
`).join('')}
</div>
</div>
</div>
</div>
`
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://www.dhairyashah.dev/posts/why-innerhtml-is-a-bad-idea-and-how-to-avoid-it/
- https://cwe.mitre.org/data/definitions/79.html
(unsafe-html-content-assignment)
[warning] 186-219: Direct modification of innerHTML or outerHTML properties detected. Modifying these properties with unsanitized user input can lead to XSS vulnerabilities. Use safe alternatives or sanitize content first.
Context: pluginState.suggestionElement.innerHTML = `
Emoji matching "${pluginState.currentQuery}"
<!-- Keyboard shortcuts hint -->
<div class="text-xs text-muted-foreground text-center font-mono">
↑↓←→ Navigate • Enter/Space Select • Esc Close
</div>
<!-- Emoji suggestions list -->
<div class="border rounded-lg overflow-hidden bg-muted/30">
<div class="p-2 max-h-[200px] overflow-y-auto">
<div class="flex gap-2 overflow-x-auto">
${pluginState.suggestions.map((suggestion: EmojiSuggestion, index: number) => `
<button class="emoji-suggestion-item flex flex-col items-center gap-1 p-2 rounded-md transition-colors min-w-fit whitespace-nowrap hover:bg-muted border border-transparent ${index === pluginState.selectedIndex ? 'bg-muted border-border' : ''}"
data-index="${index}"
data-emoji="${suggestion.emoji}"
data-code="${suggestion.code}"
onclick="window.insertEmojiSuggestion('${suggestion.code}')"
onmousedown="event.preventDefault(); window.insertEmojiSuggestion('${suggestion.code}')">
<span class="text-xl">${suggestion.emoji}</span>
<span class="text-xs font-mono text-muted-foreground">${suggestion.code}</span>
</button>
`).join('')}
</div>
</div>
</div>
</div>
`
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://owasp.org/www-community/xss-filter-evasion-cheatsheet
- https://cwe.mitre.org/data/definitions/79.html
(dom-content-modification)
| // Adjust position to ensure overlay is visible within viewport | ||
| useEffect(() => { | ||
| if (isOpen && overlayRef.current) { | ||
| const overlay = overlayRef.current; | ||
| const rect = overlay.getBoundingClientRect(); | ||
|
|
||
| const viewportWidth = window.innerWidth; | ||
| const viewportHeight = window.innerHeight; | ||
|
|
||
| let adjustedX = currentPosition.x; | ||
| let adjustedY = currentPosition.y; | ||
|
|
||
| if (rect.right > viewportWidth) { | ||
| adjustedX = viewportWidth - rect.width - 10; | ||
| } | ||
|
|
||
| if (rect.bottom > viewportHeight) { | ||
| adjustedY = viewportHeight - rect.height - 10; | ||
| } | ||
|
|
||
| if (adjustedX !== currentPosition.x || adjustedY !== currentPosition.y) { | ||
| overlay.style.left = `${adjustedX}px`; | ||
| overlay.style.top = `${adjustedY}px`; | ||
| } | ||
| } | ||
| }, [isOpen, currentPosition]); | ||
|
|
There was a problem hiding this comment.
🛠️ Refactor suggestion
Fix viewport repositioning: update React state instead of mutating DOM styles
Directly setting overlay.style.left/top will be overwritten on the next render because style is controlled by React via currentPosition. Persist the adjustment by updating state.
Apply this diff:
- if (adjustedX !== currentPosition.x || adjustedY !== currentPosition.y) {
- overlay.style.left = `${adjustedX}px`;
- overlay.style.top = `${adjustedY}px`;
- }
+ if (adjustedX !== currentPosition.x || adjustedY !== currentPosition.y) {
+ setCurrentPosition({ x: adjustedX, y: adjustedY });
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Adjust position to ensure overlay is visible within viewport | |
| useEffect(() => { | |
| if (isOpen && overlayRef.current) { | |
| const overlay = overlayRef.current; | |
| const rect = overlay.getBoundingClientRect(); | |
| const viewportWidth = window.innerWidth; | |
| const viewportHeight = window.innerHeight; | |
| let adjustedX = currentPosition.x; | |
| let adjustedY = currentPosition.y; | |
| if (rect.right > viewportWidth) { | |
| adjustedX = viewportWidth - rect.width - 10; | |
| } | |
| if (rect.bottom > viewportHeight) { | |
| adjustedY = viewportHeight - rect.height - 10; | |
| } | |
| if (adjustedX !== currentPosition.x || adjustedY !== currentPosition.y) { | |
| overlay.style.left = `${adjustedX}px`; | |
| overlay.style.top = `${adjustedY}px`; | |
| } | |
| } | |
| }, [isOpen, currentPosition]); | |
| // Adjust position to ensure overlay is visible within viewport | |
| useEffect(() => { | |
| if (isOpen && overlayRef.current) { | |
| const overlay = overlayRef.current; | |
| const rect = overlay.getBoundingClientRect(); | |
| const viewportWidth = window.innerWidth; | |
| const viewportHeight = window.innerHeight; | |
| let adjustedX = currentPosition.x; | |
| let adjustedY = currentPosition.y; | |
| if (rect.right > viewportWidth) { | |
| adjustedX = viewportWidth - rect.width - 10; | |
| } | |
| if (rect.bottom > viewportHeight) { | |
| adjustedY = viewportHeight - rect.height - 10; | |
| } | |
| if (adjustedX !== currentPosition.x || adjustedY !== currentPosition.y) { | |
| setCurrentPosition({ x: adjustedX, y: adjustedY }); | |
| } | |
| } | |
| }, [isOpen, currentPosition]); |
🤖 Prompt for AI Agents
In apps/snow-leopard/components/emoji-overlay.tsx around lines 122 to 148, the
viewport repositioning currently mutates overlay.style.left/top directly which
will be overwritten by React on next render; instead compute adjustedX/adjustedY
as you already do and call the state updater (e.g., setCurrentPosition or the
prop callback you use to control currentPosition) with the new coordinates so
the adjustment persists in React state; only call the updater if values changed,
keep the same rect/viewport logic, and ensure the setter is included in the
effect dependencies (or use a functional updater) to avoid stale closures.
| function renderSuggestions(): void { | ||
| if (!pluginState.suggestionElement) return; | ||
|
|
||
| pluginState.suggestionElement.innerHTML = ` | ||
| <div class="px-3 py-2 space-y-2"> | ||
| <!-- Header with close button --> | ||
| <div class="flex justify-between items-center"> | ||
| <div class="flex items-center gap-2"> | ||
| <h3 class="text-sm font-medium">Emoji matching "${pluginState.currentQuery}"</h3> | ||
| </div> | ||
| </div> | ||
|
|
||
| <!-- Keyboard shortcuts hint --> | ||
| <div class="text-xs text-muted-foreground text-center font-mono"> | ||
| ↑↓←→ Navigate • Enter/Space Select • Esc Close | ||
| </div> | ||
|
|
||
| <!-- Emoji suggestions list --> | ||
| <div class="border rounded-lg overflow-hidden bg-muted/30"> | ||
| <div class="p-2 max-h-[200px] overflow-y-auto"> | ||
| <div class="flex gap-2 overflow-x-auto"> | ||
| ${pluginState.suggestions.map((suggestion: EmojiSuggestion, index: number) => ` | ||
| <button class="emoji-suggestion-item flex flex-col items-center gap-1 p-2 rounded-md transition-colors min-w-fit whitespace-nowrap hover:bg-muted border border-transparent ${index === pluginState.selectedIndex ? 'bg-muted border-border' : ''}" | ||
| data-index="${index}" | ||
| data-emoji="${suggestion.emoji}" | ||
| data-code="${suggestion.code}" | ||
| onclick="window.insertEmojiSuggestion('${suggestion.code}')" | ||
| onmousedown="event.preventDefault(); window.insertEmojiSuggestion('${suggestion.code}')"> | ||
| <span class="text-xl">${suggestion.emoji}</span> | ||
| <span class="text-xs font-mono text-muted-foreground">${suggestion.code}</span> | ||
| </button> | ||
| `).join('')} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| `; | ||
|
|
||
| setTimeout(() => { | ||
| scrollToSelectedEmoji(); | ||
| }, 100); | ||
| } |
There was a problem hiding this comment.
Remove unsafe innerHTML rendering and the global window.insertEmojiSuggestion
There’s a CSP/XSS risk (user-controlled query interpolated into innerHTML), and a global mutator on window is brittle. You already have a React-based overlay; drop this legacy path.
Apply this diff to delete the entire legacy renderer and global:
- function renderSuggestions(): void {
- if (!pluginState.suggestionElement) return;
- pluginState.suggestionElement.innerHTML = `
- <div class="px-3 py-2 space-y-2">
- <!-- Header with close button -->
- <div class="flex justify-between items-center">
- <div class="flex items-center gap-2">
- <h3 class="text-sm font-medium">Emoji matching "${pluginState.currentQuery}"</h3>
- </div>
- </div>
- <!-- Keyboard shortcuts hint -->
- <div class="text-xs text-muted-foreground text-center font-mono">
- ↑↓←→ Navigate • Enter/Space Select • Esc Close
- </div>
- <!-- Emoji suggestions list -->
- <div class="border rounded-lg overflow-hidden bg-muted/30">
- <div class="p-2 max-h-[200px] overflow-y-auto">
- <div class="flex gap-2 overflow-x-auto">
- ${pluginState.suggestions.map((suggestion: EmojiSuggestion, index: number) => `
- <button class="emoji-suggestion-item flex flex-col items-center gap-1 p-2 rounded-md transition-colors min-w-fit whitespace-nowrap hover:bg-muted border border-transparent ${index === pluginState.selectedIndex ? 'bg-muted border-border' : ''}"
- data-index="${index}"
- data-emoji="${suggestion.emoji}"
- data-code="${suggestion.code}"
- onclick="window.insertEmojiSuggestion('${suggestion.code}')"
- onmousedown="event.preventDefault(); window.insertEmojiSuggestion('${suggestion.code}')">
- <span class="text-xl">${suggestion.emoji}</span>
- <span class="text-xs font-mono text-muted-foreground">${suggestion.code}</span>
- </button>
- `).join('')}
- </div>
- </div>
- </div>
- </div>
- `;
- setTimeout(() => {
- scrollToSelectedEmoji();
- }, 100);
- }
-
- function scrollToSelectedEmoji(): void {
- if (!pluginState.suggestionElement) return;
- const selectedItem = pluginState.suggestionElement.querySelector(`[data-index="${pluginState.selectedIndex}"]`) as HTMLElement;
- if (selectedItem) {
- selectedItem.scrollIntoView({
- behavior: 'smooth',
- block: 'nearest',
- inline: 'center'
- });
- }
- }
-
- (window as any).insertEmojiSuggestion = (emojiCode: string): void => {
- if (pluginState.suggestionElement) {
- hideSuggestions();
- }
- const success = insertEmojiAtCursor(emojiCode);
- if (!success) {
- console.warn('Emoji plugin: Failed to insert emoji, could not find colon');
- }
- setTimeout(() => {
- if (pluginState.editorView && pluginState.editorView.dom) {
- pluginState.editorView.focus();
- }
- }, 0);
- };Also applies to: 227-239, 241-259
🧰 Tools
🪛 ast-grep (0.38.6)
[warning] 186-219: Direct HTML content assignment detected. Modifying innerHTML, outerHTML, or using document.write with unsanitized content can lead to XSS vulnerabilities. Use secure alternatives like textContent or sanitize HTML with libraries like DOMPurify.
Context: pluginState.suggestionElement.innerHTML = `
Emoji matching "${pluginState.currentQuery}"
<!-- Keyboard shortcuts hint -->
<div class="text-xs text-muted-foreground text-center font-mono">
↑↓←→ Navigate • Enter/Space Select • Esc Close
</div>
<!-- Emoji suggestions list -->
<div class="border rounded-lg overflow-hidden bg-muted/30">
<div class="p-2 max-h-[200px] overflow-y-auto">
<div class="flex gap-2 overflow-x-auto">
${pluginState.suggestions.map((suggestion: EmojiSuggestion, index: number) => `
<button class="emoji-suggestion-item flex flex-col items-center gap-1 p-2 rounded-md transition-colors min-w-fit whitespace-nowrap hover:bg-muted border border-transparent ${index === pluginState.selectedIndex ? 'bg-muted border-border' : ''}"
data-index="${index}"
data-emoji="${suggestion.emoji}"
data-code="${suggestion.code}"
onclick="window.insertEmojiSuggestion('${suggestion.code}')"
onmousedown="event.preventDefault(); window.insertEmojiSuggestion('${suggestion.code}')">
<span class="text-xl">${suggestion.emoji}</span>
<span class="text-xs font-mono text-muted-foreground">${suggestion.code}</span>
</button>
`).join('')}
</div>
</div>
</div>
</div>
`
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://www.dhairyashah.dev/posts/why-innerhtml-is-a-bad-idea-and-how-to-avoid-it/
- https://cwe.mitre.org/data/definitions/79.html
(unsafe-html-content-assignment)
[warning] 186-219: Direct modification of innerHTML or outerHTML properties detected. Modifying these properties with unsanitized user input can lead to XSS vulnerabilities. Use safe alternatives or sanitize content first.
Context: pluginState.suggestionElement.innerHTML = `
Emoji matching "${pluginState.currentQuery}"
<!-- Keyboard shortcuts hint -->
<div class="text-xs text-muted-foreground text-center font-mono">
↑↓←→ Navigate • Enter/Space Select • Esc Close
</div>
<!-- Emoji suggestions list -->
<div class="border rounded-lg overflow-hidden bg-muted/30">
<div class="p-2 max-h-[200px] overflow-y-auto">
<div class="flex gap-2 overflow-x-auto">
${pluginState.suggestions.map((suggestion: EmojiSuggestion, index: number) => `
<button class="emoji-suggestion-item flex flex-col items-center gap-1 p-2 rounded-md transition-colors min-w-fit whitespace-nowrap hover:bg-muted border border-transparent ${index === pluginState.selectedIndex ? 'bg-muted border-border' : ''}"
data-index="${index}"
data-emoji="${suggestion.emoji}"
data-code="${suggestion.code}"
onclick="window.insertEmojiSuggestion('${suggestion.code}')"
onmousedown="event.preventDefault(); window.insertEmojiSuggestion('${suggestion.code}')">
<span class="text-xl">${suggestion.emoji}</span>
<span class="text-xs font-mono text-muted-foreground">${suggestion.code}</span>
</button>
`).join('')}
</div>
</div>
</div>
</div>
`
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://owasp.org/www-community/xss-filter-evasion-cheatsheet
- https://cwe.mitre.org/data/definitions/79.html
(dom-content-modification)
🤖 Prompt for AI Agents
In apps/snow-leopard/lib/editor/emoji-plugin.ts around lines 184-225 (and
likewise for 227-239 and 241-259), remove the legacy renderer that builds DOM
via innerHTML and the global window.insertEmojiSuggestion mutator; delete the
entire renderSuggestions function and any calls that set
suggestionElement.innerHTML or attach onclick/onmousedown strings, and remove
any global assignment to window.insertEmojiSuggestion; instead wire the existing
React overlay to receive pluginState.suggestions, pluginState.selectedIndex and
pluginState.currentQuery and call the plugin's selection handler directly (e.g.,
via a provided callback) so you eliminate unsafe innerHTML interpolation and the
global function.
| appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState): Transaction | null => { | ||
| const tr = newState.tr; | ||
| let modified = false; | ||
|
|
||
| transactions.forEach((transaction: Transaction) => { | ||
| if (transaction.docChanged) { | ||
| newState.doc.descendants((node: ProseMirrorNode, pos: number) => { | ||
| if (node.isText) { | ||
| const text = node.text || ''; | ||
|
|
||
| const emojiMatches = text.match(/:[\w+-]+:/g); | ||
| if (emojiMatches) { | ||
| emojiMatches.forEach((match: string) => { | ||
| const emojiChar = emoji.emojify(match); | ||
| if (emojiChar !== match) { | ||
| const start = pos + text.indexOf(match); | ||
| const end = start + match.length; | ||
| tr.replaceWith(start, end, newState.schema.text(emojiChar)); | ||
| modified = true; | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| const partialMatch = text.match(/:[\w]*$/); | ||
| // console.log('Emoji plugin: Text:', text, 'Partial match:', partialMatch); | ||
|
|
||
| if (partialMatch && partialMatch[0].length > 1 && pluginState.editorView) { | ||
| // console.log('Emoji plugin: Found partial match:', partialMatch[0]); | ||
|
|
||
| if (partialMatch.index !== null && partialMatch.index !== undefined) { | ||
| const calculatedPos = pos + partialMatch.index; | ||
| const docSize = pluginState.editorView.state.doc.content.size; | ||
|
|
||
| if (calculatedPos >= 0 && calculatedPos <= docSize) { | ||
| const coords = pluginState.editorView.coordsAtPos(calculatedPos); | ||
| if (coords) { | ||
| showSuggestions(partialMatch[0], coords); | ||
| } | ||
| } else { | ||
| console.warn('Emoji plugin: Calculated position out of bounds:', calculatedPos, 'doc size:', docSize); | ||
| } | ||
| } else { | ||
| console.warn('Emoji plugin: Invalid partialMatch.index:', partialMatch.index); | ||
| } | ||
| } else if (partialMatch && partialMatch[0] === ':' && pluginState.editorView) { | ||
| // console.log('Emoji plugin: Found colon, showing suggestions'); | ||
|
|
||
| if (partialMatch.index !== null && partialMatch.index !== undefined) { | ||
| const calculatedPos = pos + partialMatch.index; | ||
| const docSize = pluginState.editorView.state.doc.content.size; | ||
|
|
||
| if (calculatedPos >= 0 && calculatedPos <= docSize) { | ||
| const coords = pluginState.editorView.coordsAtPos(calculatedPos); | ||
| if (coords) { | ||
| showSuggestions(':', coords); | ||
| } | ||
| } else { | ||
| console.warn('Emoji plugin: Calculated position out of bounds:', calculatedPos, 'doc size:', docSize); | ||
| } | ||
| } else { | ||
| console.warn('Emoji plugin: Invalid partialMatch.index:', partialMatch.index); | ||
| } | ||
| } else { | ||
| const hasColon = text.includes(':'); | ||
| if (!hasColon) { | ||
| hideSuggestions(); | ||
| } | ||
| } | ||
| } | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| return modified ? tr : null; | ||
| }, |
There was a problem hiding this comment.
🛠️ Refactor suggestion
Fix appendTransaction to handle multiple matches and maintain correct positions
Same indexOf() issue here; additionally, replacements should use tr.mapping to account for prior steps in the same transaction.
Apply this diff:
- const emojiMatches = text.match(/:[\w+-]+:/g);
- if (emojiMatches) {
- emojiMatches.forEach((match: string) => {
- const emojiChar = emoji.emojify(match);
- if (emojiChar !== match) {
- const start = pos + text.indexOf(match);
- const end = start + match.length;
- tr.replaceWith(start, end, newState.schema.text(emojiChar));
- modified = true;
- }
- });
- }
+ const regexAll = /:[\w+-]+:/g;
+ let m: RegExpExecArray | null;
+ while ((m = regexAll.exec(text)) !== null) {
+ const match = m[0];
+ const emojiChar = emoji.emojify(match);
+ if (emojiChar !== match) {
+ // Map positions to account for previously added steps in this transaction
+ const start = tr.mapping.map(pos + m.index);
+ const end = tr.mapping.map(pos + m.index + match.length);
+ tr.replaceWith(start, end, newState.schema.text(emojiChar));
+ modified = true;
+ }
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState): Transaction | null => { | |
| const tr = newState.tr; | |
| let modified = false; | |
| transactions.forEach((transaction: Transaction) => { | |
| if (transaction.docChanged) { | |
| newState.doc.descendants((node: ProseMirrorNode, pos: number) => { | |
| if (node.isText) { | |
| const text = node.text || ''; | |
| const emojiMatches = text.match(/:[\w+-]+:/g); | |
| if (emojiMatches) { | |
| emojiMatches.forEach((match: string) => { | |
| const emojiChar = emoji.emojify(match); | |
| if (emojiChar !== match) { | |
| const start = pos + text.indexOf(match); | |
| const end = start + match.length; | |
| tr.replaceWith(start, end, newState.schema.text(emojiChar)); | |
| modified = true; | |
| } | |
| }); | |
| } | |
| const partialMatch = text.match(/:[\w]*$/); | |
| // console.log('Emoji plugin: Text:', text, 'Partial match:', partialMatch); | |
| if (partialMatch && partialMatch[0].length > 1 && pluginState.editorView) { | |
| // console.log('Emoji plugin: Found partial match:', partialMatch[0]); | |
| if (partialMatch.index !== null && partialMatch.index !== undefined) { | |
| const calculatedPos = pos + partialMatch.index; | |
| const docSize = pluginState.editorView.state.doc.content.size; | |
| if (calculatedPos >= 0 && calculatedPos <= docSize) { | |
| const coords = pluginState.editorView.coordsAtPos(calculatedPos); | |
| if (coords) { | |
| showSuggestions(partialMatch[0], coords); | |
| } | |
| } else { | |
| console.warn('Emoji plugin: Calculated position out of bounds:', calculatedPos, 'doc size:', docSize); | |
| } | |
| } else { | |
| console.warn('Emoji plugin: Invalid partialMatch.index:', partialMatch.index); | |
| } | |
| } else if (partialMatch && partialMatch[0] === ':' && pluginState.editorView) { | |
| // console.log('Emoji plugin: Found colon, showing suggestions'); | |
| if (partialMatch.index !== null && partialMatch.index !== undefined) { | |
| const calculatedPos = pos + partialMatch.index; | |
| const docSize = pluginState.editorView.state.doc.content.size; | |
| if (calculatedPos >= 0 && calculatedPos <= docSize) { | |
| const coords = pluginState.editorView.coordsAtPos(calculatedPos); | |
| if (coords) { | |
| showSuggestions(':', coords); | |
| } | |
| } else { | |
| console.warn('Emoji plugin: Calculated position out of bounds:', calculatedPos, 'doc size:', docSize); | |
| } | |
| } else { | |
| console.warn('Emoji plugin: Invalid partialMatch.index:', partialMatch.index); | |
| } | |
| } else { | |
| const hasColon = text.includes(':'); | |
| if (!hasColon) { | |
| hideSuggestions(); | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| }); | |
| return modified ? tr : null; | |
| }, | |
| appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState): Transaction | null => { | |
| const tr = newState.tr; | |
| let modified = false; | |
| transactions.forEach((transaction: Transaction) => { | |
| if (transaction.docChanged) { | |
| newState.doc.descendants((node: ProseMirrorNode, pos: number) => { | |
| if (node.isText) { | |
| const text = node.text || ''; | |
| const regexAll = /:[\w+-]+:/g; | |
| let m: RegExpExecArray | null; | |
| while ((m = regexAll.exec(text)) !== null) { | |
| const match = m[0]; | |
| const emojiChar = emoji.emojify(match); | |
| if (emojiChar !== match) { | |
| // Map positions to account for previously added steps in this transaction | |
| const start = tr.mapping.map(pos + m.index); | |
| const end = tr.mapping.map(pos + m.index + match.length); | |
| tr.replaceWith(start, end, newState.schema.text(emojiChar)); | |
| modified = true; | |
| } | |
| } | |
| const partialMatch = text.match(/:[\w]*$/); | |
| // console.log('Emoji plugin: Text:', text, 'Partial match:', partialMatch); | |
| if (partialMatch && partialMatch[0].length > 1 && pluginState.editorView) { | |
| // console.log('Emoji plugin: Found partial match:', partialMatch[0]); | |
| if (partialMatch.index !== null && partialMatch.index !== undefined) { | |
| const calculatedPos = pos + partialMatch.index; | |
| const docSize = pluginState.editorView.state.doc.content.size; | |
| if (calculatedPos >= 0 && calculatedPos <= docSize) { | |
| const coords = pluginState.editorView.coordsAtPos(calculatedPos); | |
| if (coords) { | |
| showSuggestions(partialMatch[0], coords); | |
| } | |
| } else { | |
| console.warn('Emoji plugin: Calculated position out of bounds:', calculatedPos, 'doc size:', docSize); | |
| } | |
| } else { | |
| console.warn('Emoji plugin: Invalid partialMatch.index:', partialMatch.index); | |
| } | |
| } else if (partialMatch && partialMatch[0] === ':' && pluginState.editorView) { | |
| // console.log('Emoji plugin: Found colon, showing suggestions'); | |
| if (partialMatch.index !== null && partialMatch.index !== undefined) { | |
| const calculatedPos = pos + partialMatch.index; | |
| const docSize = pluginState.editorView.state.doc.content.size; | |
| if (calculatedPos >= 0 && calculatedPos <= docSize) { | |
| const coords = pluginState.editorView.coordsAtPos(calculatedPos); | |
| if (coords) { | |
| showSuggestions(':', coords); | |
| } | |
| } else { | |
| console.warn('Emoji plugin: Calculated position out of bounds:', calculatedPos, 'doc size:', docSize); | |
| } | |
| } else { | |
| console.warn('Emoji plugin: Invalid partialMatch.index:', partialMatch.index); | |
| } | |
| } else { | |
| const hasColon = text.includes(':'); | |
| if (!hasColon) { | |
| hideSuggestions(); | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| }); | |
| return modified ? tr : null; | |
| }, |
🤖 Prompt for AI Agents
In apps/snow-leopard/lib/editor/emoji-plugin.ts around lines 309-383, the emoji
replacement logic uses text.indexOf() (which always returns the first
occurrence) and does not account for prior replacements in the same transaction;
change the replacement loop to iterate matches with a global-regex exec (or
while loop) to get each match's real index in the original text, compute
start/end as pos + matchIndex, then map those positions through tr.mapping (e.g.
mappedStart = tr.mapping.map(start), mappedEnd = tr.mapping.map(end)) before
calling tr.replaceWith(mappedStart, mappedEnd, newState.schema.text(emojiChar));
keep modified = true when you replace, and ensure the regex lastIndex is reset
per node so multiple matches in the same node are handled correctly.
What does this PR do?
Fixes #39
Screen recording of the feature.
Screen.Recording.2025-08-14.at.12.49.47.PM.mov
Tested
Summary by CodeRabbit
New Features
Style
Chores