diff --git a/packages/drawnix/src/drawnix.tsx b/packages/drawnix/src/drawnix.tsx index 7b962ddc..cee34470 100644 --- a/packages/drawnix/src/drawnix.tsx +++ b/packages/drawnix/src/drawnix.tsx @@ -10,7 +10,7 @@ import { ThemeColorMode, Viewport, } from '@plait/core'; -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { withGroup } from '@plait/common'; import { withDraw } from '@plait/draw'; import { MindThemeColors, withMind } from '@plait/mind'; diff --git a/packages/drawnix/src/libs/image-viewer.ts b/packages/drawnix/src/libs/image-viewer.ts new file mode 100644 index 00000000..750719c0 --- /dev/null +++ b/packages/drawnix/src/libs/image-viewer.ts @@ -0,0 +1,416 @@ +interface ImageViewerOptions { + zoomStep?: number; + minZoom?: number; + maxZoom?: number; + enableKeyboard?: boolean; +} + +interface ImageState { + zoom: number; + x: number; + y: number; + isDragging: boolean; + dragStartX: number; + dragStartY: number; + imageStartX: number; + imageStartY: number; +} + +export class ImageViewer { + private options: Required; + private overlay: HTMLDivElement | null = null; + private imageContainer: HTMLDivElement | null = null; + private image: HTMLImageElement | null = null; + private closeButton: HTMLDivElement | null = null; + private controlsContainer: HTMLDivElement | null = null; + private delegationHandler: ((e: Event) => void) | null = null; + private dragHandler: ((e: MouseEvent) => void) | null = null; + private mouseUpHandler: (() => void) | null = null; + private animationFrameId: number | null = null; + private pendingUpdate = false; + private state: ImageState = { + zoom: 1, + x: 0, + y: 0, + isDragging: false, + dragStartX: 0, + dragStartY: 0, + imageStartX: 0, + imageStartY: 0, + }; + + constructor(options: ImageViewerOptions = {}) { + this.options = { + zoomStep: options.zoomStep || 0.2, + minZoom: options.minZoom || 0.1, + maxZoom: options.maxZoom || 5, + enableKeyboard: options.enableKeyboard !== false, + }; + + this.addStyles(); + this.bindEvents(); + } + + // 打开图片查看器 + open(src: string, alt = ''): void { + this.createOverlay(); + this.createImage(src, alt); + this.resetState(); + document.body.style.overflow = 'hidden'; + } + + // 关闭图片查看器 + close(): void { + if (this.overlay) { + // 清理拖动事件监听器 + this.cleanupDragEvents(); + + // 清理全局事件监听器 + document.removeEventListener('mousemove', this.delegationHandler!); + document.removeEventListener('mouseup', this.delegationHandler!); + document.removeEventListener('keydown', this.delegationHandler!); + document.removeEventListener('wheel', this.delegationHandler!); + + // 取消动画帧 + if (this.animationFrameId) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + + document.body.removeChild(this.overlay); + this.overlay = null; + this.image = null; + this.imageContainer = null; + this.closeButton = null; + this.controlsContainer = null; + this.delegationHandler = null; + this.dragHandler = null; + this.mouseUpHandler = null; + this.pendingUpdate = false; + } + document.body.style.overflow = ''; + } + + // 创建遮罩层 + private createOverlay(): void { + this.overlay = document.createElement('div'); + this.overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(45, 45, 45, 0.95); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + cursor: grab; + `; + + // 点击遮罩层关闭 + this.overlay.addEventListener('click', (e) => { + if (e.target === this.overlay) { + this.close(); + } + }); + + this.createCloseButton(); + this.createControls(); + document.body.appendChild(this.overlay); + } + + // 创建关闭按钮 + private createCloseButton(): void { + this.closeButton = document.createElement('div'); + this.closeButton.innerHTML = '×'; + this.closeButton.className = 'image-viewer-close-btn'; + this.closeButton.addEventListener('click', () => this.close()); + this.overlay!.appendChild(this.closeButton); + } + + // 创建控制按钮 + private createControls(): void { + this.controlsContainer = document.createElement('div'); + this.controlsContainer.style.cssText = ` + position: absolute; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 10px; + z-index: 10001; + `; + + this.addStyles(); + + // 放大按钮 + const zoomInBtn = document.createElement('button'); + zoomInBtn.innerHTML = '+'; + zoomInBtn.className = 'image-viewer-control-btn'; + zoomInBtn.addEventListener('click', () => this.zoomIn()); + + // 缩小按钮 + const zoomOutBtn = document.createElement('button'); + zoomOutBtn.innerHTML = '-'; + zoomOutBtn.className = 'image-viewer-control-btn'; + zoomOutBtn.addEventListener('click', () => this.zoomOut()); + + // 重置按钮 + const resetBtn = document.createElement('button'); + resetBtn.innerHTML = '⌂'; + resetBtn.className = 'image-viewer-control-btn'; + resetBtn.addEventListener('click', () => this.resetState()); + + this.controlsContainer.appendChild(zoomOutBtn); + this.controlsContainer.appendChild(resetBtn); + this.controlsContainer.appendChild(zoomInBtn); + this.overlay!.appendChild(this.controlsContainer); + } + + // 创建图片元素 + private createImage(src: string, alt: string): void { + this.imageContainer = document.createElement('div'); + this.imageContainer.style.cssText = ` + position: relative; + cursor: grab; + display: flex; + align-items: center; + justify-content: center; + max-width: calc(100vw - 80px); + max-height: calc(100vh - 160px); + `; + + this.image = document.createElement('img'); + this.image.src = src; + this.image.alt = alt; + this.image.style.cssText = ` + max-width: calc(100vw - 80px); + max-height: calc(100vh - 160px); + width: auto; + height: auto; + display: block; + user-select: none; + pointer-events: none; + object-fit: contain; + `; + + this.imageContainer.appendChild(this.image); + this.overlay!.appendChild(this.imageContainer); + + // 绑定拖拽事件 + this.bindDragEvents(); + } + + // 绑定拖拽事件 + private bindDragEvents(): void { + if (!this.imageContainer) return; + + // 使用 requestAnimationFrame 优化的拖动处理器 + this.dragHandler = (e: MouseEvent) => { + if (!this.state.isDragging) return; + + const deltaX = e.clientX - this.state.dragStartX; + const deltaY = e.clientY - this.state.dragStartY; + + this.state.x = this.state.imageStartX + deltaX; + this.state.y = this.state.imageStartY + deltaY; + + // 使用 requestAnimationFrame 优化渲染 + if (!this.pendingUpdate) { + this.pendingUpdate = true; + this.animationFrameId = requestAnimationFrame(() => { + this.updateImageTransform(); + this.pendingUpdate = false; + }); + } + }; + + this.mouseUpHandler = () => { + if (this.state.isDragging) { + this.state.isDragging = false; + if (this.imageContainer) { + this.imageContainer.style.cursor = 'grab'; + } + if (this.overlay) { + this.overlay.style.cursor = 'grab'; + } + this.cleanupDragEvents(); + } + }; + + this.imageContainer.addEventListener('mousedown', (e) => { + e.preventDefault(); + this.state.isDragging = true; + this.state.dragStartX = e.clientX; + this.state.dragStartY = e.clientY; + this.state.imageStartX = this.state.x; + this.state.imageStartY = this.state.y; + + if (this.imageContainer) { + this.imageContainer.style.cursor = 'grabbing'; + } + if (this.overlay) { + this.overlay.style.cursor = 'grabbing'; + } + + // 添加事件监听器 + if (this.dragHandler && this.mouseUpHandler) { + document.addEventListener('mousemove', this.dragHandler, { passive: true }); + document.addEventListener('mouseup', this.mouseUpHandler, { once: true }); + } + }); + } + + // 清理拖动事件监听器 + private cleanupDragEvents(): void { + if (this.dragHandler) { + document.removeEventListener('mousemove', this.dragHandler); + } + if (this.mouseUpHandler) { + document.removeEventListener('mouseup', this.mouseUpHandler); + } + } + + // 绑定全局事件 + private bindEvents(): void { + this.delegationHandler = (e: Event) => { + if (!this.overlay) return; + + if (e.type === 'keydown' && this.options.enableKeyboard) { + const keyboardEvent = e as KeyboardEvent; + switch (keyboardEvent.key) { + case 'Escape': + this.close(); + break; + case '+': + case '=': + keyboardEvent.preventDefault(); + this.zoomIn(); + break; + case '-': + keyboardEvent.preventDefault(); + this.zoomOut(); + break; + case '0': + keyboardEvent.preventDefault(); + this.resetState(); + break; + } + } else if (e.type === 'wheel') { + const wheelEvent = e as WheelEvent; + wheelEvent.preventDefault(); + if (wheelEvent.deltaY < 0) { + this.zoomIn(); + } else { + this.zoomOut(); + } + } + }; + + document.addEventListener('keydown', this.delegationHandler); + document.addEventListener('wheel', this.delegationHandler, { + passive: false, + }); + } + + // 放大 + private zoomIn(): void { + this.state.zoom = Math.min( + this.state.zoom + this.options.zoomStep, + this.options.maxZoom + ); + this.updateImageTransform(); + } + + // 缩小 + private zoomOut(): void { + this.state.zoom = Math.max( + this.state.zoom - this.options.zoomStep, + this.options.minZoom + ); + this.updateImageTransform(); + } + + // 重置状态 + private resetState(): void { + this.state.zoom = 1; + this.state.x = 0; + this.state.y = 0; + this.updateImageTransform(); + } + + // 更新图片变换 + private updateImageTransform(): void { + if (!this.imageContainer) return; + this.imageContainer.style.transform = ` + translate(${this.state.x}px, ${this.state.y}px) + scale(${this.state.zoom}) + `; + } + + private styleElement: HTMLStyleElement | null = null; + + // 添加样式 + private addStyles(): void { + if (!this.styleElement) { + this.styleElement = document.createElement('style'); + this.styleElement.textContent = ` + .image-viewer-control-btn { + background: rgba(0, 0, 0, 0.8); + color: white; + border: none; + padding: 8px 14px; + border-radius: 4px; + cursor: pointer; + font-size: 18px; + transition: background 0.2s; + user-select: none; + } + + .image-viewer-control-btn:hover { + background: rgba(0, 0, 0, 0.4); + } + + .image-viewer-close-btn { + position: absolute; + top: 20px; + right: 30px; + color: white; + font-size: 18px; + cursor: pointer; + z-index: 10001; + user-select: none; + width: 36px; + height: 34px; + display: flex; + border-radius: 50%; + justify-content: center; + background: rgba(0, 0, 0, 0.8); + transition: all 0.2s ease; + line-height: 34px; + padding-bottom:2px; + } + + .image-viewer-close-btn:hover { + background: rgba(0, 0, 0, 0.4); + } + `; + document.head.appendChild(this.styleElement); + } + } + + // 移除样式 + private removeStyles(): void { + if (this.styleElement) { + document.head.removeChild(this.styleElement); + this.styleElement = null; + } + } + + // 销毁实例 + destroy(): void { + this.close(); + this.removeStyles(); + } +} diff --git a/packages/drawnix/src/plugins/components/image.tsx b/packages/drawnix/src/plugins/components/image.tsx index e253c6a5..fdb19af0 100644 --- a/packages/drawnix/src/plugins/components/image.tsx +++ b/packages/drawnix/src/plugins/components/image.tsx @@ -1,9 +1,20 @@ import type { ImageProps } from '@plait/common'; +import classNames from 'classnames'; export const Image: React.FC = (props: ImageProps) => { + const imgProps = { + src: props.imageItem.url, + draggable: false, + width: '100%', + }; return (
- +
); }; diff --git a/packages/drawnix/src/plugins/with-image.tsx b/packages/drawnix/src/plugins/with-image.tsx index 97d211a7..3208973e 100644 --- a/packages/drawnix/src/plugins/with-image.tsx +++ b/packages/drawnix/src/plugins/with-image.tsx @@ -1,6 +1,13 @@ -import { type PlaitImageBoard } from '@plait/common'; +import { + getElementOfFocusedImage, + isResizing, + type PlaitImageBoard, +} from '@plait/common'; import { ClipboardData, + getHitElementByPoint, + isDragging, + isSelectionMoving, PlaitBoard, Point, toHostPoint, @@ -9,10 +16,18 @@ import { } from '@plait/core'; import { isSupportedImageFileType } from '../data/blob'; import { insertImage } from '../data/image'; +import { isHitImage, MindElement, ImageData } from '@plait/mind'; +import { ImageViewer } from '../libs/image-viewer'; export const withImagePlugin = (board: PlaitBoard) => { const newBoard = board as PlaitBoard & PlaitImageBoard; - const { insertFragment, drop } = newBoard; + const { insertFragment, drop, pointerUp } = newBoard; + const viewer = new ImageViewer({ + zoomStep: 0.3, + minZoom: 0.1, + maxZoom: 5, + enableKeyboard: true, + }); newBoard.insertFragment = ( clipboardData: ClipboardData | null, @@ -45,5 +60,27 @@ export const withImagePlugin = (board: PlaitBoard) => { return drop(event); }; + newBoard.pointerUp = (event: PointerEvent) => { + const focusMindNode = getElementOfFocusedImage(board); + if ( + focusMindNode && + !isResizing(board) && + !isSelectionMoving(board) && + !isDragging(board) + ) { + const point = toViewBoxPoint(board, toHostPoint(board, event.x, event.y)); + const hitElement = getHitElementByPoint(board, point); + const isHittingImage = + hitElement && + MindElement.isMindElement(board, hitElement) && + MindElement.hasImage(hitElement) && + isHitImage(board, hitElement as MindElement, point); + if (isHittingImage && focusMindNode === hitElement) { + viewer.open(hitElement.data.image.url); + } + } + pointerUp(event); + }; + return newBoard; }; diff --git a/packages/drawnix/src/styles/index.scss b/packages/drawnix/src/styles/index.scss index e727430f..f09c87d9 100644 --- a/packages/drawnix/src/styles/index.scss +++ b/packages/drawnix/src/styles/index.scss @@ -128,4 +128,10 @@ .drawnix-button { @include outlineButtonStyles; } + + [plait-mindmap="true"] { + img.image-origin--focus:hover { + cursor: zoom-in; + } + } } \ No newline at end of file