Browse Source

Send the document with Ctrl+S / Cmd+S

Fixes #739. While creating a document, Ctrl+S (or Cmd+S on macOS) now
triggers the send button instead of the browser's "save page" dialog.
The shortcut is ignored while viewing a document, and with Alt (AltGr
is reported as Ctrl+Alt and types letters on some layouts) or Shift.

The send button shows the shortcut as tooltip, built from the existing
"Ctrl"/"Cmd" translations, and exposes it via aria-keyshortcuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vatsanbalaji-ossagent 1 month ago
parent
commit
d8c04bf90e
4 changed files with 156 additions and 1 deletions
  1. 1 0
      CHANGELOG.md
  2. 48 0
      js/privatebin.js
  3. 106 0
      js/test/TopNav.js
  4. 1 1
      lib/Configuration.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.

+ 48 - 0
js/privatebin.js

@@ -4045,6 +4045,52 @@ 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. Alt is
+         * excluded because AltGr is reported as Ctrl+Alt and some keyboard
+         * layouts type letters with AltGr+S.
+         *
+         * @name   TopNav.sendOnShortcut
+         * @private
+         * @function
+         * @param  {KeyboardEvent} event
+         */
+        function sendOnShortcut(event) {
+            if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey ||
+                typeof event.key !== 'string' || event.key.toLowerCase() !== 's' ||
+                !sendButton || sendButton.classList.contains('hidden') || sendButton.disabled
+            ) {
+                return;
+            }
+            event.preventDefault();
+            sendButton.click();
+        }
+
+        /**
+         * shows the send shortcut as tooltip and exposes it to assistive technology
+         *
+         * The tooltip only uses the existing "Ctrl"/"Cmd" translations, so no
+         * new message ID is needed in the language files.
+         *
+         * @name   TopNav.showSendShortcutHint
+         * @private
+         * @function
+         */
+        function showSendShortcutHint() {
+            const isMac = /Mac/.test(navigator.userAgent);
+            function applyTitle() {
+                sendButton.setAttribute('title', I18n._(isMac ? 'Cmd' : 'Ctrl') + '+S');
+            }
+            sendButton.setAttribute('aria-keyshortcuts', isMac ? 'Meta+S' : 'Control+S');
+            // the language may still be loading, so register the re-translation
+            // first and then apply the current (possibly English) text
+            I18n.onLanguageLoaded(applyTitle);
+            applyTitle();
+        }
+
         /**
          * retrys some callback registered before
          *
@@ -4733,6 +4779,8 @@ window.PrivateBin = (function () {
             }
             if (sendButton) {
                 sendButton.addEventListener('click', PasteEncrypter.sendPaste);
+                document.addEventListener('keydown', sendOnShortcut);
+                showSendShortcutHint();
             }
             if (cloneButton) {
                 cloneButton.addEventListener('click', Controller.clonePaste);

+ 106 - 0
js/test/TopNav.js

@@ -127,6 +127,112 @@ 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(userAgent) {
+            cleanup();
+            sendCount = 0;
+            if (userAgent) {
+                Object.defineProperty(window.navigator, 'userAgent', {
+                    value: userAgent, configurable: true
+                });
+            }
+            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', ctrlKey: true});
+            assert.strictEqual(sendCount, 1);
+            assert.ok(prevented);
+        });
+
+        it('sends the document on Cmd+S (macOS)', function () {
+            setUpCreateMode();
+            const prevented = pressKey({key: 's', metaKey: true});
+            assert.strictEqual(sendCount, 1);
+            assert.ok(prevented);
+        });
+
+        it('still sends when caps lock turns the key into an upper case S', function () {
+            setUpCreateMode();
+            pressKey({key: 'S', 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', ctrlKey: true});
+            assert.strictEqual(sendCount, 0);
+            assert.ok(!prevented);
+        });
+
+        it('ignores S without Ctrl/Cmd, with AltGr (Ctrl+Alt) or with Shift', function () {
+            setUpCreateMode();
+            const prevented = [
+                pressKey({key: 's'}),
+                pressKey({key: 's', ctrlKey: true, altKey: true}),
+                pressKey({key: 'S', ctrlKey: true, shiftKey: true})
+            ];
+            assert.strictEqual(sendCount, 0);
+            assert.deepStrictEqual(prevented, [false, false, false]);
+        });
+
+        it('shows the Ctrl+S shortcut as tooltip and to assistive technology', function () {
+            setUpCreateMode();
+            const sendButton = query('#sendbutton');
+            assert.strictEqual(sendButton.getAttribute('title'), 'Ctrl+S');
+            assert.strictEqual(sendButton.getAttribute('aria-keyshortcuts'), 'Control+S');
+        });
+
+        it('shows Cmd+S on macOS', function () {
+            setUpCreateMode('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)');
+            const sendButton = query('#sendbutton');
+            assert.strictEqual(sendButton.getAttribute('title'), 'Cmd+S');
+            assert.strictEqual(sendButton.getAttribute('aria-keyshortcuts'), 'Meta+S');
+        });
+
+        it('translates the tooltip once the language has loaded', function () {
+            setUpCreateMode();
+            PrivateBin.I18n.reset('de', {Ctrl: 'Strg'});
+            document.dispatchEvent(new window.CustomEvent('languageLoaded'));
+            assert.strictEqual(query('#sendbutton').getAttribute('title'), 'Strg+S');
+        });
+    });
+
     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-HvXCKJgjH+YD17gPj3c5d65is3nEaXMletNvdOarcoyGM+sbUpV75/pccSc8L1eDabEe+CYCvtFVqkrZm60MQw==',
             '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==',