DEV: Convert fast-edit on mobile to a modal (#22570)

Motivation: to fix an issue with fast-edit positioning on mobile (android) Trying to correctly position that textarea/popper element proved difficult

see: https://meta.discourse.org/t/fast-edit-input-container-position/263190
This commit is contained in:
Jarek Radosz 2023-07-19 11:43:00 +02:00 committed by GitHub
parent 70cebfb6ab
commit bdd97ff931
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 160 additions and 133 deletions

View File

@ -0,0 +1,17 @@
<div class="fast-edit-container">
<textarea
{{auto-focus}}
{{on "input" this.updateValue}}
id="fast-edit-input"
>{{@initialValue}}</textarea>
<DButton
class="btn-small btn-primary save-fast-edit"
@action={{this.save}}
@icon="pencil-alt"
@label="composer.save_edit"
@translatedTitle={{this.buttonTitle}}
@isLoading={{this.isSaving}}
@disabled={{eq @initialValue this.value}}
/>
</div>

View File

@ -0,0 +1,42 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { fixQuotes } from "discourse/components/quote-button";
import { translateModKey } from "discourse/lib/utilities";
import I18n from "I18n";
export default class FastEdit extends Component {
@tracked value = this.args.initialValue;
@tracked isSaving = false;
buttonTitle = I18n.t("composer.title", {
modifier: translateModKey("Meta+"),
});
@action
updateValue(event) {
this.value = event.target.value;
}
@action
async save() {
this.isSaving = true;
try {
const result = await ajax(`/posts/${this.args.post.id}`);
const newRaw = result.raw.replace(
fixQuotes(this.args.initialValue),
fixQuotes(this.value)
);
await this.args.post.save({ raw: newRaw });
} catch (error) {
popupAjaxError(error);
} finally {
this.isSaving = false;
this.args.afterSave?.();
}
}
}

View File

@ -0,0 +1,7 @@
<DModal @title={{i18n "post.quote_edit"}} @closeModal={{@closeModal}}>
<FastEdit
@initialValue={{@model.initialValue}}
@post={{@model.post}}
@afterSave={{@closeModal}}
/>
</DModal>

View File

@ -2,7 +2,7 @@
{{#if this.embedQuoteButton}}
<DButton
@class="btn-flat insert-quote"
@action={{action "insertQuote"}}
@action={{this.insertQuote}}
@icon="quote-left"
@label="post.quote_reply"
@title="post.quote_reply_shortcut"
@ -13,7 +13,7 @@
{{#if this._canEditPost}}
<DButton
@icon="pencil-alt"
@action={{action "_toggleFastEditForm"}}
@action={{this._toggleFastEditForm}}
@label="post.quote_edit"
@class="btn-flat quote-edit-label"
@title="post.quote_edit_shortcut"
@ -50,21 +50,13 @@
</div>
<div class="extra">
{{#if this.siteSettings.enable_fast_edit}}
{{#if this._displayFastEditInput}}
<div class="fast-edit-container">
<Textarea id="fast-edit-input" @value={{this._fastEditNewSelection}} />
<DButton
@action={{action "_saveFastEdit"}}
@class="btn-small btn-primary save-fast-edit"
@icon="pencil-alt"
@label="composer.save_edit"
@translatedTitle={{this._saveEditButtonTitle}}
@disabled={{this._saveFastEditDisabled}}
@isLoading={{this._isSavingFastEdit}}
/>
</div>
{{/if}}
{{#if this._displayFastEditInput}}
<FastEdit
@initialValue={{this._fastEditInitialSelection}}
@post={{this.post}}
@afterSave={{this._hideButton}}
/>
{{/if}}
<PluginOutlet @name="quote-button-after" @connectorTagName="div" />
</div>

View File

@ -1,20 +1,16 @@
import { propertyEqual } from "discourse/lib/computed";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import {
postUrl,
selectedElement,
selectedRange,
selectedText,
setCaretPosition,
translateModKey,
} from "discourse/lib/utilities";
import Component from "@ember/component";
import I18n from "I18n";
import { INPUT_DELAY } from "discourse-common/config/environment";
import KeyEnterEscape from "discourse/mixins/key-enter-escape";
import Sharing from "discourse/lib/sharing";
import { action } from "@ember/object";
import { action, computed } from "@ember/object";
import { alias } from "@ember/object/computed";
import discourseComputed, { bind } from "discourse-common/utils/decorators";
import discourseDebounce from "discourse-common/lib/debounce";
@ -24,6 +20,8 @@ import toMarkdown from "discourse/lib/to-markdown";
import escapeRegExp from "discourse-common/utils/escape-regexp";
import { createPopper } from "@popperjs/core";
import virtualElementFromTextRange from "discourse/lib/virtual-element-from-text-range";
import { inject as service } from "@ember/service";
import FastEditModal from "discourse/components/modal/fast-edit";
function getQuoteTitle(element) {
const titleEl = element.querySelector(".title");
@ -39,13 +37,15 @@ function getQuoteTitle(element) {
return titleEl.textContent.trim().replace(/:$/, "");
}
function fixQuotes(str) {
export function fixQuotes(str) {
// u+201c, u+201d = “ ”
// u+2018, u+2019 =
return str.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'");
}
export default Component.extend(KeyEnterEscape, {
modal: service(),
classNames: ["quote-button"],
classNameBindings: [
"visible",
@ -63,16 +63,12 @@ export default Component.extend(KeyEnterEscape, {
_isFastEditable: false,
_displayFastEditInput: false,
_fastEditInitialSelection: null,
_fastEditNewSelection: null,
_isSavingFastEdit: false,
_canEditPost: false,
_saveEditButtonTitle: I18n.t("composer.title", {
modifier: translateModKey("Meta+"),
}),
_isMouseDown: false,
_reselected: false,
@bind
_hideButton() {
this.quoteState.clear();
this.set("visible", false);
@ -81,7 +77,6 @@ export default Component.extend(KeyEnterEscape, {
this.set("_isFastEditable", false);
this.set("_displayFastEditInput", false);
this.set("_fastEditInitialSelection", null);
this.set("_fastEditNewSelection", null);
this._teardownSelectionListeners();
},
@ -157,10 +152,7 @@ export default Component.extend(KeyEnterEscape, {
this.set("visible", quoteState.buffer.length > 0);
if (this.siteSettings.enable_fast_edit) {
this.set(
"_canEditPost",
this.topic.postStream.findLoadedPost(postId)?.can_edit
);
this.set("_canEditPost", this.post?.can_edit);
if (this._canEditPost) {
const regexp = new RegExp(escapeRegExp(quoteState.buffer), "gi");
@ -176,11 +168,9 @@ export default Component.extend(KeyEnterEscape, {
) {
this.set("_isFastEditable", false);
this.set("_fastEditInitialSelection", null);
this.set("_fastEditNewSelection", null);
} else if (matches?.length === 1) {
this.set("_isFastEditable", true);
this.set("_fastEditInitialSelection", quoteState.buffer);
this.set("_fastEditNewSelection", quoteState.buffer);
}
}
}
@ -313,6 +303,11 @@ export default Component.extend(KeyEnterEscape, {
this._teardownSelectionListeners();
},
@computed("topic", "quoteState.postId")
get post() {
return this.topic.postStream.findLoadedPost(this.quoteState.postId);
},
@discourseComputed("topic.{isPrivateMessage,invisible,category}")
quoteSharingEnabled(topic) {
if (
@ -343,11 +338,11 @@ export default Component.extend(KeyEnterEscape, {
return this.quoteSharingSources.length > 1;
},
@discourseComputed("topic.{id,slug}", "quoteState")
shareUrl(topic, quoteState) {
const postId = quoteState.postId;
const postNumber = topic.postStream.findLoadedPost(postId).post_number;
return getAbsoluteURL(postUrl(topic.slug, topic.id, postNumber));
@computed("topic.{id,slug}", "post")
get shareUrl() {
return getAbsoluteURL(
postUrl(this.topic.slug, this.topic.id, this.post.post_number)
);
},
@discourseComputed(
@ -361,113 +356,69 @@ export default Component.extend(KeyEnterEscape, {
);
},
_saveFastEditDisabled: propertyEqual(
"_fastEditInitialSelection",
"_fastEditNewSelection"
),
@action
insertQuote() {
this.attrs.selectText().then(() => this._hideButton());
},
@action
_toggleFastEditForm() {
async _toggleFastEditForm() {
if (this._isFastEditable) {
this.toggleProperty("_displayFastEditInput");
if (this.site.desktopView) {
this.toggleProperty("_displayFastEditInput");
} else {
this.modal.show(FastEditModal, {
model: {
initialValue: this._fastEditInitialSelection,
post: this.post,
},
});
this._hideButton();
}
schedule("afterRender", () => {
if (this.site.mobileView) {
this.textRange = document.querySelector("#main-outlet");
this._popper?.update();
}
next(() => document.querySelector("#fast-edit-input")?.focus());
});
} else {
const postId = this.quoteState.postId;
const postModel = this.topic.postStream.findLoadedPost(postId);
return ajax(`/posts/${postModel.id}`, { type: "GET", cache: false }).then(
(result) => {
let bestIndex = 0;
const rows = result.raw.split("\n");
// selecting even a part of the text of a list item will include
// "* " at the beginning of the buffer, we remove it to be able
// to find it in row
const buffer = fixQuotes(
this.quoteState.buffer.split("\n")[0].replace(/^\* /, "")
);
rows.some((row, index) => {
if (row.length && row.includes(buffer)) {
bestIndex = index;
return true;
}
});
this.editPost(postModel);
document
.querySelector("#reply-control")
?.addEventListener("transitionend", () => {
const textarea = document.querySelector(".d-editor-input");
if (!textarea || this.isDestroyed || this.isDestroying) {
return;
}
// best index brings us to one row before as slice start from 1
// we add 1 to be at the beginning of next line, unless we start from top
setCaretPosition(
textarea,
rows.slice(0, bestIndex).join("\n").length +
(bestIndex > 0 ? 1 : 0)
);
// ensures we correctly scroll to caret and reloads composer
// if we do another selection/edit
textarea.blur();
textarea.focus();
});
}
);
return;
}
},
@action
_saveFastEdit() {
const postId = this.quoteState?.postId;
const postModel = this.topic.postStream.findLoadedPost(postId);
const result = await ajax(`/posts/${this.post.id}`, { cache: false });
let bestIndex = 0;
const rows = result.raw.split("\n");
this.set("_isSavingFastEdit", true);
// selecting even a part of the text of a list item will include
// "* " at the beginning of the buffer, we remove it to be able
// to find it in row
const buffer = fixQuotes(
this.quoteState.buffer.split("\n")[0].replace(/^\* /, "")
);
return ajax(`/posts/${postModel.id}`, { type: "GET", cache: false })
.then((result) => {
const newRaw = result.raw.replace(
fixQuotes(this._fastEditInitialSelection),
fixQuotes(this._fastEditNewSelection)
rows.some((row, index) => {
if (row.length && row.includes(buffer)) {
bestIndex = index;
return true;
}
});
this.editPost(this.post);
document
.querySelector("#reply-control")
?.addEventListener("transitionend", () => {
const textarea = document.querySelector(".d-editor-input");
if (!textarea || this.isDestroyed || this.isDestroying) {
return;
}
// best index brings us to one row before as slice start from 1
// we add 1 to be at the beginning of next line, unless we start from top
setCaretPosition(
textarea,
rows.slice(0, bestIndex).join("\n").length + (bestIndex > 0 ? 1 : 0)
);
postModel
.save({ raw: newRaw })
.catch(popupAjaxError)
.finally(() => {
this.set("_isSavingFastEdit", false);
this._hideButton();
});
})
.catch(popupAjaxError);
},
@action
save() {
if (this._displayFastEditInput && !this._saveFastEditDisabled) {
this._saveFastEdit();
}
},
@action
cancelled() {
this._hideButton();
// ensures we correctly scroll to caret and reloads composer
// if we do another selection/edit
textarea.blur();
textarea.focus();
});
},
@action

View File

@ -1,4 +1,10 @@
import { tracked } from "@glimmer/tracking";
export default class QuoteState {
@tracked postId;
@tracked buffer;
@tracked opts;
constructor() {
this.clear();
}

View File

@ -0,0 +1,12 @@
import Modifier from "ember-modifier";
export default class AutoFocusModifier extends Modifier {
didFocus = false;
modify(element) {
if (!this.didFocus) {
element.focus();
this.didFocus = true;
}
}
}