diff options
| author | Adam Soltys <[email protected]> | 2026-09-29 11:18:21 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-29 11:18:21 -0700 |
| commit | 689e21d5e77765661c44d60c10b18134b995162a (patch) | |
| tree | 2a6e878c9b8e400d3fcd60022d72f7d4bf3030ca /src | |
| parent | c7186db46f6e45969f01709bc4a0a9472a40dff1 (diff) | |
| download | coinosv3-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')
| -rw-r--r-- | src/app.js | 16 | ||||
| -rw-r--r-- | src/features/messages.js | 44 | ||||
| -rw-r--r-- | src/rich-text.js | 181 | ||||
| -rw-r--r-- | src/style.css | 10 |
4 files changed, 237 insertions, 14 deletions
@@ -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 {} } } diff --git a/src/features/messages.js b/src/features/messages.js index b5b4c01..11204f5 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -5495,6 +5495,19 @@ export function messagesFeature(ctx) { // so outright — plenty of perfectly good picture URLs carry no extension // (a CDN path, a /media/ route), and ![…] is the author telling us what it // is, which beats guessing from the filename. + // A picture is fitted inside its box (object-fit: contain), so a tall one + // leaves blank bands either side. A tap there is a tap on the post — it + // opens the thread — not on the picture. + function onPicture(e) { + const el = e.currentTarget; + const r = el.getBoundingClientRect(); + const nw = el.naturalWidth, nh = el.naturalHeight; + if (!nw || !nh || !r.width || !r.height) return true; + const k = Math.min(r.width / nw, r.height / nh); + const w = nw * k, hh = nh * k; + const x = e.clientX - (r.left + (r.width - w) / 2), y = e.clientY - (r.top + (r.height - hh) / 2); + return x >= 0 && x <= w && y >= 0 && y <= hh; + } function urlNode(url, { label = null, isImage = false } = {}) { if (/\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i.test(url)) { return videoNode(url, { stable: !!feedPaint }); @@ -5508,7 +5521,7 @@ export function messagesFeature(ctx) { src: url, class: 'note-img clickable', loading: feedPaint ? 'eager' : 'lazy', alt: label || '', width: size?.width, height: size?.height, style: size ? 'height:auto;aspect-ratio:' + size.width + '/' + size.height : undefined, - onClick: (e) => { e.stopPropagation(); ctx.openImage && ctx.openImage(url); }, + onClick: (e) => { if (!onPicture(e)) return; e.stopPropagation(); ctx.openImage && ctx.openImage(url); }, // a picture we were TOLD was a picture and which won't load leaves // nothing behind — the alt text is already in the sentence above it onError: (e) => { if (!size) e.target.style.display = 'none'; }, @@ -6110,7 +6123,7 @@ export function messagesFeature(ctx) { ui.msgView = 'feed'; feedNow(); render(); - setTimeout(() => { const el = document.querySelector('.chat-page textarea'); if (el) { el.focus(); el.setSelectionRange(0, 0); } }, 80); + setTimeout(() => { const el = document.querySelector('.chat-page coinos-text'); if (el) { el.focus(); el.setSelectionRange(0, 0); } }, 80); } // ---- mute list (NIP-51 kind 10000) --------------------------------------- @@ -7054,11 +7067,17 @@ export function messagesFeature(ctx) { // A textarea that grows with the reply, like the chat composer: Enter // sends, Shift+Enter (or Ctrl+J) breaks the line — a reply used to be // a one-line field with no way to write a paragraph. - h('textarea', { + h('coinos-text', { class: 'thread-reply-input', placeholder: t('threadReplyHint'), - rows: String(Math.min(6, Math.max(2, (s.draft || '').split('\n').length))), - style: 'font-family:var(--sans);resize:none;max-height:160px;overflow-y:auto;line-height:1.4;width:100%;box-sizing:border-box', + style: 'font-family:var(--sans);min-height:64px;max-height:160px', value: s.draft || '', + // a keyboard GIF or a pasted picture: attached like the paperclip's + onMedia: (f) => attachTo(f, (url) => { + s.draft = ((s.draft || '').replace(/\s+$/, '') + ' ' + url).trim(); + const inp = document.querySelector('.thread-reply-input'); + if (inp) inp.value = s.draft; + s.preview = true; + }), // a render per keystroke only while the preview is open; the morph // leaves a focused field alone, so this can't fight the typing onInput: (e) => { s.draft = e.target.value; growComposer(e.target); if (s.preview) render(); }, @@ -7758,10 +7777,12 @@ export function messagesFeature(ctx) { if (ui.profCompose == null && !draftFor(POST_DRAFT)) return null; const text = composeText(); return h('div', { class: 'col', style: 'gap:8px' }, - h('textarea', { - rows: '3', placeholder: t('profComposePh'), - style: 'font-family:var(--sans);min-height:64px', + h('coinos-text', { + class: 'post-input', placeholder: t('profComposePh'), + style: 'font-family:var(--sans);min-height:84px;max-height:320px', value: text, + // the field is focused, so the morph leaves it: put the URL in by hand + onMedia: (f) => attachMedia(f).then(() => { const el = document.querySelector('.post-input'); if (el) el.value = composeText(); }), onInput: (ev) => { ui.profCompose = ev.target.value; setDraft(POST_DRAFT, ev.target.value); @@ -8498,9 +8519,10 @@ export function messagesFeature(ctx) { type: 'file', id: 'msg-file', accept: 'image/*,video/*', style: 'display:none', onChange: (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; if (f) onAttach(f); }, }) : null, - h('textarea', { - class: 'grow', id: 'msg-draft', placeholder, rows: String(Math.min(5, draftFor(draftKey).split('\n').length)), - value: draftFor(draftKey), maxlength: '2000', + h('coinos-text', { + class: 'grow', id: 'msg-draft', placeholder, + value: draftFor(draftKey), + onMedia: onAttach ? (f) => onAttach(f) : null, onInput: (e) => { setDraft(draftKey, e.target.value); growComposer(e.target); updateEmojiAc(e.target, draftKey); if (onType && e.target.value) onType(); }, onClick: (e) => updateEmojiAc(e.target, draftKey), // the caret moved onBlur: () => { if (ui.emojiAc) { ui.emojiAc = null; render(); } }, diff --git a/src/rich-text.js b/src/rich-text.js new file mode 100644 index 0000000..7003f5c --- /dev/null +++ b/src/rich-text.js @@ -0,0 +1,181 @@ +// <coinos-text>: a plain-text field built on contenteditable, standing in +// for <textarea> in the composers. +// +// Why: Android keyboards (Gboard's GIFs, stickers, image suggestions) only +// offer pictures to a field that says it accepts them, and Chrome says so +// for contenteditable — never for <textarea>, where the GIF key sits greyed +// out with "This app does not support images here". So the field is +// editable HTML that behaves, to the code around it, like a textarea: +// `value`, `selectionStart`/`selectionEnd` (read and write), +// `setSelectionRange`, a `placeholder`, Enter handled by the page. Text +// stays plain — pasted formatting is dropped — and any picture that arrives +// (a keyboard GIF, a pasted or dropped image) is handed to `onmedia(file)` +// as a File, the same path as the paperclip, instead of landing in the text. + +const BLOCK = new Set(['DIV', 'P', 'LI']); + +// The field's text, newlines where the editor put <br> or a new block. +function textOf(root) { + let out = ''; + const walk = (n) => { + for (const c of n.childNodes) { + if (c.nodeType === 3) out += c.data; + else if (c.nodeName === 'BR') out += '\n'; + else if (c.nodeType === 1) { + if (BLOCK.has(c.nodeName) && out && !out.endsWith('\n')) out += '\n'; + walk(c); + } + } + }; + walk(root); + // an editor leaves a lone trailing <br> to hold an empty last line open + return out.endsWith('\n') && root.lastChild && root.lastChild.nodeName === 'BR' ? out.slice(0, -1) : out; +} + +// Text length from the start of `root` up to (node, offset). +function offsetOf(root, node, offset) { + const r = document.createRange(); + r.selectNodeContents(root); + try { r.setEnd(node, offset); } catch { return textOf(root).length; } + const frag = r.cloneContents(); + const div = document.createElement('div'); + div.append(frag); + let t = textOf(div); + // a <br> right at the caret counts as the newline it is + if (div.lastChild && div.lastChild.nodeName === 'BR') t += '\n'; + return t.length; +} + +// (node, offset) for a text position; the field is normalised to text nodes +// and <br>s, so that is all this walks. +function pointAt(root, pos) { + let left = pos; + for (const c of root.childNodes) { + if (c.nodeType === 3) { + if (left <= c.data.length) return [c, left]; + left -= c.data.length; + } else if (c.nodeName === 'BR') { + if (left === 0) return [root, [...root.childNodes].indexOf(c)]; + left -= 1; + } + } + return [root, root.childNodes.length]; +} + +const mediaOf = (dt) => [...((dt && dt.files) || [])].filter((f) => /^(image|video)\//.test(f.type)); + +class CoinosText extends HTMLElement { + constructor() { + super(); + this._pendingValue = null; + this.addEventListener('paste', (e) => this._onPaste(e)); + this.addEventListener('drop', (e) => { + const files = mediaOf(e.dataTransfer); + if (files.length) { e.preventDefault(); this._media(files); } + }); + this.addEventListener('beforeinput', (e) => this._onBeforeInput(e)); + this.addEventListener('input', () => this._tidy()); + } + connectedCallback() { + if (!this.hasAttribute('contenteditable')) this.setAttribute('contenteditable', 'true'); + this.setAttribute('role', 'textbox'); + this.setAttribute('aria-multiline', 'true'); + if (this.hasAttribute('placeholder')) this.setAttribute('aria-placeholder', this.getAttribute('placeholder')); + this._empty(); + } + + get value() { return textOf(this); } + set value(v) { + v = v == null ? '' : String(v); + if (v === this.value) return; + // text nodes and <br>s only: what pointAt walks + this.replaceChildren(...v.split('\n').flatMap((line, i) => [...(i ? [document.createElement('br')] : []), ...(line ? [document.createTextNode(line)] : [])])); + if (v.endsWith('\n')) this.append(document.createElement('br')); + this._empty(); + } + get placeholder() { return this.getAttribute('placeholder') || ''; } + set placeholder(v) { this.setAttribute('placeholder', v); } + + get selectionStart() { return this._sel()[0]; } + get selectionEnd() { return this._sel()[1]; } + set selectionStart(v) { const [, e] = this._sel(); this.setSelectionRange(v, Math.max(v, e)); } + set selectionEnd(v) { const [s] = this._sel(); this.setSelectionRange(Math.min(s, v), v); } + _sel() { + const sel = document.getSelection(); + if (!sel || !sel.rangeCount || !this.contains(sel.anchorNode)) { const n = this.value.length; return [n, n]; } + const r = sel.getRangeAt(0); + return [offsetOf(this, r.startContainer, r.startOffset), offsetOf(this, r.endContainer, r.endOffset)]; + } + setSelectionRange(start, end = start) { + this.normalize(); + const sel = document.getSelection(); + if (!sel) return; + const r = document.createRange(); + const [sn, so] = pointAt(this, start); + const [en, eo] = pointAt(this, end); + try { r.setStart(sn, so); r.setEnd(en, eo); sel.removeAllRanges(); sel.addRange(r); } catch {} + } + + // Insert text at the caret as the editor would (undo history intact). + _insert(text) { + if (!document.execCommand('insertText', false, text)) { + const s = this.selectionStart, e = this.selectionEnd, v = this.value; + this.value = v.slice(0, s) + text + v.slice(e); + this.setSelectionRange(s + text.length); + this.dispatchEvent(new Event('input', { bubbles: true })); + } + } + _media(files) { + for (const f of files) { + if (typeof this.onmedia === 'function') this.onmedia(f); + } + } + _onPaste(e) { + const dt = e.clipboardData; + const files = mediaOf(dt); + if (files.length) { e.preventDefault(); this._media(files); return; } + // plain text only: no fonts, links or colours from wherever it was copied + e.preventDefault(); + const text = dt ? dt.getData('text/plain') : ''; + if (text) this._insert(text.replace(/\r\n?/g, '\n')); + } + _onBeforeInput(e) { + // keyboard-inserted pictures (Android's commitContent) and drops arrive + // as insertions carrying a DataTransfer + if (e.dataTransfer) { + const files = mediaOf(e.dataTransfer); + if (files.length) { e.preventDefault(); this._media(files); return; } + if (/^insertFrom/.test(e.inputType)) { + e.preventDefault(); + const text = e.dataTransfer.getData('text/plain'); + if (text) this._insert(text.replace(/\r\n?/g, '\n')); + return; + } + } + // a new line is a newline, not a new <div> + if (e.inputType === 'insertParagraph') { e.preventDefault(); this._insert('\n'); return; } + if (/^format/.test(e.inputType)) e.preventDefault(); // no bold/italic shortcuts + } + // Anything that isn't text or <br> (an <img> a keyboard managed to put + // in, a styled span) is taken out; a picture goes to onmedia. + _tidy() { + for (const img of this.querySelectorAll('img')) { + const src = img.getAttribute('src'); + img.remove(); + if (src) fetch(src).then((r) => r.blob()).then((b) => { + if (/^(image|video)\//.test(b.type)) this._media([new File([b], 'pasted.' + (b.type.split('/')[1] || 'png'), { type: b.type })]); + }).catch(() => {}); + } + if ([...this.children].some((c) => c.nodeName !== 'BR')) { + const s = this.selectionStart, v = this.value; + this.value = v; + if (document.activeElement === this) this.setSelectionRange(Math.min(s, v.length)); + } + this._empty(); + } + _empty() { this.toggleAttribute('data-empty', !this.value); } +} + +if (typeof customElements !== 'undefined' && !customElements.get('coinos-text')) { + customElements.define('coinos-text', CoinosText); +} diff --git a/src/style.css b/src/style.css index 9b0bf45..9bdaf3c 100644 --- a/src/style.css +++ b/src/style.css @@ -248,6 +248,16 @@ select { background-repeat: no-repeat; background-position: right 14px center; } textarea { resize: vertical; min-height: 84px; font-family: var(--mono); font-size: 15px; } +/* The composers' text field (src/rich-text.js): a textarea to look at and + type in, contenteditable underneath so keyboards offer GIFs and images. */ +coinos-text { + display: block; width: 100%; box-sizing: border-box; font: inherit; color: var(--ink); background: var(--paper); + border: 1px solid var(--line-strong); border-radius: 11px; padding: 11px 13px; + white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto; line-height: 1.4; cursor: text; +} +coinos-text:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); } +coinos-text[data-empty]::before { content: attr(placeholder); color: var(--muted); pointer-events: none; } +.chat-compose coinos-text { flex: 1; min-width: 0; padding: 10px 13px; max-height: 120px; } .mono-input { font-family: var(--mono); } .input-group { display: flex; gap: 8px; } .input-group input { flex: 1; } |
