FEATURE: Show post helper as bottom modal on mobile (#704)

This commit is contained in:
Keegan George 2024-07-10 11:01:05 -07:00 committed by GitHub
parent 8d4a67fbe2
commit 08355ea5d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 691 additions and 563 deletions

View File

@ -2,7 +2,7 @@ import DButton from "discourse/components/d-button";
import i18n from "discourse-common/helpers/i18n";
const AiHelperLoading = <template>
<div class="ai-helper-context-menu__loading">
<div class="ai-helper-loading">
<div class="dot-falling"></div>
<span>
{{i18n "discourse_ai.ai_helper.context_menu.loading"}}

View File

@ -0,0 +1,31 @@
import { fn } from "@ember/helper";
import DButton from "discourse/components/d-button";
import eq from "truth-helpers/helpers/eq";
import AiHelperCustomPrompt from "../components/ai-helper-custom-prompt";
const AiHelperOptionsList = <template>
<ul class="ai-helper-options">
{{#each @options as |option|}}
{{#if (eq option.name "custom_prompt")}}
<AiHelperCustomPrompt
@value={{@customPromptValue}}
@promptArgs={{option}}
@submit={{@performAction}}
/>
{{else}}
<li data-name={{option.translated_name}} data-value={{option.id}}>
<DButton
@icon={{option.icon}}
@translatedLabel={{option.translated_name}}
@action={{fn @performAction option}}
data-name={{option.name}}
data-value={{option.id}}
class="btn-flat ai-helper-options__button"
/>
</li>
{{/if}}
{{/each}}
</ul>
</template>;
export default AiHelperOptionsList;

View File

@ -0,0 +1,357 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { inject as service } from "@ember/service";
import { and } from "truth-helpers";
import CookText from "discourse/components/cook-text";
import DButton from "discourse/components/d-button";
import FastEdit from "discourse/components/fast-edit";
import FastEditModal from "discourse/components/modal/fast-edit";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { sanitize } from "discourse/lib/text";
import { clipboardCopy } from "discourse/lib/utilities";
import i18n from "discourse-common/helpers/i18n";
import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
import eq from "truth-helpers/helpers/eq";
import AiHelperLoading from "../components/ai-helper-loading";
import AiHelperOptionsList from "../components/ai-helper-options-list";
export default class AiPostHelperMenu extends Component {
@service messageBus;
@service site;
@service modal;
@service siteSettings;
@service currentUser;
@service menu;
@tracked menuState = this.MENU_STATES.options;
@tracked loading = false;
@tracked suggestion = "";
@tracked customPromptValue = "";
@tracked copyButtonIcon = "copy";
@tracked copyButtonLabel = "discourse_ai.ai_helper.post_options_menu.copy";
@tracked showFastEdit = false;
@tracked showAiButtons = true;
@tracked streaming = false;
@tracked lastSelectedOption = null;
@tracked isSavingFootnote = false;
MENU_STATES = {
options: "OPTIONS",
loading: "LOADING",
result: "RESULT",
};
@tracked _activeAiRequest = null;
get helperOptions() {
let prompts = this.currentUser?.ai_helper_prompts;
prompts = prompts.filter((item) => item.location.includes("post"));
// Find the custom_prompt object and move it to the beginning of the array
const customPromptIndex = prompts.findIndex(
(p) => p.name === "custom_prompt"
);
if (customPromptIndex !== -1) {
const customPrompt = prompts.splice(customPromptIndex, 1)[0];
prompts.unshift(customPrompt);
}
if (!this._showUserCustomPrompts()) {
prompts = prompts.filter((p) => p.name !== "custom_prompt");
}
if (!this.args.data.canEditPost) {
prompts = prompts.filter((p) => p.name !== "proofread");
}
return prompts;
}
get highlightedTextToggleIcon() {
if (this.showHighlightedText) {
return "angle-double-left";
} else {
return "angle-double-right";
}
}
get allowInsertFootnote() {
const siteSettings = this.siteSettings;
const canEditPost = this.args.data.canEditPost;
if (
!siteSettings?.enable_markdown_footnotes ||
!siteSettings?.display_footnotes_inline ||
!canEditPost
) {
return false;
}
return this.lastSelectedOption?.name === "explain";
}
_showUserCustomPrompts() {
return this.currentUser?.can_use_custom_prompts;
}
_sanitizeForFootnote(text) {
// Remove line breaks (line-breaks breaks the inline footnote display)
text = text.replace(/[\r\n]+/g, " ");
// Remove headings (headings don't work in inline footnotes)
text = text.replace(/^(#+)\s+/gm, "");
// Trim excess space
text = text.trim();
return sanitize(text);
}
@bind
subscribe() {
const channel = `/discourse-ai/ai-helper/explain/${this.args.data.quoteState.postId}`;
this.messageBus.subscribe(channel, this._updateResult);
}
@bind
unsubscribe() {
this.messageBus.unsubscribe(
"/discourse-ai/ai-helper/explain/*",
this._updateResult
);
}
@bind
_updateResult(result) {
this.streaming = !result.done;
this.suggestion = result.result;
}
@action
toggleHighlightedTextPreview() {
this.showHighlightedText = !this.showHighlightedText;
}
@action
async performAiSuggestion(option) {
this.menuState = this.MENU_STATES.loading;
this.lastSelectedOption = option;
if (option.name === "explain") {
return this._handleExplainOption(option);
} else {
this._activeAiRequest = ajax("/discourse-ai/ai-helper/suggest", {
method: "POST",
data: {
mode: option.id,
text: this.args.data.quoteState.buffer,
custom_prompt: this.customPromptValue,
},
});
}
this._activeAiRequest
.then(({ suggestions }) => {
this.suggestion = suggestions[0].trim();
if (option.name === "proofread") {
return this._handleProofreadOption();
}
})
.catch(popupAjaxError)
.finally(() => {
this.loading = false;
this.menuState = this.MENU_STATES.result;
});
return this._activeAiRequest;
}
_handleExplainOption(option) {
this.menuState = this.MENU_STATES.result;
const menu = this.menu.getByIdentifier("post-text-selection-toolbar");
if (menu) {
menu.options.placement = "bottom";
}
const fetchUrl = `/discourse-ai/ai-helper/explain`;
this._activeAiRequest = ajax(fetchUrl, {
method: "POST",
data: {
mode: option.value,
text: this.args.data.selectedText,
post_id: this.args.data.quoteState.postId,
},
});
return this._activeAiRequest;
}
_handleProofreadOption() {
this.showAiButtons = false;
if (this.site.desktopView) {
this.showFastEdit = true;
return;
} else {
return this.modal.show(FastEditModal, {
model: {
initialValue: this.args.data.quoteState.buffer,
newValue: this.suggestion,
post: this.args.data.post,
close: this.closeFastEdit,
},
});
}
}
@action
cancelAiAction() {
if (this._activeAiRequest) {
this._activeAiRequest.abort();
this._activeAiRequest = null;
this.loading = false;
this.menuState = this.MENU_STATES.options;
}
}
@action
copySuggestion() {
if (this.suggestion?.length > 0) {
clipboardCopy(this.suggestion);
this.copyButtonIcon = "check";
this.copyButtonLabel = "discourse_ai.ai_helper.post_options_menu.copied";
setTimeout(() => {
this.copyButtonIcon = "copy";
this.copyButtonLabel = "discourse_ai.ai_helper.post_options_menu.copy";
}, 3500);
}
}
@action
closeMenu() {
if (this.site.mobileView) {
return this.args.close();
}
const menu = this.menu.getByIdentifier("post-text-selection-toolbar");
return menu?.close();
}
@action
async closeFastEdit() {
this.showFastEdit = false;
await this.args.data.hideToolbar();
}
@action
async insertFootnote() {
this.isSavingFootnote = true;
if (this.allowInsertFootnote) {
try {
const result = await ajax(`/posts/${this.args.data.post.id}`);
const sanitizedSuggestion = this._sanitizeForFootnote(this.suggestion);
const credits = I18n.t(
"discourse_ai.ai_helper.post_options_menu.footnote_credits"
);
const withFootnote = `${this.args.data.selectedText} ^[${sanitizedSuggestion} (${credits})]`;
const newRaw = result.raw.replace(
this.args.data.selectedText,
withFootnote
);
await this.args.data.post.save({ raw: newRaw });
} catch (error) {
popupAjaxError(error);
} finally {
this.isSavingFootnote = false;
await this.closeMenu();
}
}
}
<template>
{{#if
(and this.site.mobileView (eq this.menuState this.MENU_STATES.options))
}}
<div class="ai-post-helper-menu__selected-text">
<h2>
{{i18n "discourse_ai.ai_helper.post_options_menu.selected_text"}}
</h2>
<p>{{@data.selectedText}}</p>
</div>
{{/if}}
{{#if this.showAiButtons}}
<div class="ai-post-helper">
{{#if (eq this.menuState this.MENU_STATES.options)}}
<AiHelperOptionsList
@options={{this.helperOptions}}
@customPromptValue={{this.customPromptValue}}
@performAction={{this.performAiSuggestion}}
/>
{{else if (eq this.menuState this.MENU_STATES.loading)}}
<AiHelperLoading @cancel={{this.cancelAiAction}} />
{{else if (eq this.menuState this.MENU_STATES.result)}}
<div
class="ai-post-helper__suggestion"
{{didInsert this.subscribe}}
{{willDestroy this.unsubscribe}}
>
{{#if this.suggestion}}
<div class="ai-post-helper__suggestion__text" dir="auto">
<CookText @rawText={{this.suggestion}} />
</div>
<div class="ai-post-helper__suggestion__buttons">
<DButton
@icon="times"
@label="discourse_ai.ai_helper.post_options_menu.cancel"
@action={{this.cancelAiAction}}
class="btn-flat ai-post-helper__suggestion__cancel"
/>
<DButton
@icon={{this.copyButtonIcon}}
@label={{this.copyButtonLabel}}
@action={{this.copySuggestion}}
@disabled={{this.streaming}}
class="btn-flat ai-post-helper__suggestion__copy"
/>
{{#if this.allowInsertFootnote}}
<DButton
@icon="asterisk"
@label="discourse_ai.ai_helper.post_options_menu.insert_footnote"
@action={{this.insertFootnote}}
@isLoading={{this.isSavingFootnote}}
@disabled={{this.streaming}}
class="btn-flat ai-post-helper__suggestion__insert-footnote"
/>
{{/if}}
</div>
{{else}}
<AiHelperLoading @cancel={{this.cancelAiAction}} />
{{/if}}
</div>
{{/if}}
</div>
{{/if}}
{{#if this.showFastEdit}}
<div class="ai-post-helper__fast-edit">
<FastEdit
@initialValue={{@data.quoteState.buffer}}
@newValue={{this.suggestion}}
@post={{@data.post}}
@close={{this.closeFastEdit}}
/>
</div>
{{/if}}
</template>
}

View File

@ -14,27 +14,11 @@
</ul>
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.options)}}
<ul class="ai-helper-context-menu__options">
{{#each this.helperOptions as |option|}}
{{#if (eq option.name "custom_prompt")}}
<AiHelperCustomPrompt
@value={{this.customPromptValue}}
@promptArgs={{option}}
@submit={{this.updateSelected}}
/>
{{else}}
<li data-name={{option.translated_name}} data-value={{option.id}}>
<DButton
@icon={{option.icon}}
@translatedLabel={{option.translated_name}}
@action={{fn this.updateSelected option}}
class="btn-flat"
/>
</li>
{{/if}}
{{/each}}
</ul>
<AiHelperOptionsList
@options={{this.helperOptions}}
@customPromptValue={{this.customPromptValue}}
@performAction={{this.updateSelected}}
/>
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.loading)}}
<AiHelperLoading @cancel={{this.cancelAIAction}} />

View File

@ -1,465 +0,0 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { inject as service } from "@ember/service";
import CookText from "discourse/components/cook-text";
import DButton from "discourse/components/d-button";
import FastEdit from "discourse/components/fast-edit";
import FastEditModal from "discourse/components/modal/fast-edit";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { sanitize } from "discourse/lib/text";
import { clipboardCopy } from "discourse/lib/utilities";
import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
import eq from "truth-helpers/helpers/eq";
import AiHelperCustomPrompt from "../../components/ai-helper-custom-prompt";
import AiHelperLoading from "../../components/ai-helper-loading";
import { showPostAIHelper } from "../../lib/show-ai-helper";
export default class AIHelperOptionsMenu extends Component {
static shouldRender(outletArgs, helper) {
return showPostAIHelper(outletArgs, helper);
}
@service messageBus;
@service site;
@service modal;
@service siteSettings;
@service currentUser;
@service menu;
@tracked menuState = this.MENU_STATES.triggers;
@tracked loading = false;
@tracked suggestion = "";
@tracked showMainButtons = true;
@tracked customPromptValue = "";
@tracked copyButtonIcon = "copy";
@tracked copyButtonLabel = "discourse_ai.ai_helper.post_options_menu.copy";
@tracked showFastEdit = false;
@tracked showAiButtons = true;
@tracked originalPostHTML = null;
@tracked postHighlighted = false;
@tracked streaming = false;
@tracked lastSelectedOption = null;
@tracked isSavingFootnote = false;
MENU_STATES = {
triggers: "TRIGGERS",
options: "OPTIONS",
loading: "LOADING",
result: "RESULT",
};
@tracked _activeAIRequest = null;
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;
}
willDestroy() {
super.willDestroy(...arguments);
this.removeHighlightedText();
}
@action
async showAIHelperOptions() {
this.highlightSelectedText();
this.showMainButtons = false;
this.menuState = this.MENU_STATES.options;
}
@bind
subscribe() {
const channel = `/discourse-ai/ai-helper/explain/${this.args.outletArgs.data.quoteState.postId}`;
this.messageBus.subscribe(channel, this._updateResult);
}
@bind
unsubscribe() {
this.messageBus.unsubscribe(
"/discourse-ai/ai-helper/explain/*",
this._updateResult
);
}
@bind
_updateResult(result) {
this.streaming = !result.done;
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;
this.lastSelectedOption = option;
if (option.name === "explain") {
this.menuState = this.MENU_STATES.result;
const menu = this.menu.getByIdentifier("post-text-selection-toolbar");
if (menu) {
menu.options.placement = "bottom";
}
const fetchUrl = `/discourse-ai/ai-helper/explain`;
this._activeAIRequest = ajax(fetchUrl, {
method: "POST",
data: {
mode: option.value,
text: this.args.outletArgs.data.quoteState.buffer,
post_id: this.args.outletArgs.data.quoteState.postId,
},
});
} else {
this._activeAIRequest = ajax("/discourse-ai/ai-helper/suggest", {
method: "POST",
data: {
mode: option.id,
text: this.args.outletArgs.data.quoteState.buffer,
custom_prompt: this.customPromptValue,
},
});
}
if (option.name !== "explain") {
this._activeAIRequest
.then(({ suggestions }) => {
this.suggestion = suggestions[0].trim();
if (option.name === "proofread") {
this.showAiButtons = false;
if (this.site.desktopView) {
this.showFastEdit = true;
return;
} else {
return this.modal.show(FastEditModal, {
model: {
initialValue: this.args.outletArgs.data.quoteState.buffer,
newValue: this.suggestion,
post: this.args.outletArgs.post,
close: this.closeFastEdit,
},
});
}
}
})
.catch(popupAjaxError)
.finally(() => {
this.loading = false;
this.menuState = this.MENU_STATES.result;
});
}
return this._activeAIRequest;
}
@action
cancelAIAction() {
if (this._activeAIRequest) {
this._activeAIRequest.abort();
this._activeAIRequest = null;
this.loading = false;
this.menuState = this.MENU_STATES.options;
}
}
@action
copySuggestion() {
if (this.suggestion?.length > 0) {
clipboardCopy(this.suggestion);
this.copyButtonIcon = "check";
this.copyButtonLabel = "discourse_ai.ai_helper.post_options_menu.copied";
setTimeout(() => {
this.copyButtonIcon = "copy";
this.copyButtonLabel = "discourse_ai.ai_helper.post_options_menu.copy";
}, 3500);
}
}
get helperOptions() {
let prompts = this.currentUser?.ai_helper_prompts;
prompts = prompts.filter((item) => item.location.includes("post"));
// Find the custom_prompt object and move it to the beginning of the array
const customPromptIndex = prompts.findIndex(
(p) => p.name === "custom_prompt"
);
if (customPromptIndex !== -1) {
const customPrompt = prompts.splice(customPromptIndex, 1)[0];
prompts.unshift(customPrompt);
}
if (!this._showUserCustomPrompts()) {
prompts = prompts.filter((p) => p.name !== "custom_prompt");
}
if (!this.args.outletArgs.data.canEditPost) {
prompts = prompts.filter((p) => p.name !== "proofread");
}
return prompts;
}
_showUserCustomPrompts() {
return this.currentUser?.can_use_custom_prompts;
}
@action
async closeFastEdit() {
this.showFastEdit = false;
await this.args.outletArgs.data.hideToolbar();
}
sanitizeForFootnote(text) {
// Remove line breaks (line-breaks breaks the inline footnote display)
text = text.replace(/[\r\n]+/g, " ");
// Remove headings (headings don't work in inline footnotes)
text = text.replace(/^(#+)\s+/gm, "");
// Trim excess space
text = text.trim();
return sanitize(text);
}
@action
async insertFootnote() {
this.isSavingFootnote = true;
if (this.allowInsertFootnote) {
try {
const result = await ajax(`/posts/${this.args.outletArgs.post.id}`);
const sanitizedSuggestion = this.sanitizeForFootnote(this.suggestion);
const credits = I18n.t(
"discourse_ai.ai_helper.post_options_menu.footnote_credits"
);
const withFootnote = `${this.selectedText} ^[${sanitizedSuggestion} (${credits})]`;
const newRaw = result.raw.replace(this.selectedText, withFootnote);
await this.args.outletArgs.post.save({ raw: newRaw });
} catch (error) {
popupAjaxError(error);
} finally {
this.isSavingFootnote = false;
this.menu.close();
}
}
}
get allowInsertFootnote() {
const siteSettings = this.siteSettings;
const canEditPost = this.args.outletArgs.data.canEditPost;
if (
!siteSettings?.enable_markdown_footnotes ||
!siteSettings?.display_footnotes_inline ||
!canEditPost
) {
return false;
}
return this.lastSelectedOption?.name === "explain";
}
<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.showAIHelperOptions}}
class="btn-flat ai-post-helper__trigger"
/>
{{else if (eq this.menuState this.MENU_STATES.options)}}
<div class="ai-post-helper__options">
{{#each this.helperOptions as |option|}}
{{#if (eq option.name "custom_prompt")}}
<AiHelperCustomPrompt
@value={{this.customPromptValue}}
@promptArgs={{option}}
@submit={{this.performAISuggestion}}
/>
{{else}}
<DButton
@icon={{option.icon}}
@translatedLabel={{option.translated_name}}
@action={{fn this.performAISuggestion option}}
data-name={{option.name}}
data-value={{option.id}}
class="btn-flat ai-post-helper__options-button"
/>
{{/if}}
{{/each}}
</div>
{{else if (eq this.menuState this.MENU_STATES.loading)}}
<AiHelperLoading @cancel={{this.cancelAIAction}} />
{{else if (eq this.menuState this.MENU_STATES.result)}}
<div
class="ai-post-helper__suggestion"
{{didInsert this.subscribe}}
{{willDestroy this.unsubscribe}}
>
{{#if this.suggestion}}
<div class="ai-post-helper__suggestion__text" dir="auto">
<CookText @rawText={{this.suggestion}} />
</div>
<div class="ai-post-helper__suggestion__buttons">
<DButton
@icon="times"
@label="discourse_ai.ai_helper.post_options_menu.cancel"
@action={{this.cancelAIAction}}
class="btn-flat ai-post-helper__suggestion__cancel"
/>
<DButton
@icon={{this.copyButtonIcon}}
@label={{this.copyButtonLabel}}
@action={{this.copySuggestion}}
@disabled={{this.streaming}}
class="btn-flat ai-post-helper__suggestion__copy"
/>
{{#if this.allowInsertFootnote}}
<DButton
@icon="asterisk"
@label="discourse_ai.ai_helper.post_options_menu.insert_footnote"
@action={{this.insertFootnote}}
@isLoading={{this.isSavingFootnote}}
@disabled={{this.streaming}}
class="btn-flat ai-post-helper__suggestion__insert-footnote"
/>
{{/if}}
</div>
{{else}}
<AiHelperLoading @cancel={{this.cancelAIAction}} />
{{/if}}
</div>
{{/if}}
</div>
{{/if}}
{{#if this.showFastEdit}}
<div class="ai-post-helper__fast-edit">
<FastEdit
@initialValue={{@outletArgs.data.quoteState.buffer}}
@newValue={{this.suggestion}}
@post={{@outletArgs.post}}
@close={{this.closeFastEdit}}
/>
</div>
{{/if}}
</template>
}

View File

@ -0,0 +1,190 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as 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",
};
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;
}
willDestroy() {
super.willDestroy(...arguments);
this.removeHighlightedText();
}
@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,
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>
}

View File

@ -50,53 +50,6 @@
list-style: none;
}
li {
.btn-flat {
justify-content: left;
text-align: left;
background: none;
width: 100%;
border-radius: 0;
margin: 0;
padding-block: 0.6rem;
&:focus,
&:hover {
color: var(--primary);
background: var(--d-hover);
.d-icon {
color: var(--primary-medium);
}
}
.d-button-label {
color: var(--primary-very-high);
}
}
}
&__options {
padding: 0.25rem;
li:not(:last-child) {
border-bottom: 1px solid var(--primary-low);
}
}
&__loading {
display: flex;
padding: 0.5rem;
gap: 1rem;
justify-content: flex-start;
align-items: center;
.dot-falling {
margin-inline: 1rem;
margin-left: 1.5rem;
}
}
&__resets {
display: flex;
align-items: center;
@ -125,6 +78,19 @@
}
}
.ai-helper-loading {
display: flex;
padding: 0.5rem;
gap: 1rem;
justify-content: flex-start;
align-items: center;
.dot-falling {
margin-inline: 1rem;
margin-left: 1.5rem;
}
}
.d-editor-input.loading {
animation: loading-text 1.5s infinite linear;
}
@ -329,30 +295,6 @@
}
.ai-post-helper {
&__options {
display: flex;
flex-flow: column nowrap;
align-items: flex-start;
gap: 0.25rem;
justify-content: flex-start;
> button:last-child {
margin-bottom: 0.5rem;
}
.ai-custom-prompt {
padding: 0.5rem;
margin-bottom: 0;
}
}
&__options-button {
padding: 0.65rem 1rem;
display: block;
width: 100%;
text-align: left;
}
&__suggestion {
padding: 0.5rem;
display: flex;
@ -601,3 +543,66 @@
gap: 0.5rem;
color: var(--primary-high);
}
.ai-post-helper-menu__selected-text {
padding: 0.75rem 1rem;
margin: 0.5rem;
border: 1px solid var(--primary-low-mid);
border-radius: var(--d-border-radius);
h2 {
font-size: var(--font-0);
}
p {
@include ellipsis;
font-size: var(--font-down-2);
margin: 0;
}
}
// Mobile Bottom Modal
.ai-post-helper-menu-content {
.ai-helper-loading {
justify-content: center;
}
.ai-helper-options__button {
padding: 1rem;
}
}
// AI Helper Options List
.ai-helper-options {
margin: 0;
list-style: none;
padding: 0.5rem;
li:not(:last-child) {
border-bottom: 1px solid var(--primary-low);
}
&__button {
justify-content: left;
text-align: left;
background: none;
width: 100%;
border-radius: 0;
margin: 0;
padding-block: 0.6rem;
.d-button-label {
color: var(--primary-very-high);
}
&:focus,
&:hover {
color: var(--primary);
background: var(--d-hover);
.d-icon {
color: var(--primary-medium);
}
}
}
}

View File

@ -303,6 +303,7 @@ en:
cancel: "Cancel"
insert_footnote: "Add footnote"
footnote_credits: "Explanation by AI"
selected_text: "Selected Text"
fast_edit:
suggest_button: "Suggest Edit"
thumbnail_suggestions:

View File

@ -23,7 +23,7 @@ RSpec.describe "AI Post helper", type: :system, js: true do
)
end
let(:topic_page) { PageObjects::Pages::Topic.new }
let(:post_ai_helper) { PageObjects::Components::AIHelperPostOptions.new }
let(:post_ai_helper) { PageObjects::Components::AiPostHelperMenu.new }
let(:fast_editor) { PageObjects::Components::FastEditor.new }
before do
@ -57,6 +57,12 @@ RSpec.describe "AI Post helper", type: :system, js: true do
expect(post_ai_helper).to have_post_ai_helper_options
end
it "should not have the mobile post AI helper" do
select_post_text(post)
post_ai_helper.click_ai_button
expect(post_ai_helper).to have_no_mobile_post_ai_helper
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
@ -200,4 +206,12 @@ RSpec.describe "AI Post helper", type: :system, js: true do
end
end
end
context "when triggering post AI helper on mobile", mobile: true do
it "should use the bottom modal instead of the popup menu" do
select_post_text(post)
post_ai_helper.click_ai_button
expect(post_ai_helper).to have_mobile_post_ai_helper
end
end
end

View File

@ -6,7 +6,7 @@ module PageObjects
COMPOSER_EDITOR_SELECTOR = ".d-editor-input"
CONTEXT_MENU_SELECTOR = ".ai-helper-context-menu"
TRIGGER_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__trigger"
OPTIONS_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__options"
OPTIONS_STATE_SELECTOR = ".ai-helper-options"
LOADING_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__loading"
RESETS_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__resets"
REVIEW_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__review"
@ -19,7 +19,9 @@ module PageObjects
end
def select_helper_model(mode)
find("#{OPTIONS_STATE_SELECTOR} li[data-value=\"#{mode}\"] .btn").click
find(
"#{OPTIONS_STATE_SELECTOR} li[data-value=\"#{mode}\"] .ai-helper-options__button",
).click
end
def click_undo_button

View File

@ -2,15 +2,16 @@
module PageObjects
module Components
class AIHelperPostOptions < PageObjects::Components::Base
class AiPostHelperMenu < PageObjects::Components::Base
POST_SELECTION_TOOLBAR_SELECTOR = ".quote-button"
QUOTE_SELECTOR = ".insert-quote"
EDIT_SELECTOR = ".quote-edit-label"
SHARE_SELECTOR = ".quote-sharing"
AI_HELPER_SELECTOR = ".ai-post-helper"
AI_HELPER_MOBILE_SELECTOR = ".ai-post-helper-menu-content"
TRIGGER_SELECTOR = "#{AI_HELPER_SELECTOR}__trigger"
OPTIONS_SELECTOR = "#{AI_HELPER_SELECTOR}__options"
OPTIONS_SELECTOR = ".ai-helper-options"
LOADING_SELECTOR = ".ai-helper-context-menu__loading"
SUGGESTION_SELECTOR = "#{AI_HELPER_SELECTOR}__suggestion"
HIGHLIGHT_SELECTOR = ".ai-helper-highlighted-selection"
@ -39,6 +40,14 @@ module PageObjects
page.has_no_css?(HIGHLIGHT_SELECTOR)
end
def has_mobile_post_ai_helper?
page.has_css?(AI_HELPER_MOBILE_SELECTOR)
end
def has_no_mobile_post_ai_helper?
page.has_no_css?(AI_HELPER_MOBILE_SELECTOR)
end
def has_post_ai_helper?
page.has_css?(AI_HELPER_SELECTOR)
end