866 lines
28 KiB
JavaScript
866 lines
28 KiB
JavaScript
/* Inlined stubs for sovereign_browser — WebKit's custom sovereign://
|
|
* scheme does not resolve relative ES module imports, so we inline the
|
|
* dependencies instead of importing from separate .mjs files. These
|
|
* stubs are sufficient for the chat composer (no uploads, no preview). */
|
|
function uploadToAllServers() {
|
|
return Promise.reject(new Error('Blossom uploads are not available in sovereign_browser'));
|
|
}
|
|
function getBlobUrl(sha256, ext) {
|
|
return `blossom://${sha256}.${ext}`;
|
|
}
|
|
function htmlFormatText(text = '') {
|
|
const div = document.createElement('div');
|
|
div.textContent = String(text || '');
|
|
return div.innerHTML;
|
|
}
|
|
function hydrateNostrEntities() {
|
|
/* No-op in sovereign_browser. */
|
|
}
|
|
|
|
function debounce(fn, delay = 250) {
|
|
let timer = null;
|
|
return (...args) => {
|
|
if (timer) clearTimeout(timer);
|
|
timer = setTimeout(() => fn(...args), delay);
|
|
};
|
|
}
|
|
|
|
function getFileExtension(file) {
|
|
const fromName = String(file?.name || '').split('.').pop();
|
|
if (fromName && fromName !== file?.name) return fromName.toLowerCase();
|
|
const fromType = String(file?.type || '').split('/').pop();
|
|
return fromType ? fromType.toLowerCase() : '';
|
|
}
|
|
|
|
function getCaretCharacterOffsetWithin(element) {
|
|
const selection = window.getSelection();
|
|
if (!selection || selection.rangeCount === 0) return 0;
|
|
|
|
const range = selection.getRangeAt(0);
|
|
const preCaretRange = range.cloneRange();
|
|
preCaretRange.selectNodeContents(element);
|
|
preCaretRange.setEnd(range.endContainer, range.endOffset);
|
|
return preCaretRange.toString().length;
|
|
}
|
|
|
|
function setCaretByCharacterOffset(element, offset) {
|
|
const selection = window.getSelection();
|
|
if (!selection) return;
|
|
|
|
const range = document.createRange();
|
|
let currentOffset = 0;
|
|
let found = false;
|
|
|
|
const walk = (node) => {
|
|
if (found) return;
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
const textLength = node.textContent?.length || 0;
|
|
if (currentOffset + textLength >= offset) {
|
|
range.setStart(node, Math.max(0, offset - currentOffset));
|
|
range.collapse(true);
|
|
found = true;
|
|
} else {
|
|
currentOffset += textLength;
|
|
}
|
|
return;
|
|
}
|
|
|
|
for (const child of node.childNodes) {
|
|
walk(child);
|
|
if (found) return;
|
|
}
|
|
};
|
|
|
|
walk(element);
|
|
|
|
if (!found) {
|
|
range.selectNodeContents(element);
|
|
range.collapse(false);
|
|
}
|
|
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
}
|
|
|
|
function detectMentionQuery(text, caretOffset) {
|
|
const left = text.slice(0, caretOffset);
|
|
const match = left.match(/(^|\s)@([a-z0-9_.-]{1,32})$/i);
|
|
if (!match) return null;
|
|
|
|
const query = match[2] || '';
|
|
const start = left.length - query.length - 1;
|
|
const end = caretOffset;
|
|
return { query, start, end };
|
|
}
|
|
|
|
function escapeHtml(text = '') {
|
|
return String(text)
|
|
.replace(/&/g, '\u0026amp;')
|
|
.replace(/</g, '\u0026lt;')
|
|
.replace(/>/g, '\u0026gt;')
|
|
.replace(/"/g, '\u0026quot;')
|
|
.replace(/'/g, '\u0026#39;');
|
|
}
|
|
|
|
function toNpub(pubkeyOrNpub) {
|
|
if (!pubkeyOrNpub) return null;
|
|
if (String(pubkeyOrNpub).startsWith('npub1')) return String(pubkeyOrNpub);
|
|
try {
|
|
return window?.NostrTools?.nip19?.npubEncode?.(pubkeyOrNpub) || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function positionDropdownNearCaret(hostEl, dropdownEl) {
|
|
const selection = window.getSelection();
|
|
if (!selection || selection.rangeCount === 0) return;
|
|
|
|
const range = selection.getRangeAt(0).cloneRange();
|
|
range.collapse(true);
|
|
|
|
let rect = range.getBoundingClientRect();
|
|
if (!rect || (!rect.width && !rect.height)) {
|
|
const hostRect = hostEl.getBoundingClientRect();
|
|
rect = {
|
|
left: hostRect.left + 8,
|
|
top: hostRect.top + hostRect.height - 8,
|
|
bottom: hostRect.top + hostRect.height - 8
|
|
};
|
|
}
|
|
|
|
dropdownEl.style.left = `${Math.max(8, rect.left)}px`;
|
|
dropdownEl.style.top = `${Math.max(8, rect.bottom + 6)}px`;
|
|
}
|
|
|
|
function filterMentionCandidates(followedProfiles, query) {
|
|
const q = String(query || '').toLowerCase().trim();
|
|
if (!q) return [];
|
|
|
|
return followedProfiles
|
|
.filter((p) => {
|
|
const name = String(p?.name || '').toLowerCase();
|
|
const display = String(p?.display_name || '').toLowerCase();
|
|
const nip05 = String(p?.nip05 || '').toLowerCase();
|
|
const shortPk = String(p?.pubkey || '').slice(0, 12).toLowerCase();
|
|
return name.includes(q) || display.includes(q) || nip05.includes(q) || shortPk.includes(q);
|
|
})
|
|
.slice(0, 8);
|
|
}
|
|
|
|
function createUploadIcon(kind = 'upload') {
|
|
const normalizedKind = kind === 'image' ? 'image' : 'upload';
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'post-composer-upload-icon';
|
|
if (normalizedKind === 'image') {
|
|
btn.classList.add('is-image-icon');
|
|
}
|
|
|
|
if (normalizedKind === 'image') {
|
|
btn.title = 'Attach image';
|
|
btn.setAttribute('aria-label', 'Attach image');
|
|
btn.innerHTML = `
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<rect x="3" y="5" width="18" height="14" rx="2" ry="2"/>
|
|
<circle cx="9" cy="10" r="1.5"/>
|
|
<path d="M6 16l4-4 3 3 3-3 2 2"/>
|
|
</svg>
|
|
`;
|
|
return btn;
|
|
}
|
|
|
|
btn.title = 'Upload file';
|
|
btn.setAttribute('aria-label', 'Upload file');
|
|
btn.innerHTML = `
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
|
<path d="M7 10l5-5 5 5"/>
|
|
<path d="M12 5v12"/>
|
|
</svg>
|
|
`;
|
|
return btn;
|
|
}
|
|
|
|
export function mountComposer(hostEl, options = {}) {
|
|
if (!hostEl) throw new Error('mountComposer requires a host element');
|
|
|
|
let followedProfiles = Array.isArray(options.followedProfiles) ? options.followedProfiles.slice() : [];
|
|
const showUploadIcon = options.showUploadIcon !== false;
|
|
const showPreview = options.showPreview !== false;
|
|
const autoHideOnSubmit = options.autoHideOnSubmit === true;
|
|
const submitOnEnter = options.submitOnEnter === true;
|
|
const hideOnEscape = options.hideOnEscape === true;
|
|
const alwaysShowSendButton = options.alwaysShowSendButton === true;
|
|
const clearBeforeSubmit = options.clearBeforeSubmit !== false;
|
|
const enableHistory = options.enableHistory !== false;
|
|
const historyKey = typeof options.historyKey === 'string' && options.historyKey.trim()
|
|
? options.historyKey.trim()
|
|
: 'post_composer_history_v1';
|
|
const historyMaxEntries = Math.max(1, Math.floor(Number(options.historyMaxEntries || 50)));
|
|
const layout = options.layout === 'inline' ? 'inline' : 'default';
|
|
const popupMode = options.popupMode === true;
|
|
const onSubmit = typeof options.onSubmit === 'function' ? options.onSubmit : null;
|
|
const onFileAttach = typeof options.onFileAttach === 'function' ? options.onFileAttach : null;
|
|
const uploadMode = options.uploadMode === 'direct-image' ? 'direct-image' : 'blossom-url';
|
|
const uploadIcon = options.uploadIcon === 'image' ? 'image' : (uploadMode === 'direct-image' ? 'image' : 'upload');
|
|
const defaultFileAccept = uploadMode === 'direct-image'
|
|
? 'image/*'
|
|
: 'image/*,video/*,audio/*,.pdf,.txt,.md,.zip';
|
|
const fileAccept = typeof options.fileAccept === 'string' && options.fileAccept.trim()
|
|
? options.fileAccept.trim()
|
|
: defaultFileAccept;
|
|
|
|
hostEl.setAttribute('contenteditable', 'true');
|
|
|
|
let wrapper = hostEl.parentElement;
|
|
if (!wrapper || !wrapper.classList.contains('post-composer-wrapper')) {
|
|
wrapper = document.createElement('div');
|
|
wrapper.className = 'post-composer-wrapper';
|
|
hostEl.parentNode?.insertBefore(wrapper, hostEl);
|
|
wrapper.appendChild(hostEl);
|
|
}
|
|
|
|
const editorShell = document.createElement('div');
|
|
editorShell.className = 'post-composer-editor-shell';
|
|
wrapper.insertBefore(editorShell, hostEl);
|
|
editorShell.appendChild(hostEl);
|
|
|
|
const dragOverlay = document.createElement('div');
|
|
dragOverlay.className = 'post-composer-drag-overlay';
|
|
dragOverlay.textContent = 'Drop files to upload';
|
|
editorShell.appendChild(dragOverlay);
|
|
|
|
const uploadStatus = document.createElement('div');
|
|
uploadStatus.className = 'post-composer-uploading';
|
|
uploadStatus.style.display = 'none';
|
|
editorShell.appendChild(uploadStatus);
|
|
|
|
let uploadButton = null;
|
|
if (showUploadIcon) {
|
|
uploadButton = createUploadIcon(uploadIcon);
|
|
editorShell.appendChild(uploadButton);
|
|
}
|
|
|
|
const fileInput = document.createElement('input');
|
|
fileInput.type = 'file';
|
|
fileInput.multiple = true;
|
|
fileInput.accept = fileAccept;
|
|
fileInput.style.display = 'none';
|
|
wrapper.appendChild(fileInput);
|
|
|
|
const previewEl = document.createElement('div');
|
|
previewEl.className = 'post-composer-preview';
|
|
previewEl.innerHTML = `
|
|
<div class="post-composer-preview-label">Preview</div>
|
|
<div class="post-composer-preview-content divPostContent"></div>
|
|
`;
|
|
const previewContentEl = previewEl.querySelector('.post-composer-preview-content');
|
|
|
|
const sendButton = document.createElement('button');
|
|
sendButton.type = 'button';
|
|
sendButton.className = 'post-composer-send-btn';
|
|
if (alwaysShowSendButton) sendButton.classList.add('is-persistent-visible');
|
|
sendButton.textContent = 'Send';
|
|
sendButton.disabled = true;
|
|
|
|
if (layout === 'inline') {
|
|
wrapper.classList.add('post-composer-wrapper--inline');
|
|
if (showPreview) wrapper.appendChild(previewEl);
|
|
const inlineRow = document.createElement('div');
|
|
inlineRow.className = 'post-composer-inline-row';
|
|
wrapper.appendChild(inlineRow);
|
|
inlineRow.appendChild(editorShell);
|
|
inlineRow.appendChild(sendButton);
|
|
} else if (popupMode) {
|
|
wrapper.classList.add('post-composer-wrapper--popup');
|
|
const scrollBody = document.createElement('div');
|
|
scrollBody.className = 'post-composer-popup-body';
|
|
scrollBody.appendChild(editorShell);
|
|
if (showPreview) scrollBody.appendChild(previewEl);
|
|
wrapper.appendChild(scrollBody);
|
|
const stickyFooter = document.createElement('div');
|
|
stickyFooter.className = 'post-composer-popup-footer';
|
|
stickyFooter.appendChild(sendButton);
|
|
wrapper.appendChild(stickyFooter);
|
|
} else {
|
|
if (showPreview) wrapper.appendChild(previewEl);
|
|
wrapper.appendChild(sendButton);
|
|
}
|
|
|
|
const mentionDropdown = document.createElement('div');
|
|
mentionDropdown.className = 'post-composer-mention-dropdown';
|
|
document.body.appendChild(mentionDropdown);
|
|
|
|
let dragDepth = 0;
|
|
let uploadingCount = 0;
|
|
let externallyDisabled = options.disabled === true;
|
|
let pendingAttachments = [];
|
|
let mentionState = {
|
|
query: '',
|
|
start: -1,
|
|
end: -1,
|
|
candidates: [],
|
|
activeIndex: 0,
|
|
visible: false
|
|
};
|
|
|
|
function loadHistory() {
|
|
if (!enableHistory) return [];
|
|
try {
|
|
const raw = localStorage.getItem(historyKey);
|
|
const parsed = raw ? JSON.parse(raw) : [];
|
|
return Array.isArray(parsed)
|
|
? parsed.map((entry) => String(entry || '').trim()).filter(Boolean)
|
|
: [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function saveHistory(nextHistory) {
|
|
if (!enableHistory) return;
|
|
try {
|
|
localStorage.setItem(historyKey, JSON.stringify(nextHistory));
|
|
} catch {
|
|
// Ignore localStorage failures.
|
|
}
|
|
}
|
|
|
|
let history = loadHistory();
|
|
let historyIndex = -1;
|
|
let historyDraft = '';
|
|
|
|
function pushToHistory(text) {
|
|
if (!enableHistory) return;
|
|
const normalized = String(text || '').trim();
|
|
if (!normalized) return;
|
|
|
|
history = history.filter((item) => item !== normalized);
|
|
history.unshift(normalized);
|
|
if (history.length > historyMaxEntries) {
|
|
history = history.slice(0, historyMaxEntries);
|
|
}
|
|
saveHistory(history);
|
|
historyIndex = -1;
|
|
historyDraft = '';
|
|
}
|
|
|
|
function resetHistoryBrowseState() {
|
|
historyIndex = -1;
|
|
historyDraft = '';
|
|
}
|
|
|
|
function setEditorText(text = '') {
|
|
hostEl.innerText = String(text || '');
|
|
setCaretByCharacterOffset(hostEl, (hostEl.innerText || '').length);
|
|
renderPreview();
|
|
updateSendButtonVisibility();
|
|
}
|
|
|
|
function updateUploadingUi() {
|
|
if (uploadingCount > 0) {
|
|
uploadStatus.style.display = 'block';
|
|
uploadStatus.textContent = `Uploading ${uploadingCount} file${uploadingCount > 1 ? 's' : ''}…`;
|
|
return;
|
|
}
|
|
|
|
if (pendingAttachments.length > 0) {
|
|
uploadStatus.style.display = 'block';
|
|
uploadStatus.textContent = `${pendingAttachments.length} image${pendingAttachments.length > 1 ? 's' : ''} attached`;
|
|
return;
|
|
}
|
|
|
|
uploadStatus.style.display = 'none';
|
|
uploadStatus.textContent = '';
|
|
}
|
|
|
|
function hideMentionDropdown() {
|
|
mentionState.visible = false;
|
|
mentionState.candidates = [];
|
|
mentionState.activeIndex = 0;
|
|
mentionDropdown.classList.remove('is-visible');
|
|
mentionDropdown.innerHTML = '';
|
|
}
|
|
|
|
function renderMentionDropdown() {
|
|
if (!mentionState.visible || mentionState.candidates.length === 0) {
|
|
hideMentionDropdown();
|
|
return;
|
|
}
|
|
|
|
mentionDropdown.innerHTML = mentionState.candidates
|
|
.map((profile, idx) => {
|
|
const display = profile.display_name || profile.name || `${String(profile.pubkey || '').slice(0, 8)}…`;
|
|
const handle = profile.name ? `@${profile.name}` : `${String(profile.pubkey || '').slice(0, 12)}…`;
|
|
const avatar = profile.picture
|
|
? `<img class="post-composer-mention-avatar" src="${escapeHtml(profile.picture)}" alt="" onerror="this.style.visibility='hidden'" />`
|
|
: `<div class="post-composer-mention-avatar" style="background:var(--muted-color);"></div>`;
|
|
|
|
return `
|
|
<div class="post-composer-mention-item ${idx === mentionState.activeIndex ? 'is-active' : ''}" data-index="${idx}">
|
|
${avatar}
|
|
<div class="post-composer-mention-meta">
|
|
<div class="post-composer-mention-name">${escapeHtml(display)}</div>
|
|
<div class="post-composer-mention-handle">${escapeHtml(handle)}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
})
|
|
.join('');
|
|
|
|
mentionDropdown.classList.add('is-visible');
|
|
positionDropdownNearCaret(hostEl, mentionDropdown);
|
|
}
|
|
|
|
function replaceMentionWithProfile(profile) {
|
|
const npub = toNpub(profile?.npub || profile?.pubkey);
|
|
if (!npub) return;
|
|
|
|
const currentText = hostEl.innerText || '';
|
|
if (mentionState.start < 0 || mentionState.end < mentionState.start) return;
|
|
|
|
const replacement = `nostr:${npub}`;
|
|
const nextText = currentText.slice(0, mentionState.start) + replacement + currentText.slice(mentionState.end);
|
|
hostEl.innerText = nextText;
|
|
const nextCaret = mentionState.start + replacement.length;
|
|
setCaretByCharacterOffset(hostEl, nextCaret);
|
|
|
|
hideMentionDropdown();
|
|
queuePreviewRender();
|
|
updateSendButtonVisibility();
|
|
}
|
|
|
|
function updateMentionStateFromCaret() {
|
|
const text = hostEl.innerText || '';
|
|
const caret = getCaretCharacterOffsetWithin(hostEl);
|
|
const mention = detectMentionQuery(text, caret);
|
|
|
|
if (!mention) {
|
|
hideMentionDropdown();
|
|
return;
|
|
}
|
|
|
|
const candidates = filterMentionCandidates(followedProfiles, mention.query);
|
|
if (candidates.length === 0) {
|
|
hideMentionDropdown();
|
|
return;
|
|
}
|
|
|
|
mentionState = {
|
|
...mention,
|
|
candidates,
|
|
activeIndex: 0,
|
|
visible: true
|
|
};
|
|
|
|
renderMentionDropdown();
|
|
}
|
|
|
|
function normalizePendingAttachments(attachments) {
|
|
if (!Array.isArray(attachments)) return [];
|
|
return attachments
|
|
.map((att) => {
|
|
const dataUrl = String(att?.dataUrl || '').trim();
|
|
if (!dataUrl.startsWith('data:image/')) return null;
|
|
return {
|
|
id: String(att?.id || crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`),
|
|
name: String(att?.name || 'image').trim() || 'image',
|
|
mimeType: String(att?.mimeType || '').trim() || dataUrl.slice(5, dataUrl.indexOf(';') > 0 ? dataUrl.indexOf(';') : undefined),
|
|
dataUrl
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
async function insertUploadedFiles(files) {
|
|
const arr = Array.from(files || []).filter((f) => f && f.size > 0);
|
|
if (arr.length === 0) return;
|
|
|
|
uploadingCount += arr.length;
|
|
updateUploadingUi();
|
|
|
|
try {
|
|
if (uploadMode === 'direct-image') {
|
|
if (!onFileAttach) {
|
|
console.warn('[post-composer] uploadMode "direct-image" is enabled but onFileAttach is missing.');
|
|
return;
|
|
}
|
|
const attached = await onFileAttach(arr);
|
|
const normalized = normalizePendingAttachments(attached);
|
|
if (normalized.length > 0) {
|
|
pendingAttachments = [...pendingAttachments, ...normalized];
|
|
}
|
|
updateSendButtonVisibility();
|
|
return;
|
|
}
|
|
|
|
for (const file of arr) {
|
|
const { sha256 } = await uploadToAllServers(file);
|
|
const ext = getFileExtension(file);
|
|
const url = getBlobUrl(sha256, ext);
|
|
|
|
hostEl.focus();
|
|
const prefix = (hostEl.innerText || '').trim().length > 0 ? '\n' : '';
|
|
document.execCommand('insertText', false, `${prefix}${url}\n`);
|
|
}
|
|
} finally {
|
|
uploadingCount = Math.max(0, uploadingCount - arr.length);
|
|
updateUploadingUi();
|
|
queuePreviewRender();
|
|
}
|
|
}
|
|
|
|
function updateSendButtonVisibility() {
|
|
const hasContent = !!(hostEl.innerText || '').trim();
|
|
const hasAttachments = pendingAttachments.length > 0;
|
|
const canSubmit = hasContent || hasAttachments;
|
|
const interactive = canSubmit && uploadingCount === 0 && !externallyDisabled;
|
|
sendButton.classList.toggle('is-visible', canSubmit);
|
|
if (alwaysShowSendButton) sendButton.classList.add('is-visible');
|
|
sendButton.disabled = !interactive;
|
|
sendButton.tabIndex = (canSubmit || alwaysShowSendButton) ? 0 : -1;
|
|
}
|
|
|
|
function clearComposerContent() {
|
|
hostEl.innerHTML = '';
|
|
hostEl.innerText = '';
|
|
pendingAttachments = [];
|
|
renderPreview();
|
|
updateUploadingUi();
|
|
updateSendButtonVisibility();
|
|
hideMentionDropdown();
|
|
resetHistoryBrowseState();
|
|
}
|
|
|
|
function hideComposer() {
|
|
wrapper.style.display = 'none';
|
|
}
|
|
|
|
function showComposer() {
|
|
wrapper.style.display = '';
|
|
}
|
|
|
|
function renderPreview() {
|
|
if (!showPreview || !previewContentEl) return;
|
|
|
|
const raw = hostEl.innerText || '';
|
|
if (!raw.trim()) {
|
|
previewEl.classList.remove('is-visible');
|
|
previewContentEl.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
previewEl.classList.add('is-visible');
|
|
previewContentEl.innerHTML = htmlFormatText(raw);
|
|
hydrateNostrEntities(previewContentEl);
|
|
}
|
|
|
|
const queuePreviewRender = debounce(renderPreview, 250);
|
|
|
|
function onInput() {
|
|
queuePreviewRender();
|
|
updateMentionStateFromCaret();
|
|
updateSendButtonVisibility();
|
|
if (historyIndex !== -1) {
|
|
resetHistoryBrowseState();
|
|
}
|
|
}
|
|
|
|
function onKeydown(event) {
|
|
if (event.key === 'Tab' && !event.shiftKey) {
|
|
const hasContent = !!(hostEl.innerText || '').trim();
|
|
if (hasContent && !mentionState.visible) {
|
|
event.preventDefault();
|
|
sendButton.focus();
|
|
return;
|
|
}
|
|
}
|
|
|
|
const isEnterKey = event.key === 'Enter' || event.code === 'Enter' || event.code === 'NumpadEnter';
|
|
if (isEnterKey && (event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && !mentionState.visible) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
onSendClick();
|
|
return;
|
|
}
|
|
|
|
if (submitOnEnter && event.key === 'Enter' && !event.shiftKey && !mentionState.visible) {
|
|
event.preventDefault();
|
|
onSendClick();
|
|
return;
|
|
}
|
|
|
|
if (event.key === 'Escape') {
|
|
if (mentionState.visible) {
|
|
event.preventDefault();
|
|
hideMentionDropdown();
|
|
return;
|
|
}
|
|
|
|
if (hideOnEscape) {
|
|
event.preventDefault();
|
|
hideComposer();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!mentionState.visible && enableHistory) {
|
|
const currentText = hostEl.innerText || '';
|
|
const caretOffset = getCaretCharacterOffsetWithin(hostEl);
|
|
const singleLine = !currentText.includes('\n');
|
|
|
|
if (event.key === 'ArrowUp') {
|
|
const atStart = caretOffset <= 0;
|
|
if (atStart || singleLine) {
|
|
event.preventDefault();
|
|
if (historyIndex === -1) {
|
|
historyDraft = currentText;
|
|
}
|
|
if (historyIndex < history.length - 1) {
|
|
historyIndex += 1;
|
|
setEditorText(history[historyIndex] || '');
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (event.key === 'ArrowDown') {
|
|
const atEnd = caretOffset >= currentText.length;
|
|
if ((atEnd || singleLine) && historyIndex >= 0) {
|
|
event.preventDefault();
|
|
historyIndex -= 1;
|
|
if (historyIndex < 0) {
|
|
setEditorText(historyDraft || '');
|
|
} else {
|
|
setEditorText(history[historyIndex] || '');
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!mentionState.visible) return;
|
|
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault();
|
|
mentionState.activeIndex = (mentionState.activeIndex + 1) % mentionState.candidates.length;
|
|
renderMentionDropdown();
|
|
return;
|
|
}
|
|
|
|
if (event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
mentionState.activeIndex = (mentionState.activeIndex - 1 + mentionState.candidates.length) % mentionState.candidates.length;
|
|
renderMentionDropdown();
|
|
return;
|
|
}
|
|
|
|
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
event.preventDefault();
|
|
const selected = mentionState.candidates[mentionState.activeIndex];
|
|
if (selected) replaceMentionWithProfile(selected);
|
|
return;
|
|
}
|
|
}
|
|
|
|
function onSelectionChange() {
|
|
if (!mentionState.visible) return;
|
|
updateMentionStateFromCaret();
|
|
}
|
|
|
|
function onDocumentClick(event) {
|
|
if (wrapper.contains(event.target) || mentionDropdown.contains(event.target)) return;
|
|
hideMentionDropdown();
|
|
}
|
|
|
|
function onDragEnter(event) {
|
|
if (!event.dataTransfer?.types?.includes('Files')) return;
|
|
event.preventDefault();
|
|
dragDepth += 1;
|
|
editorShell.classList.add('is-dragover');
|
|
}
|
|
|
|
function onDragOver(event) {
|
|
if (!event.dataTransfer?.types?.includes('Files')) return;
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = 'copy';
|
|
}
|
|
|
|
function onDragLeave(event) {
|
|
if (!event.dataTransfer?.types?.includes('Files')) return;
|
|
event.preventDefault();
|
|
dragDepth = Math.max(0, dragDepth - 1);
|
|
if (dragDepth === 0) editorShell.classList.remove('is-dragover');
|
|
}
|
|
|
|
async function onDrop(event) {
|
|
if (!event.dataTransfer?.files?.length) return;
|
|
event.preventDefault();
|
|
dragDepth = 0;
|
|
editorShell.classList.remove('is-dragover');
|
|
await insertUploadedFiles(event.dataTransfer.files);
|
|
}
|
|
|
|
async function onFileInputChange(event) {
|
|
const files = event.target?.files;
|
|
if (!files || files.length === 0) return;
|
|
await insertUploadedFiles(files);
|
|
fileInput.value = '';
|
|
}
|
|
|
|
async function onPaste(event) {
|
|
const clipboardItems = Array.from(event.clipboardData?.items || []);
|
|
const imageFiles = clipboardItems
|
|
.filter((item) => item.kind === 'file' && String(item.type || '').startsWith('image/'))
|
|
.map((item) => item.getAsFile())
|
|
.filter(Boolean);
|
|
|
|
if (imageFiles.length === 0) return;
|
|
|
|
event.preventDefault();
|
|
await insertUploadedFiles(imageFiles);
|
|
}
|
|
|
|
function onMentionDropdownMouseDown(event) {
|
|
const item = event.target.closest('.post-composer-mention-item');
|
|
if (!item) return;
|
|
event.preventDefault();
|
|
const index = Number(item.dataset.index);
|
|
if (Number.isNaN(index)) return;
|
|
const selected = mentionState.candidates[index];
|
|
if (selected) replaceMentionWithProfile(selected);
|
|
}
|
|
|
|
async function onSendClick() {
|
|
if (sendButton.disabled) return;
|
|
if (!onSubmit) return;
|
|
|
|
const text = (hostEl.innerText || '').trim();
|
|
const attachments = pendingAttachments.slice();
|
|
if (!text && attachments.length === 0) return;
|
|
|
|
const previousText = text;
|
|
const previousAttachments = attachments;
|
|
|
|
if (clearBeforeSubmit) {
|
|
clearComposerContent();
|
|
if (!autoHideOnSubmit) {
|
|
hostEl.focus();
|
|
setCaretByCharacterOffset(hostEl, 0);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const submitted = await onSubmit(previousText, { attachments: previousAttachments });
|
|
if (submitted === false) {
|
|
if (clearBeforeSubmit) {
|
|
setEditorText(previousText);
|
|
pendingAttachments = previousAttachments;
|
|
updateUploadingUi();
|
|
updateSendButtonVisibility();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (previousText) {
|
|
pushToHistory(previousText);
|
|
}
|
|
|
|
if (autoHideOnSubmit) {
|
|
if (!clearBeforeSubmit) {
|
|
clearComposerContent();
|
|
}
|
|
hideComposer();
|
|
} else if (!clearBeforeSubmit) {
|
|
pendingAttachments = [];
|
|
queuePreviewRender();
|
|
updateUploadingUi();
|
|
updateSendButtonVisibility();
|
|
hostEl.focus();
|
|
setCaretByCharacterOffset(hostEl, 0);
|
|
}
|
|
} catch (error) {
|
|
if (clearBeforeSubmit) {
|
|
setEditorText(previousText);
|
|
pendingAttachments = previousAttachments;
|
|
updateUploadingUi();
|
|
updateSendButtonVisibility();
|
|
hostEl.focus();
|
|
setCaretByCharacterOffset(hostEl, (hostEl.innerText || '').length);
|
|
}
|
|
console.error('[post-composer] Submit failed:', error);
|
|
}
|
|
}
|
|
|
|
hostEl.addEventListener('input', onInput);
|
|
hostEl.addEventListener('keydown', onKeydown);
|
|
hostEl.addEventListener('dragenter', onDragEnter);
|
|
hostEl.addEventListener('dragover', onDragOver);
|
|
hostEl.addEventListener('dragleave', onDragLeave);
|
|
hostEl.addEventListener('drop', onDrop);
|
|
document.addEventListener('selectionchange', onSelectionChange);
|
|
document.addEventListener('mousedown', onDocumentClick);
|
|
hostEl.addEventListener('paste', onPaste);
|
|
|
|
if (uploadButton) {
|
|
uploadButton.addEventListener('click', () => fileInput.click());
|
|
}
|
|
fileInput.addEventListener('change', onFileInputChange);
|
|
mentionDropdown.addEventListener('mousedown', onMentionDropdownMouseDown);
|
|
sendButton.addEventListener('click', onSendClick);
|
|
|
|
queuePreviewRender();
|
|
updateSendButtonVisibility();
|
|
|
|
return {
|
|
refresh(nextOptions = {}) {
|
|
if (Array.isArray(nextOptions.followedProfiles)) {
|
|
followedProfiles = nextOptions.followedProfiles.slice();
|
|
}
|
|
queuePreviewRender();
|
|
},
|
|
clear() {
|
|
clearComposerContent();
|
|
},
|
|
setDisabled(disabled) {
|
|
externallyDisabled = !!disabled;
|
|
updateSendButtonVisibility();
|
|
},
|
|
show() {
|
|
showComposer();
|
|
},
|
|
hide() {
|
|
hideComposer();
|
|
},
|
|
isUploading() {
|
|
return uploadingCount > 0;
|
|
},
|
|
destroy() {
|
|
hostEl.removeEventListener('input', onInput);
|
|
hostEl.removeEventListener('keydown', onKeydown);
|
|
hostEl.removeEventListener('dragenter', onDragEnter);
|
|
hostEl.removeEventListener('dragover', onDragOver);
|
|
hostEl.removeEventListener('dragleave', onDragLeave);
|
|
hostEl.removeEventListener('drop', onDrop);
|
|
document.removeEventListener('selectionchange', onSelectionChange);
|
|
document.removeEventListener('mousedown', onDocumentClick);
|
|
hostEl.removeEventListener('paste', onPaste);
|
|
fileInput.removeEventListener('change', onFileInputChange);
|
|
mentionDropdown.removeEventListener('mousedown', onMentionDropdownMouseDown);
|
|
sendButton.removeEventListener('click', onSendClick);
|
|
|
|
if (mentionDropdown.parentNode) mentionDropdown.parentNode.removeChild(mentionDropdown);
|
|
if (uploadButton?.parentNode) uploadButton.parentNode.removeChild(uploadButton);
|
|
if (dragOverlay.parentNode) dragOverlay.parentNode.removeChild(dragOverlay);
|
|
if (uploadStatus.parentNode) uploadStatus.parentNode.removeChild(uploadStatus);
|
|
if (previewEl.parentNode) previewEl.parentNode.removeChild(previewEl);
|
|
if (sendButton.parentNode) sendButton.parentNode.removeChild(sendButton);
|
|
if (editorShell.parentNode) editorShell.parentNode.removeChild(editorShell);
|
|
if (fileInput.parentNode) fileInput.parentNode.removeChild(fileInput);
|
|
}
|
|
};
|
|
}
|
|
|
|
export default mountComposer;
|