autumnnote 1.0.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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +874 -0
  3. package/dist/autumnnote.css +1 -0
  4. package/dist/autumnnote.es.js +5888 -0
  5. package/dist/autumnnote.es.js.map +1 -0
  6. package/dist/autumnnote.umd.js +74 -0
  7. package/dist/autumnnote.umd.js.map +1 -0
  8. package/package.json +55 -0
  9. package/src/js/Context.js +497 -0
  10. package/src/js/core/dom.js +315 -0
  11. package/src/js/core/env.js +25 -0
  12. package/src/js/core/func.js +153 -0
  13. package/src/js/core/key.js +66 -0
  14. package/src/js/core/lists.js +121 -0
  15. package/src/js/core/markdown.js +294 -0
  16. package/src/js/core/range.js +194 -0
  17. package/src/js/core/sanitise.js +78 -0
  18. package/src/js/editing/History.js +205 -0
  19. package/src/js/editing/Style.js +329 -0
  20. package/src/js/editing/Table.js +59 -0
  21. package/src/js/editing/Typing.js +142 -0
  22. package/src/js/index.js +126 -0
  23. package/src/js/module/Buttons.js +300 -0
  24. package/src/js/module/Clipboard.js +460 -0
  25. package/src/js/module/CodeTooltip.js +428 -0
  26. package/src/js/module/Codeview.js +122 -0
  27. package/src/js/module/ContextMenu.js +470 -0
  28. package/src/js/module/Editor.js +528 -0
  29. package/src/js/module/EmojiDialog.js +726 -0
  30. package/src/js/module/FindReplace.js +440 -0
  31. package/src/js/module/Fullscreen.js +80 -0
  32. package/src/js/module/IconDialog.js +620 -0
  33. package/src/js/module/ImageDialog.js +208 -0
  34. package/src/js/module/ImageResizer.js +216 -0
  35. package/src/js/module/ImageTooltip.js +286 -0
  36. package/src/js/module/LinkDialog.js +204 -0
  37. package/src/js/module/LinkTooltip.js +242 -0
  38. package/src/js/module/Placeholder.js +44 -0
  39. package/src/js/module/ShortcutsDialog.js +141 -0
  40. package/src/js/module/Statusbar.js +238 -0
  41. package/src/js/module/TableTooltip.js +568 -0
  42. package/src/js/module/Toolbar.js +562 -0
  43. package/src/js/module/VideoDialog.js +263 -0
  44. package/src/js/module/VideoResizer.js +227 -0
  45. package/src/js/module/VideoTooltip.js +252 -0
  46. package/src/js/renderer.js +107 -0
  47. package/src/js/settings.js +134 -0
  48. package/src/styles/_variables.scss +48 -0
  49. package/src/styles/autumnnote.scss +1740 -0
  50. package/types/index.d.ts +324 -0
