Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
21 kB · 730 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731import { html, render } from "/js/lib/lit-html.js";import { Component } from "/js/components/component.js";import { getUnresolvedFacetsFromText } from "/js/facetHelpers.js";import { avatarTemplate } from "/js/templates/avatar.template.js";import { getDisplayName } from "/js/dataHelpers.js";import { getIndexFromByteIndex, getByteLength } from "/js/utils.js";import { TYPEAHEAD_SERVICE_URL } from "/js/config.js";
const FACET_TYPES = new Set([ "app.bsky.richtext.facet#mention", "app.bsky.richtext.facet#link", "app.bsky.richtext.facet#tag",]);
function parseUriList(uriList) { return uriList .split(/\r?\n/) .filter((line) => line.length > 0 && !line.startsWith("#")) .join("\n");}
function facetsOverlap(a, b) { return ( a.index.byteStart < b.index.byteEnd && a.index.byteEnd > b.index.byteStart );}
function editableLineTemplate(lineText, lineFacets, lineByteOffset) { if (lineText.length === 0) return html`<div><br /></div>`; const parts = []; let cursor = 0; for (const facet of lineFacets) { const startChar = getIndexFromByteIndex( lineText, facet.index.byteStart - lineByteOffset, ); const endChar = getIndexFromByteIndex( lineText, facet.index.byteEnd - lineByteOffset, ); if (startChar < cursor) continue; if (cursor < startChar) parts.push(lineText.slice(cursor, startChar)); parts.push( html`<span class="facet" data-testid="facet" >${lineText.slice(startChar, endChar)}</span >`, ); cursor = endChar; } if (cursor < lineText.length) parts.push(lineText.slice(cursor)); return html`<div>${parts}</div>`;}
function editableContentTemplate(text, facets) { const valid = facets .filter((f) => FACET_TYPES.has(f.features[0]?.$type)) .sort((a, b) => a.index.byteStart - b.index.byteStart); const distinct = []; for (const facet of valid) { if (!distinct.some((d) => facetsOverlap(d, facet))) distinct.push(facet); }
const lines = text.split("\n"); const divs = []; let byteOffset = 0; for (const line of lines) { const lineByteLength = getByteLength(line); const lineFacets = distinct.filter( (facet) => facet.index.byteStart >= byteOffset && facet.index.byteEnd <= byteOffset + lineByteLength, ); divs.push(editableLineTemplate(line, lineFacets, byteOffset)); byteOffset += lineByteLength + 1; } return html`${divs}`;}
function findNodeAtCharOffset(root, target) { let pos = 0; let result = null;
function walk(node) { if (result) return;
if (node.nodeType === Node.TEXT_NODE) { const len = node.textContent.length; if (pos + len >= target) { result = { node, offset: target - pos }; return; } pos += len; return; }
if (node.nodeName === "BR") { if (pos + 1 > target) { result = { node: node.parentNode, offset: indexOfChild(node) }; return; } pos += 1; return; }
if (node.nodeName === "DIV" && node !== root) { for (const child of node.childNodes) { walk(child); if (result) return; } const lastChild = node.childNodes[node.childNodes.length - 1]; if (!lastChild || lastChild.nodeName !== "BR") { if (pos + 1 > target) { result = { node, offset: node.childNodes.length }; return; } pos += 1; } return; }
for (const child of node.childNodes) { walk(child); if (result) return; } }
walk(root); return result;}
function indexOfChild(node) { let i = 0; let sibling = node; while ((sibling = sibling.previousSibling)) i++; return i;}
function rangeForCharRange(root, start, end) { const startPos = findNodeAtCharOffset(root, start); const endPos = findNodeAtCharOffset(root, end); if (!startPos || !endPos) return null; const range = document.createRange(); range.setStart(startPos.node, startPos.offset); range.setEnd(endPos.node, endPos.offset); return range;}
function getCursorPosition(editableDiv) { const sel = window.getSelection(); if (!sel.rangeCount) return 0;
const range = sel.getRangeAt(0); const endContainer = range.endContainer; const endOffset = range.endOffset;
let position = 0; let found = false;
function walkNodes(node) { if (found) return;
if (node === endContainer) { if (node.nodeType === Node.TEXT_NODE) { position += endOffset; } else { // Element node - count children up to endOffset for (let i = 0; i < endOffset && i < node.childNodes.length; i++) { countNodeChars(node.childNodes[i]); } } found = true; return; }
if (node.nodeType === Node.TEXT_NODE) { position += node.textContent.length; } else if (node.nodeName === "BR") { position += 1; } else if (node.nodeName === "DIV" && node !== editableDiv) { for (let child of node.childNodes) { walkNodes(child); if (found) return; } // Add 1 for the newline after the DIV, but only if it didn't end with BR // (BR already counted as newline, so don't double-count) if (!found) { const lastChild = node.childNodes[node.childNodes.length - 1]; if (!lastChild || lastChild.nodeName !== "BR") { position += 1; } } } else { for (let child of node.childNodes) { walkNodes(child); if (found) return; } } }
function countNodeChars(node) { if (node.nodeType === Node.TEXT_NODE) { position += node.textContent.length; } else if (node.nodeName === "BR") { position += 1; } else if (node.nodeName === "DIV") { for (let child of node.childNodes) { countNodeChars(child); } // Only add newline if DIV didn't end with BR (which already counted) const lastChild = node.childNodes[node.childNodes.length - 1]; if (!lastChild || lastChild.nodeName !== "BR") { position += 1; } } else { for (let child of node.childNodes) { countNodeChars(child); } } }
walkNodes(editableDiv); return position;}
function setCursorPosition(editableDiv, position) { const found = findNodeAtCharOffset(editableDiv, position); if (!found) return; const range = document.createRange(); range.setStart(found.node, found.offset); range.collapse(true); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); editableDiv.focus();}
function getContentEditableText(element) { if ( element.childNodes.length === 1 && element.childNodes[0].nodeName === "BR" ) { return ""; } function extractText(node) { let text = ""; if (node.nodeType === Node.TEXT_NODE) { return node.textContent; } if (node.nodeName === "BR") { return "\n"; } const blockElements = [ "DIV", "P", "H1", "H2", "H3", "H4", "H5", "H6", "LI", "BLOCKQUOTE", ]; const isBlock = blockElements.includes(node.nodeName); for (let child of node.childNodes) { text += extractText(child); } if (isBlock && node !== element && text && !text.endsWith("\n")) { text += "\n"; } return text; } let result = extractText(element); if (result.endsWith("\n")) { result = result.slice(0, -1); } return result;}
function mentionSuggestionsTemplate({ mentionSuggestions, selectedSuggestionIndex, onSelect,}) { return html` <div class="mention-typeahead" id="mention-typeahead" data-testid="mention-typeahead" @mousedown=${(e) => { // Keep focus in the input so its blur handler doesn't close the // typeahead before a suggestion click lands e.preventDefault(); }} > ${mentionSuggestions.length === 0 ? html` <div class="mention-typeahead-empty" data-testid="empty-state"> No results </div> ` : ""} ${mentionSuggestions.map( (actor, index) => html` <div class="mention-suggestion ${index === selectedSuggestionIndex ? "selected" : ""}" data-testid="mention-suggestion" @click=${() => onSelect(actor)} > ${avatarTemplate({ author: actor, clickAction: "none", })} <div class="mention-suggestion-text"> <div class="mention-suggestion-name"> ${getDisplayName(actor)} </div> <div class="mention-suggestion-handle" data-testid="mention-suggestion-handle" > @${actor.handle} </div> </div> </div> `, )} </div> `;}
export class RichTextInput extends Component { static get observedAttributes() { return ["disabled", "autofocus"]; }
connectedCallback() { if (this.initialized) { this.paintFacets(); return; } this.placeholder = this.getAttribute("placeholder") || ""; this.typeaheadDirection = this.getAttribute("typeahead-direction") || "down"; this.disabled = this.getAttribute("disabled") !== null; this.facets = []; this.text = ""; this.mentionSuggestions = []; this.selectedSuggestionIndex = null; this.currentMentionQuery = null; this.currentMentionStart = null; this.currentMentionEnd = null; this.isComposing = false; this.isPasting = false; this.render(); // Give the empty editable its <div><br></div> line structure so it keeps // its one-line height before the first input this.paintFacets(); this.initialized = true; }
disconnectedCallback() { this.closeTypeahead(); }
attributeChangedCallback(name) { if (!this.initialized) return; if (name === "disabled") { this.disabled = this.getAttribute("disabled") !== null; this.render(); } if (name === "autofocus") { this.render(); } }
focus(options) { const input = this.querySelector(".rich-text-input"); if (input) { input.focus(options); } }
blur() { const input = this.querySelector(".rich-text-input"); if (input) { input.blur(); } }
getCursor() { const input = this.querySelector(".rich-text-input"); if (!input) return null; return getCursorPosition(input); }
setText(text) { this.text = text; this.facets = getUnresolvedFacetsFromText(this.text); this.render(); this.paintFacets(); this.dispatchEvent( new CustomEvent("input", { detail: { text: this.text, facets: this.facets, inputType: null }, }), ); }
setCursor(cursor) { const input = this.querySelector(".rich-text-input"); if (!input) return; const position = Math.max(0, Math.min(this.text.length, cursor)); setCursorPosition(input, position); }
insertText(str, position) { const at = position ?? this.getCursor() ?? this.text.length; this.setText(this.text.slice(0, at) + str + this.text.slice(at)); this.setCursor(at + str.length); }
render() { render( html` <div class="rich-text-input-container"> <div class="rich-text-input" data-testid="rich-text-input" contenteditable=${this.disabled ? "false" : "true"} ?autofocus=${this.hasAttribute("autofocus")} @input=${(e) => { e.stopPropagation(); this.handleInput(e); }} @keydown=${(e) => { this.handleKeydown(e); }} @blur=${() => { this.resetMentionState(); }} @compositionstart=${() => { this.isComposing = true; }} @compositionend=${(e) => { this.isComposing = false; this.handleInput(e); }} @paste=${(e) => { e.preventDefault(); // https://stackoverflow.com/a/58980415 const clipboard = e.clipboardData || window.clipboardData; // iOS share-sheet links arrive as text/uri-list with no // text/plain representation const text = clipboard.getData("text/plain") || parseUriList(clipboard.getData("text/uri-list")); // execCommand fires the input event synchronously, so // handleInput runs while this flag is set this.isPasting = true; try { document.execCommand("insertText", false, text); } finally { this.isPasting = false; } }} ></div> <div class="rich-text-input-placeholder ${this.text.length > 0 ? "hidden" : ""}" > ${this.placeholder} </div> </div> `, this, ); }
openTypeahead() { if (this._typeaheadHost) return; this._typeaheadHost = document.createElement("div"); this._typeaheadHost.className = "mention-typeahead-host"; this._typeaheadHost.popover = "manual"; const parent = this.closest("dialog") ?? document.body; parent.appendChild(this._typeaheadHost); this._repositionTypeahead = () => this.positionTypeahead(); window.addEventListener("resize", this._repositionTypeahead); window.addEventListener("scroll", this._repositionTypeahead, true); this.updateTypeahead(); if (typeof this._typeaheadHost.showPopover === "function") { this._typeaheadHost.showPopover(); } }
updateTypeahead() { if (!this._typeaheadHost) return; render( mentionSuggestionsTemplate({ mentionSuggestions: this.mentionSuggestions, selectedSuggestionIndex: this.selectedSuggestionIndex, onSelect: (actor) => this.selectMention(actor), }), this._typeaheadHost, ); requestAnimationFrame(() => this.positionTypeahead()); }
closeTypeahead() { if (!this._typeaheadHost) return; window.removeEventListener("resize", this._repositionTypeahead); window.removeEventListener("scroll", this._repositionTypeahead, true); this._typeaheadHost.remove(); this._typeaheadHost = null; this._repositionTypeahead = null; }
positionTypeahead() { const typeahead = this._typeaheadHost?.querySelector("#mention-typeahead"); const input = this.querySelector(".rich-text-input"); if (!typeahead || !input || this.currentMentionStart === null) return;
const range = rangeForCharRange( input, this.currentMentionStart, this.currentMentionStart, ); if (!range) return;
const rect = range.getBoundingClientRect(); const inputRect = input.getBoundingClientRect(); if (this.typeaheadDirection === "up") { typeahead.classList.add("mention-typeahead-above"); typeahead.style.bottom = `${window.innerHeight - rect.top}px`; } else { typeahead.style.top = `${rect.bottom}px`; } typeahead.style.left = `${inputRect.left}px`; }
paintFacets() { const input = this.querySelector(".rich-text-input"); if (!input) return; const hadFocus = document.activeElement === input; const cursorPos = hadFocus ? getCursorPosition(input) : null;
const scratch = document.createElement("div"); render(editableContentTemplate(this.text, this.facets), scratch); input.innerHTML = scratch.innerHTML;
if (hadFocus && cursorPos !== null) setCursorPosition(input, cursorPos); }
detectPendingMention() { const input = this.querySelector(".rich-text-input"); const cursorPosition = getCursorPosition(input);
// Look backwards from cursor to find a potential mention let mentionStart = -1; for (let i = cursorPosition - 1; i >= 0; i--) { const char = this.text[i]; if (char === "@") { mentionStart = i; break; } // Stop if we hit a space or newline if (char === " " || char === "\n") { break; } }
if (mentionStart !== -1) { const query = this.text.substring(mentionStart + 1, cursorPosition); if (query.length > 0) { return { query, start: mentionStart, end: cursorPosition }; } }
return null; }
async fetchMentionSuggestions(query) { try { const queryParams = new URLSearchParams({ q: query, limit: "8", }); const response = await fetch( `${TYPEAHEAD_SERVICE_URL}/xrpc/app.bsky.actor.searchActorsTypeahead?${queryParams.toString()}`, ); if (response.ok) { const data = await response.json(); return data.actors; } } catch (error) { console.error("Error fetching mention suggestions:", error); }
return []; }
async updateMentionSuggestions() { const pendingMention = this.detectPendingMention();
if (pendingMention) { this.currentMentionQuery = pendingMention.query; this.currentMentionStart = pendingMention.start; this.currentMentionEnd = pendingMention.end; const suggestions = await this.fetchMentionSuggestions( pendingMention.query, ); // A newer input superseded this request while it was in flight if (this.currentMentionQuery !== pendingMention.query) return; this.mentionSuggestions = suggestions; if (this._typeaheadHost) { this.updateTypeahead(); } else { this.openTypeahead(); } } else { this.resetMentionState(); } }
resetMentionState() { this.mentionSuggestions = []; this.selectedSuggestionIndex = null; this.currentMentionQuery = null; this.currentMentionStart = null; this.currentMentionEnd = null; this.closeTypeahead(); }
selectMention(actor) { if (this.currentMentionStart === null) return;
const input = this.querySelector(".rich-text-input"); const mention = `@${actor.handle} `;
// Select the @query span, then replace via execCommand so the browser's // native undo stack records this edit alongside ordinary typing. const range = rangeForCharRange( input, this.currentMentionStart, this.currentMentionEnd, ); if (range) { const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); input.focus(); document.execCommand("insertText", false, mention); }
this.resetMentionState(); }
handleKeydown(e) { if (this.mentionSuggestions.length > 0) { if (e.key === "ArrowDown") { e.preventDefault(); if (this.selectedSuggestionIndex === null) { this.selectedSuggestionIndex = 0; } else { this.selectedSuggestionIndex = Math.min( this.selectedSuggestionIndex + 1, this.mentionSuggestions.length - 1, ); } this.updateTypeahead(); } else if (e.key === "ArrowUp") { e.preventDefault(); if (this.selectedSuggestionIndex === null) { this.selectedSuggestionIndex = 0; } else { this.selectedSuggestionIndex = Math.max( this.selectedSuggestionIndex - 1, 0, ); } this.updateTypeahead(); } else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); const index = this.selectedSuggestionIndex ?? 0; const selectedActor = this.mentionSuggestions[index]; if (selectedActor) { this.selectMention(selectedActor); } } else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); this.resetMentionState(); } } else if (this._typeaheadHost && e.key === "Escape") { // The typeahead can be open with no suggestions (empty state) e.preventDefault(); e.stopPropagation(); this.resetMentionState(); } }
handleInput(e) { this.text = getContentEditableText(e.target);
this.facets = getUnresolvedFacetsFromText(this.text);
// Rewriting the editable's DOM mid-composition cancels the IME, so facet // painting waits for compositionend if (!this.isComposing) { this.paintFacets(); } this.render();
this.updateMentionSuggestions();
this.dispatchEvent( new CustomEvent("input", { detail: { text: this.text, facets: this.facets, inputType: this.isPasting ? "insertFromPaste" : e.inputType || null, }, }), ); }}
RichTextInput.register();