lascii 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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.0] - 2026-05-27
9
+
10
+ ### Added
11
+
12
+ - Initial public release of `lascii`.
13
+ - `LasciiTextEffect` — scramble/reveal text with optional phrase looping (`|:|` separator).
14
+ - `LasciiImageEffect` — ASCII canvas animation with staggered cell reveal and image fade-in.
15
+ - DOM adapter with `data-lascii-text` and `data-lascii-image` attributes.
16
+ - Auto-initialization on `DOMContentLoaded` when importing `lascii` or `lascii/dom`.
17
+ - Subpath exports: `lascii/dom`, `lascii/core/text`, `lascii/core/image`.
18
+ - TypeScript declaration files for all public entry points.
19
+ - Documentation: README, API reference, contributing and security policies.
20
+
21
+ [1.0.0]: https://github.com/whosramoss/lascii/releases/tag/v1.0.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lascii
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ <h1>
2
+ <p align="center">
3
+ <img src="https://lascii.whosramoss.com/icons/android-chrome-512x512.png" alt="logo" width="128">
4
+ <br>LASCII
5
+ </p>
6
+ </h1>
7
+
8
+ <p align="center">
9
+ Lightweight ASCII animation effects for the web. Transform images and text into living character art.
10
+ <br /> <br />
11
+ <a href="#how-to-install">Install</a>
12
+ ·
13
+ <a href="#usage">Usage</a>
14
+ ·
15
+ <a href="#effects">Effects</a>
16
+ </p>
17
+
18
+ <p align="center">
19
+ <a href="https://lascii.whosramoss.com">Live demo</a>
20
+ </p>
21
+
22
+ ## How to install
23
+
24
+ ```bash
25
+ npm install lascii
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ Add `data-lascii-text` or `data-lascii-image` attributes to your elements and import the module:
31
+
32
+ ```html
33
+ <script type="module">
34
+ import "lascii";
35
+ </script>
36
+
37
+ <p data-lascii-text>Hello World</p>
38
+ <p data-lascii-text>First|:|Second|:|Third</p>
39
+
40
+ <div style="position: relative; aspect-ratio: 4/5; overflow: hidden;">
41
+ <img data-lascii-image src="photo.jpg" alt="photo" />
42
+ </div>
43
+ ```
44
+
45
+ The effect auto-initializes on DOM ready. No configuration needed.
46
+
47
+ ## Effects
48
+
49
+ ### Text effect
50
+
51
+ | Feature | How |
52
+ | ------------- | ---------------------------------------------------- |
53
+ | Single reveal | `<p data-lascii-text>Hello</p>` |
54
+ | Loop phrases | Separate with `\|:\|` — `First\|:\|Second\|:\|Third` |
55
+
56
+ ### Image effect
57
+
58
+ Wrap an `<img>` with `data-lascii-image` inside a positioned container with `overflow: hidden`. The effect samples the image, renders an ASCII canvas animation, then fades to the original.
59
+
60
+ ### Configuration
61
+
62
+ By default, `import "lascii"` runs `init()` on DOM ready and uses built-in defaults. To customize behavior, import the effect classes, pass an options object to the constructor (merged over `DEFAULTS`), and wire elements yourself.
63
+
64
+ See the **[API reference](./docs/API.md)** for all options, exports, and TypeScript types.
65
+
66
+ ### Manual setup
67
+
68
+ ```js
69
+ import LasciiTextEffect from "lascii/core/text";
70
+ import LasciiImageEffect from "lascii/core/image";
71
+
72
+ document.querySelectorAll("[data-lascii-image]").forEach((img, index) => {
73
+ new LasciiImageEffect(img, index, { SCRAMBLE_COUNT: 20 });
74
+ });
75
+
76
+ new LasciiTextEffect(document.querySelector(".headline"), {
77
+ phraseDelay: 1200,
78
+ revealOrigin: LasciiTextEffect.RevealOrigin.MIDDLE,
79
+ });
80
+ ```
81
+
82
+ For auto-init without importing the main entry (side effects), use subpath imports from `lascii/core/*` and call `LasciiTextEffect.init()` / `LasciiImageEffect.init()` — details in [API](./docs/API.md).
83
+
84
+ ## TypeScript
85
+
86
+ Declaration files ship with the package. No separate `@types` package required.
87
+
88
+ ```ts
89
+ import { LasciiTextEffect, LasciiImageEffect } from "lascii";
90
+ ```
package/docs/API.md ADDED
@@ -0,0 +1,250 @@
1
+ # API Reference
2
+
3
+ ## Package entry points
4
+
5
+ | Import | Description |
6
+ | ------ | ----------- |
7
+ | `lascii` | Main entry. Auto-initializes on `DOMContentLoaded` via `autoInitDom()`. |
8
+ | `lascii/dom` | Same exports as main, but scoped to the DOM adapter (also auto-inits on import). |
9
+ | `lascii/core/text` | `LasciiTextEffect` only (no auto-init). |
10
+ | `lascii/core/image` | `LasciiImageEffect` only (no auto-init). |
11
+
12
+ Use `lascii/core/*` when you want full control and no side effects on import.
13
+
14
+ ---
15
+
16
+ ## `lascii` exports
17
+
18
+ ```ts
19
+ import lascii, {
20
+ LasciiTextEffect,
21
+ LasciiImageEffect,
22
+ init,
23
+ autoInitDom,
24
+ } from "lascii";
25
+ ```
26
+
27
+ | Export | Type | Description |
28
+ | ------ | ---- | ----------- |
29
+ | `LasciiTextEffect` | `class` | Scramble/reveal text animation. |
30
+ | `LasciiImageEffect` | `class` | ASCII canvas reveal for images. |
31
+ | `init` | `function` | Scans the DOM and starts effects (`initDom`). |
32
+ | `autoInitDom` | `function` | Registers `init` on `DOMContentLoaded`, or runs immediately if the document is ready. |
33
+ | `default` | `object` | `{ LasciiTextEffect, LasciiImageEffect, init, autoInitDom }`. |
34
+
35
+ ### `init()` / `autoInitDom()`
36
+
37
+ `init()` (alias of `initDom`) runs:
38
+
39
+ - `LasciiImageEffect.init("[data-lascii-image]")`
40
+ - `LasciiTextEffect.init("[data-lascii-text]")`
41
+
42
+ `autoInitDom()` calls `init()` when the document is ready.
43
+
44
+ To avoid auto-init on import, use subpath imports:
45
+
46
+ ```js
47
+ import { LasciiTextEffect } from "lascii/core/text";
48
+ import { LasciiImageEffect } from "lascii/core/image";
49
+ ```
50
+
51
+ ---
52
+
53
+ ## Data attributes
54
+
55
+ | Attribute | Element | Behavior |
56
+ | --------- | ------- | -------- |
57
+ | `data-lascii-text` | Any text container | Reads `textContent`, runs text scramble effect. |
58
+ | `data-lascii-image` | `<img>` | Samples image, ASCII animation, then fades to original. |
59
+
60
+ ### Text: phrase separator
61
+
62
+ Multiple phrases in one element are separated by `|:|` (configurable via `separator`):
63
+
64
+ ```html
65
+ <p data-lascii-text>First|:|Second|:|Third</p>
66
+ ```
67
+
68
+ When the separator is present, phrases loop with `phraseDelay` between transitions.
69
+
70
+ ### Image: layout requirements
71
+
72
+ Place the image inside a **positioned** wrapper with **overflow hidden** and a defined aspect ratio (e.g. `aspect-ratio: 4/5`):
73
+
74
+ ```html
75
+ <div style="position: relative; aspect-ratio: 4/5; overflow: hidden;">
76
+ <img data-lascii-image src="photo.jpg" alt="photo" />
77
+ </div>
78
+ ```
79
+
80
+ Cross-origin images need CORS headers on the image server (`crossOrigin = "anonymous"` is set internally).
81
+
82
+ ---
83
+
84
+ ## `LasciiTextEffect`
85
+
86
+ ```js
87
+ import { LasciiTextEffect } from "lascii";
88
+ // or
89
+ import LasciiTextEffect from "lascii/core/text";
90
+ ```
91
+
92
+ ### Constructor
93
+
94
+ ```js
95
+ new LasciiTextEffect(element, options?)
96
+ ```
97
+
98
+ - **element** — DOM node whose `textContent` is the source string.
99
+ - **options** — Partial override of `LasciiTextEffect.DEFAULTS`.
100
+
101
+ On construction, the element’s text is cleared and the animation starts.
102
+
103
+ ### Static members
104
+
105
+ #### `LasciiTextEffect.RevealOrigin`
106
+
107
+ | Key | Value | Effect |
108
+ | --- | ----- | ------ |
109
+ | `START` | `"start"` | Reveal progresses from the start of the string. |
110
+ | `MIDDLE` | `"middle"` | Reveal radiates from the center outward. |
111
+
112
+ #### `LasciiTextEffect.DEFAULTS`
113
+
114
+ | Option | Type | Default | Description |
115
+ | ------ | ---- | ------- | ----------- |
116
+ | `introChars` | `string` | `"█▓▒░x92"` | Character sequence shown at the start of each cell’s scramble. |
117
+ | `introPhaseFrames` | `number` | `10` | Frames to step through `introChars` before random chars. |
118
+ | `chars` | `string` | `"!<>-_\\/[]{}—=+*^?#________"` | Pool of random scramble characters. |
119
+ | `frameStartMax` | `number` | `40` | Max frames before a character begins scrambling (spread along string). |
120
+ | `frameEndMax` | `number` | `40` | Random extra scramble length cap per character. |
121
+ | `randomCharChance` | `number` | `0.28` | Probability of picking a new random char each frame. |
122
+ | `phraseDelay` | `number` | `800` | Ms between phrases when looping (`|:|`). |
123
+ | `separator` | `string` | `"\|:|"` | Delimiter between phrases in `textContent`. |
124
+ | `revealOrigin` | `string` | `"start"` | `"start"` or `"middle"` (`RevealOrigin`). |
125
+
126
+ Scramble characters are rendered in `<span class="dud">` — style `.dud` in your CSS if needed.
127
+
128
+ ### Instance methods
129
+
130
+ | Method | Returns | Description |
131
+ | ------ | ------- | ----------- |
132
+ | `setText(newText)` | `Promise<void>` | Animates from current text to `newText`. Resolves when complete (3s safety timeout). |
133
+
134
+ ### Static methods
135
+
136
+ ```js
137
+ LasciiTextEffect.init(selector = "[data-lascii-text]")
138
+ ```
139
+
140
+ Creates one `LasciiTextEffect` per matching element.
141
+
142
+ ### Example
143
+
144
+ ```js
145
+ const effect = new LasciiTextEffect(document.querySelector(".headline"), {
146
+ phraseDelay: 1200,
147
+ revealOrigin: LasciiTextEffect.RevealOrigin.MIDDLE,
148
+ });
149
+
150
+ await effect.setText("Updated copy");
151
+ ```
152
+
153
+ ---
154
+
155
+ ## `LasciiImageEffect`
156
+
157
+ ```js
158
+ import { LasciiImageEffect } from "lascii";
159
+ // or
160
+ import LasciiImageEffect from "lascii/core/image";
161
+ ```
162
+
163
+ ### Constructor
164
+
165
+ ```js
166
+ new LasciiImageEffect(img, index = 0, options?)
167
+ ```
168
+
169
+ - **img** — `<img>` element. Opacity is set to `0` until reveal; a canvas is appended to the parent.
170
+ - **index** — Stagger index: delay = `index * IMAGE_STAGGER_MS`.
171
+ - **options** — Partial override of `LasciiImageEffect.DEFAULTS`.
172
+
173
+ ### `LasciiImageEffect.DEFAULTS`
174
+
175
+ | Option | Type | Default | Description |
176
+ | ------ | ---- | ------- | ----------- |
177
+ | `ASCII_CHARS` | `string` | `" . . . . . . :::=+xX#0369"` | Light-to-dark character ramp for luminance mapping. |
178
+ | `FONT_SIZE` | `number` | `40` | Monospace font size used for measurement and drawing. |
179
+ | `ASPECT_WIDTH` | `number` | `4` | Target crop aspect (width). |
180
+ | `ASPECT_HEIGHT` | `number` | `5` | Target crop aspect (height). |
181
+ | `ASCII_COLUMNS` | `number` | `25` | Minimum column count (may increase with container width). |
182
+ | `MAX_ASCII_COLUMNS` | `number` | `96` | Upper cap when scaling to container width. |
183
+ | `TARGET_CELL_CSS_PX` | `number` | `9` | Target cell size in CSS pixels for column scaling. |
184
+ | `IMAGE_STAGGER_MS` | `number` | `100` | Delay multiplier per image `index`. |
185
+ | `CELL_APPEAR_MS` | `number` | `0.5` | Delay between starting each cell animation. |
186
+ | `SCRAMBLE_COUNT` | `number` | `10` | Scramble frames for “dense” (dark) cells. |
187
+ | `SCRAMBLE_SPEED_MS` | `number` | `50` | Interval between scramble frame updates. |
188
+ | `REVEAL_DELAY_MS` | `number` | `0` | Delay before fading canvas out and showing the image. |
189
+ | `BACKGROUND_COLOR` | `string` | `"transparent"` | Canvas cell background. |
190
+ | `TEXT_COLOR` | `string` | `"#c8c8c8"` | ASCII character color. |
191
+
192
+ Column count is recalculated from the image’s displayed width:
193
+ `cols = clamp(ASCII_COLUMNS, round(width / TARGET_CELL_CSS_PX), MAX_ASCII_COLUMNS)`.
194
+
195
+ ### Static methods
196
+
197
+ ```js
198
+ LasciiImageEffect.init(selector = "[data-lascii-image]")
199
+ ```
200
+
201
+ Creates one `LasciiImageEffect` per matching image, with `index` from `forEach` order.
202
+
203
+ ### Example
204
+
205
+ ```js
206
+ document.querySelectorAll("[data-lascii-image]").forEach((img, index) => {
207
+ new LasciiImageEffect(img, index, {
208
+ SCRAMBLE_COUNT: 20,
209
+ TEXT_COLOR: "#ffffff",
210
+ });
211
+ });
212
+ ```
213
+
214
+ ---
215
+
216
+ ## Import patterns
217
+
218
+ ### Declarative (auto-init)
219
+
220
+ ```html
221
+ <script type="module">
222
+ import "lascii";
223
+ </script>
224
+ ```
225
+
226
+ ### Manual init (no constructor side effects until you call `init`)
227
+
228
+ ```js
229
+ import { LasciiImageEffect, LasciiTextEffect, init } from "lascii";
230
+
231
+ // If you imported from "lascii", autoInitDom may already have run.
232
+ // Prefer core/* subpaths to avoid double init:
233
+
234
+ import LasciiTextEffect from "lascii/core/text";
235
+ import LasciiImageEffect from "lascii/core/image";
236
+ import { initDom } from "lascii/dom"; // note: lascii/dom still auto-inits on import
237
+
238
+ initDom();
239
+ ```
240
+
241
+ To import DOM helpers without auto-init, import from `initDom` via a future dedicated export or duplicate `initDom` in your bundle by importing only from `lascii/core/*` and calling `.init()` yourself.
242
+
243
+ ### Tree-shaking / side effects
244
+
245
+ `package.json` marks these as side-effectful:
246
+
247
+ - `./src/index.js`
248
+ - `./src/adapters/dom/index.js`
249
+
250
+ Bundlers will not drop them if imported. Use `lascii/core/text` and `lascii/core/image` for side-effect-free imports.
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "lascii",
3
+ "version": "1.0.0",
4
+ "description": "Lightweight ASCII animation effects for the web",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "types": "./src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "import": "./src/index.js",
12
+ "default": "./src/index.js"
13
+ },
14
+ "./dom": {
15
+ "types": "./src/adapters/dom/index.d.ts",
16
+ "import": "./src/adapters/dom/index.js",
17
+ "default": "./src/adapters/dom/index.js"
18
+ },
19
+ "./core/text": {
20
+ "types": "./src/core/effects/LasciiTextEffect.d.ts",
21
+ "import": "./src/core/effects/LasciiTextEffect.js",
22
+ "default": "./src/core/effects/LasciiTextEffect.js"
23
+ },
24
+ "./core/image": {
25
+ "types": "./src/core/effects/LasciiImageEffect.d.ts",
26
+ "import": "./src/core/effects/LasciiImageEffect.js",
27
+ "default": "./src/core/effects/LasciiImageEffect.js"
28
+ }
29
+ },
30
+ "sideEffects": [
31
+ "./src/index.js",
32
+ "./src/adapters/dom/index.js"
33
+ ],
34
+ "files": [
35
+ "src",
36
+ "docs",
37
+ "CHANGELOG.md"
38
+ ],
39
+ "keywords": [
40
+ "ascii",
41
+ "animation",
42
+ "effect",
43
+ "dom",
44
+ "canvas",
45
+ "text-effect"
46
+ ],
47
+ "license": "MIT",
48
+ "author": "Gabriel Ramos (https://github.com/whosramoss)",
49
+ "homepage": "https://lascii.whosramoss.com",
50
+ "bugs": {
51
+ "url": "https://github.com/whosramoss/lascii/issues"
52
+ },
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "git+https://github.com/whosramoss/lascii.git"
56
+ }
57
+ }
@@ -0,0 +1,14 @@
1
+ import LasciiImageEffect from "../../core/effects/LasciiImageEffect.js";
2
+ import LasciiTextEffect from "../../core/effects/LasciiTextEffect.js";
3
+ import { autoInitDom, initDom } from "./initDom.js";
4
+
5
+ export { LasciiImageEffect, LasciiTextEffect, initDom, autoInitDom };
6
+
7
+ declare const lasciiDom: {
8
+ LasciiImageEffect: typeof LasciiImageEffect;
9
+ LasciiTextEffect: typeof LasciiTextEffect;
10
+ initDom: typeof initDom;
11
+ autoInitDom: typeof autoInitDom;
12
+ };
13
+
14
+ export default lasciiDom;
@@ -0,0 +1,8 @@
1
+ import LasciiImageEffect from "../../core/effects/LasciiImageEffect.js";
2
+ import LasciiTextEffect from "../../core/effects/LasciiTextEffect.js";
3
+ import { autoInitDom, initDom } from "./initDom.js";
4
+
5
+ autoInitDom();
6
+
7
+ export { LasciiImageEffect, LasciiTextEffect, initDom, autoInitDom };
8
+ export default { LasciiImageEffect, LasciiTextEffect, initDom, autoInitDom };
@@ -0,0 +1,2 @@
1
+ export function initDom(): void;
2
+ export function autoInitDom(): void;
@@ -0,0 +1,16 @@
1
+ import LasciiImageEffect from "../../core/effects/LasciiImageEffect.js";
2
+ import LasciiTextEffect from "../../core/effects/LasciiTextEffect.js";
3
+
4
+ export function initDom() {
5
+ LasciiImageEffect.init("[data-lascii-image]");
6
+ LasciiTextEffect.init("[data-lascii-text]");
7
+ }
8
+
9
+ export function autoInitDom() {
10
+ if (document.readyState === "loading") {
11
+ document.addEventListener("DOMContentLoaded", initDom);
12
+ return;
13
+ }
14
+
15
+ initDom();
16
+ }
@@ -0,0 +1,38 @@
1
+ export interface LasciiImageEffectOptions {
2
+ ASCII_CHARS?: string;
3
+ FONT_SIZE?: number;
4
+ ASPECT_WIDTH?: number;
5
+ ASPECT_HEIGHT?: number;
6
+ ASCII_COLUMNS?: number;
7
+ MAX_ASCII_COLUMNS?: number;
8
+ TARGET_CELL_CSS_PX?: number;
9
+ IMAGE_STAGGER_MS?: number;
10
+ CELL_APPEAR_MS?: number;
11
+ SCRAMBLE_COUNT?: number;
12
+ SCRAMBLE_SPEED_MS?: number;
13
+ REVEAL_DELAY_MS?: number;
14
+ BACKGROUND_COLOR?: string;
15
+ TEXT_COLOR?: string;
16
+ }
17
+
18
+ export interface LasciiImageEffectDefaults
19
+ extends Required<LasciiImageEffectOptions> {}
20
+
21
+ declare class LasciiImageEffect {
22
+ static readonly DEFAULTS: LasciiImageEffectDefaults;
23
+
24
+ readonly img: HTMLImageElement;
25
+ readonly index: number;
26
+ readonly config: LasciiImageEffectDefaults;
27
+
28
+ constructor(
29
+ img: HTMLImageElement,
30
+ index?: number,
31
+ options?: LasciiImageEffectOptions,
32
+ );
33
+
34
+ static init(selector?: string): void;
35
+ }
36
+
37
+ export default LasciiImageEffect;
38
+ export { LasciiImageEffect };
@@ -0,0 +1,283 @@
1
+ class LasciiImageEffect {
2
+ static DEFAULTS = {
3
+ ASCII_CHARS: " . . . . . . :::=+xX#0369",
4
+ FONT_SIZE: 40,
5
+ ASPECT_WIDTH: 4,
6
+ ASPECT_HEIGHT: 5,
7
+ ASCII_COLUMNS: 25,
8
+ MAX_ASCII_COLUMNS: 96,
9
+ TARGET_CELL_CSS_PX: 9,
10
+ IMAGE_STAGGER_MS: 100,
11
+ CELL_APPEAR_MS: 0.5,
12
+ SCRAMBLE_COUNT: 10,
13
+ SCRAMBLE_SPEED_MS: 50,
14
+ REVEAL_DELAY_MS: 0,
15
+ BACKGROUND_COLOR: "transparent",
16
+ TEXT_COLOR: "#c8c8c8",
17
+ };
18
+
19
+ constructor(img, index = 0, options = {}) {
20
+ this.img = img;
21
+ this.img.style.opacity = "0";
22
+ this.index = index;
23
+ this.config = { ...LasciiImageEffect.DEFAULTS, ...options };
24
+ this._minAsciiColumns = this.config.ASCII_COLUMNS;
25
+ this.canvas = document.createElement("canvas");
26
+ this.ctx = this.canvas.getContext("2d");
27
+ this.staggerDelay = this.index * this.config.IMAGE_STAGGER_MS;
28
+ this.load();
29
+ }
30
+
31
+ applyDisplayScaledColumns() {
32
+ const rect = this.img.getBoundingClientRect();
33
+ const width = rect.width;
34
+ if (width < 8) return;
35
+ const target = this.config.TARGET_CELL_CSS_PX;
36
+ const maxCols = this.config.MAX_ASCII_COLUMNS;
37
+ const cols = Math.min(
38
+ maxCols,
39
+ Math.max(this._minAsciiColumns, Math.round(width / target)),
40
+ );
41
+ this.config.ASCII_COLUMNS = cols;
42
+ }
43
+
44
+ measureCharacters() {
45
+ const measureCtx = document.createElement("canvas").getContext("2d");
46
+ measureCtx.font = `${this.config.FONT_SIZE}px monospace`;
47
+ this.charWidth = Math.ceil(measureCtx.measureText("M").width);
48
+ this.charHeight = this.config.FONT_SIZE;
49
+ this.ASCII_ROWS = Math.round(
50
+ this.config.ASCII_COLUMNS *
51
+ (this.config.ASPECT_HEIGHT / this.config.ASPECT_WIDTH) *
52
+ (this.charWidth / this.charHeight),
53
+ );
54
+ this.denseCharIndex = this.config.ASCII_CHARS.lastIndexOf(".");
55
+ this.denseChars = this.config.ASCII_CHARS.slice(
56
+ this.denseCharIndex + 1,
57
+ ).split("");
58
+ }
59
+
60
+ prepareCanvas() {
61
+ this.canvas.width = this.config.ASCII_COLUMNS * this.charWidth;
62
+ this.canvas.height = this.ASCII_ROWS * this.charHeight;
63
+ this.ctx.font = `${this.charHeight}px monospace`;
64
+ this.ctx.textBaseline = "top";
65
+ }
66
+
67
+ attachCanvas() {
68
+ const wrapper = this.img.parentElement;
69
+ if (wrapper) {
70
+ this.img.style.opacity = "0";
71
+ this.canvas.style.position = "absolute";
72
+ this.canvas.style.top = "0";
73
+ this.canvas.style.left = "0";
74
+ this.canvas.style.width = "100%";
75
+ this.canvas.style.height = "100%";
76
+ this.canvas.style.objectFit = "cover";
77
+ const computedPosition = getComputedStyle(wrapper).position;
78
+ if (computedPosition === "static") {
79
+ wrapper.style.position = "relative";
80
+ }
81
+ if (!this.canvas.isConnected) {
82
+ wrapper.appendChild(this.canvas);
83
+ }
84
+ }
85
+ }
86
+
87
+ load() {
88
+ this.samplingImg = new Image();
89
+ this.samplingImg.crossOrigin = "anonymous";
90
+ this.samplingImg.onload = () => {
91
+ const kickoff = () => {
92
+ try {
93
+ this.applyDisplayScaledColumns();
94
+ this.measureCharacters();
95
+ this.prepareCanvas();
96
+ this.attachCanvas();
97
+ this.start();
98
+ } catch (error) {
99
+ this.img.style.opacity = "1";
100
+ this.canvas.remove();
101
+ }
102
+ };
103
+ requestAnimationFrame(() => {
104
+ requestAnimationFrame(kickoff);
105
+ });
106
+ };
107
+ this.samplingImg.onerror = () => {
108
+ this.img.style.opacity = "1";
109
+ this.canvas.remove();
110
+ };
111
+ this.samplingImg.src = this.img.src;
112
+ }
113
+
114
+ start() {
115
+ const { asciiGrid, brightnessGrid } = this.imageToAsciiGrid();
116
+ this.animateCells(asciiGrid, brightnessGrid);
117
+ }
118
+
119
+ imageToAsciiGrid() {
120
+ const img = this.samplingImg;
121
+ const imageAspect = img.naturalWidth / img.naturalHeight;
122
+ const itemAspect = this.config.ASPECT_WIDTH / this.config.ASPECT_HEIGHT;
123
+ let cropX = 0;
124
+ let cropY = 0;
125
+ let cropW = img.naturalWidth;
126
+ let cropH = img.naturalHeight;
127
+
128
+ if (imageAspect > itemAspect) {
129
+ cropW = img.naturalHeight * itemAspect;
130
+ cropX = (img.naturalWidth - cropW) / 2;
131
+ } else {
132
+ cropH = img.naturalWidth / itemAspect;
133
+ cropY = (img.naturalHeight - cropH) / 2;
134
+ }
135
+
136
+ const samplingCanvas = document.createElement("canvas");
137
+ const samplingCtx = samplingCanvas.getContext("2d");
138
+ samplingCanvas.width = this.config.ASCII_COLUMNS;
139
+ samplingCanvas.height = this.ASCII_ROWS;
140
+ samplingCtx.drawImage(
141
+ img,
142
+ cropX,
143
+ cropY,
144
+ cropW,
145
+ cropH,
146
+ 0,
147
+ 0,
148
+ this.config.ASCII_COLUMNS,
149
+ this.ASCII_ROWS,
150
+ );
151
+
152
+ const { data } = samplingCtx.getImageData(
153
+ 0,
154
+ 0,
155
+ this.config.ASCII_COLUMNS,
156
+ this.ASCII_ROWS,
157
+ );
158
+ const asciiGrid = [];
159
+ const brightnessGrid = [];
160
+
161
+ for (let row = 0; row < this.ASCII_ROWS; row++) {
162
+ const asciiRow = [];
163
+ const brightnessRow = [];
164
+ for (let col = 0; col < this.config.ASCII_COLUMNS; col++) {
165
+ const pixelIndex = (row * this.config.ASCII_COLUMNS + col) * 4;
166
+ const brightness =
167
+ (data[pixelIndex] * 0.299 +
168
+ data[pixelIndex + 1] * 0.587 +
169
+ data[pixelIndex + 2] * 0.114) /
170
+ 255;
171
+ const charIndex = Math.min(
172
+ this.config.ASCII_CHARS.length - 1,
173
+ Math.floor((1 - brightness) * this.config.ASCII_CHARS.length),
174
+ );
175
+ asciiRow.push(this.config.ASCII_CHARS[charIndex]);
176
+ brightnessRow.push(charIndex);
177
+ }
178
+ asciiGrid.push(asciiRow);
179
+ brightnessGrid.push(brightnessRow);
180
+ }
181
+
182
+ return { asciiGrid, brightnessGrid };
183
+ }
184
+
185
+ animateCells(asciiGrid, brightnessGrid) {
186
+ const totalCells = this.config.ASCII_COLUMNS * this.ASCII_ROWS;
187
+ const scrambleState = new Array(totalCells).fill(null);
188
+ let settledCount = 0;
189
+
190
+ const cellOrder = this.shuffleArray(
191
+ Array.from({ length: totalCells }, (_, i) => i),
192
+ );
193
+
194
+ cellOrder.forEach((cellIndex, i) => {
195
+ setTimeout(
196
+ () => {
197
+ const row = Math.floor(cellIndex / this.config.ASCII_COLUMNS);
198
+ const col = cellIndex % this.config.ASCII_COLUMNS;
199
+ const isDark = brightnessGrid[row][col] > this.denseCharIndex;
200
+
201
+ if (!isDark) {
202
+ this.drawCharacter(col, row, asciiGrid[row][col]);
203
+ scrambleState[cellIndex] = 0;
204
+ settledCount++;
205
+ if (settledCount === totalCells) this.revealImage();
206
+ } else {
207
+ scrambleState[cellIndex] = this.config.SCRAMBLE_COUNT;
208
+ }
209
+ },
210
+ this.staggerDelay + i * this.config.CELL_APPEAR_MS,
211
+ );
212
+ });
213
+
214
+ const scrambleTicker = setInterval(() => {
215
+ let stillScrambling = false;
216
+ for (let cellIndex = 0; cellIndex < totalCells; cellIndex++) {
217
+ const remaining = scrambleState[cellIndex];
218
+ if (remaining === null || remaining <= 0) continue;
219
+ stillScrambling = true;
220
+ const row = Math.floor(cellIndex / this.config.ASCII_COLUMNS);
221
+ const col = cellIndex % this.config.ASCII_COLUMNS;
222
+
223
+ if (remaining === 1) {
224
+ this.drawCharacter(col, row, asciiGrid[row][col]);
225
+ scrambleState[cellIndex] = 0;
226
+ settledCount++;
227
+ if (settledCount === totalCells) this.revealImage();
228
+ } else {
229
+ this.drawCharacter(col, row, this.randomDenseCharacter());
230
+ scrambleState[cellIndex] = remaining - 1;
231
+ }
232
+ }
233
+ if (!stillScrambling && settledCount === totalCells) {
234
+ clearInterval(scrambleTicker);
235
+ }
236
+ }, this.config.SCRAMBLE_SPEED_MS);
237
+ }
238
+
239
+ drawCharacter(col, row, char) {
240
+ this.ctx.fillStyle = this.config.BACKGROUND_COLOR;
241
+ this.ctx.fillRect(
242
+ col * this.charWidth,
243
+ row * this.charHeight,
244
+ this.charWidth,
245
+ this.charHeight,
246
+ );
247
+ this.ctx.fillStyle = this.config.TEXT_COLOR;
248
+ this.ctx.fillText(char, col * this.charWidth, row * this.charHeight);
249
+ }
250
+
251
+ randomDenseCharacter() {
252
+ return this.denseChars[Math.floor(Math.random() * this.denseChars.length)];
253
+ }
254
+
255
+ revealImage() {
256
+ setTimeout(() => {
257
+ this.canvas.style.transition = "opacity 0.5s ease";
258
+ this.canvas.style.opacity = "0";
259
+ this.img.style.transition = "opacity 0.5s ease";
260
+ this.img.style.opacity = "1";
261
+ setTimeout(() => {
262
+ this.canvas.remove();
263
+ }, 500);
264
+ }, this.config.REVEAL_DELAY_MS);
265
+ }
266
+
267
+ shuffleArray(array) {
268
+ for (let i = array.length - 1; i > 0; i--) {
269
+ const j = Math.floor(Math.random() * (i + 1));
270
+ [array[i], array[j]] = [array[j], array[i]];
271
+ }
272
+ return array;
273
+ }
274
+
275
+ static init(selector = "[data-lascii-image]") {
276
+ const images = document.querySelectorAll(selector);
277
+ images.forEach((img, index) => {
278
+ new LasciiImageEffect(img, index);
279
+ });
280
+ }
281
+ }
282
+
283
+ export default LasciiImageEffect;
@@ -0,0 +1,36 @@
1
+ export type RevealOriginValue = "start" | "middle";
2
+
3
+ export interface LasciiTextEffectOptions {
4
+ introChars?: string;
5
+ introPhaseFrames?: number;
6
+ chars?: string;
7
+ frameStartMax?: number;
8
+ frameEndMax?: number;
9
+ randomCharChance?: number;
10
+ phraseDelay?: number;
11
+ separator?: string;
12
+ revealOrigin?: RevealOriginValue;
13
+ }
14
+
15
+ export interface LasciiTextEffectDefaults extends Required<LasciiTextEffectOptions> {}
16
+
17
+ declare class LasciiTextEffect {
18
+ static readonly RevealOrigin: Readonly<{
19
+ readonly START: "start";
20
+ readonly MIDDLE: "middle";
21
+ }>;
22
+
23
+ static readonly DEFAULTS: LasciiTextEffectDefaults;
24
+
25
+ readonly el: HTMLElement;
26
+ readonly config: LasciiTextEffectDefaults;
27
+
28
+ constructor(element: HTMLElement, options?: LasciiTextEffectOptions);
29
+
30
+ setText(newText: string): Promise<void>;
31
+
32
+ static init(selector?: string): void;
33
+ }
34
+
35
+ export default LasciiTextEffect;
36
+ export { LasciiTextEffect };
@@ -0,0 +1,185 @@
1
+ class LasciiTextEffect {
2
+ static RevealOrigin = Object.freeze({
3
+ START: "start",
4
+ MIDDLE: "middle",
5
+ });
6
+
7
+ static DEFAULTS = {
8
+ introChars: "█▓▒░x92",
9
+ introPhaseFrames: 10,
10
+ chars: "!<>-_\\/[]{}—=+*^?#________",
11
+ frameStartMax: 40,
12
+ frameEndMax: 40,
13
+ randomCharChance: 0.28,
14
+ phraseDelay: 800,
15
+ separator: "|:|",
16
+ revealOrigin: LasciiTextEffect.RevealOrigin.START,
17
+ };
18
+
19
+ constructor(element, options = {}) {
20
+ this.el = element;
21
+ this.config = { ...LasciiTextEffect.DEFAULTS, ...options };
22
+ this.queue = [];
23
+ this.frame = 0;
24
+ this.frameRequest = null;
25
+ this.resolve = null;
26
+ this.safetyTimeout = null;
27
+ this.rawText = this.el.textContent.trim();
28
+ this.phrases = this.extractPhrases();
29
+ this.shouldLoop = this.rawText.includes(this.config.separator);
30
+ this.counter = 0;
31
+ this.clearInitialText();
32
+ this.start();
33
+ }
34
+
35
+ extractPhrases() {
36
+ return this.rawText
37
+ .split(this.config.separator)
38
+ .map((text) => text.trim())
39
+ .filter(Boolean);
40
+ }
41
+
42
+ clearInitialText() {
43
+ this.el.textContent = "";
44
+ }
45
+
46
+ start() {
47
+ if (!this.phrases.length) return;
48
+ if (this.shouldLoop) {
49
+ this.displayNextPhrase();
50
+ } else {
51
+ this.setText(this.phrases[0]);
52
+ }
53
+ }
54
+
55
+ displayNextPhrase() {
56
+ const currentPhrase = this.phrases[this.counter];
57
+ this.setText(currentPhrase).then(() => {
58
+ setTimeout(() => {
59
+ this.updateCounter();
60
+ this.displayNextPhrase();
61
+ }, this.config.phraseDelay);
62
+ });
63
+ }
64
+
65
+ updateCounter() {
66
+ this.counter = (this.counter + 1) % this.phrases.length;
67
+ }
68
+
69
+ setText(newText) {
70
+ const oldText = this.el.innerText;
71
+ const length = Math.max(oldText.length, newText.length);
72
+ const promise = new Promise((resolve) => {
73
+ this.resolve = resolve;
74
+ });
75
+ this.queue = this.buildQueue(oldText, newText, length);
76
+ this.resetAnimation();
77
+
78
+ this.safetyTimeout = setTimeout(() => {
79
+ if (this.frameRequest) {
80
+ cancelAnimationFrame(this.frameRequest);
81
+ this.frameRequest = null;
82
+ this.el.textContent = newText;
83
+ this.resolve?.();
84
+ }
85
+ }, 3000);
86
+
87
+ return promise;
88
+ }
89
+
90
+ buildQueue(oldText, newText, length) {
91
+ const queue = [];
92
+ const origin =
93
+ this.config.revealOrigin ?? LasciiTextEffect.RevealOrigin.START;
94
+ const cap = this.config.frameStartMax;
95
+ const scrambleLen = Math.max(
96
+ 1,
97
+ Math.floor(Math.random() * this.config.frameEndMax),
98
+ );
99
+
100
+ for (let i = 0; i < length; i++) {
101
+ const from = oldText[i] || "";
102
+ const to = newText[i] || "";
103
+ let start = 0;
104
+
105
+ if (origin === LasciiTextEffect.RevealOrigin.MIDDLE) {
106
+ const mid = (length - 1) / 2;
107
+ const dist = Math.abs(i - mid);
108
+ const maxDist = Math.max(mid, length - 1 - mid) || 1;
109
+ start = Math.floor((dist / maxDist) * cap);
110
+ } else if (length > 1) {
111
+ start = Math.floor((i / (length - 1)) * cap);
112
+ }
113
+
114
+ const end = start + scrambleLen;
115
+ queue.push({ from, to, start, end, char: "" });
116
+ }
117
+ return queue;
118
+ }
119
+
120
+ resetAnimation() {
121
+ cancelAnimationFrame(this.frameRequest);
122
+ this.frame = 0;
123
+ this.update();
124
+ }
125
+
126
+ update = () => {
127
+ let output = "";
128
+ let complete = 0;
129
+ for (let i = 0; i < this.queue.length; i++) {
130
+ const item = this.queue[i];
131
+ const { from, to, start, end } = item;
132
+ let char = item.char;
133
+ if (this.frame >= end) {
134
+ complete++;
135
+ output += to;
136
+ } else if (this.frame >= start) {
137
+ const local = this.frame - start;
138
+ const intro = this.config.introChars ?? "";
139
+ const introPhase = this.config.introPhaseFrames ?? 10;
140
+ if (intro.length && local < introPhase) {
141
+ const last = intro.length - 1;
142
+ const idx =
143
+ last <= 0
144
+ ? 0
145
+ : Math.min(
146
+ last,
147
+ Math.floor((local / Math.max(introPhase - 1, 1)) * last),
148
+ );
149
+ char = intro[idx];
150
+ this.queue[i].char = char;
151
+ } else if (!char || Math.random() < this.config.randomCharChance) {
152
+ char = this.randomChar();
153
+ this.queue[i].char = char;
154
+ }
155
+ output += `<span class="dud">${char}</span>`;
156
+ } else {
157
+ output += from;
158
+ }
159
+ }
160
+ this.el.innerHTML = output;
161
+ if (complete === this.queue.length) {
162
+ this.el.textContent = this.queue.map((item) => item.to).join("");
163
+ clearTimeout(this.safetyTimeout);
164
+ this.resolve?.();
165
+ } else {
166
+ this.frameRequest = requestAnimationFrame(this.update);
167
+ this.frame++;
168
+ }
169
+ };
170
+
171
+ randomChar() {
172
+ return this.config.chars[
173
+ Math.floor(Math.random() * this.config.chars.length)
174
+ ];
175
+ }
176
+
177
+ static init(selector = "[data-lascii-text]") {
178
+ const elements = document.querySelectorAll(selector);
179
+ elements.forEach((element) => {
180
+ new LasciiTextEffect(element);
181
+ });
182
+ }
183
+ }
184
+
185
+ export default LasciiTextEffect;
package/src/index.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ import LasciiImageEffect from "./core/effects/LasciiImageEffect.js";
2
+ import LasciiTextEffect from "./core/effects/LasciiTextEffect.js";
3
+ import { autoInitDom, initDom } from "./adapters/dom/initDom.js";
4
+
5
+ export { LasciiImageEffect, LasciiTextEffect };
6
+ export { initDom as init, autoInitDom };
7
+
8
+ declare const lascii: {
9
+ LasciiImageEffect: typeof LasciiImageEffect;
10
+ LasciiTextEffect: typeof LasciiTextEffect;
11
+ init: typeof initDom;
12
+ autoInitDom: typeof autoInitDom;
13
+ };
14
+
15
+ export default lascii;
package/src/index.js ADDED
@@ -0,0 +1,13 @@
1
+ import LasciiImageEffect from "./core/effects/LasciiImageEffect.js";
2
+ import LasciiTextEffect from "./core/effects/LasciiTextEffect.js";
3
+ import { autoInitDom, initDom } from "./adapters/dom/initDom.js";
4
+
5
+ autoInitDom();
6
+
7
+ export { LasciiImageEffect, LasciiTextEffect, initDom as init, autoInitDom };
8
+ export default {
9
+ LasciiImageEffect,
10
+ LasciiTextEffect,
11
+ init: initDom,
12
+ autoInitDom,
13
+ };