REFACTOR: AI Composer Helper Menu (#715)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
import DButton from "discourse/components/d-button";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
|
||||
const AiHelperButtonGroup = <template>
|
||||
<ul class="ai-helper-button-group" ...attributes>
|
||||
{{#each @buttons as |button|}}
|
||||
<li>
|
||||
<DButton
|
||||
@icon={{button.icon}}
|
||||
@label={{button.label}}
|
||||
@action={{button.action}}
|
||||
class={{concatClass "btn-flat" button.classes}}
|
||||
/>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</template>;
|
||||
|
||||
export default AiHelperButtonGroup;
|
||||
@@ -1,38 +1,40 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { Input } from "@ember/component";
|
||||
import { fn } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import withEventValue from "discourse/helpers/with-event-value";
|
||||
import autoFocus from "discourse/modifiers/auto-focus";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import not from "truth-helpers/helpers/not";
|
||||
|
||||
export default class AiHelperCustomPrompt extends Component {
|
||||
@tracked _customPromptInput;
|
||||
|
||||
@action
|
||||
setupCustomPrompt() {
|
||||
this._customPromptInput = document.querySelector(
|
||||
".ai-custom-prompt__input"
|
||||
);
|
||||
this._customPromptInput.focus();
|
||||
}
|
||||
|
||||
@action
|
||||
sendInput() {
|
||||
sendInput(event) {
|
||||
if (event.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
return this.args.submit(this.args.promptArgs);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="ai-custom-prompt" {{didInsert this.setupCustomPrompt}}>
|
||||
<Input
|
||||
@value={{@value}}
|
||||
<div class="ai-custom-prompt">
|
||||
|
||||
<input
|
||||
{{on "input" (withEventValue (fn (mut @value)))}}
|
||||
{{on "keydown" this.sendInput}}
|
||||
value={{@value}}
|
||||
placeholder={{i18n
|
||||
"discourse_ai.ai_helper.context_menu.custom_prompt.placeholder"
|
||||
}}
|
||||
class="ai-custom-prompt__input"
|
||||
@enter={{this.sendInput}}
|
||||
type="text"
|
||||
{{!-- Using {{autoFocus}} helper instead of built in autofocus="autofocus"
|
||||
because built in autofocus doesn't work consistently when component is
|
||||
invoked twice separetly without a page refresh.
|
||||
(i.e. trigger in post AI helper followed by trigger in composer AI helper)
|
||||
--}}
|
||||
{{autoFocus}}
|
||||
/>
|
||||
|
||||
<DButton
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { modifier } from "ember-modifier";
|
||||
import { caretPosition, getCaretPosition } from "discourse/lib/utilities";
|
||||
import { INPUT_DELAY } from "discourse-common/config/environment";
|
||||
import { afterRender, bind, debounce } from "discourse-common/utils/decorators";
|
||||
import AiComposerHelperMenu from "../../components/ai-composer-helper-menu";
|
||||
import { showComposerAIHelper } from "../../lib/show-ai-helper";
|
||||
import virtualElementFromCaretCoords from "../../lib/virtual-element-from-caret-coords";
|
||||
|
||||
export default class AiComposerHelper extends Component {
|
||||
static shouldRender(outletArgs, helper) {
|
||||
return showComposerAIHelper(outletArgs, helper, "context_menu");
|
||||
}
|
||||
|
||||
@service menu;
|
||||
@service aiComposerHelper;
|
||||
@tracked caretCoords;
|
||||
@tracked menuPlacement = "bottom-start";
|
||||
@tracked menuOffset = [9, 21];
|
||||
@tracked selectedText = "";
|
||||
@tracked isSelecting = false;
|
||||
@tracked menuElement = null;
|
||||
@tracked menuInstance = null;
|
||||
@tracked dEditorInput;
|
||||
@tracked selectionRange = { x: 0, y: 0 };
|
||||
minSelectionChars = 3;
|
||||
|
||||
documentListeners = modifier(() => {
|
||||
document.addEventListener("mousedown", this.onMouseDown, { passive: true });
|
||||
document.addEventListener("mouseup", this.onMouseUp, { passive: true });
|
||||
document.addEventListener("selectionchange", this.onSelectionChanged);
|
||||
|
||||
this.dEditorInput = document.querySelector(".d-editor-input");
|
||||
|
||||
if (this.dEditorInput) {
|
||||
this.dEditorInput.addEventListener("scroll", this.updatePosition);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", this.onMouseDown);
|
||||
document.removeEventListener("mouseup", this.onMouseUp);
|
||||
document.removeEventListener("selectionchange", this.onSelectionChanged);
|
||||
|
||||
if (this.dEditorInput) {
|
||||
this.dEditorInput.removeEventListener("scroll", this.updatePosition);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
this.menuInstance?.close();
|
||||
}
|
||||
|
||||
@bind
|
||||
onSelectionChanged() {
|
||||
if (
|
||||
this.aiComposerHelper.menuState !==
|
||||
this.aiComposerHelper.MENU_STATES.triggers
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isSelecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.activeElement !== this.dEditorInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canSelect = Boolean(
|
||||
window.getSelection() &&
|
||||
document.activeElement &&
|
||||
document.activeElement.value
|
||||
);
|
||||
|
||||
this.selectedText = canSelect
|
||||
? document.activeElement.value.substring(
|
||||
document.activeElement.selectionStart,
|
||||
document.activeElement.selectionEnd
|
||||
)
|
||||
: "";
|
||||
|
||||
this.selectionRange = canSelect
|
||||
? {
|
||||
x: document.activeElement.selectionStart,
|
||||
y: document.activeElement.selectionEnd,
|
||||
}
|
||||
: { x: 0, y: 0 };
|
||||
|
||||
if (this.selectedText?.length === 0) {
|
||||
this.menuInstance?.close();
|
||||
return;
|
||||
}
|
||||
if (this.selectedText?.length < this.minSelectionChars) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectionChanged();
|
||||
}
|
||||
|
||||
@debounce(INPUT_DELAY)
|
||||
selectionChanged() {
|
||||
this.positionMenu();
|
||||
}
|
||||
|
||||
@bind
|
||||
onMouseDown() {
|
||||
this.isSelecting = true;
|
||||
}
|
||||
|
||||
@bind
|
||||
onMouseUp() {
|
||||
this.isSelecting = false;
|
||||
this.onSelectionChanged();
|
||||
}
|
||||
|
||||
@bind
|
||||
updatePosition() {
|
||||
if (!this.menuInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.positionMenu();
|
||||
}
|
||||
|
||||
@afterRender
|
||||
async positionMenu() {
|
||||
this.caretCoords = getCaretPosition(this.dEditorInput, {
|
||||
pos: caretPosition(this.dEditorInput),
|
||||
});
|
||||
const virtualElement = virtualElementFromCaretCoords(
|
||||
this.caretCoords,
|
||||
this.menuOffset
|
||||
);
|
||||
|
||||
if (this.handleBoundaries(virtualElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Position context menu at based on if interfering with button bar
|
||||
this.menuInstance = await this.menu.show(virtualElement, {
|
||||
identifier: "ai-composer-helper-menu",
|
||||
placement: this.menuPlacement,
|
||||
component: AiComposerHelperMenu,
|
||||
inline: true,
|
||||
modalForMobile: false,
|
||||
data: {
|
||||
selectedText: this.selectedText,
|
||||
dEditorInput: this.dEditorInput,
|
||||
selectionRange: this.selectionRange,
|
||||
},
|
||||
interactive: true,
|
||||
onClose: () => {
|
||||
this.aiComposerHelper.menuState =
|
||||
this.aiComposerHelper.MENU_STATES.triggers;
|
||||
},
|
||||
});
|
||||
this.menuElement = this.menuInstance.content;
|
||||
}
|
||||
|
||||
handleBoundaries(virtualElement) {
|
||||
const textAreaWrapper = document
|
||||
.querySelector(".d-editor-textarea-wrapper")
|
||||
.getBoundingClientRect();
|
||||
const buttonBar = document
|
||||
.querySelector(".d-editor-button-bar")
|
||||
.getBoundingClientRect();
|
||||
const boundaryElement = {
|
||||
top: buttonBar.bottom,
|
||||
bottom: textAreaWrapper.bottom,
|
||||
};
|
||||
|
||||
const menuHeightBuffer = 35; // rough estimate of menu height since we can't get actual in this context.
|
||||
if (this.caretCoords.y - menuHeightBuffer < boundaryElement.top) {
|
||||
this.menuPlacement = "bottom-start";
|
||||
} else {
|
||||
this.menuPlacement = "top-start";
|
||||
}
|
||||
|
||||
if (this.isScrolledOutOfBounds(boundaryElement, virtualElement)) {
|
||||
this.menuInstance?.close();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
isScrolledOutOfBounds(boundaryElement, virtualElement) {
|
||||
// Hide context menu if it's scrolled out of bounds:
|
||||
if (virtualElement.rect.top < boundaryElement.top) {
|
||||
return true;
|
||||
} else if (virtualElement.rect.bottom > boundaryElement.bottom) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div {{this.documentListeners}}></div>
|
||||
</template>
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
<div {{did-insert this.setupContextMenu}}>
|
||||
{{#if this.showContextMenu}}
|
||||
<div class="ai-helper-context-menu">
|
||||
{{#if (eq this.menuState this.CONTEXT_MENU_STATES.triggers)}}
|
||||
<ul class="ai-helper-context-menu__trigger">
|
||||
<li>
|
||||
<DButton
|
||||
@icon="discourse-sparkles"
|
||||
@action={{this.toggleAiHelperOptions}}
|
||||
@label="discourse_ai.ai_helper.context_menu.trigger"
|
||||
class="btn-flat"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.options)}}
|
||||
<AiHelperOptionsList
|
||||
@options={{this.helperOptions}}
|
||||
@customPromptValue={{this.customPromptValue}}
|
||||
@performAction={{this.updateSelected}}
|
||||
/>
|
||||
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.loading)}}
|
||||
<AiHelperLoading @cancel={{this.cancelAIAction}} />
|
||||
|
||||
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.review)}}
|
||||
<ul class="ai-helper-context-menu__review">
|
||||
<li>
|
||||
<DButton
|
||||
@icon="exchange-alt"
|
||||
@label="discourse_ai.ai_helper.context_menu.view_changes"
|
||||
@action={{this.viewChanges}}
|
||||
class="btn-flat view-changes"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<DButton
|
||||
@icon="undo"
|
||||
@label="discourse_ai.ai_helper.context_menu.revert"
|
||||
@action={{this.undoAIAction}}
|
||||
class="btn-flat revert"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<DButton
|
||||
@icon="check"
|
||||
@label="discourse_ai.ai_helper.context_menu.confirm"
|
||||
@action={{this.confirmChanges}}
|
||||
class="btn-flat confirm"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.resets)}}
|
||||
<ul class="ai-helper-context-menu__resets">
|
||||
<li>
|
||||
<DButton
|
||||
@icon="undo"
|
||||
@label="discourse_ai.ai_helper.context_menu.undo"
|
||||
@action={{this.undoAIAction}}
|
||||
class="btn-flat undo"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<DButton
|
||||
@icon="discourse-sparkles"
|
||||
@label="discourse_ai.ai_helper.context_menu.regen"
|
||||
@action={{fn this.updateSelected this.lastUsedOption}}
|
||||
class="btn-flat"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if this.showDiffModal}}
|
||||
<Modal::DiffModal
|
||||
@confirm={{this.confirmChanges}}
|
||||
@diff={{this.diff}}
|
||||
@oldValue={{this.initialValue}}
|
||||
@newValue={{this.newSelectedText}}
|
||||
@revert={{this.undoAIAction}}
|
||||
@closeModal={{fn (mut this.showDiffModal) false}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.showThumbnailModal}}
|
||||
<Modal::ThumbnailSuggestions
|
||||
@thumbnails={{this.thumbnailSuggestions}}
|
||||
@closeModal={{fn (mut this.showThumbnailModal) false}}
|
||||
/>
|
||||
{{/if}}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
class VirtualElementFromCaretCoords {
|
||||
constructor(caretCoords, offset = [0, 0]) {
|
||||
this.caretCoords = caretCoords;
|
||||
this.offset = offset;
|
||||
this.updateRect();
|
||||
}
|
||||
|
||||
updateRect() {
|
||||
const [xOffset, yOffset] = this.offset;
|
||||
this.rect = {
|
||||
top: this.caretCoords.y + yOffset,
|
||||
right: this.caretCoords.x,
|
||||
bottom: this.caretCoords.y + yOffset,
|
||||
left: this.caretCoords.x + xOffset,
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: this.caretCoords.x,
|
||||
y: this.caretCoords.y,
|
||||
toJSON() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
return this.rect;
|
||||
}
|
||||
|
||||
getBoundingClientRect() {
|
||||
return this.rect;
|
||||
}
|
||||
|
||||
getClientRects() {
|
||||
return [this.rect];
|
||||
}
|
||||
|
||||
get clientWidth() {
|
||||
return this.rect.width;
|
||||
}
|
||||
|
||||
get clientHeight() {
|
||||
return this.rect.height;
|
||||
}
|
||||
}
|
||||
|
||||
export default function virtualElementFromCaretCoords(caretCoords, offset) {
|
||||
return new VirtualElementFromCaretCoords(caretCoords, offset);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import Service from "@ember/service";
|
||||
|
||||
export default class AiComposerHelper extends Service {
|
||||
@tracked menuState = this.MENU_STATES.triggers;
|
||||
|
||||
MENU_STATES = {
|
||||
triggers: "TRIGGERS",
|
||||
options: "OPTIONS",
|
||||
resets: "RESETS",
|
||||
loading: "LOADING",
|
||||
review: "REVIEW",
|
||||
};
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
margin: 4.5em 0 1em;
|
||||
}
|
||||
|
||||
.ai-helper-context-menu {
|
||||
.ai-composer-helper-menu {
|
||||
background: var(--secondary);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 0.25rem;
|
||||
@@ -40,16 +40,16 @@
|
||||
list-style: none;
|
||||
z-index: 999;
|
||||
|
||||
&.out-of-bounds {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-helper-button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-flow: row wrap;
|
||||
&__resets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -606,3 +606,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fk-d-menu[data-identifier="ai-composer-helper-menu"] {
|
||||
z-index: z("composer", "dropdown");
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||
let(:input) { "The rain in spain stays mainly in the Plane." }
|
||||
|
||||
let(:composer) { PageObjects::Components::Composer.new }
|
||||
let(:ai_helper_context_menu) { PageObjects::Components::AIHelperContextMenu.new }
|
||||
let(:ai_helper_context_menu) { PageObjects::Components::AiComposerHelperMenu.new }
|
||||
let(:diff_modal) { PageObjects::Modals::DiffModal.new }
|
||||
let(:ai_suggestion_dropdown) { PageObjects::Components::AISuggestionDropdown.new }
|
||||
fab!(:category)
|
||||
@@ -239,21 +239,7 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||
input.gsub(/[[:space:]]+/, " ").gsub(/[‘’]/, "'").gsub(/[“”]/, '"').strip,
|
||||
)
|
||||
diff_modal.confirm_changes
|
||||
expect(ai_helper_context_menu).to be_showing_resets
|
||||
end
|
||||
end
|
||||
|
||||
it "should not close the context menu when in review state" do
|
||||
trigger_context_menu(spanish_input)
|
||||
ai_helper_context_menu.click_ai_button
|
||||
|
||||
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
||||
ai_helper_context_menu.select_helper_model(mode)
|
||||
|
||||
wait_for { composer.composer_input.value == input }
|
||||
|
||||
find(".d-editor-preview").click
|
||||
expect(ai_helper_context_menu).to have_context_menu
|
||||
expect(ai_helper_context_menu).to have_no_context_menu
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+5
-5
@@ -2,12 +2,12 @@
|
||||
|
||||
module PageObjects
|
||||
module Components
|
||||
class AIHelperContextMenu < PageObjects::Components::Base
|
||||
class AiComposerHelperMenu < PageObjects::Components::Base
|
||||
COMPOSER_EDITOR_SELECTOR = ".d-editor-input"
|
||||
CONTEXT_MENU_SELECTOR = ".ai-helper-context-menu"
|
||||
TRIGGER_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__trigger"
|
||||
CONTEXT_MENU_SELECTOR = ".ai-composer-helper-menu"
|
||||
TRIGGER_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__triggers"
|
||||
OPTIONS_STATE_SELECTOR = ".ai-helper-options"
|
||||
LOADING_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__loading"
|
||||
LOADING_STATE_SELECTOR = ".ai-helper-loading"
|
||||
RESETS_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__resets"
|
||||
REVIEW_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__review"
|
||||
CUSTOM_PROMPT_SELECTOR = "#{CONTEXT_MENU_SELECTOR} .ai-custom-prompt"
|
||||
@@ -41,7 +41,7 @@ module PageObjects
|
||||
end
|
||||
|
||||
def press_undo_keys
|
||||
find(COMPOSER_EDITOR_SELECTOR).send_keys([:control, "z"])
|
||||
find(COMPOSER_EDITOR_SELECTOR).send_keys([PLATFORM_KEY_MODIFIER, "z"])
|
||||
end
|
||||
|
||||
def press_escape_key
|
||||
Reference in New Issue
Block a user