aboutsummaryrefslogtreecommitdiffstats
path: root/src/app.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-29 11:18:21 -0700
committerAdam Soltys <[email protected]>2026-09-29 11:18:21 -0700
commit689e21d5e77765661c44d60c10b18134b995162a (patch)
tree2a6e878c9b8e400d3fcd60022d72f7d4bf3030ca /src/app.js
parentc7186db46f6e45969f01709bc4a0a9472a40dff1 (diff)
downloadcoinosv3-689e21d5e77765661c44d60c10b18134b995162a.tar.gz
coinosv3-689e21d5e77765661c44d60c10b18134b995162a.zip
Composers take pictures from the keyboard: the reply, post and chat fields are contenteditable.
Android keyboards (Gboard GIFs, stickers) only offer images to fields that accept them, and Chrome says so for contenteditable, never <textarea> — hence the greyed-out GIF key and "This app does not support images here". <coinos-text> (src/rich-text.js) is a contenteditable that acts like a textarea to the code around it (value, selectionStart/End, setSelection- Range, placeholder), keeps text plain, and hands any pasted/dropped/ keyboard picture to onMedia — the paperclip's upload. The morph syncs its value like a field's and leaves its own editing attributes alone. composer-media-test: types like a textarea, strips pasted formatting, a pasted GIF uploads and lands in the reply, still editable after repaint. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp
Diffstat (limited to 'src/app.js')
-rw-r--r--src/app.js16
1 files changed, 13 insertions, 3 deletions
diff --git a/src/app.js b/src/app.js
index e9412ec..0bf3086 100644
--- a/src/app.js
+++ b/src/app.js
@@ -6,6 +6,7 @@
import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxoId, parseExtendedKey, xpubToZpub, encryptVault, decryptVault } from './wallet.js';
import { qrSvg } from './qr.js';
+import './rich-text.js'; // <coinos-text>, the composers' field
import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './recipient-search.js';
import { npubOf, seedPubkey, neventOf } from './nostr.js';
import { nip98Header } from './nip98.js';
@@ -233,7 +234,10 @@ function morph(a, b) {
// <details open> is the user's doing (the browser toggles the attribute on
// click), like a field's value: a render that doesn't set `open` leaves it
// alone instead of collapsing the section on every background repaint.
- const userOwned = (at) => a.nodeName === 'DETAILS' && at.name === 'open';
+ // <coinos-text> sets its own editing attributes when it joins the page;
+ // the freshly built copy doesn't have them yet, and must not strip them
+ const userOwned = (at) => (a.nodeName === 'DETAILS' && at.name === 'open')
+ || (a.nodeName === 'COINOS-TEXT' && /^(contenteditable|role|aria-multiline|aria-placeholder|data-empty)$/.test(at.name));
for (const at of [...a.attributes]) {
if (b.hasAttribute(at.name) || userOwned(at)) continue;
if (at.name === 'src' && b.hasAttribute('data-lazy-src')) continue; // the lazy source stands for it
@@ -261,6 +265,12 @@ function morph(a, b) {
}
if (a.disabled !== b.disabled) a.disabled = b.disabled;
}
+ // the contenteditable field: its text is its value, owned like a
+ // textarea's — synced when not being edited, children never diffed
+ if (tag === 'COINOS-TEXT') {
+ if (document.activeElement !== a && a.value !== b.value) a.value = b.value;
+ return;
+ }
// innerHTML-authored subtrees (svg icons, QRs): compare source, not nodes
if (b._html != null) {
if (a._html !== b._html) a.innerHTML = b._html;
@@ -724,7 +734,7 @@ function renderInner() {
// payment push, an SP scan) can't kick the user out of a field they're editing.
const a = document.activeElement;
let fpath = null, selStart = null, selEnd = null;
- if (a && root.contains(a) && /^(INPUT|SELECT|TEXTAREA)$/.test(a.tagName)) {
+ if (a && root.contains(a) && /^(INPUT|SELECT|TEXTAREA|COINOS-TEXT)$/.test(a.tagName)) {
fpath = focusPath(a);
try { selStart = a.selectionStart; selEnd = a.selectionEnd; } catch {}
}
@@ -789,7 +799,7 @@ function renderInner() {
try { document.documentElement.classList.toggle('no-scroll', !!ui.lightbox); } catch {}
if (fpath) {
const el = nodeAtPath(fpath);
- if (el && el !== a && /^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName)) {
+ if (el && el !== a && /^(INPUT|SELECT|TEXTAREA|COINOS-TEXT)$/.test(el.tagName)) {
try { el.focus({ preventScroll: true }); if (selStart != null && el.setSelectionRange) el.setSelectionRange(selStart, selEnd); } catch {}
}
}