FEATURE: experimental fast edit (#14340)

FEATURE: experimental fast edit (#14340)

Fast edit allows you to quickly edit a typo in a post, this is experimental ATM and behind a site setting: enable_fast_edit (default false)

diff --git a/app/assets/javascripts/discourse/app/components/quote-button.js b/app/assets/javascripts/discourse/app/components/quote-button.js
index b159a25..b5410dd 100644
--- a/app/assets/javascripts/discourse/app/components/quote-button.js
+++ b/app/assets/javascripts/discourse/app/components/quote-button.js
@@ -1,3 +1,6 @@
+import { propertyEqual } from "discourse/lib/computed";
+import { ajax } from "discourse/lib/ajax";
+import { popupAjaxError } from "discourse/lib/ajax-error";
 import {
   postUrl,
   selectedElement,
@@ -28,11 +31,27 @@ function getQuoteTitle(element) {
   return titleEl.textContent.trim().replace(/:$/, "");
 }
 
+function fixQuotes(str) {
+  return str.replace(/‘|’|„|“|«|»|”/g, '"');
+}
+
+function regexSafeStr(str) {
+  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
 export default Component.extend({
   classNames: ["quote-button"],
   classNameBindings: ["visible"],
   visible: false,
   privateCategory: alias("topic.category.read_restricted"),
+  editPost: null,
+
+  _isFastEditable: false,
+  _displayFastEditInput: false,
+  _fastEditInitalSelection: null,
+  _fastEditNewSelection: null,
+  _isSavingFastEdit: false,
+  _canEditPost: false,
 
   _isMouseDown: false,
   _reselected: false,
@@ -40,9 +59,18 @@ export default Component.extend({
   _hideButton() {
     this.quoteState.clear();
     this.set("visible", false);
+
+    this.set("_isFastEditable", false);
+    this.set("_displayFastEditInput", false);
+    this.set("_fastEditInitalSelection", null);
+    this.set("_fastEditNewSelection", null);
   },
 
   _selectionChanged() {
+    if (this._displayFastEditInput) {
+      return;
+    }
+
     const quoteState = this.quoteState;
 
     const selection = window.getSelection();
@@ -104,6 +132,33 @@ export default Component.extend({
     quoteState.selected(postId, _selectedText, opts);
     this.set("visible", quoteState.buffer.length > 0);
 
+    if (this.siteSettings.enable_fast_edit) {
+      this.set(
+        "_canEditPost",
+        this.topic.postStream.findLoadedPost(postId)?.can_edit
+      );
+
+      // if we have a linebreak, the selection is probably too complex to be handled
+      // by fast edit, so ignore it
+      // if the selection is present multiple times, we also consider it too complex
+      // and ignore it, note this specific case could probably be handled in the future
+      const regexp = new RegExp(regexSafeStr(quoteState.buffer), "gi");
+      const matches = postBody.match(regexp);
+      if (
+        quoteState.buffer.length < 1 ||
+        quoteState.buffer.match(/\n/g) ||
+        matches?.length > 1
+      ) {
+        this.set("_isFastEditable", false);
+        this.set("_fastEditInitalSelection", null);
+        this.set("_fastEditNewSelection", null);
+      } else if (matches?.length === 1) {
+        this.set("_isFastEditable", true);
+        this.set("_fastEditInitalSelection", quoteState.buffer);
+        this.set("_fastEditNewSelection", quoteState.buffer);
+      }
+    }
+
     // avoid hard loops in quote selection unconditionally
     // this can happen if you triple click text in firefox
     if (this._prevSelection === _selectedText) {
@@ -192,6 +247,12 @@ export default Component.extend({
         this._prevSelection = null;
         this._isMouseDown = true;
         this._reselected = false;
+
+        // prevents fast-edit input event to trigger mousedown
+        if (e.target.classList.contains("fast-edit-input")) {
+          return;
+        }
+
         if (
           $(e.target).closest(".quote-button, .create, .share, .reply-new")
             .length === 0
@@ -199,7 +260,12 @@ export default Component.extend({
           this._hideButton();
         }
       })
-      .on("mouseup.quote-button", () => {
+      .on("mouseup.quote-button", (e) => {
+        // prevents fast-edit input event to trigger mouseup
+        if (e.target.classList.contains("fast-edit-input")) {
+          return;
+        }
+
         this._prevSelection = null;
         this._isMouseDown = false;
         onSelectionChanged();
@@ -264,12 +330,57 @@ export default Component.extend({
     );
   },
 
+  _saveFastEditDisabled: propertyEqual(
+    "_fastEditInitalSelection",
+    "_fastEditNewSelection"
+  ),
+
   @action
   insertQuote() {
     this.attrs.selectText().then(() => this._hideButton());
   },
 
   @action
+  _toggleFastEditForm() {
+    if (this._isFastEditable) {
+      this.toggleProperty("_displayFastEditInput");
+
+      schedule("afterRender", () => {
+        document.querySelector("#fast-edit-input")?.focus();
+      });
+    } else {
+      const postId = this.quoteState.postId;
+      const postModel = this.topic.postStream.findLoadedPost(postId);
+      this?.editPost(postModel);
+    }
+  },
+
+  @action
+  _saveFastEdit() {
+    const postId = this.quoteState?.postId;
+    const postModel = this.topic.postStream.findLoadedPost(postId);
+
+    this.set("_isSavingFastEdit", true);
+
+    return ajax(`/posts/${postModel.id}`, { type: "GET", cache: false })
+      .then((result) => {
+        const newRaw = result.raw.replace(
+          fixQuotes(this._fastEditInitalSelection),
+          fixQuotes(this._fastEditNewSelection)
+        );
+
+        postModel
+          .save({ raw: newRaw })
+          .catch(popupAjaxError)
+          .finally(() => {
+            this.set("_isSavingFastEdit", false);
+            this._hideButton();
+          });
+      })
+      .catch(popupAjaxError);
+  },
+
+  @action
   share(source) {
     Sharing.shareSource(source, {
       url: this.shareUrl,
diff --git a/app/assets/javascripts/discourse/app/templates/components/quote-button.hbs b/app/assets/javascripts/discourse/app/templates/components/quote-button.hbs
index a28dd8e..1ee2118 100644
--- a/app/assets/javascripts/discourse/app/templates/components/quote-button.hbs
+++ b/app/assets/javascripts/discourse/app/templates/components/quote-button.hbs
@@ -1,31 +1,64 @@
-{{#if embedQuoteButton}}
-  {{d-button
-    class="btn-flat insert-quote"
-    action=(action "insertQuote")
-    icon="quote-left"
-    label="post.quote_reply"}}
-{{/if}}
+<div class="buttons">
+  {{#if embedQuoteButton}}
+    {{d-button
+      class="btn-flat insert-quote"
+      action=(action "insertQuote")
+      icon="quote-left"
+      label="post.quote_reply"}}
+  {{/if}}
 
-{{#if quoteSharingEnabled}}
-  <span class="quote-sharing">
-    {{#if quoteSharingShowLabel}}
+  {{#if quoteSharingEnabled}}
+    <span class="quote-sharing">
+      {{#if quoteSharingShowLabel}}
+        {{d-button
+          icon="share"
+          label="post.quote_share"
+          class="btn-flat quote-share-label"}}
+      {{/if}}
+
+      <span class="quote-share-buttons">
+        {{#each quoteSharingSources as |source|}}
+          {{d-button
+            class="btn-flat"
+            action=(action "share" source)
+            translatedTitle=source.title
+            icon=source.icon}}
+        {{/each}}
+        {{plugin-outlet name="quote-share-buttons-after" tagName=""}}
+      </span>
+
+    </span>
+  {{/if}}
+
+  {{#if siteSettings.enable_fast_edit}}
+    {{#if _canEditPost}}
       {{d-button
-        icon="share"
-        label="post.quote_share"
-        class="btn-flat quote-share-label"}}
+        icon="pencil-alt"
+        action=(action "_toggleFastEditForm")
+        label="post.quote_edit"
+        class="btn-flat quote-edit-label"
+      }}
     {{/if}}
+  {{/if}}
+</div>
 
-    <span class="quote-share-buttons">
-      {{#each quoteSharingSources as |source|}}
+<div class="extra">
+  {{#if siteSettings.enable_fast_edit}}
+    {{#if _displayFastEditInput}}
+      <div class="fast-edit-container">
+        {{textarea
+          id="fast-edit-input"
+          value=_fastEditNewSelection
+        }}
         {{d-button
-          class="btn-flat"
-          action=(action "share" source)
-          translatedTitle=source.title
-          icon=source.icon}}
-      {{/each}}

[... diff too long, it was truncated ...]

GitHub sha: b83868bfb00f6cc39b36849f2d33c08177862cdd

This commit appears in #14340 which was approved by udan11. It was merged by jjaffeux.