discourse-ai/assets/javascripts/discourse/connectors/post-text-buttons/ai-post-helper-trigger.gjs
Keegan George e264572597
FIX: Closing AI menu stops post audio from playing (#1369)
## 🔍 Overview
When you have a post with audio being played and you open and close the AI post helper menu, it re-renders the entire post DOM, causing the audio to be interrupted and stop playing.

The reason for this is because we highlight the selected text when opening the AI post helper menu and we replace the entire post back with the original post HTML when closing the menu. This fix ensures that we do not re-render the entire post DOM and instead only remove the highlighted section that was added.

## 🔗 Context
https://meta.discourse.org/t/ai-helper-interrupting-uploaded-mp3-audio-stream/366817?u=keegan
2025-05-26 10:24:06 -07:00

197 lines
5.4 KiB
Text

import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import virtualElementFromTextRange from "discourse/lib/virtual-element-from-text-range";
import eq from "truth-helpers/helpers/eq";
import AiPostHelperMenu from "../../components/ai-post-helper-menu";
import { showPostAIHelper } from "../../lib/show-ai-helper";
export default class AiPostHelperTrigger extends Component {
static shouldRender(outletArgs, helper) {
return showPostAIHelper(outletArgs, helper);
}
@service site;
@service menu;
@tracked menuState = this.MENU_STATES.triggers;
@tracked showMainButtons = true;
@tracked showAiButtons = true;
@tracked postHighlighted = false;
@tracked currentMenu = this.menu.getByIdentifier(
"post-text-selection-toolbar"
);
MENU_STATES = {
triggers: "TRIGGERS",
options: "OPTIONS",
};
willDestroy() {
super.willDestroy(...arguments);
this.removeHighlightedText();
}
highlightSelectedText() {
const postId = this.args.outletArgs.data.quoteState.postId;
const postElement = document.querySelector(
`article[data-post-id='${postId}'] .cooked`
);
if (!postElement) {
return;
}
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 = [];
if (walker.currentNode?.nodeType === Node.TEXT_NODE) {
textNodes.push(walker.currentNode);
} else {
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}'] .cooked`
);
if (!postElement) {
return;
}
const highlightedSpans = postElement.querySelectorAll(
"span.ai-helper-highlighted-selection"
);
highlightedSpans.forEach((span) => {
const textNode = document.createTextNode(span.textContent);
span.parentNode.replaceChild(textNode, span);
});
this.postHighlighted = false;
}
@action
async showAiPostHelperMenu() {
this.highlightSelectedText();
if (this.site.mobileView) {
this.currentMenu.close();
await this.menu.show(virtualElementFromTextRange(), {
identifier: "ai-post-helper-menu",
component: AiPostHelperMenu,
inline: true,
interactive: true,
placement: this.shouldRenderUnder ? "bottom-start" : "top-start",
fallbackPlacements: this.shouldRenderUnder
? ["bottom-end", "top-start"]
: ["bottom-start"],
trapTab: false,
closeOnScroll: false,
modalForMobile: true,
data: this.menuData,
});
}
this.showMainButtons = false;
this.menuState = this.MENU_STATES.options;
}
get menuData() {
// Streamline of data model to be passed to the component when
// instantiated as a DMenu or a simple component in the template
return {
...this.args.outletArgs.data,
quoteState: {
buffer: this.args.outletArgs.data.quoteState.buffer,
opts: this.args.outletArgs.data.quoteState.opts,
postId: this.args.outletArgs.data.quoteState.postId,
},
post: this.args.outletArgs.post,
selectedText: this.selectedText,
};
}
<template>
{{#if this.showMainButtons}}
{{yield}}
{{/if}}
{{#if this.showAiButtons}}
<div class="ai-post-helper">
{{#if (eq this.menuState this.MENU_STATES.triggers)}}
<DButton
@icon="discourse-sparkles"
@title="discourse_ai.ai_helper.post_options_menu.title"
@label="discourse_ai.ai_helper.post_options_menu.trigger"
@action={{this.showAiPostHelperMenu}}
class="btn-flat ai-post-helper__trigger"
/>
{{else if (eq this.menuState this.MENU_STATES.options)}}
<AiPostHelperMenu @data={{this.menuData}} />
{{/if}}
</div>
{{/if}}
</template>
}