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"; import i18n from "discourse-common/helpers/i18n";
const AiHelperLoading = <template> const AiHelperLoading = <template>
<div class="ai-helper-context-menu__loading"> <div class="ai-helper-loading">
<div class="dot-falling"></div> <div class="dot-falling"></div>
<span> <span>
{{i18n "discourse_ai.ai_helper.context_menu.loading"}} {{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> </ul>
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.options)}} {{else if (eq this.menuState this.CONTEXT_MENU_STATES.options)}}
<ul class="ai-helper-context-menu__options"> <AiHelperOptionsList
{{#each this.helperOptions as |option|}} @options={{this.helperOptions}}
{{#if (eq option.name "custom_prompt")}} @customPromptValue={{this.customPromptValue}}
<AiHelperCustomPrompt @performAction={{this.updateSelected}}
@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>
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.loading)}} {{else if (eq this.menuState this.CONTEXT_MENU_STATES.loading)}}
<AiHelperLoading @cancel={{this.cancelAIAction}} /> <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; 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 { &__resets {
display: flex; display: flex;
align-items: center; 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 { .d-editor-input.loading {
animation: loading-text 1.5s infinite linear; animation: loading-text 1.5s infinite linear;
} }
@ -329,30 +295,6 @@
} }
.ai-post-helper { .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 { &__suggestion {
padding: 0.5rem; padding: 0.5rem;
display: flex; display: flex;
@ -601,3 +543,66 @@
gap: 0.5rem; gap: 0.5rem;
color: var(--primary-high); 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" cancel: "Cancel"
insert_footnote: "Add footnote" insert_footnote: "Add footnote"
footnote_credits: "Explanation by AI" footnote_credits: "Explanation by AI"
selected_text: "Selected Text"
fast_edit: fast_edit:
suggest_button: "Suggest Edit" suggest_button: "Suggest Edit"
thumbnail_suggestions: thumbnail_suggestions:

View File

@ -23,7 +23,7 @@ RSpec.describe "AI Post helper", type: :system, js: true do
) )
end end
let(:topic_page) { PageObjects::Pages::Topic.new } 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 } let(:fast_editor) { PageObjects::Components::FastEditor.new }
before do 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 expect(post_ai_helper).to have_post_ai_helper_options
end 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 it "highlights the selected text after clicking the AI button and removes after closing" do
select_post_text(post) select_post_text(post)
post_ai_helper.click_ai_button post_ai_helper.click_ai_button
@ -200,4 +206,12 @@ RSpec.describe "AI Post helper", type: :system, js: true do
end end
end 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 end

View File

@ -6,7 +6,7 @@ module PageObjects
COMPOSER_EDITOR_SELECTOR = ".d-editor-input" COMPOSER_EDITOR_SELECTOR = ".d-editor-input"
CONTEXT_MENU_SELECTOR = ".ai-helper-context-menu" CONTEXT_MENU_SELECTOR = ".ai-helper-context-menu"
TRIGGER_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__trigger" 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" LOADING_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__loading"
RESETS_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__resets" RESETS_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__resets"
REVIEW_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__review" REVIEW_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__review"
@ -19,7 +19,9 @@ module PageObjects
end end
def select_helper_model(mode) 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 end
def click_undo_button def click_undo_button

View File

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