Compare commits

..

3 Commits

Author SHA1 Message Date
BaekRyang
6b6c8d8fd0 feat: Add area selection functionality
- EditorCanvas 컴포넌트에 영역 선택 콜백(onSelectArea) 추가
- 비선택 영역 클릭 시 해당 영역을 선택하는 기능 구현
- 드래그 시작 조건에서 showEditor만 확인하도록 수정 (selectedArea 불필요)
- package.json 버전 1.2.0에서 1.2.1로 업데이트
2025-11-26 13:53:15 +09:00
BaekRyang
317c7c5c92 feat: Add cubic easing and improve oscillation motion
- `src/types/area.ts`: cubic easing 함수 타입 추가
- `package.json`: 버전 1.1.0에서 1.2.0으로 업데이트
- `src/engine/AnimationLoop.ts`: 왕복 모션 로직을 sin 함수 기반으로 개선하여 자연스러운 좌우/상하 왕복 구현
- `src/utils/easing.ts`: easeInCubic, easeOutCubic 함수 추가
2025-11-26 13:48:21 +09:00
BaekRyang
4db9839f28 feat: Add motion preset registration API
- 모션 프리셋을 동적으로 등록하고 관리할 수 있는 API를 추가했습니다.
- `registerMotionPreset`, `registerMotionPresets`, `unregisterMotionPreset`, `getRegisteredPresets`, `hasPreset`, `resetToBuiltInPresets` 함수를 제공합니다.
- `MotionPreset` 타입을 `BuiltInMotionPreset`과 사용자 정의 문자열을 포함하도록 확장했습니다.
- `MotionPresetDefinition` 타입을 추가하여 커스텀 프리셋 정의 방식을 명확히 했습니다.
2025-11-26 11:05:36 +09:00
14 changed files with 550 additions and 155 deletions

77
dist/index.d.mts vendored
View File

