masoneffect 0.1.17 → 0.1.19

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,549 @@
1
+ import React, { forwardRef, useRef, useEffect, useImperativeHandle } from 'react';
2
+
3
+ /**
4
+ * MasonEffect - 파티클 모핑 효과 라이브러리
5
+ * 바닐라 JS 코어 클래스
6
+ */
7
+ // 디바운스 유틸리티 함수
8
+ function debounce(func, wait) {
9
+ let timeout = null;
10
+ return function executedFunction(...args) {
11
+ const later = () => {
12
+ timeout = null;
13
+ func.apply(this, args);
14
+ };
15
+ if (timeout !== null) {
16
+ clearTimeout(timeout);
17
+ }
18
+ timeout = setTimeout(later, wait);
19
+ };
20
+ }
21
+ class MasonEffect {
22
+ constructor(container, options = {}) {
23
+ // 컨테이너 요소
24
+ this.container = typeof container === 'string'
25
+ ? document.querySelector(container)
26
+ : container;
27
+ if (!this.container) {
28
+ throw new Error('Container element not found');
29
+ }
30
+ // 설정값들
31
+ this.config = {
32
+ text: options.text || 'mason effect',
33
+ densityStep: options.densityStep ?? 2,
34
+ maxParticles: options.maxParticles ?? 3200,
35
+ pointSize: options.pointSize ?? 0.5,
36
+ ease: options.ease ?? 0.05,
37
+ repelRadius: options.repelRadius ?? 150,
38
+ repelStrength: options.repelStrength ?? 1,
39
+ particleColor: options.particleColor || '#fff',
40
+ fontFamily: options.fontFamily || 'Inter, system-ui, Arial',
41
+ fontSize: options.fontSize || null,
42
+ width: options.width || null,
43
+ height: options.height || null,
44
+ devicePixelRatio: options.devicePixelRatio ?? null,
45
+ onReady: options.onReady || null,
46
+ onUpdate: options.onUpdate || null,
47
+ };
48
+ // 캔버스 생성
49
+ this.canvas = document.createElement('canvas');
50
+ const ctx = this.canvas.getContext('2d');
51
+ if (!ctx) {
52
+ throw new Error('Canvas context not available');
53
+ }
54
+ this.ctx = ctx;
55
+ this.container.appendChild(this.canvas);
56
+ this.canvas.style.display = 'block';
57
+ // 오프스크린 캔버스 (텍스트 렌더링용)
58
+ this.offCanvas = document.createElement('canvas');
59
+ const offCtx = this.offCanvas.getContext('2d');
60
+ if (!offCtx) {
61
+ throw new Error('Offscreen canvas context not available');
62
+ }
63
+ this.offCtx = offCtx;
64
+ // 상태
65
+ this.W = 0;
66
+ this.H = 0;
67
+ this.DPR = this.config.devicePixelRatio || Math.min(window.devicePixelRatio || 1, 1.8);
68
+ this.particles = [];
69
+ this.mouse = { x: 0, y: 0, down: false };
70
+ this.animationId = null;
71
+ this.isRunning = false;
72
+ this.isVisible = false;
73
+ this.intersectionObserver = null;
74
+ // 디바운스 설정 (ms)
75
+ this.debounceDelay = options.debounceDelay ?? 150;
76
+ // 이벤트 핸들러 바인딩 (디바운스 적용 전에 바인딩)
77
+ const boundHandleResize = this.handleResize.bind(this);
78
+ this.handleResize = debounce(boundHandleResize, this.debounceDelay);
79
+ this.handleMouseMove = this.handleMouseMove.bind(this);
80
+ this.handleMouseLeave = this.handleMouseLeave.bind(this);
81
+ this.handleMouseDown = this.handleMouseDown.bind(this);
82
+ this.handleMouseUp = this.handleMouseUp.bind(this);
83
+ // morph와 updateConfig를 위한 디바운스된 내부 메서드
84
+ this._debouncedMorph = debounce(this._morphInternal.bind(this), this.debounceDelay);
85
+ this._debouncedUpdateConfig = debounce(this._updateConfigInternal.bind(this), this.debounceDelay);
86
+ // 초기화
87
+ this.init();
88
+ }
89
+ init() {
90
+ this.resize();
91
+ this.setupEventListeners();
92
+ this.setupIntersectionObserver();
93
+ if (this.config.onReady) {
94
+ this.config.onReady(this);
95
+ }
96
+ }
97
+ setupIntersectionObserver() {
98
+ // IntersectionObserver가 지원되지 않는 환경에서는 항상 재생
99
+ if (typeof window === 'undefined' || typeof window.IntersectionObserver === 'undefined') {
100
+ this.isVisible = true;
101
+ this.start();
102
+ return;
103
+ }
104
+ // 이미 설정되어 있다면 다시 만들지 않음
105
+ if (this.intersectionObserver) {
106
+ return;
107
+ }
108
+ this.intersectionObserver = new IntersectionObserver((entries) => {
109
+ for (const entry of entries) {
110
+ if (entry.target !== this.container)
111
+ continue;
112
+ if (entry.isIntersecting) {
113
+ this.isVisible = true;
114
+ this.start();
115
+ }
116
+ else {
117
+ this.isVisible = false;
118
+ this.stop();
119
+ }
120
+ }
121
+ }, {
122
+ threshold: 0.1, // 10% 이상 보일 때 동작
123
+ });
124
+ this.intersectionObserver.observe(this.container);
125
+ }
126
+ resize() {
127
+ const width = this.config.width || this.container.clientWidth || window.innerWidth;
128
+ const height = this.config.height || this.container.clientHeight || window.innerHeight * 0.7;
129
+ // 최소 크기 보장
130
+ if (width <= 0 || height <= 0) {
131
+ return;
132
+ }
133
+ this.W = Math.floor(width * this.DPR);
134
+ this.H = Math.floor(height * this.DPR);
135
+ // 캔버스 크기 제한 (메모리 오류 방지)
136
+ // getImageData는 최대 약 268MB (4096x4096x4)까지 지원
137
+ const MAX_CANVAS_SIZE = 4096;
138
+ if (this.W > MAX_CANVAS_SIZE || this.H > MAX_CANVAS_SIZE) {
139
+ const scale = Math.min(MAX_CANVAS_SIZE / this.W, MAX_CANVAS_SIZE / this.H);
140
+ this.W = Math.floor(this.W * scale);
141
+ this.H = Math.floor(this.H * scale);
142
+ this.DPR = this.DPR * scale;
143
+ }
144
+ this.canvas.width = this.W;
145
+ this.canvas.height = this.H;
146
+ this.canvas.style.width = width + 'px';
147
+ this.canvas.style.height = height + 'px';
148
+ // 크기가 유효할 때만 buildTargets 실행
149
+ if (this.W > 0 && this.H > 0) {
150
+ this.buildTargets();
151
+ if (!this.particles.length) {
152
+ this.initParticles();
153
+ }
154
+ }
155
+ }
156
+ buildTargets() {
157
+ // 크기 검증
158
+ if (this.W <= 0 || this.H <= 0) {
159
+ return;
160
+ }
161
+ const text = this.config.text;
162
+ this.offCanvas.width = this.W;
163
+ this.offCanvas.height = this.H;
164
+ this.offCtx.clearRect(0, 0, this.offCanvas.width, this.offCanvas.height);
165
+ const base = Math.min(this.W, this.H);
166
+ const fontSize = this.config.fontSize || Math.max(80, Math.floor(base * 0.18));
167
+ this.offCtx.fillStyle = '#ffffff';
168
+ this.offCtx.textAlign = 'center';
169
+ this.offCtx.textBaseline = 'middle';
170
+ this.offCtx.font = `400 ${fontSize}px ${this.config.fontFamily}`;
171
+ // 글자 간격 계산 및 그리기
172
+ const chars = text.split('');
173
+ const spacing = fontSize * 0.05;
174
+ const totalWidth = this.offCtx.measureText(text).width + spacing * (chars.length - 1);
175
+ let x = this.W / 2 - totalWidth / 2;
176
+ for (const ch of chars) {
177
+ this.offCtx.fillText(ch, x + this.offCtx.measureText(ch).width / 2, this.H / 2);
178
+ x += this.offCtx.measureText(ch).width + spacing;
179
+ }
180
+ // 픽셀 샘플링
181
+ const step = Math.max(2, this.config.densityStep);
182
+ const img = this.offCtx.getImageData(0, 0, this.W, this.H).data;
183
+ const targets = [];
184
+ for (let y = 0; y < this.H; y += step) {
185
+ for (let x = 0; x < this.W; x += step) {
186
+ const i = (y * this.W + x) * 4;
187
+ if (img[i] + img[i + 1] + img[i + 2] > 600) {
188
+ targets.push({ x, y });
189
+ }
190
+ }
191
+ }
192
+ // 파티클 수 제한
193
+ while (targets.length > this.config.maxParticles) {
194
+ targets.splice(Math.floor(Math.random() * targets.length), 1);
195
+ }
196
+ // 파티클 수 조정
197
+ if (this.particles.length < targets.length) {
198
+ const need = targets.length - this.particles.length;
199
+ for (let i = 0; i < need; i++) {
200
+ this.particles.push(this.makeParticle());
201
+ }
202
+ }
203
+ else if (this.particles.length > targets.length) {
204
+ this.particles.length = targets.length;
205
+ }
206
+ // 목표 좌표 할당
207
+ for (let i = 0; i < this.particles.length; i++) {
208
+ const p = this.particles[i];
209
+ const t = targets[i];
210
+ p.tx = t.x;
211
+ p.ty = t.y;
212
+ }
213
+ }
214
+ makeParticle() {
215
+ // 캔버스 전체에 골고루 분포 (여백 없이)
216
+ const sx = Math.random() * this.W;
217
+ const sy = Math.random() * this.H;
218
+ return {
219
+ x: sx,
220
+ y: sy,
221
+ vx: 0,
222
+ vy: 0,
223
+ tx: sx,
224
+ ty: sy,
225
+ initialX: sx, // 초기 위치 저장 (scatter 시 돌아갈 위치)
226
+ initialY: sy,
227
+ j: Math.random() * Math.PI * 2,
228
+ };
229
+ }
230
+ initParticles() {
231
+ // 캔버스 전체에 골고루 분포 (여백 없이)
232
+ for (const p of this.particles) {
233
+ const sx = Math.random() * this.W;
234
+ const sy = Math.random() * this.H;
235
+ p.x = sx;
236
+ p.y = sy;
237
+ p.vx = p.vy = 0;
238
+ // 초기 위치 저장 (scatter 시 돌아갈 위치)
239
+ p.initialX = sx;
240
+ p.initialY = sy;
241
+ }
242
+ }
243
+ scatter() {
244
+ // 각 파티클을 초기 위치로 돌아가도록 설정
245
+ for (const p of this.particles) {
246
+ // 초기 위치가 저장되어 있으면 그 위치로, 없으면 현재 위치 유지
247
+ if (p.initialX !== undefined && p.initialY !== undefined) {
248
+ p.tx = p.initialX;
249
+ p.ty = p.initialY;
250
+ }
251
+ else {
252
+ // 초기 위치가 없으면 현재 위치를 초기 위치로 저장
253
+ p.initialX = p.x;
254
+ p.initialY = p.y;
255
+ p.tx = p.initialX;
256
+ p.ty = p.initialY;
257
+ }
258
+ }
259
+ }
260
+ morph(textOrOptions) {
261
+ // 즉시 실행이 필요한 경우 (예: 초기화 시)를 위해 내부 메서드 직접 호출
262
+ // 일반적인 경우에는 디바운스 적용
263
+ this._debouncedMorph(textOrOptions);
264
+ }
265
+ _morphInternal(textOrOptions) {
266
+ // W와 H가 0이면 resize 먼저 실행
267
+ if (this.W === 0 || this.H === 0) {
268
+ this.resize();
269
+ }
270
+ if (typeof textOrOptions === 'string') {
271
+ // 문자열인 경우: 기존 동작 유지
272
+ this.config.text = textOrOptions;
273
+ this.buildTargets();
274
+ }
275
+ else if (textOrOptions && typeof textOrOptions === 'object') {
276
+ // 객체인 경우: 텍스트와 함께 다른 설정도 변경
277
+ const needsRebuild = textOrOptions.text !== undefined;
278
+ this.config = { ...this.config, ...textOrOptions };
279
+ if (needsRebuild) {
280
+ this.buildTargets();
281
+ }
282
+ }
283
+ else {
284
+ // null이거나 undefined인 경우: 현재 텍스트로 재빌드
285
+ this.buildTargets();
286
+ }
287
+ }
288
+ update() {
289
+ this.ctx.clearRect(0, 0, this.W, this.H);
290
+ for (const p of this.particles) {
291
+ // 목표 좌표로 당기는 힘
292
+ let ax = (p.tx - p.x) * this.config.ease;
293
+ let ay = (p.ty - p.y) * this.config.ease;
294
+ // 마우스 반발/흡입
295
+ if (this.mouse.x || this.mouse.y) {
296
+ const dx = p.x - this.mouse.x;
297
+ const dy = p.y - this.mouse.y;
298
+ const d2 = dx * dx + dy * dy;
299
+ const r = this.config.repelRadius * this.DPR;
300
+ if (d2 < r * r) {
301
+ const d = Math.sqrt(d2) + 0.0001;
302
+ const f = (this.mouse.down ? -1 : 1) * this.config.repelStrength * (1 - d / r);
303
+ ax += (dx / d) * f * 6.0;
304
+ ay += (dy / d) * f * 6.0;
305
+ }
306
+ }
307
+ // 진동 효과
308
+ p.j += 2;
309
+ ax += Math.cos(p.j) * 0.05;
310
+ ay += Math.sin(p.j * 1.3) * 0.05;
311
+ // 속도와 위치 업데이트
312
+ p.vx = (p.vx + ax) * Math.random();
313
+ p.vy = (p.vy + ay) * Math.random();
314
+ p.x += p.vx;
315
+ p.y += p.vy;
316
+ }
317
+ // 파티클 그리기
318
+ this.ctx.fillStyle = this.config.particleColor;
319
+ const r = this.config.pointSize * this.DPR;
320
+ for (const p of this.particles) {
321
+ this.ctx.beginPath();
322
+ this.ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
323
+ this.ctx.fill();
324
+ }
325
+ if (this.config.onUpdate) {
326
+ this.config.onUpdate(this);
327
+ }
328
+ }
329
+ animate() {
330
+ if (!this.isRunning)
331
+ return;
332
+ this.update();
333
+ this.animationId = requestAnimationFrame(() => this.animate());
334
+ }
335
+ start() {
336
+ if (this.isRunning)
337
+ return;
338
+ this.isRunning = true;
339
+ this.animate();
340
+ }
341
+ stop() {
342
+ this.isRunning = false;
343
+ if (this.animationId) {
344
+ cancelAnimationFrame(this.animationId);
345
+ this.animationId = null;
346
+ }
347
+ }
348
+ setupEventListeners() {
349
+ window.addEventListener('resize', this.handleResize);
350
+ this.canvas.addEventListener('mousemove', this.handleMouseMove);
351
+ this.canvas.addEventListener('mouseleave', this.handleMouseLeave);
352
+ this.canvas.addEventListener('mousedown', this.handleMouseDown);
353
+ window.addEventListener('mouseup', this.handleMouseUp);
354
+ }
355
+ removeEventListeners() {
356
+ window.removeEventListener('resize', this.handleResize);
357
+ this.canvas.removeEventListener('mousemove', this.handleMouseMove);
358
+ this.canvas.removeEventListener('mouseleave', this.handleMouseLeave);
359
+ this.canvas.removeEventListener('mousedown', this.handleMouseDown);
360
+ window.removeEventListener('mouseup', this.handleMouseUp);
361
+ }
362
+ handleResize() {
363
+ this.resize();
364
+ }
365
+ handleMouseMove(e) {
366
+ const rect = this.canvas.getBoundingClientRect();
367
+ this.mouse.x = (e.clientX - rect.left) * this.DPR;
368
+ this.mouse.y = (e.clientY - rect.top) * this.DPR;
369
+ }
370
+ handleMouseLeave() {
371
+ this.mouse.x = this.mouse.y = 0;
372
+ }
373
+ handleMouseDown() {
374
+ this.mouse.down = true;
375
+ }
376
+ handleMouseUp() {
377
+ this.mouse.down = false;
378
+ }
379
+ // 설정 업데이트
380
+ updateConfig(newConfig) {
381
+ // 디바운스 적용
382
+ this._debouncedUpdateConfig(newConfig);
383
+ }
384
+ _updateConfigInternal(newConfig) {
385
+ this.config = { ...this.config, ...newConfig };
386
+ if (newConfig.text) {
387
+ this.buildTargets();
388
+ }
389
+ }
390
+ // 파괴 및 정리
391
+ destroy() {
392
+ this.stop();
393
+ this.removeEventListeners();
394
+ if (this.intersectionObserver) {
395
+ this.intersectionObserver.disconnect();
396
+ this.intersectionObserver = null;
397
+ }
398
+ if (this.canvas && this.canvas.parentNode) {
399
+ this.canvas.parentNode.removeChild(this.canvas);
400
+ }
401
+ }
402
+ }
403
+
404
+ const MasonEffectComponent = forwardRef((props, ref) => {
405
+ const containerRef = useRef(null);
406
+ const instanceRef = useRef(null);
407
+ useEffect(() => {
408
+ if (!containerRef.current)
409
+ return;
410
+ let resizeObserver = null;
411
+ let initTimeout = null;
412
+ // 컨테이너가 실제 크기를 가지도록 대기
413
+ const initEffect = () => {
414
+ const container = containerRef.current;
415
+ if (!container)
416
+ return;
417
+ // 컨테이너 크기가 0이거나 너무 작으면 다음 프레임에 다시 시도
418
+ const rect = container.getBoundingClientRect();
419
+ if (rect.width <= 0 || rect.height <= 0) {
420
+ initTimeout = window.setTimeout(initEffect, 50);
421
+ return;
422
+ }
423
+ // 최소 크기 보장 (너무 작으면 기본값 사용)
424
+ const minSize = 100;
425
+ if (rect.width < minSize || rect.height < minSize) {
426
+ container.style.minWidth = minSize + 'px';
427
+ container.style.minHeight = minSize + 'px';
428
+ }
429
+ const { className, style, text, densityStep, maxParticles, pointSize, ease, repelRadius, repelStrength, particleColor, fontFamily, fontSize, width, height, devicePixelRatio, onReady, onUpdate, } = props;
430
+ const options = {
431
+ text,
432
+ densityStep,
433
+ maxParticles,
434
+ pointSize,
435
+ ease,
436
+ repelRadius,
437
+ repelStrength,
438
+ particleColor,
439
+ fontFamily,
440
+ fontSize,
441
+ width,
442
+ height,
443
+ devicePixelRatio,
444
+ onReady,
445
+ onUpdate,
446
+ };
447
+ instanceRef.current = new MasonEffect(container, options);
448
+ // ResizeObserver로 컨테이너 크기 변경 감지
449
+ if (typeof ResizeObserver !== 'undefined') {
450
+ resizeObserver = new ResizeObserver(() => {
451
+ if (instanceRef.current) {
452
+ instanceRef.current.resize();
453
+ }
454
+ });
455
+ resizeObserver.observe(container);
456
+ }
457
+ };
458
+ // 다음 프레임에 초기화 (DOM이 완전히 렌더링된 후)
459
+ requestAnimationFrame(initEffect);
460
+ return () => {
461
+ if (initTimeout) {
462
+ clearTimeout(initTimeout);
463
+ }
464
+ if (resizeObserver) {
465
+ resizeObserver.disconnect();
466
+ }
467
+ if (instanceRef.current) {
468
+ instanceRef.current.destroy();
469
+ instanceRef.current = null;
470
+ }
471
+ };
472
+ }, []);
473
+ // props 변경 시 설정 업데이트
474
+ useEffect(() => {
475
+ if (!instanceRef.current)
476
+ return;
477
+ const { text, densityStep, maxParticles, pointSize, ease, repelRadius, repelStrength, particleColor, fontFamily, fontSize, width, height, devicePixelRatio, } = props;
478
+ instanceRef.current.updateConfig({
479
+ text,
480
+ densityStep,
481
+ maxParticles,
482
+ pointSize,
483
+ ease,
484
+ repelRadius,
485
+ repelStrength,
486
+ particleColor,
487
+ fontFamily,
488
+ fontSize,
489
+ width,
490
+ height,
491
+ devicePixelRatio,
492
+ });
493
+ }, [
494
+ props.text,
495
+ props.densityStep,
496
+ props.maxParticles,
497
+ props.pointSize,
498
+ props.ease,
499
+ props.repelRadius,
500
+ props.repelStrength,
501
+ props.particleColor,
502
+ props.fontFamily,
503
+ props.fontSize,
504
+ props.width,
505
+ props.height,
506
+ props.devicePixelRatio,
507
+ ]);
508
+ useImperativeHandle(ref, () => ({
509
+ morph: (textOrOptions) => {
510
+ if (!instanceRef.current) {
511
+ console.warn('MasonEffect: 인스턴스가 아직 초기화되지 않았습니다.');
512
+ return;
513
+ }
514
+ instanceRef.current.morph(textOrOptions);
515
+ },
516
+ scatter: () => {
517
+ if (!instanceRef.current) {
518
+ console.warn('MasonEffect: 인스턴스가 아직 초기화되지 않았습니다.');
519
+ return;
520
+ }
521
+ instanceRef.current.scatter();
522
+ },
523
+ updateConfig: (config) => {
524
+ if (!instanceRef.current) {
525
+ console.warn('MasonEffect: 인스턴스가 아직 초기화되지 않았습니다.');
526
+ return;
527
+ }
528
+ instanceRef.current.updateConfig(config);
529
+ },
530
+ destroy: () => {
531
+ if (instanceRef.current) {
532
+ instanceRef.current.destroy();
533
+ instanceRef.current = null;
534
+ }
535
+ },
536
+ }));
537
+ // 기본 스타일: 컨테이너가 크기를 가지도록 함
538
+ const defaultStyle = {
539
+ width: '100%',
540
+ height: '100%',
541
+ minHeight: props.height || 400,
542
+ position: 'relative',
543
+ ...props.style,
544
+ };
545
+ return (React.createElement("div", { ref: containerRef, className: props.className, style: defaultStyle }));
546
+ });
547
+ MasonEffectComponent.displayName = 'MasonEffect';
548
+
549
+ export { MasonEffectComponent as default };
@@ -4,4 +4,3 @@
4
4
  */
