@rapidmx/web-client 0.28.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/apps/shared/components/admin/diagnostics/DiagnosticsManager.tsx +22 -10
- package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +60 -0
- package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +108 -0
- package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +25 -1
- package/apps/shared/components/calendar/CalendarSearchBox.tsx +83 -0
- package/apps/shared/components/calendar/EventMatchNav.tsx +72 -0
- package/apps/shared/components/calendar/EventModal.tsx +33 -12
- package/apps/shared/components/calendar/ListView.tsx +207 -0
- package/apps/shared/components/calendar/MonthView.tsx +14 -2
- package/apps/shared/components/calendar/SlideIn.tsx +47 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +5 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +23 -12
- package/apps/shared/components/calendar/activeOccurrence.ts +28 -0
- package/apps/shared/components/calendar/allDay.ts +16 -1
- package/apps/shared/components/calendar/calendarView.ts +41 -0
- package/apps/shared/components/layout/AppShell.tsx +13 -6
- package/apps/shared/components/mail/ConversationList.tsx +29 -3
- package/apps/shared/components/mail/ConversationThreadPane.tsx +15 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +117 -55
- package/apps/shared/components/mail/MessageDetailPane.tsx +3 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +193 -35
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +62 -11
- package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +21 -10
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +2 -1
- package/apps/shared/components/mail/compose/quotedBody.ts +45 -2
- package/apps/shared/components/mail/labelMenu.tsx +4 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +9 -2
- package/apps/shared/components/mail/reading/MessageBody.tsx +1 -1
- package/apps/shared/components/mail/reading/bodyContent.ts +15 -13
- package/apps/shared/components/mail/reading/bodyHtml.ts +12 -7
- package/apps/shared/gestures/useEnterSlide.ts +1 -1
- package/apps/shared/keyboard/keymap.ts +2 -0
- package/apps/shared/mail/rangeSelection.ts +45 -0
- package/apps/shared/mail/rowExit.ts +39 -0
- package/apps/www/calendar/index.tsx +251 -53
- package/apps/www/index.tsx +522 -343
- package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.d.ts +3 -3
- package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +15 -9
- package/dist/apps/shared/components/admin/diagnostics/InformationPanel.d.ts +15 -0
- package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +16 -0
- package/dist/apps/shared/components/admin/diagnostics/SettingsTable.d.ts +20 -0
- package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +31 -0
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +20 -1
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.js +7 -0
- package/dist/apps/shared/components/calendar/CalendarSearchBox.d.ts +22 -0
- package/dist/apps/shared/components/calendar/CalendarSearchBox.js +24 -0
- package/dist/apps/shared/components/calendar/EventMatchNav.d.ts +17 -0
- package/dist/apps/shared/components/calendar/EventMatchNav.js +37 -0
- package/dist/apps/shared/components/calendar/EventModal.d.ts +17 -1
- package/dist/apps/shared/components/calendar/EventModal.js +13 -11
- package/dist/apps/shared/components/calendar/ListView.d.ts +30 -0
- package/dist/apps/shared/components/calendar/ListView.js +111 -0
- package/dist/apps/shared/components/calendar/MonthView.js +12 -3
- package/dist/apps/shared/components/calendar/SlideIn.d.ts +16 -0
- package/dist/apps/shared/components/calendar/SlideIn.js +32 -0
- package/dist/apps/shared/components/calendar/SplitDayView.js +5 -1
- package/dist/apps/shared/components/calendar/TimeGridView.js +11 -3
- package/dist/apps/shared/components/calendar/activeOccurrence.d.ts +15 -0
- package/dist/apps/shared/components/calendar/activeOccurrence.js +21 -0
- package/dist/apps/shared/components/calendar/allDay.d.ts +4 -0
- package/dist/apps/shared/components/calendar/allDay.js +14 -1
- package/dist/apps/shared/components/calendar/calendarView.d.ts +16 -0
- package/dist/apps/shared/components/calendar/calendarView.js +36 -0
- package/dist/apps/shared/components/layout/AppShell.js +8 -4
- package/dist/apps/shared/components/mail/ConversationList.d.ts +9 -1
- package/dist/apps/shared/components/mail/ConversationList.js +10 -2
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +9 -1
- package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -4
- package/dist/apps/shared/components/mail/MessageDetailPane.js +3 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +31 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +123 -11
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +6 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +37 -11
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +5 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +10 -6
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +2 -1
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.js +43 -1
- package/dist/apps/shared/components/mail/labelMenu.d.ts +3 -1
- package/dist/apps/shared/components/mail/labelMenu.js +2 -2
- package/dist/apps/shared/components/mail/layout/MailShell.js +7 -1
- package/dist/apps/shared/components/mail/reading/MessageBody.js +1 -1
- package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +5 -1
- package/dist/apps/shared/components/mail/reading/bodyContent.js +11 -11
- package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +3 -2
- package/dist/apps/shared/components/mail/reading/bodyHtml.js +8 -4
- package/dist/apps/shared/gestures/useEnterSlide.d.ts +2 -0
- package/dist/apps/shared/gestures/useEnterSlide.js +1 -1
- package/dist/apps/shared/keyboard/keymap.d.ts +12 -0
- package/dist/apps/shared/keyboard/keymap.js +2 -0
- package/dist/apps/shared/mail/rangeSelection.d.ts +20 -0
- package/dist/apps/shared/mail/rangeSelection.js +34 -0
- package/dist/apps/shared/mail/rowExit.d.ts +12 -0
- package/dist/apps/shared/mail/rowExit.js +33 -0
- package/dist/apps/www/calendar/index.js +177 -18
- package/dist/apps/www/index.js +220 -65
- package/dist/lib/calendar/eventSearch.d.ts +42 -0
- package/dist/lib/calendar/eventSearch.js +152 -0
- package/dist/lib/components/navigation/BottomTabBar.d.ts +15 -3
- package/dist/lib/components/navigation/BottomTabBar.js +52 -15
- package/dist/lib/components/navigation/NavBadge.d.ts +13 -0
- package/dist/lib/components/navigation/NavBadge.js +18 -0
- package/dist/lib/mail/compose/quotedImages.d.ts +21 -0
- package/dist/lib/mail/compose/quotedImages.js +41 -0
- package/dist/lib/mail/inlineImages.d.ts +38 -0
- package/dist/lib/mail/inlineImages.js +90 -0
- package/dist/lib/mail/mailApi.d.ts +8 -1
- package/dist/lib/mail/mailApi.js +57 -5
- package/lib/calendar/eventSearch.ts +172 -0
- package/lib/components/navigation/BottomTabBar.tsx +107 -22
- package/lib/components/navigation/NavBadge.tsx +33 -0
- package/lib/mail/compose/quotedImages.ts +49 -0
- package/lib/mail/inlineImages.ts +104 -0
- package/lib/mail/mailApi.ts +64 -5
- package/package.json +1 -1
- package/apps/shared/components/admin/diagnostics/VersionsPanel.tsx +0 -36
- package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.d.ts +0 -10
- package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.js +0 -11
|
@@ -31,6 +31,12 @@ import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
|
|
|
31
31
|
import { SHORTCUTS } from "../../../keyboard/keymap.js";
|
|
32
32
|
import { useKeyEnvironment } from "../../../keyboard/ShortcutProvider.js";
|
|
33
33
|
import { useShortcut } from "../../../keyboard/useShortcut.js";
|
|
34
|
+
/**
|
|
35
|
+
* From `md` up the sidebar and the content beside it are each exactly as tall as the window under the title bar, so each scrolls by itself (the frame's
|
|
36
|
+
* own height is open-ended - `min-h-screen` - so a column's `overflow` would otherwise never apply and the whole page would scroll instead). On a phone
|
|
37
|
+
* the page scrolls as a whole, as it always did. The same height the Contacts page uses; `--rr-header-h` is the title bar's (or branding header's) height.
|
|
38
|
+
*/
|
|
39
|
+
const DESKTOP_PANE_HEIGHT = "md:h-[calc(100dvh_-_var(--rr-header-h,4rem))]";
|
|
34
40
|
/** The well-known mail folder types an "aggregate" pseudo-folder can merge across every accessible
|
|
35
41
|
* mailbox - a fixed, small set: `outbox` is deliberately excluded (transient per-mailbox send-queue
|
|
36
42
|
* state, not a "merge across mailboxes" concept), and there's no aggregate concept for `calendar`/
|
|
@@ -239,7 +245,7 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
239
245
|
].join(" "), children: [_jsxs("span", { className: ["flex items-center gap-2.5 min-w-0", badge?.kind === "unread" ? "font-semibold" : ""].join(" "), children: [_jsx(FolderTypeIcon, { type: folder.type }), _jsx("span", { className: "truncate", children: FOLDER_LABELS[folder.type] ?? folder.name })] }), badge &&
|
|
240
246
|
(folder.type === "outbox" ? (_jsx(OutboxBadge, { total: badge.value, status: outboxStatuses[folder.uid], pendingHere: pendingCountFor(mailbox.uid, pendingSends) })) : (_jsx(FolderBadgeChip, { badge: badge })))] }, folder.uid));
|
|
241
247
|
}) }, mailbox.uid)))] }))] }));
|
|
242
|
-
inner = (_jsxs(_Fragment, { children: [_jsx(MailShortcuts, { mailboxUid: defaultMailboxUid }), _jsx("aside", { className: "hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", children: sidebarContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", fullScreen: true, children: _jsx("div", { className: "flex flex-col", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 overflow-y-auto", children: [_jsxs("div", { className: "md:hidden flex items-center gap-2 p-3", children: [_jsx("button", { type: "button", className: "shrink-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open folders", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("div", { ref: setMobileSearchSlot, className: "flex-1 min-w-0" })] }), _jsx(MobileComposeButton, { mailboxUid: defaultMailboxUid }), _jsx(MailShellContext.Provider, { value: contextValue, children: children })] })] }));
|
|
248
|
+
inner = (_jsxs(_Fragment, { children: [_jsx(MailShortcuts, { mailboxUid: defaultMailboxUid }), _jsx("aside", { className: ["hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", DESKTOP_PANE_HEIGHT].join(" "), children: sidebarContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", fullScreen: true, children: _jsx("div", { className: "flex flex-col", children: sidebarContent("mobile") }) }), _jsxs("main", { className: ["flex-1 min-w-0 overflow-y-auto", DESKTOP_PANE_HEIGHT].join(" "), children: [_jsxs("div", { className: "md:hidden flex items-center gap-2 p-3", children: [_jsx("button", { type: "button", className: "shrink-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open folders", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("div", { ref: setMobileSearchSlot, className: "flex-1 min-w-0" })] }), _jsx(MobileComposeButton, { mailboxUid: defaultMailboxUid }), _jsx(MailShellContext.Provider, { value: contextValue, children: children })] })] }));
|
|
243
249
|
}
|
|
244
250
|
// Wraps AppShell unconditionally, at a stable tree position regardless of whether mailboxUid has
|
|
245
251
|
// resolved yet - KeyEnrollmentGate itself passes `children` through untouched until a real
|
|
@@ -78,7 +78,7 @@ function HtmlBody({ messageUid, title, html, remoteAvailable, attachments, inlin
|
|
|
78
78
|
const theme = useResolvedTheme();
|
|
79
79
|
const surface = useThemeSurface(theme);
|
|
80
80
|
// Keyed on what the resolver reads rather than on the arrays, which a caller may rebuild on every render.
|
|
81
|
-
const inlineKey = [...(attachments ?? []).map((a) => `${a.uid}:${a.contentId ?? ""}`), inlineParts ? `parts:${inlineParts.length}` : ""].join("|");
|
|
81
|
+
const inlineKey = [...(attachments ?? []).map((a) => `${a.uid}:${a.contentId ?? ""}:${a.filename}:${a.mimeType}`), inlineParts ? `parts:${inlineParts.length}` : ""].join("|");
|
|
82
82
|
const resolveCid = useMemo(() => makeCidResolver(attachments, inlineParts), [inlineKey]);
|
|
83
83
|
const prepared = useMemo(() => prepareBodyHtml(html, { resolveCid }), [html, resolveCid]);
|
|
84
84
|
const mode = prepared.declaresDarkSupport ? "native" : original ? "original" : "adapt";
|
|
@@ -34,5 +34,9 @@ export declare function attachmentSource(): string;
|
|
|
34
34
|
* How an inline `cid:` image is resolved to something the frame may load: for a message shown from the server, the attachment whose
|
|
35
35
|
* `Content-ID` matches (its URL on this server); for a decrypted or verified one, the part inside the signed/encrypted entity, embedded
|
|
36
36
|
* as a `data:` URI (raster and SVG images up to `MAX_INLINE_IMAGE_BYTES`). Anything else has no source, so the image is not shown.
|
|
37
|
+
*
|
|
38
|
+
* The `Content-ID` is matched without regard to angle brackets, percent-encoding or case. An image that names no part (`contentId`
|
|
39
|
+
* undefined: the server left it with only its `alt`, as it does for a picture a sender attached without a `Content-ID`) is matched to the
|
|
40
|
+
* one image attachment of the same file name, when `alt` says it.
|
|
37
41
|
*/
|
|
38
|
-
export declare function makeCidResolver(attachments: InlineCapable[] | undefined, parts: MimeAttachment[] | undefined): (contentId: string) => string | undefined;
|
|
42
|
+
export declare function makeCidResolver(attachments: InlineCapable[] | undefined, parts: MimeAttachment[] | undefined): (contentId: string | undefined, alt?: string) => string | undefined;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import { ApiRequestError, apiUrl } from "../../../../../lib/util/api.js";
|
|
6
6
|
import { attachmentContentUrl } from "../../../../../lib/mail/mailApi.js";
|
|
7
|
+
import { findInlineAttachment, toDataUri } from "../../../../../lib/mail/inlineImages.js";
|
|
7
8
|
/** How many bodies are remembered for the session - a thread that is expanded, collapsed and expanded again does not ask the server twice. */
|
|
8
9
|
const CACHE_LIMIT = 40;
|
|
9
10
|
/** What the pane asks `/content` for. */
|
|
@@ -51,13 +52,6 @@ export async function fetchBodyContent(uid, version, signal) {
|
|
|
51
52
|
/** The largest inline image embedded into a body from a signed or encrypted message. */
|
|
52
53
|
export const MAX_INLINE_IMAGE_BYTES = 5000000;
|
|
53
54
|
const INLINE_IMAGE_TYPES = /^image\/(?:png|jpe?g|gif|webp|avif|bmp|svg\+xml)$/;
|
|
54
|
-
function base64(bytes) {
|
|
55
|
-
let binary = "";
|
|
56
|
-
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
57
|
-
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
58
|
-
}
|
|
59
|
-
return btoa(binary);
|
|
60
|
-
}
|
|
61
55
|
/** The absolute URL of an attachment - the frame is a document of its own, where a relative URL would not mean what it does here. */
|
|
62
56
|
export function absoluteAttachmentUrl(uid) {
|
|
63
57
|
return new URL(attachmentContentUrl(uid), window.location.href).href;
|
|
@@ -70,15 +64,21 @@ export function attachmentSource() {
|
|
|
70
64
|
* How an inline `cid:` image is resolved to something the frame may load: for a message shown from the server, the attachment whose
|
|
71
65
|
* `Content-ID` matches (its URL on this server); for a decrypted or verified one, the part inside the signed/encrypted entity, embedded
|
|
72
66
|
* as a `data:` URI (raster and SVG images up to `MAX_INLINE_IMAGE_BYTES`). Anything else has no source, so the image is not shown.
|
|
67
|
+
*
|
|
68
|
+
* The `Content-ID` is matched without regard to angle brackets, percent-encoding or case. An image that names no part (`contentId`
|
|
69
|
+
* undefined: the server left it with only its `alt`, as it does for a picture a sender attached without a `Content-ID`) is matched to the
|
|
70
|
+
* one image attachment of the same file name, when `alt` says it.
|
|
73
71
|
*/
|
|
74
72
|
export function makeCidResolver(attachments, parts) {
|
|
75
|
-
return (contentId) => {
|
|
73
|
+
return (contentId, alt) => {
|
|
76
74
|
if (parts) {
|
|
77
|
-
const
|
|
75
|
+
const candidates = parts.map((part, index) => ({ uid: String(index), filename: part.filename ?? "", mimeType: part.contentType, contentId: part.contentId }));
|
|
76
|
+
const found = findInlineAttachment(candidates, { cid: contentId, alt });
|
|
77
|
+
const part = found ? parts[Number(found.uid)] : undefined;
|
|
78
78
|
const bytes = part && INLINE_IMAGE_TYPES.test(part.contentType) ? part.decode() : undefined;
|
|
79
|
-
return part && bytes && bytes.length <= MAX_INLINE_IMAGE_BYTES ?
|
|
79
|
+
return part && bytes && bytes.length <= MAX_INLINE_IMAGE_BYTES ? toDataUri(part.contentType, bytes) : undefined;
|
|
80
80
|
}
|
|
81
|
-
const attachment = attachments
|
|
81
|
+
const attachment = findInlineAttachment(attachments, { cid: contentId, alt });
|
|
82
82
|
return attachment ? absoluteAttachmentUrl(attachment.uid) : undefined;
|
|
83
83
|
};
|
|
84
84
|
}
|
|
@@ -29,8 +29,9 @@ export interface PreparedBody {
|
|
|
29
29
|
}
|
|
30
30
|
export interface PrepareOptions {
|
|
31
31
|
/** The URL an inline image's `cid:` reference stands for, or `undefined` when there is no such part. Only `data:` URIs of an
|
|
32
|
-
* image type and `http(s)` URLs are accepted; anything else is treated as unresolved.
|
|
33
|
-
|
|
32
|
+
* image type and `http(s)` URLs are accepted; anything else is treated as unresolved. An image with no source at all (the
|
|
33
|
+
* server removes one it could not point at a part) is asked for with no `contentId` and its `alt`. */
|
|
34
|
+
resolveCid?: (contentId: string | undefined, alt?: string) => string | undefined;
|
|
34
35
|
}
|
|
35
36
|
/** CSS with the constructs above neutralised. */
|
|
36
37
|
export declare function sanitizeCss(css: string): string;
|
|
@@ -189,10 +189,14 @@ function hardenLink(element) {
|
|
|
189
189
|
element.removeAttribute("target");
|
|
190
190
|
}
|
|
191
191
|
}
|
|
192
|
+
/** Whether the image ends up pointing at a part of the message (so the frame's CSP must allow this server's attachment URLs). */
|
|
192
193
|
function hardenImage(element, options) {
|
|
193
194
|
const src = (element.getAttribute("src") ?? "").trim();
|
|
194
|
-
const
|
|
195
|
-
|
|
195
|
+
const cid = /^cid:/i.test(src);
|
|
196
|
+
// A picture the server could not tie to a part keeps its `alt` and loses its `src`: the message's own attachment of that name, if any.
|
|
197
|
+
const inline = cid || src === "";
|
|
198
|
+
const alt = element.getAttribute("alt") ?? undefined;
|
|
199
|
+
const target = cid ? options.resolveCid?.(src.slice(4).replace(/^<|>$/g, ""), alt) : inline ? options.resolveCid?.(undefined, alt) : src;
|
|
196
200
|
// Remote images are never fetched (they would tell the sender the message was read); an inline image resolves to an embedded
|
|
197
201
|
// image or to this server's own attachment URL, and one that doesn't resolve has nothing to show.
|
|
198
202
|
if (target !== undefined && (IMAGE_DATA_URI.test(target) || (inline && /^https?:\/\//i.test(target)))) {
|
|
@@ -201,6 +205,7 @@ function hardenImage(element, options) {
|
|
|
201
205
|
else {
|
|
202
206
|
element.removeAttribute("src");
|
|
203
207
|
}
|
|
208
|
+
return cid || (inline && element.hasAttribute("src"));
|
|
204
209
|
}
|
|
205
210
|
/**
|
|
206
211
|
* The second, structural pass over already-sanitized markup: everything a mail body has no use for is removed or unwrapped, every
|
|
@@ -235,10 +240,9 @@ function harden(body, options) {
|
|
|
235
240
|
hardenLink(element);
|
|
236
241
|
}
|
|
237
242
|
else if (tag === "img") {
|
|
238
|
-
if (
|
|
243
|
+
if (hardenImage(element, options)) {
|
|
239
244
|
inlineImages++;
|
|
240
245
|
}
|
|
241
|
-
hardenImage(element, options);
|
|
242
246
|
}
|
|
243
247
|
}
|
|
244
248
|
return { elements: all.length, inlineImages };
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type CSSProperties } from "react";
|
|
2
2
|
/** How long the new contents take to settle. Short, so a run of steps (a wheel kept turning) never waits on the last one. */
|
|
3
3
|
export declare const ENTER_SLIDE_MS = 140;
|
|
4
|
+
/** Whether the user asked their device for less motion. */
|
|
5
|
+
export declare function prefersReducedMotion(): boolean;
|
|
4
6
|
/**
|
|
5
7
|
* The look of a page that has just been swapped for the next or previous one by a vertical step (the wheel): the new contents start
|
|
6
8
|
* a little way off in the direction the step went - below for `1` (the next), above for `-1` - and faded, and settle into place.
|
|
@@ -11,7 +11,7 @@ const ENTER_START_OPACITY = 0.35;
|
|
|
11
11
|
/** The pause that lets the browser draw the contents at their starting place before they move. */
|
|
12
12
|
const ENTER_SETTLE_MS = 20;
|
|
13
13
|
/** Whether the user asked their device for less motion. */
|
|
14
|
-
function prefersReducedMotion() {
|
|
14
|
+
export function prefersReducedMotion() {
|
|
15
15
|
return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
@@ -268,6 +268,18 @@ export declare const SHORTCUTS: {
|
|
|
268
268
|
label: string;
|
|
269
269
|
scope: "calendar";
|
|
270
270
|
};
|
|
271
|
+
readonly list: {
|
|
272
|
+
id: string;
|
|
273
|
+
keys: string[];
|
|
274
|
+
label: string;
|
|
275
|
+
scope: "calendar";
|
|
276
|
+
};
|
|
277
|
+
readonly search: {
|
|
278
|
+
id: string;
|
|
279
|
+
keys: string[];
|
|
280
|
+
label: string;
|
|
281
|
+
scope: "calendar";
|
|
282
|
+
};
|
|
271
283
|
};
|
|
272
284
|
readonly contacts: {
|
|
273
285
|
readonly create: {
|
|
@@ -67,6 +67,8 @@ export const SHORTCUTS = {
|
|
|
67
67
|
workWeek: def({ id: "calendar.workWeek", keys: ["ctrl+alt+2"], label: "Work week view", scope: "calendar" }),
|
|
68
68
|
week: def({ id: "calendar.week", keys: ["ctrl+alt+3"], label: "Week view", scope: "calendar" }),
|
|
69
69
|
month: def({ id: "calendar.month", keys: ["ctrl+alt+4"], label: "Month view", scope: "calendar" }),
|
|
70
|
+
list: def({ id: "calendar.list", keys: ["ctrl+alt+5"], label: "List view", scope: "calendar" }),
|
|
71
|
+
search: def({ id: "calendar.search", keys: ["/", "mod+e"], label: "Search events", scope: "calendar" }),
|
|
70
72
|
},
|
|
71
73
|
contacts: {
|
|
72
74
|
create: def({ id: "contacts.new", keys: ["alt+n"], electronKeys: ["mod+n"], label: "New contact", scope: "contacts" }),
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The modifier keys of a click - what a mouse event, or the native event behind a checkbox's change, carries. */
|
|
2
|
+
export interface ClickModifiers {
|
|
3
|
+
shiftKey?: boolean;
|
|
4
|
+
ctrlKey?: boolean;
|
|
5
|
+
metaKey?: boolean;
|
|
6
|
+
}
|
|
7
|
+
/** Whether a click is a multi-select click (Shift, Ctrl, or Cmd on a Mac) rather than the plain click that opens a row. */
|
|
8
|
+
export declare function isSelectionClick(modifiers: ClickModifiers | undefined): boolean;
|
|
9
|
+
/** Ctrl (or Cmd on a Mac): toggle just the clicked row. */
|
|
10
|
+
export declare function isToggleClick(modifiers: ClickModifiers | undefined): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* The anchor of a Shift+click range, as Explorer and Outlook have it: the row last clicked (or opened) - `anchor` - while it is still in
|
|
13
|
+
* the list, else the row open in the reading pane (`open`), else nothing (the range is then just the clicked row).
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveAnchor(keys: readonly string[], anchor: string | null, open: string | null): string | null;
|
|
16
|
+
/**
|
|
17
|
+
* Every key from `anchor` to `target` inclusive, in the order `keys` has them - the order the list is displayed in, whichever end of the
|
|
18
|
+
* range was clicked first. With no usable anchor the range is `target` alone.
|
|
19
|
+
*/
|
|
20
|
+
export declare function rangeBetween(keys: readonly string[], anchor: string | null, target: string): string[];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/** Whether a click is a multi-select click (Shift, Ctrl, or Cmd on a Mac) rather than the plain click that opens a row. */
|
|
6
|
+
export function isSelectionClick(modifiers) {
|
|
7
|
+
return !!modifiers && !!(modifiers.shiftKey || modifiers.ctrlKey || modifiers.metaKey);
|
|
8
|
+
}
|
|
9
|
+
/** Ctrl (or Cmd on a Mac): toggle just the clicked row. */
|
|
10
|
+
export function isToggleClick(modifiers) {
|
|
11
|
+
return !!modifiers && !!(modifiers.ctrlKey || modifiers.metaKey);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The anchor of a Shift+click range, as Explorer and Outlook have it: the row last clicked (or opened) - `anchor` - while it is still in
|
|
15
|
+
* the list, else the row open in the reading pane (`open`), else nothing (the range is then just the clicked row).
|
|
16
|
+
*/
|
|
17
|
+
export function resolveAnchor(keys, anchor, open) {
|
|
18
|
+
if (anchor !== null && keys.includes(anchor)) {
|
|
19
|
+
return anchor;
|
|
20
|
+
}
|
|
21
|
+
return open !== null && keys.includes(open) ? open : null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Every key from `anchor` to `target` inclusive, in the order `keys` has them - the order the list is displayed in, whichever end of the
|
|
25
|
+
* range was clicked first. With no usable anchor the range is `target` alone.
|
|
26
|
+
*/
|
|
27
|
+
export function rangeBetween(keys, anchor, target) {
|
|
28
|
+
const from = anchor === null ? -1 : keys.indexOf(anchor);
|
|
29
|
+
const to = keys.indexOf(target);
|
|
30
|
+
if (from === -1 || to === -1) {
|
|
31
|
+
return [target];
|
|
32
|
+
}
|
|
33
|
+
return keys.slice(Math.min(from, to), Math.max(from, to) + 1);
|
|
34
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** How long a row that leaves the list (moved, deleted, archived) takes to collapse. The list drops it from its data after this long. */
|
|
2
|
+
export declare const ROW_EXIT_MS = 200;
|
|
3
|
+
/**
|
|
4
|
+
* Whether a row that leaves should collapse rather than vanish: the browser can animate an element (`Element.animate()` - every current one
|
|
5
|
+
* can) and the reader has not asked for less motion (`prefers-reduced-motion`). Otherwise the row is removed at once.
|
|
6
|
+
*/
|
|
7
|
+
export declare function canAnimateRowExit(): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Collapses a row that is leaving: its height and opacity run to zero over `ROW_EXIT_MS`, so the rows below slide up into the gap, and it
|
|
10
|
+
* stays collapsed (`fill: forwards`) until the list drops it. The row is marked so a second call for the same element does nothing.
|
|
11
|
+
*/
|
|
12
|
+
export declare function collapseRow(row: HTMLElement): void;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/** How long a row that leaves the list (moved, deleted, archived) takes to collapse. The list drops it from its data after this long. */
|
|
6
|
+
export const ROW_EXIT_MS = 200;
|
|
7
|
+
/**
|
|
8
|
+
* Whether a row that leaves should collapse rather than vanish: the browser can animate an element (`Element.animate()` - every current one
|
|
9
|
+
* can) and the reader has not asked for less motion (`prefers-reduced-motion`). Otherwise the row is removed at once.
|
|
10
|
+
*/
|
|
11
|
+
export function canAnimateRowExit() {
|
|
12
|
+
// Only called from event handlers, i.e. in a browser.
|
|
13
|
+
if (typeof Element.prototype.animate !== "function") {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return !(typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Collapses a row that is leaving: its height and opacity run to zero over `ROW_EXIT_MS`, so the rows below slide up into the gap, and it
|
|
20
|
+
* stays collapsed (`fill: forwards`) until the list drops it. The row is marked so a second call for the same element does nothing.
|
|
21
|
+
*/
|
|
22
|
+
export function collapseRow(row) {
|
|
23
|
+
if (row.dataset.collapsing) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
row.dataset.collapsing = "true";
|
|
27
|
+
const height = row.getBoundingClientRect().height;
|
|
28
|
+
row.style.overflow = "hidden";
|
|
29
|
+
row.animate([
|
|
30
|
+
{ height: `${height}px`, opacity: 1 },
|
|
31
|
+
{ height: "0px", opacity: 0, borderBottomWidth: "0px" },
|
|
32
|
+
], { duration: ROW_EXIT_MS, easing: "ease-out", fill: "forwards" });
|
|
33
|
+
}
|