mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
429 lines
15 KiB
JavaScript
429 lines
15 KiB
JavaScript
// assets/controllers/compose_controller.js
|
|
import { Controller } from '@hotwired/stimulus'
|
|
|
|
export default class extends Controller {
|
|
static targets = ['ccField', 'bccField', 'body', 'saveStatus', 'toCollection', 'collapsible', 'minimizeIcon', 'expandIcon', 'ccBtn', 'bccBtn', 'title', 'accountSelect', 'fromBtn', 'fromLabel', 'fromChevron', 'fromDropdown', 'fromRow'];
|
|
static values = {
|
|
draftUrl: String,
|
|
sendUrl: String,
|
|
autosaveDelay: { type: Number, default: 2000 },
|
|
minimized: { type: Boolean, default: false },
|
|
expanded: { type: Boolean, default: false },
|
|
}
|
|
|
|
#autosaveTimer = null
|
|
|
|
connect() {
|
|
const input = this.element.querySelector('.compose-to[data-prototype]');
|
|
this._ensureEntry(input);
|
|
this._submitting = false;
|
|
this._boundHandleSubmit = this._handleSubmit.bind(this);
|
|
this._boundAutosave = this._scheduleAutosave.bind(this);
|
|
|
|
|
|
const form = this.element.querySelector('form');
|
|
|
|
form.action = this.sendUrlValue;
|
|
form.addEventListener('submit', this._boundHandleSubmit);
|
|
form.addEventListener('input', this._boundAutosave);
|
|
|
|
// Mirror subject into header title
|
|
const subjectInput = this.element.querySelector('[name$="[subject]"]');
|
|
if (null !== subjectInput) {
|
|
this._updateTitle(subjectInput.value);
|
|
subjectInput.addEventListener('input', () => this._updateTitle(subjectInput.value));
|
|
}
|
|
|
|
// Close from-dropdown when clicking outside
|
|
this._boundCloseDropdown = this._closeFromDropdown.bind(this);
|
|
|
|
// Auto-expand on mobile
|
|
if (window.innerWidth < 768) {
|
|
this.expandedValue = true;
|
|
}
|
|
|
|
if (this.hasBodyTarget) {
|
|
this._collapseQuotedContent();
|
|
this._focusCursorAtTop();
|
|
}
|
|
}
|
|
|
|
disconnect() {
|
|
clearTimeout(this.#autosaveTimer);
|
|
const form = this.element.querySelector('form');
|
|
form.removeEventListener('input', this._boundAutosave);
|
|
form.removeEventListener('submit', this._boundHandleSubmit);
|
|
document.removeEventListener('click', this._boundCloseDropdown, { capture: true });
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
// ── Quoted content ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Wraps every top-level blockquote and forwarded-message div in a
|
|
* collapsible toggle so the compose window doesn't grow unbounded.
|
|
*/
|
|
_collapseQuotedContent() {
|
|
const editor = this.bodyTarget;
|
|
|
|
const quoted = Array.from(editor.querySelectorAll(
|
|
':scope > blockquote, :scope > div[style*="border-top"]',
|
|
));
|
|
|
|
if (quoted.length === 0) {
|
|
return;
|
|
}
|
|
|
|
quoted.forEach((node) => {
|
|
if (node.dataset.quoteWrapped) {
|
|
return;
|
|
}
|
|
|
|
const wrapper = document.createElement('div');
|
|
wrapper.dataset.quoteWrapped = '1';
|
|
wrapper.style.cssText = 'margin-top: 0.5em;';
|
|
|
|
const toggle = document.createElement('button');
|
|
toggle.type = 'button';
|
|
toggle.contentEditable = 'false';
|
|
toggle.textContent = '··· (show quoted text)';
|
|
toggle.style.cssText = [
|
|
'display: inline-block',
|
|
'margin-bottom: 0.4em',
|
|
'padding: 0.1em 0.6em',
|
|
'font-size: 0.75em',
|
|
'border: 1px solid #d1d5db',
|
|
'border-radius: 9999px',
|
|
'background: transparent',
|
|
'color: #6b7280',
|
|
'cursor: pointer',
|
|
'user-select: none',
|
|
].join(';');
|
|
|
|
// Prevent clicks on the toggle from moving the editor cursor.
|
|
toggle.addEventListener('mousedown', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
});
|
|
|
|
toggle.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const isHidden = node.style.display === 'none';
|
|
node.style.display = isHidden ? '' : 'none';
|
|
toggle.textContent = isHidden
|
|
? '··· (hide quoted text)'
|
|
: '··· (show quoted text)';
|
|
});
|
|
|
|
node.parentNode.insertBefore(wrapper, node);
|
|
wrapper.appendChild(toggle);
|
|
wrapper.appendChild(node);
|
|
|
|
// Start collapsed.
|
|
node.style.display = 'none';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Places the cursor at the very start of the editor (before any
|
|
* quoted content) and scrolls the editor to the top.
|
|
*/
|
|
_focusCursorAtTop() {
|
|
const editor = this.bodyTarget;
|
|
editor.focus();
|
|
|
|
const firstNode = this._firstEditableNode(editor);
|
|
if (firstNode === null) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const range = document.createRange();
|
|
const sel = window.getSelection();
|
|
range.setStart(firstNode, 0);
|
|
range.collapse(true);
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
} catch (_) {
|
|
// Silently ignore — editor is still focused.
|
|
}
|
|
|
|
editor.scrollTop = 0;
|
|
}
|
|
|
|
/** Returns the first child node of `editor` that is not a quote wrapper. */
|
|
_firstEditableNode(editor) {
|
|
for (const child of editor.childNodes) {
|
|
if (child.dataset && child.dataset.quoteWrapped) {
|
|
continue;
|
|
}
|
|
|
|
if (child.nodeType === Node.TEXT_NODE) {
|
|
return child;
|
|
}
|
|
|
|
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
const inner = child.firstChild;
|
|
if (inner !== null) {
|
|
return inner;
|
|
}
|
|
|
|
return child;
|
|
}
|
|
}
|
|
|
|
return editor.firstChild;
|
|
}
|
|
|
|
// ── Minimize ──────────────────────────────────────────────────────
|
|
|
|
toggleMinimize() {
|
|
// Can't minimize while expanded — collapse first
|
|
if (this.expandedValue) {
|
|
this.expandedValue = false;
|
|
return;
|
|
}
|
|
this.minimizedValue = !this.minimizedValue;
|
|
}
|
|
|
|
minimizedValueChanged() {
|
|
const minimized = this.minimizedValue;
|
|
|
|
if (this.hasCollapsibleTarget) {
|
|
this.collapsibleTarget.style.display = minimized ? 'none' : '';
|
|
}
|
|
|
|
if (this.hasMinimizeIconTarget) {
|
|
this.minimizeIconTarget.className = minimized
|
|
? 'fa-solid fa-chevron-up text-xs'
|
|
: 'fa-solid fa-minus text-xs';
|
|
}
|
|
|
|
this.element.classList.toggle('rounded-b-2xl', minimized);
|
|
}
|
|
|
|
// ── Expand / fullscreen ───────────────────────────────────────────
|
|
|
|
toggleExpand() {
|
|
this.expandedValue = !this.expandedValue;
|
|
}
|
|
|
|
expandedValueChanged() {
|
|
const expanded = this.expandedValue;
|
|
const el = this.element;
|
|
|
|
if (expanded) {
|
|
this.minimizedValue = false;
|
|
|
|
el.style.cssText = `
|
|
position: fixed;
|
|
inset: 1rem;
|
|
width: auto;
|
|
max-width: none;
|
|
height: auto;
|
|
margin: 0;
|
|
z-index: 50;
|
|
display: flex;
|
|
flex-direction: column;
|
|
`;
|
|
|
|
if (this.hasBodyTarget) {
|
|
this.bodyTarget.closest('div').style.flex = '1';
|
|
this.bodyTarget.style.flex = '1';
|
|
this.bodyTarget.style.height = '0';
|
|
}
|
|
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
} else {
|
|
el.style.cssText = '';
|
|
|
|
if (this.hasBodyTarget) {
|
|
this.bodyTarget.closest('div').style.flex = '';
|
|
this.bodyTarget.style.flex = '';
|
|
this.bodyTarget.style.height = '';
|
|
}
|
|
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
if (this.hasExpandIconTarget) {
|
|
this.expandIconTarget.className = expanded
|
|
? 'fa-solid fa-down-left-and-up-right-to-center text-[10px]'
|
|
: 'fa-solid fa-up-right-and-down-left-from-center text-[10px]';
|
|
}
|
|
}
|
|
|
|
// ── From dropdown ─────────────────────────────────────────────────
|
|
|
|
toggleFromDropdown() {
|
|
const open = !this.fromDropdownTarget.classList.contains('hidden');
|
|
if (open) {
|
|
this._closeFromDropdown();
|
|
} else {
|
|
this.fromDropdownTarget.classList.remove('hidden');
|
|
this.fromChevronTarget.style.transform = 'rotate(180deg)';
|
|
document.addEventListener('click', this._boundCloseDropdown, { capture: true, once: true });
|
|
}
|
|
}
|
|
|
|
selectAccount(event) {
|
|
const btn = event.currentTarget;
|
|
const value = btn.dataset.value;
|
|
const label = btn.dataset.label;
|
|
|
|
this.accountSelectTarget.value = value;
|
|
this.fromLabelTarget.textContent = label;
|
|
|
|
this.fromDropdownTarget.querySelectorAll('button').forEach(b => {
|
|
const selected = b.dataset.value === value;
|
|
b.classList.toggle('bg-accent-soft', selected);
|
|
b.classList.toggle('text-accent', selected);
|
|
});
|
|
|
|
this._closeFromDropdown();
|
|
}
|
|
|
|
_closeFromDropdown() {
|
|
if (this.hasFromDropdownTarget) {
|
|
this.fromDropdownTarget.classList.add('hidden');
|
|
}
|
|
if (this.hasFromChevronTarget) {
|
|
this.fromChevronTarget.style.transform = '';
|
|
}
|
|
}
|
|
|
|
// ── Close ─────────────────────────────────────────────────────────
|
|
|
|
close() {
|
|
document.body.style.overflow = '';
|
|
this.element.closest('turbo-frame').innerHTML = '';
|
|
}
|
|
|
|
// ── Save draft ────────────────────────────────────────────────────
|
|
|
|
_scheduleAutosave() {
|
|
clearTimeout(this.#autosaveTimer);
|
|
this.#autosaveTimer = setTimeout(
|
|
() => this.saveDraft(),
|
|
this.autosaveDelayValue,
|
|
);
|
|
}
|
|
|
|
async saveDraft(event = null) {
|
|
event?.preventDefault();
|
|
|
|
const form = this.element.querySelector('form');
|
|
if (!form) { return; }
|
|
|
|
const url = this.hasDraftUrlValue ? this.draftUrlValue : form.action;
|
|
const status = this.hasSaveStatusTarget ? this.saveStatusTarget : null;
|
|
const STATUS_CLASSES = ['text-ink-faint', 'text-danger', 'text-success'];
|
|
if (status) {
|
|
status.textContent = 'Saving…';
|
|
status.classList.remove(...STATUS_CLASSES);
|
|
status.classList.add('text-ink-faint');
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
body: new FormData(form),
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'Accept': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (true === response.ok) {
|
|
if (status) {
|
|
const html = await response.text();
|
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
const newController = doc.querySelector('[data-compose-draft-url-value]');
|
|
const oldForm = this.element.querySelector('form');
|
|
|
|
if (newController) {
|
|
this.draftUrlValue = newController.dataset.composeDraftUrlValue;
|
|
this.sendUrlValue = newController.dataset.composeSendUrlValue;
|
|
}
|
|
|
|
if (oldForm) {
|
|
oldForm.action = this.sendUrlValue;
|
|
}
|
|
|
|
status.textContent = 'Draft saved';
|
|
status.classList.remove(...STATUS_CLASSES);
|
|
status.classList.add('text-success');
|
|
}
|
|
} else {
|
|
throw new Error('Server error');
|
|
}
|
|
} catch (_) {
|
|
if (status) {
|
|
status.textContent = 'Failed to save';
|
|
status.classList.remove(...STATUS_CLASSES);
|
|
status.classList.add('text-danger');
|
|
}
|
|
}
|
|
}
|
|
|
|
_handleSubmit(event) {
|
|
if (true === this._submitting) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
clearTimeout(this.#autosaveTimer);
|
|
this._submitting = true;
|
|
|
|
const sendBtn = this.element.querySelector('[type="submit"]');
|
|
if (sendBtn) {
|
|
sendBtn.disabled = true;
|
|
sendBtn.textContent = 'Sending…';
|
|
}
|
|
|
|
setTimeout(() => {
|
|
this._submitting = false;
|
|
if (sendBtn) {
|
|
sendBtn.disabled = false;
|
|
sendBtn.textContent = 'Send';
|
|
}
|
|
}, 15_000);
|
|
}
|
|
|
|
// ── Cc / Bcc ──────────────────────────────────────────────────────
|
|
|
|
showCc() {
|
|
this.ccFieldTarget.classList.remove('hidden');
|
|
if (this.hasCcBtnTarget) {
|
|
this.ccBtnTarget.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
showBcc() {
|
|
this.bccFieldTarget.classList.remove('hidden');
|
|
if (this.hasBccBtnTarget) {
|
|
this.bccBtnTarget.classList.add('hidden');
|
|
}
|
|
}
|
|
// ── Helpers ───────────────────────────────────────────────────────
|
|
|
|
_updateTitle(value) {
|
|
if (this.hasTitleTarget) {
|
|
this.titleTarget.textContent = value.trim() || 'New Message';
|
|
}
|
|
}
|
|
|
|
_ensureEntry(collection) {
|
|
if (!collection || collection.children.length > 0) { return; }
|
|
|
|
const index = collection.dataset.index ?? 0;
|
|
const prototype = collection.dataset.prototype
|
|
.replace(/__cc__|__bcc__|__to__/g, index);
|
|
|
|
collection.dataset.index = parseInt(index) + 1;
|
|
collection.insertAdjacentHTML('beforeend', prototype);
|
|
collection.querySelector('input')?.focus();
|
|
}
|
|
}
|