tiny-essentials 1.13.2 β†’ 1.14.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.
@@ -0,0 +1,211 @@
1
+ import { safeTextTrim } from '../basics/text.mjs';
2
+ /**
3
+ * A utility class to manage browser notifications with sound and custom behavior.
4
+ * Useful for triggering system notifications with optional sound, avatar icon, body truncation, and click actions.
5
+ *
6
+ * @class
7
+ */
8
+ class TinyNotifications {
9
+ /** @type {boolean} Whether notifications are currently allowed by the user. */
10
+ #allowed = false;
11
+ /** @type {boolean} Indicates whether the user has already requested permission at least once. */
12
+ #permissionRequested = false;
13
+ /** @type {HTMLAudioElement|null} Audio element to play when a notification is triggered. */
14
+ #audio = null;
15
+ /** @type {number} Maximum number of characters in the notification body. */
16
+ #bodyLimit = 100;
17
+ /** @type {string|null} Default avatar icon URL for notifications. */
18
+ #defaultIcon = null;
19
+ /** @type {(this: Notification, evt: Event) => any} Default handler when a notification is clicked. */
20
+ #defaultOnClick;
21
+ /**
22
+ * Constructs a new instance of TinyNotifications.
23
+ *
24
+ * @param {Object} [settings={}] - Optional settings to initialize the notification manager.
25
+ * @param {string|HTMLAudioElement|null} [settings.audio] - Path or URL to the audio file for notification sounds.
26
+ * @param {string|null} [settings.defaultIcon] - Default icon URL to be used in notifications.
27
+ * @param {number} [settings.bodyLimit=100] - Maximum number of characters allowed in the notification body.
28
+ * @param {(this: Notification, evt: Event) => any} [settings.defaultOnClick] - Default function to execute when a notification is clicked.
29
+ * @throws {TypeError} If any of the parameters are of an invalid type.
30
+ */
31
+ constructor({ audio = null, defaultIcon = null, bodyLimit = 100, defaultOnClick = function (event) {
32
+ event.preventDefault();
33
+ if (window.focus)
34
+ window.focus();
35
+ this.close();
36
+ }, } = {}) {
37
+ if (!(audio instanceof HTMLAudioElement) && typeof audio !== 'string' && audio !== null)
38
+ throw new TypeError('audio must be an instance of HTMLAudioElement or null.');
39
+ if (defaultIcon !== null && typeof defaultIcon !== 'string')
40
+ throw new TypeError('defaultIcon must be a string or null.');
41
+ if (!Number.isFinite(bodyLimit) || bodyLimit < 0)
42
+ throw new TypeError('bodyLimit must be a non-negative number.');
43
+ if (typeof defaultOnClick !== 'function')
44
+ throw new TypeError('defaultOnClick must be a function.');
45
+ this.#audio = typeof audio !== 'string' ? audio : new Audio(audio);
46
+ this.#defaultIcon = defaultIcon;
47
+ this.#bodyLimit = bodyLimit;
48
+ this.#defaultOnClick = defaultOnClick;
49
+ }
50
+ /**
51
+ * Requests permission from the user to show notifications.
52
+ * Updates the internal `#allowed` flag.
53
+ *
54
+ * @returns {Promise<boolean>} Resolves to `true` if permission is granted, otherwise `false`.
55
+ */
56
+ requestPerm() {
57
+ const tinyThis = this;
58
+ return new Promise((resolve, reject) => {
59
+ if (tinyThis.isCompatible()) {
60
+ if (Notification.permission === 'default') {
61
+ Notification.requestPermission()
62
+ .then((permission) => {
63
+ this.#permissionRequested = true;
64
+ tinyThis.#allowed = permission === 'granted';
65
+ resolve(tinyThis.#allowed);
66
+ })
67
+ .catch(reject);
68
+ }
69
+ else {
70
+ this.#permissionRequested = true;
71
+ tinyThis.#allowed = Notification.permission === 'granted';
72
+ resolve(tinyThis.#allowed);
73
+ }
74
+ }
75
+ else {
76
+ this.#permissionRequested = true;
77
+ tinyThis.#allowed = false;
78
+ resolve(false);
79
+ }
80
+ });
81
+ }
82
+ /**
83
+ * Checks if the Notification API is supported by the current browser.
84
+ *
85
+ * @returns {boolean} Returns `true` if notifications are supported, otherwise `false`.
86
+ */
87
+ isCompatible() {
88
+ return 'Notification' in window;
89
+ }
90
+ /**
91
+ * Sends a browser notification with the provided title and configuration.
92
+ * Truncates the body if necessary and plays a sound if configured.
93
+ *
94
+ * @param {string} title - The title of the notification.
95
+ * @param {NotificationOptions & { vibrate?: number[] }} [config={}] - Optional configuration for the notification.
96
+ * @returns {Notification|null} The created `Notification` instance, or `null` if permission is not granted.
97
+ * @throws {TypeError} If the title is not a string or config is not a valid object.
98
+ */
99
+ send(title, config = {}) {
100
+ if (!this.#permissionRequested)
101
+ throw new Error('You must call requestPerm() before sending a notification.');
102
+ if (typeof title !== 'string')
103
+ throw new TypeError('title must be a string.');
104
+ if (typeof config !== 'object' || config === null)
105
+ throw new TypeError('config must be a non-null object.');
106
+ if (!this.#allowed)
107
+ return null;
108
+ const { icon = this.#defaultIcon || undefined, vibrate = [200, 100, 200] } = config;
109
+ const options = { ...config };
110
+ if (typeof icon === 'string')
111
+ options.icon = icon;
112
+ if (Array.isArray(vibrate))
113
+ options.vibrate = vibrate;
114
+ if (typeof options.body === 'string')
115
+ options.body = safeTextTrim(options.body, this.#bodyLimit);
116
+ const notification = new Notification(title, options);
117
+ notification.addEventListener('show', () => {
118
+ if (!(this.#audio instanceof HTMLAudioElement))
119
+ return;
120
+ this.#audio.currentTime = 0;
121
+ this.#audio.play().catch((err) => console.error(err));
122
+ });
123
+ if (typeof this.#defaultOnClick === 'function')
124
+ notification.addEventListener('click', this.#defaultOnClick);
125
+ return notification;
126
+ }
127
+ // === Getters and Setters ===
128
+ /**
129
+ * Whether the requestPerm() method was already called.
130
+ * @returns {boolean}
131
+ */
132
+ wasPermissionRequested() {
133
+ return this.#permissionRequested;
134
+ }
135
+ /**
136
+ * Returns the current permission status.
137
+ * @returns {boolean} `true` if permission was granted, otherwise `false`.
138
+ */
139
+ isAllowed() {
140
+ return this.#allowed;
141
+ }
142
+ /**
143
+ * Gets the current notification audio.
144
+ * @returns {HTMLAudioElement|null} The sound element, or `null` if not set.
145
+ */
146
+ getAudio() {
147
+ return this.#audio;
148
+ }
149
+ /**
150
+ * Sets the audio element used for notification sounds.
151
+ * @param {HTMLAudioElement|string|null} value - A valid `HTMLAudioElement` or `null` to disable sound.
152
+ * @throws {TypeError} If the value is not an `HTMLAudioElement` or `null`.
153
+ */
154
+ setAudio(value) {
155
+ if (!(value instanceof HTMLAudioElement) && typeof value !== 'string' && value !== null)
156
+ throw new TypeError('sound must be an instance of HTMLAudioElement or null.');
157
+ this.#audio = typeof value !== 'string' ? value : new Audio(value);
158
+ }
159
+ /**
160
+ * Gets the maximum length of the notification body text.
161
+ * @returns {number} Number of characters allowed.
162
+ */
163
+ getBodyLimit() {
164
+ return this.#bodyLimit;
165
+ }
166
+ /**
167
+ * Sets the maximum number of characters allowed in the notification body.
168
+ * @param {number} value - A non-negative integer.
169
+ * @throws {TypeError} If the value is not a valid non-negative number.
170
+ */
171
+ setBodyLimit(value) {
172
+ if (!Number.isFinite(value) || value < 0)
173
+ throw new TypeError('bodyLimit must be a non-negative number.');
174
+ this.#bodyLimit = value;
175
+ }
176
+ /**
177
+ * Gets the default avatar icon URL.
178
+ * @returns {string|null} The URL string or `null`.
179
+ */
180
+ getDefaultAvatar() {
181
+ return this.#defaultIcon;
182
+ }
183
+ /**
184
+ * Sets the default avatar icon URL.
185
+ * @param {string|null} value - A string URL or `null` to disable default icon.
186
+ * @throws {TypeError} If the value is not a string or `null`.
187
+ */
188
+ setDefaultAvatar(value) {
189
+ if (!(typeof value === 'string' || value === null))
190
+ throw new TypeError('defaultIcon must be a string or null.');
191
+ this.#defaultIcon = value;
192
+ }
193
+ /**
194
+ * Gets the default click event handler for notifications.
195
+ * @returns {(this: Notification, evt: Event) => any} The current click handler function.
196
+ */
197
+ getDefaultOnClick() {
198
+ return this.#defaultOnClick;
199
+ }
200
+ /**
201
+ * Sets the default click event handler for notifications.
202
+ * @param {(this: Notification, evt: Event) => any} value - A function to handle the notification click event.
203
+ * @throws {TypeError} If the value is not a function.
204
+ */
205
+ setDefaultOnClick(value) {
206
+ if (typeof value !== 'function')
207
+ throw new TypeError('defaultOnClick must be a function.');
208
+ this.#defaultOnClick = value;
209
+ }
210
+ }
211
+ export default TinyNotifications;
package/docs/v1/README.md CHANGED
@@ -29,9 +29,10 @@ This folder contains the core scripts we have worked on so far. Each file is a m
29
29
  - πŸ”” **[TinyNotifyCenter](./libs/TinyNotifyCenter.md)** β€” A dynamic notification center class to display, manage, and interact with notifications, supporting avatars, clickable items, HTML/text modes, and clean UI controls.
30
30
  - 🍞 **[TinyToastNotify](./libs/TinyToastNotify.md)** β€” A lightweight toast notification system supporting positioning, timing customization, avatars, click actions, and fade-out animations.
31
31
  - πŸ“₯ **[TinyDragDropDetector](./libs/TinyDragDropDetector.md)** β€” A lightweight drag-and-drop detector for files, handling the full drag lifecycle (`enter`, `over`, `leave`, `drop`) with CSS hover management and safe event handling on any DOM element or the full page.
32
- * πŸ“‚ **[TinyUploadClicker](./libs/TinyUploadClicker.md)** β€” A minimal utility to bind any clickable element to a hidden file input, offering full control over styling, behavior, and upload event hooks.
33
- * 🧲 **[TinyDragger](./libs/TinyDragger.md)** β€” A flexible drag-and-drop manager with collision detection, jail constraints, vibration feedback, visual proxies, revert-on-drop, and full custom event support.
34
- * πŸ•’ **[TinyDomReadyManager](./libs/TinyDomReadyManager.md)** β€” A readiness manager for DOM and async conditions, supporting prioritized callbacks, custom filters, and event-based or promise-based bootstrapping.
32
+ - πŸ“‚ **[TinyUploadClicker](./libs/TinyUploadClicker.md)** β€” A minimal utility to bind any clickable element to a hidden file input, offering full control over styling, behavior, and upload event hooks.
33
+ - 🧲 **[TinyDragger](./libs/TinyDragger.md)** β€” A flexible drag-and-drop manager with collision detection, jail constraints, vibration feedback, visual proxies, revert-on-drop, and full custom event support.
34
+ - πŸ•’ **[TinyDomReadyManager](./libs/TinyDomReadyManager.md)** β€” A readiness manager for DOM and async conditions, supporting prioritized callbacks, custom filters, and event-based or promise-based bootstrapping.
35
+ - πŸ“£ **[TinyNotifications](./libs/TinyNotifications.md)** β€” A browser notification utility with sound support, permission management, truncation logic, default icons, and enforced validation to ensure safe and predictable usage.
35
36
 
36
37
  ### 3. **`fileManager/`**
37
38
  * πŸ“ **[main](./fileManager/main.md)** β€” A Node.js file/directory utility module with support for JSON, backups, renaming, size analysis, and more.
@@ -1,16 +1,18 @@
1
1
  # πŸ“¦ array.mjs
2
2
 
3
- A tiny utility for shuffling arrays using the good old Fisher–Yates algorithm. Simple, efficient, and perfectly random (as far as JavaScript's `Math.random()` allows, anyway).
3
+ A minimal and handy module offering a couple of focused utilities for working with arrays β€” from shuffling items randomly to generating smart sort functions for object arrays. Ideal for quick scripting or modular use in larger projects.
4
4
 
5
- ## ✨ Features
5
+ ---
6
+
7
+ ## `shuffleArray(items: string[]): string[]`
6
8
 
7
- - πŸ“š Uses the classic **Fisher–Yates** algorithm
8
- - πŸ”„ Shuffles arrays **in-place**
9
- - 🎯 Guarantees **uniform distribution** of permutations
10
- - πŸ” Well-documented and easy to read
11
- - πŸ§ͺ No dependencies β€” just plug and play
9
+ πŸ”„ A tiny utility for shuffling arrays using the good old Fisher–Yates algorithm. Simple, efficient, and perfectly random (as far as JavaScript's `Math.random()` allows, anyway).
12
10
 
13
- ## πŸš€ Usage
11
+ - **items** β€” An array of strings to shuffle.
12
+ - **Returns** β€” The same array instance, but now shuffled.
13
+
14
+
15
+ ### πŸš€ Usage
14
16
 
15
17
  ```js
16
18
  import { shuffleArray } from './array.mjs';
@@ -23,54 +25,29 @@ console.log(fruits); // ['banana', 'cherry', 'apple', 'date'] (order will vary)
23
25
 
24
26
  > Note: The original array is shuffled in place. If you want to preserve the original order, make a copy before shuffling.
25
27
 
26
- ## 🧠 How It Works
27
-
28
- This function implements the **Fisher–Yates shuffle** (also known as the Knuth shuffle), which runs in linear time and guarantees uniform randomness:
29
-
30
- ```js
31
- while (currentIndex !== 0) {
32
- randomIndex = Math.floor(Math.random() * currentIndex);
33
- currentIndex--;
34
- [items[currentIndex], items[randomIndex]] = [items[randomIndex], items[currentIndex]];
35
- }
36
- ```
37
-
38
28
  You can find the original discussion here:
39
29
  πŸ”— https://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array
40
30
 
41
- ## πŸ“ API
42
-
43
- ### `shuffleArray(items: string[]): string[]`
44
-
45
- - **items** β€” An array of strings to shuffle.
46
- - **Returns** β€” The same array instance, but now shuffled.
47
-
48
31
  ---
49
32
 
50
- # πŸ“¦ `arraySortPositions`
33
+ ## πŸ“¦ `arraySortPositions`
51
34
 
52
35
  πŸ”§ **Generates a comparator function** to sort an array of objects by a specified key, with optional reverse order.
53
36
 
54
- ---
55
-
56
- ## πŸ“Œ Function Signature
37
+ ### πŸ“Œ Function Signature
57
38
 
58
39
  ```js
59
40
  arraySortPositions(item: string, isReverse?: boolean): (a: Object<string|number, *>, b: Object<string|number, *>) => number
60
41
  ```
61
42
 
62
- ---
63
-
64
- ## 🧠 Parameters
43
+ ### 🧠 Parameters
65
44
 
66
45
  | Name | Type | Default | Description |
67
46
  | ----------- | --------- | ------- | ---------------------------------------------------------- |
68
47
  | `item` | `string` | β€” | πŸ”‘ The key to sort the objects by. |
69
48
  | `isReverse` | `boolean` | `false` | πŸ”„ If `true`, the sorting will be in **descending** order. |
70
49
 
71
- ---
72
-
73
- ## 🎯 Returns
50
+ ### 🎯 Returns
74
51
 
75
52
  🧩 A **comparator function** compatible with `Array.prototype.sort()`:
76
53
 
@@ -80,9 +57,7 @@ arraySortPositions(item: string, isReverse?: boolean): (a: Object<string|number,
80
57
 
81
58
  It compares two objects based on the specified `item` key.
82
59
 
83
- ---
84
-
85
- ## πŸ’‘ Examples
60
+ ### πŸ’‘ Examples
86
61
 
87
62
  ```js
88
63
  const arr = [{ pos: 2 }, { pos: 1 }, { pos: 3 }];
@@ -96,9 +71,7 @@ arr.sort(arraySortPositions('pos', true));
96
71
  // πŸ”½ Descending: [{ pos: 3 }, { pos: 2 }, { pos: 1 }]
97
72
  ```
98
73
 
99
- ---
100
-
101
- ## πŸ› οΈ Use Case
74
+ ### πŸ› οΈ Use Case
102
75
 
103
76
  Great for situations where you need to **dynamically sort objects** by one of their keys, such as:
104
77
 
@@ -11,16 +11,12 @@ It compares the bounding rectangles of both elements:
11
11
  * βœ… Checks if **rect1** is NOT entirely to the left, right, above, or below **rect2**.
12
12
  * βœ… If none of these are true, then the elements are overlapping.
13
13
 
14
- ---
15
-
16
14
  #### 🧠 Syntax
17
15
 
18
16
  ```javascript
19
17
  areHtmlElsColliding(elem1, elem2);
20
18
  ```
21
19
 
22
- ---
23
-
24
20
  #### 🎯 Parameters
25
21
 
26
22
  | Parameter | Type | Description |
@@ -28,16 +24,12 @@ areHtmlElsColliding(elem1, elem2);
28
24
  | `elem1` | `Element` | The first DOM element. |
29
25
  | `elem2` | `Element` | The second DOM element. |
30
26
 
31
- ---
32
-
33
27
  #### πŸ” Return
34
28
 
35
29
  | Type | Description |
36
30
  | --------- | ------------------------------------------------------------------ |
37
31
  | `boolean` | βœ… `true` if elements are colliding. <br>❌ `false` if they are not. |
38
32
 
39
- ---
40
-
41
33
  #### πŸ“¦ Example
42
34
 
43
35
  ```javascript
@@ -51,8 +43,6 @@ if (areHtmlElsColliding(box1, box2)) {
51
43
  }
52
44
  ```
53
45
 
54
- ---
55
-
56
46
  #### 🚧 Limitations
57
47
 
58
48
  * Only works with **axis-aligned elements** (rectangular shapes).
@@ -60,6 +50,90 @@ if (areHtmlElsColliding(box1, box2)) {
60
50
 
61
51
  ---
62
52
 
53
+ ### πŸ“– `readBase64Blob(file: File, isDataUrl?: boolean | string): Promise<string>`
54
+
55
+ Reads a file and returns its Base64 content using the FileReader API, with optional formatting as a full Data URL.
56
+
57
+ #### πŸ“₯ Parameters
58
+
59
+ * `file` *(File)*: The file object selected by the user (e.g., from an `<input type="file">` element).
60
+ * `isDataUrl` *(boolean | string, optional)*:
61
+
62
+ * If `false` *(default)*: returns only the Base64 portion.
63
+ * If `true`: returns the original Data URL string from `FileReader`.
64
+ * If a string: treated as a custom MIME type for building a new Data URL.
65
+
66
+ #### πŸ“€ Returns
67
+
68
+ * `Promise<string>`: Resolves with either the Base64 string or a complete Data URL, depending on `isDataUrl`.
69
+
70
+ #### ⚠️ Throws
71
+
72
+ * `TypeError` if:
73
+
74
+ * The result is not a string.
75
+ * The `isDataUrl` argument is not a boolean or a string.
76
+ * `Error` if:
77
+
78
+ * The string is not a valid Base64 or Data URL format.
79
+ * The MIME string format is invalid.
80
+ * `DOMException` if the file cannot be read by `FileReader`.
81
+
82
+ #### πŸ§ͺ Example
83
+
84
+ ```js
85
+ const input = document.querySelector('input[type="file"]');
86
+ input.addEventListener('change', async () => {
87
+ try {
88
+ const base64 = await readBase64Blob(input.files[0], false);
89
+ console.log(base64); // Logs only the Base64 string
90
+ } catch (err) {
91
+ console.error('Error reading file:', err.message);
92
+ }
93
+ });
94
+ ```
95
+
96
+ ---
97
+
98
+ ### πŸ“– `readFileBlob(file: File, method: 'readAsArrayBuffer' | 'readAsDataURL' | 'readAsText' | 'readAsBinaryString'): Promise<any>`
99
+
100
+ Reads the contents of a file using a specified FileReader method.
101
+
102
+ #### πŸ“₯ Parameters
103
+
104
+ * `file` *(File)*: The file object selected by the user (e.g., from an `<input type="file">` element).
105
+ * `method` *(string)*: The FileReader method to use:
106
+
107
+ * `'readAsArrayBuffer'` β€” for binary buffers
108
+ * `'readAsDataURL'` β€” for Base64 encoded data URLs
109
+ * `'readAsText'` β€” for plain text
110
+ * `'readAsBinaryString'` β€” for legacy binary string output
111
+
112
+ #### πŸ“€ Returns
113
+
114
+ * `Promise<any>`: Resolves with the file content, depending on the method used.
115
+
116
+ #### ⚠️ Throws
117
+
118
+ * `Error` if an unexpected error occurs while resolving the result.
119
+ * `DOMException` if `FileReader` encounters a failure during the read process.
120
+
121
+ #### πŸ§ͺ Example
122
+
123
+ ```js
124
+ const input = document.querySelector('input[type="file"]');
125
+ input.addEventListener('change', async () => {
126
+ try {
127
+ const text = await readFileBlob(input.files[0], 'readAsText');
128
+ console.log(text); // Logs the file content as plain text
129
+ } catch (err) {
130
+ console.error('Error reading file:', err.message);
131
+ }
132
+ });
133
+ ```
134
+
135
+ ---
136
+
63
137
  ### πŸ“– `readJsonBlob(file: File): Promise<any>`
64
138
 
65
139
  Reads and parses a JSON file using the [`FileReader`](https://developer.mozilla.org/en-US/docs/Web/API/FileReader) API.
@@ -250,17 +324,14 @@ Automatically toggles CSS classes on a given element based on the browser window
250
324
 
251
325
  Perfect for UI states like dimming, pausing animations, or showing "away" statuses.
252
326
 
253
- ---
254
-
255
327
  #### 🧠 Features
256
328
 
257
329
  * βœ… Adds or removes custom CSS classes depending on page visibility or focus
258
330
  * βœ… Supports modern and legacy browsers (including IE9)
259
331
  * βœ… Automatically dispatches an initial state check on load
332
+ * βœ… Allows custom **callbacks** for visibility changes (`onVisible`, `onHidden`)
260
333
  * βœ… Returns a cleanup function to remove all listeners
261
334
 
262
- ---
263
-
264
335
  #### πŸ§ͺ Usage
265
336
 
266
337
  ```js
@@ -270,14 +341,14 @@ const uninstall = installWindowHiddenScript({
270
341
  element: document.getElementById('app'),
271
342
  hiddenClass: 'is-hidden',
272
343
  visibleClass: 'is-visible',
344
+ onVisible: () => console.log('Window is now visible'),
345
+ onHidden: () => console.log('Window is now hidden'),
273
346
  });
274
347
 
275
348
  // To remove all listeners later
276
349
  uninstall();
277
350
  ```
278
351
 
279
- ---
280
-
281
352
  #### βš™οΈ Options
282
353
 
283
354
  | Option | Type | Default | Description |
@@ -285,8 +356,8 @@ uninstall();
285
356
  | `element` | `HTMLElement` | `document.body` | The element to which the visibility classes will be applied |
286
357
  | `hiddenClass` | `string` | `'windowHidden'` | Class name to apply when the window is **not visible or blurred** |
287
358
  | `visibleClass` | `string` | `'windowVisible'` | Class name to apply when the window is **visible or focused** |
288
-
289
- ---
359
+ | `onVisible` | `() => void` | `undefined` | Optional callback fired when the window becomes visible |
360
+ | `onHidden` | `() => void` | `undefined` | Optional callback fired when the window becomes hidden |
290
361
 
291
362
  #### πŸ”„ Return Value
292
363
 
@@ -299,8 +370,6 @@ Returns a function that, when called, will:
299
370
  * 🧹 Remove all attached event listeners
300
371
  * ❌ Remove both visibility classes from the target element
301
372
 
302
- ---
303
-
304
373
  #### 🚦 Events Supported
305
374
 
306
375
  The script handles multiple events depending on browser support:
@@ -310,13 +379,9 @@ The script handles multiple events depending on browser support:
310
379
  * `pageshow`, `pagehide`
311
380
  * IE fallback: `onfocusin`, `onfocusout`
312
381
 
313
- ---
314
-
315
382
  #### πŸ” Initial Trigger
316
383
 
317
- Immediately after installation, the script simulates a `focus` or `blur` event based on the current visibility state to **ensure the classes are applied from the start**.
318
-
319
- ---
384
+ Immediately after installation, the script simulates a `focus` or `blur` event based on the current visibility state to **ensure the classes and callbacks are applied from the start**.
320
385
 
321
386
  #### 🧯 Uninstalling
322
387
 
@@ -327,8 +392,6 @@ const stopWatching = installWindowHiddenScript(...);
327
392
  stopWatching(); // later
328
393
  ```
329
394
 
330
- ---
331
-
332
395
  #### 🎨 CSS Example
333
396
 
334
397
  ```css
@@ -43,32 +43,24 @@ toTitleCaseLowerFirst('hello world'); // β†’ "hello World"
43
43
 
44
44
  Enables a keyboard shortcut (`Ctrl + Alt + [key]`) that toggles a CSS class on the `<body>` element. Useful for marking or highlighting AI-generated content dynamically.
45
45
 
46
- ---
47
-
48
46
  ### πŸ”€ Syntax
49
47
 
50
48
  ```js
51
49
  addAiMarkerShortcut(key)
52
50
  ```
53
51
 
54
- ---
55
-
56
52
  ### 🧾 Parameters
57
53
 
58
54
  | Name | Type | Default | Description |
59
55
  | ----- | -------- | ------- | ---------------------------------------------------------------------------- |
60
56
  | `key` | `string` | `'a'` | The character key to use in combination with `Ctrl + Alt`. Case-insensitive. |
61
57
 
62
- ---
63
-
64
58
  ### βš™οΈ Behavior
65
59
 
66
60
  * ⌨️ When the user presses `Ctrl + Alt + [key]`, the function toggles the CSS class `detect-made-by-ai` on the `<body>` element.
67
61
  * 🧠 The shortcut only works in environments where the DOM is available (e.g., browsers).
68
62
  * 🚫 If `document.body` is not available when the shortcut is used (e.g., if the DOM hasn't finished loading), a warning is logged and nothing happens.
69
63
 
70
- ---
71
-
72
64
  ### ❗ Error Handling
73
65
 
74
66
  Two types of errors are handled:
@@ -85,8 +77,6 @@ Two types of errors are handled:
85
77
  [AiMarkerShortcut] <body> element not found. Cannot toggle class. Ensure the DOM is fully loaded when using the shortcut.
86
78
  ```
87
79
 
88
- ---
89
-
90
80
  ### πŸ§ͺ Example
91
81
 
92
82
  ```js
@@ -94,8 +84,6 @@ addAiMarkerShortcut(); // Uses default key 'a'
94
84
  // Pressing Ctrl + Alt + A toggles the class "detect-made-by-ai" on <body>
95
85
  ```
96
86
 
97
- ---
98
-
99
87
  ### 🎨 CSS Integration Example
100
88
 
101
89
  Define the class in your stylesheet to make the toggle visually meaningful:
@@ -107,8 +95,6 @@ body.detect-made-by-ai .ai-content {
107
95
  }
108
96
  ```
109
97
 
110
- ---
111
-
112
98
  ### πŸ’‘ Tip
113
99
 
114
100
  To avoid the `<body>` warning, make sure you only call the function after the DOM is ready:
@@ -127,3 +113,47 @@ You can use a pre-built CSS template for the `detect-made-by-ai` class, availabl
127
113
  * `/dist/v1/css/aiMarker.css` – The non-minified version for easier readability and customization.
128
114
 
129
115
  Simply include the appropriate file in your project to style the elements marked with the `detect-made-by-ai` class.
116
+
117
+ ---
118
+
119
+ ## 🎯 `safeTextTrim(text, limit, safeCutZone = 0.6)`
120
+
121
+ Trims a text string to a specified character limit, attempting to avoid cutting words in half. If a space is found before the limit and it’s not too far from the limit (at least a fraction controlled by `safeCutZone`), the cut is made at that space; otherwise, the text is hard-cut at the limit. If the input text is shorter than or equal to the limit, it is returned unchanged.
122
+
123
+ ### πŸ”€ Syntax
124
+
125
+ ```js
126
+ safeTextTrim(text, limit, safeCutZone = 0.6)
127
+ ```
128
+
129
+ ### 🧾 Parameters
130
+
131
+ | Name | Type | Default | Description |
132
+ | ------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------- |
133
+ | `text` | `string` | β€” | The input text to be trimmed. Must be a string. |
134
+ | `limit` | `number` | β€” | The maximum number of characters allowed. Must be a positive integer. |
135
+ | `safeCutZone` | `number` | `0.6` | A decimal between 0 and 1 representing the minimal acceptable position (fraction of `limit`) to cut at a space. |
136
+
137
+ ### βš™οΈ Behavior
138
+
139
+ * If `text` length is less than or equal to `limit`, returns `text` unchanged.
140
+ * Attempts to cut at the last space character before `limit` but only if it’s within the `safeCutZone` (e.g., at least 60% of the `limit`).
141
+ * If no suitable space is found within the zone, the text is cut strictly at the `limit`.
142
+ * The resulting trimmed text ends with an ellipsis (`"..."`) if it was cut.
143
+ * Leading and trailing whitespace on input is trimmed before processing.
144
+
145
+ ### ❗ Error Handling
146
+
147
+ Throws a `TypeError` in these cases:
148
+
149
+ * If `text` is not a string.
150
+ * If `limit` is not a positive integer.
151
+ * If `safeCutZone` is not a number between 0 and 1 (inclusive).
152
+
153
+ ### πŸ§ͺ Example
154
+
155
+ ```js
156
+ const longText = "This is a sample sentence that will be trimmed properly.";
157
+ console.log(safeTextTrim(longText, 30));
158
+ // Output: "This is a sample sentence that..."
159
+ ```