chore: bump version to 0.5.45
This commit is contained in:
1 parent
d1beae13f9
commit
247676f7de
7 files changed
+278
-8
No files matched your search
+1
-1
@@ -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"
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user