FEATURE: Show post helper as bottom modal on mobile (#704)
This commit is contained in:
parent
8d4a67fbe2
commit
08355ea5d8
|
@ -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"}}
|
||||||
|
|
|
@ -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;
|
|
@ -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>
|
||||||
|
}
|
|
@ -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}} />
|
||||||
|
|
||||||
|
|
|
@ -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>
|
|
||||||
}
|
|
|
@ -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>
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
Loading…
Reference in New Issue