Skip to content

feat: emoji support in document text editor#62

Merged
will-lp1 merged 5 commits intowill-lp1:mainfrom
Praashh:feat/emoji_in_document
Aug 18, 2025
Merged

feat: emoji support in document text editor#62
will-lp1 merged 5 commits intowill-lp1:mainfrom
Praashh:feat/emoji_in_document

Conversation

@Praashh
Copy link
Collaborator

@Praashh Praashh commented Aug 14, 2025

What does this PR do?

Fixes #39

Screen recording of the feature.

Screen.Recording.2025-08-14.at.12.49.47.PM.mov

Tested

  • I have self-reviewed the feature

Summary by CodeRabbit

  • New Features

    • Emoji support in the text editor: type ":" to open a suggestion panel, navigate via keyboard or mouse, and insert emojis; colon-style codes render as inline glyphs.
  • Style

    • New emoji panel and editor styling (panel layout, focus states, custom scrollbar, emoji rendering) and updated synonym overlay appearance.
  • Chores

    • Reorganized and added dependencies to support the emoji UI and related components.

@vercel
Copy link

vercel bot commented Aug 14, 2025

@Praashh is attempting to deploy a commit to the william Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link

coderabbitai bot commented Aug 14, 2025

Important

Review skipped

Review was skipped due to path filters

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml

CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including **/dist/** will override the default block on the dist directory, by removing the pattern from both the lists.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Walkthrough

Adds 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

Cohort / File(s) Summary
Emoji plugin implementation
apps/snow-leopard/lib/editor/emoji-plugin.ts
New ProseMirror plugin: detects colon-delimited emoji tokens, provides suggestion lifecycle, keyboard navigation, decorations-based inline emoji rendering, insertion helper, and exports emojiPlugin(). Adds global window.insertEmojiSuggestion.
Plugin registration
apps/snow-leopard/lib/editor/editor-plugins.ts
Imports and registers emojiPlugin() in the editor plugin array.
Emoji overlay UI
apps/snow-leopard/components/emoji-overlay.tsx
New client-side React EmojiOverlay component with keyboard/mouse navigation, viewport-aware positioning, framer-motion animations, and exported EmojiSuggestion type.
Editor styles for emoji UI
apps/snow-leopard/components/document/text-editor.tsx
Adds large emoji-related CSS (emoji widget, panel, suggestions, scrollbars) inserted in two places, producing duplicate rules; no logic changes.
Dependencies update
apps/snow-leopard/package.json
Reorganizes dependencies and adds packages (notably node-emoji, Radix UI entries, prosemirror-related libs, framer-motion etc.).
Synonym overlay styling
apps/snow-leopard/lib/editor/synonym-plugin.ts
UI/styling adjustments: changed overlay container className, increased z-index, button class tweaks; no functional 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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Assessment against linked issues

Objective Addressed Explanation
Implement colon-triggered emoji insertion in editor (#39)
Provide emoji suggestions while typing (#39)
Render emoji glyphs in-place for :name: tokens (#39)

Assessment against linked issues: Out-of-scope changes

Code Change Explanation
Dependency reorganization and additions (apps/snow-leopard/package.json) package.json grouping and Radix/framer-motion additions are not required by issue #39 and no direct dependency usage is necessary for core emoji plugin functionality in this diff.
Synonym overlay styling change (apps/snow-leopard/lib/editor/synonym-plugin.ts) Styling/className and z-index updates affect a different plugin (synonym) and are unrelated to the emoji feature objectives in #39.

Possibly related PRs

  • Staging #56 — Modifies editor plugin wiring and text-editor UI; directly related to registering new editor plugins (emojiPlugin) in the plugin array.

Poem

"I nibble keys and hunt for :smile:,
I mount the list where small glyphs pile.
A hop, a click, the token turns bright —
My whiskers cheer: an emoji takes flight! 🥕✨"

✨ Finishing Touches
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.tsx

Most 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

📥 Commits

Reviewing files that changed from the base of the PR and between 78e729d and 76d4e31.

📒 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 plugin

node-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 placed

emojiPlugin() is added after formatPlugin and before savePlugin, which is a sensible order for UI interaction and doc mutations.

Comment on lines +323 to +341
(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);
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.handleKeyDown

ProseMirror 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 codes

node-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 placeholder

You 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 leaks

Implement 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 decorations

indexOf() 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 backwards

Tab 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-selected

Convey 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 type

EmojiSuggestion 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.

📥 Commits

Reviewing files that changed from the base of the PR and between 76d4e31 and f453c27.

📒 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)

Comment on lines +122 to +148
// 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]);

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Suggested change
// 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.

Comment on lines +184 to +225
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);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

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.

Comment on lines +309 to +383
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;
},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Suggested change
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.

@will-lp1 will-lp1 closed this Aug 18, 2025
@will-lp1 will-lp1 reopened this Aug 18, 2025
@will-lp1 will-lp1 merged commit a92f5fd into will-lp1:main Aug 18, 2025
2 of 3 checks passed
This was referenced Aug 21, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add new emoji feature, using (: - and the name of emoji...)

2 participants