mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-03-03 07:49:30 +00:00
Previously, when clicking add footnote on an explain suggestion it would replace the selected word by finding the first occurrence of the word. This results in issues when there are more than one occurrences of a word in a post. This is not trivial to solve, so this PR instead prevents incorrect text replacements by only allowing the replacement if it's unique. We use the same logic here that we use to determine if something can be fast edited. In this PR we also update tests for post helper explain suggestions. For a while, we haven't had tests here due to streaming/timing issues, we've been skipping our system specs. In this PR, we add acceptance tests to handle this which gives us improved ability to publish message bus updates in the testing environment so that it can be better tested without issues.
192 lines
5.3 KiB
Plaintext
192 lines
5.3 KiB
Plaintext
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 originalPostHTML = null;
|
|
@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.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 = [];
|
|
|
|
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;
|
|
}
|
|
|
|
postElement.innerHTML = this.originalPostHTML;
|
|
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>
|
|
}
|