FEATURE: Show post helper as bottom modal on mobile (#704)
This commit is contained in:
@@ -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"}}
|
||||
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}} />
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
-2
@@ -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
|
||||
Reference in New Issue
Block a user