From 9f0b5b00f7ada4c5735230d0f93e04a3c58d4d7a Mon Sep 17 00:00:00 2001
From: Kit La Touche <kit@transneptune.net>
Date: Sun, 11 May 2025 21:56:33 -0400
Subject: Fix up input to be a contenteditable div

---
 ui/lib/components/MessageInput.svelte | 18 ++++++++++++++----
 1 file changed, 14 insertions(+), 4 deletions(-)

(limited to 'ui/lib/components/MessageInput.svelte')

diff --git a/ui/lib/components/MessageInput.svelte b/ui/lib/components/MessageInput.svelte
index 69a8298..1f4ba99 100644
--- a/ui/lib/components/MessageInput.svelte
+++ b/ui/lib/components/MessageInput.svelte
@@ -1,7 +1,10 @@
 <script>
   let { sendMessage = async (message) => {} } = $props();
 
+  let form = $state(null);
   let value = $state('');
+  // This doesn't seem to work with a contenteditable two-way bound svelte
+  // thing:
   let disabled = $state(false);
 
   async function onsubmit(event) {
@@ -9,7 +12,7 @@
     disabled = true;
     try {
       await sendMessage(value);
-      event.target.reset();
+      value = '';
     } finally {
       disabled = false;
     }
@@ -19,12 +22,19 @@
     let modifier = event.shiftKey || event.altKey || event.ctrlKey || event.metaKey;
     if (!modifier && event.key === 'Enter') {
       event.preventDefault();
-      event.target.form.requestSubmit();
+      form?.requestSubmit?.();
     }
   }
 </script>
 
-<form {onsubmit}>
-  <textarea {onkeydown} bind:value {disabled} placeholder="Say something..."></textarea>
+<form {onsubmit} bind:this={form}>
+  <div
+    contenteditable="plaintext-only"
+    class="textarea"
+    bind:textContent={value}
+    {onkeydown}
+    {disabled}
+    placeholder="Say something..."
+  ></div>
   <button type="submit">&raquo;</button>
 </form>
-- 
cgit v1.2.3


From 6eb8487d89ab14f8f11a40b78d7db72e3266840d Mon Sep 17 00:00:00 2001
From: Owen Jacobson <owen@grimoire.ca>
Date: Tue, 13 May 2025 18:08:21 -0400
Subject: Send multi-line messages as multiple lines.

For whatever reason, `innerText` captures interior line breaks, while `textContent` does not. Wild. DOM APIs.
---
 ui/lib/components/MessageInput.svelte | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

(limited to 'ui/lib/components/MessageInput.svelte')

diff --git a/ui/lib/components/MessageInput.svelte b/ui/lib/components/MessageInput.svelte
index 1f4ba99..71b7b8f 100644
--- a/ui/lib/components/MessageInput.svelte
+++ b/ui/lib/components/MessageInput.svelte
@@ -31,7 +31,7 @@
   <div
     contenteditable="plaintext-only"
     class="textarea"
-    bind:textContent={value}
+    bind:innerText={value}
     {onkeydown}
     {disabled}
     placeholder="Say something..."
-- 
cgit v1.2.3


From 361623aaccf08063712500a213548a2756dd13c4 Mon Sep 17 00:00:00 2001
From: Owen Jacobson <owen@grimoire.ca>
Date: Tue, 13 May 2025 18:01:19 -0400
Subject: Implement the disabled state:

* Suppress input (including paste) while the input is disabled.
* Style the input to make it visible that it's not accepting input.
---
 ui/lib/components/MessageInput.svelte | 20 ++++++++++++++++----
 ui/styles/forms.css                   |  4 ++++
 2 files changed, 20 insertions(+), 4 deletions(-)

(limited to 'ui/lib/components/MessageInput.svelte')

diff --git a/ui/lib/components/MessageInput.svelte b/ui/lib/components/MessageInput.svelte
index 71b7b8f..848321d 100644
--- a/ui/lib/components/MessageInput.svelte
+++ b/ui/lib/components/MessageInput.svelte
@@ -3,8 +3,6 @@
 
   let form = $state(null);
   let value = $state('');
