BaekRyang 0c3c0b606e feat: Add canvas style customization
- 왜곡 영역 원 레벨 스타일, 중심점, 포인트 핸들, 영역 외곽선 등
  캔버스 스타일을 커스터마이징할 수 있도록 `EditorCanvasStyle` 타입을
  추가했습니다.
- `DistortionEditorProps`에 `canvasStyle` prop을 추가하여
  외부에서 캔버스 스타일을 전달받을 수 있도록 했습니다.
- `EditorCanvas` 컴포넌트에서 `useMemo`를 사용하여
  기본 스타일과 사용자 정의 스타일을 병합하고, 이를 렌더링에
  반영하도록 수정했습니다.
2025-11-05 11:48:05 +09:00

1231 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
ANIMATION_CONFIG: () => ANIMATION_CONFIG,
AnimationLoop: () => AnimationLoop,
DEFAULT_AREA: () => DEFAULT_AREA,
DistortionEditor: () => DistortionEditor,
ImageDistortion: () => ImageDistortion,
SHADER_CONFIG: () => SHADER_CONFIG,
ShaderManager: () => ShaderManager,
ThreeScene: () => ThreeScene,
applyEasing: () => applyEasing,
useAnimationFrame: () => useAnimationFrame,
useDistortionEditor: () => useDistortionEditor
});
module.exports = __toCommonJS(index_exports);
// src/components/ImageDistortion.tsx
var import_react2 = require("react");
var THREE2 = __toESM(require("three"));
// src/engine/ThreeScene.ts
var THREE = __toESM(require("three"));
var ThreeScene = class {
constructor(container) {
this.container = container;
this.mesh = null;
/**
* 윈도우 리사이즈 핸들러
*/
this.handleResize = () => {
const width = this.container.clientWidth;
const height = this.container.clientHeight;
this.renderer.setSize(width, height);
this.uniforms.u_resolution.value.set(width, height);
if (this.mesh) {
this.render();
}
};
this.scene = new THREE.Scene();
this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false
});
this.renderer.setPixelRatio(window.devicePixelRatio);
this.container.appendChild(this.renderer.domElement);
this.uniforms = {
u_resolution: { value: new THREE.Vector2() },
u_texture: { value: null },
u_points: { value: new Float32Array(64) },
// 32포인트 × 2(x,y)
u_numAreas: { value: 0 },
u_dragVectors: { value: new Float32Array(16) },
// 8벡터 × 2(x,y)
u_distortionStrengths: { value: new Float32Array(8) }
};
this.handleResize();
window.addEventListener("resize", this.handleResize);
}
/**
* 셰이더 머티리얼 설정
* @param vertexShader 버텍스 셰이더 소스
* @param fragmentShader 프래그먼트 셰이더 소스
*/
setShaderMaterial(vertexShader, fragmentShader) {
console.log("[ThreeScene] setShaderMaterial \uD638\uCD9C\uB428");
console.log("[ThreeScene] vertexShader \uAE38\uC774:", vertexShader.length);
console.log("[ThreeScene] fragmentShader \uAE38\uC774:", fragmentShader.length);
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({
uniforms: this.uniforms,
vertexShader,
fragmentShader
});
console.log("[ThreeScene] ShaderMaterial \uC0DD\uC131\uB428");
const renderer = this.renderer;
const testScene = new THREE.Scene();
const testMesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
testScene.add(testMesh);
try {
renderer.compile(testScene, this.camera);
console.log("[ThreeScene] \uC170\uC774\uB354 \uCEF4\uD30C\uC77C \uC131\uACF5!");
} catch (e) {
console.error("[ThreeScene] \uC170\uC774\uB354 \uCEF4\uD30C\uC77C \uC5D0\uB7EC:", e);
}
if (this.mesh) {
this.scene.remove(this.mesh);
}
this.mesh = new THREE.Mesh(geometry, material);
this.scene.add(this.mesh);
console.log("[ThreeScene] mesh\uB97C \uC52C\uC5D0 \uCD94\uAC00\uD568");
}
/**
* 유니폼 값 업데이트
* @param updates 업데이트할 유니폼 값들
*/
updateUniforms(updates) {
Object.keys(updates).forEach((key) => {
const uniformKey = key;
this.uniforms[uniformKey].value = updates[uniformKey].value;
});
}
/**
* 씬 렌더링
*/
render() {
console.log("[ThreeScene] render() \uD638\uCD9C\uB428, mesh:", this.mesh);
this.renderer.render(this.scene, this.camera);
}
/**
* 현재 해상도 가져오기
*/
getResolution() {
return {
x: this.uniforms.u_resolution.value.x,
y: this.uniforms.u_resolution.value.y
};
}
/**
* 리소스 정리
*/
dispose() {
window.removeEventListener("resize", this.handleResize);
this.renderer.dispose();
if (this.mesh) {
this.mesh.geometry.dispose();
this.mesh.material.dispose();
}
if (this.container.contains(this.renderer.domElement)) {
this.container.removeChild(this.renderer.domElement);
}
}
};
// src/engine/ShaderManager.ts
var ShaderManager = class {
constructor() {
this.vertexShaderSource = null;
this.fragmentShaderSource = null;
}
/**
* 셰이더 파일들을 비동기로 로드
* @param vertexPath 버텍스 셰이더 파일 경로
* @param fragmentPath 프래그먼트 셰이더 파일 경로
* @returns 로드된 셰이더 소스 코드
*/
async loadShaders(vertexPath, fragmentPath) {
console.log("[ShaderManager] loadShaders \uC2DC\uC791:", { vertexPath, fragmentPath });
try {
console.log("[ShaderManager] fetch \uC2DC\uC791...");
const [vertexResponse, fragmentResponse] = await Promise.all([
fetch(vertexPath),
fetch(fragmentPath)
]);
console.log("[ShaderManager] fetch \uC644\uB8CC:", {
vertexStatus: vertexResponse.status,
fragmentStatus: fragmentResponse.status
});
if (!vertexResponse.ok) {
throw new Error(`\uBC84\uD14D\uC2A4 \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328: ${vertexResponse.statusText}`);
}
if (!fragmentResponse.ok) {
throw new Error(`\uD504\uB798\uADF8\uBA3C\uD2B8 \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328: ${fragmentResponse.statusText}`);
}
console.log("[ShaderManager] text() \uBCC0\uD658 \uC2DC\uC791...");
this.vertexShaderSource = await vertexResponse.text();
this.fragmentShaderSource = await fragmentResponse.text();
console.log("[ShaderManager] \uC170\uC774\uB354 \uB85C\uB4DC \uC644\uB8CC!", {
vertexLength: this.vertexShaderSource.length,
fragmentLength: this.fragmentShaderSource.length
});
return {
vertex: this.vertexShaderSource,
fragment: this.fragmentShaderSource
};
} catch (error) {
console.error("[ShaderManager] \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328:", error);
throw new Error("\uC170\uC774\uB354 \uB85C\uB529\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4");
}
}
/**
* 버텍스 셰이더 소스 코드 반환
*/
getVertexShader() {
if (!this.vertexShaderSource) {
throw new Error("\uBC84\uD14D\uC2A4 \uC170\uC774\uB354\uAC00 \uB85C\uB4DC\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4");
}
return this.vertexShaderSource;
}
/**
* 프래그먼트 셰이더 소스 코드 반환
*/
getFragmentShader() {
if (!this.fragmentShaderSource) {
throw new Error("\uD504\uB798\uADF8\uBA3C\uD2B8 \uC170\uC774\uB354\uAC00 \uB85C\uB4DC\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4");
}
return this.fragmentShaderSource;
}
};
// src/utils/easing.ts
var easingFunctions = {
linear: (t) => t,
easeIn: (t) => t * t,
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)
};
var applyEasing = (progress, easingType) => {
const clampedProgress = Math.max(0, Math.min(1, progress));
return easingFunctions[easingType](clampedProgress);
};
// src/engine/AnimationLoop.ts
var AnimationLoop = class {
/**
* 영역들의 드래그 벡터를 현재 진행도에 따라 업데이트
* @param areas 왜곡 영역 배열
* @returns 업데이트된 영역 배열
*/
static updateAreaDragVectors(areas) {
return areas.map((area) => {
const { progress, movement } = area;
const easedProgress = applyEasing(progress, movement.easing);
let dragVector;
if (easedProgress < 0.5) {
const t = easedProgress * 2;
dragVector = {
x: movement.vectorA.x * t,
y: movement.vectorA.y * t
};
} else {
const t = (easedProgress - 0.5) * 2;
dragVector = {
x: movement.vectorA.x * (1 - t),
y: movement.vectorA.y * (1 - t)
};
}
return {
...area,
dragVector
};
});
}
/**
* 모든 영역의 진행도를 델타 타임만큼 업데이트
* @param areas 왜곡 영역 배열
* @param deltaTime 델타 타임 (초)
* @returns 업데이트된 영역 배열
*/
static updateProgress(areas, deltaTime) {
return areas.map((area) => {
let newProgress = area.progress + deltaTime / area.movement.duration;
newProgress %= 1;
return {
...area,
progress: newProgress
};
});
}
};
// src/hooks/useAnimationFrame.ts
var import_react = require("react");
var useAnimationFrame = (callback, isPlaying = true) => {
const requestRef = (0, import_react.useRef)(void 0);
const previousTimeRef = (0, import_react.useRef)(void 0);
(0, import_react.useEffect)(() => {
if (!isPlaying) return;
const animate = (time) => {
if (previousTimeRef.current !== void 0) {
const deltaTime = (time - previousTimeRef.current) / 1e3;
callback(deltaTime);
}
previousTimeRef.current = time;
requestRef.current = requestAnimationFrame(animate);
};
requestRef.current = requestAnimationFrame(animate);
return () => {
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
}
};
}, [callback, isPlaying]);
};
// src/utils/constants.ts
var SHADER_CONFIG = {
/** 최대 영역 개수 */
MAX_AREAS: 8,
/** 최대 포인트 개수 (8영역 × 4포인트) */
MAX_POINTS: 32,
/** 최대 드래그 벡터 개수 */
MAX_DRAG_VECTORS: 8,
/** 최대 강도 배열 크기 */
MAX_STRENGTHS: 8
};
var ANIMATION_CONFIG = {
/** 목표 FPS */
TARGET_FPS: 60,
/** 델타 타임 (약 16.67ms) */
DELTA_TIME: 1 / 60
};
var DEFAULT_AREA = {
/** 기본 왜곡 강도 */
DISTORTION_STRENGTH: 0.5,
/** 기본 애니메이션 지속 시간 (초) */
DURATION: 2,
/** 기본 이징 함수 */
EASING: "easeInOut",
/** 기본 벡터 A */
VECTOR_A: { x: 0.1, y: 0.1 },
/** 기본 벡터 B */
VECTOR_B: { x: -0.1, y: -0.1 }
};
// src/components/ImageDistortion.tsx
var import_jsx_runtime = require("react/jsx-runtime");
var ImageDistortion = ({
imageSrc,
areas,
vertexShaderPath,
fragmentShaderPath,
isPlaying = true,
style,
className
}) => {
const containerRef = (0, import_react2.useRef)(null);
const sceneRef = (0, import_react2.useRef)(null);
const shaderManagerRef = (0, import_react2.useRef)(new ShaderManager());
const textureRef = (0, import_react2.useRef)(null);
const [isReady, setIsReady] = (0, import_react2.useState)(false);
const [imageLoaded, setImageLoaded] = (0, import_react2.useState)(false);
const [currentAreas, setCurrentAreas] = (0, import_react2.useState)(areas);
(0, import_react2.useEffect)(() => {
setCurrentAreas(areas);
}, [areas]);
(0, import_react2.useEffect)(() => {
console.log("[ImageDistortion] useEffect \uC2E4\uD589, containerRef.current:", containerRef.current);
if (!containerRef.current) {
console.warn("[ImageDistortion] containerRef.current\uAC00 null\uC785\uB2C8\uB2E4. \uCEF4\uD3EC\uB10C\uD2B8\uAC00 \uC81C\uB300\uB85C \uB9C8\uC6B4\uD2B8\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4.");
return;
}
console.log("[ImageDistortion] \uCD08\uAE30\uD654 \uC2DC\uC791");
const scene = new ThreeScene(containerRef.current);
sceneRef.current = scene;
const vertPath = vertexShaderPath || "/shaders/distortion.vert.glsl";
const fragPath = fragmentShaderPath || "/shaders/distortion.frag.glsl";
console.log("[ImageDistortion] \uC170\uC774\uB354 \uB85C\uB4DC \uC2DC\uB3C4:", { vertPath, fragPath });
shaderManagerRef.current.loadShaders(vertPath, fragPath).then(({ vertex, fragment }) => {
console.log("[ImageDistortion] \uC170\uC774\uB354 \uB85C\uB4DC \uC131\uACF5");
scene.setShaderMaterial(vertex, fragment);
setIsReady(true);
}).catch((error) => {
console.error("[ImageDistortion] \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328:", error);
});
return () => {
scene.dispose();
if (textureRef.current) {
textureRef.current.dispose();
}
};
}, [vertexShaderPath, fragmentShaderPath]);
(0, import_react2.useEffect)(() => {
if (!imageSrc || !isReady) {
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2A4\uD0B5:", { imageSrc, isReady });
return;
}
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2DC\uC791:", imageSrc);
setImageLoaded(false);
const loader = new THREE2.TextureLoader();
loader.load(
imageSrc,
(texture) => {
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC131\uACF5!", {
width: texture.image.width,
height: texture.image.height
});
textureRef.current = texture;
setImageLoaded(true);
if (sceneRef.current) {
sceneRef.current.updateUniforms({
u_texture: { value: texture }
});
sceneRef.current.render();
console.log("[ImageDistortion] \uD14D\uC2A4\uCC98 \uC5C5\uB370\uC774\uD2B8 \uBC0F \uB80C\uB354\uB9C1 \uC644\uB8CC");
}
},
(progress) => {
console.log(
"[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB529 \uC911...",
Math.round(progress.loaded / progress.total * 100) + "%"
);
},
(error) => {
console.error("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2E4\uD328:", error);
setImageLoaded(false);
}
);
return () => {
if (textureRef.current) {
textureRef.current.dispose();
textureRef.current = null;
}
};
}, [imageSrc, isReady]);
(0, import_react2.useEffect)(() => {
if (!sceneRef.current || !isReady) return;
const resolution = sceneRef.current.getResolution();
const points = new Float32Array(SHADER_CONFIG.MAX_POINTS * 2);
currentAreas.forEach((area, areaIndex) => {
area.basePoints.forEach((point, pointIndex) => {
const index = (areaIndex * 4 + pointIndex) * 2;
points[index] = point.x;
points[index + 1] = 1 - point.y;
});
});
const dragVectors = new Float32Array(SHADER_CONFIG.MAX_DRAG_VECTORS * 2);
currentAreas.forEach((area, index) => {
const baseIndex = index * 2;
dragVectors[baseIndex] = area.dragVector.x;
dragVectors[baseIndex + 1] = -area.dragVector.y;
});
const strengths = new Float32Array(SHADER_CONFIG.MAX_STRENGTHS);
currentAreas.forEach((area, index) => {
strengths[index] = area.distortionStrength;
});
sceneRef.current.updateUniforms({
u_numAreas: { value: currentAreas.length },
u_points: { value: points },
u_dragVectors: { value: dragVectors },
u_distortionStrengths: { value: strengths }
});
sceneRef.current.render();
}, [currentAreas, isReady]);
const animationCallback = (0, import_react2.useCallback)((deltaTime) => {
if (!isReady) return;
setCurrentAreas((prevAreas) => {
const updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime);
return AnimationLoop.updateAreaDragVectors(updatedAreas);
});
}, [isReady]);
useAnimationFrame(animationCallback, isPlaying);
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
"div",
{
ref: containerRef,
style: {
width: "100%",
height: "100%",
position: "relative",
...style
},
className,
children: !imageLoaded && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
"div",
{
style: {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(0, 0, 0, 0.7)",
color: "white",
padding: "20px",
borderRadius: "8px",
zIndex: 999
},
children: "\uC774\uBBF8\uC9C0 \uB85C\uB529 \uC911..."
}
)
}
);
};
// src/editor/DistortionEditor.tsx
var import_react5 = require("react");
// src/editor/hooks/useDistortionEditor.ts
var import_react3 = require("react");
var useDistortionEditor = (initialAreas = []) => {
const [state, setState] = (0, import_react3.useState)({
selectedAreaId: initialAreas[0]?.id || null,
areas: initialAreas,
editMode: "normal",
draggingPointIndex: null
});
const selectArea = (0, import_react3.useCallback)((areaId) => {
setState((prev) => ({ ...prev, selectedAreaId: areaId }));
}, []);
const addArea = (0, import_react3.useCallback)((area) => {
setState((prev) => ({
...prev,
areas: [...prev.areas, area],
selectedAreaId: area.id
}));
}, []);
const removeArea = (0, import_react3.useCallback)((areaId) => {
setState((prev) => {
const newAreas = prev.areas.filter((a) => a.id !== areaId);
return {
...prev,
areas: newAreas,
selectedAreaId: prev.selectedAreaId === areaId ? newAreas[0]?.id || null : prev.selectedAreaId
};
});
}, []);
const updateArea = (0, import_react3.useCallback)((areaId, updates) => {
setState((prev) => ({
...prev,
areas: prev.areas.map((area) => area.id === areaId ? { ...area, ...updates } : area)
}));
}, []);
const updatePoint = (0, import_react3.useCallback)((areaId, pointIndex, point) => {
setState((prev) => ({
...prev,
areas: prev.areas.map((area) => {
if (area.id === areaId) {
const newPoints = [...area.basePoints];
newPoints[pointIndex] = point;
return { ...area, basePoints: newPoints };
}
return area;
})
}));
}, []);
const startDragging = (0, import_react3.useCallback)((pointIndex) => {
setState((prev) => ({ ...prev, draggingPointIndex: pointIndex }));
}, []);
const stopDragging = (0, import_react3.useCallback)(() => {
setState((prev) => ({ ...prev, draggingPointIndex: null }));
}, []);
const setEditMode = (0, import_react3.useCallback)((mode) => {
setState((prev) => ({ ...prev, editMode: mode }));
}, []);
const getSelectedArea = (0, import_react3.useCallback)(() => {
return state.areas.find((a) => a.id === state.selectedAreaId) || null;
}, [state.areas, state.selectedAreaId]);
return {
state,
selectArea,
addArea,
removeArea,
updateArea,
updatePoint,
startDragging,
stopDragging,
setEditMode,
getSelectedArea
};
};
// src/editor/components/EditorCanvas.tsx
var import_react4 = require("react");
// src/editor/constants.ts
var DEFAULT_EDITOR_CANVAS_STYLE = {
// 3단계 원 스타일 (외부 -> 내부)
circleLevels: [
{
radius: 0.5,
opacity: 0.3,
lineWidth: 2,
color: "rgba(255, 200, 0, 1)",
dashPattern: [8, 4]
},
{
radius: 0.33,
opacity: 0.6,
lineWidth: 2.5,
color: "rgba(255, 200, 0, 1)",
dashPattern: [8, 4]
},
{
radius: 0.167,
opacity: 0.9,
lineWidth: 3,
color: "rgba(255, 200, 0, 1)",
dashPattern: [8, 4]
}
],
// 원 내부 채우기
circleFillColor: "rgba(255, 200, 0, 0.08)",
// 중심점
centerPoint: {
radius: 5,
fillColor: "rgba(255, 200, 0, 1)",
strokeColor: "rgba(255, 255, 255, 0.8)",
strokeWidth: 2
},
// 포인트 핸들
pointHandle: {
size: 16,
fillColor: "#00aaff",
strokeColor: "white",
strokeWidth: 2,
labelColor: "#00aaff",
labelFontSize: 11
},
// 영역 외곽선
areaOutline: {
selectedColor: "#00aaff",
unselectedColor: "#888",
selectedWidth: 2,
unselectedWidth: 1,
unselectedDashPattern: [5, 5],
selectedFillColor: "rgba(0, 170, 255, 0.08)",
// 선택된 영역 배경 (연한 파란색)
unselectedFillColor: "rgba(136, 136, 136, 0.03)"
// 선택 안된 영역 배경 (연한 회색)
}
};
// src/editor/components/EditorCanvas.tsx
var import_jsx_runtime2 = require("react/jsx-runtime");
var EditorCanvas = ({
areas,
selectedAreaId,
imageSrc,
width,
height,
onUpdatePoint,
onUpdateArea,
draggingPointIndex,
onStartDragging,
onStopDragging,
style: customStyle
}) => {
const containerRef = (0, import_react4.useRef)(null);
const [canvasSize, setCanvasSize] = (0, import_react4.useState)({ width: 0, height: 0 });
const [isDraggingArea, setIsDraggingArea] = (0, import_react4.useState)(false);
const [dragStartPos, setDragStartPos] = (0, import_react4.useState)(null);
const editorStyle = (0, import_react4.useMemo)(() => ({
...DEFAULT_EDITOR_CANVAS_STYLE,
...customStyle,
circleLevels: customStyle?.circleLevels || DEFAULT_EDITOR_CANVAS_STYLE.circleLevels,
centerPoint: {
...DEFAULT_EDITOR_CANVAS_STYLE.centerPoint,
...customStyle?.centerPoint
},
pointHandle: {
...DEFAULT_EDITOR_CANVAS_STYLE.pointHandle,
...customStyle?.pointHandle
},
areaOutline: {
...DEFAULT_EDITOR_CANVAS_STYLE.areaOutline,
...customStyle?.areaOutline
}
}), [customStyle]);
(0, import_react4.useEffect)(() => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setCanvasSize({ width: rect.width, height: rect.height });
}, [width, height]);
const selectedArea = areas.find((a) => a.id === selectedAreaId);
const isPointInPolygon = (0, import_react4.useCallback)((point, polygon) => {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x, yi = polygon[i].y;
const xj = polygon[j].x, yj = polygon[j].y;
const intersect = yi > point.y !== yj > point.y && point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
}, []);
const handleMouseDown = (0, import_react4.useCallback)(
(pointIndex) => (e) => {
e.preventDefault();
e.stopPropagation();
onStartDragging(pointIndex);
},
[onStartDragging]
);
const handleCanvasMouseDown = (0, import_react4.useCallback)(
(e) => {
if (!selectedArea || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
const clickPoint = { x, y };
if (isPointInPolygon(clickPoint, selectedArea.basePoints)) {
setIsDraggingArea(true);
setDragStartPos(clickPoint);
e.preventDefault();
}
},
[selectedArea, isPointInPolygon]
);
const handleMouseMove = (0, import_react4.useCallback)(
(e) => {
if (!selectedArea || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
if (draggingPointIndex !== null) {
const clampedX = Math.max(0, Math.min(1, x));
const clampedY = Math.max(0, Math.min(1, y));
onUpdatePoint(selectedArea.id, draggingPointIndex, { x: clampedX, y: clampedY });
} else if (isDraggingArea && dragStartPos) {
const deltaX = x - dragStartPos.x;
const deltaY = y - dragStartPos.y;
const newPoints = selectedArea.basePoints.map((point) => ({
x: Math.max(0, Math.min(1, point.x + deltaX)),
y: Math.max(0, Math.min(1, point.y + deltaY))
}));
onUpdateArea(selectedArea.id, { basePoints: newPoints });
setDragStartPos({ x, y });
}
},
[draggingPointIndex, isDraggingArea, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea]
);
const handleMouseUp = (0, import_react4.useCallback)(() => {
if (draggingPointIndex !== null) {
onStopDragging();
}
if (isDraggingArea) {
setIsDraggingArea(false);
setDragStartPos(null);
}
}, [draggingPointIndex, isDraggingArea, onStopDragging]);
(0, import_react4.useEffect)(() => {
if (draggingPointIndex !== null || isDraggingArea) {
window.addEventListener("mouseup", handleMouseUp);
return () => window.removeEventListener("mouseup", handleMouseUp);
}
}, [draggingPointIndex, isDraggingArea, handleMouseUp]);
const uvToPixel = (u, v, points, canvasWidth, canvasHeight) => {
const [p0, p1, p2, p3] = points;
const leftX = p0.x * (1 - u) + p1.x * u;
const leftY = p0.y * (1 - u) + p1.y * u;
const rightX = p3.x * (1 - u) + p2.x * u;
const rightY = p3.y * (1 - u) + p2.y * u;
const posX = leftX * (1 - v) + rightX * v;
const posY = leftY * (1 - v) + rightY * v;
return {
x: posX * canvasWidth,
y: posY * canvasHeight
};
};
const drawDistortionCircle = (0, import_react4.useCallback)((ctx, points, canvasWidth, canvasHeight) => {
const segments = 128;
const centerU = 0.5;
const centerV = 0.5;
const circleLevels = editorStyle.circleLevels || [];
circleLevels.forEach((level, index) => {
const levelPoints = [];
for (let i = 0; i <= segments; i++) {
const theta = i / segments * 2 * Math.PI;
const u = centerU - level.radius * Math.sin(theta);
const v = centerV + level.radius * Math.cos(theta);
const pixelPos = uvToPixel(u, v, points, canvasWidth, canvasHeight);
levelPoints.push(pixelPos);
}
ctx.beginPath();
ctx.moveTo(levelPoints[0].x, levelPoints[0].y);
for (let i = 1; i < levelPoints.length; i++) {
ctx.lineTo(levelPoints[i].x, levelPoints[i].y);
}
ctx.closePath();
const baseColor = level.color || "rgba(255, 200, 0, 1)";
const colorWithOpacity = baseColor.replace(/rgba?\(([^)]+)\)/, (_, rgb) => {
const parts = rgb.split(",").map((p) => p.trim());
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${level.opacity})`;
});
ctx.strokeStyle = colorWithOpacity;
ctx.lineWidth = level.lineWidth;
if (level.dashPattern) {
ctx.setLineDash(level.dashPattern);
}
ctx.stroke();
ctx.setLineDash([]);
if (index === 0 && editorStyle.circleFillColor) {
ctx.fillStyle = editorStyle.circleFillColor;
ctx.fill();
}
});
const centerPointStyle = editorStyle.centerPoint || {};
const centerPixel = uvToPixel(centerU, centerV, points, canvasWidth, canvasHeight);
ctx.beginPath();
ctx.arc(centerPixel.x, centerPixel.y, centerPointStyle.radius || 5, 0, 2 * Math.PI);
if (centerPointStyle.fillColor) {
ctx.fillStyle = centerPointStyle.fillColor;
ctx.fill();
}
if (centerPointStyle.strokeColor) {
ctx.strokeStyle = centerPointStyle.strokeColor;
ctx.lineWidth = centerPointStyle.strokeWidth || 2;
ctx.stroke();
}
}, [editorStyle]);
const getCursorStyle = () => {
if (draggingPointIndex !== null) return "grabbing";
if (isDraggingArea) return "grabbing";
return "default";
};
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
"div",
{
ref: containerRef,
className: "editor-canvas",
style: { width, height, position: "relative", cursor: getCursorStyle() },
onMouseDown: handleCanvasMouseDown,
onMouseMove: handleMouseMove,
children: [
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(ImageDistortion, { imageSrc, areas }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
"svg",
{
style: {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
pointerEvents: "none"
},
children: areas.map((area) => {
const isSelected = area.id === selectedAreaId;
const points = area.basePoints;
const outlineStyle = editorStyle.areaOutline || {};
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("g", { children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
"polygon",
{
points: points.map((p) => `${p.x * canvasSize.width},${p.y * canvasSize.height}`).join(" "),
fill: isSelected ? outlineStyle.selectedFillColor || "rgba(0, 170, 255, 0.08)" : outlineStyle.unselectedFillColor || "rgba(136, 136, 136, 0.03)",
stroke: isSelected ? outlineStyle.selectedColor || "#00aaff" : outlineStyle.unselectedColor || "#888",
strokeWidth: isSelected ? outlineStyle.selectedWidth || 2 : outlineStyle.unselectedWidth || 1,
strokeDasharray: isSelected ? "0" : outlineStyle.unselectedDashPattern?.join(",") || "5,5",
opacity: isSelected ? 1 : 0.5
}
) }, area.id);
})
}
),
selectedArea && canvasSize.width > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
"canvas",
{
style: {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
pointerEvents: "none"
},
width: canvasSize.width,
height: canvasSize.height,
ref: (canvas) => {
if (canvas) {
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.clearRect(0, 0, canvasSize.width, canvasSize.height);
drawDistortionCircle(ctx, selectedArea.basePoints, canvasSize.width, canvasSize.height);
}
}
}
}
),
selectedArea && selectedArea.basePoints.map((point, index) => {
const handleStyle = editorStyle.pointHandle || {};
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
"div",
{
className: `point-handle ${draggingPointIndex === index ? "dragging" : ""}`,
style: {
position: "absolute",
left: `${point.x * 100}%`,
top: `${point.y * 100}%`,
transform: "translate(-50%, -50%)",
width: handleStyle.size || 16,
height: handleStyle.size || 16,
borderRadius: "50%",
backgroundColor: handleStyle.fillColor || "#00aaff",
border: `${handleStyle.strokeWidth || 2}px solid ${handleStyle.strokeColor || "white"}`,
cursor: "grab",
pointerEvents: "auto",
boxShadow: "0 2px 4px rgba(0,0,0,0.3)"
},
onMouseDown: handleMouseDown(index),
children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
"div",
{
style: {
position: "absolute",
top: -24,
left: "50%",
transform: "translateX(-50%)",
fontSize: handleStyle.labelFontSize || 11,
color: handleStyle.labelColor || "#00aaff",
fontWeight: "bold",
textShadow: "1px 1px 2px rgba(0,0,0,0.8)",
whiteSpace: "nowrap"
},
children: [
"P",
index + 1
]
}
)
},
index
);
})
]
}
);
};
// src/editor/components/AreaList.tsx
var import_jsx_runtime3 = require("react/jsx-runtime");
var AreaList = ({
areas,
selectedAreaId,
onSelectArea,
onRemoveArea,
onAddArea
}) => {
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "area-list", children: [
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "area-list-header", children: [
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h3", { children: "\uC65C\uACE1 \uC601\uC5ED" }),
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
"button",
{
onClick: onAddArea,
disabled: areas.length >= 8,
className: "btn-add",
title: areas.length >= 8 ? "\uCD5C\uB300 8\uAC1C \uC601\uC5ED\uAE4C\uC9C0 \uC9C0\uC6D0" : "\uC0C8 \uC601\uC5ED \uCD94\uAC00",
children: "+ \uCD94\uAC00"
}
)
] }),
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "area-list-items", children: areas.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "area-list-empty", children: "\uC601\uC5ED\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. + \uCD94\uAC00 \uBC84\uD2BC\uC744 \uB20C\uB7EC\uC8FC\uC138\uC694." }) : areas.map((area, index) => /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
"div",
{
className: `area-item ${selectedAreaId === area.id ? "selected" : ""}`,
onClick: () => onSelectArea(area.id),
children: [
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "area-item-info", children: [
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { className: "area-item-name", children: [
"\uC601\uC5ED ",
index + 1
] }),
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { className: "area-item-strength", children: [
"\uAC15\uB3C4: ",
(area.distortionStrength * 100).toFixed(0),
"%"
] })
] }),
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
"button",
{
onClick: (e) => {
e.stopPropagation();
onRemoveArea(area.id);
},
className: "btn-remove",
title: "\uC601\uC5ED \uC0AD\uC81C",
children: "\xD7"
}
)
]
},
area.id
)) })
] });
};
// src/editor/components/ParameterPanel.tsx
var import_jsx_runtime4 = require("react/jsx-runtime");
var EASING_OPTIONS = [
{ value: "linear", label: "\uC120\uD615 (Linear)" },
{ value: "easeIn", label: "\uAC00\uC18D (Ease In)" },
{ value: "easeOut", label: "\uAC10\uC18D (Ease Out)" },
{ value: "easeInOut", label: "\uAC00\uAC10\uC18D (Ease In Out)" },
{ value: "easeInQuad", label: "\uAC00\uC18D\xB2 (Ease In Quad)" },
{ value: "easeOutQuad", label: "\uAC10\uC18D\xB2 (Ease Out Quad)" }
];
var ParameterPanel = ({ area, onUpdateArea }) => {
if (!area) {
return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "parameter-panel", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "parameter-panel-empty", children: "\uC601\uC5ED\uC744 \uC120\uD0DD\uD574\uC8FC\uC138\uC694" }) });
}
return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "parameter-panel", children: [
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("h3", { children: "\uD30C\uB77C\uBBF8\uD130 \uD3B8\uC9D1" }),
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "parameter-group", children: [
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("label", { children: [
"\uC65C\uACE1 \uAC15\uB3C4: ",
(area.distortionStrength * 100).toFixed(0),
"%"
] }),
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
"input",
{
type: "range",
min: "0",
max: "1",
step: "0.01",
value: area.distortionStrength,
onChange: (e) => onUpdateArea({ distortionStrength: parseFloat(e.target.value) }),
className: "slider"
}
)
] }),
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "parameter-group", children: [
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("label", { children: [
"\uC9C0\uC18D \uC2DC\uAC04: ",
area.movement.duration.toFixed(1),
"\uCD08"
] }),
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
"input",
{
type: "number",
min: "0.1",
max: "10",
step: "0.1",
value: area.movement.duration,
onChange: (e) => onUpdateArea({
movement: { ...area.movement, duration: parseFloat(e.target.value) }
}),
className: "input-number"
}
)
] }),
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "parameter-group", children: [
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("label", { children: "\uC774\uC9D5 \uD568\uC218" }),
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
"select",
{
value: area.movement.easing,
onChange: (e) => onUpdateArea({
movement: { ...area.movement, easing: e.target.value }
}),
className: "select",
children: EASING_OPTIONS.map((option) => /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("option", { value: option.value, children: option.label }, option.value))
}
)
] }),
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "parameter-group", children: [
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("label", { children: [
"\uBCA1\uD130 X: ",
area.movement.vectorA.x.toFixed(2)
] }),
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
"input",
{
type: "range",
min: "-1",
max: "1",
step: "0.01",
value: area.movement.vectorA.x,
onChange: (e) => onUpdateArea({
movement: {
...area.movement,
vectorA: { ...area.movement.vectorA, x: parseFloat(e.target.value) }
}
}),
className: "slider"
}
)
] }),
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "parameter-group", children: [
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("label", { children: [
"\uBCA1\uD130 Y: ",
area.movement.vectorA.y.toFixed(2)
] }),
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
"input",
{
type: "range",
min: "-1",
max: "1",
step: "0.01",
value: area.movement.vectorA.y,
onChange: (e) => onUpdateArea({
movement: {
...area.movement,
vectorA: { ...area.movement.vectorA, y: parseFloat(e.target.value) }
}
}),
className: "slider"
}
)
] }),
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "parameter-group", children: [
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("label", { children: "\uD3EC\uC778\uD2B8 \uC88C\uD45C (\uCE94\uBC84\uC2A4\uC5D0\uC11C \uB4DC\uB798\uADF8)" }),
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "points-display", children: area.basePoints.map((point, idx) => /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "point-coord", children: [
"P",
idx + 1,
": (",
point.x.toFixed(3),
", ",
point.y.toFixed(3),
")"
] }, idx)) })
] })
] });
};
// src/editor/DistortionEditor.tsx
var import_jsx_runtime5 = require("react/jsx-runtime");
var DistortionEditor = ({
initialAreas = [],
imageSrc,
onAreasChange,
onSelectedAreaChange,
width = 800,
height = 600,
canvasStyle
}) => {
const {
state,
selectArea,
addArea,
removeArea,
updateArea,
updatePoint,
startDragging,
stopDragging,
getSelectedArea
} = useDistortionEditor(initialAreas);
(0, import_react5.useEffect)(() => {
onAreasChange?.(state.areas);
}, [state.areas, onAreasChange]);
(0, import_react5.useEffect)(() => {
onSelectedAreaChange?.(state.selectedAreaId);
}, [state.selectedAreaId, onSelectedAreaChange]);
const handleAddArea = () => {
const newArea = {
id: `area-${Date.now()}`,
basePoints: [
{ x: 0.3, y: 0.3 },
{ x: 0.7, y: 0.3 },
{ x: 0.7, y: 0.7 },
{ x: 0.3, y: 0.7 }
],
movement: {
vectorA: { x: DEFAULT_AREA.VECTOR_A.x, y: DEFAULT_AREA.VECTOR_A.y },
vectorB: { x: DEFAULT_AREA.VECTOR_B.x, y: DEFAULT_AREA.VECTOR_B.y },
duration: DEFAULT_AREA.DURATION,
easing: DEFAULT_AREA.EASING
},
distortionStrength: DEFAULT_AREA.DISTORTION_STRENGTH,
progress: 0,
dragVector: { x: 0, y: 0 }
};
addArea(newArea);
};
const handleUpdateArea = (updates) => {
if (state.selectedAreaId) {
updateArea(state.selectedAreaId, updates);
}
};
const selectedArea = getSelectedArea();
return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { className: "distortion-editor", children: /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "editor-main", children: [
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { className: "editor-canvas-container", children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
EditorCanvas,
{
areas: state.areas,
selectedAreaId: state.selectedAreaId,
imageSrc,
width,
height,
onUpdatePoint: updatePoint,
onUpdateArea: updateArea,
draggingPointIndex: state.draggingPointIndex,
onStartDragging: startDragging,
onStopDragging: stopDragging,
style: canvasStyle
}
) }),
/* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "editor-sidebar", children: [
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
AreaList,
{
areas: state.areas,
selectedAreaId: state.selectedAreaId,
onSelectArea: selectArea,
onRemoveArea: removeArea,
onAddArea: handleAddArea
}
),
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)(ParameterPanel, { area: selectedArea, onUpdateArea: handleUpdateArea })
] })
] }) });
};
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
ANIMATION_CONFIG,
AnimationLoop,
DEFAULT_AREA,
DistortionEditor,
ImageDistortion,
SHADER_CONFIG,
ShaderManager,
ThreeScene,
applyEasing,
useAnimationFrame,
useDistortionEditor
});
//# sourceMappingURL=index.js.map