5
5
  import MasonEffect from './core/index.js';
6
6
  export default MasonEffect;
7
- //# sourceMappingURL=index.umd.d.ts.map
@@ -29,4 +29,3 @@ interface MasonEffectProps extends MasonEffectOptions {
29
29
  }
30
30
  declare const MasonEffectComponent: React.ForwardRefExoticComponent<MasonEffectProps & React.RefAttributes<MasonEffectRef>>;
31
31
  export default MasonEffectComponent;
32
- //# sourceMappingURL=MasonEffect.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"MasonEffect.d.ts","sourceRoot":"","sources":["../../../src/react/MasonEffect.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA6D,MAAM,OAAO,CAAC;AAClF,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAE/C,MAAM,WAAW,kBAAkB;IACjC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,WAAW,KAAK,IAAI,CAAC;IAC1C,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,WAAW,KAAK,IAAI,CAAC;CAC5C;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC,KAAK,IAAI,CAAC;IACtE,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,YAAY,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC,kBAAkB,CAAC,KAAK,IAAI,CAAC;IAC5D,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,UAAU,gBAAiB,SAAQ,kBAAkB;IACnD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;CAC7B;AAED,QAAA,MAAM,oBAAoB,yFA6LzB,CAAC;AAIF,eAAe,oBAAoB,CAAC"}
1
+ {"version":3,"file":"MasonEffect.d.ts","sourceRoot":"","sources":["../../../src/react/MasonEffect.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA6D,MAAM,OAAO,CAAC;AAClF,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAE/C,MAAM,WAAW,kBAAkB;IACjC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,WAAW,KAAK,IAAI,CAAC;IAC1C,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,WAAW,KAAK,IAAI,CAAC;CAC5C;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC,KAAK,IAAI,CAAC;IACtE,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,YAAY,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC,kBAAkB,CAAC,KAAK,IAAI,CAAC;IAC5D,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,UAAU,gBAAiB,SAAQ,kBAAkB;IACnD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;CAC7B;AAED,QAAA,MAAM,oBAAoB,yFAoMzB,CAAC;AAIF,eAAe,oBAAoB,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { default } from './MasonEffect';
2
+ export type { MasonEffectRef, MasonEffectOptions } from './MasonEffect';
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/react/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AACxC,YAAY,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "masoneffect",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "파티클 모핑 효과를 제공하는 라이브러리 - React, Vue, 바닐라 JS 지원",
5
5
  "main": "dist/index.min.js",
6
6
  "module": "dist/index.esm.min.js",
@@ -14,7 +14,7 @@
14
14
  "./react": {
15
15
  "import": "./dist/react/MasonEffect.min.js",
16
16
  "require": "./dist/react/MasonEffect.min.cjs",
17
- "types": "./dist/react/MasonEffect.d.ts"
17
+ "types": "./dist/react/index.d.ts"
18
18
  },
19
19
  "./vue": {
20
20
  "import": "./src/vue/MasonEffect.vue"
@@ -25,7 +25,7 @@
25
25
  "src"
26
26
  ],
27
27
  "scripts": {
28
- "build": "rollup -c rollup.config.mjs",
28
+ "build": "rollup -c rollup.config.mjs && node scripts/generate-react-types.js",
29
29
  "dev": "rollup -c rollup.config.mjs -w",
30
30
  "serve": "npx http-server . -p 8080 -o --cors",
31
31
  "dev:example": "npm run serve",
package/src/core/index.ts CHANGED
@@ -201,21 +201,44 @@ export class MasonEffect {
201
201
  const width = this.config.width || this.container.clientWidth || window.innerWidth;
202
202
  const height = this.config.height || this.container.clientHeight || window.innerHeight * 0.7;
203
203
 
204
+ // 최소 크기 보장
205
+ if (width <= 0 || height <= 0) {
206
+ return;
207
+ }
208
+
204
209
  this.W = Math.floor(width * this.DPR);
205
210
  this.H = Math.floor(height * this.DPR);
206
211
 
212
+ // 캔버스 크기 제한 (메모리 오류 방지)
213
+ // getImageData는 최대 약 268MB (4096x4096x4)까지 지원
214
+ const MAX_CANVAS_SIZE = 4096;
215
+ if (this.W > MAX_CANVAS_SIZE || this.H > MAX_CANVAS_SIZE) {
216
+ const scale = Math.min(MAX_CANVAS_SIZE / this.W, MAX_CANVAS_SIZE / this.H);
217
+ this.W = Math.floor(this.W * scale);
218
+ this.H = Math.floor(this.H * scale);
219
+ this.DPR = this.DPR * scale;
220
+ }
221
+
207
222
  this.canvas.width = this.W;
208
223
  this.canvas.height = this.H;
209
224
  this.canvas.style.width = width + 'px';
210
225
  this.canvas.style.height = height + 'px';
211
226
 
212
- this.buildTargets();
213
- if (!this.particles.length) {
214
- this.initParticles();
227
+ // 크기가 유효할 때만 buildTargets 실행
228
+ if (this.W > 0 && this.H > 0) {
229
+ this.buildTargets();
230
+ if (!this.particles.length) {
231
+ this.initParticles();
232
+ }
215
233
  }
216
234
  }
217
235
 
218
236
  buildTargets(): void {
237
+ // 크기 검증
238
+ if (this.W <= 0 || this.H <= 0) {
239
+ return;
240
+ }
241
+
219
242
  const text = this.config.text;
220
243
  this.offCanvas.width = this.W;
221
244
  this.offCanvas.height = this.H;
@@ -47,12 +47,19 @@ const MasonEffectComponent = forwardRef<MasonEffectRef, MasonEffectProps>(
47
47
  const container = containerRef.current;
48
48
  if (!container) return;
49
49
 
50
- // 컨테이너 크기가 0이면 다음 프레임에 다시 시도
50
+ // 컨테이너 크기가 0이거나 너무 작으면 다음 프레임에 다시 시도
51
51
  const rect = container.getBoundingClientRect();
52
- if (rect.width === 0 || rect.height === 0) {
52
+ if (rect.width <= 0 || rect.height <= 0) {
53
53
  initTimeout = window.setTimeout(initEffect, 50);
54
54
  return;
55
55
  }
56
+
57
+ // 최소 크기 보장 (너무 작으면 기본값 사용)
58
+ const minSize = 100;
59
+ if (rect.width < minSize || rect.height < minSize) {
60
+ container.style.minWidth = minSize + 'px';
61
+ container.style.minHeight = minSize + 'px';
62
+ }
56
63
 
57
64
  const {
58
65
  className,
@@ -0,0 +1,4 @@
1
+ // Re-export for convenience
2
+ export { default } from './MasonEffect';
3
+ export type { MasonEffectRef, MasonEffectOptions } from './MasonEffect';
4
+
@@ -4,7 +4,8 @@
4
4
 
5
5
  <script setup lang="ts">
6
6
  import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
7
- import { MasonEffect, type MasonEffectOptions } from '../core/index';
7
+ import { MasonEffect } from '../core/index';
8
+ import type { MasonEffectOptions } from '../core/index';
8
9
 
9
10
  interface Props extends Partial<MasonEffectOptions> {
10
11
  className?: string;