From 004b570c5bca29d3c576e1f212ad1951b55ae2e1 Mon Sep 17 00:00:00 2001 From: wuming230 <1819845645@qq.com> Date: Sat, 15 Mar 2025 17:39:22 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=BC=A9=E6=94=BE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/.vitepress/src/@types/type-zoom.ts | 52 +++ .../category/docs/icon-exit-full-screen.svg | 3 + .../assets/category/docs/icon-full-screen.svg | 3 + .../.vitepress/src/assets/style/markdown.scss | 78 +++- .../svg-icons/icon-exit-full-screen.svg | 3 + .../src/assets/svg-icons/icon-full-screen.svg | 3 + .../.vitepress/src/components/ImgZoomDrag.vue | 48 +++ .../src/components/header/AppHeader.vue | 2 +- .../.vitepress/src/composables/useZoomDrag.ts | 369 ++++++++++++++++++ docs/.vitepress/src/layouts/LayoutDoc.vue | 121 +++++- 10 files changed, 675 insertions(+), 7 deletions(-) create mode 100644 docs/.vitepress/src/@types/type-zoom.ts create mode 100644 docs/.vitepress/src/assets/category/docs/icon-exit-full-screen.svg create mode 100644 docs/.vitepress/src/assets/category/docs/icon-full-screen.svg create mode 100644 docs/.vitepress/src/assets/svg-icons/icon-exit-full-screen.svg create mode 100644 docs/.vitepress/src/assets/svg-icons/icon-full-screen.svg create mode 100644 docs/.vitepress/src/components/ImgZoomDrag.vue create mode 100644 docs/.vitepress/src/composables/useZoomDrag.ts diff --git a/docs/.vitepress/src/@types/type-zoom.ts b/docs/.vitepress/src/@types/type-zoom.ts new file mode 100644 index 0000000..2cbf72e --- /dev/null +++ b/docs/.vitepress/src/@types/type-zoom.ts @@ -0,0 +1,52 @@ +import { type Ref, type ComputedRef } from 'vue'; + +export interface ZoomDragSizeT { + width: number; + height: number; + left: number; + top: number; +} + +export type ZoomDragMethodsT = { + fitSize: (animate?: boolean) => void; + focus: (dom: HTMLElement, padding: number) => void; +}; + +export type useZoomDragOptionsT = { + /** + * 容器区域 + */ + board: Ref | ComputedRef; + /** + * 目标区域 + */ + target?: Ref | ComputedRef; + /** + * 目标变化事件 + */ + onTargetChange?: (info: ZoomDragSizeT & { zoom: number }, methods: ZoomDragMethodsT) => void; + /** + * 容器大小变化事件 + */ + onBoardChange?: (info: ZoomDragSizeT, methods: ZoomDragMethodsT) => void; + /** + * 初始化完成事件 + */ + onReady?: () => void; + /** + * 放大缩小速率(默认 0.1) + */ + zoomSpeed?: number; + /** + * 最高放大倍速(默认 3) + */ + zoomMax?: number; + /** + * 最低缩小倍速(默认 0.2) + */ + zoomMin?: number; + /** + * 内边距 + */ + padding?: [number, number, number, number]; +}; diff --git a/docs/.vitepress/src/assets/category/docs/icon-exit-full-screen.svg b/docs/.vitepress/src/assets/category/docs/icon-exit-full-screen.svg new file mode 100644 index 0000000..f0d4ebe --- /dev/null +++ b/docs/.vitepress/src/assets/category/docs/icon-exit-full-screen.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/.vitepress/src/assets/category/docs/icon-full-screen.svg b/docs/.vitepress/src/assets/category/docs/icon-full-screen.svg new file mode 100644 index 0000000..4af1d2e --- /dev/null +++ b/docs/.vitepress/src/assets/category/docs/icon-full-screen.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/.vitepress/src/assets/style/markdown.scss b/docs/.vitepress/src/assets/style/markdown.scss index 57a96d9..f9e2fda 100644 --- a/docs/.vitepress/src/assets/style/markdown.scss +++ b/docs/.vitepress/src/assets/style/markdown.scss @@ -154,11 +154,9 @@ img { max-width: min(920px, 100%); - border-radius: var(--o-radius-m); - padding: var(--o-gap-2); + border-radius: var(--o-radius-xs); margin: 0 auto; background-color: transparent; - margin-left: -8px; } code { @@ -185,6 +183,15 @@ margin-left: 0; background-color: transparent; } + + .img-expand { + padding: 0; + margin-left: 0; + .img-expand-btn, + .img-mask { + display: none; + } + } } pre { @@ -198,7 +205,7 @@ code { display: block; overflow-x: auto; - padding: 16px 36px 16px 16px; + padding: 12px 36px 12px 16px; margin: 0; @include scrollbar; } @@ -321,6 +328,69 @@ } } } + + .img-expand { + width: auto; + position: relative; + display: inline-block; + padding: var(--o-gap-2); + margin-left: -8px; + cursor: pointer; + .img-expand-btn { + position: absolute; + width: 32px; + height: 32px; + background-color: rgba(var(--o-black), 0.4); + border: 1px solid rgba(var(--o-black), 0.1); + border-radius: var(--o-radius-xs); + opacity: 0; + z-index: 2; + top: 20px; + right: 20px; + transition: all var(--o-duration-m1) var(--o-easing-standard-in); + &::after { + content: ''; + position: absolute; + top: 3px; + left: 3px; + width: 24px; + height: 24px; + background-color: transparent; + background-image: url('@/assets/category/docs/icon-full-screen.svg'); + background-position: center; + background-size: 24px; + background-repeat: no-repeat; + } + } + + .img-mask { + position: absolute; + top: 8px; + left: 8px; + width: calc(100% - 16px); + height: calc(100% - 16px); + background-color: rgba(var(--o-black), 0.2); + border: 1px solid rgba(var(--o-black), 0.1); + opacity: 0; + border-radius: var(--o-radius-xs); + z-index: 1; + transition: all var(--o-duration-m1) var(--o-easing-standard-in); + } + + @include hover { + .img-expand-btn, + .img-mask { + opacity: 1; + } + } + } + + .img-expand-not { + .img-expand-btn, + .img-mask { + display: none; + } + } } @include in-dark { diff --git a/docs/.vitepress/src/assets/svg-icons/icon-exit-full-screen.svg b/docs/.vitepress/src/assets/svg-icons/icon-exit-full-screen.svg new file mode 100644 index 0000000..caab407 --- /dev/null +++ b/docs/.vitepress/src/assets/svg-icons/icon-exit-full-screen.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/.vitepress/src/assets/svg-icons/icon-full-screen.svg b/docs/.vitepress/src/assets/svg-icons/icon-full-screen.svg new file mode 100644 index 0000000..f48a258 --- /dev/null +++ b/docs/.vitepress/src/assets/svg-icons/icon-full-screen.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/.vitepress/src/components/ImgZoomDrag.vue b/docs/.vitepress/src/components/ImgZoomDrag.vue new file mode 100644 index 0000000..f4672e6 --- /dev/null +++ b/docs/.vitepress/src/components/ImgZoomDrag.vue @@ -0,0 +1,48 @@ + + + diff --git a/docs/.vitepress/src/components/header/AppHeader.vue b/docs/.vitepress/src/components/header/AppHeader.vue index 4b67e19..1ce3c64 100644 --- a/docs/.vitepress/src/components/header/AppHeader.vue +++ b/docs/.vitepress/src/components/header/AppHeader.vue @@ -71,7 +71,7 @@ const mobileClick = () => { left: 0; right: 0; top: 0; - z-index: 1998; + z-index: 998; box-shadow: var(--o-shadow-1); backdrop-filter: blur(5px); diff --git a/docs/.vitepress/src/composables/useZoomDrag.ts b/docs/.vitepress/src/composables/useZoomDrag.ts new file mode 100644 index 0000000..239a613 --- /dev/null +++ b/docs/.vitepress/src/composables/useZoomDrag.ts @@ -0,0 +1,369 @@ +import { ref, type Ref, reactive, watch } from 'vue'; + +import type { useZoomDragOptionsT, ZoomDragMethodsT, ZoomDragSizeT } from '@/@types/type-zoom'; + +const DefaultOptions: Partial = { + zoomSpeed: 0.1, + zoomMax: 3, + zoomMin: 1, + padding: [0, 0, 0, 0], +}; + +export default function useZoomDrag(opts: useZoomDragOptionsT): { + target: Ref; + board: Ref; + methods: ZoomDragMethodsT; +} { + const options = { ...DefaultOptions, ...opts }; + + // 状态值 + const state = reactive({ + lastLeft: 0, // 上次的left + lastTop: 0, // 上次的top + overX: 0, // 鼠标移动坐标x + overY: 0, // 鼠标移动坐标y + boardLeft: 0, // 容器区域距离浏览器左边距离 + boardTop: 0, // 容器区域距离浏览器上边距离 + + startX: 0, // 长按开始坐标x + startY: 0, // 长按开始坐标y + isDown: false, // 鼠标是否长按中 + moveX: 0, // 长按移动坐标x + moveY: 0, // 长按移动坐标y + + boardWidth: 0, // 容器区域宽 + boardHeight: 0, // 容器区高 + targetWidth: 0, // 目标区域宽 + targetHeight: 0, // 目标区域高 + }); + + const zoom = ref(0); + const left = ref(0); + const top = ref(0); + + const targetInfoRef: Ref = ref({ + width: 0, + height: 0, + left: 0, + top: 0, + zoom: 1, + }); + const boardInfoRef: Ref = ref({ + width: 0, + height: 0, + left: 0, + top: 0, + }); + + function getTarget() { + if (options.target === void 0) { + if (options.board.value !== void 0) { + return options.board.value.children[0] as HTMLElement; + } + } + + return options.target?.value; + } + + function updateTargetStyle() { + const target = getTarget(); + + if (target) { + target.style.transform = `scale(${zoom.value + 1})`; + target.style.left = `${left.value}px`; + target.style.top = `${top.value}px`; + + targetInfoRef.value = { + width: Math.round(state.targetWidth * (zoom.value + 1)), + height: Math.round(state.targetHeight * (zoom.value + 1)), + left: left.value, + top: top.value, + zoom: zoom.value, + }; + + options.onTargetChange && options.onTargetChange(targetInfoRef.value, { fitSize, focus }); + } + } + + // 自适应大小 + async function fitSize(animate = false) { + const target = getTarget(); + if (options.board.value && target) { + // 记录容器、目标大小 + [state.boardWidth, state.boardHeight, state.boardLeft, state.boardTop] = await getSize(options.board.value); + [state.targetWidth, state.targetHeight] = await getSize(target); + // + + if (animate) { + target.style.transition = 'all 0.3s ease-in'; + } + + const rateBoard = state.boardWidth / state.boardHeight; + const rateTarget = state.targetWidth / state.targetHeight; + + const [boardWidth, boardHeight] = [ + state.boardWidth - (options.padding?.[1] ?? 0) - (options.padding?.[3] ?? 0), + state.boardHeight - (options.padding?.[0] ?? 0) - (options.padding?.[2] ?? 0), + ]; + + if (rateBoard > rateTarget) { + zoom.value = boardHeight / state.targetHeight - 1; + } else if (rateBoard < rateTarget) { + zoom.value = boardWidth / state.targetWidth - 1; + } + + zoom.value = Math.floor(zoom.value * 1000) / 1000; + + if (zoom.value > 0) { + zoom.value = 0; + } + left.value = Math.round((boardWidth - state.targetWidth * (1 + zoom.value)) / 2 + (options.padding?.[3] ?? 0)); + top.value = Math.round((boardHeight - state.targetHeight * (1 + zoom.value)) / 2 + (options.padding?.[0] ?? 0)); + state.lastLeft = left.value; + state.lastTop = top.value; + + updateTargetStyle(); + + if (animate) { + setTimeout(() => { + if (target) { + target.style.transition = 'none'; + } + }, 300); + } + } + } + + // 聚焦目标 + async function focus(dom: HTMLElement, padding: number = 4) { + const target = getTarget(); + if (options.board.value && target) { + [state.boardWidth, state.boardHeight, state.boardLeft, state.boardTop] = await getSize(options.board.value); + [state.targetWidth, state.targetHeight] = await getSize(target); + + const [domWidth, domHeight, domLeft, domTop] = [dom.offsetWidth + padding * 2, dom.offsetHeight + padding * 2, dom.offsetLeft, dom.offsetTop]; + + const rateBoard = state.boardWidth / state.boardHeight; + const rateDom = domWidth / domHeight; + + const [boardWidth, boardHeight] = [ + state.boardWidth - (options.padding?.[1] ?? 0) - (options.padding?.[3] ?? 0), + state.boardHeight - (options.padding?.[0] ?? 0) - (options.padding?.[2] ?? 0), + ]; + + if (rateBoard > rateDom) { + zoom.value = boardHeight / domHeight - 1; + } else if (rateBoard < rateDom) { + zoom.value = boardWidth / domWidth - 1; + } + + zoom.value = Math.floor(zoom.value * 1000) / 1000; + + left.value = Math.round( + (boardWidth - domWidth * (1 + zoom.value)) / 2 + (options.padding?.[3] ?? 0) - domLeft * (1 + zoom.value) + padding * (1 + zoom.value) + ); + top.value = Math.round( + (boardHeight - domHeight * (1 + zoom.value)) / 2 + (options.padding?.[0] ?? 0) - domTop * (1 + zoom.value) + padding * (1 + zoom.value) + ); + state.lastLeft = left.value; + state.lastTop = top.value; + + updateTargetStyle(); + } + } + + // 放大缩小 + function changeZoom(value: number) { + const target = getTarget(); + + if (options.board.value && target) { + const lastTargetWidth = state.targetWidth * (1 + zoom.value); + const lastTargetHeight = state.targetHeight * (1 + zoom.value); + const lastOffsetX = state.overX - state.lastLeft - state.boardLeft; + const lastOffsetY = state.overY - state.lastTop - state.boardTop; + const rateX = lastOffsetX / lastTargetWidth; + const rateY = lastOffsetY / lastTargetHeight; + + zoom.value += value; + zoom.value = Math.round(zoom.value * 1000) / 1000; + + const newTargetWidth = state.targetWidth * (1 + zoom.value); + const newTargetHeight = state.targetHeight * (1 + zoom.value); + + const newSpanX = newTargetWidth * rateX - lastOffsetX; + const newSpanY = newTargetHeight * rateY - lastOffsetY; + + left.value = Math.round(state.lastLeft - newSpanX); + top.value = Math.round(state.lastTop - newSpanY); + state.lastLeft = left.value; + state.lastTop = top.value; + + updateTargetStyle(); + } + } + + // 获取元素大小 + async function getSize(ele: HTMLElement | undefined): Promise<[number, number, number, number]> { + function inner(resolve: (res: [number, number, number, number]) => void) { + if (ele) { + const { left, top } = ele.getBoundingClientRect(); + const [width, height] = [ele.clientWidth, ele.clientHeight]; + resolve([width, height, left, top]); + } else { + resolve([0, 0, 0, 0]); + } + } + return new Promise((resolve) => { + if (ele) { + if (ele instanceof HTMLImageElement) { + if (ele.complete) { + inner(resolve); + } else { + ele.onload = () => { + inner(resolve); + }; + } + } else { + inner(resolve); + } + } else { + resolve([0, 0, 0, 0]); + } + }); + } + + const eventHandlers = { + zoom: (e: WheelEvent) => { + if (e.deltaY < 0) { + if (zoom.value <= options.zoomMax! - options.zoomSpeed!) { + changeZoom(options.zoomSpeed!); + } + } else if (e.deltaY > 0) { + if (zoom.value >= options.zoomMin! - 0.5 + options.zoomSpeed!) { + changeZoom(-options.zoomSpeed!); + } + } + + e.preventDefault(); + }, + contextmenu: (e: MouseEvent) => { + e.preventDefault(); + }, + dragStart: (e: MouseEvent) => { + if (e.button === 0) { + state.startX = e.clientX; + state.startY = e.clientY; + state.isDown = true; + } + }, + dragMove: (e: MouseEvent) => { + state.overX = e.clientX; + state.overY = e.clientY; + if (state.isDown) { + state.moveX = e.clientX; + state.moveY = e.clientY; + left.value = Math.round(state.lastLeft + state.moveX - state.startX); + top.value = Math.round(state.lastTop + state.moveY - state.startY); + + updateTargetStyle(); + } + }, + dragEnd: () => { + state.isDown = false; + state.lastLeft = left.value; + state.lastTop = top.value; + }, + }; + + // 事件处理 + function eventHandle() { + const target = getTarget(); + + if (options.board.value && target) { + options.board.value.addEventListener('wheel', eventHandlers.zoom); + // + options.board.value.addEventListener('mousedown', eventHandlers.dragStart); + options.board.value.addEventListener('mousemove', eventHandlers.dragMove); + options.board.value.addEventListener('mouseup', eventHandlers.dragEnd); + options.board.value.addEventListener('mouseleave', eventHandlers.dragEnd); + // + options.board.value.addEventListener('contextmenu', eventHandlers.contextmenu); + // + const resizeObserver = new ResizeObserver(async () => { + [state.boardWidth, state.boardHeight, state.boardLeft, state.boardTop] = await getSize(options.board.value); + + boardInfoRef.value = { + width: state.boardWidth, + height: state.boardHeight, + left: state.boardLeft, + top: state.boardTop, + }; + + options.onBoardChange && options.onBoardChange(boardInfoRef.value, { fitSize, focus }); + }); + resizeObserver.observe(options.board.value); + } + } + + // 切换鼠标 cursor + watch( + () => state.isDown, + () => { + options.board.value && (options.board.value.style.cursor = state.isDown ? 'pointer' : 'default'); + } + ); + + // 容器区域必须样式 + function boardStyle() { + if (options.board.value) { + const boardComputedStyle = getComputedStyle(options.board.value); + options.board.value.style.overflow = 'hidden'; + options.board.value.style.userSelect = 'none'; + if (!['absolute', 'relative', 'fixed'].includes(boardComputedStyle.position)) { + options.board.value.style.position = 'relative'; + } + } + } + + // 目标区域必须样式 + function targetStyle() { + const target = getTarget(); + + if (target) { + target.style.position = 'absolute'; + target.style.transform = 'scale(1)'; + target.style.transformOrigin = '0 0'; + target.style.userSelect = 'none'; + target.draggable = false; + } + } + + watch( + () => [options.board.value, options.target?.value], + async () => { + const target = getTarget(); + + if (options.board.value && target) { + // 必须样式 + boardStyle(); + targetStyle(); + // 事件控制 + eventHandle(); + // + await fitSize(); + + // 初始化完成 + options.onReady && options.onReady(); + } + }, + { + immediate: true, + } + ); + + return { + target: targetInfoRef, + board: boardInfoRef, + methods: { fitSize, focus }, + }; +} diff --git a/docs/.vitepress/src/layouts/LayoutDoc.vue b/docs/.vitepress/src/layouts/LayoutDoc.vue index 8512c90..934310d 100644 --- a/docs/.vitepress/src/layouts/LayoutDoc.vue +++ b/docs/.vitepress/src/layouts/LayoutDoc.vue @@ -2,7 +2,21 @@ import { onMounted, onUnmounted, ref, computed, onUpdated, watch } from 'vue'; import { storeToRefs } from 'pinia'; import { Content, useRouter, useRoute, useData } from 'vitepress'; -import { OIcon, ODropdown, ODropdownItem, OPopup, ODialog, ORadioGroup, ORadio, OToggle, OButton, ODivider, OScroller, useMessage } from '@opensig/opendesign'; +import { + OIcon, + ODropdown, + ODropdownItem, + OPopup, + ODialog, + ORadioGroup, + ORadio, + OToggle, + OButton, + ODivider, + OScroller, + useMessage, + OLayer, +} from '@opensig/opendesign'; import AppFooter2 from '@/components/AppFooter2.vue'; import DocSearch from '@/components/DocSearch.vue'; @@ -18,6 +32,10 @@ import TheDocsNode from '@/views/docsNode/TheDocsNode.vue'; import IconChevronDown from '~icons/app/icon-chevron-down.svg'; import IconExpand from '~icons/app/icon-expand.svg'; +import ImgZoomDrag from '@/components/ImgZoomDrag.vue'; + +import IconExitFullScreen from '~icons/app/icon-exit-full-screen.svg'; + import { useScreen } from '@/composables/useScreen'; import useSelect from '@/composables/useSelect'; import { useLocale } from '@/composables/useLocale'; @@ -252,7 +270,7 @@ const moduleNode = computed(() => { const childMenu = nodeMenu?.children?.find((item) => item?.href && pathHref.includes(item.href.replace('index.html', ''))); return childMenu; } - + return nodeMenu; }); @@ -374,6 +392,41 @@ onMounted(() => { onUnmounted(() => { maskRef.value?.removeEventListener('click', handleOutClick); }); + +// -------------------- 图片缩放 -------------------- +const imgUrl = ref(''); +const zoomVisible = ref(false); +const zoomWidth = ref(0); +const zoomHeight = ref(0); + +onMounted(() => { + const imgDom = document.querySelectorAll('.markdown-body img'); + imgDom.forEach((img) => { + const image = new Image(); + image.src = `${img.src}?time=${new Date().getTime()}`; + image.onload = () => { + if (img.clientWidth < 200) { + img.parentElement.classList.add('img-expand-not'); + } else { + const btn = img.parentElement.querySelector('.img-expand-btn'); + btn.addEventListener('click', () => { + zoomWidth.value = img.naturalWidth; + zoomHeight.value = img.naturalHeight; + + imgUrl.value = img.src; + zoomVisible.value = true; + }); + } + }; + }); +}); + +const closeZoomBtn = () => { + zoomVisible.value = false; +}; + +const demoRef = ref(); +const readyVisible = ref(false); @@ -863,4 +944,40 @@ onUnmounted(() => { .version-scroller { max-height: 200px; } + +.img-scaler { + position: relative; +} +.close-btn { + position: absolute; + width: 32px; + height: 32px; + background-color: rgba(var(--o-black), 0.4); + border: 1px solid rgba(var(--o-black), 0.1); + border-radius: var(--o-radius-xs); + z-index: 2; + top: 12px; + right: 12px; + cursor: pointer; + .icon { + color: var(--o-color-white); + margin-top: 3px; + margin-left: 3px; + } +} + +.demo { + display: inline-block; + overflow: hidden; + background-color: #fff; + max-width: calc(100vw - 80px); + + &::before { + position: absolute; + width: 100%; + color: #999; + font-size: 24px; + text-align: center; + } +} -- Gitee