[READ-ONLY] Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
6

Configure Feed

Select the types of activity you want to include in your feed.

Update rich text rendering

Grace Kind (Jun 29, 2026, 2:25 PM -0500) 96cc7455 4b11f013

+76 -61
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.76", 3 + "version": "0.17.77", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+3 -15
src/css/style.css
··· 5716 5716 min-height: 120px; 5717 5717 border: none; 5718 5718 background: transparent; 5719 - color: transparent; 5720 - caret-color: var(--text-color); 5719 + color: var(--text-color); 5721 5720 font-size: 16px; 5722 5721 font-family: inherit; 5723 5722 resize: none; 5724 5723 outline: none; 5725 5724 } 5726 5725 5727 - .rich-text-input, 5728 - .rich-text-input-overlay { 5726 + .rich-text-input { 5729 5727 white-space: pre-wrap; 5730 5728 overflow-wrap: break-word; 5731 5729 word-break: break-word; 5732 5730 } 5733 5731 5734 - .rich-text-input-overlay { 5735 - position: absolute; 5736 - inset: 0; 5737 - pointer-events: none; 5738 - color: var(--text-color); 5739 - font: inherit; 5740 - font-size: 16px; 5741 - font-family: inherit; 5742 - } 5743 - 5744 - .rich-text-input-overlay .facet { 5732 + .rich-text-input .facet { 5745 5733 color: var(--text-link-color); 5746 5734 } 5747 5735
+64 -35
src/js/components/rich-text-input.js
··· 3 3 import { getUnresolvedFacetsFromText } from "/js/facetHelpers.js"; 4 4 import { avatarTemplate } from "/js/templates/avatar.template.js"; 5 5 import { getDisplayName } from "/js/dataHelpers.js"; 6 - import { getIndexFromByteIndex } from "/js/utils.js"; 6 + import { getIndexFromByteIndex, getByteLength } from "/js/utils.js"; 7 7 import { TYPEAHEAD_SERVICE_URL } from "/js/config.js"; 8 8 9 9 const FACET_TYPES = new Set([ ··· 12 12 "app.bsky.richtext.facet#tag", 13 13 ]); 14 14 15 + function facetsOverlap(a, b) { 16 + return ( 17 + a.index.byteStart < b.index.byteEnd && a.index.byteEnd > b.index.byteStart 18 + ); 19 + } 20 + 21 + function editableLineTemplate(lineText, lineFacets, lineByteOffset) { 22 + if (lineText.length === 0) return html`<div><br /></div>`; 23 + const parts = []; 24 + let cursor = 0; 25 + for (const facet of lineFacets) { 26 + const startChar = getIndexFromByteIndex( 27 + lineText, 28 + facet.index.byteStart - lineByteOffset, 29 + ); 30 + const endChar = getIndexFromByteIndex( 31 + lineText, 32 + facet.index.byteEnd - lineByteOffset, 33 + ); 34 + if (startChar < cursor) continue; 35 + if (cursor < startChar) parts.push(lineText.slice(cursor, startChar)); 36 + parts.push( 37 + html`<span class="facet">${lineText.slice(startChar, endChar)}</span>`, 38 + ); 39 + cursor = endChar; 40 + } 41 + if (cursor < lineText.length) parts.push(lineText.slice(cursor)); 42 + return html`<div>${parts}</div>`; 43 + } 44 + 45 + function editableContentTemplate(text, facets) { 46 + const valid = facets 47 + .filter((f) => FACET_TYPES.has(f.features[0]?.$type)) 48 + .sort((a, b) => a.index.byteStart - b.index.byteStart); 49 + const distinct = []; 50 + for (const facet of valid) { 51 + if (!distinct.some((d) => facetsOverlap(d, facet))) distinct.push(facet); 52 + } 53 + 54 + const lines = text.split("\n"); 55 + const divs = []; 56 + let byteOffset = 0; 57 + for (const line of lines) { 58 + const lineByteLength = getByteLength(line); 59 + const lineFacets = distinct.filter( 60 + (facet) => 61 + facet.index.byteStart >= byteOffset && 62 + facet.index.byteEnd <= byteOffset + lineByteLength, 63 + ); 64 + divs.push(editableLineTemplate(line, lineFacets, byteOffset)); 65 + byteOffset += lineByteLength + 1; 66 + } 67 + return html`${divs}`; 68 + } 69 + 15 70 function findNodeAtCharOffset(root, target) { 16 71 let pos = 0; 17 72 let result = null; ··· 277 332 278 333 setText(text) { 279 334 this.text = text; 280 - const input = this.querySelector(".rich-text-input"); 281 - if (input) { 282 - input.textContent = text; 283 - } 284 335 this.facets = getUnresolvedFacetsFromText(this.text); 285 336 this.render(); 286 337 this.paintFacets(); ··· 302 353 render( 303 354 html` 304 355 <div class="rich-text-input-container"> 305 - <div class="rich-text-input-overlay" aria-hidden="true"></div> 306 356 <div 307 357 class="rich-text-input" 308 358 contenteditable="true" ··· 312 362 }} 313 363 @keydown=${(e) => { 314 364 this.handleKeydown(e); 315 - }} 316 - @scroll=${(e) => { 317 - const overlay = this.querySelector(".rich-text-input-overlay"); 318 - if (overlay) overlay.scrollTop = e.target.scrollTop; 319 365 }} 320 366 @compositionstart=${() => { 321 367 this.isComposing = true; ··· 404 450 } 405 451 406 452 paintFacets() { 407 - const overlay = this.querySelector(".rich-text-input-overlay"); 408 - if (!overlay) return; 409 - 410 - const sorted = [...this.facets] 411 - .filter((f) => FACET_TYPES.has(f.features[0]?.$type)) 412 - .sort((a, b) => a.index.byteStart - b.index.byteStart); 453 + const input = this.querySelector(".rich-text-input"); 454 + if (!input) return; 455 + const hadFocus = document.activeElement === input; 456 + const cursorPos = hadFocus ? getCursorPosition(input) : null; 413 457 414 - const parts = []; 415 - let cursor = 0; 416 - for (const facet of sorted) { 417 - const charStart = getIndexFromByteIndex(this.text, facet.index.byteStart); 418 - const charEnd = getIndexFromByteIndex(this.text, facet.index.byteEnd); 419 - if (charStart < cursor) continue; 420 - if (cursor < charStart) parts.push(this.text.slice(cursor, charStart)); 421 - parts.push( 422 - html`<span class="facet">${this.text.slice(charStart, charEnd)}</span>`, 423 - ); 424 - cursor = charEnd; 425 - } 426 - if (cursor < this.text.length) parts.push(this.text.slice(cursor)); 458 + const scratch = document.createElement("div"); 459 + render(editableContentTemplate(this.text, this.facets), scratch); 460 + input.innerHTML = scratch.innerHTML; 427 461 428 - render(html`${parts}`, overlay); 462 + if (hadFocus && cursorPos !== null) setCursorPosition(input, cursorPos); 429 463 } 430 464 431 465 detectPendingMention() { ··· 535 569 } 536 570 537 571 handleKeydown(e) { 538 - if (e.key === "Enter" && this.mentionSuggestions.length === 0) { 539 - e.preventDefault(); 540 - document.execCommand("insertText", false, "\n"); 541 - return; 542 - } 543 572 if (this.mentionSuggestions.length > 0) { 544 573 if (e.key === "ArrowDown") { 545 574 e.preventDefault();
+8 -10
tests/unit/specs/components/rich-text-input.test.js
··· 168 168 const element = document.createElement("rich-text-input"); 169 169 document.body.appendChild(element); 170 170 element.setText("Check https://example.com today"); 171 - const overlay = element.querySelector(".rich-text-input-overlay"); 172 - const facets = overlay.querySelectorAll(".facet"); 171 + const input = element.querySelector(".rich-text-input"); 172 + const facets = input.querySelectorAll(".facet"); 173 173 assertEquals(facets.length, 1); 174 174 assertEquals(facets[0].textContent, "https://example.com"); 175 175 }); ··· 178 178 const element = document.createElement("rich-text-input"); 179 179 document.body.appendChild(element); 180 180 element.setText("hello #news"); 181 - const overlay = element.querySelector(".rich-text-input-overlay"); 182 - const facets = overlay.querySelectorAll(".facet"); 181 + const input = element.querySelector(".rich-text-input"); 182 + const facets = input.querySelectorAll(".facet"); 183 183 assertEquals(facets.length, 1); 184 184 assertEquals(facets[0].textContent, "#news"); 185 185 }); ··· 188 188 const element = document.createElement("rich-text-input"); 189 189 document.body.appendChild(element); 190 190 element.setText("hello #news"); 191 - const overlay = element.querySelector(".rich-text-input-overlay"); 192 - assertEquals(overlay.querySelectorAll(".facet").length, 1); 191 + const input = element.querySelector(".rich-text-input"); 192 + assertEquals(input.querySelectorAll(".facet").length, 1); 193 193 element.setText("hello world"); 194 - assertEquals(overlay.querySelectorAll(".facet").length, 0); 194 + assertEquals(input.querySelectorAll(".facet").length, 0); 195 195 }); 196 196 197 - it("does not write innerHTML for facet rendering", () => { 197 + it("preserves the text content across facet rendering", () => { 198 198 const element = document.createElement("rich-text-input"); 199 199 document.body.appendChild(element); 200 200 element.setText("hello #news today"); 201 201 const input = element.querySelector(".rich-text-input"); 202 - // No anchors or spans should be injected — DOM stays as the user typed it. 203 - assertEquals(input.querySelectorAll("a, span").length, 0); 204 202 assertEquals(input.textContent, "hello #news today"); 205 203 }); 206 204 });