diff --git a/apps/web/index.html b/apps/web/index.html index 7129fe6e..06fbeb59 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -107,7 +107,7 @@ - + void; onConfirmCrop?: () => void; hasCropArea?: boolean; + /** 智能去白边回调 */ + onAutoTrimWhitespace?: () => void; + /** 是否正在检测白边 */ + isDetectingWhitespace?: boolean; } export const CropPanel: React.FC = ({ @@ -33,9 +37,30 @@ export const CropPanel: React.FC = ({ onFlipV, onConfirmCrop, hasCropArea = false, + onAutoTrimWhitespace, + isDetectingWhitespace = false, }) => { return (
+ {/* 智能去白边 */} + {onAutoTrimWhitespace && ( +
+
智能裁剪
+ +
+ 自动检测并移除图片周围的白色边框 +
+
+ )} + {/* 裁剪比例 */}
裁剪比例
diff --git a/packages/drawnix/src/components/image-editor/ImageEditorCore.scss b/packages/drawnix/src/components/image-editor/ImageEditorCore.scss index 301e2db3..df25e173 100644 --- a/packages/drawnix/src/components/image-editor/ImageEditorCore.scss +++ b/packages/drawnix/src/components/image-editor/ImageEditorCore.scss @@ -5,6 +5,9 @@ // 统一配色 $accent-color: #ffb366; +// 裁剪框颜色 +$crop-border-color: #4f46e5; // 深靛蓝色,用于边框线 +$crop-handle-color: #818cf8; // 浅靛蓝色,用于控制点 $accent-hover: #ffc989; $text-primary: #fff; $text-secondary: rgba(255, 255, 255, 0.7); @@ -457,6 +460,48 @@ $header-bg: rgba(30, 30, 30, 0.8); color: $text-muted; } + &__auto-trim-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + height: 40px; + border: 1px solid $panel-border; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + color: $text-secondary; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + background: rgba($accent-color, 0.1); + border-color: rgba($accent-color, 0.5); + color: $accent-color; + } + + &:disabled, + &.loading { + opacity: 0.5; + cursor: not-allowed; + } + + &.loading { + svg { + animation: spin 1s linear infinite; + } + } + } + + &__auto-trim-hint { + margin-top: 8px; + font-size: 11px; + color: $text-muted; + line-height: 1.4; + } + &__confirm-btn { display: flex; align-items: center; @@ -666,8 +711,9 @@ $header-bg: rgba(30, 30, 30, 0.8); &__crop-area { position: absolute; - border: 2px solid $accent-color; - box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.6); + // 使用 box-shadow 模拟边框,这样边框画在元素外部,不影响实际裁剪区域 + // 第一个 box-shadow 是边框(2px 青蓝色),第二个是遮罩层 + box-shadow: 0 0 0 2px $crop-border-color, 0 0 0 9999px rgba(0, 0, 0, 0.6); cursor: move; z-index: 10; } @@ -722,7 +768,7 @@ $header-bg: rgba(30, 30, 30, 0.8); width: 12px; height: 12px; background: #fff; - border: 2px solid $accent-color; + border: 2px solid $crop-handle-color; border-radius: 50%; box-shadow: 0 0 8px rgba(0, 0, 0, 0.5); @@ -764,3 +810,13 @@ $header-bg: rgba(30, 30, 30, 0.8); } } } + +// 动画 +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/packages/drawnix/src/components/image-editor/ImageEditorCore.tsx b/packages/drawnix/src/components/image-editor/ImageEditorCore.tsx index 801fe6b3..a143cec9 100644 --- a/packages/drawnix/src/components/image-editor/ImageEditorCore.tsx +++ b/packages/drawnix/src/components/image-editor/ImageEditorCore.tsx @@ -45,6 +45,130 @@ import './ImageEditorCore.scss'; // Tooltip z-index 需要高于模态框 const TOOLTIP_Z_INDEX = 10010; +/** + * 检测图片白边的配置 + */ +interface WhitespaceDetectionOptions { + /** 白色阈值 (0-255),像素值高于此值被认为是白色,默认 250 */ + threshold?: number; + /** 容差百分比 (0-1),允许边缘有一定比例的非白色像素,默认 0.02 */ + tolerance?: number; + /** 最小内容尺寸(像素),防止裁剪过度,默认 50 */ + minContentSize?: number; +} + +/** + * 检测图片白边并返回裁剪区域 + * @param img 图片元素 + * @param options 检测配置 + * @returns 裁剪区域,如果没有检测到白边则返回 null + */ +function detectWhitespace( + img: HTMLImageElement, + options: WhitespaceDetectionOptions = {} +): { x: number; y: number; width: number; height: number } | null { + const { + threshold = 250, + tolerance = 0.02, + minContentSize = 50, + } = options; + + const width = img.naturalWidth; + const height = img.naturalHeight; + + // 创建 canvas 读取像素数据 + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + + ctx.drawImage(img, 0, 0); + const imageData = ctx.getImageData(0, 0, width, height); + const data = imageData.data; + + /** + * 检查单个像素是否接近白色 + */ + const isWhitePixel = (index: number): boolean => { + const r = data[index]; + const g = data[index + 1]; + const b = data[index + 2]; + const a = data[index + 3]; + // 透明像素也视为白色 + if (a < 10) return true; + // RGB 都接近白色 + return r >= threshold && g >= threshold && b >= threshold; + }; + + /** + * 检查一行是否大部分是白色 + */ + const isRowWhite = (y: number): boolean => { + let whiteCount = 0; + for (let x = 0; x < width; x++) { + const index = (y * width + x) * 4; + if (isWhitePixel(index)) whiteCount++; + } + return whiteCount / width >= 1 - tolerance; + }; + + /** + * 检查一列是否大部分是白色 + */ + const isColWhite = (x: number): boolean => { + let whiteCount = 0; + for (let y = 0; y < height; y++) { + const index = (y * width + x) * 4; + if (isWhitePixel(index)) whiteCount++; + } + return whiteCount / height >= 1 - tolerance; + }; + + // 从上往下找第一行非白色 + let top = 0; + while (top < height && isRowWhite(top)) { + top++; + } + + // 从下往上找第一行非白色 + let bottom = height - 1; + while (bottom > top && isRowWhite(bottom)) { + bottom--; + } + + // 从左往右找第一列非白色 + let left = 0; + while (left < width && isColWhite(left)) { + left++; + } + + // 从右往左找第一列非白色 + let right = width - 1; + while (right > left && isColWhite(right)) { + right--; + } + + // 计算裁剪区域 + const cropWidth = right - left + 1; + const cropHeight = bottom - top + 1; + + // 检查是否有效裁剪(有白边且内容区域足够大) + const hasWhitespace = top > 0 || left > 0 || right < width - 1 || bottom < height - 1; + const contentSizeValid = cropWidth >= minContentSize && cropHeight >= minContentSize; + + if (!hasWhitespace || !contentSizeValid) { + return null; + } + + return { + x: left, + y: top, + width: cropWidth, + height: cropHeight, + }; +} + export interface ImageEditorCoreProps { /** 图片 URL */ imageUrl: string; @@ -132,6 +256,9 @@ export const ImageEditorCore = forwardRef(null); + // 智能去白边检测中 + const [isDetectingWhitespace, setIsDetectingWhitespace] = useState(false); + // 历史记录(撤销/重做) const [history, setHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); @@ -617,6 +744,38 @@ export const ImageEditorCore = forwardRef { + const img = imageRef.current; + if (!img) { + MessagePlugin.warning('图片未加载完成'); + return; + } + + setIsDetectingWhitespace(true); + + // 使用 requestAnimationFrame 避免阻塞 UI + requestAnimationFrame(() => { + try { + const detectedArea = detectWhitespace(img); + + if (detectedArea) { + // 清除固定比例,因为智能裁剪可能不符合预设比例 + setAspectRatio(null); + // 设置裁剪区域 + setCropArea(detectedArea); + MessagePlugin.success('已检测到白边,裁剪框已自动调整'); + } else { + MessagePlugin.info('未检测到明显的白边'); + } + } catch (error) { + MessagePlugin.error('检测白边失败'); + } finally { + setIsDetectingWhitespace(false); + } + }); + }, []); + // 键盘快捷键监听 useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -889,6 +1048,8 @@ export const ImageEditorCore = forwardRef ) : (