-  // This doesn't seem to work with a contenteditable two-way bound svelte
-  // thing:
   let disabled = $state(false);
 
   async function onsubmit(event) {
@@ -19,21 +17,35 @@
   }
 
   function onkeydown(event) {
+    if (disabled) {
+      event.preventDefault();
+      return;
+    }
+
     let modifier = event.shiftKey || event.altKey || event.ctrlKey || event.metaKey;
     if (!modifier && event.key === 'Enter') {
       event.preventDefault();
       form?.requestSubmit?.();
     }
   }
+
+  function onpaste(event) {
+    if (disabled) {
+      event.preventDefault();
+    }
+  }
 </script>
 
 <form {onsubmit} bind:this={form}>
   <div
     contenteditable="plaintext-only"
-    class="textarea"
+    class={{
+      textarea: true,
+      disabled
+    }}
     bind:innerText={value}
     {onkeydown}
-    {disabled}
+    {onpaste}
     placeholder="Say something..."
   ></div>
   <button type="submit">&raquo;</button>
diff --git a/ui/styles/forms.css b/ui/styles/forms.css
index 59dda30..907f1bf 100644
--- a/ui/styles/forms.css
+++ b/ui/styles/forms.css
@@ -26,3 +26,7 @@ form.form > button {
 [contenteditable]:empty {
   color: var(--light-text);
 }
+
+.disabled {
+  color: var(--light-text);
+}
-- 
cgit v1.2.3


From a0568ada5568bd8d6538e26e003b26c8409e2cb7 Mon Sep 17 00:00:00 2001
From: Owen Jacobson <owen@grimoire.ca>
Date: Tue, 13 May 2025 18:14:04 -0400
Subject: Make the resulting form more amenable to normal DOM operations.

This is purely an aesthetic choice:

* The DOM `reset()` function can be used to clear the form, but can't be used to clear editable DIVs. Binding the editable div to a (hidden) form field allows `reset()` to clear both.

* We can find the target `form` element out of the event, but the API needed to do so differs between events dispatched to form controls and events dispatched to random DOM nodes. Using `closest('form')` works for both kinds of event target.

In practice, there is little need to make sure the message input form uses "normal" DOM APIs for functional reasons. Everything inside `MessageInput` is controllable through the component's script. This change isn't based on a functional need, but rather in the hopes that integrating with the DOM APIs makes it easier for _code we don't control_ - screen readers, password managers, saved-form support in browsers, &c - to integrate with Pilcrow. It is purely speculative.

(This also used to be necessary because Firefox didn't support `contenteditable="plaintext-only"`, but [support was added in March][ff-pto]

[ff-pto]: https://www.mozilla.org/en-US/firefox/136.0/releasenotes/#:~:text=The%20value%20plaintext%2Donly%20can%20now%20be%20specified%20for%20the%20contenteditable%20attribute%2C%20making%20the%20raw%20text%20of%20an%20element%20editable%20but%20without%20supporting%20rich%20text%20formatting.
---
 ui/lib/components/MessageInput.svelte | 8 ++++----
 ui/styles/forms.css                   | 4 ++++
 2 files changed, 8 insertions(+), 4 deletions(-)

(limited to 'ui/lib/components/MessageInput.svelte')

diff --git a/ui/lib/components/MessageInput.svelte b/ui/lib/components/MessageInput.svelte
index 848321d..6c22b29 100644
--- a/ui/lib/components/MessageInput.svelte
+++ b/ui/lib/components/MessageInput.svelte
@@ -1,7 +1,6 @@
 <script>
   let { sendMessage = async (message) => {} } = $props();
 
-  let form = $state(null);
   let value = $state('');
   let disabled = $state(false);
 
@@ -10,7 +9,7 @@
     disabled = true;
     try {
       await sendMessage(value);
-      value = '';
+      event.target.closest('form')?.reset();
     } finally {
       disabled = false;
     }
@@ -25,7 +24,7 @@
     let modifier = event.shiftKey || event.altKey || event.ctrlKey || event.metaKey;
     if (!modifier && event.key === 'Enter') {
       event.preventDefault();
-      form?.requestSubmit?.();
+      event.target.closest('form')?.requestSubmit();
     }
   }
 
@@ -36,7 +35,8 @@
   }
 </script>
 