@@ -0,0 +1,315 @@
1
+ /**
2
+ * dom.js - DOM manipulation utilities
3
+ * Inspired by Summernote's dom.js — rewritten for vanilla JS without jQuery
4
+ */
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Node type helpers
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export const ELEMENT_NODE = 1;
11
+ export const TEXT_NODE = 3;
12
+
13
+ /** @param {Node} node */
14
+ export const isElement = (node) => node && node.nodeType === ELEMENT_NODE;
15
+ /** @param {Node} node */
16
+ export const isText = (node) => node && node.nodeType === TEXT_NODE;
17
+ /** @param {Node} node */
18
+ export const isVoid = (node) => isElement(node) && /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/i.test(node.nodeName);
19
+ /** @param {Node} node */
20
+ export const isPara = (node) => isElement(node) && /^(p|div|li|h[1-6]|blockquote|td|th|pre)$/i.test(node.nodeName);
21
+ /** @param {Node} node */
22
+ export const isLi = (node) => isElement(node) && /^(li)$/i.test(node.nodeName);
23
+ /** @param {Node} node */
24
+ export const isList = (node) => isElement(node) && /^(ul|ol)$/i.test(node.nodeName);
25
+ /** @param {Node} node */
26
+ export const isTable = (node) => isElement(node) && node.nodeName.toUpperCase() === 'TABLE';
27
+ /** @param {Node} node */
28
+ export const isInline = (node) =>
29
+ isElement(node) &&
30
+ /^(a|abbr|acronym|b|bdo|big|br|button|cite|code|dfn|em|i|img|input|kbd|label|map|object|output|q|s|samp|select|small|span|strong|sub|sup|textarea|time|tt|u|var)$/i.test(node.nodeName);
31
+ /** @param {Node} node */
32
+ export const isEditable = (node) => isElement(node) && node.isContentEditable;
33
+ /** @param {Node} node */
34
+ export const isAnchor = (node) => isElement(node) && node.nodeName.toUpperCase() === 'A';
35
+ /** @param {Node} node */
36
+ export const isImage = (node) => isElement(node) && node.nodeName.toUpperCase() === 'IMG';
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Tree traversal
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Walk up the DOM tree from node, returning the first element matching predicate (inclusive).
44
+ * @param {Node} node
45
+ * @param {(node: Node) => boolean} predicate
46
+ * @param {Node} [stopAt] - stop traversal at this ancestor (exclusive)
47
+ * @returns {Node|null}
48
+ */
49
+ export function closest(node, predicate, stopAt) {
50
+ let cur = node;
51
+ while (cur && cur !== stopAt) {
52
+ if (predicate(cur)) return cur;
53
+ cur = cur.parentNode;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * Returns the nearest ancestor that is a paragraph-like block.
60
+ * @param {Node} node
61
+ * @param {Node} [editable]
62
+ * @returns {Node|null}
63
+ */
64
+ export function closestPara(node, editable) {
65
+ return closest(node, isPara, editable);
66
+ }
67
+
68
+ /**
69
+ * Returns all ancestors of node up to (but not including) stopAt.
70
+ * @param {Node} node
71
+ * @param {Node} [stopAt]
72
+ * @returns {Node[]}
73
+ */
74
+ export function ancestors(node, stopAt) {
75
+ const result = [];
76
+ let cur = node.parentNode;
77
+ while (cur && cur !== stopAt) {
78
+ result.push(cur);
79
+ cur = cur.parentNode;
80
+ }
81
+ return result;
82
+ }
83
+
84
+ /**
85
+ * Returns all children of node as an Array.
86
+ * @param {Node} node
87
+ * @returns {Node[]}
88
+ */
89
+ export function children(node) {
90
+ return Array.from(node.childNodes);
91
+ }
92
+
93
+ /**
94
+ * Returns the previous sibling element (skipping text/comment nodes).
95
+ * @param {Node} node
96
+ * @returns {Element|null}
97
+ */
98
+ export function prevElement(node) {
99
+ let sibling = node.previousSibling;
100
+ while (sibling && !isElement(sibling)) {
101
+ sibling = sibling.previousSibling;
102
+ }
103
+ return sibling;
104
+ }
105
+
106
+ /**
107
+ * Returns the next sibling element.
108
+ * @param {Node} node
109
+ * @returns {Element|null}
110
+ */
111
+ export function nextElement(node) {
112
+ let sibling = node.nextSibling;
113
+ while (sibling && !isElement(sibling)) {
114
+ sibling = sibling.nextSibling;
115
+ }
116
+ return sibling;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // DOM mutation helpers
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Creates an element with optional attributes and children.
125
+ * @param {string} tag
126
+ * @param {Record<string, string>} [attrs]
127
+ * @param {(Node|string)[]} [childNodes]
128
+ * @returns {HTMLElement}
129
+ */
130
+ export function createElement(tag, attrs = {}, childNodes = []) {
131
+ const el = document.createElement(tag);
132
+ for (const [k, v] of Object.entries(attrs)) {
133
+ el.setAttribute(k, v);
134
+ }
135
+ for (const child of childNodes) {
136
+ if (typeof child === 'string') {
137
+ el.appendChild(document.createTextNode(child));
138
+ } else {
139
+ el.appendChild(child);
140
+ }
141
+ }
142
+ return el;
143
+ }
144
+
145
+ /**
146
+ * Removes a node from its parent.
147
+ * @param {Node} node
148
+ */
149
+ export function remove(node) {
150
+ if (node && node.parentNode) {
151
+ node.parentNode.removeChild(node);
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Unwraps a node — replaces the node with its children.
157
+ * @param {Node} node
158
+ */
159
+ export function unwrap(node) {
160
+ const parent = node.parentNode;
161
+ if (!parent) return;
162
+ while (node.firstChild) {
163
+ parent.insertBefore(node.firstChild, node);
164
+ }
165
+ parent.removeChild(node);
166
+ }
167
+
168
+ /**
169
+ * Wraps a node with a wrapper element.
170
+ * @param {Node} node
171
+ * @param {HTMLElement} wrapper
172
+ * @returns {HTMLElement} the wrapper
173
+ */
174
+ export function wrap(node, wrapper) {
175
+ node.parentNode.insertBefore(wrapper, node);
176
+ wrapper.appendChild(node);
177
+ return wrapper;
178
+ }
179
+
180
+ /**
181
+ * Insert node after reference node.
182
+ * @param {Node} newNode
183
+ * @param {Node} refNode
184
+ */
185
+ export function insertAfter(newNode, refNode) {
186
+ if (refNode.nextSibling) {
187
+ refNode.parentNode.insertBefore(newNode, refNode.nextSibling);
188
+ } else {
189
+ refNode.parentNode.appendChild(newNode);
190
+ }
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Content helpers
195
+ // ---------------------------------------------------------------------------
196
+
197
+ /**
198
+ * Returns the text content of a node (safe).
199
+ * @param {Node} node
200
+ * @returns {string}
201
+ */
202
+ export function nodeValue(node) {
203
+ return isText(node) ? node.nodeValue : node.textContent || '';
204
+ }
205
+
206
+ /**
207
+ * Returns true if a node is empty (no visible content).
208
+ * @param {Node} node
209
+ * @returns {boolean}
210
+ */
211
+ export function isEmpty(node) {
212
+ if (isText(node)) return !node.nodeValue;
213
+ if (isVoid(node)) return false;
214
+ return !node.textContent.trim() && !node.querySelector('img, video, hr, table');
215
+ }
216
+
217
+ /**
218
+ * Returns the outerHTML of an element.
219
+ * @param {Element} el
220
+ * @returns {string}
221
+ */
222
+ export function outerHtml(el) {
223
+ return el.outerHTML;
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Selection / editing helpers
228
+ // ---------------------------------------------------------------------------
229
+
230
+ /**
231
+ * Places the caret at the end of a contenteditable element.
232
+ * @param {HTMLElement} el
233
+ */
234
+ export function placeCaret(el) {
235
+ const range = document.createRange();
236
+ range.selectNodeContents(el);
237
+ range.collapse(false);
238
+ const sel = window.getSelection();
239
+ if (sel) {
240
+ sel.removeAllRanges();
241
+ sel.addRange(range);
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Returns true if the node is inside a contenteditable root.
247
+ * @param {Node} node
248
+ * @returns {boolean}
249
+ */
250
+ export function isInsideEditable(node) {
251
+ return !!closest(node, isEditable);
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Event helpers
256
+ // ---------------------------------------------------------------------------
257
+
258
+ /**
259
+ * Adds an event listener and returns a disposer function.
260
+ * @param {EventTarget} target
261
+ * @param {string} type
262
+ * @param {EventListener} handler
263
+ * @param {AddEventListenerOptions} [options]
264
+ * @returns {() => void} disposer
265
+ */
266
+ export function on(target, type, handler, options) {
267
+ target.addEventListener(type, handler, options);
268
+ return () => target.removeEventListener(type, handler, options);
269
+ }
270
+
271
+ /**
272
+ * Installs a keyboard focus trap inside a dialog container.
273
+ * - Tab / Shift+Tab cycles focus within the container's focusable children.
274
+ * - Escape calls `onEscape` and removes the trap listener.
275
+ *
276
+ * Returns a disposer function that removes the listener (call on dialog close).
277
+ *
278
+ * @param {HTMLElement} container - the dialog element to trap focus inside
279
+ * @param {() => void} onEscape - called when Escape is pressed
280
+ * @returns {() => void} disposer
281
+ */
282
+ export function trapFocus(container, onEscape) {
283
+ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
284
+
285
+ const getFocusable = () => Array.from(container.querySelectorAll(FOCUSABLE)).filter(
286
+ (el) => !el.closest('[style*="display: none"]') && !el.closest('[style*="display:none"]'),
287
+ );
288
+
289
+ const handler = (e) => {
290
+ if (e.key === 'Escape') {
291
+ e.stopPropagation();
292
+ onEscape && onEscape();
293
+ return;
294
+ }
295
+ if (e.key !== 'Tab') return;
296
+ const els = getFocusable();
297
+ if (!els.length) return;
298
+ const first = els[0];
299
+ const last = els[els.length - 1];
300
+ if (e.shiftKey) {
301
+ if (document.activeElement === first) {
302
+ e.preventDefault();
303
+ last.focus();
304
+ }
305
+ } else {
306
+ if (document.activeElement === last) {
307
+ e.preventDefault();
308
+ first.focus();
309
+ }
310
+ }
311
+ };
312
+
313
+ document.addEventListener('keydown', handler);
314
+ return () => document.removeEventListener('keydown', handler);
315
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * env.js - Environment / browser detection
3
+ * Inspired by Summernote's env.js
4
+ */
5
+
6
+ const userAgent = navigator.userAgent;
7
+
8
+ export const env = {
9
+ /** True if browser is Chrome */
10
+ isChrome: /Chrome\//.test(userAgent),
11
+ /** True if browser is Firefox */
12
+ isFF: /Firefox\//.test(userAgent),
13
+ /** True if browser is Safari (not Chrome) */
14
+ isSafari: /^((?!chrome|android).)*safari/i.test(userAgent),
15
+ /** True if browser is Edge (Chromium) */
16
+ isEdge: /Edg\//.test(userAgent),
17
+ /** True if running on macOS */
18
+ isMac: /Macintosh/.test(userAgent),
19
+ /** True if running on mobile */
20
+ isMobile: /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent),
21
+ /** True if touch is supported */
22
+ isTouch: 'ontouchstart' in window || navigator.maxTouchPoints > 0,
23
+ /** Modifier key name depending on platform */
24
+ modifierKey: /Macintosh/.test(userAgent) ? 'metaKey' : 'ctrlKey',
25
+ };
@@ -0,0 +1,153 @@
1
+ /**
2
+ * func.js - General utility / functional helpers
3
+ * Inspired by Summernote's func.js
4
+ */
5
+
6
+ /**
7
+ * Clamp a value between min and max.
8
+ * @param {number} val
9
+ * @param {number} min
10
+ * @param {number} max
11
+ * @returns {number}
12
+ */
13
+ export function clamp(val, min, max) {
14
+ return Math.min(Math.max(val, min), max);
15
+ }
16
+
17
+ /**
18
+ * Debounce a function call.
19
+ * @param {Function} fn
20
+ * @param {number} delay - milliseconds
21
+ * @returns {Function}
22
+ */
23
+ export function debounce(fn, delay) {
24
+ let timer;
25
+ return function (...args) {
26
+ clearTimeout(timer);
27
+ timer = setTimeout(() => fn.apply(this, args), delay);
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Throttle a function call.
33
+ * @param {Function} fn
34
+ * @param {number} limit - milliseconds
35
+ * @returns {Function}
36
+ */
37
+ export function throttle(fn, limit) {
38
+ let lastCall = 0;
39
+ return function (...args) {
40
+ const now = Date.now();
41
+ if (now - lastCall >= limit) {
42
+ lastCall = now;
43
+ return fn.apply(this, args);
44
+ }
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Compose multiple functions right-to-left.
50
+ * @param {...Function} fns
51
+ * @returns {Function}
52
+ */
53
+ export function compose(...fns) {
54
+ return (x) => fns.reduceRight((v, f) => f(v), x);
55
+ }
56
+
57
+ /**
58
+ * Identity function.
59
+ * @template T
60
+ * @param {T} x
61
+ * @returns {T}
62
+ */
63
+ export function identity(x) {
64
+ return x;
65
+ }
66
+
67
+ /**
68
+ * Determines if a value is null or undefined.
69
+ * @param {*} val
70
+ * @returns {boolean}
71
+ */
72
+ export function isNil(val) {
73
+ return val === null || val === undefined;
74
+ }
75
+
76
+ /**
77
+ * Determines if a value is a string.
78
+ * @param {*} val
79
+ * @returns {boolean}
80
+ */
81
+ export function isString(val) {
82
+ return typeof val === 'string';
83
+ }
84
+
85
+ /**
86
+ * Determines if a value is a function.
87
+ * @param {*} val
88
+ * @returns {boolean}
89
+ */
90
+ export function isFunction(val) {
91
+ return typeof val === 'function';
92
+ }
93
+
94
+ /**
95
+ * Deep-merge two plain objects. Returns a new object.
96
+ * Arrays are cloned (shallow copy) rather than shared by reference so that
97
+ * mutations to the merged result do not bleed back into the source object
98
+ * (e.g. mutating `instance.options.fontFamilies` should not affect
99
+ * `AutumnNote.defaults.fontFamilies`).
100
+ * @param {object} target
101
+ * @param {object} source
102
+ * @returns {object}
103
+ */
104
+ export function mergeDeep(target, source) {
105
+ // Start with a shallow copy of target; clone any arrays to avoid shared refs
106
+ const output = {};
107
+ for (const key of Object.keys(target)) {
108
+ output[key] = Array.isArray(target[key]) ? [...target[key]] : target[key];
109
+ }
110
+ if (isPlainObject(target) && isPlainObject(source)) {
111
+ for (const key of Object.keys(source)) {
112
+ if (isPlainObject(source[key])) {
113
+ if (!(key in target)) {
114
+ output[key] = mergeDeep({}, source[key]);
115
+ } else {
116
+ output[key] = mergeDeep(target[key], source[key]);
117
+ }
118
+ } else if (Array.isArray(source[key])) {
119
+ output[key] = [...source[key]];
120
+ } else {
121
+ output[key] = source[key];
122
+ }
123
+ }
124
+ }
125
+ return output;
126
+ }
127
+
128
+ /**
129
+ * Checks if value is a plain object.
130
+ * @param {*} val
131
+ * @returns {boolean}
132
+ */
133
+ export function isPlainObject(val) {
134
+ return val !== null && typeof val === 'object' && !Array.isArray(val);
135
+ }
136
+
137
+ /**
138
+ * Convert a DOMRect (or similar bounding object) to a plain object bounding box.
139
+ * Guards against missing/null rect (e.g. in AirMode).
140
+ * @param {DOMRect|null|undefined} rect
141
+ * @returns {{ top: number, left: number, width: number, height: number, bottom: number, right: number }|null}
142
+ */
143
+ export function rect2bnd(rect) {
144
+ if (!rect) return null;
145
+ return {
146
+ top: Math.round(rect.top),
147
+ left: Math.round(rect.left),
148
+ width: Math.round(rect.width),
149
+ height: Math.round(rect.height),
150
+ bottom: Math.round(rect.bottom),
151
+ right: Math.round(rect.right),
152
+ };
153
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * key.js - Keyboard key code constants
3
+ * Inspired by Summernote's key.js
4
+ */
5
+
6
+ export const key = {
7
+ BACKSPACE: 'Backspace',
8
+ TAB: 'Tab',
9
+ ENTER: 'Enter',
10
+ ESCAPE: 'Escape',
11
+ SPACE: ' ',
12
+ PAGE_UP: 'PageUp',
13
+ PAGE_DOWN: 'PageDown',
14
+ END: 'End',
15
+ HOME: 'Home',
16
+ LEFT: 'ArrowLeft',
17
+ UP: 'ArrowUp',
18
+ RIGHT: 'ArrowRight',
19
+ DOWN: 'ArrowDown',
20
+ DELETE: 'Delete',
21
+ // Numbers
22
+ NUM0: '0',
23
+ NUM1: '1',
24
+ NUM2: '2',
25
+ NUM3: '3',
26
+ NUM4: '4',
27
+ NUM5: '5',
28
+ NUM6: '6',
29
+ NUM7: '7',
30
+ NUM8: '8',
31
+ // Letters
32
+ B: 'b',
33
+ E: 'e',
34
+ I: 'i',
35
+ J: 'j',
36
+ K: 'k',
37
+ L: 'l',
38
+ R: 'r',
39
+ S: 's',
40
+ U: 'u',
41
+ V: 'v',
42
+ Y: 'y',
43
+ Z: 'z',
44
+ SLASH: '/',
45
+ PERIOD: '.',
46
+ };
47
+
48
+ /**
49
+ * Returns true if the event matches the given key
50
+ * @param {KeyboardEvent} event
51
+ * @param {string} keyName - one of key.*
52
+ * @returns {boolean}
53
+ */
54
+ export function isKey(event, keyName) {
55
+ return event.key === keyName || event.key === keyName.toUpperCase();
56
+ }
57
+
58
+ /**
59
+ * Returns true if the event is a modifier key press (Ctrl/Cmd + key)
60
+ * @param {KeyboardEvent} event
61
+ * @param {string} keyName
62
+ * @returns {boolean}
63
+ */
64
+ export function isModifier(event, keyName) {
65
+ return (event.ctrlKey || event.metaKey) && isKey(event, keyName);
66
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * lists.js - Array/list utility helpers
3
+ * Inspired by Summernote's lists.js
4
+ */
5
+
6
+ /**
7
+ * Returns the last element of an array.
8
+ * @template T
9
+ * @param {T[]} arr
10
+ * @returns {T|undefined}
11
+ */
12
+ export function last(arr) {
13
+ return arr[arr.length - 1];
14
+ }
15
+
16
+ /**
17
+ * Returns the first element of an array.
18
+ * @template T
19
+ * @param {T[]} arr
20
+ * @returns {T|undefined}
21
+ */
22
+ export function first(arr) {
23
+ return arr[0];
24
+ }
25
+
26
+ /**
27
+ * Returns a new array without the last n items.
28
+ * @template T
29
+ * @param {T[]} arr
30
+ * @param {number} [n=1]
31
+ * @returns {T[]}
32
+ */
33
+ export function initial(arr, n = 1) {
34
+ return arr.slice(0, arr.length - n);
35
+ }
36
+
37
+ /**
38
+ * Returns a new array without the first n items.
39
+ * @template T
40
+ * @param {T[]} arr
41
+ * @param {number} [n=1]
42
+ * @returns {T[]}
43
+ */
44
+ export function tail(arr, n = 1) {
45
+ return arr.slice(n);
46
+ }
47
+
48
+ /**
49
+ * Returns a flattened (one level) array.
50
+ * @template T
51
+ * @param {T[][]} arr
52
+ * @returns {T[]}
53
+ */
54
+ export function flatten(arr) {
55
+ return arr.reduce((acc, val) => acc.concat(val), []);
56
+ }
57
+
58
+ /**
59
+ * Returns unique elements of an array (using Set).
60
+ * @template T
61
+ * @param {T[]} arr
62
+ * @returns {T[]}
63
+ */
64
+ export function unique(arr) {
65
+ return [...new Set(arr)];
66
+ }
67
+
68
+ /**
69
+ * Splits an array into chunks of size n.
70
+ * @template T
71
+ * @param {T[]} arr
72
+ * @param {number} n
73
+ * @returns {T[][]}
74
+ */
75
+ export function chunk(arr, n) {
76
+ const result = [];
77
+ for (let i = 0; i < arr.length; i += n) {
78
+ result.push(arr.slice(i, i + n));
79
+ }
80
+ return result;
81
+ }
82
+
83
+ /**
84
+ * Groups array elements by a key function.
85
+ * @template T
86
+ * @param {T[]} arr
87
+ * @param {(item: T) => string} keyFn
88
+ * @returns {Record<string, T[]>}
89
+ */
90
+ export function groupBy(arr, keyFn) {
91
+ return arr.reduce((groups, item) => {
92
+ const key = keyFn(item);
93
+ if (!groups[key]) {
94
+ groups[key] = [];
95
+ }
96
+ groups[key].push(item);
97
+ return groups;
98
+ }, {});
99
+ }
100
+
101
+ /**
102
+ * Returns true if all elements satisfy the predicate.
103
+ * @template T
104
+ * @param {T[]} arr
105
+ * @param {(item: T) => boolean} predicate
106
+ * @returns {boolean}
107
+ */
108
+ export function all(arr, predicate) {
109
+ return arr.every(predicate);
110
+ }
111
+
112
+ /**
113
+ * Returns true if any element satisfies the predicate.
114
+ * @template T
115
+ * @param {T[]} arr
116
+ * @param {(item: T) => boolean} predicate
117
+ * @returns {boolean}
118
+ */
119
+ export function any(arr, predicate) {
120
+ return arr.some(predicate);
121
+ }