Quellcode durchsuchen

Merge pull request #2032 from vatsanbalaji-ossagent/feat/739-ctrl-s-save

Send the document with Ctrl+S / Cmd+S
El RIDO vor 1 Monat
Ursprung
Commit
10c6ba07e9
44 geänderte Dateien mit 189 neuen und 41 gelöschten Zeilen
  1. 1 0
      CHANGELOG.md
  2. 2 1
      i18n/ar.json
  3. 2 1
      i18n/bg.json
  4. 2 1
      i18n/ca.json
  5. 2 1
      i18n/co.json
  6. 2 1
      i18n/cs.json
  7. 2 1
      i18n/de.json
  8. 2 1
      i18n/el.json
  9. 2 1
      i18n/en.json
  10. 2 1
      i18n/es.json
  11. 2 1
      i18n/et.json
  12. 2 1
      i18n/fa.json
  13. 2 1
      i18n/fi.json
  14. 2 1
      i18n/fr.json
  15. 2 1
      i18n/he.json
  16. 2 1
      i18n/hi.json
  17. 2 1
      i18n/hu.json
  18. 2 1
      i18n/id.json
  19. 2 1
      i18n/it.json
  20. 2 1
      i18n/ja.json
  21. 2 1
      i18n/jbo.json
  22. 2 1
      i18n/ko.json
  23. 2 1
      i18n/ku.json
  24. 2 1
      i18n/la.json
  25. 2 1
      i18n/lt.json
  26. 2 1
      i18n/nl.json
  27. 2 1
      i18n/no.json
  28. 2 1
      i18n/oc.json
  29. 2 1
      i18n/pl.json
  30. 2 1
      i18n/pt.json
  31. 2 1
      i18n/ro.json
  32. 2 1
      i18n/ru.json
  33. 2 1
      i18n/sk.json
  34. 2 1
      i18n/sl.json
  35. 2 1
      i18n/sv.json
  36. 2 1
      i18n/th.json
  37. 2 1
      i18n/tr.json
  38. 2 1
      i18n/uk.json
  39. 2 1
      i18n/zh-tw.json
  40. 2 1
      i18n/zh.json
  41. 26 0
      js/privatebin.js
  42. 82 0
      js/test/TopNav.js
  43. 1 1
      lib/Configuration.php
  44. 1 1
      tpl/bootstrap5.php

+ 1 - 0
CHANGELOG.md

@@ -4,6 +4,7 @@
 * ADDED: Added `shortenviachhoto` endpoint with an `chhoto` configuration section
 * ADDED: Translation for Chinese (Traditional)
 * ADDED: Subject line to the "Email" share button's mailto link (#928)
