mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
* Run the end-to-end suite on pull requests into milestone and wip branches
A release's work is now collected on a milestone branch (milestone/v0.4.0)
before it reaches main, and the suite only ran on pull requests into main,
so nothing proposed for the milestone was tested. `milestone/**` joins
`main`, and `wip/**` comes along from wip/28, where it already runs the
suite on the phases of #28 — the same block on both, so they merge
without a conflict.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit d009ff72ad)
* linked-issue: read the description of a pull request into a milestone branch (#254)
GitHub links an issue from "Closes #N" only in a pull request into the
default branch. Into milestone/v0.3.8 or a wip/ branch the closing
references stay empty, so the check failed on every such pull request
however its description named the issue (#252 was the first). For a pull
request into another branch, the description is now read for the same
keywords GitHub accepts.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* The release pull request is the suite run behind a tag (#252)
Releasing asked for the E2E workflow to be started by hand on main before
a tag. Since main became protected, the Release vX.Y.Z pull request cannot
merge until that workflow is green on it, and its commit is what is tagged,
so a manual run only ran the same jobs twice. Releasing now says so, and to
update the branch if main moved after its checks ran, since the protection
does not ask for up-to-date branches.
The paragraph in CONTRIBUTING and the header of e2e.yml that said main is
not verified commit by commit describe main before it was protected; both
now describe it as it is.
Closes #251
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Calendar on a phone: a readable month, a toolbar and top bar that fit, a live now line (#256)
The month chip is its title first, on up to two lines, then its time range
and place; a narrower day drops the end and the place for a third line of
title, long words are hyphenated, and an unanswered invitation has a
dashed bar. Below 39rem, where a day is bars, the grid shrinks and the
chosen day is listed in full under it: today at first, any tapped day
after, with a + for that day that the toolbar's + follows too. On a wider
touch screen the first tap opens a day's card and the second its entry
(calendar--month-tap, and a day-open: variant that the hover and the tap
share).
The calendar toolbar fits below 37rem with icons, a short date label and
smaller buttons, and under 22rem today's date in a box for Today. English
writes a same-month week as "Oct 20-26" (range_short_month). The top bar
tightens below 640px and moves Sync now, What's new and Send feedback into
the user menu. Dialogs are the whole screen below 768px, as compose is.
calendar--now moves the now line every minute on the view's clock and
redraws a view that shows the old day or the new one when the date
changes, which also moves the month's circle on today.
And a + on a day that has gone opened the editor on today:
DefaultEventStart::onDay pulled any past day to the next hour. A past day
now keeps its date.
Closes #253
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Close an issue when its pull request is merged into a milestone branch (#260)
GitHub acts on "Closes #N" only for merges into the default branch, so
issues fixed on a milestone branch stayed open until the release reached
main. A workflow on merged pull requests into milestone/** now closes the
issues the description names, with a comment linking the pull request.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Preview the reply, not the quote, and send replies with their quote visible (#258)
The list preview flattened the whole body and skipped short blocks to find
the first long one, so a one-line reply previewed as its attribution line or
the quoted mail. The quoted history (same seam as the reading view's "···")
and a "-- " signature are cut off first now, in HTML and plain text, and the
chrome skip only applies to bulk mail. The JMAP preview uses the same code.
The composer folded the quote with an inline display: none and its clean-up
removed only the toggle, so replies went out with an invisible quote. The
fold is removed in the editor and again on the server, and the reading view
reveals a folded quote in mail already received.
Closes #257
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Move a thread's unread count when JMAP marks a message read or unread (#267)
EmailPatchApplier wrote seenAt for $seen and left MessageThread::$unreadCount alone, so the web list (which draws bold from that counter) disagreed with the phone app. $seen now goes through ThreadStatusUpdater::markRead(), the web UI's own path, and markRead() counts the whole thread rather than the messages passed in.
Closes #266
Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Leave a message that is already in the destination folder where it is (#270)
Trashing a conversation with a message already in the bin queued that message as a move from Trash into Trash. webklex's move() then left the connection in EXAMINE and the server refused it ("NO Not permitted in EXAMINE state"), and the row had already lost its UID. ApplyImapFlagsHandler no longer sends a move into the folder a message is in (and gives the row its UID back), and ThreadStatusUpdater's trash(), archive() and restore() leave such messages out of the move.
Closes #269
Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Hide a conversation from the lists only when all of it is in the bin (#272)
A thread's labels are the union of its messages', so one deleted message put Trash on the thread and took the whole conversation out of the Inbox, its tab, label lists, All Mail, Unread, the badges and search, along with any reply threaded into it afterwards. The bin rule is now "carries Trash and has no message outside it", written once (binnedThreads()) for every list and badge and spelled out the same way in the search SQL. Option (b) of the issue.
Closes #268
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Let a long message scroll in the expanded compose window (#273)
expandedValueChanged() used closest('div') to reach the editor's wrapper, but closest() starts at the element itself and the editor is a div, so the wrapper never became a flex container and the editor stayed 170px tall, clipped by [contain:paint]. The wrapper is now the flex column and the editor grows (1 0 auto) to fill the window or its content; the plain-text field fills the expanded window too.
Closes #262
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Write each changelog entry as a file of its own, collected at release (#275)
Every pull request added its paragraph under ## Unreleased in CHANGELOG.md at the same place, so the second of any two conflicted with the first once it merged, and each conflict fix re-ran the whole E2E suite. A pull request now adds changelog.d/<issue>-<words>.md; bin/changelog-release.sh, run in the Release vX.Y.Z pull request, collects the fragments (highlights first) and anything still under ## Unreleased into the release's section and deletes them. CHANGELOG.md stays the one file What's new, bin/release-notes.sh and GitHub read.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Say which step failed when notifications can't be turned on (#278)
Every failure in the enable path (registering the service worker, the
browser's push subscription, the server's answer) ended in the same
"Something went wrong" and the exception was dropped, so a report from a
Pixel 8 could not say which. The pane now names the step and the
DOMException name or HTTP status, and logs the exception to the console.
Closes #277
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Contacts: one address book, with Google, Outlook, Nextcloud and CardDAV contacts in it (#282)
A contacts page and pane for plMail's own contacts and the ones on a server:
Google and Outlook on the mail account's own sign-in, Nextcloud through the
Nextcloud connection, any CardDAV server through Settings -> Contacts. Every
field a phone keeps, the photo included, syncs both ways. Duplicates merge
with 90 days to undo; vCard and CSV import and export; contacts suggested
while composing; JMAP Contact/get, /query and /set. Nextcloud calendars come
with the Nextcloud connection too.
Replaces #237 and #109 by @jo2904 and #244 by @DanversKara.
Closes #157
Closes #236
Co-authored-by: jo2904 <jo2904@users.noreply.github.com>
Co-authored-by: DanversKara <DanversKara@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Install APCu and Xdebug with PIE instead of from PECL (#286)
PECL is deprecated in favour of PIE and was down for most of an evening on 2026-10-21, failing every image build. PIE 1.5.2 now installs APCu (and Xdebug in the dev image) from the extensions' own releases; it is mounted for that one step and not left in the image. Nothing changes at runtime.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Keep contact photos where the web container can serve them (#287)
Contact photos were stored in var/contacts, outside the volumes the web and worker containers share, so synced photos answered 404. They now live under APP_STORAGE_DIR/uploads/contacts; a migration clears the lost paths and has synced books read again so the photos come back.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Match a contact's email addresses in any case (#288)
Addresses are stored lower-cased but were matched as the provider spelled them, so a pull removed and re-inserted the same address and broke the unique key (prod sync failure). They are now matched in their stored form.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Open a contact's reference email in the mail pane (#294)
A click on one of a contact's reference emails in the docked contacts
pane was a Turbo visit inside the contacts frame, which blanked the mail
pane. The click is now offered to the mail pane as a mail-pane:open
event; the pane opens the message beside the contact. Without a mail
pane (the full contacts page) it stays an ordinary visit.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Open mail beside the full Contacts page without a reload (#297)
The full Contacts page was a page of its own, so a reference email
clicked there was a visit: the contact was gone, and on a notebook the
mailbox came back on the calendar it remembered.
The full page is now the mailbox with data-contacts-full on the shell:
the mail column put away and the contacts dock given the row, with the
page's own content in the dock's frame. Opening a message from it loads
the message into the existing mail pane, then contacts--pane#reveal
drops the state in a view transition: the mail slides in from the left
and the dock narrows to its width, the same DOM throughout. On a
notebook the calendar gives way; on a wide screen it stays.
The reference links lose data-turbo-frame="_top", which made ui--split
stash a calendar position for a visit that never came.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Preview image and video attachments on click, like PDFs (#295) (#298)
Clicking an image or video attachment chip opens a viewer in the modal
instead of downloading. Previewable chips (PDF included) get a download
half on the right. Images load from the existing attachment route; videos
from a new /mail/attachment/{id}/video route that serves a fixed video
type inline under the sandbox CSP and answers Range requests.
Closes #295
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Erase a message's parts with it, and delete mail one way (#300)
MessageEraser loaded a message's parts to delete their files, then removed
only the message. After the delete Doctrine forgot the message but kept the
parts managed, so the next flush in the same unit of work failed with "a new
entity was found through the relationship MessagePart#message". Every sync
that removed server-deleted mail with an attachment failed that way.
The eraser now removes the parts, removes a thread it empties (announced to
JMAP as destroyed), and logs file-delete failures instead of keeping the row.
MessagePurger hands its local delete to the eraser, so "delete forever" gains
the thread-label re-sync it lacked. ImapAccountSyncer clears an entity manager
a failed folder left open, so one folder's refused flush no longer fails the
rest.
Closes #299
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Rework the contact editor: quick add, paste details and Improve with AI (#305) (#306)
New contacts start in quick add: paste a name, signature, address line or
vCard and the details it finds are listed, each with a one-click type. The
editor shows the name as one field whose parts fill themselves (titles and
degrees included), rows that appear as you type, Paste details for existing
contacts and a Save to picker. Contact help is a new AI feature, off by
default, that rereads pasted text and suggests updates from the signature
of a contact's newest mail. Phones are read with libphonenumber.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Keep spam off the radar and out of search; add a Not spam button (#308)
Cards read out of mail in Spam are left off the Happening Soon panel and
the mailbox strip (read-time filter, so moving the mail out brings them
back), and a spam conversation no longer shows its cards above the
message. The bin keeps its cards. Search and the type-ahead leave Spam
out unless asked with in:spam, as they already did for the bin. A spam
conversation's toolbar gets a Not spam button that moves it to the inbox.
Closes #304
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Add to contacts asks new or existing; contacts button gets the calendar's three positions (#312)
Closes #309
Closes #311
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Show the pictures a review request hid, dropped or never asked for (#274)
* Show the pictures a review request hid, dropped or never asked for
A BAUHAUS review request, sent through Bazaarvoice, showed neither its
logo nor its product photos, with images allowed. The image proxy was
fine; three steps around it were not.
- The logo is linked as http://. The sanitizer keeps http sources and
leaves them to render time, the fetcher only connects over TLS, and
nothing in between upgraded the URL, so every http image became the
placeholder. RemoteContentBlocker now signs the https:// form. The
fetcher's own rules also say in the log which one refused a target;
until now that refusal was the one silent path.
- The first product card sits inside a <form>, and dropElement('form')
took the photo, the name and the star links with it. The form is
unwrapped now; textarea and select still go with their content.
- Each card is class="d" style="display:none", switched on with
.d {display:block !important}. CssToInlineStyles keeps an inline
property whatever the rule says, so the card stayed hidden next to its
Outlook twin. Html5CssInliner now lets a stylesheet !important beat a
plain inline style, and matches selectors against forms as the divs
the reader will see, so `form .a` rules take the sender's fallback
for clients without forms.
app:backfill safe-html-hidden-cards
re-sanitizes stored mail with a form or a stylesheet !important. One
task for both, because neither fix brings the card back alone.
Closes #271
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Mark the German mail page as translated from the current English one
Both pages gained the same two paragraphs in the previous commit, but
the translated-from hash still named the English page before them, so
DocumentationCoverageTest reported the translation as stale.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Credit the image proxy entry and keep it as a changelog fragment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <karatektus@gmail.com>
* Swipe a mail row, choose what each swipe does, and long-press for its actions on a phone (#314)
On a phone, below md and by touch only: a row swipes right for read/unread and left for archive, each direction set under Settings → Mail → Swipe actions (read/unread, archive, star, nothing; never trash), and holding a row opens an action sheet with every action its strip offers. Mouse, trackpad and wider windows are unchanged.
Takes over #117, #118 and #119, whose fork was archived. Also gives the Contacts settings' two cards their gap.
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Admin → Integrations lists the calendar services, so a pinned CalDAV address can be changed later (#279)
CalDAV and Calendar feed get their own group below the file services, with the shared provider row and Configure form; Nextcloud stays with the file services. Calendar services are offered to everyone, so their rows say so and the form has no switch. The server address accepts only http or https, up to 512 characters.
Once people have connected, the CalDAV form says how many connections a changed or cleared pin reaches. A calendar feed offers its address only while one is stored, so it can be cleared.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Offer an address book another connection already mirrors locked, not as new (#319)
Two connections to the same server, a Nextcloud one and a CardDAV one made by hand, reach the same address book under the same remote id. Settings → Contacts now offers such a book locked, saying which connection has it, and refuses a posted tick, as it does for calendars. Otherwise every contact in it showed twice.
Closes #317
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Gmailify: the IMAP folder places a sibling's mail, Gmail deletions detach, and a switch to turn it off (#204)
Closes #203
Closes #205
- A sibling's IMAP copy keeps the placement its folder gives it; Gmail's INBOX/SENT/DRAFT/TRASH/SPAM no longer move it (by @phdelodder).
- Gmail deleting its copy detaches the sibling's IMAP copy instead of erasing it.
- app:backfill gmailify-placement repairs copies the old merge had moved.
- Settings → Aliases: a Gmailify switch per Gmail account. Off detaches carried copies in a background job and declines mail Gmail still collects for another account (Bcc'd mail excepted, as documented).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Gmail sync: look up only the listed ids, not every stored one (#321)
Part of #223: newGmailIds() asks only about the ids Gmail just listed, and nothing when none were.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Store whether a conversation is in the bin, kept by triggers (#322)
Part of #223: message_thread.binned holds #268's rule, kept by triggers on message_label, thread_label and message; lists, badges and search read the column. Identical results on 41 compared queries, most 5-100x faster on a 67k-thread mailbox.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Account sync: wait out a provider that can't be reached, quietly (#333)
When the worker couldn't connect to the Gmail or Graph API at all, the
account sync went up Messenger's retry ladder: a WARNING with three nested
stack traces per attempt, then CRITICAL and a parked job if the outage
lasted. That job resumes from a cursor and is asked for again every
quarter hour, so nothing was ever lost.
WaitOutConnectionFailures now puts a SyncAccountMessage that failed only
with an HttpClient TransportException back on its own queue, on the
ingest ladder's delays, with one info line per attempt. After five waits
it is dropped with a single warning. Message batches and every other
failure keep the normal path.
Closes #332
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Sven Joder <sj@pmdevelopment.de>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com>
Co-authored-by: jo2904 <jo2904@users.noreply.github.com>
Co-authored-by: DanversKara <DanversKara@users.noreply.github.com>
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
1130 lines
41 KiB
JavaScript
1130 lines
41 KiB
JavaScript
// assets/controllers/compose-toolbar_controller.js
|
|
import { Controller } from '@hotwired/stimulus';
|
|
|
|
export default class extends Controller {
|
|
static targets = [
|
|
'editor',
|
|
'hiddenInput',
|
|
'fontFamily',
|
|
'fontSize',
|
|
'boldBtn',
|
|
'italicBtn',
|
|
'underlineBtn',
|
|
'colorSwatch',
|
|
// The link popover and its two faces. See #openLinkPopover.
|
|
'linkBtn',
|
|
'linkPopover',
|
|
'linkEdit',
|
|
'linkView',
|
|
'linkHeading',
|
|
'linkUrl',
|
|
'linkText',
|
|
'linkError',
|
|
'linkUnlink',
|
|
'linkHref',
|
|
];
|
|
|
|
static values = {
|
|
// Translated server-side and handed over as one object, exactly as
|
|
// compose--compose and ui--select take theirs. Nothing in this file
|
|
// spells an English string it expects a user to read.
|
|
i18n: { type: Object, default: {} },
|
|
};
|
|
|
|
// Keyed by the <option>'s value, not its label. The labels used to be the
|
|
// keys, which meant they could never be translated: "Small" was both what
|
|
// the German user read and what this map had to be looked up by.
|
|
static FONT_SIZES = {
|
|
small: '0.8em',
|
|
normal: '1em',
|
|
large: '1.3em',
|
|
huge: '1.8em',
|
|
};
|
|
|
|
static FONT_FAMILIES = {
|
|
sans: 'Arial, sans-serif',
|
|
serif: 'Georgia, serif',
|
|
monospace: '"Courier New", monospace',
|
|
};
|
|
|
|
/**
|
|
* The schemes a link may carry, and deliberately the same four as
|
|
* MailBodySanitizer::allowLinkSchemes(). Kept identical so the editor never
|
|
* accepts an href the save would silently drop — a `javascript:` URL that
|
|
* looked fine while writing and vanished on reload is a worse bug than a
|
|
* refusal, because nothing tells the writer it happened.
|
|
*/
|
|
static LINK_SCHEMES = ['http', 'https', 'mailto', 'tel'];
|
|
|
|
/**
|
|
* Elements a caret can be reported inside and nothing can be inserted
|
|
* into. See _insertAtCaret().
|
|
*/
|
|
static VOID_ELEMENTS = ['BR', 'IMG', 'HR'];
|
|
|
|
// Saved selection range — populated on editor blur so colour picker
|
|
// and other focus-stealing controls can restore it before acting.
|
|
#savedRange = null;
|
|
|
|
// While the link popover is open the saved range is FROZEN. Focusing a
|
|
// text field inside the popover blurs the editor, which fires the blur
|
|
// handler, which would otherwise overwrite the very range the popover was
|
|
// opened to act on — with whatever the selection had collapsed to. This is
|
|
// the whole selection-preservation story in one flag.
|
|
#rangeLocked = false;
|
|
|
|
// The <a> the popover is currently about, if any. Non-null means "edit this
|
|
// one in place"; null means "wrap the selection in a new one". Nesting an
|
|
// anchor inside an anchor is not something a mail client should ever
|
|
// produce, and execCommand('createLink') happily would.
|
|
#linkAnchor = null;
|
|
|
|
connect() {
|
|
this._boundUpdateState = this._updateButtonState.bind(this);
|
|
this._boundSyncInput = this._syncHiddenInput.bind(this);
|
|
this._boundSaveRange = this._saveRange.bind(this);
|
|
this._boundEditorClick = this._handleEditorClick.bind(this);
|
|
this._boundOutside = this._handleOutside.bind(this);
|
|
this._boundEscape = this._handleEscape.bind(this);
|
|
this._boundSelection = this._handleSelectionChange.bind(this);
|
|
|
|
if (this.hasEditorTarget) {
|
|
this.editorTarget.addEventListener('keyup', this._boundUpdateState);
|
|
this.editorTarget.addEventListener('mouseup', this._boundUpdateState);
|
|
this.editorTarget.addEventListener('input', this._boundSyncInput);
|
|
this.editorTarget.addEventListener('blur', this._boundSaveRange);
|
|
this.editorTarget.addEventListener('click', this._boundEditorClick);
|
|
}
|
|
|
|
this.element.querySelectorAll('[data-compose--compose-toolbar-action]').forEach(btn => {
|
|
btn.addEventListener('mousedown', e => e.preventDefault());
|
|
});
|
|
}
|
|
|
|
disconnect() {
|
|
if (this.hasEditorTarget) {
|
|
this.editorTarget.removeEventListener('keyup', this._boundUpdateState);
|
|
this.editorTarget.removeEventListener('mouseup', this._boundUpdateState);
|
|
this.editorTarget.removeEventListener('input', this._boundSyncInput);
|
|
this.editorTarget.removeEventListener('blur', this._boundSaveRange);
|
|
this.editorTarget.removeEventListener('click', this._boundEditorClick);
|
|
}
|
|
|
|
this._detachLinkListeners();
|
|
}
|
|
|
|
// ── Selection save / restore ──────────────────────────────────────
|
|
|
|
_saveRange() {
|
|
if (true === this.#rangeLocked) {
|
|
return;
|
|
}
|
|
|
|
const sel = window.getSelection();
|
|
if (sel && sel.rangeCount > 0) {
|
|
this.#savedRange = sel.getRangeAt(0).cloneRange();
|
|
} else {
|
|
this.#savedRange = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Capture the selection RIGHT NOW, past the lock.
|
|
*
|
|
* Used at the moment a popover opens: the toolbar buttons cancel their own
|
|
* mousedown, so the editor still holds the user's selection when the click
|
|
* handler runs, and this is the last instant at which that is true.
|
|
*
|
|
* It does not itself LEAVE the lock on — _openLinkEditor sets it, before
|
|
* it focuses a field and therefore before anything can blur the editor.
|
|
* Capturing without opening (a surface with no popover in it) must not
|
|
* leave the range frozen for the rest of the session.
|
|
*/
|
|
_captureRange() {
|
|
const locked = this.#rangeLocked;
|
|
|
|
this.#rangeLocked = false;
|
|
this._saveRange();
|
|
this.#rangeLocked = locked;
|
|
}
|
|
|
|
_restoreRange() {
|
|
this._focusEditor();
|
|
if (!this.#savedRange) { return; }
|
|
const sel = window.getSelection();
|
|
sel.removeAllRanges();
|
|
sel.addRange(this.#savedRange);
|
|
}
|
|
|
|
// ── Focus helper ──────────────────────────────────────────────────
|
|
|
|
_focusEditor() {
|
|
if (this.hasEditorTarget) {
|
|
this.editorTarget.focus();
|
|
}
|
|
}
|
|
|
|
// ── execCommand wrapper ───────────────────────────────────────────
|
|
|
|
_exec(command, value = null) {
|
|
this._focusEditor();
|
|
document.execCommand(command, false, value);
|
|
this._updateButtonState();
|
|
this._syncHiddenInput();
|
|
}
|
|
|
|
// ── Span style injection ──────────────────────────────────────────
|
|
//
|
|
// Strategy: rather than always wrapping in a new <span>, we:
|
|
// 1. Find all existing <span> nodes inside the selection that carry
|
|
// the same CSS property and remove that property from them
|
|
// (cleaning up empty spans afterwards).
|
|
// 2. Check whether the *entire* selection is already inside a single
|
|
// ancestor <span> with that property — if so, just mutate it.
|
|
// 3. Otherwise wrap the (extracted) contents in one new <span>.
|
|
//
|
|
// For a *collapsed* cursor we look for the nearest ancestor <span>
|
|
// that has the property set and update it; otherwise we insert a
|
|
// zero-width-space span so subsequent typing picks up the style.
|
|
|
|
_applySpanStyle(property, value) {
|
|
// Always restore the saved range first — the selection may have been
|
|
// lost if a picker or dropdown stole focus.
|
|
this._restoreRange();
|
|
|
|
const sel = window.getSelection();
|
|
if (!sel || sel.rangeCount === 0) { return; }
|
|
|
|
const range = sel.getRangeAt(0);
|
|
|
|
if (range.collapsed) {
|
|
// Collapsed cursor: update nearest ancestor span that has this
|
|
// property, or insert a carrier span for future typing.
|
|
const ancestor = this._nearestStyledAncestor(range.startContainer, property);
|
|
if (ancestor) {
|
|
ancestor.style[property] = value;
|
|
} else {
|
|
const span = document.createElement('span');
|
|
span.style[property] = value;
|
|
span.innerHTML = '​'; // zero-width space
|
|
range.insertNode(span);
|
|
|
|
const newRange = document.createRange();
|
|
newRange.setStart(span.firstChild, 1);
|
|
newRange.collapse(true);
|
|
sel.removeAllRanges();
|
|
sel.addRange(newRange);
|
|
}
|
|
} else {
|
|
// Non-collapsed selection:
|
|
// Step 1 — strip the property from any existing spans inside the range.
|
|
this._stripPropertyInRange(range, property);
|
|
|
|
// Step 2 — re-fetch range (DOM may have changed) and wrap in one span.
|
|
const freshRange = sel.getRangeAt(0);
|
|
const span = document.createElement('span');
|
|
span.style[property] = value;
|
|
span.appendChild(freshRange.extractContents());
|
|
|
|
// Clean up any now-empty spans we pulled in.
|
|
this._removeEmptySpans(span);
|
|
|
|
freshRange.insertNode(span);
|
|
|
|
const newRange = document.createRange();
|
|
newRange.selectNodeContents(span);
|
|
sel.removeAllRanges();
|
|
sel.addRange(newRange);
|
|
}
|
|
|
|
this._syncHiddenInput();
|
|
}
|
|
|
|
// Walk up from `node` to the editor boundary; return the first <span>
|
|
// that has `property` set in its inline style.
|
|
_nearestStyledAncestor(node, property) {
|
|
let el = (node.nodeType === Node.TEXT_NODE) ? node.parentElement : node;
|
|
while (el && el !== this.editorTarget) {
|
|
if (el.tagName === 'SPAN' && el.style[property]) {
|
|
return el;
|
|
}
|
|
el = el.parentElement;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Remove inline `property` from every <span> descendant of `root`
|
|
// that intersects `range`, then clean up any spans that become
|
|
// style-less (and therefore invisible wrappers).
|
|
_stripPropertyInRange(range, property) {
|
|
const editor = this.editorTarget;
|
|
// Collect all spans inside the editor that are fully or partially
|
|
// within the range.
|
|
const spans = Array.from(editor.querySelectorAll('span'));
|
|
for (const span of spans) {
|
|
if (span.style[property] && range.intersectsNode(span)) {
|
|
span.style[property] = '';
|
|
// If the span now has no inline style at all, unwrap it.
|
|
if (span.getAttribute('style') === '' || span.style.cssText.trim() === '') {
|
|
this._unwrapNode(span);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Replace `node` in the DOM with its own children (unwrap).
|
|
_unwrapNode(node) {
|
|
const parent = node.parentNode;
|
|
if (!parent) { return; }
|
|
while (node.firstChild) {
|
|
parent.insertBefore(node.firstChild, node);
|
|
}
|
|
parent.removeChild(node);
|
|
}
|
|
|
|
// Remove empty <span> elements (no text content, no meaningful children)
|
|
// from inside `root`.
|
|
_removeEmptySpans(root) {
|
|
root.querySelectorAll('span').forEach(span => {
|
|
if (span.textContent === '' && !span.querySelector('img,br,input')) {
|
|
span.remove();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Formatting actions ────────────────────────────────────────────
|
|
|
|
bold() { this._exec('bold'); }
|
|
italic() { this._exec('italic'); }
|
|
underline() { this._exec('underline'); }
|
|
undo() { this._exec('undo'); }
|
|
redo() { this._exec('redo'); }
|
|
outdent() { this._exec('outdent'); }
|
|
indent() { this._exec('indent'); }
|
|
|
|
alignLeft() { this._exec('justifyLeft'); }
|
|
alignCenter() { this._exec('justifyCenter'); }
|
|
alignRight() { this._exec('justifyRight'); }
|
|
alignJustify() { this._exec('justifyFull'); }
|
|
|
|
// ── Caret insertion ───────────────────────────────────────────────
|
|
//
|
|
// Emoji and inline images are the same operation: put a node where the
|
|
// cursor was, not where the DOM happens to end. Both arrive from a control
|
|
// that stole focus first — a picker panel, a file dialog, an upload that
|
|
// took a second — so the saved range is the only record of where the user
|
|
// was writing, and appending to the editor instead is the bug this exists
|
|
// to avoid.
|
|
|
|
/**
|
|
* One emoji, as text.
|
|
*
|
|
* Deliberately the only way an emoji can enter the body: nothing here
|
|
* watches what is typed, so `:)` stays `:)` and `:smile:` stays
|
|
* `:smile:`. Auto-substitution rewrites what someone wrote, and a mail
|
|
* client is the wrong place to guess.
|
|
*/
|
|
insertEmoji(emoji) {
|
|
if ('string' !== typeof emoji || '' === emoji) { return; }
|
|
|
|
this._insertAtCaret(document.createTextNode(emoji));
|
|
}
|
|
|
|
/**
|
|
* An uploaded image, referenced by the part it was stored as.
|
|
*
|
|
* `src` is the attachment route so the editor can render it; `data-cid` is
|
|
* the identity InlineImageRewriter turns back into `cid:` on save, which is
|
|
* what the recipient's client resolves against the embedded part.
|
|
*/
|
|
insertInlineImage({ url, contentId }) {
|
|
if ('string' !== typeof url || '' === url) { return; }
|
|
|
|
const img = document.createElement('img');
|
|
|
|
img.setAttribute('src', url);
|
|
img.setAttribute('style', 'max-width:100%');
|
|
|
|
if ('string' === typeof contentId && '' !== contentId) {
|
|
img.setAttribute('data-cid', contentId);
|
|
}
|
|
|
|
this._insertAtCaret(img);
|
|
}
|
|
|
|
_insertAtCaret(node) {
|
|
if (false === this.hasEditorTarget) { return; }
|
|
|
|
this._restoreRange();
|
|
|
|
const sel = window.getSelection();
|
|
let range = null;
|
|
|
|
// Only a selection that is actually inside this editor may be written
|
|
// to — a range left over in another window's body, or none at all,
|
|
// becomes "the end of this one".
|
|
if (sel && sel.rangeCount > 0 && this.editorTarget.contains(sel.getRangeAt(0).commonAncestorContainer)) {
|
|
range = sel.getRangeAt(0);
|
|
} else {
|
|
range = document.createRange();
|
|
range.selectNodeContents(this.editorTarget);
|
|
range.collapse(false);
|
|
}
|
|
|
|
// A caret can be INSIDE an element that cannot have children, and one
|
|
// routinely is: compose--compose#_focusCursorAtTop() parks it at
|
|
// offset 0 of the first node of the first block, which in a fresh
|
|
// reply is the <br> of `<p><br></p>`. insertNode() obliges and puts
|
|
// the node inside the <br> — where the DOM holds it and nothing ever
|
|
// renders or serialises it. Whatever was inserted into a reply nobody
|
|
// had clicked into yet simply did not appear: that was "the template
|
|
// does not load in a reply", and the same went for an emoji, an inline
|
|
// image and the signature. Typing never showed it, because the
|
|
// browser moves the caret out of the <br> before it inserts a
|
|
// character; a script has to do that for itself.
|
|
const container = range.startContainer;
|
|
|
|
if (Node.ELEMENT_NODE === container.nodeType
|
|
&& true === this.constructor.VOID_ELEMENTS.includes(container.nodeName)) {
|
|
range.setStartBefore(container);
|
|
range.collapse(true);
|
|
}
|
|
|
|
range.deleteContents();
|
|
range.insertNode(node);
|
|
|
|
// Cursor after what was just inserted, so a second emoji lands beside
|
|
// the first rather than before it.
|
|
const after = document.createRange();
|
|
after.setStartAfter(node);
|
|
after.collapse(true);
|
|
|
|
if (sel) {
|
|
sel.removeAllRanges();
|
|
sel.addRange(after);
|
|
}
|
|
|
|
this.#savedRange = after.cloneRange();
|
|
|
|
this._syncHiddenInput();
|
|
|
|
// The autosave is a listener on the form's `input` event, and nothing
|
|
// the DOM is changed by script fires one. Without this the emoji is on
|
|
// screen and in the hidden input, and gone again on reload.
|
|
this.editorTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
// toggleDisabled() lived here: it greyed the settings signature editor out
|
|
// while an "inherit" checkbox was ticked. It was the only thing that ever
|
|
// applied the greying — the markup's `data-signature-disabled` hook matched
|
|
// no rule in the stylesheet — so an inheriting alias rendered a live-looking
|
|
// editor until you toggled the box, which is exactly what users reported.
|
|
// The settings panel no longer renders an editor for an inheriting address
|
|
// at all, so there is nothing left to disable.
|
|
|
|
// ── Links ─────────────────────────────────────────────────────────
|
|
//
|
|
// Three jobs, one popover.
|
|
//
|
|
// • the toolbar button — write a link, or edit the one the caret is in
|
|
// • a click on a link in the body — say where it goes and offer the three
|
|
// things you can do to it, because a click in a contenteditable only
|
|
// places a caret and a link you just wrote is otherwise unreachable
|
|
// • Change, which is the first of those reached from the second
|
|
//
|
|
// The URL is normalised and screened HERE rather than left to the server.
|
|
// MailBodySanitizer is the backstop and refuses anything outside
|
|
// http/https/mailto/tel on the way out — but a `javascript:` href that the
|
|
// editor accepted and the save silently dropped is an editing experience
|
|
// that lies, and a bare "example.com" that becomes a relative link to
|
|
// /mail/example.com is worse: it survives the sanitiser intact and is
|
|
// simply wrong. Both are decided before anything enters the document.
|
|
|
|
/** The toolbar button. */
|
|
insertLink() {
|
|
this._captureRange();
|
|
|
|
const anchor = this._anchorInSelection();
|
|
const selection = this.#savedRange ? this.#savedRange.toString() : '';
|
|
|
|
// The caret is inside a link: edit that one, prefilled from it. The
|
|
// alternative — wrapping it in a second anchor — produces markup no
|
|
// mail client renders the way the writer meant.
|
|
if (null !== anchor) {
|
|
this._openLinkEditor(anchor, anchor.getAttribute('href') ?? '', anchor.textContent);
|
|
|
|
return;
|
|
}
|
|
|
|
this._openLinkEditor(null, '', selection);
|
|
}
|
|
|
|
/** Enter submits, Escape closes and gives the selection back. */
|
|
linkKeydown(event) {
|
|
// Only the editing face has anything to submit. Enter on the viewing
|
|
// face's Open button must stay the browser's own "activate this link".
|
|
const editing = this.hasLinkEditTarget && false === this.linkEditTarget.hidden;
|
|
|
|
if ('Enter' === event.key && true === editing) {
|
|
// The popover lives inside the compose <form>: an un-cancelled
|
|
// Enter in a text field submits it, which is to say it SENDS the
|
|
// message the user was still writing a link into.
|
|
event.preventDefault();
|
|
this.applyLink();
|
|
|
|
return;
|
|
}
|
|
|
|
if ('Escape' === event.key) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.closeLink();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel.
|
|
*
|
|
* The selection is handed back only if this was the EDITING face. That
|
|
* face froze the range and took focus away from the body, so giving it
|
|
* back is the courtesy that makes Escape a true no-op. The viewing face
|
|
* did neither — it appears beside a caret the user placed themselves — and
|
|
* restoring anything there would drag the caret back to wherever the last
|
|
* saved range happened to be, which is the popover fighting the caret.
|
|
*/
|
|
closeLink() {
|
|
if (false === this.hasLinkPopoverTarget || true === this.linkPopoverTarget.hidden) {
|
|
return;
|
|
}
|
|
|
|
const wasEditing = this.hasLinkEditTarget && false === this.linkEditTarget.hidden;
|
|
|
|
this.linkPopoverTarget.hidden = true;
|
|
this.#linkAnchor = null;
|
|
this._detachLinkListeners();
|
|
this._clearLinkError();
|
|
|
|
if (this.hasLinkBtnTarget) {
|
|
this.linkBtnTarget.setAttribute('aria-expanded', 'false');
|
|
}
|
|
|
|
// Unfreeze first, then restore — the restore itself moves the selection
|
|
// and the next genuine blur must be free to record where it ended up.
|
|
this.#rangeLocked = false;
|
|
|
|
if (true === wasEditing) {
|
|
this._restoreRange();
|
|
}
|
|
}
|
|
|
|
/** Apply, from either the button or Enter. */
|
|
applyLink() {
|
|
if (false === this.hasLinkUrlTarget) {
|
|
return;
|
|
}
|
|
|
|
const url = this._normaliseUrl(this.linkUrlTarget.value);
|
|
|
|
if (null === url) {
|
|
this._showLinkError(this._t(
|
|
'' === this.linkUrlTarget.value.trim() ? 'linkInvalid' : 'linkUnsafe',
|
|
'' === this.linkUrlTarget.value.trim()
|
|
? 'Enter a web address.'
|
|
: 'Only http, https, mailto and tel addresses can be linked.',
|
|
));
|
|
this.linkUrlTarget.focus();
|
|
|
|
return;
|
|
}
|
|
|
|
const text = this.hasLinkTextTarget ? this.linkTextTarget.value.trim() : '';
|
|
const anchor = this.#linkAnchor;
|
|
|
|
if (null !== anchor) {
|
|
this._writeAnchor(anchor, url, '' === text ? anchor.textContent : text);
|
|
this.#linkAnchor = null;
|
|
this._closeAndSync();
|
|
|
|
return;
|
|
}
|
|
|
|
const created = document.createElement('a');
|
|
this._writeAnchor(created, url, '' === text ? url : text);
|
|
|
|
// _insertAtCaret restores the saved range and replaces whatever it
|
|
// covers, which is right: the text field was prefilled FROM that
|
|
// selection, so the selection is what the new link stands in for.
|
|
this.#rangeLocked = false;
|
|
this.linkPopoverTarget.hidden = true;
|
|
this._detachLinkListeners();
|
|
this._insertAtCaret(created);
|
|
this._closeAndSync();
|
|
}
|
|
|
|
/** Open, from the viewing face. */
|
|
openLink() {
|
|
const href = this.hasLinkHrefTarget ? this.linkHrefTarget.getAttribute('href') : null;
|
|
|
|
if (null === href || '#' === href) {
|
|
return;
|
|
}
|
|
|
|
// `noopener` as well as the attribute on the anchor: this is a scripted
|
|
// open and the feature string is what governs it.
|
|
window.open(href, '_blank', 'noopener,noreferrer');
|
|
}
|
|
|
|
/** Change, from the viewing face — the same popover, other side up. */
|
|
changeLink() {
|
|
const anchor = this.#linkAnchor;
|
|
|
|
if (null === anchor) {
|
|
return;
|
|
}
|
|
|
|
this._openLinkEditor(anchor, anchor.getAttribute('href') ?? '', anchor.textContent);
|
|
}
|
|
|
|
/** Remove — the text stays, the link goes. */
|
|
removeLink() {
|
|
const anchor = this.#linkAnchor;
|
|
|
|
if (null === anchor) {
|
|
this.closeLink();
|
|
|
|
return;
|
|
}
|
|
|
|
// Where to put the caret afterwards, worked out before the node it
|
|
// refers to stops existing.
|
|
const parent = anchor.parentNode;
|
|
const marker = document.createTextNode('');
|
|
|
|
parent?.insertBefore(marker, anchor);
|
|
this._unwrapNode(anchor);
|
|
|
|
this.#linkAnchor = null;
|
|
this.#rangeLocked = false;
|
|
|
|
const range = document.createRange();
|
|
range.setStartAfter(marker);
|
|
range.collapse(true);
|
|
this.#savedRange = range.cloneRange();
|
|
marker.remove();
|
|
|
|
this.linkPopoverTarget.hidden = true;
|
|
this._detachLinkListeners();
|
|
this._closeAndSync();
|
|
}
|
|
|
|
// ── Link popover internals ────────────────────────────────────────
|
|
|
|
/**
|
|
* Show the editing face.
|
|
*
|
|
* `anchor` non-null makes this an edit in place and turns Remove on.
|
|
*
|
|
* The URL field takes focus, and doing so blurs the editor — which is
|
|
* exactly why _captureRange() has already frozen the range. Nothing
|
|
* between here and applyLink() can move the user's place.
|
|
*/
|
|
_openLinkEditor(anchor, url, text) {
|
|
if (false === this.hasLinkPopoverTarget
|
|
|| false === this.hasLinkEditTarget
|
|
|| false === this.hasLinkUrlTarget) {
|
|
return;
|
|
}
|
|
|
|
this.#linkAnchor = anchor;
|
|
this.#rangeLocked = true;
|
|
|
|
this._clearLinkError();
|
|
|
|
this.linkEditTarget.hidden = false;
|
|
if (this.hasLinkViewTarget) { this.linkViewTarget.hidden = true; }
|
|
|
|
if (this.hasLinkHeadingTarget) {
|
|
this.linkHeadingTarget.textContent = null === anchor
|
|
? this._t('linkAdd', 'Insert link')
|
|
: this._t('linkEdit', 'Edit link');
|
|
}
|
|
|
|
this.linkUrlTarget.value = url;
|
|
|
|
if (this.hasLinkTextTarget) {
|
|
this.linkTextTarget.value = text ?? '';
|
|
}
|
|
|
|
if (this.hasLinkUnlinkTarget) {
|
|
this.linkUnlinkTarget.hidden = null === anchor;
|
|
}
|
|
|
|
this._showLinkPopover();
|
|
|
|
// A new link starts in the URL field; an existing one usually wants its
|
|
// address changed too, so it does the same.
|
|
this.linkUrlTarget.focus();
|
|
this.linkUrlTarget.select();
|
|
}
|
|
|
|
/** Show the viewing face for a link the caret has landed in. */
|
|
_openLinkViewer(anchor) {
|
|
if (false === this.hasLinkPopoverTarget
|
|
|| false === this.hasLinkViewTarget
|
|
|| false === this.hasLinkEditTarget) {
|
|
return;
|
|
}
|
|
|
|
const href = anchor.getAttribute('href') ?? '';
|
|
|
|
this.#linkAnchor = anchor;
|
|
|
|
this.linkEditTarget.hidden = true;
|
|
this.linkViewTarget.hidden = false;
|
|
|
|
if (this.hasLinkHrefTarget) {
|
|
this.linkHrefTarget.setAttribute('href', href);
|
|
this.linkHrefTarget.textContent = href;
|
|
}
|
|
|
|
this._showLinkPopover();
|
|
|
|
// Deliberately NOT focused and NOT range-locked: this face appears
|
|
// while the user is still typing, and stealing the caret out of the
|
|
// body to a popover they did not ask for is the behaviour the report
|
|
// called "fighting the caret".
|
|
document.addEventListener('selectionchange', this._boundSelection);
|
|
}
|
|
|
|
_showLinkPopover() {
|
|
this.linkPopoverTarget.hidden = false;
|
|
|
|
if (this.hasLinkBtnTarget) {
|
|
this.linkBtnTarget.setAttribute('aria-expanded', 'true');
|
|
}
|
|
|
|
// Capture phase, like ui--dropdown's: the menu has to close even when
|
|
// whatever was clicked stops propagation on its way up.
|
|
document.addEventListener('click', this._boundOutside, { capture: true });
|
|
document.addEventListener('keydown', this._boundEscape);
|
|
}
|
|
|
|
_detachLinkListeners() {
|
|
document.removeEventListener('click', this._boundOutside, { capture: true });
|
|
document.removeEventListener('keydown', this._boundEscape);
|
|
document.removeEventListener('selectionchange', this._boundSelection);
|
|
}
|
|
|
|
_closeAndSync() {
|
|
this.#rangeLocked = false;
|
|
this._detachLinkListeners();
|
|
|
|
if (this.hasLinkPopoverTarget) {
|
|
this.linkPopoverTarget.hidden = true;
|
|
}
|
|
|
|
if (this.hasLinkBtnTarget) {
|
|
this.linkBtnTarget.setAttribute('aria-expanded', 'false');
|
|
}
|
|
|
|
this._syncHiddenInput();
|
|
|
|
// Nothing script changes in the DOM fires `input`, and the autosave is
|
|
// a listener on it — without this the link is on screen and gone again
|
|
// on reload. Same reason as _insertAtCaret's.
|
|
if (this.hasEditorTarget) {
|
|
this.editorTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
this._focusEditor();
|
|
this._restoreRange();
|
|
}
|
|
|
|
/** href, and the two attributes that keep a new tab from reaching back. */
|
|
_writeAnchor(anchor, url, text) {
|
|
anchor.setAttribute('href', url);
|
|
anchor.setAttribute('target', '_blank');
|
|
anchor.setAttribute('rel', 'noopener noreferrer');
|
|
|
|
if ('string' === typeof text && '' !== text && anchor.textContent !== text) {
|
|
anchor.textContent = text;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A typed address, as an href — or null if it must not become one.
|
|
*
|
|
* Refusals and rewrites, in order:
|
|
*
|
|
* • control characters are stripped first, so `java\nscript:alert(1)` —
|
|
* which browsers happily execute and a naive scheme test reads as
|
|
* scheme-less — is measured as the `javascript:` URL it is.
|
|
* • a URL that names a scheme keeps it only if it is one the sanitiser
|
|
* will also keep. That list is MailBodySanitizer's allowLinkSchemes()
|
|
* verbatim, so nothing this accepts is dropped on save.
|
|
* • "example.com" becomes "https://example.com" rather than a relative
|
|
* link. This is the case the old prompt got wrong every time.
|
|
* • "someone@example.com" becomes a mailto:, which is what anyone typing
|
|
* an address into a link field meant.
|
|
*/
|
|
_normaliseUrl(raw) {
|
|
if ('string' !== typeof raw) { return null; }
|
|
|
|
// Control characters out first. Browsers strip the tab and newline
|
|
// from a URL themselves, which is what makes "java\nscript:alert(1)"
|
|
// execute while reading as scheme-less to a naive test.
|
|
const value = raw.replace(/[\u0000-\u001F\u007F]/g, '').trim();
|
|
|
|
if ('' === value) { return null; }
|
|
|
|
const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(value);
|
|
|
|
if (null !== scheme) {
|
|
return this.constructor.LINK_SCHEMES.includes(scheme[1].toLowerCase())
|
|
? value
|
|
: null;
|
|
}
|
|
|
|
// Protocol-relative, which is a scheme choice rather than a hostname.
|
|
if (value.startsWith('//')) { return `https:${value}`; }
|
|
|
|
// An address, not a host. A slash anywhere means it is a path.
|
|
if (value.includes('@') && false === value.includes('/')) {
|
|
return `mailto:${value}`;
|
|
}
|
|
|
|
return `https://${value}`;
|
|
}
|
|
|
|
/** The <a> the saved range sits in, or null. */
|
|
_anchorInSelection() {
|
|
if (null === this.#savedRange || false === this.hasEditorTarget) {
|
|
return null;
|
|
}
|
|
|
|
const node = this.#savedRange.startContainer;
|
|
const el = (node.nodeType === Node.TEXT_NODE) ? node.parentElement : node;
|
|
const link = el?.closest?.('a');
|
|
|
|
return (link && this.editorTarget.contains(link)) ? link : null;
|
|
}
|
|
|
|
/**
|
|
* A click on a link in the body.
|
|
*
|
|
* The caret is left exactly where the browser put it — the popover is an
|
|
* addition to the click, not a replacement for it — except on a modified
|
|
* click, which everywhere else in every application means "follow this",
|
|
* and is honoured as such.
|
|
*/
|
|
_handleEditorClick(event) {
|
|
const anchor = event.target?.closest?.('a');
|
|
|
|
if (!anchor || false === this.editorTarget.contains(anchor)) {
|
|
return;
|
|
}
|
|
|
|
if (true === event.metaKey || true === event.ctrlKey) {
|
|
event.preventDefault();
|
|
|
|
const href = this._normaliseUrl(anchor.getAttribute('href') ?? '');
|
|
|
|
if (null !== href) {
|
|
window.open(href, '_blank', 'noopener,noreferrer');
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
this._openLinkViewer(anchor);
|
|
}
|
|
|
|
/**
|
|
* The caret has moved. If it is no longer in the link the viewing face is
|
|
* about, that face has nothing left to describe.
|
|
*
|
|
* Only ever attached while the viewer is up — the editing face freezes the
|
|
* selection on purpose and must not be dismissed by its own focus move.
|
|
*/
|
|
_handleSelectionChange() {
|
|
if (null === this.#linkAnchor || false === this.hasLinkViewTarget) {
|
|
return;
|
|
}
|
|
|
|
if (true === this.linkViewTarget.hidden) {
|
|
return;
|
|
}
|
|
|
|
const sel = window.getSelection();
|
|
|
|
if (!sel || 0 === sel.rangeCount) {
|
|
return;
|
|
}
|
|
|
|
const node = sel.getRangeAt(0).startContainer;
|
|
const el = (node.nodeType === Node.TEXT_NODE) ? node.parentElement : node;
|
|
|
|
// Still inside the popover (the user is reaching for one of its
|
|
// buttons) or still inside the link: leave it up.
|
|
if (this.linkPopoverTarget.contains(el) || this.#linkAnchor.contains(el)) {
|
|
return;
|
|
}
|
|
|
|
this.linkPopoverTarget.hidden = true;
|
|
this.#linkAnchor = null;
|
|
this._detachLinkListeners();
|
|
}
|
|
|
|
_handleOutside(event) {
|
|
if (false === this.linkPopoverTarget.contains(event.target)) {
|
|
this.closeLink();
|
|
}
|
|
}
|
|
|
|
_handleEscape(event) {
|
|
if ('Escape' === event.key) {
|
|
event.stopPropagation();
|
|
this.closeLink();
|
|
}
|
|
}
|
|
|
|
_showLinkError(message) {
|
|
if (false === this.hasLinkErrorTarget) { return; }
|
|
|
|
this.linkErrorTarget.textContent = message;
|
|
this.linkErrorTarget.hidden = false;
|
|
}
|
|
|
|
_clearLinkError() {
|
|
if (false === this.hasLinkErrorTarget) { return; }
|
|
|
|
this.linkErrorTarget.textContent = '';
|
|
this.linkErrorTarget.hidden = true;
|
|
}
|
|
|
|
/** A translated string, falling back to English if the value is absent. */
|
|
_t(key, fallback) {
|
|
return this.i18nValue?.[key] ?? fallback;
|
|
}
|
|
|
|
changeFontFamily(event) {
|
|
const family = this.constructor.FONT_FAMILIES[event.target.value];
|
|
if (family) {
|
|
this._applySpanStyle('fontFamily', family);
|
|
}
|
|
}
|
|
|
|
changeFontSize(event) {
|
|
const size = this.constructor.FONT_SIZES[event.target.value];
|
|
if (size) {
|
|
this._applySpanStyle('fontSize', size);
|
|
}
|
|
}
|
|
|
|
changeColor(event) {
|
|
const color = event.target.value;
|
|
this._applySpanStyle('color', color);
|
|
if (this.hasColorSwatchTarget) {
|
|
this.colorSwatchTarget.style.backgroundColor = color;
|
|
}
|
|
}
|
|
|
|
// ── Lists ─────────────────────────────────────────────────────────
|
|
|
|
orderedList() { this._toggleList('OL'); }
|
|
unorderedList() { this._toggleList('UL'); }
|
|
|
|
_toggleList(tag) {
|
|
this._focusEditor();
|
|
|
|
const sel = window.getSelection();
|
|
if (!sel || sel.rangeCount === 0) { return; }
|
|
|
|
const range = sel.getRangeAt(0);
|
|
const existing = this._closestList(range.startContainer);
|
|
|
|
if (existing) {
|
|
if (existing.tagName === tag) {
|
|
this._unwrapList(existing);
|
|
} else {
|
|
const newList = document.createElement(tag);
|
|
while (existing.firstChild) {
|
|
newList.appendChild(existing.firstChild);
|
|
}
|
|
existing.parentNode.replaceChild(newList, existing);
|
|
}
|
|
} else {
|
|
this._wrapInList(tag, range, sel);
|
|
}
|
|
|
|
this._syncHiddenInput();
|
|
}
|
|
|
|
_wrapInList(tag, range, sel) {
|
|
const editor = this.editorTarget;
|
|
const blocks = this._getSelectedTopLevelBlocks(range, editor);
|
|
const list = document.createElement(tag);
|
|
|
|
if (blocks.length > 0) {
|
|
blocks.forEach(block => {
|
|
const li = document.createElement('li');
|
|
while (block.firstChild) {
|
|
li.appendChild(block.firstChild);
|
|
}
|
|
list.appendChild(li);
|
|
});
|
|
|
|
blocks.forEach(block => block.remove());
|
|
|
|
editor.appendChild(list);
|
|
} else {
|
|
const li = document.createElement('li');
|
|
list.appendChild(li);
|
|
editor.appendChild(list);
|
|
}
|
|
|
|
// Place cursor inside the first <li>
|
|
const firstLi = list.querySelector('li');
|
|
if (firstLi) {
|
|
const r = document.createRange();
|
|
r.setStart(firstLi, 0);
|
|
r.collapse(true);
|
|
sel.removeAllRanges();
|
|
sel.addRange(r);
|
|
}
|
|
}
|
|
|
|
_unwrapList(list) {
|
|
const parent = list.parentNode;
|
|
|
|
Array.from(list.querySelectorAll('li')).forEach(li => {
|
|
const div = document.createElement('div');
|
|
while (li.firstChild) {
|
|
div.appendChild(li.firstChild);
|
|
}
|
|
parent.insertBefore(div, list);
|
|
});
|
|
|
|
list.remove();
|
|
}
|
|
|
|
_getSelectedTopLevelBlocks(range, editor) {
|
|
const blocks = [];
|
|
|
|
for (const child of editor.children) {
|
|
if (range.intersectsNode(child)) {
|
|
blocks.push(child);
|
|
}
|
|
}
|
|
|
|
// Collapsed cursor — find the direct-child ancestor of startContainer
|
|
if (blocks.length === 0) {
|
|
let node = range.startContainer;
|
|
while (node && node.parentNode !== editor) {
|
|
node = node.parentNode;
|
|
}
|
|
if (node && node !== editor && editor.contains(node)) {
|
|
blocks.push(node);
|
|
}
|
|
}
|
|
|
|
return blocks;
|
|
}
|
|
|
|
_closestList(node) {
|
|
let el = (node.nodeType === Node.TEXT_NODE) ? node.parentElement : node;
|
|
while (el && el !== this.editorTarget) {
|
|
if (el.tagName === 'UL' || el.tagName === 'OL') { return el; }
|
|
el = el.parentElement;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ── Active-state reflection ───────────────────────────────────────
|
|
|
|
_updateButtonState() {
|
|
this._setActive('boldBtn', this._isFormatActive('bold'));
|
|
this._setActive('italicBtn', this._isFormatActive('italic'));
|
|
this._setActive('underlineBtn', this._isFormatActive('underline'));
|
|
}
|
|
|
|
_isFormatActive(format) {
|
|
const sel = window.getSelection();
|
|
if (!sel || sel.rangeCount === 0) { return false; }
|
|
|
|
let node = sel.anchorNode;
|
|
if (node.nodeType === Node.TEXT_NODE) { node = node.parentElement; }
|
|
|
|
while (node && node !== this.editorTarget) {
|
|
const tag = node.tagName;
|
|
const cs = window.getComputedStyle(node);
|
|
|
|
if (format === 'bold' && (tag === 'B' || tag === 'STRONG' || parseInt(cs.fontWeight) >= 700)) { return true; }
|
|
if (format === 'italic' && (tag === 'I' || tag === 'EM' || cs.fontStyle === 'italic')) { return true; }
|
|
if (format === 'underline' && (tag === 'U' || cs.textDecorationLine.includes('underline'))) { return true; }
|
|
|
|
node = node.parentElement;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
_setActive(targetName, active) {
|
|
const capitalized = targetName.charAt(0).toUpperCase() + targetName.slice(1);
|
|
if (!this[`has${capitalized}Target`]) { return; }
|
|
const target = this[`${targetName}Target`];
|
|
target.classList.toggle('bg-raised', active);
|
|
target.classList.toggle('text-ink', active);
|
|
}
|
|
|
|
// ── Sync contenteditable → hidden input ───────────────────────────
|
|
|
|
_syncHiddenInput() {
|
|
if (!this.hasEditorTarget || !this.hasHiddenInputTarget) {
|
|
return;
|
|
}
|
|
|
|
this.hiddenInputTarget.value = this._cleanHtml();
|
|
}
|
|
|
|
/**
|
|
* Never let dev-tooling markup or editor chrome ride along into a saved
|
|
* draft: the debug toolbar used to end up quoted inside the body, and the
|
|
* "show quoted text" toggle is a UI affordance, not part of the mail.
|
|
* Scripts go too — nothing executable belongs in a mail we're sending.
|
|
*
|
|
* The fold around the quote goes with its toggle. Removing only the button
|
|
* left the quote's `display: none` behind, and every reply sent with the
|
|
* quote still folded — which is how the composer opens — reached its
|
|
* recipient with the history invisible, in every client (#257). The quote
|
|
* is unhidden and its wrapper unwrapped; a draft reopened from this is
|
|
* folded again by compose_controller, as it always was.
|
|
* MailBodySanitizer::unfoldQuotes() does the same on the server.
|
|
*/
|
|
_cleanHtml() {
|
|
const html = this.editorTarget.innerHTML;
|
|
|
|
if (!/sf-toolbar|<script|sfwdt|data-quote-toggle|data-quote-wrapped/i.test(html)) {
|
|
return html;
|
|
}
|
|
|
|
const clone = this.editorTarget.cloneNode(true);
|
|
|
|
clone.querySelectorAll(
|
|
'script, .sf-toolbar, [id^="sfwdt"], [data-frankenphp-hot-reload-preserve], [data-quote-toggle]',
|
|
).forEach((node) => node.remove());
|
|
|
|
clone.querySelectorAll('[data-quote-wrapped]').forEach((wrapper) => {
|
|
Array.from(wrapper.children).forEach((child) => {
|
|
child.style.removeProperty('display');
|
|
if ('' === child.getAttribute('style')) {
|
|
child.removeAttribute('style');
|
|
}
|
|
});
|
|
wrapper.replaceWith(...wrapper.childNodes);
|
|
});
|
|
|
|
return clone.innerHTML;
|
|
}
|
|
}
|