feat(responsive): 全面优化多端响应式适配

- 新增 useDeviceType hook 和 _responsive.scss 响应式基础设施
- 优化 popup-toolbar、pencil/pen 设置工具栏移动端布局
- 任务队列 tab 紧凑显示,任务项自适应紧凑/宽布局
- SideDrawer 支持触摸拖拽调整宽度
- 媒体库、AI 输入框、聊天抽屉等组件移动端适配
- 新增响应式视觉测试用例

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
liquidandClaude committed 2026-01-25 20:53:38 +08:00
1 parent a6172119cd
commit 1b06d2f320
39 files changed
+3837 -272

No files matched your search

@@ -4,31 +4,31 @@ overview: 系统性地对Aitu白板应用进行手机端、平板端适配,解
todos:
- id: responsive-infra
content: 创建响应式基础设施(_responsive.scss、useDeviceType hook)
status: pending
status: completed
- id: unified-toolbar
content: 适配统一工具栏 - 移动端横向布局/可收起
status: pending
status: completed
- id: ai-input-bar
content: 适配AI输入栏 - 移动端全宽、安全区域
status: pending
status: completed
- id: view-navigation
content: 适配视图导航 - 移动端缩小/右上角
status: pending
status: completed
- id: chat-drawer
content: 适配聊天抽屉 - 移动端全屏显示
status: pending
status: completed
- id: popup-toolbar
content: 适配弹出工具栏 - 增加触控区域
status: pending
status: completed
- id: dialogs
content: 适配对话框组件 - 移动端全屏/安全区域
status: pending
status: completed
- id: touch-fix
content: 修复触控问题 - 优化双指缩放阻止逻辑
status: pending
status: completed
- id: e2e-test
content: E2E测试验证 - 多视口尺寸测试
status: pending
status: completed
isProject: false
---
+21 -5
View File
@@ -109,15 +109,31 @@ export default defineConfig({
},
},
// Uncomment for mobile browsers support
/* {
name: 'Mobile Chrome',
// 响应式测试 - 多视口尺寸
{
name: 'responsive',
testMatch: '**/visual/responsive-visual.spec.ts',
use: { ...devices['Desktop Chrome'] },
},
// 移动端浏览器测试
{
name: 'mobile-chrome',
testMatch: '**/visual/responsive-visual.spec.ts',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
name: 'mobile-safari',
testMatch: '**/visual/responsive-visual.spec.ts',
use: { ...devices['iPhone 12'] },
}, */
},
// 平板测试
{
name: 'tablet',
testMatch: '**/visual/responsive-visual.spec.ts',
use: { ...devices['iPad Pro 11'] },
},
// Uncomment for branded browsers
/* {
@@ -0,0 +1,283 @@
/**
* 响应式视觉测试
*
* 测试不同视口尺寸下的UI适配效果
* - 桌面端 (1920x1080, 1280x720)
* - 平板横屏 (1024x768)
* - 平板竖屏 (768x1024)
* - 移动端横屏 (640x360)
* - 移动端竖屏 (375x667, 360x640)
*/
import { test, expect, Page } from '@playwright/test';
// 视口尺寸配置
const VIEWPORTS = {
// 桌面端
desktopLarge: { width: 1920, height: 1080, name: 'desktop-large' },
desktopMedium: { width: 1280, height: 720, name: 'desktop-medium' },
// 平板
tabletLandscape: { width: 1024, height: 768, name: 'tablet-landscape' },
tabletPortrait: { width: 768, height: 1024, name: 'tablet-portrait' },
// 移动端
mobileLandscape: { width: 640, height: 360, name: 'mobile-landscape' },
mobilePortrait: { width: 375, height: 667, name: 'mobile-portrait-iphone' },
mobileSmall: { width: 360, height: 640, name: 'mobile-portrait-android' },
};
// 等待页面加载完成
async function waitForPageReady(page: Page): Promise<void> {
// 等待主要组件加载
await page.waitForSelector('.drawnix', { timeout: 30000 });
// 等待工具栏加载
await page.waitForSelector('.unified-toolbar', { timeout: 10000 });
// 给一些额外时间让动画完成
await page.waitForTimeout(1000);
}
// 隐藏动态内容以获得稳定截图
async function prepareForScreenshot(page: Page): Promise<void> {
await page.evaluate(() => {
// 隐藏可能变化的动态内容
const dynamicElements = document.querySelectorAll(
'.task-queue-panel, .minimap__preview, .loading-indicator'
);
dynamicElements.forEach(el => {
(el as HTMLElement).style.visibility = 'hidden';
});
});
}
test.describe('响应式布局测试', () => {
test.beforeEach(async ({ page }) => {
// 设置较长的超时时间
test.setTimeout(60000);
});
// 桌面端测试
test.describe('桌面端', () => {
test('大屏幕桌面端布局 (1920x1080)', async ({ page }) => {
await page.setViewportSize(VIEWPORTS.desktopLarge);
await page.goto('/');
await waitForPageReady(page);
await prepareForScreenshot(page);
// 验证工具栏可见
await expect(page.locator('.unified-toolbar')).toBeVisible();
// 验证AI输入栏可见
await expect(page.locator('.ai-input-bar')).toBeVisible();
// 验证视图导航可见
await expect(page.locator('.view-navigation')).toBeVisible();
// 截图对比
await expect(page).toHaveScreenshot(`responsive-${VIEWPORTS.desktopLarge.name}.png`, {
fullPage: false,
maxDiffPixelRatio: 0.1,
});
});
test('中等屏幕桌面端布局 (1280x720)', async ({ page }) => {
await page.setViewportSize(VIEWPORTS.desktopMedium);
await page.goto('/');
await waitForPageReady(page);
await prepareForScreenshot(page);
await expect(page.locator('.unified-toolbar')).toBeVisible();
await expect(page.locator('.ai-input-bar')).toBeVisible();
await expect(page).toHaveScreenshot(`responsive-${VIEWPORTS.desktopMedium.name}.png`, {
fullPage: false,
maxDiffPixelRatio: 0.1,
});
});
});
// 平板测试
test.describe('平板', () => {
test('平板横屏布局 (1024x768)', async ({ page }) => {
await page.setViewportSize(VIEWPORTS.tabletLandscape);
await page.goto('/');
await waitForPageReady(page);
await prepareForScreenshot(page);
// 验证工具栏使用图标模式
await expect(page.locator('.unified-toolbar')).toBeVisible();
await expect(page.locator('.ai-input-bar')).toBeVisible();
await expect(page).toHaveScreenshot(`responsive-${VIEWPORTS.tabletLandscape.name}.png`, {
fullPage: false,
maxDiffPixelRatio: 0.1,
});
});
test('平板竖屏布局 (768x1024)', async ({ page }) => {
await page.setViewportSize(VIEWPORTS.tabletPortrait);
await page.goto('/');
await waitForPageReady(page);
await prepareForScreenshot(page);
await expect(page.locator('.unified-toolbar')).toBeVisible();
await expect(page.locator('.ai-input-bar')).toBeVisible();
await expect(page).toHaveScreenshot(`responsive-${VIEWPORTS.tabletPortrait.name}.png`, {
fullPage: false,
maxDiffPixelRatio: 0.1,
});
});
});
// 移动端测试
test.describe('移动端', () => {
test('移动端横屏布局 (640x360)', async ({ page }) => {
await page.setViewportSize(VIEWPORTS.mobileLandscape);
await page.goto('/');
await waitForPageReady(page);
await prepareForScreenshot(page);
// 验证组件可见且不遮挡
await expect(page.locator('.unified-toolbar')).toBeVisible();
await expect(page.locator('.ai-input-bar')).toBeVisible();
await expect(page).toHaveScreenshot(`responsive-${VIEWPORTS.mobileLandscape.name}.png`, {
fullPage: false,
maxDiffPixelRatio: 0.1,
});
});
test('iPhone 竖屏布局 (375x667)', async ({ page }) => {
await page.setViewportSize(VIEWPORTS.mobilePortrait);
await page.goto('/');
await waitForPageReady(page);
await prepareForScreenshot(page);
await expect(page.locator('.unified-toolbar')).toBeVisible();
await expect(page.locator('.ai-input-bar')).toBeVisible();
await expect(page).toHaveScreenshot(`responsive-${VIEWPORTS.mobilePortrait.name}.png`, {
fullPage: false,
maxDiffPixelRatio: 0.1,
});
});
test('Android 竖屏布局 (360x640)', async ({ page }) => {
await page.setViewportSize(VIEWPORTS.mobileSmall);
await page.goto('/');
await waitForPageReady(page);
await prepareForScreenshot(page);
await expect(page.locator('.unified-toolbar')).toBeVisible();
await expect(page.locator('.ai-input-bar')).toBeVisible();
await expect(page).toHaveScreenshot(`responsive-${VIEWPORTS.mobileSmall.name}.png`, {
fullPage: false,
maxDiffPixelRatio: 0.1,
});
});
});
});
test.describe('组件响应式测试', () => {
// 工具栏响应式测试
test.describe('统一工具栏', () => {
test('移动端工具栏可展开/收起', async ({ page }) => {
await page.setViewportSize(VIEWPORTS.mobilePortrait);
await page.goto('/');
await waitForPageReady(page);
const toolbar = page.locator('.unified-toolbar');
await expect(toolbar).toBeVisible();
// 检查移动端切换按钮
const toggleBtn = page.locator('.unified-toolbar__mobile-toggle');
// 如果存在切换按钮,测试展开/收起功能
const toggleExists = await toggleBtn.count() > 0;
if (toggleExists) {
// 初始应该是收起状态
await expect(toolbar).toHaveClass(/mobile-collapsed/);
// 点击展开
await toggleBtn.click();
await page.waitForTimeout(300);
// 验证已展开
await expect(toolbar).not.toHaveClass(/mobile-collapsed/);
}
});
});
// AI输入栏响应式测试
test.describe('AI输入栏', () => {
test('移动端AI输入栏布局正确', async ({ page }) => {
await page.setViewportSize(VIEWPORTS.mobilePortrait);
await page.goto('/');
await waitForPageReady(page);
const inputBar = page.locator('.ai-input-bar');
await expect(inputBar).toBeVisible();
// 验证输入栏不与工具栏重叠
const toolbar = page.locator('.unified-toolbar');
const inputBarBox = await inputBar.boundingBox();
const toolbarBox = await toolbar.boundingBox();
if (inputBarBox && toolbarBox) {
// 输入栏和工具栏不应该重叠(允许少量重叠)
const overlap = Math.max(0,
Math.min(inputBarBox.x + inputBarBox.width, toolbarBox.x + toolbarBox.width) -
Math.max(inputBarBox.x, toolbarBox.x)
) * Math.max(0,
Math.min(inputBarBox.y + inputBarBox.height, toolbarBox.y + toolbarBox.height) -
Math.max(inputBarBox.y, toolbarBox.y)
);
expect(overlap).toBeLessThan(100); // 允许最多100px重叠
}
});
});
// 视图导航响应式测试
test.describe('视图导航', () => {
test('视图导航在不同视口尺寸下正确显示', async ({ page }) => {
// 桌面端
await page.setViewportSize(VIEWPORTS.desktopMedium);
await page.goto('/');
await waitForPageReady(page);
const viewNav = page.locator('.view-navigation');
await expect(viewNav).toBeVisible();
// 移动端
await page.setViewportSize(VIEWPORTS.mobilePortrait);
await page.waitForTimeout(500);
await expect(viewNav).toBeVisible();
// 验证缩放控件可见
await expect(page.locator('.view-navigation__zoom')).toBeVisible();
});
});
});
test.describe('触控交互测试', () => {
test('移动端触控区域足够大', async ({ page }) => {
await page.setViewportSize(VIEWPORTS.mobilePortrait);
await page.goto('/');
await waitForPageReady(page);
// 检查主要按钮的尺寸
const buttons = page.locator('.unified-toolbar button, .ai-input-bar button');
const count = await buttons.count();
for (let i = 0; i < Math.min(count, 10); i++) {
const button = buttons.nth(i);
const box = await button.boundingBox();
if (box) {
// 触控目标应该至少 32x32 像素
expect(box.width).toBeGreaterThanOrEqual(28);
expect(box.height).toBeGreaterThanOrEqual(28);
}
}
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": "0.5.50",
"buildTime": "2026-01-25T05:01:00.301Z",
"buildTime": "2026-01-25T05:22:53.411Z",
"gitCommit": "unknown",
"changelog": [
"对比模式下视频联动同步播放和重置"
@@ -1368,54 +1368,93 @@ $vip-color: #E91E63;
}
}
// Mobile responsive
@media (max-width: 640px) {
// ============================================================================
// Mobile Responsive Styles
// ============================================================================
// 平板横屏及以下 (max-width: 1024px) - 稍微缩窄
@media (max-width: 1024px) {
.ai-input-bar {
bottom: 16px;
max-width: 600px;
}
}
// 平板及以下 (max-width: 768px) - 调整布局
@media (max-width: 768px) {
.ai-input-bar {
bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
padding: 0 12px;
// 移动端考虑左侧工具栏宽度,避免遮挡
left: calc(50% + 24px); // 工具栏宽度48px的一半,使输入框在工具栏右侧区域居中
max-width: calc(100% - 60px); // 左侧工具栏48px + 右侧边距12px
max-width: calc(100% - 80px); // 给左侧工具栏留空间
left: calc(50% + 30px); // 稍微右偏以避开工具栏
&__container {
padding: 6px 8px;
border-radius: 20px;
}
&__content-preview {
gap: 6px;
}
&__content-item {
&--text {
height: auto; // 移动端也使用自动高度
min-width: 56px;
max-width: 140px;
}
&__bottom-bar {
gap: 2px;
}
// 隐藏 hover 预览(移动端不需要)
&__hover-preview {
max-width: 250px;
max-height: 250px;
display: none;
}
}
}
&--text {
max-width: 280px;
}
// 移动端竖屏 (max-width: 640px) - 全宽布局
@media (max-width: 640px) {
.ai-input-bar {
bottom: calc(env(safe-area-inset-bottom, 0px) + 12px);
padding: 0 8px;
padding-left: calc(env(safe-area-inset-left, 0px) + 8px);
padding-right: calc(env(safe-area-inset-right, 0px) + 8px);
// 移动端居中显示,工具栏在左下角不会遮挡
left: 50%;
transform: translateX(-50%);
max-width: calc(100% - 16px);
width: 100%;
&--video {
max-width: 280px;
}
&__content-preview {
gap: 4px;
padding-left: 8px;
}
&__hover-video video {
max-width: 280px;
max-height: 250px;
&__content-item {
width: 32px;
height: 32px;
&--text {
height: auto;
min-width: 48px;
max-width: 120px;
padding: 6px 8px;
}
}
&__container {
padding: 4px 6px 4px 4px;
padding: 4px 6px;
border-radius: 16px;
gap: 4px;
}
&__bottom-bar {
gap: 2px;
}
&__bottom-spacer {
min-width: 4px;
}
&__type-btn {
width: 32px;
height: 32px;
width: 36px;
height: 36px;
min-width: 36px; // 确保触控友好
}
&__model-btn {
@@ -1423,29 +1462,93 @@ $vip-color: #E91E63;
font-size: 13px;
}
&__upload-btn {
width: 32px;
height: 32px;
&__upload-btn,
&__library-btn {
width: 36px;
height: 36px;
min-width: 36px;
}
&__highlight-layer {
font-size: 14px;
padding: 4px 6px;
padding: 4px 8px;
}
&__input {
font-size: 14px;
padding: 4px 6px;
padding: 4px 8px;
min-height: 20px;
}
&__send-btn {
width: 36px;
height: 36px;
min-width: 36px;
}
&__send-logo {
width: 18px;
height: 18px;
// 下拉菜单调整
.generation-type-dropdown,
.count-dropdown {
&__trigger {
padding: 4px 6px;
font-size: 12px;
}
&__menu {
max-width: calc(100vw - 32px);
}
}
}
// Prompt suggestion panel 移动端适配
.prompt-suggestion-panel {
max-height: 240px;
border-radius: 12px;
&__item {
padding: 8px 10px;
}
&__item-text {
font-size: 13px;
}
}
}
// 小屏移动端 (max-width: 480px) - 更紧凑
@media (max-width: 480px) {
.ai-input-bar {
bottom: calc(env(safe-area-inset-bottom, 0px) + 8px);
padding: 0 6px;
padding-left: calc(env(safe-area-inset-left, 0px) + 6px);
padding-right: calc(env(safe-area-inset-right, 0px) + 6px);
&__container {
padding: 4px;
}
&__type-btn,
&__upload-btn,
&__library-btn,
&__send-btn {
width: 34px;
height: 34px;
min-width: 34px;
}
&__model-btn {
padding: 4px 6px;
font-size: 12px;
}
&__input {
font-size: 13px;
}
// 隐藏一些次要元素节省空间
.generation-type-dropdown__label,
.count-dropdown__label {
display: none;
}
}
}
@@ -724,7 +724,246 @@ $drawer-input-height: 160px;
// Mobile Styles
// ============================================================================
// 平板及以下 (max-width: 768px) - 全屏抽屉
@media (max-width: 768px) {
.chat-drawer {
width: 100vw !important;
max-width: 100vw !important;
border-left: none;
border-radius: 0;
// 添加安全区域支持
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
&__resize-handle {
display: none; // 移动端不需要调整宽度
}
&__header {
padding: $spacing-sm $spacing-md;
padding-top: calc(env(safe-area-inset-top, 0px) + #{$spacing-sm});
}
&__header-top {
gap: $spacing-xs;
}
&__title {
font-size: 15px;
}
&__close-btn {
width: 36px;
height: 36px;
min-width: 36px; // 触控友好
}
&__content {
height: calc(100vh - #{$drawer-header-height} - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
}
}
.chat-drawer-trigger {
// 移动端隐藏侧边触发器,使用其他方式打开
display: none;
}
// 消息列表调整
.chat-messages-list {
padding: $spacing-md;
}
.chat-message {
max-width: 95%;
}
// 输入区域调整
.enhanced-chat-input {
padding: $spacing-sm $spacing-md;
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + #{$spacing-sm});
&__form {
padding: 6px;
}
&__textarea {
min-height: 60px;
font-size: 15px;
}
&__send {
width: 36px;
height: 36px;
min-width: 36px;
}
}
}
// 移动端竖屏 (max-width: 640px)
@media (max-width: 640px) {
.chat-drawer {
&__header {
padding: $spacing-xs $spacing-sm;
gap: $spacing-xs;
}
&__header-bottom {
padding: 4px 8px;
gap: $spacing-xs;
}
&__title {
font-size: 14px;
}
&__session-actions {
gap: 2px;
}
}
// 会话列表调整
.session-list {
top: 80px;
max-height: 50vh;
&__header {
padding: $spacing-xs $spacing-md;
}
&__new-btn {
padding: 6px 10px;
font-size: 11px;
}
}
.session-item {
padding: $spacing-xs $spacing-sm;
&__title {
font-size: 12px;
}
}
// 消息样式调整
.chat-messages-list {
padding: $spacing-sm;
gap: $spacing-md;
}
.chat-message-content {
padding: $spacing-sm $spacing-md;
font-size: 13px;
}
.chat-message-avatar {
width: 28px;
height: 28px;
svg {
width: 14px;
height: 14px;
}
}
// 输入区域更紧凑
.enhanced-chat-input {
padding: $spacing-xs $spacing-sm;
max-height: 140px;
&__form {
padding: 4px;
border-radius: 12px;
}
&__textarea {
min-height: 44px;
max-height: 80px;
padding: 8px 10px;
font-size: 14px;
}
&__selection {
gap: $spacing-xs;
padding-bottom: $spacing-xs;
margin-bottom: $spacing-xs;
}
&__selection-item {
width: 32px;
height: 32px;
&--text {
min-width: 48px;
max-width: 100px;
padding: 4px 8px;
}
}
}
// 模型选择器调整
.model-selector {
&__trigger {
padding: 3px 8px;
font-size: 11px;
}
&__dropdown {
min-width: 280px;
max-width: calc(100vw - 32px);
}
&__item {
padding: 8px 10px;
}
&__item-name {
font-size: 13px;
}
&__item-desc {
font-size: 11px;
}
}
}
// 小屏移动端 (max-width: 480px)
@media (max-width: 480px) {
.chat-drawer {
&__header-bottom {
// 隐藏会话操作按钮,节省空间
.chat-drawer__session-actions {
display: none;
}
}
}
.enhanced-chat-input {
&__selection-item {
width: 28px;
height: 28px;
}
&__send {
width: 32px;
height: 32px;
min-width: 32px;
}
}
.model-selector {
&--variant-capsule {
.model-selector__trigger {
padding: 2px 6px;
font-size: 10px;
}
}
&__trigger-text {
max-width: 60px;
}
}
}
// ============================================================================
// @llamaindex/chat-ui Component Styles
@@ -326,25 +326,30 @@ $brand-orange-dark: #E67E22;
&__search {
flex: 1;
max-width: 480px;
min-width: 0; // 防止 flex 子元素溢出
.t-input {
border-radius: 6px;
border-color: rgba(0, 0, 0, 0.1);
background: #f8fafc;
border-radius: 8px !important;
border: 1px solid rgba(0, 0, 0, 0.1) !important;
background: #f8fafc !important;
transition: all 0.15s ease;
height: 32px !important;
min-height: 32px !important;
height: 36px !important;
min-height: 36px !important;
&:hover {
border-color: rgba(0, 0, 0, 0.2);
background: #ffffff;
border-color: rgba(0, 0, 0, 0.2) !important;
background: #ffffff !important;
}
&:focus,
&.t-is-focused {
border-color: #000;
background: #ffffff;
box-shadow: 0 0 0 1px #000;
border-color: #000 !important;
background: #ffffff !important;
box-shadow: 0 0 0 1px #000 !important;
}
.t-input__inner {
font-size: 14px;
}
}
}
@@ -552,14 +557,111 @@ $brand-orange-dark: #E67E22;
}
}
// ========== 移动端选中素材简介栏 ==========
&__mobile-inspector {
display: none; // 默认隐藏,仅在移动端媒体查询中显示
}
// ========== 移动端优化 ==========
@media (max-width: 768px) {
// 移动端选中素材简介栏
&__mobile-inspector {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: #ffffff;
border-top: 1px solid rgba(0, 0, 0, 0.06);
flex-shrink: 0;
&-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
&-type {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #64748b;
svg {
width: 14px;
height: 14px;
}
}
&-ai {
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 9px;
font-weight: 600;
padding: 2px 5px;
border-radius: 3px;
margin-left: 4px;
}
&-name {
font-size: 13px;
font-weight: 500;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
&-btn {
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: #f1f5f9;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
color: #64748b;
&:hover, &:active {
background: #e2e8f0;
color: #1e293b;
}
&--primary {
background: #F39C12;
color: #fff;
&:hover, &:active {
background: #E67E22;
color: #fff;
}
}
}
}
&__header {
padding: 10px 12px;
gap: 8px;
flex-wrap: wrap;
}
&__header-top {
flex-wrap: nowrap;
gap: 8px;
}
&__header-left {
width: 100%;
flex-wrap: wrap;
@@ -567,13 +669,41 @@ $brand-orange-dark: #E67E22;
}
&__header-right {
width: 100%;
flex: 1;
justify-content: flex-end;
flex-wrap: nowrap;
gap: 6px;
// 移动端按钮只显示图标
.t-button {
flex-shrink: 0;
padding: 0 8px !important;
min-width: 36px !important;
height: 36px !important;
// 隐藏按钮文字,只显示图标
.t-button__text {
display: none;
}
}
}
&__search {
width: 100%;
flex: 1;
min-width: 0;
max-width: none !important;
.t-input {
min-height: 36px !important;
height: 36px !important;
border-radius: 8px !important;
border: 1px solid rgba(0, 0, 0, 0.1) !important;
background: #f8fafc !important;
.t-input__inner {
font-size: 16px; // 防止 iOS 自动缩放
}
}
}
&__filter-select {
@@ -581,9 +711,95 @@ $brand-orange-dark: #E67E22;
}
&__footer {
padding: 0 12px;
padding: 8px 12px;
font-size: 11px;
height: auto;
min-height: 40px;
flex-wrap: nowrap;
gap: 12px;
}
&__footer-left {
flex: 1;
gap: 8px;
flex-wrap: nowrap;
min-width: 0;
overflow: hidden;
}
&__footer-right {
flex-shrink: 0;
}
&__footer-storage {
flex-shrink: 0;
}
&__footer-count {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&::after {
display: none;
}
}
&__footer-hint {
display: none; // 移动端隐藏提示
}
&__zoom-control {
// 移动端保留缩放控制,但更紧凑
gap: 6px;
svg {
width: 16px;
height: 16px;
}
}
&__zoom-slider {
width: 80px !important; // 移动端滑块更短
}
// 筛选按钮组更紧凑 - 只显示图标
&__filter-option {
padding: 0 6px !important;
min-width: 32px;
height: 32px;
// 隐藏数字
.media-library-grid__filter-count {
display: none;
}
}
// header-bottom 支持水平滚动(作为备用)
&__header-bottom {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
gap: 8px;
padding: 4px 8px;
&::-webkit-scrollbar {
display: none;
}
}
// 筛选岛更紧凑
&__filter-island {
flex-shrink: 0;
padding: 2px;
gap: 2px;
}
// 排序选项更紧凑
&__sort-options {
flex-shrink: 0;
padding: 2px;
gap: 2px;
}
&__drop-message {
@@ -12,6 +12,7 @@ import {
XSquare,
HardDrive,
Video as VideoIcon,
Image as ImageIcon,
Globe,
User,
Sparkles,
@@ -23,7 +24,9 @@ import {
ArrowUpNarrowWide,
Minus,
Plus,
Download
Download,
Eye,
PlusCircle,
} from 'lucide-react';
import JSZip from 'jszip';
import {
@@ -31,7 +34,9 @@ import {
MediaLibraryIcon,
} from '../icons';
import { useAssets } from '../../contexts/AssetContext';
import { filterAssets, formatFileSize } from '../../utils/asset-utils';
import { filterAssets, formatFileSize, formatDate } from '../../utils/asset-utils';
import { useDeviceType } from '../../hooks/useDeviceType';
import { downloadFile } from '../../utils/download-utils';
import { VirtualAssetGrid } from './VirtualAssetGrid';
import { MediaLibraryEmpty } from './MediaLibraryEmpty';
import { ViewModeToggle } from './ViewModeToggle';
@@ -130,8 +135,9 @@ export function MediaLibraryGrid({
onUploadClick,
storageStatus,
}: MediaLibraryGridProps) {
const { assets, filters, loading, setFilters, removeAssets } = useAssets();
const { assets, filters, loading, setFilters, removeAssets, removeAsset } = useAssets();
const { board } = useDrawnix();
const { isMobile } = useDeviceType();
const [isDragging, setIsDragging] = useState(false);
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedAssetIds, setSelectedAssetIds] = useState<Set<string>>(new Set());
@@ -164,6 +170,12 @@ export function MediaLibraryGrid({
};
}, [assets]);
// 获取选中的素材(用于移动端底部简介)
const selectedAsset = useMemo(() => {
if (!selectedAssetId) return null;
return assets.find(a => a.id === selectedAssetId) || null;
}, [assets, selectedAssetId]);
// 监听网格尺寸变化并缓存
useEffect(() => {
localStorage.setItem(GRID_SIZE_STORAGE_KEY, gridSize.toString());
@@ -644,15 +656,16 @@ export function MediaLibraryGrid({
>
<div className="media-library-grid__header">
<div className="media-library-grid__header-top">
<Input
value={filters.searchQuery}
onChange={(value) => setFilters({ searchQuery: value as string })}
placeholder="搜索素材..."
prefixIcon={<Search size={16} />}
clearable
data-track="grid_search_input"
className="media-library-grid__search"
/>
<div className="media-library-grid__search">
<Input
value={filters.searchQuery}
onChange={(value) => setFilters({ searchQuery: value as string })}
placeholder="搜索素材..."
prefixIcon={<Search size={16} />}
clearable
data-track="grid_search_input"
/>
</div>
<div className="media-library-grid__header-right">
<ViewModeToggle viewMode={pendingViewMode} onViewModeChange={handleViewModeChange} />
{isSelectionMode ? (
@@ -880,7 +893,7 @@ export function MediaLibraryGrid({
assets={filteredResult.assets}
viewMode={viewMode}
gridSize={gridSize}
selectedAssetId={selectedAssetId}
selectedAssetId={selectedAssetId ?? undefined}
selectedAssetIds={selectedAssetIds}
isSelectionMode={isSelectionMode}
onSelectAsset={isSelectionMode ? toggleAssetSelection : onSelectAsset}
@@ -889,6 +902,56 @@ export function MediaLibraryGrid({
/>
</div>
{/* 移动端:选中素材时显示简介和操作 */}
{isMobile && selectedAsset && !isSelectionMode && (
<div className="media-library-grid__mobile-inspector">
<div className="media-library-grid__mobile-inspector-info">
<div className="media-library-grid__mobile-inspector-type">
{selectedAsset.type === AssetType.IMAGE ? <ImageIcon size={14} /> : <VideoIcon size={14} />}
<span>{selectedAsset.type === AssetType.IMAGE ? '图片' : '视频'}</span>
{selectedAsset.source === AssetSource.AI_GENERATED && (
<span className="media-library-grid__mobile-inspector-ai">AI</span>
)}
</div>
<div className="media-library-grid__mobile-inspector-name" title={selectedAsset.name}>
{selectedAsset.name}
</div>
</div>
<div className="media-library-grid__mobile-inspector-actions">
<Tooltip content="预览" theme="light">
<button
className="media-library-grid__mobile-inspector-btn"
onClick={() => handlePreview(selectedAsset)}
data-track="mobile_preview"
>
<Eye size={18} />
</button>
</Tooltip>
{onDoubleClick && (
<Tooltip content="插入画布" theme="light">
<button
className="media-library-grid__mobile-inspector-btn media-library-grid__mobile-inspector-btn--primary"
onClick={() => onDoubleClick(selectedAsset)}
data-track="mobile_insert"
>
<PlusCircle size={18} />
</button>
</Tooltip>
)}
<Tooltip content="下载" theme="light">
<button
className="media-library-grid__mobile-inspector-btn"
onClick={() => downloadFile(selectedAsset.url, selectedAsset.name)}
data-track="mobile_download"
>
<Download size={18} />
</button>
</Tooltip>
</div>
</div>
)}
{/* 底部工具栏(始终显示) */}
<div className="media-library-grid__footer">
<div className="media-library-grid__footer-left">
{storageStatus ? (
@@ -87,18 +87,14 @@ export function MediaLibraryModal({
return () => window.removeEventListener('resize', checkMobile);
}, []);
// 处理资产选择
// 处理资产选择 - 移动端单击只选中,不弹出详情
const handleSelectAsset = useCallback(
(id: string) => {
setLocalSelectedAssetId(id);
setSelectedAssetId(id);
// 在移动端,点击素材时打开抽屉
if (isMobile) {
setShowMobileInspector(true);
}
// 移动端不再自动弹出详情抽屉,用户可以通过双击预览或点击详情按钮
},
[setSelectedAssetId, isMobile],
[setSelectedAssetId],
);
// 关闭移动端检查器
@@ -86,7 +86,6 @@
&:active {
background-color: var(--color-surface-primary-container);
border-color: var(--color-brand-active);
}
svg {
@@ -210,6 +210,8 @@
cursor: grab;
border-radius: 4px;
transition: color 0.2s ease, background 0.2s ease;
touch-action: none; // 防止触摸手势被浏览器拦截
user-select: none;
&:hover {
color: rgba(255, 255, 255, 0.8);
@@ -288,3 +290,78 @@
opacity: 1;
}
}
/* 移动端响应式适配 */
@media (max-width: 768px) {
.media-viewport {
// 启用触摸操作
touch-action: pan-x pan-y pinch-zoom;
&__toolbar {
// 限制最大宽度,避免超出屏幕
max-width: calc(100vw - 32px);
overflow-x: auto;
overflow-y: hidden;
padding: 4px 8px;
gap: 2px;
// 隐藏滚动条
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
button {
width: 28px;
height: 28px;
flex-shrink: 0;
}
}
&__toolbar-handle {
width: 32px;
height: 32px;
flex-shrink: 0;
}
&__toolbar-divider {
height: 16px;
margin: 0 4px;
flex-shrink: 0;
}
&__zoom-level {
min-width: 40px;
font-size: 11px;
flex-shrink: 0;
}
&__toolbar-group {
flex-shrink: 0;
}
}
}
@media (max-width: 480px) {
.media-viewport {
&__toolbar {
padding: 3px 6px;
gap: 1px;
button {
width: 26px;
height: 26px;
}
}
&__toolbar-divider {
margin: 0 2px;
}
&__zoom-level {
min-width: 36px;
font-size: 10px;
}
}
}
@@ -251,7 +251,7 @@ export const MediaViewport = forwardRef<MediaViewportRef, MediaViewportProps>(({
);
}, []);
// 工具栏拖拽开始
// 工具栏拖拽开始 - 鼠标事件
const handleToolbarDragStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
@@ -287,6 +287,49 @@ export const MediaViewport = forwardRef<MediaViewportRef, MediaViewportProps>(({
[toolbarPosition]
);
// 工具栏拖拽开始 - 触摸事件
const handleToolbarTouchStart = useCallback(
(e: React.TouchEvent) => {
e.stopPropagation();
if (e.touches.length !== 1) return;
const touch = e.touches[0];
setIsToolbarDragging(true);
const currentPos = toolbarPosition || { x: 0, y: 0 };
toolbarDragStartRef.current = {
x: touch.clientX,
y: touch.clientY,
posX: currentPos.x,
posY: currentPos.y,
};
const handleTouchMove = (moveEvent: TouchEvent) => {
if (moveEvent.touches.length !== 1) return;
moveEvent.preventDefault();
const moveTouch = moveEvent.touches[0];
const deltaX = moveTouch.clientX - toolbarDragStartRef.current.x;
const deltaY = moveTouch.clientY - toolbarDragStartRef.current.y;
setToolbarPosition({
x: toolbarDragStartRef.current.posX + deltaX,
y: toolbarDragStartRef.current.posY + deltaY,
});
};
const handleTouchEnd = () => {
setIsToolbarDragging(false);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
document.removeEventListener('touchcancel', handleTouchEnd);
};
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
document.addEventListener('touchcancel', handleTouchEnd);
},
[toolbarPosition]
);
// 重置工具栏位置
const resetToolbarPosition = useCallback(() => {
setToolbarPosition(null);
@@ -382,6 +425,7 @@ export const MediaViewport = forwardRef<MediaViewportRef, MediaViewportProps>(({
<div
className="media-viewport__toolbar-handle"
onMouseDown={handleToolbarDragStart}
onTouchStart={handleToolbarTouchStart}
onDoubleClick={(e) => {
e.stopPropagation();
resetToolbarPosition();
@@ -128,39 +128,51 @@ export const SideDrawer: React.FC<SideDrawerProps> = ({
}
}, [closeOnBackdropClick, onClose]);
// 开始拖拽
const handleResizeStart = useCallback((e: React.MouseEvent) => {
// 开始拖拽 - 支持鼠标和触摸
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
if (!drawerRef.current) return;
setIsDragging(true);
startXRef.current = e.clientX;
// 获取起始 X 坐标(支持鼠标和触摸)
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
startXRef.current = clientX;
startWidthRef.current = drawerRef.current.offsetWidth;
}, []);
// 拖拽中
// 拖拽中 - 支持鼠标和触摸
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
const deltaX = e.clientX - startXRef.current;
const handleMove = (e: MouseEvent | TouchEvent) => {
// 获取当前 X 坐标(支持鼠标和触摸)
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
const deltaX = clientX - startXRef.current;
const newWidth = Math.min(maxWidth, Math.max(minWidth, startWidthRef.current + deltaX));
setDraggingWidth(newWidth);
};
const handleMouseUp = () => {
const handleEnd = () => {
setIsDragging(false);
if (draggingWidth !== null) {
onWidthChange?.(draggingWidth);
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
// 鼠标事件
document.addEventListener('mousemove', handleMove);
document.addEventListener('mouseup', handleEnd);
// 触摸事件
document.addEventListener('touchmove', handleMove, { passive: false });
document.addEventListener('touchend', handleEnd);
document.addEventListener('touchcancel', handleEnd);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMove);
document.removeEventListener('mouseup', handleEnd);
document.removeEventListener('touchmove', handleMove);
document.removeEventListener('touchend', handleEnd);
document.removeEventListener('touchcancel', handleEnd);
};
}, [isDragging, draggingWidth, minWidth, maxWidth, onWidthChange]);
@@ -231,6 +243,7 @@ export const SideDrawer: React.FC<SideDrawerProps> = ({
<div
className="side-drawer__resize-handle"
onMouseDown={handleResizeStart}
onTouchStart={handleResizeStart}
/>
)}
</div>
@@ -47,6 +47,16 @@ $drawer-width-wide: min(1024px, 50vw);
&.side-drawer--open {
transform: translateX(0);
}
// 移动端适配:抽屉从屏幕左边缘开始
@media (max-width: 768px) {
left: 0;
transform: translateX(-100%);
&.side-drawer--open {
transform: translateX(0);
}
}
}
// 屏幕左边缘(TaskQueuePanel)
@@ -81,7 +91,10 @@ $drawer-width-wide: min(1024px, 50vw);
width: clamp(320px, 50vw, 1024px);
@media (max-width: 768px) {
width: 100%;
width: 100vw;
max-width: 100vw;
border-radius: 0;
left: 0 !important; // 覆盖 toolbar-right 的 left 设置
}
}
@@ -236,6 +249,28 @@ $drawer-width-wide: min(1024px, 50vw);
// Resize Handle
// ========================================
// ========================================
// Mobile Responsive
// ========================================
@media (max-width: 768px) {
&__header {
padding: $spacing-md $spacing-sm;
}
&__title {
font-size: 16px;
}
&__filter {
padding: 0 $spacing-sm $spacing-sm;
}
&__content {
padding-left: $spacing-sm;
}
}
&__resize-handle {
position: absolute;
top: 0;
@@ -279,3 +314,29 @@ $drawer-width-wide: min(1024px, 50vw);
background: #F39C12;
}
}
// ============================================================================
// Mobile Responsive Styles for Side Drawer
// ============================================================================
@media (max-width: 768px) {
.side-drawer {
// 移动端增加 resize handle 触摸区域
&__resize-handle {
width: 20px; // 增加触摸区域宽度
right: -10px;
// 移动端始终显示拖动指示条
&::after {
opacity: 0.5;
width: 6px;
height: 50px;
}
&:active::after {
opacity: 1;
background: #F39C12;
}
}
}
}
@@ -19,7 +19,7 @@ const EMPTY_SET = new Set<string>();
const TASK_ITEM_HEIGHT = 140;
const TASK_ITEM_GAP = 0;
const OVERSCAN_COUNT = 3;
const COMPACT_LAYOUT_THRESHOLD = 500;
const COMPACT_LAYOUT_THRESHOLD = 375;
export interface VirtualTaskListProps {
tasks: Task[];
@@ -66,7 +66,13 @@ export const VirtualTaskList: React.FC<VirtualTaskListProps> = ({
const parentRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const [internalIsCompact, setInternalIsCompact] = useState(false);
// 惰性初始化:小屏幕默认使用紧凑布局(用屏幕宽度作为初始估计)
const [internalIsCompact, setInternalIsCompact] = useState(() => {
if (typeof window !== 'undefined' && window.innerWidth < COMPACT_LAYOUT_THRESHOLD) {
return true;
}
return false;
});
const [showBackToTop, setShowBackToTop] = useState(false);
// 统一的布局检测模式
@@ -136,32 +142,36 @@ export const VirtualTaskList: React.FC<VirtualTaskListProps> = ({
// 使用一个全局的 ResizeObserver 监听容器宽度,批量同步给所有 TaskItem
// 支持抽屉(.side-drawer__content)和弹窗(.t-dialog__body)等多种容器
// 根据容器宽度判断是否使用紧凑布局
useEffect(() => {
if (forcedIsCompact !== undefined) return;
const container = parentRef.current;
if (!container) return;
// 查找合适的父容器来监听宽度(优先级:抽屉 > WinBox 内容区 > 弹窗任务列表 > 弹窗主体 > 自身)
const drawerContent = container.closest('.side-drawer__content') as HTMLElement;
const winboxContent = container.closest('.winbox-content-wrapper') as HTMLElement;
const dialogTaskList = container.closest('.dialog-task-list') as HTMLElement;
const dialogBody = container.closest('.t-dialog__body') as HTMLElement;
const targetElement = drawerContent || winboxContent || dialogTaskList || dialogBody || container;
// 查找合适的父容器来监听宽度
// 注意:监听 .side-drawer 而不是 .side-drawer__content,因为宽度变化发生在外层
const targetElement = container;
// 统一的布局检测函数 - 根据容器宽度判断
const updateLayout = (containerWidth?: number) => {
const width = containerWidth ?? targetElement.getBoundingClientRect().width;
setInternalIsCompact(width < COMPACT_LAYOUT_THRESHOLD);
};
// 监听容器宽度变化(处理抽屉拖动、窗口缩放等)
const resizeObserver = new ResizeObserver((entries) => {
const width = entries[0].contentRect.width;
const shouldBeCompact = width < COMPACT_LAYOUT_THRESHOLD;
setInternalIsCompact(shouldBeCompact);
updateLayout(entries[0].contentRect.width);
});
resizeObserver.observe(targetElement);
// 初始检查一次
const initialWidth = targetElement.getBoundingClientRect().width;
setInternalIsCompact(initialWidth < COMPACT_LAYOUT_THRESHOLD);
return () => resizeObserver.disconnect();
}, [forcedIsCompact]);
// 初始检查
updateLayout();
return () => {
resizeObserver.disconnect();
};
}, [forcedIsCompact, parentRef.current]);
// Only use virtualization for large lists
const useVirtualization = tasks.length > VIRTUALIZATION_THRESHOLD;
@@ -614,6 +614,188 @@
}
}
// Mobile specific styles
@media (max-width: 768px) {
// 任务队列抽屉的标题
.side-drawer__title {
font-size: 16px !important;
}
.side-drawer__header {
padding: 12px !important;
}
.side-drawer__filter {
padding: 0 12px 12px !important;
}
.task-queue-panel {
&__filters-container {
gap: 6px;
// TDesign Tabs 移动端样式 - 更紧凑
.t-tabs {
.t-tabs__nav-container {
padding: 0 !important;
}
.t-tabs__nav-wrap {
padding: 0 !important;
}
.t-tabs__nav {
gap: 0 !important; // 移除 tab 间距
}
.t-tabs__nav-item {
font-size: 11px !important;
padding: 4px 2px !important; // 最小 padding
min-width: auto !important;
white-space: nowrap;
margin: 0 !important;
}
// 缩小滚动箭头
.t-tabs__nav-scroll-btn {
width: 14px !important;
min-width: 14px !important;
padding: 0 !important;
}
// 底部指示条
.t-tabs__bar {
height: 2px !important;
}
}
}
&__filters {
gap: 6px;
}
&__type-filters {
padding: 2px;
border-radius: 8px;
.t-button {
height: 30px !important;
min-width: 30px !important;
}
}
&__search-input {
flex: 1;
min-width: 80px !important;
.t-input {
min-height: 28px !important;
height: 28px !important;
font-size: 13px !important;
border-width: 1px !important;
}
.t-input__inner {
font-size: 13px !important;
}
}
&__filter-actions {
.t-button {
min-height: 28px !important;
height: 28px !important;
padding: 0 10px !important;
font-size: 12px !important;
}
}
&__content {
padding: 8px 0 16px 12px;
}
&__list {
gap: 8px;
}
&__empty {
padding: 32px 16px;
&-icon {
font-size: 36px;
margin-bottom: 12px;
}
&-text {
font-size: 13px;
}
}
}
// Task Item mobile styles
.task-item {
padding: 12px 16px 12px 0;
gap: 3px 8px;
// 覆盖全局按钮移动端样式,保持紧凑
&__primary-action {
min-height: 26px !important;
height: 26px !important;
min-width: auto !important;
padding: 0 10px !important;
}
&--wide {
grid-template-columns: 80px 1fr;
&.task-item--selection-mode {
grid-template-columns: auto 80px 1fr;
}
}
&--compact {
grid-template-columns: 70px 1fr;
grid-template-areas:
"preview prompt"
"info info";
&.task-item--selection-mode {
grid-template-columns: auto 70px 1fr;
grid-template-areas:
"checkbox preview prompt"
"info info info";
}
.task-item__preview-wrapper {
grid-area: preview;
width: 70px;
height: 52px;
}
.task-item__prompt-area {
grid-area: prompt;
}
.task-item__info-area {
grid-area: info;
margin-top: 4px;
}
}
&__preview-wrapper {
width: 80px;
height: 60px;
}
&__prompt {
font-size: 13px;
-webkit-line-clamp: 2;
}
&__checkbox {
padding-left: 12px;
}
}
}
// Animations
@keyframes slideUp {
from {
@@ -77,7 +77,7 @@
background-color: initial;
&:focus-visible {
box-shadow: 0 0 0 2px var(--color-primary);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
outline: none;
}
@@ -161,3 +161,86 @@
}
}
}
// ============================================================================
// Mobile Responsive Styles
// ============================================================================
// 平板及以下 (max-width: 768px)
@media (max-width: 768px) {
.pen-settings-toolbar {
// 限制最大宽度,给右侧缩放控制留出更多空间
max-width: calc(100vw - 250px);
.island {
// 支持水平滚动
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
flex-wrap: nowrap;
padding: 3px 4px !important;
gap: 2px;
&::-webkit-scrollbar {
display: none;
}
}
// 按钮不收缩,尺寸更紧凑
.pen-color-button,
.pen-width-button {
flex-shrink: 0;
height: 26px;
width: 26px;
min-width: 26px;
}
// 选择器不收缩
.pen-stroke-style-picker,
.pen-anchor-type-picker {
flex-shrink: 0;
padding: 1px;
gap: 1px;
.pen-stroke-style-button,
.pen-anchor-type-button {
height: 24px;
width: 24px;
min-width: 24px;
}
}
// 隐藏输入框,使用按钮弹出选择
.pen-width-input-wrapper {
display: none;
}
// 紧凑的分隔线
.pen-settings-divider {
flex-shrink: 0;
height: 14px;
margin: 0 2px;
}
}
}
// 移动端竖屏 (max-width: 640px)
@media (max-width: 640px) {
.pen-settings-toolbar {
// 进一步限制最大宽度
max-width: calc(100vw - 200px);
}
}
// 小屏移动端 (max-width: 480px)
@media (max-width: 480px) {
.pen-settings-toolbar {
max-width: calc(100vw - 180px);
// 进一步紧凑化
.island {
padding: 2px 3px !important;
}
}
}
@@ -277,3 +277,110 @@
padding: 0 4px !important;
}
}
// ============================================================================
// Mobile Responsive Styles
// ============================================================================
// 平板及以下 (max-width: 768px)
@media (max-width: 768px) {
.pencil-settings-toolbar {
// 限制最大宽度,给右侧缩放控制留出更多空间
max-width: calc(100vw - 260px);
.island {
// 支持水平滚动
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
flex-wrap: nowrap;
padding: 3px 4px !important;
gap: 2px;
&::-webkit-scrollbar {
display: none;
}
}
// 隐藏光标预览点(节省空间)
.cursor-preview-dot {
display: none;
}
// 按钮不收缩,尺寸更紧凑
.pencil-color-button,
.pencil-width-button,
.size-picker-button {
flex-shrink: 0;
height: 26px;
width: 26px;
min-width: 26px;
}
// 描边样式选择器不收缩
.pencil-stroke-style-picker,
.eraser-shape-picker {
flex-shrink: 0;
padding: 1px;
gap: 1px;
.pencil-stroke-style-button,
.eraser-shape-button {
height: 24px;
width: 24px;
min-width: 24px;
}
}
// 隐藏输入框,使用按钮弹出选择
.pencil-width-input-wrapper,
.size-picker-input-wrapper {
display: none;
}
// 紧凑的分隔线
.toolbar-divider {
height: 14px;
margin: 0 1px;
flex-shrink: 0;
}
// 精确擦除开关更紧凑,隐藏文字
.precise-eraser-switch {
flex-shrink: 0;
padding: 2px;
.switch-label {
display: none;
}
}
// 压力感应开关不收缩,确保显示
.pencil-pressure-switch {
flex-shrink: 0;
margin-left: 4px;
}
}
}
// 移动端竖屏 (max-width: 640px)
@media (max-width: 640px) {
.pencil-settings-toolbar {
// 进一步限制最大宽度
max-width: calc(100vw - 200px);
}
}
// 小屏移动端 (max-width: 480px)
@media (max-width: 480px) {
.pencil-settings-toolbar {
max-width: calc(100vw - 220px);
// 进一步紧凑化
.island {
padding: 2px 3px !important;
}
}
}
@@ -224,4 +224,179 @@
flex-shrink: 0;
margin-left: -2px;
}
}
// ============================================================================
// Mobile Responsive Styles
// ============================================================================
// 平板及以下 (max-width: 768px)
@media (max-width: 768px) {
.popup-toolbar {
// 限制最大宽度,防止超出屏幕
max-width: calc(100vw - 32px);
// 内部 stack 支持水平滚动
.stack_horizontal {
// 将 grid 改为 flex 以支持更好的滚动
display: flex !important;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none; // Firefox
gap: 4px; // 保持按钮间距
&::-webkit-scrollbar {
display: none; // Chrome, Safari, Edge
}
}
// 确保弹出工具栏在移动端有足够的触控区域
.property-button {
min-width: 40px;
min-height: 40px;
flex-shrink: 0;
}
// 调整按钮内部元素
.tool-icon__icon {
svg {
width: 20px;
height: 20px;
}
}
// 所有工具按钮不收缩
.tool-button {
flex-shrink: 0;
}
}
// 颜色/描边设置面板
.stroke-setting,
.fill-setting {
// 增加滑块触控区域
.t-slider__button {
width: 20px;
height: 20px;
}
// 增加输入框尺寸
.stroke-width-input {
width: 40px;
height: 28px;
font-size: 14px;
}
// 拾色器宽度适配屏幕
.unified-color-picker {
width: min(280px, calc(100vw - 48px));
}
}
// 圆角设置面板
.corner-radius-setting-compact {
.corner-radius-row {
height: 32px;
}
.t-slider__button {
width: 18px;
height: 18px;
}
.corner-radius-input {
width: 44px;
height: 28px;
font-size: 14px;
}
}
}
// 移动端竖屏 (max-width: 640px)
@media (max-width: 640px) {
.popup-toolbar {
// 进一步限制最大宽度
max-width: calc(100vw - 24px);
// 更大的触控目标
.property-button {
min-width: 44px;
min-height: 44px;
}
.fill-label,
.stroke-label {
width: 20px !important;
height: 20px !important;
}
// 减小按钮间距以容纳更多按钮
.stack_horizontal {
gap: 2px;
}
}
.stroke-setting,
.fill-setting {
// 移动端垂直布局
.stroke-width-section {
flex-direction: column;
align-items: stretch;
gap: 8px;
.t-slider {
width: 100%;
}
}
.stroke-style-section {
flex-wrap: wrap;
}
// 滑块增大触控区域
.t-slider__button {
width: 24px;
height: 24px;
}
.t-slider__rail,
.t-slider__track {
height: 6px;
border-radius: 3px;
}
}
// 拾色器移动端适配
.unified-color-picker {
width: min(260px, calc(100vw - 32px)) !important;
}
}
// 小屏移动端 (max-width: 480px)
@media (max-width: 480px) {
.popup-toolbar {
// 简化工具栏,只显示最重要的按钮
.property-button {
padding: 8px;
}
}
.stroke-setting,
.fill-setting {
padding: 8px !important;
.stroke-style-label {
font-size: 11px;
}
}
.corner-radius-setting-compact {
padding: 6px !important;
.corner-radius-label {
font-size: 11px;
}
}
}
@@ -80,3 +80,15 @@
height: 12px;
}
}
// ============================================================================
// Mobile Responsive Styles
// ============================================================================
// 移动端竖屏 (max-width: 640px)
@media (max-width: 640px) {
.size-input-container {
// 在移动端隐藏宽高输入,节省空间
display: none;
}
}
@@ -1322,3 +1322,226 @@
background: #ffffff;
border-radius: 8px;
}
// ============================================================================
// Mobile Responsive Styles
// ============================================================================
// 平板及以下 (max-width: 768px)
@media (max-width: 768px) {
.text-property-panel {
width: min(320px, calc(100vw - 32px));
max-height: 70vh;
transform: translateX(0) translateY(10px);
&.is-open {
transform: translateX(0) translateY(0);
}
&__header {
padding: 10px 14px;
&-close {
width: 32px;
height: 32px;
min-width: 32px; // 触控友好
}
}
&__content {
max-height: calc(70vh - 60px);
}
&__section {
padding: 0 12px;
}
// 触控友好的按钮尺寸
.font-size-selector__preset {
min-width: 36px;
height: 32px;
padding: 0 10px;
}
.align-buttons__btn,
.style-buttons__btn,
.case-buttons__btn,
.decoration-style-buttons__btn {
min-width: 36px;
min-height: 36px;
}
.highlight-colors__btn {
width: 32px;
height: 32px;
}
// 滑块增大触控区域
.slider-control__input,
.inline-control__slider {
height: 8px;
&::-webkit-slider-thumb {
width: 20px;
height: 20px;
}
}
}
.color-picker-popover {
width: min(320px, calc(100vw - 32px));
}
}
// 移动端竖屏 (max-width: 640px)
@media (max-width: 640px) {
.text-property-panel {
// 移动端底部全宽显示
position: fixed !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
top: auto !important;
width: 100% !important;
max-width: 100% !important;
max-height: 60vh;
border-radius: 16px 16px 0 0;
transform: translateY(100%);
&.is-open {
transform: translateY(0);
}
// 添加顶部拖拽指示条
&::before {
content: '';
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
width: 36px;
height: 4px;
background: #e5e7eb;
border-radius: 2px;
}
&__header {
padding: 16px 16px 12px;
padding-top: 20px; // 为拖拽指示条留空间
}
&__content {
max-height: calc(60vh - 80px);
padding-bottom: env(safe-area-inset-bottom, 16px);
}
&__section {
padding: 0 16px;
margin-bottom: 12px;
&-content {
padding: 12px;
}
}
// 按钮组使用网格布局
.style-buttons,
.case-buttons {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
gap: 8px;
&__btn {
width: 100%;
height: 40px;
}
}
.align-buttons {
justify-content: space-between;
&__btn {
flex: 1;
max-width: 60px;
height: 40px;
}
}
// 字体大小预设网格
.font-size-selector__presets {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(44px, 1fr));
gap: 6px;
}
.font-size-selector__preset {
width: 100%;
height: 36px;
}
// 高亮颜色等距排列
.highlight-colors {
justify-content: space-between;
&__btn {
width: 36px;
height: 36px;
}
}
// 效果预设网格
.effect-presets__list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(44px, 1fr));
gap: 8px;
}
.effect-presets__item {
width: 100%;
height: 44px;
}
}
// 颜色选择器也底部显示
.color-picker-popover {
position: fixed !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
top: auto !important;
width: 100% !important;
max-width: 100% !important;
border-radius: 16px 16px 0 0;
padding-bottom: env(safe-area-inset-bottom, 16px);
}
}
// 小屏移动端 (max-width: 480px)
@media (max-width: 480px) {
.text-property-panel {
max-height: 70vh;
&__section {
&-title {
font-size: 11px;
}
}
.font-size-selector__input {
height: 36px;
font-size: 14px;
}
.inline-control {
&__label {
width: 36px;
font-size: 11px;
}
&__value {
font-size: 10px;
min-width: 28px;
}
}
}
}
@@ -1,6 +1,7 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import classNames from 'classnames';
import { ATTACHED_ELEMENT_CLASS_NAME } from '@plait/core';
import { ChevronUp, ChevronDown } from 'lucide-react';
import { AppToolbar } from './app-toolbar/app-toolbar';
import { CreationToolbar } from './creation-toolbar';
import { UnifiedToolbarProps } from './toolbar.types';
@@ -8,6 +9,9 @@ import { Island } from '../island';
import { BottomActionsSection } from './bottom-actions-section';
import { TaskQueuePanel } from '../task-queue/TaskQueuePanel';
import { useViewportScale } from '../../hooks/useViewportScale';
import { useDeviceType } from '../../hooks/useDeviceType';
import { AIImageIcon, AIVideoIcon } from '../icons';
import { DialogType, useDrawnix } from '../../hooks/use-drawnix';
// 工具栏高度阈值: 当容器高度小于此值时切换到图标模式
// 基于四个分区的最小高度 + 分割线 + padding 计算得出
@@ -37,10 +41,15 @@ export const UnifiedToolbar: React.FC<UnifiedToolbarProps> = React.memo(({
onOpenBackupRestore,
}) => {
const [isIconMode, setIsIconMode] = useState(false);
const [isMobileCollapsed, setIsMobileCollapsed] = useState(true); // 移动端默认收起
const hasEverExpanded = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
const scrollableRef = useRef<HTMLDivElement>(null);
const hasScrolledToAI = useRef(false);
// 检测设备类型
const { isMobile: isSmallScreen, isTablet } = useDeviceType();
const isMobileOrTablet = isSmallScreen || isTablet;
// 使用 viewport scale hook 确保缩放时工具栏保持在视口左上角且大小不变
useViewportScale(containerRef, {
@@ -135,6 +144,23 @@ export const UnifiedToolbar: React.FC<UnifiedToolbarProps> = React.memo(({
}
}, [taskPanelExpanded, onTaskPanelToggle]);
// 移动端工具栏切换
const handleMobileToggle = useCallback(() => {
setIsMobileCollapsed(prev => !prev);
}, []);
// 获取对话框控制
const { openDialog } = useDrawnix();
// AI 按钮点击处理
const handleAIImageClick = useCallback(() => {
openDialog(DialogType.aiImageGeneration);
}, [openDialog]);
const handleAIVideoClick = useCallback(() => {
openDialog(DialogType.aiVideoGeneration);
}, [openDialog]);
return (
<>
{/* 任务队列面板 - 只在首次展开后才渲染 */}
@@ -148,23 +174,65 @@ export const UnifiedToolbar: React.FC<UnifiedToolbarProps> = React.memo(({
'unified-toolbar',
ATTACHED_ELEMENT_CLASS_NAME,
{
'unified-toolbar--icon-only': isIconMode,
'unified-toolbar--icon-only': isIconMode || isMobileOrTablet,
'unified-toolbar--mobile-collapsed': isMobileOrTablet && isMobileCollapsed,
},
className
)}
padding={0}
data-testid="unified-toolbar"
>
{/* 移动端收起状态的快捷按钮区域 */}
{isMobileOrTablet && isMobileCollapsed && (
<div className="unified-toolbar__collapsed-shortcuts">
{/* 展开按钮 */}
<button
className="unified-toolbar__collapsed-btn unified-toolbar__collapsed-btn--toggle"
onClick={handleMobileToggle}
aria-label="展开工具栏"
>
<ChevronUp size={18} />
</button>
{/* AI 图片生成 */}
<button
className="unified-toolbar__collapsed-btn"
onClick={handleAIImageClick}
aria-label="AI 图片生成"
>
<AIImageIcon />
</button>
{/* AI 视频生成 */}
<button
className="unified-toolbar__collapsed-btn"
onClick={handleAIVideoClick}
aria-label="AI 视频生成"
>
<AIVideoIcon />
</button>
</div>
)}
{/* 移动端展开状态的收起按钮 */}
{isMobileOrTablet && !isMobileCollapsed && (
<button
className="unified-toolbar__mobile-toggle unified-toolbar__mobile-toggle--expanded"
onClick={handleMobileToggle}
aria-label="收起工具栏"
>
<ChevronDown size={20} />
</button>
)}
{/* 顶部固定区域 - 应用工具分区(菜单、撤销、重做) */}
<div className="unified-toolbar__section unified-toolbar__section--fixed-top">
<AppToolbar embedded={true} iconMode={isIconMode} onOpenBackupRestore={onOpenBackupRestore} />
<AppToolbar embedded={true} iconMode={isIconMode || isMobileOrTablet} onOpenBackupRestore={onOpenBackupRestore} />
</div>
{/* 可滚动的工具栏内容区 */}
<div ref={scrollableRef} className="unified-toolbar__scrollable">
{/* 创作工具分区 - 手型、选择、思维导图、文本、画笔、箭头、形状、图片、AI工具、缩放 */}
<div className="unified-toolbar__section">
<CreationToolbar embedded={true} iconMode={isIconMode} />
<CreationToolbar embedded={true} iconMode={isIconMode || isMobileOrTablet} />
</div>
</div>
@@ -5,7 +5,7 @@
* 支持最小化、常驻工具栏等功能
*/
import React, { useEffect, useState, Suspense, useCallback } from 'react';
import React, { useEffect, useState, Suspense, useCallback, useMemo } from 'react';
import { PlaitBoard, getViewportOrigination } from '@plait/core';
import { WinBoxWindow } from '../winbox';
import { toolWindowService } from '../../services/tool-window-service';
@@ -16,6 +16,7 @@ import { useDrawnix } from '../../hooks/use-drawnix';
import { ToolTransforms } from '../../plugins/with-tool';
import { DEFAULT_TOOL_CONFIG } from '../../constants/built-in-tools';
import { processToolUrl } from '../../utils/url-template';
import { useDeviceType } from '../../hooks/useDeviceType';
/**
* 工具弹窗管理器组件
@@ -24,6 +25,7 @@ export const ToolWinBoxManager: React.FC = () => {
const [toolStates, setToolStates] = useState<ToolWindowState[]>([]);
const { language } = useI18n();
const { board } = useDrawnix();
const { isMobile, isTablet, viewportWidth, viewportHeight } = useDeviceType();
useEffect(() => {
const subscription = toolWindowService.observeToolStates().subscribe(states => {
@@ -33,6 +35,26 @@ export const ToolWinBoxManager: React.FC = () => {
return () => subscription.unsubscribe();
}, []);
/**
* 计算窗口尺寸,确保不超出视口
*/
const getWindowSize = useCallback((tool: ToolDefinition, savedSize?: { width: number; height: number }) => {
const defaultWidth = savedSize?.width || tool.defaultWidth || 800;
const defaultHeight = savedSize?.height || tool.defaultHeight || 600;
// 移动端/平板端限制窗口尺寸
if (isMobile || isTablet) {
const maxWidth = viewportWidth - 16; // 留出边距
const maxHeight = viewportHeight - 60; // 留出标题栏和边距
return {
width: Math.min(defaultWidth, maxWidth),
height: Math.min(defaultHeight, maxHeight),
};
}
return { width: defaultWidth, height: defaultHeight };
}, [isMobile, isTablet, viewportWidth, viewportHeight]);
/**
* 处理工具最小化
*/
@@ -138,6 +160,9 @@ export const ToolWinBoxManager: React.FC = () => {
// 确定窗口是否可见
const isVisible = status === 'open';
// 计算窗口尺寸(移动端限制不超出屏幕)
const windowSize = getWindowSize(tool, size);
return (
<WinBoxWindow
key={tool.id}
@@ -145,8 +170,8 @@ export const ToolWinBoxManager: React.FC = () => {
visible={isVisible}
keepAlive={true}
title={tool.name}
width={size?.width || tool.defaultWidth || 800}
height={size?.height || tool.defaultHeight || 600}
width={windowSize.width}
height={windowSize.height}
x={position?.x}
y={position?.y}
onClose={() => toolWindowService.closeTool(tool.id)}
@@ -1849,62 +1849,78 @@
// 响应式适配 - 移动端统一使用垂直布局
@media (max-width: 768px) {
// 移动端容器布局
// 移动端容器布局 - 不滚动,由 wb-body 处理滚动
.ai-image-generation-container {
height: auto;
min-height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.main-content {
flex-direction: column;
gap: 12px;
min-height: auto;
height: auto;
flex: 1;
overflow-y: auto;
}
.ai-image-generation-section {
max-height: none;
flex: none;
order: 1; // 表单区域在上面
height: auto !important;
max-height: none !important;
overflow: visible !important;
display: flex !important;
flex-direction: column !important;
}
// 主内容区域 - 使用后代选择器确保匹配
.ai-image-generation-container .main-content {
display: flex !important;
flex-direction: column !important;
gap: 12px !important;
min-height: auto !important;
height: auto !important;
flex: none !important;
overflow: visible !important;
}
// 表单区域 - 确保显示
.ai-image-generation-container .ai-image-generation-section {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
min-width: 0 !important;
max-height: none !important;
height: auto !important;
flex: none !important;
overflow: visible !important;
padding-right: 0 !important;
order: 1 !important;
}
// 任务侧栏 - 全宽显示
.ai-image-generation-container .task-sidebar {
display: block !important;
flex: none !important;
width: 100% !important;
height: auto !important;
min-height: auto !important;
max-height: none !important;
overflow: visible !important;
padding: 8px !important;
order: 2 !important;
margin-top: 8px !important;
}
// 预览区域 - 不滚动
.preview-section {
max-height: none;
max-height: none !important;
height: auto !important;
flex: none !important;
overflow: visible !important;
}
// 移动端任务侧栏样式 - 放在表单下方
.task-sidebar {
flex: none;
height: auto;
min-height: 100px;
max-height: 180px; // 小窗默认高度限制,更紧凑
overflow-y: auto;
padding: 8px;
order: 2; // 任务列表在下面
margin-top: 8px;
// 任务列表内容也不滚动
.task-list {
max-height: none !important;
overflow: visible !important;
}
// 全屏/最大化时,任务列表可以更高
.winbox.max,
.winbox.full {
.task-sidebar {
max-height: 40vh; // 全屏时允许更大的任务列表区域
}
}
// 操作按钮区域 - 确保可见
// 操作按钮区域
.section-actions {
flex-wrap: wrap;
gap: 8px;
padding: 8px 0;
position: relative;
position: sticky; // 固定在底部
bottom: 0;
z-index: 10;
background: #fff;
margin-top: auto;
&.unified-action-bar {
gap: 8px;
@@ -1927,10 +1943,11 @@
}
@media (max-width: 480px) {
// 更小屏幕下任务列表进一步缩小
.ai-image-generation-container {
max-height: calc(100vh - 50px);
}
.task-sidebar {
min-height: 80px;
max-height: 150px;
padding: 6px;
}
@@ -126,70 +126,78 @@
}
/* Responsive adjustments - 移动端统一使用垂直布局 */
/* Responsive adjustments - 移动端统一使用垂直布局,单一滚动条 */
@media (max-width: 768px) {
/* 移动端容器布局 */
/* 移动端容器布局 - 不滚动,由 wb-body 处理滚动 */
.ai-video-generation-container {
height: auto;
min-height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
.main-content {
flex-direction: column;
gap: 12px;
min-height: auto;
height: auto;
flex: 1;
overflow-y: auto;
}
.ai-image-generation-section {
max-height: none;
flex: none;
order: 1;
}
/* 移动端任务侧栏样式 - 放在表单下方 */
.task-sidebar {
flex: none;
height: auto;
min-height: 100px;
max-height: 180px; /* 小窗默认高度限制,更紧凑 */
overflow-y: auto;
padding: 8px;
order: 2;
margin-top: 8px;
}
/* 操作按钮区域 - 确保可见 */
.section-actions {
flex-wrap: wrap;
gap: 8px;
padding: 8px 0;
position: relative;
z-index: 10;
background: #fff;
}
height: auto !important;
max-height: none !important;
overflow: visible !important;
display: flex !important;
flex-direction: column !important;
}
/* 主内容区域 - 使用后代选择器确保匹配 */
.ai-video-generation-container .main-content {
display: flex !important;
flex-direction: column !important;
gap: 12px !important;
min-height: auto !important;
height: auto !important;
flex: none !important;
overflow: visible !important;
}
/* 表单区域 - 确保显示 */
.ai-video-generation-container .ai-image-generation-section {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
min-width: 0 !important;
max-height: none !important;
height: auto !important;
flex: none !important;
overflow: visible !important;
order: 1 !important;
}
/* 任务侧栏 - 全宽显示 */
.ai-video-generation-container .task-sidebar {
display: block !important;
flex: none !important;
width: 100% !important;
height: auto !important;
min-height: auto !important;
max-height: none !important;
overflow: visible !important;
padding: 8px !important;
order: 2 !important;
margin-top: 8px !important;
}
/* 全屏/最大化时,任务列表可以更高 */
.winbox.max,
.winbox.full {
.ai-video-generation-container .task-sidebar {
max-height: 40vh; /* 全屏时允许更大的任务列表区域 */
}
/* 操作按钮区域 */
.ai-video-generation-container .section-actions {
flex-wrap: wrap;
gap: 8px;
padding: 8px 0;
position: sticky;
bottom: 0;
z-index: 10;
background: #fff;
}
.task-list {
max-height: none !important;
overflow: visible !important;
}
}
@media (max-width: 480px) {
.ai-video-generation-container {
/* 更小屏幕下任务列表进一步缩小 */
max-height: calc(100vh - 50px);
.task-sidebar {
min-height: 80px;
max-height: 150px;
padding: 6px;
}
@@ -597,6 +597,23 @@
gap: 10px;
}
.empty-library {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
text-align: center;
color: #999;
font-size: 13px;
line-height: 1.5;
background: #fafafa;
border-radius: 8px;
border: 1px dashed #e0e0e0;
white-space: nowrap;
}
.library-image {
aspect-ratio: 1;
border-radius: 8px;
@@ -732,3 +749,217 @@
background: rgba(243, 156, 18, 0.1) !important;
border-color: rgba(243, 156, 18, 0.3) !important;
}
// 移动端响应式适配
@media (max-width: 768px) {
.batch-image-generation {
flex-direction: column;
padding: 8px;
gap: 8px;
.batch-main-content {
padding: 12px;
border-radius: 8px;
}
// 工具栏移动端优化
.batch-toolbar {
flex-direction: column;
align-items: stretch;
gap: 8px;
margin-bottom: 8px;
padding-bottom: 8px;
.toolbar-left,
.toolbar-right {
justify-content: flex-start;
flex-wrap: wrap;
gap: 6px;
}
.toolbar-divider {
display: none;
}
}
.batch-generate-btn {
padding: 0 12px !important;
font-size: 13px !important;
}
// 表格容器移动端优化
.excel-table-container {
border-radius: 6px;
}
.excel-table {
th, td {
font-size: 11px;
}
th {
height: 36px;
}
.row-number {
width: 32px;
min-width: 32px;
padding: 4px;
font-size: 10px;
}
.col-checkbox {
width: 32px;
min-width: 32px;
}
// 移动端列宽调整
.col-prompt { width: 150px; min-width: 150px; }
.col-size { width: 70px; min-width: 70px; }
.col-images { width: 100px; min-width: 100px; }
.col-count { width: 50px; min-width: 50px; }
.col-preview { width: 100px; min-width: 100px; }
.th-content {
padding: 0 4px;
font-size: 10px;
}
.column-fill-btn {
width: 16px !important;
height: 16px !important;
min-height: 16px !important;
margin-left: 4px;
}
}
.excel-cell {
min-height: 36px;
padding: 6px 8px;
font-size: 12px;
&.cell-prompt {
.cell-text,
.cell-textarea {
padding: 8px !important;
font-size: 12px;
}
}
input[type="number"] {
height: 28px;
font-size: 12px;
}
.custom-size-dropdown {
.dropdown-trigger {
height: 28px;
font-size: 11px;
padding: 0 6px;
}
}
}
// 图片缩略图缩小
.cell-image-thumb,
.preview-thumb {
width: 28px;
height: 28px;
border-radius: 4px;
}
.add-image-btn {
width: 28px !important;
height: 28px !important;
}
// 图片库侧栏移动端隐藏或全屏
.image-library-sidebar {
width: 100%;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
border-radius: 0;
border: none;
&.hidden {
display: none;
}
.library-header {
padding: 12px;
}
.library-content {
padding: 12px;
}
.library-grid {
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
}
// 添加行区域
.add-rows-section {
padding: 8px 0;
gap: 8px;
flex-wrap: wrap;
}
.model-selector-wrapper {
min-width: 140px;
}
.hint-text {
margin-top: 8px;
font-size: 10px;
}
}
}
// 更小屏幕适配
@media (max-width: 480px) {
.batch-image-generation {
padding: 4px;
.batch-main-content {
padding: 8px;
}
.batch-toolbar {
.toolbar-left,
.toolbar-right {
gap: 4px;
}
}
.excel-table {
.row-number,
.col-checkbox {
width: 28px;
min-width: 28px;
}
.col-prompt { width: 120px; min-width: 120px; }
.col-size { width: 60px; min-width: 60px; }
.col-images { width: 80px; min-width: 80px; }
.col-count { width: 40px; min-width: 40px; }
.col-preview { width: 80px; min-width: 80px; }
}
.cell-image-thumb,
.preview-thumb {
width: 24px;
height: 24px;
}
.add-image-btn {
width: 24px !important;
height: 24px !important;
}
}
}
@@ -38,6 +38,7 @@ import type { Asset } from '../../types/asset.types';
import { MediaLibraryModal } from '../media-library/MediaLibraryModal';
import { LS_KEYS_TO_MIGRATE } from '../../constants/storage-keys';
import { kvStorageService } from '../../services/kv-storage-service';
import { useDeviceType } from '../../hooks/useDeviceType';
import './batch-image-generation.scss';
import { trackMemory } from '../../utils/common';
@@ -106,6 +107,10 @@ const BatchImageGeneration: React.FC<BatchImageGenerationProps> = ({ onSwitchToS
const { language } = useI18n();
const { createTask, tasks: queueTasks } = useTaskQueue();
const { addAsset, assets: libraryAssets, loadAssets } = useAssets();
const { isMobile, isTablet } = useDeviceType();
// 移动端/平板端隐藏素材库侧栏
const hideLibrarySidebar = isMobile || isTablet;
// 过滤出图片类型的素材
const imageAssets = libraryAssets.filter(asset => asset.type === AssetType.IMAGE);
@@ -2283,7 +2288,7 @@ const BatchImageGeneration: React.FC<BatchImageGenerationProps> = ({ onSwitchToS
<span className="toolbar-divider"></span>
{!showLibrary && (
{!hideLibrarySidebar && !showLibrary && (
<Tooltip content={language === 'zh' ? '显示素材库' : 'Show Library'} theme="light">
<Button
variant="outline"
@@ -2480,7 +2485,8 @@ const BatchImageGeneration: React.FC<BatchImageGenerationProps> = ({ onSwitchToS
</p>
</div>
{/* 素材库侧栏 */}
{/* 素材库侧栏 - 移动端/平板端隐藏 */}
{!hideLibrarySidebar && (
<div className={`image-library-sidebar ${!showLibrary ? 'hidden' : ''}`}>
<div className="library-header">
<h3>{language === 'zh' ? '素材库' : 'Library'}</h3>
@@ -2538,6 +2544,7 @@ const BatchImageGeneration: React.FC<BatchImageGenerationProps> = ({ onSwitchToS
</div>
</div>
</div>
)}
{/* 批量导入弹窗 */}
{showBatchImportModal && (
@@ -8,6 +8,7 @@ import type { ReferenceImage } from './shared/ReferenceImageUpload';
import { useI18n } from '../../i18n';
import { useBoard } from '@plait-board/react-board';
import React, { useState, useEffect, useRef, memo, useCallback, lazy, Suspense } from 'react';
import { useDeviceType } from '../../hooks/useDeviceType';
import { processSelectedContentForAI, extractSelectedContent } from '../../utils/selection-utils';
import {
AI_IMAGE_GENERATION_PREVIEW_CACHE_KEY,
@@ -28,6 +29,10 @@ const TTDDialogComponent = ({ container }: { container: HTMLElement | null }) =>
const { appState, setAppState, openDialog, closeDialog } = useDrawnix();
const { language } = useI18n();
const board = useBoard();
const { isMobile, isTablet } = useDeviceType();
// 移动端和平板端不显示批量出图
const showBatchTab = !isMobile && !isTablet;
// 使用ref来防止多次并发处理
const isProcessingRef = useRef(false);
@@ -120,6 +125,13 @@ const TTDDialogComponent = ({ container }: { container: HTMLElement | null }) =>
}
});
// 移动端/平板端自动切换回单图模式
useEffect(() => {
if (!showBatchTab && imageGenerationMode === 'batch') {
setImageGenerationMode('single');
}
}, [showBatchTab, imageGenerationMode]);
// 处理图片生成模式变化
const handleImageModeChange = useCallback((mode: ImageGenerationMode) => {
setImageGenerationMode(mode);
@@ -457,20 +469,37 @@ const TTDDialogComponent = ({ container }: { container: HTMLElement | null }) =>
: (language === 'zh' ? 'AI 图片生成' : 'AI Image Generation')
}
headerContent={
<div className="image-generation-mode-tabs">
<button
className={`mode-tab ${imageGenerationMode === 'single' ? 'active' : ''}`}
onClick={() => handleImageModeChange('single')}
showBatchTab ? (
<div
className="image-generation-mode-tabs"
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{language === 'zh' ? 'AI 图片生成' : 'AI Image'}
</button>
<button
className={`mode-tab ${imageGenerationMode === 'batch' ? 'active' : ''}`}
onClick={() => handleImageModeChange('batch')}
>
{language === 'zh' ? '批量出图' : 'Batch'}
</button>
</div>
<button
type="button"
className={`mode-tab ${imageGenerationMode === 'single' ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleImageModeChange('single');
}}
>
{language === 'zh' ? 'AI 图片生成' : 'AI Image'}
</button>
<button
type="button"
className={`mode-tab ${imageGenerationMode === 'batch' ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleImageModeChange('batch');
}}
>
{language === 'zh' ? '批量出图' : 'Batch'}
</button>
</div>
) : undefined
}
onClose={handleImageDialogClose}
width="80%"
@@ -202,16 +202,76 @@
}
}
// 响应式调整
// ============================================================================
// Responsive Styles
// ============================================================================
// 平板及以下 (max-width: 768px)
@media (max-width: 768px) {
.view-navigation {
top: calc(env(safe-area-inset-top, 0px) + 8px);
z-index: 4005;
&__zoom {
padding: 2px 6px;
border-radius: 16px;
}
// 确保触控友好的按钮尺寸 (最小44px)
&__zoom-btn {
width: 36px;
height: 36px;
min-width: 36px;
svg {
width: 16px;
height: 16px;
}
}
&__zoom-percent {
min-width: 44px;
height: 36px;
font-size: 12px;
padding: 0 6px;
}
&__minimap-toggle {
width: 32px;
height: 32px;
min-width: 32px;
svg {
width: 14px;
height: 14px;
}
}
&__minimap {
.minimap__content {
transform: scale(0.8);
transform-origin: top right;
}
}
}
}
// 移动端竖屏 (max-width: 640px)
@media (max-width: 640px) {
.view-navigation {
top: calc(env(safe-area-inset-top, 0px) + 6px);
gap: 4px;
&__zoom {
padding: 2px 4px;
border-radius: 14px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
&__zoom-btn {
width: 24px;
height: 24px;
width: 32px;
height: 32px;
min-width: 32px;
svg {
width: 14px;
@@ -220,14 +280,17 @@
}
&__zoom-percent {
min-width: 38px;
height: 24px;
font-size: 12px;
min-width: 40px;
height: 32px;
font-size: 11px;
padding: 0 4px;
}
// 移动端隐藏 minimap toggle 按钮,节省空间
&__minimap-toggle {
width: 20px;
height: 20px;
width: 28px;
height: 28px;
min-width: 28px;
svg {
width: 12px;
@@ -235,11 +298,75 @@
}
}
// 移动端 minimap 缩小更多
&__minimap {
.minimap__content {
transform: scale(0.85);
transform: scale(0.7);
transform-origin: top right;
}
}
}
}
// 小屏移动端 (max-width: 480px)
@media (max-width: 480px) {
.view-navigation {
&__zoom {
padding: 1px 3px;
}
&__zoom-btn {
width: 28px;
height: 28px;
min-width: 28px;
svg {
width: 12px;
height: 12px;
}
}
&__zoom-percent {
min-width: 36px;
height: 28px;
font-size: 10px;
}
// 小屏幕 minimap toggle 更紧凑
&__minimap-toggle {
width: 24px;
height: 24px;
min-width: 24px;
svg {
width: 10px;
height: 10px;
}
}
// 小屏幕 minimap 缩小
&__minimap {
.minimap__content {
transform: scale(0.6);
transform-origin: top right;
}
}
}
}
// 缩放菜单响应式
@media (max-width: 640px) {
.view-navigation-zoom-menu {
min-width: 120px;
padding: 2px;
.zoom-menu-item {
padding: 10px 10px; // 增加触控区域
font-size: 13px;
.zoom-menu-item__shortcut {
display: none; // 移动端隐藏快捷键提示
}
}
}
}
@@ -694,6 +694,14 @@ export const WinBoxWindow: React.FC<WinBoxWindowProps> = ({
const portalContainer = document.createElement('div');
portalContainer.className = 'wb-header-custom';
// 阻止触摸和鼠标事件冒泡到拖拽区域,确保按钮可点击
const stopPropagation = (e: Event) => {
e.stopPropagation();
};
portalContainer.addEventListener('mousedown', stopPropagation, true);
portalContainer.addEventListener('touchstart', stopPropagation, true);
portalContainer.addEventListener('pointerdown', stopPropagation, true);
// 插入到拖拽区域末尾(标题后面)
drag.appendChild(portalContainer);
setHeaderPortalContainer(portalContainer);
@@ -76,6 +76,16 @@
* {
pointer-events: auto;
}
// 确保模式切换按钮可点击
.image-generation-mode-tabs {
pointer-events: auto;
.mode-tab {
pointer-events: auto;
touch-action: manipulation;
}
}
}
}
@@ -405,6 +415,9 @@
gap: 4px;
border-radius: 8px;
margin-left: 8px;
position: relative;
z-index: 100; // 确保在拖拽区域之上
pointer-events: auto; // 确保可点击
.mode-tab {
padding: 6px 16px;
@@ -421,12 +434,18 @@
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto; // 确保可点击
touch-action: manipulation; // 优化移动端触摸
&:hover:not(.active) {
color: #333333;
background: rgba(255, 255, 255, 0.5);
}
&:active {
transform: scale(0.98);
}
&.active {
color: #F39C12;
font-weight: 600;
@@ -437,11 +456,18 @@
}
}
// 图片生成弹窗 - 有模式切换时隐藏标题
// 图片生成弹窗 - PC端有模式切换tabs时隐藏标题,移动端保留标题
.winbox-ai-image-generation {
.wb-title {
display: none !important;
}
// 移动端显示标题(因为没有模式切换tabs)
@media (max-width: 768px) {
.wb-title {
display: block !important;
}
}
}
// 视频生成弹窗 - 保留标题显示
@@ -544,7 +570,6 @@
.wb-min,
.wb-max,
.wb-full,
.wb-split,
.wb-insert-canvas,
.wb-close {
width: 30px;
@@ -553,6 +578,11 @@
border-radius: 6px;
}
// 移动端隐藏分屏按钮
.wb-split {
display: none !important;
}
.wb-body {
border-radius: 0 0 12px 12px;
}
@@ -579,6 +609,39 @@
}
}
// 移动端 header-custom 启用触摸
.wb-header-custom {
pointer-events: auto !important;
touch-action: auto !important;
}
// 移动端模式切换按钮确保可点击
.image-generation-mode-tabs {
pointer-events: auto !important;
touch-action: auto !important;
position: relative;
z-index: 100;
.mode-tab {
pointer-events: auto !important;
touch-action: manipulation !important;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
min-height: 40px;
padding: 10px 14px;
cursor: pointer;
// 确保触摸区域
&::before {
content: '';
position: absolute;
top: -5px;
left: -5px;
right: -5px;
bottom: -5px;
}
}
}
&.max, &.full {
.wb-body {
top: 44px !important;
@@ -590,7 +653,44 @@
padding: 12px;
}
.winbox-ai-generation .winbox-content-wrapper {
padding: 12px 16px;
// AI 生成弹窗移动端特殊处理 - 单一滚动条在最外层
.winbox-ai-generation {
.wb-body {
overflow-y: auto !important; // wb-body 负责滚动
overflow-x: hidden !important;
// 滚动条样式
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
}
.winbox-content-wrapper {
padding: 12px 16px;
overflow: visible !important;
height: auto !important;
flex: none !important;
}
// AI 容器不滚动
.ai-image-generation-container,
.ai-video-generation-container {
flex: none !important;
height: auto !important;
max-height: none !important;
overflow: visible !important;
}
}
}
+176
View File
@@ -0,0 +1,176 @@
/**
* useDeviceType Hook
*
* 检测当前设备类型(移动端、平板、桌面端)
* 响应窗口大小变化实时更新
*/
import { useState, useEffect, useCallback } from 'react';
export type DeviceType = 'mobile' | 'tablet' | 'desktop';
export interface DeviceInfo {
/** 设备类型 */
type: DeviceType;
/** 是否为移动端 (包括平板) */
isMobile: boolean;
/** 是否为平板 */
isTablet: boolean;
/** 是否为桌面端 */
isDesktop: boolean;
/** 是否为触控设备 */
isTouchDevice: boolean;
/** 是否为竖屏 */
isPortrait: boolean;
/** 是否为横屏 */
isLandscape: boolean;
/** 视口宽度 */
viewportWidth: number;
/** 视口高度 */
viewportHeight: number;
}
// 断点定义(与 _responsive.scss 保持一致)
const BREAKPOINTS = {
mobile: 640,
tablet: 768,
tabletLandscape: 1024,
} as const;
/**
* 检测是否为触控设备
*/
function detectTouchDevice(): boolean {
if (typeof window === 'undefined') return false;
return (
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
// @ts-expect-error - msMaxTouchPoints is IE-specific
navigator.msMaxTouchPoints > 0
);
}
/**
* 根据视口宽度获取设备类型
*/
function getDeviceType(width: number): DeviceType {
if (width <= BREAKPOINTS.mobile) {
return 'mobile';
}
if (width <= BREAKPOINTS.tabletLandscape) {
return 'tablet';
}
return 'desktop';
}
/**
* 获取完整的设备信息
*/
function getDeviceInfo(): DeviceInfo {
if (typeof window === 'undefined') {
// SSR 默认值
return {
type: 'desktop',
isMobile: false,
isTablet: false,
isDesktop: true,
isTouchDevice: false,
isPortrait: false,
isLandscape: true,
viewportWidth: 1920,
viewportHeight: 1080,
};
}
const width = window.innerWidth;
const height = window.innerHeight;
const type = getDeviceType(width);
const isTouchDevice = detectTouchDevice();
const isPortrait = height > width;
return {
type,
isMobile: type === 'mobile' || type === 'tablet',
isTablet: type === 'tablet',
isDesktop: type === 'desktop',
isTouchDevice,
isPortrait,
isLandscape: !isPortrait,
viewportWidth: width,
viewportHeight: height,
};
}
/**
* useDeviceType Hook
*
* @example
* ```tsx
* const { type, isMobile, isPortrait } = useDeviceType();
*
* if (isMobile) {
* return <MobileLayout />;
* }
* return <DesktopLayout />;
* ```
*/
export function useDeviceType(): DeviceInfo {
const [deviceInfo, setDeviceInfo] = useState<DeviceInfo>(getDeviceInfo);
const updateDeviceInfo = useCallback(() => {
setDeviceInfo(getDeviceInfo());
}, []);
useEffect(() => {
// 初始化时更新(处理 SSR hydration)
updateDeviceInfo();
// 监听窗口大小变化
window.addEventListener('resize', updateDeviceInfo);
// 监听屏幕方向变化
window.addEventListener('orientationchange', updateDeviceInfo);
return () => {
window.removeEventListener('resize', updateDeviceInfo);
window.removeEventListener('orientationchange', updateDeviceInfo);
};
}, [updateDeviceInfo]);
return deviceInfo;
}
/**
* 简化版 hook,仅返回设备类型
*/
export function useDeviceTypeSimple(): DeviceType {
const { type } = useDeviceType();
return type;
}
/**
* 检测是否为移动端(包括平板)
*/
export function useIsMobile(): boolean {
const { isMobile } = useDeviceType();
return isMobile;
}
/**
* 检测是否为触控设备
*/
export function useIsTouchDevice(): boolean {
const { isTouchDevice } = useDeviceType();
return isTouchDevice;
}
/**
* 检测屏幕方向
*/
export function useOrientation(): 'portrait' | 'landscape' {
const { isPortrait } = useDeviceType();
return isPortrait ? 'portrait' : 'landscape';
}
export default useDeviceType;
+36 -14
View File
@@ -22,6 +22,12 @@ export interface ImageViewerOptions {
enableKeyboard?: boolean;
}
// 检测是否为移动设备
const isMobileDevice = (): boolean => {
return window.innerWidth <= 768 ||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
};
export class ImageViewer {
private options: Required<ImageViewerOptions>;
private viewer: Viewer | null = null;
@@ -55,26 +61,42 @@ export class ImageViewer {
this.container.appendChild(img);
document.body.appendChild(this.container);
// 移动端使用精简的 toolbar
const isMobile = isMobileDevice();
const toolbar = isMobile ? {
zoomIn: 1,
zoomOut: 1,
oneToOne: 0,
reset: 1,
prev: 0,
play: 0,
next: 0,
rotateLeft: 1,
rotateRight: 1,
flipHorizontal: 0,
flipVertical: 0,
} : {
zoomIn: 1,
zoomOut: 1,
oneToOne: 1,
reset: 1,
prev: 0,
play: 0,
next: 0,
rotateLeft: 1,
rotateRight: 1,
flipHorizontal: 1,
flipVertical: 1,
};
// 创建 ViewerJS 实例
this.viewer = new Viewer(this.container, {
inline: false,
button: true,
navbar: false,
title: !!alt,
toolbar: {
zoomIn: 1,
zoomOut: 1,
oneToOne: 1,
reset: 1,
prev: 0,
play: 0,
next: 0,
rotateLeft: 1,
rotateRight: 1,
flipHorizontal: 1,
flipVertical: 1,
},
fullscreen: true,
toolbar,
fullscreen: !isMobile, // 移动端禁用全屏按钮
keyboard: this.options.enableKeyboard,
backdrop: true,
loading: true,
@@ -2,8 +2,121 @@
* 防止双指缩放服务
*
* 通过监听触摸事件来阻止页面缩放
*
* 优化:仅在画布区域阻止缩放,允许 UI 元素的正常触控交互
*/
/**
* 检查元素是否为可交互的 UI 元素
* 这些元素应该允许正常的触控事件
*/
function isInteractiveElement(target: EventTarget | null): boolean {
if (!target || !(target instanceof Element)) {
return false;
}
// 检查元素本身或其祖先是否为可交互元素
const element = target as HTMLElement;
// 可交互元素的选择器列表
const interactiveSelectors = [
// 表单元素
'input',
'textarea',
'select',
'button',
// 工具栏和抽屉
'.unified-toolbar',
'.chat-drawer',
'.side-drawer',
'.toolbox-drawer',
'.project-drawer',
'.popup-toolbar',
// 对话框和弹窗
'.t-dialog',
'.t-drawer',
'.t-popup',
'.t-popover',
'.Dialog',
'.winbox',
// AI 输入栏
'.ai-input-bar',
// 视图导航
'.view-navigation',
// 小地图
'.minimap',
// 任务队列
'.task-queue-panel',
// 滑块和颜色选择器
'.t-slider',
'.unified-color-picker',
'.color-picker',
// 媒体库
'.media-library',
// 图片预览器 (ViewerJS)
'.viewer-container',
'.viewer-canvas',
'.viewer-footer',
'.viewer-toolbar',
// 媒体预览器 (UnifiedMediaViewer)
'.unified-viewer',
'.media-viewport',
'.thumbnail-queue',
// 其他交互元素
'[role="button"]',
'[role="slider"]',
'[role="menu"]',
'[role="menuitem"]',
'[role="dialog"]',
'[data-allow-touch]', // 自定义属性,允许触控
];
// 检查元素或其祖先是否匹配任一选择器
for (const selector of interactiveSelectors) {
if (element.matches(selector) || element.closest(selector)) {
return true;
}
}
// 检查是否有可滚动内容
if (element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth) {
const style = window.getComputedStyle(element);
if (style.overflowY === 'auto' || style.overflowY === 'scroll' ||
style.overflowX === 'auto' || style.overflowX === 'scroll') {
return true;
}
}
return false;
}
/**
* 检查是否在画布区域内
*/
function isInCanvasArea(target: EventTarget | null): boolean {
if (!target || !(target instanceof Element)) {
return false;
}
const element = target as HTMLElement;
// 画布区域的选择器
const canvasSelectors = [
'.plait-board-container',
'.plait-board',
'[plait-board]',
'svg.rough-svg',
];
for (const selector of canvasSelectors) {
if (element.matches(selector) || element.closest(selector)) {
return true;
}
}
return false;
}
/**
* 初始化防止双指缩放
*/
@@ -14,21 +127,41 @@ export function initPreventPinchZoom(): () => void {
// console.log('[PreventPinchZoom] Initializing');
// 阻止多点触摸
// 阻止多点触摸(仅在画布区域)
const handleTouch = (event: TouchEvent) => {
if (event.touches.length > 1) {
const target = event.target;
// 如果是可交互的 UI 元素,允许触控
if (isInteractiveElement(target)) {
return;
}
// 仅在画布区域阻止缩放
if (isInCanvasArea(target)) {
event.preventDefault();
event.stopPropagation();
}
}
};
// 阻止手势事件(iOS Safari)- 仅在画布区域
const handleGesture = (event: Event) => {
const target = event.target;
// 如果是可交互的 UI 元素,允许手势
if (isInteractiveElement(target)) {
return;
}
// 仅在画布区域阻止手势
if (isInCanvasArea(target)) {
event.preventDefault();
event.stopPropagation();
}
};
// 阻止手势事件(iOS Safari)
const handleGesture = (event: Event) => {
event.preventDefault();
event.stopPropagation();
};
// 阻止 Ctrl/Cmd + 滚轮缩放
// 阻止 Ctrl/Cmd + 滚轮缩放(全局阻止,因为这是桌面端行为)
const handleWheel = (event: WheelEvent) => {
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
@@ -36,6 +169,8 @@ export function initPreventPinchZoom(): () => void {
};
// 添加事件监听器
// 使用 passive: false 确保可以调用 preventDefault
// 使用 capture: true 在捕获阶段处理事件
document.addEventListener('touchstart', handleTouch, { passive: false, capture: true });
document.addEventListener('touchmove', handleTouch, { passive: false, capture: true });
document.addEventListener('touchend', handleTouch, { passive: false, capture: true });
@@ -0,0 +1,225 @@
/**
* Responsive Design Variables and Mixins
*
* 响应式设计变量和混合宏
* 用于多端适配(移动端、平板、桌面端)
*/
// ============================================================================
// Device Breakpoints (设备断点)
// ============================================================================
$breakpoint-mobile: 640px; // 移动端竖屏
$breakpoint-tablet: 768px; // 移动端横屏/平板竖屏
$breakpoint-tablet-landscape: 1024px; // 平板横屏
$breakpoint-desktop: 1280px; // 桌面端
// ============================================================================
// Safe Area Insets (安全区域)
// ============================================================================
// 用于适配 iPhone X 及以上设备的刘海屏和底部指示条
$safe-area-top: env(safe-area-inset-top, 0px);
$safe-area-bottom: env(safe-area-inset-bottom, 0px);
$safe-area-left: env(safe-area-inset-left, 0px);
$safe-area-right: env(safe-area-inset-right, 0px);
// ============================================================================
// Mobile-specific Variables (移动端专用变量)
// ============================================================================
$mobile-toolbar-height: 48px; // 移动端工具栏高度
$mobile-input-bar-height: 56px; // 移动端输入栏高度
$mobile-button-min-size: 44px; // 移动端最小触控目标尺寸 (Apple HIG)
$mobile-spacing: 8px; // 移动端基础间距
$mobile-border-radius: 12px; // 移动端圆角
// ============================================================================
// Responsive Mixins (响应式混合宏)
// ============================================================================
/// 仅移动端竖屏 (max-width: 640px)
@mixin mobile {
@media (max-width: #{$breakpoint-mobile}) {
@content;
}
}
/// 仅移动端横屏/平板竖屏 (641px - 768px)
@mixin tablet-portrait {
@media (min-width: #{$breakpoint-mobile + 1}) and (max-width: #{$breakpoint-tablet}) {
@content;
}
}
/// 平板及以下 (max-width: 768px)
@mixin tablet-and-below {
@media (max-width: #{$breakpoint-tablet}) {
@content;
}
}
/// 仅平板横屏 (769px - 1024px)
@mixin tablet-landscape {
@media (min-width: #{$breakpoint-tablet + 1}) and (max-width: #{$breakpoint-tablet-landscape}) {
@content;
}
}
/// 平板横屏及以下 (max-width: 1024px)
@mixin tablet-landscape-and-below {
@media (max-width: #{$breakpoint-tablet-landscape}) {
@content;
}
}
/// 仅桌面端 (min-width: 1025px)
@mixin desktop {
@media (min-width: #{$breakpoint-tablet-landscape + 1}) {
@content;
}
}
/// 大桌面端 (min-width: 1281px)
@mixin desktop-large {
@media (min-width: #{$breakpoint-desktop + 1}) {
@content;
}
}
// ============================================================================
// Touch-friendly Mixins (触控友好混合宏)
// ============================================================================
/// 触控设备检测
@mixin touch-device {
@media (hover: none) and (pointer: coarse) {
@content;
}
}
/// 非触控设备检测 (鼠标/触控板)
@mixin pointer-device {
@media (hover: hover) and (pointer: fine) {
@content;
}
}
/// 确保最小触控目标尺寸
@mixin touch-target($size: $mobile-button-min-size) {
min-width: $size;
min-height: $size;
}
/// 扩展触控区域(通过伪元素)
@mixin extend-touch-area($extend: 8px) {
position: relative;
&::before {
content: '';
position: absolute;
top: -$extend;
right: -$extend;
bottom: -$extend;
left: -$extend;
}
}
// ============================================================================
// Safe Area Helpers (安全区域辅助)
// ============================================================================
/// 添加底部安全区域 padding
@mixin safe-area-bottom($additional: 0px) {
padding-bottom: calc(#{$safe-area-bottom} + #{$additional});
}
/// 添加顶部安全区域 padding
@mixin safe-area-top($additional: 0px) {
padding-top: calc(#{$safe-area-top} + #{$additional});
}
/// 添加左侧安全区域 padding
@mixin safe-area-left($additional: 0px) {
padding-left: calc(#{$safe-area-left} + #{$additional});
}
/// 添加右侧安全区域 padding
@mixin safe-area-right($additional: 0px) {
padding-right: calc(#{$safe-area-right} + #{$additional});
}
/// 添加所有安全区域 padding
@mixin safe-area-all($additional: 0px) {
padding-top: calc(#{$safe-area-top} + #{$additional});
padding-right: calc(#{$safe-area-right} + #{$additional});
padding-bottom: calc(#{$safe-area-bottom} + #{$additional});
padding-left: calc(#{$safe-area-left} + #{$additional});
}
// ============================================================================
// Layout Helpers (布局辅助)
// ============================================================================
/// 移动端全屏抽屉/对话框
@mixin mobile-fullscreen {
@include mobile {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
height: 100vh;
max-width: none;
max-height: none;
border-radius: 0;
@include safe-area-all();
}
}
/// 移动端底部固定元素
@mixin mobile-fixed-bottom($height: auto) {
@include mobile {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: $height;
@include safe-area-bottom();
@include safe-area-left();
@include safe-area-right();
}
}
/// 隐藏在移动端
@mixin hide-on-mobile {
@include mobile {
display: none !important;
}
}
/// 隐藏在平板及以下
@mixin hide-on-tablet-and-below {
@include tablet-and-below {
display: none !important;
}
}
/// 仅在移动端显示
@mixin show-on-mobile-only {
display: none !important;
@include mobile {
display: block !important;
}
}
/// 仅在平板及以下显示
@mixin show-on-tablet-and-below-only {
display: none !important;
@include tablet-and-below {
display: block !important;
}
}
+193
View File
@@ -5,6 +5,7 @@
@import './tdesign-theme.scss';
@import './theme.scss';
@import './z-index.scss'; // Z-Index层级管理变量
@import './responsive'; // 响应式设计变量和混合宏
@import './toolbox-theme.scss'; // 工具箱主题变量
@import '../components/side-drawer/side-drawer.scss'; // 通用侧边抽屉
@import '../components/chat-drawer/chat-drawer.scss';
@@ -54,6 +55,13 @@
background: var(--color-surface-high);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
// 移动端调整定位,位于工具栏右侧避免重叠
@media (max-width: 768px) {
left: 70px; // 工具栏宽度(~50px) + 间距
transform: none;
top: calc(env(safe-area-inset-top, 0px) + 8px);
}
}
.pen-settings-toolbar {
position: absolute;
@@ -64,6 +72,13 @@
background: var(--color-surface-high);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
// 移动端调整定位,位于工具栏右侧避免重叠
@media (max-width: 768px) {
left: 70px; // 工具栏宽度(~50px) + 间距
transform: none;
top: calc(env(safe-area-inset-top, 0px) + 8px);
}
}
.draw-toolbar {
cursor: default;
@@ -170,7 +185,10 @@
// 可滚动内容区域
&__scrollable {
flex: 1;
min-height: 0; // 关键:允许 flex 子元素收缩以启用滚动
overflow-y: auto;
-webkit-overflow-scrolling: touch; // iOS 惯性滚动
touch-action: pan-y; // 允许垂直滚动触摸
display: flex;
flex-direction: column;
gap: 4px; // 增加间距
@@ -234,6 +252,181 @@
display: none;
}
}
// ============================================================================
// 移动端响应式样式 (Mobile Responsive Styles)
// ============================================================================
// 平板及以下 (max-width: 768px) - 简化工具栏
@include tablet-and-below {
left: $mobile-spacing;
top: auto;
bottom: calc(#{$safe-area-bottom} + 70px); // 给 AI 输入栏留出空间
height: auto;
max-height: calc(100vh - 160px); // 限制最大高度
width: auto;
max-width: none;
border-radius: $mobile-border-radius;
// 移动端强制使用图标模式
.tool-icon__label {
display: none;
}
// 调整 section padding
.unified-toolbar__section {
padding: 0 4px;
&--fixed-top,
&--fixed-bottom {
padding: 8px 4px;
}
}
// 调整按钮尺寸确保触控友好
.tool-button,
.tool-icon {
@include touch-target($mobile-button-min-size);
}
}
// 移动端竖屏 (max-width: 640px) - 最小化工具栏
@include mobile {
left: $mobile-spacing;
bottom: calc(#{$safe-area-bottom} + 80px); // 更多空间给 AI 输入栏
max-height: calc(100vh - 180px);
// 更紧凑的布局
&__scrollable {
padding: 4px 0;
gap: 2px;
}
// 更紧凑的 section
.unified-toolbar__section {
padding: 0 2px;
&:not(:first-child):not(.unified-toolbar__section--fixed-top):not(.unified-toolbar__section--fixed-bottom) {
margin-top: 4px;
padding-top: 4px;
}
&--fixed-top,
&--fixed-bottom {
padding: 6px 2px;
}
}
}
// 移动端收起状态修饰符
&--mobile-collapsed {
@include tablet-and-below {
// 保持位置不变,只调整高度为自适应
height: auto !important;
min-height: 0 !important;
max-height: none !important;
bottom: auto !important;
overflow: visible !important;
// 隐藏所有常规内容
.unified-toolbar__scrollable,
.unified-toolbar__section,
.unified-toolbar__mobile-toggle {
display: none !important;
}
}
}
// 移动端收起状态快捷按钮区域
&__collapsed-shortcuts {
display: none; // 默认隐藏
@include tablet-and-below {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
padding: 4px 8px 8px;
width: 100%; // 填满容器宽度
}
}
// 收起状态快捷按钮
&__collapsed-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%; // 填满容器宽度
height: 40px;
min-height: 40px;
background: transparent;
border: none;
border-radius: 8px;
color: var(--color-on-surface);
cursor: pointer;
transition: all 0.15s ease;
outline: none; // 移除焦点边框
&:hover, &:active {
background: var(--color-surface-high, rgba(0, 0, 0, 0.05));
}
&:focus {
outline: none; // 移除焦点边框
}
svg {
width: 22px;
height: 22px;
}
// 展开按钮特殊样式 - 更紧凑
&--toggle {
height: 28px;
min-height: 28px;
margin-bottom: 2px;
svg {
width: 16px;
height: 16px;
color: var(--color-text-secondary, #999);
}
}
}
// 移动端展开按钮(展开状态时显示)
&__mobile-toggle {
display: none;
@include tablet-and-below {
display: flex;
align-items: center;
justify-content: center;
width: $mobile-button-min-size;
height: $mobile-button-min-size;
background: transparent;
border: none;
border-radius: 8px;
color: var(--color-on-surface);
cursor: pointer;
transition: background-color 0.2s ease;
flex-shrink: 0;
&:hover, &:active {
background: var(--color-surface-high);
}
svg {
width: 20px;
height: 20px;
transition: transform 0.2s ease;
}
&--expanded svg {
transform: rotate(180deg);
}
}
}
}
// Embedded mode for individual toolbars (适配80px垂直布局)
@@ -289,4 +289,298 @@
--td-border-level-1-color: #404040;
--td-border-level-2-color: #595959;
}
}
/* ============================================================================
* 移动端响应式适配 (Mobile Responsive Styles)
* ============================================================================ */
/* 平板及以下 (max-width: 768px) */
@media (max-width: 768px) {
/* Dialog 组件移动端适配 */
.t-dialog {
max-width: calc(100vw - 32px) !important;
margin: 16px !important;
&.t-dialog--mode-modal {
/* 模态对话框几乎全屏 */
width: calc(100vw - 32px) !important;
max-height: calc(100vh - 100px) !important;
}
/* Header 紧凑布局 */
.t-dialog__header {
padding: 12px 16px;
.t-dialog__header-content {
font-size: 16px;
}
}
/* Body 滚动支持 */
.t-dialog__body {
padding: 12px 16px;
max-height: calc(100vh - 200px);
overflow-y: auto;
}
/* Footer 固定在底部 */
.t-dialog__footer {
padding: 12px 16px;
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 12px);
}
/* 按钮触控友好 */
.t-button {
min-height: 40px;
min-width: 80px;
}
}
/* Drawer 组件移动端适配 */
.t-drawer {
&.t-drawer--left,
&.t-drawer--right {
max-width: calc(100vw - 48px) !important;
}
.t-drawer__header {
padding: 12px 16px;
}
.t-drawer__body {
padding: 12px 16px;
}
.t-drawer__footer {
padding: 12px 16px;
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 12px);
}
}
/* Popup/Popover 移动端适配 */
.t-popup,
.t-popover {
max-width: calc(100vw - 32px) !important;
}
/* Select 下拉菜单适配 */
.t-select__dropdown {
max-width: calc(100vw - 32px);
max-height: 50vh;
}
/* Input 组件触控优化 */
.t-input {
min-height: 40px;
.t-input__inner {
font-size: 16px; /* 防止 iOS 自动缩放 */
}
}
/* Textarea 组件触控优化 */
.t-textarea {
.t-textarea__inner {
font-size: 16px; /* 防止 iOS 自动缩放 */
}
}
/* Button 组件触控区域 */
.t-button {
min-height: 36px;
padding: 8px 16px;
}
/* Slider 组件触控优化 */
.t-slider {
.t-slider__button {
width: 20px !important;
height: 20px !important;
}
.t-slider__rail,
.t-slider__track {
height: 6px !important;
}
}
}
/* 移动端竖屏 (max-width: 640px) */
@media (max-width: 640px) {
/* Dialog 全屏显示 */
.t-dialog {
&.t-dialog--mode-modal {
width: 100vw !important;
max-width: 100vw !important;
height: 100vh !important;
max-height: 100vh !important;
margin: 0 !important;
border-radius: 0 !important;
}
.t-dialog__header {
padding-top: calc(env(safe-area-inset-top, 0px) + 12px);
}
.t-dialog__body {
flex: 1;
max-height: none;
}
}
/* Drawer 全宽显示 */
.t-drawer {
&.t-drawer--left,
&.t-drawer--right {
width: 100vw !important;
max-width: 100vw !important;
}
.t-drawer__header {
padding-top: calc(env(safe-area-inset-top, 0px) + 12px);
}
}
/* Message 提示位置调整 */
.t-message {
top: calc(env(safe-area-inset-top, 0px) + 16px) !important;
max-width: calc(100vw - 32px);
}
/* Notification 提示位置调整 */
.t-notification {
max-width: calc(100vw - 32px);
right: 16px !important;
left: 16px !important;
}
}
/* 小屏移动端 (max-width: 480px) */
@media (max-width: 480px) {
.t-dialog {
.t-dialog__header {
padding: 10px 12px;
padding-top: calc(env(safe-area-inset-top, 0px) + 10px);
.t-dialog__header-content {
font-size: 15px;
}
}
.t-dialog__body {
padding: 10px 12px;
}
.t-dialog__footer {
padding: 10px 12px;
flex-wrap: wrap;
gap: 8px;
.t-button {
flex: 1;
min-width: calc(50% - 4px);
}
}
}
/* Select 组件紧凑 */
.t-select {
.t-input__inner {
font-size: 14px;
}
}
/* Tabs 组件紧凑 */
.t-tabs {
.t-tabs__nav-item {
padding: 8px 12px;
font-size: 13px;
}
}
}
/* ViewerJS 图片预览器移动端适配 */
@media (max-width: 768px) {
.viewer-container {
// 底部区域允许滚动
.viewer-footer {
overflow-x: auto !important;
overflow-y: hidden !important;
}
// 工具条容器
.viewer-toolbar {
overflow-x: auto !important;
overflow-y: hidden !important;
max-width: 100vw !important;
// 隐藏滚动条但保持滚动功能
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
> ul {
display: flex !important;
flex-wrap: nowrap !important;
white-space: nowrap !important;
padding: 6px 8px !important;
margin: 0 auto 5px !important;
> li {
flex-shrink: 0 !important;
float: none !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
}
}
}
// 底部导航栏适配
.viewer-navbar {
overflow-x: auto !important;
overflow-y: hidden !important;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
.viewer-list {
display: flex !important;
flex-wrap: nowrap !important;
overflow-x: auto !important;
width: auto !important;
}
// 缩小关闭按钮
.viewer-button {
width: 60px !important;
height: 60px !important;
}
}
}
@media (max-width: 480px) {
.viewer-container {
.viewer-toolbar > ul > li {
width: 22px !important;
height: 22px !important;
margin: 0 1px !important;
&::before {
margin: 1px !important;
}
}
.viewer-button {
width: 50px !important;
height: 50px !important;
}
}
}
@@ -45,7 +45,6 @@
&:active {
background-color: var(--color-surface-primary-container);
border: 1px solid var(--button-active-border);
svg {
color: var(--color-on-primary-container);
@@ -95,7 +94,6 @@
&:active {
background-color: var(--button-active-bg, var(--island-bg-color));
border-color: var(--button-active-border, var(--color-primary-darkest));
}
&.active {