+* ADDED: Send the document with Ctrl+S, or Cmd+S on macOS (#739)
 * CHANGED: We removed jQuery in the Frontend and replaced it with vanilla JS.
 * CHANGED: Removed the unmaintained js-verify and replaced it with fast-check library.
 * CHANGED: Added a `jsconfig.json` in order to check the types of JavaScript.

+ 2 - 1
i18n/ar.json

@@ -234,5 +234,6 @@
     "Theme": "السمة",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/bg.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/ca.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/co.json

@@ -234,5 +234,6 @@
     "Theme": "Tema",
     "Copy": "Cupià",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/cs.json

@@ -234,5 +234,6 @@
     "Theme": "Vzhled",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/de.json

@@ -234,5 +234,6 @@
     "Theme": "Design",
     "Copy": "Kopieren",
     "Cmd": "Cmd",
-    "Ctrl": "Strg"
+    "Ctrl": "Strg",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/el.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/en.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/es.json

@@ -234,5 +234,6 @@
     "Theme": "Tema",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/et.json

@@ -234,5 +234,6 @@
     "Theme": "Teema",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/fa.json

@@ -234,5 +234,6 @@
     "Theme": "تم",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/fi.json

@@ -234,5 +234,6 @@
     "Theme": "Teema",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/fr.json

@@ -234,5 +234,6 @@
     "Theme": "Thème",
     "Copy": "Copier",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/he.json

@@ -234,5 +234,6 @@
     "Theme": "נושא",
     "Copy": "העתק",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/hi.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/hu.json

@@ -234,5 +234,6 @@
     "Theme": "Téma",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/id.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/it.json

@@ -234,5 +234,6 @@
     "Theme": "Tema",
     "Copy": "Copiare",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/ja.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/jbo.json

@@ -234,5 +234,6 @@
     "Theme": "jvinu",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/ko.json

@@ -234,5 +234,6 @@
     "Theme": "테마",
     "Copy": "복사",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/ku.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/la.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/lt.json

@@ -234,5 +234,6 @@
     "Theme": "Apipavidalinimas",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/nl.json

@@ -234,5 +234,6 @@
     "Theme": "Thema",
     "Copy": "Kopieëren",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/no.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/oc.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/pl.json

@@ -234,5 +234,6 @@
     "Theme": "Motyw",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/pt.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/ro.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/ru.json

@@ -234,5 +234,6 @@
     "Theme": "Тема",
     "Copy": "Скопировать",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/sk.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/sl.json

@@ -234,5 +234,6 @@
     "Theme": "Tema",
     "Copy": "Kopiraj",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/sv.json

@@ -234,5 +234,6 @@
     "Theme": "Tema",
     "Copy": "Kopiera",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/th.json

@@ -234,5 +234,6 @@
     "Theme": "ธีม ",
     "Copy": "คัดลอก",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/tr.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/uk.json

@@ -234,5 +234,6 @@
     "Theme": "Тема",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/zh-tw.json

@@ -234,5 +234,6 @@
     "Theme": "佈景主題",
     "Copy": "複製",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/zh.json

@@ -234,5 +234,6 @@
     "Theme": "主题",
     "Copy": "复制",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 26 - 0
js/privatebin.js

@@ -4045,6 +4045,31 @@ window.PrivateBin = (function () {
             Controller.newPaste();
         }
 
+        /**
+         * sends the document on [Ctrl]+[S] or, on macOS, [Cmd]+[S]
+         *
+         * Only acts while the send button is shown, so the browser's own
+         * "save page" still works when viewing an existing document.
+         *
+         * @name   TopNav.sendOnShortcut
+         * @private
+         * @function
+         * @param  {KeyboardEvent} event
+         */
+        function sendOnShortcut(event) {
+            // the physical S key, so non-Latin keyboard layouts work, too
+            const isSaveKey = event.code === 'KeyS';
+            // Alt is excluded because AltGr is reported as Ctrl+Alt and some
+            // layouts type letters with AltGr+S, Shift to leave "Save page as"
+            // ([Ctrl]+[Shift]+[S]) to the browser
+            const hasCorrectModifiers = (event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey;
+            const isButtonAvailable = sendButton && !sendButton.classList.contains('hidden') && !sendButton.disabled;
+            if (isSaveKey && hasCorrectModifiers && isButtonAvailable) {
+                event.preventDefault();
+                sendButton.click();
+            }
+        }
+
         /**
          * retrys some callback registered before
          *
@@ -4733,6 +4758,7 @@ window.PrivateBin = (function () {
             }
             if (sendButton) {
                 sendButton.addEventListener('click', PasteEncrypter.sendPaste);
+                document.addEventListener('keydown', sendOnShortcut);
             }
             if (cloneButton) {
                 cloneButton.addEventListener('click', Controller.clonePaste);

+ 82 - 0
js/test/TopNav.js

@@ -127,6 +127,88 @@ describe('TopNav', function () {
         );
     });
 
+    describe('send shortcut', function () {
+        let sendCount;
+
+        // builds the create-document navbar and stubs the actual send, so no
+        // server interaction happens
+        function setUpCreateMode() {
+            cleanup();
+            sendCount = 0;
+            document.documentElement.innerHTML =
+                '<nav><div id="navbar"><ul><li><button id="newbutton" ' +
+                'type="button" class="hidden">New</button></li><li><a ' +
+                'id="expiration" href="#" class="hidden">Expiration</a>' +
+                '</li><li><div id="burnafterreadingoption" class="hidden">' +
+                'Burn after reading</div></li><li><div id="opendiscussion' +
+                'option" class="hidden">Open discussion</div></li><li>' +
+                '<div id="password" class="hidden">Password</div></li>' +
+                '<li id="attach" class="hidden">Attach a file</li><li>' +
+                '<a id="formatter" href="#" class="hidden">Format</a>' +
+                '</li><li><button id="sendbutton" type="button" ' +
+                'class="hidden">Create</button></li></ul></div></nav>';
+            PrivateBin.PasteEncrypter.sendPaste = function () {
+                sendCount++;
+            };
+            PrivateBin.TopNav.init();
+            PrivateBin.TopNav.showCreateButtons();
+        }
+
+        // dispatches a keydown on the document, returns whether the default was prevented
+        function pressKey(options) {
+            const event = new window.KeyboardEvent('keydown', Object.assign(
+                {bubbles: true, cancelable: true}, options
+            ));
+            document.dispatchEvent(event);
+            return event.defaultPrevented;
+        }
+
+        afterEach(function () {
+            cleanup();
+        });
+
+        it('sends the document on Ctrl+S and suppresses the browser save dialog', function () {
+            setUpCreateMode();
+            const prevented = pressKey({key: 's', code: 'KeyS', ctrlKey: true});
+            assert.strictEqual(sendCount, 1);
+            assert.ok(prevented);
+        });
+
+        it('sends the document on Cmd+S (macOS)', function () {
+            setUpCreateMode();
+            const prevented = pressKey({key: 's', code: 'KeyS', metaKey: true});
+            assert.strictEqual(sendCount, 1);
+            assert.ok(prevented);
+        });
+
+        it('sends on the physical S key of non-Latin keyboard layouts', function () {
+            setUpCreateMode();
+            // on a Russian layout the S key produces "ы"
+            pressKey({key: 'ы', code: 'KeyS', ctrlKey: true});
+            assert.strictEqual(sendCount, 1);
+        });
+
+        it('leaves Ctrl+S to the browser when viewing a document', function () {
+            setUpCreateMode();
+            PrivateBin.TopNav.hideCreateButtons();
+            const prevented = pressKey({key: 's', code: 'KeyS', ctrlKey: true});
+            assert.strictEqual(sendCount, 0);
+            assert.ok(!prevented);
+        });
+
+        it('ignores S without Ctrl/Cmd, with AltGr (Ctrl+Alt), with Shift, or other keys', function () {
+            setUpCreateMode();
+            const prevented = [
+                pressKey({key: 's', code: 'KeyS'}),
+                pressKey({key: 's', code: 'KeyS', ctrlKey: true, altKey: true}),
+                pressKey({key: 'S', code: 'KeyS', ctrlKey: true, shiftKey: true}),
+                pressKey({key: 'd', code: 'KeyD', ctrlKey: true})
+            ];
+            assert.strictEqual(sendCount, 0);
+            assert.deepStrictEqual(prevented, [false, false, false, false]);
+        });
+    });
+
     describe('showNewPasteButton', function () {
         before(function () {
             cleanup();

+ 1 - 1
lib/Configuration.php

@@ -124,7 +124,7 @@ class Configuration
             'js/kjua-0.10.0.js'      => 'sha512-BYj4xggowR7QD150VLSTRlzH62YPfhpIM+b/1EUEr7RQpdWAGKulxWnOvjFx1FUlba4m6ihpNYuQab51H6XlYg==',
             'js/legacy.js'           => 'sha512-pRofxsrf5UItjiP22Dcjh3FAcBjF/n7h8U9/W5xqJk17U0N2U1oajhXypq/omo9jhwS1iVGOhWrRepoPeFns+w==',
             'js/prettify.js'         => 'sha512-puO0Ogy++IoA2Pb9IjSxV1n4+kQkKXYAEUtVzfZpQepyDPyXk8hokiYDS7ybMogYlyyEIwMLpZqVhCkARQWLMg==',
-            'js/privatebin.js'       => 'sha512-uqpdo+52wd4/uzOgfilGFw1SX3faeOuHWSRvfRwTUj8q7WPUoIArxVmp1DOeZzZUhEI/4NDJkNsQvvrKp8uQ/w==',
+            'js/privatebin.js'       => 'sha512-HedKxoytOeYbMzjcN3x7/FW01Moba3U9SZI7Yi3V7CkO7B2wsvmgr8kGeurHW0hZssKxGA/3UZ+3tNdsO4o/UA==',
             'js/purify-3.4.12.js'    => 'sha512-Akf6HnAJZm0sWWWI4gp2GYff0NDnHUB02XJE5S7Hdq/Z5xtMjkuFacsDA8ZtViv1gi+onBxMhEMIaGyQeGxBng==',
             'js/showdown-2.1.0.js'   => 'sha512-WYXZgkTR0u/Y9SVIA4nTTOih0kXMEd8RRV6MLFdL6YU8ymhR528NLlYQt1nlJQbYz4EW+ZsS0fx1awhiQJme1Q==',
             'js/zlib-1.3.2.js'       => 'sha512-RAhJgxg9siMIA8ky4c10Rc2zUgnK80olHB8Tt1IOYWY4Eh1WmrviQkDn+sgBlb38ZHq3tzufGC41kP360gmosQ==',

+ 1 - 1
tpl/bootstrap5.php

@@ -469,7 +469,7 @@ endif;
 					<li role="presentation" class="nav-item me-1"><a class="nav-link active" role="tab" id="messageedit" href="#"><?php echo I18n::_('Editor'); ?></a></li>
 					<li role="presentation" class="nav-item me-1"><a class="nav-link" role="tab" id="messagepreview" href="#"><?php echo I18n::_('Preview'); ?></a></li>
 					<li role="presentation" class="nav-item ms-auto">
-						<button id="sendbutton" type="button" tabindex="2" class="hidden btn btn-primary d-flex justify-content-center align-items-center gap-1">
+						<button id="sendbutton" type="button" tabindex="2" class="hidden btn btn-primary d-flex justify-content-center align-items-center gap-1" title="<?php echo I18n::_('Hit %s+s to create the document, storing it on %s', I18n::getCopyHotkey(), I18n::_($NAME)); ?>" aria-keyshortcuts="Control+S Meta+S">
 							<svg width="16" height="16" fill="currentColor" aria-hidden="true"><use href="img/bootstrap-icons.svg#cloud-upload" /></svg> <?php echo I18n::_('Create'), PHP_EOL; ?>
 						</button>
 					</li>