-<form {onsubmit} bind:this={form}>
+<form {onsubmit}>
+  <textarea bind:value class="hidden" {disabled}></textarea>
   <div
     contenteditable="plaintext-only"
     class={{
diff --git a/ui/styles/forms.css b/ui/styles/forms.css
index 907f1bf..1c1ae5c 100644
--- a/ui/styles/forms.css
+++ b/ui/styles/forms.css
@@ -30,3 +30,7 @@ form.form > button {
 .disabled {
   color: var(--light-text);
 }
+
+.hidden {
+  display: none;
+}
-- 
cgit v1.2.3


From e623ab68e7f4d1a9bd8222aa8d8f5d9108a238c0 Mon Sep 17 00:00:00 2001
From: Owen Jacobson <owen@grimoire.ca>
Date: Tue, 13 May 2025 20:02:25 -0400
Subject: Support non-mouse accessibility.

* Give the input `div` a marker to tell screen readers &c that it is a textbox.
* Ensure that it participates in tab order. (Zero is a sentinel value, see <https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/tabindex>.)
---
 ui/lib/components/MessageInput.svelte | 2 ++
 1 file changed, 2 insertions(+)

(limited to 'ui/lib/components/MessageInput.svelte')

diff --git a/ui/lib/components/MessageInput.svelte b/ui/lib/components/MessageInput.svelte
index 6c22b29..b230939 100644
--- a/ui/lib/components/MessageInput.svelte
+++ b/ui/lib/components/MessageInput.svelte
@@ -38,6 +38,8 @@
 <form {onsubmit}>
   <textarea bind:value class="hidden" {disabled}></textarea>
   <div
+    role="textbox"
+    tabindex="0"
     contenteditable="plaintext-only"
     class={{
       textarea: true,
-- 
cgit v1.2.3


From 61b3e3cba8a11a53947ce0e7c8a6980fb20ef914 Mon Sep 17 00:00:00 2001
From: Owen Jacobson <owen@grimoire.ca>
Date: Tue, 13 May 2025 20:40:23 -0400
Subject: Restore the placeholder when the editable input is emptied out after
 modification.

This also avoids using `placeholder` on elements where it's nonstandard, like `<div>`s.
---
 ui/lib/components/MessageInput.svelte | 12 +++++++++++-
 ui/styles/forms.css                   |  3 ++-
 2 files changed, 13 insertions(+), 2 deletions(-)

(limited to 'ui/lib/components/MessageInput.svelte')

diff --git a/ui/lib/components/MessageInput.svelte b/ui/lib/components/MessageInput.svelte
index b230939..cdb855e 100644
--- a/ui/lib/components/MessageInput.svelte
+++ b/ui/lib/components/MessageInput.svelte
@@ -4,6 +4,16 @@
   let value = $state('');
   let disabled = $state(false);
 
+  // Entering and removing text from the input area leaves a stray line break behind. This effect
+  // looks for that scenario and removes it, restoring the "empty" state (and bringing the
+  // placeholder back again in the process).
+  $effect(() => {
+    const trimmed = value.trim();
+    if (trimmed !== value && trimmed === '') {
+      value = trimmed;
+    }
+  });
+
   async function onsubmit(event) {
     event.preventDefault();
     disabled = true;
@@ -48,7 +58,7 @@
     bind:innerText={value}
     {onkeydown}
     {onpaste}
-    placeholder="Say something..."
+    data-placeholder="Say something..."
   ></div>
   <button type="submit">&raquo;</button>
 </form>
diff --git a/ui/styles/forms.css b/ui/styles/forms.css
index 1c1ae5c..a8789b1 100644
--- a/ui/styles/forms.css
+++ b/ui/styles/forms.css
@@ -23,7 +23,8 @@ form.form > button {
   margin: 0.25rem;
 }
 
-[contenteditable]:empty {
+[data-placeholder]:empty:before {
+  content: attr(data-placeholder);
   color: var(--light-text);
 }
 
-- 
cgit v1.2.3

