diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b8eb50b6eb..4c10092201 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,7 +30,6 @@ on: ref: required: false type: string - default: ${{ github.ref }} mobile: required: false type: string @@ -39,24 +38,27 @@ on: jobs: build: runs-on: ${{ inputs.platform }} + permissions: + contents: write steps: - name: Checkout repository uses: actions/checkout@v4 with: + fetch-depth: 0 repository: ${{ inputs.repository }} ref: ${{ inputs.ref }} - fetch-depth: 0 - name: Install Rust uses: actions-rust-lang/setup-rust-toolchain@v1 with: rustflags: '' + target: ${{ inputs.target }} - name: Rust Cache uses: Swatinem/rust-cache@v2 with: workspaces: src-tauri - key: ${{ inputs.mobile == '' && inputs.platform || format('{0}-{1}', inputs.platform, inputs.mobile) }} + key: ${{ inputs.mobile != '' && format('{0}-{1}', inputs.platform, inputs.mobile) || inputs.platform }}${{ inputs.target != '' && format('-{0}', inputs.target) || '' }} - name: Set up Node.js uses: actions/setup-node@v4 @@ -79,6 +81,18 @@ jobs: librsvg2-dev \ patchelf + - name: Install LLVM and Clang (Windows) + if: runner.os == 'Windows' + uses: KyleMayes/install-llvm-action@v2 + with: + version: "19" + directory: ${{ runner.temp }}/llvm + + - name: Set LIBCLANG_PATH (Windows) + if: runner.os == 'Windows' + shell: bash + run: echo "LIBCLANG_PATH=${{ runner.temp }}/llvm/bin" >> $GITHUB_ENV + # ------------- Android Setup ------------- - name: Set up JDK if: inputs.mobile == 'android' @@ -100,65 +114,79 @@ jobs: link-to-sdk: true - name: Install frontend dependencies - run: npm install + run: npm ci - name: Setup Android signing if: inputs.mobile == 'android' && github.event_name != 'pull_request' run: | cd src-tauri/gen/android - echo "keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" > keystore.properties - echo "password=${{ secrets.ANDROID_KEY_PASSWORD }}" >> keystore.properties - echo "${{ secrets.ANDROID_KEY_BASE64 }}" | base64 -d > $RUNNER_TEMP/keystore.jks - echo "storeFile=$RUNNER_TEMP/keystore.jks" >> keystore.properties - - - name: rustup install target - if: ${{ inputs.target != '' }} - run: rustup target add ${{ inputs.target }} + keytool -genkey -v \ + -keystore "$RUNNER_TEMP/rapidraw-release.jks" \ + -keyalg RSA -keysize 2048 -validity 36500 \ + -alias rapidraw \ + -storepass android123 \ + -keypass android123 \ + -dname "CN=RAW工坊, OU=Dev, O=RAW工坊, ST=China, C=CN" \ + -noprompt + echo "keyAlias=rapidraw" > keystore.properties + echo "password=android123" >> keystore.properties + echo "storeFile=$RUNNER_TEMP/rapidraw-release.jks" >> keystore.properties - id: patch-release-name shell: bash - if: ${{ inputs.release-id != '' }} run: | platform="${{ inputs.platform }}" if [[ "${{ inputs.mobile }}" == "android" ]]; then replacement="android" else - replacement="$(echo ${platform} | sed -E 's/-latest//')" + replacement="$(echo "${platform}" | sed -E 's/-latest//' | sed -E 's/windows-11-arm/windows-arm64/' | sed -E 's/windows$/windows-x64/')" + fi + if [[ -n "${{ inputs.asset-name-pattern }}" ]]; then + patched_platform=$(echo '${{ inputs.asset-name-pattern }}' | sed -E "s/\[platform\]/${replacement}/") + else + patched_platform="${replacement}" fi - patched_platform=$(echo '${{ inputs.asset-name-pattern }}' | sed -E "s/\[platform\]/${replacement}/") if [[ -n "${{ inputs.asset-prefix }}" ]]; then patched_platform="${{ inputs.asset-prefix }}_${patched_platform}" fi - echo "platform=${patched_platform}" >> $GITHUB_OUTPUT + echo "platform=${patched_platform}" >> "$GITHUB_OUTPUT" - id: tauri-build name: Build with tauri-action - if: ${{ inputs.mobile == '' }} - # FIXME set this back to a release major version, ex. v0 . This is a commit from the `dev` branch which includes PR - # https://github.com/tauri-apps/tauri-action/pull/1099 , adding the assetNamePattern feature. - uses: tauri-apps/tauri-action@cf3eb9b18add8548a40584695215c80ab7274f31 + if: inputs.mobile == '' + uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NO_STRIP: ${{ startsWith(inputs.platform, 'ubuntu') }} with: - args: --verbose ${{ inputs.build-args }} ${{ inputs.target != '' && '--target' || '' }} ${{ inputs.target }} + args: --verbose${{ inputs.build-args && format(' {0}', inputs.build-args) || '' }}${{ inputs.target != '' && format(' --target {0}', inputs.target) || '' }} assetNamePattern: ${{ steps.patch-release-name.outputs.platform }} releaseId: ${{ inputs.release-id }} retryAttempts: 3 - name: Build Android - if: ${{ inputs.mobile == 'android' }} + if: inputs.mobile == 'android' shell: bash env: + ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} ORT_SKIP_DOWNLOAD: '1' ORT_LIB_LOCATION: ${{ format('{0}/src-tauri/libs/arm64-v8a', github.workspace) }} ORT_STRATEGY: manual run: | - npx tauri android build --verbose ${{ inputs.build-args }} + # tauri android build --target accepts short names (aarch64, armv7, i686, x86_64) + # while Rust toolchain setup needs the full triple (e.g. aarch64-linux-android) + target_arg="" + if [[ -n "${{ inputs.target }}" ]]; then + tauri_target="${{ inputs.target }}" + # Strip everything after the first hyphen to get the short name + tauri_target="${tauri_target%%-*}" + target_arg="--target $tauri_target" + fi + npx tauri android build --verbose ${target_arg}${{ inputs.build-args && format(' {0}', inputs.build-args) || '' }} - name: Prepare Android Release Assets - if: ${{ inputs.mobile == 'android' && inputs.release-id != '' }} + if: inputs.mobile == 'android' && inputs.release-id != '' shell: bash run: | set -euo pipefail @@ -182,19 +210,18 @@ jobs: done - name: Upload Android Release Assets - if: ${{ inputs.mobile == 'android' && inputs.release-id != '' }} + if: inputs.mobile == 'android' && inputs.release-id != '' shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - tag_ref="${{ inputs.ref }}" - tag_name="${tag_ref#refs/tags/}" + tag_name="${{ github.event.release.tag_name || github.ref_name }}" gh release upload "$tag_name" "$RUNNER_TEMP"/android-release-assets/* --clobber - name: Upload Android Artifacts - if: ${{ inputs.mobile == 'android' && inputs.release-id == '' }} + if: inputs.mobile == 'android' && inputs.release-id == '' uses: actions/upload-artifact@v4 with: name: ${{ inputs.asset-prefix }}_android${{ inputs.target && format('_{0}', inputs.target) || '' }}_artifacts @@ -206,13 +233,15 @@ jobs: if-no-files-found: warn - name: Upload Windows Artifacts - if: ${{ startsWith(inputs.platform, 'windows') && inputs.release-id == '' && inputs.mobile == '' }} + if: startsWith(inputs.platform, 'windows') && inputs.release-id == '' && inputs.mobile == '' uses: actions/upload-artifact@v4 with: name: ${{ inputs.asset-prefix }}_${{ inputs.platform }}${{ inputs.target && format('_{0}', inputs.target) || '' }}_artifacts path: | - src-tauri/target/${{ inputs.target || 'release' }}/bundle/**/*.exe - src-tauri/target/${{ inputs.target || 'release' }}/release/bundle/**/*.exe + src-tauri/target/${{ inputs.target }}/release/bundle/nsis/*.exe + src-tauri/target/${{ inputs.target }}/release/bundle/nsis/*.nsis.zip + src-tauri/target/release/bundle/nsis/*.exe + src-tauri/target/release/bundle/nsis/*.nsis.zip retention-days: 7 compression-level: 6 if-no-files-found: warn diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f689dd317..59227b7e97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,36 +11,37 @@ jobs: fail-fast: false matrix: include: + # Windows x64 - platform: 'windows-latest' - builds-args: '--bundles nsis' + args: '--bundles nsis' target: '' asset-prefix: '01' + # Windows ARM64 (native build on ARM runner) - platform: 'windows-11-arm' - builds-args: '--bundles nsis' - target: 'aarch64-pc-windows-msvc' - asset-prefix: '01' + args: '--bundles nsis' + target: '' + asset-prefix: '01_arm' + # macOS Apple Silicon - platform: 'macos-14' target: aarch64-apple-darwin asset-prefix: '02' - - platform: 'macos-15-intel' + # macOS Intel (use macos-13) + - platform: 'macos-13' target: x86_64-apple-darwin - asset-prefix: '02' + asset-prefix: '02_intel' + # Ubuntu x64 - platform: 'ubuntu-22.04' target: '' asset-prefix: '03' - - platform: 'ubuntu-22.04-arm' - target: '' - asset-prefix: '03' + # Ubuntu 24.04 x64 - platform: 'ubuntu-24.04' target: '' - asset-prefix: '03' - - platform: 'ubuntu-24.04-arm' - target: '' - asset-prefix: '03' + asset-prefix: '03_24' + # Android ARM64 - platform: 'ubuntu-latest' mobile: 'android' target: 'aarch64-linux-android' - args: '--target aarch64' + args: '' asset-prefix: '04' uses: ./.github/workflows/build.yml with: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index dea8e99de3..a942a89efc 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: 'npm' - name: Install dependencies @@ -66,9 +66,14 @@ jobs: sudo apt-get update sudo apt-get install -y \ libwebkit2gtk-4.1-dev \ + build-essential \ + curl \ + wget \ + file \ libssl-dev \ libayatana-appindicator3-dev \ - librsvg2-dev + librsvg2-dev \ + patchelf - name: Clippy working-directory: src-tauri diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 8227ea5dd0..58d1bfaef8 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -10,35 +10,29 @@ jobs: matrix: include: - platform: 'windows-latest' - builds-args: '--bundles nsis' + args: '--bundles nsis' target: '' asset-prefix: '01' - platform: 'windows-11-arm' - builds-args: '--bundles nsis' - target: 'aarch64-pc-windows-msvc' - asset-prefix: '01' + args: '--bundles nsis' + target: '' + asset-prefix: '01_arm' - platform: 'macos-14' target: aarch64-apple-darwin asset-prefix: '02' - - platform: 'macos-15-intel' + - platform: 'macos-13' target: x86_64-apple-darwin - asset-prefix: '02' + asset-prefix: '02_intel' - platform: 'ubuntu-22.04' target: '' asset-prefix: '03' - - platform: 'ubuntu-22.04-arm' - target: '' - asset-prefix: '03' - platform: 'ubuntu-24.04' target: '' - asset-prefix: '03' - - platform: 'ubuntu-24.04-arm' - target: '' - asset-prefix: '03' + asset-prefix: '03_24' - platform: 'ubuntu-latest' mobile: 'android' target: 'aarch64-linux-android' - args: '--debug --target aarch64' + args: '--debug' asset-prefix: '04' uses: ./.github/workflows/build.yml with: @@ -47,3 +41,6 @@ jobs: build-args: ${{ matrix.args }} asset-prefix: ${{ matrix.asset-prefix }} mobile: ${{ matrix.mobile }} + secrets: inherit + permissions: + contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c47901160..728c1c38a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,43 +10,48 @@ jobs: fail-fast: false matrix: include: + # Windows x64 - platform: 'windows-latest' - builds-args: '--bundles nsis' + build-args: '--bundles nsis' target: '' asset-prefix: '01' + # Windows ARM64 (native build on ARM runner) - platform: 'windows-11-arm' - builds-args: '--bundles nsis' - target: 'aarch64-pc-windows-msvc' - asset-prefix: '01' + build-args: '--bundles nsis' + target: '' + asset-prefix: '01_arm' + # macOS Apple Silicon (M1/M2/M3) - platform: 'macos-14' + build-args: '' target: aarch64-apple-darwin asset-prefix: '02' - - platform: 'macos-15-intel' + # macOS Intel (use macos-13 for Intel) + - platform: 'macos-13' + build-args: '' target: x86_64-apple-darwin - asset-prefix: '02' + asset-prefix: '02_intel' + # Ubuntu x64 - platform: 'ubuntu-22.04' + build-args: '' target: '' asset-prefix: '03' - - platform: 'ubuntu-22.04-arm' - target: '' - asset-prefix: '03' + # Ubuntu 24.04 x64 - platform: 'ubuntu-24.04' + build-args: '' target: '' - asset-prefix: '03' - - platform: 'ubuntu-24.04-arm' - target: '' - asset-prefix: '03' + asset-prefix: '03_24' + # Android ARM64 - platform: 'ubuntu-latest' mobile: 'android' target: 'aarch64-linux-android' - args: '--target aarch64' + build-args: '' asset-prefix: '04' uses: ./.github/workflows/build.yml with: release-id: ${{ github.event.release.id }} platform: ${{ matrix.platform }} target: ${{ matrix.target }} - build-args: ${{ matrix.args }} + build-args: ${{ matrix.build-args }} asset-name-pattern: '[name]_v[version]_[platform]_[arch][ext]' asset-prefix: ${{ matrix.asset-prefix }} mobile: ${{ matrix.mobile }} diff --git a/.gitignore b/.gitignore index 1236ed3aa3..54f07af58b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,6 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* -/target/ node_modules dist dist-ssr @@ -16,25 +15,10 @@ dist-ssr # Editor directories and files .vscode/* !.vscode/extensions.json -!.vscode/settings.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln -*.sw? - -# dynamic libraries downloaded by 'ort' crate at build time -src-tauri/resources/libonnxruntime.so -src-tauri/resources/libonnxruntime.dylib -src-tauri/resources/onnxruntime.dll -src-tauri/gen/* -!src-tauri/gen/android/ -# Ignore Android assets generated by Tauri -src-tauri/gen/android/app/src/main/assets/ -src-tauri/libs/ - -**/keystore.properties -**/keystore.jks -**/key.properties \ No newline at end of file +*.sw? \ No newline at end of file diff --git a/.trae/documents/prd.md b/.trae/documents/prd.md new file mode 100644 index 0000000000..d0cfceb6ce --- /dev/null +++ b/.trae/documents/prd.md @@ -0,0 +1,89 @@ +# RapidRAW 应用介绍页面 — 产品需求文档 + +## 1. 产品概述 +RapidRAW 是一款美观、非破坏性、GPU 加速的 RAW 图像编辑器,定位为 Adobe Lightroom 的现代高性能替代品。本页面旨在以哈苏橙(Hasselblad Orange)为视觉核心,打造一个极致精美的应用介绍与使用指南落地页,向摄影爱好者和专业用户传达产品的高端、专业、极致体验感。 + +- 目标用户:摄影爱好者、专业摄影师、后期修图从业者、追求高性能工具的创作者 +- 核心价值:以顶级视觉设计传递 RapidRAW 的轻量、高性能、跨平台、非破坏性编辑等产品亮点 + +## 2. 核心功能 + +### 2.1 页面模块 +1. **首屏 Hero 区域**:全屏视觉冲击,产品名称、核心标语、CTA 下载按钮,配合哈苏橙渐变光效动画 +2. **产品特性展示区**:GPU 加速、非破坏性编辑、跨平台支持、轻量 < 20MB 等核心卖点 +3. **编辑器预览区**:RapidRAW 编辑器界面展示,模拟编辑工作流 +4. **技术架构展示**:Rust + wgpu + React + Tauri 技术栈可视化 +5. **使用指南区**:快速上手步骤指引,从安装到导出的完整流程 +6. **版本更新区**:v1.8.13 更新日志展示 +7. **下载与社区区**:多平台下载入口、Discord/Instagram 社区链接、GitHub 仓库 + +### 2.2 页面详情 + +| 页面区域 | 模块名称 | 功能描述 | +|---------|---------|---------| +| Hero 首屏 | 动态标题 | 产品名 + 核心标语,文字逐字显现动画,哈苏橙光晕背景 | +| Hero 首屏 | CTA 按钮 | 下载按钮,hover 时橙色脉冲扩散效果 | +| 产品特性 | 特性卡片 | 4 张玻璃态卡片,hover 时橙色边框光效 + 3D 倾斜 | +| 编辑器预览 | 界面展示 | 编辑器截图 + 浮动标注动画,展示核心编辑功能 | +| 技术架构 | 技术标签 | Rust/wgpu/React/Tauri 技术徽章,旋转悬浮动画 | +| 使用指南 | 步骤流程 | 4 步骤时间轴,滚动触发逐步显现 | +| 版本更新 | 更新日志 | v1.8.13 修复项列表,代码风格展示 | +| 下载社区 | 下载入口 | Windows/macOS/Linux/Android 四平台下载 | +| 下载社区 | 社区链接 | Discord/Instagram/Github 链接按钮 | + +## 3. 核心流程 + +用户访问页面 → 被 Hero 区域视觉冲击吸引 → 向下滚动浏览产品特性 → 查看编辑器预览了解功能 → 阅读使用指南快速上手 → 点击下载按钮获取应用 + +```mermaid +graph TD + A["用户访问页面"] --> B["Hero 首屏视觉冲击"] + B --> C["浏览产品特性"] + C --> D["编辑器预览"] + D --> E["技术架构展示"] + E --> F["使用指南"] + F --> G["版本更新"] + G --> H["下载 / 社区"] +``` + +## 4. 用户界面设计 + +### 4.1 设计风格 +- **主色调**:哈苏橙 #CF4E24(Hasselblad 经典橙),辅以暗色系 #0A0A0A / #1A1A1A 背景 +- **辅助色**:暖白 #FAFAF5、浅橙 #FF8C42、深橙 #9E3A12 +- **按钮风格**:圆角胶囊型,哈苏橙渐变填充,hover 时光晕扩散 + 微缩放 +- **字体方案**: + - 标题:Playfair Display(优雅衬线体,摄影/艺术感) + - 正文:DM Sans(现代几何无衬线,清晰可读) + - 代码/技术:JetBrains Mono(技术感等宽字体) +- **布局风格**:深色全屏沉浸式,大留白 + 不对称构图,滚动叙事式 +- **动画效果**: + - 页面加载:文字逐字显现 + 哈苏橙光晕从中心扩散 + - 滚动触发:IntersectionObserver 驱动,元素从下方滑入 + 淡入 + - 特性卡片:hover 时 3D 透视倾斜 + 橙色边框光效 + - 背景层:缓慢流动的橙黑渐变 mesh + 细腻噪点纹理 + - 微交互:按钮 hover 脉冲、链接下划线动画、滚动进度条 + +### 4.2 页面设计概览 + +| 页面区域 | 模块名称 | UI 元素 | +|---------|---------|---------| +| Hero 首屏 | 动态标题 | Playfair Display 72px,文字 clip 橙色渐变,逐字淡入 | +| Hero 首屏 | 光晕背景 | 径向渐变橙黑 mesh,缓慢脉动动画 | +| Hero 首屏 | CTA 按钮 | 胶囊型,bg-gradient 橙色,hover scale + glow | +| 产品特性 | 特性卡片 | glassmorphism 卡片,backdrop-blur,橙色边框光效 | +| 编辑器预览 | 界面展示 | 深色圆角容器,内嵌截图,浮动标注动画 | +| 使用指南 | 步骤时间轴 | 垂直时间轴,橙色节点,滚动逐步显现 | +| 下载社区 | 平台按钮 | 方形图标 + 平台名,hover 橙色背景渐显 | + +### 4.3 响应式设计 +- 桌面优先(1440px 基准) +- 平板适配(768px):卡片改为双列,时间轴左侧对齐 +- 移动端适配(375px):单列堆叠,字号缩小,触摸优化按钮尺寸 + +### 4.4 动效设计要点 +- 首屏:1.5s 文字逐字淡入 + 光晕从中心扩散 +- 特性卡片:hover 时 transform: perspective(1000px) rotateX/Y,box-shadow 橙色光晕 +- 滚动:全局 IntersectionObserver,元素 translateY(40px) → translateY(0) + opacity 0→1 +- 背景:CSS @keyframes 缓慢色相偏移,mesh gradient 流动 +- 进度条:顶部固定 2px 橙色线条,随滚动位置变化 diff --git a/.trae/documents/tech-architecture.md b/.trae/documents/tech-architecture.md new file mode 100644 index 0000000000..838c6e92f3 --- /dev/null +++ b/.trae/documents/tech-architecture.md @@ -0,0 +1,100 @@ +# RapidRAW 应用介绍页面 — 技术架构文档 + +## 1. 架构设计 + +```mermaid +graph TB + subgraph "前端层" + A["React 18 + TypeScript"] + B["Tailwind CSS 3"] + C["Vite 构建工具"] + end + subgraph "动画层" + D["CSS Animations / Keyframes"] + E["IntersectionObserver API"] + F["CSS Transform 3D"] + end + subgraph "资源层" + G["Google Fonts CDN"] + H["GitHub Assets 图片"] + I["Trae Text-to-Image API"] + end + A --> B + A --> C + A --> D + A --> E + A --> F + A --> G + A --> H + A --> I +``` + +## 2. 技术说明 +- **前端**:React@18 + TypeScript + Tailwind CSS@3 + Vite +- **初始化工具**:vite-init (react-ts 模板) +- **后端**:无(纯前端静态页面) +- **数据库**:无 +- **动画方案**:CSS Keyframes + IntersectionObserver,不引入额外动画库 +- **字体**:Google Fonts CDN (Playfair Display, DM Sans, JetBrains Mono) +- **图片**:GitHub 仓库 Assets + Trae Text-to-Image API 生成装饰图 + +## 3. 路由定义 + +| 路由 | 用途 | +|------|------| +| / | 单页应用,所有内容通过滚动叙事呈现 | + +## 4. 组件结构 + +``` +src/ +├── components/ +│ ├── Hero.tsx # 首屏 Hero 区域 +│ ├── Features.tsx # 产品特性卡片 +│ ├── EditorPreview.tsx # 编辑器预览展示 +│ ├── TechStack.tsx # 技术架构展示 +│ ├── UserGuide.tsx # 使用指南步骤 +│ ├── Changelog.tsx # 版本更新日志 +│ ├── Download.tsx # 下载与社区 +│ ├── ScrollProgress.tsx # 滚动进度条 +│ └── NoiseOverlay.tsx # 噪点纹理覆盖层 +├── pages/ +│ └── Home.tsx # 主页面组合所有组件 +├── App.tsx +└── main.tsx +``` + +## 5. CSS 设计令牌 + +```css +:root { + /* 哈苏橙色彩系统 */ + --hasselblad-orange: #CF4E24; + --hasselblad-light: #FF8C42; + --hasselblad-dark: #9E3A12; + --hasselblad-glow: rgba(207, 78, 36, 0.4); + + /* 中性色系 */ + --bg-primary: #0A0A0A; + --bg-secondary: #1A1A1A; + --bg-card: rgba(26, 26, 26, 0.6); + --text-primary: #FAFAF5; + --text-secondary: #A0A0A0; + + /* 字体 */ + --font-display: 'Playfair Display', serif; + --font-body: 'DM Sans', sans-serif; + --font-mono: 'JetBrains Mono', monospace; +} +``` + +## 6. 动画规范 + +| 动画类型 | 实现方式 | 参数 | +|---------|---------|------| +| 文字逐字显现 | CSS @keyframes + animation-delay | opacity 0→1, translateY 20px→0, delay: 0.08s/字 | +| 光晕脉动 | CSS @keyframes | radial-gradient scale 1→1.2, opacity 0.6→0.3 | +| 滚动触发 | IntersectionObserver | threshold: 0.15, translateY 40px→0, opacity 0→1, duration 0.8s | +| 卡片 3D 倾斜 | onMouseMove + CSS transform | perspective 1000px, rotateX/Y ±5deg | +| 滚动进度条 | scroll event | width: scrollY / (docHeight - winHeight) * 100% | +| 噪点纹理 | CSS background-image | SVG noise filter, opacity 0.03 | diff --git a/README.md b/README.md index 7581c6278f..7515059c25 100644 --- a/README.md +++ b/README.md @@ -1,629 +1,57 @@ -
-
-
|
- - - Download RapidRAW-Get the latest release for Windows, macOS, Linux, and Android. Packaged and ready to run. - Download Latest Version → -- |
-
- - - Read the Docs-Learn how RapidRAW works with step-by-step tutorials, from adjustments to masking. - View Tutorials & Docs → -- |
-
- Core Editing Engine-
Professional Grade Adjustments-
|
-
- Library & Workflow-
Productivity & UI-
|
-
- 
- The main editor interface in action.
-
-
- - Powerful batch operations and export. - |
-
-
- - Customizable editor layout and panels. - |
-
-
- - Advanced masking to speedup workflow. - |
-
-
- - Experimental generative AI features. - |
-
-
- - Library navigation and folder management. - |
-
-
- - Beautiful themes and UI customization. - |
-
-
-
- Generative Replace, which can be powered by either a local ComfyUI backend or the upcoming optional cloud service.
-
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 | + + + + + + + + + + + + + + + + + + +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + + +1x +1x +1x +1x + + +1x +1x +1x +1x +1x +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + | import {
+ Brush,
+ BringToFront,
+ Circle,
+ Cloud,
+ Droplet,
+ Droplets,
+ Eraser,
+ MoreHorizontal,
+ RectangleHorizontal,
+ Sparkles,
+ TriangleRight,
+ User,
+ Sun,
+ Stamp,
+ Bandage,
+} from 'lucide-react';
+import i18n from 'i18next';
+
+export enum Mask {
+ AiDepth = 'ai-depth',
+ AiForeground = 'ai-foreground',
+ AiSky = 'ai-sky',
+ AiSubject = 'ai-subject',
+ All = 'all',
+ Brush = 'brush',
+ Flow = 'flow',
+ Color = 'color',
+ Linear = 'linear',
+ Luminance = 'luminance',
+ QuickEraser = 'quick-eraser',
+ Radial = 'radial',
+ Clone = 'clone',
+ Heal = 'heal',
+}
+
+export enum SubMaskMode {
+ Additive = 'additive',
+ Subtractive = 'subtractive',
+ Intersect = 'intersect',
+}
+
+export enum ToolType {
+ AiSeletor = 'ai-selector',
+ Brush = 'brush',
+ Eraser = 'eraser',
+ GenerativeReplace = 'generative-replace',
+ SelectSubject = 'select-subject',
+}
+
+export interface MaskType {
+ disabled: boolean;
+ icon: any;
+ id?: string;
+ name: string;
+ type: Mask;
+}
+
+export interface SubMask {
+ id: string;
+ invert: boolean;
+ mode: SubMaskMode;
+ name?: string;
+ opacity: number;
+ parameters?: any;
+ type: Mask;
+ visible: boolean;
+}
+
+export function formatMaskTypeName(type: string) {
+ if (type === Mask.AiDepth) return i18n.t('masks.types.depth');
+ if (type === Mask.AiSubject) return i18n.t('masks.types.subject');
+ if (type === Mask.AiForeground) return i18n.t('masks.types.foreground');
+ if (type === Mask.AiSky) return i18n.t('masks.types.sky');
+ if (type === Mask.All) return i18n.t('masks.types.all');
+ if (type === Mask.QuickEraser) return i18n.t('masks.types.quickEraser');
+ if (type === Mask.Brush) return i18n.t('masks.types.brush');
+ if (type === Mask.Flow) return i18n.t('masks.types.flow');
+ if (type === Mask.Color) return i18n.t('masks.types.color');
+ if (type === Mask.Linear) return i18n.t('masks.types.linear');
+ if (type === Mask.Luminance) return i18n.t('masks.types.luminance');
+ if (type === Mask.Radial) return i18n.t('masks.types.radial');
+ if (type === Mask.Clone) return i18n.t('masks.types.clone');
+ if (type === Mask.Heal) return i18n.t('masks.types.heal');
+ return type.charAt(0).toUpperCase() + type.slice(1);
+}
+
+export function getMaskTypeName(mask: MaskType) {
+ if (mask.id === 'others') return i18n.t('masks.types.others');
+ if (mask.type === Mask.QuickEraser && mask.name === 'Quick Erase') {
+ return i18n.t('masks.types.quickErase');
+ }
+ return formatMaskTypeName(mask.type);
+}
+
+export function getSubMaskName(subMask: Pick<SubMask, 'name' | 'type'>) {
+ return subMask.name?.trim() || formatMaskTypeName(subMask.type);
+}
+
+export const MASK_ICON_MAP: Record<Mask, any> = {
+ [Mask.AiDepth]: BringToFront,
+ [Mask.AiForeground]: User,
+ [Mask.AiSky]: Cloud,
+ [Mask.AiSubject]: Sparkles,
+ [Mask.All]: RectangleHorizontal,
+ [Mask.Brush]: Brush,
+ [Mask.Flow]: Droplets,
+ [Mask.Color]: Droplet,
+ [Mask.Linear]: TriangleRight,
+ [Mask.Luminance]: Sparkles,
+ [Mask.QuickEraser]: Eraser,
+ [Mask.Radial]: Circle,
+ [Mask.Clone]: Stamp,
+ [Mask.Heal]: Bandage,
+};
+
+export const MASK_PANEL_CREATION_TYPES: Array<MaskType> = [
+ {
+ disabled: false,
+ icon: Sparkles,
+ name: 'Subject',
+ type: Mask.AiSubject,
+ },
+ {
+ disabled: false,
+ icon: Cloud,
+ name: 'Sky',
+ type: Mask.AiSky,
+ },
+ {
+ disabled: false,
+ icon: User,
+ name: 'Foreground',
+ type: Mask.AiForeground,
+ },
+ {
+ disabled: false,
+ icon: TriangleRight,
+ name: 'Linear',
+ type: Mask.Linear,
+ },
+ {
+ disabled: false,
+ icon: Circle,
+ name: 'Radial',
+ type: Mask.Radial,
+ },
+ {
+ disabled: false,
+ icon: MoreHorizontal,
+ id: 'others',
+ name: 'Others',
+ type: null as any,
+ },
+];
+
+export const AI_MANUAL_CLEANUP_TYPES: Array<MaskType> = [
+ {
+ disabled: false,
+ icon: Stamp,
+ name: 'Clone',
+ type: Mask.Clone,
+ },
+ {
+ disabled: false,
+ icon: Bandage,
+ name: 'Heal',
+ type: Mask.Heal,
+ },
+];
+
+export const AI_GENERATIVE_CREATION_TYPES: Array<MaskType> = [
+ {
+ disabled: false,
+ icon: Eraser,
+ name: 'Quick Erase',
+ type: Mask.QuickEraser,
+ },
+ {
+ disabled: false,
+ icon: Sparkles,
+ name: 'Subject',
+ type: Mask.AiSubject,
+ },
+ {
+ disabled: false,
+ icon: User,
+ name: 'Foreground',
+ type: Mask.AiForeground,
+ },
+ {
+ disabled: false,
+ icon: Brush,
+ name: 'Brush',
+ type: Mask.Brush,
+ },
+ {
+ disabled: false,
+ icon: TriangleRight,
+ name: 'Linear',
+ type: Mask.Linear,
+ },
+ {
+ disabled: false,
+ icon: Circle,
+ name: 'Radial',
+ type: Mask.Radial,
+ },
+];
+
+export const SUB_MASK_COMPONENT_TYPES: Array<MaskType> = [
+ {
+ disabled: false,
+ icon: Sparkles,
+ name: 'Subject',
+ type: Mask.AiSubject,
+ },
+ {
+ disabled: false,
+ icon: Cloud,
+ name: 'Sky',
+ type: Mask.AiSky,
+ },
+ {
+ disabled: false,
+ icon: User,
+ name: 'Foreground',
+ type: Mask.AiForeground,
+ },
+ {
+ disabled: false,
+ icon: TriangleRight,
+ name: 'Linear',
+ type: Mask.Linear,
+ },
+ {
+ disabled: false,
+ icon: Circle,
+ name: 'Radial',
+ type: Mask.Radial,
+ },
+ {
+ disabled: false,
+ icon: MoreHorizontal,
+ id: 'others',
+ name: 'Others',
+ type: null as any,
+ },
+];
+
+export const OTHERS_MASK_TYPES: Array<MaskType> = [
+ {
+ disabled: false,
+ icon: BringToFront,
+ name: 'Depth',
+ type: Mask.AiDepth,
+ },
+ {
+ disabled: false,
+ icon: Droplet,
+ name: 'Color',
+ type: Mask.Color,
+ },
+ {
+ disabled: false,
+ icon: Sun,
+ name: 'Luminance',
+ type: Mask.Luminance,
+ },
+ {
+ disabled: false,
+ icon: Brush,
+ name: 'Brush',
+ type: Mask.Brush,
+ },
+ {
+ disabled: false,
+ icon: Droplets,
+ name: 'Flow',
+ type: Mask.Flow,
+ },
+ {
+ disabled: false,
+ icon: RectangleHorizontal,
+ name: 'Whole Image',
+ type: Mask.All,
+ },
+];
+
+export const AI_SUB_MASK_COMPONENT_TYPES: Array<MaskType> = [
+ ...AI_MANUAL_CLEANUP_TYPES,
+ ...AI_GENERATIVE_CREATION_TYPES,
+];
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| Masks.tsx | +
+
+ |
+ 47.76% | +32/67 | +0% | +0/36 | +50% | +3/6 | +61.53% | +32/52 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 | + + + + + + + + + + + + + + + + +1x + + + + + + + + +3x +3x +3x + +3x + +2x + + +10x +10x + + + + + + + +1x + + +1x + + + + + + + + + + + + | import { Home, SlidersHorizontal, Palette, UserCircle, FileInput } from 'lucide-react';
+import clsx from 'clsx';
+import { useTranslation } from 'react-i18next';
+
+import { Panel } from './AppProperties';
+import { useUIStore } from '../../store/useUIStore';
+
+interface AndroidBottomNavProps {
+ isAndroid: boolean;
+}
+
+interface NavItem {
+ panel: Panel | null;
+ icon: typeof Home;
+ labelKey: string;
+}
+
+const navItems: NavItem[] = [
+ { panel: null, icon: Home, labelKey: 'editor.android.bottomNav.library' },
+ { panel: Panel.Adjustments, icon: SlidersHorizontal, labelKey: 'editor.android.bottomNav.basic' },
+ { panel: Panel.Color, icon: Palette, labelKey: 'editor.android.bottomNav.color' },
+ { panel: Panel.Portrait, icon: UserCircle, labelKey: 'editor.android.bottomNav.portrait' },
+ { panel: Panel.Export, icon: FileInput, labelKey: 'editor.android.bottomNav.export' },
+];
+
+export default function AndroidBottomNav({ isAndroid }: AndroidBottomNavProps) {
+ const { t } = useTranslation();
+ const activeRightPanel = useUIStore((s) => s.activeRightPanel);
+ const setRightPanel = useUIStore((s) => s.setRightPanel);
+
+ if (!isAndroid) return null;
+
+ return (
+ <div className="flex items-center justify-around shrink-0 h-14 bg-bg-secondary border-t border-border-color">
+ {navItems.map(({ panel, icon: Icon, labelKey }) => {
+ const isActive = panel ? activeRightPanel === panel : activeRightPanel === null;
+ return (
+ <button
+ key={labelKey}
+ className={clsx(
+ 'flex flex-col items-center justify-center gap-0.5 px-3 py-1 rounded-md transition-colors',
+ isActive ? 'text-accent' : 'text-text-secondary',
+ )}
+ onClick={() => {
+ Iif (panel === null) {
+ setRightPanel(null);
+ } else {
+ setRightPanel(activeRightPanel === panel ? null : panel);
+ }
+ }}
+ >
+ <Icon size={22} strokeWidth={1.8} />
+ <span className="text-[11px] leading-tight font-medium tracking-wide">{t(labelKey as any)}</span>
+ </button>
+ );
+ })}
+ </div>
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 | + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + +6x +6x + +6x + +1x +1x +1x +1x + + + + + + + +1x +1x + + + + + +6x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +20x + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import React, { useCallback, useState } from 'react';
+import { invoke } from '@tauri-apps/api/core';
+import { Share2, MessageCircle, X } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { motion, AnimatePresence } from 'framer-motion';
+import Text from './Text';
+import { TextVariants } from '../../types/typography';
+
+interface AndroidShareSheetProps {
+ filePath: string;
+ mimeType: string;
+ visible: boolean;
+ onClose: () => void;
+}
+
+interface ShareTarget {
+ id: string;
+ labelKey: string;
+ icon: React.ReactNode;
+}
+
+const SHARE_TARGETS: ShareTarget[] = [
+ { id: 'wechat', labelKey: 'androidShare.wechat', icon: <MessageCircle size={20} /> },
+ { id: 'qq', labelKey: 'androidShare.qq', icon: <MessageCircle size={20} /> },
+ { id: 'weibo', labelKey: 'androidShare.weibo', icon: <MessageCircle size={20} /> },
+ { id: 'more', labelKey: 'androidShare.more', icon: <Share2 size={20} /> },
+];
+
+export default function AndroidShareSheet({
+ filePath,
+ mimeType,
+ visible,
+ onClose,
+}: AndroidShareSheetProps) {
+ const { t } = useTranslation();
+ const [sharing, setSharing] = useState(false);
+
+ const handleShare = useCallback(
+ async (targetId: string) => {
+ Iif (sharing) return;
+ setSharing(true);
+ try {
+ await invoke('share_image', {
+ filePath,
+ mimeType,
+ title: t('androidShare.title' as any, { target: t(`androidShare.${targetId}` as any) }),
+ });
+ } catch (err) {
+ console.error('Share failed:', err);
+ } finally {
+ setSharing(false);
+ onClose();
+ }
+ },
+ [filePath, mimeType, sharing, t, onClose],
+ );
+
+ return (
+ <AnimatePresence>
+ {visible && (
+ <>
+ <motion.div
+ className="fixed inset-0 bg-black/40 z-40"
+ initial={{ opacity: 0 }}
+ animate={{ opacity: 1 }}
+ exit={{ opacity: 0 }}
+ onClick={onClose}
+ />
+ <motion.div
+ className="fixed bottom-0 left-0 right-0 bg-bg-primary rounded-t-2xl z-50 shadow-lg border-t border-surface"
+ initial={{ y: '100%' }}
+ animate={{ y: 0 }}
+ exit={{ y: '100%' }}
+ transition={{ type: 'spring', damping: 25, stiffness: 300 }}
+ >
+ <div className="flex items-center justify-between p-4 border-b border-surface">
+ <Text variant={TextVariants.title}>{t('androidShare.titleDefault' as any)}</Text>
+ <button
+ onClick={onClose}
+ className="p-1 rounded-full hover:bg-surface transition-colors"
+ >
+ <X size={20} className="text-text-secondary" />
+ </button>
+ </div>
+ <div className="p-4">
+ <div className="grid grid-cols-4 gap-4">
+ {SHARE_TARGETS.map((target) => (
+ <button
+ key={target.id}
+ onClick={() => handleShare(target.id)}
+ disabled={sharing}
+ className="flex flex-col items-center gap-2 p-3 rounded-xl hover:bg-surface transition-colors disabled:opacity-50"
+ >
+ <div className="w-12 h-12 rounded-full bg-surface flex items-center justify-center text-text-primary">
+ {target.icon}
+ </div>
+ <Text variant={TextVariants.small} className="text-text-secondary">
+ {t(target.labelKey as any)}
+ </Text>
+ </button>
+ ))}
+ </div>
+ </div>
+ <div className="p-4 pt-0">
+ <button
+ onClick={onClose}
+ className="w-full py-3 rounded-xl bg-surface text-text-secondary font-medium text-sm hover:bg-card-active transition-colors"
+ >
+ {t('androidShare.cancel' as any)}
+ </button>
+ </div>
+ </motion.div>
+ </>
+ )}
+ </AnimatePresence>
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 | + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + +1x + +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + + +1x +1x +1x +1x + + +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + + +1x +1x +1x +1x +1x + + +1x +1x +1x + + + + + + + + + +1x +1x +1x +1x +1x +1x +1x +1x +1x + + +1x +1x +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x +1x +1x + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x +1x +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x +1x +1x +1x +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { ExportPreset } from './ExportImportProperties';
+import { Adjustments, CopyPasteSettings } from '../../utils/adjustments';
+import { ToolType } from '../panel/right/Masks';
+
+export const GLOBAL_KEYS = [
+ ' ',
+ 'ArrowUp',
+ 'ArrowDown',
+ 'ArrowLeft',
+ 'ArrowRight',
+ 'f',
+ 'b',
+ 'a',
+ 's',
+ 'd',
+ 'r',
+ 'm',
+ 'k',
+ 'p',
+ 'i',
+ 'e',
+ '0',
+ '1',
+ '2',
+ '3',
+ '4',
+ '5',
+ 'Enter',
+];
+export const OPTION_SEPARATOR = 'separator';
+
+export enum Invokes {
+ AddTagForPaths = 'add_tag_for_paths',
+ ApplyAdjustments = 'apply_adjustments',
+ ApplyAdjustmentsToPaths = 'apply_adjustments_to_paths',
+ ApplyAutoAdjustmentsToPaths = 'apply_auto_adjustments_to_paths',
+ ApplyDenoising = 'apply_denoising',
+ CalculateAutoAdjustments = 'calculate_auto_adjustments',
+ CancelExport = 'cancel_export',
+ CheckAIConnectorStatus = 'check_ai_connector_status',
+ ClearAllSidecars = 'clear_all_sidecars',
+ ClearAiTags = 'clear_ai_tags',
+ ClearAllTags = 'clear_all_tags',
+ ClearThumbnailCache = 'clear_thumbnail_cache',
+ CopyFiles = 'copy_files',
+ CreateFolder = 'create_folder',
+ CreateVirtualCopy = 'create_virtual_copy',
+ CullImages = 'cull_images',
+ DeleteFolder = 'delete_folder',
+ DuplicateFile = 'duplicate_file',
+ EstimateExportSizes = 'estimate_export_sizes',
+ ExportImages = 'export_images',
+ FrontendLog = 'frontend_log',
+ GenerateAiForegroundMask = 'generate_ai_foreground_mask',
+ GenerateAiSkyMask = 'generate_ai_sky_mask',
+ GenerateAiSubjectMask = 'generate_ai_subject_mask',
+ GenerateAiRating = 'generate_ai_rating',
+ GenerateAiRatingsBatch = 'generate_ai_ratings_batch',
+ GenerateFullscreenPreview = 'generate_fullscreen_preview',
+ GeneratePreviewForPath = 'generate_preview_for_path',
+ GenerateMaskOverlay = 'generate_mask_overlay',
+ GeneratePresetPreview = 'generate_preset_preview',
+ GenerateThumbnailsProgressive = 'generate_thumbnails_progressive',
+ GenerateUncroppedPreview = 'generate_uncropped_preview',
+ GetFolderTree = 'get_folder_tree',
+ GetFolderChildren = 'get_folder_children',
+ GetLogFilePath = 'get_log_file_path',
+ GetOrCreateInternalLibraryRoot = 'get_or_create_internal_library_root',
+ GetPinnedFolderTrees = 'get_pinned_folder_trees',
+ GetSupportedFileTypes = 'get_supported_file_types',
+ HandleExportPresetsToFile = 'handle_export_presets_to_file',
+ HandleImportPresetsFromFile = 'handle_import_presets_from_file',
+ HandleImportLegacyPresetsFromFile = 'handle_import_legacy_presets_from_file',
+ ImportFiles = 'import_files',
+ InvokeGenerativeReplace = 'invoke_generative_replace',
+ InvokeGenerativeReplaseWithMaskDef = 'invoke_generative_replace_with_mask_def',
+ ListImagesInDir = 'list_images_in_dir',
+ ListImagesRecursive = 'list_images_recursive',
+ LoadImage = 'load_image',
+ LoadMetadata = 'load_metadata',
+ LoadPresets = 'load_presets',
+ LoadSettings = 'load_settings',
+ MoveFiles = 'move_files',
+ ReadExifForPaths = 'read_exif_for_paths',
+ RemoveTagForPaths = 'remove_tag_for_paths',
+ RenameFiles = 'rename_files',
+ RenameFolder = 'rename_folder',
+ ResetAdjustmentsForPaths = 'reset_adjustments_for_paths',
+ SaveMetadataAndUpdateThumbnail = 'save_metadata_and_update_thumbnail',
+ SaveCollage = 'save_collage',
+ SaveDenoisedImage = 'save_denoised_image',
+ SavePanorama = 'save_panorama',
+ SaveHdr = 'save_hdr',
+ SavePresets = 'save_presets',
+ SaveSettings = 'save_settings',
+ SetColorLabelForPaths = 'set_color_label_for_paths',
+ SetRatingForPaths = 'set_rating_for_paths',
+ ShowInFinder = 'show_in_finder',
+ StartBackgroundIndexing = 'start_background_indexing',
+ StitchPanorama = 'stitch_panorama',
+ MergeHdr = 'merge_hdr',
+ TestAIConnectorConnection = 'test_ai_connector_connection',
+ UpdateWgpuTransform = 'update_wgpu_transform',
+ UpdateExifFields = 'update_exif_fields',
+ FetchCommunityPresets = 'fetch_community_presets',
+ GenerateAllCommunityPreviews = 'generate_all_community_previews',
+ SaveCommunityPreset = 'save_community_preset',
+ SaveTempFile = 'save_temp_file',
+ GetAlbums = 'get_albums',
+ SaveAlbums = 'save_albums',
+ AddToAlbum = 'add_to_album',
+ GetAlbumImages = 'get_album_images',
+}
+
+export enum ExifOverlay {
+ Off = 'off',
+ Hover = 'hover',
+ Always = 'always',
+}
+
+export enum Panel {
+ Adjustments = 'adjustments',
+ Ai = 'ai',
+ Color = 'color',
+ Crop = 'crop',
+ Export = 'export',
+ Masks = 'masks',
+ Metadata = 'metadata',
+ Portrait = 'portrait',
+ Presets = 'presets',
+}
+
+export enum RawStatus {
+ All = 'all',
+ NonRawOnly = 'nonRawOnly',
+ RawOnly = 'rawOnly',
+ RawOverNonRaw = 'rawOverNonRaw',
+}
+
+export enum SortDirection {
+ Ascending = 'asc',
+ Descending = 'desc',
+}
+
+export type FolderSortKey = 'name' | 'modified' | 'created' | 'imageCount';
+
+export interface FolderTreeSort {
+ key: FolderSortKey;
+ order: SortDirection;
+}
+
+export enum Theme {
+ Arctic = 'arctic',
+ Blue = 'blue',
+ Dark = 'dark',
+ Grey = 'grey',
+ Light = 'light',
+ MutedGreen = 'muted-green',
+ Sepia = 'sepia',
+ Snow = 'snow',
+}
+
+export enum ThumbnailAspectRatio {
+ Cover = 'cover',
+ Contain = 'contain',
+}
+
+export interface AppSettings {
+ aiConnectorAddress?: string;
+ aiProvider?: string;
+ decorations?: any;
+ editorPreviewResolution?: number;
+ enableZoomHifi?: boolean;
+ useFullDpiRendering?: boolean;
+ highResZoomMultiplier?: number;
+ enableLivePreviews?: boolean;
+ livePreviewQuality?: string;
+ enableAiTagging?: boolean;
+ filterCriteria?: FilterCriteria;
+ lastFolderState?: any;
+ pinnedFolders?: any;
+ rootFolders?: string[];
+ taggingShortcuts?: string[];
+ lastRootPath: string | null;
+ libraryViewMode?: LibraryViewMode;
+ sortCriteria?: SortCriteria;
+ theme: Theme;
+ thumbnailSize?: ThumbnailSize;
+ thumbnailAspectRatio?: ThumbnailAspectRatio;
+ uiVisibility?: UiVisibility;
+ adjustmentVisibility?: { [key: string]: boolean };
+ rawHighlightCompression?: number;
+ processingBackend?: string;
+ linuxGpuOptimization?: boolean;
+ exportPresets?: ExportPreset[];
+ myLenses?: any;
+ enableFolderImageCounts?: boolean;
+ displayEditIcon?: boolean;
+ linearRawMode?: string;
+ enableXmpSync?: boolean;
+ createXmpIfMissing?: boolean;
+ isWaveformVisible?: boolean;
+ waveformHeight?: number;
+ activeWaveformChannel?: string;
+ useWgpuRenderer?: boolean;
+ canvasInputMode?: 'mouse' | 'trackpad';
+ zoomSpeedMultiplier?: number;
+ keybinds?: { [action: string]: string[] };
+ tonemapperOverrideEnabled?: boolean;
+ defaultRawTonemapper?: string;
+ defaultNonRawTonemapper?: string;
+ copyPasteSettings?: CopyPasteSettings;
+ enableFocusMode?: boolean;
+ openTreeSections?: string[];
+ folderIcons?: Record<string, string>;
+ exifOverlay?: ExifOverlay;
+ language?: string;
+ folderTreeSort?: FolderTreeSort;
+}
+
+export interface BrushSettings {
+ feather: number;
+ size: number;
+ tool: ToolType;
+}
+
+export enum LibraryViewMode {
+ Flat = 'flat',
+ Recursive = 'recursive',
+}
+
+export const EditedStatus = {
+ All: 'all',
+ EditedOnly: 'editedOnly',
+ UneditedOnly: 'uneditedOnly',
+} as const;
+
+export type EditedStatus = (typeof EditedStatus)[keyof typeof EditedStatus];
+
+export interface FilterCriteria {
+ colors: Array<string>;
+ rating: number;
+ rawStatus: RawStatus;
+ editedStatus?: EditedStatus;
+}
+
+export interface Folder {
+ children: any;
+ id?: string | undefined;
+ name?: string | undefined;
+ imageCount?: number;
+}
+
+export interface ImageFile {
+ is_edited: boolean;
+ modified: number;
+ path: string;
+ rating: number;
+ tags: Array<string> | null;
+ exif: { [key: string]: string } | null;
+ is_virtual_copy: boolean;
+ is_cloud_placeholder: boolean;
+}
+
+export interface Option {
+ color?: string;
+ disabled?: boolean;
+ icon?: any;
+ isDestructive?: boolean;
+ label?: string;
+ onClick?(): void;
+ onRightClick?(): void;
+ submenu?: any;
+ type?: string;
+}
+
+export enum Orientation {
+ Horizontal = 'horizontal',
+ Vertical = 'vertical',
+}
+
+export interface Preset {
+ adjustments: Partial<Adjustments>;
+ folder?: Folder;
+ id: string;
+ name: string;
+ includeMasks?: boolean;
+ includeCropTransform?: boolean;
+ presetType?: 'tool' | 'style' | 'portrait' | 'color' | 'ai-color' | 'combined';
+}
+
+export interface Progress {
+ completed?: number;
+ current?: number;
+ total: number;
+}
+
+export interface SelectedImage {
+ exif: any;
+ height: number;
+ isRaw: boolean;
+ isReady: boolean;
+ metadata?: any;
+ original_base64?: string;
+ originalUrl: string | null;
+ path: string;
+ thumbnailUrl: string;
+ width: number;
+}
+
+export interface SortCriteria {
+ key: string;
+ label?: string;
+ order: string;
+}
+
+export interface SupportedTypes {
+ nonRaw: Array<string>;
+ raw: Array<string>;
+}
+
+export enum ThumbnailSize {
+ Large = 'large',
+ Medium = 'medium',
+ Small = 'small',
+ List = 'list',
+}
+
+export interface TransformState {
+ positionX: number;
+ positionY: number;
+ scale: number;
+}
+
+export interface UiVisibility {
+ folderTree: boolean;
+ filmstrip: boolean;
+}
+
+export interface WaveformData {
+ blue: string;
+ green: string;
+ height: number;
+ luma: string;
+ red: string;
+ rgb: string;
+ parade: string;
+ vectorscope: string;
+ width: number;
+}
+
+export interface CullingSettings {
+ similarityThreshold: number;
+ blurThreshold: number;
+ groupSimilar: boolean;
+ filterBlurry: boolean;
+}
+
+export interface ImageAnalysisResult {
+ path: string;
+ qualityScore: number;
+ sharpnessMetric: number;
+ centerFocusMetric: number;
+ exposureMetric: number;
+ width: number;
+ height: number;
+}
+
+export interface CullGroup {
+ representative: ImageAnalysisResult;
+ duplicates: ImageAnalysisResult[];
+}
+
+export interface CullingSuggestions {
+ similarGroups: CullGroup[];
+ blurryImages: ImageAnalysisResult[];
+ failedPaths: string[];
+}
+
+export interface KeybindHandler {
+ shouldFire?: () => boolean;
+ execute: (event: KeyboardEvent) => void;
+}
+
+export type AlbumItem = Album | AlbumGroup;
+
+export interface Album {
+ type: 'album';
+ id: string;
+ name: string;
+ icon?: string;
+ images: string[];
+}
+
+export interface AlbumGroup {
+ type: 'group';
+ id: string;
+ name: string;
+ icon?: string;
+ children: AlbumItem[];
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 | + + + + + + + + + + + + + + + + + + +1x + +25x + +25x + + + + + + + + + + + + + + + + + +1x + + | import React, { forwardRef } from 'react';
+import clsx from 'clsx';
+import {
+ TextWeight,
+ TextColor,
+ VariantConfig,
+ TEXT_WEIGHT_KEYS,
+ TEXT_COLOR_KEYS,
+ TextVariants,
+} from '../../types/typography';
+
+export interface TextProps extends React.HTMLAttributes<HTMLElement> {
+ variant?: VariantConfig;
+ weight?: TextWeight;
+ color?: TextColor;
+ as?: React.ElementType;
+ children: React.ReactNode;
+}
+
+export const Text = forwardRef<HTMLElement, TextProps>(
+ ({ variant = TextVariants.body, weight, color, as, className, children, ...props }, ref) => {
+ const Component = as || variant.defaultElement;
+
+ return (
+ <Component
+ ref={ref}
+ className={clsx(
+ variant.size,
+ TEXT_WEIGHT_KEYS[weight ?? variant.defaultWeight],
+ TEXT_COLOR_KEYS[color ?? variant.defaultColor],
+ variant.extraClasses,
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ </Component>
+ );
+ },
+);
+
+Text.displayName = 'Text';
+export default Text;
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| AndroidBottomNav.tsx | +
+
+ |
+ 92.85% | +13/14 | +80% | +8/10 | +100% | +5/5 | +90.9% | +10/11 | +
| AndroidShareSheet.tsx | +
+
+ |
+ 86.66% | +13/15 | +75% | +3/4 | +100% | +4/4 | +92.85% | +13/14 | +
| AppProperties.tsx | +
+
+ |
+ 100% | +129/129 | +100% | +0/0 | +100% | +10/10 | +100% | +129/129 | +
| Text.tsx | +
+
+ |
+ 100% | +4/4 | +100% | +7/7 | +100% | +1/1 | +100% | +4/4 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| useAndroidBackHandler.ts | +
+
+ |
+ 45.61% | +26/57 | +56.25% | +18/32 | +57.14% | +4/7 | +47.16% | +25/53 | +
| useTouchGestures.ts | +
+
+ |
+ 44.24% | +73/165 | +17.3% | +9/52 | +37.5% | +12/32 | +47.71% | +73/153 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 | + + + + +4x +4x +4x + +3x +2x + +2x +1x +1x + +1x + + + +1x + + + +1x + + + +1x + + + +1x + + + +1x + + + +1x + + + +1x + + + +1x + + + + + + + + + + + + +1x + + + + + + + + + + + + +1x + + + +1x + + + +1x + + + + + +1x + + + + +1x + + +3x +3x + + + + | import { useEffect } from 'react';
+import { useUIStore } from '../store/useUIStore';
+import { useSettingsStore } from '../store/useSettingsStore';
+
+export function useAndroidBackHandler() {
+ useEffect(() => {
+ const osPlatform = useSettingsStore.getState().osPlatform;
+ if (osPlatform !== 'android') return;
+
+ (window as any).__handleAndroidBack = () => {
+ const ui = useUIStore.getState();
+
+ if (ui.confirmModalState.isOpen) {
+ ui.setUI((state: any) => ({ confirmModalState: { ...state.confirmModalState, isOpen: false } }));
+ return;
+ }
+ Iif (ui.isCreateFolderModalOpen) {
+ ui.setUI({ isCreateFolderModalOpen: false });
+ return;
+ }
+ Iif (ui.isRenameFolderModalOpen) {
+ ui.setUI({ isRenameFolderModalOpen: false });
+ return;
+ }
+ Iif (ui.isRenameFileModalOpen) {
+ ui.setUI({ isRenameFileModalOpen: false });
+ return;
+ }
+ Iif (ui.isImportModalOpen) {
+ ui.setUI({ isImportModalOpen: false });
+ return;
+ }
+ Iif (ui.isCopyPasteSettingsModalOpen) {
+ ui.setUI({ isCopyPasteSettingsModalOpen: false });
+ return;
+ }
+ Iif (ui.isCreateAlbumModalOpen) {
+ ui.setUI({ isCreateAlbumModalOpen: false });
+ return;
+ }
+ Iif (ui.isCreateAlbumGroupModalOpen) {
+ ui.setUI({ isCreateAlbumGroupModalOpen: false });
+ return;
+ }
+ Iif (ui.isRenameAlbumModalOpen) {
+ ui.setUI({ isRenameAlbumModalOpen: false });
+ return;
+ }
+ Iif (ui.panoramaModalState.isOpen) {
+ ui.setUI({
+ panoramaModalState: {
+ isOpen: false,
+ isProcessing: false,
+ progressMessage: '',
+ finalImageBase64: null,
+ error: null,
+ stitchingSourcePaths: [],
+ },
+ });
+ return;
+ }
+ Iif (ui.hdrModalState.isOpen) {
+ ui.setUI({
+ hdrModalState: {
+ isOpen: false,
+ isProcessing: false,
+ progressMessage: '',
+ finalImageBase64: null,
+ error: null,
+ stitchingSourcePaths: [],
+ },
+ });
+ return;
+ }
+ Iif (ui.negativeModalState.isOpen) {
+ ui.setUI((state: any) => ({ negativeModalState: { ...state.negativeModalState, isOpen: false } }));
+ return;
+ }
+ Iif (ui.denoiseModalState.isOpen) {
+ ui.setUI((state: any) => ({ denoiseModalState: { ...state.denoiseModalState, isOpen: false } }));
+ return;
+ }
+ Iif (ui.cullingModalState.isOpen) {
+ ui.setUI({
+ cullingModalState: { isOpen: false, progress: null, suggestions: null, error: null, pathsToCull: [] },
+ });
+ return;
+ }
+ Iif (ui.collageModalState.isOpen) {
+ ui.setUI({ collageModalState: { isOpen: false, sourceImages: [] } });
+ return;
+ }
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true, cancelable: true }));
+ };
+
+ return () => {
+ delete (window as any).__handleAndroidBack;
+ };
+ }, []);
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x +1x +1x + +1x +1x + +1x + + + + + + + +1x + + + + + + + + + + + + + + + + + +1x + + + + + + + + + +1x +1x +1x + +1x +1x +1x + +1x +1x +1x +1x + + + +1x + + + + + + + + + + + + + + + + +1x +1x +1x + +1x + + + + + + + +1x + + + + + + + + + + + + + +1x + + + + + +1x +1x +1x + +1x +1x +1x + +1x +1x +1x +1x + + + +1x + + + + + + + + + + + + + + + + + + +1x +1x +1x + +1x +1x +1x + +1x + + + + + + + + + + +1x + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + +1x +1x +1x + +1x +1x +1x + +1x +1x +1x +1x + + + +1x + + + + + + + + + + + + + + + + + + + + + +1x +1x +1x + +1x +1x + +1x + + + + + +1x + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + +1x +1x + + + + + + +1x +1x +1x + +1x +1x +1x +1x + + + + | import { useEffect, useCallback, useRef, RefObject } from 'react';
+
+interface Point {
+ x: number;
+ y: number;
+}
+
+function getTouchPoint(touch: Touch): Point {
+ return { x: touch.clientX, y: touch.clientY };
+}
+
+function getDistance(p1: Point, p2: Point): number {
+ const dx = p1.x - p2.x;
+ const dy = p1.y - p2.y;
+ return Math.sqrt(dx * dx + dy * dy);
+}
+
+function getAngle(p1: Point, p2: Point): number {
+ return Math.atan2(p2.y - p1.y, p2.x - p1.x);
+}
+
+function getMidpoint(p1: Point, p2: Point): Point {
+ return { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
+}
+
+/**
+ * 双指缩放手势 hook
+ * 监听元素上的双指捏合/张开手势,回调当前缩放比例
+ */
+export function usePinchZoom(
+ ref: RefObject<HTMLElement>,
+ options?: {
+ minScale?: number;
+ maxScale?: number;
+ onScaleChange?: (scale: number) => void;
+ },
+) {
+ const minScale = options?.minScale ?? 0.1;
+ const maxScale = options?.maxScale ?? 10;
+ const onScaleChange = options?.onScaleChange;
+
+ const initialDistanceRef = useRef(0);
+ const currentScaleRef = useRef(1);
+
+ const handleTouchStart = useCallback((e: TouchEvent) => {
+ if (e.touches.length !== 2) return;
+ e.preventDefault();
+ const p1 = getTouchPoint(e.touches[0]);
+ const p2 = getTouchPoint(e.touches[1]);
+ initialDistanceRef.current = getDistance(p1, p2);
+ }, []);
+
+ const handleTouchMove = useCallback(
+ (e: TouchEvent) => {
+ if (e.touches.length !== 2) return;
+ e.preventDefault();
+ const p1 = getTouchPoint(e.touches[0]);
+ const p2 = getTouchPoint(e.touches[1]);
+ const currentDistance = getDistance(p1, p2);
+ if (initialDistanceRef.current === 0) {
+ initialDistanceRef.current = currentDistance;
+ return;
+ }
+ const ratio = currentDistance / initialDistanceRef.current;
+ const newScale = Math.min(maxScale, Math.max(minScale, currentScaleRef.current * ratio));
+ onScaleChange?.(newScale);
+ },
+ [minScale, maxScale, onScaleChange],
+ );
+
+ const handleTouchEnd = useCallback((e: TouchEvent) => {
+ if (e.touches.length < 2) {
+ // Finalize the scale
+ // The last scale value is already applied via onScaleChange
+ initialDistanceRef.current = 0;
+ // Update the ref to the last emitted scale so next gesture is relative
+ // Consumer should call a setter to keep currentScaleRef in sync
+ }
+ }, []);
+
+ useEffect(() => {
+ const el = ref.current;
+ Iif (!el) return;
+
+ el.addEventListener('touchstart', handleTouchStart, { passive: false });
+ el.addEventListener('touchmove', handleTouchMove, { passive: false });
+ el.addEventListener('touchend', handleTouchEnd);
+
+ return () => {
+ el.removeEventListener('touchstart', handleTouchStart);
+ el.removeEventListener('touchmove', handleTouchMove);
+ el.removeEventListener('touchend', handleTouchEnd);
+ };
+ }, [ref, handleTouchStart, handleTouchMove, handleTouchEnd]);
+
+ return {
+ setCurrentScale: (scale: number) => {
+ currentScaleRef.current = scale;
+ },
+ };
+}
+
+/**
+ * 双指旋转手势 hook
+ * 监听元素上的双指旋转手势,回调旋转角度(弧度)
+ */
+export function useTwoFingerRotate(
+ ref: RefObject<HTMLElement>,
+ options?: {
+ onRotationChange?: (rotation: number) => void;
+ },
+) {
+ const onRotationChange = options?.onRotationChange;
+ const initialAngleRef = useRef(0);
+ const currentRotationRef = useRef(0);
+
+ const handleTouchStart = useCallback((e: TouchEvent) => {
+ if (e.touches.length !== 2) return;
+ e.preventDefault();
+ const p1 = getTouchPoint(e.touches[0]);
+ const p2 = getTouchPoint(e.touches[1]);
+ initialAngleRef.current = getAngle(p1, p2);
+ }, []);
+
+ const handleTouchMove = useCallback(
+ (e: TouchEvent) => {
+ if (e.touches.length !== 2) return;
+ e.preventDefault();
+ const p1 = getTouchPoint(e.touches[0]);
+ const p2 = getTouchPoint(e.touches[1]);
+ const currentAngle = getAngle(p1, p2);
+ const delta = currentAngle - initialAngleRef.current;
+ const newRotation = currentRotationRef.current + delta;
+ onRotationChange?.(newRotation);
+ },
+ [onRotationChange],
+ );
+
+ const handleTouchEnd = useCallback(() => {
+ if (initialAngleRef.current !== 0) {
+ initialAngleRef.current = 0;
+ }
+ }, []);
+
+ useEffect(() => {
+ const el = ref.current;
+ Iif (!el) return;
+
+ el.addEventListener('touchstart', handleTouchStart, { passive: false });
+ el.addEventListener('touchmove', handleTouchMove, { passive: false });
+ el.addEventListener('touchend', handleTouchEnd);
+
+ return () => {
+ el.removeEventListener('touchstart', handleTouchStart);
+ el.removeEventListener('touchmove', handleTouchMove);
+ el.removeEventListener('touchend', handleTouchEnd);
+ };
+ }, [ref, handleTouchStart, handleTouchMove, handleTouchEnd]);
+
+ return {
+ setCurrentRotation: (rotation: number) => {
+ currentRotationRef.current = rotation;
+ },
+ };
+}
+
+/**
+ * 单指拖拽画布手势 hook
+ * 监听元素上的单指拖拽,回调偏移量 {dx, dy}
+ */
+export function useCanvasPan(
+ ref: RefObject<HTMLElement>,
+ options?: {
+ onPanChange?: (offset: { dx: number; dy: number }) => void;
+ onPanStart?: () => void;
+ onPanEnd?: () => void;
+ },
+) {
+ const onPanChange = options?.onPanChange;
+ const onPanStart = options?.onPanStart;
+ const onPanEnd = options?.onPanEnd;
+
+ const startPointRef = useRef<Point | null>(null);
+ const accumulatedRef = useRef({ dx: 0, dy: 0 });
+ const isPanningRef = useRef(false);
+
+ const handleTouchStart = useCallback(
+ (e: TouchEvent) => {
+ if (e.touches.length !== 1) return;
+ const point = getTouchPoint(e.touches[0]);
+ startPointRef.current = point;
+ isPanningRef.current = true;
+ onPanStart?.();
+ },
+ [onPanStart],
+ );
+
+ const handleTouchMove = useCallback(
+ (e: TouchEvent) => {
+ if (!isPanningRef.current || e.touches.length !== 1 || !startPointRef.current) return;
+ e.preventDefault();
+ const currentPoint = getTouchPoint(e.touches[0]);
+ const dx = currentPoint.x - startPointRef.current.x;
+ const dy = currentPoint.y - startPointRef.current.y;
+ onPanChange?.({ dx: accumulatedRef.current.dx + dx, dy: accumulatedRef.current.dy + dy });
+ },
+ [onPanChange],
+ );
+
+ const handleTouchEnd = useCallback(
+ (e: TouchEvent) => {
+ if (!isPanningRef.current || !startPointRef.current) return;
+ // Finalize: accumulate the delta
+ if (e.changedTouches.length > 0) {
+ const endPoint = getTouchPoint(e.changedTouches[0]);
+ const dx = endPoint.x - startPointRef.current.x;
+ const dy = endPoint.y - startPointRef.current.y;
+ accumulatedRef.current = {
+ dx: accumulatedRef.current.dx + dx,
+ dy: accumulatedRef.current.dy + dy,
+ };
+ }
+ isPanningRef.current = false;
+ startPointRef.current = null;
+ onPanEnd?.();
+ },
+ [onPanEnd],
+ );
+
+ useEffect(() => {
+ const el = ref.current;
+ Iif (!el) return;
+
+ el.addEventListener('touchstart', handleTouchStart, { passive: false });
+ el.addEventListener('touchmove', handleTouchMove, { passive: false });
+ el.addEventListener('touchend', handleTouchEnd);
+
+ return () => {
+ el.removeEventListener('touchstart', handleTouchStart);
+ el.removeEventListener('touchmove', handleTouchMove);
+ el.removeEventListener('touchend', handleTouchEnd);
+ };
+ }, [ref, handleTouchStart, handleTouchMove, handleTouchEnd]);
+
+ return {
+ resetOffset: () => {
+ accumulatedRef.current = { dx: 0, dy: 0 };
+ onPanChange?.({ dx: 0, dy: 0 });
+ },
+ setOffset: (dx: number, dy: number) => {
+ accumulatedRef.current = { dx, dy };
+ },
+ };
+}
+
+/**
+ * 滑动翻页手势 hook(胶片条使用)
+ * 检测左滑/右滑手势并回调方向
+ */
+export function useSwipeNavigation(
+ options?: {
+ threshold?: number;
+ onSwipeLeft?: () => void;
+ onSwipeRight?: () => void;
+ },
+) {
+ const threshold = options?.threshold ?? 50;
+ const onSwipeLeft = options?.onSwipeLeft;
+ const onSwipeRight = options?.onSwipeRight;
+
+ const startPointRef = useRef<Point | null>(null);
+ const startTimeRef = useRef(0);
+
+ const handleTouchStart = useCallback((e: TouchEvent) => {
+ if (e.touches.length !== 1) return;
+ startPointRef.current = getTouchPoint(e.touches[0]);
+ startTimeRef.current = Date.now();
+ }, []);
+
+ const handleTouchMove = useCallback(
+ (e: TouchEvent) => {
+ // Optional: could add real-time visual feedback here
+ },
+ [],
+ );
+
+ const handleTouchEnd = useCallback(
+ (e: TouchEvent) => {
+ if (!startPointRef.current || e.changedTouches.length === 0) return;
+ const endPoint = getTouchPoint(e.changedTouches[0]);
+ const dx = endPoint.x - startPointRef.current.x;
+ const dy = endPoint.y - startPointRef.current.y;
+ const dt = Date.now() - startTimeRef.current;
+
+ // Only count as swipe if horizontal movement is dominant and exceeds threshold
+ const isHorizontalSwipe = Math.abs(dx) > Math.abs(dy) * 1.5;
+ const isFastEnough = dt < 500;
+ const exceedsThreshold = Math.abs(dx) > threshold;
+
+ if (isHorizontalSwipe && isFastEnough && exceedsThreshold) {
+ if (dx < 0) {
+ onSwipeLeft?.();
+ } else {
+ onSwipeRight?.();
+ }
+ }
+
+ startPointRef.current = null;
+ },
+ [threshold, onSwipeLeft, onSwipeRight],
+ );
+
+ useEffect(() => {
+ const handler = {
+ start: handleTouchStart,
+ move: handleTouchMove,
+ end: handleTouchEnd,
+ };
+
+ // Attach to window for global swipe detection (e.g. filmstrip)
+ window.addEventListener('touchstart', handler.start, { passive: true });
+ window.addEventListener('touchmove', handler.move, { passive: true });
+ window.addEventListener('touchend', handler.end);
+
+ return () => {
+ window.removeEventListener('touchstart', handler.start);
+ window.removeEventListener('touchmove', handler.move);
+ window.removeEventListener('touchend', handler.end);
+ };
+ }, [handleTouchStart, handleTouchMove, handleTouchEnd]);
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| components/panel/right | +
+
+ |
+ 47.76% | +32/67 | +0% | +0/36 | +50% | +3/6 | +61.53% | +32/52 | +
| components/ui | +
+
+ |
+ 98.14% | +159/162 | +85.71% | +18/21 | +100% | +20/20 | +98.73% | +156/158 | +
| hooks | +
+
+ |
+ 44.59% | +99/222 | +32.14% | +27/84 | +41.02% | +16/39 | +47.57% | +98/206 | +
| store | +
+
+ |
+ 25% | +3/12 | +0% | +0/10 | +20% | +1/5 | +20% | +2/10 | +
| types | +
+
+ |
+ 100% | +5/5 | +100% | +0/0 | +100% | +0/0 | +100% | +5/5 | +
| utils | +
+
+ |
+ 95.16% | +118/124 | +39.65% | +46/116 | +84% | +21/25 | +96.61% | +114/118 | +