chore: bump version to 0.5.45

This commit is contained in:
liquid committed 2026-01-23 23:51:45 +08:00
1 parent d1beae13f9
commit 247676f7de
7 files changed
+278 -8

No files matched your search

+1 -1
View File
@@ -107,7 +107,7 @@
</script>
<!-- base href 由构建工具处理,CDN 模式下使用相对路径 -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<meta name="app-version" content="0.5.43" />
<meta name="app-version" content="0.5.44" />
<link
rel="icon"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"version": "0.5.44",
"buildTime": "2026-01-23T15:32:51.711Z",
"version": "0.5.45",
"buildTime": "2026-01-23T15:51:45.332Z",
"gitCommit": "unknown",
"changelog": []
}
+28
View File
@@ -756,6 +756,34 @@ return (
- 优先使用设计系统 CSS 变量
- 属性顺序:定位 → 盒模型 → 外观 → 排版 → 动画
#### 使用 box-shadow 替代 border 实现不影响尺寸的边框
**场景**: 当需要边框不影响元素实际尺寸时(如裁剪框、选区框等精确定位场景)
❌ **错误示例**:
```scss
// 边框画在元素内部,会占用元素空间
// 导致可视边界比实际区域大 2px
.crop-area {
position: absolute;
border: 2px solid #4f46e5;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.6);
}
```
✅ **正确示例**:
```scss
// 使用 box-shadow 模拟边框,边框画在元素外部
// 元素内边缘即为实际边界
.crop-area {
position: absolute;
// 第一个 shadow 是边框,第二个是遮罩
box-shadow: 0 0 0 2px #4f46e5, 0 0 0 9999px rgba(0, 0, 0, 0.6);
}
```
**原因**: CSS `border` 默认使用 `box-sizing: content-box`,边框会画在元素边界内部。对于裁剪框等需要精确对应实际区域的场景,使用 `box-shadow: 0 0 0 Npx color` 可以模拟边框,且边框画在元素外部,不影响元素尺寸。
#### 绝对定位子元素需要正确的父容器设置
**场景**: 在容器内添加绝对定位的浮层/预览框等元素时
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@aitu/source",
"version": "0.5.44",
"version": "0.5.45",
"license": "MIT",
"packageManager": "pnpm@10.21.0",
"description": "Aitu - An open-source whiteboard tool with AI-powered image/video creation, mind mapping, flowcharts, and freehand drawing",
@@ -3,7 +3,7 @@
*/
import React from 'react';
import { RotateCcw, RotateCw, FlipHorizontal, FlipVertical, Check } from 'lucide-react';
import { RotateCcw, RotateCw, FlipHorizontal, FlipVertical, Check, Scan } from 'lucide-react';
import { Tooltip } from 'tdesign-react';
import { AspectRatioPreset } from './types';
@@ -19,6 +19,10 @@ interface CropPanelProps {
onFlipV: () => void;
onConfirmCrop?: () => void;
hasCropArea?: boolean;
/** 智能去白边回调 */
onAutoTrimWhitespace?: () => void;
/** 是否正在检测白边 */
isDetectingWhitespace?: boolean;
}
export const CropPanel: React.FC<CropPanelProps> = ({
@@ -33,9 +37,30 @@ export const CropPanel: React.FC<CropPanelProps> = ({
onFlipV,
onConfirmCrop,
hasCropArea = false,
onAutoTrimWhitespace,
isDetectingWhitespace = false,
}) => {
return (
<div className="crop-panel">
{/* 智能去白边 */}
{onAutoTrimWhitespace && (
<div className="crop-panel__section">
<div className="crop-panel__section-title">智能裁剪</div>
<button
type="button"
className={`crop-panel__auto-trim-btn ${isDetectingWhitespace ? 'loading' : ''}`}
onClick={onAutoTrimWhitespace}
disabled={isDetectingWhitespace}
>
<Scan size={16} />
<span>{isDetectingWhitespace ? '检测中...' : '智能去白边'}</span>
</button>
<div className="crop-panel__auto-trim-hint">
自动检测并移除图片周围的白色边框
</div>
</div>
)}
{/* 裁剪比例 */}
<div className="crop-panel__section">
<div className="crop-panel__section-title">裁剪比例</div>
@@ -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);
}
}
@@ -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<ImageEditorCoreRef, ImageEditorCorePro
// 已确认的裁剪区域(用于在其他模式下预览裁剪效果)
const [confirmedCropArea, setConfirmedCropArea] = useState<CropArea | null>(null);
// 智能去白边检测中
const [isDetectingWhitespace, setIsDetectingWhitespace] = useState(false);
// 历史记录(撤销/重做)
const [history, setHistory] = useState<ImageEditState[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
@@ -617,6 +744,38 @@ export const ImageEditorCore = forwardRef<ImageEditorCoreRef, ImageEditorCorePro
setMode('filter');
}, [cropArea]);
// 智能去白边
const handleAutoTrimWhitespace = useCallback(() => {
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<ImageEditorCoreRef, ImageEditorCorePro
onFlipV={handleFlipV}
onConfirmCrop={handleConfirmCrop}
hasCropArea={!!cropArea}
onAutoTrimWhitespace={handleAutoTrimWhitespace}
isDetectingWhitespace={isDetectingWhitespace}
/>
) : (
<FilterPanel