포트폴리오 확대 기능 추가

This commit is contained in:
CSKIM
2026-05-07 18:19:03 +09:00
parent 348288caeb
commit ec50a60a31
5 changed files with 415 additions and 37 deletions
+286 -3
View File
@@ -9,7 +9,11 @@ import "ag-grid-community/styles/ag-theme-quartz.css";
ModuleRegistry.registerModules([AllCommunityModule]);
const DEFAULT_SELECTOR = "[data-portfolio-list-panel]";
const DETAIL_CONTENT_SELECTOR = ".portfolio-content";
const DEFAULT_PAGE_SIZE = 5;
const MIN_ZOOM_SCALE = 1;
const MAX_ZOOM_SCALE = 6;
const WHEEL_ZOOM_STEP = 0.0015;
function toDate(value) {
if (!value) {
@@ -318,8 +322,287 @@ function initPortfolioListPanel(selector = DEFAULT_SELECTOR) {
};
}
document.addEventListener("DOMContentLoaded", () => {
initPortfolioListPanel();
function getTouchDistance(firstPointer, secondPointer) {
return Math.hypot(
firstPointer.clientX - secondPointer.clientX,
firstPointer.clientY - secondPointer.clientY,
);
}
function getTouchCenter(firstPointer, secondPointer) {
return {
x: (firstPointer.clientX + secondPointer.clientX) / 2,
y: (firstPointer.clientY + secondPointer.clientY) / 2,
};
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function initPortfolioImageZoom(selector = DETAIL_CONTENT_SELECTOR) {
const content = document.querySelector(selector);
if (!content) {
return null;
}
const images = Array.from(content.querySelectorAll("img")).filter(
(image) => image.getAttribute("src"),
);
if (!images.length) {
return null;
}
const overlay = document.createElement("div");
overlay.className = "portfolio-image-zoom";
overlay.hidden = true;
overlay.setAttribute("aria-hidden", "true");
const stage = document.createElement("div");
stage.className = "portfolio-image-zoom-stage";
const zoomImage = document.createElement("img");
zoomImage.className = "portfolio-image-zoom-img";
zoomImage.alt = "";
zoomImage.draggable = false;
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "portfolio-image-zoom-close";
closeButton.setAttribute("aria-label", "이미지 확대 닫기");
closeButton.textContent = "X";
stage.appendChild(zoomImage);
overlay.append(stage, closeButton);
document.body.appendChild(overlay);
const pointers = new Map();
let lastFocusedElement = null;
let scale = MIN_ZOOM_SCALE;
let translateX = 0;
let translateY = 0;
let startDistance = 0;
let startScale = MIN_ZOOM_SCALE;
let startCenter = null;
let startTranslateX = 0;
let startTranslateY = 0;
let lastPanPointer = null;
const applyTransform = () => {
zoomImage.style.transform = `translate3d(${translateX}px, ${translateY}px, 0) scale(${scale})`;
overlay.classList.toggle("is-zoomed", scale > MIN_ZOOM_SCALE);
};
const resetTransform = () => {
scale = MIN_ZOOM_SCALE;
translateX = 0;
translateY = 0;
startDistance = 0;
startScale = MIN_ZOOM_SCALE;
startCenter = null;
startTranslateX = 0;
startTranslateY = 0;
lastPanPointer = null;
pointers.clear();
applyTransform();
};
const closeZoom = () => {
overlay.hidden = true;
overlay.setAttribute("aria-hidden", "true");
document.body.classList.remove("portfolio-image-zoom-open");
resetTransform();
if (lastFocusedElement && typeof lastFocusedElement.focus === "function") {
lastFocusedElement.focus({ preventScroll: true });
}
};
const openZoom = (image) => {
lastFocusedElement = document.activeElement;
zoomImage.src = image.currentSrc || image.src;
zoomImage.alt = image.alt || "";
overlay.hidden = false;
overlay.setAttribute("aria-hidden", "false");
document.body.classList.add("portfolio-image-zoom-open");
resetTransform();
closeButton.focus({ preventScroll: true });
};
const updateGesture = () => {
const activePointers = Array.from(pointers.values());
if (activePointers.length >= 2) {
const [firstPointer, secondPointer] = activePointers;
const distance = getTouchDistance(firstPointer, secondPointer);
const center = getTouchCenter(firstPointer, secondPointer);
if (!startDistance || !startCenter) {
startDistance = distance;
startScale = scale;
startCenter = center;
startTranslateX = translateX;
startTranslateY = translateY;
}
scale = clamp(
startScale * (distance / startDistance),
MIN_ZOOM_SCALE,
MAX_ZOOM_SCALE,
);
if (scale <= MIN_ZOOM_SCALE) {
translateX = 0;
translateY = 0;
} else {
translateX = startTranslateX + center.x - startCenter.x;
translateY = startTranslateY + center.y - startCenter.y;
}
lastPanPointer = null;
applyTransform();
return;
}
startDistance = 0;
startCenter = null;
if (activePointers.length === 1 && scale > MIN_ZOOM_SCALE) {
const [pointer] = activePointers;
if (lastPanPointer) {
translateX += pointer.clientX - lastPanPointer.clientX;
translateY += pointer.clientY - lastPanPointer.clientY;
}
lastPanPointer = { clientX: pointer.clientX, clientY: pointer.clientY };
applyTransform();
return;
}
lastPanPointer = null;
};
const zoomAtPoint = (nextScale, clientX, clientY) => {
const previousScale = scale;
const rect = zoomImage.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
scale = clamp(nextScale, MIN_ZOOM_SCALE, MAX_ZOOM_SCALE);
if (scale <= MIN_ZOOM_SCALE) {
translateX = 0;
translateY = 0;
applyTransform();
return;
}
const scaleRatio = scale / previousScale;
translateX += (1 - scaleRatio) * (clientX - centerX);
translateY += (1 - scaleRatio) * (clientY - centerY);
applyTransform();
};
images.forEach((image) => {
image.classList.add("portfolio-zoomable-image");
image.setAttribute("tabindex", "0");
image.setAttribute("role", "button");
image.setAttribute("aria-label", "이미지 원본 확대 보기");
image.addEventListener("click", (event) => {
event.preventDefault();
openZoom(image);
});
export { initPortfolioListPanel };
image.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
event.preventDefault();
openZoom(image);
});
});
overlay.addEventListener("click", (event) => {
if (event.target === overlay || event.target === stage) {
closeZoom();
}
});
closeButton.addEventListener("click", closeZoom);
overlay.addEventListener("pointerdown", (event) => {
if (event.target !== zoomImage && event.target !== stage) {
return;
}
if (event.pointerType === "mouse" && event.button !== 0) {
return;
}
pointers.set(event.pointerId, event);
overlay.setPointerCapture?.(event.pointerId);
updateGesture();
});
overlay.addEventListener("pointermove", (event) => {
if (!pointers.has(event.pointerId)) {
return;
}
pointers.set(event.pointerId, event);
updateGesture();
});
const releasePointer = (event) => {
pointers.delete(event.pointerId);
overlay.releasePointerCapture?.(event.pointerId);
startDistance = 0;
startCenter = null;
lastPanPointer = null;
};
overlay.addEventListener("pointerup", releasePointer);
overlay.addEventListener("pointercancel", releasePointer);
overlay.addEventListener(
"wheel",
(event) => {
if (overlay.hidden) {
return;
}
event.preventDefault();
const nextScale = scale * (1 - event.deltaY * WHEEL_ZOOM_STEP);
zoomAtPoint(nextScale, event.clientX, event.clientY);
},
{ passive: false },
);
zoomImage.addEventListener("dblclick", (event) => {
event.preventDefault();
zoomAtPoint(scale > MIN_ZOOM_SCALE ? MIN_ZOOM_SCALE : 2.5, event.clientX, event.clientY);
});
document.addEventListener("keydown", (event) => {
if (overlay.hidden || event.key !== "Escape") {
return;
}
closeZoom();
});
return {
open: openZoom,
close: closeZoom,
};
}
document.addEventListener("DOMContentLoaded", () => {
initPortfolioListPanel();
initPortfolioImageZoom();
});
export { initPortfolioImageZoom, initPortfolioListPanel };
@@ -342,3 +342,98 @@
height: 265px;
}
}
.portfolio-zoomable-image {
cursor: zoom-in;
}
.portfolio-zoomable-image:focus-visible {
outline: 3px solid rgba(31, 90, 160, 0.35);
outline-offset: 4px;
}
.portfolio-image-zoom-open {
overflow: hidden;
}
.portfolio-image-zoom[hidden] {
display: none;
}
.portfolio-image-zoom {
position: fixed;
z-index: 1080;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.88);
padding: 72px 24px 32px;
}
.portfolio-image-zoom-stage {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
overflow: hidden;
touch-action: none;
}
.portfolio-image-zoom-img {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
cursor: grab;
user-select: none;
will-change: transform;
transition: transform 0.08s ease-out;
transform-origin: center center;
}
.portfolio-image-zoom.is-zoomed .portfolio-image-zoom-img {
cursor: move;
transition: none;
}
.portfolio-image-zoom-close {
position: fixed;
top: 22px;
right: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: 1px solid rgba(255, 255, 255, 0.32);
border-radius: 50%;
background: rgba(0, 0, 0, 0.42);
color: #ffffff;
font-family: Arial, sans-serif;
font-size: 20px;
font-weight: 600;
line-height: 1;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
}
.portfolio-image-zoom-close:hover,
.portfolio-image-zoom-close:focus-visible {
border-color: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.16);
outline: 0;
}
@media (max-width: 575.98px) {
.portfolio-image-zoom {
padding: 64px 12px 20px;
}
.portfolio-image-zoom-close {
top: 14px;
right: 14px;
width: 42px;
height: 42px;
}
}
File diff suppressed because one or more lines are too long
@@ -6,8 +6,8 @@
layout:decorate="~{kr/iotdoor/comn/site/layout/cmmnSiteLayout.html}">
<th:block layout:fragment="pageStyles">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-public.css?v=20260421}">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-post-list.css?v=20260421}">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-public.css?v=20260507}">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-post-list.css?v=20260507}">
</th:block>
<th:block layout:fragment="content">
@@ -72,6 +72,6 @@
</th:block>
<th:block layout:fragment="script">
<script th:src="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-public.js?v=20260421}"></script>
<script th:src="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-public.js?v=20260507}"></script>
</th:block>
</html>
@@ -6,8 +6,8 @@
layout:decorate="~{kr/iotdoor/comn/site/layout/cmmnSiteLayout.html}">
<th:block layout:fragment="pageStyles">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-public.css?v=20260421}">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-post-list.css?v=20260421}">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-public.css?v=20260507}">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-post-list.css?v=20260507}">
</th:block>
<th:block layout:fragment="content">
@@ -91,6 +91,6 @@
</th:block>
<th:block layout:fragment="script">
<script th:src="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-public.js?v=20260421}"></script>
<script th:src="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-public.js?v=20260507}"></script>
</th:block>
</html>