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}} {{#if this.embedQuoteButton}}
<DButton <DButton
@class="btn-flat insert-quote" @class="btn-flat insert-quote"
@action={{action "insertQuote"}} @action={{this.insertQuote}}
@icon="quote-left" @icon="quote-left"
@label="post.quote_reply" @label="post.quote_reply"
@title="post.quote_reply_shortcut" @title="post.quote_reply_shortcut"
@ -13,7 +13,7 @@
{{#if this._canEditPost}} {{#if this._canEditPost}}
<DButton <DButton
@icon="pencil-alt" @icon="pencil-alt"
@action={{action "_toggleFastEditForm"}} @action={{this._toggleFastEditForm}}
@label="post.quote_edit" @label="post.quote_edit"
@class="btn-flat quote-edit-label" @class="btn-flat quote-edit-label"
@title="post.quote_edit_shortcut" @title="post.quote_edit_shortcut"
@ -50,21 +50,13 @@
</div> </div>
<div class="extra"> <div class="extra">
{{#if this.siteSettings.enable_fast_edit}} {{#if this._displayFastEditInput}}
{{#if this._displayFastEditInput}} <FastEdit
<div class="fast-edit-container"> @initialValue={{this._fastEditInitialSelection}}
<Textarea id="fast-edit-input" @value={{this._fastEditNewSelection}} /> @post={{this.post}}
<DButton @afterSave={{this._hideButton}}
@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}} {{/if}}
<PluginOutlet @name="quote-button-after" @connectorTagName="div" /> <PluginOutlet @name="quote-button-after" @connectorTagName="div" />
</div> </div>

View File

@ -1,20 +1,16 @@
import { propertyEqual } from "discourse/lib/computed";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { import {
postUrl, postUrl,
selectedElement, selectedElement,
selectedRange, selectedRange,
selectedText, selectedText,
setCaretPosition, setCaretPosition,
translateModKey,
} from "discourse/lib/utilities"; } from "discourse/lib/utilities";
import Component from "@ember/component"; import Component from "@ember/component";
import I18n from "I18n";
import { INPUT_DELAY } from "discourse-common/config/environment"; import { INPUT_DELAY } from "discourse-common/config/environment";
import KeyEnterEscape from "discourse/mixins/key-enter-escape"; import KeyEnterEscape from "discourse/mixins/key-enter-escape";
import Sharing from "discourse/lib/sharing"; import Sharing from "discourse/lib/sharing";
import { action } from "@ember/object"; import { action, computed } from "@ember/object";
import { alias } from "@ember/object/computed"; import { alias } from "@ember/object/computed";
import discourseComputed, { bind } from "discourse-common/utils/decorators"; import discourseComputed, { bind } from "discourse-common/utils/decorators";
import discourseDebounce from "discourse-common/lib/debounce"; 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 escapeRegExp from "discourse-common/utils/escape-regexp";
import { createPopper } from "@popperjs/core"; import { createPopper } from "@popperjs/core";
import virtualElementFromTextRange from "discourse/lib/virtual-element-from-text-range"; 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) { function getQuoteTitle(element) {
const titleEl = element.querySelector(".title"); const titleEl = element.querySelector(".title");
@ -39,13 +37,15 @@ function getQuoteTitle(element) {
return titleEl.textContent.trim().replace(/:$/, ""); return titleEl.textContent.trim().replace(/:$/, "");
} }
function fixQuotes(str) { export function fixQuotes(str) {
// u+201c, u+201d = “ ” // u+201c, u+201d = “ ”
// u+2018, u+2019 = // u+2018, u+2019 =
return str.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'"); return str.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'");
} }
export default Component.extend(KeyEnterEscape, { export default Component.extend(KeyEnterEscape, {
modal: service(),
classNames: ["quote-button"], classNames: ["quote-button"],
classNameBindings: [ classNameBindings: [
"visible", "visible",
@ -63,16 +63,12 @@ export default Component.extend(KeyEnterEscape, {
_isFastEditable: false, _isFastEditable: false,
_displayFastEditInput: false, _displayFastEditInput: false,
_fastEditInitialSelection: null, _fastEditInitialSelection: null,
_fastEditNewSelection: null,
_isSavingFastEdit: false,
_canEditPost: false, _canEditPost: false,
_saveEditButtonTitle: I18n.t("composer.title", {
modifier: translateModKey("Meta+"),
}),
_isMouseDown: false, _isMouseDown: false,
_reselected: false, _reselected: false,
@bind
_hideButton() { _hideButton() {
this.quoteState.clear(); this.quoteState.clear();
this.set("visible", false); this.set("visible", false);
@ -81,7 +77,6 @@ export default Component.extend(KeyEnterEscape, {
this.set("_isFastEditable", false); this.set("_isFastEditable", false);
this.set("_displayFastEditInput", false); this.set("_displayFastEditInput", false);
this.set("_fastEditInitialSelection", null); this.set("_fastEditInitialSelection", null);
this.set("_fastEditNewSelection", null);
this._teardownSelectionListeners(); this._teardownSelectionListeners();
}, },
@ -157,10 +152,7 @@ export default Component.extend(KeyEnterEscape, {
this.set("visible", quoteState.buffer.length > 0); this.set("visible", quoteState.buffer.length > 0);
if (this.siteSettings.enable_fast_edit) { if (this.siteSettings.enable_fast_edit) {
this.set( this.set("_canEditPost", this.post?.can_edit);
"_canEditPost",
this.topic.postStream.findLoadedPost(postId)?.can_edit
);
if (this._canEditPost) { if (this._canEditPost) {
const regexp = new RegExp(escapeRegExp(quoteState.buffer), "gi"); const regexp = new RegExp(escapeRegExp(quoteState.buffer), "gi");
@ -176,11 +168,9 @@ export default Component.extend(KeyEnterEscape, {
) { ) {
this.set("_isFastEditable", false); this.set("_isFastEditable", false);
this.set("_fastEditInitialSelection", null); this.set("_fastEditInitialSelection", null);
this.set("_fastEditNewSelection", null);
} else if (matches?.length === 1) { } else if (matches?.length === 1) {
this.set("_isFastEditable", true); this.set("_isFastEditable", true);
this.set("_fastEditInitialSelection", quoteState.buffer); this.set("_fastEditInitialSelection", quoteState.buffer);
this.set("_fastEditNewSelection", quoteState.buffer);
} }
} }
} }
@ -313,6 +303,11 @@ export default Component.extend(KeyEnterEscape, {
this._teardownSelectionListeners(); this._teardownSelectionListeners();
}, },
@computed("topic", "quoteState.postId")
get post() {
return this.topic.postStream.findLoadedPost(this.quoteState.postId);
},
@discourseComputed("topic.{isPrivateMessage,invisible,category}") @discourseComputed("topic.{isPrivateMessage,invisible,category}")
quoteSharingEnabled(topic) { quoteSharingEnabled(topic) {
if ( if (
@ -343,11 +338,11 @@ export default Component.extend(KeyEnterEscape, {
return this.quoteSharingSources.length > 1; return this.quoteSharingSources.length > 1;
}, },
@discourseComputed("topic.{id,slug}", "quoteState") @computed("topic.{id,slug}", "post")
shareUrl(topic, quoteState) { get shareUrl() {
const postId = quoteState.postId; return getAbsoluteURL(
const postNumber = topic.postStream.findLoadedPost(postId).post_number; postUrl(this.topic.slug, this.topic.id, this.post.post_number)
return getAbsoluteURL(postUrl(topic.slug, topic.id, postNumber)); );
}, },
@discourseComputed( @discourseComputed(
@ -361,113 +356,69 @@ export default Component.extend(KeyEnterEscape, {
); );
}, },
_saveFastEditDisabled: propertyEqual(
"_fastEditInitialSelection",
"_fastEditNewSelection"
),
@action @action
insertQuote() { insertQuote() {
this.attrs.selectText().then(() => this._hideButton()); this.attrs.selectText().then(() => this._hideButton());
}, },
@action @action
_toggleFastEditForm() { async _toggleFastEditForm() {
if (this._isFastEditable) { 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", () => { return;
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();
});
}
);
} }
},
@action const result = await ajax(`/posts/${this.post.id}`, { cache: false });
_saveFastEdit() { let bestIndex = 0;
const postId = this.quoteState?.postId; const rows = result.raw.split("\n");
const postModel = this.topic.postStream.findLoadedPost(postId);
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 }) rows.some((row, index) => {
.then((result) => { if (row.length && row.includes(buffer)) {
const newRaw = result.raw.replace( bestIndex = index;
fixQuotes(this._fastEditInitialSelection), return true;
fixQuotes(this._fastEditNewSelection) }
});
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 // ensures we correctly scroll to caret and reloads composer
.save({ raw: newRaw }) // if we do another selection/edit
.catch(popupAjaxError) textarea.blur();
.finally(() => { textarea.focus();
this.set("_isSavingFastEdit", false); });
this._hideButton();
});
})
.catch(popupAjaxError);
},
@action
save() {
if (this._displayFastEditInput && !this._saveFastEditDisabled) {
this._saveFastEdit();
}
},
@action
cancelled() {
this._hideButton();
}, },
@action @action

View File

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