mirror of
https://ghfast.top/https://github.com/discourse/discourse-ai.git
synced 2026-07-17 11:46:28 +08:00
## 🔍 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
197 lines
5.4 KiB
Text
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>
|
|
}
|