diff --git a/assets/javascripts/discourse/connectors/post-text-buttons/ai-helper-options-menu.gjs b/assets/javascripts/discourse/connectors/post-text-buttons/ai-helper-options-menu.gjs index c4a9f32b..f76820e7 100644 --- a/assets/javascripts/discourse/connectors/post-text-buttons/ai-helper-options-menu.gjs +++ b/assets/javascripts/discourse/connectors/post-text-buttons/ai-helper-options-menu.gjs @@ -40,6 +40,8 @@ export default class AIHelperOptionsMenu extends Component { @tracked copyButtonLabel = "discourse_ai.ai_helper.post_options_menu.copy"; @tracked showFastEdit = false; @tracked showAiButtons = true; + @tracked originalPostHTML = null; + @tracked postHighlighted = false; MENU_STATES = { triggers: "TRIGGERS", @@ -50,8 +52,99 @@ export default class AIHelperOptionsMenu extends Component { @tracked _activeAIRequest = null; + highlightSelectedText() { + const postId = this.args.outletArgs.data.quoteState.postId; + const postElement = document.querySelector( + `article[data-post-id='${postId}']` + ); + + if (!postElement) { + return; + } + + this.originalPostHTML = postElement.innerHTML; + this.selectedText = this.args.outletArgs.data.quoteState.buffer; + + const selection = window.getSelection(); + if (!selection.rangeCount) { + return; + } + + const range = selection.getRangeAt(0); + + // Split start/end text nodes at their range boundary + if ( + range.startContainer.nodeType === Node.TEXT_NODE && + range.startOffset > 0 + ) { + const newStartNode = range.startContainer.splitText(range.startOffset); + range.setStart(newStartNode, 0); + } + if ( + range.endContainer.nodeType === Node.TEXT_NODE && + range.endOffset < range.endContainer.length + ) { + range.endContainer.splitText(range.endOffset); + } + + // Create a Walker to traverse text nodes within range + const walker = document.createTreeWalker( + range.commonAncestorContainer, + NodeFilter.SHOW_TEXT, + { + acceptNode: (node) => + range.intersectsNode(node) + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT, + } + ); + + const textNodes = []; + while (walker.nextNode()) { + textNodes.push(walker.currentNode); + } + + for (let textNode of textNodes) { + const highlight = document.createElement("span"); + highlight.classList.add("ai-helper-highlighted-selection"); + + // Replace textNode with highlighted clone + const clone = textNode.cloneNode(true); + highlight.appendChild(clone); + + textNode.parentNode.replaceChild(highlight, textNode); + } + + selection.removeAllRanges(); + this.postHighlighted = true; + } + + removeHighlightedText() { + if (!this.postHighlighted) { + return; + } + + const postId = this.args.outletArgs.data.quoteState.postId; + const postElement = document.querySelector( + `article[data-post-id='${postId}']` + ); + + if (!postElement) { + return; + } + + postElement.innerHTML = this.originalPostHTML; + this.postHighlighted = false; + } + + willDestroy() { + super.willDestroy(...arguments); + this.removeHighlightedText(); + } + @action async showAIHelperOptions() { + this.highlightSelectedText(); this.showMainButtons = false; this.menuState = this.MENU_STATES.options; } @@ -75,6 +168,19 @@ export default class AIHelperOptionsMenu extends Component { this.suggestion = result.result; } + get highlightedTextToggleIcon() { + if (this.showHighlightedText) { + return "angle-double-left"; + } else { + return "angle-double-right"; + } + } + + @action + toggleHighlightedTextPreview() { + this.showHighlightedText = !this.showHighlightedText; + } + @action async performAISuggestion(option) { this.menuState = this.MENU_STATES.loading; diff --git a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss index 799784c1..a8f9e04d 100644 --- a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss +++ b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss @@ -141,6 +141,10 @@ } } +.ai-helper-highlighted-selection { + color: var(--tertiary); +} + // AI Typing indicator (taken from: https://github.com/nzbin/three-dots) .dot-falling { position: relative; diff --git a/spec/system/ai_helper/ai_post_helper_spec.rb b/spec/system/ai_helper/ai_post_helper_spec.rb index 771ac986..6cf12cca 100644 --- a/spec/system/ai_helper/ai_post_helper_spec.rb +++ b/spec/system/ai_helper/ai_post_helper_spec.rb @@ -57,6 +57,14 @@ RSpec.describe "AI Post helper", type: :system, js: true do expect(post_ai_helper).to have_post_ai_helper_options end + it "highlights the selected text after clicking the AI button and removes after closing" do + select_post_text(post) + post_ai_helper.click_ai_button + expect(post_ai_helper).to have_highlighted_text + find("article[data-post-id='#{post.id}']").click + expect(post_ai_helper).to have_no_highlighted_text + end + context "when using explain mode" do let(:mode) { CompletionPrompt::EXPLAIN } diff --git a/spec/system/page_objects/components/ai_helper_post_options.rb b/spec/system/page_objects/components/ai_helper_post_options.rb index 942d5b04..aed03043 100644 --- a/spec/system/page_objects/components/ai_helper_post_options.rb +++ b/spec/system/page_objects/components/ai_helper_post_options.rb @@ -13,6 +13,7 @@ module PageObjects OPTIONS_SELECTOR = "#{AI_HELPER_SELECTOR}__options" LOADING_SELECTOR = ".ai-helper-context-menu__loading" SUGGESTION_SELECTOR = "#{AI_HELPER_SELECTOR}__suggestion" + HIGHLIGHT_SELECTOR = ".ai-helper-highlighted-selection" def click_ai_button find(TRIGGER_SELECTOR).click @@ -26,6 +27,14 @@ module PageObjects find("#{SUGGESTION_SELECTOR}__text").text end + def has_highlighted_text? + page.has_css?(HIGHLIGHT_SELECTOR) + end + + def has_no_highlighted_text? + page.has_no_css?(HIGHLIGHT_SELECTOR) + end + def has_post_ai_helper? page.has_css?(AI_HELPER_SELECTOR) end