domma-cms 0.54.2 → 0.66.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.
Files changed (221) hide show
  1. package/CLAUDE.md +151 -77
  2. package/README.md +4 -4
  3. package/admin/css/admin.css +1 -1
  4. package/admin/dist/domma/domma-tools.css +3 -3
  5. package/admin/dist/domma/domma-tools.min.js +3 -3
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +3 -3
  8. package/admin/js/lib/page-picker.js +1 -0
  9. package/admin/js/lib/plugin-accent.js +1 -0
  10. package/admin/js/lib/plugin-chrome.js +1 -0
  11. package/admin/js/lib/shortcode-context-menu.js +2 -2
  12. package/admin/js/lib/sidebar-grouping.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  14. package/admin/js/lib/sidebar-renderer.js +4 -4
  15. package/admin/js/lib/slideover-resizable.js +1 -0
  16. package/admin/js/templates/context-menu-editor.html +212 -0
  17. package/admin/js/templates/context-menus.html +16 -0
  18. package/admin/js/templates/menu-editor.html +19 -25
  19. package/admin/js/templates/plugin-code.html +1 -1
  20. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  21. package/admin/js/templates/settings.html +27 -97
  22. package/admin/js/templates/theme.html +173 -0
  23. package/admin/js/views/context-menu-editor.js +55 -0
  24. package/admin/js/views/context-menus.js +5 -0
  25. package/admin/js/views/form-editor.js +7 -7
  26. package/admin/js/views/index.js +1 -1
  27. package/admin/js/views/menu-editor.js +13 -13
  28. package/admin/js/views/plugin-marketplace.js +1 -1
  29. package/admin/js/views/plugins.js +25 -23
  30. package/admin/js/views/search.js +1 -0
  31. package/admin/js/views/settings.js +3 -3
  32. package/admin/js/views/theme.js +1 -0
  33. package/bin/cli.js +11 -2
  34. package/bin/lib/smtp-defaults.js +53 -0
  35. package/bin/update.js +13 -2
  36. package/config/menus/admin-sidebar.json +129 -23
  37. package/config/plugins.json +5 -5
  38. package/config/search.json +13 -0
  39. package/config/theme.json +18 -0
  40. package/package.json +12 -4
  41. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  42. package/plugins/_lib/admin/mail/contacts.js +301 -0
  43. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  44. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  45. package/plugins/_lib/admin/mail/identity.js +480 -0
  46. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  47. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  48. package/plugins/_lib/admin/mail/mail.css +1 -0
  49. package/plugins/_lib/admin/mail/mail.html +71 -0
  50. package/plugins/_lib/admin/mail/panes.js +253 -0
  51. package/plugins/_lib/admin/mail/reader-view.js +4453 -0
  52. package/plugins/_lib/admin/mail/resizable.js +26 -0
  53. package/plugins/_lib/admin/mail/rules.js +343 -0
  54. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  55. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  56. package/plugins/_lib/admin/mail/templates.js +238 -0
  57. package/plugins/_lib/admin/mail/threads.js +200 -0
  58. package/plugins/_lib/admin/mail/vacation.js +269 -0
  59. package/plugins/_lib/admin/ui/help.css +1 -0
  60. package/plugins/_lib/admin/ui/help.js +174 -0
  61. package/plugins/_lib/admin/ui/resizable.js +151 -0
  62. package/plugins/_lib/dataStore.js +117 -0
  63. package/plugins/_lib/mail/accounts.js +919 -0
  64. package/plugins/_lib/mail/bodyTokens.js +101 -0
  65. package/plugins/_lib/mail/compose.js +256 -0
  66. package/plugins/_lib/mail/defaults.js +57 -0
  67. package/plugins/_lib/mail/diagnostics.js +154 -0
  68. package/plugins/_lib/mail/envelope.js +161 -0
  69. package/plugins/_lib/mail/folders.js +192 -0
  70. package/plugins/_lib/mail/handoff.js +274 -0
  71. package/plugins/_lib/mail/imapPool.js +293 -0
  72. package/plugins/_lib/mail/mbox.js +74 -0
  73. package/plugins/_lib/mail/pollSchedule.js +79 -0
  74. package/plugins/_lib/mail/poller.js +135 -0
  75. package/plugins/_lib/mail/priority.js +138 -0
  76. package/plugins/_lib/mail/readRoutes.js +680 -0
  77. package/plugins/_lib/mail/render.js +291 -0
  78. package/plugins/_lib/mail/ruleRunner.js +152 -0
  79. package/plugins/_lib/mail/scheduler.js +254 -0
  80. package/plugins/_lib/mail/secretbox.js +229 -0
  81. package/plugins/_lib/mail/send.js +396 -0
  82. package/plugins/_lib/mail/store.js +1002 -0
  83. package/plugins/_lib/mail/sync.js +292 -0
  84. package/plugins/_lib/mail/syncPlan.js +126 -0
  85. package/plugins/_lib/mail/syncSelection.js +82 -0
  86. package/plugins/_lib/mail/unsubscribe.js +183 -0
  87. package/plugins/_lib/mail/vacationRunner.js +114 -0
  88. package/plugins/_lib/mail/write.js +473 -0
  89. package/plugins/_lib/schemaSync.js +83 -0
  90. package/plugins/_template/admin/css/index.css +0 -0
  91. package/plugins/_template/admin/templates/index.html +4 -4
  92. package/plugins/_template/admin/views/index.js +7 -0
  93. package/plugins/analytics/admin/css/index.css +1 -0
  94. package/plugins/analytics/admin/templates/analytics.html +22 -13
  95. package/plugins/analytics/plugin.json +3 -0
  96. package/plugins/blog/admin/css/index.css +1 -0
  97. package/plugins/blog/admin/templates/blog.html +30 -18
  98. package/plugins/blog/admin/templates/categories.html +2 -2
  99. package/plugins/blog/admin/templates/comments.html +2 -2
  100. package/plugins/blog/admin/templates/post-editor.html +34 -34
  101. package/plugins/blog/admin/templates/settings.html +6 -3
  102. package/plugins/blog/admin/views/blog.js +8 -5
  103. package/plugins/blog/admin/views/categories.js +5 -10
  104. package/plugins/blog/admin/views/comments.js +5 -5
  105. package/plugins/blog/admin/views/post-editor.js +39 -20
  106. package/plugins/blog/admin/views/settings.js +52 -50
  107. package/plugins/blog/collections/categories/schema.json +7 -6
  108. package/plugins/blog/collections/comments/schema.json +11 -10
  109. package/plugins/blog/collections/posts/schema.json +14 -13
  110. package/plugins/blog/plugin.js +36 -13
  111. package/plugins/blog/plugin.json +13 -5
  112. package/plugins/blog/plugin.public.js +312 -0
  113. package/plugins/contacts/admin/css/index.css +1 -0
  114. package/plugins/contacts/admin/templates/contacts.html +128 -0
  115. package/plugins/contacts/admin/views/contacts.js +237 -4
  116. package/plugins/contacts/collections/user-contacts/schema.json +108 -0
  117. package/plugins/contacts/plugin.js +214 -27
  118. package/plugins/contacts/plugin.json +4 -1
  119. package/plugins/invoice/admin/css/index.css +1 -0
  120. package/plugins/invoice/admin/templates/editor.html +140 -49
  121. package/plugins/invoice/admin/templates/index.html +153 -23
  122. package/plugins/invoice/admin/templates/issuers.html +2 -5
  123. package/plugins/invoice/admin/templates/receivers.html +2 -5
  124. package/plugins/invoice/admin/views/contacts-source.js +266 -0
  125. package/plugins/invoice/admin/views/editor.js +366 -199
  126. package/plugins/invoice/admin/views/export.js +199 -0
  127. package/plugins/invoice/admin/views/help-content.js +61 -0
  128. package/plugins/invoice/admin/views/index.js +582 -94
  129. package/plugins/invoice/admin/views/issuers.js +24 -17
  130. package/plugins/invoice/admin/views/media.js +172 -0
  131. package/plugins/invoice/admin/views/party-view.js +305 -67
  132. package/plugins/invoice/admin/views/payments.js +127 -0
  133. package/plugins/invoice/admin/views/print.js +130 -0
  134. package/plugins/invoice/admin/views/receivers.js +49 -16
  135. package/plugins/invoice/admin/views/send.js +212 -0
  136. package/plugins/invoice/admin/views/settings.js +594 -0
  137. package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
  138. package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
  139. package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
  140. package/plugins/invoice/collections/invoices/schema.json +19 -13
  141. package/plugins/invoice/config.js +27 -6
  142. package/plugins/invoice/pdf.js +164 -0
  143. package/plugins/invoice/plugin.js +1217 -44
  144. package/plugins/invoice/plugin.json +10 -9
  145. package/plugins/invoice/templates/_base.css +1 -0
  146. package/plugins/invoice/templates/classic-nologo.html +100 -0
  147. package/plugins/invoice/templates/classic.html +91 -0
  148. package/plugins/invoice/templates/invoice-print.html +24 -0
  149. package/plugins/invoice/templates/minimal.html +99 -0
  150. package/plugins/invoice/templates/modern-nologo.html +114 -0
  151. package/plugins/invoice/templates/modern.html +113 -0
  152. package/plugins/invoice/templates/templates.json +11 -0
  153. package/plugins/mail-reader/admin/views/mail.js +19 -0
  154. package/plugins/mail-reader/config.js +7 -0
  155. package/plugins/mail-reader/plugin.js +48 -0
  156. package/plugins/mail-reader/plugin.json +33 -0
  157. package/plugins/notes/admin/views/notes.js +1 -1
  158. package/plugins/notes/plugin.json +2 -2
  159. package/plugins/surveys/lib/audience.js +37 -0
  160. package/plugins/surveys/lib/campaigns.js +43 -0
  161. package/plugins/surveys/lib/ledger.js +110 -0
  162. package/plugins/surveys/lib/sending.js +106 -0
  163. package/plugins/surveys/lib/stats.js +62 -0
  164. package/plugins/surveys/lib/submit.js +95 -0
  165. package/plugins/surveys/lib/tokens.js +28 -0
  166. package/plugins/surveys/plugin.public.js +149 -0
  167. package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
  168. package/public/css/forms.css +1 -1
  169. package/public/css/menu-highlight.css +1 -1
  170. package/public/css/search.css +1 -0
  171. package/public/css/site.css +1 -1
  172. package/public/js/collection-context.js +2 -2
  173. package/public/js/context-menus.js +1 -0
  174. package/public/js/form-logic-engine.js +1 -1
  175. package/public/js/forms.js +2 -2
  176. package/public/js/menu-decor.mjs +1 -1
  177. package/public/js/search.js +1 -0
  178. package/public/js/site.js +1 -1
  179. package/scripts/build.js +37 -3
  180. package/scripts/copy-domma.js +48 -0
  181. package/scripts/seed.js +1996 -0
  182. package/scripts/setup.js +8 -0
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/context-menus.js +104 -0
  185. package/server/routes/api/forms.js +42 -3
  186. package/server/routes/api/notifications.js +69 -19
  187. package/server/routes/api/plugins.js +50 -6
  188. package/server/routes/api/search.js +43 -0
  189. package/server/routes/api/theme.js +69 -0
  190. package/server/routes/public.js +42 -7
  191. package/server/server.js +74 -0
  192. package/server/services/adapters/FileAdapter.js +6 -1
  193. package/server/services/content.js +26 -0
  194. package/server/services/contextMenus.js +477 -0
  195. package/server/services/email.js +29 -3
  196. package/server/services/health.js +23 -2
  197. package/server/services/markdown.js +70 -9
  198. package/server/services/menuRender.js +28 -4
  199. package/server/services/menus.js +25 -1
  200. package/server/services/permissionRegistry.js +24 -0
  201. package/server/services/pluginFiles.js +52 -11
  202. package/server/services/plugins.js +229 -6
  203. package/server/services/renderer.js +148 -22
  204. package/server/services/roles.js +1 -1
  205. package/server/services/search-migration.js +82 -0
  206. package/server/services/search.js +413 -0
  207. package/server/services/sidebar-migration.js +1 -0
  208. package/server/services/themeSettings.js +541 -0
  209. package/server/services/users.js +8 -0
  210. package/server/templates/page.html +4 -2
  211. package/plugins/contacts/data/contacts.json +0 -20
  212. package/plugins/notes/data/notes.json +0 -1
  213. package/plugins/site-search/admin/views/site-search.js +0 -116
  214. package/plugins/site-search/config.js +0 -15
  215. package/plugins/site-search/plugin.js +0 -188
  216. package/plugins/site-search/plugin.json +0 -40
  217. package/plugins/site-search/public/inject-body.html +0 -17
  218. package/plugins/site-search/public/inject-head.html +0 -1
  219. package/plugins/site-search/public/search.css +0 -1
  220. package/plugins/site-search/public/search.js +0 -1
  221. package/plugins/todo/data/todos.json +0 -1
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Keyboard navigation for the reader.
3
+ *
4
+ * A mail client that needs the mouse for every message is a mail client people
5
+ * stop using at volume. The bindings follow what mail clients have agreed on
6
+ * for decades - j/k and the arrows to move, Enter to open, r to reply - so
7
+ * nobody has to learn them.
8
+ *
9
+ * Two rules keep it out of the way:
10
+ *
11
+ * - While the caret is in a field, the keyboard belongs to the field. A
12
+ * shortcut that fires while someone is typing a subject line is a bug, not
13
+ * a feature, so anything with a text target is ignored outright.
14
+ * - Only plain keypresses are claimed. Ctrl, Alt and Meta combinations
15
+ * belong to the browser and the operating system.
16
+ *
17
+ * @module _lib/admin/mail/keyboard
18
+ */
19
+
20
+ /**
21
+ * Is the keyboard currently someone's typing surface?
22
+ *
23
+ * @param {EventTarget|null} target
24
+ * @returns {boolean}
25
+ */
26
+ export function isTyping(target) {
27
+ if (!target) return false;
28
+ // Duck-typed rather than `instanceof Element`: that check fails across
29
+ // realms - an element inside an iframe is not the same Element - and it
30
+ // makes this untestable outside a browser for no benefit.
31
+ if (target.isContentEditable === true) return true;
32
+ const tag = target.tagName;
33
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
34
+ }
35
+
36
+ /**
37
+ * Should this event be considered at all?
38
+ *
39
+ * @param {KeyboardEvent} event
40
+ * @returns {boolean}
41
+ */
42
+ export function isPlainKey(event) {
43
+ return !event.ctrlKey && !event.metaKey && !event.altKey;
44
+ }
45
+
46
+ /**
47
+ * Work out what a keypress means.
48
+ *
49
+ * Returned as an intent rather than performed, so the mapping can be tested
50
+ * without a browser and the view keeps every decision about what to do.
51
+ *
52
+ * @param {{key: string, shiftKey?: boolean}} event
53
+ * @param {{pane: 'folders'|'messages'}} context
54
+ * @returns {{action: string, delta?: number}|null}
55
+ */
56
+ export function resolveKey(event, context) {
57
+ // Lower-cased, because holding Shift changes what the browser reports:
58
+ // Shift-R arrives as `R`, not `r` with a flag. Matching on the raw value
59
+ // meant Shift-R fell through to nothing at all, while a test written as
60
+ // `{key: 'r', shiftKey: true}` - which no browser sends - passed happily.
61
+ // Only single characters: `ArrowDown` and `Escape` must keep their case.
62
+ const key = event.key?.length === 1 ? event.key.toLowerCase() : event.key;
63
+ const inMessages = context.pane === 'messages';
64
+
65
+ switch (key) {
66
+ case 'ArrowDown':
67
+ case 'j':
68
+ return {action: inMessages ? 'moveMessage' : 'moveFolder', delta: 1};
69
+ case 'ArrowUp':
70
+ case 'k':
71
+ return {action: inMessages ? 'moveMessage' : 'moveFolder', delta: -1};
72
+
73
+ case 'ArrowRight':
74
+ // Rightwards is "into" - from the folder list to its messages, and
75
+ // from a message to its body.
76
+ return {action: inMessages ? 'focusReader' : 'enterFolder'};
77
+ case 'ArrowLeft':
78
+ return {action: 'focusFolders'};
79
+
80
+ case 'Enter':
81
+ return {action: inMessages ? 'openMessage' : 'enterFolder'};
82
+
83
+ case 'u':
84
+ return {action: 'toggleUnread'};
85
+ case 's':
86
+ return {action: 'toggleStar'};
87
+ case 'r':
88
+ return {action: event.shiftKey ? 'replyAll' : 'reply'};
89
+ case 'f':
90
+ return {action: 'forward'};
91
+ case 'c':
92
+ return {action: 'compose'};
93
+ case '/':
94
+ return {action: 'search'};
95
+ case 'x':
96
+ // What Gmail uses. Until this existed, selecting needed a mouse
97
+ // and a modifier key, so a tablet could not select at all.
98
+ return {action: 'toggleSelect'};
99
+ case 'a':
100
+ if (event.shiftKey) return {action: 'selectAll'};
101
+ return {action: 'replyAll'};
102
+ case '!':
103
+ // What Gmail and SnappyMail both use, so the muscle memory
104
+ // carries over. Which way it goes depends on the folder, which
105
+ // the view knows and this does not.
106
+ return {action: 'spam'};
107
+ case '#':
108
+ case 'Delete':
109
+ return {action: 'delete'};
110
+ case 'Escape':
111
+ return {action: 'escape'};
112
+ case '?':
113
+ return {action: 'help'};
114
+ default:
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /** What the help panel lists, in the order it shows them. */
120
+ export const SHORTCUTS = [
121
+ ['↑ ↓ or j k', 'Move through the list'],
122
+ ['← →', 'Between folders and messages'],
123
+ ['Enter', 'Open'],
124
+ ['u', 'Mark read or unread'],
125
+ ['s', 'Star or unstar'],
126
+ ['r / Shift-R', 'Reply / reply all'],
127
+ ['f', 'Forward'],
128
+ ['c', 'New message'],
129
+ ['/', 'Search'],
130
+ ['x', 'Select or deselect'],
131
+ ['Shift-A', 'Select all on this page'],
132
+ ['!', 'Mark as spam, or not spam'],
133
+ ['# or Delete', 'Delete'],
134
+ ['Esc', 'Clear search or selection'],
135
+ ['?', 'This list']
136
+ ];
@@ -0,0 +1 @@
1
+ #view-container.mail-fullbleed{max-width:none}.mail-layout [hidden],.dm-slideover [hidden]{display:none!important}.mail-reader-summary{display:flex;align-items:baseline;gap:.5rem;flex-wrap:wrap}.mail-header-toggle,.mail-extras-toggle{padding:0!important;font-size:.78rem;opacity:.75;text-decoration:underline}.mail-header-toggle:hover,.mail-extras-toggle:hover{opacity:1}.mail-header-details{display:grid;grid-template-columns:max-content 1fr;gap:.15rem .75rem;margin:.5rem 0 0;font-size:.8rem}.mail-header-details dt{opacity:.65}.mail-header-details dd{margin:0;overflow-wrap:anywhere}.mail-layout{display:grid;grid-template-columns:var(--mail-col-1, 190px) auto var(--mail-col-2, 340px) auto 1fr;height:calc(100vh - 140px);min-height:320px;border:1px solid var(--dm-border, #e2e8f0);border-radius:var(--dm-radius-md, 8px);overflow:hidden;background:var(--dm-surface, #fff)}.mail-folders-pane,.mail-list-pane{display:flex;flex-direction:column;border-right:1px solid var(--dm-border, #e2e8f0);min-width:0;min-height:0}.mail-reader-pane{display:flex;flex-direction:column;min-width:0;min-height:0}.mail-pane-head{display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding:.5rem .75rem;font-size:.8rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--dm-text-muted, #64748b);border-bottom:1px solid var(--dm-border, #e2e8f0);flex-shrink:0}.mail-folders-list,.mail-list{overflow-y:auto;flex:1;min-height:0}.mail-search{display:flex;align-items:center;gap:.4rem;padding:.4rem .6rem;border-bottom:1px solid var(--dm-border, #e2e8f0);color:var(--dm-text-muted, #64748b);flex-shrink:0}.mail-search input{flex:1;min-width:0;font-size:.85rem;padding:.25rem .4rem}.mail-search-note{padding:.3rem .6rem;font-size:.74rem;color:var(--dm-text-muted, #64748b);border-bottom:1px solid var(--dm-border, #e2e8f0);flex-shrink:0}.mail-slideover-resizer{padding:0;position:absolute;left:0;top:0;bottom:0;width:6px;cursor:col-resize;z-index:2;background:transparent}.mail-slideover-resizer:hover,.is-resizing>.mail-slideover-resizer{background:var(--dm-primary, #2563eb);opacity:.7}.is-resizing{user-select:none;transition:none!important}.mail-selection{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap;padding:.35rem .6rem;font-size:.78rem;background:var(--dm-primary, #2563eb);color:#fff;flex-shrink:0}.mail-selection span{flex:1;min-width:0}.mail-selection .btn{color:#fff;border:1px solid currentColor;background:transparent}.mail-selection .btn:hover{background:#ffffff26}.mail-item.is-selected{background:var(--dm-active-bg, rgba(15, 23, 42, .07));box-shadow:inset 3px 0 0 var(--dm-primary, #2563eb)}.mail-undo{display:flex;align-items:center;gap:.5rem;padding:.4rem .6rem;font-size:.78rem;background:var(--dm-active-bg, rgba(15, 23, 42, .07));border-bottom:1px solid var(--dm-border, #e2e8f0);flex-shrink:0}.mail-undo span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-splitter{width:5px;cursor:col-resize;background:var(--dm-border, #e2e8f0);flex-shrink:0;background-clip:content-box;border-left:2px solid transparent;border-right:2px solid transparent;box-sizing:content-box}.mail-splitter:hover,.mail-layout.is-resizing .mail-splitter{background-color:var(--dm-primary, #2563eb)}.mail-layout.is-resizing{user-select:none}.mail-layout.is-resizing .mail-folders-pane,.mail-layout.is-resizing .mail-list-pane,.mail-layout.is-resizing .mail-reader-pane,.mail-layout.is-resizing iframe{pointer-events:none}.mail-unread-filter{display:flex;align-items:center;gap:.3rem;font-size:.7rem;text-transform:none;letter-spacing:0;cursor:pointer;margin-left:auto;margin-right:.25rem}.mail-unread-pill{flex-shrink:0;min-width:1.15rem;padding:0 .3rem;border-radius:999px;background:var(--dm-primary, #2563eb);color:#fff;font-size:.66rem;font-weight:700;line-height:1.35;text-align:center}.mail-folder.is-context .mail-folder-name{opacity:.6}.mail-account{display:flex;align-items:center;gap:.4rem;width:100%;padding:.45rem .6rem;border:none;background:var(--dm-surface, #fff);cursor:pointer;text-align:left;font-size:.78rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;color:var(--dm-text-muted, #64748b)}.mail-account:hover{background:var(--dm-hover-bg, rgba(15, 23, 42, .04))}.mail-account.is-active{color:var(--dm-text, #1e293b);position:sticky;top:0;z-index:1;background:var(--dm-surface, #fff)}.mail-account-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-folder{display:flex;align-items:center;gap:.5rem;padding:.45rem .75rem;font-size:.875rem;cursor:pointer;border:none;background:none;width:100%;text-align:left;color:var(--dm-text, #1e293b)}.mail-folder:hover{background:var(--dm-hover-bg, rgba(15, 23, 42, .04))}.mail-folder.is-active{background:var(--dm-active-bg, rgba(15, 23, 42, .07));color:var(--dm-primary, #2563eb);font-weight:600}.mail-folder.is-unselectable{opacity:.6;cursor:default}.mail-folder.is-unselectable:hover{background:none}.mail-folder-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-item{display:block;width:100%;text-align:left;padding:.55rem .75rem;border:none;border-bottom:1px solid var(--dm-border, #e2e8f0);background:none;cursor:pointer;color:var(--dm-text, #1e293b)}.mail-item:hover{background:var(--dm-hover-bg, rgba(15, 23, 42, .04))}.mail-item.is-active{background:var(--dm-active-bg, rgba(15, 23, 42, .07))}.mail-item-top{display:flex;justify-content:space-between;gap:.5rem;align-items:baseline}.mail-item-from{font-size:.85rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-item.is-unread .mail-item-from,.mail-item.is-unread .mail-item-subject{font-weight:700}.mail-item-date{font-size:.72rem;color:var(--dm-text-muted, #64748b);flex-shrink:0}.mail-item-subject{font-size:.82rem;margin-top:.1rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-item-meta{display:flex;align-items:center;gap:.35rem;margin-top:.15rem;color:var(--dm-text-muted, #64748b)}.mail-pager{display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding:.4rem .6rem;border-top:1px solid var(--dm-border, #e2e8f0);font-size:.75rem;color:var(--dm-text-muted, #64748b);flex-shrink:0}.mail-reader-head{padding:.75rem 1rem;border-bottom:1px solid var(--dm-border, #e2e8f0);flex-shrink:0}.mail-reader-head:empty{display:none}.mail-reader-subject{font-size:1.05rem;font-weight:600;margin:0 0 .3rem;color:var(--dm-text, #1e293b)}.mail-reader-actions{display:flex;gap:.25rem;margin:.1rem 0 .35rem;flex-wrap:wrap}.mail-reader-meta{font-size:.8rem;color:var(--dm-text-muted, #64748b)}.mail-reader-body{flex:1;position:relative;overflow:hidden;display:flex;flex-direction:column;min-height:0;background:var(--dm-surface, #fff)}.mail-reader-body iframe{width:100%;flex:1;border:none;display:block;background:#fff}.mail-notice{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;padding:.4rem .75rem;font-size:.78rem;background:var(--dm-warning, #8a6d1f);color:var(--dm-warning-text, #fff);border-bottom:1px solid var(--dm-border, #e2e8f0)}.mail-notice .btn{color:var(--dm-warning-text, #fff);border:1px solid currentColor;background:transparent;opacity:.95}.mail-notice .btn:hover{background:#ffffff26;opacity:1}.mail-attachments{display:flex;flex-wrap:wrap;gap:.4rem;padding:.5rem .75rem;border-bottom:1px solid var(--dm-border, #e2e8f0);background:var(--dm-background-alt, rgba(15, 23, 42, .03))}.mail-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.5rem;height:100%;color:var(--dm-text-muted, #94a3b8);text-align:center;padding:1rem}.mail-empty p{margin:0;font-size:.875rem}@media(max-width:900px){.mail-layout{grid-template-columns:1fr;height:auto!important}.mail-splitter{display:none}.mail-folders-pane,.mail-list-pane{border-right:none;border-bottom:1px solid var(--dm-border, #e2e8f0)}.mail-folders-list,.mail-list{max-height:240px}.mail-reader-body{min-height:60vh}}.mail-box-row{display:flex;align-items:center;gap:.5rem;padding:.5rem .6rem;border:1px solid var(--dm-border, #e2e8f0);border-radius:var(--dm-radius-md, 6px)}.mail-item.is-dragging{opacity:.45}.mail-folder.is-drop-target{background:var(--dm-primary, #2563eb);color:#fff;outline:2px solid var(--dm-primary, #2563eb);outline-offset:-2px}.mail-folder.is-drop-target .mail-folder-name,.mail-folder.is-drop-target .mail-unread-pill{color:inherit}.mail-item.is-dragging *{pointer-events:none}.mail-item{position:relative}.mail-item-tick{position:absolute;left:.45rem;top:.55rem;display:inline-flex;opacity:0;color:var(--dm-text-muted, #64748b);transition:opacity .12s ease;padding:2px;margin:-2px;border-radius:var(--dm-radius-sm, 3px)}.mail-item:hover .mail-item-tick,.mail-item:focus-visible .mail-item-tick,.mail-list.is-selecting .mail-item-tick,.mail-item-tick[aria-checked=true]{opacity:1}.mail-item-tick[aria-checked=true]{color:var(--dm-primary, #2563eb)}.mail-item-tick:hover{color:var(--dm-primary, #2563eb);background:var(--dm-hover-bg)}.mail-item-top,.mail-item-subject,.mail-item-meta{padding-left:1.5rem}.mail-select-all{display:inline-flex;align-items:center;margin-right:.1rem;cursor:pointer}.mail-select-all input{cursor:pointer;margin:0}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.mail-item-meta>span{display:inline-flex}.mail-item-meta .is-high{color:var(--dm-danger, #dc2626)}.mail-item-meta .is-low{color:var(--dm-text-muted, #64748b);opacity:.7}.mail-folder.is-hidden-folder .mail-folder-name{opacity:.5;font-style:italic}.mail-thread-chip{display:inline-flex;align-items:center;margin-left:.4rem;padding:0 .35rem;border-radius:999px;font-size:.68rem;font-weight:700;line-height:1.5;color:var(--dm-text-muted, #64748b);background:var(--dm-active-bg, rgba(15, 23, 42, .07));cursor:pointer}.mail-thread-chip:hover{color:var(--dm-primary, #2563eb)}.mail-thread-chip.is-unread{background:var(--dm-primary, #2563eb);color:#fff}.mail-item.is-in-thread{padding-left:1.6rem;box-shadow:inset 3px 0 0 var(--dm-border, #e2e8f0)}.mail-item.is-in-thread .mail-item-from{font-size:.8rem}.mail-pane-head .btn.is-on{color:var(--dm-primary, #2563eb);background:var(--dm-active-bg, rgba(15, 23, 42, .07))}#mail-outbox-btn{font-size:.72rem;font-weight:700}#mail-outbox-btn.is-failed{color:var(--dm-danger, #dc2626)}.mail-attachment{display:inline-flex;align-items:center;border:1px solid var(--dm-border, #e2e8f0);border-radius:var(--dm-radius-md, 6px);overflow:hidden}.mail-attachment .btn{border:none;border-radius:0}.mail-attachment-save{padding-left:.4rem!important;padding-right:.4rem!important;border-left:1px solid var(--dm-border, #e2e8f0)!important;opacity:.7}.mail-attachment-save:hover{opacity:1;color:var(--dm-primary, #2563eb)}.mail-section-heading{margin:1rem 0 .1rem;font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;color:var(--dm-text-muted, #64748b)}.mail-section-heading:first-child{margin-top:.25rem}.mail-section-form .domma-form{margin:0}.mail-section-form .domma-form-field:last-child{margin-bottom:0}.mail-item-card{margin:0}.mail-item-card .card-header{padding:.5rem .6rem}.mail-item-card .card-body{padding:.6rem}.mail-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.mail-status{min-height:1.2em}.mail-empty-line{margin:.25rem 0}.mail-editor-fallback{min-height:8rem;font-family:inherit;resize:vertical}.mail-clause{display:flex;gap:.3rem;align-items:center;flex-wrap:wrap}.mail-clause .form-select,.mail-clause .form-input{width:auto;flex:0 1 auto}.mail-clause .mail-clause-value{flex:1 1 8rem;min-width:8rem}.mail-section-list{display:flex;flex-direction:column;gap:.5rem}.mail-scroll-sm{max-height:20vh;overflow-y:auto}.mail-scroll-md{max-height:46vh;overflow-y:auto}.mail-section-form .domma-form-field:has(select) .form-label .text-danger{display:none}.mail-compose-form{padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:.6rem;height:100%}.mail-compose-body{display:flex;flex-direction:column;flex:1;min-height:0}.mail-compose-editor{flex:1;min-height:0}.mail-compose-textarea{flex:1;min-height:34vh;font-family:inherit;resize:vertical}.mail-attach-list{display:flex;flex-direction:column;gap:.2rem}.mail-template-row{margin:-.15rem 0 .35rem}.mail-template-picker{max-width:16rem}.mail-suggest-row{display:flex;flex-direction:column;line-height:1.25}.mail-suggest-detail{opacity:.7}.mail-rule-off{opacity:.55}.mail-panel{padding:.25rem 0 .5rem}.mail-panel-stack{padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:1rem}.mail-form-stack{padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:.75rem}.mail-dialog-stack{display:flex;flex-direction:column;gap:.5rem;padding:.25rem 0}.mail-hint{margin-top:.2rem}.mail-box-sub{overflow:hidden;text-overflow:ellipsis}.mail-add-btn{align-self:flex-start;margin-top:.5rem}.mail-none{margin:0;padding:.5rem .6rem}.mail-kv{padding:.5rem 0;display:flex;flex-direction:column;gap:.35rem}.mail-kv-row{display:flex;gap:.75rem}.mail-kv-key{min-width:8.5rem;flex-shrink:0}.mail-toggle-row{display:flex;align-items:center;gap:.5rem;margin-bottom:.75rem}.mail-sender-list{display:flex;flex-direction:column;gap:.2rem;max-height:40vh;overflow-y:auto}.mail-folder-pick{display:flex;align-items:center;gap:.5rem;padding:.3rem .4rem;border-radius:var(--dm-radius-md, 6px)}.mail-diag-log{width:100%;height:30vh;margin-top:.5rem;font-family:monospace;font-size:.72rem}.mail-facts{display:flex;flex-direction:column;gap:.2rem;margin-bottom:.75rem}
@@ -0,0 +1,71 @@
1
+ <div class="mail-layout">
2
+
3
+ <!-- Folders -->
4
+ <div class="mail-folders-pane">
5
+ <div class="mail-pane-head">
6
+ <span>Folders</span>
7
+ <label class="mail-unread-filter" id="mail-unread-filter" title="Show only folders with unread mail" hidden>
8
+ <input type="checkbox" id="mail-unread-only">
9
+ <span>Unread</span>
10
+ </label>
11
+ <button class="btn btn-sm btn-ghost" id="mail-new-folder-btn" title="New folder" hidden>
12
+ <span data-icon="folder-plus" data-icon-size="14"></span>
13
+ </button>
14
+ <button class="btn btn-sm btn-ghost" id="mail-settings-btn" title="Mail account settings">
15
+ <span data-icon="settings" data-icon-size="14"></span>
16
+ </button>
17
+ </div>
18
+ <div class="mail-folders-list" id="mail-folders-list"></div>
19
+ </div>
20
+
21
+ <div class="mail-splitter" data-splitter="folders" title="Drag to resize"></div>
22
+
23
+ <!-- Message list -->
24
+ <div class="mail-list-pane">
25
+ <div class="mail-pane-head">
26
+ <label class="mail-select-all" id="mail-select-all" title="Select every message on this page" hidden>
27
+ <input type="checkbox" id="mail-select-all-box">
28
+ <span class="sr-only">Select every message on this page</span>
29
+ </label>
30
+ <span id="mail-list-title" title="The folder you are looking at">Inbox</span>
31
+ <div class="mail-pane-head-actions">
32
+ <button class="btn btn-sm btn-ghost" id="mail-outbox-btn" title="Mail waiting to be sent" hidden>
33
+ <span data-icon="clock" data-icon-size="14"></span><span id="mail-outbox-count"></span>
34
+ </button>
35
+ <button class="btn btn-sm btn-ghost" id="mail-threads-btn" title="Group messages into conversations" aria-pressed="false" hidden>
36
+ <span data-icon="messages-square" data-icon-size="14"></span>
37
+ </button>
38
+ <button class="btn btn-sm btn-ghost" id="mail-compose-btn" title="New message" hidden>
39
+ <span data-icon="edit-3" data-icon-size="14"></span>
40
+ </button>
41
+ <button class="btn btn-sm btn-ghost" id="mail-refresh-btn" title="Refresh">
42
+ <span data-icon="refresh-cw" data-icon-size="14"></span>
43
+ </button>
44
+ </div>
45
+ </div>
46
+ <div class="mail-search" id="mail-search" hidden>
47
+ <span data-icon="search" data-icon-size="14"></span>
48
+ <input type="search" id="mail-search-input" class="form-input" placeholder="Search mail…" autocomplete="off">
49
+ <button class="btn btn-sm btn-ghost" id="mail-search-clear" title="Clear the search and go back to the folder" hidden>Clear</button>
50
+ </div>
51
+ <div class="mail-search-note" id="mail-search-note" hidden></div>
52
+ <div class="mail-selection" id="mail-selection" hidden></div>
53
+ <div class="mail-undo" id="mail-undo" hidden></div>
54
+ <div class="mail-list" id="mail-list"></div>
55
+ <div class="mail-pager" id="mail-pager"></div>
56
+ </div>
57
+
58
+ <div class="mail-splitter" data-splitter="list" title="Drag to resize"></div>
59
+
60
+ <!-- Reader -->
61
+ <div class="mail-reader-pane">
62
+ <div class="mail-reader-head" id="mail-reader-head"></div>
63
+ <div class="mail-reader-body" id="mail-reader-body">
64
+ <div class="mail-empty" id="mail-reader-empty">
65
+ <span data-icon="mail" data-icon-size="40"></span>
66
+ <p>Select a message to read it.</p>
67
+ </div>
68
+ </div>
69
+ </div>
70
+
71
+ </div>
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Pane sizing for the mail reader, shared by the Mail Reader and Email Pro.
3
+ *
4
+ * Two jobs, both of which every mail client is expected to do and neither of
5
+ * which CSS can manage alone:
6
+ *
7
+ * 1. Fill the window. The reader is a workspace, not an article - leftover
8
+ * space below it is wasted, and a fixed `calc(100vh - 140px)` guesses at
9
+ * admin chrome that changes with the theme and the viewport.
10
+ * 2. Let the dividers move. Folder names and subject lines vary wildly in
11
+ * length, so whatever widths we pick will be wrong for someone.
12
+ *
13
+ * Pure DOM, no framework: the plugins own their markup, this owns the geometry.
14
+ *
15
+ * @module _lib/admin/mail/panes
16
+ */
17
+
18
+ /** Smallest a pane may be dragged, in pixels. Below this it stops being usable. */
19
+ const MIN_PANE = 120;
20
+
21
+ /** Breathing room under the layout, if its container does not already provide it. */
22
+ const BOTTOM_GAP = 16;
23
+
24
+ /**
25
+ * The nearest ancestor that scrolls, or null when that is the window.
26
+ *
27
+ * The admin puts its views inside `main.dashboard-main`, which scrolls on its
28
+ * own and sits below a fixed header. Measuring against `window.innerHeight`
29
+ * therefore overshoots by whatever padding that container adds, and the
30
+ * container scrolls instead of the layout fitting - which looks exactly like
31
+ * "it doesn't fill the screen".
32
+ *
33
+ * @param {HTMLElement} el
34
+ * @returns {HTMLElement|null}
35
+ */
36
+ function scrollParentOf(el) {
37
+ for (let node = el.parentElement; node && node !== document.body; node = node.parentElement) {
38
+ const overflow = getComputedStyle(node).overflowY;
39
+ if (overflow === 'auto' || overflow === 'scroll') return node;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * Keep an element stretched to the bottom of the window.
46
+ *
47
+ * Measures where the element actually starts rather than assuming a fixed
48
+ * header height, so it survives a theme with taller chrome, a wrapped
49
+ * breadcrumb, or a notice bar appearing above it.
50
+ *
51
+ * @param {HTMLElement} el
52
+ * @param {{minHeight?: number}} [options]
53
+ * @returns {() => void} stop, for when the view unmounts
54
+ */
55
+ export function fitToViewport(el, {minHeight = 320} = {}) {
56
+ let frame = 0;
57
+
58
+ const apply = () => {
59
+ frame = 0;
60
+ // Measuring after clearing avoids the element's own height feeding back
61
+ // into the calculation as it grows.
62
+ el.style.height = 'auto';
63
+ const top = el.getBoundingClientRect().top;
64
+
65
+ const scroller = scrollParentOf(el);
66
+ let bottom;
67
+ if (scroller) {
68
+ const rect = scroller.getBoundingClientRect();
69
+ // clientHeight excludes a horizontal scrollbar; padding-bottom is
70
+ // inside the client box, so it has to come off separately.
71
+ bottom = rect.top + scroller.clientHeight
72
+ - (parseFloat(getComputedStyle(scroller).paddingBottom) || 0);
73
+ } else {
74
+ bottom = window.innerHeight;
75
+ }
76
+
77
+ // Padding on the containers in between is space we cannot occupy.
78
+ let trailing = 0;
79
+ for (let node = el.parentElement; node && node !== scroller; node = node.parentElement) {
80
+ trailing += parseFloat(getComputedStyle(node).paddingBottom) || 0;
81
+ if (node === document.body) break;
82
+ }
83
+
84
+ // Only add a gap if the containers do not already provide one.
85
+ const gap = Math.max(0, BOTTOM_GAP - trailing);
86
+ const available = bottom - top - trailing - gap;
87
+ el.style.height = `${Math.max(minHeight, Math.round(available))}px`;
88
+ };
89
+
90
+ const schedule = () => {
91
+ if (!frame) frame = requestAnimationFrame(apply);
92
+ };
93
+
94
+ apply();
95
+ window.addEventListener('resize', schedule);
96
+
97
+ // The admin sidebar can collapse, which moves nothing vertically but does
98
+ // change layout; watching the element itself catches anything a resize
99
+ // event does not.
100
+ const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(schedule) : null;
101
+ observer?.observe(document.body);
102
+ const scroller = scrollParentOf(el);
103
+ if (scroller) observer?.observe(scroller);
104
+
105
+ return () => {
106
+ window.removeEventListener('resize', schedule);
107
+ observer?.disconnect();
108
+ if (frame) cancelAnimationFrame(frame);
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Make the dividers in a grid layout draggable.
114
+ *
115
+ * The container is expected to lay its panes out with
116
+ * `grid-template-columns: var(--mail-col-1) <divider> var(--mail-col-2) <divider> 1fr`,
117
+ * and to contain elements matching `selector` sitting in the divider columns.
118
+ * Widths are written back as those two custom properties.
119
+ *
120
+ * @param {HTMLElement} container
121
+ * @param {{selector?: string, widths?: number[], onChange?: (widths: number[]) => void}} [options]
122
+ * @returns {{setWidths: (w: number[]) => void, reset: () => void, destroy: () => void}}
123
+ */
124
+ export function createSplitters(container, {selector = '.mail-splitter', widths, onChange} = {}) {
125
+ const handles = [...container.querySelectorAll(selector)];
126
+ const defaults = [190, 340];
127
+ let current = normalise(widths ?? defaults);
128
+
129
+ /**
130
+ * Clamp widths so neither pane collapses and the reader keeps room.
131
+ *
132
+ * @param {number[]} next
133
+ * @returns {number[]}
134
+ */
135
+ function normalise(next) {
136
+ const available = container.clientWidth;
137
+ const out = [...next];
138
+ out[0] = Math.max(MIN_PANE, Math.round(out[0] ?? defaults[0]));
139
+ out[1] = Math.max(MIN_PANE, Math.round(out[1] ?? defaults[1]));
140
+
141
+ // Before the browser has laid the container out, clientWidth is 0.
142
+ // Clamping against a guessed width would quietly shrink remembered
143
+ // widths on every single load - which is what it did until 0.61.0.
144
+ // The re-clamp on the next frame catches a genuinely too-narrow window.
145
+ if (!available) return out;
146
+ // Always leave the reader at least a pane's worth, whatever the drag
147
+ // asked for - a mail client with no room to read mail is a bad joke.
148
+ const maxFirstTwo = Math.max(MIN_PANE * 2, available - MIN_PANE);
149
+ if (out[0] + out[1] > maxFirstTwo) {
150
+ const overshoot = out[0] + out[1] - maxFirstTwo;
151
+ out[1] = Math.max(MIN_PANE, out[1] - overshoot);
152
+ const stillOver = out[0] + out[1] - maxFirstTwo;
153
+ if (stillOver > 0) out[0] = Math.max(MIN_PANE, out[0] - stillOver);
154
+ }
155
+ return out;
156
+ }
157
+
158
+ /**
159
+ * Push the current widths into the grid.
160
+ *
161
+ * @returns {void}
162
+ */
163
+ function apply() {
164
+ container.style.setProperty('--mail-col-1', `${current[0]}px`);
165
+ container.style.setProperty('--mail-col-2', `${current[1]}px`);
166
+ }
167
+
168
+ /**
169
+ * Begin a drag on one divider.
170
+ *
171
+ * @param {PointerEvent} event
172
+ * @param {number} index
173
+ * @returns {void}
174
+ */
175
+ function startDrag(event, index) {
176
+ event.preventDefault();
177
+ const startX = event.clientX;
178
+ const startWidths = [...current];
179
+ const handle = event.currentTarget;
180
+ handle.setPointerCapture?.(event.pointerId);
181
+ container.classList.add('is-resizing');
182
+
183
+ const move = moveEvent => {
184
+ const delta = moveEvent.clientX - startX;
185
+ const next = [...startWidths];
186
+ next[index] = startWidths[index] + delta;
187
+ current = normalise(next);
188
+ apply();
189
+ };
190
+
191
+ const end = () => {
192
+ handle.removeEventListener('pointermove', move);
193
+ handle.removeEventListener('pointerup', end);
194
+ handle.removeEventListener('pointercancel', end);
195
+ container.classList.remove('is-resizing');
196
+ onChange?.([...current]);
197
+ };
198
+
199
+ handle.addEventListener('pointermove', move);
200
+ handle.addEventListener('pointerup', end);
201
+ handle.addEventListener('pointercancel', end);
202
+ }
203
+
204
+ const listeners = handles.map((handle, index) => {
205
+ const down = event => startDrag(event, index);
206
+ const dbl = () => {
207
+ current = normalise(defaults);
208
+ apply();
209
+ onChange?.([...current]);
210
+ };
211
+ handle.addEventListener('pointerdown', down);
212
+ // Double-click to put it back, which is what people try first.
213
+ handle.addEventListener('dblclick', dbl);
214
+ handle.setAttribute('role', 'separator');
215
+ handle.setAttribute('aria-orientation', 'vertical');
216
+ handle.title = 'Drag to resize · double-click to reset';
217
+ return {handle, down, dbl};
218
+ });
219
+
220
+ apply();
221
+
222
+ // Clamp for real once the container has a measured width, and again
223
+ // whenever the window changes shape.
224
+ const reclamp = () => {
225
+ if (!container.clientWidth) return;
226
+ const clamped = normalise(current);
227
+ if (clamped[0] !== current[0] || clamped[1] !== current[1]) {
228
+ current = clamped;
229
+ apply();
230
+ }
231
+ };
232
+ requestAnimationFrame(reclamp);
233
+ window.addEventListener('resize', reclamp);
234
+
235
+ return {
236
+ setWidths(next) {
237
+ current = normalise(next);
238
+ apply();
239
+ },
240
+ reset() {
241
+ current = normalise(defaults);
242
+ apply();
243
+ onChange?.([...current]);
244
+ },
245
+ destroy() {
246
+ window.removeEventListener('resize', reclamp);
247
+ for (const {handle, down, dbl} of listeners) {
248
+ handle.removeEventListener('pointerdown', down);
249
+ handle.removeEventListener('dblclick', dbl);
250
+ }
251
+ }
252
+ };
253
+ }