import { 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`

`; 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`${lineText.slice(startChar, endChar)}`, ); cursor = endChar; } if (cursor < lineText.length) parts.push(lineText.slice(cursor)); return html`
${parts}
`; } 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`
{ // 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`
No results
` : ""} ${mentionSuggestions.map( (actor, index) => html`
onSelect(actor)} > ${avatarTemplate({ author: actor, clickAction: "none", })}
${getDisplayName(actor)}
@${actor.handle}
`, )}
`; } 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

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`
{ 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; } }} >
${this.placeholder}
`, 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();