포트폴리오 확대 기능 추가
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user