@ -11,11 +11,22 @@ interface Point {
/**
*
*/
type EasingFunction = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'easeInQuad' | 'easeOutQuad';
type EasingFunction = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'easeInQuad' | 'easeOutQuad' | 'easeInCubic' | 'easeOutCubic';
/**
*
*
*/
type MotionPreset = 'none' | 'horizontal' | 'vertical' | 'rotate-cw' | 'rotate-ccw' | 'pulse' | 'diagonal-1' | 'diagonal-2';
type BuiltInMotionPreset = 'none' | 'horizontal' | 'vertical' | 'rotate-cw' | 'rotate-ccw' | 'pulse' | 'diagonal-1' | 'diagonal-2';
/**
* ( + )
* registerMotionPreset()
*/
type MotionPreset = BuiltInMotionPreset | (string & {});
/**
*
* @param strength (기본값: 0.1)
* @returns x, y
*/
type MotionPresetDefinition = (strength: number) => Point;
/**
*
*/
@ -321,6 +332,8 @@ interface EditorCanvasProps {
style?: EditorCanvasStyle;
/** 에디터 UI 표시 여부 (기본값: true) */
showEditor?: boolean;
/** 영역 선택 콜백 (비선택 영역 클릭 시) */
onSelectArea?: (areaId: string) => void;
}
declare const EditorCanvas: React$1.FC<EditorCanvasProps>;
@ -409,6 +422,62 @@ declare const DEFAULT_AREA: {
};
};
/**
*
* @param name
* @param definition (strength를 Point )
* @param options
* @param options.isRotation (true면 )
*
* @example
* // 좌우 진짜 왕복 (좌↔우)
* registerMotionPreset('horizontal-full', (strength) => ({
* x: strength * 2, // 진폭 2배
* y: 0
* }));
*
* // 8자 모양 운동 (회전)
* registerMotionPreset('figure-8', (strength) => ({
* x: strength,
* y: strength * 0.5
* }), { isRotation: true });
*/
declare function registerMotionPreset(name: string, definition: MotionPresetDefinition, options?: {
isRotation?: boolean;
}): void;
/**
*
* @param presets ( )
* @param rotationPresetNames
*
* @example
* registerMotionPresets({
* 'horizontal-full': (s) => ({x: s * 2, y: 0}),
* 'wave': (s) => ({x: s, y: s * 0.3}),
* }, ['wave']); // wave는 회전 애니메이션
*/
declare function registerMotionPresets(presets: Record<string, MotionPresetDefinition>, rotationPresetNames?: string[]): void;
/**
*
* @param name
* @returns
*/
declare function unregisterMotionPreset(name: string): boolean;
/**
*
* @returns
*/
declare function getRegisteredPresets(): string[];
/**
*
* @param name
* @returns
*/
declare function hasPreset(name: string): boolean;
/**
* ( )
*/
declare function resetToBuiltInPresets(): void;
/**
*
* @param preset
@ -586,4 +655,4 @@ declare const useMouseInteraction: (containerRef: React.RefObject<HTMLElement |
getInteractingAreaIndices: () => Set<number>;
};
export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, AreaList, type AreaListProps, type AreaOutlineStyle, type CenterPointStyle, type CircleLevelStyle, DEFAULT_AREA, DEFAULT_EDITOR_CANVAS_STYLE, type DistortionArea, type DistortionMovement, type EasingFunction, type EditMode, EditorCanvas, type EditorCanvasProps, type EditorCanvasStyle, type EditorState, ImageDistortion, type ImageDistortionProps, type MotionPreset, type MouseInteractionConfig, type MouseState, ParameterPanel, type ParameterPanelProps, type Point, type PointHandleStyle, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, SpringPhysics, type SpringPhysicsConfig, type SpringState, ThreeScene, applyEasing, isRotationPreset, presetToVector, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity };
export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, AreaList, type AreaListProps, type AreaOutlineStyle, type BuiltInMotionPreset, type CenterPointStyle, type CircleLevelStyle, DEFAULT_AREA, DEFAULT_EDITOR_CANVAS_STYLE, type DistortionArea, type DistortionMovement, type EasingFunction, type EditMode, EditorCanvas, type EditorCanvasProps, type EditorCanvasStyle, type EditorState, ImageDistortion, type ImageDistortionProps, type MotionPreset, type MotionPresetDefinition, type MouseInteractionConfig, type MouseState, ParameterPanel, type ParameterPanelProps, type Point, type PointHandleStyle, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, SpringPhysics, type SpringPhysicsConfig, type SpringState, ThreeScene, applyEasing, getRegisteredPresets, hasPreset, isRotationPreset, presetToVector, registerMotionPreset, registerMotionPresets, resetToBuiltInPresets, unregisterMotionPreset, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity };

77
dist/index.d.ts vendored
View File

@ -11,11 +11,22 @@ interface Point {
/**
*
*/
type EasingFunction = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'easeInQuad' | 'easeOutQuad';
type EasingFunction = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'easeInQuad' | 'easeOutQuad' | 'easeInCubic' | 'easeOutCubic';
/**
*
*
*/
type MotionPreset = 'none' | 'horizontal' | 'vertical' | 'rotate-cw' | 'rotate-ccw' | 'pulse' | 'diagonal-1' | 'diagonal-2';
type BuiltInMotionPreset = 'none' | 'horizontal' | 'vertical' | 'rotate-cw' | 'rotate-ccw' | 'pulse' | 'diagonal-1' | 'diagonal-2';
/**
* ( + )
* registerMotionPreset()
*/
type MotionPreset = BuiltInMotionPreset | (string & {});
/**
*
* @param strength (기본값: 0.1)
* @returns x, y
*/
type MotionPresetDefinition = (strength: number) => Point;
/**
*
*/
@ -321,6 +332,8 @@ interface EditorCanvasProps {
style?: EditorCanvasStyle;
/** 에디터 UI 표시 여부 (기본값: true) */
showEditor?: boolean;
/** 영역 선택 콜백 (비선택 영역 클릭 시) */
onSelectArea?: (areaId: string) => void;
}
declare const EditorCanvas: React$1.FC<EditorCanvasProps>;
@ -409,6 +422,62 @@ declare const DEFAULT_AREA: {
};
};
/**
*
* @param name
* @param definition (strength를 Point )
* @param options
* @param options.isRotation (true면 )
*
* @example
* // 좌우 진짜 왕복 (좌↔우)
* registerMotionPreset('horizontal-full', (strength) => ({
* x: strength * 2, // 진폭 2배
* y: 0
* }));
*
* // 8자 모양 운동 (회전)
* registerMotionPreset('figure-8', (strength) => ({
* x: strength,
* y: strength * 0.5
* }), { isRotation: true });
*/
declare function registerMotionPreset(name: string, definition: MotionPresetDefinition, options?: {
isRotation?: boolean;
}): void;
/**
*
* @param presets ( )
* @param rotationPresetNames
*
* @example
* registerMotionPresets({
* 'horizontal-full': (s) => ({x: s * 2, y: 0}),
* 'wave': (s) => ({x: s, y: s * 0.3}),
* }, ['wave']); // wave는 회전 애니메이션
*/
declare function registerMotionPresets(presets: Record<string, MotionPresetDefinition>, rotationPresetNames?: string[]): void;
/**
*
* @param name
* @returns
*/
declare function unregisterMotionPreset(name: string): boolean;
/**
*
* @returns
*/
declare function getRegisteredPresets(): string[];
/**
*
* @param name
* @returns
*/
declare function hasPreset(name: string): boolean;
/**
* ( )
*/
declare function resetToBuiltInPresets(): void;
/**
*
* @param preset
@ -586,4 +655,4 @@ declare const useMouseInteraction: (containerRef: React.RefObject<HTMLElement |
getInteractingAreaIndices: () => Set<number>;
};
export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, AreaList, type AreaListProps, type AreaOutlineStyle, type CenterPointStyle, type CircleLevelStyle, DEFAULT_AREA, DEFAULT_EDITOR_CANVAS_STYLE, type DistortionArea, type DistortionMovement, type EasingFunction, type EditMode, EditorCanvas, type EditorCanvasProps, type EditorCanvasStyle, type EditorState, ImageDistortion, type ImageDistortionProps, type MotionPreset, type MouseInteractionConfig, type MouseState, ParameterPanel, type ParameterPanelProps, type Point, type PointHandleStyle, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, SpringPhysics, type SpringPhysicsConfig, type SpringState, ThreeScene, applyEasing, isRotationPreset, presetToVector, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity };
export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, AreaList, type AreaListProps, type AreaOutlineStyle, type BuiltInMotionPreset, type CenterPointStyle, type CircleLevelStyle, DEFAULT_AREA, DEFAULT_EDITOR_CANVAS_STYLE, type DistortionArea, type DistortionMovement, type EasingFunction, type EditMode, EditorCanvas, type EditorCanvasProps, type EditorCanvasStyle, type EditorState, ImageDistortion, type ImageDistortionProps, type MotionPreset, type MotionPresetDefinition, type MouseInteractionConfig, type MouseState, ParameterPanel, type ParameterPanelProps, type Point, type PointHandleStyle, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, SpringPhysics, type SpringPhysicsConfig, type SpringState, ThreeScene, applyEasing, getRegisteredPresets, hasPreset, isRotationPreset, presetToVector, registerMotionPreset, registerMotionPresets, resetToBuiltInPresets, unregisterMotionPreset, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity };

132
dist/index.js vendored
View File

@ -43,8 +43,14 @@ __export(index_exports, {
SpringPhysics: () => SpringPhysics,
ThreeScene: () => ThreeScene,
applyEasing: () => applyEasing,
getRegisteredPresets: () => getRegisteredPresets,
hasPreset: () => hasPreset,
isRotationPreset: () => isRotationPreset,
presetToVector: () => presetToVector,
registerMotionPreset: () => registerMotionPreset,
registerMotionPresets: () => registerMotionPresets,
resetToBuiltInPresets: () => resetToBuiltInPresets,
unregisterMotionPreset: () => unregisterMotionPreset,
useAnimationFrame: () => useAnimationFrame,
useDistortionEditor: () => useDistortionEditor,
useMouseInteraction: () => useMouseInteraction,
@ -242,7 +248,9 @@ var easingFunctions = {
easeOut: (t) => t * (2 - t),
easeInOut: (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
easeInQuad: (t) => t * t,
easeOutQuad: (t) => t * (2 - t)
easeOutQuad: (t) => t * (2 - t),
easeInCubic: (t) => t * t * t,
easeOutCubic: (t) => 1 - Math.pow(1 - t, 3)
};
var applyEasing = (progress, easingType) => {
const clampedProgress = Math.max(0, Math.min(1, progress));
@ -250,31 +258,65 @@ var applyEasing = (progress, easingType) => {
};
// src/utils/motionPresets.ts
function presetToVector(preset, strength = 0.1) {
switch (preset) {
case "none":
return { x: 0, y: 0 };
case "horizontal":
return { x: strength, y: 0 };
case "vertical":
return { x: 0, y: strength };
case "rotate-cw":
return { x: strength, y: 0 };
case "rotate-ccw":
return { x: -strength, y: 0 };
case "pulse":
return { x: strength, y: strength };
case "diagonal-1":
return { x: strength * 0.707, y: strength * 0.707 };
// √2/2 ≈ 0.707
case "diagonal-2":
return { x: strength * 0.707, y: -strength * 0.707 };
default:
return { x: 0, y: 0 };
var presetRegistry = /* @__PURE__ */ new Map();
var rotationPresets = /* @__PURE__ */ new Set(["rotate-cw", "rotate-ccw"]);
var BUILT_IN_PRESETS = {
"none": () => ({ x: 0, y: 0 }),
"horizontal": (strength) => ({ x: strength, y: 0 }),
"vertical": (strength) => ({ x: 0, y: strength }),
"rotate-cw": (strength) => ({ x: strength, y: 0 }),
"rotate-ccw": (strength) => ({ x: -strength, y: 0 }),
"pulse": (strength) => ({ x: strength, y: strength }),
"diagonal-1": (strength) => ({ x: strength * 0.707, y: strength * 0.707 }),
"diagonal-2": (strength) => ({ x: strength * 0.707, y: -strength * 0.707 })
};
Object.entries(BUILT_IN_PRESETS).forEach(([name, definition]) => {
presetRegistry.set(name, definition);
});
function registerMotionPreset(name, definition, options) {
presetRegistry.set(name, definition);
if (options?.isRotation) {
rotationPresets.add(name);
} else {
rotationPresets.delete(name);
}
}
function registerMotionPresets(presets, rotationPresetNames) {
Object.entries(presets).forEach(([name, definition]) => {
presetRegistry.set(name, definition);
});
rotationPresetNames?.forEach((name) => rotationPresets.add(name));
}
function unregisterMotionPreset(name) {
rotationPresets.delete(name);
return presetRegistry.delete(name);
}
function getRegisteredPresets() {
return Array.from(presetRegistry.keys());
}
function hasPreset(name) {
return presetRegistry.has(name);
}
function resetToBuiltInPresets() {
presetRegistry.clear();
rotationPresets.clear();
Object.entries(BUILT_IN_PRESETS).forEach(([name, definition]) => {
presetRegistry.set(name, definition);
});
rotationPresets.add("rotate-cw");
rotationPresets.add("rotate-ccw");
}
function presetToVector(preset, strength = 0.1) {
const definition = presetRegistry.get(preset);
if (definition) {
return definition(strength);
}
console.warn(`Unknown motion preset: "${preset}". Falling back to "none".`);
return { x: 0, y: 0 };
}
function isRotationPreset(preset) {
return preset === "rotate-cw" || preset === "rotate-ccw";
if (!preset) return false;
return rotationPresets.has(preset);
}
// src/engine/AnimationLoop.ts
@ -311,19 +353,11 @@ var AnimationLoop = class {
y: Math.sin(angle * direction) * radius
};
} else {
if (easedProgress < 0.5) {
const t = easedProgress * 2;
dragVector = {
x: baseVector.x * t,
y: baseVector.y * t
};
} else {
const t = (easedProgress - 0.5) * 2;
dragVector = {
x: baseVector.x * (1 - t),
y: baseVector.y * (1 - t)
};
}
const oscillation = Math.sin(easedProgress * Math.PI * 2);
dragVector = {
x: baseVector.x * oscillation,
y: baseVector.y * oscillation
};
}
return {
...area,
@ -1282,7 +1316,8 @@ var EditorCanvas = ({
onStartDragging,
onStopDragging,
style: customStyle,
showEditor = true
showEditor = true,
onSelectArea
}) => {
const containerRef = (0, import_react6.useRef)(null);
const [canvasSize, setCanvasSize] = (0, import_react6.useState)({ width: 0, height: 0 });
@ -1331,7 +1366,7 @@ var EditorCanvas = ({
);
const handleCanvasDown = (0, import_react6.useCallback)(
(e) => {
if (!showEditor || !selectedArea || !containerRef.current) return;
if (!showEditor || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
let clientX, clientY;
if ("touches" in e) {
@ -1345,13 +1380,24 @@ var EditorCanvas = ({
const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height;
const clickPoint = { x, y };
if (isPointInPolygon2(clickPoint, selectedArea.basePoints)) {
if (selectedArea && isPointInPolygon2(clickPoint, selectedArea.basePoints)) {
setIsDraggingArea(true);
setDragStartPos(clickPoint);
e.preventDefault();
return;
}
if (onSelectArea) {
for (let i = areas.length - 1; i >= 0; i--) {
const area = areas[i];
if (area.id !== selectedAreaId && isPointInPolygon2(clickPoint, area.basePoints)) {
onSelectArea(area.id);
e.preventDefault();
return;
}
}
}
},
[showEditor, selectedArea, isPointInPolygon2]
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon2, onSelectArea]
);
const handleMove = (0, import_react6.useCallback)(
(e) => {
@ -1616,8 +1662,14 @@ var EditorCanvas = ({
SpringPhysics,
ThreeScene,
applyEasing,
getRegisteredPresets,
hasPreset,
isRotationPreset,
presetToVector,
registerMotionPreset,
registerMotionPresets,
resetToBuiltInPresets,
unregisterMotionPreset,
useAnimationFrame,
useDistortionEditor,
useMouseInteraction,

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

126
dist/index.mjs vendored
View File

@ -188,7 +188,9 @@ var easingFunctions = {
easeOut: (t) => t * (2 - t),
easeInOut: (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
easeInQuad: (t) => t * t,
easeOutQuad: (t) => t * (2 - t)
easeOutQuad: (t) => t * (2 - t),
easeInCubic: (t) => t * t * t,
easeOutCubic: (t) => 1 - Math.pow(1 - t, 3)
};
var applyEasing = (progress, easingType) => {
const clampedProgress = Math.max(0, Math.min(1, progress));
@ -196,31 +198,65 @@ var applyEasing = (progress, easingType) => {
};
// src/utils/motionPresets.ts
function presetToVector(preset, strength = 0.1) {
switch (preset) {
case "none":
return { x: 0, y: 0 };
case "horizontal":
return { x: strength, y: 0 };
case "vertical":
return { x: 0, y: strength };
case "rotate-cw":
return { x: strength, y: 0 };
case "rotate-ccw":
return { x: -strength, y: 0 };
case "pulse":
return { x: strength, y: strength };
case "diagonal-1":
return { x: strength * 0.707, y: strength * 0.707 };
// √2/2 ≈ 0.707
case "diagonal-2":
return { x: strength * 0.707, y: -strength * 0.707 };
default:
return { x: 0, y: 0 };
var presetRegistry = /* @__PURE__ */ new Map();
var rotationPresets = /* @__PURE__ */ new Set(["rotate-cw", "rotate-ccw"]);
var BUILT_IN_PRESETS = {
"none": () => ({ x: 0, y: 0 }),
"horizontal": (strength) => ({ x: strength, y: 0 }),
"vertical": (strength) => ({ x: 0, y: strength }),
"rotate-cw": (strength) => ({ x: strength, y: 0 }),
"rotate-ccw": (strength) => ({ x: -strength, y: 0 }),
"pulse": (strength) => ({ x: strength, y: strength }),
"diagonal-1": (strength) => ({ x: strength * 0.707, y: strength * 0.707 }),
"diagonal-2": (strength) => ({ x: strength * 0.707, y: -strength * 0.707 })
};
Object.entries(BUILT_IN_PRESETS).forEach(([name, definition]) => {
presetRegistry.set(name, definition);
});
function registerMotionPreset(name, definition, options) {
presetRegistry.set(name, definition);
if (options?.isRotation) {
rotationPresets.add(name);
} else {
rotationPresets.delete(name);
}
}
function registerMotionPresets(presets, rotationPresetNames) {
Object.entries(presets).forEach(([name, definition]) => {
presetRegistry.set(name, definition);
});
rotationPresetNames?.forEach((name) => rotationPresets.add(name));
}
function unregisterMotionPreset(name) {
rotationPresets.delete(name);
return presetRegistry.delete(name);
}
function getRegisteredPresets() {
return Array.from(presetRegistry.keys());
}
function hasPreset(name) {
return presetRegistry.has(name);
}
function resetToBuiltInPresets() {
presetRegistry.clear();
rotationPresets.clear();
Object.entries(BUILT_IN_PRESETS).forEach(([name, definition]) => {
presetRegistry.set(name, definition);
});
rotationPresets.add("rotate-cw");
rotationPresets.add("rotate-ccw");
}
function presetToVector(preset, strength = 0.1) {
const definition = presetRegistry.get(preset);
if (definition) {
return definition(strength);
}
console.warn(`Unknown motion preset: "${preset}". Falling back to "none".`);
return { x: 0, y: 0 };
}
function isRotationPreset(preset) {
return preset === "rotate-cw" || preset === "rotate-ccw";
if (!preset) return false;
return rotationPresets.has(preset);
}
// src/engine/AnimationLoop.ts
@ -257,19 +293,11 @@ var AnimationLoop = class {
y: Math.sin(angle * direction) * radius
};
} else {
if (easedProgress < 0.5) {
const t = easedProgress * 2;
dragVector = {
x: baseVector.x * t,
y: baseVector.y * t
};
} else {
const t = (easedProgress - 0.5) * 2;
dragVector = {
x: baseVector.x * (1 - t),
y: baseVector.y * (1 - t)
};
}
const oscillation = Math.sin(easedProgress * Math.PI * 2);
dragVector = {
x: baseVector.x * oscillation,
y: baseVector.y * oscillation
};
}
return {
...area,
@ -1228,7 +1256,8 @@ var EditorCanvas = ({
onStartDragging,
onStopDragging,
style: customStyle,
showEditor = true
showEditor = true,
onSelectArea
}) => {
const containerRef = useRef5(null);
const [canvasSize, setCanvasSize] = useState4({ width: 0, height: 0 });
@ -1277,7 +1306,7 @@ var EditorCanvas = ({
);
const handleCanvasDown = useCallback5(
(e) => {
if (!showEditor || !selectedArea || !containerRef.current) return;
if (!showEditor || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
let clientX, clientY;
if ("touches" in e) {
@ -1291,13 +1320,24 @@ var EditorCanvas = ({
const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height;
const clickPoint = { x, y };
if (isPointInPolygon2(clickPoint, selectedArea.basePoints)) {
if (selectedArea && isPointInPolygon2(clickPoint, selectedArea.basePoints)) {
setIsDraggingArea(true);
setDragStartPos(clickPoint);
e.preventDefault();
return;
}
if (onSelectArea) {
for (let i = areas.length - 1; i >= 0; i--) {
const area = areas[i];
if (area.id !== selectedAreaId && isPointInPolygon2(clickPoint, area.basePoints)) {
onSelectArea(area.id);
e.preventDefault();
return;
}
}
}
},
[showEditor, selectedArea, isPointInPolygon2]
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon2, onSelectArea]
);
const handleMove = useCallback5(
(e) => {
@ -1561,8 +1601,14 @@ export {
SpringPhysics,
ThreeScene,
applyEasing,
getRegisteredPresets,
hasPreset,
isRotationPreset,
presetToVector,
registerMotionPreset,
registerMotionPresets,
resetToBuiltInPresets,
unregisterMotionPreset,
useAnimationFrame,
useDistortionEditor,
useMouseInteraction,

2
dist/index.mjs.map vendored

File diff suppressed because one or more lines are too long

14
package-lock.json generated
View File

@ -1,12 +1,13 @@
{
"name": "responsive-image-canvas",
"version": "1.0.0",
"name": "@baekryang/responsive-image-canvas",
"version": "1.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "responsive-image-canvas",
"version": "1.0.0",
"name": "@baekryang/responsive-image-canvas",
"version": "1.0.5",
"license": "MIT",
"devDependencies": {
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
@ -16,6 +17,11 @@
"three": "^0.181.0",
"tsup": "^8.5.0",
"typescript": "^5.5.3"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"three": ">=0.150.0"
}
},
"node_modules/@dimforge/rapier3d-compat": {

View File

@ -1,6 +1,6 @@
{
"name": "@baekryang/responsive-image-canvas",
"version": "1.0.5",
"version": "1.2.1",
"publishConfig": {
"registry": "https://git.bnovalab.com/api/packages/baekryang/npm/"
},

View File

@ -19,6 +19,8 @@ export interface EditorCanvasProps {
style?: EditorCanvasStyle;
/** 에디터 UI 표시 여부 (기본값: true) */
showEditor?: boolean;
/** 영역 선택 콜백 (비선택 영역 클릭 시) */
onSelectArea?: (areaId: string) => void;
}
export const EditorCanvas: React.FC<EditorCanvasProps> = ({
@ -34,6 +36,7 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
onStopDragging,
style: customStyle,
showEditor = true,
onSelectArea,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [canvasSize, setCanvasSize] = useState({width: 0, height: 0});
@ -97,7 +100,7 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
const handleCanvasDown = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
// 에디터가 숨겨진 상태면 동작하지 않음
if (!showEditor || !selectedArea || !containerRef.current) return;
if (!showEditor || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
@ -116,14 +119,28 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
const y = (clientY - rect.top) / rect.height;
const clickPoint = { x, y };
// 사각형 내부를 클릭했는지 확인
if (isPointInPolygon(clickPoint, selectedArea.basePoints)) {
// 선택된 영역 내부를 클릭했는지 확인 (드래그 시작)
if (selectedArea && isPointInPolygon(clickPoint, selectedArea.basePoints)) {
setIsDraggingArea(true);
setDragStartPos(clickPoint);
e.preventDefault();
return;
}
// 비선택 영역 클릭 시 해당 영역 선택
if (onSelectArea) {
// 역순으로 검사 (위에 그려진 영역 우선)
for (let i = areas.length - 1; i >= 0; i--) {
const area = areas[i];
if (area.id !== selectedAreaId && isPointInPolygon(clickPoint, area.basePoints)) {
onSelectArea(area.id);
e.preventDefault();
return;
}
}
}
},
[showEditor, selectedArea, isPointInPolygon]
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon, onSelectArea]
);
// 이동 (마우스/터치 공통)

View File

@ -51,22 +51,13 @@ export class AnimationLoop {
y: Math.sin(angle * direction) * radius,
};
} else {
// 일반 왕복 모션
if (easedProgress < 0.5) {
// 0.0 -> 0.5: 0에서 baseVector로 보간
const t = easedProgress * 2;
dragVector = {
x: baseVector.x * t,
y: baseVector.y * t,
};
} else {
// 0.5 -> 1.0: baseVector에서 0으로 보간
const t = (easedProgress - 0.5) * 2;
dragVector = {
x: baseVector.x * (1 - t),
y: baseVector.y * (1 - t),
};
}
// 일반 왕복 모션 (sin 기반으로 진짜 좌↔우/상↔하 왕복)
// sin(0)=0 → sin(π/2)=1 → sin(π)=0 → sin(3π/2)=-1 → sin(2π)=0
const oscillation = Math.sin(easedProgress * Math.PI * 2);
dragVector = {
x: baseVector.x * oscillation,
y: baseVector.y * oscillation,
};
}
return {

View File

@ -31,7 +31,9 @@ export { DEFAULT_EDITOR_CANVAS_STYLE } from './editor/constants';
export type {
Point,
EasingFunction,
BuiltInMotionPreset,
MotionPreset,
MotionPresetDefinition,
DistortionMovement,
DistortionArea,
AreaBounds,
@ -52,7 +54,17 @@ export type {
// 유틸리티 함수
export { applyEasing } from './utils/easing';
export { SHADER_CONFIG, ANIMATION_CONFIG, DEFAULT_AREA } from './utils/constants';
export { presetToVector, isRotationPreset } from './utils/motionPresets';
export {
presetToVector,
isRotationPreset,
// 프리셋 레지스트리 API
registerMotionPreset,
registerMotionPresets,
unregisterMotionPreset,
getRegisteredPresets,
hasPreset,
resetToBuiltInPresets,
} from './utils/motionPresets';
// 엔진 클래스 (고급 사용자용)
export { ThreeScene } from './engine/ThreeScene';

View File

@ -15,12 +15,14 @@ export type EasingFunction =
| 'easeOut'
| 'easeInOut'
| 'easeInQuad'
| 'easeOutQuad';
| 'easeOutQuad'
| 'easeInCubic'
| 'easeOutCubic';
/**
*
*
*/
export type MotionPreset =
export type BuiltInMotionPreset =
| 'none' // 없음 (애니메이션 없음)
| 'horizontal' // 좌우 왕복
| 'vertical' // 상하 왕복
@ -30,6 +32,24 @@ export type MotionPreset =
| 'diagonal-1' // 대각선 (좌상→우하)
| 'diagonal-2'; // 대각선 (우상→좌하)
/**
* ( + )
* registerMotionPreset()
*/
export type MotionPreset = BuiltInMotionPreset | (string & {});
/**
*
* @param strength (기본값: 0.1)
* @returns x, y
*/
export type MotionPresetDefinition = (strength: number) => Point;
/**
*
*/
export type RotationPresetChecker = (preset: MotionPreset) => boolean;
/**
*
*/

View File

@ -14,6 +14,9 @@ const easingFunctions: Record<EasingFunction, EasingFunc> = {
easeInQuad: (t) => t * t,
easeOutQuad: (t) => t * (2 - t),
easeInCubic: (t) => t * t * t,
easeOutCubic: (t) => 1 - Math.pow(1 - t, 3),
};
/**

View File

@ -1,4 +1,130 @@
import type {MotionPreset, Point} from '../types';
import type {MotionPreset, MotionPresetDefinition, Point, RotationPresetChecker} from '../types';
/**
* ( + )
*/
const presetRegistry = new Map<string, MotionPresetDefinition>();
/**
*
*/
const rotationPresets = new Set<string>(['rotate-cw', 'rotate-ccw']);
/**
*
*/
const BUILT_IN_PRESETS: Record<string, MotionPresetDefinition> = {
'none': () => ({x: 0, y: 0}),
'horizontal': (strength) => ({x: strength, y: 0}),
'vertical': (strength) => ({x: 0, y: strength}),
'rotate-cw': (strength) => ({x: strength, y: 0}),
'rotate-ccw': (strength) => ({x: -strength, y: 0}),
'pulse': (strength) => ({x: strength, y: strength}),
'diagonal-1': (strength) => ({x: strength * 0.707, y: strength * 0.707}),
'diagonal-2': (strength) => ({x: strength * 0.707, y: -strength * 0.707}),
};
// 내장 프리셋 등록
Object.entries(BUILT_IN_PRESETS).forEach(([name, definition]) => {
presetRegistry.set(name, definition);
});
/**
*
* @param name
* @param definition (strength를 Point )
* @param options
* @param options.isRotation (true면 )
*
* @example
* // 좌우 진짜 왕복 (좌↔우)
* registerMotionPreset('horizontal-full', (strength) => ({
* x: strength * 2, // 진폭 2배
* y: 0
* }));
*
* // 8자 모양 운동 (회전)
* registerMotionPreset('figure-8', (strength) => ({
* x: strength,
* y: strength * 0.5
* }), { isRotation: true });
*/
export function registerMotionPreset(
name: string,
definition: MotionPresetDefinition,
options?: { isRotation?: boolean }
): void {
presetRegistry.set(name, definition);
if (options?.isRotation) {
rotationPresets.add(name);
} else {
rotationPresets.delete(name);
}
}
/**
*
* @param presets ( )
* @param rotationPresetNames
*
* @example
* registerMotionPresets({
* 'horizontal-full': (s) => ({x: s * 2, y: 0}),
* 'wave': (s) => ({x: s, y: s * 0.3}),
* }, ['wave']); // wave는 회전 애니메이션
*/
export function registerMotionPresets(
presets: Record<string, MotionPresetDefinition>,
rotationPresetNames?: string[]
): void {
Object.entries(presets).forEach(([name, definition]) => {
presetRegistry.set(name, definition);
});
rotationPresetNames?.forEach(name => rotationPresets.add(name));
}
/**
*
* @param name
* @returns
*/
export function unregisterMotionPreset(name: string): boolean {
rotationPresets.delete(name);
return presetRegistry.delete(name);
}
/**
*
* @returns
*/
export function getRegisteredPresets(): string[] {
return Array.from(presetRegistry.keys());
}
/**
*
* @param name
* @returns
*/
export function hasPreset(name: string): boolean {
return presetRegistry.has(name);
}
/**
* ( )
*/
export function resetToBuiltInPresets(): void {
presetRegistry.clear();
rotationPresets.clear();
Object.entries(BUILT_IN_PRESETS).forEach(([name, definition]) => {
presetRegistry.set(name, definition);
});
rotationPresets.add('rotate-cw');
rotationPresets.add('rotate-ccw');
}
/**
*
@ -7,47 +133,31 @@ import type {MotionPreset, Point} from '../types';
* @returns (vectorA)
*/
export function presetToVector(preset: MotionPreset, strength: number = 0.1): Point {
switch (preset) {
case 'none':
// 애니메이션 없음
return {x: 0, y: 0};
const definition = presetRegistry.get(preset);
case 'horizontal':
// 좌우 왕복
return {x: strength, y: 0};
case 'vertical':
// 상하 왕복
return {x: 0, y: strength};
case 'rotate-cw':
// 시계방향 회전 (원운동의 시작점)
return {x: strength, y: 0};
case 'rotate-ccw':
// 반시계방향 회전 (원운동의 시작점)
return {x: -strength, y: 0};
case 'pulse':
// 펄스 (중심에서 바깥으로)
return {x: strength, y: strength};
case 'diagonal-1':
// 대각선 (좌상→우하)
return {x: strength * 0.707, y: strength * 0.707}; // √2/2 ≈ 0.707
case 'diagonal-2':
// 대각선 (우상→좌하)
return {x: strength * 0.707, y: -strength * 0.707};
default:
return {x: 0, y: 0};
if (definition) {
return definition(strength);
}
// 등록되지 않은 프리셋은 none으로 처리
console.warn(`Unknown motion preset: "${preset}". Falling back to "none".`);
return {x: 0, y: 0};
}
/**
*
*/
export function isRotationPreset(preset?: MotionPreset): boolean {
return preset === 'rotate-cw' || preset === 'rotate-ccw';
if (!preset) return false;
return rotationPresets.has(preset);
}
/**
*
* @param checker
* @deprecated isRotation registerMotionPreset에
*/
export function setRotationChecker(checker: RotationPresetChecker): void {
// Legacy support - 기존 코드 호환성을 위해 유지
console.warn('setRotationChecker is deprecated. Use registerMotionPreset with { isRotation: true } option instead.');
}