포트폴리오 확대 기능 추가
This commit is contained in:
@@ -9,7 +9,11 @@ import "ag-grid-community/styles/ag-theme-quartz.css";
|
|||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
const DEFAULT_SELECTOR = "[data-portfolio-list-panel]";
|
const DEFAULT_SELECTOR = "[data-portfolio-list-panel]";
|
||||||
|
const DETAIL_CONTENT_SELECTOR = ".portfolio-content";
|
||||||
const DEFAULT_PAGE_SIZE = 5;
|
const DEFAULT_PAGE_SIZE = 5;
|
||||||
|
const MIN_ZOOM_SCALE = 1;
|
||||||
|
const MAX_ZOOM_SCALE = 6;
|
||||||
|
const WHEEL_ZOOM_STEP = 0.0015;
|
||||||
|
|
||||||
function toDate(value) {
|
function toDate(value) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -318,8 +322,287 @@ function initPortfolioListPanel(selector = DEFAULT_SELECTOR) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
initPortfolioListPanel();
|
initPortfolioListPanel();
|
||||||
|
initPortfolioImageZoom();
|
||||||
});
|
});
|
||||||
|
|
||||||
export { initPortfolioListPanel };
|
export { initPortfolioImageZoom, initPortfolioListPanel };
|
||||||
|
|||||||
@@ -342,3 +342,98 @@
|
|||||||
height: 265px;
|
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}">
|
layout:decorate="~{kr/iotdoor/comn/site/layout/cmmnSiteLayout.html}">
|
||||||
|
|
||||||
<th:block layout:fragment="pageStyles">
|
<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-public.css?v=20260507}">
|
||||||
<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-post-list.css?v=20260507}">
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<th:block layout:fragment="content">
|
<th:block layout:fragment="content">
|
||||||
@@ -72,6 +72,6 @@
|
|||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<th:block layout:fragment="script">
|
<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>
|
</th:block>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
layout:decorate="~{kr/iotdoor/comn/site/layout/cmmnSiteLayout.html}">
|
layout:decorate="~{kr/iotdoor/comn/site/layout/cmmnSiteLayout.html}">
|
||||||
|
|
||||||
<th:block layout:fragment="pageStyles">
|
<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-public.css?v=20260507}">
|
||||||
<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-post-list.css?v=20260507}">
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<th:block layout:fragment="content">
|
<th:block layout:fragment="content">
|
||||||
@@ -91,6 +91,6 @@
|
|||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<th:block layout:fragment="script">
|
<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>
|
</th:block>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user