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();