diff --git a/public/components/assistant.html b/public/components/assistant.html index 2a54a469..e99bdaff 100644 --- a/public/components/assistant.html +++ b/public/components/assistant.html @@ -91,6 +91,14 @@ + + diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index 1db5b848..fbf454af 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -137,6 +137,35 @@ import { document.addEventListener(type, handler); } + // Per device, not per account: a keyboard preference belongs to the keyboard. + // Someone who wants Enter to send at a desk usually does not want it on a + // phone, where Enter is how you get a second line in a note. + var ENTER_KEY = 'ped_assistant_enter_sends'; + + function enterSends() { + try { + var saved = localStorage.getItem(ENTER_KEY); + if (saved === '1') return true; + if (saved === '0') return false; + } catch (e) { /* private window, blocked storage: fall through to the default */ } + // Unset: send on a device with a real keyboard, newline on a touch one. + try { return !window.matchMedia('(hover: none) and (pointer: coarse)').matches; } + catch (e) { return true; } + } + + function setEnterSends(on) { + try { localStorage.setItem(ENTER_KEY, on ? '1' : '0'); } catch (e) {} + } + + // Say which key sends, where someone is already looking when they wonder. + function applyEnterHint() { + var input = document.getElementById('assistant-input'); + if (!input) return; + input.title = enterSends() + ? 'Enter sends · Shift+Enter for a new line' + : 'Enter for a new line · Ctrl+Enter sends'; + } + function bindEvents() { var form = document.getElementById('assistant-form'); var clearBtn = document.getElementById('btn-assistant-clear'); @@ -319,10 +348,35 @@ import { if (attachInput) attachInput.addEventListener('change', onAttachFiles); document.addEventListener('click', onAssistantDocumentClick); document.addEventListener('keydown', onAssistantKeydown); + // Enter: send, or newline. Two rules never change, whatever the preference — + // Shift+Enter is always a newline, and Ctrl/Cmd+Enter always sends. Those + // are the muscle memory people arrive with, and a setting that broke either + // would be worse than no setting. + // + // Composition matters: an IME (Chinese, Japanese, Korean, and predictive + // keyboards on Android) uses Enter to accept a candidate word. Sending on + // that would cut a sentence in half mid-word, so a keystroke during + // composition is never a send. if (input) input.addEventListener('keydown', function (e) { - if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') onAsk(e); + if (e.key !== 'Enter') return; + if (e.isComposing || e.keyCode === 229) return; + if ((e.ctrlKey || e.metaKey)) { e.preventDefault(); onAsk(e); return; } + if (e.shiftKey || e.altKey) return; // newline, always + if (!enterSends()) return; // newline, by preference + e.preventDefault(); + onAsk(e); }); + var enterToggle = document.getElementById('assistant-enter-sends'); + if (enterToggle) { + enterToggle.checked = enterSends(); + enterToggle.addEventListener('change', function () { + setEnterSends(enterToggle.checked); + applyEnterHint(); + }); + } + applyEnterHint(); + bindExampleButtons(document); loadSavedChats(); documentListenersBound = true; // every delegated listener above is now registered diff --git a/test/assistant-enter-to-send.test.js b/test/assistant-enter-to-send.test.js new file mode 100644 index 00000000..9c86201d --- /dev/null +++ b/test/assistant-enter-to-send.test.js @@ -0,0 +1,51 @@ +// Enter sends, or makes a newline, and the person decides. Two rules hold +// whatever they choose: Shift+Enter is always a newline, Ctrl/Cmd+Enter always +// sends. Those are the habits people arrive with. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const src = fs.readFileSync(path.join(__dirname, '..', 'public/js/clinicalAssistant.js'), 'utf8'); +const handler = src.slice(src.indexOf("input.addEventListener('keydown'"), src.indexOf('var enterToggle')); + +test('Ctrl or Cmd + Enter always sends, whatever the preference', () => { + assert.match(handler, /if \(\(e\.ctrlKey \|\| e\.metaKey\)\) \{ e\.preventDefault\(\); onAsk\(e\); return; \}/); + // It is checked before the preference, so it cannot be switched off. + assert.ok(handler.indexOf('ctrlKey') < handler.indexOf('enterSends()'), + 'the modifier must win over the preference'); +}); + +test('Shift+Enter is always a newline, whatever the preference', () => { + assert.match(handler, /if \(e\.shiftKey \|\| e\.altKey\) return;/); + assert.ok(handler.indexOf('shiftKey') < handler.indexOf('enterSends()')); +}); + +test('an IME composition keystroke never sends', () => { + // Enter accepts a candidate word in Chinese, Japanese, Korean and on + // predictive Android keyboards. Sending there cuts a sentence mid-word. + assert.match(handler, /if \(e\.isComposing \|\| e\.keyCode === 229\) return;/); + assert.ok(handler.indexOf('isComposing') < handler.indexOf('ctrlKey'), + 'composition is checked before anything can send'); +}); + +test('the preference is per device, and survives storage being unavailable', () => { + const pref = src.slice(src.indexOf('function enterSends()'), src.indexOf('function applyEnterHint')); + assert.match(pref, /localStorage\.getItem\(ENTER_KEY\)/); + assert.match(pref, /catch \(e\)/, 'a private window must not throw here'); + // Unset defaults by device class rather than guessing one answer for both. + assert.match(pref, /\(hover: none\) and \(pointer: coarse\)/); +}); + +test('the toggle reflects and writes the preference', () => { + const bind = src.slice(src.indexOf('var enterToggle'), src.indexOf('bindExampleButtons')); + assert.match(bind, /enterToggle\.checked = enterSends\(\)/, 'opens showing the current state'); + assert.match(bind, /setEnterSends\(enterToggle\.checked\)/); + const html = fs.readFileSync(path.join(__dirname, '..', 'public/components/assistant.html'), 'utf8'); + assert.match(html, /id="assistant-enter-sends"/); +}); + +test('the composer says which key sends', () => { + assert.match(src, /Enter sends · Shift\+Enter for a new line/); + assert.match(src, /Enter for a new line · Ctrl\+Enter sends/); +});