diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 4c707025f..f6a4cd384 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -6,6 +6,11 @@ on: - main workflow_dispatch: inputs: + plugins: + description: '指定要构建的插件文件夹名,多个可用逗号、空格或换行分隔' + required: false + type: string + default: '' build_all: description: '构建所有插件(而不是仅构建变动的插件)' required: false @@ -13,11 +18,108 @@ on: default: false jobs: - build-and-release: + build-pasteboardpro-helper: + runs-on: macos-14 + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Detect Paste剪切板 helper requirement + id: helper + env: + BUILD_ALL: ${{ github.event.inputs.build_all || 'false' }} + BUILD_PLUGINS: ${{ github.event.inputs.plugins || '' }} + run: | + if [ "${BUILD_ALL}" = "true" ] || [[ "${BUILD_PLUGINS}" == *"pasteboard-pro"* ]] || ! git diff --quiet HEAD~1 HEAD -- plugins/pasteboard-pro; then + echo "required=true" >> "${GITHUB_OUTPUT}" + else + echo "required=false" >> "${GITHUB_OUTPUT}" + fi + + - name: Import Developer ID certificate + if: steps.helper.outputs.required == 'true' + env: + APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: | + set -euo pipefail + test -n "${APPLE_CERTIFICATE_P12_BASE64}" + test -n "${APPLE_CERTIFICATE_PASSWORD}" + test -n "${APPLE_SIGNING_IDENTITY}" + KEYCHAIN_PASSWORD=$(openssl rand -hex 24) + echo "${APPLE_CERTIFICATE_P12_BASE64}" | base64 --decode > signing-certificate.p12 + security create-keychain -p "${KEYCHAIN_PASSWORD}" build.keychain + security set-keychain-settings -lut 21600 build.keychain + security unlock-keychain -p "${KEYCHAIN_PASSWORD}" build.keychain + security import signing-certificate.p12 -k build.keychain -P "${APPLE_CERTIFICATE_PASSWORD}" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "${KEYCHAIN_PASSWORD}" build.keychain + security list-keychains -d user -s build.keychain + + - name: Build Vision helper + if: steps.helper.outputs.required == 'true' + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: | + plugins/pasteboard-pro/apps/ztools/native/vision-helper/build.sh + codesign --force --options runtime --timestamp --sign "${APPLE_SIGNING_IDENTITY}" plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist/pasteboard-vision + codesign --verify --deep --strict --verbose=2 plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist/pasteboard-vision + + - name: Notarize Vision helper + if: steps.helper.outputs.required == 'true' + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + set -euo pipefail + test -n "${APPLE_ID}" + test -n "${APPLE_APP_SPECIFIC_PASSWORD}" + test -n "${APPLE_TEAM_ID}" + ditto -c -k --keepParent plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist/pasteboard-vision pasteboard-vision-notarization.zip + xcrun notarytool submit pasteboard-vision-notarization.zip \ + --apple-id "${APPLE_ID}" \ + --password "${APPLE_APP_SPECIFIC_PASSWORD}" \ + --team-id "${APPLE_TEAM_ID}" \ + --wait + spctl --assess --type execute --verbose=4 plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist/pasteboard-vision + node plugins/pasteboard-pro/apps/ztools/scripts/attest-vision-helper.mjs \ + plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist/pasteboard-vision \ + --signature=developer-id \ + --require-gatekeeper \ + --output=plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist/pasteboard-vision-attestation.json + + - name: Upload Vision helper + if: steps.helper.outputs.required == 'true' + uses: actions/upload-artifact@v4 + with: + name: pasteboardpro-vision-${{ github.sha }} + path: | + plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist/pasteboard-vision + plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist/pasteboard-vision-attestation.json + if-no-files-found: error + retention-days: 3 + + detect: + needs: build-pasteboardpro-helper runs-on: ubuntu-latest permissions: actions: read - contents: write + contents: read + outputs: + has_changes: ${{ steps.detect.outputs.has_changes }} + release_version: ${{ steps.detect.outputs.release_version }} + changed_plugins: ${{ steps.detect.outputs.changed_plugins }} + has_linux: ${{ steps.split.outputs.has_linux }} + has_windows: ${{ steps.split.outputs.has_windows }} + has_macos: ${{ steps.split.outputs.has_macos }} + linux_plugins: ${{ steps.split.outputs.linux_plugins }} + windows_plugins: ${{ steps.split.outputs.windows_plugins }} + macos_plugins: ${{ steps.split.outputs.macos_plugins }} steps: - name: Wait for previous workflow runs @@ -69,7 +171,79 @@ jobs: - name: Checkout code uses: actions/checkout@v4 with: - fetch-depth: 0 # 获取完整历史用于检测变动 + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Detect changed plugins + id: detect + run: node scripts/detect-changes.js + env: + BUILD_ALL: ${{ github.event.inputs.build_all || 'false' }} + BUILD_PLUGINS: ${{ github.event.inputs.plugins || '' }} + + - name: Split changed plugins by platform + id: split + if: steps.detect.outputs.has_changes == 'true' + run: node scripts/split-build-info-by-platform.js + + - name: Upload build info + if: steps.detect.outputs.has_changes == 'true' + uses: actions/upload-artifact@v4 + with: + name: release-build-info + path: | + release/build-info.json + release/build-info.linux.json + release/build-info.windows.json + release/build-info.macos.json + if-no-files-found: error + + build-linux: + needs: detect + if: needs.detect.outputs.has_linux == 'true' + uses: ./.github/workflows/build-release-plugin-platform.yml + with: + platform_name: linux + runner: ubuntu-latest + + build-windows: + needs: detect + if: needs.detect.outputs.has_windows == 'true' + uses: ./.github/workflows/build-release-plugin-platform.yml + with: + platform_name: windows + runner: windows-latest + + build-macos: + needs: detect + if: needs.detect.outputs.has_macos == 'true' + uses: ./.github/workflows/build-release-plugin-platform.yml + with: + platform_name: macos + runner: macos-latest + + release: + needs: [detect, build-linux, build-windows, build-macos] + if: | + always() && + needs.detect.outputs.has_changes == 'true' && + (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') && + (needs.build-windows.result == 'success' || needs.build-windows.result == 'skipped') && + (needs.build-macos.result == 'success' || needs.build-macos.result == 'skipped') + runs-on: ubuntu-latest + permissions: + actions: read + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 @@ -84,39 +258,37 @@ jobs: - name: Install dependencies run: npm install - - name: Detect changed plugins - id: detect - run: | - node scripts/detect-changes.js - env: - BUILD_ALL: ${{ github.event.inputs.build_all || 'false' }} + - name: Download build info + uses: actions/download-artifact@v4 + with: + name: release-build-info + path: release - - name: Build plugins - if: steps.detect.outputs.has_changes == 'true' + - name: Download built plugin packages + uses: actions/download-artifact@v4 + with: + pattern: release-*-plugin-packages + path: release-artifacts + merge-multiple: true + + - name: Collect built plugin packages run: | - node scripts/build-plugins.js - env: - BUILD_ALL: ${{ github.event.inputs.build_all || 'false' }} + cp release-artifacts/*.zip release/ + node -e "const fs = require('fs'); const info = JSON.parse(fs.readFileSync('release/build-info.json', 'utf8')); fs.writeFileSync('release/latest', info.releaseVersion, 'utf8')" + ls -la release - name: Download previous release assets - if: steps.detect.outputs.has_changes == 'true' - run: | - node scripts/download-previous-assets.js + run: node scripts/download-previous-assets.js env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Generate plugins.json - if: steps.detect.outputs.has_changes == 'true' - run: | - node scripts/generate-plugins-json.js + run: node scripts/generate-plugins-json.js - name: Prepare layout.yaml - if: steps.detect.outputs.has_changes == 'true' - run: | - cp layout.yaml release/layout.yaml + run: cp layout.yaml release/layout.yaml - name: Prepare release body - if: steps.detect.outputs.has_changes == 'true' run: | echo "## 更新的插件" > release/release-body.md echo "" >> release/release-body.md @@ -124,7 +296,6 @@ jobs: echo "| 插件名称 | 版本 | 描述 |" >> release/release-body.md echo "| --- | --- | --- |" >> release/release-body.md while IFS= read -r line; do - # 提取插件名称、版本和描述 plugin_name=$(echo "$line" | sed -E 's/ v[0-9]+\.[0-9]+\.[0-9]+:.*//') version=$(echo "$line" | sed -E 's/.*v([0-9]+\.[0-9]+\.[0-9]+).*/\1/') description=$(echo "$line" | sed -E 's/.*: //') @@ -135,11 +306,10 @@ jobs: fi - name: Create Release - if: steps.detect.outputs.has_changes == 'true' uses: softprops/action-gh-release@v1 with: - tag_name: v${{ steps.detect.outputs.release_version }} - name: Release v${{ steps.detect.outputs.release_version }} + tag_name: v${{ needs.detect.outputs.release_version }} + name: Release v${{ needs.detect.outputs.release_version }} body_path: release/release-body.md files: | release/*.zip @@ -147,13 +317,13 @@ jobs: release/categories.json release/layout.yaml release/latest + release/pasteboardpro-archive-verification.json draft: false prerelease: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Trigger EdgeOne build hook - if: steps.detect.outputs.has_changes == 'true' run: | if [ -z "${EDGEONE_BUILD_URL}" ]; then echo "EDGEONE_BUILD_URL is not set" @@ -163,219 +333,3 @@ jobs: curl -fsS -X POST "${EDGEONE_BUILD_URL}" env: EDGEONE_BUILD_URL: ${{ secrets.EDGEONE_BUILD_URL || vars.EDGEONE_BUILD_URL }} - - # - name: Setup ossutil - # if: steps.detect.outputs.has_changes == 'true' - # run: | - # wget https://gosspublic.alicdn.com/ossutil/1.7.18/ossutil-v1.7.18-linux-amd64.zip - # unzip ossutil-v1.7.18-linux-amd64.zip - # chmod +x ossutil-v1.7.18-linux-amd64/ossutil - # sudo mv ossutil-v1.7.18-linux-amd64/ossutil /usr/local/bin/ossutil - - # - name: Configure ossutil - # if: steps.detect.outputs.has_changes == 'true' - # run: | - # ossutil config -e oss-cn-beijing.aliyuncs.com -i ${{ secrets.ALIYUN_OSS_ACCESS_KEY_ID }} -k ${{ secrets.ALIYUN_OSS_ACCESS_KEY_SECRET }} - - # - name: Update plugins.json URLs for OSS - # if: steps.detect.outputs.has_changes == 'true' - # run: | - # node scripts/update-oss-urls.js - # env: - # OSS_BUCKET: ztools-center - # OSS_REGION: oss-cn-beijing - - # - name: Upload to Aliyun OSS - # if: steps.detect.outputs.has_changes == 'true' - # run: | - # # 读取变更信息 - # BUILD_INFO_FILE="release/build-info.json" - # CHANGED_PLUGINS=() - # BUILD_ALL_PLUGINS=false - - # if [ -f "$BUILD_INFO_FILE" ]; then - # # 使用 node 解析 JSON,更可靠 - # CHANGED_PLUGINS_STR=$(node -e " - # const fs = require('fs'); - # const data = JSON.parse(fs.readFileSync('$BUILD_INFO_FILE', 'utf-8')); - # console.log(data.changedPlugins.join(' ')); - # ") - - # # 转换为数组 - # if [ -n "$CHANGED_PLUGINS_STR" ]; then - # read -ra CHANGED_PLUGINS <<< "$CHANGED_PLUGINS_STR" - # fi - - # echo "本次变更的插件: ${CHANGED_PLUGINS[*]}" - # fi - - # # 检查是否是构建所有插件 - # if [ "${{ github.event.inputs.build_all }}" = "true" ]; then - # BUILD_ALL_PLUGINS=true - # echo "模式: 构建所有插件,将上传所有 ZIP 文件" - # else - # echo "模式: 仅构建变更插件,将只上传变更的 ${#CHANGED_PLUGINS[@]} 个插件" - # fi - - # # 检查插件是否在变更列表中 - # is_plugin_changed() { - # local zip_file=$1 - # # 从 ZIP 文件名提取插件名(去除版本号) - # # 例如: anywhere-1.11.2.zip -> anywhere - # local plugin_name=$(basename "$zip_file" .zip | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+$//') - - # # 如果是构建所有插件模式,返回 true - # if [ "$BUILD_ALL_PLUGINS" = "true" ]; then - # return 0 - # fi - - # # 检查插件是否在变更列表中 - # for changed in "${CHANGED_PLUGINS[@]}"; do - # if [ "$plugin_name" = "$changed" ]; then - # return 0 - # fi - # done - # return 1 - # } - - # # 检查 OSS 文件是否存在 - # check_file_exists() { - # local target=$1 - # if ossutil stat "$target" >/dev/null 2>&1; then - # return 0 # 存在 - # else - # return 1 # 不存在 - # fi - # } - - # # 上传函数,支持重试和超时 - # upload_file() { - # local file=$1 - # local target=$2 - # local allow_overwrite=$3 # true/false - # local max_retries=3 - # local timeout=10 # 10秒钟超时 - - # # 如果不允许覆盖,先检查文件是否存在 - # if [ "$allow_overwrite" != "true" ]; then - # if check_file_exists "$target"; then - # echo " ⊙ 跳过: $(basename "$file") (OSS 已存在)" - # return 2 # 返回特殊状态码表示跳过 - # fi - # fi - - # for i in $(seq 1 $max_retries); do - # echo " 尝试 $i/$max_retries: $(basename "$file")" - - # # 使用 timeout 命令限制执行时间,添加 --jobs 参数提高上传速度 - # # 始终使用 -f 参数覆盖(因为到这里说明要么允许覆盖,要么文件不存在) - # if timeout $timeout ossutil cp "$file" "$target" -f --jobs 3 2>&1; then - # echo " ✓ 上传成功: $(basename "$file")" - # return 0 - # else - # echo " ✗ 上传失败,错误码: $?" - # if [ $i -lt $max_retries ]; then - # echo " 等待 5 秒后重试..." - # sleep 5 - # fi - # fi - # done - - # echo " ✗✗✗ $(basename "$file") 上传失败,已重试 $max_retries 次" - # return 1 - # } - - # # 统计 - # success_count=0 - # fail_count=0 - # skip_count=0 - # not_changed_count=0 - # failed_files=() - - # # 上传所有 zip 文件(不允许覆盖) - # echo "" - # echo "========== 开始上传 ZIP 文件 ==========" - # zip_count=$(ls -1 release/*.zip 2>/dev/null | wc -l) - # echo "发现 $zip_count 个 ZIP 文件" - - # for file in release/*.zip; do - # if [ -f "$file" ]; then - # target="oss://ztools-center/$(basename "$file")" - - # # 检查插件是否在本次变更中 - # if ! is_plugin_changed "$file"; then - # echo " ○ 跳过: $(basename "$file") (非本次变更)" - # not_changed_count=$((not_changed_count + 1)) - # continue - # fi - - # # 调用上传函数,使用 || true 防止非 0 返回值触发 set -e 退出 - # upload_file "$file" "$target" "false" || result=$? - # # 如果上面的命令成功(返回0),则 result 未被设置,需要显式设为 0 - # result=${result:-0} - - # if [ $result -eq 0 ]; then - # success_count=$((success_count + 1)) - # elif [ $result -eq 2 ]; then - # skip_count=$((skip_count + 1)) - # else - # fail_count=$((fail_count + 1)) - # failed_files+=("$(basename "$file")") - # fi - - # # 重置 result 变量 - # result=0 - # fi - # done - - # # 上传 plugins.json(允许覆盖) - # echo "" - # echo "========== 上传 plugins.json ==========" - # if [ -f "release/plugins.json" ]; then - # upload_file "release/plugins.json" "oss://ztools-center/plugins.json" "true" || result=$? - # result=${result:-0} - # if [ $result -eq 0 ]; then - # success_count=$((success_count + 1)) - # else - # fail_count=$((fail_count + 1)) - # failed_files+=("plugins.json") - # fi - # fi - - # # 上传 latest 文件(允许覆盖) - # echo "" - # echo "========== 上传 latest 文件 ==========" - # if [ -f "release/latest" ]; then - # upload_file "release/latest" "oss://ztools-center/latest" "true" || result=$? - # result=${result:-0} - # if [ $result -eq 0 ]; then - # success_count=$((success_count + 1)) - # else - # fail_count=$((fail_count + 1)) - # failed_files+=("latest") - # fi - # fi - - # # 输出结果 - # echo "" - # echo "=========================================" - # echo "上传完成!" - # echo "成功: $success_count 个文件" - # echo "跳过: $skip_count 个文件 (已存在)" - # if [ $not_changed_count -gt 0 ]; then - # echo "跳过: $not_changed_count 个文件 (非本次变更)" - # fi - # echo "失败: $fail_count 个文件" - - # if [ $fail_count -gt 0 ]; then - # echo "" - # echo "失败的文件列表:" - # for failed_file in "${failed_files[@]}"; do - # echo " - $failed_file" - # done - # echo "" - # echo "⚠️ 部分文件上传失败,请检查日志" - # exit 1 - # else - # echo "✅ 所有文件已成功上传到阿里云 OSS" - # fi diff --git a/.github/workflows/build-pr-plugin-platform.yml b/.github/workflows/build-pr-plugin-platform.yml new file mode 100644 index 000000000..9df63eb8c --- /dev/null +++ b/.github/workflows/build-pr-plugin-platform.yml @@ -0,0 +1,165 @@ +name: Build PR Plugin Package Platform + +on: + workflow_call: + inputs: + pr_number: + required: true + type: string + platform_name: + required: true + type: string + runner: + required: true + type: string + +jobs: + build: + runs-on: ${{ inputs.runner }} + + steps: + - name: Checkout PR code + shell: bash + env: + PR_NUMBER: ${{ inputs.pr_number }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + + git init . + git remote add origin "https://github.com/${REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "pull/${PR_NUMBER}/head" + git checkout --force FETCH_HEAD + git clean -ffdx + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Download build info + uses: actions/download-artifact@v4 + with: + name: pr-${{ inputs.pr_number }}-build-info + path: release + + - name: Select platform build info + run: | + node -e "const fs = require('fs'); fs.copyFileSync('release/build-info.${{ inputs.platform_name }}.json', 'release/build-info.json')" + + - name: Detect Paste剪切板 build + id: pasteboard + shell: bash + run: | + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync } from 'node:fs' + const info = JSON.parse(readFileSync('release/build-info.json', 'utf8')) + const required = Array.isArray(info.changedPlugins) && info.changedPlugins.includes('pasteboard-pro') + appendFileSync(process.env.GITHUB_OUTPUT, `required=${required ? 'true' : 'false'}\n`) + NODE + + - name: Setup Node.js 24 for Paste剪切板 + if: steps.pasteboard.outputs.required == 'true' + uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: ${{ steps.pasteboard.outputs.required == 'true' && '11.7.0' || '9' }} + + - name: Download Paste剪切板 Vision helper + if: steps.pasteboard.outputs.required == 'true' + uses: actions/download-artifact@v4 + with: + name: pasteboardpro-vision-pr-${{ inputs.pr_number }} + path: plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist + + - name: Restore Vision helper executable bit + if: steps.pasteboard.outputs.required == 'true' + shell: bash + run: | + chmod +x plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist/pasteboard-vision + test -f plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist/pasteboard-vision-attestation.json + + - name: Verify Paste剪切板 workspace + if: steps.pasteboard.outputs.required == 'true' + working-directory: plugins/pasteboard-pro + shell: bash + run: | + set -euo pipefail + pnpm install --frozen-lockfile + pnpm typecheck + pnpm --filter @pasteboard-pro/atools typecheck + pnpm --filter @pasteboard-pro/ztools typecheck + pnpm typecheck:visual + pnpm test:contract + pnpm test:release-archive + pnpm test:visual-artifact + pnpm test:visual-contract + node apps/atools/scripts/verify-source.mjs + node apps/ztools/scripts/verify-package.mjs + pnpm test + pnpm benchmark:search + + - name: Upload Paste剪切板 performance report + if: steps.pasteboard.outputs.required == 'true' + uses: actions/upload-artifact@v4 + with: + name: pasteboardpro-performance-pr-${{ inputs.pr_number }} + path: plugins/pasteboard-pro/artifacts/pasteboardpro/search-performance.json + + - name: Install Paste剪切板 visual test browser + if: steps.pasteboard.outputs.required == 'true' + working-directory: plugins/pasteboard-pro + run: pnpm exec playwright install --with-deps chromium + + - name: Capture Paste剪切板 dual-host visual matrix + if: steps.pasteboard.outputs.required == 'true' + working-directory: plugins/pasteboard-pro + run: | + pnpm test:visual + pnpm verify:visual-artifact + + - name: Upload Paste剪切板 visual evidence + if: always() && steps.pasteboard.outputs.required == 'true' + uses: actions/upload-artifact@v4 + with: + name: pasteboardpro-visual-pr-${{ inputs.pr_number }} + path: | + plugins/pasteboard-pro/artifacts/pasteboardpro/visual-matrix + plugins/pasteboard-pro/artifacts/pasteboardpro/playwright-report.json + plugins/pasteboard-pro/artifacts/pasteboardpro/playwright-results + if-no-files-found: error + + - name: Install root dependencies + run: npm ci + + - name: Build changed plugins + run: node scripts/build-plugins.js + + - name: Verify final Paste剪切板 release archive + if: steps.pasteboard.outputs.required == 'true' + shell: bash + run: | + set -euo pipefail + archive="$(find release -maxdepth 1 -type f -name 'pasteboard-pro-*.zip' -print -quit)" + test -n "${archive}" + node plugins/pasteboard-pro/scripts/verify-release-archive.mjs \ + "${archive}" \ + --helper-attestation=plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist/pasteboard-vision-attestation.json \ + --require-helper-signature=adhoc \ + --report=release/pasteboardpro-archive-verification.json + + - name: Upload plugin packages + uses: actions/upload-artifact@v4 + with: + name: pr-${{ inputs.pr_number }}-${{ inputs.platform_name }}-plugin-packages + path: | + release/*.zip + release/build-info.json + release/latest + release/pasteboardpro-archive-verification.json + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/build-pr-plugin.yml b/.github/workflows/build-pr-plugin.yml index c2a120274..5ef7793b2 100644 --- a/.github/workflows/build-pr-plugin.yml +++ b/.github/workflows/build-pr-plugin.yml @@ -16,6 +16,7 @@ on: type: string permissions: + actions: read contents: read pull-requests: read @@ -24,8 +25,68 @@ concurrency: cancel-in-progress: true jobs: - build-pr-plugin: + build-pasteboardpro-helper: + runs-on: macos-14 + steps: + - name: Checkout PR code + env: + PR_NUMBER: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || inputs.pr_number }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + git init . + git remote add origin "https://github.com/${REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "pull/${PR_NUMBER}/head" + git checkout --force FETCH_HEAD + + - name: Detect Paste剪切板 helper requirement + id: helper + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || inputs.pr_number }} + REPOSITORY: ${{ github.repository }} + run: | + if gh api --paginate "/repos/${REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" --jq '.[].filename' | grep -q '^plugins/pasteboard-pro/'; then + echo "required=true" >> "${GITHUB_OUTPUT}" + else + echo "required=false" >> "${GITHUB_OUTPUT}" + fi + + - name: Build Vision helper + if: steps.helper.outputs.required == 'true' + run: | + plugins/pasteboard-pro/apps/ztools/native/vision-helper/build.sh + codesign --force --sign - plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist/pasteboard-vision + codesign --verify --strict plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist/pasteboard-vision + node plugins/pasteboard-pro/apps/ztools/scripts/attest-vision-helper.mjs \ + plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist/pasteboard-vision \ + --signature=adhoc \ + --output=plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist/pasteboard-vision-attestation.json + + - name: Upload Vision helper + if: steps.helper.outputs.required == 'true' + uses: actions/upload-artifact@v4 + with: + name: pasteboardpro-vision-pr-${{ github.event_name == 'pull_request' && github.event.pull_request.number || inputs.pr_number }} + path: | + plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist/pasteboard-vision + plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist/pasteboard-vision-attestation.json + if-no-files-found: error + retention-days: 3 + + detect: + needs: build-pasteboardpro-helper runs-on: ubuntu-latest + outputs: + has_changes: ${{ steps.detect.outputs.has_changes }} + changed_plugins: ${{ steps.detect.outputs.changed_plugins }} + has_linux: ${{ steps.split.outputs.has_linux }} + has_windows: ${{ steps.split.outputs.has_windows }} + has_macos: ${{ steps.split.outputs.has_macos }} + linux_plugins: ${{ steps.split.outputs.linux_plugins }} + windows_plugins: ${{ steps.split.outputs.windows_plugins }} + macos_plugins: ${{ steps.split.outputs.macos_plugins }} + pr_number: ${{ steps.pr.outputs.number }} steps: - name: Resolve PR metadata @@ -105,11 +166,6 @@ jobs: with: node-version: '20' - - name: Setup pnpm - uses: pnpm/action-setup@v3 - with: - version: 9 - - name: Detect changed plugins id: detect env: @@ -179,22 +235,136 @@ jobs: cat release/build-info.json - - name: Install root dependencies + - name: Split changed plugins by platform + id: split if: steps.detect.outputs.has_changes == 'true' - run: npm ci + run: | + node --input-type=module <<'NODE' + import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' + import { join } from 'node:path' - - name: Build changed plugins - if: steps.detect.outputs.has_changes == 'true' - run: node scripts/build-plugins.js + const RELEASE_DIR = 'release' + const BUILD_INFO_FILE = join(RELEASE_DIR, 'build-info.json') + const GROUPS = { + linux: { + output: join(RELEASE_DIR, 'build-info.linux.json'), + plugins: [], + }, + windows: { + output: join(RELEASE_DIR, 'build-info.windows.json'), + plugins: [], + }, + macos: { + output: join(RELEASE_DIR, 'build-info.macos.json'), + plugins: [], + }, + } + + if (!existsSync(BUILD_INFO_FILE)) { + console.error('找不到 release/build-info.json,请先运行变更检测脚本') + process.exit(1) + } + + if (!existsSync(RELEASE_DIR)) { + mkdirSync(RELEASE_DIR, { recursive: true }) + } + + const buildInfo = JSON.parse(readFileSync(BUILD_INFO_FILE, 'utf-8')) + const changedPlugins = Array.isArray(buildInfo.changedPlugins) ? buildInfo.changedPlugins : [] + + for (const pluginName of changedPlugins) { + const pluginInfo = getPluginInfo(pluginName) + const group = getBuildGroup(pluginInfo.platform) + GROUPS[group].plugins.push(pluginName) + } + + for (const [groupName, group] of Object.entries(GROUPS)) { + const groupBuildInfo = { + ...buildInfo, + hasChanges: group.plugins.length > 0, + changedPlugins: group.plugins, + } + + writeFileSync(group.output, JSON.stringify(groupBuildInfo, null, 2)) + console.log(`${groupName}: ${group.plugins.length > 0 ? group.plugins.join(', ') : '(none)'}`) + } + + if (process.env.GITHUB_OUTPUT) { + const lines = [ + `has_linux=${GROUPS.linux.plugins.length > 0 ? 'true' : 'false'}`, + `has_windows=${GROUPS.windows.plugins.length > 0 ? 'true' : 'false'}`, + `has_macos=${GROUPS.macos.plugins.length > 0 ? 'true' : 'false'}`, + `linux_plugins=${GROUPS.linux.plugins.join(',')}`, + `windows_plugins=${GROUPS.windows.plugins.join(',')}`, + `macos_plugins=${GROUPS.macos.plugins.join(',')}`, + ] + + appendFileSync(process.env.GITHUB_OUTPUT, lines.join('\n') + '\n') + } + + function getPluginInfo(pluginName) { + const pluginJsonPaths = [ + join('plugins', pluginName, 'plugin.json'), + join('plugins', pluginName, 'public', 'plugin.json'), + join('plugins', pluginName, 'dist', 'plugin.json'), + ] + + for (const pluginJsonPath of pluginJsonPaths) { + if (existsSync(pluginJsonPath)) { + return JSON.parse(readFileSync(pluginJsonPath, 'utf-8')) + } + } + + console.warn(`找不到插件 ${pluginName} 的 plugin.json,默认使用 linux 构建`) + return {} + } + + function getBuildGroup(platform) { + if (Array.isArray(platform) && platform.length === 1) { + if (platform[0] === 'win32') return 'windows' + if (platform[0] === 'darwin') return 'macos' + } + + return 'linux' + } + NODE - - name: Upload plugin packages + - name: Upload build info if: steps.detect.outputs.has_changes == 'true' uses: actions/upload-artifact@v4 with: - name: pr-${{ steps.pr.outputs.number }}-plugin-packages + name: pr-${{ steps.pr.outputs.number }}-build-info path: | - release/*.zip release/build-info.json - release/latest + release/build-info.linux.json + release/build-info.windows.json + release/build-info.macos.json if-no-files-found: error retention-days: 14 + + build-linux: + needs: detect + if: needs.detect.outputs.has_linux == 'true' + uses: ./.github/workflows/build-pr-plugin-platform.yml + with: + pr_number: ${{ needs.detect.outputs.pr_number }} + platform_name: linux + runner: ubuntu-latest + + build-windows: + needs: detect + if: needs.detect.outputs.has_windows == 'true' + uses: ./.github/workflows/build-pr-plugin-platform.yml + with: + pr_number: ${{ needs.detect.outputs.pr_number }} + platform_name: windows + runner: windows-latest + + build-macos: + needs: detect + if: needs.detect.outputs.has_macos == 'true' + uses: ./.github/workflows/build-pr-plugin-platform.yml + with: + pr_number: ${{ needs.detect.outputs.pr_number }} + platform_name: macos + runner: macos-latest diff --git a/.github/workflows/build-release-plugin-platform.yml b/.github/workflows/build-release-plugin-platform.yml new file mode 100644 index 000000000..d25b03653 --- /dev/null +++ b/.github/workflows/build-release-plugin-platform.yml @@ -0,0 +1,150 @@ +name: Build Release Plugin Platform + +on: + workflow_call: + inputs: + platform_name: + required: true + type: string + runner: + required: true + type: string + +jobs: + build: + runs-on: ${{ inputs.runner }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Download build info + uses: actions/download-artifact@v4 + with: + name: release-build-info + path: release + + - name: Select platform build info + run: | + node -e "const fs = require('fs'); fs.copyFileSync('release/build-info.${{ inputs.platform_name }}.json', 'release/build-info.json')" + + - name: Detect Paste剪切板 build + id: pasteboard + shell: bash + run: | + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync } from 'node:fs' + const info = JSON.parse(readFileSync('release/build-info.json', 'utf8')) + const required = Array.isArray(info.changedPlugins) && info.changedPlugins.includes('pasteboard-pro') + appendFileSync(process.env.GITHUB_OUTPUT, `required=${required ? 'true' : 'false'}\n`) + NODE + + - name: Setup Node.js 24 for Paste剪切板 + if: steps.pasteboard.outputs.required == 'true' + uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: ${{ steps.pasteboard.outputs.required == 'true' && '11.7.0' || '9' }} + + - name: Download Paste剪切板 Vision helper + if: steps.pasteboard.outputs.required == 'true' + uses: actions/download-artifact@v4 + with: + name: pasteboardpro-vision-${{ github.sha }} + path: plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist + + - name: Restore Vision helper executable bit + if: steps.pasteboard.outputs.required == 'true' + shell: bash + run: | + chmod +x plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist/pasteboard-vision + test -f plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist/pasteboard-vision-attestation.json + + - name: Verify Paste剪切板 workspace + if: steps.pasteboard.outputs.required == 'true' + working-directory: plugins/pasteboard-pro + shell: bash + run: | + set -euo pipefail + pnpm install --frozen-lockfile + pnpm typecheck + pnpm --filter @pasteboard-pro/atools typecheck + pnpm --filter @pasteboard-pro/ztools typecheck + pnpm typecheck:visual + pnpm test:contract + pnpm test:release-archive + pnpm test:visual-artifact + pnpm test:visual-contract + node apps/atools/scripts/verify-source.mjs + node apps/ztools/scripts/verify-package.mjs + pnpm test + pnpm benchmark:search + + - name: Upload Paste剪切板 performance report + if: steps.pasteboard.outputs.required == 'true' + uses: actions/upload-artifact@v4 + with: + name: pasteboardpro-performance-${{ github.sha }} + path: plugins/pasteboard-pro/artifacts/pasteboardpro/search-performance.json + + - name: Install Paste剪切板 visual test browser + if: steps.pasteboard.outputs.required == 'true' + working-directory: plugins/pasteboard-pro + run: pnpm exec playwright install --with-deps chromium + + - name: Capture Paste剪切板 dual-host visual matrix + if: steps.pasteboard.outputs.required == 'true' + working-directory: plugins/pasteboard-pro + run: | + pnpm test:visual + pnpm verify:visual-artifact + + - name: Upload Paste剪切板 visual evidence + if: always() && steps.pasteboard.outputs.required == 'true' + uses: actions/upload-artifact@v4 + with: + name: pasteboardpro-visual-${{ github.sha }} + path: | + plugins/pasteboard-pro/artifacts/pasteboardpro/visual-matrix + plugins/pasteboard-pro/artifacts/pasteboardpro/playwright-report.json + plugins/pasteboard-pro/artifacts/pasteboardpro/playwright-results + if-no-files-found: error + + - name: Install dependencies + run: npm install + + - name: Build plugins + run: node scripts/build-plugins.js + + - name: Verify final Paste剪切板 release archive + if: steps.pasteboard.outputs.required == 'true' + shell: bash + run: | + set -euo pipefail + archive="$(find release -maxdepth 1 -type f -name 'pasteboard-pro-*.zip' -print -quit)" + test -n "${archive}" + node plugins/pasteboard-pro/scripts/verify-release-archive.mjs \ + "${archive}" \ + --helper-attestation=plugins/pasteboard-pro/apps/ztools/native/vision-helper/dist/pasteboard-vision-attestation.json \ + --require-helper-signature=developer-id \ + --report=release/pasteboardpro-archive-verification.json + + - name: Upload plugin packages + uses: actions/upload-artifact@v4 + with: + name: release-${{ inputs.platform_name }}-plugin-packages + path: | + release/*.zip + release/pasteboardpro-archive-verification.json + if-no-files-found: error diff --git a/categories-mapping.json b/categories-mapping.json index 72221b776..c63565601 100644 --- a/categories-mapping.json +++ b/categories-mapping.json @@ -13,6 +13,7 @@ "calculation-paper", "chrome-history", "clipboard", + "pasteboard-pro", "easy-translate", "english-typing-practice", "he-calendar", @@ -27,6 +28,7 @@ "kuke-ai-agent", "saladict-ztools", "shorthand-box", + "stickercan", "sticky-note", "totodo", "wechat-ocr", diff --git a/layout.yaml b/layout.yaml index 3600e7820..159bdc7f3 100644 --- a/layout.yaml +++ b/layout.yaml @@ -9,8 +9,10 @@ layout: plugins: - ztools-developer-plugin - zosenlink-tunnel + - termdock - clipboard - ai-helper + - shortcut-capture - type: navigation title: 开始探索 categories: diff --git a/plugins/Image-Toolbox/core/src/CanvasManager.js b/plugins/Image-Toolbox/core/src/CanvasManager.js new file mode 100644 index 000000000..f934e8673 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/CanvasManager.js @@ -0,0 +1,527 @@ +import eventBus from './EventBus.js'; +import { CANVAS_DEFAULTS } from './utils/constants.js'; + +const CLIP_PATH_SERIALIZED_PROPS = ['clipPath', 'absolutePositioned', 'inverted']; + +/** + * 画布管理器 — 封装 Fabric.js 画布的创建、配置和基础操作 + */ +class CanvasManager { + constructor(canvasElId) { + this._canvasElId = canvasElId; + this.canvas = null; + this.originalImage = null; // 原始 fabric.Image + this.zoomLevel = 1; + this._historySaveTimer = null; + this._isCropMode = false; + this._resizeObserver = null; + this._boundResize = null; + } + + // ── 生命周期 ── + + /** + * 初始化画布 + * @param {object} [options] - Fabric.Canvas 配置 + */ + init(options = {}) { + const el = document.getElementById(this._canvasElId); + if (!el) { + throw new Error(`[CanvasManager] 找不到画布元素 #${this._canvasElId}`); + } + + const config = { + width: CANVAS_DEFAULTS.WIDTH, + height: CANVAS_DEFAULTS.HEIGHT, + backgroundColor: CANVAS_DEFAULTS.BACKGROUND_COLOR, + preserveObjectStacking: CANVAS_DEFAULTS.PRESERVE_OBJECT_STACKING, + selection: CANVAS_DEFAULTS.SELECTION, + stopContextMenu: CANVAS_DEFAULTS.STOP_CONTEXT_MENU, + fireRightClick: CANVAS_DEFAULTS.FIRE_RIGHT_CLICK, + ...options, + }; + this.canvas = new fabric.Canvas(this._canvasElId, config); + this._bindEvents(); + this._updateCanvasSize(); + eventBus.emit('canvas:initialized', this.canvas); + } + + /** + * 销毁画布,释放内存 + */ + destroy() { + if (this._resizeObserver) { + this._resizeObserver.disconnect(); + this._resizeObserver = null; + } + if (this._boundResize) { + window.removeEventListener('resize', this._boundResize); + this._boundResize = null; + } + if (this._historySaveTimer) { + clearTimeout(this._historySaveTimer); + } + if (this.canvas) { + this.canvas.dispose(); + this.canvas = null; + } + this.originalImage = null; + } + + // ── 图片操作 ── + + /** + * 加载图片到画布 + * @param {string|File} source - URL / DataURL / File 对象 + * @returns {Promise} + */ + loadImage(source) { + return new Promise((resolve, reject) => { + if (!this.canvas) { + reject(new Error('画布未初始化')); + return; + } + + // 超时保护:防止 fabric.Image.fromURL 永远不回调 + const timeout = setTimeout(() => { + reject(new Error('图片加载超时(30s),可能是格式不支持')); + }, 30000); + + /** + * fabric.Image.fromURL 回调签名: function(fabricImage, isError) + * crossOrigin 是第 4 个参数,不是 imgOptions 的一部分 + */ + const onLoad = (fabricImg, isError) => { + clearTimeout(timeout); + + if (isError || !fabricImg) { + const err = new Error('图片加载失败:格式不支持或文件已损坏'); + console.error('[CanvasManager]', err.message); + eventBus.emit('image:loadError', err); + reject(err); + return; + } + + console.log('[CanvasManager] 图片加载成功: %d×%d (type=%s)', + fabricImg.width, fabricImg.height, fabricImg.type); + + this.originalImage = fabricImg; + this._applyBackgroundImageProps(fabricImg); + this.canvas.clear(); + this.canvas.add(fabricImg); + this.canvas.renderAll(); + this._updateCanvasSize(); + eventBus.emit('image:loaded', fabricImg); + resolve(fabricImg); + }; + + if (source instanceof File) { + const reader = new FileReader(); + reader.onload = (e) => { + const dataURL = e.target.result; + // fromURL(url, callback, imgOptions, crossOrigin) + fabric.Image.fromURL(dataURL, onLoad, undefined, 'anonymous'); + }; + reader.onerror = (err) => { + clearTimeout(timeout); + console.error('[CanvasManager] FileReader 读取失败:', err); + eventBus.emit('image:loadError', err); + reject(err); + }; + reader.readAsDataURL(source); + } else if (typeof source === 'string') { + // 数据URL 不需要 crossOrigin + const needCrossOrigin = !source.startsWith('data:'); + fabric.Image.fromURL( + source, + onLoad, + undefined, // imgOptions + needCrossOrigin ? 'anonymous' : undefined // crossOrigin + ); + } else { + reject(new Error('不支持的图片源类型')); + } + }); + } + + /** + * 替换当前图片(不清除覆盖层) + * @param {string} source + */ + replaceImage(source) { + return new Promise((resolve, reject) => { + fabric.Image.fromURL(source, (fabricImg, isError) => { + if (isError || !fabricImg) { + reject(new Error('替换图片失败')); + return; + } + if (this.originalImage) { + const index = this.canvas.getObjects().indexOf(this.originalImage); + this.canvas.remove(this.originalImage); + this.originalImage = fabricImg; + this._applyBackgroundImageProps(fabricImg); + this.canvas.insertAt(fabricImg, index >= 0 ? index : 0); + } else { + this.originalImage = fabricImg; + this._applyBackgroundImageProps(fabricImg); + this.canvas.insertAt(fabricImg, 0); + } + this.canvas.renderAll(); + resolve(fabricImg); + }, undefined, 'anonymous'); + }); + } + + /** + * 给作为背景的 fabric.Image 设置通用属性: + * - 标记为 _originalImage(序列化/恢复时识别) + * - 允许选中/接收事件(用于图层面板和调色工具定位背景图层) + * - 显示变换控制点,允许像普通图层一样拖拽移动、缩放、旋转 + * @param {fabric.Image} fabricImg + */ + _applyBackgroundImageProps(fabricImg) { + fabricImg._originalImage = true; + fabricImg.set({ + selectable: true, + evented: true, + hasControls: true, + hasBorders: true, + lockMovementX: false, + lockMovementY: false, + lockRotation: false, + lockScalingX: false, + lockScalingY: false, + }); + fabricImg.setCoords(); + } + + /** + * 获取画布当前图片 DataURL + * @param {object} [options] - 导出选项 + * @returns {string} + */ + getImageData(options = {}) { + const opts = { + format: 'png', + multiplier: 1, + ...options, + }; + this.refreshDynamicMosaics?.({ render: true }); + return this.canvas.toDataURL(opts); + } + + /** + * 图片自适应画布大小 + * @param {number} [padding=40] - 边距 + */ + fitToCanvas(padding = 40) { + if (!this.originalImage) return; + + const img = this.originalImage; + const cw = this.canvas.width; + const ch = this.canvas.height; + const availableW = cw - padding * 2; + const availableH = ch - padding * 2; + + const scale = Math.min(availableW / img.width, availableH / img.height, 1); + img.set({ + scaleX: scale, + scaleY: scale, + left: (cw - img.width * scale) / 2, + top: (ch - img.height * scale) / 2, + }); + img.setCoords(); + this.canvas.renderAll(); + } + + // ── 画布控制 ── + + /** + * 设置缩放 + * @param {number} level - 缩放级别(0.1~5) + * @param {{x:number,y:number}} [point] - 缩放中心点 + */ + setZoom(level, point) { + this.zoomLevel = Math.max(0.1, Math.min(5, level)); + const zoomPoint = point || new fabric.Point( + this.canvas.width / 2, + this.canvas.height / 2 + ); + this.canvas.zoomToPoint(zoomPoint, this.zoomLevel); + eventBus.emit('canvas:zoomChanged', this.zoomLevel); + } + + zoomIn(step = 0.1) { + this.setZoom(this.zoomLevel + step); + } + + zoomOut(step = 0.1) { + this.setZoom(this.zoomLevel - step); + } + + resetZoom() { + this.setZoom(1); + } + + // ── 物件管理 ── + + addObject(obj) { + if (!this.canvas) return; + this.canvas.add(obj); + this.canvas.setActiveObject(obj); + this.canvas.renderAll(); + eventBus.emit('canvas:objectAdded', obj); + } + + removeObject(obj) { + if (!this.canvas) return; + this.canvas.remove(obj); + this.canvas.discardActiveObject(); + this.canvas.renderAll(); + eventBus.emit('canvas:objectRemoved', obj); + } + + removeActiveObject() { + const active = this.canvas.getActiveObject(); + if (active) { + this.removeObject(active); + } + } + + getActiveObject() { + return this.canvas ? this.canvas.getActiveObject() : null; + } + + /** + * 清除所有覆盖层(保留原图) + */ + clearOverlays() { + if (!this.canvas) return; + const objects = this.canvas.getObjects().filter(obj => obj !== this.originalImage); + objects.forEach(obj => this.canvas.remove(obj)); + this.canvas.renderAll(); + eventBus.emit('canvas:overlaysCleared'); + } + + /** + * 获取画布上的所有物件(不含原图) + * @returns {fabric.Object[]} + */ + getOverlays() { + if (!this.canvas) return []; + return this.canvas.getObjects().filter(obj => obj !== this.originalImage); + } + + // ── 序列化 ── + + toJSON() { + if (!this.canvas) return null; + const json = this.canvas.toJSON([ + 'clipPath', + 'filters', + 'id', + 'selectable', + 'evented', + 'hasControls', + 'hasBorders', + 'lockMovementX', + 'lockMovementY', + 'lockRotation', + 'lockScalingX', + 'lockScalingY', + 'absolutePositioned', + 'inverted', + 'objectCaching', + 'strokeLineCap', + 'strokeLineJoin', + '_strokePosition', + '_layerName', + '_layerNameAuto', + '_layerBaseName', + '_layerKind', + '_layerShapeType', + '_layerColorPresetName', + '_layerWidthPresetName', + '_layerPresetName', + '_layerLocked', + '_mosaicDynamic', + '_mosaicMode', + '_mosaicSize', + '_mosaicBlurRadius', + '_mosaicWidth', + '_mosaicHeight', + '_mosaicMaskType', + '_mosaicBrushPoints', + '_mosaicBrushSize', + '_mosaicLassoPoints', + '_originalImage', + ]); + // 手动序列化 canvas.clipPath(Fabric.js canvas.toJSON 不包含此属性) + if (this.canvas.clipPath) { + json._canvasClipPath = this.canvas.clipPath.toJSON(CLIP_PATH_SERIALIZED_PROPS); + } + return json; + } + + fromJSON(json) { + return new Promise((resolve, reject) => { + if (!this.canvas) { + reject(new Error('画布未初始化')); + return; + } + + // 提取 canvas 级别的 clipPath,避免修改历史栈里的原始快照对象。 + const canvasClipPathData = json._canvasClipPath; + const snapshot = { ...json }; + delete snapshot._canvasClipPath; + + this.canvas.loadFromJSON(snapshot, () => { + const finishRestore = () => { + // 恢复 originalImage 引用 + const objs = this.canvas.getObjects(); + this.originalImage = objs.find(o => o.type === 'image' && o._originalImage) || objs[0]; + if (this.originalImage?.type === 'image') { + this._applyBackgroundImageProps(this.originalImage); + } + this.canvas.renderAll(); + eventBus.emit('canvas:restored'); + resolve(); + }; + + if (canvasClipPathData) { + fabric.util.enlivenObjects([canvasClipPathData], (objects) => { + this.canvas.clipPath = objects[0] || null; + if (this.canvas.clipPath) { + this.canvas.clipPath.absolutePositioned = true; + } + finishRestore(); + }); + } else { + // 快照中没有 clipPath → 清除画布上已有的(撤消裁切的关键) + this.canvas.clipPath = null; + finishRestore(); + } + }); + }); + } + + // ── 辅助 ── + + /** + * 判断点是否在画布内 + * @param {{x:number,y:number}} point + * @returns {boolean} + */ + isPointInCanvas(point) { + return point.x >= 0 && point.x <= this.canvas.width + && point.y >= 0 && point.y <= this.canvas.height; + } + + /** + * 刷新动态马赛克(由 MosaicModule 注入) + * 此方法在 MosaicModule 激活时设置回调,避免直接依赖模块 + * @param {function():void} callback + */ + setRefreshDynamicMosaics(callback) { + this._refreshDynamicMosaics = callback; + } + + /** + * 调用动态马赛克刷新回调 + * @param {object} options + */ + refreshDynamicMosaics(options) { + if (typeof this._refreshDynamicMosaics === 'function') { + this._refreshDynamicMosaics(options); + } + } + + // ── 内部方法 ── + + _updateCanvasSize() { + const container = this.canvas.wrapperEl?.parentElement; + if (!container) return; + + const rect = container.getBoundingClientRect(); + const newWidth = rect.width; + const newHeight = rect.height; + + // 容器不可见时跳过(避免读到 0×0 导致图片缩没) + if (newWidth <= 0 || newHeight <= 0) return; + + if (this.canvas.width !== newWidth || this.canvas.height !== newHeight) { + this.canvas.setWidth(newWidth); + this.canvas.setHeight(newHeight); + this.canvas.calcOffset(); + + // 如果已加载图片,重新适配 + if (this.originalImage) { + this.fitToCanvas(); + } + + eventBus.emit('canvas:resized', { width: newWidth, height: newHeight }); + } + } + + _bindEvents() { + if (!this.canvas) return; + + // 选择变化 + this.canvas.on('selection:created', (e) => { + eventBus.emit('canvas:selectionCreated', e.selected); + }); + this.canvas.on('selection:updated', (e) => { + eventBus.emit('canvas:selectionUpdated', e.selected); + }); + this.canvas.on('selection:cleared', () => { + eventBus.emit('canvas:selectionCleared'); + }); + + // 物件修改 + this.canvas.on('object:modified', (e) => { + eventBus.emit('canvas:objectModified', e.target); + }); + this.canvas.on('text:changed', (e) => { + eventBus.emit('canvas:objectMetadataChanged', e.target); + }); + this.canvas.on('text:editing:exited', (e) => { + eventBus.emit('canvas:objectModified', e.target); + }); + + // 物件添加/删除 + this.canvas.on('object:added', (e) => { + eventBus.emit('canvas:objectAdded', e.target); + }); + + // 鼠标事件 + this.canvas.on('mouse:down', (e) => { + eventBus.emit('canvas:mouseDown', e); + }); + this.canvas.on('mouse:move', (e) => { + eventBus.emit('canvas:mouseMove', e); + }); + this.canvas.on('mouse:up', (e) => { + eventBus.emit('canvas:mouseUp', e); + }); + + // 渲染完成 + this.canvas.on('after:render', () => { + eventBus.emit('canvas:rendered'); + }); + + // 窗口大小变化 + this._boundResize = () => this._updateCanvasSize(); + window.addEventListener('resize', this._boundResize); + + // 使用 ResizeObserver 监听容器变化 + const container = this.canvas.wrapperEl?.parentElement; + if (container && window.ResizeObserver) { + this._resizeObserver = new ResizeObserver(() => { + this._updateCanvasSize(); + }); + this._resizeObserver.observe(container); + } + } +} + +export default CanvasManager; diff --git a/plugins/Image-Toolbox/core/src/EditorContext.js b/plugins/Image-Toolbox/core/src/EditorContext.js new file mode 100644 index 000000000..b2de36cbd --- /dev/null +++ b/plugins/Image-Toolbox/core/src/EditorContext.js @@ -0,0 +1,201 @@ +import { EventBus } from './EventBus.js'; + +/** + * EditorContext — 多端共享上下文容器 + * + * 集中持有 EventBus、EngineAdapter、HostAdapter、HistoryStore、LayerStore 等实例。 + * 由应用层(App)创建并注入各模块,避免全局单例和硬编码依赖。 + */ +export default class EditorContext { + /** + * @param {object} options + * @param {import('./interfaces/EditorEngineAdapter.js').default} options.engine + * @param {import('./interfaces/HostAdapter.js').default} [options.host] + * @param {EventBus} [options.eventBus] + * @param {number} [options.maxHistorySteps=30] + */ + constructor({ engine, host = null, eventBus = null, maxHistorySteps = 30 } = {}) { + /** @type {EventBus} */ + this.eventBus = eventBus || new EventBus(); + + /** @type {import('./interfaces/EditorEngineAdapter.js').default} */ + this.engine = engine; + + /** @type {import('./interfaces/HostAdapter.js').default} */ + this.host = host; + + /** @type {number} */ + this.maxHistorySteps = maxHistorySteps; + + /** @type {object[]} 历史栈(snapshot 模式) */ + this._undoStack = []; + + /** @type {object[]} 重做栈 */ + this._redoStack = []; + + /** @type {boolean} */ + this._isRestoring = false; + + /** @type {string|null} 当前激活的工具名 */ + this._activeTool = null; + + /** @type {object} 当前工具选项 */ + this._toolOptions = {}; + } + + // ── 工具管理 ── + + /** + * 激活工具。 + * @param {string} toolName + * @param {object} [options] + */ + setActiveTool(toolName, options = {}) { + this._activeTool = toolName; + this._toolOptions = { ...options }; + this.eventBus.emit('tool:changed', { toolName, options: this._toolOptions }); + } + + /** + * 获取当前工具名。 + * @returns {string|null} + */ + getActiveTool() { + return this._activeTool; + } + + /** + * 获取当前工具选项。 + * @returns {object} + */ + getToolOptions() { + return { ...this._toolOptions }; + } + + /** + * 更新当前工具选项。 + * @param {object} patch + */ + updateToolOptions(patch) { + Object.assign(this._toolOptions, patch); + this.eventBus.emit('tool:optionsChanged', this._toolOptions); + } + + // ── 历史管理(snapshot 模式) ── + + /** + * 保存当前快照到历史栈。 + * @param {object} snapshot + */ + saveSnapshot(snapshot) { + if (this._isRestoring) return; + + this._undoStack.push(snapshot); + + if (this._undoStack.length > this.maxHistorySteps) { + this._undoStack.shift(); + } + + this._redoStack = []; + this._notifyHistory(); + } + + /** + * 撤销。 + * @param {object} currentSnapshot + * @returns {object|null} 恢复的快照,无可撤销时返回 null + */ + undo(currentSnapshot) { + if (this._undoStack.length === 0) return null; + + this._isRestoring = true; + this._redoStack.push(currentSnapshot); + const prev = this._undoStack.pop(); + this._isRestoring = false; + + this._notifyHistory(); + return prev; + } + + /** + * 重做。 + * @param {object} currentSnapshot + * @returns {object|null} 恢复的快照,无可重做时返回 null + */ + redo(currentSnapshot) { + if (this._redoStack.length === 0) return null; + + this._isRestoring = true; + this._undoStack.push(currentSnapshot); + const next = this._redoStack.pop(); + this._isRestoring = false; + + this._notifyHistory(); + return next; + } + + /** @returns {{ canUndo: boolean, canRedo: boolean, undoCount: number }} */ + getHistoryState() { + return { + canUndo: this._undoStack.length > 0, + canRedo: this._redoStack.length > 0, + undoCount: this._undoStack.length, + }; + } + + /** 清空历史栈。 */ + clearHistory() { + this._undoStack = []; + this._redoStack = []; + this._notifyHistory(); + } + + _notifyHistory() { + this.eventBus.emit('history:changed', this.getHistoryState()); + } + + // ── 导出 ── + + /** + * 生成当前画布的 dataURL(委托给 engine adapter)。 + * @param {object} [options] - { format, quality, multiplier, trimToImage } + * @returns {string|null} + */ + exportToDataURL(options = {}) { + return this.engine?.exportToDataURL(options) || null; + } + + /** + * 保存图片(委托给 host adapter)。 + * @param {Blob|string} data + * @param {string} [suggestedName] + * @returns {Promise} + */ + async saveImage(data, suggestedName) { + if (!this.host?.saveImage) return false; + return this.host.saveImage(data, suggestedName); + } + + /** + * 复制图片到剪贴板(委托给 host adapter)。 + * @param {Blob|string} data + * @returns {Promise} + */ + async copyImage(data) { + if (!this.host?.copyImage) return false; + return this.host.copyImage(data); + } + + // ── 生命周期 ── + + /** + * 销毁上下文,释放所有资源。 + */ + destroy() { + this.engine?.destroy(); + this.eventBus.clear(); + this._undoStack = []; + this._redoStack = []; + this._activeTool = null; + } +} diff --git a/plugins/Image-Toolbox/core/src/EventBus.js b/plugins/Image-Toolbox/core/src/EventBus.js new file mode 100644 index 000000000..e9ac67abe --- /dev/null +++ b/plugins/Image-Toolbox/core/src/EventBus.js @@ -0,0 +1,86 @@ +/** + * 事件总线 — 发布-订阅模式,解耦模块间通信 + * 全局单例 + */ +class EventBus { + constructor() { + this._events = {}; + this._idCounter = 0; + } + + /** + * 订阅事件 + * @param {string} event - 事件名 + * @param {Function} callback - 回调函数 + * @param {object} [context] - 回调 this 上下文 + * @returns {Function} 取消订阅函数 + */ + on(event, callback, context) { + if (!this._events[event]) { + this._events[event] = []; + } + const listener = { id: ++this._idCounter, callback, context }; + this._events[event].push(listener); + return () => this.off(event, listener.id); + } + + /** + * 一次性订阅 + * @param {string} event + * @param {Function} callback + * @param {object} [context] + */ + once(event, callback, context) { + const off = this.on(event, (...args) => { + off(); + callback.apply(context, args); + }, context); + } + + /** + * 取消订阅 + * @param {string} event + * @param {number|Function} [target] - listener id 或 callback 函数 + */ + off(event, target) { + if (!this._events[event]) return; + if (target === undefined) { + delete this._events[event]; + return; + } + this._events[event] = this._events[event].filter(listener => { + if (typeof target === 'number') return listener.id !== target; + return listener.callback !== target; + }); + } + + /** + * 发布事件 + * @param {string} event - 事件名 + * @param {...*} args - 参数 + */ + emit(event, ...args) { + if (!this._events[event]) return; + const listeners = [...this._events[event]]; // 拷贝,避免遍历时修改 + for (const listener of listeners) { + try { + listener.callback.apply(listener.context, args); + } catch (err) { + console.error(`[EventBus] "${event}" 回调执行错误:`, err); + } + } + } + + /** + * 清空所有事件 + */ + clear() { + this._events = {}; + } +} + +// 全局单例(向后兼容) +const eventBus = new EventBus(); + +export { EventBus }; +export default eventBus; diff --git a/plugins/Image-Toolbox/core/src/HistoryManager.js b/plugins/Image-Toolbox/core/src/HistoryManager.js new file mode 100644 index 000000000..f775020e3 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/HistoryManager.js @@ -0,0 +1,177 @@ +import eventBus from './EventBus.js'; + +/** + * 历史记录管理器 — 实现撤销/重做 + * 使用 Fabric.js toJSON/loadFromJSON 序列化快照 + */ +class HistoryManager { + constructor(canvasManager, maxSteps = 30) { + this._cm = canvasManager; + this.undoStack = []; + this.redoStack = []; + this.maxSteps = maxSteps; + this._enabled = true; + this._isRestoring = false; // 防止恢复时触发保存 + } + + /** + * 保存当前画布状态 + */ + saveState() { + if (!this._enabled || this._isRestoring) return; + if (!this._cm.canvas) return; + + const json = this._cm.toJSON(); + if (!json) return; + + const last = this.undoStack[this.undoStack.length - 1]; + if (last && this._isSameSnapshot(last, json)) return; + + this.undoStack.push(json); + + // 限制栈大小 + if (this.undoStack.length > this.maxSteps) { + this.undoStack.shift(); + } + + // 新操作清空重做栈 + this.redoStack = []; + + this._notify(); + } + + /** + * 撤销 + */ + async undo() { + if (!this.canUndo()) return; + if (this._isRestoring) return; // 防止恢复期间重复触发 + + this._isRestoring = true; + + const currentJson = this._cm.toJSON(); + let prevJson = this.undoStack.pop(); + + while (prevJson && currentJson && this._isSameSnapshot(prevJson, currentJson) && this.undoStack.length > 0) { + prevJson = this.undoStack.pop(); + } + + if (!prevJson || (currentJson && this._isSameSnapshot(prevJson, currentJson))) { + this._isRestoring = false; + this._notify(); + return; + } + + if (currentJson) { + this.redoStack.push(currentJson); + } + + try { + await this._restoreState(prevJson); + } catch (err) { + console.error('[HistoryManager] undo 失败:', err); + } finally { + this._isRestoring = false; + this._notify(); + } + } + + /** + * 重做 + */ + async redo() { + if (!this.canRedo()) return; + if (this._isRestoring) return; // 防止恢复期间重复触发 + + this._isRestoring = true; + + // 保存当前状态到撤销栈 + const currentJson = this._cm.toJSON(); + if (currentJson) { + this.undoStack.push(currentJson); + } + + // 恢复下一个状态 + const nextJson = this.redoStack.pop(); + try { + await this._restoreState(nextJson); + } catch (err) { + console.error('[HistoryManager] redo 失败:', err); + } finally { + this._isRestoring = false; + this._notify(); + } + } + + /** + * 是否可撤销 + * @returns {boolean} + */ + canUndo() { + return this.undoStack.length > 0; + } + + /** + * 是否可重做 + * @returns {boolean} + */ + canRedo() { + return this.redoStack.length > 0; + } + + /** + * 清空历史 + */ + clear() { + this.undoStack = []; + this.redoStack = []; + this._notify(); + } + + /** + * 启用/禁用历史记录 + * @param {boolean} enabled + */ + setEnabled(enabled) { + this._enabled = enabled; + } + + // ── 内部方法 ── + + async _restoreState(json) { + return this._cm.fromJSON(json); + } + + _notify() { + eventBus.emit('history:changed', { + canUndo: this.canUndo(), + canRedo: this.canRedo(), + undoCount: this.undoStack.length, + }); + } + + _isSameSnapshot(a, b) { + if (a === b) return true; + if (!a || !b) return false; + + try { + // 用轻量级签名对比,过滤掉图片对象的大体积 src(base64), + // 避免对大图做深度序列化导致高频保存历史(自由绘制、拖拽等)时卡顿。 + // src 被替换为「长度:首段:尾段」的内容指纹,仍可识别图片是否被替换。 + return this._snapshotSignature(a) === this._snapshotSignature(b); + } catch (err) { + return false; + } + } + + _snapshotSignature(json) { + return JSON.stringify(json, (key, value) => { + if (key === 'src' && typeof value === 'string' && value.length > 64) { + return `${value.length}:${value.slice(0, 16)}:${value.slice(-16)}`; + } + return value; + }); + } +} + +export default HistoryManager; diff --git a/plugins/Image-Toolbox/core/src/HistoryStore.js b/plugins/Image-Toolbox/core/src/HistoryStore.js new file mode 100644 index 000000000..8c4a30fe3 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/HistoryStore.js @@ -0,0 +1,156 @@ +/** + * HistoryStore — 纯状态历史管理 + * + * 零 DOM、零 fabric、零平台依赖。 + * 通过外部传入的 snapshotProvider / snapshotRestorer 与引擎交互。 + * + * @example + * const store = new HistoryStore({ + * maxSteps: 30, + * snapshotProvider: () => canvas.toJSON(), + * snapshotRestorer: (json) => canvas.loadFromJSON(json), + * eventBus, + * }); + */ +export default class HistoryStore { + /** + * @param {object} options + * @param {number} [options.maxSteps=30] + * @param {function} options.snapshotProvider - () => snapshot(纯 JSON,平台无关) + * @param {function} options.snapshotRestorer - (snapshot) => Promise + * @param {import('./EventBus.js').EventBus} [options.eventBus] + */ + constructor({ maxSteps = 30, snapshotProvider, snapshotRestorer, eventBus = null } = {}) { + if (typeof snapshotProvider !== 'function') { + throw new Error('[HistoryStore] snapshotProvider 必须是函数'); + } + if (typeof snapshotRestorer !== 'function') { + throw new Error('[HistoryStore] snapshotRestorer 必须是函数'); + } + + this._snapshotProvider = snapshotProvider; + this._snapshotRestorer = snapshotRestorer; + this._eventBus = eventBus; + + this._undoStack = []; + this._redoStack = []; + this._maxSteps = maxSteps; + this._enabled = true; + this._isRestoring = false; + } + + // ── 保存 ── + + /** + * 保存当前快照到历史栈。 + */ + save() { + if (!this._enabled || this._isRestoring) return; + + const snapshot = this._snapshotProvider(); + if (!snapshot) return; + + this._undoStack.push(snapshot); + + if (this._undoStack.length > this._maxSteps) { + this._undoStack.shift(); + } + + this._redoStack = []; + this._notify(); + } + + // ── 撤销 / 重做 ── + + /** + * 撤销。 + * @returns {Promise} 是否成功恢复 + */ + async undo() { + if (!this.canUndo()) return false; + + this._isRestoring = true; + + const current = this._snapshotProvider(); + if (current) { + this._redoStack.push(current); + } + + const prev = this._undoStack.pop(); + let ok = false; + try { + await this._snapshotRestorer(prev); + ok = true; + } catch (err) { + console.error('[HistoryStore] 撤销恢复失败:', err); + } + + this._isRestoring = false; + this._notify(); + return ok; + } + + /** + * 重做。 + * @returns {Promise} 是否成功恢复 + */ + async redo() { + if (!this.canRedo()) return false; + + this._isRestoring = true; + + const current = this._snapshotProvider(); + if (current) { + this._undoStack.push(current); + } + + const next = this._redoStack.pop(); + let ok = false; + try { + await this._snapshotRestorer(next); + ok = true; + } catch (err) { + console.error('[HistoryStore] 重做恢复失败:', err); + } + + this._isRestoring = false; + this._notify(); + return ok; + } + + // ── 状态查询 ── + + canUndo() { return this._undoStack.length > 0; } + canRedo() { return this._redoStack.length > 0; } + + getState() { + return { + canUndo: this.canUndo(), + canRedo: this.canRedo(), + undoCount: this._undoStack.length, + redoCount: this._redoStack.length, + }; + } + + // ── 控制 ── + + setEnabled(enabled) { this._enabled = !!enabled; } + isEnabled() { return this._enabled; } + + clear() { + this._undoStack = []; + this._redoStack = []; + this._notify(); + } + + setMaxSteps(max) { this._maxSteps = Math.max(1, max); } + getMaxSteps() { return this._maxSteps; } + + // ── 内部 ── + + _notify() { + if (this._eventBus) { + this._eventBus.emit('history:changed', this.getState()); + } + } +} diff --git a/plugins/Image-Toolbox/core/src/LayerManager.js b/plugins/Image-Toolbox/core/src/LayerManager.js new file mode 100644 index 000000000..276d20702 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/LayerManager.js @@ -0,0 +1,596 @@ +import eventBus from './EventBus.js'; + +/** + * 图层管理器 — 管理 Fabric.js 物件的 z-order、显隐、锁定 + * 每个 Fabric Object 即为一个"图层" + */ +class LayerManager { + constructor(canvasManager) { + this._cm = canvasManager; + this._cm.layerManager = this; + this._layers = []; // 图层元数据 [{ id, name, visible, locked, fabricObj }] + this._idCounter = 0; + } + + /** + * 同步图层列表(从画布物件重建) + */ + syncLayers() { + const canvas = this._cm.canvas; + if (!canvas) return; + + const objects = canvas.getObjects(); + const oldLayers = this._layers; // 保留旧列表用于查找已有元数据 + const currentObjects = new Set(objects); + const newLayers = []; + + // 先处理非背景图层:从后往前(画布中后面的是上层 → 放在面板顶部) + for (let i = objects.length - 1; i >= 0; i--) { + const obj = objects[i]; + // 背景图层单独处理 + if (obj === this._cm.originalImage) continue; + + // 跳过标记为不显示在图层面板的对象(如裁剪遮罩/裁剪框) + if (obj.excludeFromLayer) continue; + + // 在旧列表中查找已有元数据(优先按对象引用,替换对象时再按稳定 id 匹配) + let meta = oldLayers.find(l => l.fabricObj === obj) || null; + if (!meta && obj.id) { + meta = oldLayers.find(l => !newLayers.includes(l) && l.fabricObj?.id === obj.id) || null; + } + if (!meta) { + meta = this._createMeta(obj, false, newLayers, currentObjects); + } else { + meta.fabricObj = obj; + this._refreshMetaName(meta, obj, false, newLayers, currentObjects); + } + this._refreshMetaState(meta, obj, false); + meta.zIndex = objects.length - 1 - i; + newLayers.push(meta); + } + + // 背景图层始终在列表末尾(面板最底部) + if (this._cm.originalImage) { + this._ensureBackgroundSelectable(this._cm.originalImage); + let bgMeta = oldLayers.find(l => l.fabricObj === this._cm.originalImage) || null; + if (!bgMeta) { + bgMeta = this._createMeta(this._cm.originalImage, true, newLayers, currentObjects); + } else { + bgMeta.fabricObj = this._cm.originalImage; + this._refreshMetaName(bgMeta, this._cm.originalImage, true, newLayers, currentObjects); + } + this._refreshMetaState(bgMeta, this._cm.originalImage, true); + bgMeta.zIndex = 0; + newLayers.push(bgMeta); + } + + this._layers = newLayers; + eventBus.emit('layers:updated', this._layers); + } + + /** + * 获取图层列表 + * @returns {Array} + */ + getLayers() { + return this._layers; + } + + /** + * 根据 ID 查找图层元数据 + * @param {number} layerId + * @returns {object|null} + */ + getLayerById(layerId) { + return this._layers.find(l => l.id === layerId) || null; + } + + /** + * 根据 fabricObj 查找图层元数据 + * @param {fabric.Object} obj + * @returns {object|null} + */ + _findMeta(obj) { + return this._layers.find(l => l.fabricObj === obj) || null; + } + + /** + * 根据 Fabric 对象获取图层元数据 + * @param {fabric.Object} obj + * @returns {object|null} + */ + getLayerByObject(obj) { + return this._findMeta(obj); + } + + _createMeta(obj, isBackground = false, newLayers = null, currentObjects = null) { + const id = ++this._idCounter; + const nameInfo = this._resolveLayerName(obj, isBackground, newLayers, null, currentObjects); + + const meta = { + id, + name: nameInfo.name, + visible: true, + locked: false, + fabricObj: obj, + zIndex: 0, + isBackground, + }; + + this._refreshMetaState(meta, obj, isBackground); + + if (!isBackground) { + this._setObjectLayerName(obj, meta.name, nameInfo.auto, nameInfo.baseName); + } + + return meta; + } + + _refreshMetaState(meta, obj, isBackground = false) { + meta.visible = obj.visible !== false; + meta.locked = this._resolveLockedState(obj, isBackground); + meta.isBackground = !!isBackground; + } + + _resolveLockedState(obj, isBackground = false) { + if (isBackground) return true; + if (typeof obj?._layerLocked === 'boolean') return obj._layerLocked; + + // 旧版本把工具激活期间创建的图层也标成 selectable/evented=false。 + // 没有明确锁定标记时按未锁定处理,避免移动/框选工具无法选中这些图层。 + return false; + } + + _ensureBackgroundSelectable(obj) { + if (!obj) return; + + obj._originalImage = true; + obj.set({ + selectable: true, + evented: true, + hasControls: true, + hasBorders: true, + lockMovementX: false, + lockMovementY: false, + lockRotation: false, + lockScalingX: false, + lockScalingY: false, + }); + obj.setCoords(); + } + + _refreshMetaName(meta, obj, isBackground = false, newLayers = null, currentObjects = null) { + const nameInfo = this._resolveLayerName(obj, isBackground, newLayers, meta, currentObjects); + meta.name = nameInfo.name; + + if (!isBackground) { + this._setObjectLayerName(obj, meta.name, nameInfo.auto, nameInfo.baseName); + } + } + + _resolveLayerName(obj, isBackground = false, newLayers = null, currentMeta = null, currentObjects = null) { + if (isBackground) { + return { name: '背景', auto: false, baseName: '背景' }; + } + + const savedName = this._getObjectLayerName(obj); + const isAutoName = obj?._layerNameAuto === true + || !savedName + || (obj?._layerNameAuto !== false && this._isLegacyDefaultLayerName(savedName, obj)); + if (!isAutoName && savedName) { + return { name: savedName, auto: false, baseName: '' }; + } + + const baseName = this._getDefaultLayerBaseName(obj); + if (savedName + && obj?._layerBaseName === baseName + && !this._isLayerNameUsed(savedName, newLayers, currentMeta, currentObjects)) { + return { name: savedName, auto: true, baseName }; + } + + return { + name: this._getUniqueDefaultLayerName(baseName, newLayers, currentMeta, currentObjects), + auto: true, + baseName, + }; + } + + _getDefaultLayerBaseName(obj) { + if (this._isTextLayerObject(obj)) { + const text = this._normalizeLayerText(obj.text); + return text ? `文字 - ${text}` : '文字'; + } + + if (this._isBrushLayerObject(obj)) { + return this._joinLayerNameParts( + '画笔', + obj._layerColorPresetName || this._getBrushColorPresetName(obj.stroke), + obj._layerWidthPresetName || this._getBrushWidthPresetName(obj.strokeWidth) + ); + } + + if (this._isMosaicLayerObject(obj)) { + return this._joinLayerNameParts('马赛克', obj._layerPresetName || this._getMosaicPresetName(obj)); + } + + // 按对象功能命名(不是按 Fabric type 字面翻译) + const funcLabelMap = { + 'image': '马赛克', // 非背景图片 = 马赛克覆盖层 + 'i-text': '文字', + 'textbox': '文字', + 'text': '文字', + 'rect': '矩形', + 'circle': '圆形', + 'path': '涂鸦', + 'group': '组合', + }; + + return funcLabelMap[obj?.type] || '图层'; + } + + _getUniqueDefaultLayerName(baseName, newLayers = null, currentMeta = null, currentObjects = null) { + let name = baseName; + let index = 2; + + while (this._isLayerNameUsed(name, newLayers, currentMeta, currentObjects)) { + name = `${baseName} - ${index}`; + index++; + } + + return name; + } + + _isLayerNameUsed(name, newLayers = null, currentMeta = null, currentObjects = null) { + const layers = [...(newLayers || []), ...this._layers]; + return layers.some(layer => { + if (!layer || layer === currentMeta || layer.name !== name) return false; + if (!currentObjects || !layer.fabricObj) return true; + return currentObjects.has(layer.fabricObj); + }); + } + + _isTextLayerObject(obj) { + return obj?.type === 'i-text' || obj?.type === 'text' || obj?.type === 'textbox'; + } + + _isBrushLayerObject(obj) { + return obj?._layerKind === 'brush' + || (typeof obj?.id === 'string' && obj.id.startsWith('brush_')); + } + + _isMosaicLayerObject(obj) { + return obj?._layerKind === 'mosaic' + || obj?._mosaicDynamic === true + || (typeof obj?.id === 'string' && obj.id.startsWith('mosaic_')); + } + + _isLegacyDefaultLayerName(name, obj) { + const label = this._getLegacyTypeLabel(obj); + if (!label) return false; + + return new RegExp(`^${this._escapeRegExp(label)}-\\d+$`).test(name); + } + + _getLegacyTypeLabel(obj) { + const legacyLabelMap = { + 'image': '马赛克', + 'i-text': '文字', + 'textbox': '文字', + 'text': '文字', + 'rect': '矩形', + 'circle': '圆形', + 'path': '涂鸦', + 'group': '组合', + }; + + return legacyLabelMap[obj?.type] || '图层'; + } + + _getBrushColorPresetName(color) { + const colorMap = { + '#d83b31': '红', + '#1677ff': '蓝', + '#ffd700': '黄', + '#2ead4a': '绿', + '#ffffff': '白', + '#111111': '黑', + }; + + return colorMap[this._normalizeColor(color)] || ''; + } + + _getBrushWidthPresetName(width) { + const widthMap = { + 3: '细', + 6: '中', + 12: '粗', + 24: '特粗', + }; + + return widthMap[Math.round(Number(width))] || ''; + } + + _getMosaicPresetName(obj) { + if ((obj?._mosaicMode || 'mosaic') === 'blur') { + const blurMap = { + 6: '轻模糊', + 12: '中模糊', + 18: '强模糊', + }; + return blurMap[Math.round(Number(obj._mosaicBlurRadius))] || ''; + } + + const mosaicMap = { + 6: '轻马赛克', + 12: '中马赛克', + 24: '重马赛克', + }; + return mosaicMap[Math.round(Number(obj?._mosaicSize))] || ''; + } + + _normalizeColor(color) { + if (typeof color !== 'string') return ''; + + const value = color.trim().toLowerCase(); + if (/^#[0-9a-f]{6}$/i.test(value)) return value; + if (/^#[0-9a-f]{3}$/i.test(value)) { + return '#' + value.slice(1).split('').map(ch => ch + ch).join(''); + } + + return ''; + } + + _escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + _joinLayerNameParts(baseName, ...parts) { + return [baseName, ...parts] + .map(part => String(part || '').trim()) + .filter(Boolean) + .join(' - '); + } + + _normalizeLayerText(text) { + return String(text || '').replace(/\s+/g, ' ').trim(); + } + + _getObjectLayerName(obj) { + if (typeof obj?._layerName !== 'string') return ''; + + return obj._layerName.trim() ? obj._layerName : ''; + } + + _setObjectLayerName(obj, name, auto = false, baseName = '') { + if (!obj || typeof name !== 'string' || !name.trim()) return; + + obj._layerName = name; + obj._layerNameAuto = !!auto; + obj._layerBaseName = auto ? (baseName || name) : ''; + } + + /** + * 切换图层可见性 + * @param {number} layerId + */ + toggleVisibility(layerId) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta) return; + + this.setVisibility(layerId, !meta.visible); + } + + /** + * 设置图层可见性 + * @param {number} layerId + * @param {boolean} visible + */ + setVisibility(layerId, visible) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta) return; + + meta.visible = !!visible; + meta.fabricObj.set({ visible: meta.visible }); + this._cm.canvas.renderAll(); + eventBus.emit('layers:updated', this._layers); + eventBus.emit('layer:visibilityChanged', meta); + } + + /** + * 切换图层锁定 + * @param {number} layerId + */ + toggleLock(layerId) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta || meta.isBackground) return; + + this.setLock(layerId, !meta.locked); + } + + /** + * 设置图层锁定状态 + * @param {number} layerId + * @param {boolean} locked + */ + setLock(layerId, locked) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta || meta.isBackground) return; + + meta.locked = !!locked; + meta.fabricObj._layerLocked = meta.locked; + meta.fabricObj.set({ + selectable: !meta.locked, + evented: !meta.locked, + }); + if (meta.locked && this._cm.canvas.getActiveObject() === meta.fabricObj) { + this._cm.canvas.discardActiveObject(); + } + this._cm.canvas.renderAll(); + eventBus.emit('layers:updated', this._layers); + eventBus.emit('layer:lockChanged', meta); + } + + /** + * 选中图层 + * @param {number} layerId + */ + selectLayer(layerId) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta) return; + + // 即使图层被锁定也触发事件(让橡皮擦等工具能响应图层切换)。 + // 普通锁定图层不调用 setActiveObject(避免误操作); + // 但背景图层允许选中和变换;图层锁定只限制删除、改名和排序。 + if (!meta.locked || meta.isBackground) { + if (meta.isBackground) { + this._ensureBackgroundSelectable(meta.fabricObj); + } + this._cm.canvas.setActiveObject(meta.fabricObj); + this._cm.canvas.renderAll(); + } + eventBus.emit('layer:selected', meta); + } + + /** + * 删除图层 + * @param {number} layerId + */ + deleteLayer(layerId) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta || meta.locked || meta.isBackground) return; + + this._cm.canvas.remove(meta.fabricObj); + this._layers = this._layers.filter(l => l.id !== layerId); + this._cm.canvas.renderAll(); + eventBus.emit('layers:updated', this._layers); + } + + /** + * 图层上移 + * @param {number} layerId + */ + moveUp(layerId) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta || meta.isBackground) return; + + this._cm.canvas.bringForward(meta.fabricObj); + this._cm.canvas.renderAll(); + this.syncLayers(); + } + + /** + * 图层下移 + * @param {number} layerId + */ + moveDown(layerId) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta || meta.isBackground) return; + + this._cm.canvas.sendBackwards(meta.fabricObj); + this._cm.canvas.renderAll(); + this.syncLayers(); + } + + /** + * 置顶 + * @param {number} layerId + */ + bringToFront(layerId) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta || meta.isBackground) return; + + this._cm.canvas.bringToFront(meta.fabricObj); + this._cm.canvas.renderAll(); + this.syncLayers(); + } + + /** + * 置底(在原图之上) + * @param {number} layerId + */ + sendToBack(layerId) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta || meta.isBackground) return; + + const objects = this._cm.canvas.getObjects(); + const imgIndex = objects.indexOf(this._cm.originalImage); + this._cm.canvas.moveTo(meta.fabricObj, imgIndex + 1); + this._cm.canvas.renderAll(); + this.syncLayers(); + } + + /** + * 按图层面板位置重排图层(0 = 顶部,背景图层固定在底部) + * @param {number} layerId + * @param {number} targetPanelIndex + * @returns {boolean} 是否发生了重排 + */ + reorderLayer(layerId, targetPanelIndex) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta || meta.isBackground || !this._cm.canvas) return false; + + const overlayLayers = this._layers.filter(l => !l.isBackground); + const fromIndex = overlayLayers.findIndex(l => l.id === layerId); + if (fromIndex === -1 || overlayLayers.length < 2) return false; + + let insertIndex = Math.max(0, Math.min(targetPanelIndex, overlayLayers.length)); + if (fromIndex < insertIndex) insertIndex -= 1; + if (insertIndex === fromIndex) return false; + + const [movedLayer] = overlayLayers.splice(fromIndex, 1); + overlayLayers.splice(insertIndex, 0, movedLayer); + + eventBus.emit('layer:reorderWillChange', { + layer: movedLayer, + fromIndex, + toIndex: insertIndex, + }); + + this._applyPanelOrder(overlayLayers); + this._cm.canvas.renderAll(); + this.syncLayers(); + + eventBus.emit('layer:reordered', { + layer: movedLayer, + fromIndex, + toIndex: insertIndex, + }); + + return true; + } + + _applyPanelOrder(overlayLayers) { + const canvas = this._cm.canvas; + const objects = canvas.getObjects(); + const backgroundIndex = objects.indexOf(this._cm.originalImage); + const startIndex = backgroundIndex >= 0 ? backgroundIndex + 1 : 0; + const bottomToTopLayers = overlayLayers.slice().reverse(); + + bottomToTopLayers.forEach((layer, index) => { + canvas.moveTo(layer.fabricObj, startIndex + index); + }); + } + + /** + * 图层重命名 + * @param {number} layerId + * @param {string} newName + */ + renameLayer(layerId, newName) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta) return; + meta.name = newName; + this._setObjectLayerName(meta.fabricObj, newName, false); + eventBus.emit('layers:updated', this._layers); + } + + /** + * 获取图层数量 + * @returns {number} + */ + getCount() { + return this._layers.length; + } +} + +export default LayerManager; diff --git a/plugins/Image-Toolbox/core/src/LayerStore.js b/plugins/Image-Toolbox/core/src/LayerStore.js new file mode 100644 index 000000000..ff5f975b7 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/LayerStore.js @@ -0,0 +1,292 @@ +/** + * LayerStore — 纯数据图层模型 + * + * 零 DOM、零 fabric、零平台依赖。 + * 图层用 engineId 关联渲染引擎对象,不保存任何引擎对象引用。 + * + * @example + * const store = new LayerStore({ eventBus }); + * store.addLayer({ engineId: 'obj_1', type: 'brush', name: '画笔' }); + * store.getLayers(); // [{ id: 1, engineId: 'obj_1', ... }] + */ +let _globalIdCounter = 0; + +export default class LayerStore { + /** + * @param {object} options + * @param {import('./EventBus.js').EventBus} [options.eventBus] + */ + constructor({ eventBus = null } = {}) { + this._eventBus = eventBus; + this._layers = []; + this._idCounter = 0; + } + + // ── 查询 ── + + /** @returns {LayerModel[]} */ + getLayers() { return this._layers.slice(); } + + /** @returns {number} */ + getCount() { return this._layers.length; } + + /** + * @param {number} layerId + * @returns {LayerModel|null} + */ + getById(layerId) { + return this._layers.find(l => l.id === layerId) || null; + } + + /** + * @param {string} engineId + * @returns {LayerModel|null} + */ + getByEngineId(engineId) { + return this._layers.find(l => l.engineId === engineId) || null; + } + + /** + * 获取非背景图层列表(面板用,顶层在前)。 + * @returns {LayerModel[]} + */ + getOverlayLayers() { + return this._layers.filter(l => !l.isBackground); + } + + /** + * 获取背景图层。 + * @returns {LayerModel|null} + */ + getBackground() { + return this._layers.find(l => l.isBackground) || null; + } + + // ── 增删 ── + + /** + * 添加图层。 + * @param {object} input - { engineId, type, name, visible, locked, opacity, metadata } + * @returns {LayerModel} + */ + addLayer(input) { + const id = ++this._idCounter; + const layer = { + id, + engineId: input.engineId || '', + name: input.name || this._defaultName(input.type), + type: input.type || 'image', + visible: input.visible !== false, + locked: !!input.locked, + opacity: typeof input.opacity === 'number' ? input.opacity : 1, + isBackground: !!input.isBackground, + zIndex: 0, + metadata: input.metadata ? { ...input.metadata } : {}, + }; + + this._layers.push(layer); + this._renumberZIndex(); + this._notify('layers:added', layer); + return { ...layer }; + } + + /** + * 删除图层。 + * @param {number} layerId + * @returns {boolean} + */ + removeLayer(layerId) { + const idx = this._layers.findIndex(l => l.id === layerId); + if (idx === -1) return false; + + const [removed] = this._layers.splice(idx, 1); + this._renumberZIndex(); + this._notify('layers:removed', removed); + return true; + } + + // ── 修改 ── + + /** + * 更新图层属性。 + * @param {number} layerId + * @param {object} patch - { name?, visible?, locked?, opacity?, metadata? } + * @returns {LayerModel|null} + */ + updateLayer(layerId, patch) { + const layer = this.getById(layerId); + if (!layer) return null; + + if (patch.name !== undefined) layer.name = patch.name; + if (patch.visible !== undefined) layer.visible = !!patch.visible; + if (patch.locked !== undefined) layer.locked = !!patch.locked; + if (patch.opacity !== undefined) layer.opacity = patch.opacity; + if (patch.engineId !== undefined) layer.engineId = patch.engineId; + if (patch.metadata !== undefined) { + layer.metadata = { ...layer.metadata, ...patch.metadata }; + } + + this._notify('layers:updated', layer); + return { ...layer }; + } + + /** + * 切换可见性。 + * @param {number} layerId + * @returns {LayerModel|null} + */ + toggleVisibility(layerId) { + const layer = this.getById(layerId); + if (!layer) return null; + return this.updateLayer(layerId, { visible: !layer.visible }); + } + + /** + * 切换锁定。 + * @param {number} layerId + * @returns {LayerModel|null} + */ + toggleLock(layerId) { + const layer = this.getById(layerId); + if (!layer || layer.isBackground) return null; + return this.updateLayer(layerId, { locked: !layer.locked }); + } + + // ── 排序 ── + + /** + * 将图层移到目标面板位置(0 = 顶部,背景图层固定在底部)。 + * @param {number} layerId + * @param {number} targetPanelIndex + * @returns {boolean} + */ + reorder(layerId, targetPanelIndex) { + const layer = this.getById(layerId); + if (!layer || layer.isBackground) return false; + + const overlays = this.getOverlayLayers(); + const fromIndex = overlays.findIndex(l => l.id === layerId); + if (fromIndex === -1 || overlays.length < 2) return false; + + let insertIndex = Math.max(0, Math.min(targetPanelIndex, overlays.length)); + if (fromIndex < insertIndex) insertIndex -= 1; + if (insertIndex === fromIndex) return false; + + // 从 _layers 中取出并插入 + const globalFrom = this._layers.indexOf(layer); + this._layers.splice(globalFrom, 1); + + // 找到插入位置的全局索引 + const targetOverlay = overlays[insertIndex]; + const globalTo = targetOverlay + ? this._layers.indexOf(targetOverlay) + : this._layers.length; + this._layers.splice(globalTo, 0, layer); + + this._renumberZIndex(); + this._notify('layers:reordered', { layer, fromIndex, toIndex: insertIndex }); + return true; + } + + /** + * 图层上移(向顶部方向)。 + * @param {number} layerId + */ + moveUp(layerId) { + const overlays = this.getOverlayLayers(); + const idx = overlays.findIndex(l => l.id === layerId); + if (idx <= 0) return; + this.reorder(layerId, idx - 1); + } + + /** + * 图层下移(向底部方向)。 + * @param {number} layerId + */ + moveDown(layerId) { + const overlays = this.getOverlayLayers(); + const idx = overlays.findIndex(l => l.id === layerId); + if (idx === -1 || idx >= overlays.length - 1) return; + this.reorder(layerId, idx + 2); + } + + /** + * 置顶。 + * @param {number} layerId + */ + bringToFront(layerId) { this.reorder(layerId, 0); } + + /** + * 置底(在背景之上)。 + * @param {number} layerId + */ + sendToBack(layerId) { + const overlays = this.getOverlayLayers(); + this.reorder(layerId, overlays.length - 1); + } + + // ── 重命名 ── + + /** + * @param {number} layerId + * @param {string} newName + * @returns {LayerModel|null} + */ + rename(layerId, newName) { + return this.updateLayer(layerId, { name: newName }); + } + + // ── 序列化 ── + + /** + * 导出为纯数据。 + * @returns {LayerModel[]} + */ + toJSON() { + return this._layers.map(l => ({ ...l, metadata: { ...l.metadata } })); + } + + /** + * 从纯数据恢复。 + * @param {LayerModel[]} data + */ + fromJSON(data) { + if (!Array.isArray(data)) return; + this._layers = data.map(l => ({ + ...l, + metadata: l.metadata ? { ...l.metadata } : {}, + })); + this._idCounter = this._layers.reduce((max, l) => Math.max(max, l.id || 0), 0); + this._renumberZIndex(); + this._notify('layers:restored'); + } + + // ── 内部 ── + + _renumberZIndex() { + let z = this._layers.length; + for (const layer of this._layers) { + layer.zIndex = z--; + } + } + + _defaultName(type) { + const names = { + background: '背景', + image: '图片', + text: '文字', + brush: '画笔', + mosaic: '马赛克', + shape: '形状', + group: '组合', + }; + return names[type] || '图层'; + } + + _notify(event, ...args) { + if (this._eventBus) { + this._eventBus.emit(event, ...args); + this._eventBus.emit('layers:changed', this._layers); + } + } +} diff --git a/plugins/Image-Toolbox/core/src/ToolManager.js b/plugins/Image-Toolbox/core/src/ToolManager.js new file mode 100644 index 000000000..b4a916a3b --- /dev/null +++ b/plugins/Image-Toolbox/core/src/ToolManager.js @@ -0,0 +1,242 @@ +import eventBus from './EventBus.js'; +import SelectModule from './modules/SelectModule.js'; +import MosaicModule from './modules/MosaicModule.js'; +import CropModule from './modules/CropModule.js'; +import ColorModule from './modules/ColorModule.js'; +import BrushModule from './modules/BrushModule.js'; +import EraserModule from './modules/EraserModule.js'; +import TextModule from './modules/TextModule.js'; +import ShapeModule from './modules/ShapeModule.js'; +import ExportModule from './modules/ExportModule.js'; + +/** + * 工具管理器 — 管理工具栏状态和工具切换 + * + * 支持两种使用方式: + * 1. 默认:自动注册内置工具(向后兼容) + * 2. 注入:通过 tools 数组外部传入工具定义列表 + */ +class ToolManager { + /** + * @param {import('./CanvasManager.js').default} canvasManager + * @param {import('./HistoryManager.js').default} historyManager + * @param {object} [options] + * @param {Array} [options.tools] - 外部注入的工具定义列表 + * @param {object} [options.host] + */ + constructor(canvasManager, historyManager, options = {}) { + this._cm = canvasManager; + this._hm = historyManager; + this._modules = {}; + this._currentTool = null; + this._tools = []; + this._host = options.host || null; + + if (options.tools) { + // 外部注入模式:注册传入的工具,不再硬编码内置模块 + options.tools.forEach(t => this.registerTool(t)); + } else { + // 默认模式:注册内置模块(向后兼容) + this._registerBuiltinModules(); + } + + // ExportModule 始终单独实例化,不显示在工具栏 + this._modules['export'] = new ExportModule(this._cm, this._hm, {}, this._host); + } + + /** + * 注入 host adapter。 + * @param {object} host + */ + setHost(host) { + this._host = host; + this._modules['export']?.setHost(host); + } + + /** + * 注册内置模块 + */ + _registerBuiltinModules() { + this.registerTool({ + name: 'select', + label: '移动/框选', + icon: 'select', + group: 'edit', + shortcut: 'V', + module: SelectModule, + }); + + this.registerTool({ + name: 'mosaic', + label: '马赛克', + icon: 'mosaic', + group: 'redact', + shortcut: 'M', + module: MosaicModule, + defaultOptions: { mode: 'mosaic', drawMode: 'rect', mosaicSize: 12, blurRadius: 8, brushSize: 20 }, + }); + + this.registerTool({ + name: 'crop', + label: '剪切', + icon: 'crop', + group: 'edit', + shortcut: 'C', + module: CropModule, + }); + + this.registerTool({ + name: 'color', + label: '调色', + icon: 'color', + group: 'adjust', + shortcut: 'A', + module: ColorModule, + defaultOptions: { filterScope: 'all' }, + }); + + this.registerTool({ + name: 'brush', + label: '画笔', + icon: 'brush', + group: 'annotate', + shortcut: 'B', + module: BrushModule, + defaultOptions: { color: '#d83b31', width: 6 }, + }); + + this.registerTool({ + name: 'eraser', + label: '橡皮擦', + icon: 'eraser', + group: 'annotate', + shortcut: 'E', + module: EraserModule, + defaultOptions: { width: 20 }, + }); + + this.registerTool({ + name: 'text', + label: '文字标注', + icon: 'text', + group: 'annotate', + shortcut: 'T', + module: TextModule, + }); + + this.registerTool({ + name: 'shape', + label: '图形', + icon: 'shape', + group: 'annotate', + shortcut: 'S', + module: ShapeModule, + defaultOptions: { shapeType: 'rect', fill: 'transparent', stroke: 'rgba(216, 59, 49, 1)', strokeWidth: 2 }, + }); + } + + /** + * 注册工具 + * @param {object} toolDef - { name, label, icon, group, shortcut, module, defaultOptions } + */ + registerTool(toolDef) { + this._tools.push(toolDef); + + // 如果有模块类,实例化 + if (toolDef.module) { + this._modules[toolDef.name] = new toolDef.module(this._cm, this._hm, toolDef.defaultOptions); + } + } + + /** + * 获取所有工具定义 + * @returns {Array} + */ + getTools() { + return this._tools; + } + + /** + * 激活工具 + * @param {string} toolName + * @param {object} [options] + */ + activateTool(toolName, options = {}) { + // 停用当前工具 + if (this._currentTool && this._modules[this._currentTool]) { + this._modules[this._currentTool].deactivate(); + } + + const tool = this._tools.find(t => t.name === toolName); + if (!tool) { + console.warn(`[ToolManager] 未知工具: ${toolName}`); + this._currentTool = null; + eventBus.emit('tool:changed', null); + return; + } + + this._currentTool = toolName; + + // 激活新工具的模块 + const module = this._modules[toolName]; + if (module) { + module.activate(options); + } + + eventBus.emit('tool:changed', toolName); + } + + /** + * 获取当前工具名 + * @returns {string|null} + */ + getCurrentTool() { + return this._currentTool; + } + + /** + * 获取当前模块实例 + * @returns {BaseModule|null} + */ + getCurrentModule() { + return this._currentTool ? this._modules[this._currentTool] : null; + } + + /** + * 获取模块实例 + * @param {string} name + * @returns {BaseModule|null} + */ + getModule(name) { + return this._modules[name] || null; + } + + /** + * 执行导出 + * @param {string} action - 'file' | 'clipboard' + */ + async export(action = 'file') { + const exportModule = this._modules['export']; + if (!exportModule) return; + + if (action === 'clipboard') { + await exportModule.exportToClipboard(); + } else { + await exportModule.exportToFile(); + } + } + + /** + * 销毁所有模块 + */ + destroy() { + Object.values(this._modules).forEach(m => { + if (m.deactivate) m.deactivate(); + if (m.destroy) m.destroy(); + }); + this._modules = {}; + this._currentTool = null; + } +} + +export default ToolManager; diff --git a/plugins/Image-Toolbox/core/src/ToolRegistry.js b/plugins/Image-Toolbox/core/src/ToolRegistry.js new file mode 100644 index 000000000..b09c50cd7 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/ToolRegistry.js @@ -0,0 +1,268 @@ +/** + * ToolRegistry — 纯工具注册 / 切换 / 状态管理 + * + * 零 DOM、零 fabric、零平台依赖。 + * 只管理工具定义和当前激活状态,实际工具逻辑由外部 handler 实现。 + * + * @example + * const registry = new ToolRegistry({ eventBus }); + * registry.register({ name: 'brush', label: '画笔', shortcut: 'B', handler: myBrushHandler }); + * registry.activate('brush'); + * registry.getActive(); // 'brush' + */ +export default class ToolRegistry { + /** + * @param {object} options + * @param {import('./EventBus.js').EventBus} [options.eventBus] + */ + constructor({ eventBus = null } = {}) { + this._eventBus = eventBus; + this._tools = new Map(); // name -> ToolDefinition + this._activeTool = null; + this._toolOptions = {}; // name -> options + } + + // ── 注册 ── + + /** + * 注册工具。 + * @param {ToolDefinition} def - { name, label, group?, shortcut?, icon?, defaultOptions?, controls?, presets?, handler? } + */ + register(def) { + if (!def || !def.name) { + throw new Error('[ToolRegistry] 工具定义必须包含 name'); + } + + this._tools.set(def.name, { + name: def.name, + label: def.label || def.name, + group: def.group || 'edit', + shortcut: def.shortcut || null, + icon: def.icon || def.name, + defaultOptions: def.defaultOptions ? { ...def.defaultOptions } : {}, + controls: def.controls || [], + presets: def.presets || [], + handler: def.handler || null, + }); + + if (!this._toolOptions[def.name]) { + this._toolOptions[def.name] = { ...(def.defaultOptions || {}) }; + } + } + + /** + * 批量注册。 + * @param {ToolDefinition[]} defs + */ + registerAll(defs) { + defs.forEach(d => this.register(d)); + } + + /** + * 注销工具。 + * @param {string} name + */ + unregister(name) { + this._tools.delete(name); + delete this._toolOptions[name]; + if (this._activeTool === name) { + this._activeTool = null; + this._emit('tool:changed', { toolName: null }); + } + } + + // ── 查询 ── + + /** + * 获取所有已注册工具。 + * @returns {ToolDefinition[]} + */ + getAll() { + return Array.from(this._tools.values()); + } + + /** + * 按组获取工具。 + * @param {string} group + * @returns {ToolDefinition[]} + */ + getByGroup(group) { + return this.getAll().filter(t => t.group === group); + } + + /** + * 获取单个工具定义。 + * @param {string} name + * @returns {ToolDefinition|null} + */ + get(name) { + return this._tools.get(name) || null; + } + + /** + * 根据快捷键查找工具。 + * @param {string} key - 按键值(如 'B', 'V', 'M') + * @returns {ToolDefinition|null} + */ + getByShortcut(key) { + const upper = key.toUpperCase(); + return this.getAll().find(t => t.shortcut === upper) || null; + } + + /** + * 工具是否已注册。 + * @param {string} name + * @returns {boolean} + */ + has(name) { + return this._tools.has(name); + } + + // ── 激活 ── + + /** + * 激活工具。 + * @param {string} name + * @param {object} [options] - 运行时选项(合并到默认选项上) + * @returns {boolean} 是否成功激活 + */ + activate(name, options = {}) { + const def = this._tools.get(name); + if (!def) { + console.warn(`[ToolRegistry] 未知工具: ${name}`); + return false; + } + + // 通知当前工具停用 + const prevName = this._activeTool; + if (prevName && prevName !== name) { + const prev = this._tools.get(prevName); + if (prev?.handler?.deactivate) { + try { prev.handler.deactivate(); } catch (e) { console.error('[ToolRegistry]', e); } + } + this._emit('tool:deactivated', { toolName: prevName }); + } + + this._activeTool = name; + + // 合并选项 + this._toolOptions[name] = { + ...def.defaultOptions, + ...options, + }; + + // 通知新工具激活 + if (def.handler?.activate) { + try { def.handler.activate(this._toolOptions[name]); } catch (e) { console.error('[ToolRegistry]', e); } + } + + this._emit('tool:changed', { + toolName: name, + options: { ...this._toolOptions[name] }, + }); + + return true; + } + + /** + * 获取当前激活工具名。 + * @returns {string|null} + */ + getActive() { + return this._activeTool; + } + + /** + * 获取当前工具定义。 + * @returns {ToolDefinition|null} + */ + getActiveDef() { + return this._activeTool ? this._tools.get(this._activeTool) || null : null; + } + + // ── 选项 ── + + /** + * 获取工具当前选项。 + * @param {string} name + * @returns {object} + */ + getOptions(name) { + return { ...(this._toolOptions[name] || {}) }; + } + + /** + * 获取当前激活工具的选项。 + * @returns {object} + */ + getActiveOptions() { + return this._activeTool ? this.getOptions(this._activeTool) : {}; + } + + /** + * 更新工具选项。 + * @param {string} name + * @param {object} patch + */ + updateOptions(name, patch) { + if (!this._toolOptions[name]) { + this._toolOptions[name] = {}; + } + Object.assign(this._toolOptions[name], patch); + this._emit('tool:optionsChanged', { + toolName: name, + options: { ...this._toolOptions[name] }, + }); + } + + /** + * 更新当前工具选项。 + * @param {object} patch + */ + updateActiveOptions(patch) { + if (this._activeTool) { + this.updateOptions(this._activeTool, patch); + } + } + + // ── 控件 schema 查询 ── + + /** + * 获取工具的预设列表。 + * @param {string} name + * @returns {ToolPreset[]} + */ + getPresets(name) { + return this._tools.get(name)?.presets || []; + } + + /** + * 获取工具的控件 schema。 + * @param {string} name + * @returns {ToolControlSchema[]} + */ + getControls(name) { + return this._tools.get(name)?.controls || []; + } + + // ── 销毁 ── + + destroy() { + for (const [name, def] of this._tools) { + if (def.handler?.deactivate) { + try { def.handler.deactivate(); } catch (e) { /* ignore */ } + } + } + this._tools.clear(); + this._toolOptions = {}; + this._activeTool = null; + } + + // ── 内部 ── + + _emit(event, ...args) { + if (this._eventBus) { + this._eventBus.emit(event, ...args); + } + } +} diff --git a/plugins/Image-Toolbox/core/src/adapters/BaseHostAdapter.js b/plugins/Image-Toolbox/core/src/adapters/BaseHostAdapter.js new file mode 100644 index 000000000..3bb3dfb99 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/adapters/BaseHostAdapter.js @@ -0,0 +1,470 @@ +/** + * BaseHostAdapter + * 宿主适配器基类,提取两个平台 adapter 的公共逻辑。 + * + * 子类需要覆盖以下方法: + * - platformId: 平台标识(如 'utools'、'ztools') + * - getDefaultHostName(): 默认宿主名称 + * - getHostApiPriority(): API 查找优先级数组 + * - getAppVersionPriority(): 版本获取方法优先级数组 + * - getHostDisplayName(api): 获取宿主显示名称 + * - normalizeUser(user): 用户数据标准化 + * - getRawUser(api): 获取原始用户数据 + * - getContactUrl(): 联系链接 + */ + +const _isUserValid = (user) => { + return user && (user.nickname || user.name || user.userName || user.username || user.avatar || user.avatarUrl || user.photo); +}; + +const _normalizeUser = (user) => { + if (!user) return null; + + const rawType = user.type || ''; + + return { + nickname: user.nickname || user.name || user.userName || user.username || '', + avatar: user.avatar || user.avatarUrl || user.photo || '', + type: rawType, + raw: user, + }; +}; + +class BaseHostAdapter { + constructor() { + this._api = this._getHostApi(); + this._isInitialized = false; + + this.platform = { + id: this.platformId, + name: this.getHostDisplayName(), + version: this.getHostAppVersion(), + runtime: 'electron', + }; + + this.user = { + getCurrentUser: () => this.getHostUser(), + fetchServerTemporaryToken: () => this.fetchUserServerTemporaryToken(), + }; + + this.storage = { + get: (key) => this.getStorageItem(key), + set: (key, value) => this.setStorageItem(key, value), + remove: (key) => this.removeStorageItem(key), + }; + + this.file = { + pickImage: () => this.pickImage(), + readImageFile: (filePath) => this.readImageFile(filePath), + saveImage: (data, suggestedName) => this.saveImage(data, suggestedName), + }; + + this.clipboard = { + writeImage: (data) => this.copyImage(data), + readText: () => this.readClipboard(), + writeText: (text) => this.writeClipboard(text), + }; + + this.window = { + setHeight: (height) => this.setWindowHeight(height), + setWidth: (width) => this.setWindowWidth(width), + setTitle: (title) => this.setWindowTitle(title), + }; + + this.system = { + openExternal: (url) => this.openHostExternal(url), + getSystemFonts: () => this.getSystemFonts(), + showNotification: (message, type) => this.showNotification(message, type), + }; + + this.lifecycle = { + onEnter: (callback) => this.onPluginEnter(callback), + onExit: (callback) => this.onPluginOut(callback), + }; + } + + // ═══ 平台特定覆盖点 ═══ + + get platformId() { + return 'unknown'; + } + + getDefaultHostName() { + return 'Unknown'; + } + + getHostApiPriority() { + return []; + } + + getAppVersionPriority() { + return []; + } + + getHostDisplayName(api) { + const target = api || this._api; + if (!target) return this.getDefaultHostName(); + + try { + if (typeof target.getAppName === 'function') { + const name = target.getAppName(); + if (name) return String(name); + } + } catch (e) { + console.warn(`[${this.platformId}HostAdapter] 获取宿主名称失败:`, e); + } + + return this.getDefaultHostName(); + } + + normalizeUser(user) { + return _normalizeUser(user); + } + + getRawUser(api) { + const target = api || this._api; + try { + if (target && typeof target.getUser === 'function') return target.getUser(); + if (target && typeof target.getUserInfo === 'function') return target.getUserInfo(); + if (typeof window !== 'undefined' && typeof window.getHostUser === 'function') return window.getHostUser(); + } catch (e) { + console.warn(`[${this.platformId}HostAdapter] 获取宿主用户失败:`, e); + } + return null; + } + + getContactUrl() { + return ''; + } + + // ═══ 内部方法 ═══ + + _getHostApi() { + const priorities = this.getHostApiPriority(); + + if (typeof window !== 'undefined') { + for (const key of priorities) { + if (window[key]) return window[key]; + } + } + + if (typeof globalThis !== 'undefined') { + for (const key of priorities) { + if (globalThis[key]) return globalThis[key]; + } + } + + return null; + } + + // ═══ 公共方法 ═══ + + get isInitialized() { + return this._isInitialized; + } + + get name() { + return this.platform.id; + } + + /** + * 设置宿主窗口高度 + */ + setWindowHeight(height) { + if (this._api && typeof this._api.setExpendHeight === 'function') { + this._api.setExpendHeight(height); + } + } + + /** + * 设置宿主窗口宽度 + */ + setWindowWidth(width) { + if (this._api && typeof this._api.setExpendWidth === 'function') { + this._api.setExpendWidth(width); + } + } + + /** + * 设置主窗口标题 + */ + setWindowTitle(title) { + if (this._api && typeof this._api.setMainWindowTitle === 'function') { + this._api.setMainWindowTitle(title); + } + } + + /** + * 插件进入回调 + */ + onPluginEnter(callback) { + if (this._api && typeof this._api.onPluginEnter === 'function') { + this._api.onPluginEnter(callback); + } + return () => {}; + } + + /** + * 插件退出回调 + */ + onPluginOut(callback) { + if (this._api && typeof this._api.onPluginOut === 'function') { + this._api.onPluginOut(callback); + } + return () => {}; + } + + /** + * 显示文件选择对话框 + */ + showOpenDialog(options) { + if (this._api && typeof this._api.showOpenDialog === 'function') { + return this._api.showOpenDialog(options); + } + return null; + } + + /** + * 显示保存对话框 + */ + showSaveDialog(options) { + if (this._api && typeof this._api.showSaveDialog === 'function') { + return this._api.showSaveDialog(options); + } + return null; + } + + /** + * 选择图片文件并返回 dataURL + */ + pickImage() { + if (typeof window !== 'undefined' && typeof window.showOpenImageDialog === 'function') { + const result = window.showOpenImageDialog(); + const filePath = Array.isArray(result) ? result[0] : result?.filePaths?.[0]; + return filePath ? this.readImageFile(filePath) : null; + } + return null; + } + + /** + * 读取图片文件为 dataURL + */ + readImageFile(filePath) { + if (typeof window !== 'undefined' && typeof window.readImageFile === 'function') { + return window.readImageFile(filePath); + } + return null; + } + + /** + * 读取文件(别名) + */ + readFile(filePath) { + return this.readImageFile(filePath); + } + + /** + * 保存图片到文件 + */ + saveImage(data, suggestedName = 'edited.png') { + if (typeof window === 'undefined') return false; + if (typeof window.showSaveImageDialog !== 'function' || typeof window.writeImageFile !== 'function') return false; + + const filePath = window.showSaveImageDialog(suggestedName); + if (!filePath) return false; + + return !!window.writeImageFile(filePath, data); + } + + /** + * 显示保存对话框(仅返回路径) + */ + showSaveImageDialog(suggestedName = 'edited.png') { + if (typeof window !== 'undefined' && typeof window.showSaveImageDialog === 'function') { + return window.showSaveImageDialog(suggestedName); + } + return null; + } + + /** + * 写入图片文件 + */ + writeImageFile(filePath, data) { + if (typeof window !== 'undefined' && typeof window.writeImageFile === 'function') { + return !!window.writeImageFile(filePath, data); + } + return false; + } + + /** + * 复制图片到剪贴板 + */ + copyImage(data) { + if (typeof window !== 'undefined' && typeof window.copyImageToClipboard === 'function') { + window.copyImageToClipboard(data); + return true; + } + return false; + } + + /** + * 写入文本到剪贴板 + */ + writeClipboard(text) { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + return navigator.clipboard.writeText(text).then(() => true); + } + return Promise.resolve(false); + } + + /** + * 从剪贴板读取文本 + */ + readClipboard() { + if (typeof navigator !== 'undefined' && navigator.clipboard?.readText) { + return navigator.clipboard.readText(); + } + return Promise.resolve(null); + } + + /** + * 显示通知 + */ + showNotification(message, type) { + if (this._api && typeof this._api.showNotification === 'function') { + this._api.showNotification(message, type); + } + } + + /** + * 获取本地文件 + */ + fetchLocalFile(filePath) { + if (this._api && typeof this._api.fetchLocalFile === 'function') { + return this._api.fetchLocalFile(filePath); + } + return null; + } + + /** + * 获取宿主应用版本 + */ + getHostAppVersion() { + const priorities = this.getAppVersionPriority(); + for (const methodName of priorities) { + if (this._api && typeof this._api[methodName] === 'function') { + try { + const version = this._api[methodName](); + if (version) return version; + } catch (e) { + console.warn(`[${this.platformId}HostAdapter] 获取版本失败 (${methodName}):`, e); + } + } + } + return 'unknown'; + } + + /** + * 获取宿主名称 + */ + getHostName() { + return this.platform?.name || this.getHostDisplayName(this._api); + } + + /** + * 获取宿主用户信息 + */ + getHostUser() { + const rawUser = this.getRawUser(this._api); + return this.normalizeUser(rawUser); + } + + /** + * 获取用户服务器临时 token + */ + fetchUserServerTemporaryToken() { + if (this._api && typeof this._api.fetchUserServerTemporaryToken === 'function') { + return this._api.fetchUserServerTemporaryToken(); + } + return Promise.resolve(null); + } + + /** + * 获取存储项 + */ + getStorageItem(key) { + const storage = this._api?.dbStorage; + if (storage && typeof storage.getItem === 'function') return storage.getItem(key); + if (typeof localStorage !== 'undefined') return localStorage.getItem(key); + return null; + } + + /** + * 设置存储项 + */ + setStorageItem(key, value) { + const storage = this._api?.dbStorage; + if (storage && typeof storage.setItem === 'function') { + storage.setItem(key, value); + return; + } + if (typeof localStorage !== 'undefined') localStorage.setItem(key, value); + } + + /** + * 删除存储项 + */ + removeStorageItem(key) { + const storage = this._api?.dbStorage; + if (storage && typeof storage.removeItem === 'function') { + storage.removeItem(key); + return; + } + if (typeof localStorage !== 'undefined') localStorage.removeItem(key); + } + + /** + * 获取系统字体 + */ + getSystemFonts() { + if (typeof window !== 'undefined' && typeof window.getSystemFonts === 'function') { + return window.getSystemFonts(); + } + return []; + } + + /** + * 异步获取系统字体 + */ + getSystemFontsAsync() { + if (typeof window !== 'undefined' && typeof window.getSystemFontsAsync === 'function') { + return window.getSystemFontsAsync(); + } + return Promise.resolve([]); + } + + /** + * 打开外部链接 + */ + openHostExternal(url) { + if (!url) return false; + + try { + if (this._api && typeof this._api.shellOpenExternal === 'function') { + this._api.shellOpenExternal(url); + return true; + } + } catch (e) { + console.warn(`[${this.platformId}HostAdapter] 使用宿主打开外部链接失败:`, e); + } + + if (typeof window !== 'undefined') { + window.open(url, '_blank', 'noopener,noreferrer'); + return true; + } + + return false; + } +} + +export default BaseHostAdapter; diff --git a/plugins/Image-Toolbox/core/src/app/App.js b/plugins/Image-Toolbox/core/src/app/App.js new file mode 100644 index 000000000..1014933bb --- /dev/null +++ b/plugins/Image-Toolbox/core/src/app/App.js @@ -0,0 +1,533 @@ +/** + * App — 图片工具箱主入口 + * 跨平台共享的应用初始化逻辑。 + * + * 各平台 index.js 只需传入 HostAdapter 构造函数即可复用。 + */ + +import { + eventBus, + CanvasManager, + LayerManager, + HistoryManager, + ToolManager, +} from '../runtime/fabric.js'; + +import Toolbar from '../ui/Toolbar.js'; +import OptionsBar from '../ui/OptionsBar.js'; +import SidePanelTabs from '../ui/SidePanelTabs.js'; +import PropertyPanel from '../ui/PropertyPanel.js'; +import LayerPanel from '../ui/LayerPanel.js'; +import StatusBar from '../ui/StatusBar.js'; +import AccountPage, { + EDITOR_BARS_LAYOUT_KEY, + EDITOR_BARS_LAYOUTS, + EDITOR_SIDE_PANEL_POSITION_KEY, + EDITOR_SIDE_PANEL_POSITIONS, +} from '../ui/AccountPage.js'; +import { initTheme } from '../utils/theme.js'; + +// ═══════════════════════════════════════ +// 应用入口 +// ═══════════════════════════════════════ + +class App { + constructor(HostAdapter) { + this.HostAdapter = HostAdapter; + this.canvasManager = null; + this.layerManager = null; + this.historyManager = null; + this.toolManager = null; + + this.toolbar = null; + this.optionsBar = null; + this.sidePanelTabs = null; + this.propertyPanel = null; + this.layerPanel = null; + this.statusBar = null; + this.accountPage = null; + this.hostAdapter = null; + this._destroyed = false; + + this._init(); + } + + _init() { + // 确保 Fabric.js 已加载 + if (typeof fabric === 'undefined') { + console.error('[App] Fabric.js 未加载,请检查 CDN'); + document.body.innerHTML = '
Fabric.js 加载失败,请检查网络连接
'; + return; + } + + try { + initTheme(); + this._applyEditorBarsLayout(this._getEditorBarsLayout()); + this._applyEditorSidePanelPosition(this._getEditorSidePanelPosition()); + + // 1. 初始化画布管理器 + this.canvasManager = new CanvasManager('fabric-canvas'); + this.canvasManager.init({ + width: 800, + height: 600, + backgroundColor: 'transparent', + preserveObjectStacking: true, + selection: true, + stopContextMenu: true, + fireRightClick: true, + }); + + // 2. 初始化图层管理器 + this.layerManager = new LayerManager(this.canvasManager); + + // 3. 初始化历史记录 + this.historyManager = new HistoryManager(this.canvasManager, 30); + + // 4. 初始化工具管理器(注入 host adapter) + this.hostAdapter = new this.HostAdapter(); + this.toolManager = new ToolManager(this.canvasManager, this.historyManager, { + host: this.hostAdapter, + }); + + // 5. 初始化 UI 组件 + this.toolbar = new Toolbar( + document.getElementById('toolbar'), + this.toolManager, + this.hostAdapter + ); + + this.optionsBar = new OptionsBar( + document.getElementById('optionsbar'), + this.toolManager + ); + + this.sidePanelTabs = new SidePanelTabs( + document.getElementById('panel-area'), + this.layerManager + ); + + this.propertyPanel = new PropertyPanel( + document.getElementById('property-panel'), + this.toolManager, + this.canvasManager, + this.layerManager + ); + + this.layerPanel = new LayerPanel( + document.getElementById('layer-panel'), + this.layerManager + ); + + this.statusBar = new StatusBar( + document.getElementById('statusbar'), + this.canvasManager, + this.layerManager + ); + + this.accountPage = new AccountPage( + document.getElementById('account-page'), + document.getElementById('app'), + this.sidePanelTabs, + this.hostAdapter + ); + + // 6. 绑定全局事件 + this._bindGlobalEvents(); + + // 7. 默认激活选择工具 + this.toolManager.activateTool('select'); + + // 8. 检查是否有外部传入的图片源 + this._checkExternalSource(); + + console.log('[App] 图片工具箱初始化完成'); + } catch (err) { + console.error('[App] 初始化失败:', err); + } + } + + _bindGlobalEvents() { + // ═══ 图片导入 ═══ + + // 拖拽导入 + document.addEventListener('dragover', (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + + document.addEventListener('drop', (e) => { + e.preventDefault(); + e.stopPropagation(); + + const files = e.dataTransfer.files; + if (files.length > 0) { + const file = files[0]; + if (file.type.startsWith('image/')) { + this._loadImage(file); + } + } + }); + + // 粘贴导入 + document.addEventListener('paste', (e) => { + const items = e.clipboardData?.items; + if (!items) return; + + for (const item of items) { + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) { + this._loadImage(file); + } + break; + } + } + }); + + // 文件选择对话框(宿主 API) + document.getElementById('welcome-btn')?.addEventListener('click', () => { + if (typeof window.showOpenImageDialog === 'function') { + const result = window.showOpenImageDialog(); + if (result && result.length > 0) { + const dataURL = window.readImageFile(result[0]); + if (dataURL) { + this._loadImage(dataURL); + } + } + } else { + // 降级方案:浏览器 file input + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/png,image/jpeg,image/webp,image/bmp,image/gif,image/svg+xml'; + input.onchange = (e) => { + const file = e.target.files[0]; + if (file) this._loadImage(file); + }; + input.click(); + } + }); + + // 点击欢迎图标导入 + document.getElementById('welcome-drop')?.addEventListener('click', () => { + document.getElementById('welcome-btn')?.click(); + }); + + // ═══ 缩放控制 ═══ + document.getElementById('zoom-in')?.addEventListener('click', () => { + this.canvasManager?.zoomIn(); + this._updateZoomLabel(); + }); + + document.getElementById('zoom-out')?.addEventListener('click', () => { + this.canvasManager?.zoomOut(); + this._updateZoomLabel(); + }); + + document.getElementById('zoom-value')?.addEventListener('click', () => { + this.canvasManager?.resetZoom(); + this._updateZoomLabel(); + }); + + // 滚轮缩放 + document.getElementById('canvas-area')?.addEventListener('wheel', (e) => { + if (!this.canvasManager?.canvas) return; + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.05 : 0.05; + this.canvasManager.zoomIn(delta); + this._updateZoomLabel(); + }, { passive: false }); + + eventBus.on('canvas:zoomIn', () => { + this.canvasManager?.zoomIn(); + this._updateZoomLabel(); + }); + eventBus.on('canvas:zoomOut', () => { + this.canvasManager?.zoomOut(); + this._updateZoomLabel(); + }); + + // ═══ 导出 ═══ + eventBus.on('export:requested', async (format) => { + if (format === 'clipboard') { + await this.toolManager?.export('clipboard'); + } else { + const exportModule = this.toolManager?.getModule('export'); + if (exportModule) { + await exportModule.exportToFile(); + } + } + }); + + // ═══ 撤销 / 重做 ═══ + eventBus.on('history:undo', () => { + this.historyManager?.undo(); + }); + eventBus.on('history:redo', () => { + this.historyManager?.redo(); + }); + + // ═══ 编辑器布局偏好 ═══ + eventBus.on('sidePanel:layoutChanged', (layout) => { + this.sidePanelTabs?.applyLayout(layout, false); + }); + + eventBus.on('editorBars:layoutChanged', (layout) => { + this._applyEditorBarsLayout(layout); + }); + + eventBus.on('editorSidePanel:positionChanged', (position) => { + this._applyEditorSidePanelPosition(position); + }); + + // ═══ 快捷键 ═══ + document.addEventListener('keydown', (e) => { + // Ctrl+Z 撤销 + if (e.ctrlKey && !e.shiftKey && e.key === 'z') { + e.preventDefault(); + this.historyManager?.undo(); + return; + } + + // Ctrl+Shift+Z 或 Ctrl+Y 重做 + if ((e.ctrlKey && e.shiftKey && e.key === 'z') || (e.ctrlKey && !e.shiftKey && e.key === 'y')) { + e.preventDefault(); + this.historyManager?.redo(); + return; + } + + // Delete 删除选中物件 + if (e.key === 'Delete' || e.key === 'Backspace') { + const active = this.canvasManager?.getActiveObject(); + if (active && active.isEditing) return; + if (active && active.excludeFromHistory) return; + this.historyManager?.saveState(); + this.canvasManager?.removeActiveObject(); + return; + } + + // 工具快捷键 + if (!e.ctrlKey && !e.metaKey) { + const activeElement = document.activeElement; + const tagName = activeElement?.tagName?.toUpperCase(); + if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') return; + if (activeElement?.isContentEditable) return; + + const active = this.canvasManager?.getActiveObject(); + if (active && active.isEditing) return; + + const tools = this.toolManager?.getTools() || []; + const tool = tools.find(t => t.shortcut === e.key.toUpperCase()); + if (tool) { + e.preventDefault(); + this.toolManager?.activateTool(tool.name); + } + } + }); + + // ═══ 画布操作后自动保存历史 ═══ + eventBus.on('canvas:objectModified', (target) => { + if (target?.excludeFromHistory) return; + + if (this._saveTimer) clearTimeout(this._saveTimer); + this._saveTimer = setTimeout(() => { + this.historyManager?.saveState(); + }, 300); + }); + + eventBus.on('layer:reorderWillChange', () => { + this.historyManager?.saveState(); + }); + + // ═══ 工具自动切换 ═══ + eventBus.on('tool:requestChange', (toolName) => { + this.toolManager?.activateTool(toolName); + }); + + // ═══ Toast ═══ + eventBus.on('toast:show', ({ message, type }) => { + this._showToast(message, type); + }); + + // ═══ 插件重复进入 ═══ + this.hostAdapter?.onPluginEnter(({ code, type, payload, from }) => { + console.log('[App] onPluginEnter:', { code, type, from, payload }); + if (code === 'image-edit') { + const source = this._getExternalImageSource(type, payload); + console.log('[App] 外部图片源:', source ? 'ok' : 'empty', { type, from }); + if (source) { + if (window.__imageSource === source) { + window.__imageSource = null; + } + this._loadImage(source); + } else if (type === 'img' && window.__imageSource) { + const source = window.__imageSource; + if (source) { + window.__imageSource = null; + this._loadImage(source); + } + } + + this.hostAdapter?.setWindowHeight(560); + } + }); + } + + destroy() { + if (this._destroyed) return; + this._destroyed = true; + + if (this._saveTimer) { + clearTimeout(this._saveTimer); + this._saveTimer = null; + } + + [ + this.accountPage, + this.toolbar, + this.optionsBar, + this.sidePanelTabs, + this.propertyPanel, + this.layerPanel, + this.statusBar, + ].forEach(component => component?.destroy?.()); + + this.toolManager?.destroy?.(); + this.canvasManager?.destroy?.(); + } + + _showToast(message, type = 'success') { + const existing = document.querySelector('.toast'); + if (existing) existing.remove(); + + const icons = { + success: '', + error: '', + }; + + const toast = document.createElement('div'); + toast.className = `toast toast--${type}`; + toast.innerHTML = icons[type] || ''; + const textEl = document.createElement('span'); + textEl.textContent = message; + toast.appendChild(textEl); + document.body.appendChild(toast); + + toast.addEventListener('animationend', () => { + if (toast.parentNode) toast.parentNode.removeChild(toast); + }); + } + + async _loadImage(source) { + try { + document.getElementById('welcome')?.classList.add('hidden'); + document.getElementById('canvas-container')?.classList.remove('hidden'); + document.getElementById('zoom-control')?.classList.remove('hidden'); + + await this.canvasManager.loadImage(source); + this.canvasManager.fitToCanvas(40); + + this.layerManager.syncLayers(); + + this.historyManager.clear(); + this.historyManager.saveState(); + + this.hostAdapter?.setWindowHeight(560); + } catch (err) { + console.error('[App] 图片加载失败:', err); + document.getElementById('welcome')?.classList.remove('hidden'); + document.getElementById('canvas-container')?.classList.add('hidden'); + document.getElementById('zoom-control')?.classList.add('hidden'); + eventBus.emit('toast:show', { message: '图片加载失败,请重试', type: 'error' }); + } + } + + _getExternalImageSource(type, payload) { + if (typeof window.getImageSourceFromPluginPayload === 'function') { + try { + const source = window.getImageSourceFromPluginPayload(type, payload); + if (source) return source; + } catch (e) { + console.error('[App] 解析外部图片 payload 失败:', e); + } + } + + if (type !== 'file' && type !== 'files') return null; + + const files = Array.isArray(payload) ? payload : [payload]; + const fileInfo = files.find(item => item && item.path); + if (!fileInfo || typeof window.readImageFile !== 'function') return null; + + try { + return window.readImageFile(fileInfo.path); + } catch (e) { + console.error('[App] 文件匹配读取失败:', e); + return null; + } + } + + _checkExternalSource() { + let attempts = 0; + const maxAttempts = 30; + + const check = () => { + if (window.__imageSource) { + const source = window.__imageSource; + window.__imageSource = null; + console.log('[App] _checkExternalSource 发现图片源,开始加载'); + if (source) { + this._loadImage(source); + } + return; + } + attempts++; + if (attempts < maxAttempts) { + setTimeout(check, 100); + } else { + console.log('[App] _checkExternalSource 超时(3s),未发现外部图片源'); + } + }; + + setTimeout(check, 100); + } + + _updateZoomLabel() { + const label = document.getElementById('zoom-value'); + if (label && this.canvasManager) { + label.textContent = Math.round(this.canvasManager.zoomLevel * 100) + '%'; + } + } + + _getEditorBarsLayout() { + const saved = localStorage.getItem(EDITOR_BARS_LAYOUT_KEY); + return Object.values(EDITOR_BARS_LAYOUTS).includes(saved) ? saved : EDITOR_BARS_LAYOUTS.PRESETS_TOP; + } + + _applyEditorBarsLayout(layout) { + const normalized = Object.values(EDITOR_BARS_LAYOUTS).includes(layout) + ? layout + : EDITOR_BARS_LAYOUTS.PRESETS_TOP; + + document.getElementById('app')?.classList.toggle( + 'app--bars-swapped', + normalized === EDITOR_BARS_LAYOUTS.STATUS_TOP + ); + } + + _getEditorSidePanelPosition() { + const saved = localStorage.getItem(EDITOR_SIDE_PANEL_POSITION_KEY); + return Object.values(EDITOR_SIDE_PANEL_POSITIONS).includes(saved) ? saved : EDITOR_SIDE_PANEL_POSITIONS.RIGHT; + } + + _applyEditorSidePanelPosition(position) { + const normalized = Object.values(EDITOR_SIDE_PANEL_POSITIONS).includes(position) + ? position + : EDITOR_SIDE_PANEL_POSITIONS.RIGHT; + + document.getElementById('app')?.classList.toggle( + 'app--panel-left', + normalized === EDITOR_SIDE_PANEL_POSITIONS.LEFT + ); + } +} + +export default App; diff --git a/plugins/Image-Toolbox/core/src/index.js b/plugins/Image-Toolbox/core/src/index.js new file mode 100644 index 000000000..049c03117 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/index.js @@ -0,0 +1,5 @@ +// ═══════════════════════════════════════════════════════ +// @img-toolbox/core — 公共无环境依赖入口 +// ═══════════════════════════════════════════════════════ + +export { default as eventBus, EventBus } from './EventBus.js'; diff --git a/plugins/Image-Toolbox/core/src/interfaces/EditorEngineAdapter.js b/plugins/Image-Toolbox/core/src/interfaces/EditorEngineAdapter.js new file mode 100644 index 000000000..2a9329e76 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/interfaces/EditorEngineAdapter.js @@ -0,0 +1,130 @@ +/** + * EditorEngineAdapter 接口定义(JSDoc) + * + * 渲染引擎适配器抽象。Fabric.js 是默认实现,后续可替换为 CanvasKit / WebGL / 原生等。 + * + * @interface EditorEngineAdapter + */ +export default class EditorEngineAdapter { + /** + * 初始化引擎。 + * @param {HTMLCanvasElement|object} target - canvas 元素或平台等价物 + * @param {object} [options] - 引擎配置 + * @returns {Promise} + */ + async init(target, options = {}) {} + + /** 销毁引擎,释放资源。 */ + destroy() {} + + /** + * 加载图片。 + * @param {string|File|Blob} source + * @returns {Promise} engineId + */ + async loadImage(source) { throw new Error('Not implemented'); } + + /** + * 替换当前背景图片。 + * @param {string} source + * @returns {Promise} engineId + */ + async replaceImage(source) { throw new Error('Not implemented'); } + + /** + * 导出画布为 dataURL。 + * @param {object} [options] - { format, quality, multiplier } + * @returns {string|null} + */ + exportToDataURL(options = {}) { return null; } + + // ── 物件操作 ── + + /** @returns {{ engineId: string, type: string, name?: string }[]} */ + getObjects() { return []; } + + /** + * @param {string} engineId + * @returns {object|null} + */ + getObject(engineId) { return null; } + + /** + * @param {object} input - { type, options } + * @returns {Promise} engineId + */ + async addObject(input) { throw new Error('Not implemented'); } + + /** + * @param {string} engineId + * @param {object} patch + */ + updateObject(engineId, patch) {} + + /** @param {string} engineId */ + removeObject(engineId) {} + + /** + * @param {string} engineId + * @param {number} targetIndex + */ + reorderObject(engineId, targetIndex) {} + + // ── 选择 ── + + /** @returns {string[]} 选中的 engineId 列表 */ + getSelection() { return []; } + + /** @param {string[]} engineIds */ + setSelection(engineIds) {} + + /** + * @param {string} engineId + * @param {boolean} interactive + */ + setInteractivity(engineId, interactive) {} + + // ── 视口 ── + + /** @returns {{ zoom: number, offsetX: number, offsetY: number }} */ + getViewport() { return { zoom: 1, offsetX: 0, offsetY: 0 }; } + + /** + * @param {{ zoom?: number, offsetX?: number, offsetY?: number }} viewport + */ + setViewport(viewport) {} + + /** + * 图片自适应视口。 + * @param {number} [padding=40] + */ + fitToViewport(padding = 40) {} + + // ── 序列化 ── + + /** + * 将当前引擎状态序列化为可存储的 JSON。 + * @returns {object} + */ + serialize() { return {}; } + + /** + * 从序列化数据恢复引擎状态。 + * @param {object} state + * @returns {Promise} + */ + async restore(state) {} + + // ── 事件 ── + + /** + * 订阅引擎事件。 + * @param {string} event + * @param {Function} callback + * @returns {Function} 取消订阅函数 + */ + on(event, callback) { return () => {}; } + + /** 取消订阅。 */ + off(event, callback) {} +} diff --git a/plugins/Image-Toolbox/core/src/interfaces/HostAdapter.js b/plugins/Image-Toolbox/core/src/interfaces/HostAdapter.js new file mode 100644 index 000000000..01c9aacb5 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/interfaces/HostAdapter.js @@ -0,0 +1,97 @@ +/** + * HostAdapter 接口定义(JSDoc) + * + * 端侧能力统一注入接口。 + * 各端根据自身环境实现:UtoolsHostAdapter、WebHostAdapter、ElectronHostAdapter 等。 + * + * @interface HostAdapter + */ +export default class HostAdapter { + /** + * 弹出图片选择对话框,返回图片 source(dataURL / URL / File / Blob)。 + * 不支持的环境返回 null。 + * @returns {Promise} + */ + async pickImage() { return null; } + + /** + * 读取本地文件路径,返回 dataURL。 + * @param {string} filePath + * @returns {Promise} + */ + async readImageFile(filePath) { throw new Error('Not implemented'); } + + /** + * 弹出保存对话框,写入图片。 + * @param {Blob|string} data - Blob 对象或 dataURL 字符串 + * @param {string} [suggestedName='edited'] + * @returns {Promise} + */ + async saveImage(data, suggestedName = 'edited') { return false; } + + /** + * 复制图片到系统剪贴板。 + * @param {Blob|string} data - Blob 对象或 dataURL 字符串 + * @returns {Promise} + */ + async copyImage(data) { return false; } + + /** + * 获取系统字体列表。 + * @returns {Promise} + */ + async getSystemFonts() { return []; } + + /** + * 异步获取系统字体列表(不阻塞UI线程)。 + * @returns {Promise} + */ + async getSystemFontsAsync() { return []; } + + /** + * 获取存储值。 + * @param {string} key + * @returns {Promise} + */ + async getStorageItem(key) { return null; } + + /** + * 设置存储值。 + * @param {string} key + * @param {string} value + * @returns {Promise} + */ + async setStorageItem(key, value) {} + + /** + * 用系统浏览器打开外部链接。 + * @param {string} url + * @returns {Promise} + */ + async openExternal(url) { return false; } + + /** + * 调整宿主窗口高度。 + * @param {number} height + */ + setWindowHeight(height) {} + + /** + * 注册宿主进入事件(文件匹配 / 超级面板等)。 + * @param {function} callback + * @returns {function} 取消订阅函数 + */ + onPluginEnter(callback) { return () => {}; } + + /** + * 获取当前宿主名称。 + * @returns {string} + */ + getHostName() { return 'browser'; } + + /** + * 获取当前宿主用户信息。 + * @returns {Promise} + */ + async getHostUser() { return null; } +} diff --git a/plugins/Image-Toolbox/core/src/lib/fabric.min.js b/plugins/Image-Toolbox/core/src/lib/fabric.min.js new file mode 100644 index 000000000..18ec24183 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/lib/fabric.min.js @@ -0,0 +1 @@ +var jsdom,virtualWindow,fabric=fabric||{version:"5.3.0"};function resizeCanvasIfNeeded(t){var e=t.targetCanvas,i=e.width,r=e.height,n=t.destinationWidth,t=t.destinationHeight;i===n&&r===t||(e.width=n,e.height=t)}function copyGLTo2DDrawImage(t,e){var t=t.canvas,e=e.targetCanvas,i=e.getContext("2d"),r=(i.translate(0,e.height),i.scale(1,-1),t.height-e.height);i.drawImage(t,0,r,e.width,e.height,0,0,e.width,e.height)}function copyGLTo2DPutImageData(t,e){var i=e.targetCanvas.getContext("2d"),r=e.destinationWidth,e=e.destinationHeight,n=r*e*4,s=new Uint8Array(this.imageBuffer,0,n),n=new Uint8ClampedArray(this.imageBuffer,0,n),t=(t.readPixels(0,0,r,e,t.RGBA,t.UNSIGNED_BYTE,s),new ImageData(n,r,e));i.putImageData(t,0,0)}"undefined"!=typeof exports?exports.fabric=fabric:"function"==typeof define&&define.amd&&define([],function(){return fabric}),"undefined"!=typeof document&&"undefined"!=typeof window?(document instanceof("undefined"!=typeof HTMLDocument?HTMLDocument:Document)?fabric.document=document:fabric.document=document.implementation.createHTMLDocument(""),fabric.window=window):(jsdom=require("jsdom"),virtualWindow=new jsdom.JSDOM(decodeURIComponent("%3C!DOCTYPE%20html%3E%3Chtml%3E%3Chead%3E%3C%2Fhead%3E%3Cbody%3E%3C%2Fbody%3E%3C%2Fhtml%3E"),{features:{FetchExternalResources:["img"]},resources:"usable"}).window,fabric.document=virtualWindow.document,fabric.jsdomImplForWrapper=require("jsdom/lib/jsdom/living/generated/utils").implForWrapper,fabric.nodeCanvas=require("jsdom/lib/jsdom/utils").Canvas,fabric.window=virtualWindow,DOMParser=fabric.window.DOMParser),fabric.isTouchSupported="ontouchstart"in fabric.window||"ontouchstart"in fabric.document||fabric.window&&fabric.window.navigator&&0_)for(var C=1,S=v.length;Ct[i-2].x?1:s.x===t[i-2].x?0:-1,h=s.y>t[i-2].y?1:s.y===t[i-2].y?0:-1),n.push(["L",s.x+c*e,s.y+h*e]),n},fabric.util.getPathSegmentsInfo=l,fabric.util.getBoundsOfCurve=function(t,e,i,r,n,s,o,a){var c;if(fabric.cachesBoundsOfCurve&&(c=w.call(arguments),fabric.boundsOfCurveCache[c]))return fabric.boundsOfCurveCache[c];for(var h,l,u,f=Math.sqrt,d=Math.min,g=Math.max,p=Math.abs,m=[],v=[[],[]],b=6*t-12*i+6*n,y=-3*t+9*i-9*n+3*o,_=3*i-3*t,x=0;x<2;++x)0/g,">")},graphemeSplit:function(t){for(var e,i=0,r=[],i=0;it.x&&this.y>t.y},gte:function(t){return this.x>=t.x&&this.y>=t.y},lerp:function(t,e){return void 0===e&&(e=.5),e=Math.max(Math.min(1,e),0),new i(this.x+(t.x-this.x)*e,this.y+(t.y-this.y)*e)},distanceFrom:function(t){var e=this.x-t.x,t=this.y-t.y;return Math.sqrt(e*e+t*t)},midPointFrom:function(t){return this.lerp(t)},min:function(t){return new i(Math.min(this.x,t.x),Math.min(this.y,t.y))},max:function(t){return new i(Math.max(this.x,t.x),Math.max(this.y,t.y))},toString:function(){return this.x+","+this.y},setXY:function(t,e){return this.x=t,this.y=e,this},setX:function(t){return this.x=t,this},setY:function(t){return this.y=t,this},setFromPoint:function(t){return this.x=t.x,this.y=t.y,this},swap:function(t){var e=this.x,i=this.y;this.x=t.x,this.y=t.y,t.x=e,t.y=i},clone:function(){return new i(this.x,this.y)}}}("undefined"!=typeof exports?exports:this),function(t){"use strict";var a=t.fabric||(t.fabric={});function c(t){this.status=t,this.points=[]}a.Intersection?a.warn("fabric.Intersection is already defined"):(a.Intersection=c,a.Intersection.prototype={constructor:c,appendPoint:function(t){return this.points.push(t),this},appendPoints:function(t){return this.points=this.points.concat(t),this}},a.Intersection.intersectLineLine=function(t,e,i,r){var n,s=(r.x-i.x)*(t.y-i.y)-(r.y-i.y)*(t.x-i.x),o=(e.x-t.x)*(t.y-i.y)-(e.y-t.y)*(t.x-i.x),r=(r.y-i.y)*(e.x-t.x)-(r.x-i.x)*(e.y-t.y);return 0!=r?(i=o/r,0<=(r=s/r)&&r<=1&&0<=i&&i<=1?(n=new c("Intersection")).appendPoint(new a.Point(t.x+r*(e.x-t.x),t.y+r*(e.y-t.y))):n=new c):n=new c(0==s||0==o?"Coincident":"Parallel"),n},a.Intersection.intersectLinePolygon=function(t,e,i){for(var r,n,s=new c,o=i.length,a=0;a=o&&(s.x-=o),s.x<=-o&&(s.x+=o),s.y>=o&&(s.y-=o),s.y<=o&&(s.y+=o),s.x-=t.offsetX,s.y-=t.offsetY,s}function w(t){return t.flipX!==t.flipY}function O(t,e,i,r,n){0!==t[e]&&(e=n/t._getTransformedDimensions()[r]*t[i],t.set(i,e))}function P(t,e,i,r){var n,s=e.target,o=s._getTransformedDimensions(0,s.skewY),i=T(e,e.originX,e.originY,i,r),r=Math.abs(2*i.x)-o.x,i=s.skewX,r=(r<2?n=0:(n=g(Math.atan2(r/s.scaleX,o.y/s.scaleY)),e.originX===c&&e.originY===u&&(n=-n),e.originX===l&&e.originY===h&&(n=-n),w(s)&&(n=-n)),i!==n);return r&&(o=s._getTransformedDimensions().y,s.set("skewX",n),O(s,"skewY","scaleY","y",o)),r}function k(t,e,i,r){var n,s=e.target,o=s._getTransformedDimensions(s.skewX,0),i=T(e,e.originX,e.originY,i,r),r=Math.abs(2*i.y)-o.y,i=s.skewY,r=(r<2?n=0:(n=g(Math.atan2(r/s.scaleY,o.x/s.scaleX)),e.originX===c&&e.originY===u&&(n=-n),e.originX===l&&e.originY===h&&(n=-n),w(s)&&(n=-n)),i!==n);return r&&(o=s._getTransformedDimensions().x,s.set("skewY",n),O(s,"skewX","scaleX","x",o)),r}function E(t,e,i,r,n){var s=e.target,o=s.lockScalingX,a=s.lockScalingY,n=(n=n||{}).by,t=b(t,s),c=_(s,n,t),h=e.gestureScale;if(c)return!1;if(h)l=e.scaleX*h,u=e.scaleY*h;else{if(c=T(e,e.originX,e.originY,i,r),h="y"!==n?p(c.x):1,i="x"!==n?p(c.y):1,e.signX||(e.signX=h),e.signY||(e.signY=i),s.lockScalingFlip&&(e.signX!==h||e.signY!==i))return!1;var l,u,r=s._getTransformedDimensions();u=t&&!n?(t=Math.abs(c.x)+Math.abs(c.y),f=e.original,t=t/(Math.abs(r.x*f.scaleX/s.scaleX)+Math.abs(r.y*f.scaleY/s.scaleY)),l=f.scaleX*t,f.scaleY*t):(l=Math.abs(c.x*s.scaleX/r.x),Math.abs(c.y*s.scaleY/r.y)),y(e)&&(l*=2,u*=2),e.signX!==h&&"y"!==n&&(e.originX=d[e.originX],l*=-1,e.signX=h),e.signY!==i&&"x"!==n&&(e.originY=d[e.originY],u*=-1,e.signY=i)}var f=s.scaleX,t=s.scaleY;return n?("x"===n&&s.set("scaleX",l),"y"===n&&s.set("scaleY",u)):(o||s.set("scaleX",l),a||s.set("scaleY",u)),f!==s.scaleX||t!==s.scaleY}o.scaleCursorStyleHandler=function(t,e,i){var t=b(t,i),r="";return 0!==e.x&&0===e.y?r="x":0===e.x&&0!==e.y&&(r="y"),_(i,r,t)?"not-allowed":(r=m(i,e),n[r]+"-resize")},o.skewCursorStyleHandler=function(t,e,i){var r="not-allowed";return 0!==e.x&&i.lockSkewingY||0!==e.y&&i.lockSkewingX?r:(r=m(i,e)%4,s[r]+"-resize")},o.scaleSkewCursorStyleHandler=function(t,e,i){return t[i.canvas.altActionKey]?o.skewCursorStyleHandler(t,e,i):o.scaleCursorStyleHandler(t,e,i)},o.rotationWithSnapping=S("rotating",C(function(t,e,i,r){var n=e.target,s=n.translateToOriginPoint(n.getCenterPoint(),e.originX,e.originY);if(n.lockRotation)return!1;var o=Math.atan2(e.ey-s.y,e.ex-s.x),r=Math.atan2(r-s.y,i-s.x),i=g(r-o+e.theta);return 0r.r2,o=(this.gradientTransform||fabric.iMatrix).concat(),a=-this.offsetX,c=-this.offsetY,h=!!e.additionalTransform,l="pixels"===this.gradientUnits?"userSpaceOnUse":"objectBoundingBox";if(n.sort(function(t,e){return t.offset-e.offset}),"objectBoundingBox"==l?(a/=t.width,c/=t.height):(a+=t.width/2,c+=t.height/2),"path"===t.type&&"percentage"!==this.gradientUnits&&(a-=t.pathOffset.x,c-=t.pathOffset.y),o[4]-=a,o[5]-=c,t='id="SVGID_'+this.id+'" gradientUnits="'+l+'"',t+=' gradientTransform="'+(h?e.additionalTransform+" ":"")+fabric.util.matrixToSVG(o)+'" ',"linear"===this.type?i=["\n']:"radial"===this.type&&(i=["\n']),"radial"===this.type){if(s)for((n=n.concat()).reverse(),f=0,d=n.length;f\n')}return i.push("linear"===this.type?"\n":"\n"),i.join("")},toLive:function(t){var e,i,r,n=fabric.util.object.clone(this.coords);if(this.type){for("linear"===this.type?e=t.createLinearGradient(n.x1,n.y1,n.x2,n.y2):"radial"===this.type&&(e=t.createRadialGradient(n.x1,n.y1,n.r1,n.x2,n.y2,n.r2)),i=0,r=this.colorStops.length;i\n\n\n'},setOptions:function(t){for(var e in t)this[e]=t[e]},toLive:function(t){var e=this.source;if(!e)return"";if(void 0!==e.src){if(!e.complete)return"";if(0===e.naturalWidth||0===e.naturalHeight)return""}return t.createPattern(e,this.repeat)}})}(),function(t){"use strict";var o=t.fabric||(t.fabric={}),a=o.util.toFixed;o.Shadow?o.warn("fabric.Shadow is already defined."):(o.Shadow=o.util.createClass({color:"rgb(0,0,0)",blur:0,offsetX:0,offsetY:0,affectStroke:!1,includeDefaultValues:!0,nonScaling:!1,initialize:function(t){for(var e in t="string"==typeof t?this._parseShadow(t):t)this[e]=t[e];this.id=o.Object.__uid++},_parseShadow:function(t){var t=t.trim(),e=o.Shadow.reOffsetsAndBlur.exec(t)||[];return{color:(t.replace(o.Shadow.reOffsetsAndBlur,"")||"rgb(0,0,0)").trim(),offsetX:parseFloat(e[1],10)||0,offsetY:parseFloat(e[2],10)||0,blur:parseFloat(e[3],10)||0}},toString:function(){return[this.offsetX,this.offsetY,this.blur,this.color].join("px ")},toSVG:function(t){var e=40,i=40,r=o.Object.NUM_FRACTION_DIGITS,n=o.util.rotateVector({x:this.offsetX,y:this.offsetY},o.util.degreesToRadians(-t.angle)),s=new o.Color(this.color);return t.width&&t.height&&(e=100*a((Math.abs(n.x)+this.blur)/t.width,r)+20,i=100*a((Math.abs(n.y)+this.blur)/t.height,r)+20),t.flipX&&(n.x*=-1),t.flipY&&(n.y*=-1),'\n\t\n\t\n\t\n\t\n\t\n\t\t\n\t\t\n\t\n\n'},toObject:function(){if(this.includeDefaultValues)return{color:this.color,blur:this.blur,offsetX:this.offsetX,offsetY:this.offsetY,affectStroke:this.affectStroke,nonScaling:this.nonScaling};var e={},i=o.Shadow.prototype;return["color","blur","offsetX","offsetY","affectStroke","nonScaling"].forEach(function(t){this[t]!==i[t]&&(e[t]=this[t])},this),e}}),o.Shadow.reOffsetsAndBlur=/(?:\s|^)(-?\d+(?:\.\d*)?(?:px)?(?:\s?|$))?(-?\d+(?:\.\d*)?(?:px)?(?:\s?|$))?(\d+(?:\.\d*)?(?:px)?)?(?:\s?|$)(?:$|\s)/)}("undefined"!=typeof exports?exports:this),function(){"use strict";var n,t,h,a,s,o,i,r,e;fabric.StaticCanvas?fabric.warn("fabric.StaticCanvas is already defined."):(n=fabric.util.object.extend,t=fabric.util.getElementOffset,h=fabric.util.removeFromArray,a=fabric.util.toFixed,s=fabric.util.transformPoint,o=fabric.util.invertTransform,i=fabric.util.getNodeCanvas,r=fabric.util.createCanvasElement,e=new Error("Could not initialize `canvas` element"),fabric.StaticCanvas=fabric.util.createClass(fabric.CommonMethods,{initialize:function(t,e){e=e||{},this.renderAndResetBound=this.renderAndReset.bind(this),this.requestRenderAllBound=this.requestRenderAll.bind(this),this._initStatic(t,e)},backgroundColor:"",backgroundImage:null,overlayColor:"",overlayImage:null,includeDefaultValues:!0,stateful:!1,renderOnAddRemove:!0,controlsAboveOverlay:!1,allowTouchScrolling:!1,imageSmoothingEnabled:!0,viewportTransform:fabric.iMatrix.concat(),backgroundVpt:!0,overlayVpt:!0,enableRetinaScaling:!0,vptCoords:{},skipOffscreen:!0,clipPath:void 0,_initStatic:function(t,e){var i=this.requestRenderAllBound;this._objects=[],this._createLowerCanvas(t),this._initOptions(e),this.interactive||this._initRetinaScaling(),e.overlayImage&&this.setOverlayImage(e.overlayImage,i),e.backgroundImage&&this.setBackgroundImage(e.backgroundImage,i),e.backgroundColor&&this.setBackgroundColor(e.backgroundColor,i),e.overlayColor&&this.setOverlayColor(e.overlayColor,i),this.calcOffset()},_isRetinaScaling:function(){return 1\n'),this._setSVGBgOverlayColor(i,"background"),this._setSVGBgOverlayImage(i,"backgroundImage",e),this._setSVGObjects(i,e),this.clipPath&&i.push("\n"),this._setSVGBgOverlayColor(i,"overlay"),this._setSVGBgOverlayImage(i,"overlayImage",e),i.push(""),i.join("")},_setSVGPreamble:function(t,e){e.suppressPreamble||t.push('\n','\n')},_setSVGHeader:function(t,e){var i,r=e.width||this.width,n=e.height||this.height,s='viewBox="0 0 '+this.width+" "+this.height+'" ',o=fabric.Object.NUM_FRACTION_DIGITS;e.viewBox?s='viewBox="'+e.viewBox.x+" "+e.viewBox.y+" "+e.viewBox.width+" "+e.viewBox.height+'" ':this.svgViewportTransformation&&(i=this.viewportTransform,s='viewBox="'+a(-i[4]/i[0],o)+" "+a(-i[5]/i[3],o)+" "+a(this.width/i[0],o)+" "+a(this.height/i[3],o)+'" '),t.push("\n',"Created with Fabric.js ",fabric.version,"\n","\n",this.createSVGFontFacesMarkup(),this.createSVGRefElementsMarkup(),this.createSVGClipPathMarkup(e),"\n")},createSVGClipPathMarkup:function(t){var e=this.clipPath;return e?(e.clipPathId="CLIPPATH_"+fabric.Object.__uid++,'\n'+this.clipPath.toClipPathSVG(t.reviver)+"\n"):""},createSVGRefElementsMarkup:function(){var n=this;return["background","overlay"].map(function(t){var e,i,r=n[t+"Color"];if(r&&r.toLive)return t=n[t+"Vpt"],e=n.viewportTransform,i={width:n.width/(t?e[0]:1),height:n.height/(t?e[3]:1)},r.toSVG(i,{additionalTransform:t?fabric.util.matrixToSVG(e):""})}).join("")},createSVGFontFacesMarkup:function(){var t,e,i,r,n,s,o,a,c,h="",l={},u=fabric.fontPaths,f=[];for(this._objects.forEach(function t(e){f.push(e),e._objects&&e._objects.forEach(t)}),o=0,a=f.length;o',"\n",h,"","\n"].join("")},_setSVGObjects:function(t,e){for(var i,r=this._objects,n=0,s=r.length;n\n")):t.push('\n"))},sendToBack:function(t){if(!t)return this;var e,i,r,n=this._activeObject;if(t===n&&"activeSelection"===t.type)for(e=(r=n._objects).length;e--;)i=r[e],h(this._objects,i),this._objects.unshift(i);else h(this._objects,t),this._objects.unshift(t);return this.renderOnAddRemove&&this.requestRenderAll(),this},bringToFront:function(t){if(!t)return this;var e,i,r,n=this._activeObject;if(t===n&&"activeSelection"===t.type)for(r=n._objects,e=0;e"}}),n(fabric.StaticCanvas.prototype,fabric.Observable),n(fabric.StaticCanvas.prototype,fabric.Collection),n(fabric.StaticCanvas.prototype,fabric.DataURLExporter),n(fabric.StaticCanvas,{EMPTY_JSON:'{"objects": [], "background": "white"}',supports:function(t){var e=r();if(!e||!e.getContext)return null;e=e.getContext("2d");return!e||"setLineDash"!==t?null:void 0!==e.setLineDash}}),fabric.StaticCanvas.prototype.toJSON=fabric.StaticCanvas.prototype.toObject,fabric.isLikelyNode&&(fabric.StaticCanvas.prototype.createPNGStream=function(){var t=i(this.lowerCanvasEl);return t&&t.createPNGStream()},fabric.StaticCanvas.prototype.createJPEGStream=function(t){var e=i(this.lowerCanvasEl);return e&&e.createJPEGStream(t)}))}(),fabric.BaseBrush=fabric.util.createClass({color:"rgb(0, 0, 0)",width:1,shadow:null,strokeLineCap:"round",strokeLineJoin:"round",strokeMiterLimit:10,strokeDashArray:null,limitedToCanvasSize:!1,_setBrushStyles:function(t){t.strokeStyle=this.color,t.lineWidth=this.width,t.lineCap=this.strokeLineCap,t.miterLimit=this.strokeMiterLimit,t.lineJoin=this.strokeLineJoin,t.setLineDash(this.strokeDashArray||[])},_saveAndTransform:function(t){var e=this.canvas.viewportTransform;t.save(),t.transform(e[0],e[1],e[2],e[3],e[4],e[5])},_setShadow:function(){var t,e,i,r;this.shadow&&(t=this.canvas,e=this.shadow,i=t.contextTop,r=t.getZoom(),t&&t._isRetinaScaling()&&(r*=fabric.devicePixelRatio),i.shadowColor=e.color,i.shadowBlur=e.blur*r,i.shadowOffsetX=e.offsetX*r,i.shadowOffsetY=e.offsetY*r)},needsFullRender:function(){return new fabric.Color(this.color).getAlpha()<1||!!this.shadow},_resetShadow:function(){var t=this.canvas.contextTop;t.shadowColor="",t.shadowBlur=t.shadowOffsetX=t.shadowOffsetY=0},_isOutSideCanvas:function(t){return t.x<0||t.x>this.canvas.getWidth()||t.y<0||t.y>this.canvas.getHeight()}}),fabric.PencilBrush=fabric.util.createClass(fabric.BaseBrush,{decimate:.4,drawStraightLine:!1,straightLineKey:"shiftKey",initialize:function(t){this.canvas=t,this._points=[]},needsFullRender:function(){return this.callSuper("needsFullRender")||this._hasStraightLine},_drawSegment:function(t,e,i){i=e.midPointFrom(i);return t.quadraticCurveTo(e.x,e.y,i.x,i.y),i},onMouseDown:function(t,e){this.canvas._isMainEvent(e.e)&&(this.drawStraightLine=e.e[this.straightLineKey],this._prepareForDrawing(t),this._captureDrawingPath(t),this._render())},onMouseMove:function(t,e){var i;this.canvas._isMainEvent(e.e)&&(this.drawStraightLine=e.e[this.straightLineKey],!0===this.limitedToCanvasSize&&this._isOutSideCanvas(t)||this._captureDrawingPath(t)&&1"},getObjectScaling:function(){if(!this.group)return{scaleX:this.scaleX,scaleY:this.scaleY};var t=g.util.qrDecompose(this.calcTransformMatrix());return{scaleX:Math.abs(t.scaleX),scaleY:Math.abs(t.scaleY)}},getTotalObjectScaling:function(){var t,e,i=this.getObjectScaling(),r=i.scaleX,i=i.scaleY;return this.canvas&&(r*=(t=this.canvas.getZoom())*(e=this.canvas.getRetinaScaling()),i*=t*e),{scaleX:r,scaleY:i}},getObjectOpacity:function(){var t=this.opacity;return this.group&&(t*=this.group.getObjectOpacity()),t},_set:function(t,e){var i=this[t]!==e;return("scaleX"===t||"scaleY"===t)&&(e=this._constrainScale(e)),"scaleX"===t&&e<0?(this.flipX=!this.flipX,e*=-1):"scaleY"===t&&e<0?(this.flipY=!this.flipY,e*=-1):"shadow"!==t||!e||e instanceof g.Shadow?"dirty"===t&&this.group&&this.group.set("dirty",e):e=new g.Shadow(e),this[t]=e,i&&(e=this.group&&this.group.isOnACache(),-1=t.x&&i.left+i.width<=e.x&&i.top>=t.y&&i.top+i.height<=e.y},containsPoint:function(t,e,i,r){i=this._getCoords(i,r),e=e||this._getImageLines(i),r=this._findCrossPoints(t,e);return 0!==r&&r%2==1},isOnScreen:function(t){if(!this.canvas)return!1;var e=this.canvas.vptCoords.tl,i=this.canvas.vptCoords.br;return!!this.getCoords(!0,t).some(function(t){return t.x<=i.x&&t.x>=e.x&&t.y<=i.y&&t.y>=e.y})||(!!this.intersectsWithRect(e,i,!0,t)||this._containsCenterOfCanvas(e,i,t))},_containsCenterOfCanvas:function(t,e,i){t={x:(t.x+e.x)/2,y:(t.y+e.y)/2};return!!this.containsPoint(t,null,!0,i)},isPartiallyOnScreen:function(t){if(!this.canvas)return!1;var e=this.canvas.vptCoords.tl,i=this.canvas.vptCoords.br;return!!this.intersectsWithRect(e,i,!0,t)||this.getCoords(!0,t).every(function(t){return(t.x>=i.x||t.x<=e.x)&&(t.y>=i.y||t.y<=e.y)})&&this._containsCenterOfCanvas(e,i,t)},_getImageLines:function(t){return{topline:{o:t.tl,d:t.tr},rightline:{o:t.tr,d:t.br},bottomline:{o:t.br,d:t.bl},leftline:{o:t.bl,d:t.tl}}},_findCrossPoints:function(t,e){var i,r,n,s=0;for(n in e)if(!((r=e[n]).o.y=t.y&&r.d.y>=t.y||((r.o.x===r.d.x&&r.o.x>=t.x?r.o.x:(i=(r.d.y-r.o.y)/(r.d.x-r.o.x),-(t.y-0*t.x-(r.o.y-i*r.o.x))/(0-i)))>=t.x&&(s+=1),2!==s)))break;return s},getBoundingRect:function(t,e){t=this.getCoords(t,e);return s.makeBoundingBoxFromPoints(t)},getScaledWidth:function(){return this._getTransformedDimensions().x},getScaledHeight:function(){return this._getTransformedDimensions().y},_constrainScale:function(t){return Math.abs(t)\n'))},toSVG:function(t){return this._createBaseSVGMarkup(this._toSVG(t),{reviver:t})},toClipPathSVG:function(t){return"\t"+this._createBaseClipPathSVGMarkup(this._toSVG(t),{reviver:t})},_createBaseClipPathSVGMarkup:function(t,e){var i=(e=e||{}).reviver,e=e.additionalTransform||"",e=[this.getSvgTransform(!0,e),this.getSvgCommons()].join(""),r=t.indexOf("COMMON_PARTS");return t[r]=e,i?i(t.join("")):t.join("")},_createBaseSVGMarkup:function(t,e){var i,r=(e=e||{}).noStyle,n=e.reviver,s=r?"":'style="'+this.getSvgStyles()+'" ',o=e.withShadow?'style="'+this.getSvgFilter()+'" ':"",a=this.clipPath,c=this.strokeUniform?'vector-effect="non-scaling-stroke" ':"",h=a&&a.absolutePositioned,l=this.stroke,u=this.fill,f=this.shadow,d=[],g=t.indexOf("COMMON_PARTS"),e=e.additionalTransform;return a&&(a.clipPathId="CLIPPATH_"+fabric.Object.__uid++,i='\n'+a.toClipPathSVG(n)+"\n"),h&&d.push("\n"),d.push("\n"),o=[s,c,r?"":this.addPaintOrder()," ",e?'transform="'+e+'" ':""].join(""),t[g]=o,u&&u.toLive&&d.push(u.toSVG(this)),l&&l.toLive&&d.push(l.toSVG(this)),f&&d.push(f.toSVG(this)),a&&d.push(i),d.push(t.join("")),d.push("\n"),h&&d.push("\n"),n?n(d.join("")):d.join("")},addPaintOrder:function(){return"fill"!==this.paintFirst?' paint-order="'+this.paintFirst+'" ':""}})}(),function(){var n=fabric.util.object.extend,r="stateProperties";function s(e,t,i){var r={};i.forEach(function(t){r[t]=e[t]}),n(e[t],r,!0)}fabric.util.object.extend(fabric.Object.prototype,{hasStateChanged:function(t){var e="_"+(t=t||r);return Object.keys(this[e]).length\n']}}),n.Line.ATTRIBUTE_NAMES=n.SHARED_ATTRIBUTES.concat("x1 y1 x2 y2".split(" ")),n.Line.fromElement=function(t,e,i){i=i||{};var t=n.parseAttributes(t,n.Line.ATTRIBUTE_NAMES),r=[t.x1||0,t.y1||0,t.x2||0,t.y2||0];e(new n.Line(r,s(t,i)))},n.Line.fromObject=function(t,e){var i=r(t,!0);i.points=[t.x1,t.y1,t.x2,t.y2],n.Object._fromObject("Line",i,function(t){delete t.points,e&&e(t)},"points")})}("undefined"!=typeof exports?exports:this),function(t){"use strict";var n=t.fabric||(t.fabric={}),s=n.util.degreesToRadians;n.Circle?n.warn("fabric.Circle is already defined."):(n.Circle=n.util.createClass(n.Object,{type:"circle",radius:0,startAngle:0,endAngle:360,cacheProperties:n.Object.prototype.cacheProperties.concat("radius","startAngle","endAngle"),_set:function(t,e){return this.callSuper("_set",t,e),"radius"===t&&this.setRadius(e),this},toObject:function(t){return this.callSuper("toObject",["radius","startAngle","endAngle"].concat(t))},_toSVG:function(){var t,e,i,r=(this.endAngle-this.startAngle)%360;return 0==r?["\n']:(t=s(this.startAngle),e=s(this.endAngle),i=this.radius,['\n"])},_render:function(t){t.beginPath(),t.arc(0,0,this.radius,s(this.startAngle),s(this.endAngle),!1),this._renderPaintInOrder(t)},getRadiusX:function(){return this.get("radius")*this.get("scaleX")},getRadiusY:function(){return this.get("radius")*this.get("scaleY")},setRadius:function(t){return this.radius=t,this.set("width",2*t).set("height",2*t)}}),n.Circle.ATTRIBUTE_NAMES=n.SHARED_ATTRIBUTES.concat("cx cy r".split(" ")),n.Circle.fromElement=function(t,e){var i,t=n.parseAttributes(t,n.Circle.ATTRIBUTE_NAMES);if(!("radius"in(i=t)&&0<=i.radius))throw new Error("value of `r` attribute is required and can not be negative");t.left=(t.left||0)-t.radius,t.top=(t.top||0)-t.radius,e(new n.Circle(t))},n.Circle.fromObject=function(t,e){n.Object._fromObject("Circle",t,e)})}("undefined"!=typeof exports?exports:this),function(t){"use strict";var i=t.fabric||(t.fabric={});i.Triangle?i.warn("fabric.Triangle is already defined"):(i.Triangle=i.util.createClass(i.Object,{type:"triangle",width:100,height:100,_render:function(t){var e=this.width/2,i=this.height/2;t.beginPath(),t.moveTo(-e,i),t.lineTo(0,-i),t.lineTo(e,i),t.closePath(),this._renderPaintInOrder(t)},_toSVG:function(){var t=this.width/2,e=this.height/2;return["']}}),i.Triangle.fromObject=function(t,e){return i.Object._fromObject("Triangle",t,e)})}("undefined"!=typeof exports?exports:this),function(t){"use strict";var i=t.fabric||(t.fabric={}),e=2*Math.PI;i.Ellipse?i.warn("fabric.Ellipse is already defined."):(i.Ellipse=i.util.createClass(i.Object,{type:"ellipse",rx:0,ry:0,cacheProperties:i.Object.prototype.cacheProperties.concat("rx","ry"),initialize:function(t){this.callSuper("initialize",t),this.set("rx",t&&t.rx||0),this.set("ry",t&&t.ry||0)},_set:function(t,e){switch(this.callSuper("_set",t,e),t){case"rx":this.rx=e,this.set("width",2*e);break;case"ry":this.ry=e,this.set("height",2*e)}return this},getRx:function(){return this.get("rx")*this.get("scaleX")},getRy:function(){return this.get("ry")*this.get("scaleY")},toObject:function(t){return this.callSuper("toObject",["rx","ry"].concat(t))},_toSVG:function(){return["\n']},_render:function(t){t.beginPath(),t.save(),t.transform(1,0,0,this.ry/this.rx,0,0),t.arc(0,0,this.rx,0,e,!1),t.restore(),this._renderPaintInOrder(t)}}),i.Ellipse.ATTRIBUTE_NAMES=i.SHARED_ATTRIBUTES.concat("cx cy rx ry".split(" ")),i.Ellipse.fromElement=function(t,e){t=i.parseAttributes(t,i.Ellipse.ATTRIBUTE_NAMES);t.left=(t.left||0)-t.rx,t.top=(t.top||0)-t.ry,e(new i.Ellipse(t))},i.Ellipse.fromObject=function(t,e){i.Object._fromObject("Ellipse",t,e)})}("undefined"!=typeof exports?exports:this),function(t){"use strict";var r=t.fabric||(t.fabric={}),n=r.util.object.extend;r.Rect?r.warn("fabric.Rect is already defined"):(r.Rect=r.util.createClass(r.Object,{stateProperties:r.Object.prototype.stateProperties.concat("rx","ry"),type:"rect",rx:0,ry:0,cacheProperties:r.Object.prototype.cacheProperties.concat("rx","ry"),initialize:function(t){this.callSuper("initialize",t),this._initRxRy()},_initRxRy:function(){this.rx&&!this.ry?this.ry=this.rx:this.ry&&!this.rx&&(this.rx=this.ry)},_render:function(t){var e=this.rx?Math.min(this.rx,this.width/2):0,i=this.ry?Math.min(this.ry,this.height/2):0,r=this.width,n=this.height,s=-this.width/2,o=-this.height/2,a=0!==e||0!==i,c=.4477152502;t.beginPath(),t.moveTo(s+e,o),t.lineTo(s+r-e,o),a&&t.bezierCurveTo(s+r-c*e,o,s+r,o+c*i,s+r,o+i),t.lineTo(s+r,o+n-i),a&&t.bezierCurveTo(s+r,o+n-c*i,s+r-c*e,o+n,s+r-e,o+n),t.lineTo(s+e,o+n),a&&t.bezierCurveTo(s+c*e,o+n,s,o+n-c*i,s,o+n-i),t.lineTo(s,o+i),a&&t.bezierCurveTo(s,o+c*i,s+c*e,o,s+e,o),t.closePath(),this._renderPaintInOrder(t)},toObject:function(t){return this.callSuper("toObject",["rx","ry"].concat(t))},_toSVG:function(){return["\n']}}),r.Rect.ATTRIBUTE_NAMES=r.SHARED_ATTRIBUTES.concat("x y rx ry width height".split(" ")),r.Rect.fromElement=function(t,e,i){if(!t)return e(null);i=i||{};t=r.parseAttributes(t,r.Rect.ATTRIBUTE_NAMES),t.left=t.left||0,t.top=t.top||0,t.height=t.height||0,t.width=t.width||0,i=new r.Rect(n(i?r.util.object.clone(i):{},t));i.visible=i.visible&&0\n']},commonRender:function(t){var e,i=this.points.length,r=this.pathOffset.x,n=this.pathOffset.y;if(!i||isNaN(this.points[i-1].y))return!1;t.beginPath(),t.moveTo(this.points[0].x-r,this.points[0].y-n);for(var s=0;s"},toObject:function(t){return r(this.callSuper("toObject",t),{path:this.path.map(function(t){return t.slice()})})},toDatalessObject:function(t){t=this.toObject(["sourcePath"].concat(t));return t.sourcePath&&delete t.path,t},_toSVG:function(){return["\n"]},_getOffsetTransform:function(){var t=f.Object.NUM_FRACTION_DIGITS;return" translate("+e(-this.pathOffset.x,t)+", "+e(-this.pathOffset.y,t)+")"},toClipPathSVG:function(t){var e=this._getOffsetTransform();return"\t"+this._createBaseClipPathSVGMarkup(this._toSVG(),{reviver:t,additionalTransform:e})},toSVG:function(t){var e=this._getOffsetTransform();return this._createBaseSVGMarkup(this._toSVG(),{reviver:t,additionalTransform:e})},complexity:function(){return this.path.length},_calcDimensions:function(){for(var t,e,i=[],r=[],n=0,s=0,o=0,a=0,c=0,h=this.path.length;c"},addWithUpdate:function(t){var e=!!this.group;return this._restoreObjectsState(),o.util.resetObjectTransform(this),t&&(e&&o.util.removeTransformFromObject(t,this.group.calcTransformMatrix()),this._objects.push(t),t.group=this,t._set("canvas",this.canvas)),this._calcBounds(),this._updateObjectsCoords(),this.dirty=!0,e?this.group.addWithUpdate():this.setCoords(),this},removeWithUpdate:function(t){return this._restoreObjectsState(),o.util.resetObjectTransform(this),this.remove(t),this._calcBounds(),this._updateObjectsCoords(),this.setCoords(),this.dirty=!0,this},_onObjectAdded:function(t){this.dirty=!0,t.group=this,t._set("canvas",this.canvas)},_onObjectRemoved:function(t){this.dirty=!0,delete t.group},_set:function(t,e){var i=this._objects.length;if(this.useSetOnGroup)for(;i--;)this._objects[i].setOnGroup(t,e);if("canvas"===t)for(;i--;)this._objects[i]._set(t,e);o.Object.prototype._set.call(this,t,e)},toObject:function(r){var n=this.includeDefaultValues,t=this._objects.filter(function(t){return!t.excludeFromExport}).map(function(t){var e=t.includeDefaultValues,i=(t.includeDefaultValues=n,t.toObject(r));return t.includeDefaultValues=e,i}),e=o.Object.prototype.toObject.call(this,r);return e.objects=t,e},toDatalessObject:function(r){var n,t=this.sourcePath,e=(t=t||(n=this.includeDefaultValues,this._objects.map(function(t){var e=t.includeDefaultValues,i=(t.includeDefaultValues=n,t.toDatalessObject(r));return t.includeDefaultValues=e,i})),o.Object.prototype.toDatalessObject.call(this,r));return e.objects=t,e},render:function(t){this._transformDone=!0,this.callSuper("render",t),this._transformDone=!1},shouldCache:function(){var t=o.Object.prototype.shouldCache.call(this);if(t)for(var e=0,i=this._objects.length;e\n"],i=0,r=this._objects.length;i\n"),e},getSvgStyles:function(){var t=void 0!==this.opacity&&1!==this.opacity?"opacity: "+this.opacity+";":"",e=this.visible?"":" visibility: hidden;";return[t,this.getSvgFilter(),e].join("")},toClipPathSVG:function(t){for(var e=[],i=0,r=this._objects.length;i"},shouldCache:function(){return!1},isOnACache:function(){return!1},_renderControls:function(t,e,i){t.save(),t.globalAlpha=this.isMoving?this.borderOpacityWhenMoving:1,this.callSuper("_renderControls",t,e),void 0===(i=i||{}).hasControls&&(i.hasControls=!1),i.forActiveSelection=!0;for(var r=0,n=this._objects.length;r\n','\t\n',"\n"),a=' clip-path="url(#imageCrop_'+e+')" '),this.imageSmoothing||(c='" image-rendering="optimizeSpeed'),r.push("\t\n"),(this.stroke||this.strokeDashArray)&&(e=this.fill,this.fill=null,t=["\t\n'],this.fill=e),"fill"!==this.paintFirst?i.concat(t,r):i.concat(r,t)):[]},getSrc:function(t){t=t?this._element:this._originalElement;return t?t.toDataURL?t.toDataURL():this.srcFromAttribute?t.getAttribute("src"):t.src:this.src||""},setSrc:function(t,i,r){return fabric.util.loadImage(t,function(t,e){this.setElement(t,r),this._setWidthHeight(),i&&i(this,e)},this,r&&r.crossOrigin),this},toString:function(){return'#'},applyResizeFilters:function(){var t=this.resizeFilter,e=this.minimumScaleTrigger,i=this.getTotalObjectScaling(),r=i.scaleX,i=i.scaleY,n=this._filteredEl||this._originalElement;if(this.group&&this.set("dirty",!0),!t||e=t,o=["highp","mediump","lowp"],a=0;a<3;a++)if(r=void 0,i="precision "+(i=o[a])+" float;\nvoid main(){}",r=(e=s).createShader(e.FRAGMENT_SHADER),e.shaderSource(r,i),e.compileShader(r),!!e.getShaderParameter(r,e.COMPILE_STATUS)){fabric.webGlPrecision=o[a];break}}return this.isSupported=n},(fabric.WebglFilterBackend=t).prototype={tileSize:2048,resources:{},setupGLContext:function(t,e){this.dispose(),this.createWebGLCanvas(t,e),this.aPosition=new Float32Array([0,0,0,1,1,0,1,1]),this.chooseFastestCopyGLTo2DMethod(t,e)},chooseFastestCopyGLTo2DMethod:function(t,e){var i=void 0!==window.performance;try{new ImageData(1,1),s=!0}catch(t){s=!1}var r="undefined"!=typeof ArrayBuffer,n="undefined"!=typeof Uint8ClampedArray;if(i&&s&&r&&n){var i=fabric.util.createCanvasElement(),s=new ArrayBuffer(t*e*4);if(fabric.forceGLPutImageData)return this.imageBuffer=s,void(this.copyGLTo2D=copyGLTo2DPutImageData);r={imageBuffer:s,destinationWidth:t,destinationHeight:e,targetCanvas:i};i.width=t,i.height=e,n=window.performance.now(),copyGLTo2DDrawImage.call(r,this.gl,r),t=window.performance.now()-n,n=window.performance.now(),copyGLTo2DPutImageData.call(r,this.gl,r),window.performance.now()-n 0.0) {\n"+this.fragmentSource[t]+"}\n}"},retrieveShader:function(t){var e,i=this.type+"_"+this.mode;return t.programCache.hasOwnProperty(i)||(e=this.buildSource(this.mode),t.programCache[i]=this.createProgram(t.context,e)),t.programCache[i]},applyTo2d:function(t){for(var e,i,r,n=t.imageData.data,s=n.length,o=1-this.alpha,t=new u.Color(this.color).getSource(),a=t[0]*this.alpha,c=t[1]*this.alpha,h=t[2]*this.alpha,l=0;l'},_getCacheCanvasDimensions:function(){var t=this.callSuper("_getCacheCanvasDimensions"),e=this.fontSize;return t.width+=e*t.zoomX,t.height+=e*t.zoomY,t},_render:function(t){var e=this.path;e&&!e.isNotVisible()&&e._render(t),this._setTextStyles(t),this._renderTextLinesBackground(t),this._renderTextDecoration(t,"underline"),this._renderText(t),this._renderTextDecoration(t,"overline"),this._renderTextDecoration(t,"linethrough")},_renderText:function(t){"stroke"===this.paintFirst?(this._renderTextStroke(t),this._renderTextFill(t)):(this._renderTextFill(t),this._renderTextStroke(t))},_setTextStyles:function(t,e,i){if(t.textBaseline="alphabetical",this.path)switch(this.pathAlign){case"center":t.textBaseline="middle";break;case"ascender":t.textBaseline="top";break;case"descender":t.textBaseline="bottom"}t.font=this._getFontDeclaration(e,i)},calcTextWidth:function(){for(var t=this.getLineWidth(0),e=1,i=this._textLines.length;ethis.__selectionStartOnMouseDown?(this.selectionStart=this.__selectionStartOnMouseDown,this.selectionEnd=t):(this.selectionStart=t,this.selectionEnd=this.__selectionStartOnMouseDown),this.selectionStart===e&&this.selectionEnd===i||(this.restartCursorIfNeeded(),this._fireSelectionChanged(),this._updateTextarea(),this.renderCursorOrSelection())))},_setEditingProps:function(){this.hoverCursor="text",this.canvas&&(this.canvas.defaultCursor=this.canvas.moveCursor="text"),this.borderColor=this.editingBorderColor,this.hasControls=this.selectable=!1,this.lockMovementX=this.lockMovementY=!0},fromStringToGraphemeSelection:function(t,e,i){var r=i.slice(0,t),r=fabric.util.string.graphemeSplit(r).length;if(t===e)return{selectionStart:r,selectionEnd:r};i=i.slice(t,e);return{selectionStart:r,selectionEnd:r+fabric.util.string.graphemeSplit(i).length}},fromGraphemeToStringSelection:function(t,e,i){var r=i.slice(0,t).join("").length;return t===e?{selectionStart:r,selectionEnd:r}:{selectionStart:r,selectionEnd:r+i.slice(t,e).join("").length}},_updateTextarea:function(){var t;this.cursorOffsetCache={},this.hiddenTextarea&&(this.inCompositionMode||(t=this.fromGraphemeToStringSelection(this.selectionStart,this.selectionEnd,this._text),this.hiddenTextarea.selectionStart=t.selectionStart,this.hiddenTextarea.selectionEnd=t.selectionEnd),this.updateTextareaPosition())},updateFromTextArea:function(){var t;this.hiddenTextarea&&(this.cursorOffsetCache={},this.text=this.hiddenTextarea.value,this._shouldClearDimensionCache()&&(this.initDimensions(),this.setCoords()),t=this.fromStringToGraphemeSelection(this.hiddenTextarea.selectionStart,this.hiddenTextarea.selectionEnd,this.hiddenTextarea.value),this.selectionEnd=this.selectionStart=t.selectionEnd,this.inCompositionMode||(this.selectionStart=t.selectionStart),this.updateTextareaPosition())},updateTextareaPosition:function(){var t;this.selectionStart===this.selectionEnd&&(t=this._calcTextareaPosition(),this.hiddenTextarea.style.left=t.left,this.hiddenTextarea.style.top=t.top)},_calcTextareaPosition:function(){if(!this.canvas)return{x:1,y:1};var t=this.inCompositionMode?this.compositionStart:this.selectionStart,e=this._getCursorBoundaries(t),t=this.get2DCursorLocation(t),i=t.lineIndex,t=t.charIndex,i=this.getValueOfPropertyAt(i,t,"fontSize")*this.lineHeight,t=e.leftOffset,r=this.calcTransformMatrix(),t={x:e.left+t,y:e.top+e.topOffset+i},e=this.canvas.getRetinaScaling(),n=this.canvas.upperCanvasEl,s=n.width/e,e=n.height/e,o=s-i,a=e-i,s=n.clientWidth/s,n=n.clientHeight/e,t=fabric.util.transformPoint(t,r);return(t=fabric.util.transformPoint(t,this.canvas.viewportTransform)).x*=s,t.y*=n,t.x<0&&(t.x=0),t.x>o&&(t.x=o),t.y<0&&(t.y=0),t.y>a&&(t.y=a),t.x+=this.canvas._offset.left,t.y+=this.canvas._offset.top,{left:t.x+"px",top:t.y+"px",fontSize:i+"px",charHeight:i}},_saveEditingProps:function(){this._savedProps={hasControls:this.hasControls,borderColor:this.borderColor,lockMovementX:this.lockMovementX,lockMovementY:this.lockMovementY,hoverCursor:this.hoverCursor,selectable:this.selectable,defaultCursor:this.canvas&&this.canvas.defaultCursor,moveCursor:this.canvas&&this.canvas.moveCursor}},_restoreEditingProps:function(){this._savedProps&&(this.hoverCursor=this._savedProps.hoverCursor,this.hasControls=this._savedProps.hasControls,this.borderColor=this._savedProps.borderColor,this.selectable=this._savedProps.selectable,this.lockMovementX=this._savedProps.lockMovementX,this.lockMovementY=this._savedProps.lockMovementY,this.canvas&&(this.canvas.defaultCursor=this._savedProps.defaultCursor,this.canvas.moveCursor=this._savedProps.moveCursor))},exitEditing:function(){var t=this._textBeforeEdit!==this.text,e=this.hiddenTextarea;return this.selected=!1,this.isEditing=!1,this.selectionEnd=this.selectionStart,e&&(e.blur&&e.blur(),e.parentNode&&e.parentNode.removeChild(e)),this.hiddenTextarea=null,this.abortCursorAnimation(),this._restoreEditingProps(),this._currentCursorOpacity=0,this._shouldClearDimensionCache()&&(this.initDimensions(),this.setCoords()),this.fire("editing:exited"),t&&this.fire("modified"),this.canvas&&(this.canvas.off("mouse:move",this.mouseMoveHandler),this.canvas.fire("text:editing:exited",{target:this}),t&&this.canvas.fire("object:modified",{target:this})),this},_removeExtraneousStyles:function(){for(var t in this.styles)this._textLines[t]||delete this.styles[t]},removeStyleFromTo:function(t,e){var t=this.get2DCursorLocation(t,!0),e=this.get2DCursorLocation(e,!0),i=t.lineIndex,r=t.charIndex,n=e.lineIndex,s=e.charIndex;if(i!==n){if(this.styles[i])for(l=r;lt?this.selectionStart=t:this.selectionStart<0&&(this.selectionStart=0),this.selectionEnd>t?this.selectionEnd=t:this.selectionEnd<0&&(this.selectionEnd=0)}})}(),fabric.util.object.extend(fabric.IText.prototype,{initDoubleClickSimulation:function(){this.__lastClickTime=+new Date,this.__lastLastClickTime=+new Date,this.__lastPointer={},this.on("mousedown",this.onMouseDown)},onMouseDown:function(t){var e;this.canvas&&(this.__newClickTime=+new Date,e=t.pointer,this.isTripleClick(e)&&(this.fire("tripleclick",t),this._stopEvent(t.e)),this.__lastLastClickTime=this.__lastClickTime,this.__lastClickTime=this.__newClickTime,this.__lastPointer=e,this.__lastIsEditing=this.isEditing,this.__lastSelected=this.selected)},isTripleClick:function(t){return this.__newClickTime-this.__lastClickTime<500&&this.__lastClickTime-this.__lastLastClickTime<500&&this.__lastPointer.x===t.x&&this.__lastPointer.y===t.y},_stopEvent:function(t){t.preventDefault&&t.preventDefault(),t.stopPropagation&&t.stopPropagation()},initCursorSelectionHandlers:function(){this.initMousedownHandler(),this.initMouseupHandler(),this.initClicks()},doubleClickHandler:function(t){this.isEditing&&this.selectWord(this.getSelectionStartFromPointer(t.e))},tripleClickHandler:function(t){this.isEditing&&this.selectLine(this.getSelectionStartFromPointer(t.e))},initClicks:function(){this.on("mousedblclick",this.doubleClickHandler),this.on("tripleclick",this.tripleClickHandler)},_mouseDownHandler:function(t){!this.canvas||!this.editable||t.e.button&&1!==t.e.button||(this.__isMousedown=!0,this.selected&&(this.inCompositionMode=!1,this.setCursorByClick(t.e)),this.isEditing&&(this.__selectionStartOnMouseDown=this.selectionStart,this.selectionStart===this.selectionEnd&&this.abortCursorAnimation(),this.renderCursorOrSelection()))},_mouseDownHandlerBefore:function(t){!this.canvas||!this.editable||t.e.button&&1!==t.e.button||(this.selected=this===this.canvas._activeObject)},initMousedownHandler:function(){this.on("mousedown",this._mouseDownHandler),this.on("mousedown:before",this._mouseDownHandlerBefore)},initMouseupHandler:function(){this.on("mouseup",this.mouseUpHandler)},mouseUpHandler:function(t){if(this.__isMousedown=!1,!(!this.editable||this.group||t.transform&&t.transform.actionPerformed||t.e.button&&1!==t.e.button)){if(this.canvas){var e=this.canvas._activeObject;if(e&&e!==this)return}this.__lastSelected&&!this.__corner?(this.selected=!1,this.__lastSelected=!1,this.enterEditing(t.e),this.selectionStart===this.selectionEnd?this.initDelayedCursor(!0):this.renderCursorOrSelection()):this.selected=!0}},setCursorByClick:function(t){var e=this.getSelectionStartFromPointer(t),i=this.selectionStart,r=this.selectionEnd;t.shiftKey?this.setSelectionStartEndWithShift(i,r,e):(this.selectionStart=e,this.selectionEnd=e),this.isEditing&&(this._fireSelectionChanged(),this._updateTextarea())},getSelectionStartFromPointer:function(t){for(var e=this.getLocalPointer(t),i=0,r=0,n=0,s=0,o=0,a=0,c=this._textLines.length;athis._text.length?this._text.length:t}}),fabric.util.object.extend(fabric.IText.prototype,{initHiddenTextarea:function(){this.hiddenTextarea=fabric.document.createElement("textarea"),this.hiddenTextarea.setAttribute("autocapitalize","off"),this.hiddenTextarea.setAttribute("autocorrect","off"),this.hiddenTextarea.setAttribute("autocomplete","off"),this.hiddenTextarea.setAttribute("spellcheck","false"),this.hiddenTextarea.setAttribute("data-fabric-hiddentextarea",""),this.hiddenTextarea.setAttribute("wrap","off");var t=this._calcTextareaPosition();this.hiddenTextarea.style.cssText="position: absolute; top: "+t.top+"; left: "+t.left+"; z-index: -999; opacity: 0; width: 1px; height: 1px; font-size: 1px; padding-top: "+t.fontSize+";",(this.hiddenTextareaContainer||fabric.document.body).appendChild(this.hiddenTextarea),fabric.util.addListener(this.hiddenTextarea,"keydown",this.onKeyDown.bind(this)),fabric.util.addListener(this.hiddenTextarea,"keyup",this.onKeyUp.bind(this)),fabric.util.addListener(this.hiddenTextarea,"input",this.onInput.bind(this)),fabric.util.addListener(this.hiddenTextarea,"copy",this.copy.bind(this)),fabric.util.addListener(this.hiddenTextarea,"cut",this.copy.bind(this)),fabric.util.addListener(this.hiddenTextarea,"paste",this.paste.bind(this)),fabric.util.addListener(this.hiddenTextarea,"compositionstart",this.onCompositionStart.bind(this)),fabric.util.addListener(this.hiddenTextarea,"compositionupdate",this.onCompositionUpdate.bind(this)),fabric.util.addListener(this.hiddenTextarea,"compositionend",this.onCompositionEnd.bind(this)),!this._clickHandlerInitialized&&this.canvas&&(fabric.util.addListener(this.canvas.upperCanvasEl,"click",this.onClick.bind(this)),this._clickHandlerInitialized=!0)},keysMap:{9:"exitEditing",27:"exitEditing",33:"moveCursorUp",34:"moveCursorDown",35:"moveCursorRight",36:"moveCursorLeft",37:"moveCursorLeft",38:"moveCursorUp",39:"moveCursorRight",40:"moveCursorDown"},keysMapRtl:{9:"exitEditing",27:"exitEditing",33:"moveCursorUp",34:"moveCursorDown",35:"moveCursorLeft",36:"moveCursorRight",37:"moveCursorRight",38:"moveCursorUp",39:"moveCursorLeft",40:"moveCursorDown"},ctrlKeysMapUp:{67:"copy",88:"cut"},ctrlKeysMapDown:{65:"selectAll"},onClick:function(){this.hiddenTextarea&&this.hiddenTextarea.focus()},onKeyDown:function(t){if(this.isEditing){var e="rtl"===this.direction?this.keysMapRtl:this.keysMap;if(t.keyCode in e)this[e[t.keyCode]](t);else{if(!(t.keyCode in this.ctrlKeysMapDown&&(t.ctrlKey||t.metaKey)))return;this[this.ctrlKeysMapDown[t.keyCode]](t)}t.stopImmediatePropagation(),t.preventDefault(),33<=t.keyCode&&t.keyCode<=40?(this.inCompositionMode=!1,this.clearContextTop(),this.renderCursorOrSelection()):this.canvas&&this.canvas.requestRenderAll()}},onKeyUp:function(t){!this.isEditing||this._copyDone||this.inCompositionMode?this._copyDone=!1:t.keyCode in this.ctrlKeysMapUp&&(t.ctrlKey||t.metaKey)&&(this[this.ctrlKeysMapUp[t.keyCode]](t),t.stopImmediatePropagation(),t.preventDefault(),this.canvas&&this.canvas.requestRenderAll())},onInput:function(t){var e=this.fromPaste;if(this.fromPaste=!1,t&&t.stopPropagation(),this.isEditing){var i,r,n,t=this._splitTextIntoLines(this.hiddenTextarea.value).graphemeText,s=this._text.length,o=t.length,a=o-s,c=this.selectionStart,h=this.selectionEnd,l=c!==h;if(""===this.hiddenTextarea.value)return this.styles={},this.updateFromTextArea(),this.fire("changed"),void(this.canvas&&(this.canvas.fire("text:changed",{target:this}),this.canvas.requestRenderAll()));var u=this.fromStringToGraphemeSelection(this.hiddenTextarea.selectionStart,this.hiddenTextarea.selectionEnd,this.hiddenTextarea.value),f=c>u.selectionStart;l?(i=this._text.slice(c,h),a+=h-c):o=this._text.length&&this.selectionEnd>=this._text.length||this._moveCursorUpOrDown("Down",t)},moveCursorUp:function(t){0===this.selectionStart&&0===this.selectionEnd||this._moveCursorUpOrDown("Up",t)},_moveCursorUpOrDown:function(t,e){t=this["get"+t+"CursorOffset"](e,"right"===this._selectionDirection);e.shiftKey?this.moveCursorWithShift(t):this.moveCursorWithoutShift(t),0!==t&&(this.setSelectionInBoundaries(),this.abortCursorAnimation(),this._currentCursorOpacity=1,this.initDelayedCursor(),this._fireSelectionChanged(),this._updateTextarea())},moveCursorWithShift:function(t){var e="left"===this._selectionDirection?this.selectionStart+t:this.selectionEnd+t;return this.setSelectionStartEndWithShift(this.selectionStart,this.selectionEnd,e),0!==t},moveCursorWithoutShift:function(t){return t<0?(this.selectionStart+=t,this.selectionEnd=this.selectionStart):(this.selectionEnd+=t,this.selectionStart=this.selectionEnd),0!==t},moveCursorLeft:function(t){0===this.selectionStart&&0===this.selectionEnd||this._moveCursorLeftOrRight("Left",t)},_move:function(t,e,i){var r;if(t.altKey)r=this["findWordBoundary"+i](this[e]);else{if(!t.metaKey&&35!==t.keyCode&&36!==t.keyCode)return this[e]+="Left"===i?-1:1,!0;r=this["findLineBoundary"+i](this[e])}if(void 0!==r&&this[e]!==r)return this[e]=r,!0},_moveLeft:function(t,e){return this._move(t,e,"Left")},_moveRight:function(t,e){return this._move(t,e,"Right")},moveCursorLeftWithoutShift:function(t){var e=!0;return this._selectionDirection="left",this.selectionEnd===this.selectionStart&&0!==this.selectionStart&&(e=this._moveLeft(t,"selectionStart")),this.selectionEnd=this.selectionStart,e},moveCursorLeftWithShift:function(t){return"right"===this._selectionDirection&&this.selectionStart!==this.selectionEnd?this._moveLeft(t,"selectionEnd"):0!==this.selectionStart?(this._selectionDirection="left",this._moveLeft(t,"selectionStart")):void 0},moveCursorRight:function(t){this.selectionStart>=this._text.length&&this.selectionEnd>=this._text.length||this._moveCursorLeftOrRight("Right",t)},_moveCursorLeftOrRight:function(t,e){t="moveCursor"+t+"With";this._currentCursorOpacity=1,e.shiftKey?t+="Shift":t+="outShift",this[t](e)&&(this.abortCursorAnimation(),this.initDelayedCursor(),this._fireSelectionChanged(),this._updateTextarea())},moveCursorRightWithShift:function(t){return"left"===this._selectionDirection&&this.selectionStart!==this.selectionEnd?this._moveRight(t,"selectionStart"):this.selectionEnd!==this._text.length?(this._selectionDirection="right",this._moveRight(t,"selectionEnd")):void 0},moveCursorRightWithoutShift:function(t){var e=!0;return this._selectionDirection="right",this.selectionStart===this.selectionEnd?(e=this._moveRight(t,"selectionStart"),this.selectionEnd=this.selectionStart):this.selectionStart=this.selectionEnd,e},removeChars:function(t,e){this.removeStyleFromTo(t,e=void 0===e?t+1:e),this._text.splice(t,e-t),this.text=this._text.join(""),this.set("dirty",!0),this._shouldClearDimensionCache()&&(this.initDimensions(),this.setCoords()),this._removeExtraneousStyles()},insertChars:function(t,e,i,r){i<(r=void 0===r?i:r)&&this.removeStyleFromTo(i,r);t=fabric.util.string.graphemeSplit(t);this.insertNewStyleBlock(t,i,e),this._text=[].concat(this._text.slice(0,i),t,this._text.slice(r)),this.text=this._text.join(""),this.set("dirty",!0),this._shouldClearDimensionCache()&&(this.initDimensions(),this.setCoords()),this._removeExtraneousStyles()}}),function(){var a=fabric.util.toFixed,c=/ +/g;fabric.util.object.extend(fabric.Text.prototype,{_toSVG:function(){var t=this._getSVGLeftTopOffsets(),t=this._getSVGTextAndBg(t.textTop,t.textLeft);return this._wrapSVGTextAndBg(t)},toSVG:function(t){return this._createBaseSVGMarkup(this._toSVG(),{reviver:t,noStyle:!0,withShadow:!0})},_getSVGLeftTopOffsets:function(){return{textLeft:-this.width/2,textTop:-this.height/2,lineTop:this.getHeightOfLine(0)}},_wrapSVGTextAndBg:function(t){var e=this.getSvgTextDecoration(this);return[t.textBgRects.join(""),'\t\t",t.textSpans.join(""),"\n"]},_getSVGTextAndBg:function(t,e){var i,r=[],n=[],s=t;this._setSVGBg(n);for(var o=0,a=this._textLines.length;o",fabric.util.string.escapeXml(t),""].join("")},_setSVGTextLineText:function(t,e,i,r){var n,s,o,a,c=this.getHeightOfLine(e),h=-1!==this.textAlign.indexOf("justify"),l="",u=0,f=this._textLines[e];r+=c*(1-this._fontSizeFraction)/this.lineHeight;for(var d=0,g=f.length-1;d<=g;d++)a=d===g||this.charSpacing,l+=f[d],o=this.__charBounds[e][d],0===u?(i+=o.kernedWidth-o.width,u+=o.width):u+=o.kernedWidth,(a=h&&!a&&this._reSpaceAndTab.test(f[d])?!0:a)||(n=n||this.getCompleteStyleDeclaration(e,d),s=this.getCompleteStyleDeclaration(e,d+1),a=fabric.util.hasStyleChanged(n,s,!0)),a&&(o=this._getStyleDeclaration(e,d)||{},t.push(this._createTextCharSpan(l,o,i,r)),l="",n=s,i+=u,u=0)},_pushTextBgRect:function(t,e,i,r,n,s){var o=fabric.Object.NUM_FRACTION_DIGITS;t.push("\t\t\n')},_setSVGTextLineBg:function(t,e,i,r){for(var n,s,o=this._textLines[e],a=this.getHeightOfLine(e)/this.lineHeight,c=0,h=0,l=this.getValueOfPropertyAt(e,0,"textBackgroundColor"),u=0,f=o.length;uthis.width&&this._set("width",this.dynamicMinWidth),-1!==this.textAlign.indexOf("justify")&&this.enlargeSpaces(),this.height=this.calcTextHeight(),this.saveState({propertySet:"_dimensionAffectingProps"}))},_generateStyleMap:function(t){for(var e=0,i=0,r=0,n={},s=0;sthis.dynamicMinWidth&&(this.dynamicMinWidth=g-m+r),c},isEndOfWrapping:function(t){return!this._styleMap[t+1]||this._styleMap[t+1].line!==this._styleMap[t].line},missingNewlineOffset:function(t){return!this.splitByGrapheme||this.isEndOfWrapping(t)?1:0},_splitTextIntoLines:function(t){for(var t=b.Text.prototype._splitTextIntoLines.call(this,t),e=this._wrapText(t.lines,this.width),i=new Array(e.length),r=0;r { + this._savedInteractivity.set(obj, { + selectable: obj.selectable, + evented: obj.evented, + }); + obj.set({ selectable: false, evented: false }); + }); + } + + /** + * 恢复所有对象到激活前的交互状态(保留锁定/背景图层的原始状态) + */ + _restoreObjectsInteractivity() { + const objects = this.canvasManager.canvas.getObjects(); + objects.forEach(obj => { + const saved = this._savedInteractivity.get(obj); + if (saved) { + obj.set({ selectable: saved.selectable, evented: saved.evented }); + } + // 不在保存映射中的对象(如工具激活期间新增的图层)保持原样, + // 不做任何修改,避免错误地解锁被用户主动锁定的图层 + }); + this._savedInteractivity.clear(); + } + + /** + * 启用可编辑图层的交互性,保留用户主动锁定的图层和临时辅助对象状态。 + */ + _enableEditableLayerInteractivity() { + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + const layerManager = this.canvasManager.layerManager; + const objects = canvas.getObjects(); + objects.forEach(obj => { + if (obj.excludeFromLayer || obj.excludeFromHistory) return; + + const meta = layerManager?.getLayerByObject?.(obj) || null; + const locked = meta ? meta.locked : obj._layerLocked === true; + if (locked && !meta?.isBackground && obj !== this.canvasManager.originalImage && !obj._originalImage) return; + + obj.set({ selectable: true, evented: true }); + }); + } + + /** + * 获取属性面板 HTML(子类可选实现) + * @returns {string} + */ + getPropertyPanelHTML() { + return ''; + } + + /** + * 属性变更回调(子类可选实现) + * @param {string} key + * @param {*} value + */ + onPropertyChange(key, value) {} + + /** + * 获取选项栏 HTML(子类可选实现) + * @returns {string} + */ + getOptionsBarHTML() { + return ''; + } + + /** + * 键盘事件(子类可选实现) + * @param {KeyboardEvent} e + */ + onKeyDown(e) {} + + /** + * 鼠标事件(子类可选实现) + * @param {Event} e + */ + onMouseDown(e) {} + onMouseMove(e) {} + onMouseUp(e) {} +} + +export default BaseModule; diff --git a/plugins/Image-Toolbox/core/src/modules/BrushModule.js b/plugins/Image-Toolbox/core/src/modules/BrushModule.js new file mode 100644 index 000000000..6fb5fa2b2 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/modules/BrushModule.js @@ -0,0 +1,328 @@ +import BaseModule from './BaseModule.js'; +import eventBus from '../EventBus.js'; +import { clamp, escapeAttr, normalizeColor, requestRender as _requestRender } from '../utils/helpers.js'; + +/** + * 画笔模块 - 使用 Fabric 自由绘制生成可编辑的 path 图层。 + */ +class BrushModule extends BaseModule { + constructor(canvasManager, historyManager, defaultOptions = {}) { + super(canvasManager, historyManager, { + color: '#d83b31', + width: 6, + ...defaultOptions, + }); + + this._cursorPreview = null; + this._savedBeforeStroke = false; + this._boundMouseDown = this._onMouseDown.bind(this); + this._boundMouseMove = this._onMouseMove.bind(this); + this._boundMouseOut = this._hideCursorPreview.bind(this); + this._boundPathCreated = this._onPathCreated.bind(this); + } + + activate(options = {}) { + super.activate(options); + + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + canvas.discardActiveObject(); + canvas.isDrawingMode = true; + canvas.defaultCursor = 'none'; + canvas.hoverCursor = 'none'; + canvas.freeDrawingCursor = 'none'; + this._ensureBrush(); + this._applyBrushOptions(); + canvas.on('mouse:down', this._boundMouseDown); + canvas.on('mouse:out', this._boundMouseOut); + canvas.on('path:created', this._boundPathCreated); + canvas.upperCanvasEl?.addEventListener('mousemove', this._boundMouseMove); + canvas.upperCanvasEl?.addEventListener('mouseleave', this._boundMouseOut); + + eventBus.emit('module:activated', 'brush'); + } + + deactivate() { + const canvas = this.canvasManager.canvas; + if (canvas) { + canvas.off('mouse:down', this._boundMouseDown); + canvas.off('mouse:out', this._boundMouseOut); + canvas.off('path:created', this._boundPathCreated); + canvas.upperCanvasEl?.removeEventListener('mousemove', this._boundMouseMove); + canvas.upperCanvasEl?.removeEventListener('mouseleave', this._boundMouseOut); + this._removeCursorPreview(); + canvas.isDrawingMode = false; + canvas.freeDrawingCursor = 'crosshair'; + canvas.hoverCursor = 'move'; + this._savedBeforeStroke = false; + } + + super.deactivate(); + } + + setColor(color) { + this.options.color = normalizeColor(color, this.options.color); + this._applyBrushOptions(); + this._updateCursorPreviewStyle(); + } + + setWidth(width) { + const parsed = parseInt(width, 10); + this.options.width = this._clamp(Number.isFinite(parsed) ? parsed : this.options.width, 1, 80); + this._applyBrushOptions(); + this._updateCursorPreviewStyle(); + } + + applyPreset(presetName) { + const presets = { + 'brush-red': { color: '#d83b31' }, + 'brush-blue': { color: '#1677ff' }, + 'brush-yellow': { color: '#ffd700' }, + 'brush-green': { color: '#2ead4a' }, + 'brush-white': { color: '#ffffff' }, + 'brush-black': { color: '#111111' }, + 'brush-thin': { width: 3 }, + 'brush-medium': { width: 6 }, + 'brush-thick': { width: 12 }, + 'brush-heavy': { width: 24 }, + }; + + const preset = presets[presetName]; + if (!preset) return; + + if (preset.color) this.setColor(preset.color); + if (preset.width) this.setWidth(preset.width); + } + + getOptionsBarHTML() { + const color = normalizeColor(this.options.color); + const width = this.options.width; + + return ` +
+ ${this._getColorPresetButton('brush-red', '红', '#d83b31', color)} + ${this._getColorPresetButton('brush-blue', '蓝', '#1677ff', color)} + ${this._getColorPresetButton('brush-yellow', '黄', '#ffd700', color)} + ${this._getColorPresetButton('brush-green', '绿', '#2ead4a', color)} + ${this._getColorPresetButton('brush-white', '白', '#ffffff', color)} + ${this._getColorPresetButton('brush-black', '黑', '#111111', color)} +
+
+ + + + +
+ `; + } + + getPropertyPanelHTML() { + return ` +
画笔工具
+
+ + +
+
+ + + ${this.options.width}px +
+
按住鼠标拖拽即可绘制,生成的画笔会作为独立图层。
+ `; + } + + onToolPropertyChange(key, value) { + switch (key) { + case 'color': + this.setColor(value); + return true; + case 'width': + this.setWidth(value); + return true; + default: + return false; + } + } + + _onMouseDown(e) { + const nativeEvent = e?.e; + if (nativeEvent && typeof nativeEvent.button === 'number' && nativeEvent.button !== 0) return; + + this.history.saveState(); + this._savedBeforeStroke = true; + } + + _onMouseMove(e) { + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + const nativeEvent = e?.e || e; + if (!nativeEvent) return; + + const pointer = canvas.getPointer(nativeEvent); + const preview = this._ensureCursorPreview(); + preview.set({ + left: pointer.x, + top: pointer.y, + visible: true, + }); + canvas.bringToFront(preview); + this._requestRender(); + } + + _onPathCreated(e) { + const path = e.path; + if (!path) return; + + path.set({ + id: 'brush_' + Date.now(), + fill: null, + stroke: this.options.color, + strokeWidth: this.options.width, + strokeLineCap: 'round', + strokeLineJoin: 'round', + selectable: false, + evented: false, + _layerKind: 'brush', + _layerColorPresetName: this._getColorPresetName(this.options.color), + _layerWidthPresetName: this._getWidthPresetName(this.options.width), + }); + path.setCoords(); + this.canvasManager.canvas.discardActiveObject(); + if (this._cursorPreview) { + this.canvasManager.canvas.bringToFront(this._cursorPreview); + } + this.canvasManager.canvas.renderAll(); + eventBus.emit('canvas:objectMetadataChanged', path); + this._savedBeforeStroke = false; + } + + _ensureCursorPreview() { + if (this._cursorPreview) return this._cursorPreview; + + const canvas = this.canvasManager.canvas; + const preview = new fabric.Circle({ + left: 0, + top: 0, + originX: 'center', + originY: 'center', + radius: this.options.width / 2, + fill: 'rgba(255,255,255,0.08)', + stroke: this.options.color, + strokeWidth: 1, + strokeUniform: true, + selectable: false, + evented: false, + excludeFromLayer: true, + excludeFromProperty: true, + excludeFromHistory: true, + excludeFromExport: true, + objectCaching: false, + visible: false, + }); + + this._cursorPreview = preview; + canvas.add(preview); + canvas.bringToFront(preview); + return preview; + } + + _updateCursorPreviewStyle() { + if (!this._cursorPreview) return; + + this._cursorPreview.set({ + radius: this.options.width / 2, + stroke: this.options.color, + }); + this._cursorPreview.setCoords(); + this._requestRender(); + } + + _hideCursorPreview() { + if (!this._cursorPreview) return; + + this._cursorPreview.set('visible', false); + this._requestRender(); + } + + _removeCursorPreview() { + if (!this._cursorPreview) return; + + const canvas = this.canvasManager.canvas; + if (canvas) { + canvas.remove(this._cursorPreview); + } + this._cursorPreview = null; + } + + _requestRender() { + _requestRender(this.canvasManager.canvas); + } + + _ensureBrush() { + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + if (!canvas.freeDrawingBrush || !(canvas.freeDrawingBrush instanceof fabric.PencilBrush)) { + canvas.freeDrawingBrush = new fabric.PencilBrush(canvas); + } + } + + _applyBrushOptions() { + const canvas = this.canvasManager.canvas; + if (!canvas?.freeDrawingBrush) return; + + canvas.freeDrawingBrush.color = this.options.color; + canvas.freeDrawingBrush.width = this.options.width; + } + + _getColorPresetButton(preset, label, color, currentColor) { + const normalized = normalizeColor(color); + const active = currentColor === normalized ? ' active' : ''; + return ` + + `; + } + + _normalizeColor(color, fallback = '#000000') { + return normalizeColor(color, fallback); + } + + _getColorPresetName(color) { + const colorMap = { + '#d83b31': '红', + '#1677ff': '蓝', + '#ffd700': '黄', + '#2ead4a': '绿', + '#ffffff': '白', + '#111111': '黑', + }; + + return colorMap[normalizeColor(color, '')] || ''; + } + + _getWidthPresetName(width) { + const widthMap = { + 3: '细', + 6: '中', + 12: '粗', + 24: '特粗', + }; + + return widthMap[Math.round(Number(width))] || ''; + } + + _clamp(value, min, max) { + return clamp(value, min, max); + } + + _escapeAttr(value) { + return escapeAttr(value); + } +} + +export default BrushModule; diff --git a/plugins/Image-Toolbox/core/src/modules/ColorModule.js b/plugins/Image-Toolbox/core/src/modules/ColorModule.js new file mode 100644 index 000000000..24b99c10f --- /dev/null +++ b/plugins/Image-Toolbox/core/src/modules/ColorModule.js @@ -0,0 +1,244 @@ +import BaseModule from './BaseModule.js'; +import eventBus from '../EventBus.js'; +import { requestRender as _requestRender } from '../utils/helpers.js'; +import { + FILTER_RANGES, + FILTER_PRESETS, + getFilterUiValue, + setFilter, + clearFilters, + applyFilterPreset, + isPresetActive, +} from '../utils/filters.js'; + +/** + * 调色模块 — 左侧工具栏「调色」工具 + * + * 激活后保持画布可选(与移动/框选一致),用户选中图片图层后: + * - 顶部预设栏显示滤镜预设(原图/暖色/冷色/复古/黑白/鲜艳/柔光/锐利) + * - 右侧属性面板显示调色滑块(亮度/对比/饱和/色相/模糊)+ 重置按钮 + */ +class ColorModule extends BaseModule { + constructor(canvasManager, historyManager, defaultOptions = {}) { + super(canvasManager, historyManager, defaultOptions); + // 标记:此模块接管属性面板,即使有选中对象也显示调色控件 + this.overridePropertyPanel = true; + this._filterDragSaving = false; + } + + activate(options = {}) { + this.active = true; + this.options = { ...this.options, ...options }; + + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + canvas.selection = true; + canvas.defaultCursor = 'default'; + + // 启用未锁定图层的交互性,确保用户可以选中图片图层 + this._enableEditableLayerInteractivity(); + } + + deactivate() { + this.active = false; + } + + // ── 顶部预设栏:滤镜预设 ── + getOptionsBarHTML() { + const targets = this._getTargetImages(); + const reference = this._getReferenceImage(); + if (!reference || targets.length === 0) { + const hint = this._getFilterScope() === 'all' + ? '当前画布没有可调色的图片图层' + : '选中图片图层以调色'; + return `
${hint}
`; + } + + const presets = FILTER_PRESETS.map(preset => { + const isActive = this._getFilterScope() === 'all' + ? targets.every(image => isPresetActive(image, preset.preset)) + : isPresetActive(reference, preset.preset); + return ``; + }).join(''); + + const scopeHint = this._getFilterScope() === 'all' + ? `全部图片图层 (${targets.length})` + : '当前图层'; + + return `
${scopeHint}${presets}
`; + } + + // ── 右侧属性面板:调色滑块 ── + getPropertyPanelHTML() { + const reference = this._getReferenceImage(); + const scopeControl = this._getScopeControlHTML(); + if (!reference) { + const hint = this._getAllImages().length > 0 + ? '选中图片图层,或将作用范围切换为全部图片图层' + : '当前画布没有可调色的图片图层'; + return `${scopeControl}
${hint}
`; + } + + const items = [ + { type: 'brightness', label: '亮度' }, + { type: 'contrast', label: '对比' }, + { type: 'saturation', label: '饱和' }, + { type: 'hue', label: '色相' }, + { type: 'blur', label: '模糊' }, + ]; + + const sliders = items.map(({ type, label }) => { + const range = FILTER_RANGES[type]; + const value = getFilterUiValue(reference, type); + return ` +
+ + + ${value} +
+ `; + }).join(''); + + const scopeTitle = this._getFilterScope() === 'all' + ? `调色 (${this._getTargetImages().length} 个图片图层)` + : '调色'; + + return ` + ${scopeControl} +
${scopeTitle}
+ ${sliders} +
+ +
+ `; + } + + // ── 滤镜预设点击(顶部预设栏) ── + applyPreset(presetName) { + if (!presetName || !presetName.startsWith('filter-')) return; + + const targets = this._getTargetImages(); + if (targets.length === 0) return; + + this.history?.saveState?.(); + targets.forEach(image => applyFilterPreset(image, presetName)); + this._markImagesChanged(targets); + _requestRender(this.canvasManager.canvas); + eventBus.emit('canvas:objectModified', targets[0]); + } + + // ── 调色滑块变化(属性面板) ── + onToolPropertyChange(prop, value, { eventType } = {}) { + if (prop === 'filterScope') { + if (eventType !== 'change') return false; + this.options.filterScope = value === 'all' ? 'all' : 'current'; + eventBus.emit('tool:propertiesChanged'); + return true; + } + + if (!prop || !prop.startsWith('filter:')) return false; + + const targets = this._getTargetImages(); + if (targets.length === 0) return false; + + const type = prop.slice('filter:'.length); + const uiValue = parseInt(value, 10); + if (!Number.isFinite(uiValue)) return false; + + // 拖拽开始时保存一次历史(仅首个 input 事件触发),保存调整前状态以支持撤销 + if (eventType === 'input' && !this._filterDragSaving) { + this._filterDragSaving = true; + this.history?.saveState?.(); + } + + targets.forEach(image => setFilter(image, type, uiValue)); + this._markImagesChanged(targets); + _requestRender(this.canvasManager.canvas); + + if (eventType === 'change') { + this._filterDragSaving = false; + eventBus.emit('canvas:objectModified', targets[0]); + } + return false; // 不刷新属性面板(避免滑块失焦) + } + + // ── 重置按钮(属性面板) ── + onToolPropertyAction(action, { eventType } = {}) { + if (action !== 'filter-reset') return; + if (eventType !== 'click') return; + + const targets = this._getTargetImages(); + if (targets.length === 0) return; + + this.history?.saveState?.(); + targets.forEach(image => clearFilters(image)); + this._markImagesChanged(targets); + _requestRender(this.canvasManager.canvas); + eventBus.emit('canvas:objectModified', targets[0]); + } + + _getScopeControlHTML() { + const scope = this._getFilterScope(); + return ` +
作用范围
+
+ + +
+ `; + } + + _getFilterScope() { + return this.options.filterScope === 'all' ? 'all' : 'current'; + } + + _getTargetImages() { + if (this._getFilterScope() === 'all') { + return this._getAllImages(); + } + + const active = this._getActiveImage(); + return active ? [active] : []; + } + + _getReferenceImage() { + if (this._getFilterScope() === 'all') { + return this._getActiveImage() || this._getAllImages()[0] || null; + } + + return this._getActiveImage(); + } + + _getAllImages() { + const canvas = this.canvasManager?.canvas; + if (!canvas) return []; + + return canvas.getObjects().filter(obj => ( + obj && + obj.type === 'image' && + !obj.excludeFromLayer && + !obj.excludeFromHistory + )); + } + + _markImagesChanged(images) { + images.forEach(image => { + image.dirty = true; + image.setCoords(); + }); + } + + // ── 获取当前选中的图片图层 ── + _getActiveImage() { + const active = this.canvasManager?.getActiveObject?.(); + if (!active || active.type === 'activeSelection') return null; + return active.type === 'image' ? active : null; + } +} + +export default ColorModule; diff --git a/plugins/Image-Toolbox/core/src/modules/CropModule.js b/plugins/Image-Toolbox/core/src/modules/CropModule.js new file mode 100644 index 000000000..01231ad59 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/modules/CropModule.js @@ -0,0 +1,785 @@ +import BaseModule from './BaseModule.js'; +import eventBus from '../EventBus.js'; +import { clamp, requestRender as _requestRender, createClipPathFromSource } from '../utils/helpers.js'; +import { CROP_RATIOS } from '../utils/constants.js'; + +// 将 CROP_RATIOS 映射为 id → ratio 的快速查找表 +const CROP_RATIO_MAP = {}; +for (const ratio of CROP_RATIOS) { + CROP_RATIO_MAP[ratio.id] = ratio.w === null ? null : { w: ratio.w, h: ratio.h }; +} + +/** + * 剪切模块 — 图片裁剪 + * 使用 canvas.clipPath 实现非破坏性裁剪 + */ +class CropModule extends BaseModule { + constructor(canvasManager, historyManager, defaultOptions = {}) { + super(canvasManager, historyManager, { + aspectRatio: null, // null = 自由比例,或 {w, h} 如 {w:1, h:1} + cropShape: 'rect', // rect = 矩形,ellipse = 椭圆/圆形 + ...defaultOptions, + }); + + this._cropRect = null; + this._maskRect = null; + this._detachedCanvasClipPath = null; + this._clipPathDetached = false; + this._objectClipPathBackups = null; + this._applyingCrop = false; + this._isAdjusting = false; + this._boundMouseDown = this._onMouseDown.bind(this); + this._boundMouseMove = this._onMouseMove.bind(this); + this._boundMouseUp = this._onMouseUp.bind(this); + } + + activate(options = {}) { + super.activate(options); // 自动禁用所有对象交互 + + const canvas = this.canvasManager.canvas; + canvas.defaultCursor = 'crosshair'; + this._applyingCrop = false; + this._detachCanvasClipPath(); + + // 创建遮罩和裁剪框(这两个对象在 super.activate() 之后创建, + // 因此不受 BaseModule 的禁用影响,各自显式设置了 selectable/evented) + this._createMask(); + this._createCropRect(); + + canvas.on('mouse:down', this._boundMouseDown); + canvas.on('mouse:move', this._boundMouseMove); + canvas.on('mouse:up', this._boundMouseUp); + + eventBus.emit('module:activated', 'crop'); + } + + deactivate() { + const canvas = this.canvasManager.canvas; + + canvas.off('mouse:down', this._boundMouseDown); + canvas.off('mouse:move', this._boundMouseMove); + canvas.off('mouse:up', this._boundMouseUp); + + this._removeCropOverlay(); + if (this._applyingCrop) { + this._discardDetachedCanvasClipPath(false); + } else { + this._restoreDetachedCanvasClipPath(false); + } + this._applyingCrop = false; + canvas.renderAll(); + + super.deactivate(); // 恢复所有对象交互 + } + + setAspectRatio(ratio) { + this.options.aspectRatio = ratio; + if (!this._cropRect) return; + + this._cropRect.set({ lockUniScaling: !!ratio }); + if (ratio) { + this._applyAspectRatioToCropRect('width'); + } else { + this._cropRect.setCoords(); + this._updateMask(); + } + eventBus.emit('crop:updated', this._getCropBounds()); + } + + setCropShape(shape) { + const nextShape = this._normalizeCropShape(shape); + if (this.options.cropShape === nextShape) return; + + this.options.cropShape = nextShape; + if (!this._cropRect) return; + + const canvas = this.canvasManager.canvas; + const oldCropRect = this._cropRect; + const nextCropRect = this._createCropFrameFromSource(oldCropRect, nextShape); + + canvas.remove(oldCropRect); + this._cropRect = nextCropRect; + this._bindCropFrameEvents(nextCropRect); + canvas.add(nextCropRect); + canvas.setActiveObject(nextCropRect); + this._updateMask(); + eventBus.emit('crop:updated', this._getCropBounds()); + } + + applyPreset(presetName) { + if (this.applyShapePreset(presetName)) return; + + if (!(presetName in CROP_RATIO_MAP)) return; + this.setAspectRatio(CROP_RATIO_MAP[presetName]); + } + + applyShapePreset(presetName) { + const shapeMap = { + 'crop-shape-rect': 'rect', + 'crop-shape-ellipse': 'ellipse', + }; + + if (!Object.prototype.hasOwnProperty.call(shapeMap, presetName)) return false; + this.setCropShape(shapeMap[presetName]); + return true; + } + + /** + * 执行裁剪 + */ + applyCrop() { + if (!this._cropRect) return; + const canvas = this.canvasManager.canvas; + const clipRect = this._createClipPathFromSource(this._cropRect); + if (this._detachedCanvasClipPath) { + clipRect.clipPath = this._createClipPathFromSource(this._detachedCanvasClipPath); + } + + this._saveStateBeforeCrop(canvas); + this._clearTemporaryObjectClipPaths(false); + + canvas.clipPath = clipRect; + this._detachedCanvasClipPath = null; + this._clipPathDetached = false; + this._applyingCrop = true; + this._removeCropOverlay(); + canvas.renderAll(); + + eventBus.emit('crop:applied'); + // 自动退出裁剪模式 + eventBus.emit('tool:requestChange', 'select'); + } + + /** + * 取消裁剪 + */ + cancelCrop() { + this._removeCropOverlay(); + this._restoreDetachedCanvasClipPath(false); + this.canvasManager.canvas.renderAll(); + eventBus.emit('tool:requestChange', 'select'); + } + + // ── 内部方法 ── + + /** + * 获取当前可视区域(canvas 坐标) + * 解决放大/平移后裁剪框错位的问题 + */ + _getVisibleBounds() { + const canvas = this.canvasManager.canvas; + const vpt = canvas.viewportTransform; + const zoom = vpt[0]; // 缩放比 + const panX = vpt[4]; // 水平平移 + const panY = vpt[5]; // 垂直平移 + + // 屏幕坐标 (0,0) → canvas 坐标 + const left = -panX / zoom; + const top = -panY / zoom; + return { + left, + top, + width: canvas.width / zoom, + height: canvas.height / zoom, + }; + } + + /** + * 获取可视区域中心点(canvas 坐标) + */ + _getVisibleCenter() { + const vb = this._getVisibleBounds(); + return { + x: vb.left + vb.width / 2, + y: vb.top + vb.height / 2, + }; + } + + _createMask() { + const vb = this._getVisibleBounds(); + this._maskRect = new fabric.Rect({ + left: vb.left, + top: vb.top, + width: vb.width, + height: vb.height, + fill: 'rgba(0, 0, 0, 0.5)', + selectable: false, + evented: false, + excludeFromExport: true, + excludeFromLayer: true, + absolutePositioned: true, + objectCaching: false, + }); + this.canvasManager.canvas.add(this._maskRect); + } + + _createCropRect() { + const canvas = this.canvasManager.canvas; + const ratio = this.options.aspectRatio; + const vb = this._getVisibleBounds(); + const center = this._getVisibleCenter(); + + const defaultSize = Math.min(vb.width, vb.height) * 0.7; + let w = defaultSize; + let h = defaultSize; + + if (ratio) { + // 按比例计算 + const ratioVal = ratio.w / ratio.h; + if (w / h > ratioVal) { + w = h * ratioVal; + } else { + h = w / ratioVal; + } + } + + this._cropRect = this._createCropFrame({ + left: center.x - w / 2, + top: center.y - h / 2, + width: w, + height: h, + }); + + this._bindCropFrameEvents(this._cropRect); + + canvas.add(this._cropRect); + canvas.setActiveObject(this._cropRect); + this._updateMask(); + } + + _createCropFrame(source, shape = this._getCropShape()) { + const width = Math.max(1, source.width || 0); + const height = Math.max(1, source.height || 0); + const commonOptions = { + left: source.left || 0, + top: source.top || 0, + fill: 'transparent', + stroke: '#FFFFFF', + strokeWidth: 1.5, + strokeDashArray: [4, 4], + selectable: true, + evented: true, + hasControls: true, + hasBorders: true, + cornerColor: '#FFFFFF', + cornerSize: 8, + cornerStyle: 'circle', + transparentCorners: false, + lockUniScaling: !!this.options.aspectRatio, + lockScalingFlip: true, + excludeFromExport: true, + excludeFromLayer: true, + excludeFromProperty: true, + excludeFromHistory: true, + absolutePositioned: true, + scaleX: source.scaleX == null ? 1 : source.scaleX, + scaleY: source.scaleY == null ? 1 : source.scaleY, + angle: source.angle || 0, + skewX: source.skewX || 0, + skewY: source.skewY || 0, + flipX: !!source.flipX, + flipY: !!source.flipY, + originX: source.originX || 'left', + originY: source.originY || 'top', + }; + + let cropFrame; + + if (shape === 'ellipse') { + cropFrame = new fabric.Ellipse({ + ...commonOptions, + width, + height, + rx: width / 2, + ry: height / 2, + }); + } else { + cropFrame = new fabric.Rect({ + ...commonOptions, + width, + height, + }); + } + + this._applyCropFrameControls(cropFrame); + return cropFrame; + } + + _createCropFrameFromSource(source, shape = this._getCropShape()) { + return this._createCropFrame({ + left: source.left || 0, + top: source.top || 0, + width: Math.max(1, source.width || (source.rx || 0) * 2), + height: Math.max(1, source.height || (source.ry || 0) * 2), + scaleX: source.scaleX == null ? 1 : source.scaleX, + scaleY: source.scaleY == null ? 1 : source.scaleY, + angle: source.angle || 0, + skewX: source.skewX || 0, + skewY: source.skewY || 0, + flipX: !!source.flipX, + flipY: !!source.flipY, + originX: source.originX || 'left', + originY: source.originY || 'top', + }, shape); + } + + _bindCropFrameEvents(cropFrame) { + cropFrame.on('moving', () => this._updateMask()); + cropFrame.on('scaling', () => this._updateMask()); + cropFrame.on('rotating', () => this._updateMask()); + cropFrame.on('resizing', () => this._updateMask()); + } + + _applyCropFrameControls(cropFrame) { + const rotateControl = cropFrame?.controls?.mtr; + if (!rotateControl || typeof fabric.Control !== 'function') return; + + cropFrame.controls = { ...cropFrame.controls }; + const rotateControlOptions = { + x: rotateControl.x, + y: rotateControl.y, + actionName: rotateControl.actionName || 'rotate', + sizeX: 22, + sizeY: 22, + render: this._renderCropRotateControl.bind(this), + }; + + [ + 'offsetX', + 'offsetY', + 'cursorStyleHandler', + 'mouseDownHandler', + 'actionHandler', + 'mouseUpHandler', + 'getActionName', + 'withConnection', + 'touchSizeX', + 'touchSizeY', + ].forEach((key) => { + if (rotateControl[key] !== undefined) { + rotateControlOptions[key] = rotateControl[key]; + } + }); + + cropFrame.controls.mtr = new fabric.Control(rotateControlOptions); + } + + _renderCropRotateControl(ctx, left, top) { + ctx.save(); + ctx.translate(left, top); + + ctx.beginPath(); + ctx.arc(0, 0, 11, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(255, 255, 255, 0.96)'; + ctx.shadowColor = 'rgba(0, 0, 0, 0.25)'; + ctx.shadowBlur = 4; + ctx.shadowOffsetY = 1; + ctx.fill(); + + ctx.shadowColor = 'transparent'; + ctx.shadowBlur = 0; + ctx.shadowOffsetY = 0; + ctx.strokeStyle = '#2563EB'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(0, 0, 5.5, -Math.PI * 0.85, Math.PI * 0.55); + ctx.strokeStyle = '#2563EB'; + ctx.lineWidth = 1.8; + ctx.lineCap = 'round'; + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(5.3, 4.5); + ctx.lineTo(8.1, 3.9); + ctx.lineTo(6.5, 1.4); + ctx.lineJoin = 'round'; + ctx.stroke(); + + ctx.restore(); + } + + _updateMask() { + if (!this._maskRect || !this._cropRect) return; + + const canvas = this.canvasManager.canvas; + const vb = this._getVisibleBounds(); + + // 遮罩跟随可视区域 + this._maskRect.set({ + left: vb.left, + top: vb.top, + width: vb.width, + height: vb.height, + clipPath: this._createMaskClipPath(), + }); + this._maskRect.dirty = true; + + canvas.renderAll(); + } + + _createMaskClipPath() { + if (!this._cropRect) return null; + + const clipPath = this._createClipPathFromSource(this._cropRect); + clipPath.inverted = true; + return clipPath; + } + + _getCropBounds() { + if (!this._cropRect) { + return { left: 0, top: 0, width: 0, height: 0 }; + } + + const cr = this._cropRect; + return { + left: cr.left || 0, + top: cr.top || 0, + width: Math.max(1, Math.abs((cr.width || 0) * (cr.scaleX || 1))), + height: Math.max(1, Math.abs((cr.height || 0) * (cr.scaleY || 1))), + }; + } + + _setCropBounds(bounds) { + if (!this._cropRect) return; + + const width = Math.max(1, bounds.width); + const height = Math.max(1, bounds.height); + + if (this._isEllipseObject(this._cropRect)) { + this._cropRect.set({ + left: bounds.left, + top: bounds.top, + width, + height, + rx: width / 2, + ry: height / 2, + scaleX: 1, + scaleY: 1, + }); + this._cropRect.setCoords(); + return; + } + + this._cropRect.set({ + left: bounds.left, + top: bounds.top, + width, + height, + scaleX: 1, + scaleY: 1, + }); + this._cropRect.setCoords(); + } + + _getAspectRatioValue() { + const ratio = this.options.aspectRatio; + if (!ratio || !ratio.w || !ratio.h) return null; + return ratio.w / ratio.h; + } + + _applyAspectRatioToCropRect(changedProp = 'width') { + const ratioVal = this._getAspectRatioValue(); + if (!this._cropRect || !ratioVal) return; + + const current = this._getCropBounds(); + const center = { + x: current.left + current.width / 2, + y: current.top + current.height / 2, + }; + + let width = current.width; + let height = current.height; + if (changedProp === 'height') { + width = height * ratioVal; + } else { + height = width / ratioVal; + } + + const bounds = this._fitCropBoundsToVisible({ left: current.left, top: current.top, width, height }, center, ratioVal); + this._setCropBounds(bounds); + this._updateMask(); + } + + _fitCropBoundsToVisible(bounds, center, ratioVal = null) { + const vb = this._getVisibleBounds(); + const maxWidth = Math.max(1, vb.width); + const maxHeight = Math.max(1, vb.height); + let width = Math.max(1, bounds.width); + let height = Math.max(1, bounds.height); + + if (ratioVal) { + if (width > maxWidth) { + width = maxWidth; + height = width / ratioVal; + } + if (height > maxHeight) { + height = maxHeight; + width = height * ratioVal; + } + } else { + width = Math.min(width, maxWidth); + height = Math.min(height, maxHeight); + } + + const nextCenter = center || { + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2, + }; + let left = nextCenter.x - width / 2; + let top = nextCenter.y - height / 2; + + left = this._clamp(left, vb.left, vb.left + maxWidth - width); + top = this._clamp(top, vb.top, vb.top + maxHeight - height); + + return { left, top, width, height }; + } + + _clamp(value, min, max) { + return clamp(value, min, max); + } + + _getCropShape() { + return this._normalizeCropShape(this.options.cropShape); + } + + _normalizeCropShape(shape) { + return shape === 'ellipse' ? 'ellipse' : 'rect'; + } + + _isEllipseObject(obj) { + return obj?.type === 'ellipse'; + } + + _createClipPathFromSource(source) { + return createClipPathFromSource(source); + } + + _detachCanvasClipPath() { + const canvas = this.canvasManager.canvas; + if (!canvas?.clipPath) return; + + this._detachedCanvasClipPath = canvas.clipPath; + this._clipPathDetached = true; + canvas.clipPath = null; + this._applyTemporaryObjectClipPaths(this._detachedCanvasClipPath, false); + this._requestRender(); + } + + _applyTemporaryObjectClipPaths(clipPath, render = true) { + const canvas = this.canvasManager.canvas; + if (!canvas || !clipPath) return; + + this._clearTemporaryObjectClipPaths(false); + this._objectClipPathBackups = canvas.getObjects() + .filter(obj => obj !== this._cropRect && obj !== this._maskRect) + .map(obj => ({ obj, clipPath: obj.clipPath || null })); + + this._objectClipPathBackups.forEach(({ obj }) => { + obj.set('clipPath', this._createClipPathFromSource(clipPath)); + obj.dirty = true; + }); + + if (render) this._requestRender(); + } + + _clearTemporaryObjectClipPaths(render = true) { + if (!this._objectClipPathBackups) return; + + this._objectClipPathBackups.forEach(({ obj, clipPath }) => { + obj.set('clipPath', clipPath || null); + obj.dirty = true; + }); + this._objectClipPathBackups = null; + + if (render) this._requestRender(); + } + + _restoreDetachedCanvasClipPath(render = true) { + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + this._clearTemporaryObjectClipPaths(false); + if (this._clipPathDetached) { + canvas.clipPath = this._detachedCanvasClipPath; + } + this._detachedCanvasClipPath = null; + this._clipPathDetached = false; + + if (render) this._requestRender(); + } + + _discardDetachedCanvasClipPath(render = true) { + this._clearTemporaryObjectClipPaths(false); + this._detachedCanvasClipPath = null; + this._clipPathDetached = false; + + if (render) this._requestRender(); + } + + _saveStateBeforeCrop(canvas) { + const currentClipPath = canvas.clipPath; + this._clearTemporaryObjectClipPaths(false); + + if (this._clipPathDetached) { + canvas.clipPath = this._detachedCanvasClipPath; + } + + this.history.saveState(); + canvas.clipPath = currentClipPath; + } + + _requestRender() { + _requestRender(this.canvasManager.canvas); + } + + _removeCropOverlay() { + const canvas = this.canvasManager.canvas; + if (this._maskRect) { + canvas.remove(this._maskRect); + this._maskRect = null; + } + if (this._cropRect) { + canvas.remove(this._cropRect); + this._cropRect = null; + } + } + + // ── 鼠标事件(重置裁剪框) ── + + _onMouseDown(e) { + // 由 Fabric.js 处理裁剪框的拖拽/缩放 + } + + _onMouseMove(e) { + // 由 Fabric.js 处理 + } + + _onMouseUp(e) { + // 由 Fabric.js 处理 + } + + getOptionsBarHTML() { + const ratio = this.options.aspectRatio; + const shape = this._getCropShape(); + const ratioButtons = CROP_RATIOS.map(r => { + const isActive = r.w === null ? !ratio : ratio && ratio.w === r.w && ratio.h === r.h; + return ``; + }).join(''); + return ` +
+ + +
+
+ ${ratioButtons} +
+ `; + } + + getPropertyPanelHTML() { + if (!this._cropRect) { + return '
启用剪切后可编辑裁剪区域
'; + } + + const bounds = this._getCropBounds(); + const ratio = this.options.aspectRatio; + const shape = this._getCropShape(); + + return ` +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
拖动裁剪框,或直接输入区域数值
+ `; + } + + onToolPropertyChange(key, value) { + if (key === 'cropShape') { + this.setCropShape(value); + return true; + } + + if (key !== 'aspectRatio') return false; + + this.setAspectRatio(this._parseRatio(value)); + return true; + } + + onToolPropertyAction(action) { + if (action === 'applyCrop') { + this.applyCrop(); + return; + } + if (action === 'cancelCrop') { + this.cancelCrop(); + } + } + + onPropertyChange(key, value, context = {}) { + if (!this._cropRect || !['left', 'top', 'width', 'height'].includes(key)) return; + + if (this.options.aspectRatio && (key === 'width' || key === 'height')) { + this._applyAspectRatioToCropRect(key); + if (context.eventType === 'change') { + eventBus.emit('crop:updated', this._getCropBounds()); + } + return; + } + + this._cropRect.setCoords(); + this._updateMask(); + if (context.eventType === 'change') { + eventBus.emit('crop:updated', this._getCropBounds()); + } + } + + _formatNumber(value) { + const number = parseFloat(value); + if (!Number.isFinite(number)) return 0; + return Math.round(number * 100) / 100; + } + + _parseRatio(value) { + if (value === 'free') return null; + const [w, h] = String(value).split(':').map(Number); + return Number.isFinite(w) && Number.isFinite(h) ? { w, h } : null; + } +} + +export default CropModule; diff --git a/plugins/Image-Toolbox/core/src/modules/EraserModule.js b/plugins/Image-Toolbox/core/src/modules/EraserModule.js new file mode 100644 index 000000000..11919d315 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/modules/EraserModule.js @@ -0,0 +1,429 @@ +import BaseModule from './BaseModule.js'; +import eventBus from '../EventBus.js'; +import { clamp } from '../utils/helpers.js'; + +/** + * 橡皮擦模块 - 默认擦除当前图层,并把擦除结果固化为位图。 + */ +class EraserModule extends BaseModule { + constructor(canvasManager, historyManager, defaultOptions = {}) { + super(canvasManager, historyManager, { + width: 20, + ...defaultOptions, + }); + + this._targetObject = null; + this._strokeTarget = null; + this._isDrawing = false; + this._lastPointer = null; + this._previewImage = null; + this._previewCtx = null; + this._previewBounds = null; + this._boundMouseDown = this._onMouseDown.bind(this); + this._boundMouseMove = this._onMouseMove.bind(this); + this._boundMouseUp = this._onMouseUp.bind(this); + this._boundLayerSelected = this._onLayerSelected.bind(this); + } + + activate(options = {}) { + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + const initialTarget = this._getErasableObject(canvas.getActiveObject()); + super.activate(options); + + this._targetObject = initialTarget; + canvas.discardActiveObject(); + canvas.isDrawingMode = false; + canvas.defaultCursor = 'crosshair'; + canvas.freeDrawingCursor = 'crosshair'; + canvas.on('mouse:down', this._boundMouseDown); + canvas.on('mouse:move', this._boundMouseMove); + canvas.on('mouse:up', this._boundMouseUp); + eventBus.on('layer:selected', this._boundLayerSelected); + + eventBus.emit('module:activated', 'eraser'); + } + + deactivate() { + const canvas = this.canvasManager.canvas; + if (canvas) { + canvas.off('mouse:down', this._boundMouseDown); + canvas.off('mouse:move', this._boundMouseMove); + canvas.off('mouse:up', this._boundMouseUp); + this._commitLivePreview(); + canvas.isDrawingMode = false; + canvas.freeDrawingCursor = 'crosshair'; + this._targetObject = null; + this._strokeTarget = null; + this._isDrawing = false; + this._lastPointer = null; + } + eventBus.off('layer:selected', this._boundLayerSelected); + + super.deactivate(); + } + + setWidth(width) { + const parsed = parseInt(width, 10); + this.options.width = this._clamp(Number.isFinite(parsed) ? parsed : this.options.width, 1, 120); + this._applyBrushOptions(); + } + + applyPreset(presetName) { + const presets = { + 'eraser-thin': 8, + 'eraser-medium': 20, + 'eraser-thick': 40, + 'eraser-heavy': 64, + }; + + const width = presets[presetName]; + if (!width) return; + + this.setWidth(width); + } + + getOptionsBarHTML() { + const width = this.options.width; + + return ` +
+ + + + +
+ `; + } + + getPropertyPanelHTML() { + return ` +
橡皮擦工具
+
+ + + ${this.options.width}px +
+
默认擦除进入工具前选中的当前图层;未选中图层时,会擦除鼠标下方最上层可编辑图层。
+ `; + } + + onToolPropertyChange(key, value) { + if (key !== 'width') return false; + + this.setWidth(value); + return true; + } + + _onMouseDown(e) { + const nativeEvent = e?.e; + if (nativeEvent && typeof nativeEvent.button === 'number' && nativeEvent.button !== 0) return; + if (this._isDrawing) return; + + const target = this._getStrokeTarget(e); + this._strokeTarget = target; + if (!target) return; + + this.history.saveState(); + + const canvas = this.canvasManager.canvas; + const pointer = canvas.getPointer(e.e); + if (!this._beginLivePreview(target)) { + this._resetStrokeState(); + return; + } + + this._isDrawing = true; + this._lastPointer = pointer; + this._drawErasePoint(pointer); + this._refreshPreview(); + } + + _onMouseMove(e) { + if (!this._isDrawing || !this._lastPointer) return; + if (typeof e?.e?.buttons === 'number' && e.e.buttons === 0) { + this._onMouseUp(); + return; + } + + const canvas = this.canvasManager.canvas; + const pointer = canvas.getPointer(e.e); + this._drawEraseSegment(this._lastPointer, pointer); + this._lastPointer = pointer; + this._refreshPreview(); + } + + _onMouseUp() { + if (!this._isDrawing) return; + + this._commitLivePreview(); + this._resetStrokeState(); + } + + _onLayerSelected(meta) { + if (!this.active) return; + if (this._isDrawing) return; + + this._targetObject = this._getErasableObject(meta?.fabricObj || null); + const canvas = this.canvasManager.canvas; + if (canvas) canvas.discardActiveObject(); + } + + _beginLivePreview(target) { + const canvas = this.canvasManager.canvas; + if (!canvas || !this._isErasableObject(target)) return false; + + try { + target.setCoords(); + const bounds = target.getBoundingRect(true, true); + const cropLeft = Math.floor(bounds.left); + const cropTop = Math.floor(bounds.top); + const cropRight = Math.ceil(bounds.left + bounds.width); + const cropBottom = Math.ceil(bounds.top + bounds.height); + const cropWidth = Math.max(1, cropRight - cropLeft); + const cropHeight = Math.max(1, cropBottom - cropTop); + const rasterCanvas = this._renderTargetRegion(target, cropLeft, cropTop, cropWidth, cropHeight); + const rasterCtx = rasterCanvas.getContext('2d'); + const previewImage = this._createRasterImage(target, rasterCanvas, cropLeft, cropTop); + const targetIndex = canvas.getObjects().indexOf(target); + + canvas.remove(target); + if (targetIndex >= 0) { + canvas.insertAt(previewImage, targetIndex); + } else { + canvas.add(previewImage); + } + + this._previewImage = previewImage; + this._previewCtx = rasterCtx; + this._previewBounds = { left: cropLeft, top: cropTop }; + this._targetObject = previewImage; + this._strokeTarget = previewImage; + canvas.discardActiveObject(); + canvas.requestRenderAll?.(); + return true; + } catch (err) { + console.error('[EraserModule] 创建实时擦除预览失败:', err); + return false; + } + } + + _createRasterImage(target, rasterCanvas, left, top) { + const layerName = typeof target._layerName === 'string' ? target._layerName : ''; + const image = new fabric.Image(rasterCanvas, { + left, + top, + width: rasterCanvas.width, + height: rasterCanvas.height, + scaleX: 1, + scaleY: 1, + angle: 0, + originX: 'left', + originY: 'top', + opacity: 1, + id: target.id, + selectable: false, + evented: false, + objectCaching: false, + }); + + if (typeof target._layerLocked === 'boolean') { + image._layerLocked = target._layerLocked; + } + + if (layerName) { + image._layerName = layerName; + image._layerNameAuto = false; + image._layerBaseName = ''; + } + + image.setCoords(); + return image; + } + + _drawErasePoint(point) { + const ctx = this._previewCtx; + const bounds = this._previewBounds; + if (!ctx || !bounds) return; + + ctx.save(); + ctx.globalCompositeOperation = 'destination-out'; + ctx.fillStyle = '#000000'; + ctx.beginPath(); + ctx.arc(point.x - bounds.left, point.y - bounds.top, this.options.width / 2, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + _drawEraseSegment(from, to) { + const ctx = this._previewCtx; + const bounds = this._previewBounds; + if (!ctx || !bounds) return; + + ctx.save(); + ctx.globalCompositeOperation = 'destination-out'; + ctx.lineWidth = this.options.width; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.strokeStyle = '#000000'; + ctx.beginPath(); + ctx.moveTo(from.x - bounds.left, from.y - bounds.top); + ctx.lineTo(to.x - bounds.left, to.y - bounds.top); + ctx.stroke(); + ctx.restore(); + } + + _refreshPreview() { + const canvas = this.canvasManager.canvas; + if (!canvas || !this._previewImage) return; + + this._previewImage.dirty = true; + canvas.requestRenderAll?.(); + } + + _commitLivePreview() { + if (!this._previewImage) return; + + const canvas = this.canvasManager.canvas; + this._previewImage.setCoords(); + this._targetObject = this._previewImage; + this._strokeTarget = this._previewImage; + this._previewCtx = null; + this._previewBounds = null; + this._previewImage = null; + this._isDrawing = false; + this._lastPointer = null; + + if (canvas) { + canvas.discardActiveObject(); + canvas.renderAll(); + } + } + + _renderTargetRegion(target, left, top, width, height) { + const canvas = this.canvasManager.canvas; + const objects = canvas.getObjects(); + const visibilityBackups = objects.map(obj => ({ obj, visible: obj.visible })); + const viewportTransform = canvas.viewportTransform?.slice(); + const backgroundColor = canvas.backgroundColor; + + try { + canvas.discardActiveObject(); + objects.forEach(obj => { + obj.visible = obj === target; + }); + canvas.backgroundColor = null; + canvas.viewportTransform = [1, 0, 0, 1, 0, 0]; + canvas.calcViewportBoundaries?.(); + + return canvas.toCanvasElement(1, { + left, + top, + width, + height, + enableRetinaScaling: false, + }); + } finally { + visibilityBackups.forEach(({ obj, visible }) => { + obj.visible = visible; + }); + if (viewportTransform) { + canvas.viewportTransform = viewportTransform; + } + canvas.backgroundColor = backgroundColor; + canvas.calcViewportBoundaries?.(); + canvas.requestRenderAll?.(); + } + } + + _getStrokeTarget(e) { + if (this._isErasableObject(this._targetObject)) return this._targetObject; + + const canvas = this.canvasManager.canvas; + const active = this._getErasableObject(canvas?.getActiveObject?.()); + if (active) { + this._targetObject = active; + return active; + } + + const pointer = e ? canvas.getPointer(e.e) : null; + const hit = pointer ? this._findTopmostObjectAt(pointer) : null; + this._targetObject = hit; + return hit; + } + + _getErasableObject(obj) { + if (!obj) return null; + + if (obj.type === 'activeSelection' && typeof obj.getObjects === 'function') { + return this._pickTopmostObject(obj.getObjects()); + } + + return this._isErasableObject(obj) ? obj : null; + } + + _isErasableObject(obj) { + const canvas = this.canvasManager.canvas; + if (!obj || !canvas?.getObjects().includes(obj)) return false; + + // 排除背景原图 + if (obj === this.canvasManager.originalImage) return false; + + // 排除工具临时对象 + if (obj.excludeFromHistory || obj.excludeFromLayer) return false; + + // 排除旧版/误残留的橡皮擦路径 + if (typeof obj.id === 'string' && obj.id.startsWith('eraser_')) return false; + if (obj.globalCompositeOperation === 'destination-out') return false; + + return true; + } + + _findTopmostObjectAt(pointer) { + const canvas = this.canvasManager.canvas; + if (!canvas) return null; + + const objects = canvas.getObjects(); + for (let i = objects.length - 1; i >= 0; i--) { + const obj = objects[i]; + if (!this._isErasableObject(obj) || obj.visible === false) continue; + if (obj.containsPoint?.(pointer)) return obj; + } + + return null; + } + + _pickTopmostObject(objects) { + const canvasObjects = this.canvasManager.canvas?.getObjects() || []; + let result = null; + let resultIndex = -1; + + objects.forEach(obj => { + if (!this._isErasableObject(obj)) return; + const index = canvasObjects.indexOf(obj); + if (index > resultIndex) { + result = obj; + resultIndex = index; + } + }); + + return result; + } + + _resetStrokeState() { + this._strokeTarget = null; + this._isDrawing = false; + this._lastPointer = null; + } + + _applyBrushOptions() { + // 实时橡皮擦直接写入预览 canvas,宽度在下一段轨迹生效。 + } + + _clamp(value, min, max) { + return clamp(value, min, max); + } +} + +export default EraserModule; diff --git a/plugins/Image-Toolbox/core/src/modules/ExportModule.js b/plugins/Image-Toolbox/core/src/modules/ExportModule.js new file mode 100644 index 000000000..2705ee330 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/modules/ExportModule.js @@ -0,0 +1,262 @@ +import BaseModule from './BaseModule.js'; +import eventBus from '../EventBus.js'; + +/** + * 导出模块 — 将编辑结果导出为图片文件或复制到剪贴板 + * + * 接受可选 host adapter 注入。未注入时降级到浏览器原生行为。 + */ +class ExportModule extends BaseModule { + /** + * @param {import('../CanvasManager.js').default} canvasManager + * @param {import('../HistoryManager.js').default} historyManager + * @param {object} [defaultOptions] + * @param {object} [host] + */ + constructor(canvasManager, historyManager, defaultOptions = {}, host = null) { + super(canvasManager, historyManager, { + format: 'png', + quality: 1, + multiplier: 1, + ...defaultOptions, + }); + this._host = host; + } + + /** + * 注入 host adapter(可在运行时设置)。 + * @param {object} host + */ + setHost(host) { + this._host = host; + } + + /** + * 导出为文件 — 先弹保存对话框,用户选择格式后自动匹配导出 + */ + async exportToFile() { + // 优先使用 host adapter + if (this._host?.showSaveImageDialog && this._host?.writeImageFile) { + const filePath = this._host.showSaveImageDialog('edited.png'); + if (!filePath) return; + + const format = this._getFormatFromFilePath(filePath); + const dataURL = this.exportToDataURL(format); + if (!dataURL) return; + + const saved = this._host.writeImageFile(filePath, dataURL); + this._notifyToast(saved ? '图片已保存' : '保存失败', saved ? 'success' : 'error'); + return; + } + + const dataURL = this.exportToDataURL('png'); + if (!dataURL) return; + + if (this._host?.saveImage) { + const saved = await this._host.saveImage(dataURL, 'edited.png'); + if (saved) { + this._notifyToast('图片已保存', 'success'); + } + return; + } + + // 降级:浏览器下载 + this._browserDownload(dataURL, 'edited.png'); + } + + /** + * 导出到剪贴板 + */ + async exportToClipboard() { + const dataURL = this.exportToDataURL('png', 1, { trimToImage: true }); + if (!dataURL) return; + + // 优先使用 host adapter + if (this._host?.copyImage) { + const ok = await this._host.copyImage(dataURL); + this._notifyToast(ok ? '已复制到剪贴板' : '复制失败', ok ? 'success' : 'error'); + return; + } + + // 降级:Clipboard API + try { + const blob = await (await fetch(dataURL)).blob(); + await navigator.clipboard.write([ + new ClipboardItem({ [blob.type]: blob }), + ]); + this._notifyToast('已复制到剪贴板', 'success'); + } catch (err) { + console.error('[ExportModule] 剪贴板操作失败:', err); + this._notifyToast('复制失败', 'error'); + } + } + + /** + * 获取 DataURL + * @param {string} [format] - 'png' | 'jpeg' | 'webp' + * @param {number} [quality] - 0~1 + * @param {object} [options] + * @returns {string|null} + */ + exportToDataURL(format, quality, options = {}) { + const canvas = this.canvasManager.canvas; + if (!canvas) return null; + + const fmt = format || 'png'; + const q = quality ?? 1; + const dataURLOptions = { + format: fmt, + quality: q, + multiplier: this.options.multiplier || 1, + }; + + if (options.trimToImage) { + const bounds = this._getImageExportBounds(); + if (bounds) { + Object.assign(dataURLOptions, bounds); + } + } + + return this._toDataURL(dataURLOptions, { + resetViewport: !!options.trimToImage, + transparentBackground: !!options.trimToImage, + }); + } + + /** + * 带选项导出 + */ + exportWithOptions(options = {}) { + const opts = { + format: 'png', + multiplier: 1, + quality: 1, + ...options, + }; + return this.exportToDataURL(opts.format, opts.quality); + } + + _getFormatFromFilePath(filePath) { + const ext = String(filePath || '').split('.').pop()?.toLowerCase(); + if (ext === 'jpg' || ext === 'jpeg') return 'jpeg'; + if (ext === 'webp') return 'webp'; + return 'png'; + } + + _toDataURL(dataURLOptions, options = {}) { + const canvas = this.canvasManager.canvas; + const viewportTransform = canvas.viewportTransform?.slice(); + const backgroundColor = canvas.backgroundColor; + + try { + this.canvasManager.refreshDynamicMosaics?.({ render: true }); + if (options.resetViewport) { + canvas.viewportTransform = [1, 0, 0, 1, 0, 0]; + } + if (options.transparentBackground) { + canvas.backgroundColor = null; + } + return canvas.toDataURL(dataURLOptions); + } finally { + if (viewportTransform) { + canvas.viewportTransform = viewportTransform; + } + canvas.backgroundColor = backgroundColor; + canvas.requestRenderAll(); + } + } + + _getImageExportBounds() { + const canvas = this.canvasManager.canvas; + const clipBounds = this._getObjectBounds(canvas.clipPath); + if (clipBounds) { + return this._normalizeBounds(clipBounds); + } + + const imageBounds = this._getObjectBounds(this.canvasManager.originalImage); + if (imageBounds) { + return this._normalizeBounds(imageBounds); + } + + return null; + } + + _getObjectBounds(obj) { + if (!obj) return null; + + try { + obj.setCoords?.(); + const rect = obj.getBoundingRect?.(true, true); + if (rect && this._isValidBounds(rect)) { + return rect; + } + } catch (err) { + console.warn('[ExportModule] 获取导出边界失败,使用备用计算:', err); + } + + const scaleX = obj.scaleX ?? 1; + const scaleY = obj.scaleY ?? 1; + return { + left: obj.left ?? 0, + top: obj.top ?? 0, + width: (obj.width ?? 0) * scaleX, + height: (obj.height ?? 0) * scaleY, + }; + } + + _normalizeBounds(bounds) { + if (!this._isValidBounds(bounds)) return null; + + const left = Math.floor(bounds.left); + const top = Math.floor(bounds.top); + const right = Math.ceil(bounds.left + bounds.width); + const bottom = Math.ceil(bounds.top + bounds.height); + + return { + left, + top, + width: Math.max(1, right - left), + height: Math.max(1, bottom - top), + }; + } + + _isValidBounds(bounds) { + return Number.isFinite(bounds.left) + && Number.isFinite(bounds.top) + && Number.isFinite(bounds.width) + && Number.isFinite(bounds.height) + && bounds.width > 0 + && bounds.height > 0; + } + + /** + * 浏览器下载(降级方案) + */ + _browserDownload(dataURL, filename) { + const link = document.createElement('a'); + link.href = dataURL; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + + /** + * 通过 eventBus 发送 Toast 事件,由 UI 层渲染。 + * @param {string} message + * @param {'success'|'error'} type + */ + _notifyToast(message, type = 'success') { + eventBus.emit('toast:show', { message, type }); + } + + activate() { + super.activate(); + } + + deactivate() { + super.deactivate(); + } +} + +export default ExportModule; diff --git a/plugins/Image-Toolbox/core/src/modules/MosaicModule.js b/plugins/Image-Toolbox/core/src/modules/MosaicModule.js new file mode 100644 index 000000000..6edd670cd --- /dev/null +++ b/plugins/Image-Toolbox/core/src/modules/MosaicModule.js @@ -0,0 +1,1451 @@ +import BaseModule from './BaseModule.js'; +import eventBus from '../EventBus.js'; +import { clamp, requestRender as _requestRender, createClipPathFromSource, normalizeBounds, intersectBounds, getPointsBounds } from '../utils/helpers.js'; + +const SELECTION_FILL = 'rgba(47,127,134,0.16)'; +const SELECTION_STROKE = '#2f7f86'; +const SELECTION_STROKE_SOFT = 'rgba(47,127,134,0.52)'; + +/** + * 马赛克模块 — 矩形/自由选区/画笔三种交互方式,马赛克 + 模糊两种效果 + * + * 框选模式 (rect):拖拽矩形选区应用马赛克/模糊 + * 自由选区 (lasso):拖拽闭合非矩形选区应用马赛克/模糊 + * 画笔模式 (brush):自由涂抹马赛克/模糊,释放时对涂抹覆盖区域(包围盒+画笔半径)应用效果 + */ +class MosaicModule extends BaseModule { + constructor(canvasManager, historyManager, defaultOptions = {}) { + super(canvasManager, historyManager, { + mode: 'mosaic', // 效果类型: 'mosaic' | 'blur' + drawMode: 'rect', // 交互方式: 'rect' | 'lasso' | 'brush' + mosaicSize: 12, // 马赛克块大小,默认对应“中马赛克”预设 + blurRadius: 8, // 模糊半径 + brushSize: 20, // 画笔直径 + ...defaultOptions, + }); + + this._isDrawing = false; + this._startPoint = null; + this._selectionRect = null; // 框选模式的虚线矩形 + this._lassoPoints = []; // 自由选区的轮廓点 + this._lassoPreview = null; // 自由选区预览轮廓 + this._brushPoints = []; // 画笔模式的轨迹点 + this._brushPreview = null; // 画笔预览圆圈 + this._liveBrushOverlay = null; // 画笔模式拖动中的实时马赛克层 + this._detachedCanvasClipPath = null; + this._clipPathDetached = false; + this._objectClipPathBackups = null; + + this._boundMouseDown = this._onMouseDown.bind(this); + this._boundMouseMove = this._onMouseMove.bind(this); + this._boundMouseUp = this._onMouseUp.bind(this); + this._boundMouseOut = this._onMouseOut.bind(this); + this._boundObjectMoving = this._onObjectMoving.bind(this); + this._refreshingDynamicMosaic = false; + this._eventBusUnsubscribers = []; + this._refreshDynamicRafId = null; + + this._bindDynamicMosaicEvents(); + } + + _bindDynamicMosaicEvents() { + const canvas = this.canvasManager.canvas; + if (canvas) { + canvas.on('object:moving', this._boundObjectMoving); + canvas.on('object:scaling', this._boundObjectMoving); + canvas.on('object:rotating', this._boundObjectMoving); + } + + this._eventBusUnsubscribers.push( + eventBus.on('canvas:objectModified', (target) => { + if (this._refreshingDynamicMosaic) return; + + const targets = this._getDynamicMosaicTargets(target); + if (targets.length > 0) { + targets.forEach(obj => this._refreshDynamicMosaicOverlay(obj, { render: false })); + this._requestRender(); + return; + } + + this.refreshDynamicMosaics(); + }), + eventBus.on('canvas:restored', () => this.refreshDynamicMosaics()), + eventBus.on('canvas:objectAdded', (target) => { + if (target === this._selectionRect || target === this._brushPreview || target === this._lassoPreview) return; + if (target && this._isDynamicMosaic(target)) return; + this.refreshDynamicMosaics(); + }), + eventBus.on('canvas:objectRemoved', () => this.refreshDynamicMosaics()), + eventBus.on('layer:visibilityChanged', () => this.refreshDynamicMosaics()), + eventBus.on('layer:reordered', () => this.refreshDynamicMosaics()), + eventBus.on('mosaic:refreshDynamic', () => this.refreshDynamicMosaics({ render: true })) + ); + + // 通过正式方法注册回调,而非动态注入 + this.canvasManager.setRefreshDynamicMosaics((options) => this.refreshDynamicMosaics(options)); + } + + // ── 生命周期 ── + + activate(options = {}) { + super.activate(options); + + const canvas = this.canvasManager.canvas; + canvas.defaultCursor = this._getCursorForDrawMode(); + this._detachCanvasClipPath(); + + canvas.on('mouse:down', this._boundMouseDown); + canvas.on('mouse:move', this._boundMouseMove); + canvas.on('mouse:up', this._boundMouseUp); + canvas.on('mouse:out', this._boundMouseOut); + + eventBus.emit('module:activated', 'mosaic'); + } + + deactivate() { + const canvas = this.canvasManager.canvas; + + canvas.off('mouse:down', this._boundMouseDown); + canvas.off('mouse:move', this._boundMouseMove); + canvas.off('mouse:up', this._boundMouseUp); + canvas.off('mouse:out', this._boundMouseOut); + this._cleanupRect(); + this._cleanupLasso(); + this._cleanupLiveBrushOverlay(); + this._cleanupBrush(); + this._restoreDetachedCanvasClipPath(false); + canvas.renderAll(); + + super.deactivate(); + } + + destroy() { + const canvas = this.canvasManager.canvas; + if (canvas) { + canvas.off('object:moving', this._boundObjectMoving); + canvas.off('object:scaling', this._boundObjectMoving); + canvas.off('object:rotating', this._boundObjectMoving); + } + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; + // 清除动态马赛克回调 + this.canvasManager.setRefreshDynamicMosaics(null); + } + + // ── 参数设置 ── + + setMode(mode) { + this.options.mode = mode; + } + + setDrawMode(mode) { + if (!['rect', 'lasso', 'brush'].includes(mode)) return; + if (this.options.drawMode !== mode) { + this._isDrawing = false; + this._startPoint = null; + this._brushPoints = []; + this._cleanupRect(); + this._cleanupLasso(); + this._cleanupLiveBrushOverlay(); + this._cleanupBrush(); + } + + this.options.drawMode = mode; + const canvas = this.canvasManager.canvas; + if (canvas) { + canvas.defaultCursor = this._getCursorForDrawMode(); + } + } + + setMosaicSize(size) { + this.options.mosaicSize = Math.max(2, Math.min(50, parseInt(size))); + } + + setBlurRadius(radius) { + this.options.blurRadius = Math.max(1, Math.min(30, parseInt(radius))); + } + + setBrushSize(size) { + this.options.brushSize = Math.max(4, Math.min(100, parseInt(size))); + if (this._brushPreview) { + const center = this._getBrushPreviewCenter(); + if (center) this._updateBrushPreview(center); + } + } + + // ── 鼠标事件分发 ── + + _onMouseDown(e) { + if (this.options.drawMode === 'brush') { + this._startBrush(e); + } else if (this.options.drawMode === 'lasso') { + this._startLasso(e); + } else { + this._startRect(e); + } + } + + _onMouseMove(e) { + if (this.options.drawMode === 'brush') { + if (this._isDrawing) { + this._continueBrush(e); + } else { + this._updateBrushPreview(this.canvasManager.canvas.getPointer(e.e)); + } + return; + } + + if (!this._isDrawing) return; + + if (this.options.drawMode === 'lasso') { + this._continueLasso(e); + } else { + this._updateRect(e); + } + } + + _onMouseOut() { + if (!this._isDrawing && this.options.drawMode === 'brush') { + this._cleanupBrush(); + } + } + + _onMouseUp(e) { + if (!this._isDrawing) return; + + if (this.options.drawMode === 'brush') { + this._finishBrush(e); + } else if (this.options.drawMode === 'lasso') { + this._finishLasso(e); + } else { + this._finishRect(e); + } + } + + _getCursorForDrawMode() { + return this.options.drawMode === 'brush' ? 'none' : 'crosshair'; + } + + // ═══════════════════════════════════════ + // 框选模式 (rect) — 拖拽矩形选区 + // ═══════════════════════════════════════ + + _startRect(e) { + const pointer = this.canvasManager.canvas.getPointer(e.e); + this._isDrawing = true; + this._startPoint = pointer; + + this._selectionRect = new fabric.Rect({ + left: pointer.x, + top: pointer.y, + width: 0, + height: 0, + fill: SELECTION_FILL, + stroke: SELECTION_STROKE, + strokeWidth: 1.5, + strokeDashArray: [4, 3], + selectable: false, + evented: false, + }); + this.canvasManager.canvas.add(this._selectionRect); + } + + _updateRect(e) { + const pointer = this.canvasManager.canvas.getPointer(e.e); + const left = Math.min(this._startPoint.x, pointer.x); + const top = Math.min(this._startPoint.y, pointer.y); + const width = Math.abs(pointer.x - this._startPoint.x); + const height = Math.abs(pointer.y - this._startPoint.y); + + this._selectionRect.set({ left, top, width, height }); + this.canvasManager.canvas.renderAll(); + } + + _finishRect(e) { + this._isDrawing = false; + + if (!this._selectionRect) return; + + const rect = this._selectionRect; + const width = rect.width * rect.scaleX; + const height = rect.height * rect.scaleY; + + const mosaicRect = { + left: rect.left, + top: rect.top, + width: Math.round(width), + height: Math.round(height), + }; + + // 先移除选区框,否则 getImageData 会把提示色读进去 + this._cleanupRect(); + + if (mosaicRect.width < 5 || mosaicRect.height < 5) return; + + this.applyMosaic(mosaicRect); + } + + _cleanupRect() { + if (this._selectionRect) { + this.canvasManager.canvas.remove(this._selectionRect); + this._selectionRect = null; + } + this.canvasManager.canvas.renderAll(); + } + + // ═══════════════════════════════════════ + // 自由选区 (lasso) — 拖拽闭合非矩形选区 + // ═══════════════════════════════════════ + + _startLasso(e) { + const pointer = this.canvasManager.canvas.getPointer(e.e); + this._isDrawing = true; + this._lassoPoints = [{ x: pointer.x, y: pointer.y }]; + + this._lassoPreview = new fabric.Polygon(this._getLassoPreviewPoints(), { + fill: SELECTION_FILL, + stroke: SELECTION_STROKE, + strokeWidth: 1.5, + strokeDashArray: [4, 3], + selectable: false, + evented: false, + objectCaching: false, + }); + this.canvasManager.canvas.add(this._lassoPreview); + this.canvasManager.canvas.renderAll(); + } + + _continueLasso(e) { + const pointer = this.canvasManager.canvas.getPointer(e.e); + if (this._appendLassoPoint(pointer)) { + this._updateLassoPreview(); + } + } + + _finishLasso(e) { + const pointer = this.canvasManager.canvas.getPointer(e.e); + this._appendLassoPoint(pointer); + this._isDrawing = false; + + const points = this._lassoPoints.slice(); + this._cleanupLasso(); + + if (points.length < 3 || this._getPolygonArea(points) < 25) return; + + const bounds = this._getPointsBounds(points); + if (!bounds) return; + + const rect = this._clipRectToEditableImage({ + left: Math.floor(bounds.left), + top: Math.floor(bounds.top), + width: Math.ceil(bounds.right - bounds.left), + height: Math.ceil(bounds.bottom - bounds.top), + }); + + if (!rect || rect.width < 5 || rect.height < 5) return; + + this._saveStateWithCanvasClipPath(); + this._createDynamicMosaicOverlay({ + rect, + maskType: 'lasso', + lassoPoints: points.map(p => ({ + x: Math.round(p.x - rect.left), + y: Math.round(p.y - rect.top), + })), + }); + } + + _appendLassoPoint(pointer) { + const last = this._lassoPoints[this._lassoPoints.length - 1]; + if (!last) { + this._lassoPoints.push({ x: pointer.x, y: pointer.y }); + return true; + } + + const dx = pointer.x - last.x; + const dy = pointer.y - last.y; + if (Math.sqrt(dx * dx + dy * dy) <= 2) return false; + + this._lassoPoints.push({ x: pointer.x, y: pointer.y }); + return true; + } + + _getLassoPreviewPoints() { + if (this._lassoPoints.length > 1) { + return this._lassoPoints.map(p => ({ x: p.x, y: p.y })); + } + + const p = this._lassoPoints[0] || { x: 0, y: 0 }; + return [{ x: p.x, y: p.y }, { x: p.x, y: p.y }]; + } + + _updateLassoPreview() { + if (!this._lassoPreview) return; + + this._lassoPreview.set({ points: this._getLassoPreviewPoints() }); + if (typeof this._lassoPreview._setPositionDimensions === 'function') { + this._lassoPreview._setPositionDimensions({}); + } + this._lassoPreview.setCoords(); + this._lassoPreview.dirty = true; + this._requestRender(); + } + + _cleanupLasso() { + if (this._lassoPreview) { + this.canvasManager.canvas.remove(this._lassoPreview); + this._lassoPreview = null; + } + this._lassoPoints = []; + this.canvasManager.canvas.renderAll(); + } + + _getPointsBounds(points) { + return getPointsBounds(points); + } + + _getPolygonArea(points) { + let area = 0; + for (let i = 0; i < points.length; i++) { + const a = points[i]; + const b = points[(i + 1) % points.length]; + area += a.x * b.y - b.x * a.y; + } + return Math.abs(area) / 2; + } + + // ═══════════════════════════════════════ + // 画笔模式 (brush) — 自由涂抹 + // ═══════════════════════════════════════ + + _startBrush(e) { + const pointer = this.canvasManager.canvas.getPointer(e.e); + this._isDrawing = true; + this._brushPoints = [{ x: pointer.x, y: pointer.y }]; + + this._saveStateWithCanvasClipPath(); + this._updateLiveBrushOverlay(); + this._updateBrushPreview(pointer); + } + + _updateBrushPreview(pointer) { + if (!pointer) return; + + const strokeWidth = 1; + const r = Math.max(1, (this.options.brushSize - strokeWidth) / 2); + + if (!this._brushPreview) { + this._brushPreview = new fabric.Circle({ + left: pointer.x - r, + top: pointer.y - r, + radius: r, + fill: 'transparent', + stroke: SELECTION_STROKE, + strokeWidth, + selectable: false, + evented: false, + objectCaching: false, + }); + this.canvasManager.canvas.add(this._brushPreview); + } else { + this._brushPreview.set({ + left: pointer.x - r, + top: pointer.y - r, + radius: r, + strokeWidth, + }); + } + + this._brushPreview.setCoords(); + this._bringBrushPreviewToFront(); + this._requestRender(); + } + + _getBrushPreviewCenter() { + if (!this._brushPreview) return null; + + const r = this._brushPreview.radius || 0; + return { + x: (this._brushPreview.left || 0) + r, + y: (this._brushPreview.top || 0) + r, + }; + } + + _bringBrushPreviewToFront() { + if (!this._brushPreview) return; + + const canvas = this.canvasManager.canvas; + if (typeof canvas?.bringToFront === 'function') { + canvas.bringToFront(this._brushPreview); + } else if (typeof this._brushPreview.bringToFront === 'function') { + this._brushPreview.bringToFront(); + } + } + + _continueBrush(e) { + const pointer = this.canvasManager.canvas.getPointer(e.e); + this._updateBrushPreview(pointer); + + // 采样优化:距离上个点超过一定距离才记录,避免点过密 + const last = this._brushPoints[this._brushPoints.length - 1]; + const dx = pointer.x - last.x; + const dy = pointer.y - last.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist > 2) { + this._brushPoints.push({ x: pointer.x, y: pointer.y }); + this._updateLiveBrushOverlay(); + } + } + + _finishBrush(e) { + this._isDrawing = false; + this._updateBrushPreview(this.canvasManager.canvas.getPointer(e.e)); + this._updateLiveBrushOverlay(); + + if (this._brushPoints.length === 0) return; + this._brushPoints = []; + + if (!this._liveBrushOverlay) return; + + this._liveBrushOverlay = null; + } + + _updateLiveBrushOverlay() { + if (this._brushPoints.length === 0) return; + + const rect = this._getBrushStrokeRect(this._brushPoints); + if (!rect || rect.width < 1 || rect.height < 1) { + this._cleanupLiveBrushOverlay(); + return; + } + + const brushPoints = this._brushPoints.map(p => ({ + x: Math.round(p.x - rect.left), + y: Math.round(p.y - rect.top), + })); + + if (!this._liveBrushOverlay) { + this._liveBrushOverlay = this._createDynamicMosaicOverlay({ + rect, + maskType: 'brush', + brushPoints, + brushSize: this.options.brushSize, + }); + this._bringBrushPreviewToFront(); + this._requestRender(); + return; + } + + this._liveBrushOverlay.set({ + left: rect.left, + top: rect.top, + scaleX: 1, + scaleY: 1, + }); + this._liveBrushOverlay._mosaicMode = this.options.mode; + this._liveBrushOverlay._mosaicSize = this.options.mosaicSize; + this._liveBrushOverlay._mosaicBlurRadius = this.options.blurRadius; + this._liveBrushOverlay._mosaicWidth = Math.max(1, Math.round(rect.width)); + this._liveBrushOverlay._mosaicHeight = Math.max(1, Math.round(rect.height)); + this._liveBrushOverlay._mosaicMaskType = 'brush'; + this._liveBrushOverlay._mosaicBrushPoints = brushPoints; + this._liveBrushOverlay._mosaicBrushSize = this.options.brushSize; + this._liveBrushOverlay._mosaicMaskCanvas = null; + this._liveBrushOverlay._layerKind = 'mosaic'; + this._liveBrushOverlay._layerPresetName = this._getCurrentLayerPresetName(); + this._liveBrushOverlay.setCoords(); + + this._refreshDynamicMosaicOverlay(this._liveBrushOverlay, { render: false }); + this._bringBrushPreviewToFront(); + this._requestRender(); + } + + _getBrushStrokeRect(points) { + const bounds = this._getPointsBounds(points); + if (!bounds) return null; + + const padding = this.options.brushSize / 2; + return this._clipRectToEditableImage({ + left: Math.round(bounds.left - padding), + top: Math.round(bounds.top - padding), + width: Math.round(bounds.right - bounds.left + padding * 2), + height: Math.round(bounds.bottom - bounds.top + padding * 2), + }); + } + + _cleanupLiveBrushOverlay() { + if (this._liveBrushOverlay) { + this.canvasManager.canvas.remove(this._liveBrushOverlay); + this._liveBrushOverlay = null; + } + } + + _cleanupBrush() { + if (this._brushPreview) { + this.canvasManager.canvas.remove(this._brushPreview); + this._brushPreview = null; + } + this.canvasManager.canvas.renderAll(); + } + + /** + * 马赛克效果 — 对阵列像素进行块化处理 + * 只处理蒙版覆盖区域内的像素 + */ + _mosaicPixels(data, w, h, mask = null, mosaicSize = this.options.mosaicSize) { + const defaultSize = this.options.mosaicSize || 12; + const rawSizeX = typeof mosaicSize === 'object' ? mosaicSize?.width : mosaicSize; + const rawSizeY = typeof mosaicSize === 'object' ? mosaicSize?.height : mosaicSize; + const sizeX = Math.max(1, Math.round(Number(rawSizeX) || defaultSize)); + const sizeY = Math.max(1, Math.round(Number(rawSizeY) || defaultSize)); + + for (let y = 0; y < h; y += sizeY) { + for (let x = 0; x < w; x += sizeX) { + // 检查该块是否有蒙版区域;矩形模式没有蒙版时整块处理。 + let hasMask = !mask; + if (mask) { + for (let dy = 0; dy < sizeY && y + dy < h && !hasMask; dy++) { + for (let dx = 0; dx < sizeX && x + dx < w && !hasMask; dx++) { + const mi = ((y + dy) * w + (x + dx)) * 4; + if (mask[mi + 3] > 0) hasMask = true; + } + } + } + if (!hasMask) continue; + + let r = 0, g = 0, b = 0, a = 0, count = 0; + for (let dy = 0; dy < sizeY && y + dy < h; dy++) { + for (let dx = 0; dx < sizeX && x + dx < w; dx++) { + const idx = ((y + dy) * w + (x + dx)) * 4; + r += data[idx]; + g += data[idx + 1]; + b += data[idx + 2]; + a += data[idx + 3]; + count++; + } + } + r = Math.round(r / count); + g = Math.round(g / count); + b = Math.round(b / count); + a = Math.round(a / count); + + for (let dy = 0; dy < sizeY && y + dy < h; dy++) { + for (let dx = 0; dx < sizeX && x + dx < w; dx++) { + const idx = ((y + dy) * w + (x + dx)) * 4; + if (!mask || mask[idx + 3] > 0) { + data[idx] = r; + data[idx + 1] = g; + data[idx + 2] = b; + data[idx + 3] = a; + } + } + } + } + } + } + + /** + * 模糊效果 — 使用 Canvas2D filter + */ + _blurPixels(data, originalImgData, w, h, mask = null, blurRadius = this.options.blurRadius) { + const radius = blurRadius || this.options.blurRadius || 8; + + // 源 canvas:放原始像素 + const srcCanvas = document.createElement('canvas'); + srcCanvas.width = w; + srcCanvas.height = h; + const srcCtx = srcCanvas.getContext('2d', { willReadFrequently: true }); + srcCtx.putImageData(new ImageData(data, w, h), 0, 0); + + // 目标 canvas:避免自绘制预乘 alpha 色偏 + const dstCanvas = document.createElement('canvas'); + dstCanvas.width = w; + dstCanvas.height = h; + const dstCtx = dstCanvas.getContext('2d', { willReadFrequently: true }); + dstCtx.filter = `blur(${radius}px)`; + dstCtx.drawImage(srcCanvas, 0, 0); + dstCtx.filter = 'none'; + + const blurred = dstCtx.getImageData(0, 0, w, h); + + // 只拷贝蒙版区域的模糊像素;矩形模式没有蒙版时整块处理。 + for (let i = 0; i < data.length; i += 4) { + if (!mask || mask[i + 3] > 0) { + data[i] = blurred.data[i]; + data[i + 1] = blurred.data[i + 1]; + data[i + 2] = blurred.data[i + 2]; + data[i + 3] = blurred.data[i + 3]; + } + } + } + + // ═══════════════════════════════════════ + // 核心马赛克方法 + // ═══════════════════════════════════════ + + /** + * 对指定矩形区域应用马赛克/模糊(框选模式使用) + */ + applyMosaic(rect) { + rect = this._clipRectToEditableImage(rect); + if (!rect || rect.width < 1 || rect.height < 1) return; + + this._saveStateWithCanvasClipPath(); + this._createDynamicMosaicOverlay({ rect, maskType: 'rect' }); + } + + _createDynamicMosaicOverlay({ rect, maskType, brushPoints = null, brushSize = null, lassoPoints = null }) { + const canvas = this.canvasManager.canvas; + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = Math.max(1, Math.round(rect.width)); + tempCanvas.height = Math.max(1, Math.round(rect.height)); + + const img = new fabric.Image(tempCanvas, { + left: rect.left, + top: rect.top, + selectable: false, + evented: false, + id: 'mosaic_' + Date.now(), + objectCaching: false, + }); + + img._mosaicDynamic = true; + img._mosaicMode = this.options.mode; + img._mosaicSize = this.options.mosaicSize; + img._mosaicBlurRadius = this.options.blurRadius; + img._mosaicWidth = tempCanvas.width; + img._mosaicHeight = tempCanvas.height; + img._mosaicMaskType = maskType; + img._mosaicBrushPoints = brushPoints; + img._mosaicBrushSize = brushSize; + img._mosaicLassoPoints = lassoPoints; + img._layerKind = 'mosaic'; + img._layerPresetName = this._getCurrentLayerPresetName(); + + this._attachCurrentCropClipPath(img); + this._refreshDynamicMosaicOverlay(img, { render: false }); + + if (this._selectionRect) { + canvas.remove(this._selectionRect); + this._selectionRect = null; + } + + canvas.add(img); + canvas.renderAll(); + return img; + } + + refreshDynamicMosaics(options = {}) { + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + const objects = canvas.getObjects(); + let changed = false; + + this._refreshingDynamicMosaic = true; + try { + for (const obj of objects) { + if (this._isDynamicMosaic(obj)) { + changed = this._refreshDynamicMosaicOverlay(obj, { render: false }) || changed; + } + } + } finally { + this._refreshingDynamicMosaic = false; + } + + if (changed && options.render !== false) { + canvas.renderAll(); + } + } + + _onObjectMoving(e) { + if (this._refreshingDynamicMosaic) return; + + const targets = this._getDynamicMosaicTargets(e.target); + if (targets.length === 0) return; + + // 使用 RAF 防抖,避免在拖动过程中频繁重算 + if (this._refreshDynamicRafId) { + cancelAnimationFrame(this._refreshDynamicRafId); + } + + this._refreshDynamicRafId = requestAnimationFrame(() => { + this._refreshDynamicRafId = null; + this._refreshingDynamicMosaic = true; + try { + targets.forEach(obj => this._refreshDynamicMosaicOverlay(obj, { render: false })); + this._requestRender(); + } finally { + this._refreshingDynamicMosaic = false; + } + }); + } + + _getDynamicMosaicTargets(target) { + if (!target) return []; + const canvas = this.canvasManager.canvas; + if (!canvas) return []; + + let explicitTargets = []; + if (this._isDynamicMosaic(target)) { + explicitTargets = [target]; + } + + if (target.type === 'activeSelection' && typeof target.getObjects === 'function') { + explicitTargets = target.getObjects().filter(obj => this._isDynamicMosaic(obj)); + } + + if (explicitTargets.length === 0) return []; + + const objects = canvas.getObjects(); + const firstIndex = explicitTargets.reduce((min, obj) => { + const index = objects.indexOf(obj); + return index >= 0 ? Math.min(min, index) : min; + }, objects.length); + + return objects.filter((obj, index) => index >= firstIndex && this._isDynamicMosaic(obj)); + } + + _isDynamicMosaic(obj) { + return !!obj && obj._mosaicDynamic === true; + } + + _refreshDynamicMosaicOverlay(obj, options = {}) { + if (!this._isDynamicMosaic(obj)) return false; + + const dimensions = this._getDynamicMosaicDimensions(obj); + if (!dimensions) return false; + + const { width, height } = dimensions; + const element = this._ensureDynamicMosaicElement(obj, width, height); + const ctx = element.getContext('2d', { willReadFrequently: true }); + const imgData = this._captureDynamicMosaicSource(obj, width, height); + const maskData = this._getDynamicMosaicMaskData(obj, width, height); + + if ((obj._mosaicMode || 'mosaic') === 'mosaic') { + this._mosaicPixels(imgData.data, width, height, maskData, this._getLocalMosaicBlockSize(obj)); + } else { + this._blurPixels(imgData.data, imgData, width, height, maskData, this._getLocalBlurRadius(obj)); + } + + if (maskData) { + this._applyAlphaMask(imgData.data, maskData); + } + + ctx.clearRect(0, 0, width, height); + ctx.putImageData(imgData, 0, 0); + obj.dirty = true; + + if (options.render !== false) { + this._requestRender(); + } + + return true; + } + + _getDynamicMosaicDimensions(obj) { + const width = Math.max(1, Math.round(obj._mosaicWidth || obj.width || 0)); + const height = Math.max(1, Math.round(obj._mosaicHeight || obj.height || 0)); + if (!Number.isFinite(width) || !Number.isFinite(height)) return null; + return { width, height }; + } + + _ensureDynamicMosaicElement(obj, width, height) { + let element = typeof obj.getElement === 'function' ? obj.getElement() : obj._element; + const needsCanvas = !element || element.nodeName?.toLowerCase() !== 'canvas'; + const needsResize = !needsCanvas && (element.width !== width || element.height !== height); + + if (needsCanvas) { + element = document.createElement('canvas'); + if (typeof obj.setElement === 'function') { + obj.setElement(element); + } else { + obj._element = element; + obj._originalElement = element; + } + } + + if (needsCanvas || needsResize) { + element.width = width; + element.height = height; + obj.set({ width, height }); + obj._mosaicWidth = width; + obj._mosaicHeight = height; + } + + return element; + } + + _captureDynamicMosaicSource(obj, width, height) { + const sourceCanvas = this._renderObjectsBelow(obj); + const sampleCanvas = document.createElement('canvas'); + sampleCanvas.width = width; + sampleCanvas.height = height; + const sampleCtx = sampleCanvas.getContext('2d', { willReadFrequently: true }); + + const matrix = this._getDynamicMosaicSamplingMatrix(obj, width, height); + const inverse = matrix ? this._invertTransform(matrix) : null; + + if (inverse) { + sampleCtx.save(); + sampleCtx.setTransform(inverse[0], inverse[1], inverse[2], inverse[3], inverse[4], inverse[5]); + sampleCtx.drawImage(sourceCanvas, 0, 0); + sampleCtx.restore(); + } else { + this._captureDynamicMosaicSourceFallback(sampleCtx, sourceCanvas, obj, width, height); + } + + return sampleCtx.getImageData(0, 0, width, height); + } + + _captureDynamicMosaicSourceFallback(sampleCtx, sourceCanvas, obj, width, height) { + const left = Math.round(obj.left || 0); + const top = Math.round(obj.top || 0); + const sampleWidth = Math.max(1, Math.round(width * Math.abs(obj.scaleX || 1))); + const sampleHeight = Math.max(1, Math.round(height * Math.abs(obj.scaleY || 1))); + const sx = Math.max(0, left); + const sy = Math.max(0, top); + const ex = Math.min(sourceCanvas.width, left + sampleWidth); + const ey = Math.min(sourceCanvas.height, top + sampleHeight); + const sw = Math.max(0, ex - sx); + const sh = Math.max(0, ey - sy); + + if (sw <= 0 || sh <= 0) return; + + sampleCtx.drawImage( + sourceCanvas, + sx, + sy, + sw, + sh, + (sx - left) * width / sampleWidth, + (sy - top) * height / sampleHeight, + sw * width / sampleWidth, + sh * height / sampleHeight + ); + } + + _getDynamicMosaicSamplingMatrix(obj, width, height) { + const matrix = this._getObjectTransformMatrix(obj); + if (!matrix) return null; + + return this._multiplyTransformMatrices(matrix, [1, 0, 0, 1, -width / 2, -height / 2]); + } + + _getObjectTransformMatrix(obj) { + if (typeof obj?.calcTransformMatrix === 'function') { + const matrix = obj.calcTransformMatrix(); + if (this._isValidTransformMatrix(matrix)) return matrix; + } + + return null; + } + + _getLocalMosaicBlockSize(obj) { + const baseSize = Math.max(1, Math.round(obj._mosaicSize || this.options.mosaicSize || 12)); + const scale = this._getDynamicMosaicTransformScale(obj); + + return { + width: Math.max(1, Math.round(baseSize / scale.x)), + height: Math.max(1, Math.round(baseSize / scale.y)), + }; + } + + _getLocalBlurRadius(obj) { + const radius = Math.max(1, Math.round(obj._mosaicBlurRadius || this.options.blurRadius || 8)); + const scale = this._getDynamicMosaicTransformScale(obj); + const averageScale = Math.max(0.0001, Math.sqrt(scale.x * scale.y)); + return Math.max(1, radius / averageScale); + } + + _getDynamicMosaicTransformScale(obj) { + const matrix = this._getObjectTransformMatrix(obj); + if (!matrix) { + return { + x: Math.max(0.0001, Math.abs(obj?.scaleX || 1)), + y: Math.max(0.0001, Math.abs(obj?.scaleY || 1)), + }; + } + + return { + x: Math.max(0.0001, Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1])), + y: Math.max(0.0001, Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3])), + }; + } + + _isValidTransformMatrix(matrix) { + return Array.isArray(matrix) + && matrix.length >= 6 + && matrix.slice(0, 6).every(value => Number.isFinite(value)); + } + + _multiplyTransformMatrices(a, b) { + return [ + a[0] * b[0] + a[2] * b[1], + a[1] * b[0] + a[3] * b[1], + a[0] * b[2] + a[2] * b[3], + a[1] * b[2] + a[3] * b[3], + a[0] * b[4] + a[2] * b[5] + a[4], + a[1] * b[4] + a[3] * b[5] + a[5], + ]; + } + + _invertTransform(matrix) { + const determinant = matrix[0] * matrix[3] - matrix[1] * matrix[2]; + if (!Number.isFinite(determinant) || Math.abs(determinant) < 1e-8) return null; + + return [ + matrix[3] / determinant, + -matrix[1] / determinant, + -matrix[2] / determinant, + matrix[0] / determinant, + (matrix[2] * matrix[5] - matrix[3] * matrix[4]) / determinant, + (matrix[1] * matrix[4] - matrix[0] * matrix[5]) / determinant, + ]; + } + + _renderObjectsBelow(obj) { + const canvas = this.canvasManager.canvas; + const width = Math.max(1, Math.ceil(canvas.width || 1)); + const height = Math.max(1, Math.ceil(canvas.height || 1)); + const sourceCanvas = document.createElement('canvas'); + sourceCanvas.width = width; + sourceCanvas.height = height; + const ctx = sourceCanvas.getContext('2d', { willReadFrequently: true }); + + this._renderSourceBackground(ctx, width, height); + + const objects = canvas.getObjects(); + const objectIndex = objects.indexOf(obj); + const endIndex = objectIndex === -1 ? objects.length : objectIndex; + + for (let i = 0; i < endIndex; i++) { + const candidate = objects[i]; + if (!candidate || candidate === obj || candidate.visible === false) continue; + if (candidate === this._selectionRect || candidate === this._brushPreview || candidate === this._lassoPreview) continue; + + try { + ctx.save(); + candidate.render(ctx); + ctx.restore(); + } catch (err) { + ctx.restore(); + console.warn('[MosaicModule] 渲染动态马赛克底层失败:', err); + } + } + + return sourceCanvas; + } + + _renderSourceBackground(ctx, width, height) { + const backgroundColor = this.canvasManager.canvas?.backgroundColor; + if (!backgroundColor || backgroundColor === 'transparent') return; + if (typeof backgroundColor !== 'string') return; + + ctx.save(); + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, width, height); + ctx.restore(); + } + + _getDynamicMosaicMaskData(obj, width, height) { + if (obj._mosaicMaskType !== 'brush' && obj._mosaicMaskType !== 'lasso') return null; + + if (!obj._mosaicMaskCanvas + || obj._mosaicMaskCanvas.width !== width + || obj._mosaicMaskCanvas.height !== height) { + obj._mosaicMaskCanvas = obj._mosaicMaskType === 'lasso' + ? this._createLassoMaskCanvas(width, height, obj._mosaicLassoPoints || []) + : this._createBrushMaskCanvas( + width, + height, + obj._mosaicBrushPoints || [], + obj._mosaicBrushSize || this.options.brushSize + ); + } + + const maskCtx = obj._mosaicMaskCanvas.getContext('2d', { willReadFrequently: true }); + return maskCtx.getImageData(0, 0, width, height).data; + } + + _createBrushMaskCanvas(width, height, points, brushSize) { + const maskCanvas = document.createElement('canvas'); + maskCanvas.width = width; + maskCanvas.height = height; + const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); + const brushR = Math.max(1, (brushSize || this.options.brushSize) / 2); + + maskCtx.fillStyle = '#ffffff'; + for (const p of points) { + if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) continue; + maskCtx.beginPath(); + maskCtx.arc(p.x, p.y, brushR, 0, Math.PI * 2); + maskCtx.fill(); + } + + return maskCanvas; + } + + _createLassoMaskCanvas(width, height, points) { + const maskCanvas = document.createElement('canvas'); + maskCanvas.width = width; + maskCanvas.height = height; + const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); + const validPoints = points.filter(p => Number.isFinite(p.x) && Number.isFinite(p.y)); + + if (validPoints.length < 3) return maskCanvas; + + maskCtx.fillStyle = '#ffffff'; + maskCtx.beginPath(); + maskCtx.moveTo(validPoints[0].x, validPoints[0].y); + for (let i = 1; i < validPoints.length; i++) { + maskCtx.lineTo(validPoints[i].x, validPoints[i].y); + } + maskCtx.closePath(); + maskCtx.fill(); + + return maskCanvas; + } + + _applyAlphaMask(data, maskData) { + for (let i = 0; i < data.length; i += 4) { + const alpha = maskData[i + 3]; + if (alpha <= 0) { + data[i] = 0; + data[i + 1] = 0; + data[i + 2] = 0; + data[i + 3] = 0; + } else if (alpha < 255) { + data[i + 3] = Math.round(data[i + 3] * alpha / 255); + } + } + } + + _clipRectToEditableImage(rect) { + const bounds = this._getEditableImageBounds(); + if (!bounds) return rect; + + const left = this._clamp(rect.left, bounds.left, bounds.right); + const top = this._clamp(rect.top, bounds.top, bounds.bottom); + const right = this._clamp(rect.left + rect.width, bounds.left, bounds.right); + const bottom = this._clamp(rect.top + rect.height, bounds.top, bounds.bottom); + + if (right <= left || bottom <= top) return null; + return { + left: Math.round(left), + top: Math.round(top), + width: Math.round(right - left), + height: Math.round(bottom - top), + }; + } + + _getEditableImageBounds() { + const imageBounds = this._getImageBounds(); + if (!imageBounds) return null; + + const cropBounds = this._getCurrentCropBounds(); + return cropBounds ? this._intersectBounds(imageBounds, cropBounds) : imageBounds; + } + + _getImageBounds() { + const image = this.canvasManager.originalImage; + if (!image) return null; + + return this._normalizeBounds(image.getBoundingRect(true, true)); + } + + _getCurrentCropBounds() { + return this._getClipPathBounds(this._getActiveCropClipPath()); + } + + _getClipPathBounds(clipPath) { + if (!clipPath) return null; + + const bounds = this._normalizeBounds(clipPath.getBoundingRect(true, true)); + const nested = this._getClipPathBounds(clipPath.clipPath); + return nested ? this._intersectBounds(bounds, nested) : bounds; + } + + _normalizeBounds(bounds) { + return normalizeBounds(bounds); + } + + _intersectBounds(a, b) { + return intersectBounds(a, b); + } + + _clamp(value, min, max) { + return clamp(value, min, max); + } + + _getActiveCropClipPath() { + return this._detachedCanvasClipPath || this.canvasManager.canvas?.clipPath || null; + } + + _detachCanvasClipPath() { + const canvas = this.canvasManager.canvas; + if (!canvas?.clipPath) return; + + this._detachedCanvasClipPath = canvas.clipPath; + this._clipPathDetached = true; + canvas.clipPath = null; + this._applyTemporaryObjectClipPaths(this._detachedCanvasClipPath, false); + this._requestRender(); + } + + _applyTemporaryObjectClipPaths(clipPath, render = true) { + const canvas = this.canvasManager.canvas; + if (!canvas || !clipPath) return; + + this._clearTemporaryObjectClipPaths(false); + this._objectClipPathBackups = canvas.getObjects() + .filter(obj => obj !== this._selectionRect && obj !== this._brushPreview && obj !== this._lassoPreview) + .map(obj => ({ obj, clipPath: obj.clipPath || null })); + + this._objectClipPathBackups.forEach(({ obj }) => { + obj.set('clipPath', this._createClipPathFromSource(clipPath)); + obj.dirty = true; + }); + + if (render) this._requestRender(); + } + + _clearTemporaryObjectClipPaths(render = true) { + if (!this._objectClipPathBackups) return; + + this._objectClipPathBackups.forEach(({ obj, clipPath }) => { + obj.set('clipPath', clipPath || null); + obj.dirty = true; + }); + this._objectClipPathBackups = null; + + if (render) this._requestRender(); + } + + _restoreDetachedCanvasClipPath(render = true) { + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + this._clearTemporaryObjectClipPaths(false); + if (this._clipPathDetached) { + canvas.clipPath = this._detachedCanvasClipPath; + } + this._detachedCanvasClipPath = null; + this._clipPathDetached = false; + + if (render) this._requestRender(); + } + + _saveStateWithCanvasClipPath() { + const canvas = this.canvasManager.canvas; + if (!canvas) { + this.history.saveState(); + return; + } + + const transientObjects = [this._selectionRect, this._brushPreview, this._lassoPreview] + .filter(obj => obj && obj.canvas === canvas); + transientObjects.forEach(obj => canvas.remove(obj)); + + try { + if (!this._clipPathDetached) { + this.history.saveState(); + return; + } + + const currentClipPath = canvas.clipPath; + this._clearTemporaryObjectClipPaths(false); + canvas.clipPath = this._detachedCanvasClipPath; + try { + this.history.saveState(); + } finally { + canvas.clipPath = currentClipPath; + this._applyTemporaryObjectClipPaths(this._detachedCanvasClipPath, false); + } + } finally { + transientObjects.forEach(obj => canvas.add(obj)); + this._bringBrushPreviewToFront(); + if (transientObjects.length > 0) this._requestRender(); + } + } + + _requestRender() { + _requestRender(this.canvasManager.canvas); + } + + _attachCurrentCropClipPath(obj) { + const clipPath = this._getActiveCropClipPath(); + if (!obj || !clipPath) return; + + obj.set('clipPath', this._createClipPathFromSource(clipPath)); + obj.dirty = true; + } + + _createClipPathFromSource(source) { + return createClipPathFromSource(source); + } + + /** + * 清除所有马赛克覆盖层 + */ + clearAllMosaics() { + const canvas = this.canvasManager.canvas; + const overlays = canvas.getObjects().filter( + o => o.id && o.id.startsWith('mosaic_') + ); + if (overlays.length === 0) return; + + // 先保存当前状态(含马赛克覆盖层),以便用户撤销清除操作 + this._saveStateWithCanvasClipPath(); + overlays.forEach(o => canvas.remove(o)); + canvas.renderAll(); + } + + applyPreset(presetName) { + const presets = { + 'mosaic-light': { mode: 'mosaic', mosaicSize: 6 }, + 'mosaic-standard': { mode: 'mosaic', mosaicSize: 12 }, + 'mosaic-heavy': { mode: 'mosaic', mosaicSize: 24 }, + 'blur-light': { mode: 'blur', blurRadius: 6 }, + 'blur-standard': { mode: 'blur', blurRadius: 12 }, + 'blur-strong': { mode: 'blur', blurRadius: 18 }, + 'mosaic-draw-rect': { drawMode: 'rect' }, + 'mosaic-draw-lasso': { drawMode: 'lasso' }, + 'mosaic-draw-brush': { drawMode: 'brush' }, + }; + + const preset = presets[presetName]; + if (!preset) return; + + if (preset.mode) this.setMode(preset.mode); + if (preset.mosaicSize) this.setMosaicSize(preset.mosaicSize); + if (preset.blurRadius) this.setBlurRadius(preset.blurRadius); + if (preset.drawMode) this.setDrawMode(preset.drawMode); + } + + _getCurrentLayerPresetName() { + if (this.options.mode === 'blur') { + const blurMap = { + 6: '轻模糊', + 12: '中模糊', + 18: '强模糊', + }; + return blurMap[Math.round(Number(this.options.blurRadius))] || ''; + } + + const mosaicMap = { + 6: '轻马赛克', + 12: '中马赛克', + 24: '重马赛克', + }; + return mosaicMap[Math.round(Number(this.options.mosaicSize))] || ''; + } + + // ═══════════════════════════════════════ + // 预设栏 HTML + // ═══════════════════════════════════════ + + getOptionsBarHTML() { + const isLight = this.options.mode === 'mosaic' && this.options.mosaicSize === 6; + const isStandard = this.options.mode === 'mosaic' && this.options.mosaicSize === 12; + const isHeavy = this.options.mode === 'mosaic' && this.options.mosaicSize === 24; + const isLightBlur = this.options.mode === 'blur' && this.options.blurRadius === 6; + const isStandardBlur = this.options.mode === 'blur' && this.options.blurRadius === 12; + const isStrongBlur = this.options.mode === 'blur' && this.options.blurRadius === 18; + const drawMode = this.options.drawMode || 'rect'; + return ` +
+ 选区 + + + +
+
+ + + + + + +
+ `; + } + + getPropertyPanelHTML() { + const effectMode = this.options.mode; + const drawMode = this.options.drawMode || 'rect'; + let html = ` +
马赛克工具
+
+ + +
+
+ + +
+ `; + + if (effectMode === 'mosaic') { + html += ` +
+ + + ${this.options.mosaicSize}px +
+ `; + } else { + html += ` +
+ + + ${this.options.blurRadius}px +
+ `; + } + + if (drawMode === 'brush') { + html += ` +
+ + + ${this.options.brushSize}px +
+ `; + } + + return html; + } + + onToolPropertyChange(key, value) { + switch (key) { + case 'mode': + this.setMode(value); + return true; + case 'drawMode': + this.setDrawMode(value); + return true; + case 'mosaicSize': + this.setMosaicSize(value); + return true; + case 'blurRadius': + this.setBlurRadius(value); + return true; + case 'brushSize': + this.setBrushSize(value); + return true; + default: + return false; + } + } +} + +export default MosaicModule; diff --git a/plugins/Image-Toolbox/core/src/modules/SelectModule.js b/plugins/Image-Toolbox/core/src/modules/SelectModule.js new file mode 100644 index 000000000..451876afc --- /dev/null +++ b/plugins/Image-Toolbox/core/src/modules/SelectModule.js @@ -0,0 +1,153 @@ +import BaseModule from './BaseModule.js'; +import eventBus from '../EventBus.js'; +import { requestRender as _requestRender } from '../utils/helpers.js'; + +/** + * 移动/框选模块 - 保持 Fabric 默认选择行为,并提供常用变换预设。 + */ +class SelectModule extends BaseModule { + activate(options = {}) { + this.active = true; + this.options = { ...this.options, ...options }; + + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + canvas.selection = true; + canvas.defaultCursor = 'default'; + + // 启用未锁定图层的交互性,确保新建图层切回移动/框选后可选中。 + this._enableEditableLayerInteractivity(); + } + + deactivate() { + this.active = false; + } + + getOptionsBarHTML() { + const targets = this._getTransformTargets(); + const disabled = targets.length === 0 ? ' disabled title="先选中一个图层"' : ''; + const angle = this._getCommonAngle(targets); + const flipX = targets.length > 0 && targets.every(obj => !!obj.flipX); + const flipY = targets.length > 0 && targets.every(obj => !!obj.flipY); + + return ` +
+ + + + +
+
+ + +
+ `; + } + + applyPreset(presetName) { + const targets = this._getTransformTargets(); + if (targets.length === 0) return; + + const rotateMap = { + 'select-rotate-0': 0, + 'select-rotate-90': 90, + 'select-rotate-180': 180, + 'select-rotate-270': 270, + }; + + if (Object.prototype.hasOwnProperty.call(rotateMap, presetName)) { + this._rotateTargets(targets, rotateMap[presetName]); + return; + } + + if (presetName === 'select-flip-x') { + this._flipTargets(targets, 'flipX'); + return; + } + + if (presetName === 'select-flip-y') { + this._flipTargets(targets, 'flipY'); + } + } + + _rotateTargets(targets, angle) { + const nextAngle = this._normalizeAngle(angle); + const changed = targets.some(obj => this._normalizeAngle(obj.angle || 0) !== nextAngle); + if (!changed) return; + + this.history.saveState(); + targets.forEach(obj => this._setObjectTransform(obj, { angle: nextAngle })); + this._refreshActiveSelection(targets); + this._refreshDynamicMosaics(); + this._requestRender(); + } + + _flipTargets(targets, prop) { + this.history.saveState(); + targets.forEach(obj => this._setObjectTransform(obj, { [prop]: !obj[prop] })); + this._refreshActiveSelection(targets); + this._refreshDynamicMosaics(); + this._requestRender(); + } + + _setObjectTransform(obj, props) { + const center = typeof obj.getCenterPoint === 'function' ? obj.getCenterPoint() : null; + obj.set(props); + + if (center && typeof obj.setPositionByOrigin === 'function') { + obj.setPositionByOrigin(center, 'center', 'center'); + } + + obj.dirty = true; + obj.setCoords(); + } + + _refreshActiveSelection(targets) { + const canvas = this.canvasManager.canvas; + const active = canvas?.getActiveObject(); + if (!canvas || active?.type !== 'activeSelection' || targets.length < 2) return; + if (typeof fabric === 'undefined' || !fabric.ActiveSelection) return; + + canvas.discardActiveObject(); + const selection = new fabric.ActiveSelection(targets, { canvas }); + canvas.setActiveObject(selection); + } + + _refreshDynamicMosaics() { + this.canvasManager.refreshDynamicMosaics?.({ render: false }); + } + + _getTransformTargets() { + const canvas = this.canvasManager.canvas; + const active = canvas?.getActiveObject(); + if (!active) return []; + const originalImage = this.canvasManager.originalImage; + + if (active.type === 'activeSelection' && typeof active.getObjects === 'function') { + // 多选时排除背景,避免框选覆盖层时误带上整张底图;单独选中背景仍可变换。 + return active.getObjects().filter(obj => !obj.excludeFromHistory && obj !== originalImage); + } + + if (active.excludeFromHistory) return []; + return [active]; + } + + _getCommonAngle(targets) { + if (targets.length === 0) return null; + + const first = this._normalizeAngle(targets[0].angle || 0); + return targets.every(obj => this._normalizeAngle(obj.angle || 0) === first) ? first : null; + } + + _normalizeAngle(angle) { + const number = parseFloat(angle) || 0; + return ((Math.round(number) % 360) + 360) % 360; + } + + _requestRender() { + _requestRender(this.canvasManager.canvas); + } +} + +export default SelectModule; diff --git a/plugins/Image-Toolbox/core/src/modules/ShapeModule.js b/plugins/Image-Toolbox/core/src/modules/ShapeModule.js new file mode 100644 index 000000000..a858a44a0 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/modules/ShapeModule.js @@ -0,0 +1,763 @@ +import BaseModule from './BaseModule.js'; +import eventBus from '../EventBus.js'; +import { clamp, escapeAttr, normalizeColor } from '../utils/helpers.js'; + +/** + * 图形绘制模块 - 支持矩形、椭圆、星星、心形、梯形、直线、箭头等多种图形 + */ +class ShapeModule extends BaseModule { + static SHAPE_OPTIONS = [ + { type: 'rect', preset: 'shape-type-rect', label: '矩形', icon: '' }, + { type: 'triangle', preset: 'shape-type-triangle', label: '三角形', icon: '' }, + { type: 'circle', preset: 'shape-type-circle', label: '椭圆', icon: '' }, + { type: 'star', preset: 'shape-type-star', label: '星星', icon: '' }, + { type: 'heart', preset: 'shape-type-heart', label: '心形', icon: '' }, + { type: 'trapezoid', preset: 'shape-type-trapezoid', label: '梯形', icon: '' }, + { type: 'parallelogram', preset: 'shape-type-parallelogram', label: '平行四边形', icon: '' }, + { type: 'line', preset: 'shape-type-line', label: '直线', icon: '' }, + { type: 'arrow', preset: 'shape-type-arrow', label: '箭头', icon: '' }, + { type: 'double-arrow', preset: 'shape-type-double-arrow', label: '双箭头', icon: '' }, + ]; + + static SHAPE_STYLE_PRESETS = [ + { preset: 'shape-style-outline', label: '仅描边', fill: 'transparent', fillOpacity: 0, stroke: '#d83b31', strokeOpacity: 100 }, + { preset: 'shape-style-red', label: '标注红', fill: '#d83b31', fillOpacity: 22, stroke: '#d83b31', strokeOpacity: 100 }, + { preset: 'shape-style-blue', label: '标注蓝', fill: '#1677ff', fillOpacity: 20, stroke: '#1677ff', strokeOpacity: 100 }, + { preset: 'shape-style-orange', label: '警示橙', fill: '#ff7a00', fillOpacity: 22, stroke: '#ff7a00', strokeOpacity: 100 }, + { preset: 'shape-style-yellow', label: '标题黄', fill: '#ffd700', fillOpacity: 28, stroke: '#b7791f', strokeOpacity: 100 }, + { preset: 'shape-style-green', label: '强调绿', fill: '#2ead4a', fillOpacity: 20, stroke: '#2ead4a', strokeOpacity: 100 }, + { preset: 'shape-style-purple', label: '重点紫', fill: '#8b5cf6', fillOpacity: 20, stroke: '#8b5cf6', strokeOpacity: 100 }, + { preset: 'shape-style-black', label: '黑白框', fill: '#111111', fillOpacity: 12, stroke: '#111111', strokeOpacity: 100 }, + { preset: 'shape-style-white', label: '白描边', fill: '#ffffff', fillOpacity: 36, stroke: '#ffffff', strokeOpacity: 100 }, + ]; + + constructor(canvasManager, historyManager, defaultOptions = {}) { + super(canvasManager, historyManager, { + shapeType: 'rect', + fill: 'transparent', + stroke: 'rgba(216, 59, 49, 1)', + strokeWidth: 2, + ...defaultOptions, + }); + + this._isDrawing = false; + this._startPoint = null; + this._currentShape = null; + this._previewShape = null; + this._savedBeforeShape = false; + + this._boundMouseDown = this._onMouseDown.bind(this); + this._boundMouseMove = this._onMouseMove.bind(this); + this._boundMouseUp = this._onMouseUp.bind(this); + this._boundMouseOut = this._onMouseOut.bind(this); + } + + activate(options = {}) { + super.activate(options); + + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + canvas.discardActiveObject(); + canvas.defaultCursor = 'crosshair'; + // 禁用 Fabric.js 的目标查找,防止拖选绘制时意外移动图层 + canvas.skipTargetFind = true; + + // 使用 Fabric.js 合成事件(与其他模块一致), + // 确保在 Fabric 内部处理完事件后才接收,避免原生 DOM 事件与 + // Fabric.js 内部 __onMouseDown 同时处理同一事件导致的状态冲突 + canvas.on('mouse:down', this._boundMouseDown); + canvas.on('mouse:move', this._boundMouseMove); + canvas.on('mouse:up', this._boundMouseUp); + canvas.on('mouse:out', this._boundMouseOut); + + eventBus.emit('module:activated', 'shape'); + } + + deactivate() { + const canvas = this.canvasManager.canvas; + if (canvas) { + canvas.off('mouse:down', this._boundMouseDown); + canvas.off('mouse:move', this._boundMouseMove); + canvas.off('mouse:up', this._boundMouseUp); + canvas.off('mouse:out', this._boundMouseOut); + // 重置 skipTargetFind,避免影响后续工具(如 SelectModule) + canvas.skipTargetFind = false; + this._removePreviewShape(); + this._isDrawing = false; + this._startPoint = null; + this._currentShape = null; + this._savedBeforeShape = false; + } + + super.deactivate(); + } + + setShapeType(type) { + if (['rect', 'triangle', 'circle', 'star', 'heart', 'trapezoid', 'parallelogram', 'line', 'arrow', 'double-arrow'].includes(type)) { + this.options.shapeType = type; + } + } + + setFill(fill) { + const normalized = normalizeColor(fill, this.options.fill, true); + this.options.fill = this._hasExplicitOpacity(normalized) + ? normalized + : this._withColorOpacity(normalized, this._getColorOpacity(this.options.fill)); + } + + setStroke(stroke) { + const normalized = normalizeColor(stroke, this.options.stroke, false); + this.options.stroke = this._hasExplicitOpacity(normalized) + ? normalized + : this._withColorOpacity(normalized, this._getColorOpacity(this.options.stroke)); + } + + setStrokeWidth(width) { + const parsed = parseInt(width, 10); + this.options.strokeWidth = this._clamp(Number.isFinite(parsed) ? parsed : this.options.strokeWidth, 1, 20); + } + + setFillOpacity(opacity) { + this.options.fill = this._withColorOpacity(this.options.fill, this._parseOpacity(opacity)); + } + + setStrokeOpacity(opacity) { + this.options.stroke = this._withColorOpacity(this.options.stroke, this._parseOpacity(opacity)); + } + + applyPreset(presetName) { + const stylePreset = ShapeModule.SHAPE_STYLE_PRESETS.find(item => item.preset === presetName); + if (stylePreset) { + this.options.fill = this._getPresetColor(stylePreset, 'fill'); + this.options.stroke = this._getPresetColor(stylePreset, 'stroke'); + return; + } + + const presets = { + 'shape-type-rect': { shapeType: 'rect' }, + 'shape-type-triangle': { shapeType: 'triangle' }, + 'shape-type-circle': { shapeType: 'circle' }, + 'shape-type-star': { shapeType: 'star' }, + 'shape-type-heart': { shapeType: 'heart' }, + 'shape-type-trapezoid': { shapeType: 'trapezoid' }, + 'shape-type-parallelogram': { shapeType: 'parallelogram' }, + 'shape-type-line': { shapeType: 'line' }, + 'shape-type-arrow': { shapeType: 'arrow' }, + 'shape-type-double-arrow': { shapeType: 'double-arrow' }, + 'shape-width-thin': { strokeWidth: 1 }, + 'shape-width-medium': { strokeWidth: 2 }, + 'shape-width-thick': { strokeWidth: 4 }, + 'shape-width-heavy': { strokeWidth: 6 }, + }; + + const preset = presets[presetName]; + if (!preset) return; + + if (preset.fill !== undefined) this.setFill(preset.fill); + if (preset.stroke !== undefined) this.setStroke(preset.stroke); + if (preset.strokeWidth !== undefined) this.setStrokeWidth(preset.strokeWidth); + if (preset.shapeType !== undefined) this.setShapeType(preset.shapeType); + } + + getOptionsBarHTML() { + const shapeType = this.options.shapeType; + const currentShape = ShapeModule.SHAPE_OPTIONS.find(item => item.type === shapeType) || ShapeModule.SHAPE_OPTIONS[0]; + const strokeWidth = this.options.strokeWidth; + const colorPresets = ShapeModule.SHAPE_STYLE_PRESETS.map(item => { + const fill = this._getPresetColor(item, 'fill'); + const stroke = this._getPresetColor(item, 'stroke'); + return ` + + `; + }).join(''); + + return ` +
+ +
+
+ ${colorPresets} +
+
+ + + + +
+ `; + } + + _isStylePresetActive(preset) { + return this._normalizeComparableColor(this.options.fill) === this._normalizeComparableColor(this._getPresetColor(preset, 'fill')) + && this._normalizeComparableColor(this.options.stroke) === this._normalizeComparableColor(this._getPresetColor(preset, 'stroke')); + } + + _getPresetColor(preset, prop) { + const opacity = this._parseOpacity(preset[`${prop}Opacity`]); + return this._withColorOpacity(preset[prop], opacity); + } + + getShapePickerHTML() { + const shapeType = this.options.shapeType; + const items = ShapeModule.SHAPE_OPTIONS.map(item => ` + + `).join(''); + + return ` + + `; + } + + getPropertyShapePickerHTML() { + const shapeType = this.options.shapeType; + return ShapeModule.SHAPE_OPTIONS.map(item => ` + + `).join(''); + } + + getPropertyPanelHTML() { + return ` +
图形工具
+
+ +
+ ${this.getPropertyShapePickerHTML()} +
+
+
+ + +
+
+ + + ${this._getColorOpacityPercent(this.options.fill)}% +
+
+ + +
+
+ + + ${this._getColorOpacityPercent(this.options.stroke)}% +
+
+ + + ${this.options.strokeWidth}px +
+
拖拽鼠标绘制图形,支持矩形、三角形、椭圆、星星、心形等。
+ `; + } + + onToolPropertyChange(key, value) { + switch (key) { + case 'fill': + this.setFill(value); + return true; + case 'stroke': + this.setStroke(value); + return true; + case 'fillOpacity': + this.setFillOpacity(value); + return true; + case 'strokeOpacity': + this.setStrokeOpacity(value); + return true; + case 'strokeWidth': + this.setStrokeWidth(value); + return true; + default: + return false; + } + } + + _onMouseDown(e) { + // Fabric.js 合成事件:e.e 是原生 DOM 事件,e.pointer 是画布坐标 + const nativeEvent = e?.e; + if (nativeEvent && typeof nativeEvent.button === 'number' && nativeEvent.button !== 0) return; + + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + const pointer = e.pointer || canvas.getPointer(nativeEvent); + this._isDrawing = true; + this._startPoint = { x: pointer.x, y: pointer.y }; + this.history.saveState(); + this._savedBeforeShape = true; + } + + _onMouseMove(e) { + if (!this._isDrawing || !this._startPoint) return; + + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + const pointer = e.pointer || canvas.getPointer(e.e); + const endPoint = { x: pointer.x, y: pointer.y }; + + // 移除旧的预览形状 + this._removePreviewShape(); + + // 创建新的预览形状 + const shape = this._createShape(this._startPoint, endPoint); + if (shape) { + this._previewShape = shape; + canvas.add(shape); + canvas.renderAll(); + } + } + + _onMouseUp(e) { + if (!this._isDrawing || !this._startPoint) return; + + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + const pointer = e.pointer || canvas.getPointer(e.e); + const endPoint = { x: pointer.x, y: pointer.y }; + + // 移除预览形状 + this._removePreviewShape(); + + // 创建最终形状 + const shape = this._createShape(this._startPoint, endPoint); + if (shape) { + // 检查形状是否足够大 + if (this._isShapeLargeEnough(this._startPoint, endPoint)) { + shape.set({ + id: 'shape_' + Date.now(), + selectable: false, + evented: false, + _layerKind: 'shape', + _layerShapeType: this.options.shapeType, + }); + canvas.add(shape); + canvas.renderAll(); + eventBus.emit('canvas:objectMetadataChanged', shape); + } + } + + this._isDrawing = false; + this._startPoint = null; + this._currentShape = null; + this._savedBeforeShape = false; + } + + _onMouseOut(e) { + if (this._isDrawing) { + this._removePreviewShape(); + this.canvasManager.canvas?.renderAll(); + } + } + + _isShapeLargeEnough(startPoint, endPoint) { + const width = Math.abs(endPoint.x - startPoint.x); + const height = Math.abs(endPoint.y - startPoint.y); + + if (['line', 'arrow', 'double-arrow'].includes(this.options.shapeType)) { + return Math.sqrt(width * width + height * height) > 5; + } + + return width > 5 && height > 5; + } + + _createShape(startPoint, endPoint) { + const type = this.options.shapeType; + const left = Math.min(startPoint.x, endPoint.x); + const top = Math.min(startPoint.y, endPoint.y); + const width = Math.abs(endPoint.x - startPoint.x); + const height = Math.abs(endPoint.y - startPoint.y); + + const commonProps = { + fill: this.options.fill, + stroke: this.options.stroke, + strokeWidth: this.options.strokeWidth, + selectable: false, + evented: false, + }; + + switch (type) { + case 'rect': + return new fabric.Rect({ + left, + top, + width, + height, + ...commonProps, + }); + + case 'triangle': + return this._createTriangle(left, top, width, height, commonProps); + + case 'circle': + return new fabric.Ellipse({ + left: left + width / 2, + top: top + height / 2, + rx: width / 2, + ry: height / 2, + originX: 'center', + originY: 'center', + ...commonProps, + }); + + case 'star': + return this._createStar(left + width / 2, top + height / 2, width, height, commonProps); + + case 'heart': + return this._createHeart(left + width / 2, top + height / 2, width, height, commonProps); + + case 'trapezoid': + return this._createTrapezoid(left, top, width, height, commonProps); + + case 'parallelogram': + return this._createParallelogram(left, top, width, height, commonProps); + + case 'line': + return this._createLine(startPoint, endPoint, commonProps); + + case 'arrow': + return this._createArrow(startPoint, endPoint, commonProps); + + case 'double-arrow': + return this._createDoubleArrow(startPoint, endPoint, commonProps); + + default: + return null; + } + } + + _createStar(cx, cy, width, height, props) { + const points = []; + const spikes = 5; + const outerX = width / 2; + const outerY = height / 2; + const innerX = outerX * 0.42; + const innerY = outerY * 0.42; + + for (let i = 0; i < spikes * 2; i++) { + const angle = (i * Math.PI) / spikes - Math.PI / 2; + const radiusX = i % 2 === 0 ? outerX : innerX; + const radiusY = i % 2 === 0 ? outerY : innerY; + points.push({ x: radiusX * Math.cos(angle), y: radiusY * Math.sin(angle) }); + } + + const path = new fabric.Polygon(points, { + ...props, + left: cx, + top: cy, + originX: 'center', + originY: 'center', + strokeLineJoin: 'round', + }); + + return path; + } + + _createHeart(cx, cy, width, height, props) { + const pathData = `M 50 96 + C 17 68 4 55 4 34 + C 4 17 17 5 32 5 + C 41 5 47 10 50 18 + C 53 10 59 5 68 5 + C 83 5 96 17 96 34 + C 96 55 83 68 50 96 Z`; + + const heart = new fabric.Path(pathData, { + ...props, + left: cx, + top: cy, + originX: 'center', + originY: 'center', + scaleX: width / 100, + scaleY: height / 100, + }); + + return heart; + } + + _createTrapezoid(left, top, width, height, props) { + const centerX = left + width / 2; + const centerY = top + height / 2; + const topInset = width * 0.22; + const points = [ + { x: -width / 2 + topInset, y: -height / 2 }, + { x: width / 2 - topInset, y: -height / 2 }, + { x: width / 2, y: height / 2 }, + { x: -width / 2, y: height / 2 }, + ]; + + return new fabric.Polygon(points, { + ...props, + left: centerX, + top: centerY, + originX: 'center', + originY: 'center', + }); + } + + _createParallelogram(left, top, width, height, props) { + const centerX = left + width / 2; + const centerY = top + height / 2; + const skewX = width * 0.22; + const baseWidth = width - 2 * skewX; + const points = [ + { x: -baseWidth / 2 + skewX, y: -height / 2 }, + { x: baseWidth / 2 + skewX, y: -height / 2 }, + { x: baseWidth / 2 - skewX, y: height / 2 }, + { x: -baseWidth / 2 - skewX, y: height / 2 }, + ]; + + return new fabric.Polygon(points, { + ...props, + left: centerX, + top: centerY, + originX: 'center', + originY: 'center', + }); + } + + _createTriangle(left, top, width, height, props) { + const centerX = left + width / 2; + const centerY = top + height / 2; + const points = [ + { x: 0, y: -height / 2 }, + { x: width / 2, y: height / 2 }, + { x: -width / 2, y: height / 2 }, + ]; + + return new fabric.Polygon(points, { + ...props, + left: centerX, + top: centerY, + originX: 'center', + originY: 'center', + }); + } + + _createLine(startPoint, endPoint, props) { + return new fabric.Line([startPoint.x, startPoint.y, endPoint.x, endPoint.y], { + ...props, + stroke: props.stroke, + strokeWidth: props.strokeWidth, + fill: null, + strokeLineCap: 'round', + }); + } + + _createArrow(startPoint, endPoint, props) { + const dx = endPoint.x - startPoint.x; + const dy = endPoint.y - startPoint.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < 10) return null; + + const angle = Math.atan2(dy, dx); + const headLength = Math.min(Math.max(distance * 0.25, 10), 28); + const headAngle = Math.PI / 7; + const headPointA = { + x: endPoint.x - headLength * Math.cos(angle - headAngle), + y: endPoint.y - headLength * Math.sin(angle - headAngle), + }; + const headPointB = { + x: endPoint.x - headLength * Math.cos(angle + headAngle), + y: endPoint.y - headLength * Math.sin(angle + headAngle), + }; + const pathData = `M ${startPoint.x} ${startPoint.y} L ${endPoint.x} ${endPoint.y} + M ${headPointA.x} ${headPointA.y} L ${endPoint.x} ${endPoint.y} L ${headPointB.x} ${headPointB.y}`; + + return new fabric.Path(pathData, { + ...props, + fill: null, + stroke: props.stroke, + strokeWidth: props.strokeWidth, + strokeLineCap: 'round', + strokeLineJoin: 'round', + }); + } + + _createDoubleArrow(startPoint, endPoint, props) { + const dx = endPoint.x - startPoint.x; + const dy = endPoint.y - startPoint.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < 10) return null; + + const angle = Math.atan2(dy, dx); + const headLength = Math.min(Math.max(distance * 0.25, 10), 28); + const headAngle = Math.PI / 7; + + const headPointA1 = { + x: endPoint.x - headLength * Math.cos(angle - headAngle), + y: endPoint.y - headLength * Math.sin(angle - headAngle), + }; + const headPointA2 = { + x: endPoint.x - headLength * Math.cos(angle + headAngle), + y: endPoint.y - headLength * Math.sin(angle + headAngle), + }; + const reverseAngle = angle + Math.PI; + const headPointB1 = { + x: startPoint.x - headLength * Math.cos(reverseAngle - headAngle), + y: startPoint.y - headLength * Math.sin(reverseAngle - headAngle), + }; + const headPointB2 = { + x: startPoint.x - headLength * Math.cos(reverseAngle + headAngle), + y: startPoint.y - headLength * Math.sin(reverseAngle + headAngle), + }; + + const pathData = `M ${startPoint.x} ${startPoint.y} L ${endPoint.x} ${endPoint.y} + M ${headPointA1.x} ${headPointA1.y} L ${endPoint.x} ${endPoint.y} L ${headPointA2.x} ${headPointA2.y} + M ${headPointB1.x} ${headPointB1.y} L ${startPoint.x} ${startPoint.y} L ${headPointB2.x} ${headPointB2.y}`; + + return new fabric.Path(pathData, { + ...props, + fill: null, + stroke: props.stroke, + strokeWidth: props.strokeWidth, + strokeLineCap: 'round', + strokeLineJoin: 'round', + }); + } + + _removePreviewShape() { + if (!this._previewShape) return; + + const canvas = this.canvasManager.canvas; + if (canvas) { + canvas.remove(this._previewShape); + } + this._previewShape = null; + } + + _normalizeColor(color, fallback = '#000000', allowTransparent = false) { + return normalizeColor(color, fallback, allowTransparent); + } + + _normalizeComparableColor(color) { + return String(color ?? '').replace(/\s+/g, '').toLowerCase(); + } + + _hasExplicitOpacity(color) { + const value = String(color ?? '').trim().toLowerCase(); + return value === 'transparent' || value.startsWith('rgba'); + } + + _parseOpacity(value) { + const parsed = parseInt(value, 10); + return this._clamp(Number.isFinite(parsed) ? parsed : 100, 0, 100) / 100; + } + + _getColorOpacityPercent(color) { + return Math.round(this._getColorOpacity(color) * 100); + } + + _getColorOpacity(color) { + const value = String(color ?? '').trim().toLowerCase(); + if (!value || value === 'transparent') return 0; + + const rgba = value.match(/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([\d.]+)\s*\)$/i); + if (rgba) { + const alpha = parseFloat(rgba[1]); + return this._clamp(Number.isFinite(alpha) ? alpha : 1, 0, 1); + } + + return 1; + } + + _withColorOpacity(color, opacity) { + const alpha = this._clamp(Number.isFinite(opacity) ? opacity : 1, 0, 1); + const rgb = this._extractRgb(color); + + if (!rgb) { + return alpha === 0 ? 'transparent' : color; + } + + if (alpha === 0 && String(color ?? '').trim().toLowerCase() === 'transparent') { + return 'transparent'; + } + + return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${this._formatAlpha(alpha)})`; + } + + _extractRgb(color) { + const value = String(color ?? '').trim().toLowerCase(); + if (!value || value === 'transparent') return { r: 0, g: 0, b: 0 }; + + if (/^#[0-9a-f]{6}$/i.test(value)) { + return { + r: parseInt(value.slice(1, 3), 16), + g: parseInt(value.slice(3, 5), 16), + b: parseInt(value.slice(5, 7), 16), + }; + } + + if (/^#[0-9a-f]{3}$/i.test(value)) { + return { + r: parseInt(value[1] + value[1], 16), + g: parseInt(value[2] + value[2], 16), + b: parseInt(value[3] + value[3], 16), + }; + } + + const rgb = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); + if (rgb) { + return { + r: this._clamp(parseInt(rgb[1], 10), 0, 255), + g: this._clamp(parseInt(rgb[2], 10), 0, 255), + b: this._clamp(parseInt(rgb[3], 10), 0, 255), + }; + } + + return null; + } + + _formatAlpha(alpha) { + return String(Math.round(alpha * 100) / 100); + } + + _extractHexColor(color) { + if (typeof color !== 'string') return '#000000'; + + // 如果已经是 hex 格式,直接返回 + if (/^#[0-9a-f]{6}$/i.test(color)) return color; + + // 从 rgba 中提取 + const rgbaMatch = color.match(/rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)/); + if (rgbaMatch) { + const r = parseInt(rgbaMatch[1], 10).toString(16).padStart(2, '0'); + const g = parseInt(rgbaMatch[2], 10).toString(16).padStart(2, '0'); + const b = parseInt(rgbaMatch[3], 10).toString(16).padStart(2, '0'); + return `#${r}${g}${b}`; + } + + return '#000000'; + } + + _clamp(value, min, max) { + return clamp(value, min, max); + } + + _escapeAttr(value) { + return escapeAttr(value); + } +} + +export default ShapeModule; diff --git a/plugins/Image-Toolbox/core/src/modules/TextModule.js b/plugins/Image-Toolbox/core/src/modules/TextModule.js new file mode 100644 index 000000000..a358a2924 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/modules/TextModule.js @@ -0,0 +1,465 @@ +import BaseModule from './BaseModule.js'; +import eventBus from '../EventBus.js'; +import { getFontOptionsHTML, recordFontUsage, isSystemFontsLoaded, onSystemFontsLoaded } from '../utils/fonts.js'; + +/** + * 加字模块 — 在图片上添加文字标注 + * 使用 fabric.IText 支持双击编辑 + */ +class TextModule extends BaseModule { + constructor(canvasManager, historyManager, defaultOptions = {}) { + super(canvasManager, historyManager, { + fontFamily: 'Microsoft YaHei, PingFang SC, sans-serif', + fontSize: 24, + fill: '#d83b31', + stroke: null, + strokeWidth: 0, + strokePosition: 'outside', + fontWeight: 'normal', + fontStyle: 'normal', + underline: false, + linethrough: false, + textAlign: 'left', + ...defaultOptions, + }); + + this._boundMouseDown = this._onMouseDown.bind(this); + } + + activate(options = {}) { + super.activate(options); // 禁用所有对象交互 + const canvas = this.canvasManager.canvas; + + // 恢复文字对象的交互性(允许点击已有文字进行编辑) + canvas.getObjects().forEach(obj => { + if (obj.type === 'i-text' || obj.type === 'text' || obj.type === 'textbox') { + obj.set({ selectable: true, evented: true }); + } + }); + + canvas.defaultCursor = 'text'; + canvas.on('mouse:down', this._boundMouseDown); + eventBus.emit('module:activated', 'text'); + } + + deactivate() { + const canvas = this.canvasManager.canvas; + canvas.off('mouse:down', this._boundMouseDown); + + // 提交所有正在编辑的文字 + canvas.getObjects().forEach(obj => { + if (obj.isEditing) { + obj.exitEditing(); + } + }); + + super.deactivate(); // 恢复所有对象交互 + } + + /** + * 点击位置添加文字 + */ + addTextOnClick(x, y) { + this.addText('双击编辑', x, y); + } + + /** + * 在指定位置添加文字 + * @param {string} text + * @param {number} x + * @param {number} y + * @returns {fabric.IText} + */ + addText(text, x, y) { + const canvas = this.canvasManager.canvas; + const opts = this.options; + + const textObj = new fabric.IText(text, { + left: x, + top: y, + fontFamily: opts.fontFamily, + fontSize: opts.fontSize, + fill: opts.fill, + stroke: opts.stroke, + strokeWidth: opts.strokeWidth, + paintFirst: opts.strokePosition === 'inside' ? 'fill' : 'stroke', + _strokePosition: opts.strokePosition || 'outside', + fontWeight: opts.fontWeight, + fontStyle: opts.fontStyle, + underline: opts.underline, + linethrough: opts.linethrough, + textAlign: opts.textAlign, + editable: true, + id: 'text_' + Date.now(), + }); + + this.history.saveState(); + + canvas.add(textObj); + canvas.setActiveObject(textObj); + canvas.renderAll(); + recordFontUsage(opts.fontFamily); + + // 进入编辑模式 + setTimeout(() => { + textObj.enterEditing(); + textObj.selectAll(); + }, 50); + return textObj; + } + + // ── 样式设置 ── + + setFontFamily(family) { + this.options.fontFamily = family; + this._updateActiveTextStyle('fontFamily', family); + recordFontUsage(family); + } + + setFontSize(size) { + this.options.fontSize = parseInt(size); + this._updateActiveTextStyle('fontSize', parseInt(size)); + } + + setFontWeight(weight) { + this.options.fontWeight = weight; + this._updateActiveTextStyle('fontWeight', weight); + } + + setFontStyle(style) { + this.options.fontStyle = style; + this._updateActiveTextStyle('fontStyle', style); + } + + setTextColor(color) { + this.options.fill = color; + this._updateActiveTextStyle('fill', color); + } + + setStroke(color, width) { + this.options.stroke = color; + this.options.strokeWidth = width; + this._updateActiveTextStyle('stroke', color); + this._updateActiveTextStyle('strokeWidth', width); + } + + setStrokePosition(position) { + this.options.strokePosition = position; + this._updateActiveTextStyle('paintFirst', position === 'inside' ? 'fill' : 'stroke'); + } + + setUnderline(underline) { + this.options.underline = underline; + this._updateActiveTextStyle('underline', underline); + } + + setLinethrough(linethrough) { + this.options.linethrough = linethrough; + this._updateActiveTextStyle('linethrough', linethrough); + } + + setBackgroundColor(color) { + this._updateActiveTextStyle('backgroundColor', color); + } + + /** + * 应用文字预设样式 + * @param {string} presetName + */ + applyPreset(presetName) { + const presets = { + red: { + fill: '#d83b31', + fontWeight: 'bold', + stroke: '#FFFFFF', + strokeWidth: 2, + }, + blue: { + fill: '#1677FF', + fontWeight: 'bold', + stroke: '#FFFFFF', + strokeWidth: 2, + }, + pink: { + fill: '#FF4FA3', + fontWeight: 'bold', + stroke: '#FFFFFF', + strokeWidth: 2, + }, + purple: { + fill: '#8B5CF6', + fontWeight: 'bold', + stroke: '#FFFFFF', + strokeWidth: 2, + }, + white: { + fill: '#FFFFFF', + fontWeight: 'normal', + stroke: null, + strokeWidth: 0, + backgroundColor: 'rgba(0,0,0,0.5)', + }, + yellow: { + fill: '#FFD700', + fontWeight: 'bold', + stroke: '#000000', + strokeWidth: 2, + }, + black: { + fill: '#111111', + fontWeight: 'bold', + stroke: '#FFFFFF', + strokeWidth: 2, + }, + outline: { + fill: '#FFFFFF', + fontWeight: 'bold', + stroke: '#000000', + strokeWidth: 3, + }, + orange: { + fill: '#FF7A00', + fontWeight: 'bold', + stroke: '#FFFFFF', + strokeWidth: 2, + }, + green: { + fill: '#2EAD4A', + fontWeight: 'bold', + stroke: '#FFFFFF', + strokeWidth: 2, + }, + }; + + const preset = presets[presetName]; + if (!preset) return; + + Object.entries(preset).forEach(([key, value]) => { + this.options[key] = value; + this._updateActiveTextStyle(key, value); + }); + } + + // ── 内部 ── + + _updateActiveTextStyle(prop, value) { + const active = this.canvasManager.getActiveObject(); + if (active && (active.type === 'i-text' || active.type === 'text' || active.type === 'textbox')) { + active.set(prop, value); + this.canvasManager.canvas.renderAll(); + } + } + + _onMouseDown(e) { + const canvas = this.canvasManager.canvas; + const pointer = canvas.getPointer(e.e); + + // 检查是否点到了已有的文字 + const target = e.target; + if (target && (target.type === 'i-text' || target.type === 'text')) { + // 允许选择和编辑已有文字 + return; + } + + // 添加新文字 + this.addTextOnClick(pointer.x, pointer.y); + } + + // ── 选项栏 ── + + getOptionsBarHTML() { + return ` +
+ + + + + + + + + +
+ `; + } + + // ── 属性面板 ── + + getPropertyPanelHTML() { + const active = this.canvasManager.getActiveObject(); + if (!active || (active.type !== 'i-text' && active.type !== 'text' && active.type !== 'textbox')) { + const opts = this.options; + return ` +
文字工具默认值
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
这些设置会用于接下来新增的文字。
+ `; + } + + return ` +
+ + +
+
+ + +
+
+ + +
+
+ + + ${Math.round(active.opacity * 100)}% +
+ `; + } + + onPropertyChange(key, value) { + const active = this.canvasManager.getActiveObject(); + if (!active) return; + + switch (key) { + case 'fontSize': + active.set('fontSize', parseInt(value)); + break; + case 'fill': + active.set('fill', value); + break; + case 'stroke': + active.set('stroke', value); + break; + case 'strokePosition': + active.set('paintFirst', value === 'inside' ? 'fill' : 'stroke'); + active.set('_strokePosition', value); + break; + case 'opacity': + // value 已被 _handleInput 转换为 0-1 区间,直接使用 + active.set('opacity', value); + break; + } + + this.canvasManager.canvas.renderAll(); + } + + onToolPropertyChange(key, value, context = {}) { + switch (key) { + case 'fontFamily': + this.options.fontFamily = value; + if (context.eventType === 'change') recordFontUsage(value); + break; + case 'fontSize': + this.options.fontSize = Math.max(8, parseInt(value, 10) || this.options.fontSize); + break; + case 'fill': + this.options.fill = value; + break; + case 'stroke': + this.options.stroke = value; + break; + case 'strokeWidth': + this.options.strokeWidth = Math.max(0, parseInt(value, 10) || 0); + break; + case 'strokePosition': + this.options.strokePosition = value; + break; + case 'fontWeight': + this.options.fontWeight = value ? 'bold' : 'normal'; + break; + case 'fontStyle': + this.options.fontStyle = value ? 'italic' : 'normal'; + break; + case 'underline': + this.options.underline = !!value; + break; + case 'linethrough': + this.options.linethrough = !!value; + break; + case 'textAlign': + this.options.textAlign = value; + break; + default: + return false; + } + + return true; + } + + _getFontOptionsHTML(current) { + if (isSystemFontsLoaded()) { + return getFontOptionsHTML(current); + } + + // 异步加载字体,完成后触发属性面板更新 + onSystemFontsLoaded(() => { + eventBus.emit('tool:propertiesChanged'); + }); + + return ''; + } + + _getSelectOption(value, label, current) { + const selected = this._normalizeValue(value) === this._normalizeValue(current) ? ' selected' : ''; + return ``; + } + + _normalizeValue(value) { + return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase(); + } +} + +export default TextModule; diff --git a/plugins/Image-Toolbox/core/src/preloadHelpers.js b/plugins/Image-Toolbox/core/src/preloadHelpers.js new file mode 100644 index 000000000..e0960c378 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/preloadHelpers.js @@ -0,0 +1,795 @@ +/** + * preloadHelpers.js + * 跨平台 preload 公共逻辑提取。 + * + * 两个平台的 preload.js 共享约 600+ 行相同代码,本模块提取了其中 + * 所有平台无关的函数,平台特定逻辑(API 查找优先级等)由各平台文件注入。 + * + * 使用方式(在 preload.js 中): + * const { + * initPreload, + * setupImageDialog, + * setupClipboard, + * setupExternalLink, + * setupStorage, + * setupWindowControl, + * setupFontTools, + * setupUserAPI, + * setupMiscAPIs, + * } = require('../../../core/src/preloadHelpers.js'); + * + * // 提供平台特定函数 + * const platform = { + * getName: () => 'uTools' | 'ZTools', + * getApiKeys: () => ['hostTools', 'utools'] | ['hostTools', 'ztools', 'utools'], + * getUserFnName: () => 'getUtoolsUser' | 'getZtoolsUser', + * getContactUrl: () => '...', + * onPluginEnter: (cb) => { ... }, + * onPluginOut: (cb) => { ... }, + * openExternal: (url) => { ... }, + * }; + * + * // 初始化所有功能 + * initPreload(api, platform); + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); +const { clipboard, nativeImage } = require('electron'); + +const FONT_EXTENSIONS = new Set(['.ttf', '.otf', '.ttc', '.woff', '.woff2', '.eot']); + +// ── 工具函数 ── + +const _readUInt16 = (buffer, offset) => { + return offset + 2 <= buffer.length ? buffer.readUInt16BE(offset) : 0; +}; + +const _readUInt32 = (buffer, offset) => { + return offset + 4 <= buffer.length ? buffer.readUInt32BE(offset) : 0; +}; + +const _readBuffer = (filePath) => { + try { + const buffer = fs.readFileSync(filePath); + return buffer.length > 0 ? buffer : null; + } catch (e) { + return null; + } +}; + +const _cleanFontName = (value) => { + return String(value || '') + .replace(/[\u0000-\u001f\u007f]/g, '') + .replace(/\s+/g, ' ') + .trim(); +}; + +const _normalizeFontName = (value) => _cleanFontName(value).toLowerCase(); + +const _isFontFile = (filePath) => FONT_EXTENSIONS.has(path.extname(filePath).toLowerCase()); + +const _hasCjk = (value) => /[\u2e80-\u9fff]/.test(value); + +const _isChineseLanguage = (languageID) => [0x0804, 0x0404, 0x0c04, 0x1004, 0x1404].includes(languageID); + +const _detectImageMime = (buffer) => { + if (!buffer || buffer.length < 4) return 'image/png'; + if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return 'image/png'; + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg'; + if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) return 'image/gif'; + if (buffer[0] === 0x42 && buffer[1] === 0x4d) return 'image/bmp'; + if (buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'; + const start = buffer.toString('utf8', 0, Math.min(buffer.length, 256)).trimStart().toLowerCase(); + if (start.startsWith(' { + const length = buffer.length - (buffer.length % 2); + const swapped = Buffer.alloc(length); // 使用 alloc 替代 allocUnsafe,避免未初始化内存泄漏 + for (let i = 0; i < length; i += 2) { + swapped[i] = buffer[i + 1]; + swapped[i + 1] = buffer[i]; + } + return swapped.toString('utf16le'); +}; + +// ── 字体检测函数 ── + +const _isMicrosoftFont = (fileName) => { + const name = (fileName || '').toLowerCase(); + const microsoftPatterns = [ + /calibri/i, /times/i, /arial/i, /verdana/i, /trebuchet/i, /comic\s*sans/i, + /georgia/i, /impact/i, /courier\s*new/i, /palatino/i, /lucida/i, /segoe/i, + /cambria/i, /consolas/i, /corbel/i, /constantia/i, /franklin/i, /gabriola/i, + /inkfree/i, /javani/i, /kristen/i, /leelawadee/i, /lucida\s*console/i, + /lucida\s*handwriting/i, /lucida\s*sans/i, /microsoft/i, /segoe/i, + /stencil/i, /sym\+\.ttf/i, /sym\+\.otf/i, + ]; + + const isMicrosoftFont = microsoftPatterns.some(pattern => pattern.test(name)); + if (isMicrosoftFont) return true; + + const isLikelyMicrosoftFont = /\.(ttf|otf)$/i.test(name) && name.length < 30; + if (isLikelyMicrosoftFont) return true; + + return false; +}; + +const _isLikelyMicrosoftFont = (path) => { + const normalizedPath = (path || '').toLowerCase(); + + if (/^\s*microsoft/i.test(normalizedPath)) return false; + if (/^\s*(c|d|e|f|g|h|i|j|k|l|m|n|o|p|q|r|s|t|u|v|w|x|y|z):/i.test(normalizedPath)) return false; + if (/fonts(?:\s|\/|\\\\|\\|%5c|\/|%2f)/i.test(normalizedPath)) return false; + if (/microsoft|windows/i.test(normalizedPath)) return false; + if (/fonts(?:\s|\/|\\\\|\\|%5c|\/|%2f)/i.test(normalizedPath)) return false; + if (/windows/i.test(normalizedPath)) return false; + + return true; +}; + +const _isCFFFont = (buffer) => { + if (!buffer || buffer.length < 4) return false; + return buffer[0] === 0x00 && buffer[1] === 0x01 && buffer[2] === 0x00 && buffer[3] === 0x00; +}; + +const _readNameRecord = (tableData, tag) => { + try { + const numRecords = tableData.readUInt16BE(6); + const stringDataOffset = tableData.readUInt16BE(8); + + for (let i = 0; i < numRecords; i++) { + const recordOffset = 10 + i * 16; + const platformID = tableData.readUInt16BE(recordOffset); + const encodingID = tableData.readUInt16BE(recordOffset + 2); + const languageID = tableData.readUInt16BE(recordOffset + 4); + const nameID = tableData.readUInt16BE(recordOffset + 6); + const length = tableData.readUInt16BE(recordOffset + 8); + const offset = tableData.readUInt16BE(recordOffset + 10); + + if (tag === 'fontFamily' && nameID === 1 && platformID === 3 && encodingID === 1) { + const recordStart = stringDataOffset + offset; + if (recordStart + length <= tableData.length) { + if (_isCFFFont) { + const rawBuffer = tableData.slice(recordStart, recordStart + length); + return _decodeUtf16BE(rawBuffer); + } + return tableData.toString('utf16be', recordStart, recordStart + length); + } + } + + if (tag === 'preferredFamily' && nameID === 16 && platformID === 3 && encodingID === 1) { + const recordStart = stringDataOffset + offset; + if (recordStart + length <= tableData.length) { + if (_isCFFFont) { + const rawBuffer = tableData.slice(recordStart, recordStart + length); + return _decodeUtf16BE(rawBuffer); + } + return tableData.toString('utf16be', recordStart, recordStart + length); + } + } + } + } catch (e) { + return ''; + } + return ''; +}; + +const _hasMicrosoftLicense = (nameTableData) => { + try { + const numRecords = nameTableData.readUInt16BE(6); + for (let i = 0; i < numRecords; i++) { + const recordOffset = 10 + i * 16; + const nameID = nameTableData.readUInt16BE(recordOffset + 6); + const length = nameTableData.readUInt16BE(recordOffset + 8); + const offset = nameTableData.readUInt16BE(recordOffset + 10); + const stringDataOffset = nameTableData.readUInt16BE(8); + + if (nameID === 134 && length > 0) { + const recordStart = stringDataOffset + offset; + if (recordStart + length <= nameTableData.length) { + return true; + } + } + } + } catch (e) { + return false; + } + return false; +}; + +const _isCFFTable = (table) => { + return typeof table === 'object' && table !== null && !Array.isArray(table) && + Object.keys(table).some(key => key.toLowerCase().includes('cmap') || + key.toLowerCase().includes('glyph') || key.toLowerCase().includes('charstring')); +}; + +const _detectCFFFont = (buffer) => { + try { + const numTables = _readUInt16(buffer, 4); + if (numTables === 0) return false; + + for (let i = 0; i < numTables; i++) { + const tableOffset = 12 + i * 16; + const tag = buffer.toString('ascii', tableOffset, tableOffset + 4); + if (tag === 'CFF ' || tag === 'CFF2') return true; + } + } catch (e) { + return false; + } + return false; +}; + +const _getFontNameFromBuffer = (buffer, filePath) => { + try { + const isCFF = _isCFFFont(buffer); + if (!isCFF) { + return _detectCFFFont(buffer) ? _readFontNameUsingCFF(buffer) : _readFontNameUsingOffsetTable(buffer); + } + return _readFontNameUsingCFF(buffer); + } catch (e) { + return null; + } +}; + +const _readFontNameUsingCFF = (buffer) => { + try { + const numTables = _readUInt16(buffer, 4); + if (numTables === 0) return null; + + for (let i = 0; i < numTables; i++) { + const tableOffset = 12 + i * 16; + const tag = buffer.toString('ascii', tableOffset, tableOffset + 4); + if (tag === 'name') { + const tableLength = _readUInt32(buffer, tableOffset + 4); + const tableOffset2 = _readUInt32(buffer, tableOffset + 8); + const nameTableData = buffer.slice(tableOffset2, tableOffset2 + tableLength); + return _extractFontName(nameTableData); + } + } + } catch (e) { + return null; + } + return null; +}; + +const _readFontNameUsingOffsetTable = (buffer) => { + try { + const numTables = _readUInt16(buffer, 4); + if (numTables === 0) return null; + + for (let i = 0; i < numTables; i++) { + const tableOffset = 12 + i * 16; + const tag = buffer.toString('ascii', tableOffset, tableOffset + 4); + if (tag === 'name') { + const tableLength = _readUInt32(buffer, tableOffset + 4); + const tableOffset2 = _readUInt32(buffer, tableOffset + 8); + const nameTableData = buffer.slice(tableOffset2, tableOffset2 + tableLength); + return _extractFontName(nameTableData); + } + } + } catch (e) { + return null; + } + return null; +}; + +const _extractFontName = (nameTableData) => { + try { + const numRecords = nameTableData.readUInt16BE(6); + const stringDataOffset = nameTableData.readUInt16BE(8); + + for (let i = 0; i < numRecords; i++) { + const recordOffset = 10 + i * 16; + const platformID = nameTableData.readUInt16BE(recordOffset); + const encodingID = nameTableData.readUInt16BE(recordOffset + 2); + const languageID = nameTableData.readUInt16BE(recordOffset + 4); + const nameID = nameTableData.readUInt16BE(recordOffset + 6); + const length = nameTableData.readUInt16BE(recordOffset + 8); + const offset = nameTableData.readUInt16BE(recordOffset + 10); + + const recordStart = stringDataOffset + offset; + if (recordStart + length <= nameTableData.length) { + if (nameID === 1 && platformID === 3 && encodingID === 1) { + if (_isCFFFont) { + const rawBuffer = nameTableData.slice(recordStart, recordStart + length); + const fontName = _decodeUtf16BE(rawBuffer); + return _hasMicrosoftLicense(nameTableData) ? fontName : null; + } + const fontName = nameTableData.toString('utf16be', recordStart, recordStart + length); + return _hasMicrosoftLicense(nameTableData) ? fontName : null; + } + if (nameID === 16 && platformID === 3 && encodingID === 1) { + if (_isCFFFont) { + const rawBuffer = nameTableData.slice(recordStart, recordStart + length); + const fontName = _decodeUtf16BE(rawBuffer); + return _hasMicrosoftLicense(nameTableData) ? fontName : null; + } + const fontName = nameTableData.toString('utf16be', recordStart, recordStart + length); + return _hasMicrosoftLicense(nameTableData) ? fontName : null; + } + } + } + } catch (e) { + return null; + } + return null; +}; + +// ── 公共初始化函数 ── + +const initPreload = (platform) => { + if (typeof window === 'undefined' || !window.require) return; + + // 设置公共 API + window.getPluginPath = () => { + try { + return path.dirname(__dirname); + } catch (e) { + return ''; + } + }; + + // 设置图片对话框 + setupImageDialog(platform); + + // 设置剪贴板 + setupClipboard(); + + // 设置外部链接 + setupExternalLink(platform); + + // 设置存储 + setupStorage(); + + // 设置窗口控制 + setupWindowControl(); + + // 设置字体工具 + setupFontTools(platform); + + // 设置用户 API + setupUserAPI(platform); + + // 设置杂项 API + setupMiscAPIs(platform); +}; + +const setupImageDialog = (platform) => { + if (typeof window === 'undefined') return; + + window.showOpenImageDialog = () => { + const hostTools = getHostTools(); + if (!hostTools || typeof hostTools.showOpenDialog !== 'function') return null; + try { + const result = hostTools.showOpenDialog({ + properties: ['openFile'], + filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp', 'bmp', 'gif', 'svg'] }], + }); + if (result && result.length > 0) { + const selectedFile = result[0]; + return selectedFile.startsWith('file://') ? selectedFile.replace('file://', '') : selectedFile; + } + } catch (e) { + console.warn(`[${platform.getName()} preload] 打开图片选择失败:`, e); + } + return null; + }; + + window.showSaveImageDialog = (suggestedName = 'edited.png') => { + const hostTools = getHostTools(); + if (!hostTools || typeof hostTools.showSaveDialog !== 'function') return null; + try { + const defaultPath = path.join(os.homedir(), 'Desktop', suggestedName); + const result = hostTools.showSaveDialog({ + title: '保存图片', + defaultPath, + filters: [ + { name: 'PNG 图片', extensions: ['png'] }, + { name: 'JPEG 图片', extensions: ['jpg', 'jpeg'] }, + { name: 'WebP 图片', extensions: ['webp'] }, + { name: '所有文件', extensions: ['*'] }, + ], + }); + if (result && result.length > 0) { + return result[0]; + } + } catch (e) { + console.warn(`[${platform.getName()} preload] 保存图片失败:`, e); + } + return null; + }; + + window.readImageFile = (filePath) => { + if (!filePath || typeof filePath !== 'string') return null; + try { + const cleanedPath = filePath.replace(/^file:\/\//, ''); + if (!fs.existsSync(cleanedPath)) return null; + const data = fs.readFileSync(cleanedPath); + if (!data || data.length === 0) return null; + const mimeType = _detectImageMime(data); + const base64 = data.toString('base64'); + return `data:${mimeType};base64,${base64}`; + } catch (e) { + console.warn(`[${platform.getName()} preload] 读取图片文件失败:`, e); + return null; + } + }; + + window.writeImageFile = (filePath, dataUrl) => { + if (!filePath || typeof filePath !== 'string') return false; + if (!dataUrl || typeof dataUrl !== 'string') return false; + try { + const cleanedPath = filePath.replace(/^file:\/\//, ''); + const fs = require('fs'); + const path = require('path'); + fs.mkdirSync(path.dirname(cleanedPath), { recursive: true }); + const base64Match = dataUrl.match(/^data:([^;]+);base64,(.+)$/i); + if (base64Match) { + const base64Data = base64Match[2]; + fs.writeFileSync(cleanedPath, Buffer.from(base64Data, 'base64')); + return true; + } + return false; + } catch (e) { + console.warn(`[${platform.getName()} preload] 保存图片失败:`, e); + return false; + } + }; +}; + +const setupClipboard = () => { + if (typeof window === 'undefined') return; + + window.copyImageToClipboard = (dataUrl) => { + if (!dataUrl) return false; + try { + const base64Match = dataUrl.match(/^data:([^;]+);base64,(.+)$/i); + if (base64Match) { + const imageBuffer = Buffer.from(base64Match[2], 'base64'); + const image = nativeImage.createFromBuffer(imageBuffer); + clipboard.writeImage(image); + return true; + } + return false; + } catch (e) { + console.warn('[preload] 复制图片到剪贴板失败:', e); + return false; + } + }; +}; + +const setupExternalLink = (platform) => { + if (typeof window === 'undefined') return; + + window.openHostExternal = (url) => { + if (!url) return false; + try { + const hostTools = getHostTools(); + if (hostTools && typeof hostTools.shellOpenExternal === 'function') { + hostTools.shellOpenExternal(url); + return true; + } + } catch (e) { + console.warn(`[${platform.getName()} preload] 打开外部链接失败:`, e); + } + window.open(url, '_blank', 'noopener,noreferrer'); + return true; + }; +}; + +const setupStorage = () => { + if (typeof window === 'undefined') return; + + window.getHostStorage = () => { + const hostTools = getHostTools(); + if (!hostTools || typeof hostTools.dbStorage !== 'object' || hostTools.dbStorage === null) { + return null; + } + return { + getItem: (key) => { + try { + return hostTools.dbStorage.getItem(key); + } catch (e) { + console.warn('[preload] 获取存储失败:', e); + return null; + } + }, + setItem: (key, value) => { + try { + hostTools.dbStorage.setItem(key, value); + return true; + } catch (e) { + console.warn('[preload] 设置存储失败:', e); + return false; + } + }, + removeItem: (key) => { + try { + hostTools.dbStorage.removeItem(key); + return true; + } catch (e) { + console.warn('[preload] 删除存储失败:', e); + return false; + } + }, + }; + }; +}; + +const setupWindowControl = () => { + if (typeof window === 'undefined') return; + + window.setPluginWindowHeight = (height) => { + try { + const hostTools = getHostTools(); + if (hostTools && typeof hostTools.setExpendHeight === 'function') { + hostTools.setExpendHeight(height); + return true; + } + } catch (e) { + console.warn('[preload] 设置窗口高度失败:', e); + } + return false; + }; + + window.setPluginWindowTitle = (title) => { + try { + const hostTools = getHostTools(); + if (hostTools && typeof hostTools.setMainWindowTitle === 'function') { + hostTools.setMainWindowTitle(title); + return true; + } + } catch (e) { + console.warn('[preload] 设置窗口标题失败:', e); + } + return false; + }; +}; + +const setupFontTools = (platform) => { + if (typeof window === 'undefined') return; + + window.getSystemFonts = () => { + try { + const result = execFileSync('fc-list', { + encoding: 'utf-8', + stdio: 'pipe', + timeout: 5000, + }); + const fonts = new Set(); + const lines = result.split('\n'); + for (const line of lines) { + const match = line.match(/^.*:\s+(.+?)\s+-?\s*$/); + if (match && match[1]) { + const fontName = match[1].trim(); + if (fontName.length > 0 && !fonts.has(fontName)) { + fonts.add(fontName); + } + } + } + return Array.from(fonts); + } catch (e) { + console.warn('[preload] 获取系统字体失败:', e); + return []; + } + }; + + window.getSystemFontsAsync = () => { + return new Promise((resolve) => { + setTimeout(() => { + try { + resolve(window.getSystemFonts()); + } catch (e) { + console.warn('[preload] 异步获取系统字体失败:', e); + resolve([]); + } + }, 0); + }); + }; + + window.getFontsDirectory = () => { + try { + const result = execFileSync('fc-list', ['-s'], { encoding: 'utf-8', stdio: 'pipe', timeout: 5000 }); + const fonts = new Set(); + const lines = result.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed && trimmed.length > 0) { + fonts.add(trimmed); + } + } + return Array.from(fonts); + } catch (e) { + console.warn('[preload] 获取字体目录失败:', e); + return []; + } + }; + + window.detectFontName = (filePath) => { + if (!filePath || typeof filePath !== 'string') return null; + try { + const cleanedPath = filePath.replace(/^file:\/\//, ''); + if (!fs.existsSync(cleanedPath)) return null; + const fontBuffer = _readBuffer(cleanedPath); + if (!fontBuffer) return null; + return _getFontNameFromBuffer(fontBuffer, cleanedPath); + } catch (e) { + console.warn('[preload] 检测字体名称失败:', e); + return null; + } + }; + + window.isFontInstalled = (fontName) => { + try { + const result = execFileSync('fc-list', [fontName], { encoding: 'utf-8', stdio: 'pipe', timeout: 5000 }); + return result.length > 0; + } catch (e) { + return false; + } + }; + + window.installFont = (filePath) => { + if (!filePath || typeof filePath !== 'string') return false; + try { + const cleanedPath = filePath.replace(/^file:\/\//, ''); + if (!fs.existsSync(cleanedPath)) return false; + const userFontsDir = path.join(os.homedir(), '.fonts'); + fs.mkdirSync(userFontsDir, { recursive: true }); + const fileName = path.basename(cleanedPath); + const destPath = path.join(userFontsDir, fileName); + fs.copyFileSync(cleanedPath, destPath); + try { + execFileSync('fc-cache', ['-f'], { timeout: 10000 }); + } catch (e) { + console.warn('[preload] 字体缓存更新失败:', e); + } + return true; + } catch (e) { + console.warn('[preload] 安装字体失败:', e); + return false; + } + }; +}; + +const setupUserAPI = (platform) => { + if (typeof window === 'undefined') return; + + window.getHostUser = () => { + const hostTools = getHostTools(); + if (!hostTools) return null; + try { + if (typeof hostTools.getUser === 'function') return hostTools.getUser(); + if (typeof hostTools.getUserInfo === 'function') return hostTools.getUserInfo(); + } catch (e) { + console.warn(`[${platform.getName()} preload] 获取宿主用户失败:`, e); + } + return null; + }; +}; + +const setupMiscAPIs = (platform) => { + if (typeof window === 'undefined') return; + + window.getImageSourceFromPluginPayload = (type, payload) => { + if (type === 'img' && payload) { + const imgPayload = Array.isArray(payload) ? payload : [payload]; + for (const item of imgPayload) { + const dataURL = (item && typeof item === 'object') ? item.dataURL || item.dataUrl || item.base64 || item.content : null; + if (dataURL && typeof dataURL === 'string' && dataURL.startsWith('data:')) return dataURL; + } + return null; + } + if (type === 'files' || type === 'file') { + const files = Array.isArray(payload) ? payload : [payload]; + for (const fileInfo of files) { + if (fileInfo && fileInfo.path) { + const cleanedPath = fileInfo.path.replace(/^file:\/\//, ''); + if (fs.existsSync(cleanedPath)) { + try { + const data = fs.readFileSync(cleanedPath); + const mimeType = _detectImageMime(data); + const base64 = data.toString('base64'); + return `data:${mimeType};base64,${base64}`; + } catch (e) { + console.warn(`[${platform.getName()} preload] 读取图片文件失败:`, e); + } + } + } + } + } + return null; + }; +}; + +const getHostTools = () => { + if (typeof window === 'undefined') return null; + const api = window.hostTools + || window.utools + || window.ztools + || (typeof globalThis !== 'undefined' ? (globalThis.utools || globalThis.ztools) : null) + || null; + if (api) { + window.hostTools = api; + if (!window.utools && window.ztools === api) { + window.utools = api; + } + } + return api; +}; + +const getHostPath = (name) => { + const hostTools = getHostTools(); + try { + if (hostTools && typeof hostTools.getPath === 'function') return hostTools.getPath(name); + } catch (e) { + console.warn('[preload] 获取宿主路径失败:', e); + } + return ''; +}; + +const getHostAppVersion = () => { + const hostTools = getHostTools(); + try { + if (hostTools && typeof hostTools.getAppVersion === 'function') return hostTools.getAppVersion(); + if (hostTools && typeof hostTools.getVersion === 'function') return hostTools.getVersion(); + if (hostTools && typeof hostTools.getPluginVersion === 'function') return hostTools.getPluginVersion(); + } catch (e) { + console.warn('[preload] 获取宿主版本失败:', e); + } + return 'unknown'; +}; + +const getHostVersion = () => getHostAppVersion(); + +const getHostAppInfo = () => { + const hostTools = getHostTools(); + if (!hostTools) return { name: _getHostName(), version: 'unknown', platform: process.platform }; + try { + if (typeof hostTools.getAppVersion === 'function') return { name: _getHostName(), version: hostTools.getAppVersion(), platform: process.platform }; + if (typeof hostTools.getVersion === 'function') return { name: _getHostName(), version: hostTools.getVersion(), platform: process.platform }; + } catch (e) { + console.warn('[preload] 获取宿主信息失败:', e); + } + return { name: _getHostName(), version: 'unknown', platform: process.platform }; +}; + +const _getHostName = () => { + const hostTools = getHostTools(); + try { + if (hostTools && typeof hostTools.getAppName === 'function') { + const name = hostTools.getAppName(); + if (name) return String(name); + } + } catch (e) { + console.warn('[preload] 获取宿主名称失败:', e); + } + if (typeof window !== 'undefined') { + if (window.ztools) return 'ZTools'; + if (window.utools) return 'uTools'; + } + return '宿主'; +}; + +// 导出所有公共函数 +module.exports = { + initPreload, + setupImageDialog, + setupClipboard, + setupExternalLink, + setupStorage, + setupWindowControl, + setupFontTools, + setupUserAPI, + setupMiscAPIs, + getHostTools, + getHostPath, + getHostAppVersion, + getHostVersion, + getHostAppInfo, + _getHostName, +}; diff --git a/plugins/Image-Toolbox/core/src/runtime/fabric.js b/plugins/Image-Toolbox/core/src/runtime/fabric.js new file mode 100644 index 000000000..1421fddee --- /dev/null +++ b/plugins/Image-Toolbox/core/src/runtime/fabric.js @@ -0,0 +1,8 @@ +// Fabric/browser runtime entry. +// This entry intentionally contains DOM and Fabric.js dependent managers/modules. + +export { default as eventBus, EventBus } from '../EventBus.js'; +export { default as CanvasManager } from '../CanvasManager.js'; +export { default as LayerManager } from '../LayerManager.js'; +export { default as HistoryManager } from '../HistoryManager.js'; +export { default as ToolManager } from '../ToolManager.js'; diff --git a/plugins/Image-Toolbox/core/src/ui/AccountPage.js b/plugins/Image-Toolbox/core/src/ui/AccountPage.js new file mode 100644 index 000000000..51befec69 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/ui/AccountPage.js @@ -0,0 +1,618 @@ +import { eventBus } from '../index.js'; +import { SIDE_PANEL_LAYOUT_KEY, SIDE_PANEL_LAYOUTS } from './SidePanelTabs.js'; +import { THEME_CHOICES, applyThemeChoice, getThemeChoice } from '../utils/theme.js'; +import { updateCategories, updateRecords, PLATFORMS } from '../updateRecords.js'; +import { escapeHTML, escapeAttr } from '../utils/helpers.js'; + +/** + * 获取当前平台标识 + */ +function getCurrentPlatform() { + if (typeof window === 'undefined') return null; + if (window.ztools) return PLATFORMS.ZTOOLS; + if (window.utools) return PLATFORMS.UTOOLS; + return null; +} + +/** + * 检查更新项是否应在当前平台显示 + * @param {null|string[]} platforms - 平台限制 (null=所有平台, ['utools']=仅utools等) + * @returns {boolean} 是否应显示 + */ +function shouldShowForCurrentPlatform(platforms) { + if (platforms === null || platforms === undefined) return true; + if (!Array.isArray(platforms)) return true; + + const currentPlatform = getCurrentPlatform(); + return platforms.includes(currentPlatform); +} + +export const EDITOR_BARS_LAYOUT_KEY = 'image-toolbox-editor-bars-layout'; +export const EDITOR_BARS_LAYOUTS = { + PRESETS_TOP: 'presets-top', + STATUS_TOP: 'status-top', +}; + +export const EDITOR_SIDE_PANEL_POSITION_KEY = 'image-toolbox-editor-side-panel-position'; +export const EDITOR_SIDE_PANEL_POSITIONS = { + RIGHT: 'right', + LEFT: 'left', +}; + +export const TOOLBAR_LABELS_VISIBLE_KEY = 'image-toolbox-toolbar-labels-visible'; +export const TOOLBAR_LABELS_VISIBLE = { + ON: 'on', + OFF: 'off', +}; + +const VALID_EDITOR_BARS_LAYOUTS = new Set(Object.values(EDITOR_BARS_LAYOUTS)); +const VALID_EDITOR_SIDE_PANEL_POSITIONS = new Set(Object.values(EDITOR_SIDE_PANEL_POSITIONS)); +const VALID_TOOLBAR_LABELS_VISIBLE = new Set(Object.values(TOOLBAR_LABELS_VISIBLE)); + +/** + * Account page UI component. + * Opens from the avatar into a standalone page with side navigation. + */ +class AccountPage { + constructor(containerEl, editorEl, sidePanelTabs, host = null) { + this._el = containerEl; + this._editorEl = editorEl; + this._sidePanelTabs = sidePanelTabs; + this._host = host; + this._activeSection = 'mine'; + this._user = this._getHostUser(); + this._eventBusUnsubscribers = []; + + this._render(); + this._bindEvents(); + } + + open() { + this._user = this._getHostUser(); + this._render(); + this._editorEl?.classList.add('hidden'); + this._el?.classList.remove('hidden'); + } + + close() { + this._el?.classList.add('hidden'); + this._editorEl?.classList.remove('hidden'); + } + + _render() { + if (!this._el) return; + + const sectionTitle = this._getSectionTitle(this._activeSection); + this._el.innerHTML = ` + + `; + } + + _bindEvents() { + if (!this._el) return; + + this._eventBusUnsubscribers.push( + eventBus.on('account:open', () => this.open()) + ); + + this._el.addEventListener('click', (e) => { + const navItem = this._closest(e.target, '[data-section]'); + if (navItem) { + this._activeSection = navItem.getAttribute('data-section'); + this._render(); + return; + } + + const action = this._closest(e.target, '[data-action]')?.getAttribute('data-action'); + if (action === 'back') { + this.close(); + return; + } + + const externalUrl = this._closest(e.target, '[data-external-url]')?.getAttribute('data-external-url'); + if (externalUrl) { + e.preventDefault(); + this._openExternalUrl(externalUrl); + return; + } + + const theme = this._closest(e.target, '[data-theme-choice]')?.getAttribute('data-theme-choice'); + if (theme) { + this._setTheme(theme); + this._render(); + return; + } + + const panelLayout = this._closest(e.target, '[data-side-panel-layout]')?.getAttribute('data-side-panel-layout'); + if (panelLayout) { + this._setSidePanelLayout(panelLayout); + this._render(); + return; + } + + const editorBarsLayout = this._closest(e.target, '[data-editor-bars-layout]')?.getAttribute('data-editor-bars-layout'); + if (editorBarsLayout) { + this._setEditorBarsLayout(editorBarsLayout); + this._render(); + return; + } + + const editorSidePanelPosition = this._closest(e.target, '[data-editor-side-panel-position]')?.getAttribute('data-editor-side-panel-position'); + if (editorSidePanelPosition) { + this._setEditorSidePanelPosition(editorSidePanelPosition); + this._render(); + return; + } + + const toolbarLabels = this._closest(e.target, '[data-toolbar-labels]')?.getAttribute('data-toolbar-labels'); + if (toolbarLabels) { + this._setToolbarLabelsVisible(toolbarLabels); + this._render(); + } + }); + + this._el.addEventListener('error', (e) => { + const avatar = e.target.closest?.('.account-page__avatar-img'); + if (!avatar) return; + const fallback = document.createElement('div'); + fallback.className = avatar.className.replace('account-page__avatar-img', 'account-page__avatar-fallback'); + fallback.textContent = avatar.dataset.initial || 'U'; + avatar.replaceWith(fallback); + }, true); + } + + _renderNavItem(section, label) { + const isActive = this._activeSection === section; + return ` + + `; + } + + _closest(target, selector) { + if (!target) return null; + const match = typeof target.closest === 'function' + ? target.closest(selector) + : target.parentElement?.closest?.(selector) || null; + + if (match && typeof this._el?.contains === 'function' && !this._el.contains(match)) return null; + return match; + } + + _renderSection() { + if (this._activeSection === 'settings') return this._renderSettings(); + if (this._activeSection === 'updates') return this._renderUpdates(); + if (this._activeSection === 'about') return this._renderAbout(); + return this._renderMine(); + } + + _renderMine() { + const user = this._getUserView(); + const hostName = this._getHostName(); + return ` + + `; + } + + _renderSettings() { + const theme = getThemeChoice(); + const sidePanelLayout = this._getSidePanelLayout(); + const editorBarsLayout = this._getEditorBarsLayout(); + const editorSidePanelPosition = this._getEditorSidePanelPosition(); + const toolbarLabels = this._getToolbarLabelsVisible(); + return ` + + + + + + + + + + `; + } + + _renderAbout() { + const appVersion = this._getCurrentVersion(); + const hostName = this._getHostName(); + const hostVersion = this._getHostVersion(); + const qqUrl = this._getContactUrl(); + + return ` + + `; + } + + _renderUpdates() { + return ` +
+ ${updateRecords.map(record => this._renderUpdateRecord(record)).join('')} +
+ `; + } + + _renderUpdateRecord(record) { + return ` +
+
+

版本 ${this._escapeHTML(record.version)}

+ +
+
+ ${updateCategories.map(category => this._renderChangeGroup(record, category)).join('')} +
+
+ `; + } + + _renderChangeGroup(record, category) { + const items = record.changes?.[category.key] || []; + if (items.length === 0) return ''; + + // 过滤出当前平台应显示的项目 + const visibleItems = items.filter(item => { + // 兼容旧格式(字符串) + if (typeof item === 'string') return true; + // 新格式(对象)- 检查平台限制 + return shouldShowForCurrentPlatform(item.platforms); + }); + + if (visibleItems.length === 0) return ''; + + return ` +
+
${this._escapeHTML(category.title)}
+
    + ${visibleItems.map(item => this._renderChangeItem(item)).join('')} +
+
+ `; + } + + /** + * 渲染单个更新项,处理平台限制标记 + */ + _renderChangeItem(item) { + // 兼容旧格式(字符串) + if (typeof item === 'string') { + return `
  • ${this._escapeHTML(item)}
  • `; + } + + // 新格式(对象) + const text = item.text || ''; + const platforms = item.platforms; + const currentPlatform = getCurrentPlatform(); + + // 如果有平台限制且当前不是所有平台,添加平台标签 + let badge = ''; + if (Array.isArray(platforms) && platforms.length > 0 && platforms.length < 3) { + const platformLabels = { + 'utools': 'uTools', + 'ztools': 'ZTools', + 'local': '本地环境' + }; + const labels = platforms.map(p => platformLabels[p] || p).join('/'); + const isCurrentPlatform = shouldShowForCurrentPlatform(platforms); + const badgeClass = isCurrentPlatform ? 'update-item__platform-badge--current' : 'update-item__platform-badge--other'; + badge = `${this._escapeHTML(labels)}`; + } + + return `
  • ${this._escapeHTML(text)}${badge}
  • `; + } + + _renderAvatar(className) { + const user = this._getUserView(); + const title = this._escapeAttr(user.name); + const initial = this._escapeAttr(user.initial); + + if (user.avatar) { + return ``; + } + + return ``; + } + + _getUserView() { + const user = this._user || {}; + const hostName = this._getHostName(); + const name = user.nickname || user.name || user.userName || user.username || `${hostName} 用户`; + const avatar = user.avatar || user.avatarUrl || user.photo || ''; + return { + name, + avatar, + initial: this._getInitial(name), + status: this._user ? `已连接 ${hostName} 用户信息` : `未获取到 ${hostName} 用户信息`, + }; + } + + _getSectionTitle(section) { + const titles = { + mine: '我的', + settings: '设置', + updates: '更新记录', + about: '关于', + }; + return titles[section] || titles.mine; + } + + _setTheme(theme) { + applyThemeChoice(theme); + } + + _getCurrentVersion() { + const version = updateRecords?.[0]?.version; + return this._formatVersion(version); + } + + _getHostVersion() { + try { + return this._formatVersion(this._host?.platform?.version || this._host?.getHostAppVersion?.()); + } catch (e) { + console.warn('[AccountPage] 获取宿主版本失败:', e); + } + + return '未知'; + } + + _formatVersion(version) { + const text = String(version || '').trim(); + if (!text) return '未知'; + return /^v/i.test(text) ? text : `v${text}`; + } + + _openExternalUrl(url) { + if (!url) return; + + try { + if (this._host?.system?.openExternal?.(url) || this._host?.openHostExternal?.(url)) { + return; + } + } catch (e) { + console.warn('[AccountPage] 使用宿主打开外部链接失败:', e); + } + + window.open(url, '_blank', 'noopener,noreferrer'); + } + + _setSidePanelLayout(layout) { + if (!Object.values(SIDE_PANEL_LAYOUTS).includes(layout)) return; + + localStorage.setItem(SIDE_PANEL_LAYOUT_KEY, layout); + this._sidePanelTabs?.applyLayout(layout, false); + eventBus.emit('sidePanel:layoutChanged', layout); + } + + _setEditorBarsLayout(layout) { + if (!VALID_EDITOR_BARS_LAYOUTS.has(layout)) return; + + localStorage.setItem(EDITOR_BARS_LAYOUT_KEY, layout); + eventBus.emit('editorBars:layoutChanged', layout); + } + + _setEditorSidePanelPosition(position) { + if (!VALID_EDITOR_SIDE_PANEL_POSITIONS.has(position)) return; + + localStorage.setItem(EDITOR_SIDE_PANEL_POSITION_KEY, position); + eventBus.emit('editorSidePanel:positionChanged', position); + } + + _setToolbarLabelsVisible(value) { + if (!VALID_TOOLBAR_LABELS_VISIBLE.has(value)) return; + + localStorage.setItem(TOOLBAR_LABELS_VISIBLE_KEY, value); + eventBus.emit('toolbar:labelsVisibilityChanged', value); + } + + _getSidePanelLayout() { + const saved = localStorage.getItem(SIDE_PANEL_LAYOUT_KEY); + return Object.values(SIDE_PANEL_LAYOUTS).includes(saved) ? saved : SIDE_PANEL_LAYOUTS.TABS; + } + + _getEditorBarsLayout() { + const saved = localStorage.getItem(EDITOR_BARS_LAYOUT_KEY); + return VALID_EDITOR_BARS_LAYOUTS.has(saved) ? saved : EDITOR_BARS_LAYOUTS.PRESETS_TOP; + } + + _getEditorSidePanelPosition() { + const saved = localStorage.getItem(EDITOR_SIDE_PANEL_POSITION_KEY); + return VALID_EDITOR_SIDE_PANEL_POSITIONS.has(saved) ? saved : EDITOR_SIDE_PANEL_POSITIONS.RIGHT; + } + + _getToolbarLabelsVisible() { + const saved = localStorage.getItem(TOOLBAR_LABELS_VISIBLE_KEY); + return VALID_TOOLBAR_LABELS_VISIBLE.has(saved) ? saved : TOOLBAR_LABELS_VISIBLE.ON; + } + + _getHostUser() { + try { + const result = this._host?.user?.getCurrentUser?.() || this._host?.getHostUser?.() || null; + if (result && typeof result.then === 'function') { + result.then((user) => { + this._user = user; + this._render(); + }).catch((e) => console.warn('[AccountPage] 获取宿主用户信息失败:', e)); + return null; + } + return result; + } catch (e) { + console.warn('[AccountPage] 获取宿主用户信息失败:', e); + } + return null; + } + + _getHostName() { + return this._host?.platform?.name || this._host?.getHostName?.() || 'uTools'; + } + + _getContactUrl() { + try { + const url = this._host?.getContactUrl?.(); + if (url) return url; + } catch (e) { + console.warn('[AccountPage] 获取联系方式失败:', e); + } + return 'https://qm.qq.com/q/Nzn12S22e6'; + } + + _getInitial(name) { + const text = String(name || '').trim(); + return text ? text.slice(0, 1).toUpperCase() : 'U'; + } + + _escapeAttr(value) { + return escapeAttr(value); + } + + _escapeHTML(value) { + return escapeHTML(value); + } + + destroy() { + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; + } +} + +export default AccountPage; diff --git a/plugins/Image-Toolbox/core/src/ui/ColorPanel.js b/plugins/Image-Toolbox/core/src/ui/ColorPanel.js new file mode 100644 index 000000000..1c8a670c8 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/ui/ColorPanel.js @@ -0,0 +1,275 @@ +import { eventBus } from '../index.js'; +import { FILTER_RANGES, FILTER_PRESETS, getFilterUiValue, setFilter, clearFilters, applyFilterPreset, isPresetActive } from '../utils/filters.js'; + +/** + * 调色面板 — 侧栏「调色」Tab + * 选中图片图层时显示滤镜预设、亮度/对比度/饱和度/色相/模糊滑块与重置按钮。 + * 非图片图层或未选中时显示提示文本。 + */ +class ColorPanel { + constructor(containerEl, canvasManager, historyManager) { + this._el = containerEl; + this._cm = canvasManager; + this._hm = historyManager; + this._filterScope = 'all'; + this._eventBusUnsubscribers = []; + + this._render(); + this._bindEvents(); + this._update(); + } + + _render() { + this._el.innerHTML = ` +
    +
    +
    选中图片图层以调色
    +
    +
    + `; + } + + _bindEvents() { + this._eventBusUnsubscribers.push( + eventBus.on('canvas:selectionCreated', () => this._update()), + eventBus.on('canvas:selectionUpdated', () => this._update()), + eventBus.on('canvas:selectionCleared', () => this._update()), + eventBus.on('layer:selected', () => this._update()), + eventBus.on('canvas:objectModified', () => this._update()), + eventBus.on('canvas:restored', () => this._update()), + eventBus.on('image:loaded', () => this._clearHint()) + ); + + this._domHandlers = { + input: (e) => this._handleEvent(e), + change: (e) => this._handleEvent(e), + click: (e) => this._handleEvent(e), + }; + this._el.addEventListener('input', this._domHandlers.input); + this._el.addEventListener('change', this._domHandlers.change); + this._el.addEventListener('click', this._domHandlers.click); + } + + _update() { + const bodyEl = this._el.querySelector('#color-panel-body'); + if (!bodyEl) return; + + const reference = this._getReferenceImage(); + if (reference) { + bodyEl.innerHTML = this._getColorAdjustHTML(reference); + } else { + const hint = this._getAllImages().length > 0 + ? '选中图片图层以调色' + : '当前画布没有可调色的图片图层'; + bodyEl.innerHTML = `${this._getScopeControlHTML()}
    ${hint}
    `; + } + } + + _clearHint() { + this._update(); + } + + _getColorAdjustHTML(active) { + const items = [ + { type: 'brightness', label: '亮度' }, + { type: 'contrast', label: '对比' }, + { type: 'saturation',label: '饱和' }, + { type: 'hue', label: '色相' }, + { type: 'blur', label: '模糊' }, + ]; + + const sliders = items.map(({ type, label }) => { + const range = FILTER_RANGES[type]; + const value = getFilterUiValue(active, type); + return ` +
    + + + ${value} +
    + `; + }).join(''); + + const filterPresets = FILTER_PRESETS.map(preset => { + const targets = this._getTargetImages(); + const isActive = this._filterScope === 'all' + ? targets.length > 0 && targets.every(image => isPresetActive(image, preset.preset)) + : isPresetActive(active, preset.preset); + return ``; + }).join(''); + + const scopeTitle = this._filterScope === 'all' + ? `调色 (${this._getTargetImages().length} 个图片图层)` + : '调色'; + + return ` + ${this._getScopeControlHTML()} +
    滤镜
    +
    ${filterPresets}
    +
    ${scopeTitle}
    + ${sliders} +
    + +
    + `; + } + + _handleEvent(e) { + // 滤镜预设按钮(一键应用) + const presetTarget = e.target.closest('[data-preset]'); + if (presetTarget && this._el.contains(presetTarget)) { + const preset = presetTarget.dataset.preset; + if (preset && preset.startsWith('filter-')) { + if (e.type !== 'click') return; + this._applyFilterPreset(preset); + return; + } + } + + // 调色滑块 / 重置 + const target = e.target.closest('[data-prop]'); + if (!target || !this._el.contains(target)) return; + + const prop = target.dataset.prop; + if (!prop) return; + + if (prop === 'filterScope') { + if (e.type !== 'change') return; + this._filterScope = target.value === 'all' ? 'all' : 'current'; + this._update(); + return; + } + + if (!prop.startsWith('filter:')) return; + + const targets = this._getTargetImages(); + if (targets.length === 0) return; + + const value = target.value; + + // 重置按钮 + if (prop === 'filter:reset') { + if (e.type !== 'click') return; + this._hm?.saveState?.(); + targets.forEach(image => clearFilters(image)); + this._markImagesChanged(targets); + this._requestRender(); + this._notifyObjectChanged(targets[0]); + this._update(); + return; + } + + // 滑块 + const type = prop.slice('filter:'.length); + const uiValue = parseInt(value, 10); + if (!Number.isFinite(uiValue)) return; + + targets.forEach(image => setFilter(image, type, uiValue)); + this._markImagesChanged(targets); + + if (target.nextElementSibling && target.nextElementSibling.classList.contains('property-value')) { + target.nextElementSibling.textContent = String(uiValue); + } + + this._requestRender(); + + if (e.type === 'change') { + this._hm?.saveState?.(); + this._notifyObjectChanged(targets[0]); + } + } + + _applyFilterPreset(presetName) { + const targets = this._getTargetImages(); + if (targets.length === 0) return; + this._hm?.saveState?.(); + targets.forEach(image => applyFilterPreset(image, presetName)); + this._markImagesChanged(targets); + this._requestRender(); + this._notifyObjectChanged(targets[0]); + this._update(); + } + + _getScopeControlHTML() { + return ` +
    作用范围
    +
    + + +
    + `; + } + + _getTargetImages() { + if (this._filterScope === 'all') { + return this._getAllImages(); + } + + const active = this._getActiveObject(); + return active && active.type === 'image' && active.type !== 'activeSelection' ? [active] : []; + } + + _getReferenceImage() { + if (this._filterScope === 'all') { + const active = this._getActiveObject(); + return active?.type === 'image' ? active : this._getAllImages()[0] || null; + } + + const active = this._getActiveObject(); + return active?.type === 'image' ? active : null; + } + + _getAllImages() { + const canvas = this._cm?.canvas; + if (!canvas) return []; + return canvas.getObjects().filter(obj => ( + obj && + obj.type === 'image' && + !obj.excludeFromLayer && + !obj.excludeFromHistory + )); + } + + _markImagesChanged(images) { + images.forEach(image => { + image.dirty = true; + image.setCoords(); + }); + } + + _getActiveObject() { + return this._cm?.getActiveObject?.() || null; + } + + _notifyObjectChanged(active) { + eventBus.emit('canvas:objectModified', active); + } + + _requestRender() { + const canvas = this._cm?.canvas; + if (!canvas) return; + if (typeof canvas.requestRenderAll === 'function') { + canvas.requestRenderAll(); + } else { + canvas.renderAll(); + } + } + + destroy() { + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; + + if (this._domHandlers) { + this._el.removeEventListener('input', this._domHandlers.input); + this._el.removeEventListener('change', this._domHandlers.change); + this._el.removeEventListener('click', this._domHandlers.click); + this._domHandlers = null; + } + } +} + +export default ColorPanel; diff --git a/plugins/Image-Toolbox/core/src/ui/LayerPanel.js b/plugins/Image-Toolbox/core/src/ui/LayerPanel.js new file mode 100644 index 000000000..62ec9bbcc --- /dev/null +++ b/plugins/Image-Toolbox/core/src/ui/LayerPanel.js @@ -0,0 +1,392 @@ +import { eventBus } from '../index.js'; +import { escapeHTML, escapeAttr } from '../utils/helpers.js'; + +/** + * 图层面板 UI 组件 + * 显示所有图层列表,支持显隐/锁定/选中/排序 + */ +class LayerPanel { + constructor(containerEl, layerManager) { + this._el = containerEl; + this._lm = layerManager; + this._dragLayerId = null; + this._dropPanelIndex = null; + this._selectedLayerId = null; + this._activeLayerIds = []; + this._eventBusUnsubscribers = []; + + this._bindEvents(); + this._render(); + } + + _render() { + this._el.innerHTML = ` +
    +
    + 图层 + 0 +
    +
    +
      +
      +
      + + +
      +
      + `; + + this._refreshLayerList(); + } + + _bindEvents() { + // Refresh the list when layers change. + this._eventBusUnsubscribers.push( + eventBus.on('layers:updated', () => { + this._refreshLayerList(); + }), + eventBus.on('canvas:objectAdded', (obj) => { + this._lm.syncLayers(); + this._selectLayerByObject(obj); + }), + eventBus.on('canvas:objectRemoved', () => { + this._lm.syncLayers(); + }), + eventBus.on('canvas:objectModified', () => { + this._lm.syncLayers(); + }), + eventBus.on('canvas:objectMetadataChanged', () => { + this._lm.syncLayers(); + }), + eventBus.on('canvas:selectionCreated', () => this._selectLayerFromActiveObject()), + eventBus.on('canvas:selectionUpdated', () => this._selectLayerFromActiveObject()), + eventBus.on('canvas:selectionCleared', () => { + this._activeLayerIds = []; + this._refreshLayerList(); + }), + eventBus.on('layer:selected', (meta) => { + this._selectedLayerId = meta?.id ?? null; + this._activeLayerIds = meta ? [meta.id] : []; + this._refreshLayerList(); + }), + eventBus.on('image:loaded', () => { + this._selectedLayerId = null; + this._activeLayerIds = []; + this._refreshLayerList(); + }), + eventBus.on('canvas:restored', () => { + this._selectedLayerId = null; + this._activeLayerIds = []; + this._refreshLayerList(); + }) + ); + + // 事件委托 + this._el.addEventListener('click', (e) => { + const layerItem = e.target.closest('.layer-item'); + if (!layerItem) { + // Check whether an action button was clicked. + this._handleActionClick(e); + return; + } + + const layerId = parseInt(layerItem.dataset.layerId); + if (isNaN(layerId)) return; + + // Toggle visibility. + if (e.target.closest('.layer-item__visibility')) { + this._lm.toggleVisibility(layerId); + return; + } + + // 锁定切换 + if (e.target.closest('.layer-item__lock')) { + this._lm.toggleLock(layerId); + return; + } + + // 选中图层 + this._selectedLayerId = layerId; + this._activeLayerIds = []; + this._refreshLayerList(); + this._lm.selectLayer(layerId); + }); + + this._el.addEventListener('dragstart', (e) => this._handleDragStart(e)); + this._el.addEventListener('dragover', (e) => this._handleDragOver(e)); + this._el.addEventListener('drop', (e) => this._handleDrop(e)); + this._el.addEventListener('dragend', () => this._handleDragEnd()); + this._el.addEventListener('dragleave', (e) => { + if (!this._el.contains(e.relatedTarget)) { + this._clearDropIndicators(false); + } + }); + + } + + _handleDragStart(e) { + const layerItem = e.target.closest('.layer-item'); + if (!layerItem) return; + + const layerId = parseInt(layerItem.dataset.layerId); + const layer = this._lm.getLayerById(layerId); + if (!layer || layer.isBackground) { + e.preventDefault(); + return; + } + + this._dragLayerId = layerId; + this._dropPanelIndex = null; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(layerId)); + layerItem.classList.add('layer-item--dragging'); + } + + _handleDragOver(e) { + if (this._dragLayerId === null) return; + + const dropInfo = this._getDropInfo(e); + if (!dropInfo) return; + + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'move'; + + this._clearDropIndicators(false); + this._dropPanelIndex = dropInfo.panelIndex; + + if (this._isNoopDrop(dropInfo.panelIndex)) return; + + dropInfo.item.classList.add( + dropInfo.position === 'after' ? 'layer-item--drop-after' : 'layer-item--drop-before' + ); + } + + _handleDrop(e) { + if (this._dragLayerId === null) return; + + e.preventDefault(); + e.stopPropagation(); + + const dropInfo = this._getDropInfo(e); + const targetIndex = dropInfo ? dropInfo.panelIndex : this._dropPanelIndex; + + if (targetIndex !== null && !this._isNoopDrop(targetIndex)) { + this._lm.reorderLayer(this._dragLayerId, targetIndex); + } + + this._handleDragEnd(); + } + + _handleDragEnd() { + this._dragLayerId = null; + this._dropPanelIndex = null; + this._clearDropIndicators(); + } + + _getDropInfo(e) { + const item = e.target.closest('.layer-item'); + if (!item) return null; + + const targetLayerId = parseInt(item.dataset.layerId); + const layers = this._lm.getLayers(); + const targetLayer = layers.find(l => l.id === targetLayerId); + if (!targetLayer) return null; + + const overlayLayers = layers.filter(l => !l.isBackground); + if (targetLayer.isBackground) { + return { + item, + panelIndex: overlayLayers.length, + position: 'before', + }; + } + + const targetIndex = overlayLayers.findIndex(l => l.id === targetLayerId); + if (targetIndex === -1) return null; + + const rect = item.getBoundingClientRect(); + const position = e.clientY > rect.top + rect.height / 2 ? 'after' : 'before'; + + return { + item, + panelIndex: targetIndex + (position === 'after' ? 1 : 0), + position, + }; + } + + _isNoopDrop(panelIndex) { + if (this._dragLayerId === null) return true; + + const overlayLayers = this._lm.getLayers().filter(l => !l.isBackground); + const fromIndex = overlayLayers.findIndex(l => l.id === this._dragLayerId); + if (fromIndex === -1) return true; + + let insertIndex = Math.max(0, Math.min(panelIndex, overlayLayers.length)); + if (fromIndex < insertIndex) insertIndex -= 1; + return insertIndex === fromIndex; + } + + _clearDropIndicators(includeDragging = true) { + const selector = includeDragging + ? '.layer-item--dragging, .layer-item--drop-before, .layer-item--drop-after' + : '.layer-item--drop-before, .layer-item--drop-after'; + + this._el.querySelectorAll(selector) + .forEach(item => { + item.classList.remove('layer-item--dragging', 'layer-item--drop-before', 'layer-item--drop-after'); + }); + } + + _handleActionClick(e) { + const target = e.target; + if (target.id === 'layer-delete') { + const selected = this._getSelectedLayerId(); + if (selected !== null) { + this._lm.deleteLayer(selected); + } + return; + } + + if (target.id === 'layer-add') { + eventBus.emit('tool:requestChange', 'text'); + } + } + + _getSelectedLayerId() { + return this._selectedLayerId; + } + + _selectLayerFromActiveObject() { + const active = this._lm._cm?.getActiveObject(); + const layerIds = this._getSelectedLayerIds(active); + this._activeLayerIds = layerIds; + if (layerIds.length > 0) { + this._selectedLayerId = layerIds[0]; + } + this._refreshLayerList(); + } + + _selectLayerByObject(obj) { + if (!obj || obj.excludeFromLayer) return; + + const meta = this._lm.getLayerByObject?.(obj) || this._lm._findMeta?.(obj); + if (!meta) return; + + this._selectedLayerId = meta.id; + this._activeLayerIds = [meta.id]; + this._refreshLayerList(); + } + + _getSelectedLayerIds(activeObj) { + if (!activeObj) return []; + + const objects = activeObj.type === 'activeSelection' && typeof activeObj.getObjects === 'function' + ? activeObj.getObjects() + : [activeObj]; + + return objects + .map(obj => this._lm.getLayerByObject?.(obj) || this._lm._findMeta?.(obj)) + .filter(Boolean) + .map(meta => meta.id); + } + + _getSelectedLayerIdSet() { + const ids = this._activeLayerIds; + return ids.length > 0 ? new Set(ids) : new Set(this._selectedLayerId === null ? [] : [this._selectedLayerId]); + } + + _ensureSelectedLayerExists(layers) { + const layerIds = new Set(layers.map(layer => layer.id)); + this._activeLayerIds = this._activeLayerIds.filter(id => layerIds.has(id)); + + if (this._selectedLayerId === null) return; + if (!layerIds.has(this._selectedLayerId)) { + this._selectedLayerId = null; + } + } + + _refreshLayerList() { + // Prevent refresh -> syncLayers -> emit('layers:updated') -> refresh loops. + if (this._refreshing) return; + this._refreshing = true; + + const listEl = this._el.querySelector('#layer-list'); + const countEl = this._el.querySelector('#layer-count'); + if (!listEl) { this._refreshing = false; return; } + + this._lm.syncLayers(); + const layers = this._lm.getLayers(); + this._ensureSelectedLayerExists(layers); + if (countEl) countEl.textContent = layers.length; + + const selectedLayerIds = this._getSelectedLayerIdSet(); + + // 图标映射 + const typeIcons = { + 'i-text': 'T', + 'text': 'T', + 'textbox': 'T', + 'rect': '+', + 'circle': 'O', + 'image': '🖼', + 'group': '+', + 'path': '~', + 'triangle': '^', + }; + + let html = ''; + for (const layer of layers) { + const isSelected = selectedLayerIds.has(layer.id); + const isBg = layer.isBackground; + + // Use a custom icon for the background layer. + const icon = isBg + ? `` + : (typeIcons[layer.fabricObj.type] || '□'); + + const eyeIcon = layer.visible + ? `` + : ``; + + // 背景图层锁定图标灰显 + const lockIconClass = isBg ? 'layer-item__lock--bg' : (layer.locked ? 'layer-item__lock--locked' : ''); + const lockIcon = layer.locked + ? `` + : ``; + const layerName = this._escapeHTML(layer.name); + const layerTitle = this._escapeAttr(layer.name); + + html += ` +
    • + ${eyeIcon} + ${icon} + ${layerName} + ${lockIcon} +
    • + `; + } + + if (layers.length === 0) { + html = '
      暂无图层
      '; + } + + listEl.innerHTML = html; + this._refreshing = false; + } + + _escapeHTML(value) { + return escapeHTML(value); + } + + _escapeAttr(value) { + return escapeAttr(value); + } + + destroy() { + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; + } +} + +export default LayerPanel; diff --git a/plugins/Image-Toolbox/core/src/ui/OptionsBar.js b/plugins/Image-Toolbox/core/src/ui/OptionsBar.js new file mode 100644 index 000000000..27d6394f1 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/ui/OptionsBar.js @@ -0,0 +1,182 @@ +import { eventBus } from '../index.js'; + +/** + * Top options bar UI component. + * Renders presets for the active tool. + */ +class OptionsBar { + constructor(containerEl, toolManager) { + this._el = containerEl; + this._tm = toolManager; + this._currentTool = null; + this._shapePickerEl = null; + this._shapePickerAnchor = null; + this._boundDocumentClick = this._handleDocumentClick.bind(this); + this._boundKeyDown = this._handleKeyDown.bind(this); + this._boundRepositionShapePicker = this._positionShapePicker.bind(this); + this._eventBusUnsubscribers = []; + + this._render(); + this._bindEvents(); + } + + _render() { + this._el.innerHTML = ` +
      + `; + } + + _bindEvents() { + // Update options when the active tool changes. + this._eventBusUnsubscribers.push( + eventBus.on('tool:changed', (toolName) => { + this._currentTool = toolName; + this._closeShapePicker(); + this._updateControls(); + }) + ); + + [ + 'canvas:selectionCreated', + 'canvas:selectionUpdated', + 'canvas:selectionCleared', + 'canvas:objectModified', + 'canvas:restored', + 'image:loaded', + 'tool:propertiesChanged', + ].forEach(eventName => { + this._eventBusUnsubscribers.push( + eventBus.on(eventName, () => { + if (this._currentTool) this._updateControls(); + }) + ); + }); + + // Only handle one-click presets here; detailed controls live in the property panel. + this._el.addEventListener('click', (e) => { + this._handleControlEvent(e); + }); + } + + _updateControls() { + this._closeShapePicker(); + + const controlsEl = this._el.querySelector('#optionsbar-controls'); + if (!controlsEl) return; + + const module = this._tm.getCurrentModule(); + if (module && typeof module.getOptionsBarHTML === 'function') { + controlsEl.innerHTML = module.getOptionsBarHTML(); + } else { + controlsEl.innerHTML = ''; + } + } + + _handleControlEvent(e) { + const pickerToggle = e.target.closest('[data-shape-picker-toggle]'); + if (pickerToggle) { + e.preventDefault(); + e.stopPropagation(); + const module = this._tm.getCurrentModule(); + if (module && typeof module.getShapePickerHTML === 'function') { + this._toggleShapePicker(pickerToggle, module); + } + return; + } + + const target = e.target.closest('[data-preset]'); + if (!target) return; + + const module = this._tm.getCurrentModule(); + if (!module) return; + + if (module.applyPreset) module.applyPreset(target.dataset.preset); + this._updateControls(); + eventBus.emit('tool:propertiesChanged'); + } + + _toggleShapePicker(anchor, module) { + if (this._shapePickerEl && this._shapePickerAnchor === anchor) { + this._closeShapePicker(); + return; + } + + this._closeShapePicker(); + + const pickerEl = document.createElement('div'); + pickerEl.className = 'shape-picker-popover'; + pickerEl.innerHTML = module.getShapePickerHTML(); + pickerEl.addEventListener('click', (e) => { + const target = e.target.closest('[data-preset]'); + if (!target) return; + + const currentModule = this._tm.getCurrentModule(); + if (currentModule && currentModule.applyPreset) currentModule.applyPreset(target.dataset.preset); + this._closeShapePicker(); + this._updateControls(); + eventBus.emit('tool:propertiesChanged'); + }); + + document.body.appendChild(pickerEl); + this._shapePickerEl = pickerEl; + this._shapePickerAnchor = anchor; + this._positionShapePicker(); + + setTimeout(() => document.addEventListener('click', this._boundDocumentClick), 0); + document.addEventListener('keydown', this._boundKeyDown); + window.addEventListener('resize', this._boundRepositionShapePicker); + window.addEventListener('scroll', this._boundRepositionShapePicker, true); + } + + _positionShapePicker() { + if (!this._shapePickerEl || !this._shapePickerAnchor) return; + + const anchorRect = this._shapePickerAnchor.getBoundingClientRect(); + const pickerRect = this._shapePickerEl.getBoundingClientRect(); + const margin = 8; + const maxLeft = Math.max(margin, window.innerWidth - pickerRect.width - margin); + const left = Math.min(Math.max(anchorRect.left, margin), maxLeft); + + let top = anchorRect.bottom + margin; + if (top + pickerRect.height > window.innerHeight - margin) { + top = anchorRect.top - pickerRect.height - margin; + } + top = Math.max(margin, top); + + this._shapePickerEl.style.left = `${left}px`; + this._shapePickerEl.style.top = `${top}px`; + } + + _handleDocumentClick(e) { + if (!this._shapePickerEl) return; + if (this._shapePickerEl.contains(e.target) || this._shapePickerAnchor?.contains(e.target)) return; + this._closeShapePicker(); + } + + _handleKeyDown(e) { + if (e.key === 'Escape') this._closeShapePicker(); + } + + _closeShapePicker() { + if (!this._shapePickerEl) return; + + this._shapePickerEl.remove(); + this._shapePickerEl = null; + this._shapePickerAnchor = null; + document.removeEventListener('click', this._boundDocumentClick); + document.removeEventListener('keydown', this._boundKeyDown); + window.removeEventListener('resize', this._boundRepositionShapePicker); + window.removeEventListener('scroll', this._boundRepositionShapePicker, true); + } + + /** + * Destroy the options bar. + */ + destroy() { + this._closeShapePicker(); + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; + } +} + +export default OptionsBar; diff --git a/plugins/Image-Toolbox/core/src/ui/PropertyPanel.js b/plugins/Image-Toolbox/core/src/ui/PropertyPanel.js new file mode 100644 index 000000000..c156eda8a --- /dev/null +++ b/plugins/Image-Toolbox/core/src/ui/PropertyPanel.js @@ -0,0 +1,692 @@ +import { eventBus } from '../index.js'; +import { getFontOptionsHTML, recordFontUsage, isSystemFontsLoaded, onSystemFontsLoaded } from '../utils/fonts.js'; +import { clamp, escapeHTML, escapeAttr } from '../utils/helpers.js'; + +/** + * Property panel UI component. + * Renders property editors for the selected layer type. + */ +class PropertyPanel { + constructor(containerEl, toolManager, canvasManager = null, layerManager = null) { + this._el = containerEl; + this._tm = toolManager; + this._cm = canvasManager || toolManager?._cm || null; + this._lm = layerManager; + this._eventBusUnsubscribers = []; + + this._bindEvents(); + this._render(); + } + + _render() { + this._el.innerHTML = ` +
      +
      + 属性 +
      +
      +
      选中物件以编辑属性
      +
      +
      + `; + } + + _bindEvents() { + // Update the property panel when selection changes. + this._eventBusUnsubscribers.push( + eventBus.on('canvas:selectionCreated', () => this._updateProperties()), + eventBus.on('canvas:selectionUpdated', () => this._updateProperties()), + eventBus.on('canvas:selectionCleared', () => this._updateProperties()), + eventBus.on('layer:selected', () => this._updateProperties()), + eventBus.on('layers:updated', () => this._updateProperties()), + eventBus.on('canvas:objectAdded', () => this._updateProperties()), + eventBus.on('canvas:objectRemoved', () => this._updateProperties()), + eventBus.on('canvas:restored', () => this._updateProperties()), + eventBus.on('image:loaded', () => this._clearProperties()), + eventBus.on('tool:changed', () => this._updateProperties()), + eventBus.on('crop:updated', () => this._updateProperties()), + eventBus.on('tool:propertiesChanged', () => this._updateProperties()), + eventBus.on('canvas:objectModified', () => this._updateProperties()) + ); + + // Delegate property input handling. + this._el.addEventListener('input', (e) => { + this._handleInput(e); + }); + this._el.addEventListener('change', (e) => { + this._handleInput(e); + }); + this._el.addEventListener('click', (e) => { + this._handleInput(e); + }); + } + + _updateProperties() { + const bodyEl = this._el.querySelector('#property-body'); + if (!bodyEl) return; + + const module = this._tm.getCurrentModule(); + const active = this._getActiveObject(); + + // 模块可声明 overridePropertyPanel 接管属性面板(即使有选中对象) + if (module?.overridePropertyPanel && typeof module.getPropertyPanelHTML === 'function') { + const html = module.getPropertyPanelHTML(); + if (html) { + bodyEl.innerHTML = html; + return; + } + } + + if (active?.excludeFromProperty && module && typeof module.getPropertyPanelHTML === 'function') { + const html = module.getPropertyPanelHTML(); + if (html) { + bodyEl.innerHTML = html; + return; + } + } + + if (active) { + bodyEl.innerHTML = this._getLayerPropertiesHTML(active); + return; + } + + if (!module) { + bodyEl.innerHTML = '
      选中物件以编辑属性
      '; + return; + } + + // Prefer the active module's custom property panel when available. + if (typeof module.getPropertyPanelHTML === 'function') { + const html = module.getPropertyPanelHTML(); + if (html) { + bodyEl.innerHTML = html; + return; + } + } + + bodyEl.innerHTML = '
      选中物件以编辑属性
      '; + } + + _getLayerPropertiesHTML(active) { + const meta = this._getLayerMeta(active); + const isText = this._isTextObject(active); + const isBackground = !!meta?.isBackground; + const locked = meta ? meta.locked : (active.selectable === false && active.evented === false); + // 背景图层的锁定只限制删除、改名和排序,仍允许显式编辑几何参数。 + const editDisabled = (locked && !isBackground) ? ' disabled' : ''; + const renameDisabled = (!meta || isBackground) ? ' disabled' : ''; + const lockDisabled = isBackground ? ' disabled' : ''; + const opacity = active.opacity == null ? 1 : active.opacity; + const typeLabel = this._getTypeLabel(active, meta); + const layerName = meta?.name || typeLabel; + const width = this._getScaledWidth(active); + const height = this._getScaledHeight(active); + + let html = ''; + + html += ` +
      + + +
      +
      + + ${this._escapeHTML(typeLabel)} +
      +
      + + +
      +
      + + +
      + `; + + // 位置 + html += ` +
      + + +
      +
      + + +
      + `; + + // 大小(如有) + if (active.width !== undefined) { + html += ` +
      + + +
      +
      + + +
      + `; + } + + // 旋转 + html += ` +
      + + +
      + `; + + // Opacity. + html += ` +
      + + + ${Math.round(opacity * 100)}% +
      + `; + + if (isText) { + html += ` +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      + `; + } else if (this._supportsPaint(active)) { + html += ` +
      + + +
      +
      + + + ${this._getColorOpacityPercent(active.fill)}% +
      +
      + + +
      +
      + + + ${this._getColorOpacityPercent(active.stroke)}% +
      +
      + + +
      + `; + } + + return html; + } + + _clearProperties() { + const bodyEl = this._el.querySelector('#property-body'); + if (bodyEl) { + bodyEl.innerHTML = '
      选中物件以编辑属性
      '; + } + } + + _handleInput(e) { + const target = e.target.closest('[data-prop], [data-module-prop], [data-module-action], [data-module-preset]'); + if (!target || !this._el.contains(target)) return; + + const prop = target.dataset.prop; + const moduleProp = target.dataset.moduleProp; + const moduleAction = target.dataset.moduleAction; + const modulePreset = target.dataset.modulePreset; + if (!prop && !moduleProp && !moduleAction && !modulePreset) return; + // 普通属性控件只在 input/change 事件中处理;click 事件仅放行模块动作/预设 + if (e.type === 'click' && !moduleAction && !modulePreset) return; + + const module = this._tm.getCurrentModule(); + if (moduleProp || moduleAction || modulePreset) { + this._handleModuleInput(e, module, moduleProp, moduleAction, modulePreset); + return; + } + + const active = this._getActiveObject(); + if (!active) return; + + let value = target.type === 'checkbox' ? target.checked : target.value; + let propertyValue = value; + + switch (prop) { + case 'layerName': + if (e.type === 'change') { + this._renameLayer(active, value); + } + return; + case 'visible': + if (e.type === 'change') { + this._setLayerVisibility(active, value); + this._notifyObjectChanged(active); + } + return; + case 'locked': + if (e.type === 'change') { + this._setLayerLock(active, value); + this._notifyObjectChanged(active); + } + return; + case 'left': + case 'top': + case 'angle': { + const parsed = parseFloat(value); + if (!Number.isFinite(parsed)) return; + active.set(prop, parsed); + propertyValue = parsed; + break; + } + case 'width': + this._setScaledDimension(active, 'width', value); + break; + case 'height': + this._setScaledDimension(active, 'height', value); + break; + case 'opacity': { + const percent = this._clamp(parseInt(value, 10), 0, 100); + propertyValue = percent / 100; + active.set('opacity', propertyValue); + if (target.nextElementSibling) { + target.nextElementSibling.textContent = percent + '%'; + } + break; + } + case 'fillOpacity': + case 'strokeOpacity': { + const percent = this._clamp(parseInt(value, 10), 0, 100); + const paintProp = prop === 'fillOpacity' ? 'fill' : 'stroke'; + propertyValue = percent / 100; + active.set(paintProp, this._withColorOpacity(active[paintProp], propertyValue)); + if (target.nextElementSibling) { + target.nextElementSibling.textContent = percent + '%'; + } + break; + } + case 'text': + active.set('text', value); + break; + case 'fontFamily': + active.set('fontFamily', value); + if (e.type === 'change') recordFontUsage(value); + break; + case 'fontSize': + case 'strokeWidth': { + const parsed = parseInt(value, 10); + if (!Number.isFinite(parsed)) return; + propertyValue = parsed; + active.set(prop, parsed); + break; + } + case 'fill': + case 'stroke': + active.set(prop, this._withColorOpacity(value, this._getColorOpacity(active[prop], 1))); + break; + case 'fontWeight': + propertyValue = value ? 'bold' : 'normal'; + active.set('fontWeight', propertyValue); + break; + case 'fontStyle': + propertyValue = value ? 'italic' : 'normal'; + active.set('fontStyle', propertyValue); + break; + case 'underline': + active.set('underline', value); + break; + case 'linethrough': + active.set('linethrough', value); + break; + case 'textAlign': + active.set('textAlign', value); + break; + case 'strokePosition': + active.set('paintFirst', value === 'inside' ? 'fill' : 'stroke'); + active.set('_strokePosition', value); + break; + default: + return; + } + + active.setCoords(); + this._requestRender(); + + // Notify the module so it can keep custom linked state in sync. + if (module?.onPropertyChange) { + module.onPropertyChange(prop, propertyValue, { eventType: e.type }); + } + + if (e.type === 'change' && !active.excludeFromHistory) { + this._notifyObjectChanged(active); + } + } + + _handleModuleInput(e, module, prop, action, preset) { + if (!module) return; + + const target = e.target; + if (preset) { + if (typeof module.applyPreset === 'function') { + module.applyPreset(preset); + this._updateProperties(); + eventBus.emit('tool:propertiesChanged'); + } + return; + } + + if (action) { + if (typeof module.onToolPropertyAction === 'function') { + module.onToolPropertyAction(action, { eventType: e.type }); + } + return; + } + + if (!prop || typeof module.onToolPropertyChange !== 'function') return; + + const value = target.type === 'checkbox' ? target.checked : target.value; + const handled = module.onToolPropertyChange(prop, value, { eventType: e.type }); + + if (target.type === 'range' && target.nextElementSibling) { + const suffix = target.dataset.valueSuffix != null ? target.dataset.valueSuffix : 'px'; + target.nextElementSibling.textContent = `${value}${suffix}`; + } + + if (handled !== false && target.dataset.refreshProperty === 'true') { + this._updateProperties(); + } + } + + _getActiveObject() { + return this._cm?.getActiveObject?.() || null; + } + + _getLayerMeta(obj) { + if (!obj || !this._lm) return null; + if (typeof this._lm.getLayerByObject === 'function') { + return this._lm.getLayerByObject(obj); + } + return typeof this._lm._findMeta === 'function' ? this._lm._findMeta(obj) : null; + } + + _renameLayer(active, name) { + const meta = this._getLayerMeta(active); + const nextName = String(name || '').trim(); + if (!meta || !nextName) return; + this._lm.renameLayer(meta.id, nextName); + } + + _setLayerVisibility(active, visible) { + const meta = this._getLayerMeta(active); + if (meta && typeof this._lm?.setVisibility === 'function') { + this._lm.setVisibility(meta.id, visible); + return; + } + + active.set('visible', visible); + this._requestRender(); + } + + _setLayerLock(active, locked) { + const meta = this._getLayerMeta(active); + if (meta && typeof this._lm?.setLock === 'function') { + this._lm.setLock(meta.id, locked); + return; + } + + active.set({ selectable: !locked, evented: !locked }); + if (locked && this._cm?.canvas?.getActiveObject() === active) { + this._cm.canvas.discardActiveObject(); + } + this._requestRender(); + } + + _setScaledDimension(active, prop, value) { + const parsed = parseFloat(value); + if (!Number.isFinite(parsed) || parsed <= 0) return; + + const base = prop === 'width' ? active.width : active.height; + if (!base) return; + + active.set(prop === 'width' ? 'scaleX' : 'scaleY', parsed / base); + } + + _notifyObjectChanged(active) { + eventBus.emit('canvas:objectModified', active); + } + + _requestRender() { + const canvas = this._cm?.canvas; + if (!canvas) return; + if (typeof canvas.requestRenderAll === 'function') { + canvas.requestRenderAll(); + } else { + canvas.renderAll(); + } + } + + _isTextObject(obj) { + return obj && (obj.type === 'i-text' || obj.type === 'text' || obj.type === 'textbox'); + } + + _supportsPaint(obj) { + return obj && obj.type !== 'image' && obj.type !== 'group' + && (obj.fill !== undefined || obj.stroke !== undefined || obj.strokeWidth !== undefined); + } + + _getScaledWidth(obj) { + if (typeof obj.getScaledWidth === 'function') return Math.abs(obj.getScaledWidth()); + return Math.abs((obj.width || 0) * (obj.scaleX || 1)); + } + + _getScaledHeight(obj) { + if (typeof obj.getScaledHeight === 'function') return Math.abs(obj.getScaledHeight()); + return Math.abs((obj.height || 0) * (obj.scaleY || 1)); + } + + _getTypeLabel(obj, meta) { + if (meta?.isBackground) return '背景图片'; + const labels = { + 'image': '图像', + 'i-text': '文字', + 'text': '文字', + 'textbox': '文字', + 'rect': '矩形', + 'circle': '圆形', + 'path': '路径', + 'triangle': '三角形', + 'group': '组合', + 'line': '线条', + }; + return labels[obj.type] || obj.type || '图层'; + } + + _getFontOptionsHTML(current) { + if (isSystemFontsLoaded()) { + return getFontOptionsHTML(current); + } + + // 异步加载字体,完成后更新属性面板 + onSystemFontsLoaded(() => { + this._updateProperties(); + }); + + return ''; + } + + _getSelectOption(value, label, current) { + const selected = this._normalizeValue(value) === this._normalizeValue(current) ? ' selected' : ''; + return ``; + } + + _normalizeValue(value) { + return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase(); + } + + _toColorValue(value, fallback = '#000000') { + if (typeof value !== 'string') return fallback; + const color = value.trim(); + const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (hex) { + if (color.length === 4) { + return '#' + color.slice(1).split('').map(ch => ch + ch).join(''); + } + return color; + } + + const rgb = color.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); + if (rgb) { + return '#' + [rgb[1], rgb[2], rgb[3]] + .map(v => this._clamp(parseInt(v, 10), 0, 255).toString(16).padStart(2, '0')) + .join(''); + } + + return fallback; + } + + _getColorOpacityPercent(color) { + return Math.round(this._getColorOpacity(color, 0) * 100); + } + + _getColorOpacity(color, fallback = 1) { + const value = String(color ?? '').trim().toLowerCase(); + if (!value) return fallback; + if (value === 'transparent') return 0; + + const rgba = value.match(/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([\d.]+)\s*\)$/i); + if (rgba) { + const alpha = parseFloat(rgba[1]); + return this._clamp(Number.isFinite(alpha) ? alpha : fallback, 0, 1); + } + + return 1; + } + + _withColorOpacity(color, opacity) { + const alpha = this._clamp(Number.isFinite(opacity) ? opacity : 1, 0, 1); + const rgb = this._extractRgb(color); + + if (!rgb) return color; + if (alpha === 0 && String(color ?? '').trim().toLowerCase() === 'transparent') return 'transparent'; + + return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${this._formatAlpha(alpha)})`; + } + + _extractRgb(color) { + const value = String(color ?? '').trim().toLowerCase(); + if (!value || value === 'transparent') return { r: 0, g: 0, b: 0 }; + + if (/^#[0-9a-f]{6}$/i.test(value)) { + return { + r: parseInt(value.slice(1, 3), 16), + g: parseInt(value.slice(3, 5), 16), + b: parseInt(value.slice(5, 7), 16), + }; + } + + if (/^#[0-9a-f]{3}$/i.test(value)) { + return { + r: parseInt(value[1] + value[1], 16), + g: parseInt(value[2] + value[2], 16), + b: parseInt(value[3] + value[3], 16), + }; + } + + const rgb = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); + if (rgb) { + return { + r: this._clamp(parseInt(rgb[1], 10), 0, 255), + g: this._clamp(parseInt(rgb[2], 10), 0, 255), + b: this._clamp(parseInt(rgb[3], 10), 0, 255), + }; + } + + return null; + } + + _formatAlpha(alpha) { + return String(Math.round(alpha * 100) / 100); + } + + _formatNumber(value) { + const number = parseFloat(value); + if (!Number.isFinite(number)) return 0; + return Math.round(number * 100) / 100; + } + + _clamp(value, min, max) { + if (!Number.isFinite(value)) return min; + return clamp(value, min, max); + } + + _escapeHTML(value) { + return escapeHTML(value); + } + + _escapeAttr(value) { + return escapeAttr(value); + } + + destroy() { + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; + } +} + +export default PropertyPanel; diff --git a/plugins/Image-Toolbox/core/src/ui/SidePanelTabs.js b/plugins/Image-Toolbox/core/src/ui/SidePanelTabs.js new file mode 100644 index 000000000..614b2b9c6 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/ui/SidePanelTabs.js @@ -0,0 +1,148 @@ +import { eventBus } from '../index.js'; + +const SIDE_PANEL_TAB_KEY = 'image-toolbox-side-panel-tab'; +export const SIDE_PANEL_LAYOUT_KEY = 'image-toolbox-side-panel-layout'; +export const SIDE_PANEL_LAYOUTS = { + TABS: 'tabs', + SPLIT: 'split', +}; + +const VALID_TABS = new Set(['property', 'layer']); +const VALID_LAYOUTS = new Set(Object.values(SIDE_PANEL_LAYOUTS)); + +/** + * Side panel container. + * Supports tab and split layouts, with remembered user preference. + */ +class SidePanelTabs { + constructor(containerEl, layerManager) { + this._el = containerEl; + this._lm = layerManager; + this._activeTab = this._getInitialTab(); + this._layout = this._getInitialLayout(); + this._eventBusUnsubscribers = []; + + this._render(); + this._bindEvents(); + this._applyLayout(this._layout, false); + this._activateTab(this._activeTab, false); + this._updateLayerCount(); + } + + _render() { + this._el.innerHTML = ` +
      +
      + + +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      + `; + } + + _bindEvents() { + this._el.addEventListener('click', (e) => { + const tabBtn = e.target.closest('[data-panel-tab]'); + if (!tabBtn) return; + + this._activateTab(tabBtn.dataset.panelTab, true); + }); + + this._eventBusUnsubscribers.push( + eventBus.on('layers:updated', () => this._updateLayerCount()), + eventBus.on('image:loaded', () => this._updateLayerCount()), + eventBus.on('canvas:restored', () => this._updateLayerCount()) + ); + } + + applyLayout(layout, persist = true) { + this._applyLayout(layout, persist); + } + + _activateTab(tabName, persist = true) { + if (!VALID_TABS.has(tabName)) return; + + this._activeTab = tabName; + this._syncTabs(); + this._syncPanes(); + + if (persist) { + localStorage.setItem(SIDE_PANEL_TAB_KEY, tabName); + eventBus.emit('sidePanel:tabChanged', tabName); + } + } + + _applyLayout(layout, persist = true) { + if (!VALID_LAYOUTS.has(layout)) return; + + this._layout = layout; + const shell = this._el.querySelector('.side-tabs'); + if (shell) { + shell.classList.toggle('side-tabs--tabs', layout === SIDE_PANEL_LAYOUTS.TABS); + shell.classList.toggle('side-tabs--split', layout === SIDE_PANEL_LAYOUTS.SPLIT); + } + + this._syncPanes(); + + if (persist) { + localStorage.setItem(SIDE_PANEL_LAYOUT_KEY, layout); + eventBus.emit('sidePanel:layoutChanged', layout); + } + } + + _syncTabs() { + this._el.querySelectorAll('[data-panel-tab]').forEach(btn => { + const active = btn.dataset.panelTab === this._activeTab; + btn.classList.toggle('side-tabs__tab--active', active); + btn.setAttribute('aria-selected', active ? 'true' : 'false'); + btn.setAttribute('tabindex', active ? '0' : '-1'); + }); + } + + _syncPanes() { + const isSplit = this._layout === SIDE_PANEL_LAYOUTS.SPLIT; + this._el.querySelectorAll('[data-panel-pane]').forEach(pane => { + const active = isSplit || pane.dataset.panelPane === this._activeTab; + pane.classList.toggle('side-tabs__pane--active', active); + pane.hidden = !active; + pane.setAttribute('aria-hidden', active ? 'false' : 'true'); + }); + } + + _updateLayerCount() { + const countEl = this._el.querySelector('#side-layer-count'); + if (!countEl) return; + + const count = this._lm?.getLayers?.().length || 0; + countEl.textContent = count; + } + + _getInitialTab() { + const saved = localStorage.getItem(SIDE_PANEL_TAB_KEY); + return VALID_TABS.has(saved) ? saved : 'property'; + } + + _getInitialLayout() { + const saved = localStorage.getItem(SIDE_PANEL_LAYOUT_KEY); + return VALID_LAYOUTS.has(saved) ? saved : SIDE_PANEL_LAYOUTS.TABS; + } + + destroy() { + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; + } +} + +export default SidePanelTabs; diff --git a/plugins/Image-Toolbox/core/src/ui/StatusBar.js b/plugins/Image-Toolbox/core/src/ui/StatusBar.js new file mode 100644 index 000000000..7d135a898 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/ui/StatusBar.js @@ -0,0 +1,86 @@ +import { eventBus } from '../index.js'; + +/** + * Status bar UI component. + * Shows image size, layer count, engine version, and export shortcuts. + */ +class StatusBar { + constructor(containerEl, canvasManager, layerManager) { + this._el = containerEl; + this._cm = canvasManager; + this._lm = layerManager; + this._eventBusUnsubscribers = []; + + this._bindEvents(); + this._render(); + } + + _render() { + this._el.innerHTML = ` +
      + -- × -- + + 图层: 0 + + Fabric.js 5.x +
      +
      + + +
      + `; + } + + _bindEvents() { + // Update size after image load. + this._eventBusUnsubscribers.push( + eventBus.on('image:loaded', (img) => { + this._updateSize(img); + }) + ); + + // Update size after canvas changes. + this._eventBusUnsubscribers.push( + eventBus.on('canvas:objectModified', () => { + if (this._cm.originalImage) { + this._updateSize(this._cm.originalImage); + } + }) + ); + + // Update layer count when layers change. + this._eventBusUnsubscribers.push( + eventBus.on('layers:updated', (layers) => { + const countEl = this._el.querySelector('#status-layers'); + if (countEl) { + countEl.textContent = `图层: ${layers ? layers.length : this._lm.getCount()}`; + } + }) + ); + + // 导出按钮 + this._el.addEventListener('click', (e) => { + if (e.target.id === 'status-save-file') { + eventBus.emit('export:requested', 'file'); + } else if (e.target.id === 'status-clipboard') { + eventBus.emit('export:requested', 'clipboard'); + } + }); + } + + _updateSize(img) { + const sizeEl = this._el.querySelector('#status-size'); + if (sizeEl && img) { + const w = Math.round(img.width * img.scaleX); + const h = Math.round(img.height * img.scaleY); + sizeEl.textContent = `${w} × ${h} px`; + } + } + + destroy() { + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; + } +} + +export default StatusBar; diff --git a/plugins/Image-Toolbox/core/src/ui/Toolbar.js b/plugins/Image-Toolbox/core/src/ui/Toolbar.js new file mode 100644 index 000000000..bd3320784 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/ui/Toolbar.js @@ -0,0 +1,240 @@ +import { eventBus } from '../index.js'; +import { escapeHTML, escapeAttr } from '../utils/helpers.js'; +import { TOOLBAR_LABELS_VISIBLE_KEY, TOOLBAR_LABELS_VISIBLE } from './AccountPage.js'; + +/** + * Toolbar UI component. + * Renders tool buttons and handles tool switching. + */ +class Toolbar { + constructor(containerEl, toolManager, host = null) { + this._el = containerEl; + this._tm = toolManager; + this._host = host; + this._currentTool = 'select'; + this._user = this._getHostUser(); + this._eventBusUnsubscribers = []; + + // SVG 图标模板 + this._icons = { + select: ``, + mosaic: ``, + crop: ``, + color: ``, + brush: ``, + eraser: ``, + text: ``, + shape: ``, + undo: ``, + redo: ``, + }; + + this._render(); + this._bindEvents(); + this._applyLabelsVisibility(); + } + + _render() { + const tools = this._tm.getTools(); + let toolsHtml = ''; + + let currentGroup = null; + + // Render tool groups in a stable order. + const groupOrder = ['edit', 'adjust', 'redact', 'annotate', 'view', 'action']; + for (const group of groupOrder) { + const groupTools = tools.filter(t => t.group === group); + if (groupTools.length === 0) continue; + + if (currentGroup !== null) { + toolsHtml += '
      '; + } + currentGroup = group; + + for (const tool of groupTools) { + // 导出按钮放在底部 + if (tool.group === 'action') continue; + + const icon = this._icons[tool.icon] || ''; + const isActive = this._currentTool === tool.name; + toolsHtml += ` + + `; + } + } + + // 撤销 / 重做 + const footerHtml = ` +
      + + + ${this._renderAccount()} + `; + + this._el.innerHTML = ` +
      ${toolsHtml}
      + + `; + } + + _bindEvents() { + this._el.addEventListener('click', (e) => { + const account = e.target.closest('.toolbar__account'); + if (account) { + eventBus.emit('account:open'); + return; + } + + const btn = e.target.closest('.toolbar__btn'); + if (!btn) return; + + const toolName = btn.dataset.tool; + + if (toolName === 'undo') { + eventBus.emit('history:undo'); + return; + } + if (toolName === 'redo') { + eventBus.emit('history:redo'); + return; + } + + // 切换工具 + this._currentTool = toolName; + this._updateActive(); + this._tm.activateTool(toolName); + }); + + this._el.addEventListener('error', (e) => { + const avatar = e.target.closest?.('.toolbar__avatar-img'); + if (!avatar) return; + const fallback = document.createElement('div'); + fallback.className = 'toolbar__avatar toolbar__avatar--fallback'; + fallback.textContent = avatar.dataset.initial || 'U'; + avatar.replaceWith(fallback); + }, true); + + // 监听工具切换事件(外部触发) + this._eventBusUnsubscribers.push( + eventBus.on('tool:changed', (toolName) => { + this._currentTool = toolName; + this._updateActive(); + }), + eventBus.on('history:changed', ({ canUndo, canRedo }) => { + this._updateHistoryButtons(canUndo, canRedo); + }), + eventBus.on('toolbar:labelsVisibilityChanged', (value) => { + this._applyLabelsVisibility(value); + }) + ); + } + + _getLabelsVisible() { + const saved = localStorage.getItem(TOOLBAR_LABELS_VISIBLE_KEY); + return saved === TOOLBAR_LABELS_VISIBLE.OFF ? TOOLBAR_LABELS_VISIBLE.OFF : TOOLBAR_LABELS_VISIBLE.ON; + } + + _applyLabelsVisibility(value) { + const resolved = value || this._getLabelsVisible(); + if (!this._el) return; + this._el.classList.toggle('toolbar--labels-hidden', resolved === TOOLBAR_LABELS_VISIBLE.OFF); + } + + _updateHistoryButtons(canUndo, canRedo) { + const undoBtn = this._el.querySelector('[data-tool="undo"]'); + const redoBtn = this._el.querySelector('[data-tool="redo"]'); + if (undoBtn) { + undoBtn.disabled = !canUndo; + undoBtn.style.opacity = canUndo ? '' : '0.4'; + } + if (redoBtn) { + redoBtn.disabled = !canRedo; + redoBtn.style.opacity = canRedo ? '' : '0.4'; + } + } + + _updateActive() { + const btns = this._el.querySelectorAll('.toolbar__btn'); + btns.forEach(btn => { + const tool = btn.dataset.tool; + if (tool === this._currentTool) { + btn.classList.add('toolbar__btn--active'); + } else { + btn.classList.remove('toolbar__btn--active'); + } + }); + } + + _renderAccount() { + const user = this._user || {}; + const name = user.nickname || user.name || user.userName || user.username || `${this._getHostName()} 用户`; + const avatar = user.avatar || user.avatarUrl || user.photo || ''; + const initial = this._getInitial(name); + const title = this._escapeAttr(name); + + if (avatar) { + return ` + + `; + } + + return ` + + `; + } + + _getHostUser() { + try { + const result = this._host?.user?.getCurrentUser?.() || this._host?.getHostUser?.() || null; + if (result && typeof result.then === 'function') { + result.then((user) => { + this._user = user; + this._render(); + }).catch((e) => console.warn('[Toolbar] 获取宿主用户信息失败:', e)); + return null; + } + return result; + } catch (e) { + console.warn('[Toolbar] 获取宿主用户信息失败:', e); + } + return null; + } + + _getHostName() { + return this._host?.platform?.name || this._host?.getHostName?.() || 'uTools'; + } + + _getInitial(name) { + const text = String(name || '').trim(); + return text ? text.slice(0, 1).toUpperCase() : 'U'; + } + + _escapeAttr(value) { + return escapeAttr(value); + } + + _escapeHTML(value) { + return escapeHTML(value); + } + + destroy() { + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; + } +} + +export default Toolbar; diff --git a/plugins/Image-Toolbox/core/src/updateRecords.js b/plugins/Image-Toolbox/core/src/updateRecords.js new file mode 100644 index 000000000..0f43c518a --- /dev/null +++ b/plugins/Image-Toolbox/core/src/updateRecords.js @@ -0,0 +1,218 @@ +export const updateRecords = [ + { + version: '2.3.1', + date: '2026-07-17', + changes: { + added: [ + { text: '设置页新增「侧栏图标文字」选项,可控制侧栏工具图标下方文字标签的展示', platforms: null } + ], + fixed: [ + { text: '修复调色面板长时间使用后内存占用持续上升的问题', platforms: null }, + { text: '修复平行四边形绘制时实际宽度超出拖拽框选范围的问题', platforms: null }, + { text: '修复调色面板滑块调整后无法撤销的问题', platforms: null }, + { text: '修复 ZTools 平台错误使用 uTools 宿主适配器导致的功能异常', platforms: ['ztools'] } + ], + improved: [ + { text: '优化了展示效果', platforms: null } + ], + adjusted: [ + { text: '修改了部分文字工具预设按钮名称并调大字体', platforms: null } + ], + removed: [] + } + }, + { + version: '2.3', + date: '2026-07-09', + changes: { + added: [ + { text: '新增「调色」工具,支持滤镜预设以及亮度、对比度、饱和度、色相、模糊等参数调整', platforms: null } + ], + fixed: [ + { text: '修复图形工具拖选绘制时部分情况下意外移动图层的问题', platforms: null }, + { text: '修复添加文字后第一次撤销无反应的问题', platforms: null }, + { text: '修复撤销/重做时偶尔出现画布状态异常的问题', platforms: null }, + { text: '修复切换或停用工具后,已锁定图层可能被意外解锁的问题', platforms: null }, + { text: '修复使用画笔、图形、马赛克等工具新建图层后,切回移动/框选工具无法直接选中图层的问题', platforms: null }, + { text: '修复移动/框选工具选中背景图片后,无法调整宽高、位置、旋转或拖拽变换的问题', platforms: null }, + { text: '修复使用文字工具编辑文字时,按快捷键会意外切换工具的问题', platforms: null } + ], + improved: [], + adjusted: [ + ], + removed: [] + } + }, + { + version: '2.2.2', + date: '2026-06-29', + changes: { + added: [ + { text: '图形工具新增平行四边形图形', platforms: null }, + { text: '文字属性新增删除线复选框', platforms: null } + ], + fixed: [ + { text: '修复部分编辑操作第一次撤销无反应的问题', platforms: null }, + { text: '修复打开新图片后撤销可能恢复上一张图片的问题', platforms: null }, + { text: '修复选择 JPEG/WebP 保存时实际仍写入 PNG 数据的问题', platforms: null }, + { text: '修复 ZTools 端选中文字后缺少描边位置设置的问题', platforms: ['ztools'] }, + { text: '修复裁剪撤销/重做时裁剪范围恢复不正确的问题', platforms: null }, + { text: '修复清除所有马赛克后无法撤销恢复的问题', platforms: null } + ], + improved: [], + adjusted: [], + removed: [] + } + }, + { + version: '2.2.1', + date: '2026-06-26', + changes: { + added: [ + { text: '图形工具新增三角形和双箭头图形', platforms: null }, + { text: '文字描边新增位置参数,支持外部和内部两种描边位置', platforms: null } + ], + fixed: [ + { text: '修复橡皮擦工具激活时切换图层后,橡皮擦仍作用在原图层的问题', platforms: null }, + { text: '修复首次进入文字工具时界面卡顿数秒的问题,改为异步加载系统字体列表', platforms: null }, + { text: '修复撤销/重做时历史记录损坏导致画布状态异常的问题', platforms: null }, + { text: '修复切换/停用工具时图层被意外解锁的问题', platforms: null } + ], + improved: [], + adjusted: [], + removed: [] + } + }, + { + version: '2.2', + date: '2026-06-23', + changes: { + added: [ + { text: '新增 ZTools 客户端,可按 ZTools 插件规范独立加载图片工具箱', platforms: ['ztools'] }, + { text: '新增图形工具,支持绘制矩形、圆形、星星、心形、梯形、直线、箭头等多种图形', platforms: null }, + { text: '图形工具支持自定义填充色、边框色、边框宽度,拖拽绘制即可创建图形', platforms: null }, + { text: '图形工具组新增属性面板入口,支持在预设栏和属性面板同步切换图形', platforms: null }, + { text: '图形属性栏支持分别设置填充不透明度和描边不透明度', platforms: null } + ], + fixed: [ + { text: '修复马赛克图层旋转后马赛克范围不准确的问题', platforms: null }, + { text: '修复马赛克图层拉伸后马赛克块大小被错误拉伸或压缩的问题', platforms: null }, + { text: '修复星形、心形、梯形、箭头等图形绘制偏移、畸形或显示不准确的问题', platforms: null }, + { text: '修复直线和箭头在水平或垂直拖拽时可能无法创建的问题', platforms: null } + ], + improved: [ + { text: '优化图形工具组选型窗口,改用 SVG 缩略图显示真实图形效果', platforms: null }, + { text: '优化图形预设栏颜色,合并填充色和边框色为一套样式预设并改为上图案下文字布局', platforms: null }, + { text: '图形颜色预设会同时应用填充不透明度和描边不透明度', platforms: null }, + { text: '优化橡皮擦工具,拖动过程中实时显示擦除效果', platforms: null } + ], + adjusted: [ + { text: '图形工具将仅描边预设调整为首位并作为默认样式', platforms: null } + ], + removed: [] + } + }, + { + version: '2.1.1', + date: '2026-06-15', + changes: { + added: [ + { text: '马赛克工具新增自由选区模式,支持非矩形区域马赛克/模糊', platforms: null } + ], + fixed: [ + { text: '修复橡皮擦擦除画笔图层后,图层名称被错误重置为马赛克的问题', platforms: null } + ], + improved: [ + { text: '优化马赛克画笔模式,鼠标悬停/涂抹时显示画笔位置,涂抹过程中实时显示马赛克效果', platforms: null }, + { text: '优化图层默认名称展示,文字/画笔/马赛克图层会按内容和预设生成更清晰的名称', platforms: null } + ], + adjusted: [ + { text: '马赛克图层改为动态重算,移动图层时会根据新的下方内容重新生成效果', platforms: null }, + { text: '马赛克工具默认预设调整为矩形 + 中马赛克', platforms: null } + ], + removed: [] + } + }, + { + version: '2.1', + date: '2026-06-13', + changes: { + added: [ + { text: '新增画笔工具,支持自由涂鸦、颜色预设和粗细调整', platforms: null }, + { text: '新增橡皮擦工具,支持大小预设和撤销/重做', platforms: null }, + { text: '新增非矩形裁剪工具', platforms: null }, + { text: '文字字体列表支持读取并显示用户系统中已安装的字体', platforms: null }, + { text: '移动/框选预设栏新增旋转0°/90/180/270与左右/前后翻转快捷操作', platforms: null } + ], + fixed: [], + improved: [ + { text: '优化了裁剪工具的使用体验', platforms: null }, + { text: '字体列表按用户实际使用频率自动排序', platforms: null } + ], + adjusted: [], + removed: [] + } + }, + { + version: '2.0', + date: '2026-06-12', + changes: { + added: [ + { text: '右侧面板新增切换状态,可切换tab布局或上下布局', platforms: null }, + { text: '新增"预设栏"和"状态栏"位置切换功能', platforms: null }, + { text: '新增属性/图层面板位置切换,可将侧栏移到左侧', platforms: null } + ], + fixed: [ + { text: '修复首次裁剪后再次剪切时,裁剪框被上一轮裁剪范围裁掉的问题', platforms: null }, + { text: '修复第二次裁剪被错误重置为原图范围、未基于首次裁剪继续裁剪的问题', platforms: null }, + { text: '修复旋转裁剪框后应用剪切仍按未旋转矩形生效的问题', platforms: null }, + { text: '修复裁剪后马赛克拖选框被错误裁掉、不能显示到图像外的问题', platforms: null } + ], + improved: [], + adjusted: [ + { text: '将深色浅色切换调整到了"设置"页面', platforms: null }, + { text: '将"选项栏"与"属性栏"合并', platforms: null }, + { text: '原"选项栏"调整为"预设栏"', platforms: null } + ], + removed: [ + { text: '移除了"剪切"工具属性的异常参数', platforms: null } + ] + } + }, + { + version: '1.0', + date: '2026-06-10', + changes: { + added: [ + { text: '发布了第一个可用版本,支持图片导入、马赛克、裁切、文字标注和导出', platforms: null }, + { text: '搭建五区编辑器布局:工具栏、选项栏、画布区、属性/图层面板和状态栏', platforms: null } + ], + fixed: [], + improved: [], + adjusted: [], + removed: [] + } + } +]; + +export const updateCategories = [ + { key: 'added', title: '新增' }, + { key: 'fixed', title: '修复' }, + { key: 'improved', title: '优化' }, + { key: 'adjusted', title: '调整' }, + { key: 'removed', title: '去除' } +]; + +/** + * 平台常量 + * null: 所有平台通用 + * ['utools']: 仅 uTools + * ['ztools']: 仅 ZTools + * ['utools', 'ztools']: uTools 和 ZTools + */ +export const PLATFORMS = { + ALL: null, // 所有平台 + UTOOLS: 'utools', // uTools 专用 + ZTOOLS: 'ztools', // ZTools 专用 + LOCAL: 'local', // 本地环境 +}; diff --git a/plugins/Image-Toolbox/core/src/utils/constants.js b/plugins/Image-Toolbox/core/src/utils/constants.js new file mode 100644 index 000000000..17a502df7 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/utils/constants.js @@ -0,0 +1,111 @@ +/** + * 常量定义 + */ + +// 画布默认配置 +export const CANVAS_DEFAULTS = { + WIDTH: 800, + HEIGHT: 600, + BACKGROUND_COLOR: '#d2d6d9', + PRESERVE_OBJECT_STACKING: true, + SELECTION: true, + STOP_CONTEXT_MENU: true, + FIRE_RIGHT_CLICK: true, +}; + +// 马赛克默认值 +export const MOSAIC_DEFAULTS = { + MODE: 'mosaic', + DRAW_MODE: 'rect', + MOSAIC_SIZE: 12, + BLUR_RADIUS: 8, + BRUSH_SIZE: 20, + MIN_MOSAIC_SIZE: 2, + MAX_MOSAIC_SIZE: 50, + MIN_BLUR_RADIUS: 1, + MAX_BLUR_RADIUS: 30, + MIN_BRUSH_SIZE: 4, + MAX_BRUSH_SIZE: 100, +}; + +// 文字默认样式 +export const TEXT_DEFAULTS = { + FONT_FAMILY: 'Microsoft YaHei, PingFang SC, sans-serif', + FONT_SIZE: 24, + FILL: '#d83b31', + STROKE: null, + STROKE_WIDTH: 0, + FONT_WEIGHT: 'normal', + FONT_STYLE: 'normal', + TEXT_ALIGN: 'left', +}; + +// 历史记录 +export const HISTORY_MAX_STEPS = 30; + +// 缩放范围 +export const ZOOM = { + MIN: 0.1, + MAX: 5.0, + STEP: 0.1, + DEFAULT: 1, +}; + +// 裁剪 +export const CROP_DEFAULTS = { + ASPECT_RATIO: null, + SHAPE: 'rect', +}; + +// 裁剪比例预设 +export const CROP_RATIOS = [ + { id: 'crop-ratio-free', w: null, h: null, label: '自由比例' }, + { id: 'crop-ratio-1-1', w: 1, h: 1, label: '1:1' }, + { id: 'crop-ratio-3-2', w: 3, h: 2, label: '3:2' }, + { id: 'crop-ratio-2-3', w: 2, h: 3, label: '2:3' }, + { id: 'crop-ratio-3-4', w: 3, h: 4, label: '3:4' }, + { id: 'crop-ratio-4-3', w: 4, h: 3, label: '4:3' }, + { id: 'crop-ratio-16-9', w: 16, h: 9, label: '16:9' }, + { id: 'crop-ratio-9-16', w: 9, h: 16, label: '9:16' }, +]; + +// 文字预设样式 +export const TEXT_PRESETS = { + red: { + fill: '#d83b31', + fontWeight: 'bold', + stroke: '#FFFFFF', + strokeWidth: 2, + }, + white: { + fill: '#FFFFFF', + fontWeight: 'normal', + stroke: null, + strokeWidth: 0, + backgroundColor: 'rgba(0,0,0,0.5)', + }, + yellow: { + fill: '#FFD700', + fontWeight: 'bold', + stroke: '#000000', + strokeWidth: 2, + }, +}; + +// 支持的图片格式 +export const SUPPORTED_FORMATS = ['png', 'jpg', 'jpeg', 'webp', 'bmp', 'gif', 'svg']; + +// 导出格式 +export const EXPORT_FORMATS = { + png: { label: 'PNG', mime: 'image/png', extension: 'png' }, + jpeg: { label: 'JPEG', mime: 'image/jpeg', extension: 'jpg', quality: true }, + webp: { label: 'WebP', mime: 'image/webp', extension: 'webp', quality: true }, +}; + +// 工具分组 +export const TOOL_GROUPS = { + edit: '编辑工具', + annotate: '标注工具', + view: '视图工具', + action: '操作', +}; diff --git a/plugins/Image-Toolbox/core/src/utils/dynamicMosaic.js b/plugins/Image-Toolbox/core/src/utils/dynamicMosaic.js new file mode 100644 index 000000000..1fb44b25a --- /dev/null +++ b/plugins/Image-Toolbox/core/src/utils/dynamicMosaic.js @@ -0,0 +1,355 @@ +export const DYNAMIC_MOSAIC_PROPS = [ + 'dynamicMosaic', + 'mosaicVersion', + 'mosaicEffect', + 'mosaicSize', + 'mosaicBlurRadius', + 'mosaicMaskType', + 'mosaicBrushPoints', + 'mosaicBrushSize', +]; + +const IDENTITY_VIEWPORT = [1, 0, 0, 1, 0, 0]; + +export function isDynamicMosaicObject(obj) { + return !!obj?.dynamicMosaic; +} + +export function createDynamicMosaicObject(rect, options = {}, maskOptions = {}) { + const width = Math.max(1, Math.round(rect.width)); + const height = Math.max(1, Math.round(rect.height)); + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = width; + tempCanvas.height = height; + + const img = new fabric.Image(tempCanvas, { + left: Math.round(rect.left), + top: Math.round(rect.top), + selectable: false, + evented: false, + objectCaching: false, + id: 'mosaic_' + Date.now(), + }); + + img.set({ + dynamicMosaic: true, + mosaicVersion: 1, + mosaicEffect: options.mode === 'blur' ? 'blur' : 'mosaic', + mosaicSize: Math.max(2, parseInt(options.mosaicSize, 10) || 12), + mosaicBlurRadius: Math.max(1, parseInt(options.blurRadius, 10) || 8), + mosaicMaskType: maskOptions.type || 'rect', + mosaicBrushPoints: maskOptions.brushPoints || null, + mosaicBrushSize: maskOptions.brushSize || null, + }); + + img._dynamicMosaicCanvas = tempCanvas; + return img; +} + +export function hydrateDynamicMosaicObjects(canvas) { + if (!canvas) return; + canvas.getObjects().forEach(obj => { + if (!isDynamicMosaicObject(obj)) return; + ensureDynamicMosaicCanvas(obj); + obj.set({ objectCaching: false }); + obj.dirty = true; + }); +} + +export function queueDynamicMosaicUpdate(canvas, obj) { + if (!canvas || !obj) return; + + const targets = getDynamicTargets(canvas, obj); + if (targets.length === 0) return; + + const schedule = typeof requestAnimationFrame === 'function' + ? requestAnimationFrame + : (fn) => setTimeout(fn, 16); + + targets.forEach(target => { + if (target._dynamicMosaicFrame) return; + target._dynamicMosaicFrame = schedule(() => { + target._dynamicMosaicFrame = null; + updateDynamicMosaicObject(canvas, target, { render: true }); + }); + }); +} + +export function updateDynamicMosaics(canvas, options = {}) { + if (!canvas) return; + + const objects = canvas.getObjects(); + const fromIndex = Number.isFinite(options.fromIndex) ? options.fromIndex : 0; + let changed = false; + + objects.forEach((obj, index) => { + if (index < fromIndex || !isDynamicMosaicObject(obj)) return; + changed = updateDynamicMosaicObject(canvas, obj, { render: false }) || changed; + }); + + if (changed && options.render !== false) { + requestCanvasRender(canvas); + } +} + +export function updateDynamicMosaicObject(canvas, obj, options = {}) { + if (!canvas || !isDynamicMosaicObject(obj) || obj._dynamicMosaicUpdating) return false; + + const width = Math.max(1, Math.round(obj.width || obj._element?.width || 1)); + const height = Math.max(1, Math.round(obj.height || obj._element?.height || 1)); + const tempCanvas = ensureDynamicMosaicCanvas(obj, width, height); + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + const sample = captureUnderlyingPixels(canvas, obj, width, height); + + if (!sample) return false; + + const maskData = createMaskData(obj, width, height); + const effectData = new Uint8ClampedArray(sample.data.data); + + if (obj.mosaicEffect === 'blur') { + blurPixels(effectData, width, height, obj.mosaicBlurRadius || 8, maskData); + } else { + mosaicPixels(effectData, width, height, obj.mosaicSize || 12, maskData); + } + + const output = sample.data; + const src = output.data; + for (let i = 0; i < src.length; i += 4) { + if (!maskData || maskData[i + 3] > 0) { + src[i] = effectData[i]; + src[i + 1] = effectData[i + 1]; + src[i + 2] = effectData[i + 2]; + src[i + 3] = effectData[i + 3]; + } else { + src[i] = 0; + src[i + 1] = 0; + src[i + 2] = 0; + src[i + 3] = 0; + } + } + + tempCtx.clearRect(0, 0, width, height); + tempCtx.putImageData(output, 0, 0); + setImageElement(obj, tempCanvas); + obj.dirty = true; + + if (options.render !== false) { + requestCanvasRender(canvas); + } + + return true; +} + +function getDynamicTargets(canvas, obj) { + const explicitTargets = obj.type === 'activeSelection' && typeof obj.getObjects === 'function' + ? obj.getObjects().filter(isDynamicMosaicObject) + : (isDynamicMosaicObject(obj) ? [obj] : []); + + if (explicitTargets.length === 0) return []; + + const objects = canvas.getObjects(); + const firstIndex = explicitTargets.reduce((min, target) => { + const index = objects.indexOf(target); + return index >= 0 ? Math.min(min, index) : min; + }, objects.length); + + return objects.filter((candidate, index) => index >= firstIndex && isDynamicMosaicObject(candidate)); +} + +function ensureDynamicMosaicCanvas(obj, width, height) { + const targetWidth = Math.max(1, Math.round(width || obj.width || obj._element?.width || 1)); + const targetHeight = Math.max(1, Math.round(height || obj.height || obj._element?.height || 1)); + let tempCanvas = obj._dynamicMosaicCanvas; + + if (!tempCanvas) { + tempCanvas = document.createElement('canvas'); + obj._dynamicMosaicCanvas = tempCanvas; + } + + if (tempCanvas.width !== targetWidth) tempCanvas.width = targetWidth; + if (tempCanvas.height !== targetHeight) tempCanvas.height = targetHeight; + setImageElement(obj, tempCanvas); + return tempCanvas; +} + +function setImageElement(obj, element) { + if (obj._element === element) return; + + if (typeof obj.setElement === 'function') { + obj.setElement(element); + } else { + obj._element = element; + obj._originalElement = element; + } +} + +function captureUnderlyingPixels(canvas, obj, width, height) { + const objects = canvas.getObjects(); + const objectIndex = objects.indexOf(obj); + const hiddenObjects = objectIndex >= 0 ? objects.slice(objectIndex) : []; + const visibilityBackups = []; + const viewportTransform = canvas.viewportTransform?.slice(); + const activeObject = canvas._activeObject; + const backgroundColor = canvas.backgroundColor; + + obj._dynamicMosaicUpdating = true; + + try { + hiddenObjects.forEach(item => { + visibilityBackups.push({ item, visible: item.visible }); + item.visible = false; + }); + + canvas._activeObject = null; + canvas.viewportTransform = IDENTITY_VIEWPORT.slice(); + canvas.renderAll(); + + const sampleWidth = Math.max(1, Math.round(width * Math.abs(obj.scaleX || 1))); + const sampleHeight = Math.max(1, Math.round(height * Math.abs(obj.scaleY || 1))); + const left = Math.round(obj.left || 0); + const top = Math.round(obj.top || 0); + const sourceCanvas = document.createElement('canvas'); + sourceCanvas.width = width; + sourceCanvas.height = height; + const sourceCtx = sourceCanvas.getContext('2d', { willReadFrequently: true }); + const canvasEl = canvas.lowerCanvasEl || canvas.getElement?.(); + + if (canvasEl) { + sourceCtx.imageSmoothingEnabled = false; + sourceCtx.drawImage( + canvasEl, + left, + top, + sampleWidth, + sampleHeight, + 0, + 0, + width, + height + ); + } else { + const ctx = canvas.getContext(); + const imageData = ctx.getImageData(left, top, width, height); + sourceCtx.putImageData(imageData, 0, 0); + } + + if (backgroundColor) { + const imageData = sourceCtx.getImageData(0, 0, width, height); + return { data: imageData }; + } + + return { data: sourceCtx.getImageData(0, 0, width, height) }; + } finally { + visibilityBackups.forEach(({ item, visible }) => { + item.visible = visible; + }); + if (viewportTransform) canvas.viewportTransform = viewportTransform; + canvas._activeObject = activeObject; + canvas.backgroundColor = backgroundColor; + obj._dynamicMosaicUpdating = false; + } +} + +function createMaskData(obj, width, height) { + if (obj.mosaicMaskType !== 'brush') return null; + + const points = Array.isArray(obj.mosaicBrushPoints) ? obj.mosaicBrushPoints : []; + const brushSize = Math.max(1, parseFloat(obj.mosaicBrushSize) || 1); + const maskCanvas = document.createElement('canvas'); + maskCanvas.width = width; + maskCanvas.height = height; + const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); + const radius = brushSize / 2; + + maskCtx.fillStyle = '#fff'; + points.forEach(point => { + maskCtx.beginPath(); + maskCtx.arc(point.x, point.y, radius, 0, Math.PI * 2); + maskCtx.fill(); + }); + + return maskCtx.getImageData(0, 0, width, height).data; +} + +function mosaicPixels(data, width, height, blockSize, mask) { + const size = Math.max(2, parseInt(blockSize, 10) || 12); + + for (let y = 0; y < height; y += size) { + for (let x = 0; x < width; x += size) { + if (mask && !blockHasMask(mask, width, height, x, y, size)) continue; + + let r = 0, g = 0, b = 0, a = 0, count = 0; + for (let dy = 0; dy < size && y + dy < height; dy++) { + for (let dx = 0; dx < size && x + dx < width; dx++) { + const idx = ((y + dy) * width + (x + dx)) * 4; + r += data[idx]; + g += data[idx + 1]; + b += data[idx + 2]; + a += data[idx + 3]; + count++; + } + } + + r = Math.round(r / count); + g = Math.round(g / count); + b = Math.round(b / count); + a = Math.round(a / count); + + for (let dy = 0; dy < size && y + dy < height; dy++) { + for (let dx = 0; dx < size && x + dx < width; dx++) { + const idx = ((y + dy) * width + (x + dx)) * 4; + if (!mask || mask[idx + 3] > 0) { + data[idx] = r; + data[idx + 1] = g; + data[idx + 2] = b; + data[idx + 3] = a; + } + } + } + } + } +} + +function blockHasMask(mask, width, height, x, y, size) { + for (let dy = 0; dy < size && y + dy < height; dy++) { + for (let dx = 0; dx < size && x + dx < width; dx++) { + const idx = ((y + dy) * width + (x + dx)) * 4; + if (mask[idx + 3] > 0) return true; + } + } + return false; +} + +function blurPixels(data, width, height, radius, mask) { + const srcCanvas = document.createElement('canvas'); + srcCanvas.width = width; + srcCanvas.height = height; + const srcCtx = srcCanvas.getContext('2d', { willReadFrequently: true }); + srcCtx.putImageData(new ImageData(data, width, height), 0, 0); + + const dstCanvas = document.createElement('canvas'); + dstCanvas.width = width; + dstCanvas.height = height; + const dstCtx = dstCanvas.getContext('2d', { willReadFrequently: true }); + dstCtx.filter = `blur(${Math.max(1, parseInt(radius, 10) || 8)}px)`; + dstCtx.drawImage(srcCanvas, 0, 0); + dstCtx.filter = 'none'; + + const blurred = dstCtx.getImageData(0, 0, width, height).data; + for (let i = 0; i < data.length; i += 4) { + if (!mask || mask[i + 3] > 0) { + data[i] = blurred[i]; + data[i + 1] = blurred[i + 1]; + data[i + 2] = blurred[i + 2]; + data[i + 3] = blurred[i + 3]; + } + } +} + +function requestCanvasRender(canvas) { + if (typeof canvas.requestRenderAll === 'function') { + canvas.requestRenderAll(); + } else { + canvas.renderAll(); + } +} diff --git a/plugins/Image-Toolbox/core/src/utils/filters.js b/plugins/Image-Toolbox/core/src/utils/filters.js new file mode 100644 index 000000000..c7a78a4c0 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/utils/filters.js @@ -0,0 +1,301 @@ +/** + * 滤镜工具 — 统一管理 fabric.Image 的滤镜读写与预设 + * + * Fabric.js 5.3.0 提供以下常用滤镜(均挂在 fabric.Image.filters 命名空间下): + * Brightness 亮度 -1 ~ 1(0 为原样) + * Contrast 对比度 -1 ~ 1 + * Saturation 饱和度 -1 ~ 1 + * HueRotation 色相旋转 弧度(0 ~ 2π,可取负值) + * Blur 高斯模糊 0 ~ 1 + * Sepia 棕褐 0 ~ 1(值越大越深) + * Grayscale 灰度 0 ~ 1(1 为完全黑白) + * Vibrance 自然饱和 -1 ~ 1 + * + * 每个 fabric.Image 实例都有一个 `filters` 数组,滤镜对象按顺序应用。 + * 本模块约定:同一类型的滤镜在数组中只保留一个实例,覆盖式更新。 + */ + +import { clamp } from './helpers.js'; + +/** 滤镜类型 → fabric.Image.filters 类名 映射 */ +const FILTER_CLASS_NAME = { + brightness: 'Brightness', + contrast: 'Contrast', + saturation: 'Saturation', + hue: 'HueRotation', + blur: 'Blur', + sepia: 'Sepia', + grayscale: 'Grayscale', + vibrance: 'Vibrance', +}; + +/** 滤镜类型 → 该滤镜在 fabric 中的属性名 */ +const FILTER_ATTR_NAME = { + brightness: 'brightness', + contrast: 'contrast', + saturation: 'saturation', + hue: 'rotation', + blur: 'blur', + sepia: 'sepia', + grayscale: 'grayscale', + vibrance: 'vibrance', +}; + +/** + * UI 滑块范围定义(百分比或度数) + * 仅这 5 项在属性面板暴露滑块;sepia / grayscale 仅通过预设触发 + */ +export const FILTER_RANGES = { + brightness: { min: -100, max: 100, step: 1, default: 0 }, + contrast: { min: -100, max: 100, step: 1, default: 0 }, + saturation: { min: -100, max: 100, step: 1, default: 0 }, + hue: { min: -180, max: 180, step: 1, default: 0 }, + blur: { min: 0, max: 100, step: 1, default: 0 }, +}; + +/** 仅通过预设使用的滤镜类型 → 默认值(用于应用预设时重置) */ +const PRESET_ONLY_DEFAULTS = { + sepia: 0, + grayscale: 0, + vibrance: 0, +}; + +/** 应用预设时需要重置的全部滤镜类型 */ +const ALL_RESETTABLE_TYPES = [...Object.keys(FILTER_RANGES), ...Object.keys(PRESET_ONLY_DEFAULTS)]; + +/** + * 将 UI 滑块值(百分比或度数)换算为 fabric 滤镜实际值 + * @param {string} type + * @param {number} uiValue + * @returns {number} + */ +export function uiToFilterValue(type, uiValue) { + switch (type) { + case 'brightness': + case 'contrast': + case 'saturation': + case 'sepia': + case 'grayscale': + case 'vibrance': + // 0~100 → 0~1(brightness/contrast/saturation/vibrance 允许 -100~100) + return clamp(uiValue, -100, 100) / 100; + case 'hue': + // -180°~180° → -π~π 弧度 + return clamp(uiValue, -180, 180) * Math.PI / 180; + case 'blur': + return clamp(uiValue, 0, 100) / 100; + default: + return uiValue; + } +} + +/** + * 将 fabric 滤镜值换算为 UI 滑块值 + * @param {string} type + * @param {number} filterValue + * @returns {number} + */ +export function filterToUiValue(type, filterValue) { + switch (type) { + case 'brightness': + case 'contrast': + case 'saturation': + case 'sepia': + case 'grayscale': + case 'vibrance': + return Math.round((filterValue || 0) * 100); + case 'hue': + return Math.round((filterValue || 0) * 180 / Math.PI); + case 'blur': + return Math.round((filterValue || 0) * 100); + default: + return Math.round(filterValue || 0); + } +} + +function getFilterClass(type) { + const className = FILTER_CLASS_NAME[type]; + if (!className) return null; + return (typeof fabric !== 'undefined' && fabric.Image && fabric.Image.filters) + ? fabric.Image.filters[className] + : null; +} + +/** + * 读取图片上某类滤镜的当前值(无则返回默认值) + * @param {fabric.Image} image + * @param {string} type + * @returns {number} UI 滑块值 + */ +export function getFilterUiValue(image, type) { + if (!image || !Array.isArray(image.filters)) { + return FILTER_RANGES[type] ? FILTER_RANGES[type].default : 0; + } + + const className = FILTER_CLASS_NAME[type]; + const filter = image.filters.find(f => f && f.type === className); + if (!filter) return FILTER_RANGES[type] ? FILTER_RANGES[type].default : 0; + + const attr = FILTER_ATTR_NAME[type]; + return filterToUiValue(type, filter[attr]); +} + +/** + * 设置/替换图片上的某类滤镜 + * @param {fabric.Image} image + * @param {string} type + * @param {number} uiValue + * @param {boolean} [skipApply=false] 为 true 时跳过 applyFilters(image) 重算, + * 用于批量设置(如应用预设)时避免多次重复调用昂贵的滤镜重算; + * 调用方需在批量结束后自行触发一次重算 + */ +export function setFilter(image, type, uiValue, skipApply = false) { + if (!image) return; + + if (!Array.isArray(image.filters)) { + image.filters = []; + } + + const FilterClass = getFilterClass(type); + if (!FilterClass) return; + + // 移除同类型旧滤镜 + const className = FILTER_CLASS_NAME[type]; + image.filters = image.filters.filter(f => !(f && f.type === className)); + + // 默认值不写入(避免无效滤镜堆积) + const defaultValue = FILTER_RANGES[type] + ? FILTER_RANGES[type].default + : (PRESET_ONLY_DEFAULTS[type] != null ? PRESET_ONLY_DEFAULTS[type] : 0); + if (uiValue === defaultValue) { + if (!skipApply) applyFilters(image); + return; + } + + const filterValue = uiToFilterValue(type, uiValue); + const attr = FILTER_ATTR_NAME[type]; + const filter = new FilterClass({ [attr]: filterValue }); + image.filters.push(filter); + if (!skipApply) applyFilters(image); +} + +/** + * 清除图片上的所有滤镜 + * @param {fabric.Image} image + */ +export function clearFilters(image) { + if (!image) return; + image.filters = []; + applyFilters(image); +} + +/** + * 应用滤镜到图片(触发重新渲染) + * @param {fabric.Image} image + */ +function applyFilters(image) { + if (typeof image.applyFilters === 'function') { + image.applyFilters(); + } +} + +/** + * 判断图片是否完全无滤镜 + * @param {fabric.Image} image + * @returns {boolean} + */ +export function hasNoFilters(image) { + if (!image || !Array.isArray(image.filters)) return true; + return image.filters.length === 0; +} + +// ── 预设 ── + +/** + * 滤镜预设定义 + * 每个预设的 filters 字段为 { type: uiValue } 映射,未列出的类型重置为默认值 + */ +export const FILTER_PRESETS = [ + { + preset: 'filter-original', + label: '原图', + filters: {}, + }, + { + preset: 'filter-warm', + label: '暖色', + filters: { brightness: 6, saturation: 18, hue: -12 }, + }, + { + preset: 'filter-cool', + label: '冷色', + filters: { brightness: 2, saturation: -8, hue: 14 }, + }, + { + preset: 'filter-vintage', + label: '复古', + filters: { brightness: 4, contrast: -8, saturation: -12, sepia: 55 }, + }, + { + preset: 'filter-bw', + label: '黑白', + filters: { grayscale: 100, contrast: 8 }, + }, + { + preset: 'filter-vivid', + label: '鲜艳', + filters: { saturation: 40, contrast: 14, brightness: 4 }, + }, + { + preset: 'filter-soft', + label: '柔光', + filters: { brightness: 10, contrast: -16, blur: 6 }, + }, + { + preset: 'filter-sharp', + label: '锐利', + filters: { contrast: 26, saturation: 10, brightness: -2 }, + }, +]; + +/** + * 应用滤镜预设到图片 + * @param {fabric.Image} image + * @param {string} presetName + */ +export function applyFilterPreset(image, presetName) { + if (!image) return false; + const preset = FILTER_PRESETS.find(p => p.preset === presetName); + if (!preset) return false; + + // 重置全部为默认值,再应用预设覆盖 + // 批量设置时跳过逐次 applyFilters,循环结束后统一应用一次,避免重复重算 + ALL_RESETTABLE_TYPES.forEach(type => { + const value = preset.filters[type] != null + ? preset.filters[type] + : (FILTER_RANGES[type] ? FILTER_RANGES[type].default : (PRESET_ONLY_DEFAULTS[type] != null ? PRESET_ONLY_DEFAULTS[type] : 0)); + setFilter(image, type, value, true); + }); + applyFilters(image); + return true; +} + +/** + * 判断当前图片是否匹配某个预设(用于预设按钮高亮) + * @param {fabric.Image} image + * @param {string} presetName + * @returns {boolean} + */ +export function isPresetActive(image, presetName) { + if (!image) return false; + const preset = FILTER_PRESETS.find(p => p.preset === presetName); + if (!preset) return false; + + return ALL_RESETTABLE_TYPES.every(type => { + const expected = preset.filters[type] != null + ? preset.filters[type] + : (FILTER_RANGES[type] ? FILTER_RANGES[type].default : (PRESET_ONLY_DEFAULTS[type] != null ? PRESET_ONLY_DEFAULTS[type] : 0)); + const actual = getFilterUiValue(image, type); + return Math.abs(actual - expected) < 0.5; + }); +} diff --git a/plugins/Image-Toolbox/core/src/utils/fonts.js b/plugins/Image-Toolbox/core/src/utils/fonts.js new file mode 100644 index 000000000..45179114d --- /dev/null +++ b/plugins/Image-Toolbox/core/src/utils/fonts.js @@ -0,0 +1,206 @@ +const FALLBACK_FONT_OPTIONS = [ + { value: 'Microsoft YaHei, PingFang SC, sans-serif', label: '微软雅黑' }, + { value: 'SimSun, STSong, serif', label: '宋体' }, + { value: 'SimHei, STHeiti, sans-serif', label: '黑体' }, + { value: 'KaiTi, STKaiti, serif', label: '楷体' }, + { value: 'Arial, sans-serif', label: 'Arial' }, +]; + +const FONT_USAGE_STORAGE_KEY = 'image-toolbox.font-usage'; +const FONT_ALIAS_KEYS = new Map([ + ['microsoft yahei', '微软雅黑'], + ['microsoft yahei ui', '微软雅黑'], + ['simsun', '宋体'], + ['nsimsun', '新宋体'], + ['simhei', '黑体'], + ['kaiti', '楷体'], + ['fangsong', '仿宋'], + ['stkaiti', '华文楷体'], + ['stfangsong', '华文仿宋'], +]); + +let systemFontOptionsCache = null; +let systemFontLoading = false; +let systemFontLoadCallbacks = []; + +export function getFontOptionsHTML(current) { + const currentKey = _getFontKey(current); + + return _getFontOptions(current).map((option) => { + const selected = currentKey && _getFontKey(option.value) === currentKey ? ' selected' : ''; + return ``; + }).join(''); +} + +export function getFontOptionsHTMLAsync(current) { + if (systemFontOptionsCache) { + return Promise.resolve(getFontOptionsHTML(current)); + } + + return _ensureSystemFontsLoaded().then(() => getFontOptionsHTML(current)); +} + +export function isSystemFontsLoaded() { + return systemFontOptionsCache !== null; +} + +export function isSystemFontsLoading() { + return systemFontLoading; +} + +export function onSystemFontsLoaded(callback) { + if (systemFontOptionsCache) { + callback(); + } else { + systemFontLoadCallbacks.push(callback); + if (!systemFontLoading) { + _loadSystemFontsAsync(); + } + } +} + +function _loadSystemFontsAsync() { + if (systemFontLoading) return; + systemFontLoading = true; + + const asyncLoader = typeof window !== 'undefined' && typeof window.getSystemFontsAsync === 'function' + ? window.getSystemFontsAsync() + : Promise.resolve().then(() => { + return typeof window !== 'undefined' && typeof window.getSystemFonts === 'function' + ? window.getSystemFonts() + : []; + }); + + asyncLoader.then((fonts) => { + systemFontOptionsCache = Array.isArray(fonts) + ? fonts.map((font) => String(font || '').trim()).filter(Boolean).map((font) => ({ value: font, label: font })) + : []; + systemFontLoading = false; + systemFontLoadCallbacks.forEach((cb) => cb()); + systemFontLoadCallbacks = []; + }).catch((e) => { + console.error('[fonts] 异步加载系统字体失败:', e); + systemFontOptionsCache = []; + systemFontLoading = false; + systemFontLoadCallbacks.forEach((cb) => cb()); + systemFontLoadCallbacks = []; + }); +} + +function _ensureSystemFontsLoaded() { + if (systemFontOptionsCache) return Promise.resolve(); + return new Promise((resolve) => { + onSystemFontsLoaded(resolve); + }); +} + +export function recordFontUsage(fontFamily) { + const key = _getFontKey(fontFamily); + if (!key) return; + + const usage = _readFontUsage(); + const current = usage[key] || { count: 0, lastUsed: 0 }; + usage[key] = { + count: Math.min((parseInt(current.count, 10) || 0) + 1, Number.MAX_SAFE_INTEGER), + lastUsed: Date.now(), + }; + _writeFontUsage(usage); +} + +function _getFontOptions(current) { + const systemOptions = _getSystemFontOptions(); + const sourceOptions = systemOptions.length > 0 ? systemOptions : FALLBACK_FONT_OPTIONS; + const options = []; + const seen = new Set(); + + _sortFontOptions(sourceOptions).forEach((option) => { + const key = _getFontKey(option.value); + if (!key || seen.has(key)) return; + + seen.add(key); + options.push(option); + }); + + const currentValue = String(current || '').trim(); + const currentKey = _getFontKey(currentValue); + if (currentValue && currentKey && !seen.has(currentKey)) { + options.unshift({ value: currentValue, label: currentValue }); + } + + return options; +} + +function _getSystemFontOptions() { + if (systemFontOptionsCache) return systemFontOptionsCache; + + try { + const fonts = typeof window.getSystemFonts === 'function' ? window.getSystemFonts() : []; + systemFontOptionsCache = Array.isArray(fonts) + ? fonts.map((font) => String(font || '').trim()).filter(Boolean).map((font) => ({ value: font, label: font })) + : []; + } catch (e) { + console.error('[fonts] 获取系统字体失败:', e); + systemFontOptionsCache = []; + } + + return systemFontOptionsCache; +} + +function _sortFontOptions(options) { + const usage = _readFontUsage(); + + return options.slice().sort((a, b) => { + const usageA = usage[_getFontKey(a.value)] || {}; + const usageB = usage[_getFontKey(b.value)] || {}; + const countA = parseInt(usageA.count, 10) || 0; + const countB = parseInt(usageB.count, 10) || 0; + if (countA !== countB) return countB - countA; + + const lastUsedA = parseInt(usageA.lastUsed, 10) || 0; + const lastUsedB = parseInt(usageB.lastUsed, 10) || 0; + if (lastUsedA !== lastUsedB) return lastUsedB - lastUsedA; + + return a.label.localeCompare(b.label, 'zh-CN', { numeric: true, sensitivity: 'base' }); + }); +} + +function _readFontUsage() { + try { + const value = localStorage.getItem(FONT_USAGE_STORAGE_KEY); + const usage = value ? JSON.parse(value) : {}; + return usage && typeof usage === 'object' ? usage : {}; + } catch (e) { + return {}; + } +} + +function _writeFontUsage(usage) { + try { + localStorage.setItem(FONT_USAGE_STORAGE_KEY, JSON.stringify(usage)); + } catch (e) { + // localStorage 不可用时仅失去排序记忆,不影响字体选择。 + } +} + +function _getFontKey(value) { + const primary = _normalizeFontValue(_getPrimaryFontName(value)); + return FONT_ALIAS_KEYS.get(primary) || primary; +} + +function _getPrimaryFontName(value) { + return String(value || '').split(',')[0].replace(/^['"]|['"]$/g, '').trim(); +} + +function _normalizeFontValue(value) { + return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase(); +} + +function _escapeHTML(value) { + return String(value ?? '').replace(/[&<>"']/g, (ch) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[ch])); +} diff --git a/plugins/Image-Toolbox/core/src/utils/helpers.js b/plugins/Image-Toolbox/core/src/utils/helpers.js new file mode 100644 index 000000000..806f6aee5 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/utils/helpers.js @@ -0,0 +1,133 @@ +/** + * 公共工具函数 — 消除各模块间的重复实现 + */ + +export function clamp(value, min, max) { + if (max < min) return min; + return Math.max(min, Math.min(max, value)); +} + +export function escapeHTML(value) { + return String(value ?? '').replace(/[&<>"']/g, ch => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[ch])); +} + +export function escapeAttr(value) { + return escapeHTML(value); +} + +export function normalizeColor(color, fallback = '#000000', allowTransparent = false) { + if (allowTransparent && color === 'transparent') return 'transparent'; + + if (typeof color !== 'string') return fallback; + + const value = color.trim().toLowerCase(); + if (value.startsWith('rgba')) return value; + if (/^#[0-9a-f]{6}$/i.test(value)) return value; + if (/^#[0-9a-f]{3}$/i.test(value)) { + return '#' + value.slice(1).split('').map(ch => ch + ch).join(''); + } + + return fallback; +} + +export function requestRender(canvas) { + if (!canvas) return; + if (typeof canvas.requestRenderAll === 'function') { + canvas.requestRenderAll(); + } else { + canvas.renderAll(); + } +} + +export function createClipPathFromSource(source) { + const width = Math.max(1, source.width || (source.rx || 0) * 2 || 0); + const height = Math.max(1, source.height || (source.ry || 0) * 2 || 0); + const isEllipse = source.type === 'ellipse'; + const commonOptions = { + left: source.left || 0, + top: source.top || 0, + scaleX: source.scaleX == null ? 1 : source.scaleX, + scaleY: source.scaleY == null ? 1 : source.scaleY, + angle: source.angle || 0, + skewX: source.skewX || 0, + skewY: source.skewY || 0, + flipX: !!source.flipX, + flipY: !!source.flipY, + originX: source.originX || 'left', + originY: source.originY || 'top', + fill: '#000', + stroke: null, + strokeWidth: 0, + absolutePositioned: true, + objectCaching: false, + }; + + const clipPath = isEllipse + ? new fabric.Ellipse({ + ...commonOptions, + width, + height, + rx: width / 2, + ry: height / 2, + }) + : new fabric.Rect({ + ...commonOptions, + width, + height, + rx: source.rx || 0, + ry: source.ry || 0, + }); + + if (source.clipPath) { + clipPath.clipPath = createClipPathFromSource(source.clipPath); + } + clipPath.setCoords(); + return clipPath; +} + +export function normalizeBounds(bounds) { + const left = bounds.left; + const top = bounds.top; + const width = Math.max(0, bounds.width || 0); + const height = Math.max(0, bounds.height || 0); + return { + left, + top, + right: left + width, + bottom: top + height, + width, + height, + }; +} + +export function intersectBounds(a, b) { + const left = Math.max(a.left, b.left); + const top = Math.max(a.top, b.top); + const right = Math.min(a.right, b.right); + const bottom = Math.min(a.bottom, b.bottom); + if (right <= left || bottom <= top) return null; + return { left, top, right, bottom, width: right - left, height: bottom - top }; +} + +export function getPointsBounds(points) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of points) { + if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) continue; + if (p.x < minX) minX = p.x; + if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; + if (p.y > maxY) maxY = p.y; + } + + if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) { + return null; + } + + return { left: minX, top: minY, right: maxX, bottom: maxY }; +} diff --git a/plugins/Image-Toolbox/core/src/utils/host.js b/plugins/Image-Toolbox/core/src/utils/host.js new file mode 100644 index 000000000..0fb9caa27 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/utils/host.js @@ -0,0 +1,21 @@ +function getHostApi() { + if (typeof window === 'undefined') return null; + + const api = window.hostTools || window.utools || window.ztools || null; + if (api && window.hostTools !== api) { + window.hostTools = api; + } + return api; +} + +export function isHostDarkColors() { + const api = getHostApi(); + + try { + if (api && typeof api.isDarkColors === 'function') return !!api.isDarkColors(); + } catch (e) { + console.warn('[Host] 读取宿主系统颜色失败:', e); + } + + return null; +} diff --git a/plugins/Image-Toolbox/core/src/utils/image.js b/plugins/Image-Toolbox/core/src/utils/image.js new file mode 100644 index 000000000..c9a0470f9 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/utils/image.js @@ -0,0 +1,104 @@ +/** + * 图片工具函数 + */ + +/** + * 从 DataURL 加载图片 + * @param {string} dataURL + * @returns {Promise} + */ +export function loadImageFromDataURL(dataURL) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = (err) => reject(err); + img.src = dataURL; + }); +} + +/** + * 从 File 对象读取为 DataURL + * @param {File} file + * @returns {Promise} + */ +export function fileToDataURL(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => resolve(e.target.result); + reader.onerror = (err) => reject(err); + reader.readAsDataURL(file); + }); +} + +/** + * 从 URL 获取 Blob + * @param {string} url + * @returns {Promise} + */ +export async function urlToBlob(url) { + const response = await fetch(url); + return response.blob(); +} + +/** + * Canvas 转 Blob + * @param {HTMLCanvasElement} canvas + * @param {string} [type='image/png'] + * @param {number} [quality=1] + * @returns {Promise} + */ +export function canvasToBlob(canvas, type = 'image/png', quality = 1) { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob); + else reject(new Error('Canvas toBlob 失败')); + }, type, quality); + }); +} + +/** + * 检测是否为支持的图片格式 + * @param {string} filename + * @returns {boolean} + */ +export function isSupportedImage(filename) { + const ext = filename.split('.').pop().toLowerCase(); + const supported = ['png', 'jpg', 'jpeg', 'webp', 'bmp', 'gif', 'svg']; + return supported.includes(ext); +} + +/** + * 获取图片文件的 MIME 类型 + * @param {string} extension + * @returns {string} + */ +export function getImageMimeType(extension) { + const mimeMap = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + webp: 'image/webp', + bmp: 'image/bmp', + gif: 'image/gif', + svg: 'image/svg+xml', + }; + return mimeMap[extension.toLowerCase()] || 'image/png'; +} + +/** + * 在画布上绘制棋盘格背景 + * @param {CanvasRenderingContext2D} ctx + * @param {number} width + * @param {number} height + * @param {number} [size=8] + * @param {string} [color1='#c8d0d4'] + * @param {string} [color2='#d2d6d9'] + */ +export function drawCheckerboard(ctx, width, height, size = 8, color1 = '#c8d0d4', color2 = '#d2d6d9') { + for (let y = 0; y < height; y += size) { + for (let x = 0; x < width; x += size) { + ctx.fillStyle = (Math.floor(x / size) + Math.floor(y / size)) % 2 === 0 ? color1 : color2; + ctx.fillRect(x, y, size, size); + } + } +} diff --git a/plugins/Image-Toolbox/core/src/utils/theme.js b/plugins/Image-Toolbox/core/src/utils/theme.js new file mode 100644 index 000000000..e86fc8656 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/utils/theme.js @@ -0,0 +1,88 @@ +import { isHostDarkColors } from './host.js'; + +export const THEME_STORAGE_KEY = 'image-toolbox-theme'; +export const THEME_VERSION_KEY = 'image-toolbox-theme-version'; +export const THEME_VERSION = 'neutral-teal-light-default-v1'; + +export const THEME_CHOICES = { + SYSTEM: 'system', + LIGHT: 'light', + DARK: 'dark', +}; + +const VALID_THEME_CHOICES = new Set(Object.values(THEME_CHOICES)); +let systemThemeListenerBound = false; + +export function initTheme() { + applyThemeChoice(getThemeChoice(), false); + bindSystemThemeListener(); +} + +export function getThemeChoice() { + const savedVersion = localStorage.getItem(THEME_VERSION_KEY); + let savedTheme = localStorage.getItem(THEME_STORAGE_KEY); + + if (savedVersion !== THEME_VERSION) { + savedTheme = THEME_CHOICES.LIGHT; + localStorage.setItem(THEME_STORAGE_KEY, savedTheme); + localStorage.setItem(THEME_VERSION_KEY, THEME_VERSION); + } else if (!VALID_THEME_CHOICES.has(savedTheme)) { + savedTheme = THEME_CHOICES.LIGHT; + } + + return savedTheme; +} + +export function applyThemeChoice(choice, persist = true) { + const themeChoice = VALID_THEME_CHOICES.has(choice) ? choice : THEME_CHOICES.LIGHT; + + if (persist) { + localStorage.setItem(THEME_STORAGE_KEY, themeChoice); + localStorage.setItem(THEME_VERSION_KEY, THEME_VERSION); + } + + document.documentElement.setAttribute('data-theme-preference', themeChoice); + document.documentElement.setAttribute('data-theme', resolveTheme(themeChoice)); +} + +function resolveTheme(choice) { + if (choice === THEME_CHOICES.SYSTEM) { + return getSystemTheme(); + } + + return choice; +} + +function getSystemTheme() { + const hostDark = isHostDarkColors(); + if (hostDark !== null) { + return hostDark ? THEME_CHOICES.DARK : THEME_CHOICES.LIGHT; + } + + if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { + return window.matchMedia('(prefers-color-scheme: dark)').matches + ? THEME_CHOICES.DARK + : THEME_CHOICES.LIGHT; + } + + return THEME_CHOICES.LIGHT; +} + +function bindSystemThemeListener() { + if (systemThemeListenerBound || typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = () => { + if (getThemeChoice() === THEME_CHOICES.SYSTEM) { + applyThemeChoice(THEME_CHOICES.SYSTEM, false); + } + }; + + if (typeof mediaQuery.addEventListener === 'function') { + mediaQuery.addEventListener('change', handleChange); + } else if (typeof mediaQuery.addListener === 'function') { + mediaQuery.addListener(handleChange); + } + + systemThemeListenerBound = true; +} diff --git a/plugins/Image-Toolbox/logo.png b/plugins/Image-Toolbox/logo.png new file mode 100644 index 000000000..b22d4c3b2 Binary files /dev/null and b/plugins/Image-Toolbox/logo.png differ diff --git a/plugins/Image-Toolbox/plugin.json b/plugins/Image-Toolbox/plugin.json new file mode 100644 index 000000000..cabeb9c35 --- /dev/null +++ b/plugins/Image-Toolbox/plugin.json @@ -0,0 +1,41 @@ +{ + "name": "image-toolbox-ztools", + "title": "图片工具箱", + "description": "马赛克、剪切、加字等图片编辑功能", + "version": "2.3.1", + "main": "src/index.html", + "logo": "logo.png", + "preload": "preload.js", + "features": [ + { + "code": "image-edit", + "explain": "图片工具箱 - 马赛克、剪切、加字等图片编辑功能", + "cmds": [ + "图片工具箱", + "图片编辑", + "编辑图片", + "P图", + { + "type": "img", + "label": "图片编辑" + }, + { + "type": "files", + "label": "编辑图片", + "fileType": "file", + "extensions": [ + "png", + "jpg", + "jpeg", + "webp", + "bmp", + "gif", + "svg" + ], + "minLength": 1, + "maxLength": 1 + } + ] + } + ] +} \ No newline at end of file diff --git a/plugins/Image-Toolbox/preload.js b/plugins/Image-Toolbox/preload.js new file mode 100644 index 000000000..fb14ac15c --- /dev/null +++ b/plugins/Image-Toolbox/preload.js @@ -0,0 +1,97 @@ +const { initPreload } = require('./core/src/preloadHelpers.js'); + +// ═══════════════════════════════════════════════════════════════ +// ZTools 平台特定配置 +// ═══════════════════════════════════════════════════════════════ + +const PLATFORM_NAME = 'ZTools'; +const USER_FN_NAME = 'getZtoolsUser'; + +const platform = { + getName: () => PLATFORM_NAME, + getApiKeys: () => ['hostTools', 'ztools', 'utools'], + getUserFnName: () => USER_FN_NAME, + getContactUrl: () => 'https://qm.qq.com/q/xdx9hstuGA', + onPluginEnter: (callback) => { + const api = getHostTools(); + if (api && typeof api.onPluginEnter === 'function') { + api.onPluginEnter(callback); + } + return () => {}; + }, + onPluginOut: (callback) => { + const api = getHostTools(); + if (api && typeof api.onPluginOut === 'function') { + api.onPluginOut(callback); + } + return () => {}; + }, + openExternal: (url) => { + const api = getHostTools(); + if (api && typeof api.shellOpenExternal === 'function') { + api.shellOpenExternal(url); + return true; + } + if (typeof window !== 'undefined') { + window.open(url, '_blank', 'noopener,noreferrer'); + return true; + } + return false; + }, +}; + +// ═══════════════════════════════════════════════════════════════ +// API 查找函数(ZTools 平台特定顺序) +// ═══════════════════════════════════════════════════════════════ + +const getHostTools = () => { + if (typeof window === 'undefined') return null; + + const priorities = platform.getApiKeys(); + + if (typeof window !== 'undefined') { + for (const key of priorities) { + if (window[key]) return window[key]; + } + } + + if (typeof globalThis !== 'undefined') { + for (const key of priorities) { + if (globalThis[key]) return globalThis[key]; + } + } + + return null; +}; + +const getHostAppVersion = () => { + const api = getHostTools(); + if (api && typeof api.getAppVersion === 'function') return api.getAppVersion(); + if (api && typeof api.getVersion === 'function') return api.getVersion(); + if (api && typeof api.getPluginVersion === 'function') return api.getPluginVersion(); + return 'unknown'; +}; + +// ═══════════════════════════════════════════════════════════════ +// 初始化 +// ═══════════════════════════════════════════════════════════════ + +if (typeof window !== 'undefined') { + initPreload(platform); + + // 注册平台特定 API + window.getHostAppVersion = getHostAppVersion; + window.getHostName = () => PLATFORM_NAME; + + window[USER_FN_NAME] = () => { + const api = getHostTools(); + if (!api) return null; + try { + if (typeof api.getUser === 'function') return api.getUser(); + if (typeof api.getUserInfo === 'function') return api.getUserInfo(); + } catch (e) { + console.warn(`[${PLATFORM_NAME} preload] 获取宿主用户失败:`, e); + } + return null; + }; +} diff --git a/plugins/Image-Toolbox/src/adapters/host/ZtoolsHostAdapter.js b/plugins/Image-Toolbox/src/adapters/host/ZtoolsHostAdapter.js new file mode 100644 index 000000000..ce062f07b --- /dev/null +++ b/plugins/Image-Toolbox/src/adapters/host/ZtoolsHostAdapter.js @@ -0,0 +1,108 @@ +/** + * ZtoolsHostAdapter + * ZTools 平台宿主适配器,继承 BaseHostAdapter。 + */ + +import BaseHostAdapter from '../../../core/src/adapters/BaseHostAdapter.js'; + +const DEFAULT_HOST_NAME = 'ZTools'; + +function normalizeZtoolsUser(user) { + if (!user) return null; + + return { + nickname: user.nickname || user.name || user.userName || user.username || '', + avatar: user.avatar || user.avatarUrl || user.photo || '', + type: user.type || '', + raw: user, + }; +} + +class ZtoolsHostAdapter extends BaseHostAdapter { + constructor() { + super(); + this._isZTools = this._api !== null; + } + + get platformId() { + return 'ztools'; + } + + getDefaultHostName() { + return DEFAULT_HOST_NAME; + } + + getHostApiPriority() { + return ['ztools', 'hostTools', 'utools']; + } + + getAppVersionPriority() { + return ['getAppVersion', 'getVersion', 'getPluginVersion']; + } + + getHostDisplayName(api) { + const target = api || this._api; + if (!target) return DEFAULT_HOST_NAME; + + try { + if (typeof target.getAppName === 'function') { + const name = target.getAppName(); + if (name) return String(name); + } + } catch (e) { + console.warn('[ZtoolsHostAdapter] 获取宿主名称失败:', e); + } + + if (typeof window !== 'undefined') { + if (window.ztools) return 'ZTools'; + if (window.utools) return 'uTools'; + } + + return DEFAULT_HOST_NAME; + } + + normalizeUser(user) { + return normalizeZtoolsUser(user); + } + + getRawUser(api) { + const target = api || this._api; + try { + if (target && typeof target.getUser === 'function') return target.getUser(); + if (target && typeof target.getUserInfo === 'function') return target.getUserInfo(); + if (typeof window !== 'undefined' && typeof window.getZtoolsUser === 'function') return window.getZtoolsUser(); + } catch (e) { + console.warn('[ZtoolsHostAdapter] 获取宿主用户失败:', e); + } + return null; + } + + getContactUrl() { + return 'https://qm.qq.com/q/xdx9hstuGA'; + } + + get isZTools() { + return this._isZTools; + } +} + +export default ZtoolsHostAdapter; + +// 便捷导出函数(旧 UI 兼容;新代码优先注入 host adapter) +const defaultAdapter = new ZtoolsHostAdapter(); + +export function getHostAppVersion() { + return defaultAdapter.getHostAppVersion(); +} + +export function getHostName() { + return defaultAdapter.getHostName(); +} + +export function getHostUser() { + return defaultAdapter.getHostUser(); +} + +export function openHostExternal(url) { + return defaultAdapter.openHostExternal(url); +} diff --git a/plugins/Image-Toolbox/src/index.html b/plugins/Image-Toolbox/src/index.html new file mode 100644 index 000000000..063dc3ea5 --- /dev/null +++ b/plugins/Image-Toolbox/src/index.html @@ -0,0 +1,62 @@ + + + + + + 图片工具箱 + + + + + + +
      + +
      + + +
      + + +
      + +
      +
      + + + + + +
      +
      拖入图片 / 粘贴截图 / 点击图标导入
      +
      支持 PNG · JPG · WebP · BMP · GIF · SVG
      + +
      + + + + + + +
      + + +
      + + +
      +
      + + + + + + + + diff --git a/plugins/Image-Toolbox/src/index.js b/plugins/Image-Toolbox/src/index.js new file mode 100644 index 000000000..f46cd2e04 --- /dev/null +++ b/plugins/Image-Toolbox/src/index.js @@ -0,0 +1,13 @@ +/** + * 图片工具箱 — ZTools 平台入口 + * 仅负责引入 HostAdapter 并启动共享 App + */ +import App from '../core/src/app/App.js'; +import ZtoolsHostAdapter from './adapters/host/ZtoolsHostAdapter.js'; + +// ═══ 启动应用 ═══ +window.addEventListener('DOMContentLoaded', () => { + const app = new App(ZtoolsHostAdapter); + window.__imageToolboxApp = app; + window.addEventListener('beforeunload', () => app.destroy(), { once: true }); +}); diff --git a/plugins/Image-Toolbox/src/style.css b/plugins/Image-Toolbox/src/style.css new file mode 100644 index 000000000..22b101553 --- /dev/null +++ b/plugins/Image-Toolbox/src/style.css @@ -0,0 +1,2172 @@ +/* ══════════════════════════════════════════════ + 图片工具箱 — 全局样式 + 中性编辑器布局 | 灰青主题 | BEM 命名 + ══════════════════════════════════════════════ */ + +/* ── CSS 变量:中性浅色主题(默认) ── */ +:root, [data-theme="light"] { + --bg-window: #f2f4f5; + --bg-panel: #eef1f2; + --bg-card: #ffffff; + --bg-canvas: #d2d6d9; + --bg-active: #2f7f86; + --bg-hover: #e2e8ea; + --bg-statusbar: #38464c; + --color-text: #222a2d; + --color-text-secondary: #637074; + --color-border: #c8d0d4; + --color-danger: #c84f46; + --color-accent: #2f7f86; + --toolbar-width: 52px; + --optionsbar-height: 50px; + --panel-width: 220px; + --statusbar-height: 28px; + --scrollbar-bg: #e2e8ea; + --scrollbar-thumb: #b7c1c6; + --scrollbar-thumb-hover: #9facb2; +} + +/* ── CSS 变量:中性深色主题 ── */ +[data-theme="dark"] { + --bg-window: #1d2225; + --bg-panel: #252b2f; + --bg-card: #202529; + --bg-canvas: #30363a; + --bg-active: #2f7378; + --bg-hover: #323a3f; + --bg-statusbar: #2a3338; + --color-text: #d8dee2; + --color-text-secondary: #8e9aa1; + --color-border: #3b454b; + --color-danger: #e06a60; + --color-accent: #46a0a6; + --scrollbar-bg: #202529; + --scrollbar-thumb: #4a555c; + --scrollbar-thumb-hover: #5c6971; +} + +/* ── 基础重置 ── */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + width: 100%; + height: 100%; + overflow: hidden; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; + font-size: 12px; + color: var(--color-text); + background: var(--bg-window); + user-select: none; + -webkit-user-select: none; +} + +/* ── 滚动条 ── */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: var(--scrollbar-bg); +} + +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); +} + +/* ══════════════════════════════════════════════ + 整体布局 — 五区结构 + ══════════════════════════════════════════════ */ + +.app { + display: grid; + grid-template-columns: var(--toolbar-width) 1fr var(--panel-width); + grid-template-rows: var(--optionsbar-height) 1fr var(--statusbar-height); + grid-template-areas: + "toolbar optionsbar optionsbar" + "toolbar canvas panel" + "toolbar statusbar statusbar"; + width: 100%; + height: 100%; + overflow: hidden; +} + +.app.app--bars-swapped { + grid-template-rows: var(--statusbar-height) 1fr var(--optionsbar-height); + grid-template-areas: + "toolbar statusbar statusbar" + "toolbar canvas panel" + "toolbar optionsbar optionsbar"; +} + +.app.app--panel-left { + grid-template-columns: var(--toolbar-width) var(--panel-width) 1fr; + grid-template-areas: + "toolbar optionsbar optionsbar" + "toolbar panel canvas" + "toolbar statusbar statusbar"; +} + +.app.app--panel-left.app--bars-swapped { + grid-template-rows: var(--statusbar-height) 1fr var(--optionsbar-height); + grid-template-areas: + "toolbar statusbar statusbar" + "toolbar panel canvas" + "toolbar optionsbar optionsbar"; +} + +/* ── 工具栏(左侧) ── */ +.toolbar { + grid-area: toolbar; + background: var(--bg-panel); + border-right: 1px solid var(--color-border); + display: flex; + flex-direction: column; + align-items: center; + padding: 6px 0; + gap: 2px; + overflow: hidden; + z-index: 10; +} + +.toolbar__tools, +.toolbar__footer { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.toolbar__tools { + flex: 1; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + padding-bottom: 4px; +} + +.toolbar__footer { + flex-shrink: 0; + padding-top: 4px; +} + +.toolbar__btn { + width: 44px; + height: 44px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + border: none; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + border-radius: 4px; + transition: background 0.15s, color 0.15s; + font-size: 10px; +} + +.toolbar__btn:hover { + background: var(--bg-hover); + color: var(--color-text); +} + +.toolbar__btn--active { + background: var(--bg-active) !important; + color: #ffffff !important; +} + +.toolbar__btn svg { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.toolbar__btn span { + font-size: 9px; + line-height: 1; + white-space: nowrap; +} + +.toolbar--labels-hidden .toolbar__btn { + width: 36px; + height: 36px; +} + +.toolbar--labels-hidden .toolbar__btn span { + display: none; +} + +.toolbar--labels-hidden .toolbar__separator { + width: 28px; +} + +.toolbar__separator { + width: 36px; + height: 1px; + background: var(--color-border); + margin: 4px 0; + flex-shrink: 0; +} + +.toolbar__spacer { + flex: 1; +} + +.toolbar__account { + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + border-radius: 4px; + cursor: pointer; + transition: background 0.15s; +} + +.toolbar__account:hover { + background: var(--bg-hover); +} + +.toolbar__avatar { + width: 28px; + height: 28px; + border-radius: 50%; + border: 1px solid var(--color-border); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.16); +} + +.toolbar__avatar-img { + display: block; + object-fit: cover; +} + +.toolbar__avatar--fallback { + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-active); + color: #ffffff; + font-size: 12px; + font-weight: 600; +} + +/* ── 账户页 ── */ +.account-page { + width: 100%; + height: 100%; + background: var(--bg-window); + color: var(--color-text); + overflow: hidden; +} + +.account-page__shell { + display: grid; + grid-template-columns: 180px 1fr; + width: 100%; + height: 100%; +} + +.account-page__sidebar { + display: flex; + flex-direction: column; + gap: 18px; + padding: 18px 12px; + background: var(--bg-panel); + border-right: 1px solid var(--color-border); +} + +.account-page__brand { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 6px 12px; + border-bottom: 1px solid var(--color-border); +} + +.account-page__brand-mark { + width: 34px; + height: 34px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 10px; + background: var(--bg-card); + border: 1px solid var(--color-border); + overflow: hidden; +} + +.account-page__brand-logo { + width: 24px; + height: 24px; + object-fit: contain; +} + +.account-page__brand-title { + font-size: 13px; + font-weight: 700; +} + +.account-page__brand-subtitle, +.account-page__eyebrow, +.account-card__label { + color: var(--color-text-secondary); + font-size: 11px; +} + +.account-page__nav { + display: flex; + flex-direction: column; + gap: 6px; +} + +.account-page__nav-item, +.account-page__back, +.account-page__header-back, +.account-page__theme-choice { + border: 1px solid transparent; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.account-page__nav-item { + width: 100%; + padding: 10px 12px; + border-radius: 8px; + text-align: left; + font-size: 13px; +} + +.account-page__nav-item:hover, +.account-page__back:hover, +.account-page__header-back:hover, +.account-page__theme-choice:hover { + background: var(--bg-hover); + color: var(--color-text); +} + +.account-page__nav-item--active { + background: var(--bg-active) !important; + color: #ffffff !important; +} + +.account-page__back { + margin-top: auto; + padding: 9px 10px; + border-color: var(--color-border); + border-radius: 8px; + font-size: 12px; +} + +.account-page__main { + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; +} + +.account-page__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 22px 28px 18px; + border-bottom: 1px solid var(--color-border); + background: var(--bg-card); +} + +.account-page__header h1 { + margin-top: 4px; + font-size: 24px; + line-height: 1.2; +} + +.account-page__header-back { + padding: 7px 12px; + border-color: var(--color-border); + border-radius: 7px; + font-size: 12px; +} + +.account-page__content { + flex: 1; + overflow-y: auto; + padding: 28px; +} + +.account-page__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + margin-top: 14px; +} + +.account-card { + background: var(--bg-card); + border: 1px solid var(--color-border); + border-radius: 14px; + padding: 18px; + box-shadow: 0 8px 24px rgba(25, 42, 46, 0.06); +} + +.account-page__content > .account-card + .account-card { + margin-top: 14px; +} + +.account-card--profile { + display: flex; + align-items: center; + gap: 18px; +} + +.account-card__avatar-wrap { + flex-shrink: 0; +} + +.account-card__body { + min-width: 0; +} + +.account-card h2 { + margin: 4px 0 6px; + font-size: 22px; +} + +.account-card p { + margin-top: 8px; + color: var(--color-text-secondary); + font-size: 12px; + line-height: 1.7; +} + +.account-card__value { + margin-top: 5px; + font-size: 16px; + font-weight: 700; +} + +.account-page__avatar { + border-radius: 50%; + border: 1px solid var(--color-border); + object-fit: cover; + box-shadow: 0 6px 16px rgba(25, 42, 46, 0.14); +} + +.account-page__avatar--large { + width: 72px; + height: 72px; +} + +.account-page__avatar-fallback { + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-active); + color: #ffffff; + font-size: 26px; + font-weight: 700; +} + +.account-page__theme-row { + display: flex; + gap: 8px; + margin-top: 14px; +} + +.account-page__theme-choice { + padding: 7px 14px; + border-color: var(--color-border); + border-radius: 999px; + font-size: 12px; +} + +.account-page__theme-choice--active { + background: var(--bg-active) !important; + border-color: var(--bg-active) !important; + color: #ffffff !important; +} + +.account-about { + max-width: 820px; +} + +.account-about__hero { + position: relative; + display: grid; + grid-template-columns: auto 1fr; + gap: 22px; + align-items: center; + overflow: hidden; + min-height: 190px; + padding: 26px; + border: 1px solid rgba(47, 127, 134, 0.28); + border-radius: 22px; + background: + radial-gradient(circle at 18% 20%, rgba(70, 160, 166, 0.28), transparent 34%), + linear-gradient(135deg, #ffffff 0%, var(--bg-card) 62%, #e3f0f1 100%); + box-shadow: 0 18px 46px rgba(25, 42, 46, 0.12); +} + +.account-about__hero-glow { + position: absolute; + right: -70px; + top: -90px; + width: 220px; + height: 220px; + border-radius: 50%; + background: rgba(47, 127, 134, 0.22); + filter: blur(10px); + pointer-events: none; +} + +.account-about__logo-wrap { + position: relative; + z-index: 1; + width: 96px; + height: 96px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 28px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(255, 255, 255, 0.72); + box-shadow: 0 16px 30px rgba(25, 42, 46, 0.14); +} + +.account-about__logo { + width: 66px; + height: 66px; + object-fit: contain; +} + +.account-about__hero-body { + position: relative; + z-index: 1; +} + +.account-about__kicker, +.account-about__section-label { + color: var(--color-accent); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.account-about__hero h2 { + margin-top: 8px; + font-size: 32px; + line-height: 1.1; +} + +.account-about__hero p, +.account-about__author p { + margin-top: 10px; + color: var(--color-text-secondary); + font-size: 13px; + line-height: 1.8; +} + +.account-about__tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 18px; +} + +.account-about__tags span { + padding: 5px 10px; + border: 1px solid rgba(47, 127, 134, 0.24); + border-radius: 999px; + background: rgba(47, 127, 134, 0.1); + color: var(--color-accent); + font-size: 11px; + font-weight: 700; +} + +.account-about__layout { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(260px, 1.1fr); + gap: 14px; + margin-top: 14px; +} + +.account-about__panel, +.account-about__footer { + background: var(--bg-card); + border: 1px solid var(--color-border); + border-radius: 18px; + box-shadow: 0 8px 24px rgba(25, 42, 46, 0.06); +} + +.account-about__panel { + padding: 20px; +} + +.account-about__author-name { + margin-top: 8px; + font-size: 24px; + font-weight: 800; + line-height: 1.2; +} + +.account-about__contacts { + display: grid; + gap: 10px; +} + +.account-about__contact { + display: grid; + grid-template-columns: 38px 1fr; + gap: 12px; + align-items: center; + min-width: 0; + padding: 12px; + border: 1px solid var(--color-border); + border-radius: 14px; + color: var(--color-text); + text-decoration: none; + background: rgba(47, 127, 134, 0.04); + transition: transform 0.15s, border-color 0.15s, background 0.15s; +} + +.account-about__contact:hover { + transform: translateY(-1px); + border-color: var(--color-accent); + background: var(--bg-hover); +} + +.account-about__contact-icon { + width: 38px; + height: 38px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: var(--bg-active); + color: #ffffff; + font-size: 14px; + font-weight: 800; +} + +.account-about__contact strong, +.account-about__contact em { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-about__contact strong { + font-size: 13px; + line-height: 1.4; +} + +.account-about__contact em { + margin-top: 2px; + color: var(--color-text-secondary); + font-size: 12px; + font-style: normal; +} + +.account-about__footer { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; + padding: 14px 16px; + color: var(--color-text-secondary); + font-size: 11px; +} + +.account-about__footer span { + padding: 4px 8px; + border-radius: 999px; + background: var(--bg-hover); +} + +[data-theme="dark"] .account-about__logo-wrap { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.1); +} + +[data-theme="dark"] .account-about__hero { + background: + radial-gradient(circle at 18% 20%, rgba(70, 160, 166, 0.22), transparent 34%), + linear-gradient(135deg, #243035 0%, var(--bg-card) 62%, #1f2b2f 100%); +} + +[data-theme="dark"] .account-about__contact { + background: rgba(70, 160, 166, 0.08); +} + +@media (max-width: 760px) { + .account-page__shell { + grid-template-columns: 1fr; + } + + .account-page__sidebar { + display: none; + } + + .account-page__header { + padding: 18px; + } + + .account-page__content { + padding: 18px; + } + + .account-page__grid, + .account-about__layout, + .account-about__hero { + grid-template-columns: 1fr; + } + + .account-about__hero { + gap: 18px; + padding: 22px; + } + + .account-about__hero h2 { + font-size: 28px; + } +} + +.updates-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.update-record { + background: var(--bg-card); + border: 1px solid var(--color-border); + border-radius: 14px; + padding: 18px; + box-shadow: 0 8px 24px rgba(25, 42, 46, 0.06); +} + +.update-record__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.update-record__header h2 { + font-size: 16px; + line-height: 1.3; +} + +.update-record__header time { + color: var(--color-text-secondary); + font-size: 11px; + white-space: nowrap; +} + +.update-record__changes { + display: flex; + flex-direction: column; + gap: 10px; +} + +.update-record__group { + display: grid; + grid-template-columns: 44px 1fr; + gap: 10px; + align-items: start; +} + +.update-record__group-title { + font-size: 12px; + font-weight: 700; + line-height: 1.8; +} + +.update-record__group--added .update-record__group-title { + color: #2e7d32; +} + +.update-record__group--adjusted .update-record__group-title { + color: #7b4f9d; +} + +.update-record__group--fixed .update-record__group-title { + color: #2f6fba; +} + +.update-record__group--improved .update-record__group-title { + color: #b7791f; +} + +.update-record__group--removed .update-record__group-title { + color: var(--color-danger); +} + +.update-record__group ul { + margin: 0; + padding-left: 16px; + color: var(--color-text-secondary); + font-size: 12px; + line-height: 1.8; +} + +.update-record__group li + li { + margin-top: 3px; +} + +/* ── 更新项平台限制标记 ── */ +.update-item__text { + display: inline-block; +} + +.update-item__platform-badge { + display: inline-block; + margin-left: 6px; + padding: 2px 6px; + font-size: 10px; + font-weight: 600; + border-radius: 3px; + white-space: nowrap; + vertical-align: middle; + letter-spacing: 0.3px; +} + +.update-item__platform-badge--current { + background: rgba(46, 125, 50, 0.15); + color: #2e7d32; + border: 1px solid rgba(46, 125, 50, 0.3); +} + +.update-item__platform-badge--other { + background: rgba(191, 144, 0, 0.12); + color: #b7791f; + border: 1px solid rgba(191, 144, 0, 0.25); +} + +/* ── 选项栏(顶部) ── */ +.optionsbar { + grid-area: optionsbar; + background: var(--bg-panel); + border-bottom: 1px solid var(--color-border); + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + padding: 0 12px; + gap: 8px; + overflow-x: auto; + overflow-y: hidden; + white-space: nowrap; + z-index: 9; +} + +.app.app--bars-swapped .optionsbar { + border-top: 1px solid var(--color-border); + border-bottom: none; +} + +.optionsbar__controls { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +/* 选项栏控件 */ +.options-group { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.options-label { + font-size: 11px; + color: var(--color-text-secondary); + white-space: nowrap; +} + +.options-slider { + width: 80px; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: var(--color-border); + border-radius: 2px; + outline: none; + cursor: pointer; +} + +.options-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--color-accent); + cursor: pointer; + border: 2px solid var(--bg-panel); +} + +.options-value { + font-size: 11px; + color: var(--color-text); + min-width: 30px; + text-align: center; +} + +.options-select { + background: var(--bg-card); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: 3px; + padding: 2px 20px 2px 6px; + font-size: 11px; + cursor: pointer; + outline: none; + -webkit-appearance: none; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3E%3Cpath fill='%23637074' d='M4 5.5L1 2.5h6z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 6px center; +} + +.options-select:hover { + border-color: var(--color-accent); +} + +.options-color { + width: 24px; + height: 24px; + border: 1px solid var(--color-border); + border-radius: 3px; + cursor: pointer; + padding: 0; + background: transparent; +} + +.options-color::-webkit-color-swatch-wrapper { + padding: 0; +} + +.options-color::-webkit-color-swatch { + border: none; + border-radius: 2px; +} + +.options-btn-group { + display: flex; + gap: 2px; +} + +.options-btn { + background: var(--bg-card); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: 3px; + padding: 2px 8px; + font-size: 11px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + white-space: nowrap; +} + +.options-btn:hover { + background: var(--bg-hover); + border-color: var(--color-accent); +} + +.options-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.options-btn:disabled:hover { + background: var(--bg-card); + border-color: var(--color-border); +} + +.options-btn.active { + background: var(--bg-active); + color: #ffffff; + border-color: var(--bg-active); +} + +.options-btn-primary { + background: var(--color-accent) !important; + color: #ffffff !important; + border-color: var(--color-accent) !important; +} + +.options-btn-primary:hover { + opacity: 0.85; +} + +.options-btn-sm { + padding: 4px 10px; + font-size: 12px; + line-height: 1.2; +} + +.options-hint { + color: var(--color-text-secondary); + font-size: 12px; + line-height: 1.4; + white-space: nowrap; +} + +.shape-picker-trigger { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 88px; + justify-content: space-between; +} + +.shape-picker-trigger__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + flex: 0 0 18px; +} + +.shape-picker-trigger__arrow { + color: var(--color-text-secondary); + font-size: 10px; +} + +.shape-picker-popover { + position: fixed; + z-index: 2000; + width: 246px; + max-width: calc(100vw - 16px); + color: var(--color-text); + filter: drop-shadow(0 14px 28px rgba(0, 0, 0, 0.22)); +} + +.shape-picker { + background: var(--bg-card); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 8px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35); +} + +.shape-picker__header { + padding: 2px 4px 8px; + color: var(--color-text-secondary); + font-size: 11px; + letter-spacing: 0.04em; +} + +.shape-picker__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; +} + +.shape-picker__item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 58px; + gap: 5px; + padding: 7px 5px; + color: var(--color-text); + background: var(--bg-panel); + border: 1px solid var(--color-border); + border-radius: 6px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.15s; +} + +.shape-picker__item:hover { + background: var(--bg-hover); + border-color: var(--color-accent); + transform: translateY(-1px); +} + +.shape-picker__item.active { + background: var(--bg-active); + border-color: var(--bg-active); + color: #ffffff; +} + +.shape-picker__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; +} + +.shape-icon-svg { + display: block; + width: 100%; + height: 100%; + fill: rgba(47, 127, 134, 0.12); + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + overflow: visible; +} + +.shape-picker__label { + font-size: 11px; + line-height: 1; +} + +.shape-style-btn { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + min-width: 56px; + padding: 4px 7px; + line-height: 1; +} + +.shape-style-btn__swatch { + position: relative; + width: 22px; + height: 15px; + flex: 0 0 15px; + overflow: hidden; + border: 2px solid var(--shape-style-stroke); + border-radius: 4px; + background: #ffffff; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.18); +} + +.shape-style-btn__swatch::before, +.shape-style-btn__swatch::after { + content: ''; + position: absolute; + inset: 0; +} + +.shape-style-btn__swatch::before { + background-image: + linear-gradient(45deg, rgba(0, 0, 0, 0.14) 25%, transparent 25%), + linear-gradient(-45deg, rgba(0, 0, 0, 0.14) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(0, 0, 0, 0.14) 75%), + linear-gradient(-45deg, transparent 75%, rgba(0, 0, 0, 0.14) 75%); + background-size: 8px 8px; + background-position: 0 0, 0 4px, 4px -4px, -4px 0; +} + +.shape-style-btn__swatch::after { + background: var(--shape-style-fill); +} + +.shape-style-btn.active .shape-style-btn__swatch { + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.72), 0 0 0 3px rgba(255, 255, 255, 0.18); +} + +.text-preset-btn { + background: #a8b0b5; + color: var(--text-preset-fill, var(--color-text)); + border-color: #8a949a; + font-size: 17px; + font-weight: 700; + line-height: 1.15; + -webkit-text-stroke: 0.45px var(--text-preset-stroke, transparent); + text-shadow: + 0 1px 0 var(--text-preset-stroke, transparent), + 1px 0 0 var(--text-preset-stroke, transparent), + 0 -1px 0 var(--text-preset-stroke, transparent), + -1px 0 0 var(--text-preset-stroke, transparent); +} + +.text-preset-btn:hover { + background: #9ca5aa; + border-color: #7c878d; +} + +/* 滤镜预设按钮(选项栏) */ +.filter-preset-btn { + min-width: 44px; +} + +.filter-preset-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.brush-color-btn { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.brush-color-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--brush-color); + border: 1px solid rgba(0, 0, 0, 0.24); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.35) inset; +} + +/* ── 画布区(中央) ── */ +.canvas-area { + grid-area: canvas; + position: relative; + background: var(--bg-canvas); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +.canvas-area__container { + position: relative; + width: 100%; + height: 100%; +} + +.canvas-area__container canvas { + display: block; +} + +/* 画布网格背景 */ +.canvas-area::before { + content: ''; + position: absolute; + inset: 0; + background-image: + linear-gradient(45deg, var(--color-border) 25%, transparent 25%), + linear-gradient(-45deg, var(--color-border) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, var(--color-border) 75%), + linear-gradient(-45deg, transparent 75%, var(--color-border) 75%); + background-size: 16px 16px; + background-position: 0 0, 0 8px, 8px -8px, -8px 0; + opacity: 0.15; + pointer-events: none; +} + +/* 缩放控制 */ +.zoom-control { + position: absolute; + bottom: 6px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 6px; + background: var(--bg-panel); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 4px 8px; + z-index: 5; +} + +.zoom-control__btn { + width: 22px; + height: 22px; + border: none; + background: transparent; + color: var(--color-text); + cursor: pointer; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + line-height: 1; +} + +.zoom-control__btn:hover { + background: var(--bg-hover); +} + +.zoom-control__value { + font-size: 11px; + color: var(--color-text); + min-width: 40px; + text-align: center; + cursor: pointer; +} + +.zoom-control__value:hover { + color: var(--color-accent); +} + +/* ── 右侧面板(属性 / 图层) ── */ +.panel-area { + grid-area: panel; + background: var(--bg-panel); + border-left: 1px solid var(--color-border); + overflow: hidden; +} + +.app.app--panel-left .panel-area { + border-left: none; + border-right: 1px solid var(--color-border); +} + +.side-tabs { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.side-tabs__header { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2px; + padding: 5px; + background: var(--bg-panel); + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.side-tabs__tab { + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + min-width: 0; + height: 26px; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + font-size: 11px; + font-weight: 600; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.side-tabs__tab:hover { + background: var(--bg-hover); + color: var(--color-text); +} + +.side-tabs__tab--active { + background: var(--bg-card); + color: var(--color-text); + border-color: var(--color-border); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); +} + +.side-tabs__badge { + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 999px; + background: var(--bg-hover); + color: var(--color-text-secondary); + font-size: 10px; + line-height: 16px; + text-align: center; +} + +.side-tabs__tab--active .side-tabs__badge { + background: var(--bg-active); + color: #ffffff; +} + +.side-tabs__content { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.side-tabs__pane { + height: 100%; + min-height: 0; +} + +.side-tabs__pane:not(.side-tabs__pane--active) { + display: none; +} + +.side-tabs__pane #property-panel, +.side-tabs__pane #color-panel, +.side-tabs__pane #layer-panel { + height: 100%; + min-height: 0; +} + +.side-tabs--tabs .panel__header { + display: none; +} + +.side-tabs--tabs .panel--property { + border-bottom: 0; +} + +.side-tabs--split .side-tabs__header { + display: none; +} + +.side-tabs--split .side-tabs__content { + display: flex; + flex-direction: column; +} + +.side-tabs--split .side-tabs__pane[data-panel-pane="property"] { + flex: 2; + height: auto; +} + +.side-tabs--split .side-tabs__pane[data-panel-pane="layer"] { + flex: 3; + height: auto; + border-top: 1px solid var(--color-border); +} + +.panel { + display: flex; + flex-direction: column; + overflow: hidden; + height: 100%; +} + +.panel--property { + border-bottom: 1px solid var(--color-border); +} + +.panel--layer { +} + +.panel--layer .panel__body { + display: block; + flex-wrap: initial; + gap: initial; + align-content: initial; +} + +.panel__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + background: var(--bg-panel); + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.panel__title { + font-size: 10px; + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.panel__body { + flex: 1; + overflow-y: auto; + padding: 4px; + display: flex; + flex-wrap: wrap; + gap: 4px; + align-content: flex-start; +} + +/* ── 属性面板 ── */ +.property-section-title { + width: 100%; + padding: 4px 6px 2px; + color: var(--color-text-secondary); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.4px; +} + +.property-empty { + padding: 8px; + text-align: center; + color: var(--color-text-secondary); + font-size: 11px; + width: 100%; +} + +.property-item { + display: flex; + align-items: center; + gap: 3px; + padding: 2px 4px; + background: var(--bg-card); + border: 1px solid var(--color-border); + border-radius: 4px; + flex-shrink: 0; +} + +.property-item--wide { + min-width: 132px; +} + +.property-item--stacked { + width: 100%; + align-items: stretch; + flex-direction: column; + gap: 6px; + padding: 6px; +} + +.property-item--stacked label { + text-align: left; +} + +.property-shape-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 5px; +} + +.property-shape-btn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 48px; + gap: 3px; + padding: 5px 3px; + color: var(--color-text); + background: var(--bg-panel); + border: 1px solid var(--color-border); + border-radius: 5px; + cursor: pointer; + font-size: 10px; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.property-shape-btn:hover { + background: var(--bg-hover); + border-color: var(--color-accent); +} + +.property-shape-btn.active { + background: var(--bg-active); + border-color: var(--bg-active); + color: #ffffff; +} + +.property-shape-btn__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; +} + +.property-shape-btn__label { + line-height: 1; +} + +.property-actions { + width: 100%; + display: flex; + gap: 6px; + padding: 4px; +} + +.property-btn { + flex: 1; + min-width: 0; + padding: 5px 8px; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--bg-card); + color: var(--color-text); + cursor: pointer; + font-size: 11px; + transition: background 0.15s, border-color 0.15s; +} + +.property-btn:hover { + background: var(--bg-hover); + border-color: var(--color-accent); +} + +.property-btn--primary { + background: var(--color-accent); + border-color: var(--color-accent); + color: #ffffff; +} + +.property-btn--primary:hover { + opacity: 0.88; +} + +/* 调色重置按钮行 */ +.property-item--actions { + justify-content: flex-end; + padding: 4px 6px; +} + +.property-item--actions .property-btn { + flex: 0 0 auto; + padding: 4px 12px; +} + +.property-item label { + font-size: 10px; + color: var(--color-text-secondary); + flex-shrink: 0; + text-align: right; +} + +.property-input { + width: 60px; + background: transparent; + color: var(--color-text); + border: none; + padding: 1px 2px; + font-size: 11px; + outline: none; + text-align: center; + -moz-appearance: textfield; +} + +.property-input--name, +.property-input--text { + width: 96px; + text-align: left; +} + +.property-input::-webkit-inner-spin-button, +.property-input::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.property-input:focus { + outline: 1px solid var(--color-accent); + border-radius: 2px; +} + +.property-color { + width: 24px; + height: 20px; + border: 1px solid var(--color-border); + border-radius: 3px; + cursor: pointer; + padding: 0; + background: transparent; +} + +.property-select { + width: 120px; + background: var(--bg-card); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: 3px; + padding: 1px 4px; + font-size: 11px; + outline: none; +} + +.property-select--short { + width: 88px; +} + +.property-checkbox { + width: 14px; + height: 14px; + margin: 0; + accent-color: var(--color-accent); +} + +.property-static { + min-width: 34px; + color: var(--color-text); + font-size: 11px; +} + +.property-input:disabled, +.property-select:disabled, +.property-color:disabled, +.property-checkbox:disabled, +.property-range:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.property-range { + width: 90px; + flex: 0 0 auto; + height: 3px; + -webkit-appearance: none; + appearance: none; + background: var(--color-border); + border-radius: 2px; + outline: none; +} + +.property-range::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--color-accent); + cursor: pointer; +} + +.property-value { + font-size: 10px; + color: var(--color-text-secondary); + min-width: 28px; + text-align: center; + flex-shrink: 0; +} + +/* ── 图层面板 ── */ +.layer-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 1px; + padding: 0; + margin: 0; + width: 100%; +} + +.layer-item { + position: relative; + display: flex; + align-items: center; + gap: 4px; + padding: 3px 6px; + border: 1px solid transparent; + border-radius: 3px; + cursor: grab; + font-size: 11px; + color: var(--color-text); + transition: background 0.1s, border-color 0.1s, box-shadow 0.1s; +} + +.layer-item:active { + cursor: grabbing; +} + +.layer-item:hover { + background: var(--bg-hover); +} + +.layer-item--selected { + background: var(--bg-active) !important; + border-color: var(--color-accent); + box-shadow: inset 3px 0 0 rgba(255, 255, 255, 0.85); + color: #ffffff !important; +} + +.layer-item--selected .layer-item__thumbnail { + background: rgba(255, 255, 255, 0.16); + border-color: rgba(255, 255, 255, 0.58); + color: #ffffff; +} + +.layer-item--selected .layer-item__visibility:hover, +.layer-item--selected .layer-item__lock:hover { + background: rgba(255, 255, 255, 0.16); +} + +.layer-item--dragging { + opacity: 0.45; +} + +.layer-item--drop-before::before, +.layer-item--drop-after::after { + content: ''; + position: absolute; + left: 6px; + right: 6px; + height: 2px; + background: var(--color-accent); + border-radius: 2px; + box-shadow: 0 0 0 1px var(--bg-panel); + pointer-events: none; +} + +.layer-item--drop-before::before { + top: -1px; +} + +.layer-item--drop-after::after { + bottom: -1px; +} + +.layer-item__visibility { + width: 16px; + height: 16px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0.7; + border-radius: 2px; +} + +.layer-item__visibility:hover { + opacity: 1; + background: var(--bg-hover); +} + +.layer-item__visibility svg { + width: 12px; + height: 12px; +} + +.layer-item__thumbnail { + width: 20px; + height: 14px; + flex-shrink: 0; + border-radius: 2px; + background: var(--bg-card); + border: 1px solid var(--color-border); + display: flex; + align-items: center; + justify-content: center; + font-size: 8px; + color: var(--color-text-secondary); +} + +.layer-item__name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.layer-item__lock { + width: 16px; + height: 16px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0.4; + border-radius: 2px; +} + +.layer-item__lock:hover { + opacity: 0.8; + background: var(--bg-hover); +} + +.layer-item__lock--locked { + opacity: 0.8; +} + +.layer-item__lock svg { + width: 10px; + height: 10px; +} + +/* 背景图层特殊样式 */ +.layer-item--background { + border-bottom: 1px solid var(--color-border); + color: var(--color-text-secondary); + cursor: default; +} + +.layer-item--background .layer-item__name { + font-style: italic; +} + +.layer-item__lock--bg { + opacity: 0.3 !important; + cursor: default !important; + pointer-events: none; +} + +.layer-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 4px; + border-top: 1px solid var(--color-border); +} + +.layer-actions__btn { + width: 20px; + height: 20px; + border: none; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; +} + +.layer-actions__btn:hover { + background: var(--bg-hover); + color: var(--color-text); +} + +/* ── 状态栏(底部) ── */ +.statusbar { + grid-area: statusbar; + background: var(--bg-statusbar); + color: #ffffff; + display: flex; + align-items: center; + padding: 0 10px; + font-size: 11px; + gap: 12px; + z-index: 9; +} + +.app.app--bars-swapped .statusbar { + border-bottom: 1px solid rgba(255,255,255,0.12); +} + +.statusbar__left { + display: flex; + align-items: center; + gap: 12px; +} + +.statusbar__item { + display: flex; + align-items: center; + gap: 4px; + opacity: 0.85; +} + +.statusbar__separator { + width: 1px; + height: 14px; + background: rgba(255,255,255,0.3); +} + +.statusbar__right { + margin-left: auto; + display: flex; + align-items: center; + gap: 4px; +} + +.statusbar__btn { + border: 1px solid rgba(255,255,255,0.55); + background: rgba(255,255,255,0.08); + color: #ffffff; + cursor: pointer; + padding: 3px 14px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + transition: background 0.15s, box-shadow 0.15s; +} + +.statusbar__btn:hover { + background: rgba(255,255,255,0.25); + border-color: rgba(255,255,255,0.75); + box-shadow: 0 1px 4px rgba(0,0,0,0.15); +} + +.statusbar__btn--primary { + background: #ffffff; + color: var(--bg-statusbar); + border-color: #ffffff; + font-weight: 600; + box-shadow: 0 1px 3px rgba(0,0,0,0.2); +} + +.statusbar__btn--primary:hover { + background: #e8f7f8; + color: var(--bg-statusbar); + box-shadow: 0 2px 6px rgba(0,0,0,0.25); +} + +/* ── 标签页(多图编辑预留) ── */ +.tabs { + display: flex; + align-items: center; + background: var(--bg-panel); + border-bottom: 1px solid var(--color-border); + height: 28px; + flex-shrink: 0; +} + +.tab { + padding: 3px 12px; + font-size: 11px; + color: var(--color-text-secondary); + border-right: 1px solid var(--color-border); + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tab:hover { + background: var(--bg-hover); +} + +.tab--active { + background: var(--bg-window); + color: var(--color-text); +} + +.tab__close { + width: 14px; + height: 14px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 2px; + flex-shrink: 0; +} + +.tab__close:hover { + background: var(--color-border); +} + +/* ── 欢迎/导入界面 ── */ +.welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 20px; + color: var(--color-text-secondary); +} + +.welcome__icon { + width: 80px; + height: 80px; + border: 2px dashed var(--color-border); + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; +} + +.welcome__icon:hover { + border-color: var(--color-accent); + background: var(--bg-hover); +} + +.welcome__icon svg { + width: 36px; + height: 36px; + opacity: 0.5; +} + +.welcome__text { + font-size: 13px; +} + +.welcome__hint { + font-size: 11px; + opacity: 0.6; +} + +.welcome__btn { + background: var(--color-accent); + color: #ffffff; + border: none; + padding: 6px 20px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + transition: opacity 0.15s; +} + +.welcome__btn:hover { + opacity: 0.85; +} + +/* ── 加载状态 ── */ +.loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--color-text-secondary); + font-size: 13px; +} + +/* ── 隐藏类 ── */ +.hidden { + display: none !important; +} + +/* ── Toast 提示 ── */ +.toast { + position: fixed; + top: 60px; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + display: flex; + align-items: center; + gap: 6px; + padding: 8px 20px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + pointer-events: none; + animation: toastFadeInOut 2s ease forwards; +} + +.toast--success { + background: #2e7d32; + color: #ffffff; + box-shadow: 0 4px 12px rgba(46,125,50,0.4); +} + +.toast--error { + background: #c62828; + color: #ffffff; + box-shadow: 0 4px 12px rgba(198,40,40,0.4); +} + +.toast__icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +@keyframes toastFadeInOut { + 0% { opacity: 0; transform: translateX(-50%) translateY(-10px); } + 15% { opacity: 1; transform: translateX(-50%) translateY(0); } + 75% { opacity: 1; transform: translateX(-50%) translateY(0); } + 100% { opacity: 0; transform: translateX(-50%) translateY(-10px); } +} diff --git a/plugins/Image-Toolbox/src/ui/AccountPage.js b/plugins/Image-Toolbox/src/ui/AccountPage.js new file mode 100644 index 000000000..4fcde80bc --- /dev/null +++ b/plugins/Image-Toolbox/src/ui/AccountPage.js @@ -0,0 +1,570 @@ +import { eventBus } from '../../core/src/index.js'; +import { SIDE_PANEL_LAYOUT_KEY, SIDE_PANEL_LAYOUTS } from './SidePanelTabs.js'; +import { THEME_CHOICES, applyThemeChoice, getThemeChoice } from '../../core/src/utils/theme.js'; +import { updateCategories, updateRecords, PLATFORMS } from '../../core/src/updateRecords.js'; +import { escapeHTML, escapeAttr } from '../../core/src/utils/helpers.js'; + +/** + * 获取当前平台标识 + */ +function getCurrentPlatform() { + if (typeof window === 'undefined') return null; + if (window.ztools) return PLATFORMS.ZTOOLS; + if (window.utools) return PLATFORMS.UTOOLS; + return null; +} + +/** + * 检查更新项是否应在当前平台显示 + * @param {null|string[]} platforms - 平台限制 (null=所有平台, ['utools']=仅utools等) + * @returns {boolean} 是否应显示 + */ +function shouldShowForCurrentPlatform(platforms) { + if (platforms === null || platforms === undefined) return true; + if (!Array.isArray(platforms)) return true; + + const currentPlatform = getCurrentPlatform(); + return platforms.includes(currentPlatform); +} + +export const EDITOR_BARS_LAYOUT_KEY = 'image-toolbox-editor-bars-layout'; +export const EDITOR_BARS_LAYOUTS = { + PRESETS_TOP: 'presets-top', + STATUS_TOP: 'status-top', +}; + +export const EDITOR_SIDE_PANEL_POSITION_KEY = 'image-toolbox-editor-side-panel-position'; +export const EDITOR_SIDE_PANEL_POSITIONS = { + RIGHT: 'right', + LEFT: 'left', +}; + +const VALID_EDITOR_BARS_LAYOUTS = new Set(Object.values(EDITOR_BARS_LAYOUTS)); +const VALID_EDITOR_SIDE_PANEL_POSITIONS = new Set(Object.values(EDITOR_SIDE_PANEL_POSITIONS)); + +/** + * Account page UI component. + * Opens from the avatar into a standalone page with side navigation. + */ +class AccountPage { + constructor(containerEl, editorEl, sidePanelTabs, host = null) { + this._el = containerEl; + this._editorEl = editorEl; + this._sidePanelTabs = sidePanelTabs; + this._host = host; + this._activeSection = 'mine'; + this._user = this._getHostUser(); + this._eventBusUnsubscribers = []; + + this._render(); + this._bindEvents(); + } + + open() { + this._user = this._getHostUser(); + this._render(); + this._editorEl?.classList.add('hidden'); + this._el?.classList.remove('hidden'); + } + + close() { + this._el?.classList.add('hidden'); + this._editorEl?.classList.remove('hidden'); + } + + _render() { + if (!this._el) return; + + const sectionTitle = this._getSectionTitle(this._activeSection); + this._el.innerHTML = ` + + `; + } + + _bindEvents() { + if (!this._el) return; + + this._eventBusUnsubscribers.push( + eventBus.on('account:open', () => this.open()) + ); + + this._el.addEventListener('click', (e) => { + const navItem = this._closest(e.target, '[data-section]'); + if (navItem) { + this._activeSection = navItem.getAttribute('data-section'); + this._render(); + return; + } + + const action = this._closest(e.target, '[data-action]')?.getAttribute('data-action'); + if (action === 'back') { + this.close(); + return; + } + + const externalUrl = this._closest(e.target, '[data-external-url]')?.getAttribute('data-external-url'); + if (externalUrl) { + e.preventDefault(); + this._openExternalUrl(externalUrl); + return; + } + + const theme = this._closest(e.target, '[data-theme-choice]')?.getAttribute('data-theme-choice'); + if (theme) { + this._setTheme(theme); + this._render(); + return; + } + + const panelLayout = this._closest(e.target, '[data-side-panel-layout]')?.getAttribute('data-side-panel-layout'); + if (panelLayout) { + this._setSidePanelLayout(panelLayout); + this._render(); + return; + } + + const editorBarsLayout = this._closest(e.target, '[data-editor-bars-layout]')?.getAttribute('data-editor-bars-layout'); + if (editorBarsLayout) { + this._setEditorBarsLayout(editorBarsLayout); + this._render(); + return; + } + + const editorSidePanelPosition = this._closest(e.target, '[data-editor-side-panel-position]')?.getAttribute('data-editor-side-panel-position'); + if (editorSidePanelPosition) { + this._setEditorSidePanelPosition(editorSidePanelPosition); + this._render(); + } + }); + + this._el.addEventListener('error', (e) => { + const avatar = e.target.closest?.('.account-page__avatar-img'); + if (!avatar) return; + const fallback = document.createElement('div'); + fallback.className = avatar.className.replace('account-page__avatar-img', 'account-page__avatar-fallback'); + fallback.textContent = avatar.dataset.initial || 'U'; + avatar.replaceWith(fallback); + }, true); + } + + _renderNavItem(section, label) { + const isActive = this._activeSection === section; + return ` + + `; + } + + _closest(target, selector) { + if (!target) return null; + const match = typeof target.closest === 'function' + ? target.closest(selector) + : target.parentElement?.closest?.(selector) || null; + + if (match && typeof this._el?.contains === 'function' && !this._el.contains(match)) return null; + return match; + } + + _renderSection() { + if (this._activeSection === 'settings') return this._renderSettings(); + if (this._activeSection === 'updates') return this._renderUpdates(); + if (this._activeSection === 'about') return this._renderAbout(); + return this._renderMine(); + } + + _renderMine() { + const user = this._getUserView(); + const hostName = this._getHostName(); + return ` + + `; + } + + _renderSettings() { + const theme = getThemeChoice(); + const sidePanelLayout = this._getSidePanelLayout(); + const editorBarsLayout = this._getEditorBarsLayout(); + const editorSidePanelPosition = this._getEditorSidePanelPosition(); + return ` + + + + + + + + `; + } + + _renderAbout() { + const appVersion = this._getCurrentVersion(); + const hostName = this._getHostName(); + const hostVersion = this._getHostVersion(); + + return ` + + `; + } + + _renderUpdates() { + return ` +
      + ${updateRecords.map(record => this._renderUpdateRecord(record)).join('')} +
      + `; + } + + _renderUpdateRecord(record) { + return ` +
      +
      +

      版本 ${this._escapeHTML(record.version)}

      + +
      +
      + ${updateCategories.map(category => this._renderChangeGroup(record, category)).join('')} +
      +
      + `; + } + + _renderChangeGroup(record, category) { + const items = record.changes?.[category.key] || []; + if (items.length === 0) return ''; + + // 过滤出当前平台应显示的项目 + const visibleItems = items.filter(item => { + // 兼容旧格式(字符串) + if (typeof item === 'string') return true; + // 新格式(对象)- 检查平台限制 + return shouldShowForCurrentPlatform(item.platforms); + }); + + if (visibleItems.length === 0) return ''; + + return ` +
      +
      ${this._escapeHTML(category.title)}
      +
        + ${visibleItems.map(item => this._renderChangeItem(item)).join('')} +
      +
      + `; + } + + /** + * 渲染单个更新项,处理平台限制标记 + */ + _renderChangeItem(item) { + // 兼容旧格式(字符串) + if (typeof item === 'string') { + return `
    • ${this._escapeHTML(item)}
    • `; + } + + // 新格式(对象) + const text = item.text || ''; + const platforms = item.platforms; + const currentPlatform = getCurrentPlatform(); + + // 如果有平台限制且当前不是所有平台,添加平台标签 + let badge = ''; + if (Array.isArray(platforms) && platforms.length > 0 && platforms.length < 3) { + const platformLabels = { + 'utools': 'uTools', + 'ztools': 'ZTools', + 'local': '本地环境' + }; + const labels = platforms.map(p => platformLabels[p] || p).join('/'); + const isCurrentPlatform = shouldShowForCurrentPlatform(platforms); + const badgeClass = isCurrentPlatform ? 'update-item__platform-badge--current' : 'update-item__platform-badge--other'; + badge = `${this._escapeHTML(labels)}`; + } + + return `
    • ${this._escapeHTML(text)}${badge}
    • `; + } + + _renderAvatar(className) { + const user = this._getUserView(); + const title = this._escapeAttr(user.name); + const initial = this._escapeAttr(user.initial); + + if (user.avatar) { + return ``; + } + + return ``; + } + + _getUserView() { + const user = this._user || {}; + const hostName = this._getHostName(); + const name = user.nickname || user.name || user.userName || user.username || `${hostName} 用户`; + const avatar = user.avatar || user.avatarUrl || user.photo || ''; + return { + name, + avatar, + initial: this._getInitial(name), + status: this._user ? `已连接 ${hostName} 用户信息` : `未获取到 ${hostName} 用户信息`, + }; + } + + _getSectionTitle(section) { + const titles = { + mine: '我的', + settings: '设置', + updates: '更新记录', + about: '关于', + }; + return titles[section] || titles.mine; + } + + _setTheme(theme) { + applyThemeChoice(theme); + } + + _getCurrentVersion() { + const version = updateRecords?.[0]?.version; + return this._formatVersion(version); + } + + _getHostVersion() { + try { + return this._formatVersion(this._host?.platform?.version || this._host?.getHostAppVersion?.()); + } catch (e) { + console.warn('[AccountPage] 获取宿主版本失败:', e); + } + + return '未知'; + } + + _formatVersion(version) { + const text = String(version || '').trim(); + if (!text) return '未知'; + return /^v/i.test(text) ? text : `v${text}`; + } + + _openExternalUrl(url) { + if (!url) return; + + try { + if (this._host?.system?.openExternal?.(url) || this._host?.openHostExternal?.(url)) { + return; + } + } catch (e) { + console.warn('[AccountPage] 使用宿主打开外部链接失败:', e); + } + + window.open(url, '_blank', 'noopener,noreferrer'); + } + + _setSidePanelLayout(layout) { + if (!Object.values(SIDE_PANEL_LAYOUTS).includes(layout)) return; + + localStorage.setItem(SIDE_PANEL_LAYOUT_KEY, layout); + this._sidePanelTabs?.applyLayout(layout, false); + eventBus.emit('sidePanel:layoutChanged', layout); + } + + _setEditorBarsLayout(layout) { + if (!VALID_EDITOR_BARS_LAYOUTS.has(layout)) return; + + localStorage.setItem(EDITOR_BARS_LAYOUT_KEY, layout); + eventBus.emit('editorBars:layoutChanged', layout); + } + + _setEditorSidePanelPosition(position) { + if (!VALID_EDITOR_SIDE_PANEL_POSITIONS.has(position)) return; + + localStorage.setItem(EDITOR_SIDE_PANEL_POSITION_KEY, position); + eventBus.emit('editorSidePanel:positionChanged', position); + } + + _getSidePanelLayout() { + const saved = localStorage.getItem(SIDE_PANEL_LAYOUT_KEY); + return Object.values(SIDE_PANEL_LAYOUTS).includes(saved) ? saved : SIDE_PANEL_LAYOUTS.TABS; + } + + _getEditorBarsLayout() { + const saved = localStorage.getItem(EDITOR_BARS_LAYOUT_KEY); + return VALID_EDITOR_BARS_LAYOUTS.has(saved) ? saved : EDITOR_BARS_LAYOUTS.PRESETS_TOP; + } + + _getEditorSidePanelPosition() { + const saved = localStorage.getItem(EDITOR_SIDE_PANEL_POSITION_KEY); + return VALID_EDITOR_SIDE_PANEL_POSITIONS.has(saved) ? saved : EDITOR_SIDE_PANEL_POSITIONS.RIGHT; + } + + _getHostUser() { + try { + const result = this._host?.user?.getCurrentUser?.() || this._host?.getHostUser?.() || null; + if (result && typeof result.then === 'function') { + result.then((user) => { + this._user = user; + this._render(); + }).catch((e) => console.warn('[AccountPage] 获取宿主用户信息失败:', e)); + return null; + } + return result; + } catch (e) { + console.warn('[AccountPage] 获取宿主用户信息失败:', e); + } + return null; + } + + _getHostName() { + return this._host?.platform?.name || this._host?.getHostName?.() || 'uTools'; + } + + _getInitial(name) { + const text = String(name || '').trim(); + return text ? text.slice(0, 1).toUpperCase() : 'U'; + } + + _escapeAttr(value) { + return escapeAttr(value); + } + + _escapeHTML(value) { + return escapeHTML(value); + } + + destroy() { + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; + } +} + +export default AccountPage; diff --git a/plugins/Image-Toolbox/src/ui/ColorPanel.js b/plugins/Image-Toolbox/src/ui/ColorPanel.js new file mode 100644 index 000000000..fcf77f9e7 --- /dev/null +++ b/plugins/Image-Toolbox/src/ui/ColorPanel.js @@ -0,0 +1,262 @@ +import { eventBus } from '../../core/src/index.js'; +import { FILTER_RANGES, FILTER_PRESETS, getFilterUiValue, setFilter, clearFilters, applyFilterPreset, isPresetActive } from '../../core/src/utils/filters.js'; + +/** + * 调色面板 — 侧栏「调色」Tab + * 选中图片图层时显示滤镜预设、亮度/对比度/饱和度/色相/模糊滑块与重置按钮。 + * 非图片图层或未选中时显示提示文本。 + */ +class ColorPanel { + constructor(containerEl, canvasManager, historyManager) { + this._el = containerEl; + this._cm = canvasManager; + this._hm = historyManager; + this._filterScope = 'all'; + this._eventBusUnsubscribers = []; + + this._render(); + this._bindEvents(); + this._update(); + } + + _render() { + this._el.innerHTML = ` +
      +
      +
      选中图片图层以调色
      +
      +
      + `; + } + + _bindEvents() { + this._eventBusUnsubscribers.push( + eventBus.on('canvas:selectionCreated', () => this._update()), + eventBus.on('canvas:selectionUpdated', () => this._update()), + eventBus.on('canvas:selectionCleared', () => this._update()), + eventBus.on('layer:selected', () => this._update()), + eventBus.on('canvas:objectModified', () => this._update()), + eventBus.on('canvas:restored', () => this._update()), + eventBus.on('image:loaded', () => this._clearHint()) + ); + + this._el.addEventListener('input', (e) => this._handleEvent(e)); + this._el.addEventListener('change', (e) => this._handleEvent(e)); + this._el.addEventListener('click', (e) => this._handleEvent(e)); + } + + _update() { + const bodyEl = this._el.querySelector('#color-panel-body'); + if (!bodyEl) return; + + const reference = this._getReferenceImage(); + if (reference) { + bodyEl.innerHTML = this._getColorAdjustHTML(reference); + } else { + const hint = this._getAllImages().length > 0 + ? '选中图片图层以调色' + : '当前画布没有可调色的图片图层'; + bodyEl.innerHTML = `${this._getScopeControlHTML()}
      ${hint}
      `; + } + } + + _clearHint() { + this._update(); + } + + _getColorAdjustHTML(active) { + const items = [ + { type: 'brightness', label: '亮度' }, + { type: 'contrast', label: '对比' }, + { type: 'saturation',label: '饱和' }, + { type: 'hue', label: '色相' }, + { type: 'blur', label: '模糊' }, + ]; + + const sliders = items.map(({ type, label }) => { + const range = FILTER_RANGES[type]; + const value = getFilterUiValue(active, type); + return ` +
      + + + ${value} +
      + `; + }).join(''); + + const filterPresets = FILTER_PRESETS.map(preset => { + const targets = this._getTargetImages(); + const isActive = this._filterScope === 'all' + ? targets.length > 0 && targets.every(image => isPresetActive(image, preset.preset)) + : isPresetActive(active, preset.preset); + return ``; + }).join(''); + + const scopeTitle = this._filterScope === 'all' + ? `调色 (${this._getTargetImages().length} 个图片图层)` + : '调色'; + + return ` + ${this._getScopeControlHTML()} +
      滤镜
      +
      ${filterPresets}
      +
      ${scopeTitle}
      + ${sliders} +
      + +
      + `; + } + + _handleEvent(e) { + // 滤镜预设按钮(一键应用) + const presetTarget = e.target.closest('[data-preset]'); + if (presetTarget && this._el.contains(presetTarget)) { + const preset = presetTarget.dataset.preset; + if (preset && preset.startsWith('filter-')) { + if (e.type !== 'click') return; + this._applyFilterPreset(preset); + return; + } + } + + // 调色滑块 / 重置 + const target = e.target.closest('[data-prop]'); + if (!target || !this._el.contains(target)) return; + + const prop = target.dataset.prop; + if (!prop) return; + + if (prop === 'filterScope') { + if (e.type !== 'change') return; + this._filterScope = target.value === 'all' ? 'all' : 'current'; + this._update(); + return; + } + + if (!prop.startsWith('filter:')) return; + + const targets = this._getTargetImages(); + if (targets.length === 0) return; + + const value = target.value; + + // 重置按钮 + if (prop === 'filter:reset') { + if (e.type !== 'click') return; + this._hm?.saveState?.(); + targets.forEach(image => clearFilters(image)); + this._markImagesChanged(targets); + this._requestRender(); + this._notifyObjectChanged(targets[0]); + this._update(); + return; + } + + // 滑块 + const type = prop.slice('filter:'.length); + const uiValue = parseInt(value, 10); + if (!Number.isFinite(uiValue)) return; + + targets.forEach(image => setFilter(image, type, uiValue)); + this._markImagesChanged(targets); + + if (target.nextElementSibling && target.nextElementSibling.classList.contains('property-value')) { + target.nextElementSibling.textContent = String(uiValue); + } + + this._requestRender(); + + if (e.type === 'change') { + this._notifyObjectChanged(targets[0]); + } + } + + _applyFilterPreset(presetName) { + const targets = this._getTargetImages(); + if (targets.length === 0) return; + this._hm?.saveState?.(); + targets.forEach(image => applyFilterPreset(image, presetName)); + this._markImagesChanged(targets); + this._requestRender(); + this._notifyObjectChanged(targets[0]); + this._update(); + } + + _getScopeControlHTML() { + return ` +
      作用范围
      +
      + + +
      + `; + } + + _getTargetImages() { + if (this._filterScope === 'all') { + return this._getAllImages(); + } + + const active = this._getActiveObject(); + return active && active.type === 'image' && active.type !== 'activeSelection' ? [active] : []; + } + + _getReferenceImage() { + if (this._filterScope === 'all') { + const active = this._getActiveObject(); + return active?.type === 'image' ? active : this._getAllImages()[0] || null; + } + + const active = this._getActiveObject(); + return active?.type === 'image' ? active : null; + } + + _getAllImages() { + const canvas = this._cm?.canvas; + if (!canvas) return []; + return canvas.getObjects().filter(obj => ( + obj && + obj.type === 'image' && + !obj.excludeFromLayer && + !obj.excludeFromHistory + )); + } + + _markImagesChanged(images) { + images.forEach(image => { + image.dirty = true; + image.setCoords(); + }); + } + + _getActiveObject() { + return this._cm?.getActiveObject?.() || null; + } + + _notifyObjectChanged(active) { + eventBus.emit('canvas:objectModified', active); + } + + _requestRender() { + const canvas = this._cm?.canvas; + if (!canvas) return; + if (typeof canvas.requestRenderAll === 'function') { + canvas.requestRenderAll(); + } else { + canvas.renderAll(); + } + } + + destroy() { + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; + } +} + +export default ColorPanel; diff --git a/plugins/Image-Toolbox/src/ui/LayerPanel.js b/plugins/Image-Toolbox/src/ui/LayerPanel.js new file mode 100644 index 000000000..c01da7910 --- /dev/null +++ b/plugins/Image-Toolbox/src/ui/LayerPanel.js @@ -0,0 +1,392 @@ +import { eventBus } from '../../core/src/index.js'; +import { escapeHTML, escapeAttr } from '../../core/src/utils/helpers.js'; + +/** + * 图层面板 UI 组件 + * 显示所有图层列表,支持显隐/锁定/选中/排序 + */ +class LayerPanel { + constructor(containerEl, layerManager) { + this._el = containerEl; + this._lm = layerManager; + this._dragLayerId = null; + this._dropPanelIndex = null; + this._selectedLayerId = null; + this._activeLayerIds = []; + this._eventBusUnsubscribers = []; + + this._bindEvents(); + this._render(); + } + + _render() { + this._el.innerHTML = ` +
      +
      + 图层 + 0 +
      +
      +
        +
        +
        + + +
        +
        + `; + + this._refreshLayerList(); + } + + _bindEvents() { + // Refresh the list when layers change. + this._eventBusUnsubscribers.push( + eventBus.on('layers:updated', () => { + this._refreshLayerList(); + }), + eventBus.on('canvas:objectAdded', (obj) => { + this._lm.syncLayers(); + this._selectLayerByObject(obj); + }), + eventBus.on('canvas:objectRemoved', () => { + this._lm.syncLayers(); + }), + eventBus.on('canvas:objectModified', () => { + this._lm.syncLayers(); + }), + eventBus.on('canvas:objectMetadataChanged', () => { + this._lm.syncLayers(); + }), + eventBus.on('canvas:selectionCreated', () => this._selectLayerFromActiveObject()), + eventBus.on('canvas:selectionUpdated', () => this._selectLayerFromActiveObject()), + eventBus.on('canvas:selectionCleared', () => { + this._activeLayerIds = []; + this._refreshLayerList(); + }), + eventBus.on('layer:selected', (meta) => { + this._selectedLayerId = meta?.id ?? null; + this._activeLayerIds = meta ? [meta.id] : []; + this._refreshLayerList(); + }), + eventBus.on('image:loaded', () => { + this._selectedLayerId = null; + this._activeLayerIds = []; + this._refreshLayerList(); + }), + eventBus.on('canvas:restored', () => { + this._selectedLayerId = null; + this._activeLayerIds = []; + this._refreshLayerList(); + }) + ); + + // 事件委托 + this._el.addEventListener('click', (e) => { + const layerItem = e.target.closest('.layer-item'); + if (!layerItem) { + // Check whether an action button was clicked. + this._handleActionClick(e); + return; + } + + const layerId = parseInt(layerItem.dataset.layerId); + if (isNaN(layerId)) return; + + // Toggle visibility. + if (e.target.closest('.layer-item__visibility')) { + this._lm.toggleVisibility(layerId); + return; + } + + // 锁定切换 + if (e.target.closest('.layer-item__lock')) { + this._lm.toggleLock(layerId); + return; + } + + // 选中图层 + this._selectedLayerId = layerId; + this._activeLayerIds = []; + this._refreshLayerList(); + this._lm.selectLayer(layerId); + }); + + this._el.addEventListener('dragstart', (e) => this._handleDragStart(e)); + this._el.addEventListener('dragover', (e) => this._handleDragOver(e)); + this._el.addEventListener('drop', (e) => this._handleDrop(e)); + this._el.addEventListener('dragend', () => this._handleDragEnd()); + this._el.addEventListener('dragleave', (e) => { + if (!this._el.contains(e.relatedTarget)) { + this._clearDropIndicators(false); + } + }); + + } + + _handleDragStart(e) { + const layerItem = e.target.closest('.layer-item'); + if (!layerItem) return; + + const layerId = parseInt(layerItem.dataset.layerId); + const layer = this._lm.getLayerById(layerId); + if (!layer || layer.isBackground) { + e.preventDefault(); + return; + } + + this._dragLayerId = layerId; + this._dropPanelIndex = null; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(layerId)); + layerItem.classList.add('layer-item--dragging'); + } + + _handleDragOver(e) { + if (this._dragLayerId === null) return; + + const dropInfo = this._getDropInfo(e); + if (!dropInfo) return; + + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'move'; + + this._clearDropIndicators(false); + this._dropPanelIndex = dropInfo.panelIndex; + + if (this._isNoopDrop(dropInfo.panelIndex)) return; + + dropInfo.item.classList.add( + dropInfo.position === 'after' ? 'layer-item--drop-after' : 'layer-item--drop-before' + ); + } + + _handleDrop(e) { + if (this._dragLayerId === null) return; + + e.preventDefault(); + e.stopPropagation(); + + const dropInfo = this._getDropInfo(e); + const targetIndex = dropInfo ? dropInfo.panelIndex : this._dropPanelIndex; + + if (targetIndex !== null && !this._isNoopDrop(targetIndex)) { + this._lm.reorderLayer(this._dragLayerId, targetIndex); + } + + this._handleDragEnd(); + } + + _handleDragEnd() { + this._dragLayerId = null; + this._dropPanelIndex = null; + this._clearDropIndicators(); + } + + _getDropInfo(e) { + const item = e.target.closest('.layer-item'); + if (!item) return null; + + const targetLayerId = parseInt(item.dataset.layerId); + const layers = this._lm.getLayers(); + const targetLayer = layers.find(l => l.id === targetLayerId); + if (!targetLayer) return null; + + const overlayLayers = layers.filter(l => !l.isBackground); + if (targetLayer.isBackground) { + return { + item, + panelIndex: overlayLayers.length, + position: 'before', + }; + } + + const targetIndex = overlayLayers.findIndex(l => l.id === targetLayerId); + if (targetIndex === -1) return null; + + const rect = item.getBoundingClientRect(); + const position = e.clientY > rect.top + rect.height / 2 ? 'after' : 'before'; + + return { + item, + panelIndex: targetIndex + (position === 'after' ? 1 : 0), + position, + }; + } + + _isNoopDrop(panelIndex) { + if (this._dragLayerId === null) return true; + + const overlayLayers = this._lm.getLayers().filter(l => !l.isBackground); + const fromIndex = overlayLayers.findIndex(l => l.id === this._dragLayerId); + if (fromIndex === -1) return true; + + let insertIndex = Math.max(0, Math.min(panelIndex, overlayLayers.length)); + if (fromIndex < insertIndex) insertIndex -= 1; + return insertIndex === fromIndex; + } + + _clearDropIndicators(includeDragging = true) { + const selector = includeDragging + ? '.layer-item--dragging, .layer-item--drop-before, .layer-item--drop-after' + : '.layer-item--drop-before, .layer-item--drop-after'; + + this._el.querySelectorAll(selector) + .forEach(item => { + item.classList.remove('layer-item--dragging', 'layer-item--drop-before', 'layer-item--drop-after'); + }); + } + + _handleActionClick(e) { + const target = e.target; + if (target.id === 'layer-delete') { + const selected = this._getSelectedLayerId(); + if (selected !== null) { + this._lm.deleteLayer(selected); + } + return; + } + + if (target.id === 'layer-add') { + eventBus.emit('tool:requestChange', 'text'); + } + } + + _getSelectedLayerId() { + return this._selectedLayerId; + } + + _selectLayerFromActiveObject() { + const active = this._lm._cm?.getActiveObject(); + const layerIds = this._getSelectedLayerIds(active); + this._activeLayerIds = layerIds; + if (layerIds.length > 0) { + this._selectedLayerId = layerIds[0]; + } + this._refreshLayerList(); + } + + _selectLayerByObject(obj) { + if (!obj || obj.excludeFromLayer) return; + + const meta = this._lm.getLayerByObject?.(obj) || this._lm._findMeta?.(obj); + if (!meta) return; + + this._selectedLayerId = meta.id; + this._activeLayerIds = [meta.id]; + this._refreshLayerList(); + } + + _getSelectedLayerIds(activeObj) { + if (!activeObj) return []; + + const objects = activeObj.type === 'activeSelection' && typeof activeObj.getObjects === 'function' + ? activeObj.getObjects() + : [activeObj]; + + return objects + .map(obj => this._lm.getLayerByObject?.(obj) || this._lm._findMeta?.(obj)) + .filter(Boolean) + .map(meta => meta.id); + } + + _getSelectedLayerIdSet() { + const ids = this._activeLayerIds; + return ids.length > 0 ? new Set(ids) : new Set(this._selectedLayerId === null ? [] : [this._selectedLayerId]); + } + + _ensureSelectedLayerExists(layers) { + const layerIds = new Set(layers.map(layer => layer.id)); + this._activeLayerIds = this._activeLayerIds.filter(id => layerIds.has(id)); + + if (this._selectedLayerId === null) return; + if (!layerIds.has(this._selectedLayerId)) { + this._selectedLayerId = null; + } + } + + _refreshLayerList() { + // Prevent refresh -> syncLayers -> emit('layers:updated') -> refresh loops. + if (this._refreshing) return; + this._refreshing = true; + + const listEl = this._el.querySelector('#layer-list'); + const countEl = this._el.querySelector('#layer-count'); + if (!listEl) { this._refreshing = false; return; } + + this._lm.syncLayers(); + const layers = this._lm.getLayers(); + this._ensureSelectedLayerExists(layers); + if (countEl) countEl.textContent = layers.length; + + const selectedLayerIds = this._getSelectedLayerIdSet(); + + // 图标映射 + const typeIcons = { + 'i-text': 'T', + 'text': 'T', + 'textbox': 'T', + 'rect': '+', + 'circle': 'O', + 'image': '🖼', + 'group': '+', + 'path': '~', + 'triangle': '^', + }; + + let html = ''; + for (const layer of layers) { + const isSelected = selectedLayerIds.has(layer.id); + const isBg = layer.isBackground; + + // Use a custom icon for the background layer. + const icon = isBg + ? `` + : (typeIcons[layer.fabricObj.type] || '□'); + + const eyeIcon = layer.visible + ? `` + : ``; + + // 背景图层锁定图标灰显 + const lockIconClass = isBg ? 'layer-item__lock--bg' : (layer.locked ? 'layer-item__lock--locked' : ''); + const lockIcon = layer.locked + ? `` + : ``; + const layerName = this._escapeHTML(layer.name); + const layerTitle = this._escapeAttr(layer.name); + + html += ` +
      • + ${eyeIcon} + ${icon} + ${layerName} + ${lockIcon} +
      • + `; + } + + if (layers.length === 0) { + html = '
        暂无图层
        '; + } + + listEl.innerHTML = html; + this._refreshing = false; + } + + _escapeHTML(value) { + return escapeHTML(value); + } + + _escapeAttr(value) { + return escapeAttr(value); + } + + destroy() { + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; + } +} + +export default LayerPanel; diff --git a/plugins/Image-Toolbox/src/ui/OptionsBar.js b/plugins/Image-Toolbox/src/ui/OptionsBar.js new file mode 100644 index 000000000..ee71a9917 --- /dev/null +++ b/plugins/Image-Toolbox/src/ui/OptionsBar.js @@ -0,0 +1,182 @@ +import { eventBus } from '../../core/src/index.js'; + +/** + * Top options bar UI component. + * Renders presets for the active tool. + */ +class OptionsBar { + constructor(containerEl, toolManager) { + this._el = containerEl; + this._tm = toolManager; + this._currentTool = null; + this._shapePickerEl = null; + this._shapePickerAnchor = null; + this._boundDocumentClick = this._handleDocumentClick.bind(this); + this._boundKeyDown = this._handleKeyDown.bind(this); + this._boundRepositionShapePicker = this._positionShapePicker.bind(this); + this._eventBusUnsubscribers = []; + + this._render(); + this._bindEvents(); + } + + _render() { + this._el.innerHTML = ` +
        + `; + } + + _bindEvents() { + // Update options when the active tool changes. + this._eventBusUnsubscribers.push( + eventBus.on('tool:changed', (toolName) => { + this._currentTool = toolName; + this._closeShapePicker(); + this._updateControls(); + }) + ); + + [ + 'canvas:selectionCreated', + 'canvas:selectionUpdated', + 'canvas:selectionCleared', + 'canvas:objectModified', + 'canvas:restored', + 'image:loaded', + 'tool:propertiesChanged', + ].forEach(eventName => { + this._eventBusUnsubscribers.push( + eventBus.on(eventName, () => { + if (this._currentTool) this._updateControls(); + }) + ); + }); + + // Only handle one-click presets here; detailed controls live in the property panel. + this._el.addEventListener('click', (e) => { + this._handleControlEvent(e); + }); + } + + _updateControls() { + this._closeShapePicker(); + + const controlsEl = this._el.querySelector('#optionsbar-controls'); + if (!controlsEl) return; + + const module = this._tm.getCurrentModule(); + if (module && typeof module.getOptionsBarHTML === 'function') { + controlsEl.innerHTML = module.getOptionsBarHTML(); + } else { + controlsEl.innerHTML = ''; + } + } + + _handleControlEvent(e) { + const pickerToggle = e.target.closest('[data-shape-picker-toggle]'); + if (pickerToggle) { + e.preventDefault(); + e.stopPropagation(); + const module = this._tm.getCurrentModule(); + if (module && typeof module.getShapePickerHTML === 'function') { + this._toggleShapePicker(pickerToggle, module); + } + return; + } + + const target = e.target.closest('[data-preset]'); + if (!target) return; + + const module = this._tm.getCurrentModule(); + if (!module) return; + + if (module.applyPreset) module.applyPreset(target.dataset.preset); + this._updateControls(); + eventBus.emit('tool:propertiesChanged'); + } + + _toggleShapePicker(anchor, module) { + if (this._shapePickerEl && this._shapePickerAnchor === anchor) { + this._closeShapePicker(); + return; + } + + this._closeShapePicker(); + + const pickerEl = document.createElement('div'); + pickerEl.className = 'shape-picker-popover'; + pickerEl.innerHTML = module.getShapePickerHTML(); + pickerEl.addEventListener('click', (e) => { + const target = e.target.closest('[data-preset]'); + if (!target) return; + + const currentModule = this._tm.getCurrentModule(); + if (currentModule && currentModule.applyPreset) currentModule.applyPreset(target.dataset.preset); + this._closeShapePicker(); + this._updateControls(); + eventBus.emit('tool:propertiesChanged'); + }); + + document.body.appendChild(pickerEl); + this._shapePickerEl = pickerEl; + this._shapePickerAnchor = anchor; + this._positionShapePicker(); + + setTimeout(() => document.addEventListener('click', this._boundDocumentClick), 0); + document.addEventListener('keydown', this._boundKeyDown); + window.addEventListener('resize', this._boundRepositionShapePicker); + window.addEventListener('scroll', this._boundRepositionShapePicker, true); + } + + _positionShapePicker() { + if (!this._shapePickerEl || !this._shapePickerAnchor) return; + + const anchorRect = this._shapePickerAnchor.getBoundingClientRect(); + const pickerRect = this._shapePickerEl.getBoundingClientRect(); + const margin = 8; + const maxLeft = Math.max(margin, window.innerWidth - pickerRect.width - margin); + const left = Math.min(Math.max(anchorRect.left, margin), maxLeft); + + let top = anchorRect.bottom + margin; + if (top + pickerRect.height > window.innerHeight - margin) { + top = anchorRect.top - pickerRect.height - margin; + } + top = Math.max(margin, top); + + this._shapePickerEl.style.left = `${left}px`; + this._shapePickerEl.style.top = `${top}px`; + } + + _handleDocumentClick(e) { + if (!this._shapePickerEl) return; + if (this._shapePickerEl.contains(e.target) || this._shapePickerAnchor?.contains(e.target)) return; + this._closeShapePicker(); + } + + _handleKeyDown(e) { + if (e.key === 'Escape') this._closeShapePicker(); + } + + _closeShapePicker() { + if (!this._shapePickerEl) return; + + this._shapePickerEl.remove(); + this._shapePickerEl = null; + this._shapePickerAnchor = null; + document.removeEventListener('click', this._boundDocumentClick); + document.removeEventListener('keydown', this._boundKeyDown); + window.removeEventListener('resize', this._boundRepositionShapePicker); + window.removeEventListener('scroll', this._boundRepositionShapePicker, true); + } + + /** + * Destroy the options bar. + */ + destroy() { + this._closeShapePicker(); + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; + } +} + +export default OptionsBar; diff --git a/plugins/Image-Toolbox/src/ui/PropertyPanel.js b/plugins/Image-Toolbox/src/ui/PropertyPanel.js new file mode 100644 index 000000000..ed4d8c1d8 --- /dev/null +++ b/plugins/Image-Toolbox/src/ui/PropertyPanel.js @@ -0,0 +1,692 @@ +import { eventBus } from '../../core/src/index.js'; +import { getFontOptionsHTML, recordFontUsage, isSystemFontsLoaded, onSystemFontsLoaded } from '../../core/src/utils/fonts.js'; +import { clamp, escapeHTML, escapeAttr } from '../../core/src/utils/helpers.js'; + +/** + * Property panel UI component. + * Renders property editors for the selected layer type. + */ +class PropertyPanel { + constructor(containerEl, toolManager, canvasManager = null, layerManager = null) { + this._el = containerEl; + this._tm = toolManager; + this._cm = canvasManager || toolManager?._cm || null; + this._lm = layerManager; + this._eventBusUnsubscribers = []; + + this._bindEvents(); + this._render(); + } + + _render() { + this._el.innerHTML = ` +
        +
        + 属性 +
        +
        +
        选中物件以编辑属性
        +
        +
        + `; + } + + _bindEvents() { + // Update the property panel when selection changes. + this._eventBusUnsubscribers.push( + eventBus.on('canvas:selectionCreated', () => this._updateProperties()), + eventBus.on('canvas:selectionUpdated', () => this._updateProperties()), + eventBus.on('canvas:selectionCleared', () => this._updateProperties()), + eventBus.on('layer:selected', () => this._updateProperties()), + eventBus.on('layers:updated', () => this._updateProperties()), + eventBus.on('canvas:objectAdded', () => this._updateProperties()), + eventBus.on('canvas:objectRemoved', () => this._updateProperties()), + eventBus.on('canvas:restored', () => this._updateProperties()), + eventBus.on('image:loaded', () => this._clearProperties()), + eventBus.on('tool:changed', () => this._updateProperties()), + eventBus.on('crop:updated', () => this._updateProperties()), + eventBus.on('tool:propertiesChanged', () => this._updateProperties()), + eventBus.on('canvas:objectModified', () => this._updateProperties()) + ); + + // Delegate property input handling. + this._el.addEventListener('input', (e) => { + this._handleInput(e); + }); + this._el.addEventListener('change', (e) => { + this._handleInput(e); + }); + this._el.addEventListener('click', (e) => { + this._handleInput(e); + }); + } + + _updateProperties() { + const bodyEl = this._el.querySelector('#property-body'); + if (!bodyEl) return; + + const module = this._tm.getCurrentModule(); + const active = this._getActiveObject(); + + // 模块可声明 overridePropertyPanel 接管属性面板(即使有选中对象) + if (module?.overridePropertyPanel && typeof module.getPropertyPanelHTML === 'function') { + const html = module.getPropertyPanelHTML(); + if (html) { + bodyEl.innerHTML = html; + return; + } + } + + if (active?.excludeFromProperty && module && typeof module.getPropertyPanelHTML === 'function') { + const html = module.getPropertyPanelHTML(); + if (html) { + bodyEl.innerHTML = html; + return; + } + } + + if (active) { + bodyEl.innerHTML = this._getLayerPropertiesHTML(active); + return; + } + + if (!module) { + bodyEl.innerHTML = '
        选中物件以编辑属性
        '; + return; + } + + // Prefer the active module's custom property panel when available. + if (typeof module.getPropertyPanelHTML === 'function') { + const html = module.getPropertyPanelHTML(); + if (html) { + bodyEl.innerHTML = html; + return; + } + } + + bodyEl.innerHTML = '
        选中物件以编辑属性
        '; + } + + _getLayerPropertiesHTML(active) { + const meta = this._getLayerMeta(active); + const isText = this._isTextObject(active); + const isBackground = !!meta?.isBackground; + const locked = meta ? meta.locked : (active.selectable === false && active.evented === false); + // 背景图层的锁定只限制删除、改名和排序,仍允许显式编辑几何参数。 + const editDisabled = (locked && !isBackground) ? ' disabled' : ''; + const renameDisabled = (!meta || isBackground) ? ' disabled' : ''; + const lockDisabled = isBackground ? ' disabled' : ''; + const opacity = active.opacity == null ? 1 : active.opacity; + const typeLabel = this._getTypeLabel(active, meta); + const layerName = meta?.name || typeLabel; + const width = this._getScaledWidth(active); + const height = this._getScaledHeight(active); + + let html = ''; + + html += ` +
        + + +
        +
        + + ${this._escapeHTML(typeLabel)} +
        +
        + + +
        +
        + + +
        + `; + + // 位置 + html += ` +
        + + +
        +
        + + +
        + `; + + // 大小(如有) + if (active.width !== undefined) { + html += ` +
        + + +
        +
        + + +
        + `; + } + + // 旋转 + html += ` +
        + + +
        + `; + + // Opacity. + html += ` +
        + + + ${Math.round(opacity * 100)}% +
        + `; + + if (isText) { + html += ` +
        + + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + + +
        + `; + } else if (this._supportsPaint(active)) { + html += ` +
        + + +
        +
        + + + ${this._getColorOpacityPercent(active.fill)}% +
        +
        + + +
        +
        + + + ${this._getColorOpacityPercent(active.stroke)}% +
        +
        + + +
        + `; + } + + return html; + } + + _clearProperties() { + const bodyEl = this._el.querySelector('#property-body'); + if (bodyEl) { + bodyEl.innerHTML = '
        选中物件以编辑属性
        '; + } + } + + _handleInput(e) { + const target = e.target.closest('[data-prop], [data-module-prop], [data-module-action], [data-module-preset]'); + if (!target || !this._el.contains(target)) return; + + const prop = target.dataset.prop; + const moduleProp = target.dataset.moduleProp; + const moduleAction = target.dataset.moduleAction; + const modulePreset = target.dataset.modulePreset; + if (!prop && !moduleProp && !moduleAction && !modulePreset) return; + // 普通属性控件只在 input/change 事件中处理;click 事件仅放行模块动作/预设 + if (e.type === 'click' && !moduleAction && !modulePreset) return; + + const module = this._tm.getCurrentModule(); + if (moduleProp || moduleAction || modulePreset) { + this._handleModuleInput(e, module, moduleProp, moduleAction, modulePreset); + return; + } + + const active = this._getActiveObject(); + if (!active) return; + + let value = target.type === 'checkbox' ? target.checked : target.value; + let propertyValue = value; + + switch (prop) { + case 'layerName': + if (e.type === 'change') { + this._renameLayer(active, value); + } + return; + case 'visible': + if (e.type === 'change') { + this._setLayerVisibility(active, value); + this._notifyObjectChanged(active); + } + return; + case 'locked': + if (e.type === 'change') { + this._setLayerLock(active, value); + this._notifyObjectChanged(active); + } + return; + case 'left': + case 'top': + case 'angle': { + const parsed = parseFloat(value); + if (!Number.isFinite(parsed)) return; + active.set(prop, parsed); + propertyValue = parsed; + break; + } + case 'width': + this._setScaledDimension(active, 'width', value); + break; + case 'height': + this._setScaledDimension(active, 'height', value); + break; + case 'opacity': { + const percent = this._clamp(parseInt(value, 10), 0, 100); + propertyValue = percent / 100; + active.set('opacity', propertyValue); + if (target.nextElementSibling) { + target.nextElementSibling.textContent = percent + '%'; + } + break; + } + case 'fillOpacity': + case 'strokeOpacity': { + const percent = this._clamp(parseInt(value, 10), 0, 100); + const paintProp = prop === 'fillOpacity' ? 'fill' : 'stroke'; + propertyValue = percent / 100; + active.set(paintProp, this._withColorOpacity(active[paintProp], propertyValue)); + if (target.nextElementSibling) { + target.nextElementSibling.textContent = percent + '%'; + } + break; + } + case 'text': + active.set('text', value); + break; + case 'fontFamily': + active.set('fontFamily', value); + if (e.type === 'change') recordFontUsage(value); + break; + case 'fontSize': + case 'strokeWidth': { + const parsed = parseInt(value, 10); + if (!Number.isFinite(parsed)) return; + propertyValue = parsed; + active.set(prop, parsed); + break; + } + case 'fill': + case 'stroke': + active.set(prop, this._withColorOpacity(value, this._getColorOpacity(active[prop], 1))); + break; + case 'fontWeight': + propertyValue = value ? 'bold' : 'normal'; + active.set('fontWeight', propertyValue); + break; + case 'fontStyle': + propertyValue = value ? 'italic' : 'normal'; + active.set('fontStyle', propertyValue); + break; + case 'underline': + active.set('underline', value); + break; + case 'linethrough': + active.set('linethrough', value); + break; + case 'textAlign': + active.set('textAlign', value); + break; + case 'strokePosition': + active.set('paintFirst', value === 'inside' ? 'fill' : 'stroke'); + active.set('_strokePosition', value); + break; + default: + return; + } + + active.setCoords(); + this._requestRender(); + + // Notify the module so it can keep custom linked state in sync. + if (module?.onPropertyChange) { + module.onPropertyChange(prop, propertyValue, { eventType: e.type }); + } + + if (e.type === 'change' && !active.excludeFromHistory) { + this._notifyObjectChanged(active); + } + } + + _handleModuleInput(e, module, prop, action, preset) { + if (!module) return; + + const target = e.target; + if (preset) { + if (typeof module.applyPreset === 'function') { + module.applyPreset(preset); + this._updateProperties(); + eventBus.emit('tool:propertiesChanged'); + } + return; + } + + if (action) { + if (typeof module.onToolPropertyAction === 'function') { + module.onToolPropertyAction(action, { eventType: e.type }); + } + return; + } + + if (!prop || typeof module.onToolPropertyChange !== 'function') return; + + const value = target.type === 'checkbox' ? target.checked : target.value; + const handled = module.onToolPropertyChange(prop, value, { eventType: e.type }); + + if (target.type === 'range' && target.nextElementSibling) { + const suffix = target.dataset.valueSuffix != null ? target.dataset.valueSuffix : 'px'; + target.nextElementSibling.textContent = `${value}${suffix}`; + } + + if (handled !== false && target.dataset.refreshProperty === 'true') { + this._updateProperties(); + } + } + + _getActiveObject() { + return this._cm?.getActiveObject?.() || null; + } + + _getLayerMeta(obj) { + if (!obj || !this._lm) return null; + if (typeof this._lm.getLayerByObject === 'function') { + return this._lm.getLayerByObject(obj); + } + return typeof this._lm._findMeta === 'function' ? this._lm._findMeta(obj) : null; + } + + _renameLayer(active, name) { + const meta = this._getLayerMeta(active); + const nextName = String(name || '').trim(); + if (!meta || !nextName) return; + this._lm.renameLayer(meta.id, nextName); + } + + _setLayerVisibility(active, visible) { + const meta = this._getLayerMeta(active); + if (meta && typeof this._lm?.setVisibility === 'function') { + this._lm.setVisibility(meta.id, visible); + return; + } + + active.set('visible', visible); + this._requestRender(); + } + + _setLayerLock(active, locked) { + const meta = this._getLayerMeta(active); + if (meta && typeof this._lm?.setLock === 'function') { + this._lm.setLock(meta.id, locked); + return; + } + + active.set({ selectable: !locked, evented: !locked }); + if (locked && this._cm?.canvas?.getActiveObject() === active) { + this._cm.canvas.discardActiveObject(); + } + this._requestRender(); + } + + _setScaledDimension(active, prop, value) { + const parsed = parseFloat(value); + if (!Number.isFinite(parsed) || parsed <= 0) return; + + const base = prop === 'width' ? active.width : active.height; + if (!base) return; + + active.set(prop === 'width' ? 'scaleX' : 'scaleY', parsed / base); + } + + _notifyObjectChanged(active) { + eventBus.emit('canvas:objectModified', active); + } + + _requestRender() { + const canvas = this._cm?.canvas; + if (!canvas) return; + if (typeof canvas.requestRenderAll === 'function') { + canvas.requestRenderAll(); + } else { + canvas.renderAll(); + } + } + + _isTextObject(obj) { + return obj && (obj.type === 'i-text' || obj.type === 'text' || obj.type === 'textbox'); + } + + _supportsPaint(obj) { + return obj && obj.type !== 'image' && obj.type !== 'group' + && (obj.fill !== undefined || obj.stroke !== undefined || obj.strokeWidth !== undefined); + } + + _getScaledWidth(obj) { + if (typeof obj.getScaledWidth === 'function') return Math.abs(obj.getScaledWidth()); + return Math.abs((obj.width || 0) * (obj.scaleX || 1)); + } + + _getScaledHeight(obj) { + if (typeof obj.getScaledHeight === 'function') return Math.abs(obj.getScaledHeight()); + return Math.abs((obj.height || 0) * (obj.scaleY || 1)); + } + + _getTypeLabel(obj, meta) { + if (meta?.isBackground) return '背景图片'; + const labels = { + 'image': '图像', + 'i-text': '文字', + 'text': '文字', + 'textbox': '文字', + 'rect': '矩形', + 'circle': '圆形', + 'path': '路径', + 'triangle': '三角形', + 'group': '组合', + 'line': '线条', + }; + return labels[obj.type] || obj.type || '图层'; + } + + _getFontOptionsHTML(current) { + if (isSystemFontsLoaded()) { + return getFontOptionsHTML(current); + } + + // 异步加载字体,完成后更新属性面板 + onSystemFontsLoaded(() => { + this._updateProperties(); + }); + + return ''; + } + + _getSelectOption(value, label, current) { + const selected = this._normalizeValue(value) === this._normalizeValue(current) ? ' selected' : ''; + return ``; + } + + _normalizeValue(value) { + return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase(); + } + + _toColorValue(value, fallback = '#000000') { + if (typeof value !== 'string') return fallback; + const color = value.trim(); + const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (hex) { + if (color.length === 4) { + return '#' + color.slice(1).split('').map(ch => ch + ch).join(''); + } + return color; + } + + const rgb = color.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); + if (rgb) { + return '#' + [rgb[1], rgb[2], rgb[3]] + .map(v => this._clamp(parseInt(v, 10), 0, 255).toString(16).padStart(2, '0')) + .join(''); + } + + return fallback; + } + + _getColorOpacityPercent(color) { + return Math.round(this._getColorOpacity(color, 0) * 100); + } + + _getColorOpacity(color, fallback = 1) { + const value = String(color ?? '').trim().toLowerCase(); + if (!value) return fallback; + if (value === 'transparent') return 0; + + const rgba = value.match(/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([\d.]+)\s*\)$/i); + if (rgba) { + const alpha = parseFloat(rgba[1]); + return this._clamp(Number.isFinite(alpha) ? alpha : fallback, 0, 1); + } + + return 1; + } + + _withColorOpacity(color, opacity) { + const alpha = this._clamp(Number.isFinite(opacity) ? opacity : 1, 0, 1); + const rgb = this._extractRgb(color); + + if (!rgb) return color; + if (alpha === 0 && String(color ?? '').trim().toLowerCase() === 'transparent') return 'transparent'; + + return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${this._formatAlpha(alpha)})`; + } + + _extractRgb(color) { + const value = String(color ?? '').trim().toLowerCase(); + if (!value || value === 'transparent') return { r: 0, g: 0, b: 0 }; + + if (/^#[0-9a-f]{6}$/i.test(value)) { + return { + r: parseInt(value.slice(1, 3), 16), + g: parseInt(value.slice(3, 5), 16), + b: parseInt(value.slice(5, 7), 16), + }; + } + + if (/^#[0-9a-f]{3}$/i.test(value)) { + return { + r: parseInt(value[1] + value[1], 16), + g: parseInt(value[2] + value[2], 16), + b: parseInt(value[3] + value[3], 16), + }; + } + + const rgb = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); + if (rgb) { + return { + r: this._clamp(parseInt(rgb[1], 10), 0, 255), + g: this._clamp(parseInt(rgb[2], 10), 0, 255), + b: this._clamp(parseInt(rgb[3], 10), 0, 255), + }; + } + + return null; + } + + _formatAlpha(alpha) { + return String(Math.round(alpha * 100) / 100); + } + + _formatNumber(value) { + const number = parseFloat(value); + if (!Number.isFinite(number)) return 0; + return Math.round(number * 100) / 100; + } + + _clamp(value, min, max) { + if (!Number.isFinite(value)) return min; + return clamp(value, min, max); + } + + _escapeHTML(value) { + return escapeHTML(value); + } + + _escapeAttr(value) { + return escapeAttr(value); + } + + destroy() { + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; + } +} + +export default PropertyPanel; diff --git a/plugins/Image-Toolbox/src/ui/SidePanelTabs.js b/plugins/Image-Toolbox/src/ui/SidePanelTabs.js new file mode 100644 index 000000000..f6296612d --- /dev/null +++ b/plugins/Image-Toolbox/src/ui/SidePanelTabs.js @@ -0,0 +1,148 @@ +import { eventBus } from '../../core/src/index.js'; + +const SIDE_PANEL_TAB_KEY = 'image-toolbox-side-panel-tab'; +export const SIDE_PANEL_LAYOUT_KEY = 'image-toolbox-side-panel-layout'; +export const SIDE_PANEL_LAYOUTS = { + TABS: 'tabs', + SPLIT: 'split', +}; + +const VALID_TABS = new Set(['property', 'layer']); +const VALID_LAYOUTS = new Set(Object.values(SIDE_PANEL_LAYOUTS)); + +/** + * Side panel container. + * Supports tab and split layouts, with remembered user preference. + */ +class SidePanelTabs { + constructor(containerEl, layerManager) { + this._el = containerEl; + this._lm = layerManager; + this._activeTab = this._getInitialTab(); + this._layout = this._getInitialLayout(); + this._eventBusUnsubscribers = []; + + this._render(); + this._bindEvents(); + this._applyLayout(this._layout, false); + this._activateTab(this._activeTab, false); + this._updateLayerCount(); + } + + _render() { + this._el.innerHTML = ` +
        +
        + + +
        +
        +
        +
        +
        +
        +
        +
        +
        +
        + `; + } + + _bindEvents() { + this._el.addEventListener('click', (e) => { + const tabBtn = e.target.closest('[data-panel-tab]'); + if (!tabBtn) return; + + this._activateTab(tabBtn.dataset.panelTab, true); + }); + + this._eventBusUnsubscribers.push( + eventBus.on('layers:updated', () => this._updateLayerCount()), + eventBus.on('image:loaded', () => this._updateLayerCount()), + eventBus.on('canvas:restored', () => this._updateLayerCount()) + ); + } + + applyLayout(layout, persist = true) { + this._applyLayout(layout, persist); + } + + _activateTab(tabName, persist = true) { + if (!VALID_TABS.has(tabName)) return; + + this._activeTab = tabName; + this._syncTabs(); + this._syncPanes(); + + if (persist) { + localStorage.setItem(SIDE_PANEL_TAB_KEY, tabName); + eventBus.emit('sidePanel:tabChanged', tabName); + } + } + + _applyLayout(layout, persist = true) { + if (!VALID_LAYOUTS.has(layout)) return; + + this._layout = layout; + const shell = this._el.querySelector('.side-tabs'); + if (shell) { + shell.classList.toggle('side-tabs--tabs', layout === SIDE_PANEL_LAYOUTS.TABS); + shell.classList.toggle('side-tabs--split', layout === SIDE_PANEL_LAYOUTS.SPLIT); + } + + this._syncPanes(); + + if (persist) { + localStorage.setItem(SIDE_PANEL_LAYOUT_KEY, layout); + eventBus.emit('sidePanel:layoutChanged', layout); + } + } + + _syncTabs() { + this._el.querySelectorAll('[data-panel-tab]').forEach(btn => { + const active = btn.dataset.panelTab === this._activeTab; + btn.classList.toggle('side-tabs__tab--active', active); + btn.setAttribute('aria-selected', active ? 'true' : 'false'); + btn.setAttribute('tabindex', active ? '0' : '-1'); + }); + } + + _syncPanes() { + const isSplit = this._layout === SIDE_PANEL_LAYOUTS.SPLIT; + this._el.querySelectorAll('[data-panel-pane]').forEach(pane => { + const active = isSplit || pane.dataset.panelPane === this._activeTab; + pane.classList.toggle('side-tabs__pane--active', active); + pane.hidden = !active; + pane.setAttribute('aria-hidden', active ? 'false' : 'true'); + }); + } + + _updateLayerCount() { + const countEl = this._el.querySelector('#side-layer-count'); + if (!countEl) return; + + const count = this._lm?.getLayers?.().length || 0; + countEl.textContent = count; + } + + _getInitialTab() { + const saved = localStorage.getItem(SIDE_PANEL_TAB_KEY); + return VALID_TABS.has(saved) ? saved : 'property'; + } + + _getInitialLayout() { + const saved = localStorage.getItem(SIDE_PANEL_LAYOUT_KEY); + return VALID_LAYOUTS.has(saved) ? saved : SIDE_PANEL_LAYOUTS.TABS; + } + + destroy() { + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; + } +} + +export default SidePanelTabs; diff --git a/plugins/Image-Toolbox/src/ui/StatusBar.js b/plugins/Image-Toolbox/src/ui/StatusBar.js new file mode 100644 index 000000000..4b0b1bba3 --- /dev/null +++ b/plugins/Image-Toolbox/src/ui/StatusBar.js @@ -0,0 +1,86 @@ +import { eventBus } from '../../core/src/index.js'; + +/** + * Status bar UI component. + * Shows image size, layer count, engine version, and export shortcuts. + */ +class StatusBar { + constructor(containerEl, canvasManager, layerManager) { + this._el = containerEl; + this._cm = canvasManager; + this._lm = layerManager; + this._eventBusUnsubscribers = []; + + this._bindEvents(); + this._render(); + } + + _render() { + this._el.innerHTML = ` +
        + -- × -- + + 图层: 0 + + Fabric.js 5.x +
        +
        + + +
        + `; + } + + _bindEvents() { + // Update size after image load. + this._eventBusUnsubscribers.push( + eventBus.on('image:loaded', (img) => { + this._updateSize(img); + }) + ); + + // Update size after canvas changes. + this._eventBusUnsubscribers.push( + eventBus.on('canvas:objectModified', () => { + if (this._cm.originalImage) { + this._updateSize(this._cm.originalImage); + } + }) + ); + + // Update layer count when layers change. + this._eventBusUnsubscribers.push( + eventBus.on('layers:updated', (layers) => { + const countEl = this._el.querySelector('#status-layers'); + if (countEl) { + countEl.textContent = `图层: ${layers ? layers.length : this._lm.getCount()}`; + } + }) + ); + + // 导出按钮 + this._el.addEventListener('click', (e) => { + if (e.target.id === 'status-save-file') { + eventBus.emit('export:requested', 'file'); + } else if (e.target.id === 'status-clipboard') { + eventBus.emit('export:requested', 'clipboard'); + } + }); + } + + _updateSize(img) { + const sizeEl = this._el.querySelector('#status-size'); + if (sizeEl && img) { + const w = Math.round(img.width * img.scaleX); + const h = Math.round(img.height * img.scaleY); + sizeEl.textContent = `${w} × ${h} px`; + } + } + + destroy() { + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; + } +} + +export default StatusBar; diff --git a/plugins/Image-Toolbox/src/ui/Toolbar.js b/plugins/Image-Toolbox/src/ui/Toolbar.js new file mode 100644 index 000000000..b2b79f978 --- /dev/null +++ b/plugins/Image-Toolbox/src/ui/Toolbar.js @@ -0,0 +1,224 @@ +import { eventBus } from '../../core/src/index.js'; +import { escapeHTML, escapeAttr } from '../../core/src/utils/helpers.js'; + +/** + * Toolbar UI component. + * Renders tool buttons and handles tool switching. + */ +class Toolbar { + constructor(containerEl, toolManager, host = null) { + this._el = containerEl; + this._tm = toolManager; + this._host = host; + this._currentTool = 'select'; + this._user = this._getHostUser(); + this._eventBusUnsubscribers = []; + + // SVG 图标模板 + this._icons = { + select: ``, + mosaic: ``, + crop: ``, + color: ``, + brush: ``, + eraser: ``, + text: ``, + shape: ``, + undo: ``, + redo: ``, + }; + + this._render(); + this._bindEvents(); + } + + _render() { + const tools = this._tm.getTools(); + let toolsHtml = ''; + + let currentGroup = null; + + // Render tool groups in a stable order. + const groupOrder = ['edit', 'adjust', 'redact', 'annotate', 'view', 'action']; + for (const group of groupOrder) { + const groupTools = tools.filter(t => t.group === group); + if (groupTools.length === 0) continue; + + if (currentGroup !== null) { + toolsHtml += '
        '; + } + currentGroup = group; + + for (const tool of groupTools) { + // 导出按钮放在底部 + if (tool.group === 'action') continue; + + const icon = this._icons[tool.icon] || ''; + const isActive = this._currentTool === tool.name; + toolsHtml += ` + + `; + } + } + + // 撤销 / 重做 + const footerHtml = ` +
        + + + ${this._renderAccount()} + `; + + this._el.innerHTML = ` +
        ${toolsHtml}
        + + `; + } + + _bindEvents() { + this._el.addEventListener('click', (e) => { + const account = e.target.closest('.toolbar__account'); + if (account) { + eventBus.emit('account:open'); + return; + } + + const btn = e.target.closest('.toolbar__btn'); + if (!btn) return; + + const toolName = btn.dataset.tool; + + if (toolName === 'undo') { + eventBus.emit('history:undo'); + return; + } + if (toolName === 'redo') { + eventBus.emit('history:redo'); + return; + } + + // 切换工具 + this._currentTool = toolName; + this._updateActive(); + this._tm.activateTool(toolName); + }); + + this._el.addEventListener('error', (e) => { + const avatar = e.target.closest?.('.toolbar__avatar-img'); + if (!avatar) return; + const fallback = document.createElement('div'); + fallback.className = 'toolbar__avatar toolbar__avatar--fallback'; + fallback.textContent = avatar.dataset.initial || 'U'; + avatar.replaceWith(fallback); + }, true); + + // 监听工具切换事件(外部触发) + this._eventBusUnsubscribers.push( + eventBus.on('tool:changed', (toolName) => { + this._currentTool = toolName; + this._updateActive(); + }), + eventBus.on('history:changed', ({ canUndo, canRedo }) => { + this._updateHistoryButtons(canUndo, canRedo); + }) + ); + } + + _updateHistoryButtons(canUndo, canRedo) { + const undoBtn = this._el.querySelector('[data-tool="undo"]'); + const redoBtn = this._el.querySelector('[data-tool="redo"]'); + if (undoBtn) { + undoBtn.disabled = !canUndo; + undoBtn.style.opacity = canUndo ? '' : '0.4'; + } + if (redoBtn) { + redoBtn.disabled = !canRedo; + redoBtn.style.opacity = canRedo ? '' : '0.4'; + } + } + + _updateActive() { + const btns = this._el.querySelectorAll('.toolbar__btn'); + btns.forEach(btn => { + const tool = btn.dataset.tool; + if (tool === this._currentTool) { + btn.classList.add('toolbar__btn--active'); + } else { + btn.classList.remove('toolbar__btn--active'); + } + }); + } + + _renderAccount() { + const user = this._user || {}; + const name = user.nickname || user.name || user.userName || user.username || `${this._getHostName()} 用户`; + const avatar = user.avatar || user.avatarUrl || user.photo || ''; + const initial = this._getInitial(name); + const title = this._escapeAttr(name); + + if (avatar) { + return ` + + `; + } + + return ` + + `; + } + + _getHostUser() { + try { + const result = this._host?.user?.getCurrentUser?.() || this._host?.getHostUser?.() || null; + if (result && typeof result.then === 'function') { + result.then((user) => { + this._user = user; + this._render(); + }).catch((e) => console.warn('[Toolbar] 获取宿主用户信息失败:', e)); + return null; + } + return result; + } catch (e) { + console.warn('[Toolbar] 获取宿主用户信息失败:', e); + } + return null; + } + + _getHostName() { + return this._host?.platform?.name || this._host?.getHostName?.() || 'uTools'; + } + + _getInitial(name) { + const text = String(name || '').trim(); + return text ? text.slice(0, 1).toUpperCase() : 'U'; + } + + _escapeAttr(value) { + return escapeAttr(value); + } + + _escapeHTML(value) { + return escapeHTML(value); + } + + destroy() { + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; + } +} + +export default Toolbar; diff --git a/plugins/bad-bear/.gitignore b/plugins/bad-bear/.gitignore new file mode 100644 index 000000000..2f6e2a556 --- /dev/null +++ b/plugins/bad-bear/.gitignore @@ -0,0 +1,20 @@ +node_modules +dist +out +.DS_Store +.eslintcache +*.log* +.vscode/extensions.json +.vscode/launch.json +.vscode/settings.json +/.history +.idea +.yalc +yalc.lock + +# uTools 源码缓存(用于同步 API Types) +.ztools-api-types-source/ +/.cursor +/.shared +.pnpm-store/* +_* diff --git a/plugins/bad-bear/README.md b/plugins/bad-bear/README.md new file mode 100644 index 000000000..85fc0b54a --- /dev/null +++ b/plugins/bad-bear/README.md @@ -0,0 +1,6 @@ +# bad-bear + +无需上传代码的插件分享应用市场 + +![预览](./public/preview.png) + diff --git a/plugins/bad-bear/index.html b/plugins/bad-bear/index.html new file mode 100644 index 000000000..eaa17316c --- /dev/null +++ b/plugins/bad-bear/index.html @@ -0,0 +1,11 @@ + + + + + + + +
        + + + diff --git a/plugins/bad-bear/package.json b/plugins/bad-bear/package.json new file mode 100644 index 000000000..48c6b2329 --- /dev/null +++ b/plugins/bad-bear/package.json @@ -0,0 +1,29 @@ +{ + "name": "bad-bear", + "version": "1.0.2", + "description": "轻松分享你的ZTools插件", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build" + }, + "dependencies": { + "@vueuse/core": "^14.2.1", + "marked": "^17.0.5", + "vue": "^3.5.13", + "ztools-ui": "^0.1.3" + }, + "devDependencies": { + "@iconify/utils": "^3.1.0", + "@types/node": "^25.6.0", + "@unocss/preset-icons": "^66.6.6", + "@unocss/transformer-directives": "^66.6.6", + "@vitejs/plugin-vue": "^6.0.7", + "@ztools-center/ztools-api-types": "^1.0.1", + "highlight.js": "^11.11.1", + "typescript": "^5.3.0", + "unocss": "^66.6.6", + "vite": "^8.0.15", + "vue-tsc": "^2.0.0" + } +} diff --git a/plugins/bad-bear/public/logo.png b/plugins/bad-bear/public/logo.png new file mode 100644 index 000000000..ad7311a0b Binary files /dev/null and b/plugins/bad-bear/public/logo.png differ diff --git a/plugins/bad-bear/public/plugin.json b/plugins/bad-bear/public/plugin.json new file mode 100644 index 000000000..091c7e49a --- /dev/null +++ b/plugins/bad-bear/public/plugin.json @@ -0,0 +1,22 @@ +{ + "$schema": "node_modules/@ztools-center/ztools-api-types/resource/ztools.schema.json", + "name": "bad-bear", + "title": "邪恶的熊", + "description": "轻松分享你的ZTools插件", + "author": "皮皮虾我们走", + "version": "1.0.0", + "main": "index.html", + "preload": "preload/services.js", + "logo": "logo.png", + "development": { + "main": "http://localhost:5173" + }, + "features": [ + { + "code": "bad bear", + "explain": "插件市场", + "icon": "logo.png", + "cmds": ["badbear", "bad bear", "插件市场"] + } + ] +} diff --git a/plugins/bad-bear/public/preload/package.json b/plugins/bad-bear/public/preload/package.json new file mode 100644 index 000000000..2c84e5ae8 --- /dev/null +++ b/plugins/bad-bear/public/preload/package.json @@ -0,0 +1,4 @@ +{ + "type": "commonjs", + "dependencies": {} +} diff --git a/plugins/bad-bear/public/preload/services.js b/plugins/bad-bear/public/preload/services.js new file mode 100644 index 000000000..2e35b4caa --- /dev/null +++ b/plugins/bad-bear/public/preload/services.js @@ -0,0 +1,156 @@ +const { Buffer } = require('node:buffer') +const os = require('node:os') + +// 确保全局可用(某些依赖库直接读取 global.Buffer) +if (typeof globalThis.Buffer === 'undefined') { + globalThis.Buffer = Buffer +} + +const fs = require('node:fs') +const { createHash } = require('node:crypto') +const path = require('node:path') + +// 通过 window 对象向渲染进程注入 nodejs 能力 +window.services = { + getSystemInfo() { + const platform = os.platform() + return { + platform: platform === 'win32' ? 'win32' : platform === 'darwin' ? 'darwin' : 'linux', + } + }, + + getThemeInfo() { + return window.ztools.getThemeInfo() + }, + + onThemeChange(callback) { + window.ztools.onThemeChange(callback) + }, + // ===== 文件系统能力 ===== + + /** + * 读取文件内容 + */ + readFile(filePath, encoding = 'utf-8') { + console.log('[Preload] readFile:', filePath) + const content = fs.readFileSync(filePath, encoding) + console.log('[Preload] readFile complete, size:', content.length) + return content + }, + + /** + * 以二进制方式读取文件内容 + */ + readBinaryFile(filePath) { + console.log('[Preload] readBinaryFile:', filePath) + const content = fs.readFileSync(filePath) + console.log('[Preload] readBinaryFile complete, size:', content.byteLength) + return content + }, + + /** + * 写入文件 + */ + writeFile(filePath, content) { + console.log('[Preload] writeFile:', filePath, 'size:', content.length) + const dir = path.dirname(filePath) + if (!fs.existsSync(dir)) { + console.log('[Preload] Creating directory:', dir) + fs.mkdirSync(dir, { recursive: true }) + } + fs.writeFileSync(filePath, content, 'utf-8') + console.log('[Preload] writeFile complete') + }, + + /** + * 计算文件 hash + */ + async computeFileHash(filePath, algorithm = 'sha256') { + console.log('[Preload] computeFileHash:', { filePath, algorithm }) + + if (algorithm !== 'sha256') { + throw new Error(`不支持的 hash 算法: ${algorithm}`) + } + + return new Promise((resolve, reject) => { + const hash = createHash(algorithm) + const stream = fs.createReadStream(filePath) + + stream.on('error', reject) + stream.on('data', chunk => hash.update(chunk)) + stream.on('end', () => { + const digest = hash.digest('hex') + console.log('[Preload] computeFileHash complete:', { filePath, algorithm, digest }) + resolve(digest) + }) + }) + }, + + /** + * 检查路径是否存在 + */ + exists(p) { + const result = fs.existsSync(p) + console.log('[Preload] exists:', p, '=>', result) + return result + }, + + /** path 工具 */ + path, + + /** + * 重命名文件或目录 + */ + rename(oldPath, newPath) { + console.log('[Preload] rename:', oldPath, '->', newPath) + fs.renameSync(oldPath, newPath) + console.log('[Preload] rename complete') + }, + + /** + * 递归删除目录 + */ + removeDirectory(dirPath) { + console.log('[Preload] removeDirectory:', dirPath) + if (fs.existsSync(dirPath)) { + fs.rmSync(dirPath, { recursive: true, force: true }) + console.log('[Preload] removeDirectory complete') + } else { + console.log('[Preload] removeDirectory: path does not exist') + } + }, + + /** + * 删除文件 + */ + removeFile(filePath) { + console.log('[Preload] removeFile:', filePath) + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath) + console.log('[Preload] removeFile complete') + } else { + console.log('[Preload] removeFile: file does not exist') + } + }, + + /** + * 复制文件 + */ + copyFile(src, dest) { + console.log('[Preload] copyFile:', src, '->', dest) + fs.copyFileSync(src, dest) + console.log('[Preload] copyFile complete') + }, + + /** + * 递归复制目录 + */ + copyDirectory(src, dest) { + console.log('[Preload] copyDirectory:', src, '->', dest) + fs.cpSync(src, dest, { recursive: true }) + console.log('[Preload] copyDirectory complete') + }, +} + + +window.ztools.setExpendHeight(600) \ No newline at end of file diff --git a/plugins/bad-bear/public/preload/yarn.lock b/plugins/bad-bear/public/preload/yarn.lock new file mode 100644 index 000000000..fb57ccd13 --- /dev/null +++ b/plugins/bad-bear/public/preload/yarn.lock @@ -0,0 +1,4 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + diff --git a/plugins/bad-bear/public/preview.png b/plugins/bad-bear/public/preview.png new file mode 100644 index 000000000..caede8069 Binary files /dev/null and b/plugins/bad-bear/public/preview.png differ diff --git a/plugins/bad-bear/src/App.vue b/plugins/bad-bear/src/App.vue new file mode 100644 index 000000000..44bbb3d1e --- /dev/null +++ b/plugins/bad-bear/src/App.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/plugins/bad-bear/src/api/auth.ts b/plugins/bad-bear/src/api/auth.ts new file mode 100644 index 000000000..df289ddcf --- /dev/null +++ b/plugins/bad-bear/src/api/auth.ts @@ -0,0 +1,116 @@ +import { requestFormData, requestJson } from './httpClient' +import type { + AuthResponse, + CaptchaResponse, + CurrentUserResponse, + GitHubBindDevicePollResponse, + GitHubBindingStatus, + GitHubDevicePollRequest, + GitHubDeviceStartResponse, + GitHubLoginDevicePollResponse, + LoginRequest, + RegisterRequest, + UpdatePasswordRequest, + UpdatePasswordResponse, + UpdateUsernameRequest, +} from '../types/auth' + +export function register(payload: RegisterRequest): Promise { + return requestJson({ + path: '/api/v1/auth/register', + method: 'POST', + body: payload, + }) +} + +export function getCaptcha(bgColor?: string): Promise { + const query = bgColor ? `?bgColor=${encodeURIComponent(bgColor)}` : '' + return requestJson({ + path: `/api/v1/auth/captcha${query}`, + }) +} + +export function login(payload: LoginRequest): Promise { + return requestJson({ + path: '/api/v1/auth/login', + method: 'POST', + body: payload, + }) +} + +export function startGithubDeviceLogin(): Promise { + return requestJson({ + path: '/api/v1/auth/github/device/start', + method: 'POST', + }) +} + +export function pollGithubDeviceLogin( + payload: GitHubDevicePollRequest, +): Promise { + return requestJson({ + path: '/api/v1/auth/github/device/poll', + method: 'POST', + body: payload, + }) +} + +export function startGithubDeviceBind(): Promise { + return requestJson({ + path: '/api/v1/auth/github/bind/device/start', + method: 'POST', + }) +} + +export function pollGithubDeviceBind( + payload: GitHubDevicePollRequest, +): Promise { + return requestJson({ + path: '/api/v1/auth/github/bind/device/poll', + method: 'POST', + body: payload, + }) +} + +export function getGithubBindingStatus(): Promise { + return requestJson({ + path: '/api/v1/auth/github/binding', + }) +} + +export function getCurrentUser(): Promise { + return requestJson({ + path: '/api/v1/auth/me', + }) +} + +export function updateMyUsername( + payload: UpdateUsernameRequest, +): Promise { + return requestJson({ + path: '/api/v1/users/me/username', + method: 'PATCH', + body: payload, + }) +} + +export function updateMyPassword( + payload: UpdatePasswordRequest, +): Promise { + return requestJson({ + path: '/api/v1/users/me/password', + method: 'PATCH', + body: payload, + }) +} + +export function uploadMyAvatar(file: File): Promise { + const formData = new FormData() + formData.append('avatar', file) + + return requestFormData({ + path: '/api/v1/users/me/avatar', + method: 'POST', + body: formData, + }) +} diff --git a/plugins/bad-bear/src/api/httpClient.ts b/plugins/bad-bear/src/api/httpClient.ts new file mode 100644 index 000000000..dcb7f0b82 --- /dev/null +++ b/plugins/bad-bear/src/api/httpClient.ts @@ -0,0 +1,128 @@ +import { clearShopApiAuth, getShopApiRuntimeConfig } from '../config/runtimeConfig' + +export class HttpClientError extends Error { + status: number + data: unknown + + constructor(message: string, status: number, data: unknown) { + super(message) + this.name = 'HttpClientError' + this.status = status + this.data = data + } +} + +interface RequestOptions { + path: string + method?: string + headers?: Record + body?: BodyInit | null +} + +function buildRequestUrl(path: string): string { + const normalizedPath = path.startsWith('/') ? path : `/${path}` + return `${getShopApiRuntimeConfig().baseUrl}${normalizedPath}` +} + +async function parseResponseBody(response: Response): Promise { + if (response.status === 204) { + return null + } + + const contentType = response.headers.get('content-type') || '' + + if (contentType.includes('application/json')) { + try { + return await response.json() + } catch { + return null + } + } + + const text = await response.text() + return text || null +} + +function extractResponseMessage(response: Response, data: unknown): string { + if (typeof data === 'object' && data && 'message' in data && typeof data.message === 'string') { + return data.message + } + + if (typeof data === 'string' && data.trim()) { + return data + } + + return response.ok ? '请求成功' : `请求失败 (${response.status})` +} + +async function request({ + path, + method = 'GET', + headers = {}, + body = null, +}: RequestOptions): Promise { + const runtimeConfig = getShopApiRuntimeConfig() + const nextHeaders = new Headers(headers) + + if (runtimeConfig.token) { + nextHeaders.set('Authorization', `Bearer ${runtimeConfig.token}`) + } + + if (!nextHeaders.has('Accept')) { + nextHeaders.set('Accept', 'application/json') + } + + let response: Response + try { + response = await fetch(buildRequestUrl(path), { + method, + headers: nextHeaders, + body, + }) + } catch (error) { + throw new Error(error instanceof Error ? error.message : '请求失败') + } + + const data = await parseResponseBody(response) + + if (response.status === 401) { + clearShopApiAuth() + } + + if (!response.ok) { + throw new HttpClientError(extractResponseMessage(response, data), response.status, data) + } + + return data as TResponse +} + +export function requestJson(options: { + path: string + method?: string + body?: TBody + headers?: Record +}): Promise { + return request({ + path: options.path, + method: options.method, + headers: { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }, + body: options.body === undefined ? null : JSON.stringify(options.body), + }) +} + +export function requestFormData(options: { + path: string + method?: string + body: FormData + headers?: Record +}): Promise { + return request({ + path: options.path, + method: options.method, + headers: options.headers, + body: options.body, + }) +} diff --git a/plugins/bad-bear/src/api/notifications.ts b/plugins/bad-bear/src/api/notifications.ts new file mode 100644 index 000000000..4fdd69fc4 --- /dev/null +++ b/plugins/bad-bear/src/api/notifications.ts @@ -0,0 +1,200 @@ +import { requestJson } from './httpClient' +import { getShopApiRuntimeConfig } from '../config/runtimeConfig' +import type { + MarkAllNotificationsReadResponse, + NotificationListQuery, + NotificationListResponse, + NotificationRecord, + NotificationStreamConnectedPayload, + NotificationStreamEvent, + NotificationStreamEventName, + NotificationStreamReadAllPayload, + NotificationStreamReadPayload, +} from '../types/notification' + +export function buildNotificationListQuery(query?: NotificationListQuery): string { + const searchParams = new URLSearchParams() + + if (typeof query?.page === 'number') { + searchParams.set('page', String(query.page)) + } + + if (typeof query?.pageSize === 'number') { + searchParams.set('pageSize', String(query.pageSize)) + } + + if (query?.status) { + searchParams.set('status', query.status) + } + + const queryString = searchParams.toString() + return queryString ? `?${queryString}` : '' +} + +export function getNotifications( + query?: NotificationListQuery, +): Promise { + return requestJson({ + path: `/api/v1/notifications${buildNotificationListQuery(query)}`, + }) +} + +export async function markNotificationRead(id: string): Promise { + await requestJson({ + path: `/api/v1/notifications/${encodeURIComponent(id)}/read`, + method: 'PATCH', + }) +} + +export function markAllNotificationsRead(): Promise { + return requestJson({ + path: '/api/v1/notifications/read-all', + method: 'PATCH', + }) +} + +const NOTIFICATION_STREAM_PATH = '/api/v1/notifications/stream' +const STREAM_ACCEPT_HEADER = 'text/event-stream' + +function buildNotificationStreamUrl(): string { + const { baseUrl } = getShopApiRuntimeConfig() + return `${baseUrl}${NOTIFICATION_STREAM_PATH}` +} + +function parseNotificationStreamPayload(payload: string): TPayload | null { + const trimmed = payload.trim() + if (!trimmed) { + return null + } + + try { + return JSON.parse(trimmed) as TPayload + } catch { + return null + } +} + +function toNotificationStreamEvent( + type: NotificationStreamEventName, + id: string | null, + data: string, +): NotificationStreamEvent | null { + switch (type) { + case 'connected': { + const parsed = parseNotificationStreamPayload(data) + return parsed ? { type, id, data: parsed } : null + } + case 'notification.created': { + const parsed = parseNotificationStreamPayload(data) + return parsed ? { type, id, data: parsed } : null + } + case 'notification.read': { + const parsed = parseNotificationStreamPayload(data) + return parsed ? { type, id, data: parsed } : null + } + case 'notification.read-all': { + const parsed = parseNotificationStreamPayload(data) + return parsed ? { type, id, data: parsed } : null + } + default: + return null + } +} + +function parseNotificationStreamChunk(chunk: string): NotificationStreamEvent[] { + return chunk + .split(/\r?\n\r?\n/) + .map((frame) => frame.trim()) + .filter(Boolean) + .flatMap((frame) => { + const lines = frame.split(/\r?\n/) + let eventType: NotificationStreamEventName = 'connected' + let eventId: string | null = null + const dataLines: string[] = [] + + lines.forEach((line) => { + if (!line || line.startsWith(':')) { + return + } + + if (line.startsWith('event:')) { + const value = line.slice('event:'.length).trim() as NotificationStreamEventName + eventType = value + return + } + + if (line.startsWith('id:')) { + eventId = line.slice('id:'.length).trim() || null + return + } + + if (line.startsWith('data:')) { + dataLines.push(line.slice('data:'.length).trimStart()) + } + }) + + const event = toNotificationStreamEvent(eventType, eventId, dataLines.join('\n')) + return event ? [event] : [] + }) +} + +export async function subscribeNotifications(options: { + signal: AbortSignal + onEvent: (event: NotificationStreamEvent) => void +}): Promise { + const { token } = getShopApiRuntimeConfig() + const headers = new Headers({ + Accept: STREAM_ACCEPT_HEADER, + 'Cache-Control': 'no-cache', + }) + + if (token) { + headers.set('Authorization', `Bearer ${token}`) + } + + const response = await fetch(buildNotificationStreamUrl(), { + method: 'GET', + headers, + signal: options.signal, + }) + + if (!response.ok) { + throw new Error(`通知流连接失败 (${response.status})`) + } + + if (!response.body) { + throw new Error('通知流不可用') + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + + buffer += decoder.decode(value, { stream: true }) + const frames = buffer.split(/\r?\n\r?\n/) + buffer = frames.pop() || '' + + frames.forEach((frame) => { + parseNotificationStreamChunk(frame).forEach((event) => { + options.onEvent(event) + }) + }) + } + + buffer += decoder.decode() + if (buffer.trim()) { + parseNotificationStreamChunk(buffer).forEach((event) => { + options.onEvent(event) + }) + } + } finally { + reader.releaseLock() + } +} diff --git a/plugins/bad-bear/src/api/pluginMarket.ts b/plugins/bad-bear/src/api/pluginMarket.ts new file mode 100644 index 000000000..1cc3a68e6 --- /dev/null +++ b/plugins/bad-bear/src/api/pluginMarket.ts @@ -0,0 +1,72 @@ +export { + getPluginDetail, + getPluginDetailByHash, + getPluginRisk, + getPluginStats, + getPluginRatings, + createPluginRating, + getPluginComments, + createPluginComment, + fetchPluginMarket, + streamPluginMarket, + checkPluginUploadHash, + checkPluginUpdates, + getMyPluginUploads, + getMyPluginUpload, + deleteMyPluginUpload, + uploadPluginPackage, + initChunkedUpload, + uploadChunk, + completeChunkedUpload, + cancelChunkedUpload, + getChunkedUploadProgress, +} from './pluginMarketRemote' + +export { + uploadPluginWithChunks, + queryChunkedUploadProgress, + abortChunkedUpload, +} from './pluginMarketChunkedUpload' + +export type { + ChunkedUploadConfig, + ChunkedUploadOptions, + ChunkedUploadProgressInfo, +} from './pluginMarketChunkedUpload' + +export { + applyMarketInstalledPluginHashes, + buildMarketPluginUpdateCheckItems, + normalizeSha256Hash, + readMarketInstalledPluginHashes, + removeMarketInstalledPluginHash, + upsertMarketInstalledPluginHash, +} from './pluginMarketInstallRegistry' + +export { + buildPluginDownloadUrl, + resolvePluginInstallPayload, + adaptPlugin, + adaptCategory, + deriveFallbackCategories, + buildStorefront, + normalizeMarketAssetUrl, + buildEncodedPluginDownloadPath, + buildCategorySummary, + toFallbackCategoryTitle, +} from './pluginMarketStorefront' + +export { + getInstalledPlugins, + getRunningPlugins, + getCurrentPlatform, + installMarketPlugin, + deleteInstalledPlugin, + getPluginReadme, + getPluginReadme as readPluginReadme, + openInstalledPlugin, + reloadInstalledPlugin, + stopInstalledPlugin, + revealPluginInFinder, + inferPlatform, +} from './pluginMarketHost' diff --git a/plugins/bad-bear/src/api/pluginMarketChunkedUpload.ts b/plugins/bad-bear/src/api/pluginMarketChunkedUpload.ts new file mode 100644 index 000000000..25bb30391 --- /dev/null +++ b/plugins/bad-bear/src/api/pluginMarketChunkedUpload.ts @@ -0,0 +1,411 @@ +import type { + ChunkedUploadInitRequest, + ChunkedUploadInitResponse, + ChunkedUploadProgressResponse, + PluginUploadResponse, +} from '../types/pluginMarket' +import { + cancelChunkedUpload, + completeChunkedUpload, + getChunkedUploadProgress, + initChunkedUpload, + uploadChunk, +} from './pluginMarketRemote' + +/** + * 分片上传配置 + */ +export interface ChunkedUploadConfig { + /** + * 分片大小(字节),默认 5MB + */ + chunkSize?: number + /** + * 最大并发上传数,默认 3 + */ + maxConcurrency?: number + /** + * 是否计算文件完整哈希(用于秒传),默认 true + */ + calculateFileHash?: boolean + /** + * 重试次数,默认 3 + */ + retryCount?: number + /** + * 重试延迟(毫秒),默认 1000 + */ + retryDelay?: number +} + +/** + * 分片上传进度回调参数 + */ +export interface ChunkedUploadProgressInfo { + /** + * 已上传的字节数 + */ + uploadedBytes: number + /** + * 文件总字节数 + */ + totalBytes: number + /** + * 上传进度百分比 (0-100) + */ + progress: number + /** + * 当前上传的分片索引 + */ + currentChunkIndex: number + /** + * 总分片数 + */ + totalChunks: number + /** + * 当前阶段 + */ + stage: 'hashing' | 'uploading' | 'merging' | 'completed' +} + +/** + * 分片上传选项 + */ +export interface ChunkedUploadOptions extends ChunkedUploadConfig { + /** + * 进度回调 + */ + onProgress?: (progress: ChunkedUploadProgressInfo) => void + /** + * 取消信号 + */ + signal?: AbortSignal +} + +/** + * 计算 Blob 的 SHA-256 哈希值 + */ +async function calculateSHA256(blob: Blob): Promise { + const arrayBuffer = await blob.arrayBuffer() + const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') + return `sha256:${hashHex}` +} + +/** + * 将文件切分为多个分片 + */ +function sliceFileIntoChunks(file: Blob, chunkSize: number): Blob[] { + const chunks: Blob[] = [] + let offset = 0 + + while (offset < file.size) { + const end = Math.min(offset + chunkSize, file.size) + chunks.push(file.slice(offset, end)) + offset = end + } + + return chunks +} + +/** + * 带重试的上传单个分片 + */ +async function uploadChunkWithRetry( + uploadId: string, + chunk: Blob, + chunkIndex: number, + chunkHash: string, + retryCount: number, + retryDelay: number, + signal?: AbortSignal, +): Promise { + let lastError: Error | null = null + + for (let attempt = 0; attempt <= retryCount; attempt++) { + if (signal?.aborted) { + throw new Error('上传已取消') + } + + try { + await uploadChunk({ + uploadId, + chunk, + chunkIndex, + chunkHash, + }) + return + } catch (error) { + lastError = error instanceof Error ? error : new Error('上传失败') + + if (attempt < retryCount) { + await new Promise((resolve) => setTimeout(resolve, retryDelay * (attempt + 1))) + } + } + } + + throw lastError || new Error('上传失败') +} + +/** + * 并发上传多个分片 + */ +async function uploadChunksConcurrently( + uploadId: string, + chunks: Blob[], + existingChunks: Set, + maxConcurrency: number, + retryCount: number, + retryDelay: number, + onProgress: (chunkIndex: number) => void, + signal?: AbortSignal, +): Promise { + const pendingChunks = chunks + .map((chunk, index) => ({ chunk, index })) + .filter(({ index }) => !existingChunks.has(index)) + + let activeUploads = 0 + let currentIndex = 0 + const errors: Error[] = [] + + return new Promise((resolve, reject) => { + const startNextUpload = async () => { + if (signal?.aborted) { + reject(new Error('上传已取消')) + return + } + + if (currentIndex >= pendingChunks.length) { + if (activeUploads === 0) { + if (errors.length > 0) { + reject(errors[0]) + } else { + resolve() + } + } + return + } + + const { chunk, index } = pendingChunks[currentIndex++] + activeUploads++ + + try { + const chunkHash = await calculateSHA256(chunk) + await uploadChunkWithRetry(uploadId, chunk, index, chunkHash, retryCount, retryDelay, signal) + onProgress(index) + } catch (error) { + errors.push(error instanceof Error ? error : new Error('上传失败')) + reject(errors[0]) + return + } finally { + activeUploads-- + } + + startNextUpload() + } + + // 启动初始并发上传 + for (let i = 0; i < Math.min(maxConcurrency, pendingChunks.length); i++) { + startNextUpload() + } + + // 如果没有待上传的分片,直接完成 + if (pendingChunks.length === 0) { + resolve() + } + }) +} + +/** + * 使用分片上传方式上传插件包 + * + * @param file 插件文件(最大 75MB) + * @param fileName 文件名 + * @param options 上传选项 + * @returns 上传结果 + * + * @example + * ```typescript + * const result = await uploadPluginWithChunks(file, 'my-plugin.zpx', { + * chunkSize: 5 * 1024 * 1024, // 5MB + * maxConcurrency: 3, + * onProgress: (progress) => { + * console.log(`进度: ${progress.progress}%`) + * } + * }) + * ``` + */ +export async function uploadPluginWithChunks( + file: Blob, + fileName: string, + options?: ChunkedUploadOptions, +): Promise { + const { + chunkSize = 5 * 1024 * 1024, // 5MB + maxConcurrency = 3, + calculateFileHash = true, + retryCount = 3, + retryDelay = 1000, + onProgress, + signal, + } = options || {} + + try { + // 阶段 1: 计算文件哈希(可选) + let fileHash: string | undefined + if (calculateFileHash) { + onProgress?.({ + uploadedBytes: 0, + totalBytes: file.size, + progress: 0, + currentChunkIndex: 0, + totalChunks: 0, + stage: 'hashing', + }) + + fileHash = await calculateSHA256(file) + + if (signal?.aborted) { + throw new Error('上传已取消') + } + } + + // 阶段 2: 切分文件 + const chunks = sliceFileIntoChunks(file, chunkSize) + const totalChunks = chunks.length + + // 阶段 3: 初始化分片上传 + const initRequest: ChunkedUploadInitRequest = { + fileName, + totalSize: file.size, + totalChunks, + fileHash, + } + + const initResponse: ChunkedUploadInitResponse = await initChunkedUpload(initRequest) + + // 如果文件已存在(秒传) + if (initResponse.fileExists) { + onProgress?.({ + uploadedBytes: file.size, + totalBytes: file.size, + progress: 100, + currentChunkIndex: totalChunks, + totalChunks, + stage: 'completed', + }) + + return { + success: true, + message: '文件已存在,跳过上传', + data: { + message: '文件已存在', + reviewTaskId: '', + ...initResponse.existingPlugin, + }, + } + } + + const { uploadId, existingChunks } = initResponse + const existingChunksSet = new Set(existingChunks) + + // 阶段 4: 上传分片 + const uploadedChunks = new Set(existingChunks) + let uploadedBytes = existingChunks.reduce((sum, index) => sum + chunks[index].size, 0) + + const handleChunkProgress = (chunkIndex: number) => { + uploadedChunks.add(chunkIndex) + uploadedBytes += chunks[chunkIndex].size + + onProgress?.({ + uploadedBytes, + totalBytes: file.size, + progress: Math.round((uploadedBytes / file.size) * 100), + currentChunkIndex: chunkIndex, + totalChunks, + stage: 'uploading', + }) + } + + try { + await uploadChunksConcurrently( + uploadId, + chunks, + existingChunksSet, + maxConcurrency, + retryCount, + retryDelay, + handleChunkProgress, + signal, + ) + } catch (error) { + // 上传失败,尝试取消 + try { + await cancelChunkedUpload(uploadId) + } catch { + // 忽略取消错误 + } + throw error + } + + if (signal?.aborted) { + try { + await cancelChunkedUpload(uploadId) + } catch { + // 忽略取消错误 + } + throw new Error('上传已取消') + } + + // 阶段 5: 完成上传 + onProgress?.({ + uploadedBytes: file.size, + totalBytes: file.size, + progress: 100, + currentChunkIndex: totalChunks, + totalChunks, + stage: 'merging', + }) + + const completeResponse = await completeChunkedUpload(uploadId) + + onProgress?.({ + uploadedBytes: file.size, + totalBytes: file.size, + progress: 100, + currentChunkIndex: totalChunks, + totalChunks, + stage: 'completed', + }) + + return { + success: true, + message: completeResponse.message || '上传成功', + reviewTaskId: completeResponse.reviewTaskId, + data: completeResponse, + } + } catch (error) { + console.error('[ChunkedUpload] 上传失败:', error) + return { + success: false, + error: error instanceof Error ? error.message : '上传失败', + } + } +} + +/** + * 查询分片上传进度 + */ +export async function queryChunkedUploadProgress( + uploadId: string, +): Promise { + return getChunkedUploadProgress(uploadId) +} + +/** + * 取消分片上传 + */ +export async function abortChunkedUpload(uploadId: string): Promise { + await cancelChunkedUpload(uploadId) +} diff --git a/plugins/bad-bear/src/api/pluginMarketHost.ts b/plugins/bad-bear/src/api/pluginMarketHost.ts new file mode 100644 index 000000000..e6b23d13c --- /dev/null +++ b/plugins/bad-bear/src/api/pluginMarketHost.ts @@ -0,0 +1,100 @@ +import type { + InstalledPlugin, + OperationResult, + Platform, + PluginLaunchOptions, + PluginLaunchResult, + PluginMarketPlugin, + PluginMarketUiPlugin, + PluginMutationResult, + PluginReadmeResponse, +} from '../types/pluginMarket' + +export function inferPlatform(): Platform { + const userAgent = navigator.userAgent.toLowerCase() + + if (userAgent.includes('mac')) { + return 'darwin' + } + + if (userAgent.includes('linux')) { + return 'linux' + } + + return 'win32' +} + +export async function getInstalledPlugins(): Promise { + return window.ztools.internal.getPlugins() +} + +export async function getRunningPlugins(): Promise { + return window.ztools.internal.getRunningPlugins?.() ?? [] +} + +export function getCurrentPlatform(): Platform { + return window.ztools.internal.getPlatform?.() ?? inferPlatform() +} + +export async function installMarketPlugin( + plugin: PluginMarketPlugin, +): Promise { + return window.ztools.internal.installPluginFromMarket(JSON.parse(JSON.stringify(plugin))) +} + +export async function deleteInstalledPlugin(pluginPath: string): Promise { + return window.ztools.internal.deletePlugin(pluginPath) +} + +export async function getPluginReadme(target: string): Promise { + return window.ztools.internal.getPluginReadme(target) +} + +export async function openInstalledPlugin( + plugin: PluginMarketUiPlugin, +): Promise { + if (!plugin.path) { + throw new Error('找不到插件路径') + } + + const launch = window.ztools.internal.launch + if (!launch) { + throw new Error('当前宿主未暴露 launch 能力') + } + + const payload: PluginLaunchOptions = { + path: plugin.path, + type: 'plugin', + name: plugin.title || plugin.name, + param: {}, + } + + return launch(payload) +} + +export async function reloadInstalledPlugin(pluginPath: string): Promise { + const reloadPlugin = window.ztools.internal.reloadPlugin + if (!reloadPlugin) { + throw new Error('当前宿主未暴露 reloadPlugin 能力') + } + + return reloadPlugin(pluginPath) +} + +export async function stopInstalledPlugin(pluginPath: string): Promise { + const killPlugin = window.ztools.internal.killPlugin + if (!killPlugin) { + throw new Error('当前宿主未暴露 killPlugin 能力') + } + + return killPlugin(pluginPath) +} + +export async function revealPluginInFinder(pluginPath: string): Promise { + const revealInFinder = window.ztools.internal.revealInFinder + if (!revealInFinder) { + throw new Error('当前宿主未暴露 revealInFinder 能力') + } + + await revealInFinder(pluginPath) +} diff --git a/plugins/bad-bear/src/api/pluginMarketInstallRegistry.ts b/plugins/bad-bear/src/api/pluginMarketInstallRegistry.ts new file mode 100644 index 000000000..eb54e0299 --- /dev/null +++ b/plugins/bad-bear/src/api/pluginMarketInstallRegistry.ts @@ -0,0 +1,249 @@ +import type { InstalledPlugin, PluginUpdateCheckRequestItem } from '../types/pluginMarket' + +export interface MarketInstalledPluginHashRecord { + name: string + hash: string +} + +const MARKET_INSTALL_REGISTRY_FILE_NAME = 'installed-plugin-hashes.csv' +const MARKET_INSTALL_REGISTRY_PATH_SEGMENTS = [ + 'plugins', + MARKET_INSTALL_REGISTRY_FILE_NAME, +] as const + +type RegistryPathTools = { + join: (...paths: string[]) => string +} + +function getRegistryPathTools(): RegistryPathTools | null { + return window.services?.path ?? null +} + +export function getMarketInstallRegistryFilePath(): string | null { + const userDataPath = window.ztools?.getPath?.('userData') + const pathTools = getRegistryPathTools() + + if (!userDataPath || !pathTools) { + return null + } + + return pathTools.join(userDataPath, ...MARKET_INSTALL_REGISTRY_PATH_SEGMENTS) +} + +export function normalizeSha256Hash(hash: unknown): string | null { + if (typeof hash !== 'string') { + return null + } + + const normalized = hash.trim().toLowerCase() + if (/^sha256:[0-9a-f]{64}$/.test(normalized)) { + return normalized + } + + if (/^[0-9a-f]{64}$/.test(normalized)) { + return `sha256:${normalized}` + } + + return null +} + +function parseCsvRows(content: string): string[][] { + const rows: string[][] = [] + let row: string[] = [] + let field = '' + let inQuotes = false + + for (let index = 0; index < content.length; index += 1) { + const char = content[index] + + if (char === '"') { + if (inQuotes && content[index + 1] === '"') { + field += '"' + index += 1 + } else { + inQuotes = !inQuotes + } + continue + } + + if (char === ',' && !inQuotes) { + row.push(field) + field = '' + continue + } + + if ((char === '\n' || char === '\r') && !inQuotes) { + row.push(field) + rows.push(row) + row = [] + field = '' + if (char === '\r' && content[index + 1] === '\n') { + index += 1 + } + continue + } + + field += char + } + + if (field.length > 0 || row.length > 0) { + row.push(field) + rows.push(row) + } + + return rows +} + +function isBlankCsvRow(row: string[]): boolean { + return row.every((field) => field.trim().length === 0) +} + +function isRegistryHeader(row: string[]): boolean { + return ( + row.length >= 2 && + row[0].trim().toLowerCase() === 'name' && + row[1].trim().toLowerCase() === 'hash' + ) +} + +function parseMarketInstallRegistry(content: string): Map { + const records = new Map() + let hasSeenDataRow = false + + for (const row of parseCsvRows(content)) { + if (isBlankCsvRow(row)) { + continue + } + + if (!hasSeenDataRow && isRegistryHeader(row)) { + hasSeenDataRow = true + continue + } + + hasSeenDataRow = true + const name = row[0]?.trim() || '' + const hash = normalizeSha256Hash(row[1]) + + if (!name || !hash) { + continue + } + + records.set(name, hash) + } + + return records +} + +function escapeCsvField(value: string): string { + if (!/[",\r\n]/.test(value)) { + return value + } + + return `"${value.replace(/"/g, '""')}"` +} + +type SerializedMarketInstalledPluginHashRecord = { + name: string + hash: string | null +} + +function serializeMarketInstallRegistry(records: Map): string { + const lines = ['name,hash'] + const sortedRecords = Array.from(records.entries()) + .map(([name, hash]): SerializedMarketInstalledPluginHashRecord => ({ name: name.trim(), hash: normalizeSha256Hash(hash) })) + .filter((record): record is MarketInstalledPluginHashRecord => !!record.name && !!record.hash) + .sort((left, right) => left.name.localeCompare(right.name)) + + for (const record of sortedRecords) { + lines.push(`${escapeCsvField(record.name)},${escapeCsvField(record.hash)}`) + } + + return `${lines.join('\n')}\n` +} + +export function readMarketInstalledPluginHashes(): Map { + const filePath = getMarketInstallRegistryFilePath() + if (!filePath || !window.services?.exists || !window.services?.readFile) { + return new Map() + } + + try { + if (!window.services.exists(filePath)) { + return new Map() + } + + return parseMarketInstallRegistry(window.services.readFile(filePath, 'utf-8')) + } catch (error) { + console.warn('[PluginMarket] 读取已安装插件 hash 记录失败:', error) + return new Map() + } +} + +export function writeMarketInstalledPluginHashes(records: Map): void { + const filePath = getMarketInstallRegistryFilePath() + if (!filePath || !window.services?.writeFile) { + return + } + + try { + window.services.writeFile(filePath, serializeMarketInstallRegistry(records)) + } catch (error) { + console.warn('[PluginMarket] 写入已安装插件 hash 记录失败:', error) + } +} + +export function upsertMarketInstalledPluginHash(name: string, hash: unknown): void { + const normalizedName = name.trim() + if (!normalizedName) { + return + } + + const normalizedHash = normalizeSha256Hash(hash) + if (!normalizedHash) { + removeMarketInstalledPluginHash(normalizedName) + return + } + + const records = readMarketInstalledPluginHashes() + records.set(normalizedName, normalizedHash) + writeMarketInstalledPluginHashes(records) +} + +export function removeMarketInstalledPluginHash(name: string): void { + const normalizedName = name.trim() + if (!normalizedName) { + return + } + + const records = readMarketInstalledPluginHashes() + if (!records.delete(normalizedName)) { + return + } + + writeMarketInstalledPluginHashes(records) +} + +export function applyMarketInstalledPluginHashes( + installedPlugins: InstalledPlugin[], + hashRecords = readMarketInstalledPluginHashes(), +): InstalledPlugin[] { + return installedPlugins.map((plugin) => { + const hash = hashRecords.get(plugin.name) + return hash ? { ...plugin, hash } : plugin + }) +} + +export function buildMarketPluginUpdateCheckItems( + installedPlugins: InstalledPlugin[], + hashRecords = readMarketInstalledPluginHashes(), +): PluginUpdateCheckRequestItem[] { + return installedPlugins.flatMap((plugin) => { + const hash = hashRecords.get(plugin.name) + return hash + ? [{ + name: plugin.name, + hash, + }] + : [] + }) +} diff --git a/plugins/bad-bear/src/api/pluginMarketRemote.ts b/plugins/bad-bear/src/api/pluginMarketRemote.ts new file mode 100644 index 000000000..50ac6748c --- /dev/null +++ b/plugins/bad-bear/src/api/pluginMarketRemote.ts @@ -0,0 +1,745 @@ +import { HttpClientError, requestFormData, requestJson } from './httpClient' +import { appendQuery } from './query' +import { getShopApiRuntimeConfig } from '../config/runtimeConfig' +import { + adaptPlugin, + buildStorefront, + deriveFallbackCategories, +} from './pluginMarketStorefront' +import type { + ChunkedUploadCancelResponse, + ChunkedUploadChunkPayload, + ChunkedUploadChunkResponse, + ChunkedUploadCompleteResponse, + ChunkedUploadInitRequest, + ChunkedUploadInitResponse, + ChunkedUploadProgressResponse, + CreatePluginCommentRequest, + CreatePluginRatingRequest, + MyPluginUploadRecord, + MyPluginUploadsQuery, + MyPluginUploadsResponse, + Platform, + PluginCommentRecord, + PluginCommentsPage, + PluginDetailResponse, + PluginHashCheckResponse, + PluginMarketCategoryDto, + PluginMarketFetchResponse, + PluginMarketPlugin, + PluginMarketPluginDto, + PluginMarketStreamSnapshot, + PluginPageQuery, + PluginRatingRecord, + PluginRatingsPage, + PluginRiskInfo, + PluginStatsResponse, + PluginUpdateCheckRequestItem, + PluginUpdateCheckResponseItem, + PluginUploadAcceptedResponse, + PluginUploadPayload, + PluginUploadResponse, + StreamPluginMarketOptions, +} from '../types/pluginMarket' + +const PLUGIN_MARKET_CACHE_KEY = 'bad-bear.plugin-market.cache.v2' +const PLUGIN_MARKET_STREAM_PATH = '/api/v1/plugins/stream' +const STREAM_ACCEPT_HEADER = 'text/event-stream' + +interface PluginMarketLatestResponse { + latestAt: string +} + +interface PluginMarketStreamStartPayload { + platform?: string + category?: string + total?: number +} + +interface PluginMarketStreamEndPayload { + platform?: string + total?: number + sent?: number +} + +interface PluginMarketCacheRecord { + version: 2 + latestSignature: string + plugins: PluginMarketPluginDto[] + categories: PluginMarketCategoryDto[] +} + +type PluginMarketStreamEvent = + | { type: 'plugins.start'; data: PluginMarketStreamStartPayload } + | { type: 'plugins.item'; data: PluginMarketPluginDto } + | { type: 'plugins.end'; data: PluginMarketStreamEndPayload } + +function getPluginMarketCacheStorage(): Storage | null { + if (typeof window === 'undefined') { + return null + } + + try { + return window.localStorage ?? null + } catch { + return null + } +} + +/** + * 校验本地缓存结构,避免旧版本快照污染新的按平台流式结果。 + */ +function isPluginMarketCacheRecord(value: unknown): value is PluginMarketCacheRecord { + if (!value || typeof value !== 'object') { + return false + } + + const candidate = value as Record + return ( + candidate.version === 2 && + typeof candidate.latestSignature === 'string' && + Array.isArray(candidate.plugins) && + Array.isArray(candidate.categories) + ) +} + +/** + * 以商店地址和平台维度隔离缓存,避免不同平台复用错列表结果。 + */ +function buildPluginMarketCacheKey(platform: Platform): string { + const { baseUrl } = getShopApiRuntimeConfig() + return `${PLUGIN_MARKET_CACHE_KEY}:${baseUrl}:${platform}` +} + +/** + * 读取当前平台的插件市场快照,格式不合法时自动清理脏数据。 + */ +function readPluginMarketCache(platform: Platform): PluginMarketCacheRecord | null { + const storage = getPluginMarketCacheStorage() + if (!storage) { + return null + } + + const cacheKey = buildPluginMarketCacheKey(platform) + const rawValue = storage.getItem(cacheKey) + if (!rawValue) { + return null + } + + try { + const parsed = JSON.parse(rawValue) + return isPluginMarketCacheRecord(parsed) ? parsed : null + } catch { + storage.removeItem(cacheKey) + return null + } +} + +/** + * 仅在拿到完整快照后写入缓存,避免后续刷新读到半截流数据。 + */ +function writePluginMarketCache(platform: Platform, record: PluginMarketCacheRecord): void { + const storage = getPluginMarketCacheStorage() + if (!storage) { + return + } + + try { + storage.setItem(buildPluginMarketCacheKey(platform), JSON.stringify(record)) + } catch (error) { + console.warn('[PluginMarket] 缓存插件商店数据失败:', error) + } +} + +function buildPluginMarketLatestSignature(value: PluginMarketLatestResponse): string { + return value.latestAt +} + +function isPluginMarketLatestResponse(value: unknown): value is PluginMarketLatestResponse { + if (!value || typeof value !== 'object') { + return false + } + + return typeof (value as Record).latestAt === 'string' +} + +/** + * 过滤掉缺失关键字段的插件条目,保证后续适配和列表合并按 name/version 工作。 + */ +function normalizePluginDtos(pluginResponse: PluginMarketPluginDto[]): PluginMarketPluginDto[] { + return (Array.isArray(pluginResponse) ? pluginResponse : []).filter( + (plugin): plugin is PluginMarketPluginDto => + !!plugin && typeof plugin.name === 'string' && typeof plugin.version === 'string', + ) +} + +/** + * 将插件 DTO 与分类 DTO 统一投影成页面可直接消费的市场返回结构。 + */ +function buildPluginMarketResponse( + pluginDtos: PluginMarketPluginDto[], + categoryDtos: PluginMarketCategoryDto[], +): PluginMarketFetchResponse { + const plugins: PluginMarketPlugin[] = pluginDtos.map(adaptPlugin) + const resolvedCategoryDtos = categoryDtos.length > 0 ? categoryDtos : deriveFallbackCategories(plugins) + + return { + success: true, + data: plugins, + storefront: buildStorefront(plugins, resolvedCategoryDtos), + } +} + +/** + * 单独拉取分类目录;失败时返回空数组,让调用方按当前已到达的插件回退生成分类。 + */ +async function fetchPluginCategoryDtos(): Promise { + try { + const categoryResponse = await requestJson({ + path: '/api/v1/plugins/categories', + }) + return Array.isArray(categoryResponse) ? categoryResponse : [] + } catch (error) { + console.warn('[PluginMarket] 加载插件分类失败,回退到插件自带分类:', error) + return [] + } +} + +/** + * 为流式接口构造与运行时认证配置一致的请求头。 + */ +function buildPluginMarketStreamHeaders(): Headers { + const { token } = getShopApiRuntimeConfig() + const headers = new Headers({ + Accept: STREAM_ACCEPT_HEADER, + 'Cache-Control': 'no-cache', + }) + + if (token) { + headers.set('Authorization', `Bearer ${token}`) + } + + return headers +} + +/** + * 解析 SSE data 行中的 JSON 载荷,非法数据直接丢弃,避免打断整条流。 + */ +function parsePluginMarketStreamPayload(payload: string): TPayload | null { + const trimmed = payload.trim() + if (!trimmed) { + return null + } + + try { + return JSON.parse(trimmed) as TPayload + } catch { + return null + } +} + +/** + * 把单个 SSE frame 解析成插件市场事件,只识别当前页面会消费的三种事件。 + */ +function parsePluginMarketStreamChunk(chunk: string): PluginMarketStreamEvent[] { + return chunk + .split(/\r?\n\r?\n/) + .map((frame) => frame.trim()) + .filter(Boolean) + .flatMap((frame) => { + const lines = frame.split(/\r?\n/) + let eventType = '' + const dataLines: string[] = [] + + lines.forEach((line) => { + if (!line || line.startsWith(':')) { + return + } + + if (line.startsWith('event:')) { + eventType = line.slice('event:'.length).trim() + return + } + + if (line.startsWith('data:')) { + dataLines.push(line.slice('data:'.length).trimStart()) + } + }) + + const payload = dataLines.join('\n') + if (eventType === 'plugins.start') { + const parsed = parsePluginMarketStreamPayload(payload) + return parsed ? [{ type: 'plugins.start', data: parsed }] : [] + } + + if (eventType === 'plugins.item') { + const parsed = parsePluginMarketStreamPayload(payload) + return parsed ? [{ type: 'plugins.item', data: parsed }] : [] + } + + if (eventType === 'plugins.end') { + const parsed = parsePluginMarketStreamPayload(payload) + return parsed ? [{ type: 'plugins.end', data: parsed }] : [] + } + + return [] + }) +} + +/** + * 把当前累计到的插件条目投影成可渲染快照,并在需要时通知页面立即刷新。 + */ +function emitPluginMarketSnapshot( + options: StreamPluginMarketOptions, + pluginDtos: PluginMarketPluginDto[], + categoryDtos: PluginMarketCategoryDto[], + complete: boolean, +): PluginMarketStreamSnapshot { + const snapshot: PluginMarketStreamSnapshot = { + ...buildPluginMarketResponse(pluginDtos, categoryDtos), + complete, + } + + if (!options.signal?.aborted) { + options.onSnapshot?.(snapshot) + } + + return snapshot +} + +/** + * 构造插件分页查询串,供评分、评论等分页接口复用统一的 page/pageSize 编码规则。 + */ +export function buildPluginPageQuery(query?: PluginPageQuery): string { + return appendQuery('', { + page: query?.page, + pageSize: query?.pageSize, + }) +} + +/** + * 为当前平台流式读取插件列表,并在插件或分类变化时持续产出可消费快照。 + */ +export async function streamPluginMarket( + options: StreamPluginMarketOptions, +): Promise { + const cachedMarket = readPluginMarketCache(options.platform) + + let latestSignature: string | null = null + try { + const latestResponse = await requestJson({ + path: '/api/v1/plugins/latest', + }) + if (!isPluginMarketLatestResponse(latestResponse)) { + throw new Error('插件商店 latest 响应格式无效') + } + + latestSignature = buildPluginMarketLatestSignature(latestResponse) + + if (cachedMarket && cachedMarket.latestSignature === latestSignature) { + const cachedSnapshot = emitPluginMarketSnapshot( + options, + cachedMarket.plugins, + cachedMarket.categories, + true, + ) + return cachedSnapshot + } + } catch (error) { + console.warn('[PluginMarket] 检查商店更新失败,将直接拉取插件列表:', error) + } + + const categoryTask = fetchPluginCategoryDtos() + let categoryDtos: PluginMarketCategoryDto[] = [] + const pluginDtos: PluginMarketPluginDto[] = [] + let expectedTotal: number | null = null + let streamEnded = false + + categoryTask.then((resolvedCategories) => { + categoryDtos = resolvedCategories + if (options.signal?.aborted) { + return + } + + if (pluginDtos.length > 0 || expectedTotal === 0) { + emitPluginMarketSnapshot(options, pluginDtos, categoryDtos, false) + } + }) + + const { baseUrl } = getShopApiRuntimeConfig() + const streamPath = appendQuery(PLUGIN_MARKET_STREAM_PATH, { + platform: options.platform, + }) + const response = await fetch(`${baseUrl}${streamPath}`, { + method: 'GET', + headers: buildPluginMarketStreamHeaders(), + signal: options.signal, + }) + + if (!response.ok) { + throw new Error(`插件商店流连接失败 (${response.status})`) + } + + if (!response.body) { + throw new Error('插件商店流不可用') + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + + buffer += decoder.decode(value, { stream: true }) + const frames = buffer.split(/\r?\n\r?\n/) + buffer = frames.pop() || '' + + frames.forEach((frame) => { + parsePluginMarketStreamChunk(frame).forEach((event) => { + if (event.type === 'plugins.start') { + expectedTotal = + typeof event.data.total === 'number' && Number.isFinite(event.data.total) + ? event.data.total + : null + return + } + + if (event.type === 'plugins.item') { + const nextPlugins = normalizePluginDtos([event.data]) + if (nextPlugins.length === 0) { + return + } + + pluginDtos.push(nextPlugins[0]) + emitPluginMarketSnapshot(options, pluginDtos, categoryDtos, false) + return + } + + streamEnded = true + if (typeof event.data.sent === 'number' && event.data.sent !== pluginDtos.length) { + throw new Error('插件商店流返回数量与 sent 不一致') + } + if (typeof event.data.total === 'number' && event.data.total !== pluginDtos.length) { + throw new Error('插件商店流返回数量与 total 不一致') + } + }) + }) + } + + buffer += decoder.decode() + if (buffer.trim()) { + parsePluginMarketStreamChunk(buffer).forEach((event) => { + if (event.type === 'plugins.start') { + expectedTotal = + typeof event.data.total === 'number' && Number.isFinite(event.data.total) + ? event.data.total + : null + return + } + + if (event.type === 'plugins.item') { + const nextPlugins = normalizePluginDtos([event.data]) + if (nextPlugins.length === 0) { + return + } + + pluginDtos.push(nextPlugins[0]) + emitPluginMarketSnapshot(options, pluginDtos, categoryDtos, false) + return + } + + streamEnded = true + if (typeof event.data.sent === 'number' && event.data.sent !== pluginDtos.length) { + throw new Error('插件商店流返回数量与 sent 不一致') + } + if (typeof event.data.total === 'number' && event.data.total !== pluginDtos.length) { + throw new Error('插件商店流返回数量与 total 不一致') + } + }) + } + } finally { + reader.releaseLock() + } + + if (!streamEnded) { + throw new Error('插件商店流提前结束') + } + + if (expectedTotal !== null && expectedTotal !== pluginDtos.length) { + throw new Error('插件商店流返回数量与 start.total 不一致') + } + + categoryDtos = await categoryTask + const finalSnapshot = emitPluginMarketSnapshot(options, pluginDtos, categoryDtos, true) + + if (latestSignature !== null && !options.signal?.aborted) { + writePluginMarketCache(options.platform, { + version: 2, + latestSignature, + plugins: pluginDtos, + categories: categoryDtos, + }) + } + + return finalSnapshot +} + +/** + * 获取插件市场完整快照,供不需要渐进刷新的调用方复用同一条流式链路。 + */ +export async function fetchPluginMarket(platform: Platform): Promise { + return streamPluginMarket({ platform }) +} + +export function getPluginDetail(name: string): Promise { + const pluginName = encodeURIComponent(name) + return requestJson({ + path: `/api/v1/plugins/${pluginName}`, + }) +} + +export function getPluginDetailByHash(hash: string): Promise { + return requestJson({ + path: `/api/v1/plugins/hash/${encodeURIComponent(hash)}`, + }) +} + +export function getPluginRisk(name: string, version?: string | null): Promise { + const pluginName = encodeURIComponent(name) + return requestJson({ + path: appendQuery(`/api/v1/plugins/${pluginName}/risk`, { + version: version?.trim() || undefined, + }), + }) +} + +export function getPluginStats(name: string): Promise { + const pluginName = encodeURIComponent(name) + return requestJson({ + path: `/api/v1/plugins/${pluginName}/stats`, + }) +} + +export function getPluginRatings( + name: string, + query?: PluginPageQuery, +): Promise { + const pluginName = encodeURIComponent(name) + return requestJson({ + path: appendQuery(`/api/v1/plugins/${pluginName}/ratings`, { + page: query?.page, + pageSize: query?.pageSize, + }), + }) +} + +export function createPluginRating( + name: string, + payload: CreatePluginRatingRequest, +): Promise { + const pluginName = encodeURIComponent(name) + return requestJson({ + path: `/api/v1/plugins/${pluginName}/ratings`, + method: 'POST', + body: payload, + }) +} + +export function getPluginComments( + name: string, + query?: PluginPageQuery, +): Promise { + const pluginName = encodeURIComponent(name) + return requestJson({ + path: appendQuery(`/api/v1/plugins/${pluginName}/comments`, { + page: query?.page, + pageSize: query?.pageSize, + }), + }) +} + +export function checkPluginUploadHash(hash: string): Promise { + return requestJson({ + path: '/api/v1/plugins/check-hash', + method: 'POST', + body: { hash }, + }) +} + +/** + * 按宿主返回的本地插件 hash 批量查询同一上传者范围内是否存在新版本。 + */ +export function checkPluginUpdates( + plugins: PluginUpdateCheckRequestItem[], +): Promise { + return requestJson({ + path: '/api/v1/plugins/check-updates', + method: 'POST', + body: { plugins }, + }) +} + +/** + * 分页读取当前用户的上传记录,列表中的 id 与上传接口返回的 reviewTaskId 一致。 + */ +export function getMyPluginUploads(query?: MyPluginUploadsQuery): Promise { + return requestJson({ + path: appendQuery('/api/v1/plugins/me/uploads', { + page: query?.page, + pageSize: query?.pageSize, + keyword: query?.keyword?.trim() || undefined, + }), + }) +} + +/** + * 按上传任务 ID 查询单条记录,用于上传接口返回后立即刷新该次处理进度。 + */ +export function getMyPluginUpload(id: string): Promise { + return requestJson({ + path: `/api/v1/plugins/me/uploads/${encodeURIComponent(id)}`, + }) +} + +/** + * 删除当前用户的一次上传记录;未发布记录由服务端保留并标记为发布失败。 + */ +export function deleteMyPluginUpload(id: string): Promise<{ message?: string } | null> { + return requestJson<{ message?: string } | null>({ + path: `/api/v1/plugins/me/uploads/${encodeURIComponent(id)}`, + method: 'DELETE', + }) +} + +export function createPluginComment( + name: string, + payload: CreatePluginCommentRequest, +): Promise { + const pluginName = encodeURIComponent(name) + return requestJson({ + path: `/api/v1/plugins/${pluginName}/comments`, + method: 'POST', + body: payload, + }) +} + +export async function uploadPluginPackage( + payload: PluginUploadPayload, +): Promise { + const fileName = payload.fileName || 'plugin.zpx' + const formData = new FormData() + formData.append('file', payload.file, fileName) + + console.log('[PluginMarket] uploadPluginPackage 请求准备完成:', { + fileName, + fileSize: payload.file.size, + fileType: payload.file.type, + }) + + try { + const data = await requestFormData({ + path: '/api/v1/plugins/upload', + method: 'POST', + body: formData, + }) + + console.log('[PluginMarket] uploadPluginPackage 请求成功:', data) + + return { + success: true, + message: typeof data?.message === 'string' ? data.message : '上传成功', + reviewTaskId: typeof data?.reviewTaskId === 'string' ? data.reviewTaskId : undefined, + data, + } + } catch (error) { + console.error('[PluginMarket] uploadPluginPackage 请求失败:', error) + if (error instanceof HttpClientError) { + console.error('[PluginMarket] uploadPluginPackage 响应详情:', { + status: error.status, + data: error.data, + }) + return { + success: false, + error: error.message, + data: error.data, + } + } + + return { + success: false, + error: error instanceof Error ? error.message : '上传失败', + } + } +} + +/** + * 初始化分片上传 + */ +export async function initChunkedUpload( + request: ChunkedUploadInitRequest, +): Promise { + return requestJson({ + path: '/api/v1/plugins/chunked-upload/init', + method: 'POST', + body: request, + }) +} + +/** + * 上传单个分片 + */ +export async function uploadChunk( + payload: ChunkedUploadChunkPayload, +): Promise { + const formData = new FormData() + formData.append('chunk', payload.chunk) + formData.append('chunkIndex', payload.chunkIndex.toString()) + formData.append('chunkHash', payload.chunkHash) + + return requestFormData({ + path: `/api/v1/plugins/chunked-upload/${encodeURIComponent(payload.uploadId)}/chunks`, + method: 'POST', + body: formData, + }) +} + +/** + * 完成分片上传 + */ +export async function completeChunkedUpload( + uploadId: string, +): Promise { + return requestJson({ + path: `/api/v1/plugins/chunked-upload/${encodeURIComponent(uploadId)}/complete`, + method: 'POST', + }) +} + +/** + * 取消分片上传 + */ +export async function cancelChunkedUpload( + uploadId: string, +): Promise { + return requestJson({ + path: `/api/v1/plugins/chunked-upload/${encodeURIComponent(uploadId)}/cancel`, + method: 'POST', + }) +} + +/** + * 查询分片上传进度 + */ +export async function getChunkedUploadProgress( + uploadId: string, +): Promise { + return requestJson({ + path: `/api/v1/plugins/chunked-upload/${encodeURIComponent(uploadId)}/progress`, + }) +} + diff --git a/plugins/bad-bear/src/api/pluginMarketStorefront.ts b/plugins/bad-bear/src/api/pluginMarketStorefront.ts new file mode 100644 index 000000000..6e2c0dd91 --- /dev/null +++ b/plugins/bad-bear/src/api/pluginMarketStorefront.ts @@ -0,0 +1,241 @@ +import { buildShopApiAssetUrl } from '../config/runtimeConfig' +import type { + PluginMarketCategoryDto, + PluginMarketPlugin, + PluginMarketPluginDto, + PluginMarketPluginReference, + PluginMarketStorefront, + PluginMarketStorefrontCategory, + StorefrontCategorySummary, +} from '../types/pluginMarket' + +export function normalizeMarketAssetUrl(resourcePath?: string): string | undefined { + if (!resourcePath) { + return undefined + } + + if (/^data:/i.test(resourcePath)) { + return resourcePath + } + + const normalizedUrl = buildShopApiAssetUrl(resourcePath) + return normalizedUrl || undefined +} + +export function buildEncodedPluginDownloadPath( + name: string, + version?: string | null, + hash?: string | null, +): string { + const encodedName = encodeURIComponent(name) + + if (hash) { + const encodedHash = encodeURIComponent(hash) + if (!version) { + return `/api/v1/plugins/${encodedName}/download?hash=${encodedHash}` + } + + const encodedVersion = encodeURIComponent(version) + return `/api/v1/plugins/${encodedName}/${encodedVersion}/${encodedHash}/download` + } + + if (!version) { + return `/api/v1/plugins/${encodedName}/download` + } + + const encodedVersion = encodeURIComponent(version) + return `/api/v1/plugins/${encodedName}/${encodedVersion}/download` +} + +export function buildPluginDownloadUrl( + name: string, + version?: string | null, + hash?: string | null, +): string { + return buildShopApiAssetUrl(buildEncodedPluginDownloadPath(name, version, hash)) +} + +export function resolvePluginInstallPayload( + plugin: PluginMarketPlugin, + options: { + version?: string | null + hash?: string | null + } = {}, +): PluginMarketPlugin { + const resolvedVersion = options.version || plugin.version + const downloadUrl = options.hash + ? buildPluginDownloadUrl(plugin.name, options.version || null, options.hash) + : options.version + ? buildPluginDownloadUrl(plugin.name, resolvedVersion) + : buildPluginDownloadUrl(plugin.name) + + return { + ...plugin, + version: resolvedVersion, + hash: options.hash || plugin.hash, + downloadUrl, + } +} + +function toPluginReference(name: string): PluginMarketPluginReference { + return { name } +} + +export function toFallbackCategoryTitle(key: string): string { + const title = key + .split(/[-_]/g) + .filter(Boolean) + .map((segment) => segment.slice(0, 1).toUpperCase() + segment.slice(1)) + .join(' ') + + return title || key +} + +export function adaptPlugin(dto: PluginMarketPluginDto): PluginMarketPlugin { + const categories = Array.isArray(dto.categories) + ? dto.categories.filter( + (category): category is string => typeof category === 'string' && category.trim().length > 0, + ) + : [] + const platform = Array.isArray(dto.platform) + ? dto.platform.filter( + (item): item is string => typeof item === 'string' && item.trim().length > 0, + ) + : [] + + return { + name: dto.name, + version: dto.version, + title: dto.title || dto.name, + description: dto.description, + author: dto.author, + main: dto.main, + logo: normalizeMarketAssetUrl(dto.logo), + preload: dto.preload, + features: Array.isArray(dto.features) ? dto.features : [], + platform, + categories, + categoryFallback: !!dto.categoryFallback, + downloadUrl: normalizeMarketAssetUrl(dto.downloadUrl), + hash: dto.hash ?? undefined, + source: dto.source, + uploaderUserId: dto.uploaderUserId ?? null, + uploaderAccount: dto.uploaderAccount ?? null, + uploaderUsername: dto.uploaderUsername ?? null, + risk: dto.risk ?? null, + } +} + +export function adaptCategory( + dto: PluginMarketCategoryDto, + validPluginNames: Set, +): PluginMarketStorefrontCategory | null { + if (!dto.key) { + return null + } + + const pluginNames = Array.isArray(dto.list) ? dto.list : [] + const seenPluginNames = new Set() + const plugins = pluginNames + .filter((name): name is string => typeof name === 'string' && name.trim().length > 0) + .filter((name) => validPluginNames.has(name) && !seenPluginNames.has(name)) + .map((name) => { + seenPluginNames.add(name) + return toPluginReference(name) + }) + + return { + key: dto.key, + title: dto.title || toFallbackCategoryTitle(dto.key), + description: dto.description, + icon: normalizeMarketAssetUrl(dto.icon), + plugins, + } +} + +export function deriveFallbackCategories(plugins: PluginMarketPlugin[]): PluginMarketCategoryDto[] { + const categoryMap = new Map() + + plugins.forEach((plugin) => { + ;(plugin.categories || []).forEach((categoryKey) => { + const normalizedKey = categoryKey.trim() + if (!normalizedKey) { + return + } + + const list = categoryMap.get(normalizedKey) || [] + if (!list.includes(plugin.name)) { + list.push(plugin.name) + } + categoryMap.set(normalizedKey, list) + }) + }) + + return Array.from(categoryMap.entries()).map(([key, list]) => ({ + key, + title: toFallbackCategoryTitle(key), + list, + })) +} + +export function buildCategorySummary( + category: PluginMarketStorefrontCategory, +): StorefrontCategorySummary { + return { + key: category.key, + title: category.title, + description: category.description, + icon: category.icon, + showDescription: !!category.description, + pluginCount: category.plugins.length, + } +} + +export function buildStorefront( + plugins: PluginMarketPlugin[], + categoryDtos: PluginMarketCategoryDto[], +): PluginMarketStorefront { + const validPluginNames = new Set(plugins.map((plugin) => plugin.name)) + const categoryEntries = categoryDtos + .map((category) => adaptCategory(category, validPluginNames)) + .filter((category): category is PluginMarketStorefrontCategory => !!category) + + const categories = Object.fromEntries( + categoryEntries.map((category) => [category.key, category]), + ) as Record + + const navigationCategories = categoryEntries.map(buildCategorySummary) + + const sections = [] as PluginMarketStorefront['sections'] + + if (navigationCategories.length > 0) { + sections.push({ + type: 'navigation', + key: 'categories', + title: '分类', + categories: navigationCategories, + }) + } + + if (plugins.length > 0) { + sections.push({ + type: 'fixed', + key: 'all-plugins', + title: '全部插件', + plugins: plugins.map((plugin) => toPluginReference(plugin.name)), + }) + } + + return { + sections, + categories, + categoryLayouts: { + default: [ + { + type: 'list', + title: '${title} 全部插件(共 ${count} 个)', + }, + ], + }, + } +} diff --git a/plugins/bad-bear/src/api/query.ts b/plugins/bad-bear/src/api/query.ts new file mode 100644 index 000000000..4e25498b5 --- /dev/null +++ b/plugins/bad-bear/src/api/query.ts @@ -0,0 +1,28 @@ +export type QueryValue = string | number | boolean | null | undefined + +export function buildQueryString(params: Record): string { + const searchParams = new URLSearchParams() + + Object.entries(params).forEach(([key, value]) => { + if (typeof value === 'number') { + searchParams.set(key, String(value)) + return + } + + if (typeof value === 'boolean') { + searchParams.set(key, String(value)) + return + } + + if (typeof value === 'string' && value) { + searchParams.set(key, value) + } + }) + + const queryString = searchParams.toString() + return queryString ? `?${queryString}` : '' +} + +export function appendQuery(path: string, params: Record): string { + return `${path}${buildQueryString(params)}` +} diff --git a/plugins/bad-bear/src/app/index.ts b/plugins/bad-bear/src/app/index.ts new file mode 100644 index 000000000..3bb4a7ecb --- /dev/null +++ b/plugins/bad-bear/src/app/index.ts @@ -0,0 +1 @@ +export * from './useMarketRiskDialog' diff --git a/plugins/bad-bear/src/app/useMarketRiskDialog.ts b/plugins/bad-bear/src/app/useMarketRiskDialog.ts new file mode 100644 index 000000000..adc171bb1 --- /dev/null +++ b/plugins/bad-bear/src/app/useMarketRiskDialog.ts @@ -0,0 +1,278 @@ +import { computed, ref } from 'vue' +import type { PluginMarketUiPlugin } from '../types/pluginMarket' + +interface MarketRiskChecklistItem { + key: string + label: string + checked: boolean +} + +interface MarketRiskDialogState { + visible: boolean + title: string + items: MarketRiskChecklistItem[] + type: 'info' | 'warning' | 'danger' + confirmText: string + cancelText: string + resolve: ((value: boolean) => void) | null +} + +const MARKET_RISK_DIALOG_TITLE = '风险提示' +const MARKET_RISK_DIALOG_KEY = 'bad-bear-market-risk-tip-dismissed' +const MARKET_RISK_ITEMS = [ + { + key: 'uploaded-by-users', + label: '商店内的插件均由用户上传,无法保证一定不存在恶意软件或其他风险内容。', + }, + { + key: 'stability-impact', + label: '安装、运行此类插件后,可能会对计算机稳定性以及相关程序的稳定性造成影响,请您自行甄别并谨慎使用。', + }, + { + key: 'copyright-risk', + label: '禁止上传有版权风险、破解付费的插件,本应用只是为了方便大家分享插件!', + }, + { + key: 'technical-exchange-only', + label: '插件内容仅供技术交流与学习参考,下载后请在 24 小时内删除,请勿用于商业用途或其他侵权场景。', + }, + { + key: 'infringement-contact', + label: '如发现插件内容涉及侵权或其他违规问题,请联系 admin@ydys.cc 处理。', + }, + { + key: 'no-data-retention-guarantee', + label: '本市场是玩票性质的插件市场,不对任何数据存留做保证,随时跑路。', + }, +] as const + +/** + * 插件内部 API 授权引导相关常量 + */ +const PLUGIN_NAME = 'bad-bear' +const INTERNAL_API_AUTH_GUIDE_KEY = 'bad-bear-internal-api-auth-guide-dismissed' + +/** + * 根据固定风险文案创建新的勾选项,确保每次打开弹框都需要重新逐项确认。 + */ +function createMarketRiskChecklistItems(): MarketRiskChecklistItem[] { + return MARKET_RISK_ITEMS.map((item) => ({ + key: item.key, + label: item.label, + checked: false, + })) +} + +/** + * 创建风险提示弹框的默认状态,确保每次打开都恢复为未确认的初始勾选状态。 + */ +function createMarketRiskDialogState(): MarketRiskDialogState { + return { + visible: false, + title: MARKET_RISK_DIALOG_TITLE, + items: createMarketRiskChecklistItems(), + type: 'warning', + confirmText: '不再弹出', + cancelText: '我已知晓', + resolve: null, + } +} + +export function useMarketRiskDialog() { + const marketRiskDialogState = ref(createMarketRiskDialogState()) + const hasConfirmedMarketRiskDialogInSession = ref(false) + const internalApiAuthGuideVisible = ref(false) + + const hasDismissedMarketRiskDialog = computed(() => { + try { + return localStorage.getItem(MARKET_RISK_DIALOG_KEY) === '1' + } catch { + return false + } + }) + + /** + * 判断用户是否已主动选择不再显示内部 API 授权引导弹窗。 + * 仅在未授权时用户点击"不再弹出"后持久化,授权成功导致的关闭不写入。 + */ + const hasDismissedInternalApiAuthGuide = computed(() => { + try { + return localStorage.getItem(INTERNAL_API_AUTH_GUIDE_KEY) === '1' + } catch { + return false + } + }) + + const canSubmitMarketRiskDialog = computed( + () => + marketRiskDialogState.value.items.length > 0 && + marketRiskDialogState.value.items.every((item) => item.checked), + ) + + /** + * 判断插件是否需要在启动前显示市场风险提示。 + * 仅对来源于插件市场的已安装非开发插件生效,避免影响本地开发调试流程。 + */ + function shouldConfirmOpenPluginRisk(plugin: PluginMarketUiPlugin): boolean { + return !!plugin.installed && !!plugin.marketPlugin && !plugin.isDevelopment + } + + /** + * 判断当前会话是否仍需要展示风险提示。 + * 用户本地已选择不再提示,或本次会话已经逐项确认过时,都会直接放行。 + */ + function shouldShowMarketRiskDialog(): boolean { + return !hasDismissedMarketRiskDialog.value && !hasConfirmedMarketRiskDialogInSession.value + } + + /** + * 打开风险提示弹框,并将所有勾选项重置为未确认状态。 + * 两个按钮都会继续打开插件,仅关闭弹框会中断本次操作。 + */ + async function openMarketRiskDialog(): Promise { + return new Promise((resolve) => { + marketRiskDialogState.value = { + ...createMarketRiskDialogState(), + visible: true, + resolve, + } + }) + } + + /** + * 在需要时弹出风险提示;若本次会话已确认或用户已选择不再提示则直接放行。 + */ + async function confirmMarketRiskDialog(): Promise { + if (!shouldShowMarketRiskDialog()) { + return true + } + + return openMarketRiskDialog() + } + + /** + * 在打开插件前执行一次风险确认;若用户已选择不再提示则直接放行。 + */ + async function confirmOpenPluginRisk(plugin: PluginMarketUiPlugin): Promise { + if (!shouldConfirmOpenPluginRisk(plugin)) { + return true + } + + return confirmMarketRiskDialog() + } + + /** + * 更新单条风险确认项的勾选状态,用于控制继续按钮何时可点击。 + */ + function updateMarketRiskChecklistItem(key: string, checked: boolean): void { + const item = marketRiskDialogState.value.items.find((entry) => entry.key === key) + if (!item) { + return + } + + item.checked = checked + } + + /** + * 处理“不再弹出”按钮,仅当四项风险都确认后才记录偏好并继续本次打开动作。 + */ + function handleMarketRiskConfirm(): void { + if (!canSubmitMarketRiskDialog.value) { + return + } + + const resolve = marketRiskDialogState.value.resolve + hasConfirmedMarketRiskDialogInSession.value = true + marketRiskDialogState.value = createMarketRiskDialogState() + + try { + localStorage.setItem(MARKET_RISK_DIALOG_KEY, '1') + } catch (error) { + console.warn('[BadBear] Failed to persist market risk dialog preference:', error) + } + + resolve?.(true) + } + + /** + * 处理“我已知晓”按钮,仅当四项风险都确认后才继续本次打开且不持久化偏好。 + */ + function handleMarketRiskCancel(): void { + if (!canSubmitMarketRiskDialog.value) { + return + } + + const resolve = marketRiskDialogState.value.resolve + hasConfirmedMarketRiskDialogInSession.value = true + marketRiskDialogState.value = createMarketRiskDialogState() + resolve?.(true) + } + + /** + * 处理弹框可见性变更;当用户直接关闭弹框时,中断本次打开动作。 + */ + function handleMarketRiskVisibleChange(visible: boolean): void { + if (visible) { + marketRiskDialogState.value.visible = true + return + } + + const resolve = marketRiskDialogState.value.resolve + if (!marketRiskDialogState.value.visible && !resolve) { + return + } + + marketRiskDialogState.value = createMarketRiskDialogState() + resolve?.(false) + } + + /** + * 打开内部 API 授权引导弹窗。 + * 仅在 canUseInternalPluginApis 为 false 且用户未点过"不再弹出"时显示。 + */ + function openInternalApiAuthGuide(): void { + internalApiAuthGuideVisible.value = true + } + + /** + * 关闭授权引导弹窗,本次关闭不持久化偏好。 + */ + function closeInternalApiAuthGuide(): void { + internalApiAuthGuideVisible.value = false + } + + /** + * 关闭授权引导弹窗并持久化"不再弹出"偏好。 + * 仅在未授权时用户主动点击"不再弹出"时调用。 + */ + function dismissInternalApiAuthGuide(): void { + internalApiAuthGuideVisible.value = false + try { + localStorage.setItem(INTERNAL_API_AUTH_GUIDE_KEY, '1') + } catch (error) { + console.warn('[BadBear] Failed to persist internal API auth guide preference:', error) + } + } + + return { + marketRiskDialogState, + hasDismissedMarketRiskDialog, + hasConfirmedMarketRiskDialogInSession, + canSubmitMarketRiskDialog, + openMarketRiskDialog, + shouldConfirmOpenPluginRisk, + shouldShowMarketRiskDialog, + confirmMarketRiskDialog, + confirmOpenPluginRisk, + updateMarketRiskChecklistItem, + handleMarketRiskConfirm, + handleMarketRiskCancel, + handleMarketRiskVisibleChange, + internalApiAuthGuideVisible, + hasDismissedInternalApiAuthGuide, + pluginName: PLUGIN_NAME, + openInternalApiAuthGuide, + closeInternalApiAuthGuide, + dismissInternalApiAuthGuide, + } +} diff --git a/plugins/bad-bear/src/assets/icons/alias.svg b/plugins/bad-bear/src/assets/icons/alias.svg new file mode 100644 index 000000000..aff617562 --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/alias.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/plugins/bad-bear/src/assets/icons/back.svg b/plugins/bad-bear/src/assets/icons/back.svg new file mode 100644 index 000000000..34fba0e8f --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/back.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/ban.svg b/plugins/bad-bear/src/assets/icons/ban.svg new file mode 100644 index 000000000..2efafe11d --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/ban.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/brain.svg b/plugins/bad-bear/src/assets/icons/brain.svg new file mode 100644 index 000000000..8d7b6977e --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/brain.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/check.svg b/plugins/bad-bear/src/assets/icons/check.svg new file mode 100644 index 000000000..9de891fc3 --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/check.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/close.svg b/plugins/bad-bear/src/assets/icons/close.svg new file mode 100644 index 000000000..1aff23a28 --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/close.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/cloud.svg b/plugins/bad-bear/src/assets/icons/cloud.svg new file mode 100644 index 000000000..aed158abb --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/cloud.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/database.svg b/plugins/bad-bear/src/assets/icons/database.svg new file mode 100644 index 000000000..886904d35 --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/database.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/file.svg b/plugins/bad-bear/src/assets/icons/file.svg new file mode 100644 index 000000000..3623bc3e3 --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/file.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/folder.svg b/plugins/bad-bear/src/assets/icons/folder.svg new file mode 100644 index 000000000..c48d5c090 --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/folder.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/image.svg b/plugins/bad-bear/src/assets/icons/image.svg new file mode 100644 index 000000000..1c7010ffe --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/image.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/info.svg b/plugins/bad-bear/src/assets/icons/info.svg new file mode 100644 index 000000000..c119a92a3 --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/info.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/keyboard.svg b/plugins/bad-bear/src/assets/icons/keyboard.svg new file mode 100644 index 000000000..f6ba30672 --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/keyboard.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/list.svg b/plugins/bad-bear/src/assets/icons/list.svg new file mode 100644 index 000000000..ebad820f3 --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/list.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/mcp.svg b/plugins/bad-bear/src/assets/icons/mcp.svg new file mode 100644 index 000000000..7d08436db --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/mcp.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/monitor.svg b/plugins/bad-bear/src/assets/icons/monitor.svg new file mode 100644 index 000000000..3fa3aff8b --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/monitor.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/more.svg b/plugins/bad-bear/src/assets/icons/more.svg new file mode 100644 index 000000000..d18307fca --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/more.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/order.svg b/plugins/bad-bear/src/assets/icons/order.svg new file mode 100644 index 000000000..dd0ad7582 --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/order.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/plugins/bad-bear/src/assets/icons/package.svg b/plugins/bad-bear/src/assets/icons/package.svg new file mode 100644 index 000000000..224d76b07 --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/package.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/pin.svg b/plugins/bad-bear/src/assets/icons/pin.svg new file mode 100644 index 000000000..810e627f1 --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/pin.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/play.svg b/plugins/bad-bear/src/assets/icons/play.svg new file mode 100644 index 000000000..a9325fafe --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/play.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/plugin.svg b/plugins/bad-bear/src/assets/icons/plugin.svg new file mode 100644 index 000000000..307dc43e0 --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/plugin.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/refresh.svg b/plugins/bad-bear/src/assets/icons/refresh.svg new file mode 100644 index 000000000..0f798b7ae --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/refresh.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/regex.svg b/plugins/bad-bear/src/assets/icons/regex.svg new file mode 100644 index 000000000..51b4e1453 --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/regex.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/search.svg b/plugins/bad-bear/src/assets/icons/search.svg new file mode 100644 index 000000000..b69872299 --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/search.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/settings.svg b/plugins/bad-bear/src/assets/icons/settings.svg new file mode 100644 index 000000000..8905fc73c --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/settings.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/stop.svg b/plugins/bad-bear/src/assets/icons/stop.svg new file mode 100644 index 000000000..93edeec4c --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/stop.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/store.svg b/plugins/bad-bear/src/assets/icons/store.svg new file mode 100644 index 000000000..4996bb094 --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/store.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/terminal.svg b/plugins/bad-bear/src/assets/icons/terminal.svg new file mode 100644 index 000000000..498fdf032 --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/terminal.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/text.svg b/plugins/bad-bear/src/assets/icons/text.svg new file mode 100644 index 000000000..13793530b --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/text.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/trash.svg b/plugins/bad-bear/src/assets/icons/trash.svg new file mode 100644 index 000000000..56b13fb44 --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/trash.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/icons/window.svg b/plugins/bad-bear/src/assets/icons/window.svg new file mode 100644 index 000000000..e1f67268c --- /dev/null +++ b/plugins/bad-bear/src/assets/icons/window.svg @@ -0,0 +1 @@ + diff --git a/plugins/bad-bear/src/assets/image/default.png b/plugins/bad-bear/src/assets/image/default.png new file mode 100644 index 000000000..5a7fc666f Binary files /dev/null and b/plugins/bad-bear/src/assets/image/default.png differ diff --git a/plugins/bad-bear/src/assets/image/image.png b/plugins/bad-bear/src/assets/image/image.png new file mode 100644 index 000000000..36df932ba Binary files /dev/null and b/plugins/bad-bear/src/assets/image/image.png differ diff --git a/plugins/bad-bear/src/assets/image/settings-fill.png b/plugins/bad-bear/src/assets/image/settings-fill.png new file mode 100644 index 000000000..640198b1e Binary files /dev/null and b/plugins/bad-bear/src/assets/image/settings-fill.png differ diff --git a/plugins/bad-bear/src/assets/styles/css/global.css b/plugins/bad-bear/src/assets/styles/css/global.css new file mode 100644 index 000000000..b624dcd5a --- /dev/null +++ b/plugins/bad-bear/src/assets/styles/css/global.css @@ -0,0 +1,708 @@ +/* 全局禁用滚动条(只针对 html 和 body,不影响内部组件) */ +html, +body { + overflow: hidden !important; + margin: 0; + padding: 0; + width: 100%; + height: 100%; + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE and Edge */ +} + +/* 隐藏 body 的滚动条 */ +html::-webkit-scrollbar, +body::-webkit-scrollbar { + display: none !important; + width: 0 !important; + height: 0 !important; +} + +:root { + --bg-color: #f4f4f4; + --text-color: #333333; + --text-secondary: #616161; + --border-color: #e5e7eb; + --divider-color: rgba(0, 0, 0, 0.1); + --card-bg: rgba(0, 0, 0, 0.015); + --control-bg: rgba(0, 0, 0, 0.035); + --control-border: rgba(0, 0, 0, 0.1); + --hover-bg: #f9fafb; + --active-bg: #f0f9ff; + --primary-color: #0284c7; + --input-bg: #f4f4f4; + --input-border: #e5e7eb; + --input-focus-bg: #f0f9ff; + --placeholder-color: #616161; + --shadow-color: rgba(0, 0, 0, 0.1); + --primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + --text-on-primary: #ffffff; + --highlight-color: #ea580c; + --danger-color: #ef4444; + --warning-color: #f59e0b; + --success-color: #10b981; + --purple-color: #8b5cf6; + --purple-light-bg: #f3e8ff; + --purple-border: #d8b4fe; + --success-light-bg: #ecfdf5; + --success-border: #a7f3d0; + --warning-light-bg: #fef3c7; + --warning-border: #fcd34d; + --danger-light-bg: #fee2e2; + --danger-border: #fca5a5; + --dialog-bg: #f4f4f4; +} + +/* macOS 下使用透明背景 */ +.os-mac:root { + --bg-color: #f4f4f44f; +} + +/* Windows 下默认使用普通背景色 */ +.os-windows:root { + --bg-color: #f4f4f4; +} + +/* Windows 下根据 data-material 属性应用不同材质样式 */ +.os-windows[data-material='mica'] { + --bg-color: transparent; + --card-bg: rgba(255, 255, 255, 0.5); /* 增加白度,更实一点 */ + --control-bg: rgba(255, 255, 255, 0.6); /* 输入框背景更明显 */ + --hover-bg: rgba(0, 0, 0, 0.04); + --active-bg: rgba(0, 0, 0, 0.06); + --divider-color: rgba(0, 0, 0, 0.06); + --highlight-color: rgba(234, 88, 12, 0.9); +} + +.os-windows[data-material='acrylic'] { + --bg-color: transparent; + --card-bg: rgba(255, 255, 255, 0.3); + --control-bg: rgba(255, 255, 255, 0.25); + --hover-bg: rgba(0, 0, 0, 0.05); + --active-bg: rgba(0, 0, 0, 0.08); + --divider-color: rgba(0, 0, 0, 0.06); + --control-border: rgba(0, 0, 0, 0.15); + --highlight-color: rgba(234, 88, 12, 0.9); +} + +.os-windows[data-material='none'] { + --bg-color: #f4f4f4; + --card-bg: white; + --control-bg: white; +} + +/* 主题色定义 - 亮色模式 */ +:root, +.theme-blue { + --primary-color: #0284c7; +} + +.theme-purple { + --primary-color: #7c3aed; +} + +.theme-green { + --primary-color: #059669; +} + +.theme-orange { + --primary-color: #ea580c; +} + +.theme-red { + --primary-color: #dc2626; +} + +.theme-pink { + --primary-color: #db2777; +} + +html.dark { + --bg-color: #303133; + --text-color: #f3f4f6; + --text-secondary: #bfc0c3; + --border-color: #374151; + --divider-color: rgba(255, 255, 255, 0.1); + --card-bg: rgba(255, 255, 255, 0.04); + --control-bg: rgba(255, 255, 255, 0.04); + --control-border: rgba(255, 255, 255, 0.1); + --hover-bg: #374151; + --input-bg: #374151; + --input-border: #374151; + --input-focus-bg: #4b5563; + --placeholder-color: #b8b8b8; + --shadow-color: rgba(0, 0, 0, 0.3); + --primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + --text-on-primary: #ffffff; + --highlight-color: #fb923c; + --danger-color: #f87171; + --warning-color: #fbbf24; + --success-color: #34d399; + --purple-color: #a78bfa; + --purple-light-bg: rgba(139, 92, 246, 0.2); + --purple-border: rgba(139, 92, 246, 0.4); + --success-light-bg: rgba(52, 211, 153, 0.2); + --success-border: rgba(52, 211, 153, 0.4); + --warning-light-bg: rgba(251, 191, 36, 0.2); + --warning-border: rgba(251, 191, 36, 0.4); + --danger-light-bg: rgba(248, 113, 113, 0.2); + --danger-border: rgba(248, 113, 113, 0.4); + --dialog-bg: #303133; +} + +/* macOS 暗色模式下使用透明背景 */ +html.dark.os-mac:root { + --bg-color: transparent; +} + +/* Windows 暗色模式下默认使用普通背景色 */ +html.dark.os-windows:root { + --bg-color: #303133; +} + +/* Windows 暗色模式下根据 data-material 属性应用不同材质样式 */ +html.dark.os-windows[data-material='mica'] { + --bg-color: transparent; + --card-bg: rgba(255, 255, 255, 0.04); + --control-bg: rgba(255, 255, 255, 0.06); + --hover-bg: rgba(255, 255, 255, 0.05); + --active-bg: rgba(255, 255, 255, 0.08); + --divider-color: rgba(255, 255, 255, 0.08); + --highlight-color: rgba(251, 146, 60, 0.85); +} + +html.dark.os-windows[data-material='acrylic'] { + --bg-color: transparent; + --card-bg: rgba(255, 255, 255, 0.02); + --control-bg: rgba(255, 255, 255, 0.04); + --hover-bg: rgba(255, 255, 255, 0.05); + --active-bg: rgba(255, 255, 255, 0.08); + --divider-color: rgba(255, 255, 255, 0.06); + --control-border: rgba(255, 255, 255, 0.15); + --highlight-color: rgba(251, 146, 60, 0.85); +} + +html.dark.os-windows[data-material='none'] { + --bg-color: #303133; + --card-bg: #3a3a3c; + --control-bg: #48484a; +} + +/* 主题色定义 - 暗色模式 */ +html.dark, +html.dark .theme-blue, +html.dark.theme-blue { + --primary-color: #38bdf8; +} + +html.dark .theme-purple, +html.dark.theme-purple { + --primary-color: #a78bfa; +} + +html.dark .theme-green, +html.dark.theme-green { + --primary-color: #34d399; +} + +html.dark .theme-orange, +html.dark.theme-orange { + --primary-color: #fb923c; +} + +html.dark .theme-red, +html.dark.theme-red { + --primary-color: #f87171; +} + +html.dark .theme-pink, +html.dark.theme-pink { + --primary-color: #f472b6; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* 注意:html 和 body 的 overflow 设置已在文件开头全局定义 */ + +body { + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + color: var(--text-color); + + /* 使用 color-mix 计算 hover 和 light-bg */ + /* 必须定义在 body 上,以便在 theme-xxx 类改变 --primary-color 时重新计算 */ + --primary-hover: color-mix(in srgb, var(--primary-color), black 10%); + --primary-light-bg: color-mix(in srgb, var(--primary-color), white 80%); +} + +/* 默认的 active-bg 和 hover-bg(非 Windows 材质模式) */ +html:not(.os-windows[data-material='mica']):not(.os-windows[data-material='acrylic']) body { + --active-bg: color-mix(in srgb, var(--primary-color) 20%, white); + --hover-bg: color-mix(in srgb, var(--primary-color) 12%, white); +} + +/* 文本选择样式 - 使用主题色 */ +::selection { + background: color-mix(in srgb, var(--primary-color) 35%, white); + color: var(--text-color); +} + +::-moz-selection { + background: color-mix(in srgb, var(--primary-color) 35%, white); + color: var(--text-color); +} + +#app { + overflow: hidden !important; /* 完全隐藏滚动条 */ + max-width: 100%; /* 限制最大宽度 */ + height: 100%; +} + +html.dark body { + --primary-hover: color-mix(in srgb, var(--primary-color), white 10%); + --primary-light-bg: color-mix(in srgb, var(--primary-color), transparent 92%); +} + +html.dark:not(.os-windows[data-material='mica']):not(.os-windows[data-material='acrylic']) body { + --active-bg: color-mix(in srgb, var(--primary-color) 12%, #303133); + --hover-bg: color-mix(in srgb, var(--primary-color) 5%, #303133); +} + +html.dark ::selection { + background: color-mix(in srgb, var(--primary-color) 40%, #303133); + color: var(--text-color); +} + +html.dark ::-moz-selection { + background: color-mix(in srgb, var(--primary-color) 40%, #303133); + color: var(--text-color); +} + +/* ==================== 通用控件样式规范 ==================== */ + +/* 按钮基础类 */ +.btn { + padding: 6px 16px; + border: 2px solid var(--control-border); + border-radius: 6px; + background: var(--control-bg); + color: var(--text-color); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + outline: none; + user-select: none; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 按钮尺寸变体 */ +.btn-sm { + padding: 4px 12px; + font-size: 12px; +} + +.btn-md { + padding: 6px 14px; + font-size: 13px; +} + +.btn-lg { + padding: 10px 20px; + font-size: 15px; +} + +.btn-icon { + padding: 6px; + min-width: 32px; + min-height: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +.btn-icon svg { + display: block; + flex-shrink: 0; +} + +/* 小图标按钮(无边框,透明背景)- 用于已安装插件页面和插件市场 */ +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + cursor: pointer; + transition: all 0.2s; + flex-shrink: 0; +} + +.icon-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 按钮类型变体 - 默认悬浮时显示主题色 */ +.btn:hover:not(:disabled) { + background: var(--hover-bg); + border-color: color-mix(in srgb, var(--primary-color), black 15%); + color: var(--primary-color); +} + +.btn:active:not(:disabled) { + background: var(--active-bg); +} + +.btn-primary:hover:not(:disabled) { + background: var(--hover-bg); + border-color: color-mix(in srgb, var(--primary-color), black 15%); + color: var(--primary-color); +} + +.btn-primary:active:not(:disabled) { + background: var(--active-bg); +} + +.btn-danger:hover:not(:disabled) { + background: var(--danger-light-bg); + border-color: color-mix(in srgb, var(--danger-color), black 15%); + color: var(--danger-color); +} + +.btn-warning:hover:not(:disabled) { + background: var(--warning-light-bg); + border-color: color-mix(in srgb, var(--warning-color), black 15%); + color: var(--warning-color); +} + +.btn-success:hover:not(:disabled) { + background: var(--success-light-bg); + border-color: color-mix(in srgb, var(--success-color), black 15%); + color: var(--success-color); +} + +.btn-purple:hover:not(:disabled) { + background: var(--purple-light-bg); + border-color: color-mix(in srgb, var(--purple-color), black 15%); + color: var(--purple-color); +} + +/* 实心按钮变体 - 默认使用实心主题色,符合控件设计规范 */ +.btn-solid { + background: var(--primary-color); + border: 2px solid var(--primary-color); + border-radius: 6px; + padding: 6px 16px; + color: #ffffff; + font-weight: 500; +} + +.btn-solid:hover:not(:disabled) { + background: color-mix(in srgb, var(--primary-color), black 10%); + border-color: color-mix(in srgb, var(--primary-color), black 10%); + color: #ffffff; +} + +.btn-solid:active:not(:disabled) { + background: color-mix(in srgb, var(--primary-color), black 20%); + border-color: color-mix(in srgb, var(--primary-color), black 20%); +} + +/* Acrylic 材质模式下使用透明风格 */ +.os-windows[data-material='acrylic'] .btn-solid { + background: var(--control-bg); + border-color: var(--primary-color); + color: var(--primary-color); + font-weight: 600; +} + +.os-windows[data-material='acrylic'] .btn-solid:hover:not(:disabled) { + background: var(--hover-bg); + border-color: color-mix(in srgb, var(--primary-color), black 15%); + color: var(--primary-color); +} + +.os-windows[data-material='acrylic'] .btn-solid:active:not(:disabled) { + background: var(--active-bg); +} + +/* 输入框基础类 */ +.input { + width: 100%; + padding: 6px 16px; + border: 2px solid var(--control-border); + border-radius: 6px; + font-size: 14px; + transition: all 0.2s; + outline: none; + background: var(--control-bg); + color: var(--text-color); +} + +.input::placeholder { + color: var(--placeholder-color); +} + +.input:hover { + background: var(--hover-bg); + border-color: color-mix(in srgb, var(--primary-color), black 15%); +} + +.input:focus { + background: var(--active-bg); + border-color: color-mix(in srgb, var(--primary-color), black 15%); + box-shadow: 0 0 0 3px var(--primary-light-bg); +} + +/* 下拉框 */ +.select { + width: 100%; + padding: 6px 16px; + border: 2px solid var(--control-border); + border-radius: 6px; + font-size: 14px; + transition: all 0.2s; + outline: none; + background: var(--control-bg); + color: var(--text-color); + cursor: pointer; + color-scheme: light dark; +} + +.select:hover { + background: var(--hover-bg); + border-color: color-mix(in srgb, var(--primary-color), black 15%); +} + +.select:focus { + background: var(--active-bg); + border-color: color-mix(in srgb, var(--primary-color), black 15%); + box-shadow: 0 0 0 3px var(--primary-light-bg); +} + +.select option { + background-color: var(--bg-color); + color: var(--text-color); +} + +/* 开关基础类 */ +.toggle { + position: relative; + display: inline-block; + width: 44px; + height: 24px; +} + +.toggle input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--control-bg); + border: 2px solid var(--control-border); + transition: all 0.3s; + border-radius: 24px; + box-sizing: border-box; +} + +.toggle-slider:hover { + background-color: var(--hover-bg); +} + +.toggle-slider:hover::before { + transform: scale(1.15); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.toggle-slider::before { + position: absolute; + content: ''; + height: 16px; + width: 16px; + left: 2px; + bottom: 2px; + background-color: var(--text-color); + transition: all 0.3s; + border-radius: 50%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.toggle input:checked + .toggle-slider { + background-color: color-mix(in srgb, var(--primary-color), black 15%); + border-color: color-mix(in srgb, var(--primary-color), black 15%); +} + +.toggle input:checked + .toggle-slider:hover { + background-color: color-mix(in srgb, var(--primary-color), black 25%); + border-color: color-mix(in srgb, var(--primary-color), black 25%); +} + +.toggle input:checked + .toggle-slider:hover::before { + transform: translateX(20px) scale(1.15); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.toggle input:checked + .toggle-slider::before { + transform: translateX(20px); + background-color: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.toggle input:focus + .toggle-slider { + box-shadow: 0 0 0 3px var(--primary-light-bg); +} + +/* 卡片基础类 */ +.card { + border-radius: 8px; + padding: 12px; + background: var(--card-bg); + border: 1px solid rgba(0, 0, 0, 0.08); + transition: all 0.2s; + user-select: none; +} + +.card:hover { + background: var(--hover-bg); +} + +.card.clickable { + cursor: pointer; +} + +/* macOS 风格毛玻璃效果 - 仅 Acrylic 模式 */ +.os-windows[data-material='acrylic'] .card, +.os-windows[data-material='acrylic'] .btn, +.os-windows[data-material='acrylic'] .input, +.os-windows[data-material='acrylic'] .select { + backdrop-filter: blur(40px) saturate(180%); + -webkit-backdrop-filter: blur(40px) saturate(180%); +} + +/* 全局自适应图标样式 - 主题色渐变 */ +/* 使用 mask + linear-gradient 实现真正的颜色渐变 */ + +.adaptive-icon { + /* 使用图标作为蒙版 */ + -webkit-mask-image: var(--icon-url); + mask-image: var(--icon-url); + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; +} + +/* 亮色模式:从主题色到极浅主题色的渐变 */ +.adaptive-icon { + background: linear-gradient( + 135deg, + var(--primary-color) 0%, + color-mix(in srgb, var(--primary-color) 10%, white) 100% + ); + opacity: 0.88; +} + +/* 暗色模式:从主题色到极浅主题色的渐变 */ +html.dark .adaptive-icon { + background: linear-gradient( + 135deg, + var(--primary-color) 0%, + color-mix(in srgb, var(--primary-color) 15%, white) 100% + ); + opacity: 0.92; +} + +/* 不同主题的色相旋转值 */ +.theme-blue { + --adaptive-icon-hue: 180deg; +} + +.theme-purple { + --adaptive-icon-hue: 260deg; +} + +.theme-green { + --adaptive-icon-hue: 100deg; +} + +.theme-orange { + --adaptive-icon-hue: 20deg; +} + +.theme-red { + --adaptive-icon-hue: 350deg; +} + +.theme-pink { + --adaptive-icon-hue: 310deg; +} + +/* 默认蓝色主题 */ +:root { + --adaptive-icon-hue: 180deg; +} + +::-webkit-scrollbar { + width: 6px !important; + height: 6px !important; +} + +::-webkit-scrollbar-track { + background: transparent !important; +} + +::-webkit-scrollbar-thumb { + border-radius: 3px !important; + transition: background 0.2s ease !important; +} + +/* 亮色模式滚动条 */ +::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.08) !important; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.15) !important; +} + +::-webkit-scrollbar-thumb:active { + background: rgba(0, 0, 0, 0.25) !important; +} + +/* 暗色模式滚动条 */ +html.dark ::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1) !important; +} + +html.dark ::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.2) !important; +} + +html.dark ::-webkit-scrollbar-thumb:active { + background: rgba(255, 255, 255, 0.35) !important; +} \ No newline at end of file diff --git a/plugins/bad-bear/src/assets/styles/css/index.ts b/plugins/bad-bear/src/assets/styles/css/index.ts new file mode 100644 index 000000000..474de5794 --- /dev/null +++ b/plugins/bad-bear/src/assets/styles/css/index.ts @@ -0,0 +1 @@ +import './global.css' diff --git a/plugins/bad-bear/src/assets/styles/index.ts b/plugins/bad-bear/src/assets/styles/index.ts new file mode 100644 index 000000000..86f8c1ac8 --- /dev/null +++ b/plugins/bad-bear/src/assets/styles/index.ts @@ -0,0 +1,3 @@ +import 'virtual:uno.css' +import 'ztools-ui/style' +import './css/global.css' diff --git a/plugins/bad-bear/src/components/common/DetailPanel/DetailPanel.vue b/plugins/bad-bear/src/components/common/DetailPanel/DetailPanel.vue new file mode 100644 index 000000000..595cf6e64 --- /dev/null +++ b/plugins/bad-bear/src/components/common/DetailPanel/DetailPanel.vue @@ -0,0 +1,165 @@ + + + + + + diff --git a/plugins/bad-bear/src/components/common/DetailPanel/index.ts b/plugins/bad-bear/src/components/common/DetailPanel/index.ts new file mode 100644 index 000000000..825e3ac4e --- /dev/null +++ b/plugins/bad-bear/src/components/common/DetailPanel/index.ts @@ -0,0 +1 @@ +export { default as DetailPanel } from './DetailPanel.vue' diff --git a/plugins/bad-bear/src/components/common/index.ts b/plugins/bad-bear/src/components/common/index.ts new file mode 100644 index 000000000..ad9fd9d34 --- /dev/null +++ b/plugins/bad-bear/src/components/common/index.ts @@ -0,0 +1,3 @@ +export * from './DetailPanel' +export { ZToast as Toast, useToast } from 'ztools-ui' +export { ZConfirmDialog as ConfirmDialog, useConfirmDialog } from 'ztools-ui' diff --git a/plugins/bad-bear/src/components/index.ts b/plugins/bad-bear/src/components/index.ts new file mode 100644 index 000000000..89a3196b1 --- /dev/null +++ b/plugins/bad-bear/src/components/index.ts @@ -0,0 +1 @@ +export * from './common' diff --git a/plugins/bad-bear/src/components/plugin-market/AccountPanel.vue b/plugins/bad-bear/src/components/plugin-market/AccountPanel.vue new file mode 100644 index 000000000..96352cfa4 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/AccountPanel.vue @@ -0,0 +1,1355 @@ + + + + + diff --git a/plugins/bad-bear/src/components/plugin-market/ApiSettingsPanel.vue b/plugins/bad-bear/src/components/plugin-market/ApiSettingsPanel.vue new file mode 100644 index 000000000..7d3e919f9 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/ApiSettingsPanel.vue @@ -0,0 +1,370 @@ + + + + + diff --git a/plugins/bad-bear/src/components/plugin-market/CategoryCard.vue b/plugins/bad-bear/src/components/plugin-market/CategoryCard.vue new file mode 100644 index 000000000..d422ff671 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/CategoryCard.vue @@ -0,0 +1,111 @@ + + + + diff --git a/plugins/bad-bear/src/components/plugin-market/CategoryDetail.vue b/plugins/bad-bear/src/components/plugin-market/CategoryDetail.vue new file mode 100644 index 000000000..22bbe8dff --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/CategoryDetail.vue @@ -0,0 +1,177 @@ + + + + diff --git a/plugins/bad-bear/src/components/plugin-market/InstalledPluginCard.vue b/plugins/bad-bear/src/components/plugin-market/InstalledPluginCard.vue new file mode 100644 index 000000000..a3441b4ef --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/InstalledPluginCard.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/plugins/bad-bear/src/components/plugin-market/NotificationPanel.vue b/plugins/bad-bear/src/components/plugin-market/NotificationPanel.vue new file mode 100644 index 000000000..b6dcbad8d --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/NotificationPanel.vue @@ -0,0 +1,695 @@ + + + + + diff --git a/plugins/bad-bear/src/components/plugin-market/PluginCard.vue b/plugins/bad-bear/src/components/plugin-market/PluginCard.vue new file mode 100644 index 000000000..1f825ac09 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/PluginCard.vue @@ -0,0 +1,199 @@ + + + + diff --git a/plugins/bad-bear/src/components/plugin-market/PluginDetail.vue b/plugins/bad-bear/src/components/plugin-market/PluginDetail.vue new file mode 100644 index 000000000..c81afb9d6 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/PluginDetail.vue @@ -0,0 +1,655 @@ + + + + + diff --git a/plugins/bad-bear/src/components/plugin-market/PluginMarketPage.vue b/plugins/bad-bear/src/components/plugin-market/PluginMarketPage.vue new file mode 100644 index 000000000..05d6224fd --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/PluginMarketPage.vue @@ -0,0 +1,2021 @@ + + + + + diff --git a/plugins/bad-bear/src/components/plugin-market/PluginUploadPanel.vue b/plugins/bad-bear/src/components/plugin-market/PluginUploadPanel.vue new file mode 100644 index 000000000..7c4a9d542 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/PluginUploadPanel.vue @@ -0,0 +1,805 @@ + + + + + diff --git a/plugins/bad-bear/src/components/plugin-market/RefreshButton.vue b/plugins/bad-bear/src/components/plugin-market/RefreshButton.vue new file mode 100644 index 000000000..c008d1e45 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/RefreshButton.vue @@ -0,0 +1,47 @@ + + + + diff --git a/plugins/bad-bear/src/components/plugin-market/detail/PluginCommentsSection.vue b/plugins/bad-bear/src/components/plugin-market/detail/PluginCommentsSection.vue new file mode 100644 index 000000000..d66e1e8d2 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/detail/PluginCommentsSection.vue @@ -0,0 +1,672 @@ + + + + + diff --git a/plugins/bad-bear/src/components/plugin-market/detail/PluginReadmePanel.vue b/plugins/bad-bear/src/components/plugin-market/detail/PluginReadmePanel.vue new file mode 100644 index 000000000..0ec56e6bb --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/detail/PluginReadmePanel.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/plugins/bad-bear/src/components/plugin-market/detail/PluginVersionsSection.vue b/plugins/bad-bear/src/components/plugin-market/detail/PluginVersionsSection.vue new file mode 100644 index 000000000..fc1980859 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/detail/PluginVersionsSection.vue @@ -0,0 +1,383 @@ + + + + + + diff --git a/plugins/bad-bear/src/components/plugin-market/detail/formatters.ts b/plugins/bad-bear/src/components/plugin-market/detail/formatters.ts new file mode 100644 index 000000000..da8266ff8 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/detail/formatters.ts @@ -0,0 +1,43 @@ +import type { PluginCommentTreeNode } from '../../../types/pluginMarket' + +export function formatSize(size: unknown): string { + const numericSize = typeof size === 'string' ? Number(size) : size + if (typeof numericSize !== 'number' || Number.isNaN(numericSize) || numericSize <= 0) { + return '-' + } + + if (numericSize >= 1024 * 1024) { + return `${(numericSize / (1024 * 1024)).toFixed(2)} MB` + } + + if (numericSize >= 1024) { + return `${(numericSize / 1024).toFixed(2)} KB` + } + + return `${numericSize} B` +} + +export function formatRating(value: number): string { + if (!value) { + return '暂无评分' + } + + return value.toFixed(1) +} + +export function formatDownloads(value: unknown): string { + const numericValue = typeof value === 'string' ? Number(value) : value + if (typeof numericValue !== 'number' || !Number.isFinite(numericValue) || numericValue < 0) { + return '-' + } + + return numericValue.toLocaleString('zh-CN') +} + +export function getCommentAuthorName(comment: PluginCommentTreeNode): string { + return comment.user.username || comment.user.account +} + +export function getCommentAuthorInitial(comment: PluginCommentTreeNode): string { + return getCommentAuthorName(comment).slice(0, 1).toUpperCase() || '?' +} diff --git a/plugins/bad-bear/src/components/plugin-market/detail/usePluginReadme.ts b/plugins/bad-bear/src/components/plugin-market/detail/usePluginReadme.ts new file mode 100644 index 000000000..d41828ca0 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/detail/usePluginReadme.ts @@ -0,0 +1,119 @@ +import hljs from 'highlight.js/lib/core' +import bash from 'highlight.js/lib/languages/bash' +import css from 'highlight.js/lib/languages/css' +import javascript from 'highlight.js/lib/languages/javascript' +import json from 'highlight.js/lib/languages/json' +import typescript from 'highlight.js/lib/languages/typescript' +import xml from 'highlight.js/lib/languages/xml' +import { marked, Renderer } from 'marked' +import { computed, onMounted, ref, watch, type Ref } from 'vue' +import { readPluginReadme } from '../../../api/pluginMarket' +import type { PluginDetailReadme, PluginMarketUiPlugin } from '../../../types/pluginMarket' + +hljs.registerLanguage('bash', bash) +hljs.registerLanguage('shell', bash) +hljs.registerLanguage('sh', bash) +hljs.registerLanguage('css', css) +hljs.registerLanguage('html', xml) +hljs.registerLanguage('xml', xml) +hljs.registerLanguage('javascript', javascript) +hljs.registerLanguage('js', javascript) +hljs.registerLanguage('json', json) +hljs.registerLanguage('typescript', typescript) +hljs.registerLanguage('ts', typescript) + +const markdownRenderer = new Renderer() +markdownRenderer.code = ({ text, lang }) => { + const language = lang?.trim().toLowerCase() || '' + const highlighted = language && hljs.getLanguage(language) + ? hljs.highlight(text, { language }).value + : hljs.highlightAuto(text).value + + const languageClass = language && hljs.getLanguage(language) ? ` language-${language}` : '' + return `
        ${highlighted}
        ` +} + +marked.setOptions({ breaks: true, gfm: true, renderer: markdownRenderer }) + +export function usePluginReadme( + plugin: Ref, + remoteReadme?: Ref, +) { + const readmeContent = ref('') + const readmeLoading = ref(false) + const readmeError = ref('') + const showAiGeneratedBadge = ref(false) + + const renderedMarkdown = computed(() => { + if (!readmeContent.value) { + return '' + } + + return marked(readmeContent.value) as string + }) + + async function loadReadme(): Promise { + readmeLoading.value = true + readmeError.value = '' + readmeContent.value = '' + showAiGeneratedBadge.value = false + + try { + if (plugin.value.installed && plugin.value.path) { + const localResult = await readPluginReadme(plugin.value.path) + if (localResult.success && localResult.content) { + readmeContent.value = localResult.content + return + } + } + + const remoteResult = await readPluginReadme(plugin.value.name) + if (remoteResult.success && remoteResult.content) { + readmeContent.value = remoteResult.content + return + } + + const inlineReadme = remoteReadme?.value + if (inlineReadme?.content) { + readmeContent.value = inlineReadme.content + showAiGeneratedBadge.value = !!inlineReadme.isAiGenerated + return + } + + readmeError.value = remoteResult.error || '暂无详情' + } catch (error) { + const inlineReadme = remoteReadme?.value + if (inlineReadme?.content) { + readmeContent.value = inlineReadme.content + showAiGeneratedBadge.value = !!inlineReadme.isAiGenerated + } else { + console.error('[PluginDetail] README 加载失败:', error) + readmeError.value = error instanceof Error ? error.message : 'README 加载失败' + } + } finally { + readmeLoading.value = false + } + } + + onMounted(() => { + if (plugin.value.name || plugin.value.path) { + void loadReadme() + } + }) + + watch( + () => [plugin.value.name, plugin.value.path, remoteReadme?.value?.hash, remoteReadme?.value?.content], + () => { + if (plugin.value.name || plugin.value.path) { + void loadReadme() + } + }, + ) + + return { + readmeLoading, + readmeError, + renderedMarkdown, + showAiGeneratedBadge, + } +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/actions/install-target.ts b/plugins/bad-bear/src/components/plugin-market/page/actions/install-target.ts new file mode 100644 index 000000000..29a9a7123 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/actions/install-target.ts @@ -0,0 +1,300 @@ +// 安装目标解析和确认辅助函数 + +import type { ComputedRef } from 'vue' +import { resolvePluginInstallPayload } from '../../../../api/pluginMarket' +import type { + PluginDetailResponse, + PluginDetailVersion, + PluginMarketPlugin, + PluginMarketUiPlugin, + ResolvedPluginDownloadTarget, +} from '../../../../types/pluginMarket' +import { compareVersions } from '../../utils' + +type LatestHashTarget = { + version: string + payloadVersion: string | null + hash: string + build: PluginDetailVersion | null +} + +/** + * 在详情版本列表中匹配服务端返回的 latestHash,命中时补齐版本号与构建信息。 + */ +function resolveLatestHashTarget( + plugin: PluginMarketUiPlugin, + detail?: PluginDetailResponse | null, +): LatestHashTarget | null { + if (!plugin.latestHash) { + return null + } + + const matchedBuild = detail?.versions?.find((version) => version.hash === plugin.latestHash) || null + if (matchedBuild) { + return { + version: matchedBuild.version, + payloadVersion: matchedBuild.version, + hash: plugin.latestHash, + build: matchedBuild, + } + } + + return { + version: '最新版本', + payloadVersion: null, + hash: plugin.latestHash, + build: null, + } +} + +/** + * 构建插件的最新下载目标,已安装更新优先使用 check-updates 返回的 latestHash。 + */ +export function buildLatestPluginDownloadTarget( + plugin: PluginMarketUiPlugin, + detail?: PluginDetailResponse | null, +): ResolvedPluginDownloadTarget { + const latestHashTarget = resolveLatestHashTarget(plugin, detail) + if (latestHashTarget) { + const payload = resolvePluginInstallPayload(plugin.marketPlugin || plugin, { + version: latestHashTarget.payloadVersion, + hash: latestHashTarget.hash, + }) + + return { + version: latestHashTarget.version, + hash: latestHashTarget.hash, + downloadMode: 'hash', + downloadUrl: payload.downloadUrl || '', + build: latestHashTarget.build, + plugin: payload, + } + } + + const latestBuild = detail?.versions?.[0] || null + const fallbackHash = latestBuild?.hash || plugin.hash || null + const payload = latestBuild + ? resolvePluginInstallPayload(plugin.marketPlugin || plugin, { + version: latestBuild.version, + hash: latestBuild.hash, + }) + : resolvePluginInstallPayload(plugin.marketPlugin || plugin, { + hash: fallbackHash, + }) + + return { + version: latestBuild?.version || payload.version, + hash: fallbackHash, + downloadMode: fallbackHash ? 'hash' : 'latest', + downloadUrl: payload.downloadUrl || '', + build: latestBuild, + plugin: payload, + } +} + +/** + * 解析插件操作的下载目标。 + * 已安装插件存在 latestHash 时优先使用服务端更新结果;详情页手动选择的历史版本仍按选择安装。 + */ +export function resolvePluginTargetForAction( + plugin: PluginMarketUiPlugin, + params: { + selectedPluginName: string | null + pluginDetailState: { detail: PluginDetailResponse | null; selectedHash?: string | null } + currentPluginDownloadTarget: ComputedRef + preferLatest?: boolean + }, +): ResolvedPluginDownloadTarget | null { + const isSelectedDetailPlugin = plugin.name === params.selectedPluginName + const hasSelectedDetailBuild = isSelectedDetailPlugin && !!params.pluginDetailState.selectedHash + if (!params.preferLatest && hasSelectedDetailBuild) { + return params.currentPluginDownloadTarget.value + } + + const detail = isSelectedDetailPlugin ? params.pluginDetailState.detail : null + if (plugin.latestHash) { + return buildLatestPluginDownloadTarget(plugin, detail) + } + + if (!params.preferLatest && isSelectedDetailPlugin && params.pluginDetailState.detail) { + return params.currentPluginDownloadTarget.value + } + + return buildLatestPluginDownloadTarget(plugin, detail) +} + +/** + * 判断已安装插件是否存在服务端确认的可更新构建。 + */ +export function canUpgrade(plugin: PluginMarketUiPlugin): boolean { + return !!plugin.installed && !!plugin.latestHash +} + +/** + * 构建升级/降级/重装场景的安装确认对话框内容 + */ +export function buildPluginInstallConfirmation( + plugin: PluginMarketUiPlugin, + target: ResolvedPluginDownloadTarget | null, +): { + title: string + message: string + confirmText: string +} | null { + if (!target || !plugin.installed || !plugin.localVersion) { + return null + } + + const comparison = target.hash === plugin.latestHash + ? -1 + : compareVersions(plugin.localVersion, target.version) + const actionLabel = comparison < 0 ? '升级' : comparison > 0 ? '降级' : '重装' + const title = comparison < 0 ? '确认升级' : comparison > 0 ? '确认降级' : '确认重装' + const targetLabel = target.version || '最新版本' + const message = `确定将 ${plugin.title} 从 ${plugin.localVersion || '未知版本'}${actionLabel}到 ${targetLabel}吗?安装过程会先删除旧插件,若新版本安装失败,你将暂时失去该插件。` + + return { + title, + message, + confirmText: actionLabel, + } +} + +/** + * 获取插件操作的有效安装载荷;详情页历史版本必须命中构建,服务端确认的 latestHash 更新允许按 hash 安装。 + */ +export function requirePluginInstallPayload( + plugin: PluginMarketUiPlugin, + params: { + selectedPluginName: string | null + pluginDetailState: { detail: PluginDetailResponse | null } + currentPluginDownloadTarget: ComputedRef + canInstallFromMarket: boolean + preferLatest?: boolean + notifyError: (message: string) => void + }, +): PluginMarketPlugin | null { + const target = resolvePluginTargetForAction(plugin, params) + const isSelectedDetailPlugin = + plugin.name === params.selectedPluginName && !!params.pluginDetailState.detail + + if (!target) { + params.notifyError('当前插件没有可安装的版本或构建') + return null + } + + if (params.canInstallFromMarket) { + const isServerConfirmedUpdate = !!plugin.latestHash && target.hash === plugin.latestHash + if (isSelectedDetailPlugin && !params.preferLatest && !target.build && !isServerConfirmedUpdate) { + params.notifyError('当前插件没有可安装的版本或构建') + return null + } + } else if (!target.downloadUrl) { + params.notifyError('当前插件没有可下载的文件') + return null + } + + return target.plugin +} + +/** + * 获取安装目标版本的可读摘要 + */ +export function requirePluginInstallTargetSummary( + plugin: PluginMarketUiPlugin, + params: { + selectedPluginName: string | null + pluginDetailState: { detail: PluginDetailResponse | null } + currentPluginDownloadTarget: ComputedRef + preferLatest?: boolean + }, +): string { + const target = resolvePluginTargetForAction(plugin, params) + if (!target) { + return plugin.version || '最新版本' + } + + if (!target.build) { + return target.version || '最新版本' + } + + return target.version || '最新版本' +} + +/** + * 获取插件安装成功后的消息文本 + */ +export function requirePluginInstallSuccessText( + plugin: PluginMarketUiPlugin, + params: { + selectedPluginName: string | null + pluginDetailState: { detail: PluginDetailResponse | null } + currentPluginDownloadTarget: ComputedRef + preferLatest?: boolean + }, +): string { + const targetLabel = requirePluginInstallTargetSummary(plugin, params) + return `已安装 ${plugin.title} ${targetLabel}` +} + +/** + * 获取插件升级/降级/重装成功后的消息文本,latestHash 更新固定按服务端确认的升级处理。 + */ +export function requirePluginUpgradeSuccessText( + plugin: PluginMarketUiPlugin, + params: { + selectedPluginName: string | null + pluginDetailState: { detail: PluginDetailResponse | null } + currentPluginDownloadTarget: ComputedRef + }, +): string { + const target = resolvePluginTargetForAction(plugin, params) + if (!target || !plugin.localVersion) { + return `已安装 ${plugin.title}` + } + + const comparison = target.hash === plugin.latestHash + ? -1 + : compareVersions(plugin.localVersion, target.version) + if (comparison < 0) { + return target.version === '最新版本' + ? `已升级 ${plugin.title} 到最新版本` + : `已升级 ${plugin.title} 到 ${target.version}` + } + + if (comparison > 0) { + return `已安装 ${plugin.title} ${target.version}` + } + + return `已重装 ${plugin.title} ${target.version}` +} + +/** + * 获取插件升级操作的失败消息文本,latestHash 更新失败保持升级语义而不受版本号影响。 + */ +export function requirePluginUpgradeFailureText( + plugin: PluginMarketUiPlugin, + params: { + selectedPluginName: string | null + pluginDetailState: { detail: PluginDetailResponse | null } + currentPluginDownloadTarget: ComputedRef + }, +): string { + const target = resolvePluginTargetForAction(plugin, params) + if (!target || !plugin.localVersion) { + return '升级失败' + } + + const comparison = target.hash === plugin.latestHash + ? -1 + : compareVersions(plugin.localVersion, target.version) + if (comparison < 0) { + return '升级失败' + } + + if (comparison > 0) { + return '安装失败' + } + + return '重装失败' +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/actions/installed-actions.ts b/plugins/bad-bear/src/components/plugin-market/page/actions/installed-actions.ts new file mode 100644 index 000000000..df9cc2d55 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/actions/installed-actions.ts @@ -0,0 +1,171 @@ +// 已安装插件操作:打开、卸载、停止、打开文件夹 + +import type { Ref } from 'vue' +import { + deleteInstalledPlugin, + openInstalledPlugin, + removeMarketInstalledPluginHash, + revealPluginInFinder, + stopInstalledPlugin, +} from '../../../../api/pluginMarket' +import type { InstalledBusyAction } from '../shared' +import type { PluginMarketUiPlugin } from '../../../../types/pluginMarket' +import { getErrorMessage } from '../shared' + +/** + * 处理打开已安装的插件。 + * 对来自插件市场的插件会先执行风险确认,弹框被直接关闭时中断本次打开。 + */ +export async function handleOpenPlugin( + plugin: PluginMarketUiPlugin, + params: { + notifyError: (message: string) => void + confirmOpenPluginRisk?: (plugin: PluginMarketUiPlugin) => Promise + }, +): Promise { + try { + if (params.confirmOpenPluginRisk) { + const confirmed = await params.confirmOpenPluginRisk(plugin) + if (!confirmed) { + return + } + } + + const result = await openInstalledPlugin(plugin) + if (result && result.success === false) { + throw new Error(result.error || '打开插件失败') + } + } catch (error) { + console.error('[PluginMarket] 打开插件失败:', error) + params.notifyError(getErrorMessage(error, '打开插件失败')) + } +} + +/** + * 处理插件卸载(带确认对话框) + */ +export async function handleUninstall( + plugin: PluginMarketUiPlugin, + params: { + marketBusyPluginName: Ref + installedBusyPluginName: Ref + installedBusyAction: Ref + notifyError: (message: string) => void + notifySuccess: (message: string) => void + confirmAction: (options: { + title?: string + message: string + type?: 'info' | 'warning' | 'danger' + confirmText?: string + cancelText?: string + }) => Promise + closePlugin: () => void + reloadMarket: () => Promise + }, +): Promise { + if (!plugin.path) { + params.notifyError('找不到插件路径,无法卸载') + return + } + + if (params.marketBusyPluginName.value || params.installedBusyPluginName.value) { + return + } + + const confirmed = await params.confirmAction({ + title: '确认卸载', + message: `确定卸载 ${plugin.title} 吗?`, + type: 'danger', + confirmText: '卸载', + cancelText: '取消', + }) + if (!confirmed) { + return + } + + params.installedBusyPluginName.value = plugin.name + params.installedBusyAction.value = 'uninstall' + + try { + const result = await deleteInstalledPlugin(plugin.path) + if (!result.success) { + throw new Error(result.error || '卸载失败') + } + + params.notifySuccess(`已卸载 ${plugin.title}`) + removeMarketInstalledPluginHash(plugin.name) + params.closePlugin() + await params.reloadMarket() + } catch (error) { + console.error('[PluginMarket] 卸载失败:', error) + params.notifyError(getErrorMessage(error, '卸载失败')) + } finally { + params.installedBusyPluginName.value = null + params.installedBusyAction.value = null + } +} + +/** + * 处理停止已运行的插件 + */ +export async function handleStopPlugin( + plugin: PluginMarketUiPlugin, + params: { + marketBusyPluginName: Ref + installedBusyPluginName: Ref + installedBusyAction: Ref + notifyError: (message: string) => void + notifySuccess: (message: string) => void + reloadMarket: () => Promise + }, +): Promise { + if (!plugin.path) { + params.notifyError('找不到插件路径,无法停止运行') + return + } + + if (params.marketBusyPluginName.value || params.installedBusyPluginName.value) { + return + } + + params.installedBusyPluginName.value = plugin.name + params.installedBusyAction.value = 'stop' + + try { + const result = await stopInstalledPlugin(plugin.path) + if (!result.success) { + throw new Error(result.error || '停止运行失败') + } + + params.notifySuccess(`已停止 ${plugin.title} 运行`) + await params.reloadMarket() + } catch (error) { + console.error('[PluginMarket] 停止插件失败:', error) + params.notifyError(getErrorMessage(error, '停止运行失败')) + } finally { + params.installedBusyPluginName.value = null + params.installedBusyAction.value = null + } +} + +/** + * 处理在 Finder/Explorer 中打开插件文件夹 + */ +export async function handleOpenFolder( + plugin: PluginMarketUiPlugin, + params: { + notifyError: (message: string) => void + }, +): Promise { + if (!plugin.path) { + params.notifyError('找不到插件路径,无法打开目录') + return + } + + try { + await revealPluginInFinder(plugin.path) + } catch (error) { + console.error('[PluginMarket] 打开插件目录失败:', error) + params.notifyError(getErrorMessage(error, '打开插件目录失败')) + } +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/actions/market-actions.ts b/plugins/bad-bear/src/components/plugin-market/page/actions/market-actions.ts new file mode 100644 index 000000000..e041f3b42 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/actions/market-actions.ts @@ -0,0 +1,256 @@ +// 市场插件操作:安装、下载、升级 + +import type { ComputedRef, Ref } from 'vue' +import { + deleteInstalledPlugin, + installMarketPlugin, + upsertMarketInstalledPluginHash, +} from '../../../../api/pluginMarket' +import type { PluginDetailState, MarketBusyAction, InstalledBusyAction } from '../shared' +import type { + PluginDetailResponse, + PluginMarketUiPlugin, + ResolvedPluginDownloadTarget, +} from '../../../../types/pluginMarket' +import { getErrorMessage } from '../shared' +import { + buildPluginInstallConfirmation, + requirePluginInstallPayload, + requirePluginInstallSuccessText, + requirePluginUpgradeFailureText, + requirePluginUpgradeSuccessText, + resolvePluginTargetForAction, +} from './install-target' + +/** + * 在外部浏览器中打开插件下载 URL(当安装 API 不可用时的后备方案) + */ +export async function openPluginDownload( + plugin: PluginMarketUiPlugin, + params: { + selectedPluginName: string | null + pluginDetailState: { detail: PluginDetailResponse | null } + currentPluginDownloadTarget: ComputedRef + preferLatest?: boolean + notifyError: (message: string) => void + notifySuccess: (message: string) => void + }, +): Promise { + const installPayload = requirePluginInstallPayload(plugin, { + ...params, + canInstallFromMarket: true, + }) + if (!installPayload?.downloadUrl) { + params.notifyError('当前插件没有可下载的文件') + return + } + + if (typeof window.ztools?.shellOpenExternal !== 'function') { + params.notifyError('当前宿主未暴露下载能力') + return + } + + window.ztools.shellOpenExternal(installPayload.downloadUrl) + params.notifySuccess(`已开始下载 ${plugin.title}`) +} + +/** + * 处理从市场安装插件 + * 如果内部 API 不可用则回退到下载 + */ +export async function handleInstall( + plugin: PluginMarketUiPlugin, + params: { + canInstallFromMarket: boolean + selectedPluginName: string | null + pluginDetailState: PluginDetailState + currentPluginDownloadTarget: ComputedRef + marketBusyPluginName: Ref + marketBusyAction: Ref + installedBusyPluginName: Ref + notifyError: (message: string) => void + notifySuccess: (message: string) => void + reloadMarket: () => Promise + openPluginByName: (name: string) => void + preferLatest?: boolean + }, +): Promise { + // 如果无法从市场安装,使用外部下载 + if (!params.canInstallFromMarket) { + await openPluginDownload(plugin, params) + return + } + + // 检查是否有其他操作正在进行 + if (params.marketBusyPluginName.value || params.installedBusyPluginName.value) { + return + } + + const installPayload = requirePluginInstallPayload(plugin, { + ...params, + canInstallFromMarket: params.canInstallFromMarket, + }) + if (!installPayload) { + return + } + + params.marketBusyPluginName.value = plugin.name + params.marketBusyAction.value = 'download' + + try { + const result = await installMarketPlugin(installPayload) + if (!result.success) { + throw new Error(result.error || '安装失败') + } + + const target = resolvePluginTargetForAction(plugin, params) + upsertMarketInstalledPluginHash(plugin.name, target?.hash || installPayload.hash) + + params.notifySuccess( + requirePluginInstallSuccessText(plugin, { + ...params, + preferLatest: params.preferLatest, + }), + ) + await params.reloadMarket() + params.openPluginByName(plugin.name) + } catch (error) { + console.error('[PluginMarket] 安装失败:', error) + params.notifyError(getErrorMessage(error, '安装失败')) + } finally { + params.marketBusyPluginName.value = null + params.marketBusyAction.value = null + } +} + +/** + * 处理插件安装(始终使用最新版本) + */ +export async function handleInstallLatest( + plugin: PluginMarketUiPlugin, + params: { + canInstallFromMarket: boolean + selectedPluginName: string | null + pluginDetailState: PluginDetailState + currentPluginDownloadTarget: ComputedRef + marketBusyPluginName: Ref + marketBusyAction: Ref + installedBusyPluginName: Ref + notifyError: (message: string) => void + notifySuccess: (message: string) => void + reloadMarket: () => Promise + openPluginByName: (name: string) => void + }, +): Promise { + await handleInstall(plugin, { ...params, preferLatest: true }) +} + +/** + * 处理插件升级(带确认对话框) + * 先删除旧版本,然后安装新版本 + */ +export async function handleUpgrade( + plugin: PluginMarketUiPlugin, + params: { + selectedPluginName: string | null + pluginDetailState: PluginDetailState + currentPluginDownloadTarget: ComputedRef + marketBusyPluginName: Ref + marketBusyAction: Ref + installedBusyPluginName: Ref + installedBusyAction: Ref + notifyError: (message: string) => void + notifySuccess: (message: string) => void + reloadMarket: () => Promise + openPluginByName: (name: string) => void + confirmAction: (options: { + title?: string + message: string + type?: 'info' | 'warning' | 'danger' + confirmText?: string + cancelText?: string + }) => Promise + }, +): Promise { + if (params.marketBusyPluginName.value || params.installedBusyPluginName.value) { + return + } + + if (!plugin.path) { + params.notifyError('找不到已安装插件路径,无法升级') + return + } + + const installPayload = requirePluginInstallPayload(plugin, { + ...params, + canInstallFromMarket: true, + }) + if (!installPayload) { + return + } + + // 构建确认对话框 + const target = resolvePluginTargetForAction(plugin, params) + const confirmation = buildPluginInstallConfirmation(plugin, target) + if (confirmation) { + const confirmed = await params.confirmAction({ + title: confirmation.title, + message: confirmation.message, + type: 'warning', + confirmText: confirmation.confirmText, + cancelText: '取消', + }) + + if (!confirmed) { + return + } + } + + const isInstalledListUpgrade = plugin.hasUpdate && !!plugin.latestHash + params.installedBusyPluginName.value = plugin.name + params.installedBusyAction.value = 'upgrade' + if (!isInstalledListUpgrade) { + params.marketBusyPluginName.value = plugin.name + params.marketBusyAction.value = 'upgrade' + } + + try { + // 先删除旧版本 + const deleteResult = await deleteInstalledPlugin(plugin.path) + if (!deleteResult.success) { + throw new Error(deleteResult.error || '删除旧版本失败') + } + + // 再安装新版本 + const installResult = await installMarketPlugin(installPayload) + if (!installResult.success) { + throw new Error(installResult.error || '安装新版本失败') + } + + upsertMarketInstalledPluginHash(plugin.name, target?.hash || installPayload.hash) + + params.notifySuccess( + requirePluginUpgradeSuccessText(plugin, { + ...params, + }), + ) + await params.reloadMarket() + params.openPluginByName(plugin.name) + } catch (error) { + console.error('[PluginMarket] 升级失败:', error) + params.notifyError( + getErrorMessage( + error, + requirePluginUpgradeFailureText(plugin, { + ...params, + }), + ), + ) + await params.reloadMarket() + } finally { + params.installedBusyPluginName.value = null + params.installedBusyAction.value = null + params.marketBusyPluginName.value = null + params.marketBusyAction.value = null + } +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/actions/share-actions.ts b/plugins/bad-bear/src/components/plugin-market/page/actions/share-actions.ts new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/bad-bear/src/components/plugin-market/page/comments.ts b/plugins/bad-bear/src/components/plugin-market/page/comments.ts new file mode 100644 index 000000000..7da379c31 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/comments.ts @@ -0,0 +1,76 @@ +// 评论树构建逻辑 + +import type { PluginCommentRecord, PluginCommentTreeNode } from '../../../types/pluginMarket' + +/** + * 将嵌套的评论记录展平为单个列表 + * 处理扁平的 API 响应和带有嵌套回复的响应 + * 使用 Set 对评论 ID 去重 + */ +export function flattenCommentRecords( + items: PluginCommentRecord[], + parentId: string | null = null, +): PluginCommentRecord[] { + return items.flatMap((item) => { + const { replies = [], ...comment } = item + return [ + { + ...comment, + parentId: comment.parentId ?? parentId, + }, + // 递归展平子回复 + ...flattenCommentRecords(replies, item.id), + ] + }) +} + +/** + * 从扁平或嵌套的评论记录构建嵌套的评论树 + * 返回根评论及其嵌套的回复 + */ +export function buildCommentTree(items: PluginCommentRecord[]): PluginCommentTreeNode[] { + const flattenedItems: PluginCommentRecord[] = [] + const seen = new Set() + + // 展平并去重评论 + flattenCommentRecords(items).forEach((item) => { + if (seen.has(item.id)) { + return + } + + seen.add(item.id) + flattenedItems.push(item) + }) + + const nodeMap = new Map() + const roots: PluginCommentTreeNode[] = [] + + // 创建所有评论节点 + flattenedItems.forEach((item) => { + nodeMap.set(item.id, { + ...item, + replies: [], + }) + }) + + // 构建树结构 + flattenedItems.forEach((item) => { + const node = nodeMap.get(item.id) + if (!node) { + return + } + + if (item.parentId) { + const parent = nodeMap.get(item.parentId) + if (parent) { + parent.replies.push(node) + return + } + } + + // 没有父节点的作为根节点 + roots.push(node) + }) + + return roots +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/errors.ts b/plugins/bad-bear/src/components/plugin-market/page/errors.ts new file mode 100644 index 000000000..3e32e80f2 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/errors.ts @@ -0,0 +1,37 @@ +// 错误处理和工具函数 + +/** + * 从未知错误值中提取用户友好的错误消息 + * 对于 Error 实例返回错误消息,其他类型返回后备字符串 + */ +export function getErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback +} + +/** + * 检测错误是否为插件 API 的主机权限被拒绝错误 + * 检查来自仅限内置插件调用的特定错误签名 + */ +export function isPluginHostPermissionDeniedError(error: unknown): boolean { + const content = + typeof error === 'string' + ? error + : error instanceof Error + ? `${error.name} ${error.message}` + : '' + + return content.includes('PermissionDeniedError') && content.includes('仅限内置插件调用') +} + +/** + * 将 ISO 时间戳字符串转换为用于比较的数字时间戳 + * 对于无效/空值返回 0 + */ +export function toTimestamp(value?: string | null): number { + if (!value) { + return 0 + } + + const date = new Date(value) + return Number.isNaN(date.getTime()) ? 0 : date.getTime() +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/notifications.ts b/plugins/bad-bear/src/components/plugin-market/page/notifications.ts new file mode 100644 index 000000000..c83b920c2 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/notifications.ts @@ -0,0 +1,120 @@ +// 通知树构建逻辑 + +import type { NotificationRecord, NotificationTreeNode } from '../../../types/notification' + +/** + * 从通知记录中提取父 ID + * 检查直接的 parentId 属性和 metadata.parentId 字段 + */ +export function resolveNotificationParentId(item: NotificationRecord): string | null { + if (typeof item.parentId === 'string' && item.parentId.trim()) { + return item.parentId.trim() + } + + if (item.metadata && typeof item.metadata === 'object') { + const parentId = (item.metadata as Record).parentId + if (typeof parentId === 'string' && parentId.trim()) { + return parentId.trim() + } + } + + return null +} + +/** + * 查找给定通知的根通知 ID + * 向上遍历父链,检测循环以防止无限循环 + */ +export function findNotificationRootId( + itemId: string, + parentMap: Map, + nodeMap: Map, +): string | null { + let currentId = itemId + const visited = new Set([itemId]) + + while (true) { + const parentId = parentMap.get(currentId) + if (!parentId) { + return currentId + } + + // 检测循环 + if (visited.has(parentId)) { + return itemId + } + + // 父节点不存在则停止 + if (!nodeMap.has(parentId)) { + return currentId + } + + visited.add(parentId) + currentId = parentId + } +} + +/** + * 将 ISO 时间戳字符串转换为用于排序的数字时间戳 + */ +export function toTimestamp(value?: string | null): number { + if (!value) { + return 0 + } + + const date = new Date(value) + return Number.isNaN(date.getTime()) ? 0 : date.getTime() +} + +/** + * 构建通知树,其中回复嵌套在其根评论下 + * 将 COMMENT_REPLY 通知分组到父 COMMENT 通知下 + * 根节点按最新优先排序,回复按最旧优先排序 + */ +export function buildNotificationTree(items: NotificationRecord[]): NotificationTreeNode[] { + const nodeMap = new Map() + const parentMap = new Map() + const roots: NotificationTreeNode[] = [] + + // 初始化节点和父映射 + items.forEach((item) => { + nodeMap.set(item.id, { + ...item, + replies: [], + depth: 0, + }) + parentMap.set(item.id, resolveNotificationParentId(item)) + }) + + // 构建树结构 + items.forEach((item) => { + const node = nodeMap.get(item.id) + if (!node) { + return + } + + const rootId = findNotificationRootId(item.id, parentMap, nodeMap) + if (!rootId || rootId === item.id) { + roots.push(node) + return + } + + const rootNode = nodeMap.get(rootId) + if (!rootNode) { + roots.push(node) + return + } + + node.depth = 1 + rootNode.replies.push(node) + }) + + // 根节点按最新优先排序 + roots.sort((left, right) => toTimestamp(right.createdAt) - toTimestamp(left.createdAt)) + // 回复按最旧优先排序(时间顺序) + roots.forEach((root) => { + root.replies.sort((left, right) => toTimestamp(left.createdAt) - toTimestamp(right.createdAt)) + }) + + return roots +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/plugin-detail.ts b/plugins/bad-bear/src/components/plugin-market/page/plugin-detail.ts new file mode 100644 index 000000000..e568f371e --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/plugin-detail.ts @@ -0,0 +1,161 @@ +import type { + PluginDetailResponse, + PluginDetailVersion, + PluginMarketPlugin, + PluginMarketUiPlugin, + PluginReleaseSource, + ResolvedPluginDownloadTarget, +} from '../../../types/pluginMarket' +import { resolvePluginInstallPayload } from '../../../api/pluginMarket' + +export function mergePluginDetailIntoPlugin( + plugin: PluginMarketUiPlugin, + detail: PluginDetailResponse | null, + resolvedDownloadTarget?: ResolvedPluginDownloadTarget | null, +): PluginMarketUiPlugin { + if (!detail && !resolvedDownloadTarget) { + return plugin + } + + const latestBuild = detail?.versions?.[0] || null + const resolvedBuild = resolvedDownloadTarget?.build || latestBuild + const resolvedVersion = resolvedDownloadTarget?.version || detail?.version || plugin.version + + return { + ...plugin, + title: detail?.title ?? plugin.title, + description: detail?.description ?? plugin.description, + author: detail?.author ?? plugin.author, + logo: detail?.logo ?? plugin.logo, + features: detail?.features ?? plugin.features, + main: detail?.main ?? plugin.main, + preload: detail?.preload ?? plugin.preload, + version: resolvedVersion, + downloadUrl: resolvedDownloadTarget?.downloadUrl || plugin.downloadUrl, + size: resolvedBuild?.fileSize ?? plugin.size ?? latestBuild?.fileSize, + totalDownloads: detail?.totalDownloads ?? plugin.totalDownloads, + avgRating: detail?.avgRating ?? plugin.avgRating, + ratingCount: detail?.ratingCount ?? plugin.ratingCount, + } +} + +export interface PluginSourceReference { + kind: 'provider-sync' | 'user-upload' | 'unknown' + providerName?: string | null +} + +export function parsePluginSourceReference( + source: PluginReleaseSource | undefined, +): PluginSourceReference | null { + if (!source) { + return null + } + + if (typeof source === 'string') { + const normalized = source.trim().toLowerCase() + if (!normalized) { + return null + } + if (normalized.includes('provider')) { + return { kind: 'provider-sync' } + } + if (normalized.includes('upload') || normalized.includes('manual') || normalized.includes('user')) { + return { kind: 'user-upload' } + } + return { kind: 'unknown' } + } + + const provider = source.provider + const providerName = + provider && + typeof provider === 'object' && + !Array.isArray(provider) && + typeof (provider as Record).name === 'string' + ? ((provider as Record).name as string).trim() + : null + const typeCandidate = + typeof source.type === 'string' + ? source.type + : typeof source.kind === 'string' + ? source.kind + : typeof source.sourceType === 'string' + ? source.sourceType + : '' + const normalizedType = typeCandidate.trim().toLowerCase() + + if (normalizedType === 'local') { + return { kind: 'user-upload' } + } + if (providerName || normalizedType.includes('provider')) { + return { kind: 'provider-sync', providerName } + } + if ( + normalizedType.includes('upload') || + normalizedType.includes('manual') || + normalizedType.includes('user') + ) { + return { kind: 'user-upload' } + } + + return { kind: 'unknown' } +} + +export function mapPluginSourceLabel(reference: PluginSourceReference | null): string { + if (!reference) { + return '' + } + + if (reference.kind === 'provider-sync') { + return reference.providerName || '' + } + + if (reference.kind === 'user-upload') { + return '用户上传' + } + + return '未知来源' +} + +export function buildCurrentPluginDownloadTarget( + plugin: PluginMarketUiPlugin | null, + detail: PluginDetailResponse | null, + selectedBuild?: PluginDetailVersion | null, +): ResolvedPluginDownloadTarget | null { + if (!plugin) { + return null + } + + if (selectedBuild) { + const payload = resolvePluginInstallPayload(plugin.marketPlugin || plugin, { + version: selectedBuild.version, + hash: selectedBuild.hash, + }) + + return { + version: selectedBuild.version, + hash: selectedBuild.hash, + downloadMode: 'hash', + downloadUrl: payload.downloadUrl || '', + build: selectedBuild, + plugin: payload, + } + } + + const marketPlugin = plugin.marketPlugin || plugin + const latestBuild = detail?.versions?.[0] || null + const payload = latestBuild + ? resolvePluginInstallPayload(marketPlugin, { + version: latestBuild.version, + hash: latestBuild.hash, + }) + : resolvePluginInstallPayload(marketPlugin) + + return { + version: latestBuild?.version || payload.version, + hash: latestBuild?.hash || null, + downloadMode: latestBuild ? 'hash' : 'latest', + downloadUrl: payload.downloadUrl || '', + build: latestBuild, + plugin: payload, + } +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/plugin-list.ts b/plugins/bad-bear/src/components/plugin-market/page/plugin-list.ts new file mode 100644 index 000000000..e3806c618 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/plugin-list.ts @@ -0,0 +1,125 @@ +// 插件列表和已安装插件状态映射辅助函数 + +import type { InstalledPlugin, InstalledViewPlugin, Platform, PluginMarketPlugin, PluginMarketUiPlugin } from '../../../types/pluginMarket' + +/** + * 检查插件在当前平台是否可见 + * 如果没有指定平台限制,则在所有平台可见 + */ +export function isPluginVisibleOnPlatform( + plugin: PluginMarketPlugin, + platform: Platform, +): boolean { + // 无平台限制则默认可见 + if (!Array.isArray(plugin.platform) || plugin.platform.length === 0) { + return true + } + + return plugin.platform.includes(platform) +} + +/** + * 将已安装插件数组转换为 Map 以实现 O(1) 复杂度的按名称查找 + */ +export function toInstalledMap(installedPlugins: InstalledPlugin[]): Map { + return new Map(installedPlugins.map((plugin) => [plugin.name, plugin])) +} + +/** + * 合并市场插件数据和已安装插件数据,创建统一的商店卡片模型。 + * 商店页只展示安装与运行状态,不根据版本或服务端更新检查结果暴露升级入口。 + */ +export function toUiPlugin( + plugin: PluginMarketPlugin, + installedMap: Map, + runningPluginSet: Set, +): PluginMarketUiPlugin { + const installedPlugin = installedMap.get(plugin.name) + const localVersion = installedPlugin?.version + + return { + ...plugin, + title: plugin.title || installedPlugin?.title || plugin.name, + description: plugin.description || installedPlugin?.description || '', + logo: plugin.logo || installedPlugin?.logo, + author: plugin.author || installedPlugin?.author, + homepage: plugin.homepage || installedPlugin?.homepage, + size: typeof plugin.size === 'number' ? plugin.size : installedPlugin?.size, + features: plugin.features || installedPlugin?.features, + installed: !!installedPlugin, + path: installedPlugin?.path, + localVersion, + latestVersion: plugin.version || localVersion || '', + localHash: installedPlugin?.hash, + latestHash: undefined, + marketPlugin: plugin, + hasUpdate: false, + isRunning: !!installedPlugin?.path && runningPluginSet.has(installedPlugin.path), + isDevelopment: installedPlugin?.isDevelopment ?? false, + } +} + +/** + * 构建已安装插件列表,并用市场数据和服务端更新检查结果进行增强。 + * 更新状态只信任 check-updates 返回的 latestHash,按安装时间降序排序。 + */ +export function buildInstalledViewPlugins( + installedPlugins: InstalledPlugin[], + marketPluginMap: Map, + runningPluginSet: Set, + updateHashMap: Map = new Map(), +): InstalledViewPlugin[] { + return installedPlugins + .map((plugin): InstalledViewPlugin => { + const marketPlugin = marketPluginMap.get(plugin.name) + const latestVersion = marketPlugin?.version || plugin.version || '' + const latestHash = updateHashMap.get(plugin.name) + + return { + ...marketPlugin, + ...plugin, + title: marketPlugin?.title || plugin.title || plugin.name, + description: marketPlugin?.description || plugin.description || '', + logo: marketPlugin?.logo || plugin.logo, + author: marketPlugin?.author || plugin.author, + homepage: marketPlugin?.homepage || plugin.homepage, + size: typeof marketPlugin?.size === 'number' ? marketPlugin.size : plugin.size, + features: marketPlugin?.features || plugin.features, + version: latestVersion, + installed: true, + path: plugin.path, + localVersion: plugin.version, + latestVersion, + localHash: plugin.hash, + latestHash, + marketPlugin, + hasUpdate: !!latestHash, + isRunning: runningPluginSet.has(plugin.path), + isDevelopment: !!plugin.isDevelopment, + } + }) + .sort((left, right) => { + // 按安装时间降序排序 + const leftTime = + typeof left.installedAt === 'string' ? new Date(left.installedAt).getTime() : 0 + const rightTime = + typeof right.installedAt === 'string' ? new Date(right.installedAt).getTime() : 0 + return rightTime - leftTime + }) +} + +/** + * 解析插件引用(如从分类/插件列表)到完整的 UI 插件对象 + */ +export function resolvePluginList( + items: Array<{ name: string }> | undefined, + pluginMap: Map, +): PluginMarketUiPlugin[] { + if (!items?.length) { + return [] + } + + return items + .map((item) => pluginMap.get(item.name)) + .filter((plugin): plugin is PluginMarketUiPlugin => !!plugin) +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/runtime/useAccountActions.ts b/plugins/bad-bear/src/components/plugin-market/page/runtime/useAccountActions.ts new file mode 100644 index 000000000..e683f6dd2 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/runtime/useAccountActions.ts @@ -0,0 +1,224 @@ +// 账户操作:登录、注册、密码、用户名、头像 + +import { ref, type Ref } from 'vue' +import { + login, + register, + updateMyPassword, + updateMyUsername, + uploadMyAvatar, +} from '../../../../api/auth' +import type { AuthUser, LoginRequest, RegisterRequest, UpdatePasswordRequest, UpdateUsernameRequest } from '../../../../types/auth' +import { + saveShopApiRuntimeConfig, + normalizeShopApiBaseUrl, +} from '../../../../config/runtimeConfig' +import { getErrorMessage } from '../shared' +import { + validateLoginPayload, + validatePassword, + validateRegisterPayload, + validateUsername, + validateAvatarFile, +} from '../shared' + +export function useAccountActions(options: { + authToken: Ref + currentUser: Ref + selectedPluginName: Ref + notifyError: (message: string) => void + notifySuccess: (message: string) => void + activeNav: Ref<'store' | 'installed' | 'notifications' | 'upload' | 'account' | 'settings'> + onReloadMarket: () => Promise + onReloadSelectedPluginDetail: () => Promise +}) { + const isLoggingIn = ref(false) + const isRegistering = ref(false) + const isUpdatingUsername = ref(false) + const isUpdatingPassword = ref(false) + const isUploadingAvatar = ref(false) + + /** + * 检查用户是否已登录,未登录则导航到账户标签 + */ + function requireShopLogin(actionLabel: string): boolean { + if (options.authToken.value && options.currentUser.value) { + return true + } + + options.activeNav.value = 'account' + options.notifyError(`请先登录后再${actionLabel}`) + return false + } + + /** + * 处理用户登录 + */ + function handleLogin(payload: LoginRequest): Promise { + try { + validateLoginPayload(payload) + } catch (error) { + options.notifyError(getErrorMessage(error, '登录参数不合法')) + return Promise.resolve() + } + + isLoggingIn.value = true + + return login(payload) + .then((response) => { + // 保存登录结果 + saveShopApiRuntimeConfig({ + token: response.token, + currentUser: response.user, + }) + options.notifySuccess(`欢迎回来,${response.user.username}`) + }) + .catch((error) => { + options.notifyError(getErrorMessage(error, '登录失败')) + }) + .finally(() => { + isLoggingIn.value = false + }) + } + + /** + * 处理用户注册 + */ + async function handleRegister(payload: RegisterRequest): Promise { + try { + validateRegisterPayload(payload) + } catch (error) { + options.notifyError(getErrorMessage(error, '注册参数不合法')) + return + } + + isRegistering.value = true + + try { + const response = await register(payload) + saveShopApiRuntimeConfig({ + token: response.token, + currentUser: response.user, + }) + options.notifySuccess(`注册成功,欢迎 ${response.user.username}`) + } catch (error) { + options.notifyError(getErrorMessage(error, '注册失败')) + } finally { + isRegistering.value = false + } + } + + /** + * 处理用户名更新 + */ + async function handleUpdateUsername(payload: UpdateUsernameRequest): Promise { + try { + validateUsername(payload.username) + } catch (error) { + options.notifyError(getErrorMessage(error, '用户名不合法')) + return + } + + isUpdatingUsername.value = true + + try { + const response = await updateMyUsername({ username: payload.username.trim() }) + saveShopApiRuntimeConfig({ currentUser: response.user }) + options.notifySuccess('用户名已更新') + } catch (error) { + options.notifyError(getErrorMessage(error, '修改用户名失败')) + } finally { + isUpdatingUsername.value = false + } + } + + /** + * 处理密码更新 + */ + async function handleUpdatePassword(payload: UpdatePasswordRequest): Promise { + try { + validatePassword(payload.newPassword) + } catch (error) { + options.notifyError(getErrorMessage(error, '密码不合法')) + return + } + + const requestPayload: UpdatePasswordRequest = { + newPassword: payload.newPassword, + } + + // 如果提供了当前密码,则包含在请求中 + if (payload.currentPassword) { + requestPayload.currentPassword = payload.currentPassword + } + + isUpdatingPassword.value = true + + try { + const response = await updateMyPassword(requestPayload) + options.notifySuccess(response.message || '密码已更新') + } catch (error) { + options.notifyError(getErrorMessage(error, '修改密码失败')) + } finally { + isUpdatingPassword.value = false + } + } + + /** + * 处理头像上传 + */ + async function handleUploadAvatar(file: File): Promise { + try { + validateAvatarFile(file) + } catch (error) { + options.notifyError(getErrorMessage(error, '头像文件不合法')) + return + } + + isUploadingAvatar.value = true + + try { + const response = await uploadMyAvatar(file) + saveShopApiRuntimeConfig({ currentUser: response.user }) + options.notifySuccess('头像已更新') + } catch (error) { + options.notifyError(getErrorMessage(error, '上传头像失败')) + } finally { + isUploadingAvatar.value = false + } + } + + /** + * 处理 API 基础 URL 保存 + */ + async function handleSaveBaseUrl(baseUrl: string): Promise { + try { + const normalizedBaseUrl = normalizeShopApiBaseUrl(baseUrl) + saveShopApiRuntimeConfig({ baseUrl: normalizedBaseUrl }) + await options.onReloadMarket() + + if (options.selectedPluginName) { + await options.onReloadSelectedPluginDetail() + } + + options.notifySuccess(`已保存 API 地址:${normalizedBaseUrl}`) + } catch (error) { + options.notifyError(getErrorMessage(error, '保存 API 地址失败')) + } + } + + return { + isLoggingIn, + isRegistering, + isUpdatingUsername, + isUpdatingPassword, + isUploadingAvatar, + requireShopLogin, + handleLogin, + handleRegister, + handleUpdateUsername, + handleUpdatePassword, + handleUploadAvatar, + handleSaveBaseUrl, + } +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/runtime/useGithubDeviceFlow.ts b/plugins/bad-bear/src/components/plugin-market/page/runtime/useGithubDeviceFlow.ts new file mode 100644 index 000000000..9e9a48a62 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/runtime/useGithubDeviceFlow.ts @@ -0,0 +1,453 @@ +// GitHub 设备流程(登录/绑定)状态管理和轮询逻辑 + +import { computed, ref, type Ref } from 'vue' +import { + getGithubBindingStatus, + pollGithubDeviceBind, + pollGithubDeviceLogin, + startGithubDeviceBind, + startGithubDeviceLogin, +} from '../../../../api/auth' +import { HttpClientError } from '../../../../api/httpClient' +import type { GitHubBindingState, GitHubBindingStatus, GitHubDeviceFlowState } from '../../../../types/auth' +import type { GitHubDeviceStartResponse, AuthUser } from '../../../../types/auth' +import { getErrorMessage } from '../shared' + +// 创建空的 GitHub 绑定状态 +function createEmptyGithubBindingState(): GitHubBindingState { + return { + loading: false, + loaded: false, + supported: true, + bound: false, + provider: null, + login: null, + errorMessage: '', + } +} + +// 创建空的 GitHub 设备流程状态 +function createEmptyGithubDeviceFlowState(): GitHubDeviceFlowState { + return { + purpose: null, + phase: 'idle', + deviceSessionId: '', + userCode: '', + verificationUri: '', + verificationUriComplete: '', + expiresAt: '', + interval: 5, + retryAfterSeconds: 5, + errorMessage: '', + } +} + +// 将 API 响应转换为 GitHub 绑定状态 +function toGithubBindingState(binding: GitHubBindingStatus): GitHubBindingState { + return { + loading: false, + loaded: true, + supported: true, + bound: !!binding.bound, + provider: binding.provider || null, + login: binding.login || null, + errorMessage: '', + } +} + +/** + * 检查 GitHub 设备流程过期时间戳是否已过期 + */ +function isExpiredAt(expiresAt: string): boolean { + const expiresTime = new Date(expiresAt).getTime() + return Number.isFinite(expiresTime) && Date.now() >= expiresTime +} + +export function useGithubDeviceFlow(options: { + authToken: Ref + currentUser: Ref + notifyError: (message: string) => void + notifySuccess: (message: string) => void + onLoginSuccess: (token: string, user: AuthUser) => void +}) { + const githubBinding = ref(createEmptyGithubBindingState()) + const githubDeviceFlow = ref(createEmptyGithubDeviceFlowState()) + // 设备流程是否正在进行中 + const isGithubDeviceFlowBusy = computed( + () => githubDeviceFlow.value.phase === 'starting' || githubDeviceFlow.value.phase === 'polling', + ) + + let githubDeviceFlowTimer: number | null = null + let githubDeviceFlowPolling = false + + /** + * 清除 GitHub 设备流程轮询定时器 + */ + function clearGithubDeviceFlowTimer(): void { + if (githubDeviceFlowTimer !== null) { + window.clearTimeout(githubDeviceFlowTimer) + githubDeviceFlowTimer = null + } + } + + /** + * 重置 GitHub 绑定状态为空 + */ + function resetGithubBindingState(): void { + githubBinding.value = createEmptyGithubBindingState() + } + + /** + * 重置 GitHub 设备流程状态为空闲 + */ + function resetGithubDeviceFlowState(): void { + clearGithubDeviceFlowTimer() + githubDeviceFlowPolling = false + githubDeviceFlow.value = createEmptyGithubDeviceFlowState() + } + + /** + * 将设备流程设置为错误状态并显示消息 + */ + function setGithubDeviceFlowError(message: string): void { + clearGithubDeviceFlowTimer() + githubDeviceFlowPolling = false + githubDeviceFlow.value = { + ...githubDeviceFlow.value, + phase: 'error', + errorMessage: message, + } + } + + /** + * 将设备流程设置为过期状态 + */ + function setGithubDeviceFlowExpired(): void { + clearGithubDeviceFlowTimer() + githubDeviceFlowPolling = false + githubDeviceFlow.value = { + ...githubDeviceFlow.value, + phase: 'expired', + errorMessage: 'GitHub 授权已过期,请重新发起', + } + } + + /** + * 获取当前设备流程的验证 URL + * 优先使用完整 URL(如果可用) + */ + function getGithubVerificationUrl(): string { + return githubDeviceFlow.value.verificationUriComplete || githubDeviceFlow.value.verificationUri + } + + /** + * 在外部浏览器中打开 GitHub 验证页面 + * 如果宿主不支持此操作,则显示错误消息 + */ + function openGithubVerificationPage(params: { notifyOnUnsupported?: boolean } = {}): void { + const verificationUrl = getGithubVerificationUrl() + if (!verificationUrl) { + options.notifyError('未获取到 GitHub 授权地址') + return + } + + if (typeof window.ztools?.shellOpenExternal === 'function') { + window.ztools.shellOpenExternal(verificationUrl) + return + } + + if (params.notifyOnUnsupported) { + options.notifyError('当前环境不支持自动打开浏览器,请手动访问下方授权地址') + } + } + + /** + * 将启动响应应用到设备流程状态 + */ + function applyGithubDeviceFlowStart( + purpose: 'login' | 'bind', + response: GitHubDeviceStartResponse, + ): void { + githubDeviceFlow.value = { + purpose, + phase: 'polling', + deviceSessionId: response.deviceSessionId, + userCode: response.userCode, + verificationUri: response.verificationUri, + verificationUriComplete: response.verificationUriComplete || response.verificationUri, + expiresAt: response.expiresAt, + interval: response.interval, + retryAfterSeconds: response.interval, + errorMessage: '', + } + } + + /** + * 安排在指定延迟后进行下一次设备流程轮询 + */ + function scheduleGithubDeviceFlowPoll(delaySeconds: number): void { + clearGithubDeviceFlowTimer() + + if ( + githubDeviceFlow.value.phase !== 'polling' || + !githubDeviceFlow.value.deviceSessionId || + !githubDeviceFlow.value.purpose + ) { + return + } + + githubDeviceFlowTimer = window.setTimeout(() => { + void pollActiveGithubDeviceFlow() + }, Math.max(1, delaySeconds) * 1000) + } + + /** + * 轮询 GitHub 设备流程端点以检查完成状态 + * 处理登录和绑定流程,待处理状态时自动重试 + */ + async function pollActiveGithubDeviceFlow(): Promise { + if (githubDeviceFlowPolling) { + return + } + + const deviceFlowState = githubDeviceFlow.value + if ( + deviceFlowState.phase !== 'polling' || + !deviceFlowState.deviceSessionId || + !deviceFlowState.purpose + ) { + return + } + + // 检查是否已过期 + if (isExpiredAt(deviceFlowState.expiresAt)) { + setGithubDeviceFlowExpired() + return + } + + githubDeviceFlowPolling = true + + try { + // 处理登录流程 + if (deviceFlowState.purpose === 'login') { + const response = await pollGithubDeviceLogin({ + deviceSessionId: deviceFlowState.deviceSessionId, + }) + + if (response.status === 'pending') { + // 更新状态并继续轮询 + githubDeviceFlow.value = { + ...githubDeviceFlow.value, + phase: 'polling', + retryAfterSeconds: response.retryAfterSeconds, + expiresAt: response.expiresAt, + errorMessage: '', + } + + if (isExpiredAt(response.expiresAt)) { + setGithubDeviceFlowExpired() + return + } + + scheduleGithubDeviceFlowPoll(response.retryAfterSeconds) + return + } + + // 登录成功 + options.onLoginSuccess(response.token, response.user) + await refreshGithubBindingStatus({ silent: true }) + resetGithubDeviceFlowState() + options.notifySuccess(`GitHub 登录成功,欢迎 ${response.user.username}`) + return + } + + // 处理绑定流程 + const response = await pollGithubDeviceBind({ + deviceSessionId: deviceFlowState.deviceSessionId, + }) + + if (response.status === 'pending') { + githubDeviceFlow.value = { + ...githubDeviceFlow.value, + phase: 'polling', + retryAfterSeconds: response.retryAfterSeconds, + expiresAt: response.expiresAt, + errorMessage: '', + } + + if (isExpiredAt(response.expiresAt)) { + setGithubDeviceFlowExpired() + return + } + + scheduleGithubDeviceFlowPoll(response.retryAfterSeconds) + return + } + + // 绑定成功 + githubBinding.value = toGithubBindingState(response.binding) + resetGithubDeviceFlowState() + options.notifySuccess('GitHub 已绑定') + } catch (error) { + setGithubDeviceFlowError( + getErrorMessage( + error, + deviceFlowState.purpose === 'login' ? 'GitHub 登录失败' : 'GitHub 绑定失败', + ), + ) + } finally { + githubDeviceFlowPolling = false + } + } + + /** + * 刷新当前用户的 GitHub 绑定状态 + */ + async function refreshGithubBindingStatus(params: { silent?: boolean } = {}): Promise { + if (!options.authToken.value || !options.currentUser.value) { + resetGithubBindingState() + return + } + + githubBinding.value = { + ...githubBinding.value, + loading: true, + errorMessage: '', + } + + try { + const binding = await getGithubBindingStatus() + githubBinding.value = toGithubBindingState(binding) + } catch (error) { + const message = getErrorMessage(error, '获取 GitHub 绑定状态失败') + // 处理不支持绑定的错误 + if (error instanceof HttpClientError && (error.status === 403 || error.status === 404)) { + githubBinding.value = { + loading: false, + loaded: true, + supported: false, + bound: false, + provider: null, + login: null, + errorMessage: message, + } + if (!params.silent) { + options.notifyError(message) + } + return + } + + githubBinding.value = { + ...githubBinding.value, + loading: false, + loaded: true, + supported: true, + bound: false, + provider: null, + login: null, + errorMessage: message, + } + + if (!params.silent) { + options.notifyError(message) + } + } + } + + /** + * 启动 GitHub 设备流程(登录或绑定) + */ + async function startGithubDeviceFlow(purpose: 'login' | 'bind'): Promise { + if (githubDeviceFlow.value.phase === 'starting' || githubDeviceFlow.value.phase === 'polling') { + return + } + + githubDeviceFlow.value = { + ...createEmptyGithubDeviceFlowState(), + purpose, + phase: 'starting', + } + + try { + const response = + purpose === 'login' ? await startGithubDeviceLogin() : await startGithubDeviceBind() + applyGithubDeviceFlowStart(purpose, response) + openGithubVerificationPage() + scheduleGithubDeviceFlowPoll(response.interval) + } catch (error) { + const message = getErrorMessage( + error, + purpose === 'login' ? '发起 GitHub 登录失败' : '发起 GitHub 绑定失败', + ) + + // 处理不支持绑定的错误 + if (purpose === 'bind' && error instanceof HttpClientError && (error.status === 403 || error.status === 404)) { + githubBinding.value = { + loading: false, + loaded: true, + supported: false, + bound: false, + provider: null, + login: null, + errorMessage: message, + } + } + + setGithubDeviceFlowError(message) + options.notifyError(message) + } + } + + /** + * 处理 GitHub 登录流程启动 + */ + async function handleGithubLogin(): Promise { + await startGithubDeviceFlow('login') + } + + /** + * 处理 GitHub 绑定流程启动 + * 需要用户先登录 + */ + async function handleGithubBind(requireShopLogin: (actionLabel: string) => boolean): Promise { + if (!requireShopLogin('绑定 GitHub')) { + return + } + + if (githubBinding.value.bound) { + options.notifySuccess( + githubBinding.value.login ? `GitHub 已绑定:${githubBinding.value.login}` : 'GitHub 已绑定', + ) + return + } + + await startGithubDeviceFlow('bind') + } + + /** + * 取消活动的 GitHub 设备流程 + */ + function handleCancelGithubDeviceFlow(): void { + resetGithubDeviceFlowState() + } + + /** + * 打开 GitHub 验证页面,如果不支持则显示用户错误 + */ + function handleOpenGithubVerificationPage(): void { + openGithubVerificationPage({ notifyOnUnsupported: true }) + } + + return { + githubBinding, + githubDeviceFlow, + isGithubDeviceFlowBusy, + refreshGithubBindingStatus, + resetGithubBindingState, + resetGithubDeviceFlowState, + handleGithubLogin, + handleGithubBind, + handleCancelGithubDeviceFlow, + handleOpenGithubVerificationPage, + } +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/runtime/useShopRuntimeConfig.ts b/plugins/bad-bear/src/components/plugin-market/page/runtime/useShopRuntimeConfig.ts new file mode 100644 index 000000000..b37a1c064 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/runtime/useShopRuntimeConfig.ts @@ -0,0 +1,147 @@ +// Shop API 运行时配置和认证状态管理 + +import { computed, onMounted, onUnmounted, ref, type Ref } from 'vue' +import { getCurrentUser } from '../../../../api/auth' +import { + buildShopApiAssetUrl, + clearShopApiAuth, + loadShopApiRuntimeConfig, + saveShopApiRuntimeConfig, + subscribeShopApiRuntimeConfig, +} from '../../../../config/runtimeConfig' +import type { AuthUser } from '../../../../types/auth' +import type { ShopApiRuntimeConfig } from '../../../../types/runtimeConfig' +import type { GitHubBindingState } from '../../../../types/auth' +import { getErrorMessage } from '../shared' + +export function useShopRuntimeConfig(options: { + notifyError: (message: string) => void + notifySuccess: (message: string) => void + onAuthChanged: () => Promise + onGithubBindingRefresh: () => Promise + onGithubBindingReset: () => void + onDeviceFlowReset: () => void +}) { + const shopApiBaseUrl = ref('http://localhost:3000') + const authToken = ref('') + const currentUser = ref(null) + const runtimeConfigLoaded = ref(false) + const isRestoringSession = ref(false) + + // 计算当前用户头像的完整 URL + const currentUserAvatarUrl = computed(() => + buildShopApiAssetUrl(currentUser.value?.avatarUrl, shopApiBaseUrl.value), + ) + + let unsubscribeRuntimeConfig: (() => void) | null = null + + /** + * 应用来自持久化存储的运行时配置状态 + * 处理认证状态变更并触发副作用 + */ + function applyRuntimeConfigState(config: ShopApiRuntimeConfig): void { + const previousToken = authToken.value + const previousUserId = currentUser.value?.id || null + + shopApiBaseUrl.value = config.baseUrl + authToken.value = config.token + currentUser.value = config.currentUser ? { ...config.currentUser } : null + + const nextUserId = currentUser.value?.id || null + const authChanged = previousToken !== authToken.value || previousUserId !== nextUserId + + // 如果没有认证,重置 GitHub 相关状态 + if (!authToken.value || !currentUser.value) { + options.onGithubBindingReset() + options.onDeviceFlowReset() + } else if (authChanged) { + // 认证变更时刷新 GitHub 绑定状态 + void options.onGithubBindingRefresh() + } + + // 认证变更时触发回调 + if (authChanged) { + void options.onAuthChanged() + } + } + + /** + * 从 API 获取当前用户并更新持久化存储 + */ + async function refreshCurrentUser(params: { silent?: boolean } = {}): Promise { + if (!authToken.value) { + return + } + + try { + const response = await getCurrentUser() + saveShopApiRuntimeConfig({ currentUser: response.user }) + await options.onGithubBindingRefresh() + } catch (error) { + if (!params.silent) { + options.notifyError(getErrorMessage(error, '获取当前用户失败')) + } + } + } + + /** + * 清除认证并重置所有认证相关状态 + */ + function handleLogout(): void { + options.onGithubBindingReset() + options.onDeviceFlowReset() + clearShopApiAuth() + } + + onMounted(() => { + // 订阅运行时配置变更 + unsubscribeRuntimeConfig = subscribeShopApiRuntimeConfig(applyRuntimeConfigState) + const runtimeConfig = loadShopApiRuntimeConfig() + runtimeConfigLoaded.value = true + + let shouldRestoreSession = !!runtimeConfig.token + + if (typeof window !== 'undefined') { + const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, '')) + const githubToken = hashParams.get('github_token')?.trim() || '' + const githubError = new URLSearchParams(window.location.search).get('githubError')?.trim() || '' + + if (githubToken) { + saveShopApiRuntimeConfig({ token: githubToken, currentUser: null }) + shouldRestoreSession = true + options.notifySuccess('GitHub 登录成功,正在恢复账号信息') + } else if (githubError) { + options.notifyError(`GitHub 登录失败:${githubError}`) + } + + if (githubToken || githubError) { + const nextUrl = `${window.location.pathname}${window.location.search.replace(/([?&])githubError=[^&]*&?/, '$1').replace(/[?&]$/, '')}` + window.history.replaceState(null, '', nextUrl) + } + } + + // 如果有令牌,恢复会话 + if (shouldRestoreSession) { + isRestoringSession.value = true + void refreshCurrentUser({ silent: true }).finally(() => { + isRestoringSession.value = false + }) + } + }) + + onUnmounted(() => { + options.onDeviceFlowReset() + unsubscribeRuntimeConfig?.() + }) + + return { + shopApiBaseUrl, + authToken, + currentUser, + runtimeConfigLoaded, + isRestoringSession, + currentUserAvatarUrl, + refreshCurrentUser, + handleLogout, + } +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/shared.ts b/plugins/bad-bear/src/components/plugin-market/page/shared.ts new file mode 100644 index 000000000..3980414fc --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/shared.ts @@ -0,0 +1,70 @@ +// 兼容性导出桶 - 从专注模块重新导出以保持向后兼容性 +// 同时允许逐步迁移到直接导入 + +// 状态类型和工厂函数 +export type { + ActiveNav, + MarketBusyAction, + InstalledBusyAction, + PluginDetailState, + NotificationState, +} from './state' +export { + createEmptyPluginDetailState, + createEmptyNotificationState, + PLUGIN_COMMENTS_PAGE_SIZE, + NOTIFICATIONS_PAGE_SIZE, +} from './state' + +// 插件列表和已安装状态辅助函数 +export { + isPluginVisibleOnPlatform, + toInstalledMap, + toUiPlugin, + buildInstalledViewPlugins, + resolvePluginList, +} from './plugin-list' + +// 插件详情解析辅助函数 +export { + mergePluginDetailIntoPlugin, + parsePluginSourceReference, + mapPluginSourceLabel, + buildCurrentPluginDownloadTarget, +} from './plugin-detail' + +// 评论树辅助函数 +export { + flattenCommentRecords, + buildCommentTree, +} from './comments' + +// 通知树辅助函数 +export { + resolveNotificationParentId, + findNotificationRootId, + toTimestamp, + buildNotificationTree, +} from './notifications' + +// 验证辅助函数 +export { + validateUsername, + validatePassword, + validateRegisterPayload, + validateLoginPayload, + validateAvatarFile, + ACCOUNT_PATTERN, + USERNAME_MIN_LENGTH, + USERNAME_MAX_LENGTH, + PASSWORD_MIN_LENGTH, + PASSWORD_MAX_LENGTH, + MAX_AVATAR_SIZE, + ALLOWED_AVATAR_TYPES, +} from './validation' + +// 错误处理辅助函数 +export { + getErrorMessage, + isPluginHostPermissionDeniedError, +} from './errors' diff --git a/plugins/bad-bear/src/components/plugin-market/page/state.ts b/plugins/bad-bear/src/components/plugin-market/page/state.ts new file mode 100644 index 000000000..87ae17c01 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/state.ts @@ -0,0 +1,95 @@ +// 页面级状态类型定义、常量和工厂函数 + +// 导航页面类型枚举 +export type ActiveNav = 'store' | 'installed' | 'notifications' | 'upload' | 'account' | 'settings' +// 市场插件正在执行的操作类型 +export type MarketBusyAction = 'download' | 'upgrade' | null +// 已安装插件正在执行的操作类型 +export type InstalledBusyAction = 'upgrade' | 'stop' | 'uninstall' | null + +import type { + PluginCommentRecord, + PluginRatingRecord, + PluginRiskInfo, +} from '../../../types/pluginMarket' + +// 插件评论分页大小 +export const PLUGIN_COMMENTS_PAGE_SIZE = 20 +// 通知分页大小 +export const NOTIFICATIONS_PAGE_SIZE = 20 + +export interface PluginDetailState { + detail: import('../../../types/pluginMarket').PluginDetailResponse | null + risk: PluginRiskInfo | null + riskLoading: boolean + riskError: string + selectedVersion: string | null + selectedHash: string | null + comments: PluginCommentRecord[] + currentUserRating: PluginRatingRecord | null + commentPage: number + commentPageSize: number + commentTotal: number + commentLoading: boolean + commentLoadingMore: boolean + commentSubmitting: boolean + ratingSubmitting: boolean + commentError: string + requestId: number +} + +export interface NotificationState { + items: import('../../../types/notification').NotificationRecord[] + filter: import('../../../types/notification').NotificationFilter + page: number + pageSize: number + total: number + loading: boolean + error: string + selectedId: string | null + selectedItem: import('../../../types/notification').NotificationRecord | null + initialized: boolean + markingAllRead: boolean + requestId: number +} + +// 创建空的插件详情状态 +export function createEmptyPluginDetailState(): PluginDetailState { + return { + detail: null, + risk: null, + riskLoading: false, + riskError: '', + selectedVersion: null, + selectedHash: null, + comments: [], + currentUserRating: null, + commentPage: 1, + commentPageSize: PLUGIN_COMMENTS_PAGE_SIZE, + commentTotal: 0, + commentLoading: false, + commentLoadingMore: false, + commentSubmitting: false, + ratingSubmitting: false, + commentError: '', + requestId: 0, + } +} + +// 创建空的通知状态 +export function createEmptyNotificationState(): NotificationState { + return { + items: [], + filter: 'ALL', + page: 1, + pageSize: NOTIFICATIONS_PAGE_SIZE, + total: 0, + loading: false, + error: '', + selectedId: null, + selectedItem: null, + initialized: false, + markingAllRead: false, + requestId: 0, + } +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/storefront.ts b/plugins/bad-bear/src/components/plugin-market/page/storefront.ts new file mode 100644 index 000000000..fd1764cbd --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/storefront.ts @@ -0,0 +1,154 @@ +import type { + CategoryInfo, + CategoryLayoutSection, + InstalledPlugin, + PluginMarketFetchResponse, + PluginMarketPlugin, + PluginMarketSectionModel, + PluginMarketStorefrontCategory, + PluginMarketStorefrontSection, + PluginMarketUiPlugin, + Platform, + StorefrontCategorySummary, +} from '../../../types/pluginMarket' +import { + buildInstalledViewPlugins, + isPluginVisibleOnPlatform, + resolvePluginList, + toInstalledMap, + toUiPlugin, +} from './shared' + +export function buildMarketViewState( + marketResult: PluginMarketFetchResponse, + nextInstalledPlugins: InstalledPlugin[], + nextRunningPluginPaths: string[], + currentPlatform: Platform, + updateHashMap: Map = new Map(), +): { + uiPlugins: PluginMarketUiPlugin[] + marketPlugins: PluginMarketPlugin[] + storefrontCategories: Record + storefrontSections: PluginMarketSectionModel[] + categoryLayouts: Record + installedViewPlugins: ReturnType +} { + const runningSet = new Set(nextRunningPluginPaths) + const installedMap = toInstalledMap(nextInstalledPlugins) + const marketPlugins: PluginMarketPlugin[] = (marketResult.data || []).filter((plugin) => + isPluginVisibleOnPlatform(plugin, currentPlatform), + ) + + const uiPlugins: PluginMarketUiPlugin[] = marketPlugins.map((plugin) => + toUiPlugin(plugin, installedMap, runningSet), + ) + + const nextPluginMap = new Map( + uiPlugins.map((plugin) => [plugin.name, plugin]), + ) + const nextMarketPluginMap = new Map( + marketPlugins.map((plugin) => [plugin.name, plugin]), + ) + const nextCategories: Record = {} + const storefront = marketResult.storefront + + if (storefront?.categories) { + Object.entries(storefront.categories).forEach( + ([key, category]: [string, PluginMarketStorefrontCategory]) => { + const categoryPlugins = resolvePluginList(category.plugins, nextPluginMap) + nextCategories[key] = { + key: category.key, + title: category.title, + description: category.description, + icon: category.icon, + plugins: categoryPlugins, + } + }, + ) + } + + const nextSections: PluginMarketSectionModel[] = [] + + ;(storefront?.sections || []).forEach((section: PluginMarketStorefrontSection) => { + if (section.type === 'banner') { + if (section.items?.length) { + nextSections.push(section) + } + return + } + + if (section.type === 'navigation') { + const categories = section.categories || [] + + if (categories.length > 0) { + nextSections.push({ + type: 'navigation', + key: section.key, + title: section.title, + categories, + }) + } + return + } + + const sectionPlugins = resolvePluginList(section.plugins, nextPluginMap) + if (sectionPlugins.length > 0) { + nextSections.push({ + type: section.type, + key: section.key, + title: section.title, + plugins: sectionPlugins, + }) + } + }) + + return { + uiPlugins, + marketPlugins, + storefrontCategories: nextCategories, + storefrontSections: nextSections, + categoryLayouts: storefront?.categoryLayouts || {}, + installedViewPlugins: buildInstalledViewPlugins( + nextInstalledPlugins, + nextMarketPluginMap, + runningSet, + updateHashMap, + ), + } +} + +/** + * 在流式刷新尚未结束时,用新快照覆盖同名插件并保留旧内容,避免页面因为未到达的条目而闪烁。 + */ +export function mergeMarketSnapshots( + previous: PluginMarketFetchResponse | null, + incoming: PluginMarketFetchResponse, +): PluginMarketFetchResponse { + if (!previous?.success || !previous.data?.length) { + return incoming + } + + const mergedPlugins = new Map() + ;(previous.data || []).forEach((plugin) => { + mergedPlugins.set(plugin.name, plugin) + }) + ;(incoming.data || []).forEach((plugin) => { + mergedPlugins.set(plugin.name, plugin) + }) + + return { + success: incoming.success, + error: incoming.error, + data: Array.from(mergedPlugins.values()), + storefront: incoming.storefront || previous.storefront, + } +} + +/** + * 流结束后直接采用最终快照,让已下线插件和无效分类在一次收敛中被清理掉。 + */ +export function finalizeMarketSnapshot( + snapshot: PluginMarketFetchResponse, +): PluginMarketFetchResponse { + return snapshot +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/usePluginMarketActions.ts b/plugins/bad-bear/src/components/plugin-market/page/usePluginMarketActions.ts new file mode 100644 index 000000000..a0bfe641e --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/usePluginMarketActions.ts @@ -0,0 +1,183 @@ +// 插件操作编排层 - 组合安装目标、市场和已安装操作 + +import { computed, ref, type ComputedRef, type Ref } from 'vue' +import type { PluginDetailState, InstalledBusyAction, MarketBusyAction } from './shared' +import type { PluginMarketUiPlugin, ResolvedPluginDownloadTarget } from '../../../types/pluginMarket' +import { canUpgrade as checkCanUpgrade } from './actions/install-target' +import { handleInstall, handleInstallLatest, handleUpgrade } from './actions/market-actions' +import { handleOpenFolder, handleOpenPlugin, handleStopPlugin, handleUninstall } from './actions/installed-actions' + +export function usePluginMarketActions(options: { + selectedPluginName: Ref + pluginDetailState: Ref + currentPluginDownloadTarget: ComputedRef + canUseInternalPluginApis: Ref + notifyError: (message: string) => void + notifySuccess: (message: string) => void + confirmAction: (params: { + title?: string + message: string + type?: 'info' | 'warning' | 'danger' + confirmText?: string + cancelText?: string + }) => Promise + confirmOpenPluginRisk?: (plugin: PluginMarketUiPlugin) => Promise + reloadMarket: () => Promise + openPluginByName: (name: string) => void + closePlugin: () => void +}) { + const marketBusyPluginName = ref(null) + const marketBusyAction = ref(null) + const installedBusyPluginName = ref(null) + const installedBusyAction = ref(null) + + const selectedPluginBusyAction = computed(() => { + if (!options.selectedPluginName.value) { + return null + } + + if (options.selectedPluginName.value === installedBusyPluginName.value) { + return installedBusyAction.value + } + + if (options.selectedPluginName.value === marketBusyPluginName.value) { + return marketBusyAction.value + } + + return null + }) + + const canInstallFromMarket = computed(() => options.canUseInternalPluginApis.value) + + function canUpgrade(plugin: PluginMarketUiPlugin): boolean { + return checkCanUpgrade(plugin) + } + + /** + * 将安装动作包装到页面状态中,统一传入当前详情选择、忙碌状态和安装后刷新逻辑。 + */ + async function handleInstallWrapper( + plugin: PluginMarketUiPlugin, + installParams: { preferLatest?: boolean } = {}, + ): Promise { + await handleInstall(plugin, { + canInstallFromMarket: canInstallFromMarket.value, + selectedPluginName: options.selectedPluginName.value, + pluginDetailState: options.pluginDetailState.value, + currentPluginDownloadTarget: options.currentPluginDownloadTarget, + marketBusyPluginName, + marketBusyAction, + installedBusyPluginName, + notifyError: options.notifyError, + notifySuccess: options.notifySuccess, + reloadMarket: options.reloadMarket, + openPluginByName: options.openPluginByName, + preferLatest: installParams.preferLatest, + }) + } + + /** + * 从详情页触发安装最新版时强制忽略历史版本选择,始终解析最新可安装目标。 + */ + async function handleInstallLatestWrapper(plugin: PluginMarketUiPlugin): Promise { + await handleInstallLatest(plugin, { + canInstallFromMarket: canInstallFromMarket.value, + selectedPluginName: options.selectedPluginName.value, + pluginDetailState: options.pluginDetailState.value, + currentPluginDownloadTarget: options.currentPluginDownloadTarget, + marketBusyPluginName, + marketBusyAction, + installedBusyPluginName, + notifyError: options.notifyError, + notifySuccess: options.notifySuccess, + reloadMarket: options.reloadMarket, + openPluginByName: options.openPluginByName, + }) + } + + /** + * 包装更新动作所需的确认弹窗、忙碌状态和刷新回调,供已安装列表与详情页复用。 + */ + async function handleUpgradeWrapper(plugin: PluginMarketUiPlugin): Promise { + await handleUpgrade(plugin, { + selectedPluginName: options.selectedPluginName.value, + pluginDetailState: options.pluginDetailState.value, + currentPluginDownloadTarget: options.currentPluginDownloadTarget, + marketBusyPluginName, + marketBusyAction, + installedBusyPluginName, + installedBusyAction, + notifyError: options.notifyError, + notifySuccess: options.notifySuccess, + reloadMarket: options.reloadMarket, + openPluginByName: options.openPluginByName, + confirmAction: options.confirmAction, + }) + } + + /** + * 统一处理已安装插件的打开动作,确保所有入口共享同一套风险确认逻辑。 + */ + async function handleOpenPluginWrapper(plugin: PluginMarketUiPlugin): Promise { + await handleOpenPlugin(plugin, { + notifyError: options.notifyError, + confirmOpenPluginRisk: options.confirmOpenPluginRisk, + }) + } + + /** + * 卸载插件时串联确认、宿主删除和列表刷新,避免多个入口重复维护状态细节。 + */ + async function handleUninstallWrapper(plugin: PluginMarketUiPlugin): Promise { + await handleUninstall(plugin, { + marketBusyPluginName, + installedBusyPluginName, + installedBusyAction, + notifyError: options.notifyError, + notifySuccess: options.notifySuccess, + confirmAction: options.confirmAction, + closePlugin: options.closePlugin, + reloadMarket: options.reloadMarket, + }) + } + + /** + * 停止运行中的插件并刷新运行状态,供列表卡片和详情页共享同一忙碌态。 + */ + async function handleStopPluginWrapper(plugin: PluginMarketUiPlugin): Promise { + await handleStopPlugin(plugin, { + marketBusyPluginName, + installedBusyPluginName, + installedBusyAction, + notifyError: options.notifyError, + notifySuccess: options.notifySuccess, + reloadMarket: options.reloadMarket, + }) + } + + /** + * 打开插件安装目录,并把宿主能力缺失等错误统一转换为页面提示。 + */ + async function handleOpenFolderWrapper(plugin: PluginMarketUiPlugin): Promise { + await handleOpenFolder(plugin, { + notifyError: options.notifyError, + }) + } + + return { + marketBusyPluginName, + marketBusyAction, + installedBusyPluginName, + installedBusyAction, + selectedPluginBusyAction, + canInstallFromMarket, + canUpgrade, + handleOpenPlugin: handleOpenPluginWrapper, + handleInstall: handleInstallWrapper, + handleInstallLatest: handleInstallLatestWrapper, + handleUpgrade: handleUpgradeWrapper, + handleUninstall: handleUninstallWrapper, + handleStopPlugin: handleStopPluginWrapper, + handleOpenFolder: handleOpenFolderWrapper, + } +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/usePluginMarketDetail.ts b/plugins/bad-bear/src/components/plugin-market/page/usePluginMarketDetail.ts new file mode 100644 index 000000000..ad6691ae3 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/usePluginMarketDetail.ts @@ -0,0 +1,374 @@ +import { computed, ref, type ComputedRef, type Ref } from 'vue' +import { + createPluginComment, + createPluginRating, + getPluginComments, + getPluginDetail, + getPluginRatings, + getPluginRisk, +} from '../../../api/pluginMarket' +import type { AuthUser } from '../../../types/auth' +import type { + PluginDetailVersion, + PluginMarketUiPlugin, + PluginRiskInfo, + ResolvedPluginDownloadTarget, +} from '../../../types/pluginMarket' +import { + buildCommentTree, + buildCurrentPluginDownloadTarget, + createEmptyPluginDetailState, + getErrorMessage, + mapPluginSourceLabel, + mergePluginDetailIntoPlugin, + parsePluginSourceReference, +} from './shared' + +export function usePluginMarketDetail(options: { + selectedPlugin: ComputedRef + selectedPluginName: Ref + currentUser: Ref + requireShopLogin: (actionLabel: string) => boolean + notifyError: (message: string) => void + notifySuccess: (message: string) => void +}) { + const pluginDetailState = ref(createEmptyPluginDetailState()) + const pluginCommentSubmitSuccessKey = ref(0) + + const pluginCommentTree = computed(() => buildCommentTree(pluginDetailState.value.comments)) + const selectedSourceLabel = computed(() => + mapPluginSourceLabel(parsePluginSourceReference(pluginDetailState.value.detail?.source)), + ) + const hasMorePluginComments = computed( + () => pluginDetailState.value.comments.length < pluginDetailState.value.commentTotal, + ) + const selectedPluginDetailVersion = computed(() => { + const detail = pluginDetailState.value.detail + const selectedVersion = pluginDetailState.value.selectedVersion + const selectedHash = pluginDetailState.value.selectedHash + if (!detail || !selectedVersion || !selectedHash) { + return null + } + + return ( + detail.versions.find( + (version) => version.version === selectedVersion && version.hash === selectedHash, + ) || null + ) + }) + const currentPluginDownloadTarget = computed(() => + buildCurrentPluginDownloadTarget( + options.selectedPlugin.value, + pluginDetailState.value.detail, + selectedPluginDetailVersion.value, + ), + ) + const mergedSelectedPlugin = computed(() => { + if (!options.selectedPlugin.value) { + return null + } + + return mergePluginDetailIntoPlugin( + options.selectedPlugin.value, + pluginDetailState.value.detail, + currentPluginDownloadTarget.value, + ) + }) + + async function loadPluginDetail(name: string, requestId: number): Promise { + const detail = await getPluginDetail(name) + if ( + pluginDetailState.value.requestId !== requestId || + options.selectedPluginName.value !== name + ) { + return + } + + pluginDetailState.value.detail = detail + + const selectedVersion = pluginDetailState.value.selectedVersion + const selectedHash = pluginDetailState.value.selectedHash + if (!selectedVersion || !selectedHash) { + return + } + + const hasSelectedVersion = detail.versions.some( + (version) => version.version === selectedVersion && version.hash === selectedHash, + ) + if (!hasSelectedVersion) { + pluginDetailState.value.selectedVersion = null + pluginDetailState.value.selectedHash = null + } + } + + async function loadPluginRisk( + name: string, + requestId: number, + version?: string | null, + ): Promise { + pluginDetailState.value.riskLoading = true + pluginDetailState.value.riskError = '' + + try { + const risk: PluginRiskInfo = await getPluginRisk(name, version) + if ( + pluginDetailState.value.requestId !== requestId || + options.selectedPluginName.value !== name + ) { + return + } + + pluginDetailState.value.risk = risk + pluginDetailState.value.riskError = '' + } catch (error) { + if ( + pluginDetailState.value.requestId !== requestId || + options.selectedPluginName.value !== name + ) { + return + } + + pluginDetailState.value.risk = null + pluginDetailState.value.riskError = getErrorMessage(error, '加载风险信息失败') + } finally { + if ( + pluginDetailState.value.requestId !== requestId || + options.selectedPluginName.value !== name + ) { + return + } + + pluginDetailState.value.riskLoading = false + } + } + + async function loadCurrentUserPluginRating(name: string, requestId: number): Promise { + if (!options.currentUser.value) { + if ( + pluginDetailState.value.requestId === requestId && + options.selectedPluginName.value === name + ) { + pluginDetailState.value.currentUserRating = null + } + return + } + + try { + const response = await getPluginRatings(name, { page: 1, pageSize: 100 }) + if ( + pluginDetailState.value.requestId !== requestId || + options.selectedPluginName.value !== name + ) { + return + } + + pluginDetailState.value.currentUserRating = + response.items.find((item) => item.user.id === options.currentUser.value?.id) || null + } catch (error) { + if ( + pluginDetailState.value.requestId !== requestId || + options.selectedPluginName.value !== name + ) { + return + } + + console.warn('[PluginMarket] 加载当前用户评分失败:', error) + pluginDetailState.value.currentUserRating = null + } + } + + async function loadPluginComments( + name: string, + requestId: number, + params: { page?: number; append?: boolean } = {}, + ): Promise { + const page = params.page ?? 1 + const append = params.append ?? false + + if (append) { + pluginDetailState.value.commentLoadingMore = true + } else { + pluginDetailState.value.commentLoading = true + pluginDetailState.value.commentError = '' + } + + try { + const response = await getPluginComments(name, { + page, + pageSize: pluginDetailState.value.commentPageSize, + }) + + if ( + pluginDetailState.value.requestId !== requestId || + options.selectedPluginName.value !== name + ) { + return + } + + const nextItems = append + ? [...pluginDetailState.value.comments, ...response.items].filter( + (item, index, list) => + list.findIndex((current) => current.id === item.id) === index, + ) + : response.items + + pluginDetailState.value.comments = nextItems + pluginDetailState.value.commentPage = response.page + pluginDetailState.value.commentPageSize = response.pageSize + pluginDetailState.value.commentTotal = response.total + pluginDetailState.value.commentError = '' + } catch (error) { + if ( + pluginDetailState.value.requestId !== requestId || + options.selectedPluginName.value !== name + ) { + return + } + + pluginDetailState.value.commentError = getErrorMessage(error, '加载评论失败') + } finally { + if ( + pluginDetailState.value.requestId !== requestId || + options.selectedPluginName.value !== name + ) { + return + } + + pluginDetailState.value.commentLoading = false + pluginDetailState.value.commentLoadingMore = false + } + } + + function resetPluginDetailState(): void { + pluginDetailState.value = createEmptyPluginDetailState() + } + + async function reloadSelectedPluginDetail(): Promise { + if (!options.selectedPlugin.value) { + resetPluginDetailState() + return + } + + const pluginName = options.selectedPlugin.value.name + const requestId = pluginDetailState.value.requestId + 1 + pluginDetailState.value = { + ...createEmptyPluginDetailState(), + requestId, + commentLoading: true, + } + + try { + await loadPluginDetail(pluginName, requestId) + await Promise.all([ + loadCurrentUserPluginRating(pluginName, requestId), + loadPluginComments(pluginName, requestId), + loadPluginRisk( + pluginName, + requestId, + pluginDetailState.value.detail?.version || options.selectedPlugin.value.version, + ), + ]) + } catch (error) { + console.error('[PluginMarket] 加载插件详情交互数据失败:', error) + } + } + + function selectPluginDetailVersion(version: PluginDetailVersion): void { + pluginDetailState.value.selectedVersion = version.version + pluginDetailState.value.selectedHash = version.hash + } + + async function handleLoadMorePluginComments(): Promise { + if ( + !options.selectedPlugin.value || + pluginDetailState.value.commentLoadingMore || + !hasMorePluginComments.value + ) { + return + } + + await loadPluginComments( + options.selectedPlugin.value.name, + pluginDetailState.value.requestId, + { + page: pluginDetailState.value.commentPage + 1, + append: true, + }, + ) + } + + async function handleSubmitPluginRating(score: number): Promise { + if (!options.selectedPlugin.value || pluginDetailState.value.ratingSubmitting) { + return + } + + if (!options.requireShopLogin('评分')) { + return + } + + pluginDetailState.value.ratingSubmitting = true + + try { + await createPluginRating(options.selectedPlugin.value.name, { score }) + options.notifySuccess('评分成功') + await Promise.all([ + loadPluginDetail(options.selectedPlugin.value.name, pluginDetailState.value.requestId), + loadCurrentUserPluginRating( + options.selectedPlugin.value.name, + pluginDetailState.value.requestId, + ), + ]) + } catch (error) { + console.error('[PluginMarket] 提交评分失败:', error) + options.notifyError(getErrorMessage(error, '提交评分失败')) + } finally { + pluginDetailState.value.ratingSubmitting = false + } + } + + async function handleSubmitPluginComment(payload: { + content: string + parentId?: string + }): Promise { + if (!options.selectedPlugin.value || pluginDetailState.value.commentSubmitting) { + return + } + + if (!options.requireShopLogin(payload.parentId ? '回复评论' : '发表评论')) { + return + } + + pluginDetailState.value.commentSubmitting = true + + try { + await createPluginComment(options.selectedPlugin.value.name, payload) + options.notifySuccess(payload.parentId ? '回复成功' : '评论成功') + pluginCommentSubmitSuccessKey.value += 1 + await loadPluginComments( + options.selectedPlugin.value.name, + pluginDetailState.value.requestId, + ) + } catch (error) { + console.error('[PluginMarket] 提交评论失败:', error) + options.notifyError(getErrorMessage(error, '提交评论失败')) + } finally { + pluginDetailState.value.commentSubmitting = false + } + } + + return { + pluginDetailState, + pluginCommentSubmitSuccessKey, + pluginCommentTree, + hasMorePluginComments, + selectedSourceLabel, + currentPluginDownloadTarget, + mergedSelectedPlugin, + selectPluginDetailVersion, + resetPluginDetailState, + reloadSelectedPluginDetail, + handleLoadMorePluginComments, + handleSubmitPluginRating, + handleSubmitPluginComment, + } +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/usePluginMarketNavigation.ts b/plugins/bad-bear/src/components/plugin-market/page/usePluginMarketNavigation.ts new file mode 100644 index 000000000..da754691b --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/usePluginMarketNavigation.ts @@ -0,0 +1,71 @@ +// 页面导航和数据刷新协调 + +import { type Ref } from 'vue' +import type { ActiveNav } from './shared' + +export function usePluginMarketNavigation(options: { + activeNav: Ref + selectedCategoryKey: Ref + selectedPluginName: Ref + canUseInternalPluginApis: Ref + clearSearchQuery: () => void + refreshNavData: (nav: ActiveNav) => Promise +}) { + /** + * 处理导航标签点击 + * 切换到目标标签并刷新其数据 + * 当内部 API 不可用时阻止导航到"已安装"标签 + */ + function handleNavClick(nav: ActiveNav): void { + // 如果已经在当前标签,则刷新数据 + if (options.activeNav.value === nav) { + void options.refreshNavData(nav) + return + } + + // 如果无法使用内部 API,阻止导航到"已安装"标签 + if (nav === 'installed' && !options.canUseInternalPluginApis.value) { + options.activeNav.value = 'store' + return + } + + options.selectedPluginName.value = null + options.activeNav.value = nav + } + + /** + * 打开分类详情视图 + */ + function openCategory(categoryKey: string) { + options.selectedCategoryKey.value = categoryKey + } + + /** + * 关闭分类详情视图 + */ + function closeCategory() { + options.selectedCategoryKey.value = null + } + + /** + * 打开插件详情视图 + */ + function openPlugin(name: string) { + options.selectedPluginName.value = name + } + + /** + * 关闭插件详情视图 + */ + function closePlugin() { + options.selectedPluginName.value = null + } + + return { + handleNavClick, + openCategory, + closeCategory, + openPlugin, + closePlugin, + } +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/usePluginMarketNotifications.ts b/plugins/bad-bear/src/components/plugin-market/page/usePluginMarketNotifications.ts new file mode 100644 index 000000000..9025ba0ad --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/usePluginMarketNotifications.ts @@ -0,0 +1,462 @@ +import { computed, onUnmounted, ref, type Ref } from 'vue' +import { + getNotifications, + markAllNotificationsRead, + markNotificationRead, + subscribeNotifications, +} from '../../../api/notifications' +import type { AuthUser } from '../../../types/auth' +import type { + NotificationFilter, + NotificationRecord, + NotificationStreamEvent, + NotificationTreeNode, +} from '../../../types/notification' +import type { ActiveNav, NotificationState } from './shared' +import { + buildNotificationTree, + createEmptyNotificationState, + getErrorMessage, +} from './shared' + +const NOTIFICATION_STREAM_RECONNECT_DELAY = 3000 + +export function usePluginMarketNotifications(options: { + activeNav: Ref + authToken: Ref + currentUser: Ref + goToAccount: () => void + notifyError: (message: string) => void + notifySuccess: (message: string) => void +}) { + const notificationState = ref(createEmptyNotificationState()) + const unreadNotificationTotal = ref(0) + const readingNotificationIds = ref([]) + const notificationTree = computed(() => buildNotificationTree(notificationState.value.items)) + + let notificationStreamAbortController: AbortController | null = null + let notificationStreamReconnectTimer: number | null = null + let notificationStreamConnectPromise: Promise | null = null + let notificationStreamSessionId = 0 + let notificationRefreshPromise: Promise | null = null + let notificationRefreshQueued = false + + function setNotificationReadStateById(id: string): void { + let unreadDelta = 0 + notificationState.value.items = notificationState.value.items.map((item) => { + if (item.id !== id || item.status === 'READ') { + return item + } + + unreadDelta += 1 + const nextItem: NotificationRecord = { + ...item, + status: 'READ', + readAt: item.readAt || new Date().toISOString(), + } + + if (notificationState.value.selectedItem?.id === id) { + notificationState.value.selectedItem = nextItem + } + + return nextItem + }) + + if (unreadDelta > 0) { + unreadNotificationTotal.value = Math.max(0, unreadNotificationTotal.value - unreadDelta) + } + } + + function markVisibleNotificationsRead(): void { + let unreadDelta = 0 + notificationState.value.items = notificationState.value.items.map((item) => { + if (item.status === 'READ') { + return item + } + + unreadDelta += 1 + const nextItem: NotificationRecord = { + ...item, + status: 'READ', + readAt: item.readAt || new Date().toISOString(), + } + + if (notificationState.value.selectedItem?.id === item.id) { + notificationState.value.selectedItem = nextItem + } + + return nextItem + }) + + if (unreadDelta > 0) { + unreadNotificationTotal.value = Math.max(0, unreadNotificationTotal.value - unreadDelta) + } + } + + async function loadNotifications(optionsArg: { + page?: number + filter?: NotificationFilter + force?: boolean + } = {}): Promise { + if (!options.authToken.value || !options.currentUser.value) { + notificationState.value = { + ...createEmptyNotificationState(), + filter: optionsArg.filter ?? notificationState.value.filter, + page: optionsArg.page ?? 1, + initialized: optionsArg.force ? false : notificationState.value.initialized, + } + unreadNotificationTotal.value = 0 + return + } + + const nextFilter = optionsArg.filter ?? notificationState.value.filter + const nextPage = optionsArg.page ?? notificationState.value.page + const requestId = notificationState.value.requestId + 1 + + notificationState.value = { + ...notificationState.value, + filter: nextFilter, + page: nextPage, + loading: true, + error: '', + requestId, + } + + try { + const response = await getNotifications({ + page: nextPage, + pageSize: notificationState.value.pageSize, + status: nextFilter === 'ALL' ? undefined : nextFilter, + }) + + if (notificationState.value.requestId !== requestId) { + return + } + + notificationState.value = { + ...notificationState.value, + items: response.items, + total: response.total, + page: response.page, + pageSize: response.pageSize, + loading: false, + error: '', + initialized: true, + } + + if (notificationState.value.selectedId) { + const matchedSelectedItem = + response.items.find((item) => item.id === notificationState.value.selectedId) || null + if (matchedSelectedItem) { + notificationState.value.selectedItem = matchedSelectedItem + } else { + notificationState.value.selectedId = null + } + } + } catch (error) { + if (notificationState.value.requestId !== requestId) { + return + } + + notificationState.value = { + ...notificationState.value, + loading: false, + error: getErrorMessage(error, '加载通知失败'), + initialized: true, + } + } + } + + async function refreshUnreadNotificationTotal(): Promise { + if (!options.authToken.value || !options.currentUser.value) { + unreadNotificationTotal.value = 0 + return + } + + try { + const response = await getNotifications({ + page: 1, + pageSize: 1, + status: 'UNREAD', + }) + unreadNotificationTotal.value = response.total + } catch (error) { + console.warn('[PluginMarket] 加载未读通知数失败:', error) + } + } + + async function markNotificationAsRead(notification: NotificationRecord): Promise { + if (!options.authToken.value || !options.currentUser.value || notification.status === 'READ') { + return + } + + if (readingNotificationIds.value.includes(notification.id)) { + return + } + + readingNotificationIds.value = [...readingNotificationIds.value, notification.id] + + try { + await markNotificationRead(notification.id) + setNotificationReadStateById(notification.id) + await Promise.all([ + loadNotifications({ + page: notificationState.value.page, + filter: notificationState.value.filter, + force: true, + }), + refreshUnreadNotificationTotal(), + ]) + } catch (error) { + console.error('[PluginMarket] 标记通知已读失败:', error) + options.notifyError(getErrorMessage(error, '标记通知已读失败')) + } finally { + readingNotificationIds.value = readingNotificationIds.value.filter((id) => id !== notification.id) + } + } + + function openNotification(notification: NotificationTreeNode): void { + notificationState.value.selectedId = notification.id + notificationState.value.selectedItem = { ...notification } + + if (notification.status === 'UNREAD') { + void markNotificationAsRead(notification) + } + } + + function closeNotificationDetail(): void { + notificationState.value.selectedId = null + notificationState.value.selectedItem = null + } + + async function handleMarkAllNotificationsRead(): Promise { + if ( + !options.authToken.value || + !options.currentUser.value || + unreadNotificationTotal.value === 0 || + notificationState.value.markingAllRead + ) { + return + } + + notificationState.value.markingAllRead = true + + try { + await markAllNotificationsRead() + markVisibleNotificationsRead() + options.notifySuccess('全部通知已标记为已读') + await Promise.all([ + loadNotifications({ + page: notificationState.value.filter === 'UNREAD' ? 1 : notificationState.value.page, + force: true, + }), + refreshUnreadNotificationTotal(), + ]) + } catch (error) { + console.error('[PluginMarket] 标记全部通知已读失败:', error) + options.notifyError(getErrorMessage(error, '标记全部通知已读失败')) + } finally { + notificationState.value.markingAllRead = false + } + } + + function handleNotificationFilterChange(filter: NotificationFilter): void { + notificationState.value.selectedId = null + notificationState.value.selectedItem = null + void loadNotifications({ page: 1, filter, force: true }) + } + + function handleNotificationPageChange(page: number): void { + if (page < 1 || page === notificationState.value.page) { + return + } + + notificationState.value.selectedId = null + notificationState.value.selectedItem = null + void loadNotifications({ page, force: true }) + } + + async function handleRefreshNotifications(): Promise { + await Promise.all([loadNotifications({ force: true }), refreshUnreadNotificationTotal()]) + } + + async function refreshNotificationsFromStream(): Promise { + if (notificationRefreshPromise) { + notificationRefreshQueued = true + return + } + + let refreshTask: Promise + refreshTask = (async () => { + do { + notificationRefreshQueued = false + await handleRefreshNotifications() + } while (notificationRefreshQueued) + })() + + notificationRefreshPromise = refreshTask + + try { + await refreshTask + } finally { + if (notificationRefreshPromise === refreshTask) { + notificationRefreshPromise = null + } + } + } + + function handleNotificationStreamEvent(event: NotificationStreamEvent): void { + if (event.type === 'connected') { + return + } + + void refreshNotificationsFromStream() + } + + function clearNotificationStreamReconnectTimer(): void { + if (notificationStreamReconnectTimer !== null) { + window.clearTimeout(notificationStreamReconnectTimer) + notificationStreamReconnectTimer = null + } + } + + function stopNotificationStream(): void { + notificationStreamSessionId += 1 + clearNotificationStreamReconnectTimer() + notificationStreamConnectPromise = null + + if (notificationStreamAbortController) { + notificationStreamAbortController.abort() + notificationStreamAbortController = null + } + } + + function scheduleNotificationStreamReconnect(): void { + if ( + notificationStreamReconnectTimer !== null || + !options.authToken.value || + !options.currentUser.value + ) { + return + } + + notificationStreamReconnectTimer = window.setTimeout(() => { + notificationStreamReconnectTimer = null + startNotificationStream({ resync: true }) + }, NOTIFICATION_STREAM_RECONNECT_DELAY) + } + + function startNotificationStream(optionsArg: { resync?: boolean } = {}): void { + if ( + notificationStreamAbortController || + notificationStreamConnectPromise || + !options.authToken.value || + !options.currentUser.value + ) { + return + } + + const sessionId = notificationStreamSessionId + let connectTask: Promise + + connectTask = (async () => { + try { + if (optionsArg.resync || !notificationState.value.initialized) { + await handleRefreshNotifications() + } + + if ( + sessionId !== notificationStreamSessionId || + !options.authToken.value || + !options.currentUser.value + ) { + return + } + + clearNotificationStreamReconnectTimer() + + const controller = new AbortController() + notificationStreamAbortController = controller + + try { + await subscribeNotifications({ + signal: controller.signal, + onEvent: handleNotificationStreamEvent, + }) + } catch (error) { + if (controller.signal.aborted || sessionId !== notificationStreamSessionId) { + return + } + + console.warn('[PluginMarket] 通知流连接失败:', error) + } finally { + if (notificationStreamAbortController === controller) { + notificationStreamAbortController = null + } + + if (!controller.signal.aborted && sessionId === notificationStreamSessionId) { + scheduleNotificationStreamReconnect() + } + } + } finally { + if (notificationStreamConnectPromise === connectTask) { + notificationStreamConnectPromise = null + } + } + })() + + notificationStreamConnectPromise = connectTask + } + + function syncNotificationStream(optionsArg: { resync?: boolean } = {}): void { + if (!options.authToken.value || !options.currentUser.value) { + stopNotificationStream() + return + } + + if (optionsArg.resync) { + stopNotificationStream() + } + + startNotificationStream(optionsArg) + } + + async function refreshNotificationsAfterAuthChange(): Promise { + if (!options.authToken.value || !options.currentUser.value) { + stopNotificationStream() + notificationState.value = createEmptyNotificationState() + unreadNotificationTotal.value = 0 + readingNotificationIds.value = [] + return + } + + syncNotificationStream({ resync: true }) + } + + function handleGoToNotificationLogin(): void { + options.goToAccount() + } + + onUnmounted(() => { + stopNotificationStream() + }) + + return { + notificationState, + unreadNotificationTotal, + readingNotificationIds, + notificationTree, + loadNotifications, + refreshUnreadNotificationTotal, + handleRefreshNotifications, + handleNotificationFilterChange, + handleNotificationPageChange, + openNotification, + closeNotificationDetail, + handleMarkAllNotificationsRead, + handleGoToNotificationLogin, + refreshNotificationsAfterAuthChange, + syncNotificationStream, + stopNotificationStream, + } +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/usePluginMarketRuntime.ts b/plugins/bad-bear/src/components/plugin-market/page/usePluginMarketRuntime.ts new file mode 100644 index 000000000..d3dd59927 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/usePluginMarketRuntime.ts @@ -0,0 +1,83 @@ +// 运行时编排层 - 组合运行时配置、GitHub 流程和账户操作 + +import { type Ref } from 'vue' +import type { ActiveNav } from './shared' +import { saveShopApiRuntimeConfig } from '../../../config/runtimeConfig' +import { useShopRuntimeConfig } from './runtime/useShopRuntimeConfig' +import { useGithubDeviceFlow } from './runtime/useGithubDeviceFlow' +import { useAccountActions } from './runtime/useAccountActions' + +export function usePluginMarketRuntime(options: { + activeNav: Ref + selectedPluginName: Ref + notifyError: (message: string) => void + notifySuccess: (message: string) => void + onAuthChanged: () => Promise + onReloadMarket: () => Promise + onReloadSelectedPluginDetail: () => Promise +}) { + // GitHub 流程和账户操作需要共享认证状态管理 + let onLoginSuccessHook: ((token: string, user: import('../../../types/auth').AuthUser) => void) | null = null + let onGithubBindingRefreshHook: (() => Promise) | null = null + + // 运行时配置和认证状态 + const runtimeConfig = useShopRuntimeConfig({ + notifyError: options.notifyError, + notifySuccess: options.notifySuccess, + onAuthChanged: options.onAuthChanged, + onGithubBindingRefresh: async () => { + await onGithubBindingRefreshHook?.() + }, + onGithubBindingReset: () => { + githubFlow.resetGithubBindingState() + }, + onDeviceFlowReset: () => { + githubFlow.resetGithubDeviceFlowState() + }, + }) + + // GitHub 设备流程逻辑 + const githubFlow = useGithubDeviceFlow({ + authToken: runtimeConfig.authToken, + currentUser: runtimeConfig.currentUser, + notifyError: options.notifyError, + notifySuccess: options.notifySuccess, + onLoginSuccess: (token, user) => { + // 保存登录结果 + saveShopApiRuntimeConfig({ token, currentUser: user }) + onLoginSuccessHook?.(token, user) + }, + }) + + // 账户操作(登录、注册、个人资料更新) + const accountActions = useAccountActions({ + authToken: runtimeConfig.authToken, + currentUser: runtimeConfig.currentUser, + selectedPluginName: options.selectedPluginName, + notifyError: options.notifyError, + notifySuccess: options.notifySuccess, + activeNav: options.activeNav, + onReloadMarket: options.onReloadMarket, + onReloadSelectedPluginDetail: options.onReloadSelectedPluginDetail, + }) + + // 设置 GitHub 绑定刷新的钩子 + onGithubBindingRefreshHook = async () => { + await githubFlow.refreshGithubBindingStatus() + } + + // 设置登录成功钩子 + onLoginSuccessHook = async () => { + await githubFlow.refreshGithubBindingStatus({ silent: true }) + } + + return { + ...runtimeConfig, + ...githubFlow, + ...accountActions, + // 转发 GitHub 绑定(带登录检查) + handleGithubBind: async () => { + await githubFlow.handleGithubBind(accountActions.requireShopLogin) + }, + } +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/usePluginMarketSelection.ts b/plugins/bad-bear/src/components/plugin-market/page/usePluginMarketSelection.ts new file mode 100644 index 000000000..7965d15f9 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/usePluginMarketSelection.ts @@ -0,0 +1,80 @@ +// 页面选择状态和派生计算 + +import { computed, type ComputedRef, type Ref } from 'vue' +import type { + CategoryInfo, + PluginMarketUiPlugin, +} from '../../../types/pluginMarket' + +export function usePluginMarketSelection(options: { + activeNav: Ref<'store' | 'installed' | 'notifications' | 'upload' | 'account' | 'settings'> + selectedCategoryKey: Ref + selectedPluginName: Ref + searchQuery: Ref + plugins: Ref + installedViewPlugins: Ref + storefrontCategories: Ref> +}) { + // 派生状态:当前导航是否为商店标签? + const isStoreNav: ComputedRef = computed(() => options.activeNav.value === 'store') + + // 派生状态:当前导航是否为已安装标签? + const isInstalledNav: ComputedRef = computed(() => options.activeNav.value === 'installed') + + // 派生状态:当前导航是否为列表视图(商店或已安装)? + const isListNav: ComputedRef = computed(() => isStoreNav.value || isInstalledNav.value) + + // 派生状态:商店标签中是否处于搜索模式? + const isSearchMode: ComputedRef = computed( + () => isStoreNav.value && options.searchQuery.value.trim().length > 0 + ) + + // 派生状态:是否应显示可滚动内容? + // 当在列表模式且没有打开详情视图时为 true + const showScrollableContent: ComputedRef = computed( + () => isListNav.value && !selectedPlugin.value && !selectedCategory.value + ) + + // 派生状态:当前选中的分类 + const selectedCategory: ComputedRef = computed(() => { + if (!isStoreNav.value || !options.selectedCategoryKey.value) { + return null + } + + return options.storefrontCategories.value[options.selectedCategoryKey.value] || null + }) + + // 派生状态:当前选中的插件 + // 根据当前标签从市场或已安装插件中解析 + const selectedPlugin: ComputedRef = computed(() => { + if (!isListNav.value || !options.selectedPluginName.value) { + return null + } + + // 如果在已安装标签,优先从已安装插件中查找 + if (isInstalledNav.value) { + return ( + options.installedViewPlugins.value.find((p) => p.name === options.selectedPluginName.value) || + options.plugins.value.find((p) => p.name === options.selectedPluginName.value) || + null + ) + } + + // 否则从市场插件中查找 + return ( + options.plugins.value.find((p) => p.name === options.selectedPluginName.value) || + options.installedViewPlugins.value.find((p) => p.name === options.selectedPluginName.value) || + null + ) + }) + + return { + isStoreNav, + isInstalledNav, + isListNav, + isSearchMode, + showScrollableContent, + selectedCategory, + selectedPlugin, + } +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/usePluginMarketUploads.ts b/plugins/bad-bear/src/components/plugin-market/page/usePluginMarketUploads.ts new file mode 100644 index 000000000..d05efd0d0 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/usePluginMarketUploads.ts @@ -0,0 +1,355 @@ +import { computed, ref, type Ref } from 'vue' +import { + checkPluginUploadHash, + deleteMyPluginUpload, + getMyPluginUpload, + getMyPluginUploads, + uploadPluginPackage, + uploadPluginWithChunks, + type ChunkedUploadProgressInfo, +} from '../../../api/pluginMarket' +import type { + MyPluginUploadRecord, + MyPluginUploadsQuery, + MyPluginUploadsResponse, + PluginHashCheckResponse, +} from '../../../types/pluginMarket' +import type { AuthUser } from '../../../types/auth' +import { getErrorMessage } from './shared' + +const MAX_UPLOAD_SIZE = 75 * 1024 * 1024 +const CHUNKED_UPLOAD_THRESHOLD = 20 * 1024 * 1024 // 20MB +const UPLOADS_PAGE_SIZE = 20 +const PROCESSING_UPLOAD_STATUSES = new Set(['AI_CLASSIFYING', 'AI_REVIEWING']) +const DELETABLE_UPLOAD_STATUSES = new Set(['PUBLISHED', 'MANUAL_REVIEW']) + +type HashCheckState = Pick | null + +export function usePluginMarketUploads(options: { + authToken: Ref + currentUser: Ref + notifyError: (message: string) => void + notifySuccess: (message: string) => void + confirmAction: (params: { + title?: string + message: string + type?: 'info' | 'warning' | 'danger' + confirmText?: string + cancelText?: string + }) => Promise + reloadMarket: () => Promise +}) { + const selectedFile = ref(null) + const validationError = ref('') + const computedHash = ref('') + const hashCheckResult = ref(null) + const isHashing = ref(false) + const isCheckingHash = ref(false) + const isUploading = ref(false) + const uploadProgress = ref(0) + const uploadStage = ref<'hashing' | 'uploading' | 'merging' | 'completed' | null>(null) + const uploadAbortController = ref(null) + + const uploads = ref([]) + const uploadsTotal = ref(0) + const uploadsPage = ref(1) + const uploadsLoading = ref(false) + const uploadsError = ref('') + const deletingIds = ref>(new Set()) + + const uploadSupported = ref(true) + + const canUpload = computed( + () => + !!selectedFile.value && + !validationError.value && + !isHashing.value && + !isCheckingHash.value && + !isUploading.value && + (!hashCheckResult.value || hashCheckResult.value.status === 'safe'), + ) + + function validateFile(file: File): string { + const name = file.name.toLowerCase() + if (!name.endsWith('.zpx') && !name.endsWith('.zip')) { + return '仅支持 .zpx 或 .zip 格式的插件包' + } + if (file.size > MAX_UPLOAD_SIZE) { + return '文件大小不能超过 75MB' + } + return '' + } + + function selectFile(file: File | null): void { + selectedFile.value = file + validationError.value = file ? validateFile(file) : '' + computedHash.value = '' + hashCheckResult.value = null + } + + /** + * 计算当前插件包的 SHA-256 并请求服务端检查重复、封禁或处理中状态;哈希或预检失败时仍允许上传,由后端最终拦截。 + */ + async function computeHashAndPrecheck(): Promise { + const file = selectedFile.value + if (!file || validationError.value) return hashCheckResult.value + + hashCheckResult.value = null + computedHash.value = '' + + isHashing.value = true + try { + const buffer = await file.arrayBuffer() + const digest = await crypto.subtle.digest('SHA-256', buffer) + const hashArray = Array.from(new Uint8Array(digest)) + const hex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') + computedHash.value = `sha256:${hex}` + } catch { + isHashing.value = false + hashCheckResult.value = { status: 'safe' } + return hashCheckResult.value + } + isHashing.value = false + + isCheckingHash.value = true + try { + const response = await checkPluginUploadHash(computedHash.value) + hashCheckResult.value = { + status: response.status, + pluginName: response.pluginName, + version: response.version, + } + } catch { + hashCheckResult.value = { status: 'safe' } + } finally { + isCheckingHash.value = false + } + + return hashCheckResult.value + } + + /** + * 用单条进度接口返回的最新记录更新当前分页列表;若当前页未包含该记录则插入到顶部便于用户立即看到进度。 + */ + function upsertUploadRecord(record: MyPluginUploadRecord): void { + const existingIndex = uploads.value.findIndex((item) => item.id === record.id) + if (existingIndex >= 0) { + const nextUploads = [...uploads.value] + nextUploads[existingIndex] = record + uploads.value = nextUploads + return + } + + uploads.value = [record, ...uploads.value].slice(0, UPLOADS_PAGE_SIZE) + uploadsTotal.value += 1 + } + + /** + * 按上传接口返回的 reviewTaskId 拉取该次记录,避免等待列表刷新才能看到新进度。 + */ + async function refreshUploadProgress(reviewTaskId: string | undefined): Promise { + if (!reviewTaskId || !options.authToken.value || !options.currentUser.value) { + return + } + + try { + const record = await getMyPluginUpload(reviewTaskId) + upsertUploadRecord(record) + } catch (error) { + console.warn('[PluginMarket] 刷新上传进度失败:', error) + } + } + + /** + * 取消当前正在进行的上传 + */ + function cancelUpload(): void { + if (uploadAbortController.value) { + uploadAbortController.value.abort() + uploadAbortController.value = null + } + } + + /** + * 响应确认上传操作:先执行哈希预检,根据文件大小选择单次上传或分片上传。 + */ + async function performUpload(): Promise<{ success: boolean; reviewTaskId?: string }> { + const file = selectedFile.value + if (!file || !canUpload.value) return { success: false } + + const precheckResult = await computeHashAndPrecheck() + if (precheckResult?.status !== 'safe') { + return { success: false } + } + + isUploading.value = true + uploadProgress.value = 0 + uploadStage.value = null + uploadAbortController.value = new AbortController() + + try { + let result + + // 根据文件大小选择上传方式 + if (file.size > CHUNKED_UPLOAD_THRESHOLD) { + // 大于 20MB 使用分片上传 + result = await uploadPluginWithChunks(file, file.name, { + chunkSize: 5 * 1024 * 1024, // 5MB 每片 + maxConcurrency: 3, + onProgress: (progress: ChunkedUploadProgressInfo) => { + uploadProgress.value = progress.progress + uploadStage.value = progress.stage + }, + signal: uploadAbortController.value.signal, + }) + } else { + // 小于等于 20MB 使用单次上传 + result = await uploadPluginPackage({ + file, + fileName: file.name, + }) + } + + if (!result.success) { + options.notifyError(result.error || '上传失败') + return { success: false } + } + + options.notifySuccess(result.message || '插件上传成功,正在后台处理中') + selectFile(null) + uploadProgress.value = 0 + uploadStage.value = null + await loadUploads() + await refreshUploadProgress(result.reviewTaskId) + return { success: true, reviewTaskId: result.reviewTaskId } + } catch (error) { + // 检查是否是用户主动取消 + if (error instanceof Error && error.message === '上传已取消') { + options.notifyError('上传已取消') + } else { + options.notifyError(getErrorMessage(error, '上传失败')) + } + return { success: false } + } finally { + isUploading.value = false + uploadProgress.value = 0 + uploadStage.value = null + uploadAbortController.value = null + } + } + + async function loadUploads(query?: MyPluginUploadsQuery): Promise { + if (!options.authToken.value || !options.currentUser.value) { + uploads.value = [] + uploadsTotal.value = 0 + uploadsPage.value = 1 + uploadsError.value = '' + return + } + + uploadsLoading.value = true + uploadsError.value = '' + + try { + const response: MyPluginUploadsResponse = await getMyPluginUploads({ + page: query?.page ?? uploadsPage.value, + pageSize: UPLOADS_PAGE_SIZE, + keyword: query?.keyword, + }) + uploads.value = response.items + uploadsTotal.value = response.total + uploadsPage.value = response.page + uploadSupported.value = true + } catch (error) { + uploadsError.value = getErrorMessage(error, '加载上传记录失败') + uploads.value = [] + uploadsTotal.value = 0 + } finally { + uploadsLoading.value = false + } + } + + async function handleDeleteUpload(record: MyPluginUploadRecord): Promise { + if (deletingIds.value.has(record.id)) return + if (PROCESSING_UPLOAD_STATUSES.has(record.status)) { + options.notifyError('正在处理的记录不可删除') + return + } + if (!DELETABLE_UPLOAD_STATUSES.has(record.status)) { + options.notifyError('该上传记录已是最终状态,无需删除') + return + } + + const confirmed = await options.confirmAction({ + title: '确认删除', + message: `确定删除 ${record.originalName} 的上传记录吗?${ + record.status === 'PUBLISHED' + ? '该记录已发布,删除后对应插件版本也会同步删除。' + : '该记录将保留并标记为发布失败。' + }`, + type: 'danger', + confirmText: '删除', + cancelText: '取消', + }) + if (!confirmed) return + + deletingIds.value = new Set([...deletingIds.value, record.id]) + try { + await deleteMyPluginUpload(record.id) + options.notifySuccess('已删除上传记录') + await Promise.all([loadUploads(), options.reloadMarket()]) + } catch (error) { + options.notifyError(getErrorMessage(error, '删除上传记录失败')) + } finally { + const next = new Set(deletingIds.value) + next.delete(record.id) + deletingIds.value = next + } + } + + function resetState(): void { + cancelUpload() + selectedFile.value = null + validationError.value = '' + computedHash.value = '' + hashCheckResult.value = null + isHashing.value = false + isCheckingHash.value = false + isUploading.value = false + uploadProgress.value = 0 + uploadStage.value = null + uploads.value = [] + uploadsTotal.value = 0 + uploadsPage.value = 1 + uploadsLoading.value = false + uploadsError.value = '' + deletingIds.value = new Set() + } + + return { + selectedFile, + validationError, + computedHash, + hashCheckResult, + isHashing, + isCheckingHash, + isUploading, + uploadProgress, + uploadStage, + canUpload, + uploads, + uploadsTotal, + uploadsPage, + uploadsLoading, + uploadsError, + deletingIds, + uploadSupported, + selectFile, + computeHashAndPrecheck, + performUpload, + cancelUpload, + loadUploads, + handleDeleteUpload, + resetState, + } +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/useStoreSubInput.ts b/plugins/bad-bear/src/components/plugin-market/page/useStoreSubInput.ts new file mode 100644 index 000000000..bab14bc2b --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/useStoreSubInput.ts @@ -0,0 +1,130 @@ +// 商店搜索子输入注册和键盘快捷键 +// 处理与 ZTools 子输入功能的集成以进行市场搜索 + +import { type Ref } from 'vue' + +// 扩展 window.ztools 类型以包含子输入相关方法 +const ztoolsWithSubInput = window.ztools as typeof window.ztools & { + setSubInput?: ( + callback: (payload: { text: string }) => void, + placeholder?: string, + autoFocus?: boolean, + ) => boolean + removeSubInput?: () => void + setSubInputValue?: (value: string) => void + subInputFocus?: () => boolean + subInputSelect?: () => boolean +} + +const MARKET_SEARCH_PLACEHOLDER = '搜索插件市场...' + +export function useStoreSubInput(options: { + isStoreNav: Ref + searchQuery: Ref +}) { + let subInputRegisterTimer: number | null = null + + /** + * 取消注册子输入并清理注册定时器 + */ + function unregisterSubInput() { + if (subInputRegisterTimer !== null) { + window.clearInterval(subInputRegisterTimer) + subInputRegisterTimer = null + } + + ztoolsWithSubInput.removeSubInput?.() + } + + /** + * 注册市场搜索的子输入 + * 使用重试逻辑,因为宿主可能没有立即准备好 + * @param autoFocus - 注册后是否聚焦输入框 + */ + function registerSubInput(autoFocus = false) { + if (!options.isStoreNav.value || !ztoolsWithSubInput.setSubInput) { + return + } + + unregisterSubInput() + + // 每 100ms 重试注册一次,直到成功 + subInputRegisterTimer = window.setInterval(() => { + const registered = ztoolsWithSubInput.setSubInput?.( + ({ text }) => { + options.searchQuery.value = text || '' + }, + MARKET_SEARCH_PLACEHOLDER, + autoFocus, + ) + + if (!registered) { + return + } + + // 注册成功,清除定时器 + if (subInputRegisterTimer !== null) { + window.clearInterval(subInputRegisterTimer) + subInputRegisterTimer = null + } + + // 使用当前搜索查询初始化子输入 + ztoolsWithSubInput.setSubInputValue?.(options.searchQuery.value) + }, 100) + } + + /** + * 聚焦子输入,可选择全选文本 + * 使用重试逻辑确保输入框准备就绪 + * @param select - 聚焦后是否全选文本 + */ + function focusSubInput(select = false) { + registerSubInput(true) + + let retryCount = 0 + const timer = window.setInterval(() => { + const handled = select + ? ztoolsWithSubInput.subInputSelect?.() + : ztoolsWithSubInput.subInputFocus?.() + + retryCount += 1 + if (handled || retryCount >= 10) { + window.clearInterval(timer) + } + }, 50) + } + + /** + * 同步子输入注册状态与当前导航 + * 在商店标签时注册,否则取消注册 + * @param autoFocus - 注册时是否自动聚焦 + */ + function syncStoreSubInput(autoFocus = false) { + if (options.isStoreNav.value) { + registerSubInput(autoFocus) + return + } + + unregisterSubInput() + } + + /** + * 清除搜索查询并更新子输入值 + */ + function clearSearchQuery() { + if (!options.searchQuery.value) { + return + } + + options.searchQuery.value = '' + ztoolsWithSubInput.setSubInputValue?.('') + } + + return { + unregisterSubInput, + registerSubInput, + focusSubInput, + syncStoreSubInput, + clearSearchQuery, + } +} diff --git a/plugins/bad-bear/src/components/plugin-market/page/validation.ts b/plugins/bad-bear/src/components/plugin-market/page/validation.ts new file mode 100644 index 000000000..018059df3 --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/page/validation.ts @@ -0,0 +1,90 @@ +// 认证和用户输入验证辅助函数 + +import type { LoginRequest, RegisterRequest } from '../../../types/auth' + +// 账号格式:3-50 位字母、数字、下划线或连字符 +export const ACCOUNT_PATTERN = /^[A-Za-z0-9_-]{3,50}$/ +export const USERNAME_MIN_LENGTH = 2 +export const USERNAME_MAX_LENGTH = 50 +export const PASSWORD_MIN_LENGTH = 8 +export const PASSWORD_MAX_LENGTH = 72 +export const MAX_AVATAR_SIZE = 5 * 1024 * 1024 +export const ALLOWED_AVATAR_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', +]) + +/** + * 验证用户名是否符合长度要求 + * @throws Error 如果用户名太短或太长 + */ +export function validateUsername(username: string): void { + const trimmed = username.trim() + if (trimmed.length < USERNAME_MIN_LENGTH || trimmed.length > USERNAME_MAX_LENGTH) { + throw new Error(`用户名长度需为 ${USERNAME_MIN_LENGTH}-${USERNAME_MAX_LENGTH} 个字符`) + } +} + +/** + * 验证密码是否符合长度要求 + * @throws Error 如果密码太短或太长 + */ +export function validatePassword(password: string): void { + if (password.length < PASSWORD_MIN_LENGTH || password.length > PASSWORD_MAX_LENGTH) { + throw new Error(`密码长度需为 ${PASSWORD_MIN_LENGTH}-${PASSWORD_MAX_LENGTH} 个字符`) + } +} + +/** + * 验证完整的注册载荷 + * 检查账号格式、用户名长度和密码长度 + * @throws Error 如果任何验证失败 + */ +export function validateRegisterPayload(payload: RegisterRequest): void { + if (!ACCOUNT_PATTERN.test(payload.account)) { + throw new Error('账号需为 3-50 位字母、数字、下划线或连字符') + } + + validateUsername(payload.username) + validatePassword(payload.password) + + if (!payload.captchaCode.trim()) { + throw new Error('请输入验证码') + } +} + +/** + * 验证登录载荷 + * 检查账号和密码是否非空 + * @throws Error 如果任何验证失败 + */ +export function validateLoginPayload(payload: LoginRequest): void { + if (!payload.account.trim()) { + throw new Error('请输入账号') + } + + if (!payload.password) { + throw new Error('请输入密码') + } + + if (!payload.captchaCode.trim()) { + throw new Error('请输入验证码') + } +} + +/** + * 验证上传的头像文件 + * 检查文件类型和大小约束 + * @throws Error 如果文件类型不允许或文件过大 + */ +export function validateAvatarFile(file: File): void { + if (!ALLOWED_AVATAR_TYPES.has(file.type)) { + throw new Error('头像仅支持 jpeg/png/gif/webp 格式') + } + + if (file.size > MAX_AVATAR_SIZE) { + throw new Error('头像大小不能超过 5MB') + } +} diff --git a/plugins/bad-bear/src/components/plugin-market/utils.ts b/plugins/bad-bear/src/components/plugin-market/utils.ts new file mode 100644 index 000000000..a1171121e --- /dev/null +++ b/plugins/bad-bear/src/components/plugin-market/utils.ts @@ -0,0 +1,90 @@ +// 比较两个版本号的大小 +// 返回值: 1 表示 left > right, -1 表示 left < right, 0 表示相等 +export function compareVersions(left?: string | null, right?: string | null): number { + // 将版本号字符串拆分为数字数组,空值默认为 '0' + const leftParts = (left || '0').split('.').map((part) => Number(part) || 0) + const rightParts = (right || '0').split('.').map((part) => Number(part) || 0) + const maxLength = Math.max(leftParts.length, rightParts.length) + + // 逐位比较版本号 + for (let index = 0; index < maxLength; index++) { + const leftValue = leftParts[index] ?? 0 + const rightValue = rightParts[index] ?? 0 + + if (leftValue > rightValue) { + return 1 + } + + if (leftValue < rightValue) { + return -1 + } + } + + return 0 +} + +// 根据查询字符串和字段权重对数组进行加权搜索 +// 返回按得分降序排列的匹配项 +export function weightedSearch( + items: T[], + query: string, + fields: Array<{ value: (item: T) => string; weight: number }>, +): T[] { + // 标准化查询字符串:去除首尾空格并转为小写 + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) { + return items + } + + return items + .map((item) => { + let score = 0 + + // 遍历所有字段,累加匹配得分 + for (const field of fields) { + if (field.value(item).toLowerCase().includes(normalizedQuery)) { + score += field.weight + } + } + + return { item, score } + }) + .filter(({ score }) => score > 0) + .sort((left, right) => right.score - left.score) + .map(({ item }) => item) +} + +// Fisher-Yates 洗牌算法:随机打乱数组 +export function shuffleArray(items: T[]): T[] { + const next = [...items] + + // 从数组末尾开始,随机交换元素 + for (let index = next.length - 1; index > 0; index--) { + const swapIndex = Math.floor(Math.random() * (index + 1)) + ;[next[index], next[swapIndex]] = [next[swapIndex], next[index]] + } + + return next +} + +// 格式化日期时间为中文本地化字符串 +export function formatDateTime(value?: string | null): string { + if (!value) { + return '-' + } + + const date = new Date(value) + // 无效日期返回原始值 + if (Number.isNaN(date.getTime())) { + return value + } + + // 返回中文格式化日期时间 + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} diff --git a/plugins/bad-bear/src/composables/index.ts b/plugins/bad-bear/src/composables/index.ts new file mode 100644 index 000000000..230144271 --- /dev/null +++ b/plugins/bad-bear/src/composables/index.ts @@ -0,0 +1 @@ +export * from './useColorScheme' diff --git a/plugins/bad-bear/src/composables/useColorScheme.ts b/plugins/bad-bear/src/composables/useColorScheme.ts new file mode 100644 index 000000000..3bfbf223b --- /dev/null +++ b/plugins/bad-bear/src/composables/useColorScheme.ts @@ -0,0 +1,24 @@ +import { computed, onMounted, onUnmounted, ref } from 'vue' + +export function useColorScheme() { + const isDark = ref(false) + let mediaQuery: MediaQueryList | null = null + + const update = () => { + isDark.value = !!mediaQuery?.matches + } + + onMounted(() => { + mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + update() + mediaQuery.addEventListener('change', update) + }) + + onUnmounted(() => { + mediaQuery?.removeEventListener('change', update) + }) + + return { + isDark: computed(() => isDark.value), + } +} diff --git a/plugins/bad-bear/src/config/pluginMarket.ts b/plugins/bad-bear/src/config/pluginMarket.ts new file mode 100644 index 000000000..dcd1e50b8 --- /dev/null +++ b/plugins/bad-bear/src/config/pluginMarket.ts @@ -0,0 +1,48 @@ +const DEFAULT_PLUGIN_MARKET_REMOTE_BASE_URL = + 'https://github.com/ZToolsCenter/ZTools-plugins/releases/latest/download' + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, '') +} + +export const PLUGIN_MARKET_REMOTE_BASE_URL = normalizeBaseUrl( + DEFAULT_PLUGIN_MARKET_REMOTE_BASE_URL, +) + +export function buildPluginMarketLatestUrl( + baseUrl = PLUGIN_MARKET_REMOTE_BASE_URL, +): string { + return `${normalizeBaseUrl(baseUrl)}/latest` +} + +export function buildPluginMarketPluginsUrl( + baseUrl = PLUGIN_MARKET_REMOTE_BASE_URL, +): string { + return `${normalizeBaseUrl(baseUrl)}/plugins.json` +} + +export function buildPluginMarketLayoutUrl( + baseUrl = PLUGIN_MARKET_REMOTE_BASE_URL, +): string { + return `${normalizeBaseUrl(baseUrl)}/layout.yaml` +} + +export function buildPluginMarketCategoriesUrl( + baseUrl = PLUGIN_MARKET_REMOTE_BASE_URL, +): string { + return `${normalizeBaseUrl(baseUrl)}/categories.json` +} + +export function getPluginMarketRemote(baseUrl = PLUGIN_MARKET_REMOTE_BASE_URL) { + const normalizedBaseUrl = normalizeBaseUrl(baseUrl) + + return { + baseUrl: normalizedBaseUrl, + latest: buildPluginMarketLatestUrl(normalizedBaseUrl), + plugins: buildPluginMarketPluginsUrl(normalizedBaseUrl), + layout: buildPluginMarketLayoutUrl(normalizedBaseUrl), + categories: buildPluginMarketCategoriesUrl(normalizedBaseUrl), + } +} + +export const PLUGIN_MARKET_REMOTE = getPluginMarketRemote() diff --git a/plugins/bad-bear/src/config/runtimeConfig.ts b/plugins/bad-bear/src/config/runtimeConfig.ts new file mode 100644 index 000000000..c1f10bdfc --- /dev/null +++ b/plugins/bad-bear/src/config/runtimeConfig.ts @@ -0,0 +1,214 @@ +import type { AuthUser } from '../types/auth' +import type { ShopApiRuntimeConfig } from '../types/runtimeConfig' + +export const DEFAULT_SHOP_API_BASE_URL = 'https://badbear.ydys.cc' + +const RUNTIME_CONFIG_FILE_NAME = 'runtime-config.json' +const RUNTIME_CONFIG_PATH_SEGMENTS = ['plugins', 'bad-bear', RUNTIME_CONFIG_FILE_NAME] as const + +const listeners = new Set<(config: ShopApiRuntimeConfig) => void>() + +let runtimeConfig: ShopApiRuntimeConfig = createDefaultRuntimeConfig() + +function createDefaultRuntimeConfig(): ShopApiRuntimeConfig { + return { + baseUrl: DEFAULT_SHOP_API_BASE_URL, + token: '', + currentUser: null, + } +} + +function cloneUser(user: AuthUser | null | undefined): AuthUser | null { + return user + ? { + ...user, + } + : null +} + +function cloneConfig(config: ShopApiRuntimeConfig): ShopApiRuntimeConfig { + return { + baseUrl: config.baseUrl, + token: config.token, + currentUser: cloneUser(config.currentUser), + } +} + +function notifyListeners(): void { + const snapshot = getShopApiRuntimeConfig() + listeners.forEach((listener) => listener(snapshot)) +} + +function isValidAuthUser(value: unknown): value is AuthUser { + if (!value || typeof value !== 'object') { + return false + } + + const candidate = value as Record + return ( + typeof candidate.id === 'string' && + typeof candidate.account === 'string' && + typeof candidate.username === 'string' && + (typeof candidate.avatarUrl === 'string' || candidate.avatarUrl === null) && + typeof candidate.createdAt === 'string' && + typeof candidate.updatedAt === 'string' + ) +} + +type RuntimePathTools = { + join: (...paths: string[]) => string +} + +function getRuntimePathTools(): RuntimePathTools | null { + return window.services?.path ?? null +} + +function getRuntimeConfigFilePath(): string | null { + const userDataPath = window.ztools?.getPath?.('userData') + const pathTools = getRuntimePathTools() + + if (!userDataPath || !pathTools) { + return null + } + + return pathTools.join(userDataPath, ...RUNTIME_CONFIG_PATH_SEGMENTS) +} + +function persistRuntimeConfig(): void { + const filePath = getRuntimeConfigFilePath() + if (!filePath || !window.services?.writeFile) { + return + } + + window.services.writeFile(filePath, JSON.stringify(runtimeConfig, null, 2)) +} + +function applyRuntimeConfig(config: ShopApiRuntimeConfig, persist = false): ShopApiRuntimeConfig { + runtimeConfig = cloneConfig(config) + + if (persist) { + persistRuntimeConfig() + } + + notifyListeners() + return getShopApiRuntimeConfig() +} + +function sanitizeRuntimeConfig(raw: unknown): ShopApiRuntimeConfig { + const defaults = createDefaultRuntimeConfig() + + if (!raw || typeof raw !== 'object') { + return defaults + } + + const source = raw as Record + + return { + baseUrl: + typeof source.baseUrl === 'string' + ? normalizeShopApiBaseUrl(source.baseUrl) + : defaults.baseUrl, + token: typeof source.token === 'string' ? source.token : defaults.token, + currentUser: isValidAuthUser(source.currentUser) ? cloneUser(source.currentUser) : null, + } +} + +export function normalizeShopApiBaseUrl(input?: string): string { + const trimmed = input?.trim() || '' + + if (!trimmed) { + return DEFAULT_SHOP_API_BASE_URL + } + + const withProtocol = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmed) + ? trimmed + : `http://${trimmed}` + + let parsedUrl: URL + try { + parsedUrl = new URL(withProtocol) + } catch { + throw new Error('API 地址格式不正确') + } + + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + throw new Error('API 地址仅支持 http 或 https') + } + + const normalizedPathname = parsedUrl.pathname.replace(/\/+$/, '') + return `${parsedUrl.protocol}//${parsedUrl.host}${normalizedPathname}` +} + +export function getShopApiRuntimeConfig(): ShopApiRuntimeConfig { + return cloneConfig(runtimeConfig) +} + +export function loadShopApiRuntimeConfig(): ShopApiRuntimeConfig { + const filePath = getRuntimeConfigFilePath() + + if (!filePath || !window.services?.exists || !window.services?.readFile) { + return applyRuntimeConfig(createDefaultRuntimeConfig()) + } + + if (!window.services.exists(filePath)) { + return applyRuntimeConfig(createDefaultRuntimeConfig()) + } + + try { + const content = window.services.readFile(filePath, 'utf-8') + if (!content.trim()) { + return applyRuntimeConfig(createDefaultRuntimeConfig()) + } + + const parsed = JSON.parse(content) + return applyRuntimeConfig(sanitizeRuntimeConfig(parsed)) + } catch (error) { + console.error('[ShopApiConfig] 读取运行时配置失败:', error) + return applyRuntimeConfig(createDefaultRuntimeConfig()) + } +} + +export function saveShopApiRuntimeConfig( + patch: Partial, +): ShopApiRuntimeConfig { + const nextConfig: ShopApiRuntimeConfig = { + baseUrl: normalizeShopApiBaseUrl(patch.baseUrl ?? runtimeConfig.baseUrl), + token: patch.token ?? runtimeConfig.token, + currentUser: + patch.currentUser === undefined ? cloneUser(runtimeConfig.currentUser) : cloneUser(patch.currentUser), + } + + return applyRuntimeConfig(nextConfig, true) +} + +export function clearShopApiAuth(): ShopApiRuntimeConfig { + return saveShopApiRuntimeConfig({ + token: '', + currentUser: null, + }) +} + +export function subscribeShopApiRuntimeConfig( + listener: (config: ShopApiRuntimeConfig) => void, +): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function buildShopApiAssetUrl( + resourcePath: string | null | undefined, + baseUrl = runtimeConfig.baseUrl, +): string { + if (!resourcePath) { + return '' + } + + if (/^https?:\/\//i.test(resourcePath)) { + return resourcePath + } + + const normalizedBaseUrl = normalizeShopApiBaseUrl(baseUrl) + return `${normalizedBaseUrl}${resourcePath.startsWith('/') ? '' : '/'}${resourcePath}` +} diff --git a/plugins/bad-bear/src/config/theme.ts b/plugins/bad-bear/src/config/theme.ts new file mode 100644 index 000000000..b88e56896 --- /dev/null +++ b/plugins/bad-bear/src/config/theme.ts @@ -0,0 +1,28 @@ +import { useZtoolsTheme } from 'ztools-ui' + +let themeInitialized = false + +function ensureThemeBridge(): void { + if (typeof window.ztools.getThemeInfo !== 'function') { + window.ztools.getThemeInfo = () => window.services.getThemeInfo() + } + + if (typeof window.ztools.onThemeChange !== 'function') { + window.ztools.onThemeChange = (callback) => window.services.onThemeChange(callback) + } + + if (typeof window.ztools.internal.getPlatform !== 'function') { + window.ztools.internal.getPlatform = () => window.services.getSystemInfo().platform + } +} + +export function useTheme() { + ensureThemeBridge() + + if (themeInitialized) { + return + } + + useZtoolsTheme() + themeInitialized = true +} diff --git a/plugins/bad-bear/src/env.d.ts b/plugins/bad-bear/src/env.d.ts new file mode 100644 index 000000000..495670571 --- /dev/null +++ b/plugins/bad-bear/src/env.d.ts @@ -0,0 +1,107 @@ +/// +/// + +import type { + HashFileResult, + InjectedPluginRecord, + InstalledPlugin, + OperationResult, + PluginLaunchOptions, + PluginLaunchResult, + PluginMarketFetchResponse, + PluginMarketPlugin, + PluginMutationResult, + PluginReadmeResponse, +} from './types/pluginMarket' + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent, Record, unknown> + export default component +} + +interface SystemInfo { + platform: 'win32' | 'darwin' | 'linux' +} + +interface ThemeInfo { + isDark: boolean + primaryColor?: string + customColor?: string + windowMaterial: string +} + +interface Services { + getSystemInfo(): SystemInfo + getThemeInfo(): ThemeInfo + onThemeChange(callback: (theme: ThemeInfo) => void): void + readFile: (filePath: string, encoding?: string) => string + readBinaryFile: (filePath: string) => Uint8Array + writeFile: (filePath: string, content: string) => void + computeFileHash: (filePath: string, algorithm?: 'sha256') => Promise + exists: (path: string) => boolean + path: { + join: (...paths: string[]) => string + dirname: (path: string) => string + basename: (path: string, ext?: string) => string + } + rename: (oldPath: string, newPath: string) => void + removeDirectory: (dirPath: string) => void + removeFile: (filePath: string) => void + copyFile: (src: string, dest: string) => void + copyDirectory: (src: string, dest: string) => void +} + +interface ImageAnalysisResult { + isSimpleIcon: boolean + mainColor: string | null + isDark: boolean + needsAdaptation: boolean +} + +interface HotkeyRecordingResult extends OperationResult {} + +type HotkeyRecordedCallback = (shortcut: string) => void + +interface ZToolsInternal { + getAllPlugins(): Promise + fetchPluginMarket(baseUrl?: string): Promise + getPlugins(): Promise + installPluginFromMarket(plugin: PluginMarketPlugin): Promise + deletePlugin(pluginPath: string): Promise + getPluginReadme(target: string): Promise + dbGet?(key: string): Promise + getPlatform?(): string + launch?(options: PluginLaunchOptions): Promise + getRunningPlugins?(): Promise + reloadPlugin?(pluginPath: string): Promise + killPlugin?(pluginPath: string): Promise + revealInFinder?(targetPath: string): Promise + computeFileHash?(filePath: string, algorithm?: 'sha256'): Promise + analyzeImage?(src: string): Promise + startHotkeyRecording?(): Promise + onHotkeyRecorded?(callback: HotkeyRecordedCallback): void +} + +interface ZToolsApi { + internal: ZToolsInternal + getThemeInfo?(): ThemeInfo + onThemeChange?(callback: (theme: ThemeInfo) => void): void + showTip(message: string): void + showToast(message: string): void + shellOpenExternal(url: string): void + getPath(name: string): string + onPluginEnter(callback: (action: any) => void): void + onPluginOut(callback: () => void): void +} + +declare global { + const __APP_VERSION__: string + + interface Window { + services: Services + ztools: ZToolsApi + } +} + +export {} diff --git a/plugins/bad-bear/src/main.ts b/plugins/bad-bear/src/main.ts new file mode 100644 index 000000000..7476c7fa5 --- /dev/null +++ b/plugins/bad-bear/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import './assets/styles' +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/plugins/bad-bear/src/styles/css/global.css b/plugins/bad-bear/src/styles/css/global.css new file mode 100644 index 000000000..241716363 --- /dev/null +++ b/plugins/bad-bear/src/styles/css/global.css @@ -0,0 +1,814 @@ +/* + * 全局共享样式入口。 + * 主题 token、基础按钮、卡片与图标按钮统一维护在这里,避免与 src/main.css 重复定义同名全局类。 + */ +html, +body { + overflow: hidden !important; + margin: 0; + padding: 0; + width: 100%; + height: 100%; + scrollbar-width: none; /* 火狐浏览器 */ + -ms-overflow-style: none; /* IE 和 Edge 浏览器 */ +} + +/* 隐藏 body 的滚动条 */ +html::-webkit-scrollbar, +body::-webkit-scrollbar { + display: none !important; + width: 0 !important; + height: 0 !important; +} + +:root { + color-scheme: light dark; + --bg-color: #f4f4f4; + --surface-color: #ffffff; + --surface-elevated: #eef3fb; + --text-color: #333333; + --text-secondary: #616161; + --border-color: #e5e7eb; + --divider-color: rgba(0, 0, 0, 0.1); + --card-bg: rgba(0, 0, 0, 0.015); + --control-bg: rgba(0, 0, 0, 0.035); + --control-border: rgba(0, 0, 0, 0.1); + --hover-bg: #f9fafb; + --active-bg: #f0f9ff; + --primary-color: #0284c7; + --input-bg: #f4f4f4; + --input-border: #e5e7eb; + --input-focus-bg: #f0f9ff; + --placeholder-color: #616161; + --shadow-color: rgba(0, 0, 0, 0.1); + --primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + --text-on-primary: #ffffff; + --highlight-color: #ea580c; + --danger-color: #ef4444; + --warning-color: #f59e0b; + --success-color: #10b981; + --purple-color: #8b5cf6; + --purple-light-bg: #f3e8ff; + --purple-border: #d8b4fe; + --success-light-bg: #ecfdf5; + --success-border: #a7f3d0; + --warning-light-bg: #fef3c7; + --warning-border: #fcd34d; + --danger-light-bg: #fee2e2; + --danger-border: #fca5a5; + --error-color: #ef4444; + --dialog-bg: #f4f4f4; +} + +/* macOS 下使用透明背景 */ +.os-mac:root { + --bg-color: #f4f4f44f; +} + +/* Windows 下默认使用普通背景色 */ +.os-windows:root { + --bg-color: #f4f4f4; +} + +/* Windows 下根据 data-material 属性应用不同材质样式 */ +.os-windows[data-material='mica'] { + --bg-color: transparent; + --card-bg: rgba(255, 255, 255, 0.5); /* 增加白度,更实一点 */ + --control-bg: rgba(255, 255, 255, 0.6); /* 输入框背景更明显 */ + --hover-bg: rgba(0, 0, 0, 0.04); + --active-bg: rgba(0, 0, 0, 0.06); + --divider-color: rgba(0, 0, 0, 0.06); + --highlight-color: rgba(234, 88, 12, 0.9); +} + +.os-windows[data-material='acrylic'] { + --bg-color: transparent; + --card-bg: rgba(255, 255, 255, 0.3); + --control-bg: rgba(255, 255, 255, 0.25); + --hover-bg: rgba(0, 0, 0, 0.05); + --active-bg: rgba(0, 0, 0, 0.08); + --divider-color: rgba(0, 0, 0, 0.06); + --control-border: rgba(0, 0, 0, 0.15); + --highlight-color: rgba(234, 88, 12, 0.9); +} + +.os-windows[data-material='none'] { + --bg-color: #f4f4f4; + --card-bg: white; + --control-bg: white; +} + +/* 主题色定义 - 亮色模式 */ +:root, +.theme-blue { + --primary-color: #0284c7; +} + +.theme-purple { + --primary-color: #7c3aed; +} + +.theme-green { + --primary-color: #059669; +} + +.theme-orange { + --primary-color: #ea580c; +} + +.theme-red { + --primary-color: #dc2626; +} + +.theme-pink { + --primary-color: #db2777; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg-color: #303133; + --surface-color: #282d36; + --surface-elevated: #323844; + --text-color: #f3f4f6; + --text-secondary: #bfc0c3; + --border-color: #374151; + --divider-color: rgba(255, 255, 255, 0.1); + --card-bg: rgba(255, 255, 255, 0.04); + --control-bg: rgba(255, 255, 255, 0.04); + --control-border: rgba(255, 255, 255, 0.1); + --hover-bg: #374151; + --input-bg: #374151; + --input-border: #374151; + --input-focus-bg: #4b5563; + --placeholder-color: #b8b8b8; + --shadow-color: rgba(0, 0, 0, 0.3); + --primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + --text-on-primary: #ffffff; + --highlight-color: #fb923c; + --danger-color: #f87171; + --warning-color: #fbbf24; + --success-color: #34d399; + --purple-color: #a78bfa; + --purple-light-bg: rgba(139, 92, 246, 0.2); + --purple-border: rgba(139, 92, 246, 0.4); + --success-light-bg: rgba(52, 211, 153, 0.2); + --success-border: rgba(52, 211, 153, 0.4); + --warning-light-bg: rgba(251, 191, 36, 0.2); + --warning-border: rgba(251, 191, 36, 0.4); + --danger-light-bg: rgba(248, 113, 113, 0.2); + --danger-border: rgba(248, 113, 113, 0.4); + --error-color: #f87171; + --dialog-bg: #303133; + } + + /* macOS 暗色模式下使用透明背景 */ + .os-mac:root { + --bg-color: transparent; + } + + /* Windows 暗色模式下默认使用普通背景色 */ + .os-windows:root { + --bg-color: #303133; + } + + /* Windows 暗色模式下根据 data-material 属性应用不同材质样式 */ + .os-windows[data-material='mica'] { + --bg-color: transparent; + --card-bg: rgba(255, 255, 255, 0.04); + --control-bg: rgba(255, 255, 255, 0.06); + --hover-bg: rgba(255, 255, 255, 0.05); + --active-bg: rgba(255, 255, 255, 0.08); + --divider-color: rgba(255, 255, 255, 0.08); + --highlight-color: rgba(251, 146, 60, 0.85); + } + + .os-windows[data-material='acrylic'] { + --bg-color: transparent; + --card-bg: rgba(255, 255, 255, 0.02); + --control-bg: rgba(255, 255, 255, 0.04); + --hover-bg: rgba(255, 255, 255, 0.05); + --active-bg: rgba(255, 255, 255, 0.08); + --divider-color: rgba(255, 255, 255, 0.06); + --control-border: rgba(255, 255, 255, 0.15); + --highlight-color: rgba(251, 146, 60, 0.85); + } + + .os-windows[data-material='none'] { + --bg-color: #303133; + --card-bg: #3a3a3c; + --control-bg: #48484a; + } + + /* 主题色定义 - 暗色模式 */ + /* 主题色定义 - 暗色模式 */ + :root, + .theme-blue { + --primary-color: #38bdf8; + } + + .theme-purple { + --primary-color: #a78bfa; + } + + .theme-green { + --primary-color: #34d399; + } + + .theme-orange { + --primary-color: #fb923c; + } + + .theme-red { + --primary-color: #f87171; + } + + .theme-pink { + --primary-color: #f472b6; + } +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button { + border: none; + background: transparent; + color: inherit; + cursor: pointer; +} + +textarea { + display: block; + margin: 0; + resize: vertical; +} + +a { + color: var(--primary-color); +} + +body { + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background: var(--bg-color); + color: var(--text-color); + + /* 使用 color-mix 计算 hover 和 light-bg */ + /* 必须定义在 body 上,以便在 theme-xxx 类改变 --primary-color 时重新计算 */ + --primary-hover: color-mix(in srgb, var(--primary-color), black 10%); + --primary-light-bg: color-mix(in srgb, var(--primary-color), white 80%); +} + +/* 默认的 active-bg 和 hover-bg(非 Windows 材质模式) */ +html:not(.os-windows[data-material='mica']):not(.os-windows[data-material='acrylic']) body { + --active-bg: color-mix(in srgb, var(--primary-color) 20%, white); + --hover-bg: color-mix(in srgb, var(--primary-color) 12%, white); +} + +/* 文本选择样式 - 使用主题色 */ +::selection { + background: color-mix(in srgb, var(--primary-color) 35%, white); + color: var(--text-color); +} + +::-moz-selection { + background: color-mix(in srgb, var(--primary-color) 35%, white); + color: var(--text-color); +} + +#app { + overflow: hidden !important; /* 完全隐藏滚动条 */ + max-width: 100%; /* 限制最大宽度 */ + height: 100%; +} + +@media (prefers-color-scheme: dark) { + body { + /* 暗色模式下的计算逻辑 */ + --primary-hover: color-mix(in srgb, var(--primary-color), white 10%); + --primary-light-bg: color-mix(in srgb, var(--primary-color), transparent 92%); + } + + /* 暗色模式下默认的 active-bg 和 hover-bg(非 Windows 材质模式) */ + html:not(.os-windows[data-material='mica']):not(.os-windows[data-material='acrylic']) body { + --active-bg: color-mix(in srgb, var(--primary-color) 12%, #303133); + --hover-bg: color-mix(in srgb, var(--primary-color) 5%, #303133); + } + + /* 暗色模式下的文本选择样式 */ + ::selection { + background: color-mix(in srgb, var(--primary-color) 40%, #303133); + color: var(--text-color); + } + + ::-moz-selection { + background: color-mix(in srgb, var(--primary-color) 40%, #303133); + color: var(--text-color); + } +} + +/* ==================== 通用控件样式规范 ==================== */ + +/* 按钮基础类 */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 32px; + padding: 6px 16px; + border: 1px solid var(--control-border); + border-radius: 8px; + background: var(--control-bg); + color: var(--text-color); + font-size: 14px; + font-weight: 600; + line-height: 1; + cursor: pointer; + transition: + background-color 0.2s ease, + color 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease, + opacity 0.2s ease; + outline: none; + user-select: none; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 按钮尺寸变体 */ +.btn-sm { + min-height: 24px; + padding: 4px 10px; + font-size: 11px; +} + +.btn-md { + min-height: 28px; + padding: 6px 14px; + font-size: 12px; +} + +.btn-lg { + min-height: 36px; + padding: 10px 20px; + font-size: 15px; +} + +.btn-icon { + width: 32px; + min-width: 32px; + min-height: 32px; + padding: 6px; + border-radius: 50%; + background: var(--primary-color); + border-color: var(--primary-color); + color: var(--text-on-primary); +} + +.btn-icon svg { + display: block; + flex-shrink: 0; +} + +/* 小图标按钮(无边框,透明背景)- 用于已安装插件页面和插件市场 */ +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + cursor: pointer; + transition: + background-color 0.2s ease, + color 0.2s ease, + transform 0.2s ease, + opacity 0.2s ease; + flex-shrink: 0; +} + +.icon-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 按钮类型变体 - 默认悬浮时显示主题色 */ +.btn:hover:not(:disabled) { + background: var(--hover-bg); + border-color: color-mix(in srgb, var(--primary-color), black 15%); + color: var(--primary-color); +} + +.btn:active:not(:disabled) { + background: var(--active-bg); +} + +.btn-primary { + background: var(--primary-color); + border-color: var(--primary-color); + color: var(--text-on-primary); +} + +.btn-primary:hover:not(:disabled) { + background: color-mix(in srgb, var(--primary-color), black 8%); + border-color: color-mix(in srgb, var(--primary-color), black 12%); + color: var(--text-on-primary); +} + +.btn-primary:active:not(:disabled) { + background: color-mix(in srgb, var(--primary-color), black 14%); + border-color: color-mix(in srgb, var(--primary-color), black 18%); +} + +.btn-ghost, +.btn-secondary { + background: var(--surface-color, var(--card-bg)); + border-color: var(--divider-color); + color: var(--text-color); +} + +.btn-ghost:hover:not(:disabled), +.btn-secondary:hover:not(:disabled) { + background: var(--surface-elevated); + border-color: color-mix(in srgb, var(--primary-color) 20%, var(--divider-color)); + color: var(--text-color); +} + +.btn-danger { + background: var(--danger-color); + border-color: var(--danger-color); + color: var(--text-on-primary); +} + +.btn-danger:hover:not(:disabled) { + background: color-mix(in srgb, var(--danger-color), black 8%); + border-color: color-mix(in srgb, var(--danger-color), black 12%); + color: var(--text-on-primary); +} + +.btn-warning { + background: var(--warning-color); + border-color: var(--warning-color); + color: var(--text-on-primary); +} + +.btn-warning:hover:not(:disabled) { + background: color-mix(in srgb, var(--warning-color), black 8%); + border-color: color-mix(in srgb, var(--warning-color), black 12%); + color: var(--text-on-primary); +} + +.btn-success { + background: var(--success-color); + border-color: var(--success-color); + color: var(--text-on-primary); +} + +.btn-success:hover:not(:disabled) { + background: color-mix(in srgb, var(--success-color), black 8%); + border-color: color-mix(in srgb, var(--success-color), black 12%); + color: var(--text-on-primary); +} + +.btn-purple { + background: var(--purple-color); + border-color: var(--purple-color); + color: var(--text-on-primary); +} + +.btn-purple:hover:not(:disabled) { + background: color-mix(in srgb, var(--purple-color), black 8%); + border-color: color-mix(in srgb, var(--purple-color), black 12%); + color: var(--text-on-primary); +} + +/* 实心按钮变体 - 默认使用实心主题色,符合控件设计规范 */ +.btn-solid { + background: var(--primary-color); + border: 1px solid var(--primary-color); + border-radius: 8px; + padding: 6px 16px; + color: #ffffff; + font-weight: 600; +} + +.btn-solid:hover:not(:disabled) { + background: color-mix(in srgb, var(--primary-color), black 10%); + border-color: color-mix(in srgb, var(--primary-color), black 10%); + color: #ffffff; +} + +.btn-solid:active:not(:disabled) { + background: color-mix(in srgb, var(--primary-color), black 20%); + border-color: color-mix(in srgb, var(--primary-color), black 20%); +} + +/* Acrylic 材质模式下使用透明风格 */ +.os-windows[data-material='acrylic'] .btn-solid { + background: var(--control-bg); + border-color: var(--primary-color); + color: var(--primary-color); + font-weight: 600; +} + +.os-windows[data-material='acrylic'] .btn-solid:hover:not(:disabled) { + background: var(--hover-bg); + border-color: color-mix(in srgb, var(--primary-color), black 15%); + color: var(--primary-color); +} + +.os-windows[data-material='acrylic'] .btn-solid:active:not(:disabled) { + background: var(--active-bg); +} + +/* 输入框基础类 */ +.input { + width: 100%; + padding: 6px 16px; + border: 2px solid var(--control-border); + border-radius: 6px; + font-size: 14px; + transition: all 0.2s; + outline: none; + background: var(--control-bg); + color: var(--text-color); +} + +.input::placeholder { + color: var(--placeholder-color); +} + +.input:hover { + background: var(--hover-bg); + border-color: color-mix(in srgb, var(--primary-color), black 15%); +} + +.input:focus { + background: var(--active-bg); + border-color: color-mix(in srgb, var(--primary-color), black 15%); + box-shadow: 0 0 0 3px var(--primary-light-bg); +} + +/* 下拉框 */ +.select { + width: 100%; + padding: 6px 16px; + border: 2px solid var(--control-border); + border-radius: 6px; + font-size: 14px; + transition: all 0.2s; + outline: none; + background: var(--control-bg); + color: var(--text-color); + cursor: pointer; + color-scheme: light dark; +} + +.select:hover { + background: var(--hover-bg); + border-color: color-mix(in srgb, var(--primary-color), black 15%); +} + +.select:focus { + background: var(--active-bg); + border-color: color-mix(in srgb, var(--primary-color), black 15%); + box-shadow: 0 0 0 3px var(--primary-light-bg); +} + +.select option { + background-color: var(--bg-color); + color: var(--text-color); +} + +/* 开关基础类 */ +.toggle { + position: relative; + display: inline-block; + width: 44px; + height: 24px; +} + +.toggle input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--control-bg); + border: 2px solid var(--control-border); + transition: all 0.3s; + border-radius: 24px; + box-sizing: border-box; +} + +.toggle-slider:hover { + background-color: var(--hover-bg); +} + +.toggle-slider:hover::before { + transform: scale(1.15); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.toggle-slider::before { + position: absolute; + content: ''; + height: 16px; + width: 16px; + left: 2px; + bottom: 2px; + background-color: var(--text-color); + transition: all 0.3s; + border-radius: 50%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.toggle input:checked + .toggle-slider { + background-color: color-mix(in srgb, var(--primary-color), black 15%); + border-color: color-mix(in srgb, var(--primary-color), black 15%); +} + +.toggle input:checked + .toggle-slider:hover { + background-color: color-mix(in srgb, var(--primary-color), black 25%); + border-color: color-mix(in srgb, var(--primary-color), black 25%); +} + +.toggle input:checked + .toggle-slider:hover::before { + transform: translateX(20px) scale(1.15); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.toggle input:checked + .toggle-slider::before { + transform: translateX(20px); + background-color: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.toggle input:focus + .toggle-slider { + box-shadow: 0 0 0 3px var(--primary-light-bg); +} + +/* 卡片基础类 */ +.card { + background: var(--surface-color, var(--card-bg)); + border-radius: 10px; + border: 1px solid var(--divider-color); +} + +.card.clickable { + cursor: pointer; +} + +/* macOS 风格毛玻璃效果 - 仅 Acrylic 模式 */ +.os-windows[data-material='acrylic'] .card, +.os-windows[data-material='acrylic'] .btn, +.os-windows[data-material='acrylic'] .input, +.os-windows[data-material='acrylic'] .select { + backdrop-filter: blur(40px) saturate(180%); + -webkit-backdrop-filter: blur(40px) saturate(180%); +} + +/* 全局自适应图标样式 - 主题色渐变 */ +/* 使用 mask + linear-gradient 实现真正的颜色渐变 */ + +.adaptive-icon { + /* 使用图标作为蒙版 */ + -webkit-mask-image: var(--icon-url); + mask-image: var(--icon-url); + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; +} + +/* 亮色模式:从主题色到极浅主题色的渐变 */ +@media (prefers-color-scheme: light) { + .adaptive-icon { + background: linear-gradient( + 135deg, + var(--primary-color) 0%, + color-mix(in srgb, var(--primary-color) 10%, white) 100% + ); + opacity: 0.88; + } +} + +/* 暗色模式:从主题色到极浅主题色的渐变 */ +@media (prefers-color-scheme: dark) { + .adaptive-icon { + background: linear-gradient( + 135deg, + var(--primary-color) 0%, + color-mix(in srgb, var(--primary-color) 15%, white) 100% + ); + opacity: 0.92; + } +} + +/* 不同主题的色相旋转值 */ +.theme-blue { + --adaptive-icon-hue: 180deg; +} + +.theme-purple { + --adaptive-icon-hue: 260deg; +} + +.theme-green { + --adaptive-icon-hue: 100deg; +} + +.theme-orange { + --adaptive-icon-hue: 20deg; +} + +.theme-red { + --adaptive-icon-hue: 350deg; +} + +.theme-pink { + --adaptive-icon-hue: 310deg; +} + +/* 默认蓝色主题 */ +:root { + --adaptive-icon-hue: 180deg; +} + +/* + * 全局滚动条样式优化 + * 注意:插件 WebContentsView 的滚动条样式在 src/main/core/globalStyles.ts 中定义 + * 两者应保持一致 + */ +::-webkit-scrollbar { + width: 6px !important; + height: 6px !important; +} + +::-webkit-scrollbar-track { + background: transparent !important; +} + +::-webkit-scrollbar-thumb { + border-radius: 3px !important; +} + +/* 全局滚动条样式优化 - 亮色模式 */ +@media (prefers-color-scheme: light) { + ::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.15) !important; + } + + ::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.25) !important; + } + + ::-webkit-scrollbar-thumb:active { + background: rgba(0, 0, 0, 0.35) !important; + } +} + +/* 全局滚动条样式优化 - 暗色模式 */ +@media (prefers-color-scheme: dark) { + ::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2) !important; + } + + ::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.35) !important; + } + + ::-webkit-scrollbar-thumb:active { + background: rgba(255, 255, 255, 0.5) !important; + } +} diff --git a/plugins/bad-bear/src/styles/css/index.ts b/plugins/bad-bear/src/styles/css/index.ts new file mode 100644 index 000000000..474de5794 --- /dev/null +++ b/plugins/bad-bear/src/styles/css/index.ts @@ -0,0 +1 @@ +import './global.css' diff --git a/plugins/bad-bear/src/styles/index.ts b/plugins/bad-bear/src/styles/index.ts new file mode 100644 index 000000000..6267d1f52 --- /dev/null +++ b/plugins/bad-bear/src/styles/index.ts @@ -0,0 +1 @@ +import './css/global.css' diff --git a/plugins/bad-bear/src/types/auth.ts b/plugins/bad-bear/src/types/auth.ts new file mode 100644 index 000000000..965f1da4d --- /dev/null +++ b/plugins/bad-bear/src/types/auth.ts @@ -0,0 +1,121 @@ +export interface AuthUser { + id: string + account: string + username: string + avatarUrl: string | null + createdAt: string + updatedAt: string +} + +export interface AuthResponse { + token: string + user: AuthUser +} + +export interface CaptchaResponse { + captchaToken: string + image: string +} + +export interface LoginRequest { + account: string + password: string + captchaToken: string + captchaCode: string +} + +export interface RegisterRequest { + account: string + username: string + password: string + captchaToken: string + captchaCode: string +} + +export interface CurrentUserResponse { + user: AuthUser +} + +export interface UpdateUsernameRequest { + username: string +} + +export interface UpdatePasswordRequest { + currentPassword?: string + newPassword: string +} + +export interface UpdatePasswordResponse { + message: string +} + +export interface GitHubDeviceStartResponse { + deviceSessionId: string + userCode: string + verificationUri: string + verificationUriComplete?: string | null + expiresAt: string + expiresIn: number + interval: number +} + +export interface GitHubDevicePollRequest { + deviceSessionId: string +} + +export interface GitHubDevicePollPendingResponse { + status: 'pending' + retryAfterSeconds: number + expiresAt: string +} + +export interface GitHubBindingStatus { + bound: boolean + provider?: string | null + login?: string | null +} + +export interface GitHubLoginDevicePollCompletedResponse { + status: 'completed' + token: string + user: AuthUser +} + +export interface GitHubBindDevicePollCompletedResponse { + status: 'completed' + binding: GitHubBindingStatus +} + +export type GitHubLoginDevicePollResponse = + | GitHubDevicePollPendingResponse + | GitHubLoginDevicePollCompletedResponse + +export type GitHubBindDevicePollResponse = + | GitHubDevicePollPendingResponse + | GitHubBindDevicePollCompletedResponse + +export type GitHubDeviceFlowPurpose = 'login' | 'bind' | null +export type GitHubDeviceFlowPhase = 'idle' | 'starting' | 'polling' | 'completed' | 'expired' | 'error' + +export interface GitHubBindingState { + loading: boolean + loaded: boolean + supported: boolean + bound: boolean + provider: string | null + login: string | null + errorMessage: string +} + +export interface GitHubDeviceFlowState { + purpose: GitHubDeviceFlowPurpose + phase: GitHubDeviceFlowPhase + deviceSessionId: string + userCode: string + verificationUri: string + verificationUriComplete: string + expiresAt: string + interval: number + retryAfterSeconds: number + errorMessage: string +} diff --git a/plugins/bad-bear/src/types/notification.ts b/plugins/bad-bear/src/types/notification.ts new file mode 100644 index 000000000..906819078 --- /dev/null +++ b/plugins/bad-bear/src/types/notification.ts @@ -0,0 +1,80 @@ +export type NotificationStatus = 'UNREAD' | 'READ' +export type NotificationFilter = 'ALL' | NotificationStatus + +export interface NotificationListQuery { + page?: number + pageSize?: number + status?: NotificationStatus +} + +export interface NotificationRecord { + id: string + type: string + status: NotificationStatus + title: string + message: string + metadata: Record | null + parentId?: string | null + createdAt: string + readAt: string | null +} + +export interface NotificationTreeNode extends NotificationRecord { + replies: NotificationTreeNode[] + depth: 0 | 1 +} + +export interface NotificationListResponse { + items: NotificationRecord[] + total: number + page: number + pageSize: number +} + +export interface MarkAllNotificationsReadResponse { + success?: boolean + message?: string +} + +export type NotificationStreamEventName = + | 'connected' + | 'notification.created' + | 'notification.read' + | 'notification.read-all' + +export interface NotificationStreamConnectedPayload { + message: string +} + +export interface NotificationStreamReadPayload { + id: string + status: 'READ' + readAt: string | null +} + +export interface NotificationStreamReadAllPayload { + updated: number + readAt: string | null +} + +export type NotificationStreamEvent = + | { + type: 'connected' + id: string | null + data: NotificationStreamConnectedPayload + } + | { + type: 'notification.created' + id: string | null + data: NotificationRecord + } + | { + type: 'notification.read' + id: string | null + data: NotificationStreamReadPayload + } + | { + type: 'notification.read-all' + id: string | null + data: NotificationStreamReadAllPayload + } diff --git a/plugins/bad-bear/src/types/pluginMarket.ts b/plugins/bad-bear/src/types/pluginMarket.ts new file mode 100644 index 000000000..9586be920 --- /dev/null +++ b/plugins/bad-bear/src/types/pluginMarket.ts @@ -0,0 +1,546 @@ +export type Platform = 'win32' | 'darwin' | 'linux' | string + +export interface PluginFeatureCommand { + type?: string + label?: string + [key: string]: unknown +} + +export interface PluginFeature { + code: string + name?: string + explain?: string + icon?: string + cmds?: Array + [key: string]: unknown +} + +export interface PluginMarketCategoryDto { + key: string + title: string + description?: string + icon?: string + list: string[] +} + +export interface PluginMarketPluginDto { + name: string + version: string + title?: string + description?: string + author?: string + main?: string + logo?: string + preload?: string + features?: PluginFeature[] + platform?: Platform[] + categories?: string[] + categoryFallback?: boolean + downloadUrl?: string + hash?: string | null + source?: PluginReleaseSource + uploaderUserId?: string | null + uploaderAccount?: string | null + uploaderUsername?: string | null + risk?: PluginMarketListRiskInfo | null +} + +export interface PluginMarketPluginReference { + name: string +} + +export interface PluginMarketListRiskSummary { + level?: string | null + summary?: string | null + filePath?: string | null + reasons?: string[] + warningsCn?: string[] +} + +export interface PluginMarketListRiskInfo { + riskLevel?: string | null + riskSummary?: PluginMarketListRiskSummary | null + warningsCn?: string[] + reviewDecision?: string | null +} + +export interface PluginMarketStreamSnapshot extends PluginMarketFetchResponse { + complete: boolean +} + +export interface StreamPluginMarketOptions { + platform: Platform + signal?: AbortSignal + onSnapshot?: (snapshot: PluginMarketStreamSnapshot) => void +} + + +export interface PluginMarketPlugin { + name: string + version: string + title?: string + description?: string + logo?: string + downloadUrl?: string + hash?: string + platform?: Platform[] + author?: string + homepage?: string + size?: number + totalDownloads?: number + avgRating?: number + ratingCount?: number + features?: PluginFeature[] + main?: string + preload?: string + categories?: string[] + categoryFallback?: boolean + source?: PluginReleaseSource + uploaderUserId?: string | null + uploaderAccount?: string | null + uploaderUsername?: string | null + risk?: PluginMarketListRiskInfo | null +} + +export interface InjectedPluginRecord { + name: string + path?: string + version?: string + [key: string]: unknown +} + +export interface InstalledPlugin { + name: string + path: string + version: string + hash?: string + title?: string + description?: string + logo?: string + features?: PluginFeature[] + author?: string + homepage?: string + size?: number + isDevelopment?: boolean + installedAt?: string + [key: string]: unknown +} + +export interface OperationResult { + success: boolean + error?: string +} + +export interface PluginMutationResult extends OperationResult { + plugin?: InstalledPlugin +} + +export interface PluginReadmeResponse extends OperationResult { + content?: string +} + +export interface HashFileResult extends OperationResult { + hash?: string + algorithm?: string +} + +export interface PluginUploadPayload { + file: Blob + fileName?: string +} + +export interface PluginUploadAcceptedResponse { + message: string + reviewTaskId: string +} + +export interface PluginUploadResponse extends OperationResult { + data?: PluginUploadAcceptedResponse | unknown + message?: string + reviewTaskId?: string +} + +export type PluginHashCheckStatus = 'blocked' | 'processing' | 'safe' | 'exists' + +export interface PluginHashCheckResponse { + status: PluginHashCheckStatus + pluginName?: string + version?: string +} + +export interface PluginUpdateCheckRequestItem { + name: string + hash: string +} + +export interface PluginUpdateCheckResponseItem { + name: string + latestHash: string +} + +export type MyPluginUploadStatus = + | 'AI_CLASSIFYING' + | 'AI_REVIEWING' + | 'MANUAL_REVIEW' + | 'PUBLISHED' + | 'PUBLISH_FAILED' + +export interface MyPluginUploadProgress { + code: MyPluginUploadStatus + label: string +} + +export interface MyPluginUploadRecord { + id: string + pluginName: string + version: string + originalName: string + fileSize: number + createdAt: string + status: MyPluginUploadStatus + progress?: MyPluginUploadProgress + downloads: number +} + +export interface MyPluginUploadsQuery extends PluginPageQuery { + keyword?: string +} + +export interface MyPluginUploadsResponse { + items: MyPluginUploadRecord[] + total: number + page: number + pageSize: number +} + +export interface PluginMarketBannerItem { + image: string + url?: string +} + +export interface StorefrontCategorySummary { + key: string + title: string + description?: string + icon?: string + showDescription: boolean + pluginCount: number +} + +export interface PluginMarketStorefrontCategory { + key: string + title: string + description?: string + icon?: string + plugins: PluginMarketPluginReference[] +} + +export interface StorefrontBannerSection { + type: 'banner' + key: string + items: PluginMarketBannerItem[] + height?: number +} + +export interface StorefrontNavigationSection { + type: 'navigation' + key: string + title?: string + categories: StorefrontCategorySummary[] +} + +export interface StorefrontPluginSection { + type: 'fixed' | 'random' + key: string + title?: string + plugins: PluginMarketPluginReference[] +} + +export type PluginMarketStorefrontSection = + | StorefrontBannerSection + | StorefrontNavigationSection + | StorefrontPluginSection + +export interface CategoryLayoutSection { + type: 'list' | 'fixed' | 'random' | string + title?: string + count?: number + plugins?: string[] +} + +export interface PluginMarketStorefront { + sections: PluginMarketStorefrontSection[] + categories: Record + categoryLayouts: Record +} + +export interface PluginMarketFetchResponse extends OperationResult { + data?: PluginMarketPlugin[] + storefront?: PluginMarketStorefront +} + +export interface PluginLaunchOptions { + path: string + type: 'plugin' + name: string + param: Record +} + +export interface PluginLaunchResult extends OperationResult {} + +export interface PluginMarketUiPlugin extends PluginMarketPlugin { + title: string + description: string + installed: boolean + path?: string + localVersion?: string + latestVersion?: string + localHash?: string + latestHash?: string + marketPlugin?: PluginMarketPlugin + hasUpdate?: boolean + isRunning?: boolean + isDevelopment?: boolean + installedAt?: string +} + +export interface InstalledViewPlugin extends PluginMarketUiPlugin { + installed: true + path: string + localVersion: string + latestVersion: string + localHash?: string + latestHash?: string + installedAt?: string +} + +export interface CategoryInfo { + key: string + title: string + description?: string + icon?: string + plugins: PluginMarketUiPlugin[] +} + +export type PluginReleaseSource = string | Record | null + +export interface PluginDetailReadme { + hash?: string | null + content?: string | null + source?: PluginReleaseSource + isAiGenerated?: boolean +} + +export interface PluginDetailVersion { + id: string + version: string + hash: string + fileSize: number + downloads: number + createdAt: string + source?: PluginReleaseSource + uploaderUserId?: string | null + uploaderAccount?: string | null + uploaderUsername?: string | null +} + +export interface ResolvedPluginDownloadTarget { + version: string + hash: string | null + downloadMode: 'latest' | 'hash' + downloadUrl: string + build: PluginDetailVersion | null + plugin: PluginMarketPlugin +} + +export interface PluginDetailResponse { + id: string + name: string + title?: string + description?: string + author?: string + logo?: string + features?: PluginFeature[] + version?: string + main?: string + preload?: string + categories: string[] + categoryFallback: boolean + avgRating: number + ratingCount: number + totalDownloads: number + source?: PluginReleaseSource + readme?: PluginDetailReadme | null + versions: PluginDetailVersion[] + createdAt: string + updatedAt: string +} + +export interface PluginRiskSummary { + level?: string | null + summary?: string | null + filePath?: string | null + reasons?: string[] +} + +export interface PluginRiskInfo { + pluginName: string + version: string + riskLevel: string + riskSummary: PluginRiskSummary | null + reviewDecision?: string | null + updatedAt: string +} + +export interface PluginStatsResponse { + totalDownloads?: number + [key: string]: unknown +} + +export interface PluginPageQuery { + page?: number + pageSize?: number +} + +export interface PluginInteractionUser { + id: string + account: string + username: string + avatarUrl: string | null +} + +export interface CreatePluginRatingRequest { + score: number +} + +export interface PluginRatingRecord { + id: string + score: number + user: PluginInteractionUser + createdAt: string + updatedAt: string +} + +export interface PluginRatingsPage { + items: PluginRatingRecord[] + avgRating: number + total: number + page: number + pageSize: number +} + +export interface CreatePluginCommentRequest { + content: string + parentId?: string +} + +export interface PluginCommentRecord { + id: string + content: string + parentId: string | null + user: PluginInteractionUser + createdAt: string + updatedAt: string + replies?: PluginCommentRecord[] +} + +export interface PluginCommentsPage { + items: PluginCommentRecord[] + total: number + page: number + pageSize: number +} + +export interface PluginCommentTreeNode extends PluginCommentRecord { + replies: PluginCommentTreeNode[] +} + +export interface UiStorefrontBannerSection { + type: 'banner' + key: string + items: PluginMarketBannerItem[] + height?: number +} + +export interface UiStorefrontNavigationSection { + type: 'navigation' + key: string + title?: string + categories: StorefrontCategorySummary[] +} + +export interface UiStorefrontPluginSection { + type: 'fixed' | 'random' + key: string + title?: string + plugins: PluginMarketUiPlugin[] +} + +export type PluginMarketSectionModel = + | UiStorefrontBannerSection + | UiStorefrontNavigationSection + | UiStorefrontPluginSection + +export interface CategorySectionModel { + key: string + type: 'list' | 'fixed' | 'random' + title?: string + plugins: PluginMarketUiPlugin[] +} + +// 分片上传相关类型定义 + +export interface ChunkedUploadInitRequest { + fileName: string + totalSize: number + totalChunks: number + fileHash?: string +} + +export interface ChunkedUploadInitResponse { + uploadId: string + existingChunks: number[] + fileExists: boolean + existingPlugin?: { + pluginName: string + version: string + } +} + +export interface ChunkedUploadChunkPayload { + uploadId: string + chunk: Blob + chunkIndex: number + chunkHash: string +} + +export interface ChunkedUploadChunkResponse { + chunkIndex: number + success: boolean +} + +export type ChunkedUploadStatus = + | 'UPLOADING' + | 'COMPLETED' + | 'MERGING' + | 'MERGED' + | 'FAILED' + | 'CANCELLED' + +export interface ChunkedUploadProgressResponse { + uploadId: string + fileName: string + totalChunks: number + uploadedChunks: number + status: ChunkedUploadStatus + progress: number +} + +export interface ChunkedUploadCompleteResponse { + message: string + reviewTaskId: string +} + +export interface ChunkedUploadCancelResponse { + message: string +} diff --git a/plugins/bad-bear/src/types/runtimeConfig.ts b/plugins/bad-bear/src/types/runtimeConfig.ts new file mode 100644 index 000000000..443e82aef --- /dev/null +++ b/plugins/bad-bear/src/types/runtimeConfig.ts @@ -0,0 +1,7 @@ +import type { AuthUser } from './auth' + +export interface ShopApiRuntimeConfig { + baseUrl: string + token: string + currentUser: AuthUser | null +} diff --git a/plugins/bad-bear/src/vue-router-shim.ts b/plugins/bad-bear/src/vue-router-shim.ts new file mode 100644 index 000000000..ca6c2e2e8 --- /dev/null +++ b/plugins/bad-bear/src/vue-router-shim.ts @@ -0,0 +1,7 @@ +export function useRoute() { + return {} +} + +export function onBeforeRouteUpdate() { + return () => {} +} diff --git a/plugins/bad-bear/tsconfig.json b/plugins/bad-bear/tsconfig.json new file mode 100644 index 000000000..5e42a27d9 --- /dev/null +++ b/plugins/bad-bear/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": false, + "noImplicitAny": false, + "types": ["@ztools-center/ztools-api-types"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"] +} diff --git a/plugins/bad-bear/uno.config.ts b/plugins/bad-bear/uno.config.ts new file mode 100644 index 000000000..a8155c349 --- /dev/null +++ b/plugins/bad-bear/uno.config.ts @@ -0,0 +1,50 @@ +import { defineConfig, presetAttributify, presetTypography, presetWind3 } from 'unocss' +import presetIcons from '@unocss/preset-icons' +import { FileSystemIconLoader } from '@iconify/utils/lib/loader/node-loaders' +import { fileURLToPath, URL } from 'node:url' + +import transformerDirectives from '@unocss/transformer-directives' + +export default defineConfig({ + // 扫描文件配置 + content: { + filesystem: ['src/**/*.{vue,js,ts}'], + }, + presets: [ + // 基础预设 - 提供所有基础工具类(包含 Tailwind CSS 兼容的类名) + presetWind3(), + + // 属性化预设 - 允许在 HTML 属性中使用工具类 + presetAttributify(), + + // 排版预设 - 提供排版相关的工具类 + presetTypography(), + + // 图标预设 + presetIcons({ + collections: { + // 自定义图标集合 + z: FileSystemIconLoader( + fileURLToPath(new URL('./src/assets/icons', import.meta.url)), + (svg: any) => { + return svg + .replace(/({for(const i of r)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&s(o)}).observe(document,{childList:!0,subtree:!0});function n(r){const i={};return r.integrity&&(i.integrity=r.integrity),r.referrerPolicy&&(i.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?i.credentials="include":r.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function s(r){if(r.ep)return;r.ep=!0;const i=n(r);fetch(r.href,i)}})();/** +* @vue/shared v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function ar(e){const t=Object.create(null);for(const n of e.split(","))t[n]=1;return n=>n in t}const le={},Xt=[],at=()=>{},Ji=()=>!1,ls=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),as=e=>e.startsWith("onUpdate:"),ve=Object.assign,cr=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},Kl=Object.prototype.hasOwnProperty,X=(e,t)=>Kl.call(e,t),V=Array.isArray,en=e=>Mn(e)==="[object Map]",Yi=e=>Mn(e)==="[object Set]",jr=e=>Mn(e)==="[object Date]",F=e=>typeof e=="function",fe=e=>typeof e=="string",We=e=>typeof e=="symbol",ee=e=>e!==null&&typeof e=="object",Xi=e=>(ee(e)||F(e))&&F(e.then)&&F(e.catch),eo=Object.prototype.toString,Mn=e=>eo.call(e),Gl=e=>Mn(e).slice(8,-1),to=e=>Mn(e)==="[object Object]",ur=e=>fe(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,yn=ar(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),cs=e=>{const t=Object.create(null);return(n=>t[n]||(t[n]=e(n)))},ql=/-\w/g,Ge=cs(e=>e.replace(ql,t=>t.slice(1).toUpperCase())),Wl=/\B([A-Z])/g,Lt=cs(e=>e.replace(Wl,"-$1").toLowerCase()),no=cs(e=>e.charAt(0).toUpperCase()+e.slice(1)),ks=cs(e=>e?`on${no(e)}`:""),lt=(e,t)=>!Object.is(e,t),Un=(e,...t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:s,value:n})},fr=e=>{const t=parseFloat(e);return isNaN(t)?e:t},Zl=e=>{const t=fe(e)?Number(e):NaN;return isNaN(t)?e:t};let Ur;const us=()=>Ur||(Ur=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function hr(e){if(V(e)){const t={};for(let n=0;n{if(n){const s=n.split(Jl);s.length>1&&(t[s[0].trim()]=s[1].trim())}}),t}function an(e){let t="";if(fe(e))t=e;else if(V(e))for(let n=0;n!!(e&&e.__v_isRef===!0),Pt=e=>fe(e)?e:e==null?"":V(e)||ee(e)&&(e.toString===eo||!F(e.toString))?io(e)?Pt(e.value):JSON.stringify(e,oo,2):String(e),oo=(e,t)=>io(t)?oo(e,t.value):en(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((n,[s,r],i)=>(n[Cs(s,i)+" =>"]=r,n),{})}:Yi(t)?{[`Set(${t.size})`]:[...t.values()].map(n=>Cs(n))}:We(t)?Cs(t):ee(t)&&!V(t)&&!to(t)?String(t):t,Cs=(e,t="")=>{var n;return We(e)?`Symbol(${(n=e.description)!=null?n:t})`:e};/** +* @vue/reactivity v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let ke;class sa{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&ke&&(ke.active?(this.parent=ke,this.index=(ke.scopes||(ke.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,n;if(this.scopes)for(t=0,n=this.scopes.length;t0&&--this._on===0){if(ke===this)ke=this.prevScope;else{let t=ke;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let n,s;for(n=0,s=this.effects.length;n0)return;if(xn){let t=xn;for(xn=void 0;t;){const n=t.next;t.next=void 0,t.flags&=-9,t=n}}let e;for(;vn;){let t=vn;for(vn=void 0;t;){const n=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(s){e||(e=s)}t=n}}if(e)throw e}function uo(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function fo(e){let t,n=e.depsTail,s=n;for(;s;){const r=s.prevDep;s.version===-1?(s===n&&(n=r),mr(s),ia(s)):t=s,s.dep.activeLink=s.prevActiveLink,s.prevActiveLink=void 0,s=r}e.deps=t,e.depsTail=n}function Hs(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(ho(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function ho(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===En)||(e.globalVersion=En,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Hs(e))))return;e.flags|=2;const t=e.dep,n=ae,s=qe;ae=e,qe=!0;try{uo(e);const r=e.fn(e._value);(t.version===0||lt(r,e._value))&&(e.flags|=128,e._value=r,t.version++)}catch(r){throw t.version++,r}finally{ae=n,qe=s,fo(e),e.flags&=-3}}function mr(e,t=!1){const{dep:n,prevSub:s,nextSub:r}=e;if(s&&(s.nextSub=r,e.prevSub=void 0),r&&(r.prevSub=s,e.nextSub=void 0),n.subs===e&&(n.subs=s,!s&&n.computed)){n.computed.flags&=-5;for(let i=n.computed.deps;i;i=i.nextDep)mr(i,!0)}!t&&!--n.sc&&n.map&&n.map.delete(n.key)}function ia(e){const{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}let qe=!0;const po=[];function ct(){po.push(qe),qe=!1}function ut(){const e=po.pop();qe=e===void 0?!0:e}function Kr(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const n=ae;ae=void 0;try{t()}finally{ae=n}}}let En=0;class oa{constructor(t,n){this.sub=t,this.dep=n,this.version=n.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class br{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!ae||!qe||ae===this.computed)return;let n=this.activeLink;if(n===void 0||n.sub!==ae)n=this.activeLink=new oa(ae,this),ae.deps?(n.prevDep=ae.depsTail,ae.depsTail.nextDep=n,ae.depsTail=n):ae.deps=ae.depsTail=n,go(n);else if(n.version===-1&&(n.version=this.version,n.nextDep)){const s=n.nextDep;s.prevDep=n.prevDep,n.prevDep&&(n.prevDep.nextDep=s),n.prevDep=ae.depsTail,n.nextDep=void 0,ae.depsTail.nextDep=n,ae.depsTail=n,ae.deps===n&&(ae.deps=s)}return n}trigger(t){this.version++,En++,this.notify(t)}notify(t){dr();try{for(let n=this.subs;n;n=n.prevSub)n.sub.notify()&&n.sub.dep.notify()}finally{gr()}}}function go(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let s=t.deps;s;s=s.nextDep)go(s)}const n=e.dep.subs;n!==e&&(e.prevSub=n,n&&(n.nextSub=e)),e.dep.subs=e}}const Fs=new WeakMap,jt=Symbol(""),js=Symbol(""),An=Symbol("");function Se(e,t,n){if(qe&&ae){let s=Fs.get(e);s||Fs.set(e,s=new Map);let r=s.get(n);r||(s.set(n,r=new br),r.map=s,r.key=n),r.track()}}function bt(e,t,n,s,r,i){const o=Fs.get(e);if(!o){En++;return}const l=a=>{a&&a.trigger()};if(dr(),t==="clear")o.forEach(l);else{const a=V(e),u=a&&ur(n);if(a&&n==="length"){const c=Number(s);o.forEach((f,p)=>{(p==="length"||p===An||!We(p)&&p>=c)&&l(f)})}else switch((n!==void 0||o.has(void 0))&&l(o.get(n)),u&&l(o.get(An)),t){case"add":a?u&&l(o.get("length")):(l(o.get(jt)),en(e)&&l(o.get(js)));break;case"delete":a||(l(o.get(jt)),en(e)&&l(o.get(js)));break;case"set":en(e)&&l(o.get(jt));break}}gr()}function Zt(e){const t=Z(e);return t===e?t:(Se(t,"iterate",An),Ue(e)?t:t.map(Ze))}function fs(e){return Se(e=Z(e),"iterate",An),e}function rt(e,t){return wt(e)?rn(Ut(e)?Ze(t):t):Ze(t)}const la={__proto__:null,[Symbol.iterator](){return Es(this,Symbol.iterator,e=>rt(this,e))},concat(...e){return Zt(this).concat(...e.map(t=>V(t)?Zt(t):t))},entries(){return Es(this,"entries",e=>(e[1]=rt(this,e[1]),e))},every(e,t){return ht(this,"every",e,t,void 0,arguments)},filter(e,t){return ht(this,"filter",e,t,n=>n.map(s=>rt(this,s)),arguments)},find(e,t){return ht(this,"find",e,t,n=>rt(this,n),arguments)},findIndex(e,t){return ht(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return ht(this,"findLast",e,t,n=>rt(this,n),arguments)},findLastIndex(e,t){return ht(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return ht(this,"forEach",e,t,void 0,arguments)},includes(...e){return As(this,"includes",e)},indexOf(...e){return As(this,"indexOf",e)},join(e){return Zt(this).join(e)},lastIndexOf(...e){return As(this,"lastIndexOf",e)},map(e,t){return ht(this,"map",e,t,void 0,arguments)},pop(){return un(this,"pop")},push(...e){return un(this,"push",e)},reduce(e,...t){return Gr(this,"reduce",e,t)},reduceRight(e,...t){return Gr(this,"reduceRight",e,t)},shift(){return un(this,"shift")},some(e,t){return ht(this,"some",e,t,void 0,arguments)},splice(...e){return un(this,"splice",e)},toReversed(){return Zt(this).toReversed()},toSorted(e){return Zt(this).toSorted(e)},toSpliced(...e){return Zt(this).toSpliced(...e)},unshift(...e){return un(this,"unshift",e)},values(){return Es(this,"values",e=>rt(this,e))}};function Es(e,t,n){const s=fs(e),r=s[t]();return s!==e&&!Ue(e)&&(r._next=r.next,r.next=()=>{const i=r._next();return i.done||(i.value=n(i.value)),i}),r}const aa=Array.prototype;function ht(e,t,n,s,r,i){const o=fs(e),l=o!==e&&!Ue(e),a=o[t];if(a!==aa[t]){const f=a.apply(e,i);return l?Ze(f):f}let u=n;o!==e&&(l?u=function(f,p){return n.call(this,rt(e,f),p,e)}:n.length>2&&(u=function(f,p){return n.call(this,f,p,e)}));const c=a.call(o,u,s);return l&&r?r(c):c}function Gr(e,t,n,s){const r=fs(e),i=r!==e&&!Ue(e);let o=n,l=!1;r!==e&&(i?(l=s.length===0,o=function(u,c,f){return l&&(l=!1,u=rt(e,u)),n.call(this,u,rt(e,c),f,e)}):n.length>3&&(o=function(u,c,f){return n.call(this,u,c,f,e)}));const a=r[t](o,...s);return l?rt(e,a):a}function As(e,t,n){const s=Z(e);Se(s,"iterate",An);const r=s[t](...n);return(r===-1||r===!1)&&xr(n[0])?(n[0]=Z(n[0]),s[t](...n)):r}function un(e,t,n=[]){ct(),dr();const s=Z(e)[t].apply(e,n);return gr(),ut(),s}const ca=ar("__proto__,__v_isRef,__isVue"),mo=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(We));function ua(e){We(e)||(e=String(e));const t=Z(this);return Se(t,"has",e),t.hasOwnProperty(e)}class bo{constructor(t=!1,n=!1){this._isReadonly=t,this._isShallow=n}get(t,n,s){if(n==="__v_skip")return t.__v_skip;const r=this._isReadonly,i=this._isShallow;if(n==="__v_isReactive")return!r;if(n==="__v_isReadonly")return r;if(n==="__v_isShallow")return i;if(n==="__v_raw")return s===(r?i?xa:_o:i?xo:vo).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(s)?t:void 0;const o=V(t);if(!r){let a;if(o&&(a=la[n]))return a;if(n==="hasOwnProperty")return ua}const l=Reflect.get(t,n,Re(t)?t:s);if((We(n)?mo.has(n):ca(n))||(r||Se(t,"get",n),i))return l;if(Re(l)){const a=o&&ur(n)?l:l.value;return r&&ee(a)?Ks(a):a}return ee(l)?r?Ks(l):hs(l):l}}class yo extends bo{constructor(t=!1){super(!1,t)}set(t,n,s,r){let i=t[n];const o=V(t)&&ur(n);if(!this._isShallow){const u=wt(i);if(!Ue(s)&&!wt(s)&&(i=Z(i),s=Z(s)),!o&&Re(i)&&!Re(s))return u||(i.value=s),!0}const l=o?Number(n)e,Vn=e=>Reflect.getPrototypeOf(e);function ga(e,t,n){return function(...s){const r=this.__v_raw,i=Z(r),o=en(i),l=e==="entries"||e===Symbol.iterator&&o,a=e==="keys"&&o,u=r[e](...s),c=n?Us:t?rn:Ze;return!t&&Se(i,"iterate",a?js:jt),ve(Object.create(u),{next(){const{value:f,done:p}=u.next();return p?{value:f,done:p}:{value:l?[c(f[0]),c(f[1])]:c(f),done:p}}})}}function Hn(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function ma(e,t){const n={get(r){const i=this.__v_raw,o=Z(i),l=Z(r);e||(lt(r,l)&&Se(o,"get",r),Se(o,"get",l));const{has:a}=Vn(o),u=t?Us:e?rn:Ze;if(a.call(o,r))return u(i.get(r));if(a.call(o,l))return u(i.get(l));i!==o&&i.get(r)},get size(){const r=this.__v_raw;return!e&&Se(Z(r),"iterate",jt),r.size},has(r){const i=this.__v_raw,o=Z(i),l=Z(r);return e||(lt(r,l)&&Se(o,"has",r),Se(o,"has",l)),r===l?i.has(r):i.has(r)||i.has(l)},forEach(r,i){const o=this,l=o.__v_raw,a=Z(l),u=t?Us:e?rn:Ze;return!e&&Se(a,"iterate",jt),l.forEach((c,f)=>r.call(i,u(c),u(f),o))}};return ve(n,e?{add:Hn("add"),set:Hn("set"),delete:Hn("delete"),clear:Hn("clear")}:{add(r){const i=Z(this),o=Vn(i),l=Z(r),a=!t&&!Ue(r)&&!wt(r)?l:r;return o.has.call(i,a)||lt(r,a)&&o.has.call(i,r)||lt(l,a)&&o.has.call(i,l)||(i.add(a),bt(i,"add",a,a)),this},set(r,i){!t&&!Ue(i)&&!wt(i)&&(i=Z(i));const o=Z(this),{has:l,get:a}=Vn(o);let u=l.call(o,r);u||(r=Z(r),u=l.call(o,r));const c=a.call(o,r);return o.set(r,i),u?lt(i,c)&&bt(o,"set",r,i):bt(o,"add",r,i),this},delete(r){const i=Z(this),{has:o,get:l}=Vn(i);let a=o.call(i,r);a||(r=Z(r),a=o.call(i,r)),l&&l.call(i,r);const u=i.delete(r);return a&&bt(i,"delete",r,void 0),u},clear(){const r=Z(this),i=r.size!==0,o=r.clear();return i&&bt(r,"clear",void 0,void 0),o}}),["keys","values","entries",Symbol.iterator].forEach(r=>{n[r]=ga(r,e,t)}),n}function yr(e,t){const n=ma(e,t);return(s,r,i)=>r==="__v_isReactive"?!e:r==="__v_isReadonly"?e:r==="__v_raw"?s:Reflect.get(X(n,r)&&r in s?n:s,r,i)}const ba={get:yr(!1,!1)},ya={get:yr(!1,!0)},va={get:yr(!0,!1)};const vo=new WeakMap,xo=new WeakMap,_o=new WeakMap,xa=new WeakMap;function _a(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function hs(e){return wt(e)?e:vr(e,!1,ha,ba,vo)}function wo(e){return vr(e,!1,da,ya,xo)}function Ks(e){return vr(e,!0,pa,va,_o)}function vr(e,t,n,s,r){if(!ee(e)||e.__v_raw&&!(t&&e.__v_isReactive)||e.__v_skip||!Object.isExtensible(e))return e;const i=r.get(e);if(i)return i;const o=_a(Gl(e));if(o===0)return e;const l=new Proxy(e,o===2?s:n);return r.set(e,l),l}function Ut(e){return wt(e)?Ut(e.__v_raw):!!(e&&e.__v_isReactive)}function wt(e){return!!(e&&e.__v_isReadonly)}function Ue(e){return!!(e&&e.__v_isShallow)}function xr(e){return e?!!e.__v_raw:!1}function Z(e){const t=e&&e.__v_raw;return t?Z(t):e}function wa(e){return!X(e,"__v_skip")&&Object.isExtensible(e)&&so(e,"__v_skip",!0),e}const Ze=e=>ee(e)?hs(e):e,rn=e=>ee(e)?Ks(e):e;function Re(e){return e?e.__v_isRef===!0:!1}function De(e){return ko(e,!1)}function ka(e){return ko(e,!0)}function ko(e,t){return Re(e)?e:new Ca(e,t)}class Ca{constructor(t,n){this.dep=new br,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=n?t:Z(t),this._value=n?t:Ze(t),this.__v_isShallow=n}get value(){return this.dep.track(),this._value}set value(t){const n=this._rawValue,s=this.__v_isShallow||Ue(t)||wt(t);t=s?t:Z(t),lt(t,n)&&(this._rawValue=t,this._value=s?t:Ze(t),this.dep.trigger())}}function It(e){return Re(e)?e.value:e}const Sa={get:(e,t,n)=>t==="__v_raw"?e:It(Reflect.get(e,t,n)),set:(e,t,n,s)=>{const r=e[t];return Re(r)&&!Re(n)?(r.value=n,!0):Reflect.set(e,t,n,s)}};function Co(e){return Ut(e)?e:new Proxy(e,Sa)}class Ea{constructor(t,n,s){this.fn=t,this.setter=n,this._value=void 0,this.dep=new br(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=En-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!n,this.isSSR=s}notify(){if(this.flags|=16,!(this.flags&8)&&ae!==this)return co(this,!0),!0}get value(){const t=this.dep.track();return ho(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function Aa(e,t,n=!1){let s,r;return F(e)?s=e:(s=e.get,r=e.set),new Ea(s,r,n)}const Fn={},Zn=new WeakMap;let Vt;function Ra(e,t=!1,n=Vt){if(n){let s=Zn.get(n);s||Zn.set(n,s=[]),s.push(e)}}function Ta(e,t,n=le){const{immediate:s,deep:r,once:i,scheduler:o,augmentJob:l,call:a}=n,u=N=>r?N:Ue(N)||r===!1||r===0?yt(N,1):yt(N);let c,f,p,g,b=!1,m=!1;if(Re(e)?(f=()=>e.value,b=Ue(e)):Ut(e)?(f=()=>u(e),b=!0):V(e)?(m=!0,b=e.some(N=>Ut(N)||Ue(N)),f=()=>e.map(N=>{if(Re(N))return N.value;if(Ut(N))return u(N);if(F(N))return a?a(N,2):N()})):F(e)?t?f=a?()=>a(e,2):e:f=()=>{if(p){ct();try{p()}finally{ut()}}const N=Vt;Vt=c;try{return a?a(e,3,[g]):e(g)}finally{Vt=N}}:f=at,t&&r){const N=f,j=r===!0?1/0:r;f=()=>yt(N(),j)}const v=ra(),x=()=>{c.stop(),v&&v.active&&cr(v.effects,c)};if(i&&t){const N=t;t=(...j)=>{const K=N(...j);return x(),K}}let y=m?new Array(e.length).fill(Fn):Fn;const T=N=>{if(!(!(c.flags&1)||!c.dirty&&!N))if(t){const j=c.run();if(N||r||b||(m?j.some((K,U)=>lt(K,y[U])):lt(j,y))){p&&p();const K=Vt;Vt=c;try{const U=[j,y===Fn?void 0:m&&y[0]===Fn?[]:y,g];y=j,a?a(t,3,U):t(...U)}finally{Vt=K}}}else c.run()};return l&&l(T),c=new lo(f),c.scheduler=o?()=>o(T,!1):T,g=N=>Ra(N,!1,c),p=c.onStop=()=>{const N=Zn.get(c);if(N){if(a)a(N,4);else for(const j of N)j();Zn.delete(c)}},t?s?T(!0):y=c.run():o?o(T.bind(null,!0),!0):c.run(),x.pause=c.pause.bind(c),x.resume=c.resume.bind(c),x.stop=x,x}function yt(e,t=1/0,n){if(t<=0||!ee(e)||e.__v_skip||(n=n||new Map,(n.get(e)||0)>=t))return e;if(n.set(e,t),t--,Re(e))yt(e.value,t,n);else if(V(e))for(let s=0;s{yt(s,t,n)});else if(to(e)){for(const s in e)yt(e[s],t,n);for(const s of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,s)&&yt(e[s],t,n)}return e}/** +* @vue/runtime-core v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function $n(e,t,n,s){try{return s?e(...s):e()}catch(r){ps(r,t,n)}}function Ke(e,t,n,s){if(F(e)){const r=$n(e,t,n,s);return r&&Xi(r)&&r.catch(i=>{ps(i,t,n)}),r}if(V(e)){const r=[];for(let i=0;i>>1,r=Ie[s],i=Rn(r);i=Rn(n)?Ie.push(e):Ie.splice(Ia(t),0,e),e.flags|=1,Ao()}}function Ao(){Qn||(Qn=So.then(To))}function Oa(e){V(e)?tn.push(...e):Rt&&e.id===-1?Rt.splice(Qt+1,0,e):e.flags&1||(tn.push(e),e.flags|=1),Ao()}function qr(e,t,n=st+1){for(;nRn(n)-Rn(s));if(tn.length=0,Rt){Rt.push(...t);return}for(Rt=t,Qt=0;Qte.id==null?e.flags&2?-1:1/0:e.id;function To(e){try{for(st=0;st{s._d&&es(-1);const i=Jn(t);let o;try{o=e(...r)}finally{Jn(i),s._d&&es(1)}return o};return s._n=!0,s._c=!0,s._d=!0,s}function Na(e,t){if(Ae===null)return e;const n=vs(Ae),s=e.dirs||(e.dirs=[]);for(let r=0;r1)return n&&F(t)?t.call(s&&s.proxy):t}}const La=Symbol.for("v-scx"),Ma=()=>_t(La);function Ot(e,t,n){return Oo(e,t,n)}function Oo(e,t,n=le){const{immediate:s,deep:r,flush:i,once:o}=n,l=ve({},n),a=t&&s||!t&&i!=="post";let u;if(On){if(i==="sync"){const g=Ma();u=g.__watcherHandles||(g.__watcherHandles=[])}else if(!a){const g=()=>{};return g.stop=at,g.resume=at,g.pause=at,g}}const c=Oe;l.call=(g,b,m)=>Ke(g,c,b,m);let f=!1;i==="post"?l.scheduler=g=>{Me(g,c&&c.suspense)}:i!=="sync"&&(f=!0,l.scheduler=(g,b)=>{b?g():_r(g)}),l.augmentJob=g=>{t&&(g.flags|=4),f&&(g.flags|=2,c&&(g.id=c.uid,g.i=c))};const p=Ta(e,t,l);return On&&(u?u.push(p):a&&p()),p}function $a(e,t,n){const s=this.proxy,r=fe(e)?e.includes(".")?No(s,e):()=>s[e]:e.bind(s,s);let i;F(t)?i=t:(i=t.handler,n=t);const o=Dn(this),l=Oo(r,i.bind(s),n);return o(),l}function No(e,t){const n=t.split(".");return()=>{let s=e;for(let r=0;re.__isTeleport,je=Symbol("_leaveCb"),fn=Symbol("_enterCb");function Ba(){const e={isMounted:!1,isLeaving:!1,isUnmounting:!1,leavingVNodes:new Map};return wr(()=>{e.isMounted=!0}),kr(()=>{e.isUnmounting=!0}),e}const Fe=[Function,Array],Mo={mode:String,appear:Boolean,persisted:Boolean,onBeforeEnter:Fe,onEnter:Fe,onAfterEnter:Fe,onEnterCancelled:Fe,onBeforeLeave:Fe,onLeave:Fe,onAfterLeave:Fe,onLeaveCancelled:Fe,onBeforeAppear:Fe,onAppear:Fe,onAfterAppear:Fe,onAppearCancelled:Fe},$o=e=>{const t=e.subTree;return t.component?$o(t.component):t},za={name:"BaseTransition",props:Mo,setup(e,{slots:t}){const n=Ar(),s=Ba();return()=>{const r=t.default&&zo(t.default(),!0),i=r&&r.length?Do(r):n.subTree?it():void 0;if(!i)return;const o=Z(e),{mode:l}=o;if(s.isLeaving)return Rs(i);const a=Wr(i);if(!a)return Rs(i);let u=Gs(a,o,s,n,f=>u=f);a.type!==Ee&&Tn(a,u);let c=n.subTree&&Wr(n.subTree);if(c&&c.type!==Ee&&!Ht(c,a)&&$o(n).type!==Ee){let f=Gs(c,o,s,n);if(Tn(c,f),l==="out-in"&&a.type!==Ee)return s.isLeaving=!0,f.afterLeave=()=>{s.isLeaving=!1,n.job.flags&8||n.update(),delete f.afterLeave,c=void 0},Rs(i);l==="in-out"&&a.type!==Ee?f.delayLeave=(p,g,b)=>{const m=Bo(s,c);m[String(c.key)]=c,p[je]=()=>{g(),p[je]=void 0,delete u.delayedLeave,c=void 0},u.delayedLeave=()=>{b(),delete u.delayedLeave,c=void 0}}:c=void 0}else c&&(c=void 0);return i}}};function Do(e){let t=e[0];if(e.length>1){for(const n of e)if(n.type!==Ee){t=n;break}}return t}const Va=za;function Bo(e,t){const{leavingVNodes:n}=e;let s=n.get(t.type);return s||(s=Object.create(null),n.set(t.type,s)),s}function Gs(e,t,n,s,r){const{appear:i,mode:o,persisted:l=!1,onBeforeEnter:a,onEnter:u,onAfterEnter:c,onEnterCancelled:f,onBeforeLeave:p,onLeave:g,onAfterLeave:b,onLeaveCancelled:m,onBeforeAppear:v,onAppear:x,onAfterAppear:y,onAppearCancelled:T}=t,N=String(e.key),j=Bo(n,e),K=(P,G)=>{P&&Ke(P,s,9,G)},U=(P,G)=>{const ne=G[1];K(P,G),V(P)?P.every(M=>M.length<=1)&&ne():P.length<=1&&ne()},me={mode:o,persisted:l,beforeEnter(P){let G=a;if(!n.isMounted)if(i)G=v||a;else return;P[je]&&P[je](!0);const ne=j[N];ne&&Ht(e,ne)&&ne.el[je]&&ne.el[je](),K(G,[P])},enter(P){if(j[N]===e)return;let G=u,ne=c,M=f;if(!n.isMounted)if(i)G=x||u,ne=y||c,M=T||f;else return;let Q=!1;P[fn]=Be=>{Q||(Q=!0,Be?K(M,[P]):K(ne,[P]),me.delayedLeave&&me.delayedLeave(),P[fn]=void 0)};const xe=P[fn].bind(null,!1);G?U(G,[P,xe]):xe()},leave(P,G){const ne=String(e.key);if(P[fn]&&P[fn](!0),n.isUnmounting)return G();K(p,[P]);let M=!1;P[je]=xe=>{M||(M=!0,G(),xe?K(m,[P]):K(b,[P]),P[je]=void 0,j[ne]===e&&delete j[ne])};const Q=P[je].bind(null,!1);j[ne]=e,g?U(g,[P,Q]):Q()},clone(P){const G=Gs(P,t,n,s,r);return r&&r(G),G}};return me}function Rs(e){if(gs(e))return e=Nt(e),e.children=null,e}function Wr(e){if(!gs(e))return Lo(e.type)&&e.children?Do(e.children):e;if(e.component)return e.component.subTree;const{shapeFlag:t,children:n}=e;if(n){if(t&16)return n[0];if(t&32&&F(n.default))return n.default()}}function Tn(e,t){e.shapeFlag&6&&e.component?(e.transition=t,Tn(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function zo(e,t=!1,n){let s=[],r=0;for(let i=0;i1)for(let i=0;i_n(m,t&&(V(t)?t[v]:t),n,s,r));return}if(nn(s)&&!r){s.shapeFlag&512&&s.type.__asyncResolved&&s.component.subTree.component&&_n(e,t,n,s.component.subTree);return}const i=s.shapeFlag&4?vs(s.component):s.el,o=r?null:i,{i:l,r:a}=e,u=t&&t.r,c=l.refs===le?l.refs={}:l.refs,f=l.setupState,p=Z(f),g=f===le?Ji:m=>Zr(c,m)?!1:X(p,m),b=(m,v)=>!(v&&Zr(c,v));if(u!=null&&u!==a){if(Qr(t),fe(u))c[u]=null,g(u)&&(f[u]=null);else if(Re(u)){const m=t;b(u,m.k)&&(u.value=null),m.k&&(c[m.k]=null)}}if(F(a)){ct();try{$n(a,l,12,[o,c])}finally{ut()}}else{const m=fe(a),v=Re(a);if(m||v){const x=()=>{if(e.f){const y=m?g(a)?f[a]:c[a]:b()||!e.k?a.value:c[e.k];if(r)V(y)&&cr(y,i);else if(V(y))y.includes(i)||y.push(i);else if(m)c[a]=[i],g(a)&&(f[a]=c[a]);else{const T=[i];b(a,e.k)&&(a.value=T),e.k&&(c[e.k]=T)}}else m?(c[a]=o,g(a)&&(f[a]=o)):v&&(b(a,e.k)&&(a.value=o),e.k&&(c[e.k]=o))};if(o){const y=()=>{x(),Yn.delete(e)};y.id=-1,Yn.set(e,y),Me(y,n)}else Qr(e),x()}}}function Qr(e){const t=Yn.get(e);t&&(t.flags|=8,Yn.delete(e))}us().requestIdleCallback;us().cancelIdleCallback;const nn=e=>!!e.type.__asyncLoader,gs=e=>e.type.__isKeepAlive;function Ha(e,t){Ho(e,"a",t)}function Fa(e,t){Ho(e,"da",t)}function Ho(e,t,n=Oe){const s=e.__wdc||(e.__wdc=()=>{let r=n;for(;r;){if(r.isDeactivated)return;r=r.parent}return e()});if(ms(t,s,n),n){let r=n.parent;for(;r&&r.parent;)gs(r.parent.vnode)&&ja(s,t,n,r),r=r.parent}}function ja(e,t,n,s){const r=ms(t,e,s,!0);Cr(()=>{cr(s[t],r)},n)}function ms(e,t,n=Oe,s=!1){if(n){const r=n[e]||(n[e]=[]),i=t.__weh||(t.__weh=(...o)=>{ct();const l=Dn(n),a=Ke(t,n,e,o);return l(),ut(),a});return s?r.unshift(i):r.push(i),i}}const kt=e=>(t,n=Oe)=>{(!On||e==="sp")&&ms(e,(...s)=>t(...s),n)},Ua=kt("bm"),wr=kt("m"),Ka=kt("bu"),Ga=kt("u"),kr=kt("bum"),Cr=kt("um"),qa=kt("sp"),Wa=kt("rtg"),Za=kt("rtc");function Qa(e,t=Oe){ms("ec",e,t)}const Ja=Symbol.for("v-ndc");function Ya(e,t,n,s){let r;const i=n,o=V(e);if(o||fe(e)){const l=o&&Ut(e);let a=!1,u=!1;l&&(a=!Ue(e),u=wt(e),e=fs(e)),r=new Array(e.length);for(let c=0,f=e.length;ct(l,a,void 0,i));else{const l=Object.keys(e);r=new Array(l.length);for(let a=0,u=l.length;a0;return t!=="default"&&(n.name=t),ue(),ts($e,null,[Ce("slot",n,s)],u?-2:64)}let i=e[t];i&&i._c&&(i._d=!1),ue();const o=i&&Fo(i(n)),l=n.key||o&&o.key,a=ts($e,{key:(l&&!We(l)?l:`_${t}`)+(!o&&s?"_fb":"")},o||[],o&&e._===1?64:-2);return i&&i._c&&(i._d=!0),a}function Fo(e){return e.some(t=>In(t)?!(t.type===Ee||t.type===$e&&!Fo(t.children)):!0)?e:null}const qs=e=>e?al(e)?vs(e):qs(e.parent):null,wn=ve(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>qs(e.parent),$root:e=>qs(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>Uo(e),$forceUpdate:e=>e.f||(e.f=()=>{_r(e.update)}),$nextTick:e=>e.n||(e.n=Eo.bind(e.proxy)),$watch:e=>$a.bind(e)}),Ts=(e,t)=>e!==le&&!e.__isScriptSetup&&X(e,t),Xa={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:n,setupState:s,data:r,props:i,accessCache:o,type:l,appContext:a}=e;if(t[0]!=="$"){const p=o[t];if(p!==void 0)switch(p){case 1:return s[t];case 2:return r[t];case 4:return n[t];case 3:return i[t]}else{if(Ts(s,t))return o[t]=1,s[t];if(r!==le&&X(r,t))return o[t]=2,r[t];if(X(i,t))return o[t]=3,i[t];if(n!==le&&X(n,t))return o[t]=4,n[t];Ws&&(o[t]=0)}}const u=wn[t];let c,f;if(u)return t==="$attrs"&&Se(e.attrs,"get",""),u(e);if((c=l.__cssModules)&&(c=c[t]))return c;if(n!==le&&X(n,t))return o[t]=4,n[t];if(f=a.config.globalProperties,X(f,t))return f[t]},set({_:e},t,n){const{data:s,setupState:r,ctx:i}=e;return Ts(r,t)?(r[t]=n,!0):s!==le&&X(s,t)?(s[t]=n,!0):X(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(i[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:s,appContext:r,props:i,type:o}},l){let a;return!!(n[l]||e!==le&&l[0]!=="$"&&X(e,l)||Ts(t,l)||X(i,l)||X(s,l)||X(wn,l)||X(r.config.globalProperties,l)||(a=o.__cssModules)&&a[l])},defineProperty(e,t,n){return n.get!=null?e._.accessCache[t]=0:X(n,"value")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}};function ec(){return tc().attrs}function tc(e){const t=Ar();return t.setupContext||(t.setupContext=ul(t))}function Yr(e){return V(e)?e.reduce((t,n)=>(t[n]=null,t),{}):e}let Ws=!0;function nc(e){const t=Uo(e),n=e.proxy,s=e.ctx;Ws=!1,t.beforeCreate&&Xr(t.beforeCreate,e,"bc");const{data:r,computed:i,methods:o,watch:l,provide:a,inject:u,created:c,beforeMount:f,mounted:p,beforeUpdate:g,updated:b,activated:m,deactivated:v,beforeDestroy:x,beforeUnmount:y,destroyed:T,unmounted:N,render:j,renderTracked:K,renderTriggered:U,errorCaptured:me,serverPrefetch:P,expose:G,inheritAttrs:ne,components:M,directives:Q,filters:xe}=t;if(u&&sc(u,s,null),o)for(const re in o){const J=o[re];F(J)&&(s[re]=J.bind(n))}if(r){const re=r.call(n,n);ee(re)&&(e.data=hs(re))}if(Ws=!0,i)for(const re in i){const J=i[re],ft=F(J)?J.bind(n,n):F(J.get)?J.get.bind(n,n):at,Ct=!F(J)&&F(J.set)?J.set.bind(n):at,Je=pe({get:ft,set:Ct});Object.defineProperty(s,re,{enumerable:!0,configurable:!0,get:()=>Je.value,set:Le=>Je.value=Le})}if(l)for(const re in l)jo(l[re],s,n,re);if(a){const re=F(a)?a.call(n):a;Reflect.ownKeys(re).forEach(J=>{Kn(J,re[J])})}c&&Xr(c,e,"c");function de(re,J){V(J)?J.forEach(ft=>re(ft.bind(n))):J&&re(J.bind(n))}if(de(Ua,f),de(wr,p),de(Ka,g),de(Ga,b),de(Ha,m),de(Fa,v),de(Qa,me),de(Za,K),de(Wa,U),de(kr,y),de(Cr,N),de(qa,P),V(G))if(G.length){const re=e.exposed||(e.exposed={});G.forEach(J=>{Object.defineProperty(re,J,{get:()=>n[J],set:ft=>n[J]=ft,enumerable:!0})})}else e.exposed||(e.exposed={});j&&e.render===at&&(e.render=j),ne!=null&&(e.inheritAttrs=ne),M&&(e.components=M),Q&&(e.directives=Q),P&&Vo(e)}function sc(e,t,n=at){V(e)&&(e=Zs(e));for(const s in e){const r=e[s];let i;ee(r)?"default"in r?i=_t(r.from||s,r.default,!0):i=_t(r.from||s):i=_t(r),Re(i)?Object.defineProperty(t,s,{enumerable:!0,configurable:!0,get:()=>i.value,set:o=>i.value=o}):t[s]=i}}function Xr(e,t,n){Ke(V(e)?e.map(s=>s.bind(t.proxy)):e.bind(t.proxy),t,n)}function jo(e,t,n,s){let r=s.includes(".")?No(n,s):()=>n[s];if(fe(e)){const i=t[e];F(i)&&Ot(r,i)}else if(F(e))Ot(r,e.bind(n));else if(ee(e))if(V(e))e.forEach(i=>jo(i,t,n,s));else{const i=F(e.handler)?e.handler.bind(n):t[e.handler];F(i)&&Ot(r,i,e)}}function Uo(e){const t=e.type,{mixins:n,extends:s}=t,{mixins:r,optionsCache:i,config:{optionMergeStrategies:o}}=e.appContext,l=i.get(t);let a;return l?a=l:!r.length&&!n&&!s?a=t:(a={},r.length&&r.forEach(u=>Xn(a,u,o,!0)),Xn(a,t,o)),ee(t)&&i.set(t,a),a}function Xn(e,t,n,s=!1){const{mixins:r,extends:i}=t;i&&Xn(e,i,n,!0),r&&r.forEach(o=>Xn(e,o,n,!0));for(const o in t)if(!(s&&o==="expose")){const l=rc[o]||n&&n[o];e[o]=l?l(e[o],t[o]):t[o]}return e}const rc={data:ei,props:ti,emits:ti,methods:mn,computed:mn,beforeCreate:Te,created:Te,beforeMount:Te,mounted:Te,beforeUpdate:Te,updated:Te,beforeDestroy:Te,beforeUnmount:Te,destroyed:Te,unmounted:Te,activated:Te,deactivated:Te,errorCaptured:Te,serverPrefetch:Te,components:mn,directives:mn,watch:oc,provide:ei,inject:ic};function ei(e,t){return t?e?function(){return ve(F(e)?e.call(this,this):e,F(t)?t.call(this,this):t)}:t:e}function ic(e,t){return mn(Zs(e),Zs(t))}function Zs(e){if(V(e)){const t={};for(let n=0;nt==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${Ge(t)}Modifiers`]||e[`${Lt(t)}Modifiers`];function uc(e,t,...n){if(e.isUnmounted)return;const s=e.vnode.props||le;let r=n;const i=t.startsWith("update:"),o=i&&cc(s,t.slice(7));o&&(o.trim&&(r=n.map(c=>fe(c)?c.trim():c)),o.number&&(r=n.map(fr)));let l,a=s[l=ks(t)]||s[l=ks(Ge(t))];!a&&i&&(a=s[l=ks(Lt(t))]),a&&Ke(a,e,6,r);const u=s[l+"Once"];if(u){if(!e.emitted)e.emitted={};else if(e.emitted[l])return;e.emitted[l]=!0,Ke(u,e,6,r)}}const fc=new WeakMap;function Go(e,t,n=!1){const s=n?fc:t.emitsCache,r=s.get(e);if(r!==void 0)return r;const i=e.emits;let o={},l=!1;if(!F(e)){const a=u=>{const c=Go(u,t,!0);c&&(l=!0,ve(o,c))};!n&&t.mixins.length&&t.mixins.forEach(a),e.extends&&a(e.extends),e.mixins&&e.mixins.forEach(a)}return!i&&!l?(ee(e)&&s.set(e,null),null):(V(i)?i.forEach(a=>o[a]=null):ve(o,i),ee(e)&&s.set(e,o),o)}function bs(e,t){return!e||!ls(t)?!1:(t=t.slice(2),t=t==="Once"?t:t.replace(/Once$/,""),X(e,t[0].toLowerCase()+t.slice(1))||X(e,Lt(t))||X(e,t))}function ni(e){const{type:t,vnode:n,proxy:s,withProxy:r,propsOptions:[i],slots:o,attrs:l,emit:a,render:u,renderCache:c,props:f,data:p,setupState:g,ctx:b,inheritAttrs:m}=e,v=Jn(e);let x,y;try{if(n.shapeFlag&4){const N=r||s,j=N;x=ot(u.call(j,N,c,f,g,p,b)),y=l}else{const N=t;x=ot(N.length>1?N(f,{attrs:l,slots:o,emit:a}):N(f,null)),y=t.props?l:hc(l)}}catch(N){kn.length=0,ps(N,e,1),x=Ce(Ee)}let T=x;if(y&&m!==!1){const N=Object.keys(y),{shapeFlag:j}=T;N.length&&j&7&&(i&&N.some(as)&&(y=pc(y,i)),T=Nt(T,y,!1,!0))}return n.dirs&&(T=Nt(T,null,!1,!0),T.dirs=T.dirs?T.dirs.concat(n.dirs):n.dirs),n.transition&&Tn(T,n.transition),x=T,Jn(v),x}const hc=e=>{let t;for(const n in e)(n==="class"||n==="style"||ls(n))&&((t||(t={}))[n]=e[n]);return t},pc=(e,t)=>{const n={};for(const s in e)(!as(s)||!(s.slice(9)in t))&&(n[s]=e[s]);return n};function dc(e,t,n){const{props:s,children:r,component:i}=e,{props:o,children:l,patchFlag:a}=t,u=i.emitsOptions;if(t.dirs||t.transition)return!0;if(n&&a>=0){if(a&1024)return!0;if(a&16)return s?si(s,o,u):!!o;if(a&8){const c=t.dynamicProps;for(let f=0;fObject.create(Wo),Qo=e=>Object.getPrototypeOf(e)===Wo;function mc(e,t,n,s=!1){const r={},i=Zo();e.propsDefaults=Object.create(null),Jo(e,t,r,i);for(const o in e.propsOptions[0])o in r||(r[o]=void 0);n?e.props=s?r:wo(r):e.type.props?e.props=r:e.props=i,e.attrs=i}function bc(e,t,n,s){const{props:r,attrs:i,vnode:{patchFlag:o}}=e,l=Z(r),[a]=e.propsOptions;let u=!1;if((s||o>0)&&!(o&16)){if(o&8){const c=e.vnode.dynamicProps;for(let f=0;f{a=!0;const[p,g]=Yo(f,t,!0);ve(o,p),g&&l.push(...g)};!n&&t.mixins.length&&t.mixins.forEach(c),e.extends&&c(e.extends),e.mixins&&e.mixins.forEach(c)}if(!i&&!a)return ee(e)&&s.set(e,Xt),Xt;if(V(i))for(let c=0;ce==="_"||e==="_ctx"||e==="$stable",Er=e=>V(e)?e.map(ot):[ot(e)],vc=(e,t,n)=>{if(t._n)return t;const s=Io((...r)=>Er(t(...r)),n);return s._c=!1,s},Xo=(e,t,n)=>{const s=e._ctx;for(const r in e){if(Sr(r))continue;const i=e[r];if(F(i))t[r]=vc(r,i,s);else if(i!=null){const o=Er(i);t[r]=()=>o}}},el=(e,t)=>{const n=Er(t);e.slots.default=()=>n},tl=(e,t,n)=>{for(const s in t)(n||!Sr(s))&&(e[s]=t[s])},xc=(e,t,n)=>{const s=e.slots=Zo();if(e.vnode.shapeFlag&32){const r=t._;r?(tl(s,t,n),n&&so(s,"_",r,!0)):Xo(t,s)}else t&&el(e,t)},_c=(e,t,n)=>{const{vnode:s,slots:r}=e;let i=!0,o=le;if(s.shapeFlag&32){const l=t._;l?n&&l===1?i=!1:tl(r,t,n):(i=!t.$stable,Xo(t,r)),o=t}else t&&(el(e,t),o={default:1});if(i)for(const l in r)!Sr(l)&&o[l]==null&&delete r[l]},Me=Ec;function wc(e){return kc(e)}function kc(e,t){const n=us();n.__VUE__=!0;const{insert:s,remove:r,patchProp:i,createElement:o,createText:l,createComment:a,setText:u,setElementText:c,parentNode:f,nextSibling:p,setScopeId:g=at,insertStaticContent:b}=e,m=(h,d,_,k=null,S=null,w=null,I=void 0,R=null,A=!!d.dynamicChildren)=>{if(h===d)return;h&&!Ht(h,d)&&(k=C(h),Le(h,S,w,!0),h=null),d.patchFlag===-2&&(A=!1,d.dynamicChildren=null);const{type:E,ref:z,shapeFlag:L}=d;switch(E){case ys:v(h,d,_,k);break;case Ee:x(h,d,_,k);break;case Gn:h==null&&y(d,_,k,I);break;case $e:M(h,d,_,k,S,w,I,R,A);break;default:L&1?j(h,d,_,k,S,w,I,R,A):L&6?Q(h,d,_,k,S,w,I,R,A):(L&64||L&128)&&E.process(h,d,_,k,S,w,I,R,A,D)}z!=null&&S?_n(z,h&&h.ref,w,d||h,!d):z==null&&h&&h.ref!=null&&_n(h.ref,null,w,h,!0)},v=(h,d,_,k)=>{if(h==null)s(d.el=l(d.children),_,k);else{const S=d.el=h.el;d.children!==h.children&&u(S,d.children)}},x=(h,d,_,k)=>{h==null?s(d.el=a(d.children||""),_,k):d.el=h.el},y=(h,d,_,k)=>{[h.el,h.anchor]=b(h.children,d,_,k,h.el,h.anchor)},T=({el:h,anchor:d},_,k)=>{let S;for(;h&&h!==d;)S=p(h),s(h,_,k),h=S;s(d,_,k)},N=({el:h,anchor:d})=>{let _;for(;h&&h!==d;)_=p(h),r(h),h=_;r(d)},j=(h,d,_,k,S,w,I,R,A)=>{if(d.type==="svg"?I="svg":d.type==="math"&&(I="mathml"),h==null)K(d,_,k,S,w,I,R,A);else{const E=h.el&&h.el._isVueCE?h.el:null;try{E&&E._beginPatch(),P(h,d,S,w,I,R,A)}finally{E&&E._endPatch()}}},K=(h,d,_,k,S,w,I,R)=>{let A,E;const{props:z,shapeFlag:L,transition:B,dirs:H}=h;if(A=h.el=o(h.type,w,z&&z.is,z),L&8?c(A,h.children):L&16&&me(h.children,A,null,k,S,Ps(h,w),I,R),H&&$t(h,null,k,"created"),U(A,h,h.scopeId,I,k),z){for(const oe in z)oe!=="value"&&!yn(oe)&&i(A,oe,null,z[oe],w,k);"value"in z&&i(A,"value",null,z.value,w),(E=z.onVnodeBeforeMount)&&tt(E,k,h)}H&&$t(h,null,k,"beforeMount");const W=Cc(S,B);W&&B.beforeEnter(A),s(A,d,_),((E=z&&z.onVnodeMounted)||W||H)&&Me(()=>{try{E&&tt(E,k,h),W&&B.enter(A),H&&$t(h,null,k,"mounted")}finally{}},S)},U=(h,d,_,k,S)=>{if(_&&g(h,_),k)for(let w=0;w{for(let E=A;E{const R=d.el=h.el;let{patchFlag:A,dynamicChildren:E,dirs:z}=d;A|=h.patchFlag&16;const L=h.props||le,B=d.props||le;let H;if(_&&Dt(_,!1),(H=B.onVnodeBeforeUpdate)&&tt(H,_,d,h),z&&$t(d,h,_,"beforeUpdate"),_&&Dt(_,!0),E&&(!h.dynamicChildren||h.dynamicChildren.length!==E.length)&&(A=0,I=!1,E=null),(L.innerHTML&&B.innerHTML==null||L.textContent&&B.textContent==null)&&c(R,""),E?G(h.dynamicChildren,E,R,_,k,Ps(d,S),w):I||J(h,d,R,null,_,k,Ps(d,S),w,!1),A>0){if(A&16)ne(R,L,B,_,S);else if(A&2&&L.class!==B.class&&i(R,"class",null,B.class,S),A&4&&i(R,"style",L.style,B.style,S),A&8){const W=d.dynamicProps;for(let oe=0;oe{H&&tt(H,_,d,h),z&&$t(d,h,_,"updated")},k)},G=(h,d,_,k,S,w,I)=>{for(let R=0;R{if(d!==_){if(d!==le)for(const w in d)!yn(w)&&!(w in _)&&i(h,w,d[w],null,S,k);for(const w in _){if(yn(w))continue;const I=_[w],R=d[w];I!==R&&w!=="value"&&i(h,w,R,I,S,k)}"value"in _&&i(h,"value",d.value,_.value,S)}},M=(h,d,_,k,S,w,I,R,A)=>{const E=d.el=h?h.el:l(""),z=d.anchor=h?h.anchor:l("");let{patchFlag:L,dynamicChildren:B,slotScopeIds:H}=d;H&&(R=R?R.concat(H):H),h==null?(s(E,_,k),s(z,_,k),me(d.children||[],_,z,S,w,I,R,A)):L>0&&L&64&&B&&h.dynamicChildren&&h.dynamicChildren.length===B.length?(G(h.dynamicChildren,B,_,S,w,I,R),(d.key!=null||S&&d===S.subTree)&&nl(h,d,!0)):J(h,d,_,z,S,w,I,R,A)},Q=(h,d,_,k,S,w,I,R,A)=>{d.slotScopeIds=R,h==null?d.shapeFlag&512?S.ctx.activate(d,_,k,I,A):xe(d,_,k,S,w,I,A):Be(h,d,A)},xe=(h,d,_,k,S,w,I)=>{const R=h.component=Lc(h,k,S);if(gs(h)&&(R.ctx.renderer=D),Mc(R,!1,I),R.asyncDep){if(S&&S.registerDep(R,de,I),!h.el){const A=R.subTree=Ce(Ee);x(null,A,d,_),h.placeholder=A.el}}else de(R,h,d,_,S,w,I)},Be=(h,d,_)=>{const k=d.component=h.component;if(dc(h,d,_))if(k.asyncDep&&!k.asyncResolved){re(k,d,_);return}else k.next=d,k.update();else d.el=h.el,k.vnode=d},de=(h,d,_,k,S,w,I)=>{const R=()=>{if(h.isMounted){let{next:L,bu:B,u:H,parent:W,vnode:oe}=h;{const Xe=sl(h);if(Xe){L&&(L.el=oe.el,re(h,L,I)),Xe.asyncDep.then(()=>{Me(()=>{h.isUnmounted||E()},S)});return}}let ie=L,ge;Dt(h,!1),L?(L.el=oe.el,re(h,L,I)):L=oe,B&&Un(B),(ge=L.props&&L.props.onVnodeBeforeUpdate)&&tt(ge,W,L,oe),Dt(h,!0);const we=ni(h),Ye=h.subTree;h.subTree=we,m(Ye,we,f(Ye.el),C(Ye),h,S,w),L.el=we.el,ie===null&&gc(h,we.el),H&&Me(H,S),(ge=L.props&&L.props.onVnodeUpdated)&&Me(()=>tt(ge,W,L,oe),S)}else{let L;const{el:B,props:H}=d,{bm:W,m:oe,parent:ie,root:ge,type:we}=h,Ye=nn(d);Dt(h,!1),W&&Un(W),!Ye&&(L=H&&H.onVnodeBeforeMount)&&tt(L,ie,d),Dt(h,!0);{ge.ce&&ge.ce._hasShadowRoot()&&ge.ce._injectChildStyle(we,h.parent?h.parent.type:void 0);const Xe=h.subTree=ni(h);m(null,Xe,_,k,h,S,w),d.el=Xe.el}if(oe&&Me(oe,S),!Ye&&(L=H&&H.onVnodeMounted)){const Xe=d;Me(()=>tt(L,ie,Xe),S)}(d.shapeFlag&256||ie&&nn(ie.vnode)&&ie.vnode.shapeFlag&256)&&h.a&&Me(h.a,S),h.isMounted=!0,d=_=k=null}};h.scope.on();const A=h.effect=new lo(R);h.scope.off();const E=h.update=A.run.bind(A),z=h.job=A.runIfDirty.bind(A);z.i=h,z.id=h.uid,A.scheduler=()=>_r(z),Dt(h,!0),E()},re=(h,d,_)=>{d.component=h;const k=h.vnode.props;h.vnode=d,h.next=null,bc(h,d.props,k,_),_c(h,d.children,_),ct(),qr(h),ut()},J=(h,d,_,k,S,w,I,R,A=!1)=>{const E=h&&h.children,z=h?h.shapeFlag:0,L=d.children,{patchFlag:B,shapeFlag:H}=d;if(B>0){if(B&128){Ct(E,L,_,k,S,w,I,R,A);return}else if(B&256){ft(E,L,_,k,S,w,I,R,A);return}}H&8?(z&16&&He(E,S,w),L!==E&&c(_,L)):z&16?H&16?Ct(E,L,_,k,S,w,I,R,A):He(E,S,w,!0):(z&8&&c(_,""),H&16&&me(L,_,k,S,w,I,R,A))},ft=(h,d,_,k,S,w,I,R,A)=>{h=h||Xt,d=d||Xt;const E=h.length,z=d.length,L=Math.min(E,z);let B;for(B=0;Bz?He(h,S,w,!0,!1,L):me(d,_,k,S,w,I,R,A,L)},Ct=(h,d,_,k,S,w,I,R,A)=>{let E=0;const z=d.length;let L=h.length-1,B=z-1;for(;E<=L&&E<=B;){const H=h[E],W=d[E]=A?mt(d[E]):ot(d[E]);if(Ht(H,W))m(H,W,_,null,S,w,I,R,A);else break;E++}for(;E<=L&&E<=B;){const H=h[L],W=d[B]=A?mt(d[B]):ot(d[B]);if(Ht(H,W))m(H,W,_,null,S,w,I,R,A);else break;L--,B--}if(E>L){if(E<=B){const H=B+1,W=HB)for(;E<=L;)Le(h[E],S,w,!0),E++;else{const H=E,W=E,oe=new Map;for(E=W;E<=B;E++){const ze=d[E]=A?mt(d[E]):ot(d[E]);ze.key!=null&&oe.set(ze.key,E)}let ie,ge=0;const we=B-W+1;let Ye=!1,Xe=0;const cn=new Array(we);for(E=0;E=we){Le(ze,S,w,!0);continue}let et;if(ze.key!=null)et=oe.get(ze.key);else for(ie=W;ie<=B;ie++)if(cn[ie-W]===0&&Ht(ze,d[ie])){et=ie;break}et===void 0?Le(ze,S,w,!0):(cn[et-W]=E+1,et>=Xe?Xe=et:Ye=!0,m(ze,d[et],_,null,S,w,I,R,A),ge++)}const Vr=Ye?Sc(cn):Xt;for(ie=Vr.length-1,E=we-1;E>=0;E--){const ze=W+E,et=d[ze],Hr=d[ze+1],Fr=ze+1{const{el:w,type:I,transition:R,children:A,shapeFlag:E}=h;if(E&6){Je(h.component.subTree,d,_,k);return}if(E&128){h.suspense.move(d,_,k);return}if(E&64){I.move(h,d,_,D);return}if(I===$e){s(w,d,_);for(let L=0;LR.enter(w),S));else{const{leave:L,delayLeave:B,afterLeave:H}=R,W=()=>{h.ctx.isUnmounted?r(w):s(w,d,_)},oe=()=>{const ie=w._isLeaving||!!w[je];w._isLeaving&&w[je](!0),R.persisted&&!ie?W():L(w,()=>{W(),H&&H()})};B?B(w,W,oe):oe()}else s(w,d,_)},Le=(h,d,_,k=!1,S=!1)=>{const{type:w,props:I,ref:R,children:A,dynamicChildren:E,shapeFlag:z,patchFlag:L,dirs:B,cacheIndex:H,memo:W}=h;if(L===-2&&(S=!1),R!=null&&(ct(),_n(R,null,_,h,!0),ut()),H!=null&&(d.renderCache[H]=void 0),z&256){d.ctx.deactivate(h);return}const oe=z&1&&B,ie=!nn(h);let ge;if(ie&&(ge=I&&I.onVnodeBeforeUnmount)&&tt(ge,d,h),z&6)Mt(h.component,_,k);else{if(z&128){h.suspense.unmount(_,k);return}oe&&$t(h,null,d,"beforeUnmount"),z&64?h.type.remove(h,d,_,D,k):E&&!E.hasOnce&&(w!==$e||L>0&&L&64)?He(E,d,_,!1,!0):(w===$e&&L&384||!S&&z&16)&&He(A,d,_),k&&qt(h)}const we=W!=null&&H==null;(ie&&(ge=I&&I.onVnodeUnmounted)||oe||we)&&Me(()=>{ge&&tt(ge,d,h),oe&&$t(h,null,d,"unmounted"),we&&(h.el=null)},_)},qt=h=>{const{type:d,el:_,anchor:k,transition:S}=h;if(d===$e){Wt(_,k);return}if(d===Gn){N(h);return}const w=()=>{r(_),S&&!S.persisted&&S.afterLeave&&S.afterLeave()};if(h.shapeFlag&1&&S&&!S.persisted){const{leave:I,delayLeave:R}=S,A=()=>I(_,w);R?R(h.el,w,A):A()}else w()},Wt=(h,d)=>{let _;for(;h!==d;)_=p(h),r(h),h=_;r(d)},Mt=(h,d,_)=>{const{bum:k,scope:S,job:w,subTree:I,um:R,m:A,a:E}=h;ii(A),ii(E),k&&Un(k),S.stop(),w&&(w.flags|=8,Le(I,h,d,_)),R&&Me(R,d),Me(()=>{h.isUnmounted=!0},d)},He=(h,d,_,k=!1,S=!1,w=0)=>{for(let I=w;I{if(h.shapeFlag&6)return C(h.component.subTree);if(h.shapeFlag&128)return h.suspense.next();const d=p(h.anchor||h.el),_=d&&d[Da];return _?p(_):d};let $=!1;const O=(h,d,_)=>{let k;h==null?d._vnode&&(Le(d._vnode,null,null,!0),k=d._vnode.component):m(d._vnode||null,h,d,null,null,null,_),d._vnode=h,$||($=!0,qr(k),Ro(),$=!1)},D={p:m,um:Le,m:Je,r:qt,mt:xe,mc:me,pc:J,pbc:G,n:C,o:e};return{render:O,hydrate:void 0,createApp:ac(O)}}function Ps({type:e,props:t},n){return n==="svg"&&e==="foreignObject"||n==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:n}function Dt({effect:e,job:t},n){n?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function Cc(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function nl(e,t,n=!1){const s=e.children,r=t.children;if(V(s)&&V(r))for(let i=0;i>1,e[n[l]]0&&(t[s]=n[i-1]),n[i]=s)}}for(i=n.length,o=n[i-1];i-- >0;)n[i]=o,o=t[o];return n}function sl(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:sl(t)}function ii(e){if(e)for(let t=0;te.__isSuspense;function Ec(e,t){t&&t.pendingBranch?V(e)?t.effects.push(...e):t.effects.push(e):Oa(e)}const $e=Symbol.for("v-fgt"),ys=Symbol.for("v-txt"),Ee=Symbol.for("v-cmt"),Gn=Symbol.for("v-stc"),kn=[];let Ve=null;function ue(e=!1){kn.push(Ve=e?null:[])}function Ac(){kn.pop(),Ve=kn[kn.length-1]||null}let Pn=1;function es(e,t=!1){Pn+=e,e<0&&Ve&&t&&(Ve.hasOnce=!0)}function ol(e){return e.dynamicChildren=Pn>0?Ve||Xt:null,Ac(),Pn>0&&Ve&&Ve.push(e),e}function ye(e,t,n,s,r,i){return ol(_e(e,t,n,s,r,i,!0))}function ts(e,t,n,s,r){return ol(Ce(e,t,n,s,r,!0))}function In(e){return e?e.__v_isVNode===!0:!1}function Ht(e,t){return e.type===t.type&&e.key===t.key}const ll=({key:e})=>e??null,qn=({ref:e,ref_key:t,ref_for:n})=>(typeof e=="number"&&(e=""+e),e!=null?fe(e)||Re(e)||F(e)?{i:Ae,r:e,k:t,f:!!n}:e:null);function _e(e,t=null,n=null,s=0,r=null,i=e===$e?0:1,o=!1,l=!1){const a={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&ll(t),ref:t&&qn(t),scopeId:Po,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:i,patchFlag:s,dynamicProps:r,dynamicChildren:null,appContext:null,ctx:Ae};return l?(ns(a,n),i&128&&e.normalize(a)):n&&(a.shapeFlag|=fe(n)?8:16),Pn>0&&!o&&Ve&&(a.patchFlag>0||i&6)&&a.patchFlag!==32&&Ve.push(a),a}const Ce=Rc;function Rc(e,t=null,n=null,s=0,r=null,i=!1){if((!e||e===Ja)&&(e=Ee),In(e)){const l=Nt(e,t,!0);return n&&ns(l,n),Pn>0&&!i&&Ve&&(l.shapeFlag&6?Ve[Ve.indexOf(e)]=l:Ve.push(l)),l.patchFlag=-2,l}if(Bc(e)&&(e=e.__vccOpts),t){t=Tc(t);let{class:l,style:a}=t;l&&!fe(l)&&(t.class=an(l)),ee(a)&&(xr(a)&&!V(a)&&(a=ve({},a)),t.style=hr(a))}const o=fe(e)?1:il(e)?128:Lo(e)?64:ee(e)?4:F(e)?2:0;return _e(e,t,n,s,r,o,i,!0)}function Tc(e){return e?xr(e)||Qo(e)?ve({},e):e:null}function Nt(e,t,n=!1,s=!1){const{props:r,ref:i,patchFlag:o,children:l,transition:a}=e,u=t?Js(r||{},t):r,c={__v_isVNode:!0,__v_skip:!0,type:e.type,props:u,key:u&&ll(u),ref:t&&t.ref?n&&i?V(i)?i.concat(qn(t)):[i,qn(t)]:qn(t):i,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:l,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==$e?o===-1?16:o|16:o,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:a,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&Nt(e.ssContent),ssFallback:e.ssFallback&&Nt(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return a&&s&&Tn(c,a.clone(c)),c}function Pc(e=" ",t=0){return Ce(ys,null,e,t)}function Ic(e,t){const n=Ce(Gn,null,e);return n.staticCount=t,n}function it(e="",t=!1){return t?(ue(),ts(Ee,null,e)):Ce(Ee,null,e)}function ot(e){return e==null||typeof e=="boolean"?Ce(Ee):V(e)?Ce($e,null,e.slice()):In(e)?mt(e):Ce(ys,null,String(e))}function mt(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:Nt(e)}function ns(e,t){let n=0;const{shapeFlag:s}=e;if(t==null)t=null;else if(V(t))n=16;else if(typeof t=="object")if(s&65){const r=t.default;r&&(r._c&&(r._d=!1),ns(e,r()),r._c&&(r._d=!0));return}else{n=32;const r=t._;!r&&!Qo(t)?t._ctx=Ae:r===3&&Ae&&(Ae.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else if(F(t)){if(s&65){ns(e,{default:t});return}t={default:t,_ctx:Ae},n=32}else t=String(t),s&64?(n=16,t=[Pc(t)]):n=8;e.children=t,e.shapeFlag|=n}function Js(...e){const t={};for(let n=0;nOe||Ae;let ss,Ys;{const e=us(),t=(n,s)=>{let r;return(r=e[n])||(r=e[n]=[]),r.push(s),i=>{r.length>1?r.forEach(o=>o(i)):r[0](i)}};ss=t("__VUE_INSTANCE_SETTERS__",n=>Oe=n),Ys=t("__VUE_SSR_SETTERS__",n=>On=n)}const Dn=e=>{const t=Oe;return ss(e),e.scope.on(),()=>{e.scope.off(),ss(t)}},oi=()=>{Oe&&Oe.scope.off(),ss(null)};function al(e){return e.vnode.shapeFlag&4}let On=!1;function Mc(e,t=!1,n=!1){t&&Ys(t);const{props:s,children:r}=e.vnode,i=al(e);mc(e,s,i,t),xc(e,r,n||t);const o=i?$c(e,t):void 0;return t&&Ys(!1),o}function $c(e,t){const n=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,Xa);const{setup:s}=n;if(s){ct();const r=e.setupContext=s.length>1?ul(e):null,i=Dn(e),o=$n(s,e,0,[e.props,r]),l=Xi(o);if(ut(),i(),(l||e.sp)&&!nn(e)&&Vo(e),l){if(o.then(oi,oi),t)return o.then(a=>{li(e,a)}).catch(a=>{ps(a,e,0)});e.asyncDep=o}else li(e,o)}else cl(e)}function li(e,t,n){F(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:ee(t)&&(e.setupState=Co(t)),cl(e)}function cl(e,t,n){const s=e.type;e.render||(e.render=s.render||at);{const r=Dn(e);ct();try{nc(e)}finally{ut(),r()}}}const Dc={get(e,t){return Se(e,"get",""),e[t]}};function ul(e){const t=n=>{e.exposed=n||{}};return{attrs:new Proxy(e.attrs,Dc),slots:e.slots,emit:e.emit,expose:t}}function vs(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(Co(wa(e.exposed)),{get(t,n){if(n in t)return t[n];if(n in wn)return wn[n](e)},has(t,n){return n in t||n in wn}})):e.proxy}function Bc(e){return F(e)&&"__vccOpts"in e}const pe=(e,t)=>Aa(e,t,On);function Rr(e,t,n){try{es(-1);const s=arguments.length;return s===2?ee(t)&&!V(t)?In(t)?Ce(e,null,[t]):Ce(e,t):Ce(e,null,t):(s>3?n=Array.prototype.slice.call(arguments,2):s===3&&In(n)&&(n=[n]),Ce(e,t,n))}finally{es(1)}}const zc="3.5.39";/** +* @vue/runtime-dom v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let Xs;const ai=typeof window<"u"&&window.trustedTypes;if(ai)try{Xs=ai.createPolicy("vue",{createHTML:e=>e})}catch{}const fl=Xs?e=>Xs.createHTML(e):e=>e,Vc="http://www.w3.org/2000/svg",Hc="http://www.w3.org/1998/Math/MathML",gt=typeof document<"u"?document:null,ci=gt&>.createElement("template"),Fc={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,s)=>{const r=t==="svg"?gt.createElementNS(Vc,e):t==="mathml"?gt.createElementNS(Hc,e):n?gt.createElement(e,{is:n}):gt.createElement(e);return e==="select"&&s&&s.multiple!=null&&r.setAttribute("multiple",s.multiple),r},createText:e=>gt.createTextNode(e),createComment:e=>gt.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>gt.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,n,s,r,i){const o=n?n.previousSibling:t.lastChild;if(r&&(r===i||r.nextSibling))for(;t.insertBefore(r.cloneNode(!0),n),!(r===i||!(r=r.nextSibling)););else{ci.innerHTML=fl(s==="svg"?`${e}`:s==="mathml"?`${e}`:e);const l=ci.content;if(s==="svg"||s==="mathml"){const a=l.firstChild;for(;a.firstChild;)l.appendChild(a.firstChild);l.removeChild(a)}t.insertBefore(l,n)}return[o?o.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},St="transition",hn="animation",Nn=Symbol("_vtc"),hl={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String},jc=ve({},Mo,hl),Uc=e=>(e.displayName="Transition",e.props=jc,e),Kc=Uc((e,{slots:t})=>Rr(Va,Gc(e),t)),Bt=(e,t=[])=>{V(e)?e.forEach(n=>n(...t)):e&&e(...t)},ui=e=>e?V(e)?e.some(t=>t.length>1):e.length>1:!1;function Gc(e){const t={};for(const M in e)M in hl||(t[M]=e[M]);if(e.css===!1)return t;const{name:n="v",type:s,duration:r,enterFromClass:i=`${n}-enter-from`,enterActiveClass:o=`${n}-enter-active`,enterToClass:l=`${n}-enter-to`,appearFromClass:a=i,appearActiveClass:u=o,appearToClass:c=l,leaveFromClass:f=`${n}-leave-from`,leaveActiveClass:p=`${n}-leave-active`,leaveToClass:g=`${n}-leave-to`}=e,b=qc(r),m=b&&b[0],v=b&&b[1],{onBeforeEnter:x,onEnter:y,onEnterCancelled:T,onLeave:N,onLeaveCancelled:j,onBeforeAppear:K=x,onAppear:U=y,onAppearCancelled:me=T}=t,P=(M,Q,xe,Be)=>{M._enterCancelled=Be,zt(M,Q?c:l),zt(M,Q?u:o),xe&&xe()},G=(M,Q)=>{M._isLeaving=!1,zt(M,f),zt(M,g),zt(M,p),Q&&Q()},ne=M=>(Q,xe)=>{const Be=M?U:y,de=()=>P(Q,M,xe);Bt(Be,[Q,de]),fi(()=>{zt(Q,M?a:i),pt(Q,M?c:l),ui(Be)||hi(Q,s,m,de)})};return ve(t,{onBeforeEnter(M){Bt(x,[M]),pt(M,i),pt(M,o)},onBeforeAppear(M){Bt(K,[M]),pt(M,a),pt(M,u)},onEnter:ne(!1),onAppear:ne(!0),onLeave(M,Q){M._isLeaving=!0;const xe=()=>G(M,Q);pt(M,f),M._enterCancelled?(pt(M,p),gi(M)):(gi(M),pt(M,p)),fi(()=>{M._isLeaving&&(zt(M,f),pt(M,g),ui(N)||hi(M,s,v,xe))}),Bt(N,[M,xe])},onEnterCancelled(M){P(M,!1,void 0,!0),Bt(T,[M])},onAppearCancelled(M){P(M,!0,void 0,!0),Bt(me,[M])},onLeaveCancelled(M){G(M),Bt(j,[M])}})}function qc(e){if(e==null)return null;if(ee(e))return[Is(e.enter),Is(e.leave)];{const t=Is(e);return[t,t]}}function Is(e){return Zl(e)}function pt(e,t){t.split(/\s+/).forEach(n=>n&&e.classList.add(n)),(e[Nn]||(e[Nn]=new Set)).add(t)}function zt(e,t){t.split(/\s+/).forEach(s=>s&&e.classList.remove(s));const n=e[Nn];n&&(n.delete(t),n.size||(e[Nn]=void 0))}function fi(e){requestAnimationFrame(()=>{requestAnimationFrame(e)})}let Wc=0;function hi(e,t,n,s){const r=e._endId=++Wc,i=()=>{r===e._endId&&s()};if(n!=null)return setTimeout(i,n);const{type:o,timeout:l,propCount:a}=Zc(e,t);if(!o)return s();const u=o+"end";let c=0;const f=()=>{e.removeEventListener(u,p),i()},p=g=>{g.target===e&&++c>=a&&f()};setTimeout(()=>{c(n[b]||"").split(", "),r=s(`${St}Delay`),i=s(`${St}Duration`),o=pi(r,i),l=s(`${hn}Delay`),a=s(`${hn}Duration`),u=pi(l,a);let c=null,f=0,p=0;t===St?o>0&&(c=St,f=o,p=i.length):t===hn?u>0&&(c=hn,f=u,p=a.length):(f=Math.max(o,u),c=f>0?o>u?St:hn:null,p=c?c===St?i.length:a.length:0);const g=c===St&&/\b(?:transform|all)(?:,|$)/.test(s(`${St}Property`).toString());return{type:c,timeout:f,propCount:p,hasTransform:g}}function pi(e,t){for(;e.lengthdi(n)+di(e[s])))}function di(e){return e==="auto"?0:Number(e.slice(0,-1).replace(",","."))*1e3}function gi(e){return(e?e.ownerDocument:document).body.offsetHeight}function Qc(e,t,n){const s=e[Nn];s&&(t=(t?[t,...s]:[...s]).join(" ")),t==null?e.removeAttribute("class"):n?e.setAttribute("class",t):e.className=t}const mi=Symbol("_vod"),Jc=Symbol("_vsh"),Yc=Symbol(""),Xc=/(?:^|;)\s*display\s*:/;function eu(e,t,n){const s=e.style,r=fe(n);let i=!1;if(n&&!r){if(t)if(fe(t))for(const o of t.split(";")){const l=o.slice(0,o.indexOf(":")).trim();n[l]==null&&bn(s,l,"")}else for(const o in t)n[o]==null&&bn(s,o,"");for(const o in n){o==="display"&&(i=!0);const l=n[o];l!=null?nu(e,o,!fe(t)&&t?t[o]:void 0,l)||bn(s,o,l):bn(s,o,"")}}else if(r){if(t!==n){const o=s[Yc];o&&(n+=";"+o),s.cssText=n,i=Xc.test(n)}}else t&&e.removeAttribute("style");mi in e&&(e[mi]=i?s.display:"",e[Jc]&&(s.display="none"))}const bi=/\s*!important$/;function bn(e,t,n){if(V(n))n.forEach(s=>bn(e,t,s));else if(n==null&&(n=""),t.startsWith("--"))e.setProperty(t,n);else{const s=tu(e,t);bi.test(n)?e.setProperty(Lt(s),n.replace(bi,""),"important"):e[s]=n}}const yi=["Webkit","Moz","ms"],Os={};function tu(e,t){const n=Os[t];if(n)return n;let s=Ge(t);if(s!=="filter"&&s in e)return Os[t]=s;s=no(s);for(let r=0;rNs||(au.then(()=>Ns=0),Ns=Date.now());function uu(e,t){const n=s=>{if(!s._vts)s._vts=Date.now();else if(s._vts<=n.attached)return;const r=n.value;if(V(r)){const i=s.stopImmediatePropagation;s.stopImmediatePropagation=()=>{i.call(s),s._stopped=!0};const o=r.slice(),l=[s];for(let a=0;ae.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,fu=(e,t,n,s,r,i)=>{const o=r==="svg";t==="class"?Qc(e,s,o):t==="style"?eu(e,n,s):ls(t)?as(t)||ru(e,t,n,s,i):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):hu(e,t,s,o))?(_i(e,t,s),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&xi(e,t,s,o,i,t!=="value")):e._isVueCE&&(pu(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!fe(s)))?_i(e,Ge(t),s,i,t):(t==="true-value"?e._trueValue=s:t==="false-value"&&(e._falseValue=s),xi(e,t,s,o))};function hu(e,t,n,s){if(s)return!!(t==="innerHTML"||t==="textContent"||t in e&&ki(t)&&F(n));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const r=e.tagName;if(r==="IMG"||r==="VIDEO"||r==="CANVAS"||r==="SOURCE")return!1}return ki(t)&&fe(n)?!1:t in e}function pu(e,t){const n=e._def.props;if(!n)return!1;const s=Ge(t);return Array.isArray(n)?n.some(r=>Ge(r)===s):Object.keys(n).some(r=>Ge(r)===s)}const Ci=e=>{const t=e.props["onUpdate:modelValue"]||!1;return V(t)?n=>Un(t,n):t};function du(e){e.target.composing=!0}function Si(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const Ls=Symbol("_assign");function Ei(e,t,n){return t&&(e=e.trim()),n&&(e=fr(e)),e}const gu={created(e,{modifiers:{lazy:t,trim:n,number:s}},r){e[Ls]=Ci(r);const i=s||r.props&&r.props.type==="number";Jt(e,t?"change":"input",o=>{o.target.composing||e[Ls](Ei(e.value,n,i))}),(n||i)&&Jt(e,"change",()=>{e.value=Ei(e.value,n,i)}),t||(Jt(e,"compositionstart",du),Jt(e,"compositionend",Si),Jt(e,"change",Si))},mounted(e,{value:t}){e.value=t??""},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:s,trim:r,number:i}},o){if(e[Ls]=Ci(o),e.composing)return;const l=(i||e.type==="number")&&!/^0\d/.test(e.value)?fr(e.value):e.value,a=t??"";if(l===a)return;const u=e.getRootNode();(u instanceof Document||u instanceof ShadowRoot)&&u.activeElement===e&&e.type!=="range"&&(s&&t===n||r&&e.value.trim()===a)||(e.value=a)}},mu={esc:"escape",space:" ",up:"arrow-up",left:"arrow-left",right:"arrow-right",down:"arrow-down",delete:"backspace"},bu=(e,t)=>{const n=e._withKeys||(e._withKeys={}),s=t.join(".");return n[s]||(n[s]=(r=>{if(!("key"in r))return;const i=Lt(r.key);if(t.some(o=>o===i||mu[o]===i))return e(r)}))},yu=ve({patchProp:fu},Fc);let Ai;function vu(){return Ai||(Ai=wc(yu))}const xu=((...e)=>{const t=vu().createApp(...e),{mount:n}=t;return t.mount=s=>{const r=wu(s);if(!r)return;const i=t._component;!F(i)&&!i.render&&!i.template&&(i.template=r.innerHTML),r.nodeType===1&&(r.textContent="");const o=n(r,!1,_u(r));return r instanceof Element&&(r.removeAttribute("v-cloak"),r.setAttribute("data-v-app","")),o},t});function _u(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function wu(e){return fe(e)?document.querySelector(e):e}/*! + * vue-router v4.6.4 + * (c) 2025 Eduardo San Martin Morote + * @license MIT + */const Yt=typeof document<"u";function pl(e){return typeof e=="object"||"displayName"in e||"props"in e||"__vccOpts"in e}function ku(e){return e.__esModule||e[Symbol.toStringTag]==="Module"||e.default&&pl(e.default)}const Y=Object.assign;function Ms(e,t){const n={};for(const s in t){const r=t[s];n[s]=Qe(r)?r.map(e):e(r)}return n}const Cn=()=>{},Qe=Array.isArray;function Ri(e,t){const n={};for(const s in e)n[s]=s in t?t[s]:e[s];return n}const dl=/#/g,Cu=/&/g,Su=/\//g,Eu=/=/g,Au=/\?/g,gl=/\+/g,Ru=/%5B/g,Tu=/%5D/g,ml=/%5E/g,Pu=/%60/g,bl=/%7B/g,Iu=/%7C/g,yl=/%7D/g,Ou=/%20/g;function Tr(e){return e==null?"":encodeURI(""+e).replace(Iu,"|").replace(Ru,"[").replace(Tu,"]")}function Nu(e){return Tr(e).replace(bl,"{").replace(yl,"}").replace(ml,"^")}function er(e){return Tr(e).replace(gl,"%2B").replace(Ou,"+").replace(dl,"%23").replace(Cu,"%26").replace(Pu,"`").replace(bl,"{").replace(yl,"}").replace(ml,"^")}function Lu(e){return er(e).replace(Eu,"%3D")}function Mu(e){return Tr(e).replace(dl,"%23").replace(Au,"%3F")}function $u(e){return Mu(e).replace(Su,"%2F")}function Ln(e){if(e==null)return null;try{return decodeURIComponent(""+e)}catch{}return""+e}const Du=/\/$/,Bu=e=>e.replace(Du,"");function $s(e,t,n="/"){let s,r={},i="",o="";const l=t.indexOf("#");let a=t.indexOf("?");return a=l>=0&&a>l?-1:a,a>=0&&(s=t.slice(0,a),i=t.slice(a,l>0?l:t.length),r=e(i.slice(1))),l>=0&&(s=s||t.slice(0,l),o=t.slice(l,t.length)),s=Fu(s??t,n),{fullPath:s+i+o,path:s,query:r,hash:Ln(o)}}function zu(e,t){const n=t.query?e(t.query):"";return t.path+(n&&"?")+n+(t.hash||"")}function Ti(e,t){return!t||!e.toLowerCase().startsWith(t.toLowerCase())?e:e.slice(t.length)||"/"}function Vu(e,t,n){const s=t.matched.length-1,r=n.matched.length-1;return s>-1&&s===r&&on(t.matched[s],n.matched[r])&&vl(t.params,n.params)&&e(t.query)===e(n.query)&&t.hash===n.hash}function on(e,t){return(e.aliasOf||e)===(t.aliasOf||t)}function vl(e,t){if(Object.keys(e).length!==Object.keys(t).length)return!1;for(var n in e)if(!Hu(e[n],t[n]))return!1;return!0}function Hu(e,t){return Qe(e)?Pi(e,t):Qe(t)?Pi(t,e):(e==null?void 0:e.valueOf())===(t==null?void 0:t.valueOf())}function Pi(e,t){return Qe(t)?e.length===t.length&&e.every((n,s)=>n===t[s]):e.length===1&&e[0]===t}function Fu(e,t){if(e.startsWith("/"))return e;if(!e)return t;const n=t.split("/"),s=e.split("/"),r=s[s.length-1];(r===".."||r===".")&&s.push("");let i=n.length-1,o,l;for(o=0;o1&&i--;else break;return n.slice(0,i).join("/")+"/"+s.slice(o).join("/")}const Et={path:"/",name:void 0,params:{},query:{},hash:"",fullPath:"/",matched:[],meta:{},redirectedFrom:void 0};let tr=(function(e){return e.pop="pop",e.push="push",e})({}),Ds=(function(e){return e.back="back",e.forward="forward",e.unknown="",e})({});function ju(e){if(!e)if(Yt){const t=document.querySelector("base");e=t&&t.getAttribute("href")||"/",e=e.replace(/^\w+:\/\/[^\/]+/,"")}else e="/";return e[0]!=="/"&&e[0]!=="#"&&(e="/"+e),Bu(e)}const Uu=/^[^#]+#/;function Ku(e,t){return e.replace(Uu,"#")+t}function Gu(e,t){const n=document.documentElement.getBoundingClientRect(),s=e.getBoundingClientRect();return{behavior:t.behavior,left:s.left-n.left-(t.left||0),top:s.top-n.top-(t.top||0)}}const xs=()=>({left:window.scrollX,top:window.scrollY});function qu(e){let t;if("el"in e){const n=e.el,s=typeof n=="string"&&n.startsWith("#"),r=typeof n=="string"?s?document.getElementById(n.slice(1)):document.querySelector(n):n;if(!r)return;t=Gu(r,e)}else t=e;"scrollBehavior"in document.documentElement.style?window.scrollTo(t):window.scrollTo(t.left!=null?t.left:window.scrollX,t.top!=null?t.top:window.scrollY)}function Ii(e,t){return(history.state?history.state.position-t:-1)+e}const nr=new Map;function Wu(e,t){nr.set(e,t)}function Zu(e){const t=nr.get(e);return nr.delete(e),t}function Qu(e){return typeof e=="string"||e&&typeof e=="object"}function xl(e){return typeof e=="string"||typeof e=="symbol"}let he=(function(e){return e[e.MATCHER_NOT_FOUND=1]="MATCHER_NOT_FOUND",e[e.NAVIGATION_GUARD_REDIRECT=2]="NAVIGATION_GUARD_REDIRECT",e[e.NAVIGATION_ABORTED=4]="NAVIGATION_ABORTED",e[e.NAVIGATION_CANCELLED=8]="NAVIGATION_CANCELLED",e[e.NAVIGATION_DUPLICATED=16]="NAVIGATION_DUPLICATED",e})({});const _l=Symbol("");he.MATCHER_NOT_FOUND+"",he.NAVIGATION_GUARD_REDIRECT+"",he.NAVIGATION_ABORTED+"",he.NAVIGATION_CANCELLED+"",he.NAVIGATION_DUPLICATED+"";function ln(e,t){return Y(new Error,{type:e,[_l]:!0},t)}function dt(e,t){return e instanceof Error&&_l in e&&(t==null||!!(e.type&t))}const Ju=["params","query","hash"];function Yu(e){if(typeof e=="string")return e;if(e.path!=null)return e.path;const t={};for(const n of Ju)n in e&&(t[n]=e[n]);return JSON.stringify(t,null,2)}function Xu(e){const t={};if(e===""||e==="?")return t;const n=(e[0]==="?"?e.slice(1):e).split("&");for(let s=0;sr&&er(r)):[s&&er(s)]).forEach(r=>{r!==void 0&&(t+=(t.length?"&":"")+n,r!=null&&(t+="="+r))})}return t}function ef(e){const t={};for(const n in e){const s=e[n];s!==void 0&&(t[n]=Qe(s)?s.map(r=>r==null?null:""+r):s==null?s:""+s)}return t}const tf=Symbol(""),Ni=Symbol(""),Pr=Symbol(""),wl=Symbol(""),sr=Symbol("");function pn(){let e=[];function t(s){return e.push(s),()=>{const r=e.indexOf(s);r>-1&&e.splice(r,1)}}function n(){e=[]}return{add:t,list:()=>e.slice(),reset:n}}function Tt(e,t,n,s,r,i=o=>o()){const o=s&&(s.enterCallbacks[r]=s.enterCallbacks[r]||[]);return()=>new Promise((l,a)=>{const u=p=>{p===!1?a(ln(he.NAVIGATION_ABORTED,{from:n,to:t})):p instanceof Error?a(p):Qu(p)?a(ln(he.NAVIGATION_GUARD_REDIRECT,{from:t,to:p})):(o&&s.enterCallbacks[r]===o&&typeof p=="function"&&o.push(p),l())},c=i(()=>e.call(s&&s.instances[r],t,n,u));let f=Promise.resolve(c);e.length<3&&(f=f.then(u)),f.catch(p=>a(p))})}function Bs(e,t,n,s,r=i=>i()){const i=[];for(const o of e)for(const l in o.components){let a=o.components[l];if(!(t!=="beforeRouteEnter"&&!o.instances[l]))if(pl(a)){const u=(a.__vccOpts||a)[t];u&&i.push(Tt(u,n,s,o,l,r))}else{let u=a();i.push(()=>u.then(c=>{if(!c)throw new Error(`Couldn't resolve component "${l}" at "${o.path}"`);const f=ku(c)?c.default:c;o.mods[l]=c,o.components[l]=f;const p=(f.__vccOpts||f)[t];return p&&Tt(p,n,s,o,l,r)()}))}}return i}function nf(e,t){const n=[],s=[],r=[],i=Math.max(t.matched.length,e.matched.length);for(let o=0;oon(u,l))?s.push(l):n.push(l));const a=e.matched[o];a&&(t.matched.find(u=>on(u,a))||r.push(a))}return[n,s,r]}/*! + * vue-router v4.6.4 + * (c) 2025 Eduardo San Martin Morote + * @license MIT + */let sf=()=>location.protocol+"//"+location.host;function kl(e,t){const{pathname:n,search:s,hash:r}=t,i=e.indexOf("#");if(i>-1){let o=r.includes(e.slice(i))?e.slice(i).length:1,l=r.slice(o);return l[0]!=="/"&&(l="/"+l),Ti(l,"")}return Ti(n,e)+s+r}function rf(e,t,n,s){let r=[],i=[],o=null;const l=({state:p})=>{const g=kl(e,location),b=n.value,m=t.value;let v=0;if(p){if(n.value=g,t.value=p,o&&o===b){o=null;return}v=m?p.position-m.position:0}else s(g);r.forEach(x=>{x(n.value,b,{delta:v,type:tr.pop,direction:v?v>0?Ds.forward:Ds.back:Ds.unknown})})};function a(){o=n.value}function u(p){r.push(p);const g=()=>{const b=r.indexOf(p);b>-1&&r.splice(b,1)};return i.push(g),g}function c(){if(document.visibilityState==="hidden"){const{history:p}=window;if(!p.state)return;p.replaceState(Y({},p.state,{scroll:xs()}),"")}}function f(){for(const p of i)p();i=[],window.removeEventListener("popstate",l),window.removeEventListener("pagehide",c),document.removeEventListener("visibilitychange",c)}return window.addEventListener("popstate",l),window.addEventListener("pagehide",c),document.addEventListener("visibilitychange",c),{pauseListeners:a,listen:u,destroy:f}}function Li(e,t,n,s=!1,r=!1){return{back:e,current:t,forward:n,replaced:s,position:window.history.length,scroll:r?xs():null}}function of(e){const{history:t,location:n}=window,s={value:kl(e,n)},r={value:t.state};r.value||i(s.value,{back:null,current:s.value,forward:null,position:t.length-1,replaced:!0,scroll:null},!0);function i(a,u,c){const f=e.indexOf("#"),p=f>-1?(n.host&&document.querySelector("base")?e:e.slice(f))+a:sf()+e+a;try{t[c?"replaceState":"pushState"](u,"",p),r.value=u}catch(g){console.error(g),n[c?"replace":"assign"](p)}}function o(a,u){i(a,Y({},t.state,Li(r.value.back,a,r.value.forward,!0),u,{position:r.value.position}),!0),s.value=a}function l(a,u){const c=Y({},r.value,t.state,{forward:a,scroll:xs()});i(c.current,c,!0),i(a,Y({},Li(s.value,a,null),{position:c.position+1},u),!1),s.value=a}return{location:s,state:r,push:l,replace:o}}function lf(e){e=ju(e);const t=of(e),n=rf(e,t.state,t.location,t.replace);function s(i,o=!0){o||n.pauseListeners(),history.go(i)}const r=Y({location:"",base:e,go:s,createHref:Ku.bind(null,e)},t,n);return Object.defineProperty(r,"location",{enumerable:!0,get:()=>t.location.value}),Object.defineProperty(r,"state",{enumerable:!0,get:()=>t.state.value}),r}function af(e){return e=location.host?e||location.pathname+location.search:"",e.includes("#")||(e+="#"),lf(e)}let Ft=(function(e){return e[e.Static=0]="Static",e[e.Param=1]="Param",e[e.Group=2]="Group",e})({});var be=(function(e){return e[e.Static=0]="Static",e[e.Param=1]="Param",e[e.ParamRegExp=2]="ParamRegExp",e[e.ParamRegExpEnd=3]="ParamRegExpEnd",e[e.EscapeNext=4]="EscapeNext",e})(be||{});const cf={type:Ft.Static,value:""},uf=/[a-zA-Z0-9_]/;function ff(e){if(!e)return[[]];if(e==="/")return[[cf]];if(!e.startsWith("/"))throw new Error(`Invalid path "${e}"`);function t(g){throw new Error(`ERR (${n})/"${u}": ${g}`)}let n=be.Static,s=n;const r=[];let i;function o(){i&&r.push(i),i=[]}let l=0,a,u="",c="";function f(){u&&(n===be.Static?i.push({type:Ft.Static,value:u}):n===be.Param||n===be.ParamRegExp||n===be.ParamRegExpEnd?(i.length>1&&(a==="*"||a==="+")&&t(`A repeatable param (${u}) must be alone in its segment. eg: '/:ids+.`),i.push({type:Ft.Param,value:u,regexp:c,repeatable:a==="*"||a==="+",optional:a==="*"||a==="?"})):t("Invalid state to consume buffer"),u="")}function p(){u+=a}for(;lt.length?t.length===1&&t[0]===Pe.Static+Pe.Segment?1:-1:0}function Cl(e,t){let n=0;const s=e.score,r=t.score;for(;n0&&t[t.length-1]<0}const mf={strict:!1,end:!0,sensitive:!1};function bf(e,t,n){const s=df(ff(e.path),n),r=Y(s,{record:e,parent:t,children:[],alias:[]});return t&&!r.record.aliasOf==!t.record.aliasOf&&t.children.push(r),r}function yf(e,t){const n=[],s=new Map;t=Ri(mf,t);function r(f){return s.get(f)}function i(f,p,g){const b=!g,m=Bi(f);m.aliasOf=g&&g.record;const v=Ri(t,f),x=[m];if("alias"in f){const N=typeof f.alias=="string"?[f.alias]:f.alias;for(const j of N)x.push(Bi(Y({},m,{components:g?g.record.components:m.components,path:j,aliasOf:g?g.record:m})))}let y,T;for(const N of x){const{path:j}=N;if(p&&j[0]!=="/"){const K=p.record.path,U=K[K.length-1]==="/"?"":"/";N.path=p.record.path+(j&&U+j)}if(y=bf(N,p,v),g?g.alias.push(y):(T=T||y,T!==y&&T.alias.push(y),b&&f.name&&!zi(y)&&o(f.name)),Sl(y)&&a(y),m.children){const K=m.children;for(let U=0;U{o(T)}:Cn}function o(f){if(xl(f)){const p=s.get(f);p&&(s.delete(f),n.splice(n.indexOf(p),1),p.children.forEach(o),p.alias.forEach(o))}else{const p=n.indexOf(f);p>-1&&(n.splice(p,1),f.record.name&&s.delete(f.record.name),f.children.forEach(o),f.alias.forEach(o))}}function l(){return n}function a(f){const p=_f(f,n);n.splice(p,0,f),f.record.name&&!zi(f)&&s.set(f.record.name,f)}function u(f,p){let g,b={},m,v;if("name"in f&&f.name){if(g=s.get(f.name),!g)throw ln(he.MATCHER_NOT_FOUND,{location:f});v=g.record.name,b=Y(Di(p.params,g.keys.filter(T=>!T.optional).concat(g.parent?g.parent.keys.filter(T=>T.optional):[]).map(T=>T.name)),f.params&&Di(f.params,g.keys.map(T=>T.name))),m=g.stringify(b)}else if(f.path!=null)m=f.path,g=n.find(T=>T.re.test(m)),g&&(b=g.parse(m),v=g.record.name);else{if(g=p.name?s.get(p.name):n.find(T=>T.re.test(p.path)),!g)throw ln(he.MATCHER_NOT_FOUND,{location:f,currentLocation:p});v=g.record.name,b=Y({},p.params,f.params),m=g.stringify(b)}const x=[];let y=g;for(;y;)x.unshift(y.record),y=y.parent;return{name:v,path:m,params:b,matched:x,meta:xf(x)}}e.forEach(f=>i(f));function c(){n.length=0,s.clear()}return{addRoute:i,resolve:u,removeRoute:o,clearRoutes:c,getRoutes:l,getRecordMatcher:r}}function Di(e,t){const n={};for(const s of t)s in e&&(n[s]=e[s]);return n}function Bi(e){const t={path:e.path,redirect:e.redirect,name:e.name,meta:e.meta||{},aliasOf:e.aliasOf,beforeEnter:e.beforeEnter,props:vf(e),children:e.children||[],instances:{},leaveGuards:new Set,updateGuards:new Set,enterCallbacks:{},components:"components"in e?e.components||null:e.component&&{default:e.component}};return Object.defineProperty(t,"mods",{value:{}}),t}function vf(e){const t={},n=e.props||!1;if("component"in e)t.default=n;else for(const s in e.components)t[s]=typeof n=="object"?n[s]:n;return t}function zi(e){for(;e;){if(e.record.aliasOf)return!0;e=e.parent}return!1}function xf(e){return e.reduce((t,n)=>Y(t,n.meta),{})}function _f(e,t){let n=0,s=t.length;for(;n!==s;){const i=n+s>>1;Cl(e,t[i])<0?s=i:n=i+1}const r=wf(e);return r&&(s=t.lastIndexOf(r,s-1)),s}function wf(e){let t=e;for(;t=t.parent;)if(Sl(t)&&Cl(e,t)===0)return t}function Sl({record:e}){return!!(e.name||e.components&&Object.keys(e.components).length||e.redirect)}function Vi(e){const t=_t(Pr),n=_t(wl),s=pe(()=>{const a=It(e.to);return t.resolve(a)}),r=pe(()=>{const{matched:a}=s.value,{length:u}=a,c=a[u-1],f=n.matched;if(!c||!f.length)return-1;const p=f.findIndex(on.bind(null,c));if(p>-1)return p;const g=Hi(a[u-2]);return u>1&&Hi(c)===g&&f[f.length-1].path!==g?f.findIndex(on.bind(null,a[u-2])):p}),i=pe(()=>r.value>-1&&Af(n.params,s.value.params)),o=pe(()=>r.value>-1&&r.value===n.matched.length-1&&vl(n.params,s.value.params));function l(a={}){if(Ef(a)){const u=t[It(e.replace)?"replace":"push"](It(e.to)).catch(Cn);return e.viewTransition&&typeof document<"u"&&"startViewTransition"in document&&document.startViewTransition(()=>u),u}return Promise.resolve()}return{route:s,href:pe(()=>s.value.href),isActive:i,isExactActive:o,navigate:l}}function kf(e){return e.length===1?e[0]:e}const Cf=ds({name:"RouterLink",compatConfig:{MODE:3},props:{to:{type:[String,Object],required:!0},replace:Boolean,activeClass:String,exactActiveClass:String,custom:Boolean,ariaCurrentValue:{type:String,default:"page"},viewTransition:Boolean},useLink:Vi,setup(e,{slots:t}){const n=hs(Vi(e)),{options:s}=_t(Pr),r=pe(()=>({[Fi(e.activeClass,s.linkActiveClass,"router-link-active")]:n.isActive,[Fi(e.exactActiveClass,s.linkExactActiveClass,"router-link-exact-active")]:n.isExactActive}));return()=>{const i=t.default&&kf(t.default(n));return e.custom?i:Rr("a",{"aria-current":n.isExactActive?e.ariaCurrentValue:null,href:n.href,onClick:n.navigate,class:r.value},i)}}}),Sf=Cf;function Ef(e){if(!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)&&!e.defaultPrevented&&!(e.button!==void 0&&e.button!==0)){if(e.currentTarget&&e.currentTarget.getAttribute){const t=e.currentTarget.getAttribute("target");if(/\b_blank\b/i.test(t))return}return e.preventDefault&&e.preventDefault(),!0}}function Af(e,t){for(const n in t){const s=t[n],r=e[n];if(typeof s=="string"){if(s!==r)return!1}else if(!Qe(r)||r.length!==s.length||s.some((i,o)=>i.valueOf()!==r[o].valueOf()))return!1}return!0}function Hi(e){return e?e.aliasOf?e.aliasOf.path:e.path:""}const Fi=(e,t,n)=>e??t??n,Rf=ds({name:"RouterView",inheritAttrs:!1,props:{name:{type:String,default:"default"},route:Object},compatConfig:{MODE:3},setup(e,{attrs:t,slots:n}){const s=_t(sr),r=pe(()=>e.route||s.value),i=_t(Ni,0),o=pe(()=>{let u=It(i);const{matched:c}=r.value;let f;for(;(f=c[u])&&!f.components;)u++;return u}),l=pe(()=>r.value.matched[o.value]);Kn(Ni,pe(()=>o.value+1)),Kn(tf,l),Kn(sr,r);const a=De();return Ot(()=>[a.value,l.value,e.name],([u,c,f],[p,g,b])=>{c&&(c.instances[f]=u,g&&g!==c&&u&&u===p&&(c.leaveGuards.size||(c.leaveGuards=g.leaveGuards),c.updateGuards.size||(c.updateGuards=g.updateGuards))),u&&c&&(!g||!on(c,g)||!p)&&(c.enterCallbacks[f]||[]).forEach(m=>m(u))},{flush:"post"}),()=>{const u=r.value,c=e.name,f=l.value,p=f&&f.components[c];if(!p)return ji(n.default,{Component:p,route:u});const g=f.props[c],b=g?g===!0?u.params:typeof g=="function"?g(u):g:null,v=Rr(p,Y({},b,t,{onVnodeUnmounted:x=>{x.component.isUnmounted&&(f.instances[c]=null)},ref:a}));return ji(n.default,{Component:v,route:u})||v}}});function ji(e,t){if(!e)return null;const n=e(t);return n.length===1?n[0]:n}const Tf=Rf;function Pf(e){const t=yf(e.routes,e),n=e.parseQuery||Xu,s=e.stringifyQuery||Oi,r=e.history,i=pn(),o=pn(),l=pn(),a=ka(Et);let u=Et;Yt&&e.scrollBehavior&&"scrollRestoration"in history&&(history.scrollRestoration="manual");const c=Ms.bind(null,C=>""+C),f=Ms.bind(null,$u),p=Ms.bind(null,Ln);function g(C,$){let O,D;return xl(C)?(O=t.getRecordMatcher(C),D=$):D=C,t.addRoute(D,O)}function b(C){const $=t.getRecordMatcher(C);$&&t.removeRoute($)}function m(){return t.getRoutes().map(C=>C.record)}function v(C){return!!t.getRecordMatcher(C)}function x(C,$){if($=Y({},$||a.value),typeof C=="string"){const _=$s(n,C,$.path),k=t.resolve({path:_.path},$),S=r.createHref(_.fullPath);return Y(_,k,{params:p(k.params),hash:Ln(_.hash),redirectedFrom:void 0,href:S})}let O;if(C.path!=null)O=Y({},C,{path:$s(n,C.path,$.path).path});else{const _=Y({},C.params);for(const k in _)_[k]==null&&delete _[k];O=Y({},C,{params:f(_)}),$.params=f($.params)}const D=t.resolve(O,$),q=C.hash||"";D.params=c(p(D.params));const h=zu(s,Y({},C,{hash:Nu(q),path:D.path})),d=r.createHref(h);return Y({fullPath:h,hash:q,query:s===Oi?ef(C.query):C.query||{}},D,{redirectedFrom:void 0,href:d})}function y(C){return typeof C=="string"?$s(n,C,a.value.path):Y({},C)}function T(C,$){if(u!==C)return ln(he.NAVIGATION_CANCELLED,{from:$,to:C})}function N(C){return U(C)}function j(C){return N(Y(y(C),{replace:!0}))}function K(C,$){const O=C.matched[C.matched.length-1];if(O&&O.redirect){const{redirect:D}=O;let q=typeof D=="function"?D(C,$):D;return typeof q=="string"&&(q=q.includes("?")||q.includes("#")?q=y(q):{path:q},q.params={}),Y({query:C.query,hash:C.hash,params:q.path!=null?{}:C.params},q)}}function U(C,$){const O=u=x(C),D=a.value,q=C.state,h=C.force,d=C.replace===!0,_=K(O,D);if(_)return U(Y(y(_),{state:typeof _=="object"?Y({},q,_.state):q,force:h,replace:d}),$||O);const k=O;k.redirectedFrom=$;let S;return!h&&Vu(s,D,O)&&(S=ln(he.NAVIGATION_DUPLICATED,{to:k,from:D}),Je(D,D,!0,!1)),(S?Promise.resolve(S):G(k,D)).catch(w=>dt(w)?dt(w,he.NAVIGATION_GUARD_REDIRECT)?w:Ct(w):J(w,k,D)).then(w=>{if(w){if(dt(w,he.NAVIGATION_GUARD_REDIRECT))return U(Y({replace:d},y(w.to),{state:typeof w.to=="object"?Y({},q,w.to.state):q,force:h}),$||k)}else w=M(k,D,!0,d,q);return ne(k,D,w),w})}function me(C,$){const O=T(C,$);return O?Promise.reject(O):Promise.resolve()}function P(C){const $=Wt.values().next().value;return $&&typeof $.runWithContext=="function"?$.runWithContext(C):C()}function G(C,$){let O;const[D,q,h]=nf(C,$);O=Bs(D.reverse(),"beforeRouteLeave",C,$);for(const _ of D)_.leaveGuards.forEach(k=>{O.push(Tt(k,C,$))});const d=me.bind(null,C,$);return O.push(d),He(O).then(()=>{O=[];for(const _ of i.list())O.push(Tt(_,C,$));return O.push(d),He(O)}).then(()=>{O=Bs(q,"beforeRouteUpdate",C,$);for(const _ of q)_.updateGuards.forEach(k=>{O.push(Tt(k,C,$))});return O.push(d),He(O)}).then(()=>{O=[];for(const _ of h)if(_.beforeEnter)if(Qe(_.beforeEnter))for(const k of _.beforeEnter)O.push(Tt(k,C,$));else O.push(Tt(_.beforeEnter,C,$));return O.push(d),He(O)}).then(()=>(C.matched.forEach(_=>_.enterCallbacks={}),O=Bs(h,"beforeRouteEnter",C,$,P),O.push(d),He(O))).then(()=>{O=[];for(const _ of o.list())O.push(Tt(_,C,$));return O.push(d),He(O)}).catch(_=>dt(_,he.NAVIGATION_CANCELLED)?_:Promise.reject(_))}function ne(C,$,O){l.list().forEach(D=>P(()=>D(C,$,O)))}function M(C,$,O,D,q){const h=T(C,$);if(h)return h;const d=$===Et,_=Yt?history.state:{};O&&(D||d?r.replace(C.fullPath,Y({scroll:d&&_&&_.scroll},q)):r.push(C.fullPath,q)),a.value=C,Je(C,$,O,d),Ct()}let Q;function xe(){Q||(Q=r.listen((C,$,O)=>{if(!Mt.listening)return;const D=x(C),q=K(D,Mt.currentRoute.value);if(q){U(Y(q,{replace:!0,force:!0}),D).catch(Cn);return}u=D;const h=a.value;Yt&&Wu(Ii(h.fullPath,O.delta),xs()),G(D,h).catch(d=>dt(d,he.NAVIGATION_ABORTED|he.NAVIGATION_CANCELLED)?d:dt(d,he.NAVIGATION_GUARD_REDIRECT)?(U(Y(y(d.to),{force:!0}),D).then(_=>{dt(_,he.NAVIGATION_ABORTED|he.NAVIGATION_DUPLICATED)&&!O.delta&&O.type===tr.pop&&r.go(-1,!1)}).catch(Cn),Promise.reject()):(O.delta&&r.go(-O.delta,!1),J(d,D,h))).then(d=>{d=d||M(D,h,!1),d&&(O.delta&&!dt(d,he.NAVIGATION_CANCELLED)?r.go(-O.delta,!1):O.type===tr.pop&&dt(d,he.NAVIGATION_ABORTED|he.NAVIGATION_DUPLICATED)&&r.go(-1,!1)),ne(D,h,d)}).catch(Cn)}))}let Be=pn(),de=pn(),re;function J(C,$,O){Ct(C);const D=de.list();return D.length?D.forEach(q=>q(C,$,O)):console.error(C),Promise.reject(C)}function ft(){return re&&a.value!==Et?Promise.resolve():new Promise((C,$)=>{Be.add([C,$])})}function Ct(C){return re||(re=!C,xe(),Be.list().forEach(([$,O])=>C?O(C):$()),Be.reset()),C}function Je(C,$,O,D){const{scrollBehavior:q}=e;if(!Yt||!q)return Promise.resolve();const h=!O&&Zu(Ii(C.fullPath,0))||(D||!O)&&history.state&&history.state.scroll||null;return Eo().then(()=>q(C,$,h)).then(d=>d&&qu(d)).catch(d=>J(d,C,$))}const Le=C=>r.go(C);let qt;const Wt=new Set,Mt={currentRoute:a,listening:!0,addRoute:g,removeRoute:b,clearRoutes:t.clearRoutes,hasRoute:v,getRoutes:m,resolve:x,options:e,push:N,replace:j,go:Le,back:()=>Le(-1),forward:()=>Le(1),beforeEach:i.add,beforeResolve:o.add,afterEach:l.add,onError:de.add,isReady:ft,install(C){C.component("RouterLink",Sf),C.component("RouterView",Tf),C.config.globalProperties.$router=Mt,Object.defineProperty(C.config.globalProperties,"$route",{enumerable:!0,get:()=>It(a)}),Yt&&!qt&&a.value===Et&&(qt=!0,N(r.location).catch(D=>{}));const $={};for(const D in Et)Object.defineProperty($,D,{get:()=>a.value[D],enumerable:!0});C.provide(Pr,Mt),C.provide(wl,wo($)),C.provide(sr,a);const O=C.unmount;Wt.add(C),C.unmount=function(){Wt.delete(C),Wt.size<1&&(u=Et,Q&&Q(),Q=null,a.value=Et,qt=!1,re=!1),O()}}};function He(C){return C.reduce(($,O)=>$.then(()=>P(O)),Promise.resolve())}return Mt}var If=Object.defineProperty,Of=(e,t,n)=>t in e?If(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n,ce=(e,t,n)=>Of(e,typeof t!="symbol"?t+"":t,n);const El=(e,t)=>{const n=e.__vccOpts||e;for(const[s,r]of t)n[s]=r;return n},Nf={class:"toast-icon"},Lf={key:0,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none"},Mf={key:1,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none"},$f={key:2,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none"},Df={key:3,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none"},Bf={class:"toast-content"},zf=ds({__name:"Toast",props:{message:{},type:{default:"info"},duration:{default:3e3},visible:{type:Boolean,default:void 0},defaultVisible:{type:Boolean,default:!1}},emits:["update:visible"],setup(e,{emit:t}){const n=e,s=t,r=De(n.visible??n.defaultVisible),i=pe(()=>n.visible===void 0?r.value:n.visible);let o=null;Ot(()=>n.visible,u=>{u!==void 0&&(r.value=u)});function l(){o&&(clearTimeout(o),o=null)}function a(u){i.value!==u&&(n.visible===void 0&&(r.value=u),s("update:visible",u))}return Ot(()=>[i.value,n.duration],([u,c])=>{l(),!(!u||c<=0)&&(o=setTimeout(()=>{o=null,a(!1)},c))},{immediate:!0}),kr(()=>{l()}),(u,c)=>(ue(),ts(Kc,{name:"toast"},{default:Io(()=>[i.value?(ue(),ye("div",{key:0,class:an(["toast",`toast-${e.type}`])},[_e("div",Nf,[e.type==="success"?(ue(),ye("svg",Lf,[...c[0]||(c[0]=[_e("path",{d:"M10 0C4.48 0 0 4.48 0 10C0 15.52 4.48 20 10 20C15.52 20 20 15.52 20 10C20 4.48 15.52 0 10 0ZM8 15L3 10L4.41 8.59L8 12.17L15.59 4.58L17 6L8 15Z",fill:"currentColor"},null,-1)])])):e.type==="error"?(ue(),ye("svg",Mf,[...c[1]||(c[1]=[_e("path",{d:"M10 0C4.48 0 0 4.48 0 10C0 15.52 4.48 20 10 20C15.52 20 20 15.52 20 10C20 4.48 15.52 0 10 0ZM11 15H9V13H11V15ZM11 11H9V5H11V11Z",fill:"currentColor"},null,-1)])])):e.type==="warning"?(ue(),ye("svg",$f,[...c[2]||(c[2]=[_e("path",{d:"M1 17H19L10 2L1 17ZM11 14H9V12H11V14ZM11 10H9V6H11V10Z",fill:"currentColor"},null,-1)])])):(ue(),ye("svg",Df,[...c[3]||(c[3]=[_e("path",{d:"M10 0C4.48 0 0 4.48 0 10C0 15.52 4.48 20 10 20C15.52 20 20 15.52 20 10C20 4.48 15.52 0 10 0ZM11 15H9V9H11V15ZM11 7H9V5H11V7Z",fill:"currentColor"},null,-1)])]))]),_e("div",Bf,Pt(e.message),1)],2)):it("",!0)]),_:1}))}}),Vf=El(zf,[["__scopeId","data-v-989af09a"]]),zs=De({visible:!1,message:"",type:"info",duration:3e3}),At=De({visible:!1,title:"确认操作",message:"",type:"info",confirmText:"确定",cancelText:"取消",resolve:null});function Hf(){const e=t=>{zs.value={visible:!0,message:t.message,type:t.type||"info",duration:t.duration||3e3}};return{toastState:zs,confirmState:At,show:e,success:(t,n=3e3)=>{e({message:t,type:"success",duration:n})},error:(t,n=3e3)=>{e({message:t,type:"error",duration:n})},warning:(t,n=3e3)=>{e({message:t,type:"warning",duration:n})},info:(t,n=3e3)=>{e({message:t,type:"info",duration:n})},hide:()=>{zs.value.visible=!1},confirm:t=>new Promise(n=>{At.value={visible:!0,title:t.title||"确认操作",message:t.message,type:t.type||"info",confirmText:t.confirmText||"确定",cancelText:t.cancelText||"取消",resolve:n}}),handleConfirm:()=>{At.value.resolve&&At.value.resolve(!0),At.value.visible=!1},handleCancel:()=>{At.value.resolve&&At.value.resolve(!1),At.value.visible=!1}}}const Ff={class:"zt-input__control"},jf={key:0,class:"zt-input__prefix"},Uf=["value","placeholder","disabled","readonly","maxlength","aria-invalid"],Kf=["value","type","placeholder","disabled","readonly","maxlength","aria-invalid"],Gf={key:4,class:"zt-input__suffix"},qf={key:0,class:"zt-input__footer"},Wf={key:0,class:"zt-input__message"},Zf={key:1,class:"zt-input__word-limit"},Qf=ds({inheritAttrs:!1,__name:"Input",props:{modelValue:{},defaultModelValue:{default:""},type:{default:"text"},placeholder:{default:""},disabled:{type:Boolean,default:!1},readonly:{type:Boolean,default:!1},clearable:{type:Boolean,default:!1},maxlength:{},size:{default:"medium"},status:{},message:{default:""},showWordLimit:{type:Boolean,default:!1}},emits:["update:modelValue","change","input","clear","focus","blur"],setup(e,{emit:t}){const n=e,s=t,r=ec(),i=De(n.modelValue??n.defaultModelValue),o=De(!1);Ot(()=>n.modelValue,P=>{P!==void 0&&(i.value=P)});const l=pe(()=>n.modelValue!==void 0?n.modelValue:i.value),a=pe(()=>n.type==="textarea"),u=pe(()=>`${l.value}`),c=pe(()=>u.value.length),f=pe(()=>n.clearable&&!n.disabled&&!n.readonly&&c.value>0),p=pe(()=>{const P=typeof n.maxlength=="number"?` / ${n.maxlength}`:"";return`${c.value}${P}`}),g=pe(()=>!!n.message||n.showWordLimit),b=pe(()=>["zt-input",`zt-input--${n.size}`,{"zt-input--textarea":a.value,"is-disabled":n.disabled,"is-readonly":n.readonly,"is-success":n.status==="success","is-warning":n.status==="warning","is-error":n.status==="error","has-clear":f.value}]);function m(P){return n.type==="number"&&P!==""?Number(P):P}function v(P){return P.target.value}function x(P){n.modelValue===void 0&&(i.value=P),s("update:modelValue",P)}function y(P){if(o.value||P.isComposing)return;const G=P.target,ne=m(v(P));x(ne),s("input",ne),n.modelValue!==void 0&&(G.value=String(l.value))}function T(){o.value=!0}function N(P){o.value&&(o.value=!1,y(P))}function j(P){s("change",m(v(P)))}function K(){x(""),s("change",""),s("clear")}function U(P){s("focus",P)}function me(P){s("blur",P)}return(P,G)=>(ue(),ye("div",{class:an(b.value)},[_e("div",Ff,[P.$slots.prefix?(ue(),ye("span",jf,[Jr(P.$slots,"prefix",{},void 0)])):it("",!0),a.value?(ue(),ye("textarea",Js({key:1},It(r),{class:"zt-input__inner zt-input__textarea",value:l.value,placeholder:e.placeholder,disabled:e.disabled,readonly:e.readonly,maxlength:e.maxlength,"aria-invalid":e.status==="error"?"true":void 0,onInput:y,onChange:j,onCompositionstart:T,onCompositionend:N,onFocus:U,onBlur:me}),null,16,Uf)):(ue(),ye("input",Js({key:2},It(r),{class:"zt-input__inner",value:l.value,type:e.type,placeholder:e.placeholder,disabled:e.disabled,readonly:e.readonly,maxlength:e.maxlength,"aria-invalid":e.status==="error"?"true":void 0,onInput:y,onChange:j,onCompositionstart:T,onCompositionend:N,onFocus:U,onBlur:me}),null,16,Kf)),f.value?(ue(),ye("button",{key:3,class:"zt-input__clear",type:"button","aria-label":"清空输入",onClick:K}," × ")):it("",!0),P.$slots.suffix?(ue(),ye("span",Gf,[Jr(P.$slots,"suffix",{},void 0)])):it("",!0)]),g.value?(ue(),ye("div",qf,[e.message?(ue(),ye("span",Wf,Pt(e.message),1)):it("",!0),e.showWordLimit?(ue(),ye("span",Zf,Pt(p.value),1)):it("",!0)])):it("",!0)],2))}}),Jf=El(Qf,[["__scopeId","data-v-617a35c2"]]);function Yf(){var e,t,n,s,r,i,o,l,a,u,c,f;if(typeof window<"u"&&window.ztools){if((t=(e=window.ztools).isMacOs)!=null&&t.call(e)||(s=(n=window.ztools).isMacOS)!=null&&s.call(n))return"darwin";if((i=(r=window.ztools).isWindows)!=null&&i.call(r))return"win32";if((l=(o=window.ztools).isLinux)!=null&&l.call(o))return"linux"}if(typeof ztools<"u"){if((a=ztools.isMacOs)!=null&&a.call(ztools)||(u=ztools.isMacOS)!=null&&u.call(ztools))return"darwin";if((c=ztools.isWindows)!=null&&c.call(ztools))return"win32";if((f=ztools.isLinux)!=null&&f.call(ztools))return"linux"}return"linux"}function Xf(e){if(typeof document>"u")return;const t=Yf();document.documentElement.classList.remove("os-mac","os-windows","os-linux"),t==="darwin"?document.documentElement.classList.add("os-mac"):t==="win32"?document.documentElement.classList.add("os-windows"):document.documentElement.classList.add("os-linux")}const eh=["theme-blue","theme-purple","theme-green","theme-orange","theme-red","theme-pink","theme-custom"],Al=De(!1),Rl=De(),Tl=De(),Pl=De("none");let Ui=!1;function th(){if(typeof window<"u"&&window.ztools){const e=window.ztools;if(typeof e.getThemeInfo=="function"&&typeof e.onThemeChange=="function")return e}if(typeof ztools<"u"){const e=ztools;if(typeof e.getThemeInfo=="function"&&typeof e.onThemeChange=="function")return e}return null}function nh(e){Al.value=e.isDark,Rl.value=e.primaryColor,Tl.value=e.customColor,Pl.value=e.windowMaterial}function Ki(e){nh(e),!(typeof document>"u")&&(document.documentElement.setAttribute("data-material",e.windowMaterial),document.documentElement.classList.toggle("dark",e.isDark),document.body.classList.remove(...eh),e.primaryColor&&document.body.classList.add(`theme-${e.primaryColor}`),e.customColor?document.documentElement.style.setProperty("--primary-color",e.customColor):document.documentElement.style.removeProperty("--primary-color"))}function sh(){if(Ui)return;const e=th();e&&(Ki(e.getThemeInfo()),Xf(),e.onThemeChange(t=>{Ki(t)}),Ui=!0)}function rh(){return sh(),{isDark:Al,primaryColor:Rl,customColor:Tl,windowMaterial:Pl}}function Ir(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var Gt=Ir();function Il(e){Gt=e}var Sn={exec:()=>null};function te(e,t=""){let n=typeof e=="string"?e:e.source;const s={replace:(r,i)=>{let o=typeof i=="string"?i:i.source;return o=o.replace(Ne.caret,"$1"),n=n.replace(r,o),s},getRegex:()=>new RegExp(n,t)};return s}var Ne={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:e=>new RegExp(`^( {0,3}${e})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}#`),htmlBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}<(?:[a-z].*>|!--)`,"i")},ih=/^(?:[ \t]*(?:\n|$))+/,oh=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,lh=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,Bn=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,ah=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,Or=/(?:[*+-]|\d{1,9}[.)])/,Ol=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,Nl=te(Ol).replace(/bull/g,Or).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),ch=te(Ol).replace(/bull/g,Or).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),Nr=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,uh=/^[^\n]+/,Lr=/(?!\s*\])(?:\\.|[^\[\]\\])+/,fh=te(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",Lr).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),hh=te(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,Or).getRegex(),_s="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",Mr=/|$))/,ph=te("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",Mr).replace("tag",_s).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),Ll=te(Nr).replace("hr",Bn).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",_s).getRegex(),dh=te(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",Ll).getRegex(),$r={blockquote:dh,code:oh,def:fh,fences:lh,heading:ah,hr:Bn,html:ph,lheading:Nl,list:hh,newline:ih,paragraph:Ll,table:Sn,text:uh},Gi=te("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",Bn).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",_s).getRegex(),gh={...$r,lheading:ch,table:Gi,paragraph:te(Nr).replace("hr",Bn).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",Gi).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",_s).getRegex()},mh={...$r,html:te(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",Mr).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:Sn,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:te(Nr).replace("hr",Bn).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",Nl).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},bh=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,yh=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,Ml=/^( {2,}|\\)\n(?!\s*$)/,vh=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\]*?>/g,Bl=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,Ch=te(Bl,"u").replace(/punct/g,ws).getRegex(),Sh=te(Bl,"u").replace(/punct/g,Dl).getRegex(),zl="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",Eh=te(zl,"gu").replace(/notPunctSpace/g,$l).replace(/punctSpace/g,Dr).replace(/punct/g,ws).getRegex(),Ah=te(zl,"gu").replace(/notPunctSpace/g,wh).replace(/punctSpace/g,_h).replace(/punct/g,Dl).getRegex(),Rh=te("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,$l).replace(/punctSpace/g,Dr).replace(/punct/g,ws).getRegex(),Th=te(/\\(punct)/,"gu").replace(/punct/g,ws).getRegex(),Ph=te(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),Ih=te(Mr).replace("(?:-->|$)","-->").getRegex(),Oh=te("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",Ih).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),rs=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,Nh=te(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",rs).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),Vl=te(/^!?\[(label)\]\[(ref)\]/).replace("label",rs).replace("ref",Lr).getRegex(),Hl=te(/^!?\[(ref)\](?:\[\])?/).replace("ref",Lr).getRegex(),Lh=te("reflink|nolink(?!\\()","g").replace("reflink",Vl).replace("nolink",Hl).getRegex(),Br={_backpedal:Sn,anyPunctuation:Th,autolink:Ph,blockSkip:kh,br:Ml,code:yh,del:Sn,emStrongLDelim:Ch,emStrongRDelimAst:Eh,emStrongRDelimUnd:Rh,escape:bh,link:Nh,nolink:Hl,punctuation:xh,reflink:Vl,reflinkSearch:Lh,tag:Oh,text:vh,url:Sn},Mh={...Br,link:te(/^!?\[(label)\]\((.*?)\)/).replace("label",rs).getRegex(),reflink:te(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",rs).getRegex()},rr={...Br,emStrongRDelimAst:Ah,emStrongLDelim:Sh,url:te(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},qi=e=>Dh[e];function nt(e,t){if(t){if(Ne.escapeTest.test(e))return e.replace(Ne.escapeReplace,qi)}else if(Ne.escapeTestNoEncode.test(e))return e.replace(Ne.escapeReplaceNoEncode,qi);return e}function Wi(e){try{e=encodeURI(e).replace(Ne.percentDecode,"%")}catch{return null}return e}function Zi(e,t){var n;const s=e.replace(Ne.findPipe,(o,l,a)=>{let u=!1,c=l;for(;--c>=0&&a[c]==="\\";)u=!u;return u?"|":" |"}),r=s.split(Ne.splitPipe);let i=0;if(r[0].trim()||r.shift(),r.length>0&&!((n=r.at(-1))!=null&&n.trim())&&r.pop(),t)if(r.length>t)r.splice(t);else for(;r.length0?-2:-1}function Qi(e,t,n,s,r){const i=t.href,o=t.title||null,l=e[1].replace(r.other.outputLinkReplace,"$1");s.state.inLink=!0;const a={type:e[0].charAt(0)==="!"?"image":"link",raw:n,href:i,title:o,text:l,tokens:s.inlineTokens(l)};return s.state.inLink=!1,a}function zh(e,t,n){const s=e.match(n.other.indentCodeCompensation);if(s===null)return t;const r=s[1];return t.split(` +`).map(i=>{const o=i.match(n.other.beginningSpace);if(o===null)return i;const[l]=o;return l.length>=r.length?i.slice(r.length):i}).join(` +`)}var is=class{constructor(e){ce(this,"options"),ce(this,"rules"),ce(this,"lexer"),this.options=e||Gt}space(e){const t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const n=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?n:gn(n,` +`)}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const n=t[0],s=zh(n,t[3]||"",this.rules);return{type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:s}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){const s=gn(n,"#");(this.options.pedantic||!s||this.rules.other.endingSpaceChar.test(s))&&(n=s.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:gn(t[0],` +`)}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let n=gn(t[0],` +`).split(` +`),s="",r="";const i=[];for(;n.length>0;){let o=!1;const l=[];let a;for(a=0;a1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=this.rules.other.listItemRegex(n);let o=!1;for(;e;){let a=!1,u="",c="";if(!(t=i.exec(e))||this.rules.block.hr.test(e))break;u=t[0],e=e.substring(u.length);let f=t[2].split(` +`,1)[0].replace(this.rules.other.listReplaceTabs,x=>" ".repeat(3*x.length)),p=e.split(` +`,1)[0],g=!f.trim(),b=0;if(this.options.pedantic?(b=2,c=f.trimStart()):g?b=t[1].length+1:(b=t[2].search(this.rules.other.nonSpaceChar),b=b>4?1:b,c=f.slice(b),b+=t[1].length),g&&this.rules.other.blankLine.test(p)&&(u+=p+` +`,e=e.substring(p.length+1),a=!0),!a){const x=this.rules.other.nextBulletRegex(b),y=this.rules.other.hrRegex(b),T=this.rules.other.fencesBeginRegex(b),N=this.rules.other.headingBeginRegex(b),j=this.rules.other.htmlBeginRegex(b);for(;e;){const K=e.split(` +`,1)[0];let U;if(p=K,this.options.pedantic?(p=p.replace(this.rules.other.listReplaceNesting," "),U=p):U=p.replace(this.rules.other.tabCharGlobal," "),T.test(p)||N.test(p)||j.test(p)||x.test(p)||y.test(p))break;if(U.search(this.rules.other.nonSpaceChar)>=b||!p.trim())c+=` +`+U.slice(b);else{if(g||f.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||T.test(f)||N.test(f)||y.test(f))break;c+=` +`+p}!g&&!p.trim()&&(g=!0),u+=K+` +`,e=e.substring(K.length+1),f=U.slice(b)}}r.loose||(o?r.loose=!0:this.rules.other.doubleBlankLine.test(u)&&(o=!0));let m=null,v;this.options.gfm&&(m=this.rules.other.listIsTask.exec(c),m&&(v=m[0]!=="[ ] ",c=c.replace(this.rules.other.listReplaceTask,""))),r.items.push({type:"list_item",raw:u,task:!!m,checked:v,loose:!1,text:c,tokens:[]}),r.raw+=u}const l=r.items.at(-1);if(l)l.raw=l.raw.trimEnd(),l.text=l.text.trimEnd();else return;r.raw=r.raw.trimEnd();for(let a=0;af.type==="space"),c=u.length>0&&u.some(f=>this.rules.other.anyLine.test(f.raw));r.loose=c}if(r.loose)for(let a=0;a({text:a,tokens:this.lexer.inline(a),header:!1,align:o.align[u]})));return o}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:t[2].charAt(0)==="="?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const n=t[1].charAt(t[1].length-1)===` +`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const n=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;const i=gn(n.slice(0,-1),"\\");if((n.length-i.length)%2===0)return}else{const i=Bh(t[2],"()");if(i===-2)return;if(i>-1){const o=(t[0].indexOf("!")===0?5:4)+t[1].length+i;t[2]=t[2].substring(0,i),t[0]=t[0].substring(0,o).trim(),t[3]=""}}let s=t[2],r="";if(this.options.pedantic){const i=this.rules.other.pedanticHrefTitle.exec(s);i&&(s=i[1],r=i[3])}else r=t[3]?t[3].slice(1,-1):"";return s=s.trim(),this.rules.other.startAngleBracket.test(s)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?s=s.slice(1):s=s.slice(1,-1)),Qi(t,{href:s&&s.replace(this.rules.inline.anyPunctuation,"$1"),title:r&&r.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){const s=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),r=t[s.toLowerCase()];if(!r){const i=n[0].charAt(0);return{type:"text",raw:i,text:i}}return Qi(n,r,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!(!s||s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))&&(!(s[1]||s[2])||!n||this.rules.inline.punctuation.exec(n))){const r=[...s[0]].length-1;let i,o,l=r,a=0;const u=s[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(u.lastIndex=0,t=t.slice(-1*e.length+r);(s=u.exec(t))!=null;){if(i=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!i)continue;if(o=[...i].length,s[3]||s[4]){l+=o;continue}else if((s[5]||s[6])&&r%3&&!((r+o)%3)){a+=o;continue}if(l-=o,l>0)continue;o=Math.min(o,o+l+a);const c=[...s[0]][0].length,f=e.slice(0,r+s.index+c+o);if(Math.min(r,o)%2){const g=f.slice(1,-1);return{type:"em",raw:f,text:g,tokens:this.lexer.inlineTokens(g)}}const p=f.slice(2,-2);return{type:"strong",raw:f,text:p,tokens:this.lexer.inlineTokens(p)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," ");const s=this.rules.other.nonSpaceChar.test(n),r=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return s&&r&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let n,s;return t[2]==="@"?(n=t[1],s="mailto:"+n):(n=t[1],s=n),{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}url(e){var t;let n;if(n=this.rules.inline.url.exec(e)){let s,r;if(n[2]==="@")s=n[0],r="mailto:"+s;else{let i;do i=n[0],n[0]=((t=this.rules.inline._backpedal.exec(n[0]))==null?void 0:t[0])??"";while(i!==n[0]);s=n[0],n[1]==="www."?r="http://"+n[0]:r=n[0]}return{type:"link",raw:n[0],text:s,href:r,tokens:[{type:"text",raw:s,text:s}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){const n=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:n}}}},vt=class ir{constructor(t){ce(this,"tokens"),ce(this,"options"),ce(this,"state"),ce(this,"tokenizer"),ce(this,"inlineQueue"),this.tokens=[],this.tokens.links=Object.create(null),this.options=t||Gt,this.options.tokenizer=this.options.tokenizer||new is,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};const n={other:Ne,block:jn.normal,inline:dn.normal};this.options.pedantic?(n.block=jn.pedantic,n.inline=dn.pedantic):this.options.gfm&&(n.block=jn.gfm,this.options.breaks?n.inline=dn.breaks:n.inline=dn.gfm),this.tokenizer.rules=n}static get rules(){return{block:jn,inline:dn}}static lex(t,n){return new ir(n).lex(t)}static lexInline(t,n){return new ir(n).inlineTokens(t)}lex(t){t=t.replace(Ne.carriageReturn,` +`),this.blockTokens(t,this.tokens);for(let n=0;n(l=u.call({lexer:this},t,n))?(t=t.substring(l.raw.length),n.push(l),!0):!1))continue;if(l=this.tokenizer.space(t)){t=t.substring(l.raw.length);const u=n.at(-1);l.raw.length===1&&u!==void 0?u.raw+=` +`:n.push(l);continue}if(l=this.tokenizer.code(t)){t=t.substring(l.raw.length);const u=n.at(-1);(u==null?void 0:u.type)==="paragraph"||(u==null?void 0:u.type)==="text"?(u.raw+=` +`+l.raw,u.text+=` +`+l.text,this.inlineQueue.at(-1).src=u.text):n.push(l);continue}if(l=this.tokenizer.fences(t)){t=t.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.heading(t)){t=t.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.hr(t)){t=t.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.blockquote(t)){t=t.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.list(t)){t=t.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.html(t)){t=t.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.def(t)){t=t.substring(l.raw.length);const u=n.at(-1);(u==null?void 0:u.type)==="paragraph"||(u==null?void 0:u.type)==="text"?(u.raw+=` +`+l.raw,u.text+=` +`+l.raw,this.inlineQueue.at(-1).src=u.text):this.tokens.links[l.tag]||(this.tokens.links[l.tag]={href:l.href,title:l.title});continue}if(l=this.tokenizer.table(t)){t=t.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.lheading(t)){t=t.substring(l.raw.length),n.push(l);continue}let a=t;if((o=this.options.extensions)!=null&&o.startBlock){let u=1/0;const c=t.slice(1);let f;this.options.extensions.startBlock.forEach(p=>{f=p.call({lexer:this},c),typeof f=="number"&&f>=0&&(u=Math.min(u,f))}),u<1/0&&u>=0&&(a=t.substring(0,u+1))}if(this.state.top&&(l=this.tokenizer.paragraph(a))){const u=n.at(-1);s&&(u==null?void 0:u.type)==="paragraph"?(u.raw+=` +`+l.raw,u.text+=` +`+l.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=u.text):n.push(l),s=a.length!==t.length,t=t.substring(l.raw.length);continue}if(l=this.tokenizer.text(t)){t=t.substring(l.raw.length);const u=n.at(-1);(u==null?void 0:u.type)==="text"?(u.raw+=` +`+l.raw,u.text+=` +`+l.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=u.text):n.push(l);continue}if(t){const u="Infinite loop on byte: "+t.charCodeAt(0);if(this.options.silent){console.error(u);break}else throw new Error(u)}}return this.state.top=!0,n}inline(t,n=[]){return this.inlineQueue.push({src:t,tokens:n}),n}inlineTokens(t,n=[]){var s,r,i;let o=t,l=null;if(this.tokens.links){const c=Object.keys(this.tokens.links);if(c.length>0)for(;(l=this.tokenizer.rules.inline.reflinkSearch.exec(o))!=null;)c.includes(l[0].slice(l[0].lastIndexOf("[")+1,-1))&&(o=o.slice(0,l.index)+"["+"a".repeat(l[0].length-2)+"]"+o.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(l=this.tokenizer.rules.inline.anyPunctuation.exec(o))!=null;)o=o.slice(0,l.index)+"++"+o.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(l=this.tokenizer.rules.inline.blockSkip.exec(o))!=null;)o=o.slice(0,l.index)+"["+"a".repeat(l[0].length-2)+"]"+o.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);let a=!1,u="";for(;t;){a||(u=""),a=!1;let c;if((r=(s=this.options.extensions)==null?void 0:s.inline)!=null&&r.some(p=>(c=p.call({lexer:this},t,n))?(t=t.substring(c.raw.length),n.push(c),!0):!1))continue;if(c=this.tokenizer.escape(t)){t=t.substring(c.raw.length),n.push(c);continue}if(c=this.tokenizer.tag(t)){t=t.substring(c.raw.length),n.push(c);continue}if(c=this.tokenizer.link(t)){t=t.substring(c.raw.length),n.push(c);continue}if(c=this.tokenizer.reflink(t,this.tokens.links)){t=t.substring(c.raw.length);const p=n.at(-1);c.type==="text"&&(p==null?void 0:p.type)==="text"?(p.raw+=c.raw,p.text+=c.text):n.push(c);continue}if(c=this.tokenizer.emStrong(t,o,u)){t=t.substring(c.raw.length),n.push(c);continue}if(c=this.tokenizer.codespan(t)){t=t.substring(c.raw.length),n.push(c);continue}if(c=this.tokenizer.br(t)){t=t.substring(c.raw.length),n.push(c);continue}if(c=this.tokenizer.del(t)){t=t.substring(c.raw.length),n.push(c);continue}if(c=this.tokenizer.autolink(t)){t=t.substring(c.raw.length),n.push(c);continue}if(!this.state.inLink&&(c=this.tokenizer.url(t))){t=t.substring(c.raw.length),n.push(c);continue}let f=t;if((i=this.options.extensions)!=null&&i.startInline){let p=1/0;const g=t.slice(1);let b;this.options.extensions.startInline.forEach(m=>{b=m.call({lexer:this},g),typeof b=="number"&&b>=0&&(p=Math.min(p,b))}),p<1/0&&p>=0&&(f=t.substring(0,p+1))}if(c=this.tokenizer.inlineText(f)){t=t.substring(c.raw.length),c.raw.slice(-1)!=="_"&&(u=c.raw.slice(-1)),a=!0;const p=n.at(-1);(p==null?void 0:p.type)==="text"?(p.raw+=c.raw,p.text+=c.text):n.push(c);continue}if(t){const p="Infinite loop on byte: "+t.charCodeAt(0);if(this.options.silent){console.error(p);break}else throw new Error(p)}}return n}},os=class{constructor(e){ce(this,"options"),ce(this,"parser"),this.options=e||Gt}space(e){return""}code({text:e,lang:t,escaped:n}){var s;const r=(s=(t||"").match(Ne.notSpaceStart))==null?void 0:s[0],i=e.replace(Ne.endingNewline,"")+` +`;return r?'
        '+(n?i:nt(i,!0))+`
        +`:"
        "+(n?i:nt(i,!0))+`
        +`}blockquote({tokens:e}){return`
        +${this.parser.parse(e)}
        +`}html({text:e}){return e}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)} +`}hr(e){return`
        +`}list(e){const t=e.ordered,n=e.start;let s="";for(let o=0;o +`+s+" +`}listitem(e){var t;let n="";if(e.task){const s=this.checkbox({checked:!!e.checked});e.loose?((t=e.tokens[0])==null?void 0:t.type)==="paragraph"?(e.tokens[0].text=s+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&e.tokens[0].tokens[0].type==="text"&&(e.tokens[0].tokens[0].text=s+" "+nt(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:s+" ",text:s+" ",escaped:!0}):n+=s+" "}return n+=this.parser.parse(e.tokens,!!e.loose),`
      • ${n}
      • +`}checkbox({checked:e}){return"'}paragraph({tokens:e}){return`

        ${this.parser.parseInline(e)}

        +`}table(e){let t="",n="";for(let r=0;r${s}`),` + +`+t+` +`+s+`
        +`}tablerow({text:e}){return` +${e} +`}tablecell(e){const t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+` +`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${nt(e,!0)}`}br(e){return"
        "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){const s=this.parser.parseInline(n),r=Wi(e);if(r===null)return s;e=r;let i='
        ",i}image({href:e,title:t,text:n,tokens:s}){s&&(n=this.parser.parseInline(s,this.parser.textRenderer));const r=Wi(e);if(r===null)return nt(n);e=r;let i=`${n}{const a=o[l].flat(1/0);r=r.concat(this.walkTokens(a,t))}):o.tokens&&(r=r.concat(this.walkTokens(o.tokens,t)))}}return r}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{const s={...n};if(s.async=this.defaults.async||s.async||!1,n.extensions&&(n.extensions.forEach(r=>{if(!r.name)throw new Error("extension name required");if("renderer"in r){const i=t.renderers[r.name];i?t.renderers[r.name]=function(...o){let l=r.renderer.apply(this,o);return l===!1&&(l=i.apply(this,o)),l}:t.renderers[r.name]=r.renderer}if("tokenizer"in r){if(!r.level||r.level!=="block"&&r.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");const i=t[r.level];i?i.unshift(r.tokenizer):t[r.level]=[r.tokenizer],r.start&&(r.level==="block"?t.startBlock?t.startBlock.push(r.start):t.startBlock=[r.start]:r.level==="inline"&&(t.startInline?t.startInline.push(r.start):t.startInline=[r.start]))}"childTokens"in r&&r.childTokens&&(t.childTokens[r.name]=r.childTokens)}),s.extensions=t),n.renderer){const r=this.defaults.renderer||new os(this.defaults);for(const i in n.renderer){if(!(i in r))throw new Error(`renderer '${i}' does not exist`);if(["options","parser"].includes(i))continue;const o=i,l=n.renderer[o],a=r[o];r[o]=(...u)=>{let c=l.apply(r,u);return c===!1&&(c=a.apply(r,u)),c||""}}s.renderer=r}if(n.tokenizer){const r=this.defaults.tokenizer||new is(this.defaults);for(const i in n.tokenizer){if(!(i in r))throw new Error(`tokenizer '${i}' does not exist`);if(["options","rules","lexer"].includes(i))continue;const o=i,l=n.tokenizer[o],a=r[o];r[o]=(...u)=>{let c=l.apply(r,u);return c===!1&&(c=a.apply(r,u)),c}}s.tokenizer=r}if(n.hooks){const r=this.defaults.hooks||new Wn;for(const i in n.hooks){if(!(i in r))throw new Error(`hook '${i}' does not exist`);if(["options","block"].includes(i))continue;const o=i,l=n.hooks[o],a=r[o];Wn.passThroughHooks.has(i)?r[o]=u=>{if(this.defaults.async)return Promise.resolve(l.call(r,u)).then(f=>a.call(r,f));const c=l.call(r,u);return a.call(r,c)}:r[o]=(...u)=>{let c=l.apply(r,u);return c===!1&&(c=a.apply(r,u)),c}}s.hooks=r}if(n.walkTokens){const r=this.defaults.walkTokens,i=n.walkTokens;s.walkTokens=function(o){let l=[];return l.push(i.call(this,o)),r&&(l=l.concat(r.call(this,o))),l}}this.defaults={...this.defaults,...s}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return vt.lex(e,t??this.defaults)}parser(e,t){return xt.parse(e,t??this.defaults)}parseMarkdown(e){return(t,n)=>{const s={...n},r={...this.defaults,...s},i=this.onError(!!r.silent,!!r.async);if(this.defaults.async===!0&&s.async===!1)return i(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof t>"u"||t===null)return i(new Error("marked(): input parameter is undefined or null"));if(typeof t!="string")return i(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(t)+", string expected"));r.hooks&&(r.hooks.options=r,r.hooks.block=e);const o=r.hooks?r.hooks.provideLexer():e?vt.lex:vt.lexInline,l=r.hooks?r.hooks.provideParser():e?xt.parse:xt.parseInline;if(r.async)return Promise.resolve(r.hooks?r.hooks.preprocess(t):t).then(a=>o(a,r)).then(a=>r.hooks?r.hooks.processAllTokens(a):a).then(a=>r.walkTokens?Promise.all(this.walkTokens(a,r.walkTokens)).then(()=>a):a).then(a=>l(a,r)).then(a=>r.hooks?r.hooks.postprocess(a):a).catch(i);try{r.hooks&&(t=r.hooks.preprocess(t));let a=o(t,r);r.hooks&&(a=r.hooks.processAllTokens(a)),r.walkTokens&&this.walkTokens(a,r.walkTokens);let u=l(a,r);return r.hooks&&(u=r.hooks.postprocess(u)),u}catch(a){return i(a)}}}onError(e,t){return n=>{if(n.message+=` +Please report this to https://github.com/markedjs/marked.`,e){const s="

        An error occurred:

        "+nt(n.message+"",!0)+"
        ";return t?Promise.resolve(s):s}if(t)return Promise.reject(n);throw n}}},Kt=new Vh;function se(e,t){return Kt.parse(e,t)}se.options=se.setOptions=function(e){return Kt.setOptions(e),se.defaults=Kt.defaults,Il(se.defaults),se};se.getDefaults=Ir;se.defaults=Gt;se.use=function(...e){return Kt.use(...e),se.defaults=Kt.defaults,Il(se.defaults),se};se.walkTokens=function(e,t){return Kt.walkTokens(e,t)};se.parseInline=Kt.parseInline;se.Parser=xt;se.parser=xt.parse;se.Renderer=os;se.TextRenderer=zr;se.Lexer=vt;se.lexer=vt.lex;se.Tokenizer=is;se.Hooks=Wn;se.parse=se;se.options;se.setOptions;se.use;se.walkTokens;se.parseInline;xt.parse;vt.lex;se.setOptions({breaks:!0,gfm:!0});typeof WorkerGlobalScope<"u"&&globalThis instanceof WorkerGlobalScope;const Fl=["capitalize","camelCase","pascalCase","snake_case","constant_case","kebab-case","uppercase","lowercase","base64","base64decode","md5","urlEncode","urlDecode"];function zn(e){const t=String(e).trim();return t?t.replace(/([a-z\d])([A-Z])/g,"$1 $2").replace(/([A-Z]+)([A-Z][a-z\d])/g,"$1 $2").split(/[\s_\-./]+/).filter(Boolean).map(n=>n.toLowerCase()):[]}function Hh(e){const t=String(e);return t?t.charAt(0).toUpperCase()+t.slice(1).toLowerCase():""}function Fh(e){const t=zn(e);return t.length?t[0]+t.slice(1).map(n=>n.charAt(0).toUpperCase()+n.slice(1)).join(""):""}function jh(e){return zn(e).map(n=>n.charAt(0).toUpperCase()+n.slice(1)).join("")}function Uh(e){return zn(e).join("_")}function Kh(e){return zn(e).join("_").toUpperCase()}function Gh(e){return zn(e).join("-")}function qh(e){return String(e).toUpperCase()}function Wh(e){return String(e).toLowerCase()}function Zh(e){const t=String(e);return typeof Buffer<"u"?Buffer.from(t,"utf8").toString("base64"):btoa(unescape(encodeURIComponent(t)))}function Qh(e){const t=String(e);if(!t||!/^[A-Za-z0-9+/]*={0,2}$/.test(t)||t.length%4===1)throw new Error("无效的 Base64 字符串");try{if(typeof Buffer<"u"){const n=Buffer.from(t,"base64").toString("utf8");if(Buffer.from(n,"utf8").toString("base64").replace(/=+$/,"")!==t.replace(/=+$/,""))throw new Error("无效的 Base64 字符串");return n}return decodeURIComponent(escape(atob(t)))}catch{throw new Error("无效的 Base64 字符串")}}function Jh(e){return encodeURIComponent(String(e))}function Yh(e){try{return decodeURIComponent(String(e))}catch{throw new Error("无效的 URL 编码字符串")}}function Xh(e){const t=String(e),n=unescape(encodeURIComponent(t));function s(g,b){let[m,v,x,y]=g;m=i(m,v,x,y,b[0],7,-680876936),y=i(y,m,v,x,b[1],12,-389564586),x=i(x,y,m,v,b[2],17,606105819),v=i(v,x,y,m,b[3],22,-1044525330),m=i(m,v,x,y,b[4],7,-176418897),y=i(y,m,v,x,b[5],12,1200080426),x=i(x,y,m,v,b[6],17,-1473231341),v=i(v,x,y,m,b[7],22,-45705983),m=i(m,v,x,y,b[8],7,1770035416),y=i(y,m,v,x,b[9],12,-1958414417),x=i(x,y,m,v,b[10],17,-42063),v=i(v,x,y,m,b[11],22,-1990404162),m=i(m,v,x,y,b[12],7,1804603682),y=i(y,m,v,x,b[13],12,-40341101),x=i(x,y,m,v,b[14],17,-1502002290),v=i(v,x,y,m,b[15],22,1236535329),m=o(m,v,x,y,b[1],5,-165796510),y=o(y,m,v,x,b[6],9,-1069501632),x=o(x,y,m,v,b[11],14,643717713),v=o(v,x,y,m,b[0],20,-373897302),m=o(m,v,x,y,b[5],5,-701558691),y=o(y,m,v,x,b[10],9,38016083),x=o(x,y,m,v,b[15],14,-660478335),v=o(v,x,y,m,b[4],20,-405537848),m=o(m,v,x,y,b[9],5,568446438),y=o(y,m,v,x,b[14],9,-1019803690),x=o(x,y,m,v,b[3],14,-187363961),v=o(v,x,y,m,b[8],20,1163531501),m=o(m,v,x,y,b[13],5,-1444681467),y=o(y,m,v,x,b[2],9,-51403784),x=o(x,y,m,v,b[7],14,1735328473),v=o(v,x,y,m,b[12],20,-1926607734),m=l(m,v,x,y,b[5],4,-378558),y=l(y,m,v,x,b[8],11,-2022574463),x=l(x,y,m,v,b[11],16,1839030562),v=l(v,x,y,m,b[14],23,-35309556),m=l(m,v,x,y,b[1],4,-1530992060),y=l(y,m,v,x,b[4],11,1272893353),x=l(x,y,m,v,b[7],16,-155497632),v=l(v,x,y,m,b[10],23,-1094730640),m=l(m,v,x,y,b[13],4,681279174),y=l(y,m,v,x,b[0],11,-358537222),x=l(x,y,m,v,b[3],16,-722521979),v=l(v,x,y,m,b[6],23,76029189),m=l(m,v,x,y,b[9],4,-640364487),y=l(y,m,v,x,b[12],11,-421815835),x=l(x,y,m,v,b[15],16,530742520),v=l(v,x,y,m,b[2],23,-995338651),m=a(m,v,x,y,b[0],6,-198630844),y=a(y,m,v,x,b[7],10,1126891415),x=a(x,y,m,v,b[14],15,-1416354905),v=a(v,x,y,m,b[5],21,-57434055),m=a(m,v,x,y,b[12],6,1700485571),y=a(y,m,v,x,b[3],10,-1894986606),x=a(x,y,m,v,b[10],15,-1051523),v=a(v,x,y,m,b[1],21,-2054922799),m=a(m,v,x,y,b[8],6,1873313359),y=a(y,m,v,x,b[15],10,-30611744),x=a(x,y,m,v,b[6],15,-1560198380),v=a(v,x,y,m,b[13],21,1309151649),m=a(m,v,x,y,b[4],6,-145523070),y=a(y,m,v,x,b[11],10,-1120210379),x=a(x,y,m,v,b[2],15,718787259),v=a(v,x,y,m,b[9],21,-343485551),g[0]=u(m,g[0]),g[1]=u(v,g[1]),g[2]=u(x,g[2]),g[3]=u(y,g[3])}function r(g,b,m,v,x,y){return b=u(u(b,g),u(v,y)),u(b<>>32-x,m)}function i(g,b,m,v,x,y,T){return r(b&m|~b&v,g,b,x,y,T)}function o(g,b,m,v,x,y,T){return r(b&v|m&~v,g,b,x,y,T)}function l(g,b,m,v,x,y,T){return r(b^m^v,g,b,x,y,T)}function a(g,b,m,v,x,y,T){return r(m^(b|~v),g,b,x,y,T)}function u(g,b){return g+b&4294967295}function c(g){const b=g.length,m=[1732584193,-271733879,-1732584194,271733878];let v;for(v=64;v<=b;v+=64)s(m,f(g.substring(v-64,v)));g=g.substring(v-64);const x=new Array(16).fill(0);for(v=0;v>2]|=g.charCodeAt(v)<<(v%4<<3);if(x[v>>2]|=128<<(v%4<<3),v>55){s(m,x);for(let y=0;y<16;y+=1)x[y]=0}return x[14]=b*8,s(m,x),m}function f(g){const b=[];for(let m=0;m<64;m+=4)b[m>>2]=g.charCodeAt(m)+(g.charCodeAt(m+1)<<8)+(g.charCodeAt(m+2)<<16)+(g.charCodeAt(m+3)<<24);return b}function p(g){const b="0123456789abcdef";let m="";for(let v=0;v<4;v+=1)m+=b.charAt(g>>v*8+4&15)+b.charAt(g>>v*8&15);return m}return c(n).map(p).join("")}function ep(e,t){const n={capitalize:Hh,camelCase:Fh,pascalCase:jh,snake_case:Uh,constant_case:Kh,"kebab-case":Gh,uppercase:qh,lowercase:Wh,base64:Zh,base64decode:Qh,md5:Xh,urlEncode:Jh,urlDecode:Yh};if(!n[t])throw new Error(`不支持的模式: ${t},可选: ${Fl.join(", ")}`);return n[t](e)}const lr={capitalize:"首字母大写",camelCase:"小驼峰 camelCase",pascalCase:"大驼峰 PascalCase",snake_case:"蛇形 snake_case",constant_case:"常量 CONSTANT_CASE","kebab-case":"短横线 kebab-case",uppercase:"全大写 UPPERCASE",lowercase:"全小写 lowercase",base64:"Base64 编码",base64decode:"Base64 解码",md5:"MD5 哈希",urlEncode:"URL 编码",urlDecode:"URL 解码"};function tp(){return Fl.map(e=>({id:e,label:lr[e],keywords:`${lr[e]} ${e}`.toLowerCase()}))}function np(e){return e&&(e.type==="over"||e.type==="text")?e.payload||"":null}function sp(e,t){if(!e)return{output:"",status:"请输入文本",error:null};try{return{output:ep(e,t),status:`已转换:${lr[t]}`,error:null}}catch(n){const s=n instanceof Error?n.message:String(n);return{output:"",status:s,error:s}}}function rp(e){return tp().map(t=>{const n=sp(e,t.id);return{...t,output:n.output,error:n.error}})}function ip(e,t){const n=String(t||"").trim().toLowerCase();return n?e.filter(s=>s.keywords.includes(n)||s.label.toLowerCase().includes(n)):e}function op(e){return e?{ok:!0,status:"已复制到剪贴板"}:{ok:!1,status:"没有可复制的内容"}}const lp=(e,t)=>{const n=e.__vccOpts||e;for(const[s,r]of t)n[s]=r;return n},ap={class:"char-transform"},cp={class:"header"},up={class:"header__search"},fp={class:"input-section"},hp={class:"result-list",role:"list"},pp=["onClick","onKeydown"],dp={class:"result-item__main"},gp={class:"result-item__label"},mp={class:"result-item__value"},bp={class:"result-item__shortcut"},yp={key:0,class:"result-empty"},vp={key:0,class:"footer"},xp={__name:"App",setup(e){const{success:t}=Hf(),n=De(""),s=De(""),r=De(""),i=De(""),o=pe(()=>rp(n.value)),l=pe(()=>ip(o.value,s.value));function a(){return typeof navigator<"u"&&/Mac|iPhone|iPod|iPad/i.test(navigator.platform)}function u(g){return`${a()?"⌘":"Ctrl"}+${g+1}`}async function c(g){var v;const{ok:b,status:m}=op(g.output);if(!b){r.value=m;return}i.value=g.id;try{(v=window.ztools)!=null&&v.copyText?window.ztools.copyText(g.output):await navigator.clipboard.writeText(g.output),t("复制成功"),r.value=`已复制:${g.label}`}catch{r.value="复制失败"}}function f(g){if(!(g.metaKey||g.ctrlKey))return;const m=Number.parseInt(g.key,10);Number.isNaN(m)||m<1||m>l.value.length||(g.preventDefault(),c(l.value[m-1]))}function p(g){const b=np(g);b!==null&&(n.value=b)}return Ot(n,g=>{g||(r.value="",i.value="")}),wr(()=>{window.addEventListener("keydown",f),window.ztools&&window.ztools.onPluginEnter(p)}),Cr(()=>{window.removeEventListener("keydown",f)}),(g,b)=>{const m=Vf,v=Jf;return ue(),ye("div",ap,[Ce(m),_e("header",cp,[b[2]||(b[2]=Ic('

        字符转换

        文本处理
        ',1)),_e("div",up,[Ce(v,{modelValue:s.value,"onUpdate:modelValue":b[0]||(b[0]=x=>s.value=x),placeholder:"筛选",size:"small",clearable:""},null,8,["modelValue"])])]),_e("section",fp,[Na(_e("textarea",{"onUpdate:modelValue":b[1]||(b[1]=x=>n.value=x),class:"input-area",rows:"2",placeholder:"输入字符,实时转换…",autofocus:""},null,512),[[gu,n.value]])]),_e("ul",hp,[(ue(!0),ye($e,null,Ya(l.value,(x,y)=>(ue(),ye("li",{key:x.id,class:an(["result-item",{"result-item--active":i.value===x.id}]),role:"button",tabindex:"0",onClick:T=>c(x),onKeydown:bu(T=>c(x),["enter"])},[_e("div",dp,[_e("div",gp,Pt(x.label),1),_e("div",mp,Pt(x.output||"—"),1)]),_e("kbd",bp,Pt(u(y)),1)],42,pp))),128)),l.value.length===0?(ue(),ye("li",yp," 无匹配的转换类型 ")):it("",!0)]),r.value?(ue(),ye("footer",vp,Pt(r.value),1)):it("",!0)])}}},jl=lp(xp,[["__scopeId","data-v-caf4254d"]]),_p=Pf({history:af(),routes:[{path:"/",component:jl}]}),Ul=xu(jl);Ul.use(_p);Ul.mount("#app");rh();window.ztools&&window.ztools.setExpendHeight(520); diff --git a/plugins/char-transform/index.html b/plugins/char-transform/index.html new file mode 100644 index 000000000..38d8943c2 --- /dev/null +++ b/plugins/char-transform/index.html @@ -0,0 +1,13 @@ + + + + + + 字符转换 + + + + +
        + + diff --git a/plugins/char-transform/lib/char-transform.cjs b/plugins/char-transform/lib/char-transform.cjs new file mode 100644 index 000000000..634b695c9 --- /dev/null +++ b/plugins/char-transform/lib/char-transform.cjs @@ -0,0 +1,135 @@ +/** + * preload 使用的 CommonJS 副本(Node 环境,与 lib/char-transform.js 逻辑一致) + */ +const { createHash } = require('node:crypto'); + +const MODES = [ + 'capitalize', + 'camelCase', + 'pascalCase', + 'snake_case', + 'constant_case', + 'kebab-case', + 'uppercase', + 'lowercase', + 'base64', + 'base64decode', + 'md5', + 'urlEncode', + 'urlDecode', +]; + +function splitIntoWords(text) { + const value = String(text).trim(); + if (!value) return []; + + return value + .replace(/([a-z\d])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z\d])/g, '$1 $2') + .split(/[\s_\-./]+/) + .filter(Boolean) + .map((word) => word.toLowerCase()); +} + +function toCapitalize(text) { + const value = String(text); + if (!value) return ''; + return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase(); +} + +function toCamelCase(text) { + const words = splitIntoWords(text); + if (!words.length) return ''; + return words[0] + words.slice(1).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(''); +} + +function toPascalCase(text) { + const words = splitIntoWords(text); + return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(''); +} + +function toSnakeCase(text) { + return splitIntoWords(text).join('_'); +} + +function toConstantCase(text) { + return splitIntoWords(text).join('_').toUpperCase(); +} + +function toKebabCase(text) { + return splitIntoWords(text).join('-'); +} + +function toUpperCase(text) { + return String(text).toUpperCase(); +} + +function toLowerCase(text) { + return String(text).toLowerCase(); +} + +function toBase64(text) { + return Buffer.from(String(text), 'utf8').toString('base64'); +} + +function fromBase64(text) { + const value = String(text); + if (!value || !/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 === 1) { + throw new Error('无效的 Base64 字符串'); + } + + try { + const decoded = Buffer.from(value, 'base64').toString('utf8'); + if (Buffer.from(decoded, 'utf8').toString('base64').replace(/=+$/, '') !== value.replace(/=+$/, '')) { + throw new Error('无效的 Base64 字符串'); + } + return decoded; + } catch { + throw new Error('无效的 Base64 字符串'); + } +} + +function toUrlEncode(text) { + return encodeURIComponent(String(text)); +} + +function fromUrlDecode(text) { + try { + return decodeURIComponent(String(text)); + } catch { + throw new Error('无效的 URL 编码字符串'); + } +} + +function toMd5(text) { + return createHash('md5').update(String(text), 'utf8').digest('hex'); +} + +function transform(text, mode) { + const handlers = { + capitalize: toCapitalize, + camelCase: toCamelCase, + pascalCase: toPascalCase, + snake_case: toSnakeCase, + constant_case: toConstantCase, + 'kebab-case': toKebabCase, + uppercase: toUpperCase, + lowercase: toLowerCase, + base64: toBase64, + base64decode: fromBase64, + md5: toMd5, + urlEncode: toUrlEncode, + urlDecode: fromUrlDecode, + }; + + if (!handlers[mode]) { + throw new Error(`不支持的模式: ${mode},可选: ${MODES.join(', ')}`); + } + + return handlers[mode](text); +} + +module.exports = { + MODES, + transform, +}; diff --git a/plugins/char-transform/logo.png b/plugins/char-transform/logo.png new file mode 100644 index 000000000..1c2d4866b Binary files /dev/null and b/plugins/char-transform/logo.png differ diff --git a/plugins/char-transform/package.json b/plugins/char-transform/package.json new file mode 100644 index 000000000..53820f7f2 --- /dev/null +++ b/plugins/char-transform/package.json @@ -0,0 +1,4 @@ +{ + "type": "commonjs", + "private": true +} diff --git a/plugins/char-transform/plugin.json b/plugins/char-transform/plugin.json new file mode 100644 index 000000000..0d3002666 --- /dev/null +++ b/plugins/char-transform/plugin.json @@ -0,0 +1,30 @@ +{ + "name": "char-transform", + "title": "字符转换", + "description": "快速转换字符格式:驼峰、蛇形、常量、短横线、Base64、MD5 等,点击或快捷键复制", + "version": "2.0.0", + "author": "ZTools Developer", + "main": "index.html", + "logo": "logo.png", + "preload": "preload.js", + "features": [ + { + "code": "char-transform", + "explain": "字符格式转换工具", + "cmds": [ + "字符转换", + "驼峰", + "编码转换", + "camelCase", + "base64", + "md5", + { + "type": "over", + "label": "字符转换", + "minLength": 1, + "maxLength": 10000 + } + ] + } + ] +} diff --git a/plugins/char-transform/preload.js b/plugins/char-transform/preload.js new file mode 100644 index 000000000..824cf4070 --- /dev/null +++ b/plugins/char-transform/preload.js @@ -0,0 +1,12 @@ +const { transform, MODES } = require('./lib/char-transform.cjs'); + +/** + * preload 脚本:向渲染进程暴露 Node 侧能力 + * 规范:代码清晰可读,禁止打包/压缩/混淆 + */ +window.charTransformApi = { + modes: MODES, + transform(text, mode) { + return transform(text, mode); + }, +}; diff --git a/plugins/color-helper/.gitignore b/plugins/color-helper/.gitignore new file mode 100644 index 000000000..86826b558 --- /dev/null +++ b/plugins/color-helper/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Vite +*.local + +# Editor +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Environment +.env + +# TypeScript +*.tsbuildinfo diff --git a/plugins/color-helper/App.tsx b/plugins/color-helper/App.tsx new file mode 100644 index 000000000..6513820d9 --- /dev/null +++ b/plugins/color-helper/App.tsx @@ -0,0 +1,292 @@ +import React, { Component } from 'react'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Snackbar from '@mui/material/Snackbar'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Tooltip from '@mui/material/Tooltip'; +import PaletteIcon from '@mui/icons-material/Palette'; +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; +import ImageIcon from '@mui/icons-material/Image'; +import ViewCompactIcon from '@mui/icons-material/ViewCompact'; +import BrushIcon from '@mui/icons-material/Brush'; +import GradientIcon from '@mui/icons-material/Gradient'; +import StarIcon from '@mui/icons-material/Star'; +import ColorizeIcon from '@mui/icons-material/Colorize'; +import chroma from 'chroma-js'; +import ColorPage from './pages/ColorPage'; +import UIPalettesPage from './pages/UIPalettesPage'; +import TraditionalColorsPage from './pages/TraditionalColorsPage'; +import GradientsPage from './pages/GradientsPage'; +import ImagePalettePage from './pages/ImagePalettePage'; +import CollectColorsPage from './pages/CollectColorsPage'; +import AIPalettePage from './pages/AIPalettePage'; +import { copyText, dbStorage, screenColorPick, onPluginEnter, onPluginOut, isPlatform } from './utils/platform'; + +/** + * App - 核心架构: 状态驱动导航(this.state.nav) + 平台生命周期 + */ + +// 从backgroundColor(rgb字符串)解析为hex, setting=true时去掉# +function colorFromBackgroundColor(bgColor: string, setting: boolean): string | null { + if (!bgColor) return null; + try { + const match = bgColor.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/); + if (match) { + const hex = match.slice(1).map(n => parseInt(n, 10).toString(16).padStart(2, "0")).join(""); + return setting ? hex : "#" + hex; + } + // fallback for other formats + const c = chroma(bgColor); + return setting ? c.hex().substring(1) : c.hex(); + } catch { + return null; + } +} + +// 导航项 +const navItems = [ + { key: "color", label: "颜色", icon: }, + { key: "ai", label: "AI 配色", icon: }, + { key: "ui", label: "UI 色卡", icon: }, + { key: "image", label: "图片色卡", icon: }, + { key: "traditional", label: "传统色", icon: }, + { key: "gradient", label: "渐变色", icon: }, + { key: "collect", label: "收藏颜色", icon: }, +]; + +interface AppState { + nav: string; + colorValue: (string | null)[]; + openMessage: boolean; + messageData: { key: number; color: string; body?: string }; + setting: boolean; +} + +class App extends Component<{}, AppState> { + private imageEnterPayload: any = null; + + state: AppState = { + nav: "", + colorValue: [], + openMessage: false, + messageData: { key: 0, color: "" }, + setting: false, + }; + + handleNavChange = (nav: string) => () => { + (document.activeElement as HTMLElement)?.blur(); + this.setState({ nav }); + }; + + handleColorClick = (eventOrColor: any) => { + let color: string | null = null; + + if (eventOrColor?.currentTarget?.style) { + color = colorFromBackgroundColor(eventOrColor.currentTarget.style.backgroundColor, this.state.setting); + } else if (typeof eventOrColor === 'string') { + try { + const c = chroma(eventOrColor); + color = this.state.setting ? c.hex().substring(1) : c.hex(); + } catch {} + } + + if (color) { + copyText(color); + this.setState({ + messageData: { color, key: Date.now() }, + openMessage: true, + }); + } + }; + + handleViewColorInfo = () => { + if (this.state.messageData.color) { + this.setState({ nav: "color", colorValue: [this.state.messageData.color], openMessage: false }); + } + }; + + handleColorToAi = () => { + if (this.state.messageData.color) { + this.setState({ nav: "ai", colorValue: [this.state.messageData.color], openMessage: false }); + } + }; + + handleSnackbarClose = (_event: any, reason: string) => { + if (reason === "clickaway") return; + this.setState({ openMessage: false }); + }; + + showMessage = (msg: string) => { + this.setState({ messageData: { key: Date.now(), color: "", body: msg }, openMessage: true }); + }; + + handleSettingCheckboxChange = (e: any) => { + if (e.target.checked) { + dbStorage.setItem("setting", true); + } else { + dbStorage.removeItem("setting"); + } + this.setState({ setting: e.target.checked }); + }; + + // 平台生命周期 + componentDidMount() { + onPluginEnter(({ code, type, payload }: any) => { + const setting = !!dbStorage.getItem("setting"); + + if (code === "image") { + if (type === "img") { + this.imageEnterPayload = payload; + } else if (type === "files") { + this.imageEnterPayload = payload?.[0]?.path; + } else { + this.imageEnterPayload = null; + } + } else { + if (code === "pickercolor") { + screenColorPick(({ hex }) => { + this.setState({ nav: "color", colorValue: [hex], setting }); + }); + return; + } + if (code === "color" && type === "regex") { + this.setState({ nav: "color", colorValue: [payload], setting }); + return; + } + if (code === "ai" && type === "regex") { + this.setState({ nav: "ai", colorValue: [payload], setting }); + return; + } + } + + this.setState({ nav: code, setting }); + }); + + onPluginOut(() => { + this.setState({ nav: "", openMessage: false }); + }); + + const setting = !!dbStorage.getItem("setting"); + if (setting !== this.state.setting) { + this.setState({ setting }); + } + + // 非平台环境默认 + if (!isPlatform && !this.state.nav) { + this.setState({ nav: "color" }); + } + } + + render() { + const { nav, colorValue, openMessage, messageData, setting } = this.state; + + // 页面内容 + let pageContent: React.ReactNode; + switch (nav) { + case "color": + pageContent = ; + break; + case "ui": + pageContent = ; + break; + case "traditional": + pageContent = ; + break; + case "gradient": + pageContent = ; + break; + case "image": + pageContent = ; + break; + case "collect": + pageContent = ; + break; + case "ai": + pageContent = ; + break; + default: + pageContent = false; + } + + return ( +
        +
        + + {navItems.map(item => ( + + + {item.icon} + + + + ))} + + + } + label='色值去 "#"' + /> + +
        +
        + {pageContent} +
        + + + + + ) : undefined} + key={messageData.key} + /> +
        + ); + } +} + +export default App; diff --git a/plugins/color-helper/CHANGELOG.md b/plugins/color-helper/CHANGELOG.md new file mode 100644 index 000000000..e9db8ef8e --- /dev/null +++ b/plugins/color-helper/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## 1.0.0 + +### 新增 + +- 颜色助手:颜色、AI 配色、图片色卡、UI 色卡、传统色、渐变色、收藏颜色 diff --git a/plugins/color-helper/README.md b/plugins/color-helper/README.md new file mode 100644 index 000000000..8a154931c --- /dev/null +++ b/plugins/color-helper/README.md @@ -0,0 +1,118 @@ +# 颜色助手 + +颜色助手复刻,仅供学习交流。 + +## 特性 + +- 多格式颜色转换(HEX / RGB / HSL / HSV / HSI / CMYK / LAB) +- AI 智能配色方案生成 +- 图片色卡提取与生成 +- UI 色卡浏览(Material Design / Ant Design / Fluent / Flat UI / Open Color) +- 传统色浏览(中国传统色 384 种 / 日本传统色 465 种) +- 渐变色合集浏览 +- 屏幕取色 +- 颜色收藏管理 +- 一键复制色值,支持去 `#` 设置 +- 色彩和谐方案(互补色 / 类似色 / 三角色 / 四角色) + +## 快速开始 + +### 环境要求 + +- Node.js 16+ +- npm 或 yarn + +### 安装与运行 + +```bash +# 安装依赖 +npm install + +# 开发模式 +npm run dev + +# 构建生产版本 +npm run build + +# 预览构建结果 +npm run preview +``` + +## 项目结构 + +``` +├── assets/ # 静态资源(色卡封面图) +├── context/ +│ └── AppContext.tsx # 全局上下文 +├── data/ # 颜色数据 +│ ├── gradients-*.json # 渐变色数据 +│ ├── traditional-*.json # 传统色数据 +│ └── ui-*.json # UI 色卡数据 +├── pages/ # 页面组件 +│ ├── ColorPage.tsx # 颜色转换页 +│ ├── AIPalettePage.tsx # AI 配色页 +│ ├── UIPalettesPage.tsx # UI 色卡页 +│ ├── TraditionalColorsPage.tsx # 传统色页 +│ ├── GradientsPage.tsx # 渐变色页 +│ ├── ImagePalettePage.tsx # 图片色卡页 +│ └── CollectColorsPage.tsx # 收藏颜色页 +├── public/ # 公共资源 +│ ├── logo.png +│ ├── plugin.json # 插件配置 +│ └── preload.cjs # 预加载脚本 +├── types/ +│ └── index.ts # 类型定义 +├── utils/ # 工具函数 +│ ├── color.ts # 颜色处理 +│ ├── platform.ts # 平台 API 适配层 +│ ├── quantize.ts # 颜色量化算法 +│ └── storage.ts # 存储工具 +├── App.tsx # 应用入口组件 +├── main.tsx # 渲染入口 +├── index.html # HTML 模板 +├── index.css # 全局样式 +├── vite.config.ts # Vite 配置 +├── tsconfig.json # TypeScript 配置 +└── package.json # 项目配置 +``` + +## 技术栈 + +| 技术 | 用途 | +|------|------| +| React 18 | UI 框架 | +| TypeScript | 类型安全 | +| Vite 4 | 构建工具 | +| MUI 5 | UI 组件库 | +| chroma-js | 颜色空间转换与计算 | +| iro.js | 色轮选择器 | + +## 核心功能说明 + +### 颜色转换 + +支持 HEX、RGB、HSL、HSV、HSI、CMYK、LAB 多种颜色格式的相互转换,提供色轮选择器与饱和度/明度滑块,并基于当前颜色生成互补色、类似色、三角色、四角色等和谐方案。 + +### AI 配色 + +基于用户选择的主色,结合风格、色系、配色数量等参数,调用 AI 模型生成配色方案,每个配色包含色值、名称和描述。 + +### 图片色卡 + +从图片中提取主要颜色,支持截图、本地图片导入,使用中位切分(Median Cut)量化算法进行颜色聚类,可生成色卡图片并复制导出。 + +### UI 色卡 + +内置 Material Design、Ant Design、Fluent、Flat UI、Open Color 五套主流 UI 设计系统的完整色板,方便查阅和取用。 + +### 传统色 + +收录故宫二十四节气相关的 384 种中国传统色和 465 种日本传统色,每种颜色附带名称与色值。 + +### 渐变色 + +提供多组精选渐变色方案,支持一键复制 CSS 渐变代码。 + +### 平台适配 + +通过 `utils/platform.ts` 统一适配层,支持插件平台 API(屏幕取色、截图、剪贴板、AI、存储等),同时在浏览器环境中提供 fallback 实现,确保独立运行时功能可用。 diff --git a/plugins/color-helper/assets/color-card-01.jpg b/plugins/color-helper/assets/color-card-01.jpg new file mode 100644 index 000000000..0694cb455 Binary files /dev/null and b/plugins/color-helper/assets/color-card-01.jpg differ diff --git a/plugins/color-helper/assets/color-card-02.jpg b/plugins/color-helper/assets/color-card-02.jpg new file mode 100644 index 000000000..c4a53b98c Binary files /dev/null and b/plugins/color-helper/assets/color-card-02.jpg differ diff --git a/plugins/color-helper/assets/color-card-03.jpg b/plugins/color-helper/assets/color-card-03.jpg new file mode 100644 index 000000000..55e295dee Binary files /dev/null and b/plugins/color-helper/assets/color-card-03.jpg differ diff --git a/plugins/color-helper/assets/color-card-04.jpg b/plugins/color-helper/assets/color-card-04.jpg new file mode 100644 index 000000000..a17ad4ca5 Binary files /dev/null and b/plugins/color-helper/assets/color-card-04.jpg differ diff --git a/plugins/color-helper/context/AppContext.tsx b/plugins/color-helper/context/AppContext.tsx new file mode 100644 index 000000000..f436db4d8 --- /dev/null +++ b/plugins/color-helper/context/AppContext.tsx @@ -0,0 +1,131 @@ +import React, { createContext, useContext, useState, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { dbStorage, copyText } from '../utils/platform'; + +/** + * AppContext - 全局状态管理 + * + * 提供全局功能: + * - setting: 色值去 "#" 的设置状态 + * - onColorClick: 点击颜色后的全局处理(复制+Snackbar+操作按钮) + * - formatCopiedColor: 根据setting格式化复制的颜色值 + */ + +interface ColorMessageData { + color: string; + key: number; + body?: string; +} + +interface AppContextValue { + setting: boolean; + setSetting: (value: boolean) => void; + onColorClick: (eventOrColor: any) => void; + showMessage: (msg: string) => void; + colorMessage: ColorMessageData | null; + openMessage: boolean; + setOpenMessage: (value: boolean) => void; +} + +const AppContext = createContext(null); + +export const useAppContext = () => { + const ctx = useContext(AppContext); + if (!ctx) throw new Error('useAppContext must be used within AppContextProvider'); + return ctx; +}; + +/** + * 从 rgb(r, g, b) 格式的backgroundColor解析出HEX颜色值 + * setting=true时去掉#前缀,setting=false时保留#前缀 + */ +function colorFromBackgroundColor(backgroundColor: string, setting: boolean): string { + const match = backgroundColor.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/); + if (!match) return backgroundColor; + const hex = match.slice(1).map((n: string) => parseInt(n, 10).toString(16).padStart(2, "0")).join(""); + return `${setting ? "" : "#"}${hex}`; +} + +/** + * 将HEX颜色转为 rgb(r, g, b) 格式 + */ +export function hexToRgbString(hex: string): string { + hex = hex.slice(1); + const re = new RegExp(`.{1,${hex.length >= 6 ? 2 : 1}}`, "g"); + let result = hex.match(re); + if (result && result[0].length === 1) { + result = result.map((o: string) => o + o); + } + if (!result) return ""; + return `rgb${result.length === 4 ? "a" : ""}(${result.map((o: string, s: number) => + s < 3 ? parseInt(o, 16) : Math.round(parseInt(o, 16) / 255 * 1000) / 1000 + ).join(", ")})`; +} + +export const AppContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const navigate = useNavigate(); + const [setting, setSettingState] = useState(() => { + return !!dbStorage.getItem('setting'); + }); + const [colorMessage, setColorMessage] = useState(null); + const [openMessage, setOpenMessage] = useState(false); + + // 全局颜色点击处理 + // 兼容两种调用方式: 1) 传入DOM event对象 2) 传入颜色字符串 + const onColorClick = useCallback((eventOrColor: any) => { + let color: string; + if (eventOrColor && eventOrColor.currentTarget && eventOrColor.currentTarget.style) { + // 从DOM event的backgroundColor提取 + color = colorFromBackgroundColor(eventOrColor.currentTarget.style.backgroundColor, setting); + } else if (typeof eventOrColor === 'string') { + // 字符串方式: 直接使用 + color = eventOrColor; + } else { + return; + } + + copyText(color); + setColorMessage({ color, key: Date.now() }); + setOpenMessage(true); + }, [setting]); + + // showMessage + const showMessage = useCallback((msg: string) => { + setColorMessage({ key: Date.now(), body: msg, color: '' }); + setOpenMessage(true); + }, []); + + // 处理设置checkbox变更 + const handleSettingChange = useCallback((checked: boolean) => { + if (checked) { + dbStorage.setItem('setting', true); + } else { + dbStorage.removeItem('setting'); + } + setSettingState(checked); + }, []); + + // 暴露全局路由函数给main.tsx的onPluginEnter使用 + React.useEffect(() => { + window.__platformNavigate = (path: string, state?: any) => { + navigate(path, { state }); + }; + return () => { window.__platformNavigate = null; }; + }, [navigate]); + + return ( + + {children} + + ); +}; + +export default AppContext; diff --git a/plugins/color-helper/data/gradients-eT.json b/plugins/color-helper/data/gradients-eT.json new file mode 100644 index 000000000..6208b5f16 --- /dev/null +++ b/plugins/color-helper/data/gradients-eT.json @@ -0,0 +1 @@ +[["#FCE38A","#F38181"],["#F54EA2","#FF7676"],["#17EAD9","#6078EA"],["#622774","#C53364"],["#7117EA","#EA6060"],["#42E695","#3BB2B8"],["#F02FC2","#6094EA"],["#65799B","#5E2563"],["#184E68","#57CA85"],["#5B247A","#1BCEDF"]] \ No newline at end of file diff --git a/plugins/color-helper/data/gradients-nT.json b/plugins/color-helper/data/gradients-nT.json new file mode 100644 index 000000000..0378bcc04 --- /dev/null +++ b/plugins/color-helper/data/gradients-nT.json @@ -0,0 +1 @@ +[["#091E3A","#2F80ED","#2D9EE0"],["#9400D3","#4B0082"],["#C84E89","#F15F79"],["#00F5A0","#00D9F5"],["#F7941E","#72C6EF","#00A651"],["#F7941E","#004E8F"],["#72C6EF","#004E8F"],["#FD8112","#0085CA"],["#BF5AE0","#A811DA"],["#00416A","#E4E5E6"],["#FBED96","#ABECD6"],["#FFE000","#799F0C"],["#F7F8F8","#ACBB78"],["#00416A","#799F0C","#FFE000"],["#334D50","#CBCAA5"],["#0052D4","#4364F7","#6FB1FC"],["#5433FF","#20BDFF","#A5FECB"],["#799F0C","#ACBB78"],["#FFE259","#FFA751"],["#00416A","#E4E5E6"],["#FFE000","#799F0C"],["#ACB6E5","#86FDE8"],["#536976","#292E49"],["#BBD2C5","#536976","#292E49"],["#B79891","#94716B"],["#9796F0","#FBC7D4"],["#BBD2C5","#536976"],["#076585","#FFF"],["#00467F","#A5CC82"],["#000C40","#607D8B"],["#1488CC","#2B32B2"],["#EC008C","#FC6767"],["#CC2B5E","#753A88"],["#2193B0","#6DD5ED"],["#E65C00","#F9D423"],["#2B5876","#4E4376"],["#314755","#26A0DA"],["#77A1D3","#79CBCA","#E684AE"],["#FF6E7F","#BFE9FF"],["#E52D27","#B31217"],["#603813","#B29F94"],["#16A085","#F4D03F"],["#D31027","#EA384D"],["#EDE574","#E1F5C4"],["#02AAB0","#00CDAC"],["#DA22FF","#9733EE"],["#348F50","#56B4D3"],["#3CA55C","#B5AC49"],["#CC95C0","#DBD4B4","#7AA1D2"],["#003973","#E5E5BE"],["#E55D87","#5FC3E4"],["#403B4A","#E7E9BB"],["#F09819","#EDDE5D"],["#FF512F","#DD2476"],["#AA076B","#61045F"],["#1A2980","#26D0CE"],["#FF512F","#F09819"],["#1D2B64","#F8CDDA"],["#1FA2FF","#12D8FA","#A6FFCB"],["#4CB8C4","#3CD3AD"],["#DD5E89","#F7BB97"],["#EB3349","#F45C43"],["#1D976C","#93F9B9"],["#FF8008","#FFC837"],["#16222A","#3A6073"],["#1F1C2C","#928DAB"],["#614385","#516395"],["#4776E6","#8E54E9"],["#085078","#85D8CE"],["#2BC0E4","#EAECC6"],["#134E5E","#71B280"],["#5C258D","#4389A2"],["#757F9A","#D7DDE8"],["#232526","#414345"],["#1CD8D2","#93EDC7"],["#3D7EAA","#FFE47A"],["#283048","#859398"],["#24C6DC","#514A9D"],["#DC2424","#4A569D"],["#ED4264","#FFEDBC"],["#DAE2F8","#D6A4A4"],["#ECE9E6","#FFFFFF"],["#7474BF","#348AC7"],["#EC6F66","#F3A183"],["#5F2C82","#49A09D"],["#C04848","#480048"],["#E43A15","#E65245"],["#414D0B","#727A17"],["#FC354C","#0ABFBC"],["#4B6CB7","#182848"],["#F857A6","#FF5858"],["#A73737","#7A2828"],["#D53369","#CBAD6D"],["#E9D362","#333333"],["#DE6262","#FFB88C"],["#666600","#999966"],["#FFEEEE","#DDEFBB"],["#EFEFBB","#D4D3DD"],["#C21500","#FFC500"],["#215F00","#E4E4D9"],["#50C9C3","#96DEDA"],["#616161","#9BC5C3"],["#DDD6F3","#FAACA8"],["#5D4157","#A8CABA"],["#E6DADA","#274046"],["#F2709C","#FF9472"],["#DAD299","#B0DAB9"],["#D3959B","#BFE6BA"],["#00D2FF","#3A7BD5"],["#870000","#190A05"],["#B993D6","#8CA6DB"],["#649173","#DBD5A4"],["#C9FFBF","#FFAFBD"],["#606C88","#3F4C6B"],["#FBD3E9","#BB377D"],["#ADD100","#7B920A"],["#FF4E50","#F9D423"],["#F0C27B","#4B1248"],["#000000","#E74C3C"],["#AAFFA9","#11FFBD"],["#B3FFAB","#12FFF7"],["#780206","#061161"],["#9D50BB","#6E48AA"],["#556270","#FF6B6B"],["#70E1F5","#FFD194"],["#00C6FF","#0072FF"],["#FE8C00","#F83600"],["#52C234","#061700"],["#485563","#29323C"],["#83A4D4","#B6FBFF"],["#FDFC47","#24FE41"],["#ABBAAB","#FFFFFF"],["#73C8A9","#373B44"],["#D38312","#A83279"],["#1E130C","#9A8478"],["#948E99","#2E1437"],["#360033","#0B8793"],["#FFA17F","#00223E"],["#43CEA2","#185A9D"],["#FFB347","#FFCC33"],["#6441A5","#2A0845"],["#FEAC5E","#C779D0","#4BC0C8"],["#833AB4","#FD1D1D","#FCB045"],["#FF0084","#33001B"],["#00BF8F","#001510"],["#136A8A","#267871"],["#8E9EAB","#EEF2F3"],["#7B4397","#DC2430"],["#D1913C","#FFD194"],["#F1F2B5","#135058"],["#6A9113","#141517"],["#004FF9","#FFF94C"],["#525252","#3D72B4"],["#BA8B02","#181818"],["#EE9CA7","#FFDDE1"],["#304352","#D7D2CC"],["#CCCCB2","#757519"],["#2C3E50","#3498DB"],["#FC00FF","#00DBDE"],["#E53935","#E35D5B"],["#005C97","#363795"],["#F46B45","#EEA849"],["#00C9FF","#92FE9D"],["#673AB7","#512DA8"],["#76B852","#8DC26F"],["#8E0E00","#1F1C18"],["#FFB75E","#ED8F03"],["#C2E59C","#64B3F4"],["#403A3E","#BE5869"],["#C02425","#F0CB35"],["#B24592","#F15F79"],["#457FCA","#5691C8"],["#6A3093","#A044FF"],["#EACDA3","#D6AE7B"],["#FD746C","#FF9068"],["#114357","#F29492"],["#1E3C72","#2A5298"],["#2F7336","#AA3A38"],["#5614B0","#DBD65C"],["#4DA0B0","#D39D38"],["#5A3F37","#2C7744"],["#2980B9","#2C3E50"],["#0099F7","#F11712"],["#834D9B","#D04ED6"],["#4B79A1","#283E51"],["#000000","#434343"],["#4CA1AF","#C4E0E5"],["#E0EAFC","#CFDEF3"],["#BA5370","#F4E2D8"],["#FF4B1F","#1FDDFF"],["#F7FF00","#DB36A4"],["#A80077","#66FF00"],["#1D4350","#A43931"],["#EECDA3","#EF629F"],["#16BFFD","#CB3066"],["#FF4B1F","#FF9068"],["#FF5F6D","#FFC371"],["#2196F3","#F44336"],["#00D2FF","#928DAB"],["#3A7BD5","#3A6073"],["#0B486B","#F56217"],["#E96443","#904E95"],["#2C3E50","#4CA1AF"],["#2C3E50","#FD746C"],["#F00000","#DC281E"],["#141E30","#243B55"],["#42275A","#734B6D"],["#000428","#004E92"],["#56AB2F","#A8E063"],["#CB2D3E","#EF473A"],["#F79D00","#64F38C"],["#F85032","#E73827"],["#FCEABB","#F8B500"],["#808080","#3FADA8"],["#FFD89B","#19547B"],["#BDC3C7","#2C3E50"],["#BE93C5","#7BC6CC"],["#A1FFCE","#FAFFD1"],["#4ECDC4","#556270"],["#3A6186","#89253E"],["#EF32D9","#89FFFD"],["#DE6161","#2657EB"],["#FF00CC","#333399"],["#FFFC00","#FFFFFF"],["#FF7E5F","#FEB47B"],["#00C3FF","#FFFF1C"],["#F4C4F3","#FC67FA"],["#41295A","#2F0743"],["#A770EF","#CF8BF3","#FDB99B"],["#EE0979","#FF6A00"],["#F3904F","#3B4371"],["#67B26F","#4CA2CD"],["#3494E6","#EC6EAD"],["#DBE6F6","#C5796D"],["#9CECFB","#65C7F7","#0052D4"],["#C0C0AA","#1CEFFF"],["#DCE35B","#45B649"],["#E8CBC0","#636FA4"],["#F0F2F0","#000C40"],["#FFAFBD","#FFC3A0"],["#43C6AC","#F8FFAE"],["#093028","#237A57"],["#43C6AC","#191654"],["#4568DC","#B06AB3"],["#0575E6","#021B79"],["#200122","#6F0000"],["#44A08D","#093637"],["#6190E8","#A7BFE8"],["#34E89E","#0F3443"],["#F7971E","#FFD200"],["#C33764","#1D2671"],["#20002C","#CBB4D4"],["#D66D75","#E29587"],["#30E8BF","#FF8235"],["#B2FEFA","#0ED2F7"],["#4AC29A","#BDFFF3"],["#E44D26","#F16529"],["#EB5757","#000000"],["#F2994A","#F2C94C"],["#56CCF2","#2F80ED"],["#007991","#78FFD6"],["#000046","#1CB5E0"],["#159957","#155799"],["#C0392B","#8E44AD"],["#EF3B36","#FFFFFF"],["#283C86","#45A247"],["#3A1C71","#D76D77","#FFAF7B"],["#CB356B","#BD3F32"],["#36D1DC","#5B86E5"],["#000000","#0F9B0F"],["#1C92D2","#F2FCFE"],["#642B73","#C6426E"],["#06BEB6","#48B1BF"],["#0CEBEB","#20E3B2","#29FFC6"],["#D9A7C7","#FFFCDC"],["#396AFC","#2948FF"],["#C9D6FF","#E2E2E2"],["#7F00FF","#E100FF"],["#FF9966","#FF5E62"],["#22C1C3","#FDBB2D"],["#1A2A6C","#B21F1F","#FDBB2D"],["#E1EEC3","#F05053"],["#ADA996","#F2F2F2","#DBDBDB","#EAEAEA"],["#667DB6","#0082C8","#0082C8","#667DB6"],["#03001E","#7303C0","#EC38BC","#FDEFF9"],["#6D6027","#D3CBB8"],["#74EBD5","#ACB6E5"],["#FC4A1A","#F7B733"],["#00F260","#0575E6"],["#800080","#FFC0CB"],["#CAC531","#F3F9A7"],["#3C3B3F","#605C3C"],["#D3CCE3","#E9E4F0"],["#00B09B","#96C93D"],["#0F0C29","#302B63","#24243E"],["#FFFBD5","#B20A2C"],["#23074D","#CC5333"],["#C94B4B","#4B134F"],["#FC466B","#3F5EFB"],["#FC5C7D","#6A82FB"],["#108DC7","#EF8E38"],["#11998E","#38EF7D"],["#3E5151","#DECBA4"],["#40E0D0","#FF8C00","#FF0080"],["#BC4E9C","#F80759"],["#355C7D","#6C5B7B","#C06C84"],["#4E54C8","#8F94FB"],["#333333","#DD1818"],["#A8C0FF","#3F2B96"],["#AD5389","#3C1053"],["#636363","#A2AB58"],["#DA4453","#89216B"],["#005AA7","#FFFDE4"],["#59C173","#A17FE0","#5D26C1"],["#FFEFBA","#FFFFFF"],["#00B4DB","#0083B0"],["#FDC830","#F37335"],["#ED213A","#93291E"],["#1E9600","#FFF200","#FF0000"],["#A8FF78","#78FFD6"],["#8A2387","#E94057","#F27121"],["#FF416C","#FF4B2B"],["#654EA3","#EAAFC8"],["#009FFF","#EC2F4B"],["#544A7D","#FFD452"],["#8360C3","#2EBF91"],["#DD3E54","#6BE585"],["#659999","#F4791F"],["#F12711","#F5AF19"],["#C31432","#240B36"],["#7F7FD5","#86A8E7","#91EAE4"],["#F953C6","#B91D73"],["#1F4037","#99F2C8"],["#8E2DE2","#4A00E0"],["#AA4B6B","#6B6B83","#3B8D99"],["#FF0099","#493240"],["#2980B9","#6DD5FA","#FFFFFF"],["#373B44","#4286F4"],["#B92B27","#1565C0"],["#12C2E9","#C471ED","#F64F59"],["#0F2027","#203A43","#2C5364"],["#C6FFDD","#FBD786","#F7797D"],["#2193B0","#6DD5ED"],["#EE9CA7","#FFDDE1"],["#BDC3C7","#2C3E50"],["#F36222","#5CB644","#007FC3"],["#2A2D3E","#FECB6E"],["#8A2BE2","#0000CD","#228B22","#CCFF00"],["#051937","#004D7A","#008793","#00BF72","#A8EB12"],["#6025F5","#FF5555"],["#8A2BE2","#FFA500","#F8F8FF"],["#2774AE","#002E5D","#002E5D"],["#004680","#4484BA"],["#7EC6BC","#EBE717"],["#FF1E56","#F9C942","#1E90FF"],["#DE8A41","#2ADA53"],["#F7F0AC","#ACF7F0","#F0ACF7"],["#FF0000","#FDCF58"],["#36B1C7","#960B33"],["#1DA1F2","#009FFC"],["#6DA6BE","#4B859E","#6DA6BE"],["#B5B9FF","#2B2C49"],["#9FA0A8","#5C7852"],["#DCFFBD","#CC86D1"],["#8BDEDA","#43ADD0","#998EE0","#E17DC2","#EF9393"],["#E6AE8C","#A8CECF"],["#00FFF0","#0083FE"],["#333333","#A2AB58","#A43931"],["#0C0C6D","#DE512B","#98D0C1","#5BB226","#023C0D"],["#05386B","#5CDB95"],["#4284DB","#29EAC4"],["#554023","#C99846"],["#516B8B","#056B3B"],["#D70652","#FF025E"],["#152331","#000000"],["#F7F7F7","#B9A0A0","#794747","#4E2020","#111111"],["#59CDE9","#0A2A88"],["#EB0000","#95008A","#3300FC"],["#FF75C3","#FFA647","#FFE83F","#9FFF5B","#70E2FF","#CD93FF"],["#81FF8A","#64965E"],["#D4FC79","#96E6A1"],["#003D4D","#00C996"]] \ No newline at end of file diff --git a/plugins/color-helper/data/gradients-tT.json b/plugins/color-helper/data/gradients-tT.json new file mode 100644 index 000000000..cc9c53a9d --- /dev/null +++ b/plugins/color-helper/data/gradients-tT.json @@ -0,0 +1 @@ +[["#FDEB71","#F8D800"],["#ABDCFF","#0396FF"],["#FEB692","#EA5455"],["#CE9FFC","#7367F0"],["#90F7EC","#32CCBC"],["#FFF6B7","#F6416C"],["#81FBB8","#28C76F"],["#E2B0FF","#9F44D3"],["#F97794","#623AA2"],["#FCCF31","#F55555"],["#F761A1","#8C1BAB"],["#43CBFF","#9708CC"],["#5EFCE8","#736EFE"],["#FAD7A1","#E96D71"],["#FFD26F","#3677FF"],["#A0FE65","#FA016D"],["#FFDB01","#0E197D"],["#FEC163","#DE4313"],["#92FFC0","#002661"],["#EEAD92","#6018DC"],["#F6CEEC","#D939CD"],["#52E5E7","#130CB7"],["#F1CA74","#A64DB6"],["#E8D07A","#5312D6"],["#EECE13","#B210FF"],["#79F1A4","#0E5CAD"],["#FDD819","#E80505"],["#FFF3B0","#CA26FF"],["#FFF5C3","#9452A5"],["#F05F57","#360940"],["#2AFADF","#4C83FF"],["#FFF886","#F072B6"],["#97ABFF","#123597"],["#F5CBFF","#C346C2"],["#FFF720","#3CD500"],["#FF6FD8","#3813C2"],["#EE9AE5","#5961F9"],["#FFD3A5","#FD6585"],["#C2FFD8","#465EFB"],["#FD6585","#0D25B9"],["#FD6E6A","#FFC600"],["#65FDF0","#1D6FA3"],["#6B73FF","#000DFF"],["#FF7AF5","#513162"],["#F0FF00","#58CFFB"],["#FFE985","#FA742B"],["#FFA6B7","#1E2AD2"],["#FFAA85","#B3315F"],["#72EDF2","#5151E5"],["#FF9D6C","#BB4E75"],["#F6D242","#FF52E5"],["#69FF97","#00E4FF"],["#3B2667","#BC78EC"],["#70F570","#49C628"],["#3C8CE7","#00EAFF"],["#FAB2FF","#1904E5"],["#81FFEF","#F067B4"],["#FFA8A8","#FCFF00"],["#FFCF71","#2376DD"],["#FF96F9","#C32BAC"]] \ No newline at end of file diff --git a/plugins/color-helper/data/traditional-china.json b/plugins/color-helper/data/traditional-china.json new file mode 100644 index 000000000..a5d99f7fd --- /dev/null +++ b/plugins/color-helper/data/traditional-china.json @@ -0,0 +1 @@ +[{"title":"立春","colors":[{"name":"黄白游","color":"#FFF799"},{"name":"松花","color":"#FFEE6F"},{"name":"缃叶","color":"#ECD452"},{"name":"苍黄","color":"#B6A014"},{"name":"天缥","color":"#D5EBE1"},{"name":"沧浪","color":"#B1D5C8"},{"name":"苍筤","color":"#99BCAC"},{"name":"缥碧","color":"#80A492"},{"name":"流黄","color":"#8B7042"},{"name":"栗壳","color":"#775039"},{"name":"龙战","color":"#5F4321"},{"name":"青骊","color":"#422517"},{"name":"海天霞","color":"#F3A694"},{"name":"缙云","color":"#EE7959"},{"name":"纁黄","color":"#BA5140"},{"name":"珊瑚赫","color":"#C12C1F"}]},{"title":"雨水","colors":[{"name":"盈盈","color":"#F9D3E3"},{"name":"水红","color":"#ECB0C1"},{"name":"苏梅 ","color":"#DD7694"},{"name":"紫茎屏风","color":"#A76283"},{"name":"葭灰","color":"#BEB1AA"},{"name":"黄埃","color":"#B49273"},{"name":"老僧衣","color":"#A46244"},{"name":"玄天","color":"#6B5458"},{"name":"黄河琉璃","color":"#E5A84B"},{"name":"库金","color":"#E18A3B"},{"name":"缊韨","color":"#984F31"},{"name":"紫瓯","color":"#7C461E"},{"name":"欧碧","color":"#C0D695"},{"name":"春辰","color":"#A9BE7B"},{"name":"碧山","color":"#779649"},{"name":"青青","color":"#4F6F46"}]},{"title":"惊蛰","colors":[{"name":"赤缇","color":"#BA5B49"},{"name":"朱草","color":"#A64036"},{"name":"綪茷","color":"#9E2A22"},{"name":"顺圣","color":"#7C191E"},{"name":"桃夭","color":"#F6BEC8"},{"name":"杨妃","color":"#F091A0"},{"name":"长春","color":"#DC6B82"},{"name":"牙绯","color":"#C35C5D"},{"name":"黄栗留","color":"#FEDC5E"},{"name":"栀子","color":"#FAC03D"},{"name":"黄不老","color":"#DB9B34"},{"name":"柘黄","color":"#C67915"},{"name":"青鸾","color":"#9AA7B1"},{"name":"菘蓝","color":"#6B798E"},{"name":"青黛","color":"#45465E"},{"name":"绀蝶","color":"#2C2F3B"}]},{"title":"春分","colors":[{"name":"皦玉","color":"#EBEEE8"},{"name":"吉量","color":"#EBEDDF"},{"name":"韶粉","color":"#E0E0D0"},{"name":"霜地","color":"#C7C6B6"},{"name":"夏籥","color":"#D2AF9D"},{"name":"紫磨金","color":"#BC836B"},{"name":"檀色","color":"#B26D5D"},{"name":"赭罗","color":"#9A6655"},{"name":"黄丹","color":"#EA5514"},{"name":"洛神珠","color":"#D23918"},{"name":"丹雘","color":"#C8161D"},{"name":"水华朱","color":"#A72126"},{"name":"青冥","color":"#3271AE"},{"name":"青雘","color":"#007175"},{"name":"青緺","color":"#284852"},{"name":"骐驎","color":"#12264F"}]},{"title":"清明","colors":[{"name":"紫蒲","color":"#A6559D"},{"name":"赪紫","color":"#8A1874"},{"name":"齐紫","color":"#6C216D"},{"name":"凝夜紫","color":"#422256"},{"name":"冻缥","color":"#BEC2B3"},{"name":"春碧","color":"#9D9D82"},{"name":"执大象","color":"#919177"},{"name":"苔古","color":"#79836C"},{"name":"香炉紫烟","color":"#D3CCD6"},{"name":"紫菂","color":"#9B8EA9"},{"name":"拂紫绵","color":"#7E527F"},{"name":"三公子","color":"#663D74"},{"name":"琅玕紫","color":"#CB5C83"},{"name":"红踯躅","color":"#B83570"},{"name":"魏红","color":"#A73766"},{"name":"魏紫","color":"#903754"}]},{"title":"谷雨","colors":[{"name":"昌荣","color":"#DCC7E1"},{"name":"紫薄汗","color":"#BBA1CB"},{"name":"茈藐","color":"#A67EB7"},{"name":"紫紶","color":"#7D5284"},{"name":"苍葭","color":"#A8BF8F"},{"name":"庭芜绿","color":"#68945C"},{"name":"翠微","color":"#4C8045"},{"name":"翠虬","color":"#446A37"},{"name":"碧落","color":"#AED0EE"},{"name":"挼蓝","color":"#6E9BC5"},{"name":"青雀头黛","color":"#354E6B"},{"name":"螺子黛","color":"#13393E"},{"name":"露褐","color":"#BD8253"},{"name":"檀褐","color":"#945635"},{"name":"緅絺","color":"#804C2E"},{"name":"目童子","color":"#5B3222"}]},{"title":"立夏","colors":[{"name":"青粲","color":"#C3D94E"},{"name":"翠缥","color":"#B7D332"},{"name":"人籁","color":"#9EBC19"},{"name":"水龙吟","color":"#84A729"},{"name":"地籁","color":"#DFCEB4"},{"name":"大块","color":"#BFA782"},{"name":"养生主","color":"#B49B7F"},{"name":"大云","color":"#94784F"},{"name":"溶溶月","color":"#BEC2BC"},{"name":"绍衣","color":"#A8A19C"},{"name":"石莲褐","color":"#92897B"},{"name":"黑朱","color":"#70695D"},{"name":"朱颜酡","color":"#F29A76"},{"name":"苕荣","color":"#ED6D3D"},{"name":"檎丹","color":"#E94829"},{"name":"丹罽","color":"#E60012"}]},{"title":"小满","colors":[{"name":"彤管","color":"#E2A2AC"},{"name":"渥赭","color":"#DD6B7B"},{"name":"唇脂","color":"#C25160"},{"name":"朱孔阳","color":"#B81A35"},{"name":"石发","color":"#6A8D52"},{"name":"漆姑","color":"#5D8351"},{"name":"芰荷","color":"#4F794A"},{"name":"官绿","color":"#2A6E3F"},{"name":"仙米","color":"#D4C9AA"},{"name":"黄螺","color":"#B4A379"},{"name":"降真香","color":"#9E8358"},{"name":"远志","color":"#81663B"},{"name":"嫩鹅黄","color":"#F2C867"},{"name":"鞠衣","color":"#D3A237"},{"name":"郁金裙","color":"#D08635"},{"name":"黄流","color":"#9F6027"}]},{"title":"芒种","colors":[{"name":"筠雾","color":"#D5D1AE"},{"name":"瓷秘","color":"#BFC096"},{"name":"琬琰","color":"#A9A886"},{"name":"青圭","color":"#92905D"},{"name":"鸣珂","color":"#B3B59C"},{"name":"青玉案","color":"#A8B092"},{"name":"出岫","color":"#A9A773"},{"name":"风入松","color":"#868C4E"},{"name":"如梦令","color":"#DDBB99"},{"name":"芸黄","color":"#D2A36C"},{"name":"金埒","color":"#BE9457"},{"name":"雌黄","color":"#B4884D"},{"name":"曾青","color":"#535164"},{"name":"䒌靘","color":"#454659"},{"name":"璆琳","color":"#343041"},{"name":"瑾瑜","color":"#1E2732"}]},{"title":"夏至","colors":[{"name":"赩炽","color":"#CB523E"},{"name":"石榴裙","color":"#B13B2E"},{"name":"朱湛","color":"#95302E"},{"name":"大繎","color":"#822327"},{"name":"月魄","color":"#B2B6B6"},{"name":"不皂","color":"#A7AAA1"},{"name":"雷雨垂","color":"#7A7B78"},{"name":"石涅","color":"#686A67"},{"name":"扶光","color":"#F0C2A2"},{"name":"椒房","color":"#DB9C5E"},{"name":"红友","color":"#D9883D"},{"name":"光明砂","color":"#CC5D20"},{"name":"山矾","color":"#F5F3F2"},{"name":"玉頩","color":"#EAE5E3"},{"name":"二目鱼","color":"#DFE0D9"},{"name":"明月珰","color":"#D4D3CA"}]},{"title":"小暑","colors":[{"name":"骍刚","color":"#F5B087"},{"name":"赪霞","color":"#F18F60"},{"name":"赪尾","color":"#EF845D"},{"name":"朱柿","color":"#ED6D46"},{"name":"天球","color":"#E0DFC6"},{"name":"行香子","color":"#BFB99C"},{"name":"王刍","color":"#A99F70"},{"name":"荩箧","color":"#877D52"},{"name":"赤灵","color":"#954024"},{"name":"丹秫","color":"#873424"},{"name":"木兰","color":"#662B1F"},{"name":"麒麟竭","color":"#4C1E1A"},{"name":"柔蓝","color":"#106898"},{"name":"碧城","color":"#12507B"},{"name":"蓝采和","color":"#06436F"},{"name":"帝释青","color":"#003460"}]},{"title":"大暑","colors":[{"name":"夕岚","color":"#E3ADB9"},{"name":"雌霓","color":"#CF929E"},{"name":"绛纱","color":"#B27777"},{"name":"茹藘","color":"#A35F65"},{"name":"葱青","color":"#EDF1BB"},{"name":"少艾","color":"#E3EB98"},{"name":"绮钱","color":"#D8DE8A"},{"name":"翠樽","color":"#CDD171"},{"name":"石蜜","color":"#D4BF89"},{"name":"沙饧","color":"#BFA670"},{"name":"巨吕","color":"#AA8E59"},{"name":"吉金","color":"#896D47"},{"name":"山岚","color":"#BED2BB"},{"name":"渌波","color":"#9BB496"},{"name":"青楸","color":"#81A380"},{"name":"菉竹","color":"#698E6A"}]},{"title":"立秋","colors":[{"name":"窃蓝","color":"#88ABDA"},{"name":"监德","color":"#6F94CD"},{"name":"苍苍","color":"#5976BA"},{"name":"群青","color":"#2E59A7"},{"name":"白青","color":"#98B6C2"},{"name":"竹月","color":"#7F9FAF"},{"name":"空青","color":"#66889E"},{"name":"太师青","color":"#547689"},{"name":"缟羽","color":"#EFEFEF"},{"name":"香皮","color":"#D8D1C5"},{"name":"云母","color":"#C6BEB1"},{"name":"佩玖","color":"#AC9F8A"},{"name":"麹尘","color":"#C0D09D"},{"name":"绿沈","color":"#938F4C"},{"name":"绞衣","color":"#7F754C"},{"name":"素綦","color":"#595333"}]},{"title":"处暑","colors":[{"name":"退红","color":"#F0CFE3"},{"name":"樱花","color":"#E4B8D5"},{"name":"丁香","color":"#CE93BF"},{"name":"木槿","color":"#BA79B1"},{"name":"余白","color":"#C9CFC1"},{"name":"兰苕","color":"#A8B78C"},{"name":"碧滋","color":"#90A07D"},{"name":"葱倩","color":"#6C8650"},{"name":"云门","color":"#A2D2E2"},{"name":"西子","color":"#87C0CA"},{"name":"天水碧","color":"#5AA4AE"},{"name":"法翠","color":"#108B96"},{"name":"桑蕾","color":"#EAD89A"},{"name":"太一余粮","color":"#D5B45C"},{"name":"秋香","color":"#BF9C46"},{"name":"老茯神","color":"#AA8534"}]},{"title":"白露","colors":[{"name":"凝脂","color":"#F5F2E9"},{"name":"玉色","color":"#EAE4D1"},{"name":"黄润","color":"#DFD6B8"},{"name":"缣缃","color":"#D5C8A0"},{"name":"蕉月","color":"#86908A"},{"name":"千山翠","color":"#6B7D73"},{"name":"结绿","color":"#555F4D"},{"name":"绿云","color":"#45493D"},{"name":"藕丝秋半","color":"#D3CBC5"},{"name":"苍烟落照","color":"#C8B5B3"},{"name":"红藤杖","color":"#928187"},{"name":"紫鼠","color":"#594C57"},{"name":"黄粱","color":"#C4B798"},{"name":"蒸栗","color":"#A58A5F"},{"name":"射干","color":"#7C623F"},{"name":"油葫芦","color":"#644D31"}]},{"title":"秋分","colors":[{"name":"卵色","color":"#D5E3D4"},{"name":"葭菼","color":"#CAD7C5"},{"name":"冰台","color":"#BECAB7"},{"name":"青古","color":"#B3BDA9"},{"name":"栾华","color":"#C0AD5E"},{"name":"大赤","color":"#AA9649"},{"name":"佛赤","color":"#8F3D2C"},{"name":"蜜褐","color":"#683632"},{"name":"孔雀蓝","color":"#4994C4"},{"name":"吐绶蓝","color":"#4182A4"},{"name":"鱼师青","color":"#32788A"},{"name":"软翠","color":"#006D87"},{"name":"浅云","color":"#EAEEF1"},{"name":"素采","color":"#D4DDE1"},{"name":"影青","color":"#BDCBD2"},{"name":"逍遥游","color":"#B2BFC3"}]},{"title":"寒露","colors":[{"name":"醽醁","color":"#A6BAB1"},{"name":"翠涛","color":"#819D8E"},{"name":"青梅","color":"#778A77"},{"name":"翕赩","color":"#5F766A"},{"name":"九斤黄","color":"#DDB078"},{"name":"杏子","color":"#DA9233"},{"name":"媚蝶","color":"#BC6E37"},{"name":"韎韐","color":"#9F5221"},{"name":"东方既白","color":"#8BA3C7"},{"name":"绀宇","color":"#003D74"},{"name":"佛头青","color":"#19325F"},{"name":"花青","color":"#1A2847"},{"name":"弗肯红","color":"#ECD9C7"},{"name":"赤璋","color":"#E1C199"},{"name":"茧色","color":"#C6A268"},{"name":"密陀僧","color":"#B3934B"}]},{"title":"霜降","colors":[{"name":"银朱","color":"#D12920"},{"name":"胭脂虫","color":"#AB1D22"},{"name":"朱樱","color":"#8F1D22"},{"name":"爵头","color":"#631216"},{"name":"甘石","color":"#BDB2B2"},{"name":"迷楼灰","color":"#91828F"},{"name":"鸦雏","color":"#6A5B6D"},{"name":"烟墨","color":"#5C4F55"},{"name":"十样锦","color":"#F8C6B5"},{"name":"檀唇","color":"#DA9E8C"},{"name":"琼琚","color":"#D77F66"},{"name":"棠梨","color":"#B15A43"},{"name":"蜜合","color":"#DFD7C2"},{"name":"假山南","color":"#D4C1A6"},{"name":"紫花布","color":"#BEA78B"},{"name":"沉香","color":"#99806C"}]},{"title":"立冬","colors":[{"name":"半见","color":"#FFFBC7"},{"name":"女贞黄","color":"#F7EEAD"},{"name":"绢纨","color":"#ECE093"},{"name":"姜黄","color":"#D6C560"},{"name":"繱犗","color":"#88BFB8"},{"name":"二绿","color":"#5DA39D"},{"name":"铜青","color":"#3D8E86"},{"name":"石绿","color":"#206864"},{"name":"黄琮","color":"#9E8C6B"},{"name":"茶色","color":"#887657"},{"name":"伽罗","color":"#6D5C3D"},{"name":"苍艾","color":"#5A4B3B"},{"name":"藕丝褐","color":"#A88787"},{"name":"葡萄褐","color":"#9E696D"},{"name":"苏方","color":"#81474C"},{"name":"福色","color":"#662B2F"}]},{"title":"小雪","colors":[{"name":"龙膏烛","color":"#DE82A7"},{"name":"黪紫","color":"#CC73A0"},{"name":"胭脂水","color":"#B95A89"},{"name":"胭脂紫","color":"#B0436F"},{"name":"小红","color":"#E67762"},{"name":"岱赭","color":"#DD6B4F"},{"name":"鹤顶红","color":"#D24735"},{"name":"朱殷","color":"#B93A26"},{"name":"月白","color":"#D4E5EF"},{"name":"星郎","color":"#BCD4E7"},{"name":"晴山","color":"#A3BBDB"},{"name":"品月","color":"#8AABCC"},{"name":"明茶褐","color":"#9E8368"},{"name":"荆褐","color":"#906C4A"},{"name":"驼褐","color":"#7C5B3E"},{"name":"椒褐","color":"#72453A"}]},{"title":"大雪","colors":[{"name":"粉米","color":"#EFC4CE"},{"name":"縓缘","color":"#CE8892"},{"name":"美人祭","color":"#C35C6A"},{"name":"鞓红","color":"#B04552"},{"name":"米汤娇","color":"#EEEAD9"},{"name":"草白","color":"#BFC1A9"},{"name":"玄校","color":"#A9A082"},{"name":"綟绶","color":"#756C4B"},{"name":"雀梅","color":"#788A6F"},{"name":"油绿","color":"#5D7259"},{"name":"莓莓","color":"#4E6548"},{"name":"螺青","color":"#3F503B"},{"name":"暮山紫","color":"#A4ABD6"},{"name":"紫苑","color":"#757CBB"},{"name":"优昙瑞","color":"#615EA8"},{"name":"延维","color":"#4A4B9D"}]},{"title":"冬至","colors":[{"name":"银红","color":"#E7CAD3"},{"name":"莲红","color":"#D9A0B3"},{"name":"紫梅","color":"#BB7A8C"},{"name":"紫矿","color":"#9E4E56"},{"name":"咸池","color":"#DAA9A9"},{"name":"红䵂","color":"#CD7372"},{"name":"蚩尤旗","color":"#A85858"},{"name":"霁红","color":"#7C4449"},{"name":"莺儿","color":"#EBE1A9"},{"name":"禹余粮","color":"#E1D279"},{"name":"姚黄","color":"#D6BC46"},{"name":"蛾黄","color":"#BE8A2F"},{"name":"濯绛","color":"#796860"},{"name":"墨黪","color":"#585248"},{"name":"驖骊","color":"#46433B"},{"name":"京元","color":"#31322C"}]},{"title":"小寒","colors":[{"name":"酂白","color":"#F6F9E4"},{"name":"断肠","color":"#ECEBC2"},{"name":"田赤","color":"#E1D384"},{"name":"黄封","color":"#CAB272"},{"name":"丁香褐","color":"#BD9683"},{"name":"棠梨褐","color":"#955A42"},{"name":"朱石栗","color":"#81492C"},{"name":"枣褐","color":"#68361A"},{"name":"秋蓝","color":"#7D929F"},{"name":"育阳染","color":"#576470"},{"name":"霁蓝","color":"#3C4654"},{"name":"獭见","color":"#151D29"},{"name":"井天","color":"#A4C9CC"},{"name":"正青","color":"#6CA8AF"},{"name":"扁青","color":"#509296"},{"name":"䌦色","color":"#226B68"}]},{"title":"大寒","colors":[{"name":"紫府","color":"#995D7F"},{"name":"地血","color":"#814662"},{"name":"芥拾紫","color":"#602641"},{"name":"油紫","color":"#420B2F"},{"name":"骨缥","color":"#EBE3C7"},{"name":"青白玉","color":"#CAC5A0"},{"name":"绿豆褐","color":"#92896B"},{"name":"冥色","color":"#665F4D"},{"name":"肉红","color":"#DDC5B8"},{"name":"珠子褐","color":"#BEA89D"},{"name":"鹰背褐","color":"#8F6D5F"},{"name":"麝香褐","color":"#694B3C"},{"name":"石英","color":"#C8B6BB"},{"name":"银褐","color":"#9C8D9B"},{"name":"烟红","color":"#9D858F"},{"name":"紫诰","color":"#76555D"}]}] \ No newline at end of file diff --git a/plugins/color-helper/data/traditional-japan.json b/plugins/color-helper/data/traditional-japan.json new file mode 100644 index 000000000..921c6b723 --- /dev/null +++ b/plugins/color-helper/data/traditional-japan.json @@ -0,0 +1 @@ +[{"name":"桜色","jname":"さくらいろ","color":"#FEF4F4"},{"name":"小豆色","jname":"あずきいろ","color":"#96514D"},{"name":"黄金","jname":"こがね","color":"#E6B422"},{"name":"萌葱色","jname":"もえぎいろ","color":"#006E54"},{"name":"古代紫","jname":"こだいむらさき","color":"#895B8A"},{"name":"薄桜","jname":"うすざくら","color":"#FDEFF2"},{"name":"枯茶","jname":"からちゃ","color":"#8D6449"},{"name":"櫨染","jname":"はじぞめ","color":"#D9A62E"},{"name":"花緑青","jname":"はなろくしょう","color":"#00A381"},{"name":"茄子紺","jname":"なすこん","color":"#824880"},{"name":"桜鼠","jname":"さくらねず","color":"#E9DFE5"},{"name":"飴色","jname":"あめいろ","color":"#DEB068"},{"name":"黄朽葉色","jname":"きくちばいろ","color":"#D3A243"},{"name":"翡翠色","jname":"ひすいいろ","color":"#38B48B"},{"name":"二藍","jname":"ふたあい","color":"#915C8B"},{"name":"鴇鼠","jname":"ときねず","color":"#E4D2D8"},{"name":"駱駝色","jname":"らくだいろ","color":"#BF794E"},{"name":"山吹茶","jname":"やまぶきちゃ","color":"#C89932"},{"name":"青緑","jname":"あおみどり","color":"#00A497"},{"name":"京紫","jname":"きょうむらさき","color":"#9D5B8B"},{"name":"虹色","jname":"にじいろ","color":"#F6BFBC"},{"name":"土色","jname":"つちいろ","color":"#BC763C"},{"name":"芥子色","jname":"からしいろ","color":"#D0AF4C"},{"name":"水浅葱","jname":"みずあさぎ","color":"#80ABA9"},{"name":"蒲葡","jname":"えびぞめ","color":"#7A4171"},{"name":"珊瑚色","jname":"さんごいろ","color":"#F5B1AA"},{"name":"黄唐茶","jname":"きがらちゃ","color":"#B98C46"},{"name":"豆がら茶","jname":"まめがらちゃ","color":"#8B968D"},{"name":"錆浅葱","jname":"さびあさぎ","color":"#5C9291"},{"name":"若紫","jname":"わかむらさき","color":"#BC64A4"},{"name":"一斤染","jname":"いっこんぞめ","color":"#F5B199"},{"name":"桑染","jname":"くわぞめ","color":"#B79B5B"},{"name":"麹塵","jname":"きくじん","color":"#6E7955"},{"name":"青碧","jname":"せいへき","color":"#478384"},{"name":"紅紫","jname":"べにむらさき","color":"#B44C97"},{"name":"宍色","jname":"ししいろ","color":"#EFAB93"},{"name":"櫨色","jname":"はじいろ","color":"#B77B57"},{"name":"山鳩色","jname":"やまばといろ","color":"#767C6B"},{"name":"御召茶","jname":"おめしちゃ","color":"#43676B"},{"name":"梅紫","jname":"うめむらさき","color":"#AA4C8F"},{"name":"紅梅色","jname":"こうばいいろ","color":"#F2A0A1"},{"name":"黄橡","jname":"きつるばみ","color":"#B68D4C"},{"name":"利休鼠","jname":"りきゅうねずみ","color":"#888E7E"},{"name":"湊鼠","jname":"みなとねずみ","color":"#80989B"},{"name":"菖蒲色","jname":"あやめいろ","color":"#CC7EB1"},{"name":"薄紅","jname":"うすべに","color":"#F0908D"},{"name":"丁字染","jname":"ちょうじぞめ","color":"#AD7D4C"},{"name":"海松茶","jname":"みるちゃ","color":"#5A544B"},{"name":"高麗納戸","jname":"こうらいなんど","color":"#2C4F54"},{"name":"紅藤色","jname":"べにふじいろ","color":"#CCA6BF"},{"name":"甚三紅","jname":"じんざもみ","color":"#EE827C"},{"name":"香染","jname":"こうぞめ","color":"#AD7D4C"},{"name":"藍海松茶","jname":"あいみるちゃ","color":"#56564B"},{"name":"百入茶","jname":"ももしおちゃ","color":"#1F3134"},{"name":"浅紫","jname":"あさむらさき","color":"#C4A3BF"},{"name":"桃色","jname":"ももいろ","color":"#F09199"},{"name":"枇杷茶","jname":"びわちゃ","color":"#AE7C4F"},{"name":"藍媚茶","jname":"あいこびちゃ","color":"#555647"},{"name":"錆鼠","jname":"さびねず","color":"#47585C"},{"name":"紫水晶","jname":"むらさきすいしょう","color":"#E7E7EB"},{"name":"鴇色","jname":"ときいろ","color":"#F4B3C2"},{"name":"芝翫茶","jname":"しかんちゃ","color":"#AD7E4E"},{"name":"千歳茶","jname":"せんさいちゃ","color":"#494A41"},{"name":"錆鉄御納戸","jname":"さびてつおなんど","color":"#485859"},{"name":"薄梅鼠","jname":"うすうめねず","color":"#DCD6D9"},{"name":"撫子色","jname":"なでしこいろ","color":"#EEBBCB"},{"name":"焦香","jname":"こがれこう","color":"#AE7C58"},{"name":"岩井茶","jname":"いわいちゃ","color":"#6B6F59"},{"name":"藍鼠","jname":"あいねず","color":"#6C848D"},{"name":"暁鼠","jname":"あかつきねず","color":"#D3CFD9"},{"name":"灰梅","jname":"はいうめ","color":"#E8D3C7"},{"name":"胡桃色","jname":"くるみいろ","color":"#A86F4C"},{"name":"仙斎茶","jname":"せんさいちゃ","color":"#474B42"},{"name":"錆御納戸","jname":"さびおなんど","color":"#53727D"},{"name":"牡丹鼠","jname":"ぼたんねず","color":"#D3CCD6"},{"name":"灰桜","jname":"はいざくら","color":"#E8D3D1"},{"name":"渋紙色","jname":"しぶかみいろ","color":"#946243"},{"name":"黒緑","jname":"くろみどり","color":"#333631"},{"name":"舛花色","jname":"ますはないろ","color":"#5B7E91"},{"name":"霞色","jname":"かすみいろ","color":"#C8C2C6"},{"name":"淡紅藤","jname":"あわべにふじ","color":"#E6CDE3"},{"name":"朽葉色","jname":"くちばいろ","color":"#917347"},{"name":"柳煤竹","jname":"やなぎすすたけ","color":"#5B6356"},{"name":"熨斗目花色","jname":"のしめはないろ","color":"#426579"},{"name":"藤鼠","jname":"ふじねず","color":"#A6A5C4"},{"name":"石竹色","jname":"せきちくいろ","color":"#E5ABBE"},{"name":"桑茶","jname":"くわちゃ","color":"#956F29"},{"name":"樺茶色","jname":"かばちゃいろ","color":"#726250"},{"name":"御召御納戸","jname":"おめしおなんど","color":"#4C6473"},{"name":"半色","jname":"はしたいろ","color":"#A69ABD"},{"name":"薄紅梅","jname":"うすこうばい","color":"#E597B2"},{"name":"路考茶","jname":"ろこうちゃ","color":"#8C7042"},{"name":"空五倍子色","jname":"うつぶしいろ","color":"#9D896C"},{"name":"鉄御納戸","jname":"てつおなんど","color":"#455765"},{"name":"薄色","jname":"うすいろ","color":"#A89DAC"},{"name":"桃花色","jname":"ももはないろ","color":"#E198B4"},{"name":"国防色","jname":"こくぼうしょく","color":"#7B6C3E"},{"name":"生壁色","jname":"なまかべいろ","color":"#94846A"},{"name":"紺鼠","jname":"こんねず","color":"#44617B"},{"name":"薄鼠","jname":"うすねず","color":"#9790A4"},{"name":"水柿","jname":"みずがき","color":"#E4AB9B"},{"name":"伽羅色","jname":"きゃらいろ","color":"#D8A373"},{"name":"肥後煤竹","jname":"ひごすすたけ","color":"#897858"},{"name":"藍鉄","jname":"あいてつ","color":"#393F4C"},{"name":"鳩羽鼠","jname":"はとばねずみ","color":"#9E8B8E"},{"name":"ときがら茶","jname":"ときがらちゃ","color":"#E09E87"},{"name":"江戸茶","jname":"えどちゃ","color":"#CD8C5C"},{"name":"媚茶","jname":"こびちゃ","color":"#716246"},{"name":"青褐","jname":"あおかち","color":"#393E4F"},{"name":"鳩羽色","jname":"はとばいろ","color":"#95859C"},{"name":"退紅","jname":"あらぞめ","color":"#D69090"},{"name":"樺色","jname":"かばいろ","color":"#CD5E3C"},{"name":"白橡","jname":"しろつるばみ","color":"#CBB994"},{"name":"褐返","jname":"かちかえし","color":"#203744"},{"name":"桔梗鼠","jname":"ききょうねず","color":"#95949A"},{"name":"薄柿","jname":"うすがき","color":"#D4ACAD"},{"name":"紅鬱金","jname":"べにうこん","color":"#CB8347"},{"name":"亜麻色","jname":"あまいろ","color":"#D6C6AF"},{"name":"褐色","jname":"かちいろ","color":"#4D4C61"},{"name":"紫鼠","jname":"むらさきねず","color":"#71686C"},{"name":"長春色","jname":"ちょうしゅんいろ","color":"#C97586"},{"name":"土器色","jname":"かわらけいろ","color":"#C37854"},{"name":"榛色","jname":"はしばみいろ","color":"#BFA46F"},{"name":"月白","jname":"げっぱく","color":"#EAF4FC"},{"name":"葡萄鼠","jname":"ぶどうねずみ","color":"#705B67"},{"name":"梅鼠","jname":"うめねず","color":"#C099A0"},{"name":"狐色","jname":"きつねいろ","color":"#C38743"},{"name":"灰汁色","jname":"あくいろ","color":"#9E9478"},{"name":"白菫色","jname":"しろすみれいろ","color":"#EAEDF7"},{"name":"濃色","jname":"こきいろ","color":"#634950"},{"name":"鴇浅葱","jname":"ときあさぎ","color":"#B88884"},{"name":"黄土色","jname":"おうどいろ","color":"#C39143"},{"name":"利休茶","jname":"りきゅうちゃ","color":"#A59564"},{"name":"白花色","jname":"しらはないろ","color":"#E8ECEF"},{"name":"紫鳶","jname":"むらさきとび","color":"#5F414B"},{"name":"梅染","jname":"うめぞめ","color":"#B48A76"},{"name":"琥珀色","jname":"こはくいろ","color":"#BF783A"},{"name":"鶯茶","jname":"うぐいすちゃ","color":"#715C1F"},{"name":"藍白","jname":"あいじろ","color":"#EBF6F7"},{"name":"濃鼠","jname":"こいねず","color":"#4F455C"},{"name":"蘇芳香","jname":"すおうこう","color":"#A86965"},{"name":"赤茶","jname":"あかちゃ","color":"#BB5535"},{"name":"木蘭色","jname":"もくらんじき","color":"#C7B370"},{"name":"白藍","jname":"しらあい","color":"#C1E4E9"},{"name":"藤煤竹","jname":"ふじすすたけ","color":"#5A5359"},{"name":"浅蘇芳","jname":"あさすおう","color":"#A25768"},{"name":"代赭","jname":"たいしゃ","color":"#BB5520"},{"name":"砂色","jname":"すないろ","color":"#DCD3B2"},{"name":"水色","jname":"みずいろ","color":"#BCE2E8"},{"name":"滅紫","jname":"けしむらさき","color":"#594255"},{"name":"真朱","jname":"まそお","color":"#EC6D71"},{"name":"煉瓦色","jname":"れんがいろ","color":"#B55233"},{"name":"油色","jname":"あぶらいろ","color":"#A19361"},{"name":"瓶覗","jname":"かめのぞき","color":"#A2D7DD"},{"name":"紅消鼠","jname":"べにけしねずみ","color":"#524748"},{"name":"赤紫","jname":"あかむらさき","color":"#EB6EA5"},{"name":"雀茶","jname":"すずめちゃ","color":"#AA4F37"},{"name":"利休色","jname":"りきゅういろ","color":"#8F8667"},{"name":"秘色色","jname":"ひそくいろ","color":"#ABCED8"},{"name":"似せ紫","jname":"にせむらさき","color":"#513743"},{"name":"躑躅色","jname":"つつじいろ","color":"#E95295"},{"name":"団十郎茶","jname":"だんじゅうろうちゃ","color":"#9F563A"},{"name":"梅幸茶","jname":"ばいこうちゃ","color":"#887938"},{"name":"空色","jname":"そらいろ","color":"#A0D8EF"},{"name":"灰黄緑","jname":"はいきみどり","color":"#E6EAE3"},{"name":"牡丹色","jname":"ぼたんいろ","color":"#E7609E"},{"name":"柿渋色","jname":"かきしぶいろ","color":"#9F563A"},{"name":"璃寛茶","jname":"りかんちゃ","color":"#6A5D21"},{"name":"勿忘草色","jname":"わすれなぐさいろ","color":"#89C3EB"},{"name":"蕎麦切色","jname":"そばきりいろ","color":"#D4DCD6"},{"name":"今様色","jname":"いまよういろ","color":"#D0576B"},{"name":"紅鳶","jname":"べにとび","color":"#9A493F"},{"name":"黄海松茶","jname":"きみるちゃ","color":"#918754"},{"name":"青藤色","jname":"あおふじいろ","color":"#84A2D4"},{"name":"薄雲鼠","jname":"うすくもねず","color":"#D4DCDA"},{"name":"中紅","jname":"なかべに","color":"#C85179"},{"name":"灰茶","jname":"はいちゃ","color":"#98623C"},{"name":"菜種油色","jname":"なたねゆいろ","color":"#A69425"},{"name":"白群","jname":"びゃくぐん","color":"#83CCD2"},{"name":"枯野色","jname":"かれのいろ","color":"#D3CBC6"},{"name":"薔薇色","jname":"ばらいろ","color":"#E9546B"},{"name":"茶色","jname":"ちゃいろ","color":"#965042"},{"name":"青朽葉","jname":"あおくちば","color":"#ADA250"},{"name":"浅縹","jname":"あさはなだ","color":"#84B9CB"},{"name":"潤色","jname":"うるみいろ","color":"#C8C2BE"},{"name":"韓紅","jname":"からくれない","color":"#E95464"},{"name":"檜皮色","jname":"ひわだいろ","color":"#965036"},{"name":"根岸色","jname":"ねぎしいろ","color":"#938B4B"},{"name":"薄花色","jname":"うすはないろ","color":"#698AAB"},{"name":"利休白茶","jname":"りきゅうしろちゃ","color":"#B3ADA0"},{"name":"銀朱","jname":"ぎんしゅ","color":"#C85554"},{"name":"鳶色","jname":"とびいろ","color":"#95483F"},{"name":"鶸茶","jname":"ひわちゃ","color":"#8C8861"},{"name":"納戸色","jname":"なんどいろ","color":"#008899"},{"name":"茶鼠","jname":"ちゃねずみ","color":"#A99E93"},{"name":"赤紅","jname":"あかべに","color":"#C53D43"},{"name":"柿茶","jname":"かきちゃ","color":"#954E2A"},{"name":"柳茶","jname":"やなぎちゃ","color":"#A1A46D"},{"name":"浅葱色","jname":"あさぎいろ","color":"#00A3AF"},{"name":"胡桃染","jname":"くるみぞめ","color":"#A58F86"},{"name":"紅緋","jname":"べにひ","color":"#E83929"},{"name":"弁柄色","jname":"べんがらいろ","color":"#8F2E14"},{"name":"海松色","jname":"みるいろ","color":"#726D40"},{"name":"花浅葱","jname":"はなあさぎ","color":"#2A83A2"},{"name":"江戸鼠","jname":"えどねず","color":"#928178"},{"name":"赤","jname":"あか","color":"#E60033"},{"name":"赤錆色","jname":"あかさびいろ","color":"#8A3319"},{"name":"鶯色","jname":"うぐいすいろ","color":"#928C36"},{"name":"新橋色","jname":"しんばしいろ","color":"#59B9C6"},{"name":"煤色","jname":"すすいろ","color":"#887F7A"},{"name":"猩々緋","jname":"しょうじょうひ","color":"#E2041B"},{"name":"褐色","jname":"かっしょく","color":"#8A3B00"},{"name":"緑黄色","jname":"りょくおうしょく","color":"#DCCB18"},{"name":"天色","jname":"あまいろ","color":"#2CA9E1"},{"name":"丁子茶","jname":"ちょうじちゃ","color":"#B4866B"},{"name":"紅","jname":"くれない","color":"#D7003A"},{"name":"栗梅","jname":"くりうめ","color":"#852E19"},{"name":"鶸色","jname":"ひわいろ","color":"#D7CF3A"},{"name":"露草色","jname":"つゆくさいろ","color":"#38A1DB"},{"name":"柴染","jname":"ふしぞめ","color":"#B28C6E"},{"name":"深緋","jname":"こきひ","color":"#C9171E"},{"name":"紅檜皮","jname":"べにひはだ","color":"#7B4741"},{"name":"抹茶色","jname":"まっちゃいろ","color":"#C5C56A"},{"name":"青","jname":"あお","color":"#0095D9"},{"name":"宗伝唐茶","jname":"そうでんからちゃ","color":"#A16D5D"},{"name":"緋色","jname":"ひいろ","color":"#D3381C"},{"name":"海老茶","jname":"えびちゃ","color":"#773C30"},{"name":"若草色","jname":"わかくさいろ","color":"#C3D825"},{"name":"薄藍","jname":"うすあい","color":"#0094C8"},{"name":"砺茶","jname":"とのちゃ","color":"#9F6F55"},{"name":"赤丹","jname":"あかに","color":"#CE5242"},{"name":"唐茶","jname":"からちゃ","color":"#783C1D"},{"name":"黄緑","jname":"きみどり","color":"#B8D200"},{"name":"縹色","jname":"はなだいろ","color":"#2792C3"},{"name":"煎茶色","jname":"せんちゃいろ","color":"#8C6450"},{"name":"紅赤","jname":"べにあか","color":"#D9333F"},{"name":"栗色","jname":"くりいろ","color":"#762F07"},{"name":"若芽色","jname":"わかめいろ","color":"#E0EBAF"},{"name":"紺碧","jname":"こんぺき","color":"#007BBB"},{"name":"銀煤竹","jname":"ぎんすすだけ","color":"#856859"},{"name":"臙脂","jname":"えんじ","color":"#B94047"},{"name":"赤銅色","jname":"しゃくどういろ","color":"#752100"},{"name":"若菜色","jname":"わかないろ","color":"#D8E698"},{"name":"薄群青","jname":"うすぐんじょう","color":"#5383C3"},{"name":"黄枯茶","jname":"きがらちゃ","color":"#765C47"},{"name":"朱・緋","jname":"あけ","color":"#BA2636"},{"name":"錆色","jname":"さびいろ","color":"#6C3524"},{"name":"若苗色","jname":"わかなえいろ","color":"#C7DC68"},{"name":"薄花桜","jname":"うすはなざくら","color":"#5A79BA"},{"name":"煤竹色","jname":"すすたけいろ","color":"#6F514C"},{"name":"茜色","jname":"あかねいろ","color":"#B7282E"},{"name":"赤褐色","jname":"せっかっしょく","color":"#683F36"},{"name":"青丹","jname":"あおに","color":"#99AB4E"},{"name":"群青色","jname":"ぐんじょういろ","color":"#4C6CB3"},{"name":"焦茶","jname":"こげちゃ","color":"#6F4B3E"},{"name":"紅海老茶","jname":"べにえびちゃ","color":"#A73836"},{"name":"茶褐色","jname":"ちゃかっしょく","color":"#664032"},{"name":"草色","jname":"くさいろ","color":"#7B8D42"},{"name":"杜若色","jname":"かきつばたいろ","color":"#3E62AD"},{"name":"黒橡","jname":"くろつるばみ","color":"#544A47"},{"name":"蘇芳","jname":"すおう","color":"#9E3D3F"},{"name":"栗皮茶","jname":"くりかわちゃ","color":"#6D3C32"},{"name":"苔色","jname":"こけいろ","color":"#69821B"},{"name":"瑠璃色","jname":"るりいろ","color":"#1E50A2"},{"name":"憲法色","jname":"けんぽういろ","color":"#543F32"},{"name":"真紅","jname":"しんく","color":"#A22041"},{"name":"黒茶","jname":"くろちゃ","color":"#583822"},{"name":"萌黄","jname":"もえぎ","color":"#AACF53"},{"name":"薄縹","jname":"うすはなだ","color":"#507EA4"},{"name":"涅色","jname":"くりいろ","color":"#554738"},{"name":"濃紅","jname":"こいくれない","color":"#A22041"},{"name":"葡萄茶","jname":"えびちゃ","color":"#6C2C2F"},{"name":"苗色","jname":"なえいろ","color":"#B0CA71"},{"name":"瑠璃紺","jname":"るりこん","color":"#19448E"},{"name":"檳榔子染","jname":"びんろうじぞめ","color":"#433D3C"},{"name":"象牙色","jname":"ぞうげいろ","color":"#F8F4E6"},{"name":"葡萄色","jname":"えびいろ","color":"#640125"},{"name":"若葉色","jname":"わかばいろ","color":"#B9D08B"},{"name":"紺瑠璃","jname":"こんるり","color":"#164A84"},{"name":"黒鳶","jname":"くろとび","color":"#432F2F"},{"name":"練色","jname":"ねりいろ","color":"#EDE4CD"},{"name":"萱草色","jname":"かんぞういろ","color":"#F8B862"},{"name":"松葉色","jname":"まつばいろ","color":"#839B5C"},{"name":"藍色","jname":"あいいろ","color":"#165E83"},{"name":"赤墨","jname":"あかすみ","color":"#3F312B"},{"name":"灰白色","jname":"かいはくしょく","color":"#E9E4D4"},{"name":"柑子色","jname":"こうじいろ","color":"#F6AD49"},{"name":"夏虫色","jname":"なつむしいろ","color":"#CEE4AE"},{"name":"青藍","jname":"せいらん","color":"#274A78"},{"name":"黒紅","jname":"くろべに","color":"#302833"},{"name":"蒸栗色","jname":"むしぐりいろ","color":"#EBE1A9"},{"name":"金茶","jname":"きんちゃ","color":"#F39800"},{"name":"鶸萌黄","jname":"ひわもえぎ","color":"#82AE46"},{"name":"深縹","jname":"こきはなだ","color":"#2A4073"},{"name":"白","jname":"しろ","color":"#FFFFFF"},{"name":"女郎花","jname":"おみなえし","color":"#F2F2B0"},{"name":"蜜柑色","jname":"みかんいろ","color":"#F08300"},{"name":"柳色","jname":"やなぎいろ","color":"#A8C97F"},{"name":"紺色","jname":"こんいろ","color":"#223A70"},{"name":"胡粉色","jname":"ごふんいろ","color":"#FFFFFC"},{"name":"枯草色","jname":"かれくさいろ","color":"#E4DC8A"},{"name":"鉛丹色","jname":"えんたんいろ","color":"#EC6D51"},{"name":"青白橡","jname":"あおしろつるばみ","color":"#9BA88D"},{"name":"紺青","jname":"こんじょう","color":"#192F60"},{"name":"卯の花色","jname":"うのはないろ","color":"#F7FCFE"},{"name":"淡黄","jname":"たんこう","color":"#F8E58C"},{"name":"黄丹","jname":"おうに","color":"#EE7948"},{"name":"柳鼠","jname":"やなぎねず","color":"#C8D5BB"},{"name":"留紺","jname":"とめこん","color":"#1C305C"},{"name":"白磁","jname":"はくじ","color":"#F8FBF8"},{"name":"白茶","jname":"しらちゃ","color":"#DDBB99"},{"name":"柿色","jname":"かきいろ","color":"#ED6D3D"},{"name":"裏葉柳","jname":"うらはやなぎ","color":"#C1D8AC"},{"name":"濃藍","jname":"こいあい","color":"#0F2350"},{"name":"生成り色","jname":"きなりいろ","color":"#FBFAF5"},{"name":"赤白橡","jname":"あかしろつるばみ","color":"#D7A98C"},{"name":"黄赤","jname":"きあか","color":"#EC6800"},{"name":"山葵色","jname":"わさびいろ","color":"#A8BF93"},{"name":"鉄紺","jname":"てつこん","color":"#17184B"},{"name":"乳白色","jname":"にゅうはくしょく","color":"#F3F3F3"},{"name":"洗柿","jname":"あらいがき","color":"#F2C9AC"},{"name":"人参色","jname":"にんじんいろ","color":"#EC6800"},{"name":"老竹色","jname":"おいたけいろ","color":"#769164"},{"name":"漆黒","jname":"しっこく","color":"#0D0015"},{"name":"白練","jname":"しろねり","color":"#F3F3F2"},{"name":"鳥の子色","jname":"とりのこいろ","color":"#FFF1CF"},{"name":"橙色","jname":"だいだいいろ","color":"#EE7800"},{"name":"白緑","jname":"びゃくろく","color":"#D6E9CA"},{"name":"淡藤色","jname":"あわふじいろ","color":"#BBC8E6"},{"name":"素色","jname":"そしょく","color":"#EAE5E3"},{"name":"蜂蜜色","jname":"はちみついろ","color":"#FDDEA5"},{"name":"照柿","jname":"てりがき","color":"#EB6238"},{"name":"淡萌黄","jname":"うすもえぎ","color":"#93CA76"},{"name":"藤色","jname":"ふじいろ","color":"#BBBCDE"},{"name":"白梅鼠","jname":"しらうめねず","color":"#E5E4E6"},{"name":"肌色","jname":"はだいろ","color":"#FCE2C4"},{"name":"赤橙","jname":"あかだいだい","color":"#EA5506"},{"name":"柳染","jname":"やなぎぞめ","color":"#93B881"},{"name":"紅掛空色","jname":"べにかけそらいろ","color":"#8491C3"},{"name":"白鼠","jname":"しろねず","color":"#DCDDDD"},{"name":"薄卵色","jname":"うすたまごいろ","color":"#FDE8D0"},{"name":"金赤","jname":"きんあか","color":"#EA5506"},{"name":"薄萌葱","jname":"うすもえぎ","color":"#BADCAD"},{"name":"紅碧","jname":"べにみどり","color":"#8491C3"},{"name":"絹鼠","jname":"きぬねず","color":"#DDDCD6"},{"name":"雄黄","jname":"ゆうおう","color":"#F9C89B"},{"name":"朱色","jname":"しゅいろ","color":"#EB6101"},{"name":"深川鼠","jname":"ふかがわねずみ","color":"#97A791"},{"name":"紺桔梗","jname":"こんききょう","color":"#4D5AAF"},{"name":"灰青","jname":"はいあお","color":"#C0C6C9"},{"name":"洒落柿","jname":"しゃれがき","color":"#F7BD8F"},{"name":"小麦色","jname":"こむぎいろ","color":"#E49E61"},{"name":"若緑","jname":"わかみどり","color":"#98D98E"},{"name":"花色","jname":"はないろ","color":"#4D5AAF"},{"name":"銀鼠","jname":"ぎんねず","color":"#AFAFB0"},{"name":"赤香","jname":"あかこう","color":"#F6B894"},{"name":"丹色","jname":"にいろ","color":"#E45E32"},{"name":"浅緑","jname":"あさみどり","color":"#88CB7F"},{"name":"紺藍","jname":"こんあい","color":"#4A488E"},{"name":"薄鈍","jname":"うすにび","color":"#ADADAD"},{"name":"砥粉色","jname":"とのこいろ","color":"#F4DDA5"},{"name":"黄茶","jname":"きちゃ","color":"#E17B34"},{"name":"薄緑","jname":"うすみどり","color":"#69B076"},{"name":"紅桔梗","jname":"べにききょう","color":"#4D4398"},{"name":"薄墨色","jname":"うすずみいろ","color":"#A3A3A2"},{"name":"肉色","jname":"にくいろ","color":"#F1BF99"},{"name":"肉桂色","jname":"にっけいいろ","color":"#DD7A56"},{"name":"青鈍","jname":"あおにび","color":"#6B7B6E"},{"name":"桔梗色","jname":"ききょういろ","color":"#5654A2"},{"name":"錫色","jname":"すずいろ","color":"#9EA1A3"},{"name":"人色","jname":"ひといろ","color":"#F1BF99"},{"name":"赤朽葉色","jname":"あかくちばいろ","color":"#DB8449"},{"name":"青磁鼠","jname":"せいじねず","color":"#BED2C3"},{"name":"藤納戸","jname":"ふじなんど","color":"#706CAA"},{"name":"素鼠","jname":"すねずみ","color":"#9FA0A0"},{"name":"丁子色","jname":"ちょうじいろ","color":"#EFCD9A"},{"name":"黄櫨染","jname":"こうろぜん","color":"#D66A35"},{"name":"薄青","jname":"うすあお","color":"#93B69C"},{"name":"紅掛花色","jname":"べにかけはないろ","color":"#68699B"},{"name":"鼠色","jname":"ねずみいろ","color":"#949495"},{"name":"香色","jname":"こういろ","color":"#EFCD9A"},{"name":"蒲公英色","jname":"たんぽぽいろ","color":"#FFD900"},{"name":"錆青磁","jname":"さびせいじ","color":"#A6C8B2"},{"name":"紫苑色","jname":"しおんいろ","color":"#867BA9"},{"name":"源氏鼠","jname":"げんじねず","color":"#888084"},{"name":"薄香","jname":"うすこう","color":"#F0CFA0"},{"name":"黄色","jname":"きいろ","color":"#FFD900"},{"name":"緑青色","jname":"ろくしょういろ","color":"#47885E"},{"name":"白藤色","jname":"しらふじいろ","color":"#DBD0E6"},{"name":"灰色","jname":"はいいろ","color":"#7D7D7D"},{"name":"浅黄","jname":"うすき","color":"#EDD3A1"},{"name":"中黄","jname":"ちゅうき","color":"#FFEA00"},{"name":"千歳緑","jname":"ちとせみどり","color":"#316745"},{"name":"藤紫","jname":"ふじむらさき","color":"#A59ACA"},{"name":"鉛色","jname":"なまりいろ","color":"#7B7C7D"},{"name":"枯色","jname":"かれいろ","color":"#E0C38C"},{"name":"菜の花色","jname":"なのはないろ","color":"#FFEC47"},{"name":"若竹色","jname":"わかたけいろ","color":"#68BE8D"},{"name":"菫色","jname":"すみれいろ","color":"#7058A3"},{"name":"鈍色","jname":"にびいろ","color":"#727171"},{"name":"淡香","jname":"うすこう","color":"#F3BF88"},{"name":"黄檗色","jname":"きはだいろ","color":"#FEF263"},{"name":"緑","jname":"みどり","color":"#3EB370"},{"name":"青紫","jname":"あおむらさき","color":"#674598"},{"name":"墨","jname":"すみ","color":"#595857"},{"name":"杏色","jname":"あんずいろ","color":"#F7B977"},{"name":"卵色","jname":"たまごいろ","color":"#FCD575"},{"name":"常磐色","jname":"ときわいろ","color":"#007B43"},{"name":"菖蒲色","jname":"しょうぶいろ","color":"#674196"},{"name":"丼鼠","jname":"どぶねずみ","color":"#595455"},{"name":"東雲色","jname":"しののめいろ","color":"#F19072"},{"name":"花葉色","jname":"はなばいろ","color":"#FBD26B"},{"name":"千草鼠","jname":"ちぐさねず","color":"#BED3CA"},{"name":"竜胆色","jname":"りんどういろ","color":"#9079AD"},{"name":"消炭色","jname":"けしずみいろ","color":"#524E4D"},{"name":"曙色","jname":"あけぼのいろ","color":"#F19072"},{"name":"刈安色","jname":"かりやすいろ","color":"#F5E56B"},{"name":"千草色","jname":"ちぐさいろ","color":"#92B5A9"},{"name":"江戸紫","jname":"えどむらさき","color":"#745399"},{"name":"藍墨茶","jname":"あいすみちゃ","color":"#474A4D"},{"name":"珊瑚朱色","jname":"さんごしゅいろ","color":"#EE836F"},{"name":"玉蜀黍色","jname":"とうもろこしいろ","color":"#EEC362"},{"name":"青磁色","jname":"せいじいろ","color":"#7EBEA5"},{"name":"本紫","jname":"ほんむらさき","color":"#65318E"},{"name":"羊羹色","jname":"ようかんいろ","color":"#383C3C"},{"name":"深支子","jname":"こきくちなし","color":"#EB9B6F"},{"name":"金糸雀色","jname":"かなりあいろ","color":"#EBD842"},{"name":"青竹色","jname":"あおたけいろ","color":"#7EBEAB"},{"name":"葡萄色","jname":"ぶどういろ","color":"#522F60"},{"name":"蝋色","jname":"ろういろ","color":"#2B2B2B"},{"name":"纁","jname":"そひ","color":"#E0815E"},{"name":"黄支子色","jname":"きくちなしいろ","color":"#FFDB4F"},{"name":"常磐緑","jname":"ときわみどり","color":"#028760"},{"name":"深紫","jname":"ふかむらさき","color":"#493759"},{"name":"黒","jname":"くろ","color":"#2B2B2B"},{"name":"浅緋","jname":"うすきひ","color":"#DF7163"},{"name":"支子色","jname":"くちなしいろ","color":"#FBCA4D"},{"name":"木賊色","jname":"とくさいろ","color":"#3B7960"},{"name":"紫黒","jname":"しこく","color":"#2E2930"},{"name":"烏羽色","jname":"からすばいろ","color":"#180614"},{"name":"真赭","jname":"まそほ","color":"#D57C6B"},{"name":"向日葵色","jname":"ひまわりいろ","color":"#FCC800"},{"name":"天鵞絨","jname":"びろうど","color":"#2F5D50"},{"name":"紫","jname":"むらさき","color":"#884898"},{"name":"鉄黒","jname":"てつぐろ","color":"#281A14"},{"name":"洗朱","jname":"あらいしゅ","color":"#D0826C"},{"name":"山吹色","jname":"やまぶきいろ","color":"#F8B500"},{"name":"虫襖","jname":"むしあお","color":"#3A5B52"},{"name":"薄葡萄","jname":"うすぶどう","color":"#C0A2C7"},{"name":"濡羽色","jname":"ぬればいろ","color":"#000B00"},{"name":"遠州茶","jname":"えんしゅうちゃ","color":"#CA8269"},{"name":"鬱金色","jname":"うこんいろ","color":"#FABF14"},{"name":"革色","jname":"かわいろ","color":"#475950"},{"name":"紫紺","jname":"しこん","color":"#460E44"},{"name":"黒檀","jname":"こくたん","color":"#250D00"},{"name":"紅樺色","jname":"べにかばいろ","color":"#BB5548"},{"name":"藤黄","jname":"とうおう","color":"#F7C114"},{"name":"深緑","jname":"ふかみどり","color":"#00552E"},{"name":"暗紅色","jname":"あんこうしょく","color":"#74325C"},{"name":"憲法黒茶","jname":"けんぽうくろちゃ","color":"#241A08"},{"name":"赭","jname":"そほ","color":"#AB6953"},{"name":"金色","jname":"こんじき","color":"#E6B422"},{"name":"鉄色","jname":"てついろ","color":"#005243"},{"name":"桑の実色","jname":"くわのみいろ","color":"#55295B"},{"name":"暗黒色","jname":"あんこくしょく","color":"#16160E"}] \ No newline at end of file diff --git a/plugins/color-helper/data/ui-ant-design.json b/plugins/color-helper/data/ui-ant-design.json new file mode 100644 index 000000000..b263e1196 --- /dev/null +++ b/plugins/color-helper/data/ui-ant-design.json @@ -0,0 +1 @@ +[{"title":"中性","description":"平稳、中态","colors":["#f5f5f5","#f0f0f0","#d9d9d9","#bfbfbf","#8c8c8c","#595959","#434343","#262626","#1f1f1f","#141414"]},{"title":"薄暮","description":"斗志、奔放","colors":["#fff1f0","#ffccc7","#ffa39e","#ff7875","#ff4d4f","#f5222d","#cf1322","#a8071a","#820014","#5c0011"]},{"title":"火山","description":"醒目、洋洒","colors":["#fff2e8","#ffd8bf","#ffbb96","#ff9c6e","#ff7a45","#fa541c","#d4380d","#ad2102","#871400","#610b00"]},{"title":"日暮","description":"温暖、欢快","colors":["#fff7e6","#ffe7ba","#ffd591","#ffc069","#ffa940","#fa8c16","#d46b08","#ad4e00","#873800","#612500"]},{"title":"金盏花","description":"活力、积极","colors":["#fffbe6","#fff1b8","#ffe58f","#ffd666","#ffc53d","#faad14","#d48806","#ad6800","#874d00","#613400"]},{"title":"日出","description":"出生、阳光","colors":["#feffe6","#ffffb8","#fffb8f","#fff566","#ffec3d","#fadb14","#d4b106","#ad8b00","#876800","#614700"]},{"title":"青柠","description":"自然、生机","colors":["#fcffe6","#f4ffb8","#eaff8f","#d3f261","#bae637","#a0d911","#7cb305","#5b8c00","#3f6600","#254000"]},{"title":"极光绿","description":"健康、创新","colors":["#f6ffed","#d9f7be","#b7eb8f","#95de64","#73d13d","#52c41a","#389e0d","#237804","#135200","#092b00"]},{"title":"明青","description":"希望、坚强","colors":["#e6fffb","#b5f5ec","#87e8de","#5cdbd3","#36cfc9","#13c2c2","#08979c","#006d75","#00474f","#002329"]},{"title":"拂晓蓝","description":"包容、科技、普惠","colors":["#e6f7ff","#bae7ff","#91d5ff","#69c0ff","#40a9ff","#1890ff","#096dd9","#0050b3","#003a8c","#002766"]},{"title":"极客蓝","description":"探索、钻研","colors":["#f0f5ff","#d6e4ff","#adc6ff","#85a5ff","#597ef7","#2f54eb","#1d39c4","#10239e","#061178","#030852"]},{"title":"酱紫","description":"优雅、浪漫","colors":["#f9f0ff","#efdbff","#d3adf7","#b37feb","#9254de","#722ed1","#531dab","#391085","#22075e","#120338"]},{"title":"法式洋红","description":"明快、感性","colors":["#fff0f6","#ffd6e7","#ffadd2","#ff85c0","#f759ab","#eb2f96","#c41d7f","#9e1068","#780650","#520339"]}] \ No newline at end of file diff --git a/plugins/color-helper/data/ui-flat-ui.json b/plugins/color-helper/data/ui-flat-ui.json new file mode 100644 index 000000000..98ee23169 --- /dev/null +++ b/plugins/color-helper/data/ui-flat-ui.json @@ -0,0 +1 @@ +[{"title":"默认\nDefault","colors":[["#1abc9c","#16a085"],["#2ecc71","#27ae60"],["#3498db","#2980b9"],["#9b59b6","#8e44ad"],["#34495e","#2c3e50"],["#f1c40f","#f39c12"],["#e67e22","#d35400"],["#e74c3c","#c0392b"],["#ecf0f1","#bdc3c7"],["#95a5a6","#7f8c8d"]]},{"title":"中国\nChinese","colors":[["#eccc68","#ffa502"],["#ff7f50","#ff6348"],["#ff6b81","#ff4757"],["#a4b0be","#747d8c"],["#57606f","#2f3542"],["#7bed9f","#2ed573"],["#70a1ff","#1e90ff"],["#5352ed","#3742fa"],["#ffffff","#f1f2f6"],["#dfe4ea","#ced6e0"]]},{"title":"美国\nAmerican","colors":[["#55efc4","#00b894"],["#81ecec","#00cec9"],["#74b9ff","#0984e3"],["#a29bfe","#6c5ce7"],["#dfe6e9","#b2bec3"],["#ffeaa7","#fdcb6e"],["#fab1a0","#e17055"],["#ff7675","#d63031"],["#fd79a8","#e84393"],["#636e72","#2d3436"]]},{"title":"澳大利亚\nAussie","colors":[["#f6e58d","#f9ca24"],["#ffbe76","#f0932b"],["#ff7979","#eb4d4b"],["#badc58","#6ab04c"],["#dff9fb","#c7ecee"],["#7ed6df","#22a6b3"],["#e056fd","#be2edd"],["#686de0","#4834d4"],["#30336b","#130f40"],["#95afc0","#535c68"]]},{"title":"英国\nBritish","colors":[["#00a8ff","#0097e6"],["#9c88ff","#8c7ae6"],["#fbc531","#e1b12c"],["#4cd137","#44bd32"],["#487eb0","#40739e"],["#e84118","#c23616"],["#f5f6fa","#dcdde1"],["#7f8fa6","#718093"],["#273c75","#192a56"],["#353b48","#2f3640"]]},{"title":"加拿大\nCanadian","colors":[["#ff9ff3","#f368e0"],["#feca57","#ff9f43"],["#ff6b6b","#ee5253"],["#48dbfb","#0abde3"],["#1dd1a1","#10ac84"],["#00d2d3","#01a3a4"],["#54a0ff","#2e86de"],["#5f27cd","#341f97"],["#c8d6e5","#8395a7"],["#576574","#222f3e"]]},{"title":"荷兰\nDutch","colors":[["#ffc312","#f79f1f"],["#c4e538","#a3cb38"],["#12cbc4","#1289a7"],["#fda7df","#d980fa"],["#ed4c67","#b53471"],["#ee5a24","#ea2027"],["#009432","#006266"],["#0652dd","#1b1464"],["#9980fa","#5758bb"],["#833471","#6f1e51"]]},{"title":"法国\nFrench","colors":[["#fad390","#f6b93b"],["#f8c291","#e55039"],["#6a89cc","#4a69bd"],["#82ccdd","#60a3bc"],["#b8e994","#78e08f"],["#fa983a","#e58e26"],["#eb2f06","#b71540"],["#1e3799","#0c2461"],["#3c6382","#0a3d62"],["#38ada9","#079992"]]},{"title":"德国\nGerman","colors":[["#fc5c65","#eb3b5a"],["#fd9644","#fa8231"],["#fed330","#f7b731"],["#26de81","#20bf6b"],["#2bcbba","#0fb9b1"],["#45aaf2","#2d98da"],["#4b7bec","#3867d6"],["#a55eea","#8854d0"],["#d1d8e0","#a5b1c2"],["#778ca3","#4b6584"]]},{"title":"印度\nIndian","colors":[["#fea47f","#f97f51"],["#25ccf7","#1b9cfc"],["#eab543","#f8efba"],["#55e6c1","#58b19f"],["#cad3c8","#2c3a47"],["#b33771","#6d214f"],["#3b3b98","#182c61"],["#fd7272","#fc427b"],["#9aecdb","#bdc581"],["#d6a2e8","#82589f"]]},{"title":"俄罗斯\nRussian","colors":[["#f3a683","#f19066"],["#f7d794","#f5cd79"],["#778beb","#546de5"],["#e77f67","#e15f41"],["#cf6a87","#c44569"],["#786fa6","#574b90"],["#f8a5c2","#f78fb3"],["#63cdda","#3dc1d3"],["#ea8685","#e66767"],["#596275","#303952"]]},{"title":"西班牙\nSpanish","colors":[["#40407a","#2c2c54"],["#706fd3","#474787"],["#f7f1e3","#aaa69d"],["#34ace0","#227093"],["#33d9b2","#218c74"],["#ff5252","#b33939"],["#ff793f","#cd6133"],["#d1ccc0","#84817a"],["#ffb142","#cc8e35"],["#ffda79","#ccae62"]]},{"title":"瑞典\nSwedish","colors":[["#ef5777","#f53b57"],["#575fcf","#3c40c6"],["#4bcffa","#0fbcf9"],["#34e7e4","#00d8d6"],["#0be881","#05c46b"],["#ffc048","#ffa801"],["#ffdd59","#ffd32a"],["#ff5e57","#ff3f34"],["#d2dae2","#808e9b"],["#485460","#1e272e"]]},{"title":"土耳其\nTurkish","colors":[["#fc427b","#b33771"],["#3dc1d3","#0fb9b1"],["#f5cd79","#f19066"],["#e15f41","#c44569"],["#574b90","#303952"],["#58B19F","#2C3A47"],["#CAD3C8","#1B9CFC"],["#D6A2E8","#82589F"],["#9AECDB","#BDC581"],["#FEA47F","#F97F51"]]}] \ No newline at end of file diff --git a/plugins/color-helper/data/ui-fluent.json b/plugins/color-helper/data/ui-fluent.json new file mode 100644 index 000000000..d89580d45 --- /dev/null +++ b/plugins/color-helper/data/ui-fluent.json @@ -0,0 +1 @@ +[{"title":"主题\nTheme","colors":["#004578","#005a9e","#106ebe","#0078d4","#2b88d8","#71afe5","#c7e0f4","#deecf9","#eff6fc"]},{"title":"中性\nNeutral","colors":["#000000","#201f1e","#323130","#3b3a39","#605e5c","#8a8886","#a19f9d","#c8c6c4","#d2d0ce","#e1dfdd","#edebe9","#f3f2f1","#faf9f8"]},{"title":"强调\nAccent","colors":["#d29200","#ffb900","#fff100","#d83b01","#ea4300","#ff8c00","#a4262c","#d13438","#5c005c","#b4009e","#e3008c","#32145a","#5c2d91","#b4a0ff","#002050","#00188f","#0078d4","#00bcf2","#004b50","#008272","#00b294","#004b1c","#107c10","#bad80a"]},{"title":"消息\nMessage","colors":["#fff4ce","#797673","#fed9cc","#d83b01","#fde7e9","#a80000","#dff6dd","#107c10"]}] \ No newline at end of file diff --git a/plugins/color-helper/data/ui-material-design.json b/plugins/color-helper/data/ui-material-design.json new file mode 100644 index 000000000..5aba1f59d --- /dev/null +++ b/plugins/color-helper/data/ui-material-design.json @@ -0,0 +1 @@ +[{"title":"Red","colors":["#ffebee","#ffcdd2","#ef9a9a","#e57373","#ef5350","#f44336","#e53935","#d32f2f","#c62828","#b71c1c","#ff8a80","#ff5252","#ff1744","#d50000"]},{"title":"Pink","colors":["#fce4ec","#f8bbd0","#f48fb1","#f06292","#ec407a","#e91e63","#d81b60","#c2185b","#ad1457","#880e4f","#ff80ab","#ff4081","#f50057","#c51162"]},{"title":"Purple","colors":["#f3e5f5","#e1bee7","#ce93d8","#ba68c8","#ab47bc","#9c27b0","#8e24aa","#7b1fa2","#6a1b9a","#4a148c","#ea80fc","#e040fb","#d500f9","#aa00ff"]},{"title":"Deep Purple","colors":["#ede7f6","#d1c4e9","#b39ddb","#9575cd","#7e57c2","#673ab7","#5e35b1","#512da8","#4527a0","#311b92","#b388ff","#7c4dff","#651fff","#6200ea"]},{"title":"Indigo","colors":["#e8eaf6","#c5cae9","#9fa8da","#7986cb","#5c6bc0","#3f51b5","#3949ab","#303f9f","#283593","#1a237e","#8c9eff","#536dfe","#3d5afe","#304ffe"]},{"title":"Blue","colors":["#e3f2fd","#bbdefb","#90caf9","#64b5f6","#42a5f5","#2196f3","#1e88e5","#1976d2","#1565c0","#0d47a1","#82b1ff","#448aff","#2979ff","#2962ff"]},{"title":"Light Blue","colors":["#e1f5fe","#b3e5fc","#81d4fa","#4fc3f7","#29b6f6","#03a9f4","#039be5","#0288d1","#0277bd","#01579b","#80d8ff","#40c4ff","#00b0ff","#0091ea"]},{"title":"Cyan","colors":["#e0f7fa","#b2ebf2","#80deea","#4dd0e1","#26c6da","#00bcd4","#00acc1","#0097a7","#00838f","#006064","#84ffff","#18ffff","#00e5ff","#00b8d4"]},{"title":"Teal","colors":["#e0f2f1","#b2dfdb","#80cbc4","#4db6ac","#26a69a","#009688","#00897b","#00796b","#00695c","#004d40","#a7ffeb","#64ffda","#1de9b6","#00bfa5"]},{"title":"Green","colors":["#e8f5e9","#c8e6c9","#a5d6a7","#81c784","#66bb6a","#4caf50","#43a047","#388e3c","#2e7d32","#1b5e20","#b9f6ca","#69f0ae","#00e676","#00c853"]},{"title":"Light Green","colors":["#f1f8e9","#dcedc8","#c5e1a5","#aed581","#9ccc65","#8bc34a","#7cb342","#689f38","#558b2f","#33691e","#ccff90","#b2ff59","#76ff03","#64dd17"]},{"title":"Lime","colors":["#f9fbe7","#f0f4c3","#e6ee9c","#dce775","#d4e157","#cddc39","#c0ca33","#afb42b","#9e9d24","#827717","#f4ff81","#eeff41","#c6ff00","#aeea00"]},{"title":"Yellow","colors":["#fffde7","#fff9c4","#fff59d","#fff176","#ffee58","#ffeb3b","#fdd835","#fbc02d","#f9a825","#f57f17","#ffff8d","#ffff00","#ffea00","#ffd600"]},{"title":"Amber","colors":["#fff8e1","#ffecb3","#ffe082","#ffd54f","#ffca28","#ffc107","#ffb300","#ffa000","#ff8f00","#ff6f00","#ffe57f","#ffd740","#ffc400","#ffab00"]},{"title":"Orange","colors":["#fff3e0","#ffe0b2","#ffcc80","#ffb74d","#ffa726","#ff9800","#fb8c00","#f57c00","#ef6c00","#e65100","#ffd180","#ffab40","#ff9100","#ff6d00"]},{"title":"Deep Orange","colors":["#fbe9e7","#ffccbc","#ffab91","#ff8a65","#ff7043","#ff5722","#f4511e","#e64a19","#d84315","#bf360c","#ff9e80","#ff6e40","#ff3d00","#dd2c00"]},{"title":"Brown","colors":["#efebe9","#d7ccc8","#bcaaa4","#a1887f","#8d6e63","#795548","#6d4c41","#5d4037","#4e342e","#3e2723","","","",""]},{"title":"Grey","colors":["#fafafa","#f5f5f5","#eeeeee","#e0e0e0","#bdbdbd","#9e9e9e","#757575","#616161","#424242","#212121","","","",""]},{"title":"Blue Grey","colors":["#eceff1","#cfd8dc","#b0bec5","#90a4ae","#78909c","#607d8b","#546e7a","#455a64","#37474f","#263238","","","",""]}] \ No newline at end of file diff --git a/plugins/color-helper/data/ui-open-color.json b/plugins/color-helper/data/ui-open-color.json new file mode 100644 index 000000000..0bcc0102f --- /dev/null +++ b/plugins/color-helper/data/ui-open-color.json @@ -0,0 +1 @@ +[{"title":"Gray","colors":["#f8f9fa","#f1f3f5","#e9ecef","#dee2e6","#ced4da","#adb5bd","#868e96","#495057","#343a40","#212529"]},{"title":"Red","colors":["#fff5f5","#ffe3e3","#ffc9c9","#ffa8a8","#ff8787","#ff6b6b","#fa5252","#f03e3e","#e03131","#c92a2a"]},{"title":"Pink","colors":["#fff0f6","#ffdeeb","#fcc2d7","#faa2c1","#f783ac","#f06595","#e64980","#d6336c","#c2255c","#a61e4d"]},{"title":"Grape","colors":["#f8f0fc","#f3d9fa","#eebefa","#e599f7","#da77f2","#cc5de8","#be4bdb","#ae3ec9","#9c36b5","#862e9c"]},{"title":"Violet","colors":["#f3f0ff","#e5dbff","#d0bfff","#b197fc","#9775fa","#845ef7","#7950f2","#7048e8","#6741d9","#5f3dc4"]},{"title":"Indigo","colors":["#edf2ff","#dbe4ff","#bac8ff","#91a7ff","#748ffc","#5c7cfa","#4c6ef5","#4263eb","#3b5bdb","#364fc7"]},{"title":"Blue","colors":["#e7f5ff","#d0ebff","#a5d8ff","#74c0fc","#4dabf7","#339af0","#228be6","#1c7ed6","#1971c2","#1864ab"]},{"title":"Cyan","colors":["#e3fafc","#c5f6fa","#99e9f2","#66d9e8","#3bc9db","#22b8cf","#15aabf","#1098ad","#0c8599","#0b7285"]},{"title":"Teal","colors":["#e6fcf5","#c3fae8","#96f2d7","#63e6be","#38d9a9","#20c997","#12b886","#0ca678","#099268","#087f5b"]},{"title":"Green","colors":["#ebfbee","#d3f9d8","#b2f2bb","#8ce99a","#69db7c","#51cf66","#40c057","#37b24d","#2f9e44","#2b8a3e"]},{"title":"Lime","colors":["#f4fce3","#e9fac8","#d8f5a2","#c0eb75","#a9e34b","#94d82d","#82c91e","#74b816","#66a80f","#5c940d"]},{"title":"Yellow","colors":["#fff9db","#fff3bf","#ffec99","#ffe066","#ffd43b","#fcc419","#fab005","#f59f00","#f08c00","#e67700"]},{"title":"Orange","colors":["#fff4e6","#ffe8cc","#ffd8a8","#ffc078","#ffa94d","#ff922b","#fd7e14","#f76707","#e8590c","#d9480f"]}] \ No newline at end of file diff --git a/plugins/color-helper/index.css b/plugins/color-helper/index.css new file mode 100644 index 000000000..2c126fc1c --- /dev/null +++ b/plugins/color-helper/index.css @@ -0,0 +1,1272 @@ +html, body { + margin: 0; + width: 100%; + height: 100%; + background-color: #fff; +} + +#root { + width: 100%; + height: 100%; +} + +/* ============ App 布局 ============ */ +.app-body { + width: 100%; + height: 100%; + display: flex; +} + +.app-body .app-nav { + width: 136px; +} + +.app-body .app-nav .app-nav-icon { + min-width: 32px; +} + +.app-body .app-nav > ul { + padding-top: 0; +} + +.app-body .app-content { + flex: 1; + overflow-x: hidden; + overflow-y: auto; + border-top-left-radius: 16px; + border-bottom-left-radius: 16px; + border-top-right-radius: 16px; +} + +.app-body .app-setting { + position: fixed; + left: 22px; + bottom: 6px; + color: #717171; +} + +.app-body .app-setting .MuiCheckbox-root { + padding: 6px; +} + +.app-body .app-setting .MuiSvgIcon-root { + font-size: 16px; +} + +.app-body .app-setting .MuiFormControlLabel-label { + font-size: 12px; +} + +/* ============ ColorPage 颜色页 ============ */ +.color-content { + padding: 8px 8px 8px 16px; + box-sizing: border-box; + height: 100%; +} + +.color-content .color-info { + display: flex; + position: relative; + justify-content: space-between; + height: 100%; +} + +.color-content .color-screen-picker { + box-shadow: none; + position: absolute; + top: 0; + left: 0; + z-index: 1; +} + +.color-content .color-screen-picker:hover, +.color-content .color-screen-picker:active { + box-shadow: none; +} + +.color-content .color-value { + padding: 5px 16px; + display: flex; + flex-direction: column; + border-radius: 10px; +} + +.color-content .color-value .color-title { + padding-top: 0; + font-weight: 700; + color: #212121; + display: flex; + align-items: center; + justify-content: space-between; +} + +.color-content .color-value .color-hub { + display: flex; + align-items: center; + padding-bottom: 12px; + margin-top: auto; +} + +.color-content .color-value .color-hub .color-hub-wrapper { + flex: 1; + display: flex; +} + +.color-content .color-value .color-hub .color-hub-wrapper .color-hub-value { + display: flex; + margin-right: 8px; + width: 36px; + height: 36px; + border-radius: 4px; + cursor: pointer; + position: relative; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.color-content .color-value .color-hub .color-hub-wrapper .color-hub-value .color-hub-check { + opacity: 0; +} + +.color-content .color-value .color-hub .color-hub-wrapper .color-hub-value.color-checked .color-hub-check { + opacity: 1; +} + +.color-content .color-value > div { + padding: 6px 0; + display: flex; + align-items: center; +} + +.color-content .color-value > div > div:first-child { + -webkit-user-select: none; + user-select: none; + width: 76px; +} + +.color-content .color-value input { + outline: none; + border: 1px solid #ccc; + border-radius: 2px; + height: 28px; + padding: 0 6px; + font-size: 14px; + width: 168px; +} + +.color-content .color-value input:focus { + border-color: #2a6acd; + box-shadow: 0 0 2px #2a6acd; +} + +.color-content .color-value .css-code-copy { + padding-left: 10px; +} + +.color-content .IroColorPicker .IroHandle--isActive { + border-radius: 50%; + box-shadow: 0 0 0 2px #fff; +} + +.color-content .IroColorPicker .IroWheel { + margin: 65px 0; +} + +.color-content .IroColorPicker .IroSlider:after { + content: "饱和度"; + position: absolute; + top: -20px; + right: 0; + background-color: #0000004d; + color: #fff; + font-size: 12px; + padding: 2px 10px; + box-sizing: border-box; + border-radius: 12px; + display: none; +} + +.color-content .IroColorPicker .IroSlider:last-child:after { + top: 29px; + content: "明亮度"; +} + +.color-content .IroColorPicker .IroSlider:hover:after { + display: block; +} + +/* 配色方案 */ +.color-extend { + display: grid !important; + grid-template-rows: repeat(2, 1fr); + grid-template-columns: repeat(2, 1fr); + row-gap: 20px; +} + +.color-extend .color-extend-label { + font-size: 14px; + color: #666; + white-space: nowrap; +} + +.color-extend .color-extend-item { + display: flex; + align-items: center; + gap: 8px; +} + +.color-extend .color-extend-value { + width: 24px; + height: 24px; + border-radius: 4px; + cursor: pointer; +} + +/* ============ UIPalettesPage UI色卡 ============ */ +.ui-content { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + -webkit-user-select: none; + user-select: none; +} + +.ui-content > div:first-child { + border-bottom: 1px solid #e6e6e6; +} + +.ui-content > div:last-child { + flex: 1; + min-height: 0; +} + +.ui-content .ui-tab { + text-transform: none; +} + +.ui-color-body { + width: 100%; + height: 100%; + padding: 0 20px 20px; + box-sizing: border-box; + overflow-x: hidden; + overflow-y: auto; + position: relative; +} + +/* Flat UI / Fluent */ +.flat-ui-box { + display: flex; + margin: 12px 0; +} + +.flat-ui-box .flat-ui-title { + color: #666; + width: 100px; + display: flex; + align-items: center; + white-space: pre-wrap; + line-height: 20px; +} + +.flat-ui-box .flat-ui-colors, +.flat-ui-box .fluent-ui-colors { + flex: 1; + display: flex; + font-size: 1.5vw; +} + +.flat-ui-box .flat-ui-colors > div, +.flat-ui-box .fluent-ui-colors > div { + box-sizing: border-box; + padding: .5px; +} + +.flat-ui-box .flat-ui-colors > div > div, +.flat-ui-box .fluent-ui-colors > div > div { + padding-top: 100%; + transition: all .3s cubic-bezier(.25, .8, .25, 1); + cursor: pointer; + position: relative; +} + +.flat-ui-box .flat-ui-colors > div > div > span, +.flat-ui-box .fluent-ui-colors > div > div > span { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: #fff; + display: none; +} + +.flat-ui-box .flat-ui-colors > div > div:hover, +.flat-ui-box .fluent-ui-colors > div > div:hover { + border-radius: 50%; +} + +.flat-ui-box .flat-ui-colors > div > div:hover > span, +.flat-ui-box .fluent-ui-colors > div > div:hover > span { + display: block; +} + +.flat-ui-box .flat-ui-colors > div { + display: flex; + flex-direction: column; + flex: 1; +} + +.flat-ui-box .fluent-ui-colors { + flex-wrap: wrap; +} + +.flat-ui-box .fluent-ui-colors > div { + width: 10%; +} + +/* Open Color / Ant Design / Material Design 通用 */ +.ui-colors { + width: 100%; + display: flex; + padding-bottom: 10px; +} + +.ui-colors > div { + flex: 1; + height: 36px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.ui-colors > div span { + color: #fff; + display: none; + font-size: 12px; +} + +.ui-colors > div:hover span { + display: block; +} + +.ui-color-row, +.ui-color-header { + display: flex; +} + +.ui-color-row > div:first-child, +.ui-color-header > div:first-child { + width: 100px; + display: flex; + align-items: center; + color: #666; +} + +.ui-color-row > div:last-child, +.ui-color-header > div:last-child { + flex: 1; + display: flex; +} + +.ui-color-row > div:last-child > div, +.ui-color-header > div:last-child > div { + flex: 1; + padding: .5px; + box-sizing: border-box; + cursor: pointer; +} + +.ui-color-row > div:last-child > div:before, +.ui-color-header > div:last-child > div:before { + content: ""; + padding-top: 100%; + float: left; +} + +.ui-color-row > div:last-child > div > div, +.ui-color-header > div:last-child > div > div { + width: 100%; + height: 100%; + transition: all .3s cubic-bezier(.25, .8, .25, 1); + display: flex; + align-items: center; + justify-content: center; +} + +.ui-color-row > div:last-child > div > div > span, +.ui-color-header > div:last-child > div > div > span { + display: none; +} + +.ui-color-row > div:last-child > div:hover > div, +.ui-color-header > div:last-child > div:hover > div { + border-radius: 50%; +} + +.ui-color-row > div:last-child > div:hover > div > span, +.ui-color-header > div:last-child > div:hover > div > span { + display: block; +} + +.ui-color-header { + box-sizing: border-box; + position: sticky; + top: 0; + left: 0; + width: 100%; + font-size: 13px; + color: #666; + background-color: #fff; + text-align: center; + line-height: 28px; +} + +.ui-color-header > div:last-child > div { + height: 28px; + cursor: default; +} + +/* ============ TraditionalColorsPage 传统色 ============ */ +.traditional-body { + display: flex; + width: 100%; + height: 100%; + flex-direction: column; +} + +.traditional-nav { + border-bottom: 1px solid #e6e6e6; +} + +.traditional-header { + display: flex; + padding: 12px 20px; + box-sizing: border-box; + align-items: center; +} + +.traditional-header > div:first-child { + width: 20%; +} + +.traditional-header > div:last-child { + display: flex; + flex: 1; + justify-content: space-around; +} + +.traditional-header > div:last-child > div { + height: 24px; + width: 24px; + border-radius: 12px; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + color: #fff; +} + +.traditional-content { + flex: 1; + min-height: 0; + overflow-y: auto; + box-sizing: border-box; +} + +/* 日本传统色 */ +.traditional-japan { + display: flex; + flex-wrap: wrap; + color: #fff; + padding: 0 20px 20px; +} + +.traditional-japan > div { + width: 20%; + cursor: pointer; +} + +.traditional-japan > div > div { + padding: 0 5px 2px; + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.traditional-japan > div > div:first-child { + padding-top: 5px; + font-size: 14px; +} + +.traditional-japan > div:before { + content: ""; + padding-top: 50%; + float: left; +} + +/* 中国传统色(故宫) */ +.traditional-gugong { + padding: 0 20px; +} + +.traditional-gugong .traditional-gugong-box { + display: flex; + margin-bottom: 20px; +} + +.traditional-gugong .traditional-gugong-title { + width: 20%; + margin: 0; +} + +.traditional-gugong .traditional-gugong-colors { + flex: 1; + min-width: 0; + display: flex; + flex-wrap: wrap; + color: #fff; +} + +.traditional-gugong .traditional-gugong-colors > div { + width: 25%; + cursor: pointer; +} + +.traditional-gugong .traditional-gugong-colors > div:before { + content: ""; + padding-top: 50%; + float: left; +} + +.traditional-gugong .traditional-gugong-colors > div > div { + font-size: 10px; + padding: 0 8px 2px; +} + +.traditional-gugong .traditional-gugong-colors > div > div:first-child { + padding-top: 8px; + font-size: 14px; +} + +/* ============ CollectColorsPage 收藏颜色 ============ */ +.collect-body { + padding: 20px; + box-sizing: border-box; + -webkit-user-select: none; + user-select: none; +} + +.collect-body .collect-btn-add { + position: fixed; + right: 10px; + bottom: 10px; +} + +.collect-body .collect-empty { + width: 100%; + color: #717171; + font-size: 13px; + text-align: center; +} + +.collect-body .collect-grid { + -webkit-user-select: none; + user-select: none; + display: flex; + flex-wrap: wrap; +} + +.collect-form-content { + display: flex; + align-items: center; +} + +.collect-item { + cursor: pointer; + width: 25%; +} + +.collect-item:before { + content: ""; + padding-top: 50%; + float: left; +} + +.collect-item > div { + display: flex; + height: 100%; + flex-direction: column; +} + +.collect-item .collect-item-info { + flex: 1; + padding: 8px 8px 0; + box-sizing: border-box; +} + +.collect-item .collect-item-info > div { + font-size: 10px; + padding-bottom: 2px; +} + +.collect-item .collect-item-info > div:first-child { + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.collect-item .collect-item-handle { + padding: 0 2px 2px 0; + box-sizing: border-box; + text-align: right; + display: none; +} + +.collect-item .collect-item-handle > button:last-child { + margin-left: 2px; +} + +.collect-item:hover .collect-item-handle { + display: block; +} + +/* ============ GradientsPage 渐变色 ============ */ +.gradient-body { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + +.gradient-filter { + padding: 10px 0; + display: flex; + justify-content: space-around; + border-bottom: 1px solid #e6e6e6; +} + +.gradient-filter > div { + height: 24px; + width: 24px; + border-radius: 50%; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + color: #fff; + border: 2px solid #fff; +} + +.gradient-content { + flex: 1; + overflow-x: hidden; + overflow-y: auto; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + padding: 0 20px; + box-sizing: border-box; +} + +.gradient-item { + width: 22.5%; + margin: 10px 0; + display: flex; + flex-direction: column; +} + +.gradient-item > div:first-child { + flex: 1; + cursor: pointer; + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} + +.gradient-item > div:first-child:before { + content: ""; + padding-top: 100%; + float: left; +} + +.gradient-item > div:last-child { + display: flex; + padding: 5px; + box-sizing: border-box; + justify-content: space-between; +} + +.gradient-item > div:last-child > div { + width: 16px; + height: 10px; + cursor: pointer; +} + +/* 渐变对话框 */ +.gradient-dialog .MuiPaper-root { + max-width: 540px; +} + +.gradient-dialog-viewer { + width: 540px; + height: 270px; +} + +.gradient-dialog-content { + display: flex; + padding: 10px; + box-sizing: border-box; + overflow: hidden; +} + +.gradient-dialog-content > div:first-child { + padding: 2px; + box-sizing: border-box; +} + +.gradient-dialog-content .gradient-dialog-form { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.gradient-dialog-content .gradient-dialog-form > div { + display: flex; + width: 100%; + padding: 10px 0; +} + +.gradient-dialog-content .gradient-dialog-form > div > div:first-child { + flex: 1; + padding: 0 10px; + box-sizing: border-box; +} + +.gradient-dialog-content .gradient-dialog-form > div > div:last-child { + display: flex; + align-items: center; +} + +.gradient-dialog-content .gradient-dialog-image-output { + display: flex; + justify-content: space-between; + width: 100%; +} + +.gradient-dialog-content .gradient-dialog-image-output > div { + width: 48%; +} + +/* 方向编辑器 */ +.direction-editor { + width: 172px; + height: 172px; + box-sizing: border-box; + position: relative; +} + +.direction-editor > svg { + overflow: visible; +} + +.direction-editor .direction-editor-box { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.direction-editor .direction-editor-polyline { + fill: #666; + stroke: #666; + stroke-width: 3px; + stroke-linecap: round; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; +} + +.direction-editor .direction-editor-circle { + stroke-width: 3px; + stroke-linecap: round; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; + stroke: #1976d2; + fill: none; +} + +.direction-editor .direction-editor-knob { + border-radius: 50%; + width: 20px; + height: 20px; + background-color: #fff; + box-shadow: 0 2px 16px #00000014, 0 .25px 2px #00000029, inset 0 2px 16px transparent, inset 0 .25px 2px transparent; + position: absolute; + transform: translate(-50%, -50%); +} + +/* ============ ImagePalettePage 图片色卡 ============ */ +.image-body { + width: 100%; + height: 100%; + position: relative; +} + +.image-empty { + color: #888; + text-align: center; + padding-top: 100px; +} + +.image-content { + display: flex; + height: 100%; + width: 100%; +} + +.image-content > div:first-child { + flex: 1; + padding: 20px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; +} + +.image-content > div:first-child > img { + max-width: 100%; + max-height: 100%; +} + +.image-content .image-colors { + box-sizing: border-box; + display: flex; + padding: 5px; +} + +.image-content .image-colors .image-colors-card { + border-radius: 10px; +} + +.image-content .image-colors .image-colors-card > div { + overflow-y: auto; + height: 100%; +} + +.image-content .image-colors .image-colors-label { + padding: 8px 8px 0; +} + +.image-content .image-main-color { + padding: 8px; + box-sizing: border-box; +} + +.image-content .image-main-color > div { + height: 56px; + border-radius: 4px; + cursor: pointer; + width: 112px; +} + +.image-content .image-palette { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px; + padding: 8px; +} + +.image-content .image-palette > div { + height: 54px; + width: 54px; + border-radius: 4px; + cursor: pointer; +} + +.image-from-btns { + position: absolute; + left: 0; + bottom: 0; + padding: 10px 0; +} + +.image-from-btns.image-selected .MuiButtonBase-root { + background-color: #e0e0e0; + color: #000; +} + +.image-from-btns > button { + margin-left: 20px; +} + +/* ============ 侧边栏列表 ============ */ +.app-side-list .MuiListItem-root { + padding: 0 9px; + white-space: nowrap; + margin: 3px 0; +} + +.app-side-list .MuiListItem-root .MuiListItemButton-root { + padding: 8px 13px; + border-radius: 6px; +} + +.app-side-list .MuiListItem-root .MuiListItemButton-root.Mui-selected { + background-color: #e5e5e5; +} + +.app-side-list .MuiListItem-root .MuiListItemIcon-root { + min-width: 0px; + margin-right: 9px; +} + +/* ============ TraditionalColorsPage 新布局 ============ */ +.traditional-body { + display: flex; + width: 100%; + height: 100%; + flex-direction: column; +} + +.traditional-header { + display: flex; + padding: 12px 20px; + box-sizing: border-box; + align-items: center; + border-bottom: 1px solid #e6e6e6; +} + +.traditional-tabs { + display: flex; + gap: 16px; +} + +.traditional-tab { + cursor: pointer; + padding: 6px 16px; + border-radius: 6px; + font-size: 14px; + color: #666; + transition: all .2s; +} + +.traditional-tab:hover { + background-color: #f0f0f0; +} + +.traditional-tab.traditional-tab-active { + background-color: #e0e0e0; + color: #333; + font-weight: 500; +} + +.traditional-search { + margin-left: auto !important; + width: 200px; +} + +.traditional-colors { + flex: 1; + min-height: 0; + overflow-y: auto; + box-sizing: border-box; + display: flex; + flex-wrap: wrap; + color: #fff; + padding: 0 20px 20px; +} + +.traditional-china .traditional-color-item, +.traditional-japan .traditional-color-item { + cursor: pointer; + position: relative; +} + +.traditional-china .traditional-color-item { + width: 25%; +} + +.traditional-japan .traditional-color-item { + width: 20%; +} + +.traditional-color-item:before { + content: ""; + padding-top: 50%; + float: left; +} + +.traditional-color-item > div { + font-size: 10px; + padding: 0 5px 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.traditional-color-item > div:first-child { + padding-top: 5px; + font-size: 14px; +} + +/* ============ UIPalettesPage 新布局 ============ */ +.ui-palette-body { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + -webkit-user-select: none; + user-select: none; +} + +.ui-palette-tabs { + display: flex; + gap: 8px; + padding: 12px 20px; + box-sizing: border-box; + border-bottom: 1px solid #e6e6e6; +} + +.ui-palette-tab { + cursor: pointer; + padding: 6px 16px; + border-radius: 6px; + font-size: 14px; + color: #666; + transition: all .2s; +} + +.ui-palette-tab:hover { + background-color: #f0f0f0; +} + +.ui-palette-tab.ui-palette-tab-active { + background-color: #e0e0e0; + color: #333; + font-weight: 500; +} + +.ui-palette-content { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 20px 20px; + box-sizing: border-box; +} + +.ui-palette-flat { + display: flex; + flex-wrap: wrap; +} + +.ui-palette-flat-item { + width: 20%; + cursor: pointer; + position: relative; +} + +.ui-palette-flat-item:before { + content: ""; + padding-top: 75%; + float: left; +} + +.ui-palette-flat-item > div { + font-size: 10px; + padding: 0 8px 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ui-palette-flat-item > div:first-child { + padding-top: 8px; + font-size: 14px; +} + +.ui-palette-grouped { + display: flex; + flex-direction: column; +} + +.ui-palette-group { + display: flex; + margin: 8px 0; +} + +.ui-palette-group-name { + color: #666; + width: 100px; + display: flex; + align-items: center; + white-space: pre-wrap; + line-height: 20px; + font-size: 13px; +} + +.ui-palette-group-colors { + flex: 1; + display: flex; +} + +.ui-palette-group-item { + flex: 1; + cursor: pointer; + position: relative; + box-sizing: border-box; + padding: .5px; +} + +.ui-palette-group-item:before { + content: ""; + padding-top: 100%; + float: left; +} + +.ui-palette-group-item > span { + display: none; + font-size: 12px; +} + +.ui-palette-group-item:hover { + border-radius: 50%; +} + +/* ============ ImagePalettePage 新布局 ============ */ +.image-palette-body { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + padding: 20px; + box-sizing: border-box; +} + +.image-palette-upload { + width: 100%; + height: 300px; + border: 2px dashed #ccc; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + background-size: cover; + background-position: center; + transition: border-color .2s; +} + +.image-palette-upload:hover { + border-color: #999; +} + +.image-palette-upload-hint { + text-align: center; + color: #999; +} + +.image-palette-upload-hint > div { + padding: 4px 0; +} + +.image-palette-loading { + text-align: center; + padding: 20px; + color: #666; +} + +.image-palette-colors { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; +} + +.image-palette-color-item { + width: 80px; + height: 80px; + cursor: pointer; + border-radius: 4px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: 11px; +} + +.image-palette-color-item > div { + padding: 1px 0; +} + +/* ============ AIPalettePage 新布局 ============ */ +.ai-palette-body { + width: 100%; + height: 100%; + padding: 20px; + box-sizing: border-box; +} + +.ai-palette-controls { + display: flex; + align-items: center; + padding-bottom: 16px; +} + +.ai-palette-error { + color: #d32f2f; + padding: 8px 0; +} + +.ai-palette-result { + margin-top: 16px; +} + +.ai-palette-preview { + width: 100%; + height: 120px; + border-radius: 8px; + margin-bottom: 16px; +} + +.ai-palette-colors { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ai-palette-color-item { + width: 100px; + height: 80px; + cursor: pointer; + border-radius: 4px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: 12px; +} + +.ai-palette-color-item > div { + padding: 1px 0; +} + +/* ============ CollectColorsPage 新布局补充 ============ */ +.collect-grid { + display: flex; + flex-wrap: wrap; +} + +.collect-empty { + width: 100%; + color: #717171; + font-size: 13px; + text-align: center; + padding-top: 40px; +} diff --git a/plugins/color-helper/index.html b/plugins/color-helper/index.html new file mode 100644 index 000000000..eaee3ad68 --- /dev/null +++ b/plugins/color-helper/index.html @@ -0,0 +1,12 @@ + + + + + + 颜色助手 + + +
        + + + diff --git a/plugins/color-helper/main.tsx b/plugins/color-helper/main.tsx new file mode 100644 index 000000000..31c0372ea --- /dev/null +++ b/plugins/color-helper/main.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; +import CssBaseline from '@mui/material/CssBaseline'; +import './index.css'; + +// 创建主题配置 +const theme = createTheme({ + palette: { + primary: { + main: '#1976d2' + }, + secondary: { + main: '#dc004e' + } + }, + typography: { + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif' + }, + shape: { + borderRadius: 8 + } +}); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + +); diff --git a/plugins/color-helper/package-lock.json b/plugins/color-helper/package-lock.json new file mode 100644 index 000000000..6338a3369 --- /dev/null +++ b/plugins/color-helper/package-lock.json @@ -0,0 +1,2073 @@ +{ + "name": "color-helper", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "color-helper", + "version": "1.0.0", + "dependencies": { + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "@jaames/iro": "^5.5.2", + "@mui/icons-material": "^5.14.0", + "@mui/material": "^5.14.0", + "chroma-js": "^2.4.2", + "konva": "^9.2.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-konva": "^18.2.10", + "react-router-dom": "^6.15.0" + }, + "devDependencies": { + "@types/chroma-js": "^2.4.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.0.4", + "typescript": "^5.1.6", + "vite": "^4.4.9" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" + }, + "node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@irojs/iro-core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@irojs/iro-core/-/iro-core-1.2.1.tgz", + "integrity": "sha512-p2OvsBSSmidsDsTSkID6jEyXDF7lcyxPrkh3qBzasBZFpjkYd6kZ3yMWai3MlAaQ3F7li/Et7rSJVV09Fpei+A==" + }, + "node_modules/@jaames/iro": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@jaames/iro/-/iro-5.5.2.tgz", + "integrity": "sha512-Fbi5U4Vdkw6UsF+R3oMlPONqkvUDMkwzh+mX718gQsDFt3+1r1jvGsrfCbedmXAAy0WsjDHOrefK0BkDk99TQg==", + "dependencies": { + "@irojs/iro-core": "^1.2.1", + "preact": "^10.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.18.0.tgz", + "integrity": "sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.18.0.tgz", + "integrity": "sha512-1s0vEZj5XFXDMmz3Arl/R7IncFqJ+WQ95LDp1roHWGDE2oCO3IS4/hmiOv1/8SD9r6B7tv9GLiqVZYHo+6PkTg==", + "dependencies": { + "@babel/runtime": "^7.23.9" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^5.0.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz", + "integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/core-downloads-tracker": "^5.18.0", + "@mui/system": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.10", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz", + "integrity": "sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/utils": "^5.17.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz", + "integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz", + "integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/private-theming": "^5.17.1", + "@mui/styled-engine": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz", + "integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/types": "~7.2.15", + "@types/prop-types": "^15.7.12", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chroma-js": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.4.5.tgz", + "integrity": "sha512-6ISjhzJViaPCy2q2e6PgK+8HcHQDQ0V2LDiKmYAh+jJlLqDa6HbwDh0wOevHY0kHHUx0iZwjSRbVD47WOUx5EQ==", + "dev": true + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", + "integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chroma-js": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.6.0.tgz", + "integrity": "sha512-BLHvCB9s8Z1EV4ethr6xnkl/P2YRFOGqfgvuMG/MyCbZPrTA+NeiByY6XvgF0zP4/2deU2CXnWyMa3zu1LqQ3A==" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.368", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", + "integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==", + "dev": true + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/konva": { + "version": "9.3.22", + "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.22.tgz", + "integrity": "sha512-yQI5d1bmELlD/fowuyfOp9ff+oamg26WOCkyqUyc+nczD/lhRa3EvD2MZOoc4c1293TAubW9n34fSQLgSeEgSw==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ] + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", + "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==" + }, + "node_modules/react-konva": { + "version": "18.2.16", + "resolved": "https://registry.npmjs.org/react-konva/-/react-konva-18.2.16.tgz", + "integrity": "sha512-bYs5TuTpaSSxBTZ79btaSXjDvLaQIZOVgBEg2ITMyc2p3jwbdIJDh7kOuK4ivxY8uZHWxmQP32bLxlwA17EgXQ==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "dependencies": { + "@types/react-reconciler": "^0.28.2", + "its-fine": "^1.1.1", + "react-reconciler": "~0.29.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "konva": "^8.0.1 || ^7.2.5 || ^9.0.0 || ^10.0.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", + "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz", + "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "4.5.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", + "integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==", + "dev": true, + "dependencies": { + "esbuild": "^0.18.10", + "postcss": "^8.4.27", + "rollup": "^3.27.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "engines": { + "node": ">= 6" + } + } + } +} diff --git a/plugins/color-helper/package.json b/plugins/color-helper/package.json new file mode 100644 index 000000000..66117ab38 --- /dev/null +++ b/plugins/color-helper/package.json @@ -0,0 +1,30 @@ +{ + "name": "color-helper", + "version": "1.0.0", + "description": "颜色助手", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "@jaames/iro": "^5.5.2", + "@mui/icons-material": "^5.14.0", + "@mui/material": "^5.14.0", + "chroma-js": "^2.4.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.15.0" + }, + "devDependencies": { + "@types/chroma-js": "^2.4.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.0.4", + "typescript": "^5.1.6", + "vite": "^4.4.9" + } +} diff --git a/plugins/color-helper/pages/AIPalettePage.tsx b/plugins/color-helper/pages/AIPalettePage.tsx new file mode 100644 index 000000000..ef3cc9916 --- /dev/null +++ b/plugins/color-helper/pages/AIPalettePage.tsx @@ -0,0 +1,512 @@ +import React, { useState, useEffect, useMemo, useCallback, useRef, memo } from 'react'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogActions from '@mui/material/DialogActions'; +import TextField from '@mui/material/TextField'; +import Divider from '@mui/material/Divider'; +import Box from '@mui/material/Box'; +import Tooltip from '@mui/material/Tooltip'; +import IconButton from '@mui/material/IconButton'; +import ColorizeIcon from '@mui/icons-material/Colorize'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; +import { styled } from '@mui/material/styles'; +import chroma from 'chroma-js'; +import iro from '@jaames/iro'; +import { dbStorage, screenColorPick, aiChat, isAIAvailable } from '../utils/platform'; + +/** + * AIPalettePage - AI配色方案生成页面 + * + * 核心功能: + * - 用户选择主色、风格、色系、配色数量 + * - 调用 AI 生成配色方案(每个配色包含 hex/名称/描述) + * - 支持屏幕取色、随机色 + * - 配色结果可点击复制 + */ + +/** AI 返回的配色项 */ +interface AIPaletteItem { + hex: string; + name: string; + description: string; +} + +/** 解析 AI 返回的 JSON 字符串 */ +function parseAIResponse(content: string): AIPaletteItem[] { + if (content.startsWith("{")) return JSON.parse(content); + return JSON.parse(content.replace(/^```json\n/, "").replace(/\n```$/, "")); +} + +// useLocalStorage - 持久化状态到 dbStorage +function useLocalStorage(key: string, defaultValue: T): [T, (v: T) => void] { + const [value, setValue] = useState(() => dbStorage.getItem(key) ?? defaultValue); + useEffect(() => { + dbStorage.setItem(key, value); + }, [key, value]); + return [value, setValue]; +} + +/** AI 配色风格选项 */ +const AI_STYLES = ["现代", "复古", "自然", "活泼", "浪漫"]; +/** AI 配色色系选项 */ +const AI_THEMES = ["暖", "冷"]; +/** AI 配色数量选项 */ +const AI_COUNTS = [3, 4, 5, 6, 7, 8]; + +/** 设置行样式: 标签 + 值横向排列 */ +const StyleRow = styled('div')({ + display: 'flex', + flexDirection: "row", + alignItems: "center", + width: "100%", + marginBottom: "44px", + whiteSpace: "nowrap", + ">.label": { marginRight: "30px" }, + ">.value": { width: 28, height: 28, boxSizing: "border-box", borderRadius: "4px" }, +}); + +/** ChipSelect - 色块选择器组件,用于风格/色系/数量选择 */ +interface ChipSelectProps { + data: any[]; + value: any; + size: number | [number, number]; + shape?: string; + onChange: (v: any, item?: any) => void; +} + +function ChipSelect({ data, value, size, shape, onChange }: ChipSelectProps) { + const [width, height] = useMemo(() => { + if (Array.isArray(size)) { + const [w = 22, h = w] = size; + return [w, h]; + } + const s = size ?? 22; + return [s, s]; + }, [size]); + + const borderRadius = useMemo(() => { + if (shape === "circle") { + const dims = [width, height].filter(v => typeof v === "number" && v > 0 && !Number.isNaN(v)); + return dims.length === 0 ? 4 : Math.min(...dims) / 2; + } + return 4; + }, [width, height, shape]); + + return ( + + {data?.map(item => { + const val = typeof item === "object" ? (item.value ?? item.label) : String(item); + const label = typeof item === "object" ? (item.label ?? item.value) : String(item); + return ( + + ); + })} + + ); +} + +// 空配色占位 +function EmptyPalette() { + return ( + + + + + 暂无配色 + + + + ); +} + +// iro ColorPicker组件 +interface IroPickerProps { + color: string; + onChange: (hex: string) => void; +} + +function IroPicker({ color, onChange }: IroPickerProps) { + const containerRef = useRef(null); + const pickerRef = useRef(null); + // 保持 onChange 引用最新,避免事件回调中使用过期的闭包 + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + useEffect(() => { + if (!containerRef.current) return; + if (pickerRef.current) { + const c = chroma.valid(color) ? chroma(color) : null; + if (c) pickerRef.current.color.hexString = c.hex(); + return; + } + pickerRef.current = new iro.ColorPicker(containerRef.current, { + width: 190, + borderWidth: 1, + handleSvg: "#colorHandle", + layout: [ + { component: iro.ui.Wheel, options: { wheelDirection: "clockwise", wheelAngle: -90 } }, + { component: iro.ui.Slider, options: { sliderType: "saturation" } }, + { component: iro.ui.Slider }, + ], + }); + const handleChange = (c: any) => onChangeRef.current(c.hexString); + pickerRef.current.on("input:change", handleChange); + const c = chroma.valid(color) ? chroma(color) : null; + if (c) pickerRef.current.color.hexString = c.hex(); + + return () => { + if (pickerRef.current) { + pickerRef.current.off("input:change", handleChange); + } + }; + }, [color]); + + return ( + <> + + + + + + + +
        + + ); +} + +// 颜色选择弹窗 +interface ColorPickerDialogProps { + color: string; + open: boolean; + onClose: () => void; + onSubmit: (color: string) => void; +} + +function ColorPickerDialog({ color, open, onClose, onSubmit }: ColorPickerDialogProps) { + const [inputColor, setInputColor] = useState(""); + const [error, setError] = useState(""); + + useEffect(() => { + if (open && color) setInputColor(color); + }, [color, open]); + + useEffect(() => { + if (!open) { + setInputColor(""); + setError(""); + } + }, [open]); + + return ( + + 输入颜色 + + + + setInputColor(e.target.value)} + fullWidth + helperText={error || "任意格式的色 值"} + margin="normal" + /> + + + + + + + + ); +} + +/** AI配色主页面 - 上次生成结果缓存 */ +let lastMainColor = chroma.random().hex(); +let lastPalettes: AIPaletteItem[] = []; + +interface AIPaletteProps { + value: (string | null)[]; + onColorClick: (e: any) => void; + setting: boolean; + showMessage: (msg: string) => void; +} + +const AIPalettePage = memo(function AIPalettePage(props: AIPaletteProps) { + const [mainColor, setMainColor] = useState(""); + const [style, setStyle] = useLocalStorage("ai/paletteStyle", "现代"); + const [theme, setTheme] = useLocalStorage("ai/paletteTheme", "暖"); + const [count, setCount] = useLocalStorage("ai/paletteCount", 3); + const [palettes, setPalettes] = useState([]); + const [colorPickerOpen, setColorPickerOpen] = useState(false); + const [status, setStatus] = useState<"pending" | "loading" | "finished">("pending"); + + useEffect(() => { + setPalettes(lastPalettes); + if (lastPalettes.length > 0) setStatus("finished"); + }, []); + + useEffect(() => { + lastPalettes = palettes; + }, [palettes]); + + const buttonText = useMemo(() => { + if (status === "loading") return "AI 生成中"; + if (status === "finished") return "AI 重新生成"; + return "AI 生成配色"; + }, [status]); + + /** 调用 AI 生成配色方案 */ + const generate = useCallback(async () => { + if (!mainColor) return; + setStatus("loading"); + + if (!isAIAvailable()) { + props.showMessage("当前版本不支持 AI 功能"); + return; + } + + try { + const result = await aiChat([ + { + role: "system", + content: ` +# Role +你是一个颜色配色大师 +## Skills +- 读取用户输入的颜色值 +- 给读取的颜色选择合适的配色方案,共需要给用户提供 ${count} 个颜色, 配色中不要有 ${mainColor} +- 生成的配色方案必须符合 ${style} 风格 +- 生成的配色方案必须符合 ${theme} 色系 +- 你给出的每个配色方案必须包含颜色hex值、颜色名称以及颜色描述 +- 返回的配色方案以 JSON 数组格式返回 +## Actions +- 根据用户输入的颜色值生成合适的配色方案 +- 你直接输出 JSON 数组,不再额外输出其他内容 +## Example +[{ + "hex": "#E797B7", + "name": "芭比粉", + "description": "比主色调更浅淡一些,粉色调更明显,与主色调搭配能营造出柔和温暖且具有现代感的氛围。" +}] +## Input +输入:{颜色值} +## Output +{JSON} +`, + }, + { role: "user", content: `输入: ${[mainColor]}` }, + ] + ); + + const parsed = parseAIResponse(result.content); + if (!Array.isArray(parsed)) { + props.showMessage("AI 生成配色失败"); + return; + } + setPalettes(parsed); + } catch { + props.showMessage("AI 生成配色失败"); + } finally { + setStatus("finished"); + } + }, [mainColor, style, theme, count, props.showMessage]); + + useEffect(() => { + if (mainColor) { + lastMainColor = mainColor; + } + }, [mainColor]); + + useEffect(() => { + if (props.value?.[0]) { + const parsed = parseColorInput(props.value[0]); + if (!parsed) { + setMainColor(lastMainColor); + return; + } + setMainColor(parsed.hex()); + } else { + setMainColor(lastMainColor); + } + }, []); + + return ( + + + + {/* 左侧控制区 */} + + {/* 主色行 */} + + 主色 + `1px solid ${theme.palette.divider}` : 'none', + cursor: 'pointer', + }} + onClick={() => setColorPickerOpen(true)} + /> + + + { screenColorPick(({ hex }: any) => { setMainColor(hex); }); }}> + + + + + { props.onColorClick(mainColor); }}> + + + + + + + {/* 风格行 */} + + 风格 + + + + {/* 色系行 */} + + 色系 + + + + {/* 数量行 */} + + 数量 + + + + {/* 生成按钮 */} + + + + + + {/* 分隔线 */} + + + {/* 右侧配色结果 */} + + 配色 + + {palettes.length === 0 && } + + {palettes.map((w) => ( + + {w.name} {w.hex} + {w.description} + + } + placement="bottom" + componentsProps={{ + tooltip: { sx: { bgcolor: '#fff', color: '#000', boxShadow: '0px 0px 10px 0px rgba(0, 0, 0, 0.15)', borderRadius: 2 } }, + arrow: { sx: { color: '#fff' } }, + }} + arrow + > + { props.onColorClick(w.hex); }} + /> + + ))} + + + + + + setColorPickerOpen(false)} onSubmit={(c) => setMainColor(c)} /> + + ); +}); + +// 解析颜色输入 +function parseColorInput(input: string | null): chroma.Color | null { + if (!input) return null; + try { + return chroma(input); + } catch { + return null; + } +} + +export default AIPalettePage; diff --git a/plugins/color-helper/pages/CollectColorsPage.tsx b/plugins/color-helper/pages/CollectColorsPage.tsx new file mode 100644 index 000000000..a131af7f4 --- /dev/null +++ b/plugins/color-helper/pages/CollectColorsPage.tsx @@ -0,0 +1,377 @@ +import React, { Component } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogActions from '@mui/material/DialogActions'; +import TextField from '@mui/material/TextField'; +import Tooltip from '@mui/material/Tooltip'; +import Alert from '@mui/material/Alert'; +import Fab from '@mui/material/Fab'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import AddIcon from '@mui/icons-material/Add'; +import ColorizeIcon from '@mui/icons-material/Colorize'; +import chroma from 'chroma-js'; +import { db, dbStorage, screenColorPick } from '../utils/platform'; + +/** + * CollectColorsPage - 收藏颜色管理页面 + * + * 管理用户收藏的颜色,支持分组、拖拽排序、添加/删除 + * 数据持久化到 dbStorage + */ + +interface CollectColor { + _id: string; + _rev?: string; + name: string; + color: string; + dark?: boolean; +} + +// 编辑表单对话框 +interface FormDialogProps { + formData: CollectColor | null; + onSubmit: (isEdit: boolean) => void; +} + +interface FormDialogState { + open: boolean; + name: string; + color: string; + error: string; +} + +class CollectFormDialog extends Component { + state: FormDialogState = { + open: false, + name: "", + color: "", + error: "", + }; + + handleClose = () => { + this.setState({ open: false }); + }; + + handleOk = () => { + const { name, color } = this.state; + if (name && name.length > 64) { + this.setState({ error: "名称长度太长" }); + return; + } + this.props.formData!.name = name; + const isEdit = !!this.props.formData!._id; + if (!isEdit) { + const parsed = chroma.valid(color) ? chroma(color) : null; + if (!parsed) { + this.setState({ error: "错误的色值" }); + return; + } + const hex = parsed.hex(); + const docId = "color/" + hex.substring(1).toLowerCase(); + if (db.get(docId)) { + this.setState({ error: "该颜色已收藏!" }); + return; + } + this.props.formData!._id = docId; + this.props.formData!.color = hex.toUpperCase(); + this.props.formData!.dark = parsed.get("lab.l") < 70; + } + const result = db.put(this.props.formData!); + if (result?.error) { + this.setState({ error: "保存失败" }); + return; + } + this.props.formData!._rev = result!.rev; + this.setState({ open: false }); + this.props.onSubmit(isEdit); + }; + + handleNameInputChange = (e: React.ChangeEvent) => { + this.setState({ name: e.target.value }); + }; + + handleColorInputChange = (e: React.ChangeEvent) => { + this.setState({ color: e.target.value }); + }; + + handleColorPicker = () => { + screenColorPick(({ hex }: { hex: string }) => { + this.setState({ color: hex }); + }); + }; + + componentDidUpdate(prev: FormDialogProps) { + if (this.props.formData && prev.formData !== this.props.formData) { + this.setState({ + open: true, + name: this.props.formData.name, + color: this.props.formData.color, + error: "", + }); + } + } + + render() { + const { formData } = this.props; + if (!formData) return false; + + const { open, name, color, error } = this.state; + const hasColor = !!formData.color; + + return ( + + {formData._id ? "修改名称" : "收藏新颜色"} + + + + + + + + + + + {error && {error}} + + + + + + + ); + } +} + +// 删除确认对话框 +interface DeleteDialogProps { + deleteData: CollectColor | null; + onSubmit: () => void; +} + +interface DeleteDialogState { + open: boolean; +} + +class CollectDeleteDialog extends Component { + state: DeleteDialogState = { open: false }; + + handleClose = () => { + this.setState({ open: false }); + }; + + shouldComponentUpdate(nextProps: DeleteDialogProps, nextState: DeleteDialogState) { + return nextProps.deleteData !== this.props.deleteData || nextState.open !== this.state.open; + } + + componentDidUpdate(prev: DeleteDialogProps) { + if (this.props.deleteData && prev.deleteData !== this.props.deleteData) { + this.setState({ open: true }); + } + } + + render() { + const { deleteData, onSubmit } = this.props; + if (!deleteData) return false; + + const { open } = this.state; + + return ( + + 确认删除该颜色? + + deleteData.color === "#FFFFFF" ? `${theme.palette.divider} solid 1px` : "none", + }} + /> + + + + + + + ); + } +} + +interface CollectPageState { + colors: CollectColor[]; + colorForm: CollectColor | null; + openDeleteData: CollectColor | null; +} + +class CollectColorsPage extends Component<{ onColorClick: (e: any) => void }, CollectPageState> { + constructor(props: { onColorClick: (e: any) => void }) { + super(props); + + let colors: CollectColor[] = []; + let sortDoc: any = null; + let markerDoc: any = null; + + const allDocs = db.allDocs(); + allDocs.forEach((doc: any) => { + if (doc._id.startsWith("color/")) { + colors.push(doc); + } else if (doc._id === "collectsort") { + sortDoc = doc; + } else if (doc._id === "markercolor") { + markerDoc = doc; + } + }); + + // 迁移markercolor旧数据 + if (markerDoc) { + markerDoc.colors.forEach((color: string) => { + const upper = color.toUpperCase(); + if (!/^#[A-F0-9]{6}$/.test(upper) || colors.find(c => c.color === upper)) return; + const dark = chroma(color).get("lab.l") < 70; + const newDoc: CollectColor = { _id: "color/" + color.substring(1).toLowerCase(), name: "", color: upper, dark }; + const result = db.put(newDoc); + if (result.ok) { + newDoc._rev = result.rev; + colors.push(newDoc); + } + }); + db.remove(markerDoc); + dbStorage.setItem("collectsort", colors.map(c => c._id)); + } + + // 排序 + if (sortDoc) { + colors = colors.sort((a, b) => { + let ai = sortDoc.value.indexOf(a._id); + let bi = sortDoc.value.indexOf(b._id); + if (ai === -1) ai = 9999999; + if (bi === -1) bi = 9999999; + return ai - bi; + }); + } + + this.state = { + colors, + colorForm: null, + openDeleteData: null, + }; + } + + handleOpenNewForm = () => { + this.setState({ colorForm: { _id: "", name: "", color: "" } }); + }; + + handleOpenEditForm = (color: CollectColor) => (e: React.MouseEvent) => { + e.stopPropagation(); + this.setState({ colorForm: { ...color } }); + }; + + handleOpenDeleteConfirm = (color: CollectColor) => (e: React.MouseEvent) => { + e.stopPropagation(); + this.setState({ openDeleteData: { ...color } }); + }; + + handleDeleteSubmit = () => { + const { colors, openDeleteData } = this.state; + if (!openDeleteData || db.remove(openDeleteData).error) { + this.setState({ openDeleteData: null }); + return; + } + const index = colors.findIndex(c => c._id === openDeleteData._id); + if (index !== -1) colors.splice(index, 1); + this.setState({ openDeleteData: null }); + }; + + handleFormSubmit = (isEdit: boolean) => { + const { colors, colorForm } = this.state; + if (!colorForm) return; + + if (isEdit) { + // 编辑 - 有_id表示已存在的文档 + const index = colors.findIndex(c => c._id === colorForm._id); + if (index !== -1) colors.splice(index, 1, colorForm); + } else { + // 新增 + colors.push(colorForm); + dbStorage.setItem("collectsort", colors.map(c => c._id)); + } + this.setState({ colorForm: null }); + }; + + render() { + const { colors, colorForm, openDeleteData } = this.state; + + return ( +
        +
        + {colors.map(c => ( + this.props.onColorClick(c.color)} + sx={{ + backgroundColor: c.color, + color: c.dark ? "#fff" : "#333", + ".MuiSvgIcon-root": { color: c.dark ? "#fff" : "#333" }, + boxSizing: "border-box", + border: (theme: any) => c.color === "#FFFFFF" ? `${theme.palette.divider} solid 1px` : "none", + }} + > +
        +
        + {c.name &&
        {c.name}
        } +
        {c.color}
        +
        +
        + + + + + + +
        +
        +
        + ))} +
        + {colors.length === 0 && ( +
        ~~ 无收藏记录 ~~
        + )} + + + + + +
        + ); + } +} + +export default CollectColorsPage; diff --git a/plugins/color-helper/pages/ColorPage.tsx b/plugins/color-helper/pages/ColorPage.tsx new file mode 100644 index 000000000..ab164af73 --- /dev/null +++ b/plugins/color-helper/pages/ColorPage.tsx @@ -0,0 +1,608 @@ +import React, { Component, PureComponent } from 'react'; +import Box from '@mui/material/Box'; +import Card from '@mui/material/Card'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import { copyText as platformCopyText, screenColorPick } from '../utils/platform'; +import Tooltip from '@mui/material/Tooltip'; +import Divider from '@mui/material/Divider'; +import CheckIcon from '@mui/icons-material/Check'; +import AddIcon from '@mui/icons-material/Add'; +import ColorizeIcon from '@mui/icons-material/Colorize'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import chroma from 'chroma-js'; +import iro from '@jaames/iro'; + +/** + * ColorPage - 颜色转换页面 + * + * 核心功能: + * - 颜色选择器(色轮 + 饱和度/明度滑块) + * - 多格式颜色值显示与编辑(HEX/RGB/HSL/HSV/HSI/CMYK/LAB) + * - 色彩和谐方案(互补色/类似色/三角色/四角色) + * - 屏幕取色 + */ + +/** 格式化色相角度,处理负值和超过360度的情况 */ + +function formatHueAngle(h: number): string { + return h >= 0 + ? Math.round((h > 360 ? h - 360 : h) * 100) / 100 + ", " + : Math.round((360 + h) * 100) / 100 + ", "; +} + +/** 将 hsl(...) 字符串转为 HEX */ +function hslToHex(hslStr: string): string { + try { + const match = hslStr.match(/hsl\(([\d.]+),\s*([\d.]+)%,\s*([\d.]+)%\)/); + if (match) { + return chroma.hsl(parseFloat(match[1]), parseFloat(match[2]) / 100, parseFloat(match[3]) / 100).hex(); + } + } catch {} + return hslStr; +} + +/** 颜色状态: 存储各格式的颜色值及用户输入草稿 */ +interface ColorState { + hex: string; + rgb: string; + hsl: string; + hsv: string; + hsi: string; + cmyk: string; + lab: string; + hexInput?: string; + rgbInput?: string; + hslInput?: string; + hsvInput?: string; + hsiInput?: string; + cmykInput?: string; + labInput?: string; + hslExtend: { + complementary: string[]; + analogous: string[]; + triadic: string[]; + tetradic: string[]; + }; +} + +/** 将 chroma.Color 对象转换为 ColorState,包含所有格式的颜色值和和谐色方案 */ +function convertStateColor(color: chroma.Color): ColorState { + const hex = color.hex(); + const rgb = color.rgb().join(", "); + const hsl = color.hsl(); + const h = isNaN(hsl[0]) ? 0 : hsl[0]; + const sl = Math.round(hsl[1] * 10000) / 100 + "%, " + Math.round(hsl[2] * 10000) / 100 + "%"; + const hslStr = formatHueAngle(h) + sl; + + const hsv = color.hsv(); + const hsvStr = (isNaN(hsv[0]) ? 0 : Math.round(hsv[0] * 100) / 100) + ", " + + Math.round(hsv[1] * 10000) / 100 + "%, " + Math.round(hsv[2] * 10000) / 100 + "%"; + + const hsi = color.hsi(); + const hsiStr = (isNaN(hsi[0]) ? 0 : Math.round(hsi[0] * 100) / 100) + ", " + + Math.round(hsi[1] * 10000) / 100 + "%, " + Math.round(hsi[2] * 10000) / 100 + "%"; + + const cmyk = color.cmyk().map((v: number) => Math.round(v * 100) + "%").join(", "); + const lab = color.lab().map((v: number) => Math.round(v * 1000) / 1000 || 0).join(", "); + + const complementary = ["hsl(" + formatHueAngle(h + 180) + sl + ")"]; + const analogous = ["hsl(" + formatHueAngle(h - 30) + sl + ")", "hsl(" + formatHueAngle(h + 30) + sl + ")"]; + const triadic = ["hsl(" + formatHueAngle(h + 120) + sl + ")", "hsl(" + formatHueAngle(h + 240) + sl + ")"]; + const tetradic = ["hsl(" + formatHueAngle(h + 90) + sl + ")", "hsl(" + formatHueAngle(h + 180) + sl + ")", "hsl(" + formatHueAngle(h + 270) + sl + ")"]; + + return { + hex, rgb, hsl: hslStr, hsv: hsvStr, hsi: hsiStr, cmyk, lab, + hslExtend: { complementary, analogous, triadic, tetradic } + }; +} + +/** 按指定格式解析颜色字符串,返回 chroma.Color 或 null */ +function parseColorByType(type: string, value: string): chroma.Color | null { + if (type === "hex") { + if (/^[0-9a-fA-F]{6}$/.test(value)) return chroma("#" + value); + return null; + } + if (type === "rgb") { + const m = value.match(/((?:(?:25[0-5]|2[0-4]\d|1?\d{1,2})(?:, *| +)){2}(?:25[0-5]|2[0-4]\d|1?\d{1,2}))/); + if (m) { + const parts = m[1].split(/, *| +/).map((v: string) => parseInt(v.trim())); + return chroma.rgb(parts[0], parts[1], parts[2]); + } + return null; + } + if (type === "cmyk") { + const m = value.match(/((?:(?:100|\d{1,2})%?, ?){3}(?:100|\d{1,2})%?|(?:(?:1|0|0\.\d{1,2}), ?){3}(?:1|0|0\.\d{1,2}))/); + if (m) { + const parts = m[1].replace(/%/g, "").split(",").map((v: string) => parseFloat(v.trim())).map((v: number) => v > 1 ? v / 100 : v); + return chroma.cmyk(parts[0], parts[1], parts[2], parts[3]); + } + return null; + } + if (type === "hsl" || type === "hsi" || type === "hsv") { + const m = value.match(/((?:360|((?:3[0-5]\d|[1-2]\d{2}|\d{1,2})(?:\.\d{1,15})?))(?:deg)?(?:, *| +)(?:100|\d{1,2}(?:\.\d{1,15})?)%?(?:, *| +)(?:100|\d{1,2}(?:\.\d{1,15})?)%?)/); + if (m) { + const parts = m[1].replace(/[^\d., ]/g, "").split(/, *| +/).map((v: string) => parseFloat(v.trim())); + if (parts[1] > 1) parts[1] = parts[1] / 100; + if (parts[2] > 1) parts[2] = parts[2] / 100; + return (chroma as any)[type](parts[0], parts[1], parts[2]); + } + return null; + } + if (type === "lab") { + const m = value.match(/((?:100|\d{1,2}(?:\.\d{1,15})?)(?:(?:, *| +)-?(?:100|\d{1,2}(?:\.\d{1,15})?)){2})/); + if (m) { + const parts = m[1].split(/, *| +/).map((v: string) => parseFloat(v)); + return chroma.lab(parts[0], parts[1], parts[2]); + } + return null; + } + return null; +} + +/** 自动识别颜色格式并解析输入字符串 */ +function parseColorInput(input: string): chroma.Color | null { + if (!input || input.length < 4) return null; + let type: string; + let value = input; + + if (/^(?:#[a-f0-9]{3}|#?[a-f0-9]{6}|#[a-f0-9]{8});?$/i.test(value)) { + type = "hex"; + value = value.replace(/[^0-9a-fA-F]/gi, ""); + if (value.length === 3) value = value + value; + else if (value.length === 8) value = value.substring(2); + } else { + type = value.substring(0, 3).toLowerCase(); + } + return parseColorByType(type, value); +} + +/** 页面状态缓存,用于跨组件保持颜色选择 */ +let cachedState: ColorPageState | null = null; + +/** Iro 色轮选择器组件 - 支持多色选择 */ +interface IroPickerProps { + colors: ColorState[]; + activeIndex: number; + onChange: (color: any) => void; + onSetActive: (color: any) => void; +} + +class IroColorPicker extends PureComponent { + private colorPicker: any = null; + private containerRef = React.createRef(); + + componentDidMount() { + if (!this.containerRef.current) return; + this.colorPicker = new iro.ColorPicker(this.containerRef.current, { + width: 300, + borderWidth: 1, + handleSvg: "#handle", + colors: this.props.colors.map((t: ColorState) => "hsl(" + t.hsl + ")"), + layout: [ + { component: iro.ui.Wheel, options: { wheelDirection: "clockwise" as any, wheelAngle: -90 } }, + { component: iro.ui.Slider, options: { sliderType: "saturation" } }, + { component: iro.ui.Slider }, + ], + }); + this.colorPicker.on("color:change", this.props.onChange); + this.colorPicker.on("color:setActive", this.props.onSetActive); + this.colorPicker.setActiveColor(this.props.activeIndex); + } + + componentWillUnmount() { + if (this.colorPicker) { + this.colorPicker.off("color:change", this.props.onChange); + this.colorPicker.off("color:setActive", this.props.onSetActive); + if (this.containerRef.current) { + this.containerRef.current.innerHTML = ""; + } + this.colorPicker = null; + } + } + + componentDidUpdate(prevProps: IroPickerProps) { + if (prevProps.colors !== this.props.colors) { + this.colorPicker.setColors(this.props.colors.map((n: ColorState) => "hsl(" + n.hsl + ")")); + this.colorPicker.setActiveColor(this.props.activeIndex); + } + } + + render() { + return ( + <> + + + + + + + +
        + + ); + } +} + +interface ColorPageProps { + value: (string | null)[]; + onColorClick: (e: any) => void; + setting: boolean; + showMessage: (msg: string) => void; +} + +interface ColorPageState { + colors: ColorState[]; + activeIndex: number; +} + +/** 配色方案标签映射 */ +const schemeLabels: Record = { + complementary: "互补色", + triadic: "对比色", + analogous: "类似色", + tetradic: "中差色", +}; + +class ColorPage extends Component { + private _currentChromaColor: chroma.Color | null = null; + + /** 添加新颜色到色板(最多8个,超出时移除最早的) */ + setNewColor = (color: ColorState) => { + const colors = [...this.state.colors, color]; + if (colors.length > 8) colors.shift(); + this.setState({ colors, activeIndex: colors.length - 1 }); + }; + + /** 屏幕取色 */ + handleScreenColorPicker = () => { + screenColorPick(({ hex }) => { + this.setNewColor(convertStateColor(chroma(hex))); + }); + }; + + /** 随机生成新颜色 */ + handleColorHubNew = () => { + this.setNewColor(convertStateColor(chroma.random())); + }; + + /** 切换当前激活的颜色索引 */ + handleColorSelect = (index: number) => { + if (index === this.state.activeIndex) return; + this.setState({ colors: [...this.state.colors], activeIndex: index }); + }; + + /** 复制指定格式的颜色值到剪贴板 */ + handleCssCodeCopy = (format: string) => () => { + const color = this.state.colors[this.state.activeIndex]; + let copyText: string; + if (this.props.setting) { + if (format === "hex") copyText = color.hex.substring(1); + else if (format === "rgb") copyText = color.rgb; + else if (format === "cmyk") copyText = color.cmyk; + else if (["hsl", "hsi", "hsv"].includes(format)) copyText = (color as any)[format]; + else if (format === "lab") copyText = color.lab; + else return; + } else { + if (format === "hex") copyText = color.hex; + else if (format === "rgb") copyText = "rgb(" + color.rgb + ")"; + else if (format === "cmyk") copyText = "cmyk(" + color.cmyk + ")"; + else if (["hsl", "hsi", "hsv"].includes(format)) copyText = format + "(" + (color as any)[format] + ")"; + else if (format === "lab") copyText = "lab(" + color.lab + ")"; + else return; + } + platformCopyText(copyText); + }; + + /** 颜色值输入变更: 解析输入并更新颜色状态,解析失败则保留输入草稿 */ + handleValueChange = (format: string) => (e: React.ChangeEvent) => { + const value = e.target.value; + const parsed = parseColorByType(format, value); + const { colors, activeIndex } = this.state; + if (parsed) { + colors[activeIndex] = convertStateColor(parsed); + } else { + (colors[activeIndex] as any)[format + "Input"] = value; + } + this.setState({ colors: [...colors] }); + }; + + handleColorWheelSetActive = (color: any) => { + this.setState({ activeIndex: color.index }); + }; + + handleColorWheelChange = (color: any) => { + const newColor = convertStateColor(chroma.hsv(color.hsv.h, color.hsv.s / 100, color.hsv.v / 100)); + const colors = [...this.state.colors]; + colors[color.index] = newColor; + this.setState({ colors }); + }; + + constructor(props: ColorPageProps) { + super(props); + + let initialColor: ColorState | undefined; + + if (props.value?.[0]) { + const parsed = parseColorInput(props.value[0]!); + props.value[0] = null; + if (parsed) initialColor = convertStateColor(parsed); + } + + if (cachedState) { + if (initialColor) { + cachedState.colors.push(initialColor); + if (cachedState.colors.length > 8) cachedState.colors.shift(); + cachedState.activeIndex = cachedState.colors.length - 1; + } + this.state = cachedState; + return; + } + + if (!initialColor) initialColor = convertStateColor(chroma.random()); + this.state = { colors: [initialColor], activeIndex: 0 }; + } + + componentWillUnmount() { + cachedState = this.state; + } + + componentDidUpdate(prevProps: ColorPageProps) { + if (prevProps.value !== this.props.value) { + const parsed = parseColorInput(this.props.value?.[0] || ""); + if (parsed) { + (this.props.value as any)[0] = null; + this.setNewColor(convertStateColor(parsed)); + } + } + } + + render() { + const { colors, activeIndex } = this.state; + const { setting, onColorClick } = this.props; + const u = colors[activeIndex]; + + const extendData: Record = { + complementary: { label: "互补色", colors: u.hslExtend.complementary }, + triadic: { label: "对比色", colors: u.hslExtend.triadic }, + analogous: { label: "类似色", colors: u.hslExtend.analogous }, + tetradic: { label: "中差色", colors: u.hslExtend.tetradic }, + }; + + return ( +
        +
        +
        + + + + + + +
        + + {/* 配色方案 */} +
        + {Object.keys(extendData).map(key => { + const ext = extendData[key]; + return ( +
        +
        {ext.label}
        + {ext.colors.map((color, i) => { + const hex = hslToHex(color); + return ( +
        onColorClick({ currentTarget: { style: { backgroundColor: hex } } })} + style={{ + backgroundColor: color, + boxSizing: 'border-box', + border: hex === '#ffffff' ? '1px solid rgba(0,0,0,0.12)' : 'none', + }} + /> + ); + })} +
        + ); + })} +
        + + + + {/* HEX */} +
        +
        HEX
        +
        + +
        +
        + + + + + +
        +
        + + {/* RGB */} +
        +
        RGB
        +
        + +
        +
        + + + + + +
        +
        + + {/* HSV/HSB */} +
        +
        HSV/HSB
        +
        + +
        +
        + + + + + +
        +
        + + {/* HSL */} +
        +
        HSL
        +
        + +
        +
        + + + + + +
        +
        + + {/* CMYK */} +
        +
        CMYK
        +
        + +
        +
        + + + + + +
        +
        + + {/* HSI */} +
        +
        HSI
        +
        + +
        +
        + + + + + +
        +
        + + {/* CIE-LAB */} +
        +
        CIE-LAB
        +
        + +
        +
        + + + + + +
        +
        + + {/* 颜色Hub */} +
        +
        + {colors.map((color, index) => { + const isDark = chroma(color.hex).get('lab.l') < 70; + return ( + this.handleColorSelect(index)} + className={`color-hub-value ${index === activeIndex ? 'color-checked' : ''}`} + sx={{ + backgroundColor: color.hex, + border: (theme: any) => color.hex === u.hex ? `1px solid ${theme.palette.divider}` : 'none', + color: isDark ? '#fff' : '#000', + cursor: 'pointer', + }} + > + + + ); + })} +
        + + + + + +
        + +
        +
        + ); + } +} + +export default ColorPage; diff --git a/plugins/color-helper/pages/GradientsPage.tsx b/plugins/color-helper/pages/GradientsPage.tsx new file mode 100644 index 000000000..d9fbd1695 --- /dev/null +++ b/plugins/color-helper/pages/GradientsPage.tsx @@ -0,0 +1,411 @@ +import React, { PureComponent, Component } from 'react'; +import Card from '@mui/material/Card'; +import Dialog from '@mui/material/Dialog'; +import TextField from '@mui/material/TextField'; +import Button from '@mui/material/Button'; +import CodeIcon from '@mui/icons-material/Code'; +import ImageIcon from '@mui/icons-material/Image'; +import chroma from 'chroma-js'; +import gradientsET from '../data/gradients-eT.json'; +import gradientsTT from '../data/gradients-tT.json'; +import gradientsNT from '../data/gradients-nT.json'; +import { copyText, copyImage, hideMainWindow } from '../utils/platform'; + +/** + * GradientsPage - 渐变色页面 + * + * 核心功能: + * - 展示渐变色库(按色系分类和筛选) + * - 渐变色详情弹窗(角度/尺寸调整、CSS代码复制、导出图片) + * - 方向编辑器(可视化调整渐变角度) + */ + +/** 颜色筛选选项 */ +const filterColors = [ + { id: "red", color: "#D7003A" }, + { id: "orange", color: "#EE7800" }, + { id: "yellow", color: "#FFD900" }, + { id: "green", color: "#3EB370" }, + { id: "cyan", color: "#0095D9" }, + { id: "blue", color: "#165E83" }, + { id: "magenta", color: "#884898" }, + { id: "white", color: "#eaeaea" }, + { id: "gray", color: "#c0c0cb" }, + { id: "black", color: "#333333" }, +]; + +/** 根据颜色 HSL 值分类到对应色系 */ +function classifyColor(color: string): string { + const [h, s, l] = chroma(color).hsl(); + const hue = isNaN(h) ? 0 : h; + if (l < 0.2) return "black"; + if (l > 0.85) return "white"; + if (s < 0.2) return "gray"; + if (hue < 26) return "red"; + if (hue < 50) return "orange"; + if (hue < 70) return "yellow"; + if (hue < 165) return "green"; + if (hue < 190) return "cyan"; + if (hue < 265) return "blue"; + if (hue < 320) return "magenta"; + return "red"; +} + +/** 渐变数据项 */ +interface GradientItem { colors: string[]; tags: string[] }; + +/** 懒加载并缓存所有渐变色数据,合并三个数据源并自动分类标签 */ +let allGradients: GradientItem[] | null = null; + +function getGradients(): GradientItem[] { + if (allGradients) return allGradients; + allGradients = []; + [gradientsET, gradientsTT, gradientsNT].forEach(group => { + (group as string[][]).forEach(colors => { + allGradients!.push({ + colors, + tags: colors.map(c => classifyColor(c)), + }); + }); + }); + return allGradients; +} + +/** 计算渐变线的起止坐标(用于 Canvas 渲染) */ +function calculateGradientLine(width: number, height: number, angle: number) { + const rad = angle * Math.PI / 180 + Math.PI / 2; + const len = Math.sqrt(width / 2 * width / 2 + height / 2 * height / 2); + const tx = Math.cos(rad) * len + width / 2; + const ty = Math.sin(rad) * len + height / 2; + return { tx, ty, bx: width / 2, by: height / 2 }; +} + +/** 默认渐变角度和尺寸 */ +const DEFAULT_ANGLE = 135; +const DEFAULT_SIZE = 500; + +/** 方向编辑器 - 可视化调整渐变角度的圆形控件 */ +interface DirectionEditorProps { + angle: number; + onChange: (angle: number) => void; +} + +class DirectionEditor extends PureComponent { + private directionEditorRef: HTMLDivElement | null = null; + private _centerPoint = { x: 0, y: 0 }; + + getPointByAngle(angle: number) { + const rad = 2 * Math.PI / 360 * angle; + const r = 30; + const x = 50 + Math.sin(rad) * r; + const y = 50 - Math.cos(rad) * r; + return { x, y }; + } + + handleKnobMouseDown = () => { + if (!this.directionEditorRef) return; + const rect = this.directionEditorRef.getBoundingClientRect(); + this._centerPoint = { + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, + }; + window.addEventListener("mousemove", this.handleKnobMouseMove); + window.addEventListener("mouseup", this.handleKnobMouseUp); + }; + + handleKnobMouseMove = (e: MouseEvent) => { + const dx = e.clientX - this._centerPoint.x; + const dy = this._centerPoint.y - e.clientY; + let angle = 90 - Math.atan2(dy, dx) * 180 / Math.PI; + if (angle < 0) angle = 360 + angle; + this.props.onChange(Math.round(angle)); + }; + + handleKnobMouseUp = () => { + window.removeEventListener("mousemove", this.handleKnobMouseMove); + window.removeEventListener("mouseup", this.handleKnobMouseUp); + }; + + render() { + const { angle } = this.props; + const point = this.getPointByAngle(angle); + + return ( +
        { this.directionEditorRef = el; }} + className="direction-editor" + > + + + +
        + + + +
        +
        +
        + ); + } +} + +/** 渐变色详情弹窗 - 角度/尺寸调整、CSS代码复制、导出图片 */ +interface GradientDialogProps { + gradientData: string[] | null; +} + +interface GradientDialogState { + open: boolean; + angle: number; + imageWidth: string; + imageHeight: string; +} + +class GradientDialog extends Component { + state: GradientDialogState = { + open: false, + angle: DEFAULT_ANGLE, + imageWidth: '1000', + imageHeight: '1000', + }; + + handleClose = () => { + this.setState({ open: false }); + }; + + handleDirectionChange = (angle: number) => { + this.setState({ angle }); + }; + + handleImageWidthChange = (e: React.ChangeEvent) => { + const v = e.target.value; + if (/^\d*$/.test(v)) { + this.setState({ imageWidth: v }); + } + }; + + handleImageHeightChange = (e: React.ChangeEvent) => { + const v = e.target.value; + if (/^\d*$/.test(v)) { + this.setState({ imageHeight: v }); + } + }; + + handleCssCodeCopy = () => { + const cssCode = `linear-gradient(${this.state.angle}deg,${this.props.gradientData!.join(",").toLowerCase()})`; + copyText(cssCode); + hideMainWindow(); + }; + + handleImageExport = () => { + const { angle, imageWidth, imageHeight } = this.state; + const w = parseInt(imageWidth); + const h = parseInt(imageHeight); + if (w < 1 || h < 1) return; + + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + canvas.style.zIndex = "0"; + canvas.style.position = "fixed"; + document.body.appendChild(canvas); + + const ctx = canvas.getContext("2d")!; + const line = calculateGradientLine(w, h, angle); + const gradient = ctx.createLinearGradient(line.tx, line.ty, line.bx, line.by); + + const stops = [0]; + if (this.props.gradientData!.length > 2) { + const total = this.props.gradientData!.length - 1; + for (let i = 1; i < total; i++) { + stops.push(i / total); + } + } + stops.push(1); + + stops.forEach((stop, i) => { + gradient.addColorStop(stop, this.props.gradientData![i]); + }); + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, w, h); + copyImage(canvas.toDataURL()); + canvas.remove(); + hideMainWindow(); + }; + + componentDidUpdate(prev: GradientDialogProps) { + if (this.props.gradientData && prev.gradientData !== this.props.gradientData) { + this.setState({ open: true, angle: DEFAULT_ANGLE, imageWidth: '1000', imageHeight: '1000' }); + } + } + + render() { + const { gradientData } = this.props; + if (!gradientData) return false; + + const { open, angle, imageWidth, imageHeight } = this.state; + const gradientCSS = `linear-gradient(${angle}deg,${gradientData.join(",").toLowerCase()})`; + + return ( + +
        +
        +
        + +
        +
        +
        +
        + +
        +
        + +
        +
        +
        +
        +
        + +
        +
        + +
        +
        +
        + +
        +
        +
        +
        +
        + ); + } +} + +interface GradientsPageState { + gradients: GradientItem[]; + filterColor: string; + gradientData: string[] | null; +} + +class GradientsPage extends Component<{ onColorClick: (e: any) => void }, GradientsPageState> { + private gradientContentRef: HTMLDivElement | null = null; + + constructor(props: { onColorClick: (e: any) => void }) { + super(props); + this.state = { + gradients: getGradients(), + filterColor: "", + gradientData: null, + }; + } + + handleFilterColor = (id: string) => () => { + if (this.gradientContentRef && this.gradientContentRef.scrollTop > 0) { + this.gradientContentRef.scrollTop = 0; + } + if (id === this.state.filterColor) { + this.setState({ gradients: getGradients(), filterColor: "" }); + return; + } + const filtered = getGradients().filter(g => g.tags.includes(id)); + this.setState({ gradients: filtered, filterColor: id }); + }; + + handleGradientOpen = (gradient: GradientItem) => () => { + this.setState({ gradientData: [...gradient.colors] }); + }; + + render() { + const { gradients, filterColor, gradientData } = this.state; + + return ( +
        +
        + {filterColors.map(fc => ( +
        + ))} +
        +
        { this.gradientContentRef = el; }} + className="gradient-content" + > + {gradients.map((gradient, i) => ( + +
        +
        + {gradient.colors.map((color, j) => ( +
        + ))} +
        + + ))} +
        + +
        + ); + } +} + +export default GradientsPage; diff --git a/plugins/color-helper/pages/ImagePalettePage.tsx b/plugins/color-helper/pages/ImagePalettePage.tsx new file mode 100644 index 000000000..4f69b4548 --- /dev/null +++ b/plugins/color-helper/pages/ImagePalettePage.tsx @@ -0,0 +1,918 @@ +import React, { Component, createRef, useState, useMemo, useEffect, useRef, useCallback } from 'react'; +import Card from '@mui/material/Card'; +import Tooltip from '@mui/material/Tooltip'; +import Fab from '@mui/material/Fab'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import Stack from '@mui/material/Stack'; +import Dialog from '@mui/material/Dialog'; +import IconButton from '@mui/material/IconButton'; +import TextField from '@mui/material/TextField'; +import CircularProgress from '@mui/material/CircularProgress'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import CloseIcon from '@mui/icons-material/Close'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import ClearIcon from '@mui/icons-material/Clear'; +import FolderOpenIcon from '@mui/icons-material/FolderOpen'; +import CropIcon from '@mui/icons-material/Crop'; +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; +import chroma from 'chroma-js'; +import { quantize } from '../utils/quantize'; +import { isDarkColor, textColorFor, dimColor } from '../utils/color'; +import { screenCapture, copyText, hideMainWindow, aiChat, isAIAvailable } from '../utils/platform'; + +// 导入模板封面图 +import cover01 from '../assets/color-card-01.jpg'; +import cover02 from '../assets/color-card-02.jpg'; +import cover03 from '../assets/color-card-03.jpg'; +import cover04 from '../assets/color-card-04.jpg'; + +/** + * ImagePalettePage - 图片取色页面 + * + * 核心功能: + * - 从图片中提取主色和配色方案(基于 Median Cut 量化算法) + * - 生成 AI 色卡(多种模板、AI 命名、导出图片) + * - 支持文件选择和屏幕截图两种图片来源 + */ + +/** RGB 数组 → HEX 字符串 */ +function rgbToHex(rgb: number[]): string { + return '#' + rgb.map(v => { + const hex = v.toString(16); + return hex.length === 1 ? '0' + hex : hex; + }).join(''); +} + +/** 创建 canvas 获取图片像素数据 */ +function getImageData(img: HTMLImageElement): ImageData { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + return ctx.getImageData(0, 0, canvas.width, canvas.height); +} + +/** + * 像素过滤函数 + * 过滤规则: alpha < 125 的跳过(透明像素), RGB 均 > 250 的跳过(接近白色) + * @param quality 采样间隔,值越大采样越稀疏 + */ +function filterPixels(data: Uint8ClampedArray, totalPixels: number, quality: number): number[][] { + const pixels: number[][] = []; + for (let i = 0; i < totalPixels; i += quality) { + const offset = 4 * i; + const r = data[offset]; + const g = data[offset + 1]; + const b = data[offset + 2]; + const a = data[offset + 3]; + // alpha 检查: undefined 或 >= 125 + if (a !== undefined && a < 125) continue; + // 跳过接近白色的像素 + if (r > 250 && g > 250 && b > 250) continue; + pixels.push([r, g, b]); + } + return pixels; +} + +/** 提取图片主色(取配色方案的第一项) */ +function getColor(img: HTMLImageElement, quality: number = 10): number[] | null { + const palette = getPalette(img, 5, quality); + return palette ? palette[0] : null; +} + +/** + * 提取图片配色方案 + * @param colorCount 颜色数量(2-20),默认10 + * @param quality 采样间隔,默认10 + */ +function getPalette(img: HTMLImageElement, colorCount?: number, quality?: number): number[][] | null { + // 参数校验 - + let count = colorCount; + let q = quality; + if (count !== undefined && Number.isInteger(count)) { + if (count === 1) throw new Error('colorCount should be between 2 and 20'); + count = Math.max(count, 2); + count = Math.min(count, 20); + } else { + count = 10; + } + if (q === undefined || !Number.isInteger(q) || q < 1) { + q = 10; + } + + const imageData = getImageData(img); + const pixels = filterPixels(imageData.data, imageData.width * imageData.height, q); + const cmap = quantize(pixels, count); + return cmap && cmap !== false ? cmap.palette() : null; +} + +/** + * 从图片中提取主色和配色 + * 主色: getColor 提取5色中的第一个 + * 配色: getPalette 提取10色,排除主色后的列表 + */ +// 原始: const o = new Uu; s = a4(o.getColor(n.target)); c = o.getPalette(n.target).map(u => a4(u)) +function extractColorsFromImage(img: HTMLImageElement): { mainColor: string; paletteColors: string[] } | null { + try { + const mainRgb = getColor(img, 10); // getColor(target, 10) → getPalette(target, 5, 10)[0] + const paletteRgbs = getPalette(img); // getPalette(target) → colorCount=10, quality=10 + + if (!mainRgb) return null; + + const mainColor = rgbToHex(mainRgb); + const paletteColors = paletteRgbs + ? paletteRgbs.map(rgb => rgbToHex(rgb)).filter(c => c !== mainColor) + : []; + + return { mainColor, paletteColors }; + } catch { + return null; + } +} + +/** + * AI 色卡名称生成 Hook + * 调用 AI 根据颜色值生成文艺名称,支持缓存和自动生成 + * @param color 颜色 HEX 值 + * @param style 命名风格,默认"文艺优雅" + * @param nameLength 名称字数,默认4 + * @param autoGenerate 是否在挂载时自动生成 + */ +/** AI 名称缓存,避免重复调用 */ +const nameCache = new Map(); + +function useGenerateName(color: string, style: string = '文艺优雅', nameLength: number = 4, autoGenerate: boolean = false) { + const [name, setName] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const cacheKey = useMemo(() => color + style + nameLength, [color, style, nameLength]); + const isMounted = useRef(true); + const latestCacheKey = useRef(cacheKey); + + useEffect(() => { + isMounted.current = true; + latestCacheKey.current = cacheKey; + return () => { + isMounted.current = false; + }; + }, [cacheKey]); + + const generateName = useCallback((targetCacheKey: string, excludeName?: string) => { + const ai = isAIAvailable(); + if (!ai) { + setError('当前版本不支持 AI 功能'); + return null; + } + + const systemPrompt = ` +# Role +你是一个颜色命名大师 +## Skills +- 读取用户输入的颜色值 +- 给读取的颜色起一个好听的名字,名字风格${style} +## Actions +- 根据用户输入的颜色起一个名字 +- 你直接输出名字,不再额外输出其他内容 +- 名字必须是${nameLength}个汉字组成 +${excludeName ? `- 名字不能是${excludeName}` : ''} +## Input +输入:{颜色值} +## 输出 +{名字} +`; + + setLoading(true); + setName(''); + setError(''); + + const promise = aiChat([ + { role: 'system', content: systemPrompt }, + { role: 'user', content: `输入: ${color}` }, + ]); + + promise.then(result => { + if (!isMounted.current || targetCacheKey !== latestCacheKey.current) return; + setLoading(false); + setName(result.content); + nameCache.set(targetCacheKey, result.content); + }).catch(() => { + if (!isMounted.current || targetCacheKey !== latestCacheKey.current) return; + setLoading(false); + setError('AI 调用异常'); + }); + + return promise; + }, [style, nameLength, color]); + + // 自动生成 + useEffect(() => { + if (!autoGenerate || !color) return; + + const key = color + style + nameLength; + if (nameCache.has(key)) { + setName(nameCache.get(key)!); + return; + } + + generateName(key); + }, [color, style, nameLength, generateName, autoGenerate]); + + return [name, { loading, error, generateName, cacheKey }] as const; +} + +/** 画面场景选项(用于 AI 色卡描述) */ +const SCENE_OPTIONS = ["明星影视", "游戏动漫", "生活家居", "文旅出行", "时尚美妆", "体育赛事", "科技数码", "汽车交通", "艺术设计"]; + +/** 画面场景选择器 - 下拉菜单选择场景标签 */ +interface SceneSelectorProps { + data: string[]; + value: string | null; + disabled?: boolean; + onChange: (value: string | null) => void; +} + +function SceneSelector({ data, value, disabled, onChange }: SceneSelectorProps) { + const [anchorEl, setAnchorEl] = useState(null); + + if (value) { + return ( + + {value} + { if (!disabled) onChange(null); }} /> + + ); + } + + return ( + <> + { if (!disabled) setAnchorEl(e.currentTarget); }}> + + 画面 + + setAnchorEl(null)} + MenuListProps={{ dense: true, disablePadding: true }}> + {data.map(item => ( + { onChange(item); setAnchorEl(null); }}> + {item} + + ))} + + + ); +} + +/** 色卡模板定义: id/尺寸/封面图/名称字数 */ +interface TemplateDef { + id: string; + sizes: number | [number, number]; + cover: string; + nameLength: number; +} + +const TEMPLATES: TemplateDef[] = [ + { id: '01', sizes: 1200, cover: cover01, nameLength: 4 }, + { id: '02', sizes: 1200, cover: cover02, nameLength: 4 }, + { id: '03', sizes: [1200 * 3 / 4, 1200], cover: cover03, nameLength: 4 }, + { id: '04', sizes: [1800 * 9 / 16, 1800], cover: cover04, nameLength: 4 }, +]; + +/** 获取模板的画布尺寸 [宽, 高] */ +function getTemplateSize(template: TemplateDef): [number, number] { + if (Array.isArray(template.sizes)) { + const [w = 1200, h = w] = template.sizes; + return [w, h]; + } + return [template.sizes, template.sizes]; +} + +/** + * 色卡 Canvas 渲染 + * 根据模板类型在 Canvas 上绘制色卡: 背景色/图片 + 名称 + 配色色块 + * 支持4种模板: 01(渐变遮罩)/02(模糊大背景)/03(cover填充)/04(底部横条) + */ +function renderColorCard( + canvas: HTMLCanvasElement, + options: { + bgImage: HTMLImageElement | null; + primaryColor: string; + paletteColors: string[]; + name: string; + templateId: string; + } +) { + const { bgImage, primaryColor, paletteColors, name, templateId } = options; + const template = TEMPLATES.find(t => t.id === templateId) || TEMPLATES[0]; + const [w, h] = getTemplateSize(template); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d')!; + const dark = isDarkColor(primaryColor); + const textCol = dark ? '#fff' : '#000'; + const dimCol = dimColor(primaryColor, 0.3); + const semiCol = dimColor(primaryColor, 0.6); + + // 通用渲染:背景色填充 + ctx.fillStyle = primaryColor; + ctx.fillRect(0, 0, w, h); + + // 背景图(如果有) + if (bgImage) { + const coverFit = (cw: number, ch: number) => { + const scale = Math.max(cw / bgImage.width, ch / bgImage.height) * 1.8; + const dw = bgImage.width * scale; + const dh = bgImage.height * scale; + return { dx: (cw - dw) / 2, dy: 0, dw, dh }; + }; + + if (templateId === '01') { + // 01: 图片居中上半部分,下半渐变遮罩 + const scale = Math.min(w / bgImage.width, h / bgImage.height); + const dw = bgImage.width * scale; + const dh = bgImage.height * scale; + ctx.drawImage(bgImage, (w - dw) / 2, 0, dw, dh); + + // 下半渐变遮罩 + const grad = ctx.createLinearGradient(0, h * 0.5, 0, h * 0.65); + grad.addColorStop(0, chroma(primaryColor).alpha(0.01).hex()); + grad.addColorStop(0.1, chroma(primaryColor).alpha(0.5).hex()); + grad.addColorStop(0.25, primaryColor); + grad.addColorStop(1, primaryColor); + ctx.fillStyle = grad; + ctx.fillRect(0, h * 0.5, w, h * 0.5); + } else if (templateId === '02') { + // 002: 图片模糊大背景 + 居中裁剪 + const { dx, dy, dw, dh } = coverFit(w, h); + ctx.globalAlpha = 0.3; + ctx.filter = 'blur(40px)'; + ctx.drawImage(bgImage, dx, dy, dw, dh); + ctx.filter = 'none'; + ctx.globalAlpha = 1; + + // 居中裁剪图 + const scale2 = Math.min(w * 0.95 / bgImage.width, h * 0.95 / bgImage.height); + const dw2 = bgImage.width * scale2; + const dh2 = bgImage.height * scale2; + ctx.drawImage(bgImage, (w - dw2) / 2, (h - dh2) / 2, dw2, dh2); + } else if (templateId === '03') { + // 003: 图片 cover 填充 + const scale = Math.max(w / bgImage.width, h / bgImage.height); + const dw = bgImage.width * scale; + const dh = bgImage.height * scale; + ctx.drawImage(bgImage, (w - dw) / 2, (h - dh) / 2, dw, dh); + } else if (templateId === '04') { + // 004: 图片 cover 填充 + const scale = Math.max(w / bgImage.width, h / bgImage.height); + const dw = bgImage.width * scale; + const dh = bgImage.height * scale; + ctx.drawImage(bgImage, (w - dw) / 2, (h - dh) / 2, dw, dh); + } + } + + // 文字和配色色块 + if (templateId === '01') { + // 01 模板:下半部分文字 + 配色 + const baseY = h * 2.1 / 3; + ctx.fillStyle = semiCol; + ctx.font = `bold ${w / 9}px serif`; + ctx.textAlign = 'left'; + ctx.fillText(name || '配色方案', w / 20, baseY); + + // 主色圆 + 色值 + const circleR = w / 50; + const circleY = baseY + h / 6; + ctx.beginPath(); + ctx.arc(w / 20 + circleR, circleY, circleR, 0, Math.PI * 2); + ctx.fillStyle = semiCol; + ctx.fill(); + ctx.fillStyle = semiCol; + ctx.font = `bold ${w / 30}px sans-serif`; + ctx.textAlign = 'left'; + ctx.fillText(primaryColor, w / 20 + circleR * 2 + w / 30, circleY + w / 80); + + // 配色色块 + const colors = paletteColors.slice(0, 4); + const blockSize = w / 7; + const startX = w / 2; + colors.forEach((color, i) => { + const y = circleY + w / 60 + i * (blockSize + w / 60); + ctx.fillStyle = color; + ctx.beginPath(); + ctx.roundRect(startX, y, blockSize, blockSize, 8); + ctx.fill(); + ctx.fillStyle = textColorFor(color); + ctx.font = `${w / 38}px sans-serif`; + ctx.textAlign = 'center'; + ctx.fillText(color, startX + blockSize / 2, y + blockSize / 2 + w / 76); + }); + + // 签名 + // ctx.fillStyle = dimCol; + // ctx.font = `${w / 32}px sans-serif`; + // ctx.textAlign = 'left'; + // ctx.fillText('色卡', w / 20, baseY + h / 6 + w / 20); + } else if (templateId === '02') { + // 002 模板:底部配色横排 + const allColors = [primaryColor, ...paletteColors.slice(0, 5)]; + const blockSize = w / 6; + const startX = w / 20; + const startY = h - h / 4; + + allColors.forEach((color, i) => { + const x = startX + i * (blockSize + w / 60); + ctx.fillStyle = color; + ctx.beginPath(); + ctx.roundRect(x, startY, blockSize, blockSize, 8); + ctx.fill(); + ctx.fillStyle = textColorFor(color); + ctx.font = `${w / 38}px sans-serif`; + ctx.textAlign = 'center'; + ctx.fillText(color, x + blockSize / 2, startY + blockSize / 2 + w / 76); + }); + + // 签名 + ctx.fillStyle = dimColor(primaryColor, 0.6); + ctx.font = `${w / 35}px sans-serif`; + ctx.textAlign = 'left'; + ctx.fillText(name || '配色方案', startX, startY + blockSize + w / 20); + } else if (templateId === '03') { + // 003 模板:左上圆环 + 右下标题 + const circleR = w / 12; + const allColors = [primaryColor, ...paletteColors.slice(0, 2)]; + allColors.forEach((color, i) => { + const y = w / 18 + i * (circleR * 2 + w / 20); + ctx.beginPath(); + ctx.arc(w / 20 + circleR, y + circleR, circleR, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.strokeStyle = textCol; + ctx.lineWidth = w / 250; + ctx.stroke(); + ctx.fillStyle = textColorFor(color); + ctx.font = `${w / 38}px sans-serif`; + ctx.textAlign = 'center'; + ctx.fillText(color, w / 20 + circleR, y + circleR + w / 76); + }); + + // 标题 + ctx.fillStyle = textCol; + ctx.font = `bold ${w / 10}px serif`; + ctx.textAlign = 'left'; + ctx.fillText(name || '配色方案', w / 20, h - h / 4.8); + + // ctx.fillStyle = dimColor(primaryColor, 0.5); + // ctx.font = `${w / 32}px sans-serif`; + // ctx.fillText('色卡', w / 20, h - h / 4.8 + w / 7.5); + } else if (templateId === '04') { + // 004 模板:底部横条配色 + const barH = h * 0.18; + const barY = h - barH - h * 0.05; + const barX = w * 0.1; + const barW = w * 0.8; + const allColors = [primaryColor, ...paletteColors.slice(0, 5)]; + const colW = barW / allColors.length; + + allColors.forEach((color, i) => { + ctx.fillStyle = color; + ctx.beginPath(); + ctx.roundRect(barX + i * colW + 2, barY, colW - 4, barH, 8); + ctx.fill(); + ctx.fillStyle = textColorFor(color); + ctx.font = `${w / 30}px sans-serif`; + ctx.textAlign = 'center'; + ctx.fillText(color, barX + i * colW + colW / 2, barY + barH / 2 + w / 60); + }); + + // 标题 + ctx.fillStyle = textCol; + ctx.font = `bold ${w / 8}px serif`; + ctx.textAlign = 'left'; + ctx.fillText(name || '配色方案', w / 20, barY - h * 0.05); + } +} + +/** 色卡预览弹窗 - 模板切换/描述输入/导出图片 */ +interface ColorCardDialogProps { + open: boolean; + onClose: () => void; + bgImage: string; + primaryColor: string; + paletteColors: string[]; + showMessage: (msg: string) => void; +} + +function ColorCardDialog({ open, onClose, bgImage, primaryColor, paletteColors, showMessage }: ColorCardDialogProps) { + const [previewUrl, setPreviewUrl] = useState(''); + const [generating, setGenerating] = useState(false); + const [description, setDescription] = useState(''); + const [descriptionDraft, setDescriptionDraft] = useState(''); + const [scene, setScene] = useState(null); + const [templateId, setTemplateId] = useState('01'); + const [rerender, setRerender] = useState(false); + const canvasRef = useRef(null); + const bgImgRef = useRef(null); + + // AI 名称生成 + const [aiName, { loading: aiLoading, error: aiError, generateName }] = useGenerateName( + primaryColor, '文艺优雅', 4, open + ); + + // 色卡名称: AI 生成的名称优先,用户可手动修改 + const cardName = description || aiName || '配色方案'; + + // 预加载背景图 + useEffect(() => { + if (bgImage) { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { bgImgRef.current = img; }; + img.src = bgImage; + } + }, [bgImage]); + + // 生成色卡预览 + const generatePreview = useCallback(() => { + setGenerating(true); + setTimeout(() => { + const canvas = canvasRef.current; + if (!canvas) { setGenerating(false); return; } + + renderColorCard(canvas, { + bgImage: bgImgRef.current, + primaryColor, + paletteColors, + name: cardName, + templateId, + }); + + setPreviewUrl(canvas.toDataURL('image/png')); + setGenerating(false); + }, 500); + }, [primaryColor, paletteColors, cardName, templateId]); + + // 打开时自动生成 + useEffect(() => { + if (open) { + generatePreview(); + } else { + setPreviewUrl(''); + setTemplateId('01'); + setDescription(''); + setDescriptionDraft(''); + setScene(null); + } + }, [open]); + + // 模板/AI名称变化时重新生成 + useEffect(() => { + if (open) { + generatePreview(); + } + }, [templateId, aiName]); + + // 用户手动修改描述时重新生成 + useEffect(() => { + if (open && description !== undefined) { + generatePreview(); + } + }, [description]); + + // 切换模板 + const handleTemplateChange = (id: string) => { + if (id !== templateId) { + setTemplateId(id); + setRerender(true); + } + }; + + // 导出色卡 + const handleExport = () => { + const canvas = canvasRef.current; + if (!canvas) return; + const dataUrl = canvas.toDataURL('image/png'); + if (window.services?.saveColorCard) { + const buffer = Uint8Array.from(atob(dataUrl.split(',')[1]), c => c.charCodeAt(0)); + window.services.saveColorCard(Array.from(buffer)); + } else { + const a = document.createElement('a'); + a.href = dataUrl; + a.download = 'color-card.png'; + a.click(); + } + }; + + // 预览图尺寸计算 + const previewSize = useMemo((): [string, string] => { + const template = TEMPLATES.find(t => t.id === templateId) || TEMPLATES[0]; + const [tw, th] = getTemplateSize(template); + if (tw === th) return ['50vw', '50vw']; + const ratio = tw / th; + if (ratio > 1) return ['50vw', `${50 / ratio}vw`]; + return [`${50 * ratio}vw`, '50vw']; + }, [templateId]); + + return ( + { + if (reason === 'escapeKeyDown') onClose(); + }}> +
        + {/* 关闭按钮 */} + + + + + {/* 中间区域:色卡预览 + 描述输入 */} + + {/* 色卡预览图 */} + + {previewUrl && !generating ? ( + preview + ) : ( + + + + )} + + + {/* 描述输入 + 画面选择 + 重新生成 */} + + setDescriptionDraft(e.target.value)} + onBlur={() => { if (descriptionDraft !== description) setDescription(descriptionDraft); }} + variant="standard" + fullWidth + sx={{ '& .MuiInput-root': { fontSize: 14, paddingRight: 0 } }} + InputProps={{ + endAdornment: descriptionDraft ? ( + { setDescription(''); setDescriptionDraft(''); }} sx={{ p: 0.3 }}> + + + ) : null, + }} + /> + + + + { + // 重新调用 AI 生成名称(排除当前名称) + const key = primaryColor + '文艺优雅' + 4; + generateName(key, aiName || undefined); + generatePreview(); + }} + > + + + + + + + + {/* 右侧面板:模板选择 + 导出按钮 */} + + 模板 + + {TEMPLATES.map(t => ( + handleTemplateChange(t.id)} + sx={{ cursor: 'pointer' }} + > + cover + + + ))} + + + + + +
        + + {/* 隐藏的 Canvas 用于渲染色卡 */} + +
        + ); +} + +/** 生成 AI 色卡按钮 + 弹窗入口 */ +interface ColorCardButtonProps { + bgImage: string; + primaryColor: string; + paletteColors: string[]; + onColorClick: (e: any) => void; + showMessage: (msg: string) => void; +} + +function ColorCardButton({ bgImage, primaryColor, paletteColors, onColorClick, showMessage }: ColorCardButtonProps) { + const [dialogOpen, setDialogOpen] = useState(false); + const [generating, setGenerating] = useState(false); + + return ( + <> + + + + { setDialogOpen(false); setGenerating(false); }} + bgImage={bgImage} + primaryColor={primaryColor} + paletteColors={paletteColors} + showMessage={showMessage} + /> + + ); +} + +// 图片取色主页面 +interface ImagePaletteState { + imageUrl: string | null; + primaryColor: string | null; + paletteColors: string[] | null; +} + +class ImagePalettePage extends Component<{ onColorClick: (e: any) => void; showMessage?: (msg: string) => void }, ImagePaletteState> { + private fileInputRef = createRef(); + + state: ImagePaletteState = { + imageUrl: null, + primaryColor: null, + paletteColors: null, + }; + + handleImgLoad = (e: React.SyntheticEvent) => { + try { + const img = e.currentTarget; + const result = extractColorsFromImage(img); + if (result) { + this.setState({ primaryColor: result.mainColor, paletteColors: result.paletteColors }); + } + } catch { /* ignore */ } + }; + + handleDialogSelectImage = () => { + this.fileInputRef.current?.click(); + }; + + handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (ev) => { + const url = ev.target?.result as string; + this.setState({ imageUrl: url, primaryColor: null, paletteColors: null }); + }; + reader.readAsDataURL(file); + } + }; + + handleScreenCapture = () => { + screenCapture((imagePath) => { + this.setState({ imageUrl: imagePath, primaryColor: null, paletteColors: null }); + }); + }; + + render() { + const { imageUrl, primaryColor, paletteColors } = this.state; + + return ( +
        + {imageUrl ? ( +
        +
        + +
        + {primaryColor && ( +
        + + +
        +
        主色
        +
        +
        +
        +
        +
        +
        配色
        +
        + {paletteColors?.slice(0, 14).map((color, i) => ( +
        + ))} +
        +
        + c !== primaryColor) || []} + onColorClick={this.props.onColorClick} + showMessage={this.props.showMessage || (() => {})} + /> + + +
        + )} +
        + ) : ( +
        左下角选择图片或屏幕截图
        + )} +
        + + + + + + + + + + +
        + +
        + ); + } +} + +export default ImagePalettePage; diff --git a/plugins/color-helper/pages/TraditionalColorsPage.tsx b/plugins/color-helper/pages/TraditionalColorsPage.tsx new file mode 100644 index 000000000..bbfb3ea6f --- /dev/null +++ b/plugins/color-helper/pages/TraditionalColorsPage.tsx @@ -0,0 +1,346 @@ +import React, { Component } from 'react'; +import Tabs from '@mui/material/Tabs'; +import Tab from '@mui/material/Tab'; +import TextField from '@mui/material/TextField'; +import InputAdornment from '@mui/material/InputAdornment'; +import IconButton from '@mui/material/IconButton'; +import CloseIcon from '@mui/icons-material/Close'; +import Typography from '@mui/material/Typography'; +import CheckIcon from '@mui/icons-material/Check'; +import chroma from 'chroma-js'; +import chinaColors from '../data/traditional-china.json'; +import japanColors from '../data/traditional-japan.json'; + +/** + * TraditionalColorsPage - 传统色页面 + * + * 展示中国传统色/日本传统色色板,支持搜索和收藏 + * 点击色块可复制颜色值,长按可收藏/取消收藏 + */ + +// 颜色属性分类(tag + dark) +function getColorAttr(color: string): { tag: string; dark: boolean } { + const c = chroma(color); + let [h, s, l] = c.hsl(); + const dark = c.get('lab.l') < 70; + h = isNaN(h) ? 0 : h; + let tag: string; + if (l < 0.2) tag = "black"; + else if (l > 0.85) tag = "white"; + else if (s < 0.2) tag = "gray"; + else if (h < 26) tag = "red"; + else if (h < 50) tag = "orange"; + else if (h < 70) tag = "yellow"; + else if (h < 165) tag = "green"; + else if (h < 190) tag = "cyan"; + else if (h < 265) tag = "blue"; + else if (h < 320) tag = "magenta"; + else tag = "red"; + return { tag, dark }; +} + +// AD数组 +const filterColors = [ + { id: "red", color: "#D7003A" }, + { id: "orange", color: "#EE7800" }, + { id: "yellow", color: "#FFD900" }, + { id: "green", color: "#3EB370" }, + { id: "cyan", color: "#0095D9" }, + { id: "blue", color: "#165E83" }, + { id: "magenta", color: "#884898" }, + { id: "white", color: "#eaeaea" }, + { id: "gray", color: "#c0c0cb" }, + { id: "black", color: "#333333" }, +]; + +// 预处理颜色数据 +// 中国传统色数据: nc = JSON.parse(...) - 按节气分组 +interface ChinaColorItem { + name: string; + color: string; + attr?: { tag: string; dark: boolean }; +} + +interface ChinaColorGroup { + title: string; + colors: ChinaColorItem[]; +} + +// 日本传统色数据: tc = JSON.parse(...) - 扁平列表 +interface JapanColorItem { + name: string; + jname: string; + color: string; + attr?: { tag: string; dark: boolean }; +} + +const ncData: ChinaColorGroup[] = (chinaColors as any[]).map((group: any) => ({ + title: group.title, + colors: group.colors.map((c: any) => ({ + name: c.name, + color: c.color, + })), +})); + +const tcData: JapanColorItem[] = (japanColors as any[]).map((c: any) => ({ + name: c.name, + jname: c.jname || c.romaji || "", + color: c.color, +})); + +// 中国传统色 +interface ChinaColorsProps { + onColorClick: (e: any) => void; + searchName: string; + filterColor: string; +} + +interface ChinaColorsState { + colors: ChinaColorGroup[]; +} + +class ChinaColors extends Component { + constructor(props: ChinaColorsProps) { + super(props); + // 初始化attr=> { o.colors.forEach(s => { s.attr = o8(s.color) }) })" + if (!ncData[0]?.colors[0]?.attr) { + ncData.forEach(group => { + group.colors.forEach(c => { + c.attr = getColorAttr(c.color); + }); + }); + } + this.state = { colors: ncData }; + } + + filterBySearchName = () => { + const result: ChinaColorGroup[] = []; + ncData.forEach(group => { + const filtered = group.colors.filter(c => c.name.indexOf(this.props.searchName) !== -1); + if (filtered.length > 0) { + result.push({ title: group.title, colors: filtered }); + } + }); + this.setState({ colors: result }); + }; + + filterByFilterColor = () => { + const result: ChinaColorGroup[] = []; + ncData.forEach(group => { + const filtered = group.colors.filter(c => c.attr!.tag === this.props.filterColor); + if (filtered.length > 0) { + result.push({ title: group.title, colors: filtered }); + } + }); + this.setState({ colors: result }); + }; + + componentDidMount() { + if (this.props.searchName) return this.filterBySearchName(); + if (this.props.filterColor) this.filterByFilterColor(); + } + + componentDidUpdate(prev: ChinaColorsProps) { + if (prev.searchName !== this.props.searchName) { + if (this.props.searchName) return this.filterBySearchName(); + if (prev.filterColor === this.props.filterColor) return this.setState({ colors: ncData }); + } + if (prev.filterColor !== this.props.filterColor) { + if (this.props.filterColor) this.filterByFilterColor(); + else this.setState({ colors: ncData }); + } + } + + render() { + const { colors } = this.state; + return ( +
        + {colors.map((group, gi) => ( +
        +

        {group.title}

        +
        + {group.colors.map((c, ci) => ( +
        +
        {c.name}
        +
        {c.color}
        +
        + ))} +
        +
        + ))} +
        + ); + } +} + +// 日本传统色 +interface JapanColorsProps { + onColorClick: (e: any) => void; + searchName: string; + filterColor: string; +} + +interface JapanColorsState { + colors: JapanColorItem[]; +} + +class JapanColors extends Component { + constructor(props: JapanColorsProps) { + super(props); + // 初始化attr=> { o.attr = o8(o.color) })" + if (!tcData[0]?.attr) { + tcData.forEach(c => { + c.attr = getColorAttr(c.color); + }); + } + this.state = { colors: tcData }; + } + + filterBySearchName = () => { + const result = tcData.filter(c => c.name.indexOf(this.props.searchName) !== -1 || c.jname.indexOf(this.props.searchName) !== -1); + this.setState({ colors: result }); + }; + + filterByFilterColor = () => { + const result = tcData.filter(c => c.attr!.tag === this.props.filterColor); + this.setState({ colors: result }); + }; + + componentDidMount() { + if (this.props.searchName) return this.filterBySearchName(); + if (this.props.filterColor) this.filterByFilterColor(); + } + + componentDidUpdate(prev: JapanColorsProps) { + if (prev.searchName !== this.props.searchName) { + if (this.props.searchName) return this.filterBySearchName(); + if (prev.filterColor === this.props.filterColor) return this.setState({ colors: tcData }); + } + if (prev.filterColor !== this.props.filterColor) { + if (this.props.filterColor) this.filterByFilterColor(); + else this.setState({ colors: tcData }); + } + } + + render() { + const { colors } = this.state; + return ( +
        + {colors.map((c, i) => ( +
        +
        {c.name}
        +
        {c.jname}
        +
        {c.color}
        +
        + ))} +
        + ); + } +} + +// 传统色主页面 +interface TraditionalPageState { + nav: string; + searchName: string; + filterColor: string; +} + +class TraditionalColorsPage extends Component<{ onColorClick: (e: any) => void }, TraditionalPageState> { + private contentRef: HTMLDivElement | null = null; + + constructor(props: { onColorClick: (e: any) => void }) { + super(props); + this.state = { + nav: "china", + searchName: "", + filterColor: "", + }; + } + + handleTabChange = (_e: any, value: string) => { + this.setState({ nav: value }); + if (this.contentRef && this.contentRef.scrollTop > 0) { + this.contentRef.scrollTop = 0; + } + }; + + handleSearchInputChange = (e: React.ChangeEvent) => { + if (this.contentRef && this.contentRef.scrollTop > 0) { + this.contentRef.scrollTop = 0; + } + this.setState({ searchName: e.target.value, filterColor: "" }); + }; + + handleFilterColor = (id: string) => () => { + if (this.contentRef && this.contentRef.scrollTop > 0) { + this.contentRef.scrollTop = 0; + } + if (id === this.state.filterColor) { + this.setState({ searchName: "", filterColor: "" }); + return; + } + this.setState({ searchName: "", filterColor: id }); + }; + + render() { + const { nav, searchName, filterColor } = this.state; + + return ( +
        +
        + + + + +
        +
        +
        + + this.setState({ searchName: "", filterColor: "" })}> + + + + ) : null, + }} + /> +
        +
        + {filterColors.map(fc => ( +
        + {filterColor === fc.id && } +
        + ))} +
        +
        +
        { this.contentRef = el; }} className="traditional-content"> + {nav === "china" && } + {nav === "japan" && } +
        +
        + ); + } +} + +export default TraditionalColorsPage; diff --git a/plugins/color-helper/pages/UIPalettesPage.tsx b/plugins/color-helper/pages/UIPalettesPage.tsx new file mode 100644 index 000000000..243b03274 --- /dev/null +++ b/plugins/color-helper/pages/UIPalettesPage.tsx @@ -0,0 +1,247 @@ +import React, { Component, PureComponent } from 'react'; +import Tabs from '@mui/material/Tabs'; +import Tab from '@mui/material/Tab'; +import { db } from '../utils/platform'; +import flatUIData from '../data/ui-flat-ui.json'; +import fluentData from '../data/ui-fluent.json'; +import openColorData from '../data/ui-open-color.json'; +import antDesignData from '../data/ui-ant-design.json'; +import materialDesignData from '../data/ui-material-design.json'; + +/** + * UIPalettesPage - UI色卡页面 + * + * 展示主流 UI 设计系统的色板: Flat UI / Fluent / Open Color / Ant Design / Material Design + * 点击色块可复制颜色值 + */ + +// FlatUI子组件 +class FlatUIColors extends PureComponent<{ onColorClick: (e: any) => void }> { + render() { + return ( +
        + {flatUIData.map((group, gi) => ( +
        +
        {group.title}
        +
        + {group.colors.map((pair, ci) => ( +
        + {pair.map((color, pi) => ( +
        + {color.substr(1)} +
        + ))} +
        + ))} +
        +
        + ))} +
        + ); + } +} + +// Fluent子组件 +class FluentColors extends PureComponent<{ onColorClick: (e: any) => void }> { + render() { + return ( +
        + {fluentData.map((group, gi) => ( +
        +
        {group.title}
        +
        + {group.colors.map((color, ci) => ( +
        +
        + {color.substr(1)} +
        +
        + ))} +
        +
        + ))} +
        + ); + } +} + +// OpenColor子组件 +class OpenColors extends PureComponent<{ onColorClick: (e: any) => void }> { + render() { + return ( +
        +
        +
        +
        + {[0,1,2,3,4,5,6,7,8,9].map(n =>
        {n}
        )} +
        +
        +
        + {openColorData.map((group, gi) => ( +
        +
        {group.title}
        +
        + {group.colors.map((color, ci) => ( +
        +
        + {color.substr(1)} +
        +
        + ))} +
        +
        + ))} +
        +
        + ); + } +} + +// AntDesign子组件 +class AntDesignColors extends PureComponent<{ onColorClick: (e: any) => void }> { + render() { + return ( +
        +
        +
        +
        + {[1,2,3,4,5,6,7,8,9,10].map(n =>
        {n}
        )} +
        +
        +
        + {antDesignData.map((group, gi) => ( +
        +
        {group.title}
        +
        + {group.colors.map((color, ci) => ( +
        +
        + {color.substr(1)} +
        +
        + ))} +
        +
        + ))} +
        +
        + ); + } +} + +// MaterialDesign子组件 +class MaterialDesignColors extends PureComponent<{ onColorClick: (e: any) => void }> { + render() { + return ( +
        +
        +
        +
        + {["50","100","200","300","400","500","600","700","800","900"].map(n => ( +
        {n}
        + ))} + {["A100","A200","A300","A400"].map(n => ( +
        {n}
        + ))} +
        +
        +
        + {materialDesignData.map((group, gi) => ( +
        +
        {group.title}
        +
        + {group.colors.map((color, ci) => ( +
        + {color ? ( +
        + {color.substr(1)} +
        + ) :
        } +
        + ))} +
        +
        + ))} +
        +
        + ); + } +} + +// 主组件 +interface UIPalettesState { + ui: string; +} + +class UIPalettesPage extends Component<{ onColorClick: (e: any) => void }, UIPalettesState> { + constructor(props: { onColorClick: (e: any) => void }) { + super(props); + + this.handleTabChange = this.handleTabChange.bind(this); + + const saved = db.get("uicolor"); + let ui = "flat"; + if (saved && ["flat","fluent","open","antd","material"].includes(saved.ui)) { + ui = saved.ui; + } + this.state = { ui }; + } + + handleTabChange(_e: any, value: string) { + this.setState({ ui: value }); + } + + getTabContent() { + switch (this.state.ui) { + case "flat": return ; + case "fluent": return ; + case "open": return ; + case "antd": return ; + case "material": return ; + default: return false; + } + } + + componentWillUnmount() { + const saved = db.get("uicolor") || { _id: "uicolor", ui: "flat" }; + if (saved.ui !== this.state.ui) { + saved.ui = this.state.ui; + db.put(saved); + } + } + + render() { + return ( +
        +
        + + + + + + + +
        +
        {this.getTabContent()}
        +
        + ); + } +} + +export default UIPalettesPage; diff --git a/plugins/color-helper/public/logo.png b/plugins/color-helper/public/logo.png new file mode 100644 index 000000000..07e66fc45 Binary files /dev/null and b/plugins/color-helper/public/logo.png differ diff --git a/plugins/color-helper/public/plugin.json b/plugins/color-helper/public/plugin.json new file mode 100644 index 000000000..98e319c44 --- /dev/null +++ b/plugins/color-helper/public/plugin.json @@ -0,0 +1,98 @@ +{ + "name": "color-helper", + "title": "颜色助手", + "pluginName": "颜色助手", + "description": "颜色、AI 配色、图片色卡、UI 色卡、传统色、渐变色、收藏颜色", + "version": "1.0.0", + "ai": true, + "main": "index.html", + "logo": "logo.png", + "preload": "preload.cjs", + "author": "mylesy", + "platform": [ + "darwin", + "win32", + "linux" + ], + "features": [ + { + "code": "color", + "explain": "颜色", + "cmds": [ + "Color", + "颜色", + { + "type": "regex", + "maxLength": 50, + "match": "/^(?:#[a-f0-9]{3}|#?[a-f0-9]{6}|#[a-f0-9]{8});?$|^(?:(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})(?:, *| +)){2}(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})$|^rgba?\\((?:(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})(?:, *| +)){2}(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})|^hs[liv]a?\\((?:360|(?:(?:3[0-5]\\d|[1-2]\\d{2}|\\d{1,2})(?:\\.\\d{1,15})?))(?:deg)?(?:, *| +)(?:100|\\d{1,2}(?:\\.\\d{1,15})?)%?(?:, *| +)(?:100|\\d{1,2}(?:\\.\\d{1,15})?)%?/i", + "label": "颜色" + } + ] + }, + { + "code": "ui", + "cmds": [ + "UI 色卡" + ] + }, + { + "code": "traditional", + "explain": "故宫二十四节气 • 384 种中国传统色、465 种日本传统色", + "cmds": [ + "传统色" + ] + }, + { + "code": "gradient", + "explain": "渐变色合集", + "cmds": [ + "渐变色" + ] + }, + { + "code": "image", + "explain": "图片颜色提取,可以生成色卡图片", + "cmds": [ + "图片色卡", + { + "type": "img", + "label": "图片色卡" + }, + { + "type": "files", + "fileType": "file", + "match": "/\\.(?:jpg|jpeg|png)$/i", + "maxLength": 1, + "label": "图片色卡" + } + ] + }, + { + "code": "collect", + "explain": "收藏颜色", + "cmds": [ + "收藏颜色" + ] + }, + { + "code": "pickercolor", + "explain": "屏幕取色", + "cmds": [ + "屏幕取色" + ] + }, + { + "code": "ai", + "explain": "AI 配色", + "cmds": [ + "AI 配色", + { + "type": "regex", + "maxLength": 50, + "match": "/^(?:#[a-f0-9]{3}|#?[a-f0-9]{6}|#[a-f0-9]{8});?$|^(?:(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})(?:, *| +)){2}(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})$|^rgba?\\((?:(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})(?:, *| +)){2}(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})|^hs[liv]a?\\((?:360|(?:(?:3[0-5]\\d|[1-2]\\d{2}|\\d{1,2})(?:\\.\\d{1,15})?))(?:deg)?(?:, *| +)(?:100|\\d{1,2}(?:\\.\\d{1,15})?)%?(?:, *| +)(?:100|\\d{1,2}(?:\\.\\d{1,15})?)%?/i", + "label": "AI 配色" + } + ] + } + ] +} \ No newline at end of file diff --git a/plugins/color-helper/public/preload.cjs b/plugins/color-helper/public/preload.cjs new file mode 100644 index 000000000..2c0309acd --- /dev/null +++ b/plugins/color-helper/public/preload.cjs @@ -0,0 +1,32 @@ +const { writeFile } = require('fs/promises'); +const { Buffer } = require('buffer'); +const path = require('path'); + +const platform = window.ztools; +window.platform = platform; + +window.services = { + async saveColorCard(bf) { + const defaultPath = path.resolve( + platform.getPath("downloads"), + "color-card-" + Date.now() + ); + const buffer = Buffer.from(bf); + const savePath = platform.showSaveDialog({ + title: "保存色卡", + defaultPath, + buttonLabel: "保存", + filters: [ + { + extensions: ["png"], + name: "色卡" + } + ] + }); + if (!savePath) { + return; + } + await writeFile(savePath, buffer); + platform.shellShowItemInFolder(savePath); + } +}; diff --git a/plugins/color-helper/tsconfig.json b/plugins/color-helper/tsconfig.json new file mode 100644 index 000000000..3934b8f6d --- /dev/null +++ b/plugins/color-helper/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/plugins/color-helper/tsconfig.node.json b/plugins/color-helper/tsconfig.node.json new file mode 100644 index 000000000..42872c59f --- /dev/null +++ b/plugins/color-helper/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/plugins/color-helper/types/index.ts b/plugins/color-helper/types/index.ts new file mode 100644 index 000000000..33188d500 --- /dev/null +++ b/plugins/color-helper/types/index.ts @@ -0,0 +1,100 @@ +// 颜色格式类型 +export type ColorFormat = 'hex' | 'rgb' | 'hsl' | 'hsv'; + +// 颜色对象接口 +export interface ColorObject { + hex: string; + rgb: { r: number; g: number; b: number }; + hsl: { h: number; s: number; l: number }; + hsv: { h: number; s: number; v: number }; +} + +// 收藏的颜色项 +export interface CollectedColor { + id: string; + name: string; + color: string; + createdAt: number; +} + +// 渐变色配置 +export interface GradientConfig { + colors: string[]; + angle: number; + type: 'linear' | 'radial'; +} + +// 渐变预设 +export interface GradientPreset { + name: string; + colors: string[]; + angle?: number; + category: string; +} + +// UI色卡分类 +export interface UIColorCategory { + name: string; + colors: string[]; +} + +// 传统色分类 +export interface TraditionalColorCategory { + name: string; + title: string; + colors: Array<{ + name: string; + color: string; + pinyin?: string; + }>; +} + +// 平台 API 类型声明 +declare global { + interface Window { + platform?: { + onPluginEnter: (callback: (action: { code: string; type: string; payload: any }) => void) => void; + onPluginOut: (callback: () => void) => void; + dbStorage: { + getItem: (key: string) => any; + setItem: (key: string, value: any) => void; + removeItem: (key: string) => void; + }; + copyText: (text: string) => void; + copyImage: (base64: string) => void; + showNotification: (title: string, body: string) => void; + setSubInput: (callback: (data: { text: string }) => void, placeholder: string, isFocus: boolean) => void; + removeSubInput: () => void; + hideMainWindow: () => void; + outPlugin: () => void; + showSaveDialog: (options: { + title: string; + defaultPath: string; + buttonLabel: string; + filters: Array<{ extensions: string[]; name: string }>; + }) => string | undefined; + showOpenDialog: (options: { + title?: string; + filters?: Array<{ name: string; extensions: string[] }>; + properties?: string[]; + }) => string[] | undefined; + shellShowItemInFolder: (path: string) => void; + getPath: (name: string) => string; + pickColor: () => string; + screenColorPick: (callback: (result: { hex: string }) => void) => void; + screenCapture: (callback: (data: string) => void) => void; + db: { + put: (doc: any) => any; + get: (id: string) => any; + remove: (id: string) => any; + }; + }; + services?: { + saveColorCard: (buffer: ArrayBuffer) => Promise; + }; + __platformNavigate?: ((path: string, state?: any) => void) | null; + __platformSetInitialData?: ((data: any) => void) | null; + } +} + +export {}; diff --git a/plugins/color-helper/utils/color.ts b/plugins/color-helper/utils/color.ts new file mode 100644 index 000000000..db735ffd4 --- /dev/null +++ b/plugins/color-helper/utils/color.ts @@ -0,0 +1,310 @@ +import chroma from 'chroma-js'; +import type { ColorObject } from '../types'; + +/** + * 解析颜色输入,转换为标准颜色对象 + */ +export function parseColor(input: string): ColorObject | null { + try { + const color = chroma(input); + // chroma.js 不验证无效颜色时会抛出异常或返回特殊值 + + return { + hex: color.hex(), + rgb: { + r: Math.round(color.rgb()[0]), + g: Math.round(color.rgb()[1]), + b: Math.round(color.rgb()[2]) + }, + hsl: { + h: Math.round(color.hsl()[0]), + s: Math.round(color.hsl()[1] * 100), + l: Math.round(color.hsl()[2] * 100) + }, + hsv: { + h: Math.round(color.hsv()[0]), + s: Math.round(color.hsv()[1] * 100), + v: Math.round(color.hsv()[2] * 100) + } + }; + } catch { + return null; + } +} + +/** + * HEX转RGB + */ +export function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const color = parseColor(hex); + return color ? color.rgb : null; +} + +/** + * RGB转HEX + */ +export function rgbToHex(r: number, g: number, b: number): string { + return chroma(r, g, b).hex(); +} + +/** + * HSL转HEX + */ +export function hslToHex(h: number, s: number, l: number): string { + try { + return chroma.hsl(h, s / 100, l / 100).hex(); + } catch { + return chroma.hsl(h % 360, Math.min(100, Math.max(0, s)) / 100, Math.min(100, Math.max(0, l)) / 100).hex(); + } +} + +/** + * 生成颜色的浅色变体 + */ +export function lightenColor(color: string, amount: number = 0.2): string { + return chroma(color).brighten(amount * 10).hex(); +} + +/** + * 生成颜色的深色变体 + */ +export function darkenColor(color: string, amount: number = 0.2): string { + return chroma(color).darken(amount * 10).hex(); +} + +/** + * 生成互补色 + */ +export function getComplementaryColor(color: string): string { + const h = chroma(color).hsl()[0]; + return chroma.hsl((h + 180) % 360, chroma(color).hsl()[1], chroma(color).hsl()[2]).hex(); +} + +/** + * 生成 triadic 配色(三色) + */ +export function getTriadicColors(color: string): string[] { + const base = chroma(color); + const hsl = base.hsl(); + return [ + base.hex(), + chroma.hsl((hsl[0] + 120) % 360, hsl[1], hsl[2]).hex(), + chroma.hsl((hsl[0] + 240) % 360, hsl[1], hsl[2]).hex() + ]; +} + +/** + * 生成 analogous 配色(类似色) + */ +export function getAnalogousColors(color: string, count: number = 5): string[] { + const base = chroma(color); + const hsl = base.hsl(); + const colors: string[] = [base.hex()]; + + for (let i = 1; i < count; i++) { + colors.push(chroma.hsl((hsl[0] + i * 30) % 360, hsl[1], hsl[2]).hex()); + } + + return colors; +} + +/** + * 生成单色系 + */ +export function getMonochromaticColors(color: string, count: number = 5): string[] { + return chroma.scale([color, '#fff']).mode('lch').colors(count); +} + +/** + * 从图片中提取主色调(简化版,实际需要Canvas处理) + */ +export async function extractColorsFromImage(imageSrc: string, count: number = 5): Promise { + return new Promise((resolve) => { + const img = new Image(); + img.crossOrigin = 'Anonymous'; + img.onload = () => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + resolve(['#000000']); + return; + } + + canvas.width = img.width; + canvas.height = img.height; + ctx.drawImage(img, 0, 0); + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + const colorMap: Record = {}; + + for (let i = 0; i < data.length; i += 4 * 10) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + const a = data[i + 3]; + + if (a < 128) continue; + + const hex = chroma(r, g, b).hex(); + colorMap[hex] = (colorMap[hex] || 0) + 1; + } + + const sortedColors = Object.entries(colorMap) + .sort((a, b) => b[1] - a[1]) + .slice(0, count) + .map(([color]) => color); + + resolve(sortedColors.length > 0 ? sortedColors : ['#000000']); + }; + img.src = imageSrc; + }); +} + +/** + * 计算两个颜色的相似度(0-1) + */ +export function getColorSimilarity(color1: string, color2: string): number { + const c1 = chroma(color1); + const c2 = chroma(color2); + return 1 - chroma.deltaE(c1, c2) / 100; +} + +/** + * 判断颜色是否适合白色文字 + */ +export function isLightColor(color: string): boolean { + return chroma(color).luminance() > 0.5; +} + +/** + * 判断颜色是否为深色(Lab 亮度 < 80) + */ +export function isDarkColor(hex: string): boolean { + return chroma(hex).get('lab.l') < 80; +} + +/** + * 根据背景色深浅返回合适的文字颜色(黑/白) + */ +export function textColorFor(hex: string): string { + return isDarkColor(hex) ? '#fff' : '#000'; +} + +/** + * 生成半透明辅助色: 深色背景用白色、浅色背景用黑色 + */ +export function dimColor(hex: string, alpha: number): string { + return isDarkColor(hex) + ? chroma('#fff').alpha(alpha).hex() + : chroma('#000').alpha(alpha).hex(); +} + +/** + * 获取对比色(用于文字) + */ +export function getContrastColor(color: string): string { + return isLightColor(color) ? '#000000' : '#FFFFFF'; +} + +/** + * 格式化CSS渐变字符串 + */ +export function formatGradient(colors: string[], angle: number = 90, type: 'linear' | 'radial' = 'linear'): string { + if (type === 'radial') { + return `radial-gradient(circle, ${colors.join(', ')})`; + } + return `linear-gradient(${angle}deg, ${colors.join(', ')})`; +} + +/** + * 从base64生成色卡图片 + */ +export function generateColorCardImage(colors: string[], width: number = 540, height: number = 270): Promise { + return new Promise((resolve) => { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) { resolve(''); return; } + + const segmentWidth = width / colors.length; + colors.forEach((color, index) => { + ctx.fillStyle = color; + ctx.fillRect(index * segmentWidth, 0, segmentWidth, height); + }); + + resolve(canvas.toDataURL('image/png')); + }); +} + +/** + * 保存色卡图片 - 使用平台 API或浏览器下载 + */ +export async function saveColorCard(colors: string[]): Promise { + const base64 = await generateColorCardImage(colors); + + if (window.services?.saveColorCard) { + const buffer = base64ToArrayBuffer(base64); + await window.services.saveColorCard(buffer); + } else { + // 浏览器降级: 使用下载方式 + const a = document.createElement('a'); + a.href = base64; + a.download = 'color-card.png'; + a.click(); + } +} + +/** + * base64转ArrayBuffer + */ +function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binaryString = atob(base64.split(',')[1]); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes.buffer; +} + +/** + * 复制图片到剪贴板 + */ +export async function copyImageToClipboard(base64: string): Promise { + try { + const response = await fetch(base64); + const blob = await response.blob(); + await navigator.clipboard.write([ + new ClipboardItem({ [blob.type]: blob }) + ]); + } catch { + // 降级处理 + console.warn('Failed to copy image to clipboard'); + } +} + +/** + * 读取剪贴板中的图片 + */ +export async function readClipboardImage(): Promise { + try { + const items = await navigator.clipboard.read(); + for (const item of items) { + for (const type of item.types) { + if (type.startsWith('image/')) { + const blob = await item.getType(type); + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.readAsDataURL(blob); + }); + } + } + } + return null; + } catch { + return null; + } +} diff --git a/plugins/color-helper/utils/platform.ts b/plugins/color-helper/utils/platform.ts new file mode 100644 index 000000000..68a1062d7 --- /dev/null +++ b/plugins/color-helper/utils/platform.ts @@ -0,0 +1,229 @@ +/** + * 平台 API 统一适配层 + * - 集中管理所有平台 API 调用 + * - 非平台环境提供 fallback + * - 解耦业务代码与平台 API + */ + +// 平台实例 +const platform = window.platform; + +// localStorage key 前缀 +const STORAGE_PREFIX = 'color_helper_'; + +// 环境检测 +export const isPlatform = !!platform; + +// dbStorage 适配 +export const dbStorage = { + getItem(key: string): any { + if (platform?.dbStorage) { + return platform.dbStorage.getItem(key); + } + try { + const v = localStorage.getItem(`${STORAGE_PREFIX}${key}`); + return v ? JSON.parse(v) : null; + } catch { + return null; + } + }, + + setItem(key: string, value: any): void { + if (platform?.dbStorage) { + platform.dbStorage.setItem(key, value); + return; + } + try { + localStorage.setItem(`${STORAGE_PREFIX}${key}`, JSON.stringify(value)); + } catch { /* ignore */ } + }, + + removeItem(key: string): void { + if (platform?.dbStorage) { + platform.dbStorage.removeItem(key); + return; + } + try { + localStorage.removeItem(`${STORAGE_PREFIX}${key}`); + } catch { /* ignore */ } + }, +}; + +// db 适配 +export const db = { + get(id: string): any { + if (platform?.db) { + return platform.db.get(id); + } + try { + const v = localStorage.getItem(`${STORAGE_PREFIX}db_${id}`); + return v ? JSON.parse(v) : null; + } catch { + return null; + } + }, + + put(doc: any): { ok: boolean; id: string; rev?: string; error?: string } { + if (platform?.db) { + return platform.db.put(doc); + } + try { + const id = doc._id; + const existing = db.get(id); + const rev = existing?._rev ? String(parseInt(existing._rev || '0') + 1) : '1'; + const toSave = { ...doc, _rev: rev }; + localStorage.setItem(`${STORAGE_PREFIX}db_${id}`, JSON.stringify(toSave)); + return { ok: true, id, rev }; + } catch (e: any) { + return { ok: false, id: doc._id, error: e.message }; + } + }, + + remove(doc: any): { ok: boolean; error?: string } { + if (platform?.db) { + return platform.db.remove(doc); + } + try { + localStorage.removeItem(`${STORAGE_PREFIX}db_${doc._id}`); + return { ok: true }; + } catch (e: any) { + return { ok: false, error: e.message }; + } + }, + + allDocs(key?: string): any[] { + if (platform?.db) { + return platform.db.allDocs(key); + } + try { + const results: any[] = []; + const prefix = `${STORAGE_PREFIX}db_`; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.startsWith(prefix)) { + const v = localStorage.getItem(k); + if (v) { + const doc = JSON.parse(v); + if (!key || doc._id.startsWith(key)) { + results.push(doc); + } + } + } + } + return results; + } catch { + return []; + } + }, +}; + +// 剪贴板 +export function copyText(text: string): void { + if (platform?.copyText) { + platform.copyText(text); + return; + } + try { + navigator.clipboard.writeText(text); + } catch { + // fallback: textarea copy + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } +} + +export function copyImage(dataUrl: string): void { + if (platform?.copyImage) { + platform.copyImage(dataUrl); + return; + } + console.warn('[fallback] copyImage not supported in browser'); +} + +// 屏幕截图 +export function screenCapture(callback: (imagePath: string) => void): void { + if ((platform as any)?.screenCapture) { + (platform as any).screenCapture(callback); + return; + } + console.warn('[fallback] screenCapture not supported in browser'); +} + +// 屏幕取色 +export function screenColorPick(callback: (result: { hex: string; rgb: string }) => void): void { + if (platform?.screenColorPick) { + platform.screenColorPick(callback); + return; + } + // 非平台环境: 使用浏览器原生 EyeDropper API (Chrome 95+) + if ((window as any).EyeDropper) { + const dropper = new (window as any).EyeDropper(); + dropper.open().then((result: any) => { + callback({ hex: result.sRGBHex, rgb: '' }); + }).catch(() => { /* cancelled */ }); + return; + } + console.warn('[fallback] screenColorPick not supported'); +} + +// 窗口控制 +export function hideMainWindow(): void { + if (platform?.hideMainWindow) { + platform.hideMainWindow(); + return; + } + // 非平台环境无操作 +} + +export function showMainWindow(): void { + if (platform?.showMainWindow) { + platform.showMainWindow(); + return; + } +} + +// 插件生命周期 +export function onPluginEnter(callback: (action: { code: string; type: string; payload: string }) => void): void { + if (platform?.onPluginEnter) { + platform.onPluginEnter(callback); + return; + } + // 非平台环境: 直接触发一次模拟进入 + setTimeout(() => callback({ code: 'color', type: 'text', payload: '' }), 100); +} + +export function onPluginOut(callback: () => void): void { + if (platform?.onPluginOut) { + platform.onPluginOut(callback); + return; + } +} + +// AI API +// 调用方式: platform.ai({model:"doubao-1.5-pro-32k", messages:[...]}) +// 返回 Promise<{content: string}> + +export function isAIAvailable(): boolean { + return !!(platform as any)?.ai && typeof (platform as any).ai === 'function'; +} + +export async function aiChat(messages: { role: string; content: string }[], model: string = 'doubao-1.5-pro-32k'): Promise<{ content: string }> { + if (!isAIAvailable()) { + throw new Error('当前版本不支持 AI 功能'); + } + return (platform as any).ai({ model, messages }); +} + +// 文件对话框 +export function showOpenDialog(options: any): string[] | undefined { + if (platform?.showOpenDialog) { + return platform.showOpenDialog(options); + } + return undefined; +} diff --git a/plugins/color-helper/utils/quantize.ts b/plugins/color-helper/utils/quantize.ts new file mode 100644 index 000000000..d197dcecf --- /dev/null +++ b/plugins/color-helper/utils/quantize.ts @@ -0,0 +1,465 @@ +/** + * Median Cut 颜色量化算法 + * + * 将图像像素集合量化为指定数量的代表色。 + * 核心流程: + * 1. 将所有像素放入一个 VBox(颜色盒子) + * 2. 反复将 VBox 按颜色通道中位数切分为两个子 VBox + * 3. 直到达到目标颜色数量 + * 4. 每个 VBox 的平均色即为代表色 + */ + +/** 数值自然排序比较函数 */ +function naturalOrder(a: number, b: number): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** 数组求和 */ +function sumArray(array: number[]): number { + return array.reduce((a, b) => a + b, 0); +} + +/** + * 优先队列 - 存储 VBox 对象,按比较器排序 + * 延迟排序: dirty 标记确保只在需要时才排序 + */ +class PQueue { + private sorted: VBox[] = []; + private dirty = false; + + constructor(private comparator: (a: VBox, b: VBox) => number) {} + + push(item: VBox): void { + this.sorted.push(item); + this.dirty = true; + } + + peek(index?: number): VBox { + if (this.dirty) this.sort(); + if (index === undefined) index = this.sorted.length - 1; + return this.sorted[index]; + } + + pop(): VBox | undefined { + if (this.dirty) this.sort(); + return this.sorted.pop(); + } + + size(): number { + return this.sorted.length; + } + + map(fn: (item: VBox, index: number) => T): T[] { + return this.sorted.map(fn); + } + + private sort(): void { + this.sorted.sort(this.comparator); + this.dirty = false; + } +} + +/** 颜色直方图: 5位R + 5位G + 5位B → 像素计数 */ +type HistoMap = (number | undefined)[]; + +/** 将 RGB 各通道右移3位(5位精度)后计算直方图索引 */ +function getColorIndex(r: number, g: number, b: number): number { + return (r << 10) + (g << 5) + b; +} + +/** + * VBox - 颜色空间中的长方体 + * 代表 RGB 颜色空间的一个子区域,用于量化算法中的切分操作 + * r1/r2, g1/g2, b1/b2 定义了该长方体在5位精度下的边界 + */ +class VBox { + r1: number; r2: number; + g1: number; g2: number; + b1: number; b2: number; + histo: HistoMap; + private _volume?: number; + private _count?: number; + private _count_set = false; + private _avg?: number[]; + + constructor(r1: number, r2: number, g1: number, g2: number, b1: number, b2: number, histo: HistoMap) { + this.r1 = r1; this.r2 = r2; + this.g1 = g1; this.g2 = g2; + this.b1 = b1; this.b2 = b2; + this.histo = histo; + } + + /** 计算长方体的体积(像素范围乘积) */ + volume(force?: boolean): number { + if (!this._volume || force) { + this._volume = (this.r2 - this.r1 + 1) * (this.g2 - this.g1 + 1) * (this.b2 - this.b1 + 1); + } + return this._volume; + } + + /** 统计长方体内像素总数 */ + count(force?: boolean): number { + if (!this._count_set || force) { + let c = 0; + for (let r = this.r1; r <= this.r2; r++) { + for (let g = this.g1; g <= this.g2; g++) { + for (let b = this.b1; b <= this.b2; b++) { + c += this.histo[getColorIndex(r, g, b)] || 0; + } + } + } + this._count = c; + this._count_set = true; + } + return this._count; + } + + copy(): VBox { + return new VBox(this.r1, this.r2, this.g1, this.g2, this.b1, this.b2, this.histo); + } + + /** 计算长方体内所有像素的加权平均色(RGB) */ + avg(force?: boolean): number[] { + if (this._avg && !force) return this._avg; + + const histo = this.histo; + let ntot = 0, mult_r = 0, mult_g = 0, mult_b = 0; + + if (this.r1 === this.r2 && this.g1 === this.g2 && this.b1 === this.b2) { + this._avg = [this.r1 << 3, this.g1 << 3, this.b1 << 3]; + } else { + for (let r = this.r1; r <= this.r2; r++) { + for (let g = this.g1; g <= this.g2; g++) { + for (let b = this.b1; b <= this.b2; b++) { + const hval = histo[getColorIndex(r, g, b)] || 0; + ntot += hval; + mult_r += hval * (r + 0.5) * 8; + mult_g += hval * (g + 0.5) * 8; + mult_b += hval * (b + 0.5) * 8; + } + } + } + if (ntot) { + this._avg = [~~(mult_r / ntot), ~~(mult_g / ntot), ~~(mult_b / ntot)]; + } else { + this._avg = [~~(8 * (this.r1 + this.r2 + 1) / 2), ~~(8 * (this.g1 + this.g2 + 1) / 2), ~~(8 * (this.b1 + this.b2 + 1) / 2)]; + } + } + return this._avg; + } + + /** 判断像素是否在此长方体范围内 */ + contains(pixel: number[]): boolean { + const rval = pixel[0] >> 3; + const gval = pixel[1] >> 3; + const bval = pixel[2] >> 3; + return rval >= this.r1 && rval <= this.r2 && + gval >= this.g1 && gval <= this.g2 && + bval >= this.b1 && bval <= this.b2; + } +} + +/** 简单颜色映射 - 当去重后像素数 <= 目标数时直接使用 */ +class SimpleCMap { + private colors: number[][]; + constructor(colors: number[][]) { + this.colors = colors; + } + palette(): number[][] { + return this.colors; + } + map(pixel: number[]): number { + return pixel; + } +} + +/** + * CMap - 颜色映射表 + * 存储量化后的 VBox 集合,提供调色板和像素映射功能 + */ +class CMap { + vboxes: CMapPQueue; + + constructor() { + this.vboxes = new CMapPQueue((a, b) => naturalOrder(a.vbox.count() * a.vbox.volume(), b.vbox.count() * b.vbox.volume())); + } + + push(vbox: VBox): void { + this.vboxes.push({ vbox, color: vbox.avg() }); + } + + /** 返回所有代表色组成的调色板 */ + palette(): number[][] { + return this.vboxes.map(item => item.color); + } + + size(): number { + return this.vboxes.size(); + } + + /** 将像素映射到最近的代表色 */ + map(pixel: number[]): number { + for (let i = 0; i < this.vboxes.size(); i++) { + if (this.vboxes.peek(i).vbox.contains(pixel)) { + return this.vboxes.peek(i).color; + } + } + return this.nearest(pixel); + } + + /** 欧氏距离找最近代表色 */ + private nearest(pixel: number[]): number { + let minDist: number | undefined; + let best: number[] = pixel; + for (let i = 0; i < this.vboxes.size(); i++) { + const color = this.vboxes.peek(i).color; + const dist = Math.sqrt( + Math.pow(pixel[0] - color[0], 2) + + Math.pow(pixel[1] - color[1], 2) + + Math.pow(pixel[2] - color[2], 2) + ); + if (minDist === undefined || dist < minDist) { + minDist = dist; + best = color; + } + } + return best; + } +} + +/** CMap 专用的优先队列 - 存储 {vbox, color} 对象 */ +class CMapPQueue { + private sorted: { vbox: VBox; color: number[] }[] = []; + private dirty = false; + + constructor(private comparator: (a: { vbox: VBox; color: number[] }, b: { vbox: VBox; color: number[] }) => number) {} + + push(item: { vbox: VBox; color: number[] }): void { + this.sorted.push(item); + this.dirty = true; + } + + peek(index?: number): { vbox: VBox; color: number[] } { + if (this.dirty) this.sort(); + if (index === undefined) index = this.sorted.length - 1; + return this.sorted[index]; + } + + pop(): { vbox: VBox; color: number[] } | undefined { + if (this.dirty) this.sort(); + return this.sorted.pop(); + } + + size(): number { + return this.sorted.length; + } + + map(fn: (item: { vbox: VBox; color: number[] }, index: number) => T): T[] { + return this.sorted.map(fn); + } + + private sort(): void { + this.sorted.sort(this.comparator); + this.dirty = false; + } +} + +/** + * 中位切分: 将 VBox 沿最长维度在中位数处切分为两个子 VBox + * @returns 两个子 VBox 的数组,或 null(无法切分时) + */ +function medianCutApply(histo: HistoMap, vbox: VBox): VBox[] | null { + if (!vbox.count()) return null; + + const rw = vbox.r2 - vbox.r1 + 1; + const gw = vbox.g2 - vbox.g1 + 1; + const bw = vbox.b2 - vbox.b1 + 1; + const maxw = Math.max(rw, gw, bw); + + if (vbox.count() === 1) return [vbox.copy()]; + + let total = 0; + const partialsum: Record = []; + const lookaheadsum: Record = []; + + if (maxw === rw) { + for (let r = vbox.r1; r <= vbox.r2; r++) { + let s = 0; + for (let g = vbox.g1; g <= vbox.g2; g++) { + for (let b = vbox.b1; b <= vbox.b2; b++) { + s += histo[getColorIndex(r, g, b)] || 0; + } + } + total += s; + partialsum[r] = total; + } + } else if (maxw === gw) { + for (let g = vbox.g1; g <= vbox.g2; g++) { + let s = 0; + for (let r = vbox.r1; r <= vbox.r2; r++) { + for (let b = vbox.b1; b <= vbox.b2; b++) { + s += histo[getColorIndex(r, g, b)] || 0; + } + } + total += s; + partialsum[g] = total; + } + } else { + for (let b = vbox.b1; b <= vbox.b2; b++) { + let s = 0; + for (let r = vbox.r1; r <= vbox.r2; r++) { + for (let g = vbox.g1; g <= vbox.g2; g++) { + s += histo[getColorIndex(r, g, b)] || 0; + } + } + total += s; + partialsum[b] = total; + } + } + + for (const key in partialsum) { + lookaheadsum[key as any] = total - partialsum[key as any]; + } + + const dim = maxw === rw ? 'r' : maxw === gw ? 'g' : 'b'; + const dim1 = dim + '1' as 'r1' | 'g1' | 'b1'; + const dim2 = dim + '2' as 'r2' | 'g2' | 'b2'; + + let left: VBox | null = null; + let right: VBox | null = null; + + for (let d = (vbox as any)[dim1]; d <= (vbox as any)[dim2]; d++) { + if (partialsum[d]! > total / 2) { + const vbox1 = vbox.copy(); + const vbox2 = vbox.copy(); + + const d1 = d - (vbox as any)[dim1]; + const d2 = (vbox as any)[dim2] - d; + let half = d1 <= d2 + ? Math.min((vbox as any)[dim2] - 1, ~~(d + d2 / 2)) + : Math.max((vbox as any)[dim1], ~~(d - 1 - d1 / 2)); + + while (!partialsum[half]) half++; + let count2 = lookaheadsum[half]; + while (!count2 && partialsum[half - 1]) count2 = lookaheadsum[--half]; + + (vbox1 as any)[dim2] = half; + (vbox2 as any)[dim1] = (vbox1 as any)[dim2] + 1; + + left = vbox1; + right = vbox2; + break; + } + } + + if (!left) return null; + return [left, right!].filter(v => v.count()); +} + +/** + * 主量化函数: 将像素集合量化为 maxColors 个代表色 + * + * 流程: + * 1. 像素去重,若去重后数量 <= maxColors 直接返回 + * 2. 构建5位精度直方图,找到颜色空间范围 + * 3. 第一轮切分: 目标 0.75 * maxColors + * 4. 按 count*volume 排序后第二轮切分: 目标 maxColors + * 5. 生成 CMap 颜色映射表 + */ +export function quantize(pixels: number[][], maxColors: number): CMap | SimpleCMap | null | false { + if (!Number.isInteger(maxColors) || maxColors < 1 || maxColors > 256) { + throw new Error('Invalid maximum color count. It must be an integer between 1 and 256.'); + } + + if (!pixels.length || maxColors < 2 || maxColors > 256) return false; + + // 去重 + const uniquePixels: number[][] = []; + const seen = new Set(); + for (const pixel of pixels) { + const key = pixel.join(','); + if (!seen.has(key)) { + seen.add(key); + uniquePixels.push(pixel); + } + } + + // 如果去重后像素数 <= maxColors,直接返回简单 CMap + if (uniquePixels.length <= maxColors) { + return new SimpleCMap(uniquePixels); + } + + // 构建直方图 + const histo: HistoMap = new Array(32768); + pixels.forEach(pixel => { + const idx = getColorIndex(pixel[0] >> 3, pixel[1] >> 3, pixel[2] >> 3); + histo[idx] = (histo[idx] || 0) + 1; + }); + + // 找到颜色空间范围 + let rmin = 1e6, rmax = 0, gmin = 1e6, gmax = 0, bmin = 1e6, bmax = 0; + pixels.forEach(pixel => { + const r = pixel[0] >> 3; + const g = pixel[1] >> 3; + const b = pixel[2] >> 3; + if (r < rmin) rmin = r; if (r > rmax) rmax = r; + if (g < gmin) gmin = g; if (g > gmax) gmax = g; + if (b < bmin) bmin = b; if (b > bmax) bmax = b; + }); + + const vbox = new VBox(rmin, rmax, gmin, gmax, bmin, bmax, histo); + const pq = new PQueue((a, b) => naturalOrder(a.count(), b.count())); + pq.push(vbox); + + /** 迭代切分: 从优先队列中取出 VBox 进行中位切分,直到达到目标数量 */ + function iterate(pq: PQueue, target: number): void { + let A = pq.size(); // A = E.size() + let I = 0; + + while (I < 1000) { + if (A >= target || I++ > 1000) return; // if(A>=w||I++>1e3)return + + const P = pq.pop(); + if (!P) return; + + if (P.count()) { + const B = medianCutApply(histo, P); + const N = B ? B[0] : null; + const j = B && B.length >= 2 ? B[1] : null; + if (!N) return; // if(!N)return + pq.push(N); + if (j) { + pq.push(j); + A++; // j&&(E.push(j),A++) + } + } else { + pq.push(P); + I++; // I++ (only when count==0) + } + } + } + + // 第一次切分: S(b, .75 * u) - u 是 maxColors,不是像素数! + iterate(pq, 0.75 * maxColors); + + // 排序后放入新队列 + // 先把 pq 中的 vbox 全部取出排序 + const allVboxes: VBox[] = []; + while (pq.size() > 0) { + allVboxes.push(pq.pop()!); + } + allVboxes.sort((a, b) => naturalOrder(a.count() * a.volume(), b.count() * b.volume())); + + // 第二次切分 + const pq2 = new PQueue((a, b) => naturalOrder(a.count() * a.volume(), b.count() * b.volume())); + allVboxes.forEach(v => pq2.push(v)); + iterate(pq2, maxColors); + + // 生成 CMap + const cmap = new CMap(); + while (pq2.size() > 0) { + cmap.push(pq2.pop()!); + } + + return cmap; +} diff --git a/plugins/color-helper/utils/storage.ts b/plugins/color-helper/utils/storage.ts new file mode 100644 index 000000000..d47a28aac --- /dev/null +++ b/plugins/color-helper/utils/storage.ts @@ -0,0 +1,64 @@ +import type { CollectedColor } from '../types'; +import { dbStorage } from './platform'; + +const STORAGE_KEY = 'color-collected'; + +/** + * 获取收藏的颜色列表 + */ +export function getCollectedColors(): CollectedColor[] { + try { + return dbStorage.getItem(STORAGE_KEY) || []; + } catch { + return []; + } +} + +/** + * 保存收藏的颜色列表 + */ +export function saveCollectedColors(colors: CollectedColor[]): void { + try { + dbStorage.setItem(STORAGE_KEY, colors); + } catch (error) { + console.error('Failed to save collected colors:', error); + } +} + +/** + * 添加收藏颜色 + */ +export function addColoredColor(color: string, name?: string): CollectedColor { + const colors = getCollectedColors(); + const newColor: CollectedColor = { + id: Date.now().toString(), + name: name || color, + color, + createdAt: Date.now() + }; + + colors.unshift(newColor); + saveCollectedColors(colors); + + return newColor; +} + +/** + * 删除收藏颜色 + */ +export function removeCollectedColor(id: string): void { + const colors = getCollectedColors(); + const filtered = colors.filter(c => c.id !== id); + saveCollectedColors(filtered); +} + +/** + * 更新收藏颜色名称 + */ +export function updateColoredColorName(id: string, name: string): void { + const colors = getCollectedColors(); + const updated = colors.map(c => + c.id === id ? { ...c, name } : c + ); + saveCollectedColors(updated); +} diff --git a/plugins/color-helper/vite.config.ts b/plugins/color-helper/vite.config.ts new file mode 100644 index 000000000..5dee9864c --- /dev/null +++ b/plugins/color-helper/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + base: './' +}) diff --git a/plugins/colors/.oxfmtrc.json b/plugins/colors/.oxfmtrc.json index 84f515b82..6833994a7 100644 --- a/plugins/colors/.oxfmtrc.json +++ b/plugins/colors/.oxfmtrc.json @@ -1,6 +1,6 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", - "ignorePatterns": ["node_modules", "dist", "typed-router.d.ts"], + "ignorePatterns": ["node_modules", "dist", "typed-router.d.ts", "CHANGELOG.md"], "sortImports": { "newlinesBetween": false, "groups": [ diff --git a/plugins/colors/.oxlintrc.json b/plugins/colors/.oxlintrc.json index 68b8a6921..8b6ef2cf0 100644 --- a/plugins/colors/.oxlintrc.json +++ b/plugins/colors/.oxlintrc.json @@ -4,7 +4,9 @@ "categories": { "correctness": "warn" }, - "rules": {}, + "rules": { + "no-unused-vars": "off" + }, "env": { "builtin": true } diff --git a/plugins/colors/CHANGELOG.md b/plugins/colors/CHANGELOG.md new file mode 100644 index 000000000..e0e91e3c9 --- /dev/null +++ b/plugins/colors/CHANGELOG.md @@ -0,0 +1,217 @@ +# Changelog + +所有对本项目的重要变动都会记录在此文件中。 +## 1.5.0 - 2026-06-15 ([v1.4.2...v1.5.0](https://github.com/mekefly/colors/compare/v1.4.2...v1.5.0)) + + +### ✨ Features + +- 增加颜色助手别名,减少因改名导致用户不习惯问题 +- AI智能配色 (https://github.com/mekefly/colors/pull/14) + +### 🔧 Chores + +- fmt +- 插件更名为调色板 +## 1.4.2 - 2026-06-14 ([v1.4.1...v1.4.2](https://github.com/mekefly/colors/compare/v1.4.1...v1.4.2)) + + +### 🐛 Bug Fixes + +- 修复输入框可能因为NAN导致的异常问题 +- 修复数据库可能存在的保存漏洞 + +### 🔧 Chores + +- 忽略掉lint未使用的变量规则 +## 1.4.1 - 2026-06-14 ([v1.4.0...v1.4.1](https://github.com/mekefly/colors/compare/v1.4.0...v1.4.1)) + + +### 🔧 Chores + +- fmt +- 添加 git-cliff 配置、release 脚本和 CHANGELOG + +## 1.4.0 - 2026-06-13 ([#13](https://github.com/mekefly/colors/pull/13)) + +#### ✨ 新功能 + +- 统一数据库,添加数据库版本升级功能 +- 数据库迁移ui页,LoadingPage + MigrationPage + App.vue状态机 +- 添加渐变生成器,和收藏渐变色 +- 添加数据库导入导出功能 +- 关于页更多信息 + +#### 🐛 Bug 修复 + +- 添加更多验证 +- 新数据库创建失败 +- 数据库更新页面显示异常 + +#### ♻️ 重构 + +- 重构收藏,升级数据库 +- 重构数据库注册,消除使用全局变量 + +#### 🔧 杂务 + +- updata version + +## 1.3.0 - 2026-05-11 ([#11](https://github.com/mekefly/colors/pull/11)) + +#### ✨ 新功能 + +- 更多的颜色滑块 +- 添加复制颜色的设置选项,可控制复制颜色的格式 + +#### ♻️ 重构 + +- 重构copy颜色的方式 +- 优化配置选项卡片 + +#### 🔧 杂务 + +- 压缩logo.png文件大小 +- 更新版本号 +- 修复样式 + +## 1.2.2 - 2026-05-09 ([#10](https://github.com/mekefly/colors/pull/10)) + +#### 🐛 Bug 修复 + +- xyz色彩空间bug + +#### ♻️ 重构 + +- 优化性能 + +#### 🔧 杂务 + +- 更新版本1.2.2 + +## 1.2.1 - 2026-05-09 ([#9](https://github.com/mekefly/colors/pull/9)) + +#### 🐛 Bug 修复 + +- 修复router异常,build后打开页面白屏的问题 + +#### 🔧 杂务 + +- 更新版本号1.2.1 + +## 1.2.0 - 2026-05-08 ([#8](https://github.com/mekefly/colors/pull/8)) + +#### ✨ 新功能 + +- 预览颜色功能 +- slider组件 +- 增加了滚轮控制滑动条的能力 +- 修改选中颜色的视觉效果,使其更明显 +- 增加了更多颜色显示模式 +- 隐藏滚动条 + +#### 🐛 Bug 修复 + +- 滑动条初始位置不正确的bug +- 色轮亮度更新不正确 +- 颜色预览卡片颜色更新延迟问题 + +#### ♻️ 重构 + +- 重构引入vue-router +- 重构NavBar为独立组件 +- 重构颜色滑动条,使用了新的滑动条 +- 重构,让颜色使用Colord而不是string hex值,提供更高的精度 + +#### 🔧 杂务 + +- 修复代码格式问题 +- 修复npm scripts错误 +- 更新README文档 +- 发布1.2.0版本 + +## 1.1.0 - 2026-04-28 ([#7](https://github.com/mekefly/colors/pull/7)) + +#### ✨ 新功能 + +- 手动输入颜色的值 +- 去色值#功能 +- message能力 +- 收藏功能页 +- 美化tag展示 + +#### 🐛 Bug 修复 + +- 修复数据库保存不正确的问题 +- 修复颜色格式化不正确的问题 + +#### 🔧 杂务 + +- 配置了lint和fmt规则 +- 配置格式化和lint工具,配置类型检查工具 +- 代码样式自动修复 +- 修改版本号到1.1.0 +- 代码格式化 + +## 1.0.4 - 2026-04-25 ([#6](https://github.com/mekefly/colors/pull/6)) + +#### ✨ 新功能 + +- 当不在ztools中启动时提供文档提示 + +#### 🐛 Bug 修复 + +- 修复了颜色选取条的异常情况 + +#### 🔧 杂务 + +- 部分 let 改为 const 声明变量 +- 版本管理器切换为了npm +- 不想在手动更新agent文件了,所以就先从仓库里删除了 +- 更新版本信息 + +#### 📝 其他 + +- 修改package +- 不直接从@ztools-center/ztools-api-types引入api而是经过window + +## 1.0.3 - 2026-04-24 ([#5](https://github.com/mekefly/colors/pull/5)) + +#### ✨ 新功能 + +- 修改默认颜色 +- 减少颜色轮盘的更新行为 + +#### 🔧 杂务 + +- 更新文档 +- 更新版本信息 + +## 1.0.2 - 2026-04-24 ([#4](https://github.com/mekefly/colors/pull/4)) + +#### ✨ 新功能 + +- 优化,使颜色轮盘更好用 + +#### 🐛 Bug 修复 + +- 修复复制时通知的错误 +- hsv中的v写死的bug,色盘颜色对比度不足的问题 + +#### 🔧 杂务 + +- 删除遗留的console.log +- 删除一些暂时没用启用的代码 + +## 1.0.1 - 2026-04-24 ([#3](https://github.com/mekefly/colors/pull/3)) + +#### 📝 其他 + +- 颜色助手已完成 +- 优化显示异常 +- 颜色助手发布1.0.1版本 +- 不再使用vite-plus,更换为了vite +- 修复去掉hash按钮的bug + +--- +此文件由 [git-cliff](https://github.com/orhun/git-cliff) 配置 + 脚本自动生成。 diff --git a/plugins/colors/cliff.toml b/plugins/colors/cliff.toml new file mode 100644 index 000000000..b66ac7f1b --- /dev/null +++ b/plugins/colors/cliff.toml @@ -0,0 +1,49 @@ +[changelog] +header = "# Changelog\n\n所有对本项目的重要变动都会记录在此文件中。\n" + +body = """ +{% if version %}\ +## {{ version | trim_start_matches(pat="v") }} - {{ timestamp | date(format="%Y-%m-%d") }}\ +{% if previous.version %} ([{{ previous.version }}...{{ version }}](https://github.com/mekefly/colors/compare/{{ previous.version }}...{{ version }}))\ +{% endif %}\ +{% else %}\ +## Unreleased\ +{% endif %} + +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group | upper_first }} + +{% for commit in commits %}- {{ commit.message | trim }} +{% endfor %}{% endfor %} +""" + +footer = """ +--- +此文件由 [git-cliff](https://github.com/orhun/git-cliff) 自动生成。 +""" + +[git] +conventional_commits = true +filter_unconventional = true +split_commits = false +tag_pattern = "v[0-9]+\\.[0-9]+\\.[0-9]+" +topo_order = false +sort_commits = "newest" + +commit_parsers = [ + { message = "^feat[:(]", group = "✨ Features" }, + { message = "^fix[:(]", group = "🐛 Bug Fixes" }, + { message = "^docs[:(]", group = "📝 Documentation" }, + { message = "^style[:(]", group = "💎 Style" }, + { message = "^refactor[:(]", group = "♻️ Refactoring" }, + { message = "^perf[:(]", group = "⚡ Performance" }, + { message = "^test[:(]", group = "✅ Tests" }, + { message = "^chore[:(]", group = "🔧 Chores" }, + { message = "^ci[:(]", group = "📦 CI/CD" }, + { message = "^build[:(]", group = "🔨 Build" }, + { message = "^revert[:(]", group = "⏪ Reverts" }, +] + +[remote.github] +owner = "mekefly" +repo = "colors" diff --git a/plugins/colors/package.json b/plugins/colors/package.json index 613ad9d13..5413eedfc 100644 --- a/plugins/colors/package.json +++ b/plugins/colors/package.json @@ -1,6 +1,6 @@ { "name": "colors", - "version": "1.3.0", + "version": "1.5.0", "private": true, "type": "module", "scripts": { @@ -13,6 +13,7 @@ "lint:fix": "oxlint --fix", "preview": "vite preview", "publish": "npm run check-all && ztools publish", + "release": "node scripts/release.js", "type-check": "vue-tsc --build --noEmit" }, "dependencies": { diff --git a/plugins/colors/public/plugin.json b/plugins/colors/public/plugin.json index 475796166..b0951220c 100644 --- a/plugins/colors/public/plugin.json +++ b/plugins/colors/public/plugin.json @@ -1,10 +1,10 @@ { "$schema": "node_modules/@ztools-center/ztools-api-types/resource/ztools.schema.json", "name": "colors", - "title": "颜色助手", - "description": "这是一个快速选择生成颜色的插件,提供了常用的颜色选择和生成工具,帮助用户轻松获取所需的颜色值。", + "title": "调色板", + "description": "这是一个快速选择生成颜色的插件,提供了常用的颜色选择和生成工具,帮助用户轻松获取所需的颜色值。之前叫颜色助手,由于与utools移植的颜色助手冲突打算更名为调色板。搜索匹配词:颜色助手、color helper、color palette、调色板,颜色生成器,色彩助手,色彩生成器,Ai配色,配色,颜色收藏器,颜色空间,颜色选择器", "author": "mekefly", - "version": "1.3.0", + "version": "1.5.0", "main": "index.html", "preload": "preload/services.js", "logo": "logo.png", @@ -12,12 +12,24 @@ "main": "http://localhost:5173" }, "features": [ + { + "code": "颜色助手", + "explain": "使用调色版快速生成你需要的颜色。", + "icon": "logo.png", + "cmds": ["颜色助手", "colors"] + }, { "code": "选择颜色", "explain": "打开插件选择一个颜色", "icon": "logo.png", "cmds": ["选择颜色"] }, + { + "code": "AI配色", + "explain": "AI智能生成配色方案", + "icon": "logo.png", + "cmds": ["AI配色", "配色生成", "智能配色"] + }, { "code": "预览颜色", "explain": "查看颜色", diff --git a/plugins/colors/scripts/release.js b/plugins/colors/scripts/release.js new file mode 100644 index 000000000..106bd20c0 --- /dev/null +++ b/plugins/colors/scripts/release.js @@ -0,0 +1,155 @@ +#!/usr/bin/env node +/** + * release.js — 一键发版 + * + * 用法:node scripts/release.js <版本号> + * 示例:node scripts/release.js 1.5.0 + * + * 流程:检查命令 → check-all → 更新版本号 → changelog → fmt → commit → tag + * 不自动 push,由用户确认后手动执行 + */ + +import { execSync } from "child_process"; +import { readFileSync, writeFileSync, existsSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, ".."); + +function run(cmd) { + return execSync(cmd, { cwd: root, encoding: "utf-8" }); +} + +function git(cmd) { + return execSync(cmd, { cwd: root, encoding: "utf-8", stdio: "pipe" }).trim(); +} + +// ── 1. 检查参数 ────────────────────────────────────────────── +const version = process.argv[2]; +if (!version || !/^\d+\.\d+\.\d+$/.test(version)) { + console.error("❌ 用法:node scripts/release.js <版本号>"); + console.error(" 示例:node scripts/release.js 1.5.0"); + process.exit(1); +} + +const tag = `v${version}`; +const commitMsg = `Release ${version}`; + +// ── 2. 检查必要命令 ────────────────────────────────────────── +for (const cmd of ["git", "git-cliff"]) { + try { + execSync(`${cmd} --version`, { stdio: "ignore" }); + } catch { + console.error(`❌ 未检测到 ${cmd},请先安装`); + if (cmd === "git-cliff") { + console.error("\n macOS: brew install git-cliff"); + console.error(" Windows: scoop install git-cliff"); + console.error(" Linux: yay -S git-cliff"); + console.error(" 通用: cargo install git-cliff"); + } + process.exit(1); + } +} + +// ── 3. 安全检查 ────────────────────────────────────────────── +const tagExists = git(`git tag -l "${tag}"`); +if (tagExists) { + console.error(`❌ Tag ${tag} 已存在!不能重复使用版本号`); + process.exit(1); +} + +const dirty = git("git status --porcelain"); +if (dirty) { + console.error("❌ 工作区有未提交的修改,请先 commit 或 stash"); + process.exit(1); +} + +// 检查 HEAD 是否是 main 分支之后的提交(可以不在 main 上,但必须基于 main) +function isAncestor(ancestor, descendant) { + try { + execSync(`git merge-base --is-ancestor ${ancestor} ${descendant}`, { + cwd: root, + stdio: "pipe", + }); + return true; + } catch { + return false; + } +} + +if (!isAncestor("origin/main", "HEAD")) { + console.error("❌ 当前 HEAD 不是 main 分支之后的提交"); + console.error(" 请确保已基于最新的 main 分支开发"); + process.exit(1); +} + +// ── 4. check-all(先验证,不改文件)────────────────────────── +console.log("🔍 执行 check-all ..."); +try { + run("npm run check-all"); + console.log("✅ check-all 通过\n"); +} catch { + console.error("❌ check-all 失败,请修复后再发版"); + process.exit(1); +} + +// ── 5. 更新版本号(check-all 通过后才改)───────────────────── +const changelogPath = resolve(root, "CHANGELOG.md"); +if (!existsSync(changelogPath)) { + writeFileSync( + changelogPath, + "# Changelog\n\n所有对本项目的重要变动都会记录在此文件中。\n\n", + "utf-8", + ); +} + +for (const { path: rel, label } of [ + { path: "package.json", label: "package.json" }, + { path: "public/plugin.json", label: "plugin.json" }, +]) { + const fp = resolve(root, rel); + const raw = readFileSync(fp, "utf-8"); + const next = raw.replace(/("version"\s*:\s*")[^"]+(")/, `$1${version}$2`); + if (raw !== next) { + writeFileSync(fp, next, "utf-8"); + console.log(`✅ ${label} → ${version}`); + } +} + +// ── 6. 生成 CHANGELOG ───────────────────────────────────────── +console.log("\n📝 生成 CHANGELOG.md ..."); +for (const cmd of [ + `git-cliff --unreleased --prepend CHANGELOG.md --offline --tag "${tag}"`, + `git-cliff --latest --prepend CHANGELOG.md --offline --tag "${tag}"`, +]) { + try { + run(cmd); + break; + } catch {} +} + +// ── 8. commit + tag ────────────────────────────────────────── +console.log("\n📦 提交中 ..."); +run(`git add package.json public/plugin.json CHANGELOG.md`); +run(`git add -u`); +run(`git commit -m "${commitMsg}"`); +run(`git tag ${tag}`); +console.log(`✅ commit: ${commitMsg}`); +console.log(`✅ tag: ${tag}`); + +// ── 9. 提示下一步 ──────────────────────────────────────────── +console.log(` +🎉 版本 ${version} 已在本地就绪! + + 下一步: + + 1. git branch release_${version} 从当前 commit 创建 release 分支 + 2. git checkout main 切换到 main + 3. git merge release_${version} --ff-only 快进合并到 main + 4. git push origin main --tags 推送 main 和 tag 到远程 + 5. npm run publish 发布到插件商 + + or 单条命令: + git branch release_${version} && git checkout main && git merge release_${version} --ff-only && git push origin main --tags && npm run publish +`); diff --git a/plugins/colors/src/App.vue b/plugins/colors/src/App.vue index 3e9639e7f..a1e9c3e58 100644 --- a/plugins/colors/src/App.vue +++ b/plugins/colors/src/App.vue @@ -1,38 +1,44 @@ - - diff --git a/plugins/colors/src/components/ColorCard.vue b/plugins/colors/src/components/ColorCard.vue index 44684d8e1..1ce382089 100644 --- a/plugins/colors/src/components/ColorCard.vue +++ b/plugins/colors/src/components/ColorCard.vue @@ -3,7 +3,8 @@ import { colord } from "colord"; import { computed } from "vue"; import { useConfigStore } from "@/utils/config"; import { useTagsEditing, type ColorFavorite } from "@/utils/favorites"; -import { copyColor2 } from "../utils/copy"; +import { colorToCSS, colorToDisplay } from "@/utils/favorites"; +import { copyColor, copyColor2, copyCSS } from "../utils/copy"; import Tags from "./tags.vue"; const editing = useTagsEditing(); @@ -22,7 +23,17 @@ const props = defineProps(); const emit = defineEmits(); const tags = computed(() => props.favorite.tags ?? []); -const handleCopy = () => copyColor2(colord(props.favorite.color), config); +const cssColor = computed(() => colorToCSS(props.favorite.color)); +const displayText = computed(() => colorToDisplay(props.favorite.color)); + +// 纯色用 colord 格式化后复制,渐变直接复制 CSS 原文 +const handleCopy = () => { + if (props.favorite.color.type === "hex") { + copyColor2(colord(props.favorite.color.hex), config); + } else { + copyCSS(cssColor.value); + } +};